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
22 changes: 22 additions & 0 deletions pgpm/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,28 @@ pgpm package --no-plan

### Utilities

#### `pgpm dump`

Dump a postgres database to a sql file.

```bash
# dump to default timestamped file
pgpm dump --database mydb

# interactive mode (prompts for database)
pgpm dump

# dump to specific file
pgpm dump --database mydb --out ./backup.sql

# dump from a specific working directory
pgpm dump --database mydb --cwd ./packages/my-module

# dump with pruning
# useful for creating test fixtures or development snapshots
pgpm dump --database mydb --database-id <uuid>
```

#### `pgpm export`

Export migrations from existing databases.
Expand Down
114 changes: 114 additions & 0 deletions pgpm/cli/__tests__/dump.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@

import * as child_process from 'child_process';
import * as fs from 'fs';
import * as path from 'path';

// Mock child_process
jest.mock('child_process');
// Mock fs
jest.mock('fs');
// mock utils to avoid loading deep dependencies that cause issues in test environment
jest.mock('../src/utils', () => ({
getTargetDatabase: jest.fn().mockResolvedValue('test_db')
}));

// mock quoteutils
jest.mock('pgsql-deparser/utils/quote-utils', () => ({
QuoteUtils: {
quoteIdentifier: (s: string) => `"${s}"`,
quoteQualifiedIdentifier: (s: string, t: string) => `"${s}"."${t}"`,
formatEString: (s: string) => `'${s}'`
}
}));

// mock pg-cache to simulate db results for prune logic
const mockPoolQuery = jest.fn();
jest.mock('pg-cache', () => ({
getPgPool: () => ({
query: mockPoolQuery
})
}));

import dumpCmd from '../src/commands/dump';

describe('dump command', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should call pg_dump with correct arguments', async () => {
const spawnMock = child_process.spawn as unknown as jest.Mock;
spawnMock.mockImplementation(() => ({
on: (event: string, cb: any) => {
if (event === 'close') cb(0);
},
stdout: { pipe: jest.fn() },
stderr: { pipe: jest.fn() },
}));

const fsMkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
const fsWriteSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => { });

const argv = {
database: 'test_db',
out: 'output.sql',
cwd: '/tmp'
};
const prompter = {} as any;
const options = {} as any;

await dumpCmd(argv, prompter, options);

expect(spawnMock).toHaveBeenCalledWith(
'pg_dump',
expect.arrayContaining([
'--format=plain',
'--no-owner',
'--no-privileges',
'--file',
expect.stringContaining('output.sql'),
'test_db'
]),
expect.objectContaining({
env: expect.anything()
})
);
});

it('should generate prune sql when database-id is provided', async () => {
const spawnMock = child_process.spawn as unknown as jest.Mock;
spawnMock.mockImplementation(() => ({
on: (event: string, cb: any) => {
if (event === 'close') cb(0);
}
}));

jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
const fsWriteSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
jest.spyOn(console, 'log').mockImplementation(() => { });

// mock db responses for resolveDatabaseId and buildPruneSql
mockPoolQuery
.mockResolvedValueOnce({ rows: [{ id: 'uuid-123', name: 'test_db_id' }] }) // resolveDatabaseId
.mockResolvedValueOnce({ rows: [{ table_schema: 'public', table_name: 'test_table' }] }); // buildPruneSql

const argv = {
database: 'test_db',
out: 'output_prune.sql',
'database-id': 'uuid-123',
cwd: '/tmp'
};
const prompter = {} as any;
const options = {} as any;

await dumpCmd(argv, prompter, options);

// verify prune sql appended
expect(fsWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('output_prune.sql'),
expect.stringContaining('delete from "public"."test_table" where database_id <> \'uuid-123\''),
expect.objectContaining({ flag: 'a' })
);
});
});
1 change: 1 addition & 0 deletions pgpm/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"minimist": "^1.2.8",
"pg-cache": "workspace:^",
"pg-env": "workspace:^",
"pgsql-deparser": "^17.17.0",
"semver": "^7.6.2",
"shelljs": "^0.10.0",
"yanse": "^0.1.11"
Expand Down
2 changes: 2 additions & 0 deletions pgpm/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import cache from './commands/cache';
import clear from './commands/clear';
import deploy from './commands/deploy';
import docker from './commands/docker';
import dump from './commands/dump';
import env from './commands/env';
import _export from './commands/export';
import extension from './commands/extension';
Expand Down Expand Up @@ -48,6 +49,7 @@ export const createPgpmCommandMap = (skipPgTeardown: boolean = false): Record<st
clear: pgt(clear),
deploy: pgt(deploy),
docker,
dump: pgt(dump),
env,
verify: pgt(verify),
revert: pgt(revert),
Expand Down
195 changes: 195 additions & 0 deletions pgpm/cli/src/commands/dump.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { Logger } from '@pgpmjs/logger';
import { CLIOptions, Inquirerer } from 'inquirerer';
import { ParsedArgs } from 'minimist';
import { getPgEnvOptions, getSpawnEnvWithPg } from 'pg-env';
import { getPgPool } from 'pg-cache';
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { QuoteUtils } from 'pgsql-deparser/utils/quote-utils';

