Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cspell-dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ Icann
infile
iname
indexrelid
indexrelname
indisunique
indrelid
inreplace
iotta
Expand Down
44 changes: 44 additions & 0 deletions src/commands/pg/table-size.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
42 changes: 42 additions & 0 deletions src/commands/pg/total-index-size.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
44 changes: 44 additions & 0 deletions src/commands/pg/total-table-size.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
46 changes: 46 additions & 0 deletions src/commands/pg/unused-indexes.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
43 changes: 43 additions & 0 deletions src/commands/pg/user-connections.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
97 changes: 97 additions & 0 deletions test/unit/commands/pg/table-size.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Loading
Loading