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 @@ -415,9 +415,11 @@ wrapline
writeout
wubalubadubdub
xact
xgen
xlarge
xvzf
yargs
yetanotherapp
ygen
yourdomain
ztestdomain7
48 changes: 48 additions & 0 deletions src/commands/pg/long-running-queries.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
54 changes: 54 additions & 0 deletions src/commands/pg/mandelbrot.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
43 changes: 43 additions & 0 deletions src/commands/pg/records-rank.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 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<void> {
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)
}
}
41 changes: 41 additions & 0 deletions src/commands/pg/seq-scans.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
38 changes: 38 additions & 0 deletions src/commands/pg/stats-reset.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
44 changes: 44 additions & 0 deletions src/commands/pg/table-indexes-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 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<void> {
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)
}
}
102 changes: 102 additions & 0 deletions test/unit/commands/pg/long-running-queries.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Loading
Loading