diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index 9c90be7299..df25f5638b 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -415,9 +415,11 @@ wrapline writeout wubalubadubdub xact +xgen xlarge xvzf yargs yetanotherapp +ygen yourdomain ztestdomain7 diff --git a/src/commands/pg/long-running-queries.ts b/src/commands/pg/long-running-queries.ts new file mode 100644 index 0000000000..7f4e01ed4e --- /dev/null +++ b/src/commands/pg/long-running-queries.ts @@ -0,0 +1,48 @@ +import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' + +import {nls} from '../../nls.js' + +const heredoc = tsheredoc.default + +export const generateLongRunningQueriesQuery = (): string => ` +SELECT + pid, + now() - pg_stat_activity.query_start AS duration, + query AS query +FROM + pg_stat_activity +WHERE + pg_stat_activity.query <> ''::text + AND state <> 'idle' + AND now() - pg_stat_activity.query_start > interval '5 minutes' +ORDER BY + now() - pg_stat_activity.query_start DESC; +`.trim() + +export default class PgLongRunningQueries extends Command { + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`, required: false}), + } + static description = 'show all queries longer than five minutes by descending duration' + static examples = [heredoc` + ${color.command('heroku pg:long-running-queries --app example-app')} + `] + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + static hiddenAliases = ['pg:long_running_queries'] + static topic = 'pg' + + public async run(): Promise { + const {args, flags} = await this.parse(PgLongRunningQueries) + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const db = await dbResolver.getDatabase(flags.app, args.database) + const psqlService = new utils.pg.PsqlService(db) + const output = await psqlService.execQuery(generateLongRunningQueriesQuery()) + ux.stdout(output) + } +} diff --git a/src/commands/pg/mandelbrot.ts b/src/commands/pg/mandelbrot.ts new file mode 100644 index 0000000000..62809b223c --- /dev/null +++ b/src/commands/pg/mandelbrot.ts @@ -0,0 +1,54 @@ +import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' + +import {nls} from '../../nls.js' + +const heredoc = tsheredoc.default + +export const generateMandelbrotQuery = (): string => ` + WITH RECURSIVE Z(IX, IY, CX, CY, X, Y, I) AS ( + SELECT IX, IY, X::float, Y::float, X::float, Y::float, 0 + FROM (select -2.2 + 0.031 * i, i from generate_series(0,101) as i) as xgen(x,ix), + (select -1.5 + 0.031 * i, i from generate_series(0,101) as i) as ygen(y,iy) + UNION ALL + SELECT IX, IY, CX, CY, X * X - Y * Y + CX AS X, Y * X * 2 + CY, I + 1 + FROM Z + WHERE X * X + Y * Y < 16::float + AND I < 100 + ) +SELECT array_to_string(array_agg(SUBSTRING(' .,,,-----++++%%%%@@@@#### ', LEAST(GREATEST(I,1),27), 1)),'') +FROM ( + SELECT IX, IY, MAX(I) AS I + FROM Z + GROUP BY IY, IX + ORDER BY IY, IX + ) AS ZT +GROUP BY IY +ORDER BY IY + `.trim() + +export default class PgMandelbrot extends Command { + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`, required: false}), + } + static description = 'show the mandelbrot set' + static examples = [heredoc` + ${color.command('heroku pg:mandelbrot --app example-app')} + `] + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + static topic = 'pg' + + public async run(): Promise { + const {args, flags} = await this.parse(PgMandelbrot) + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const db = await dbResolver.getDatabase(flags.app, args.database) + const psqlService = new utils.pg.PsqlService(db) + const output = await psqlService.execQuery(generateMandelbrotQuery()) + ux.stdout(output) + } +} diff --git a/src/commands/pg/records-rank.ts b/src/commands/pg/records-rank.ts new file mode 100644 index 0000000000..b9ae95a0ec --- /dev/null +++ b/src/commands/pg/records-rank.ts @@ -0,0 +1,43 @@ +import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' + +import {nls} from '../../nls.js' + +const heredoc = tsheredoc.default + +export const generateRecordsRankQuery = (): string => ` +SELECT + relname AS name, + n_live_tup AS estimated_count +FROM + pg_stat_user_tables +ORDER BY + n_live_tup DESC; +`.trim() + +export default class PgRecordsRank extends Command { + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`, required: false}), + } + static description = 'show all tables and the number of rows in each ordered by number of rows descending' + static examples = [heredoc` + ${color.command('heroku pg:records-rank --app example-app')} + `] + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + static hiddenAliases = ['pg:records_rank'] + static topic = 'pg' + + public async run(): Promise { + const {args, flags} = await this.parse(PgRecordsRank) + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const db = await dbResolver.getDatabase(flags.app, args.database) + const psqlService = new utils.pg.PsqlService(db) + const output = await psqlService.execQuery(generateRecordsRankQuery()) + ux.stdout(output) + } +} diff --git a/src/commands/pg/seq-scans.ts b/src/commands/pg/seq-scans.ts new file mode 100644 index 0000000000..93dc416d16 --- /dev/null +++ b/src/commands/pg/seq-scans.ts @@ -0,0 +1,41 @@ +import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' + +import {nls} from '../../nls.js' + +const heredoc = tsheredoc.default + +export const generateSeqScansQuery = (): string => ` +SELECT relname AS name, + seq_scan as count +FROM + pg_stat_user_tables +ORDER BY seq_scan DESC; +`.trim() + +export default class PgSeqScans extends Command { + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`, required: false}), + } + static description = 'show the count of sequential scans by table descending by order' + static examples = [heredoc` + ${color.command('heroku pg:seq-scans --app example-app')} + `] + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + static hiddenAliases = ['pg:seq_scans'] + static topic = 'pg' + + public async run(): Promise { + const {args, flags} = await this.parse(PgSeqScans) + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const db = await dbResolver.getDatabase(flags.app, args.database) + const psqlService = new utils.pg.PsqlService(db) + const output = await psqlService.execQuery(generateSeqScansQuery()) + ux.stdout(output) + } +} diff --git a/src/commands/pg/stats-reset.ts b/src/commands/pg/stats-reset.ts new file mode 100644 index 0000000000..e03e56305e --- /dev/null +++ b/src/commands/pg/stats-reset.ts @@ -0,0 +1,38 @@ +import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' + +import {ensureEssentialTierPlan} from '../../lib/pg/extras.js' +import {nls} from '../../nls.js' + +const heredoc = tsheredoc.default + +export default class PgStatsReset extends Command { + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`, required: false}), + } + static description = 'calls the Postgres functions pg_stat_reset()' + static examples = [heredoc` + ${color.command('heroku pg:stats-reset --app example-app')} + `] + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + static hiddenAliases = ['pg:stats_reset'] + static topic = 'pg' + + public async run(): Promise { + const {args, flags} = await this.parse(PgStatsReset) + const {app} = flags + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const db = await dbResolver.getDatabase(app, args.database) + await ensureEssentialTierPlan(db) + const {addon} = await dbResolver.getAttachment(app, args.database) + const {body} = await this.heroku.put<{message: string}>(`/client/v11/databases/${addon.id}/stats_reset`, { + hostname: utils.pg.host(), + }) + ux.stdout(body.message) + } +} diff --git a/src/commands/pg/table-indexes-size.ts b/src/commands/pg/table-indexes-size.ts new file mode 100644 index 0000000000..9dac69d3ce --- /dev/null +++ b/src/commands/pg/table-indexes-size.ts @@ -0,0 +1,44 @@ +import {Command, flags} from '@heroku-cli/command' +import {color, utils} from '@heroku/heroku-cli-util' +import {Args, ux} from '@oclif/core' +import tsheredoc from 'tsheredoc' + +import {nls} from '../../nls.js' + +const heredoc = tsheredoc.default + +export const generateTableIndexesSizeQuery = (): string => ` +SELECT c.relname AS table, + pg_size_pretty(pg_indexes_size(c.oid)) AS index_size +FROM pg_class c +LEFT JOIN pg_namespace n ON (n.oid = c.relnamespace) +WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') +AND n.nspname !~ '^pg_toast' +AND c.relkind='r' +ORDER BY pg_indexes_size(c.oid) DESC; +`.trim() + +export default class PgTableIndexesSize extends Command { + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`, required: false}), + } + static description = 'show the total size of all the indexes on each table, descending by size' + static examples = [heredoc` + ${color.command('heroku pg:table-indexes-size --app example-app')} + `] + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + static hiddenAliases = ['pg:table_indexes_size'] + static topic = 'pg' + + public async run(): Promise { + const {args, flags} = await this.parse(PgTableIndexesSize) + const dbResolver = new utils.pg.DatabaseResolver(this.heroku) + const db = await dbResolver.getDatabase(flags.app, args.database) + const psqlService = new utils.pg.PsqlService(db) + const output = await psqlService.execQuery(generateTableIndexesSizeQuery()) + ux.stdout(output) + } +} diff --git a/test/unit/commands/pg/long-running-queries.unit.test.ts b/test/unit/commands/pg/long-running-queries.unit.test.ts new file mode 100644 index 0000000000..8247878fc9 --- /dev/null +++ b/test/unit/commands/pg/long-running-queries.unit.test.ts @@ -0,0 +1,102 @@ +import {runCommand} from '@heroku-cli/test-utils' +import {pg, utils} from '@heroku/heroku-cli-util' +import {expect} from 'chai' +import { + createSandbox, SinonSandbox, SinonStub, +} from 'sinon' + +import Cmd, {generateLongRunningQueriesQuery} from '../../../../src/commands/pg/long-running-queries.js' + +describe('pg:long-running-queries', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = 'pid | duration | query\n---+---\n100 | 00:06:00 | SELECT * FROM users' + + const mockDb: pg.ConnectionDetails = { + database: 'testdb', + host: 'localhost', + password: 'testpass', + pathname: '/testdb', + port: '5432', + url: 'postgres://localhost:5432/testdb', + user: 'testuser', + } + + beforeEach(function () { + sandbox = createSandbox() + getDatabaseStub = sandbox.stub(utils.pg.DatabaseResolver.prototype, 'getDatabase').resolves(mockDb) + execQueryStub = sandbox.stub(utils.pg.PsqlService.prototype, 'execQuery').resolves(expectedOutput) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('generateLongRunningQueriesQuery', function () { + it('generates the exact expected SQL query', function () { + const expectedQuery = `SELECT + pid, + now() - pg_stat_activity.query_start AS duration, + query AS query +FROM + pg_stat_activity +WHERE + pg_stat_activity.query <> ''::text + AND state <> 'idle' + AND now() - pg_stat_activity.query_start > interval '5 minutes' +ORDER BY + now() - pg_stat_activity.query_start DESC;` + + expect(generateLongRunningQueriesQuery()).to.equal(expectedQuery) + }) + + it('generates SQL selecting long running queries', function () { + const query = generateLongRunningQueriesQuery() + expect(query).to.contain('pg_stat_activity') + expect(query).to.contain('now() - pg_stat_activity.query_start') + expect(query).to.contain("state <> 'idle'") + expect(query).to.contain("interval '5 minutes'") + }) + }) + + describe('command behavior', function () { + it('displays long running queries', async function () { + const {stderr, stdout} = await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + expect(getDatabaseStub.calledOnceWith('myapp')).to.be.true + expect(execQueryStub.calledOnce).to.be.true + expect(execQueryStub.getCall(0).args[0]).to.eq(generateLongRunningQueriesQuery()) + expect(stdout.trim()).to.eq(expectedOutput) + expect(stderr).to.eq('') + }) + + it('accepts a database argument', async function () { + await runCommand(Cmd, [ + '--app', + 'myapp', + 'postgres-123', + ]) + + expect(getDatabaseStub.calledOnce).to.be.true + expect(getDatabaseStub.getCall(0).args[1]).to.eq('postgres-123') + }) + }) + + describe('error handling', function () { + it('surfaces database connection failures', async function () { + getDatabaseStub.rejects(new Error('Database connection failed')) + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + expect(error?.message).to.contain('Database connection failed') + }) + + it('surfaces SQL execution failures', async function () { + execQueryStub.rejects(new Error('SQL execution failed')) + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + expect(error?.message).to.contain('SQL execution failed') + }) + }) +}) diff --git a/test/unit/commands/pg/mandelbrot.unit.test.ts b/test/unit/commands/pg/mandelbrot.unit.test.ts new file mode 100644 index 0000000000..de86630cd6 --- /dev/null +++ b/test/unit/commands/pg/mandelbrot.unit.test.ts @@ -0,0 +1,111 @@ +import {runCommand} from '@heroku-cli/test-utils' +import {pg, utils} from '@heroku/heroku-cli-util' +import {expect} from 'chai' +import { + createSandbox, SinonSandbox, SinonStub, +} from 'sinon' + +import Cmd, {generateMandelbrotQuery} from '../../../../src/commands/pg/mandelbrot.js' + +describe('pg:mandelbrot', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = [ + ' ....,,,,----++++', + ' ...,,,----++++%%%%@', + ' ..,,,---+++%%%@@@####@', + ].join('\n') + + const mockDb: pg.ConnectionDetails = { + database: 'testdb', + host: 'localhost', + password: 'testpass', + pathname: '/testdb', + port: '5432', + url: 'postgres://localhost:5432/testdb', + user: 'testuser', + } + + beforeEach(function () { + sandbox = createSandbox() + getDatabaseStub = sandbox.stub(utils.pg.DatabaseResolver.prototype, 'getDatabase').resolves(mockDb) + execQueryStub = sandbox.stub(utils.pg.PsqlService.prototype, 'execQuery').resolves(expectedOutput) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('generateMandelbrotQuery', function () { + it('generates the exact expected SQL query', function () { + const expectedQuery = `WITH RECURSIVE Z(IX, IY, CX, CY, X, Y, I) AS ( + SELECT IX, IY, X::float, Y::float, X::float, Y::float, 0 + FROM (select -2.2 + 0.031 * i, i from generate_series(0,101) as i) as xgen(x,ix), + (select -1.5 + 0.031 * i, i from generate_series(0,101) as i) as ygen(y,iy) + UNION ALL + SELECT IX, IY, CX, CY, X * X - Y * Y + CX AS X, Y * X * 2 + CY, I + 1 + FROM Z + WHERE X * X + Y * Y < 16::float + AND I < 100 + ) +SELECT array_to_string(array_agg(SUBSTRING(' .,,,-----++++%%%%@@@@#### ', LEAST(GREATEST(I,1),27), 1)),'') +FROM ( + SELECT IX, IY, MAX(I) AS I + FROM Z + GROUP BY IY, IX + ORDER BY IY, IX + ) AS ZT +GROUP BY IY +ORDER BY IY` + + expect(generateMandelbrotQuery()).to.equal(expectedQuery) + }) + + it('generates SQL with the expected clauses', function () { + const query = generateMandelbrotQuery() + expect(query).to.contain('RECURSIVE Z') + expect(query).to.contain('generate_series') + }) + }) + + describe('command behavior', function () { + it('displays the mandelbrot set', async function () { + const {stderr, stdout} = await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + expect(getDatabaseStub.calledOnceWith('myapp')).to.be.true + expect(execQueryStub.calledOnce).to.be.true + expect(execQueryStub.getCall(0).args[0]).to.eq(generateMandelbrotQuery()) + expect(stdout).to.contain(expectedOutput) + expect(stderr).to.eq('') + }) + + it('accepts a database argument', async function () { + await runCommand(Cmd, [ + '--app', + 'myapp', + 'postgres-123', + ]) + + expect(getDatabaseStub.calledOnce).to.be.true + expect(getDatabaseStub.getCall(0).args[1]).to.eq('postgres-123') + }) + }) + + describe('error handling', function () { + it('surfaces database connection failures', async function () { + getDatabaseStub.rejects(new Error('Database connection failed')) + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + expect(error?.message).to.contain('Database connection failed') + }) + + it('surfaces SQL execution failures', async function () { + execQueryStub.rejects(new Error('SQL execution failed')) + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + expect(error?.message).to.contain('SQL execution failed') + }) + }) +}) diff --git a/test/unit/commands/pg/records-rank.unit.test.ts b/test/unit/commands/pg/records-rank.unit.test.ts new file mode 100644 index 0000000000..01f9190862 --- /dev/null +++ b/test/unit/commands/pg/records-rank.unit.test.ts @@ -0,0 +1,97 @@ +import {runCommand} from '@heroku-cli/test-utils' +import {pg, utils} from '@heroku/heroku-cli-util' +import {expect} from 'chai' +import { + createSandbox, SinonSandbox, SinonStub, +} from 'sinon' + +import Cmd, {generateRecordsRankQuery} from '../../../../src/commands/pg/records-rank.js' + +describe('pg:records-rank', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = 'name | estimated_count\n---+---\nusers | 1000' + + const mockDb: pg.ConnectionDetails = { + database: 'testdb', + host: 'localhost', + password: 'testpass', + pathname: '/testdb', + port: '5432', + url: 'postgres://localhost:5432/testdb', + user: 'testuser', + } + + beforeEach(function () { + sandbox = createSandbox() + getDatabaseStub = sandbox.stub(utils.pg.DatabaseResolver.prototype, 'getDatabase').resolves(mockDb) + execQueryStub = sandbox.stub(utils.pg.PsqlService.prototype, 'execQuery').resolves(expectedOutput) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('generateRecordsRankQuery', function () { + it('generates the exact expected SQL query', function () { + const expectedQuery = `SELECT + relname AS name, + n_live_tup AS estimated_count +FROM + pg_stat_user_tables +ORDER BY + n_live_tup DESC;` + + expect(generateRecordsRankQuery()).to.equal(expectedQuery) + }) + + it('generates SQL ranking tables by row count', function () { + const query = generateRecordsRankQuery() + expect(query).to.contain('pg_stat_user_tables') + expect(query).to.contain('n_live_tup AS estimated_count') + expect(query).to.contain('ORDER BY') + expect(query).to.contain('n_live_tup DESC') + }) + }) + + describe('command behavior', function () { + it('displays the records rank', async function () { + const {stderr, stdout} = await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + expect(getDatabaseStub.calledOnceWith('myapp')).to.be.true + expect(execQueryStub.calledOnce).to.be.true + expect(execQueryStub.getCall(0).args[0]).to.eq(generateRecordsRankQuery()) + expect(stdout.trim()).to.eq(expectedOutput) + expect(stderr).to.eq('') + }) + + it('accepts a database argument', async function () { + await runCommand(Cmd, [ + '--app', + 'myapp', + 'postgres-123', + ]) + + expect(getDatabaseStub.calledOnce).to.be.true + expect(getDatabaseStub.getCall(0).args[1]).to.eq('postgres-123') + }) + }) + + describe('error handling', function () { + it('surfaces database connection failures', async function () { + getDatabaseStub.rejects(new Error('Database connection failed')) + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + expect(error?.message).to.contain('Database connection failed') + }) + + it('surfaces SQL execution failures', async function () { + execQueryStub.rejects(new Error('SQL execution failed')) + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + expect(error?.message).to.contain('SQL execution failed') + }) + }) +}) diff --git a/test/unit/commands/pg/seq-scans.unit.test.ts b/test/unit/commands/pg/seq-scans.unit.test.ts new file mode 100644 index 0000000000..c0a0098aa7 --- /dev/null +++ b/test/unit/commands/pg/seq-scans.unit.test.ts @@ -0,0 +1,94 @@ +import {runCommand} from '@heroku-cli/test-utils' +import {pg, utils} from '@heroku/heroku-cli-util' +import {expect} from 'chai' +import { + createSandbox, SinonSandbox, SinonStub, +} from 'sinon' + +import Cmd, {generateSeqScansQuery} from '../../../../src/commands/pg/seq-scans.js' + +describe('pg:seq-scans', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = 'name | count\n---+---\nusers | 42' + + const mockDb: pg.ConnectionDetails = { + database: 'testdb', + host: 'localhost', + password: 'testpass', + pathname: '/testdb', + port: '5432', + url: 'postgres://localhost:5432/testdb', + user: 'testuser', + } + + beforeEach(function () { + sandbox = createSandbox() + getDatabaseStub = sandbox.stub(utils.pg.DatabaseResolver.prototype, 'getDatabase').resolves(mockDb) + execQueryStub = sandbox.stub(utils.pg.PsqlService.prototype, 'execQuery').resolves(expectedOutput) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('generateSeqScansQuery', function () { + it('generates the exact expected SQL query', function () { + const expectedQuery = `SELECT relname AS name, + seq_scan as count +FROM + pg_stat_user_tables +ORDER BY seq_scan DESC;` + + expect(generateSeqScansQuery()).to.equal(expectedQuery) + }) + + it('generates SQL counting sequential scans by table', function () { + const query = generateSeqScansQuery() + expect(query).to.contain('pg_stat_user_tables') + expect(query).to.contain('seq_scan') + expect(query).to.contain('ORDER BY seq_scan DESC') + }) + }) + + describe('command behavior', function () { + it('displays the sequential scan counts', async function () { + const {stderr, stdout} = await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + expect(getDatabaseStub.calledOnceWith('myapp')).to.be.true + expect(execQueryStub.calledOnce).to.be.true + expect(execQueryStub.getCall(0).args[0]).to.eq(generateSeqScansQuery()) + expect(stdout.trim()).to.eq(expectedOutput) + expect(stderr).to.eq('') + }) + + it('accepts a database argument', async function () { + await runCommand(Cmd, [ + '--app', + 'myapp', + 'postgres-123', + ]) + + expect(getDatabaseStub.calledOnce).to.be.true + expect(getDatabaseStub.getCall(0).args[1]).to.eq('postgres-123') + }) + }) + + describe('error handling', function () { + it('surfaces database connection failures', async function () { + getDatabaseStub.rejects(new Error('Database connection failed')) + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + expect(error?.message).to.contain('Database connection failed') + }) + + it('surfaces SQL execution failures', async function () { + execQueryStub.rejects(new Error('SQL execution failed')) + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + expect(error?.message).to.contain('SQL execution failed') + }) + }) +}) diff --git a/test/unit/commands/pg/stats-reset.unit.test.ts b/test/unit/commands/pg/stats-reset.unit.test.ts new file mode 100644 index 0000000000..a98e9e64cd --- /dev/null +++ b/test/unit/commands/pg/stats-reset.unit.test.ts @@ -0,0 +1,129 @@ +import {runCommand} from '@heroku-cli/test-utils' +import {pg, utils} from '@heroku/heroku-cli-util' +import {expect} from 'chai' +import nock from 'nock' +import { + createSandbox, SinonSandbox, SinonStub, +} from 'sinon' + +import Cmd from '../../../../src/commands/pg/stats-reset.js' + +const buildMockDb = (planName: string): pg.ConnectionDetails => ({ + attachment: { + addon: { + id: 'c667bce0-3c19-4372-8d5c-3eb1ff5d0e9a', + name: 'postgres-1', + plan: {name: planName}, + }, + name: 'DATABASE', + }, + database: 'testdb', + host: 'localhost', + password: 'testpass', + pathname: '/testdb', + port: '5432', + url: 'postgres://localhost:5432/testdb', + user: 'testuser', +} as unknown as pg.ConnectionDetails) + +describe('pg:stats-reset', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let getAttachmentStub: SinonStub + let pgApi: nock.Scope + const addonId = 'c667bce0-3c19-4372-8d5c-3eb1ff5d0e9a' + + beforeEach(function () { + sandbox = createSandbox() + getDatabaseStub = sandbox.stub(utils.pg.DatabaseResolver.prototype, 'getDatabase') + .resolves(buildMockDb('heroku-postgresql:premium-0')) + getAttachmentStub = sandbox.stub(utils.pg.DatabaseResolver.prototype, 'getAttachment') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .resolves({addon: {id: addonId, name: 'DATABASE'}} as any) + pgApi = nock('https://api.data.heroku.com') + }) + + afterEach(function () { + sandbox.restore() + nock.cleanAll() + }) + + it('resets the statistics and prints the returned message', async function () { + pgApi.put(`/client/v11/databases/${addonId}/stats_reset`) + .reply(200, {message: 'stats reset'}) + + const {stderr, stdout} = await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + expect(getDatabaseStub.calledOnce).to.be.true + expect(getAttachmentStub.calledOnce).to.be.true + expect(stdout.trim()).to.eq('stats reset') + expect(stderr).to.eq('') + pgApi.done() + }) + + it('sends the stats_reset request to the Postgres data API host with no body', async function () { + // Confirm the data API host nock matches what utils.pg.host() resolves to by default. + expect(utils.pg.host()).to.eq('api.data.heroku.com') + + let capturedBody: unknown + // The body matcher returns true only for an empty body, so the interceptor + // only matches (and pgApi.done() only succeeds) when no request body is sent. + pgApi.put(`/client/v11/databases/${addonId}/stats_reset`, body => { + capturedBody = body + return body === '' || body === undefined || (typeof body === 'object' && Object.keys(body).length === 0) + }) + .reply(200, {message: 'stats reset'}) + + const {stderr, stdout} = await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + // pgApi.done() asserts the interceptor on api.data.heroku.com was hit at the + // exact path, which proves the hostname option routed the PUT to the data API host. + pgApi.done() + expect(capturedBody === '' || (typeof capturedBody === 'object' && Object.keys(capturedBody as object).length === 0)).to.be.true + expect(stdout.trim()).to.eq('stats reset') + expect(stderr).to.eq('') + }) + + it('accepts a database argument', async function () { + pgApi.put(`/client/v11/databases/${addonId}/stats_reset`) + .reply(200, {message: 'stats reset'}) + + await runCommand(Cmd, [ + '--app', + 'myapp', + 'postgres-123', + ]) + + expect(getDatabaseStub.getCall(0).args[1]).to.eq('postgres-123') + expect(getAttachmentStub.getCall(0).args[1]).to.eq('postgres-123') + pgApi.done() + }) + + it('rejects Essential-tier databases', async function () { + getDatabaseStub.resolves(buildMockDb('heroku-postgresql:essential-0')) + + const {error} = await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + expect(error?.message).to.include('This operation is not supported by Essential-tier databases.') + }) + + it('surfaces an error when resolving the database fails', async function () { + getDatabaseStub.rejects(new Error('Couldn\'t find that database.')) + + const {error} = await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + expect(error?.message).to.include('Couldn\'t find that database.') + }) +}) diff --git a/test/unit/commands/pg/table-indexes-size.unit.test.ts b/test/unit/commands/pg/table-indexes-size.unit.test.ts new file mode 100644 index 0000000000..57da3aad46 --- /dev/null +++ b/test/unit/commands/pg/table-indexes-size.unit.test.ts @@ -0,0 +1,97 @@ +import {runCommand} from '@heroku-cli/test-utils' +import {pg, utils} from '@heroku/heroku-cli-util' +import {expect} from 'chai' +import { + createSandbox, SinonSandbox, SinonStub, +} from 'sinon' + +import Cmd, {generateTableIndexesSizeQuery} from '../../../../src/commands/pg/table-indexes-size.js' + +describe('pg:table-indexes-size', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = 'table | index_size\n---+---\nusers | 128 kB' + + const mockDb: pg.ConnectionDetails = { + database: 'testdb', + host: 'localhost', + password: 'testpass', + pathname: '/testdb', + port: '5432', + url: 'postgres://localhost:5432/testdb', + user: 'testuser', + } + + beforeEach(function () { + sandbox = createSandbox() + getDatabaseStub = sandbox.stub(utils.pg.DatabaseResolver.prototype, 'getDatabase').resolves(mockDb) + execQueryStub = sandbox.stub(utils.pg.PsqlService.prototype, 'execQuery').resolves(expectedOutput) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('generateTableIndexesSizeQuery', function () { + it('generates SQL with the expected clauses', function () { + const query = generateTableIndexesSizeQuery() + expect(query).to.contain('pg_indexes_size') + expect(query).to.contain('pg_class') + expect(query).to.contain('information_schema') + }) + + it('generates the exact expected SQL query', function () { + const expectedQuery = `SELECT c.relname AS table, + pg_size_pretty(pg_indexes_size(c.oid)) AS index_size +FROM pg_class c +LEFT JOIN pg_namespace n ON (n.oid = c.relnamespace) +WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') +AND n.nspname !~ '^pg_toast' +AND c.relkind='r' +ORDER BY pg_indexes_size(c.oid) DESC;` + + expect(generateTableIndexesSizeQuery()).to.equal(expectedQuery) + }) + }) + + describe('command behavior', function () { + it('displays the total size of all indexes on each table', async function () { + const {stderr, stdout} = await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + expect(getDatabaseStub.calledOnceWith('myapp')).to.be.true + expect(execQueryStub.calledOnce).to.be.true + expect(execQueryStub.getCall(0).args[0]).to.eq(generateTableIndexesSizeQuery()) + expect(stdout).to.contain(expectedOutput) + expect(stderr).to.eq('') + }) + + it('accepts a database argument', async function () { + await runCommand(Cmd, [ + '--app', + 'myapp', + 'postgres-123', + ]) + + expect(getDatabaseStub.calledOnce).to.be.true + expect(getDatabaseStub.getCall(0).args[1]).to.eq('postgres-123') + }) + }) + + describe('error handling', function () { + it('surfaces database connection failures', async function () { + getDatabaseStub.rejects(new Error('Database connection failed')) + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + expect(error?.message).to.contain('Database connection failed') + }) + + it('surfaces SQL execution failures', async function () { + execQueryStub.rejects(new Error('SQL execution failed')) + const {error} = await runCommand(Cmd, ['--app', 'myapp']) + expect(error?.message).to.contain('SQL execution failed') + }) + }) +})