Skip to content

Commit 9b210da

Browse files
authored
Merge pull request #1562 from constructive-io/feat/revert-verify-generation
feat(pgpm): generate revert/verify scripts for restructured exports
2 parents 97a18c2 + fa7e8c3 commit 9b210da

7 files changed

Lines changed: 2702 additions & 7161 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/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
},
4545
"dependencies": {
4646
"@pgpmjs/naming-spec": "workspace:^",
47-
"@pgsql/transform": "^18.10.0",
47+
"@pgsql/scripts": "^18.0.0",
48+
"@pgsql/transform": "^18.11.0",
4849
"plpgsql-parser": "^18.2.2"
4950
}
5051
}

pgpm/transform/src/granularity-driver.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* - `consolidated` — additionally inlines FKs proven safe by the graph.
1919
*/
2020
import { pathFor } from '@pgpmjs/naming-spec';
21+
import { revertFor, verifyFor } from '@pgsql/scripts';
2122
import type { Granularity, StatementFacts } from '@pgsql/transform';
2223
import {
2324
buildStatementGraph,
@@ -38,6 +39,22 @@ export interface GranularityChange {
3839
deploy: string;
3940
}
4041

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

5168
export interface RestructureModuleResult {
5269
/** Restructured changes in deploy order, dependencies recomputed. */
53-
changes: GranularityChange[];
70+
changes: RestructuredChange[];
5471
/** Non-fatal notes (folds rejected to preserve ordering, etc.). */
5572
warnings: string[];
5673
}
@@ -145,14 +162,17 @@ export function restructureChanges(
145162
}
146163
});
147164

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

151170
facts.forEach((f, i) => {
152171
const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
153172
const g = groupOf[i];
154173
if (g !== -1 && text) {
155174
groupSql[g].push(text.endsWith(';') ? text : `${text};`);
175+
groupStatements[g].push(f);
156176
}
157177
});
158178

@@ -185,13 +205,25 @@ export function restructureChanges(
185205
return firstA - firstB;
186206
});
187207

188-
const result: GranularityChange[] = order
208+
const result: RestructuredChange[] = order
189209
.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-
}));
210+
.map(g => {
211+
const revert = revertFor(groupStatements[g]);
212+
const verify = verifyFor(groupStatements[g]);
213+
for (const warning of revert.warnings) {
214+
warnings.push(`${groupNames[g]}: ${warning}`);
215+
}
216+
for (const warning of verify.warnings) {
217+
warnings.push(`${groupNames[g]}: ${warning}`);
218+
}
219+
return {
220+
name: groupNames[g],
221+
dependencies: [...groupDeps[g]].map(d => groupNames[d]).sort(),
222+
deploy: groupSql[g].join('\n\n'),
223+
revert: revert.sql,
224+
verify: verify.sql
225+
};
226+
});
195227

196228
return { changes: result, warnings };
197229
}

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)