import { getTargetDatabase } from '../utils';

const log = new Logger('dump');

const dumpUsageText = `
Dump Command:

pgpm dump [options]

Dump a postgres database to a sql file.

Options:
--help, -h Show this help message
--db, --database <name> Target postgres database name
--out <path> Output file path
--database-id <id> When set, the dump will include a prune step that keeps only this database_id after restore
--cwd <directory> Working directory (default: current directory)

Examples:
pgpm dump
pgpm dump --database mydb
pgpm dump --database mydb --out ./mydb.sql
pgpm dump --database mydb --database-id 00000000-0000-0000-0000-000000000000
`;

function nowStamp(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}

async function runPgDump(args: string[], env: NodeJS.ProcessEnv): Promise<void> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using the lib you said, much cleaner @pyramation

await new Promise<void>((resolve, reject) => {
const child = spawn('pg_dump', args, {
env,
stdio: 'inherit',
shell: false
});

child.on('error', (err: any) => {
if (err.code === 'ENOENT') {
log.error('pg_dump not found; ensure PostgreSQL client tools are installed and in PATH');
}
reject(err);
});

child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`pg_dump exited with code ${code ?? 1}`));
});
});
}

async function resolveDatabaseId(dbname: string, databaseIdRaw: string): Promise<{ id: string; name: string } | null> {
const pool = getPgPool(getPgEnvOptions({ database: dbname }));
const res = await pool.query(`select id, name from metaschema_public.database order by name`);

const byId = res.rows.find((r: any) => String(r.id) === databaseIdRaw);
if (byId) return { id: String(byId.id), name: String(byId.name) };

const byName = res.rows.find((r: any) => String(r.name) === databaseIdRaw);
if (byName) return { id: String(byName.id), name: String(byName.name) };

return null;
}

