From 7b80a130d2aba329d348033b5f8938bcfe731d59 Mon Sep 17 00:00:00 2001 From: Jon Dodson Date: Thu, 18 Jun 2026 12:46:18 -0700 Subject: [PATCH] First pass at adding the final set of pg-extras plugin commands --- cspell-dictionary.txt | 2 + src/commands/pg/table-size.ts | 44 +++++++ src/commands/pg/total-index-size.ts | 42 +++++++ src/commands/pg/total-table-size.ts | 44 +++++++ src/commands/pg/unused-indexes.ts | 46 +++++++ src/commands/pg/user-connections.ts | 43 +++++++ test/unit/commands/pg/table-size.unit.test.ts | 97 +++++++++++++++ .../commands/pg/total-index-size.unit.test.ts | 96 +++++++++++++++ .../commands/pg/total-table-size.unit.test.ts | 98 +++++++++++++++ .../commands/pg/unused-indexes.unit.test.ts | 112 ++++++++++++++++++ .../commands/pg/user-connections.unit.test.ts | 97 +++++++++++++++ .../commands/pg/vacuum-stats.unit.test.ts | 86 ++++++++++++++ 12 files changed, 807 insertions(+) create mode 100644 src/commands/pg/table-size.ts create mode 100644 src/commands/pg/total-index-size.ts create mode 100644 src/commands/pg/total-table-size.ts create mode 100644 src/commands/pg/unused-indexes.ts create mode 100644 src/commands/pg/user-connections.ts create mode 100644 test/unit/commands/pg/table-size.unit.test.ts create mode 100644 test/unit/commands/pg/total-index-size.unit.test.ts create mode 100644 test/unit/commands/pg/total-table-size.unit.test.ts create mode 100644 test/unit/commands/pg/unused-indexes.unit.test.ts create mode 100644 test/unit/commands/pg/user-connections.unit.test.ts create mode 100644 test/unit/commands/pg/vacuum-stats.unit.test.ts diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index df25f5638b..e68dc4ce2c 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -134,6 +134,8 @@ Icann infile iname indexrelid +indexrelname +indisunique indrelid inreplace iotta diff --git a/src/commands/pg/table-size.ts b/src/commands/pg/table-size.ts new file mode 100644 index 0000000000..61c50321ef --- /dev/null +++ b/src/commands/pg/table-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 generateTableSizeQuery = (): string => ` +SELECT c.relname AS name, + pg_size_pretty(pg_table_size(c.oid)) AS 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_table_size(c.oid) DESC; +`.trim() + +export default class PgTableSize extends Command { + static aliases = ['pg:table_size'] + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`, required: false}), + } + static description = 'show the size of the tables (excluding indexes), descending by size' + static examples = [heredoc` + ${color.command('heroku pg:table-size --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(PgTableSize) + 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(generateTableSizeQuery()) + ux.stdout(output) + } +} diff --git a/src/commands/pg/total-index-size.ts b/src/commands/pg/total-index-size.ts new file mode 100644 index 0000000000..dec37a0adc --- /dev/null +++ b/src/commands/pg/total-index-size.ts @@ -0,0 +1,42 @@ +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 generateTotalIndexSizeQuery = (): string => ` +SELECT pg_size_pretty(sum(c.relpages::bigint*8192)::bigint) AS 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='i'; +`.trim() + +export default class PgTotalIndexSize 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 indexes in MB' + static examples = [heredoc` + ${color.command('heroku pg:total-index-size --app example-app')} + `] + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + static hiddenAliases = ['pg:total_index_size'] + static topic = 'pg' + + public async run(): Promise { + const {args, flags} = await this.parse(PgTotalIndexSize) + 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(generateTotalIndexSizeQuery()) + ux.stdout(output) + } +} diff --git a/src/commands/pg/total-table-size.ts b/src/commands/pg/total-table-size.ts new file mode 100644 index 0000000000..89e3c921de --- /dev/null +++ b/src/commands/pg/total-table-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 generateTotalTableSizeQuery = (): string => ` +SELECT c.relname AS name, + pg_size_pretty(pg_total_relation_size(c.oid)) AS 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_total_relation_size(c.oid) DESC; +`.trim() + +export default class PgTotalTableSize extends Command { + static aliases = ['pg:total_table_size'] + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`, required: false}), + } + static description = 'show the size of the tables (including indexes), descending by size' + static examples = [heredoc` + ${color.command('heroku pg:total-table-size --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(PgTotalTableSize) + 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(generateTotalTableSizeQuery()) + ux.stdout(output) + } +} diff --git a/src/commands/pg/unused-indexes.ts b/src/commands/pg/unused-indexes.ts new file mode 100644 index 0000000000..cfa0bae03c --- /dev/null +++ b/src/commands/pg/unused-indexes.ts @@ -0,0 +1,46 @@ +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 generateUnusedIndexesQuery = (): string => ` +SELECT + schemaname || '.' || relname AS table, + indexrelname AS index, + pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size, + idx_scan as index_scans +FROM pg_stat_user_indexes ui +JOIN pg_index i ON ui.indexrelid = i.indexrelid +WHERE NOT indisunique AND idx_scan < 50 AND pg_relation_size(relid) > 5 * 8192 +ORDER BY pg_relation_size(i.indexrelid) / nullif(idx_scan, 0) DESC NULLS FIRST, +pg_relation_size(i.indexrelid) DESC; +`.trim() + +export default class PgUnusedIndexes extends Command { + static aliases = ['pg:unused_indexes'] + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`, required: false}), + } + static description = 'show unused and almost unused indexes' + static examples = [heredoc` + ${color.command('heroku pg:unused-indexes --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(PgUnusedIndexes) + 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(generateUnusedIndexesQuery()) + ux.stdout(output) + } +} diff --git a/src/commands/pg/user-connections.ts b/src/commands/pg/user-connections.ts new file mode 100644 index 0000000000..aa2739c2b0 --- /dev/null +++ b/src/commands/pg/user-connections.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 generateUserConnectionsQuery = (): string => ` +SELECT + usename AS credential, + count(*) AS connections +FROM pg_stat_activity +WHERE state = 'active' +GROUP BY usename +ORDER BY connections DESC; +`.trim() + +export default class PgUserConnections extends Command { + static args = { + database: Args.string({description: `${nls('pg:database:arg:description')} ${nls('pg:database:arg:description:default:suffix')}`, required: false}), + } + static description = 'returns the number of connections per credential' + static examples = [heredoc` + ${color.command('heroku pg:user-connections --app example-app')} + `] + static flags = { + app: flags.app({required: true}), + remote: flags.remote(), + } + static hiddenAliases = ['pg:user_connections'] + static topic = 'pg' + + public async run(): Promise { + const {args, flags} = await this.parse(PgUserConnections) + 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(generateUserConnectionsQuery()) + ux.stdout(output) + } +} diff --git a/test/unit/commands/pg/table-size.unit.test.ts b/test/unit/commands/pg/table-size.unit.test.ts new file mode 100644 index 0000000000..ecfe5ddaec --- /dev/null +++ b/test/unit/commands/pg/table-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, {generateTableSizeQuery} from '../../../../src/commands/pg/table-size.js' + +describe('pg:table-size', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = 'name | size\n---+---\nusers | 50 MB' + + 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('generateTableSizeQuery', function () { + it('generates SQL with the expected clauses', function () { + const query = generateTableSizeQuery() + expect(query).to.contain('pg_table_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 name, + pg_size_pretty(pg_table_size(c.oid)) AS 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_table_size(c.oid) DESC;` + + expect(generateTableSizeQuery()).to.equal(expectedQuery) + }) + }) + + describe('command behavior', function () { + it('displays the size of the tables', 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(generateTableSizeQuery()) + 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/total-index-size.unit.test.ts b/test/unit/commands/pg/total-index-size.unit.test.ts new file mode 100644 index 0000000000..3f4002a305 --- /dev/null +++ b/test/unit/commands/pg/total-index-size.unit.test.ts @@ -0,0 +1,96 @@ +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, {generateTotalIndexSizeQuery} from '../../../../src/commands/pg/total-index-size.js' + +describe('pg:total-index-size', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = 'size\n---\n15.2 MB' + + 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('generateTotalIndexSizeQuery', function () { + it('generates SQL with the expected clauses', function () { + const query = generateTotalIndexSizeQuery() + expect(query).to.contain('sum(c.relpages') + expect(query).to.contain('pg_size_pretty') + expect(query).to.contain("c.relkind='i'") + expect(query).to.contain('information_schema') + }) + + it('generates the exact expected SQL query', function () { + const expectedQuery = `SELECT pg_size_pretty(sum(c.relpages::bigint*8192)::bigint) AS 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='i';` + + expect(generateTotalIndexSizeQuery()).to.equal(expectedQuery) + }) + }) + + describe('command behavior', function () { + it('displays the total size of all indexes', 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(generateTotalIndexSizeQuery()) + 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/total-table-size.unit.test.ts b/test/unit/commands/pg/total-table-size.unit.test.ts new file mode 100644 index 0000000000..b401001f0a --- /dev/null +++ b/test/unit/commands/pg/total-table-size.unit.test.ts @@ -0,0 +1,98 @@ +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, {generateTotalTableSizeQuery} from '../../../../src/commands/pg/total-table-size.js' + +describe('pg:total-table-size', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = 'name | size\n---+---\nusers | 75 MB' + + 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('generateTotalTableSizeQuery', function () { + it('generates SQL with the expected clauses', function () { + const query = generateTotalTableSizeQuery() + expect(query).to.contain('pg_total_relation_size') + expect(query).to.contain('pg_class') + expect(query).to.contain('information_schema') + expect(query).to.contain("c.relkind='r'") + }) + + it('generates the exact expected SQL query', function () { + const expectedQuery = `SELECT c.relname AS name, + pg_size_pretty(pg_total_relation_size(c.oid)) AS 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_total_relation_size(c.oid) DESC;` + + expect(generateTotalTableSizeQuery()).to.equal(expectedQuery) + }) + }) + + describe('command behavior', function () { + it('displays the size of the tables (including indexes)', 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(generateTotalTableSizeQuery()) + 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/unused-indexes.unit.test.ts b/test/unit/commands/pg/unused-indexes.unit.test.ts new file mode 100644 index 0000000000..7a6b462545 --- /dev/null +++ b/test/unit/commands/pg/unused-indexes.unit.test.ts @@ -0,0 +1,112 @@ +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, {generateUnusedIndexesQuery} from '../../../../src/commands/pg/unused-indexes.js' + +describe('pg:unused-indexes', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = 'table | index | index_size | index_scans\n---+---\npublic.users | idx_users_email | 2.1 MB | 12' + + 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('generateUnusedIndexesQuery', function () { + it('generates SQL with idx_scan < 50 filter', function () { + const query = generateUnusedIndexesQuery() + expect(query).to.contain('idx_scan < 50') + }) + + it('generates SQL with NOT indisunique filter', function () { + const query = generateUnusedIndexesQuery() + expect(query).to.contain('NOT indisunique') + }) + + it('generates SQL with pg_relation_size filter', function () { + const query = generateUnusedIndexesQuery() + expect(query).to.contain('pg_relation_size(relid) > 5 * 8192') + }) + + it('generates SQL with correct ORDER BY clause', function () { + const query = generateUnusedIndexesQuery() + expect(query).to.contain('ORDER BY pg_relation_size(i.indexrelid) / nullif(idx_scan, 0) DESC NULLS FIRST') + }) + + it('generates the exact expected SQL query', function () { + const expectedQuery = `SELECT + schemaname || '.' || relname AS table, + indexrelname AS index, + pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size, + idx_scan as index_scans +FROM pg_stat_user_indexes ui +JOIN pg_index i ON ui.indexrelid = i.indexrelid +WHERE NOT indisunique AND idx_scan < 50 AND pg_relation_size(relid) > 5 * 8192 +ORDER BY pg_relation_size(i.indexrelid) / nullif(idx_scan, 0) DESC NULLS FIRST, +pg_relation_size(i.indexrelid) DESC;` + + expect(generateUnusedIndexesQuery()).to.equal(expectedQuery) + }) + }) + + describe('command behavior', function () { + it('displays unused and almost unused indexes', 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(generateUnusedIndexesQuery()) + 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/user-connections.unit.test.ts b/test/unit/commands/pg/user-connections.unit.test.ts new file mode 100644 index 0000000000..cf43b33e68 --- /dev/null +++ b/test/unit/commands/pg/user-connections.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, {generateUserConnectionsQuery} from '../../../../src/commands/pg/user-connections.js' + +describe('pg:user-connections', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = 'credential | connections\n---+---\npostgres | 5' + + 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('generateUserConnectionsQuery', function () { + it('generates SQL with the expected clauses', function () { + const query = generateUserConnectionsQuery() + expect(query).to.contain('WHERE state = \'active\'') + expect(query).to.contain('GROUP BY usename') + expect(query).to.contain('ORDER BY connections DESC') + expect(query).to.contain('count(*) AS connections') + }) + + it('generates the exact expected SQL query', function () { + const expectedQuery = `SELECT + usename AS credential, + count(*) AS connections +FROM pg_stat_activity +WHERE state = 'active' +GROUP BY usename +ORDER BY connections DESC;` + + expect(generateUserConnectionsQuery()).to.equal(expectedQuery) + }) + }) + + describe('command behavior', function () { + it('displays the number of connections per credential', 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(generateUserConnectionsQuery()) + 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/vacuum-stats.unit.test.ts b/test/unit/commands/pg/vacuum-stats.unit.test.ts new file mode 100644 index 0000000000..80e34fda14 --- /dev/null +++ b/test/unit/commands/pg/vacuum-stats.unit.test.ts @@ -0,0 +1,86 @@ +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 from '../../../../src/commands/pg/vacuum-stats.js' + +describe('pg:vacuum-stats', function () { + let sandbox: SinonSandbox + let getDatabaseStub: SinonStub + let execQueryStub: SinonStub + const expectedOutput = 'schema | table | last_vacuum\n---+---\npublic | users | 2024-01-01 12:00' + + 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('command behavior', function () { + it('displays dead rows and whether an automatic vacuum is expected', async function () { + const {stderr, stdout} = await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + expect(getDatabaseStub.calledOnceWith('myapp')).to.be.true + expect(execQueryStub.calledOnce).to.be.true + expect(stdout).to.contain(expectedOutput) + expect(stderr).to.eq('') + }) + + it('executes the vacuum-stats query with expected clauses', async function () { + await runCommand(Cmd, [ + '--app', + 'myapp', + ]) + + const executedQuery = execQueryStub.getCall(0).args[0] + expect(executedQuery).to.contain('autovacuum_vacuum_threshold') + expect(executedQuery).to.contain('pg_stat_user_tables') + expect(executedQuery).to.contain('expect_autovacuum') + }) + + 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') + }) + }) +})