Skip to content

Commit c2497a7

Browse files
authored
Merge pull request #1569 from constructive-io/feat/pgpm-transform
feat(pgpm): `pgpm transform` — re-dial an existing module/workspace; wire `--partition` into `pgpm export`
2 parents 6d3e25d + 73a70ff commit c2497a7

15 files changed

Lines changed: 1548 additions & 50 deletions
Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
/**
2+
* e2e for `pgpm transform` against live Postgres.
3+
*
4+
* Fixture module: two schemas, tables + FK, function, trigger, policy, grant,
5+
* index. Transforms to each granularity, deploys each output, asserts catalog
6+
* equivalence with the original, exercises --check/--dry-run/overwrite guard,
7+
* verifies, reverts, partitions into pkg-app + pkg-security, and round-trips
8+
* consolidated -> atomic -> consolidated.
9+
*
10+
* PREREQUISITES: a running PostgreSQL instance via standard PG* env vars.
11+
*/
12+
import { diffCatalogSnapshots, snapshotCatalog } from '@pgpmjs/export';
13+
import * as fs from 'fs';
14+
import * as path from 'path';
15+
import { teardownPgPools } from 'pg-cache';
16+
17+
import { CLIDeployTestFixture } from '../test-utils';
18+
19+
jest.setTimeout(180000);
20+
21+
afterAll(async () => {
22+
await teardownPgPools();
23+
});
24+
25+
const MODULE_NAME = 'transform-e2e';
26+
const WS = 'transform-ws';
27+
28+
interface ChangeSpec {
29+
name: string;
30+
deps?: string[];
31+
deploy: string;
32+
}
33+
34+
/** Two schemas, tables + FK, function, trigger, policy, grant, index. */
35+
const CHANGES: ChangeSpec[] = [
36+
{
37+
name: 'schemas/tfx_app/schema',
38+
deploy: 'CREATE SCHEMA tfx_app;'
39+
},
40+
{
41+
name: 'schemas/tfx_sec/schema',
42+
deploy: 'CREATE SCHEMA tfx_sec;'
43+
},
44+
{
45+
name: 'schemas/tfx_app/tables/users/table',
46+
deps: ['schemas/tfx_app/schema'],
47+
deploy: 'CREATE TABLE tfx_app.users (id serial PRIMARY KEY, email text NOT NULL);'
48+
},
49+
{
50+
name: 'schemas/tfx_sec/tables/audit/table',
51+
deps: ['schemas/tfx_sec/schema', 'schemas/tfx_app/tables/users/table'],
52+
deploy: [
53+
'CREATE TABLE tfx_sec.audit (',
54+
' id serial PRIMARY KEY,',
55+
' user_id int NOT NULL REFERENCES tfx_app.users (id),',
56+
' note text',
57+
');'
58+
].join('\n')
59+
},
60+
{
61+
name: 'schemas/tfx_app/procedures/touch',
62+
deps: ['schemas/tfx_app/schema'],
63+
deploy:
64+
'CREATE FUNCTION tfx_app.touch() RETURNS trigger AS $$ BEGIN RETURN NEW; END $$ LANGUAGE plpgsql;'
65+
},
66+
{
67+
name: 'schemas/tfx_app/tables/users/triggers/users_touch',
68+
deps: ['schemas/tfx_app/tables/users/table', 'schemas/tfx_app/procedures/touch'],
69+
deploy:
70+
'CREATE TRIGGER users_touch BEFORE UPDATE ON tfx_app.users FOR EACH ROW EXECUTE FUNCTION tfx_app.touch();'
71+
},
72+
{
73+
name: 'schemas/tfx_app/tables/users/policies/users_self',
74+
deps: ['schemas/tfx_app/tables/users/table'],
75+
deploy: [
76+
'ALTER TABLE tfx_app.users ENABLE ROW LEVEL SECURITY;',
77+
'CREATE POLICY users_self ON tfx_app.users FOR SELECT USING (true);'
78+
].join('\n')
79+
},
80+
{
81+
name: 'schemas/tfx_app/tables/users/grants/select',
82+
deps: ['schemas/tfx_app/tables/users/table'],
83+
deploy: 'GRANT SELECT ON tfx_app.users TO PUBLIC;'
84+
},
85+
{
86+
name: 'schemas/tfx_app/tables/users/indexes/users_email_idx',
87+
deps: ['schemas/tfx_app/tables/users/table'],
88+
deploy: 'CREATE INDEX users_email_idx ON tfx_app.users (email);'
89+
}
90+
];
91+
92+
const writeModule = (moduleDir: string) => {
93+
const workspaceDir = path.dirname(moduleDir);
94+
fs.mkdirSync(workspaceDir, { recursive: true });
95+
fs.writeFileSync(path.join(workspaceDir, 'pgpm.json'), '{\n "packages": [\n "*"\n ]\n}');
96+
fs.mkdirSync(moduleDir, { recursive: true });
97+
const planLines = CHANGES.map((c, i) => {
98+
const deps = c.deps && c.deps.length > 0 ? ` [${c.deps.join(' ')}]` : '';
99+
return `${c.name}${deps} 2024-01-0${(i % 9) + 1}T00:00:00Z test <test@example.com> # add ${c.name}`;
100+
});
101+
fs.writeFileSync(
102+
path.join(moduleDir, 'pgpm.plan'),
103+
`%syntax-version=1.0.0\n%project=${MODULE_NAME}\n%uri=https://github.com/test/${MODULE_NAME}\n\n${planLines.join('\n')}\n`
104+
);
105+
fs.writeFileSync(
106+
path.join(moduleDir, `${MODULE_NAME}.control`),
107+
`comment = 'Transform e2e module'\ndefault_version = '0.0.1'\nrelocatable = false\nsuperuser = false\n`
108+
);
109+
for (const c of CHANGES) {
110+
const filePath = path.join(moduleDir, 'deploy', `${c.name}.sql`);
111+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
112+
fs.writeFileSync(filePath, `-- Deploy ${c.name} to pg\n\nBEGIN;\n\n${c.deploy}\n\nCOMMIT;\n`);
113+
const revertPath = path.join(moduleDir, 'revert', `${c.name}.sql`);
114+
fs.mkdirSync(path.dirname(revertPath), { recursive: true });
115+
fs.writeFileSync(revertPath, `-- Revert ${c.name} from pg\n\nBEGIN;\n\n-- noop\n\nCOMMIT;\n`);
116+
const verifyPath = path.join(moduleDir, 'verify', `${c.name}.sql`);
117+
fs.mkdirSync(path.dirname(verifyPath), { recursive: true });
118+
fs.writeFileSync(verifyPath, `-- Verify ${c.name} on pg\n\nBEGIN;\n\nROLLBACK;\n`);
119+
}
120+
};
121+
122+
const PARTITION_CONFIG = {
123+
defaultPackage: 'pkg-app',
124+
splitRiders: ['grant'],
125+
rules: [
126+
{
127+
package: 'pkg-security',
128+
select: [{ kind: 'policy' }, { statementKind: 'grant' }]
129+
}
130+
]
131+
};
132+
133+
describe('pgpm transform e2e', () => {
134+
let fixture: CLIDeployTestFixture;
135+
let originalDb: any;
136+
let wsDir: string;
137+
let moduleDir: string;
138+
139+
beforeAll(async () => {
140+
fixture = new CLIDeployTestFixture();
141+
wsDir = path.join(fixture.tempFixtureDir, WS);
142+
moduleDir = path.join(wsDir, MODULE_NAME);
143+
writeModule(moduleDir);
144+
145+
originalDb = await fixture.setupTestDatabase();
146+
await fixture.runTerminalCommands(
147+
`
148+
cd ${WS}/${MODULE_NAME}
149+
pgpm deploy --database ${originalDb.name} --package ${MODULE_NAME} --yes
150+
`,
151+
{ database: originalDb.name }
152+
);
153+
});
154+
155+
afterAll(async () => {
156+
await fixture.cleanup();
157+
});
158+
159+
it('--dry-run prints the plan without writing anything', async () => {
160+
await fixture.runTerminalCommands(
161+
`
162+
cd ${WS}/${MODULE_NAME}
163+
pgpm transform --granularity object --dry-run
164+
`,
165+
{}
166+
);
167+
expect(fs.existsSync(path.join(wsDir, `${MODULE_NAME}-object`))).toBe(false);
168+
});
169+
170+
it.each(['atomic', 'object', 'consolidated'] as const)(
171+
'transform --granularity %s deploys with an equivalent catalog, verifies, and reverts clean',
172+
async granularity => {
173+
await fixture.runTerminalCommands(
174+
`
175+
cd ${WS}/${MODULE_NAME}
176+
pgpm transform --granularity ${granularity}
177+
`,
178+
{}
179+
);
180+
181+
const outDir = path.join(wsDir, `${MODULE_NAME}-${granularity}`);
182+
expect(fs.existsSync(path.join(outDir, 'pgpm.plan'))).toBe(true);
183+
expect(fs.existsSync(path.join(outDir, `${MODULE_NAME}-${granularity}.control`))).toBe(true);
184+
185+
const testDb = await fixture.setupTestDatabase();
186+
await fixture.runTerminalCommands(
187+
`
188+
cd ${WS}/${MODULE_NAME}-${granularity}
189+
pgpm deploy --database ${testDb.name} --package ${MODULE_NAME}-${granularity} --yes
190+
`,
191+
{ database: testDb.name }
192+
);
193+
194+
const snapOriginal = await snapshotCatalog(originalDb);
195+
const snapTransformed = await snapshotCatalog(testDb);
196+
expect(diffCatalogSnapshots(snapOriginal, snapTransformed)).toEqual([]);
197+
198+
await fixture.runTerminalCommands(
199+
`
200+
cd ${WS}/${MODULE_NAME}-${granularity}
201+
pgpm verify --database ${testDb.name} --package ${MODULE_NAME}-${granularity} --yes
202+
pgpm revert --database ${testDb.name} --package ${MODULE_NAME}-${granularity} --yes
203+
`,
204+
{ database: testDb.name }
205+
);
206+
207+
expect(await testDb.exists('schema', 'tfx_app')).toBe(false);
208+
expect(await testDb.exists('schema', 'tfx_sec')).toBe(false);
209+
const remaining = await testDb.query(
210+
`SELECT COUNT(*)::int AS count FROM pgpm_migrate.changes WHERE package = $1`,
211+
[`${MODULE_NAME}-${granularity}`]
212+
);
213+
expect(remaining.rows[0].count).toBe(0);
214+
}
215+
);
216+
217+
it('rewrites an existing output when --write is passed', async () => {
218+
// The refusal path (no --write) exits the process, so it is covered by the
219+
// checkOverwrite unit tests; here we prove --write re-generates in place.
220+
const outDir = path.join(wsDir, `${MODULE_NAME}-object`);
221+
expect(fs.existsSync(outDir)).toBe(true);
222+
223+
await fixture.runTerminalCommands(
224+
`
225+
cd ${WS}/${MODULE_NAME}
226+
pgpm transform --granularity object --write
227+
`,
228+
{}
229+
);
230+
expect(fs.existsSync(path.join(outDir, 'pgpm.plan'))).toBe(true);
231+
});
232+
233+
it('--check proves the transform is structurally lossless', async () => {
234+
await fixture.runTerminalCommands(
235+
`
236+
cd ${WS}/${MODULE_NAME}
237+
pgpm transform --granularity consolidated --write --check
238+
`,
239+
{}
240+
);
241+
});
242+
243+
it('partitions into pkg-app + pkg-security with catalog equivalence', async () => {
244+
fs.writeFileSync(path.join(wsDir, 'partition.json'), JSON.stringify(PARTITION_CONFIG, null, 2));
245+
246+
await fixture.runTerminalCommands(
247+
`
248+
cd ${WS}/${MODULE_NAME}
249+
pgpm transform --granularity object --partition ../partition.json
250+
`,
251+
{}
252+
);
253+
254+
const appDir = path.join(wsDir, 'pkg-app');
255+
const secDir = path.join(wsDir, 'pkg-security');
256+
expect(fs.existsSync(path.join(appDir, 'pgpm.plan'))).toBe(true);
257+
expect(fs.existsSync(path.join(secDir, 'pgpm.plan'))).toBe(true);
258+
259+
// security package requires the app package (policies/grants target app tables)
260+
const secControl = fs.readFileSync(path.join(secDir, 'pkg-security.control'), 'utf-8');
261+
expect(secControl).toMatch(/requires = '.*pkg-app.*'/);
262+
263+
const testDb = await fixture.setupTestDatabase();
264+
await fixture.runTerminalCommands(
265+
`
266+
cd ${WS}/pkg-app
267+
pgpm deploy --database ${testDb.name} --package pkg-app --yes
268+
cd ../pkg-security
269+
pgpm deploy --database ${testDb.name} --package pkg-security --yes
270+
`,
271+
{ database: testDb.name }
272+
);
273+
274+
const snapOriginal = await snapshotCatalog(originalDb);
275+
const snapPartitioned = await snapshotCatalog(testDb);
276+
expect(diffCatalogSnapshots(snapOriginal, snapPartitioned)).toEqual([]);
277+
});
278+
279+
it('round-trips consolidated -> atomic -> consolidated to the same object set', async () => {
280+
// consolidated already exists at <module>-consolidated (from --check test)
281+
const c1 = path.join(wsDir, `${MODULE_NAME}-consolidated`);
282+
expect(fs.existsSync(path.join(c1, 'pgpm.plan'))).toBe(true);
283+
284+
await fixture.runTerminalCommands(
285+
`
286+
cd ${WS}/${MODULE_NAME}-consolidated
287+
pgpm transform --granularity atomic --out ../roundtrip-atomic --write
288+
cd ../roundtrip-atomic/${MODULE_NAME}-consolidated-atomic
289+
pgpm transform --granularity consolidated --out ../../roundtrip-consolidated --write
290+
`,
291+
{}
292+
);
293+
294+
const c2 = path.join(
295+
wsDir,
296+
'roundtrip-consolidated',
297+
`${MODULE_NAME}-consolidated-atomic-consolidated`
298+
);
299+
expect(fs.existsSync(path.join(c2, 'pgpm.plan'))).toBe(true);
300+
301+
const changeSet = (planPath: string): string[] =>
302+
fs
303+
.readFileSync(planPath, 'utf-8')
304+
.split('\n')
305+
.filter(line => line && !line.startsWith('%'))
306+
.map(line => line.split(' ')[0])
307+
.sort();
308+
309+
expect(changeSet(path.join(c2, 'pgpm.plan'))).toEqual(
310+
changeSet(path.join(c1, 'pgpm.plan'))
311+
);
312+
});
313+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { mkdirSync, mkdtempSync, rmSync } from 'fs';
2+
import { tmpdir } from 'os';
3+
import { join } from 'path';
4+
5+
import { checkOverwrite, orderPackages, resolveOutBase } from '../src/commands/transform';
6+
7+
describe('resolveOutBase', () => {
8+
it('defaults to the source module parent (packages land as siblings)', () => {
9+
expect(resolveOutBase('/ws/my-mod')).toBe('/ws');
10+
});
11+
12+
it('resolves --out when given', () => {
13+
expect(resolveOutBase('/ws/my-mod', '/tmp/out')).toBe('/tmp/out');
14+
});
15+
});
16+
17+
describe('checkOverwrite', () => {
18+
let dir: string;
19+
20+
beforeEach(() => {
21+
dir = mkdtempSync(join(tmpdir(), 'pgpm-transform-guard-'));
22+
});
23+
24+
afterEach(() => {
25+
rmSync(dir, { recursive: true, force: true });
26+
});
27+
28+
it('refuses to overwrite the source module without --write', () => {
29+
const mod = join(dir, 'my-mod');
30+
mkdirSync(mod);
31+
expect(checkOverwrite(mod, mod, false)).toMatch(/source module/);
32+
expect(checkOverwrite(mod, mod, true)).toBeNull();
33+
});
34+
35+
it('refuses to clobber an existing output directory without --write', () => {
36+
const mod = join(dir, 'my-mod');
37+
const out = join(dir, 'my-mod-object');
38+
mkdirSync(mod);
39+
mkdirSync(out);
40+
expect(checkOverwrite(out, mod, false)).toMatch(/already exists/);
41+
expect(checkOverwrite(out, mod, true)).toBeNull();
42+
});
43+
44+
it('allows a fresh output directory', () => {
45+
const mod = join(dir, 'my-mod');
46+
mkdirSync(mod);
47+
expect(checkOverwrite(join(dir, 'fresh'), mod, false)).toBeNull();
48+
});
49+
});
50+
51+
describe('orderPackages', () => {
52+
it('orders prerequisites before dependents', () => {
53+
const ordered = orderPackages([
54+
{ name: 'pkg-security', requires: ['pkg-app'], rows: [] },
55+
{ name: 'pkg-app', requires: [], rows: [] }
56+
]);
57+
expect(ordered.map(p => p.name)).toEqual(['pkg-app', 'pkg-security']);
58+
});
59+
60+
it('keeps independent packages in input order', () => {
61+
const ordered = orderPackages([
62+
{ name: 'a', requires: [], rows: [] },
63+
{ name: 'b', requires: [], rows: [] }
64+
]);
65+
expect(ordered.map(p => p.name)).toEqual(['a', 'b']);
66+
});
67+
});

pgpm/cli/src/commands.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import slice from './commands/slice';
2929
import syncVersions from './commands/sync-versions';
3030
import tag from './commands/tag';
3131
import testPackages from './commands/test-packages';
32+
import transform from './commands/transform';
3233
import tune from './commands/tune';
3334
import updateCmd from './commands/update';
3435
import upgrade from './commands/upgrade';
@@ -112,6 +113,7 @@ export const createPgpmCommandMap = (skipPgTeardown: boolean = false): Record<st
112113
slice,
113114
'sync-versions': syncVersions,
114115
'test-packages': pgt(testPackages),
116+
transform: pgt(transform),
115117
tune: pgt(tune),
116118
upgrade: pgt(upgrade),
117119
up: pgt(upgrade),

0 commit comments

Comments
 (0)