async function buildPruneSql(dbname: string, databaseId: string): Promise<string> {
const pool = getPgPool(getPgEnvOptions({ database: dbname }));

const tables = await pool.query(`
select c.table_schema, c.table_name
from information_schema.columns c
join information_schema.tables t
on t.table_schema = c.table_schema
and t.table_name = c.table_name
where c.column_name = 'database_id'
and t.table_type = 'BASE TABLE'
and c.table_schema not in ('pg_catalog', 'information_schema')
order by c.table_schema, c.table_name
`);

const lines: string[] = [];
lines.push('');
lines.push('-- pgpm dump prune');
lines.push('-- this section keeps only one database_id after restore');
lines.push('do $$ begin');
lines.push(` raise notice 'pruning data to database_id ${databaseId}';`);
lines.push('end $$;');
lines.push('set session_replication_role = replica;');

for (const row of tables.rows) {
const schema = String(row.table_schema);
const table = String(row.table_name);
// Use QuoteUtils for robust identifier quoting
const qualified = QuoteUtils.quoteQualifiedIdentifier(schema, table);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here @pyramation, much cleaner

// Use formatEString to safely escape the UUID/string literal
const dbIdLiteral = QuoteUtils.formatEString(databaseId);
lines.push(`delete from ${qualified} where database_id <> ${dbIdLiteral};`);
}

// Handle metaschema_public.database deletion
const metaschemaDb = QuoteUtils.quoteQualifiedIdentifier('metaschema_public', 'database');
const dbIdLiteral = QuoteUtils.formatEString(databaseId);
lines.push(`delete from ${metaschemaDb} where id <> ${dbIdLiteral};`);

lines.push('set session_replication_role = origin;');
lines.push('do $$ begin');
lines.push(` raise notice 'prune done';`);
lines.push('end $$;');
lines.push('');

return lines.join('\n');
}

// Helper to retrieve argument from parsed argv or positional _ array
function getArg(argv: Partial<ParsedArgs>, key: string): string | undefined {
if (argv[key]) return argv[key] as string;
const args = (argv._ as string[]) || [];
const idx = args.indexOf(`--${key}`);
if (idx > -1 && args.length > idx + 1) {
return args[idx + 1];
}
return undefined;
}

export default async (
argv: Partial<ParsedArgs>,
prompter: Inquirerer,
_options: CLIOptions
) => {
if (argv.help || argv.h) {
console.log(dumpUsageText);
process.exit(0);
}

const cwd = (argv.cwd as string) || process.cwd();
const dbname = await getTargetDatabase(argv, prompter, { message: 'Select database' });

const outPath = path.resolve(cwd, (argv.out as string) || `pgpm-dump-${dbname}-${nowStamp()}.sql`);
fs.mkdirSync(path.dirname(outPath), { recursive: true });

let databaseIdInfo: { id: string; name: string } | null = null;
const databaseIdRaw = getArg(argv, 'database-id');
if (databaseIdRaw) {
databaseIdInfo = await resolveDatabaseId(dbname, databaseIdRaw);
if (!databaseIdInfo) {
throw new Error(`unknown database-id ${databaseIdRaw}`);
}
}

log.info(`dumping database ${dbname}`);
log.info(`writing to ${outPath}`);
if (databaseIdInfo) {
log.info(`database id ${databaseIdInfo.id}`);
}

const pgEnv = getPgEnvOptions({ database: dbname });
const spawnEnv = getSpawnEnvWithPg(pgEnv);

const args = [
'--format=plain',
'--no-owner',
'--no-privileges',
'--file',
outPath,
dbname
];

await runPgDump(args, spawnEnv);

if (databaseIdInfo) {
const pruneSql = await buildPruneSql(dbname, databaseIdInfo.id);
// Use writeFileSync with 'a' flag for explicit append as requested
fs.writeFileSync(outPath, pruneSql, { encoding: 'utf8', flag: 'a' });
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

write sync @pyramation , still works

log.info('added prune section to dump file');
}

log.success('dump complete');
return argv;
};


1 change: 1 addition & 0 deletions pgpm/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export { default as analyze } from './commands/analyze';
export { default as clear } from './commands/clear';
export { default as deploy } from './commands/deploy';
export { default as docker } from './commands/docker';
export { default as dump } from './commands/dump';
export { default as env } from './commands/env';
export { default as _export } from './commands/export';
export { default as extension } from './commands/extension';
Expand Down
1 change: 1 addition & 0 deletions pgpm/cli/src/utils/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const usageText = `
upgrade Upgrade installed pgpm modules to latest versions (alias: up)

Database Administration:
dump Dump a database to a sql file
kill Terminate database connections and optionally drop databases
install Install database modules
tag Add tags to changes for versioning
Expand Down
Loading