Skip to content

Commit a604bcf

Browse files
committed
feat(pgpm): generate revert/verify scripts for restructured exports
restructureChanges now derives per-change revert (mechanical inverses in reverse topological order via revertFor) and verify (raise-on-failure existence checks via verifyFor) scripts from each change's statement group, and pgpm export writes them instead of empty files. Refs constructive-planning#1329 (Phase 4 follow-up).
1 parent 270ea97 commit a604bcf

5 files changed

Lines changed: 141 additions & 13 deletions

File tree

pgpm/export/__tests__/export-granularity.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,50 @@ relocatable = false
248248
`);
249249
expect(fk.rows).toHaveLength(1);
250250
});
251+
252+
it('generates populated revert/verify scripts for each change', () => {
253+
const moduleDir = join(exportWorkspaceDir, 'packages', EXTENSION_NAME);
254+
255+
const ownersVerify = readFileSync(
256+
join(moduleDir, 'verify', 'schemas', 'pets_consolidated_public', 'tables', 'owners', 'table.sql'),
257+
'utf-8'
258+
);
259+
expect(ownersVerify).toContain("to_regclass('pets_consolidated_public.owners')");
260+
261+
const ownersRevert = readFileSync(
262+
join(moduleDir, 'revert', 'schemas', 'pets_consolidated_public', 'tables', 'owners', 'table.sql'),
263+
'utf-8'
264+
);
265+
expect(ownersRevert).toContain('DROP TABLE pets_consolidated_public.owners;');
266+
expect(ownersRevert).not.toContain('CASCADE');
267+
268+
const schemaRevert = readFileSync(
269+
join(moduleDir, 'revert', 'schemas', 'pets_consolidated_public', 'schema.sql'),
270+
'utf-8'
271+
);
272+
expect(schemaRevert).toContain('DROP SCHEMA pets_consolidated_public;');
273+
});
274+
275+
it('verifies the deployed module with the generated verify scripts', async () => {
276+
const moduleDir = join(exportWorkspaceDir, 'packages', EXTENSION_NAME);
277+
const migrate = new PgpmMigrate(dbConfig);
278+
const result = await migrate.verify({ modulePath: moduleDir });
279+
expect(result.failed).toEqual([]);
280+
expect(result.verified.length).toBeGreaterThan(0);
281+
});
282+
283+
it('reverts the module with the generated revert scripts, leaving the DB clean', async () => {
284+
const moduleDir = join(exportWorkspaceDir, 'packages', EXTENSION_NAME);
285+
const migrate = new PgpmMigrate(dbConfig);
286+
const result = await migrate.revert({ modulePath: moduleDir });
287+
expect(result.failed).toBeUndefined();
288+
289+
const schema = await pg.query(`
290+
SELECT schema_name FROM information_schema.schemata
291+
WHERE schema_name = 'pets_consolidated_public'
292+
`);
293+
expect(schema.rows).toHaveLength(0);
294+
});
251295
});
252296

253297
describe('atomic granularity', () => {

pgpm/export/src/restructure.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ export interface RestructureExportRowsResult {
3131
* alteration convention via `alterationPathFor` (monotonic per-parent
3232
* counter), with the parent added to their deps.
3333
*
34-
* Revert/verify scripts are not carried through: the restructured grouping no
35-
* longer matches the original rows one-to-one, so they are emitted empty.
34+
* Revert/verify scripts are not carried through from the original rows (the
35+
* restructured grouping no longer matches them one-to-one); instead each
36+
* change gets scripts generated from its own statements via
37+
* `revertFor`/`verifyFor` (drops in reverse topological order, one existence
38+
* check per created object).
3639
*/
3740
export const restructureExportRows = async (
3841
rows: PgpmRow[],
@@ -68,8 +71,8 @@ export const restructureExportRows = async (
6871
deploy,
6972
deps,
7073
content: change.deploy,
71-
revert: '',
72-
verify: ''
74+
revert: change.revert,
75+
verify: change.verify
7376
};
7477
});
7578

pgpm/transform/__tests__/granularity-driver.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,53 @@ describe('restructureChanges', () => {
7777
expect(users.deploy.match(/ALTER TABLE/g)!.length).toBeGreaterThanOrEqual(2);
7878
});
7979

80+
it('generates per-change revert scripts (drops in reverse topo order)', () => {
81+
const result = restructureChanges(ATOMIC_CHANGES, { granularity: 'consolidated' });
82+
83+
const schema = result.changes.find(c => c.name === 'schemas/app/schema')!;
84+
expect(schema.revert).toEqual('DROP SCHEMA app;');
85+
86+
const users = result.changes.find(c => c.name === 'schemas/app/tables/users/table')!;
87+
expect(users.revert).toEqual('DROP TABLE app.users;');
88+
expect(users.revert).not.toContain('CASCADE');
89+
});
90+
91+
it('generates per-change verify scripts (raise-on-failure existence checks)', () => {
92+
const result = restructureChanges(ATOMIC_CHANGES, { granularity: 'consolidated' });
93+
94+
const schema = result.changes.find(c => c.name === 'schemas/app/schema')!;
95+
expect(schema.verify).toEqual(
96+
"SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'app') THEN 1 ELSE 0 END);"
97+
);
98+
99+
const users = result.changes.find(c => c.name === 'schemas/app/tables/users/table')!;
100+
expect(users.verify).toEqual(
101+
"SELECT 1/(CASE WHEN to_regclass('app.users') IS NOT NULL THEN 1 ELSE 0 END);"
102+
);
103+
});
104+
105+
it('reverts the atomic shape per command, dependents first', () => {
106+
const result = restructureChanges(ATOMIC_CHANGES, { granularity: 'atomic' });
107+
const users = result.changes.find(c => c.name === 'schemas/app/tables/users/table')!;
108+
// Constraint dropped before its column, column before the table.
109+
const drops = users.revert.split('\n\n');
110+
expect(drops[0]).toContain('DROP CONSTRAINT users_pkey');
111+
expect(drops[drops.length - 1]).toEqual('DROP TABLE app.users;');
112+
});
113+
114+
it('prefixes revert/verify warnings with the owning change name', () => {
115+
const result = restructureChanges([
116+
{
117+
name: 'schemas/app/tables/users/table',
118+
dependencies: [],
119+
deploy: 'CREATE TABLE app.users (id int);\nALTER TABLE app.users ADD CHECK (id > 0);'
120+
}
121+
], { granularity: 'atomic' });
122+
expect(result.warnings.some(w =>
123+
w.startsWith('schemas/app/tables/users/table: revert not derivable:')
124+
)).toBe(true);
125+
});
126+
80127
it('supports custom change naming', () => {
81128
const result = restructureChanges(ATOMIC_CHANGES, {
82129
granularity: 'consolidated',

pgpm/transform/src/granularity-driver.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import {
2323
buildStatementGraph,
2424
classifyStatements,
2525
identityOf,
26-
restructureSql
26+
restructureSql,
27+
revertFor,
28+
verifyFor
2729
} from '@pgsql/transform';
2830

2931
export type { Granularity } from '@pgsql/transform';
@@ -38,6 +40,22 @@ export interface GranularityChange {
3840
deploy: string;
3941
}
4042

43+
/** A restructured change: deploy plus generated revert/verify scripts. */
44+
export interface RestructuredChange extends GranularityChange {
45+
/**
46+
* Generated revert SQL (headerless): mechanical inverses of the change's
47+
* statements in reverse topological order within the group, via
48+
* `revertFor`. Non-invertible statements leave a `-- revert not
49+
* derivable` comment and a warning.
50+
*/
51+
revert: string;
52+
/**
53+
* Generated verify SQL (headerless): one raise-on-failure existence check
54+
* per created object, via `verifyFor`.
55+
*/
56+
verify: string;
57+
}
58+
4159
export interface RestructureModuleOptions {
4260
granularity: Granularity;
4361
/**
@@ -50,7 +68,7 @@ export interface RestructureModuleOptions {
5068

5169
export interface RestructureModuleResult {
5270
/** Restructured changes in deploy order, dependencies recomputed. */
53-
changes: GranularityChange[];
71+
changes: RestructuredChange[];
5472
/** Non-fatal notes (folds rejected to preserve ordering, etc.). */
5573
warnings: string[];
5674
}
@@ -145,14 +163,17 @@ export function restructureChanges(
145163
}
146164
});
147165

148-
// Slice statement text per group, in the emitted (topological) order.
166+
// Slice statement text per group, in the emitted (topological) order,
167+
// and collect each group's facts for revert/verify generation.
149168
const groupSql: string[][] = groupNames.map((): string[] => []);
169+
const groupStatements: StatementFacts[][] = groupNames.map((): StatementFacts[] => []);
150170

151171
facts.forEach((f, i) => {
152172
const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
153173
const g = groupOf[i];
154174
if (g !== -1 && text) {
155175
groupSql[g].push(text.endsWith(';') ? text : `${text};`);
176+
groupStatements[g].push(f);
156177
}
157178
});
158179

@@ -185,13 +206,25 @@ export function restructureChanges(
185206
return firstA - firstB;
186207
});
187208

188-
const result: GranularityChange[] = order
209+
const result: RestructuredChange[] = order
189210
.filter(g => groupSql[g].length > 0)
190-
.map(g => ({
191-
name: groupNames[g],
192-
dependencies: [...groupDeps[g]].map(d => groupNames[d]).sort(),
193-
deploy: groupSql[g].join('\n\n')
194-
}));
211+
.map(g => {
212+
const revert = revertFor(groupStatements[g]);
213+
const verify = verifyFor(groupStatements[g]);
214+
for (const warning of revert.warnings) {
215+
warnings.push(`${groupNames[g]}: ${warning}`);
216+
}
217+
for (const warning of verify.warnings) {
218+
warnings.push(`${groupNames[g]}: ${warning}`);
219+
}
220+
return {
221+
name: groupNames[g],
222+
dependencies: [...groupDeps[g]].map(d => groupNames[d]).sort(),
223+
deploy: groupSql[g].join('\n\n'),
224+
revert: revert.sql,
225+
verify: verify.sql
226+
};
227+
});
195228

196229
return { changes: result, warnings };
197230
}

pgpm/transform/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export {
1616
} from './bundle-driver';
1717
export type {
1818
GranularityChange,
19+
RestructuredChange,
1920
RestructureModuleOptions,
2021
RestructureModuleResult,
2122
} from './granularity-driver';

0 commit comments

Comments
 (0)