Skip to content

Commit 7d31920

Browse files
authored
Merge pull request #573 from constructive-io/dev/pgpm-dump
added pgpm dump
2 parents ba5bbac + 654d60f commit 7d31920

File tree

8 files changed

+2804
-7023
lines changed

8 files changed

+2804
-7023
lines changed

pgpm/cli/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,28 @@ pgpm package --no-plan
328328

329329
### Utilities
330330

331+
#### `pgpm dump`
332+
333+
Dump a postgres database to a sql file.
334+
335+
```bash
336+
# dump to default timestamped file
337+
pgpm dump --database mydb
338+
339+
# interactive mode (prompts for database)
340+
pgpm dump
341+
342+
# dump to specific file
343+
pgpm dump --database mydb --out ./backup.sql
344+
345+
# dump from a specific working directory
346+
pgpm dump --database mydb --cwd ./packages/my-module
347+
348+
# dump with pruning
349+
# useful for creating test fixtures or development snapshots
350+
pgpm dump --database mydb --database-id <uuid>
351+
```
352+
331353
#### `pgpm export`
332354

333355
Export migrations from existing databases.

pgpm/cli/__tests__/dump.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
2+
import * as child_process from 'child_process';
3+
import * as fs from 'fs';
4+
import * as path from 'path';
5+
6+
// Mock child_process
7+
jest.mock('child_process');
8+
// Mock fs
9+
jest.mock('fs');
10+
// mock utils to avoid loading deep dependencies that cause issues in test environment
11+
jest.mock('../src/utils', () => ({
12+
getTargetDatabase: jest.fn().mockResolvedValue('test_db')
13+
}));
14+
15+
// mock quoteutils
16+
jest.mock('pgsql-deparser/utils/quote-utils', () => ({
17+
QuoteUtils: {
18+
quoteIdentifier: (s: string) => `"${s}"`,
19+
quoteQualifiedIdentifier: (s: string, t: string) => `"${s}"."${t}"`,
20+
formatEString: (s: string) => `'${s}'`
21+
}
22+
}));
23+
24+
// mock pg-cache to simulate db results for prune logic
25+
const mockPoolQuery = jest.fn();
26+
jest.mock('pg-cache', () => ({
27+
getPgPool: () => ({
28+
query: mockPoolQuery
29+
})
30+
}));
31+
32+
import dumpCmd from '../src/commands/dump';
33+
34+
describe('dump command', () => {
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
});
38+
39+
it('should call pg_dump with correct arguments', async () => {
40+
const spawnMock = child_process.spawn as unknown as jest.Mock;
41+
spawnMock.mockImplementation(() => ({
42+
on: (event: string, cb: any) => {
43+
if (event === 'close') cb(0);
44+
},
45+
stdout: { pipe: jest.fn() },
46+
stderr: { pipe: jest.fn() },
47+
}));
48+
49+
const fsMkdirSpy = jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
50+
const fsWriteSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
51+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => { });
52+
53+
const argv = {
54+
database: 'test_db',
55+
out: 'output.sql',
56+
cwd: '/tmp'
57+
};
58+
const prompter = {} as any;
59+
const options = {} as any;
60+
61+
await dumpCmd(argv, prompter, options);
62+
63+
expect(spawnMock).toHaveBeenCalledWith(
64+
'pg_dump',
65+
expect.arrayContaining([
66+
'--format=plain',
67+
'--no-owner',
68+
'--no-privileges',
69+
'--file',
70+
expect.stringContaining('output.sql'),
71+
'test_db'
72+
]),
73+
expect.objectContaining({
74+
env: expect.anything()
75+
})
76+
);
77+
});
78+
79+
it('should generate prune sql when database-id is provided', async () => {
80+
const spawnMock = child_process.spawn as unknown as jest.Mock;
81+
spawnMock.mockImplementation(() => ({
82+
on: (event: string, cb: any) => {
83+
if (event === 'close') cb(0);
84+
}
85+
}));
86+
87+
jest.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
88+
const fsWriteSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
89+
jest.spyOn(console, 'log').mockImplementation(() => { });
90+
91+
// mock db responses for resolveDatabaseId and buildPruneSql
92+
mockPoolQuery
93+
.mockResolvedValueOnce({ rows: [{ id: 'uuid-123', name: 'test_db_id' }] }) // resolveDatabaseId
94+
.mockResolvedValueOnce({ rows: [{ table_schema: 'public', table_name: 'test_table' }] }); // buildPruneSql
95+
96+
const argv = {
97+
database: 'test_db',
98+
out: 'output_prune.sql',
99+
'database-id': 'uuid-123',
100+
cwd: '/tmp'
101+
};
102+
const prompter = {} as any;
103+
const options = {} as any;
104+
105+
await dumpCmd(argv, prompter, options);
106+
107+
// verify prune sql appended
108+
expect(fsWriteSpy).toHaveBeenCalledWith(
109+
expect.stringContaining('output_prune.sql'),
110+
expect.stringContaining('delete from "public"."test_table" where database_id <> \'uuid-123\''),
111+
expect.objectContaining({ flag: 'a' })
112+
);
113+
});
114+
});

pgpm/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"minimist": "^1.2.8",
6060
"pg-cache": "workspace:^",
6161
"pg-env": "workspace:^",
62+
"pgsql-deparser": "^17.17.0",
6263
"semver": "^7.6.2",
6364
"shelljs": "^0.10.0",
6465
"yanse": "^0.1.11"

pgpm/cli/src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import cache from './commands/cache';
1111
import clear from './commands/clear';
1212
import deploy from './commands/deploy';
1313
import docker from './commands/docker';
14+
import dump from './commands/dump';
1415
import env from './commands/env';
1516
import _export from './commands/export';
1617
import extension from './commands/extension';
@@ -48,6 +49,7 @@ export const createPgpmCommandMap = (skipPgTeardown: boolean = false): Record<st
4849
clear: pgt(clear),
4950
deploy: pgt(deploy),
5051
docker,
52+
dump: pgt(dump),
5153
env,
5254
verify: pgt(verify),
5355
revert: pgt(revert),

pgpm/cli/src/commands/dump.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { Logger } from '@pgpmjs/logger';
2+
import { CLIOptions, Inquirerer } from 'inquirerer';
3+
import { ParsedArgs } from 'minimist';
4+
import { getPgEnvOptions, getSpawnEnvWithPg } from 'pg-env';
5+
import { getPgPool } from 'pg-cache';
6+
import { spawn } from 'child_process';
7+
import fs from 'fs';
8+
import path from 'path';
9+
import { QuoteUtils } from 'pgsql-deparser/utils/quote-utils';
10+
11+
import { getTargetDatabase } from '../utils';
12+
13+
const log = new Logger('dump');
14+
15+
const dumpUsageText = `
16+
Dump Command:
17+
18+
pgpm dump [options]
19+
20+
Dump a postgres database to a sql file.
21+
22+
Options:
23+
--help, -h Show this help message
24+
--db, --database <name> Target postgres database name
25+
--out <path> Output file path
26+
--database-id <id> When set, the dump will include a prune step that keeps only this database_id after restore
27+
--cwd <directory> Working directory (default: current directory)
28+
29+
Examples:
30+
pgpm dump
31+
pgpm dump --database mydb
32+
pgpm dump --database mydb --out ./mydb.sql
33+
pgpm dump --database mydb --database-id 00000000-0000-0000-0000-000000000000
34+
`;
35+
36+
function nowStamp(): string {
37+
const d = new Date();
38+
const pad = (n: number) => String(n).padStart(2, '0');
39+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
40+
}
41+
42+
async function runPgDump(args: string[], env: NodeJS.ProcessEnv): Promise<void> {
43+
await new Promise<void>((resolve, reject) => {
44+
const child = spawn('pg_dump', args, {
45+
env,
46+
stdio: 'inherit',
47+
shell: false
48+
});
49+
50+
child.on('error', (err: any) => {
51+
if (err.code === 'ENOENT') {
52+
log.error('pg_dump not found; ensure PostgreSQL client tools are installed and in PATH');
53+
}
54+
reject(err);
55+
});
56+
57+
child.on('close', (code) => {
58+
if (code === 0) {
59+
resolve();
60+
return;
61+
}
62+
reject(new Error(`pg_dump exited with code ${code ?? 1}`));
63+
});
64+
});
65+
}
66+
67+
async function resolveDatabaseId(dbname: string, databaseIdRaw: string): Promise<{ id: string; name: string } | null> {
68+
const pool = getPgPool(getPgEnvOptions({ database: dbname }));
69+
const res = await pool.query(`select id, name from metaschema_public.database order by name`);
70+
71+
const byId = res.rows.find((r: any) => String(r.id) === databaseIdRaw);
72+
if (byId) return { id: String(byId.id), name: String(byId.name) };
73+
74+
const byName = res.rows.find((r: any) => String(r.name) === databaseIdRaw);
75+
if (byName) return { id: String(byName.id), name: String(byName.name) };
76+
77+
return null;
78+
}
79+
80+
async function buildPruneSql(dbname: string, databaseId: string): Promise<string> {
81+
const pool = getPgPool(getPgEnvOptions({ database: dbname }));
82+
83+
const tables = await pool.query(`
84+
select c.table_schema, c.table_name
85+
from information_schema.columns c
86+
join information_schema.tables t
87+
on t.table_schema = c.table_schema
88+
and t.table_name = c.table_name
89+
where c.column_name = 'database_id'
90+
and t.table_type = 'BASE TABLE'
91+
and c.table_schema not in ('pg_catalog', 'information_schema')
92+
order by c.table_schema, c.table_name
93+
`);
94+
95+
const lines: string[] = [];
96+
lines.push('');
97+
lines.push('-- pgpm dump prune');
98+
lines.push('-- this section keeps only one database_id after restore');
99+
lines.push('do $$ begin');
100+
lines.push(` raise notice 'pruning data to database_id ${databaseId}';`);
101+
lines.push('end $$;');
102+
lines.push('set session_replication_role = replica;');
103+
104+
for (const row of tables.rows) {
105+
const schema = String(row.table_schema);
106+
const table = String(row.table_name);
107+
// Use QuoteUtils for robust identifier quoting
108+
const qualified = QuoteUtils.quoteQualifiedIdentifier(schema, table);
109+
// Use formatEString to safely escape the UUID/string literal
110+
const dbIdLiteral = QuoteUtils.formatEString(databaseId);
111+
lines.push(`delete from ${qualified} where database_id <> ${dbIdLiteral};`);
112+
}
113+
114+
// Handle metaschema_public.database deletion
115+
const metaschemaDb = QuoteUtils.quoteQualifiedIdentifier('metaschema_public', 'database');
116+
const dbIdLiteral = QuoteUtils.formatEString(databaseId);
117+
lines.push(`delete from ${metaschemaDb} where id <> ${dbIdLiteral};`);
118+
119+
lines.push('set session_replication_role = origin;');
120+
lines.push('do $$ begin');
121+
lines.push(` raise notice 'prune done';`);
122+
lines.push('end $$;');
123+
lines.push('');
124+
125+
return lines.join('\n');
126+
}
127+
128+
// Helper to retrieve argument from parsed argv or positional _ array
129+
function getArg(argv: Partial<ParsedArgs>, key: string): string | undefined {
130+
if (argv[key]) return argv[key] as string;
131+
const args = (argv._ as string[]) || [];
132+
const idx = args.indexOf(`--${key}`);
133+
if (idx > -1 && args.length > idx + 1) {
134+
return args[idx + 1];
135+
}
136+
return undefined;
137+
}
138+
139+
export default async (
140+
argv: Partial<ParsedArgs>,
141+
prompter: Inquirerer,
142+
_options: CLIOptions
143+
) => {
144+
if (argv.help || argv.h) {
145+
console.log(dumpUsageText);
146+
process.exit(0);
147+
}
148+
149+
const cwd = (argv.cwd as string) || process.cwd();
150+
const dbname = await getTargetDatabase(argv, prompter, { message: 'Select database' });
151+
152+
const outPath = path.resolve(cwd, (argv.out as string) || `pgpm-dump-${dbname}-${nowStamp()}.sql`);
153+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
154+
155+
let databaseIdInfo: { id: string; name: string } | null = null;
156+
const databaseIdRaw = getArg(argv, 'database-id');
157+
if (databaseIdRaw) {
158+
databaseIdInfo = await resolveDatabaseId(dbname, databaseIdRaw);
159+
if (!databaseIdInfo) {
160+
throw new Error(`unknown database-id ${databaseIdRaw}`);
161+
}
162+
}
163+
164+
log.info(`dumping database ${dbname}`);
165+
log.info(`writing to ${outPath}`);
166+
if (databaseIdInfo) {
167+
log.info(`database id ${databaseIdInfo.id}`);
168+
}
169+
170+
const pgEnv = getPgEnvOptions({ database: dbname });
171+
const spawnEnv = getSpawnEnvWithPg(pgEnv);
172+
173+
const args = [
174+
'--format=plain',
175+
'--no-owner',
176+
'--no-privileges',
177+
'--file',
178+
outPath,
179+
dbname
180+
];
181+
182+
await runPgDump(args, spawnEnv);
183+
184+
if (databaseIdInfo) {
185+
const pruneSql = await buildPruneSql(dbname, databaseIdInfo.id);
186+
// Use writeFileSync with 'a' flag for explicit append as requested
187+
fs.writeFileSync(outPath, pruneSql, { encoding: 'utf8', flag: 'a' });
188+
log.info('added prune section to dump file');
189+
}
190+
191+
log.success('dump complete');
192+
return argv;
193+
};
194+
195+

pgpm/cli/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { default as analyze } from './commands/analyze';
1313
export { default as clear } from './commands/clear';
1414
export { default as deploy } from './commands/deploy';
1515
export { default as docker } from './commands/docker';
16+
export { default as dump } from './commands/dump';
1617
export { default as env } from './commands/env';
1718
export { default as _export } from './commands/export';
1819
export { default as extension } from './commands/extension';

pgpm/cli/src/utils/display.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const usageText = `
1818
upgrade Upgrade installed pgpm modules to latest versions (alias: up)
1919
2020
Database Administration:
21+
dump Dump a database to a sql file
2122
kill Terminate database connections and optionally drop databases
2223
install Install database modules
2324
tag Add tags to changes for versioning

0 commit comments

Comments
 (0)