Skip to content

Commit 0db84a2

Browse files
committed
feat(transform): emit deploy/revert/verify triples via @pgsql/scripts in semantic diff
1 parent ce88218 commit 0db84a2

5 files changed

Lines changed: 2638 additions & 7212 deletions

File tree

pgpm/transform/__tests__/semantic-diff-driver.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,29 @@ describe('diffSchemas', () => {
4747
expect(posts.deploy).toContain('CREATE TABLE app.posts');
4848
});
4949

50+
it('derives revert and verify for every emitted change via @pgsql/scripts', () => {
51+
const to = `${BASE}\nCREATE TABLE app.posts (id uuid PRIMARY KEY);`;
52+
const added = diffSchemas(BASE, to).changes.find(c => c.name === 'schemas/app/tables/posts/table')!;
53+
expect(added.revert).toContain('DROP TABLE app.posts');
54+
expect(added.verify).toContain('to_regclass');
55+
56+
const modified = diffSchemas(BASE, BASE.replace('count(*)', 'count(1)'))
57+
.changes.find(c => c.name === 'schemas/app/procedures/user_count/procedure')!;
58+
expect(modified.revert).toContain('DROP FUNCTION app.user_count');
59+
expect(modified.revert).toContain('count(*)');
60+
expect(modified.verify).toContain('to_regprocedure');
61+
62+
const alterUsers = diffSchemas(BASE, BASE.replace('name text', 'name text, email text'))
63+
.changes.find(c => c.name === 'schemas/app/tables/users/table/alter')!;
64+
expect(alterUsers.deploy).toContain('ADD COLUMN email text');
65+
expect(alterUsers.revert).toContain('DROP COLUMN email');
66+
67+
const dropped = diffSchemas(BASE, BASE.replace('CREATE VIEW app.active_users AS SELECT * FROM app.users;', ''))
68+
.changes.find(c => c.name === 'schemas/app/views/active_users/view/drop')!;
69+
expect(dropped.deploy).toContain('DROP VIEW app.active_users');
70+
expect(dropped.revert).toContain('CREATE VIEW app.active_users');
71+
});
72+
5073
it('diffs tables column-by-column', () => {
5174
const to = BASE
5275
.replace('name text', 'name text, email text')

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.1",
48+
"@pgsql/transform": "^18.11.0",
4849
"plpgsql-parser": "^18.2.2"
4950
}
5051
}

pgpm/transform/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ export {
2626
export type {
2727
DiffInputChange,
2828
ObjectDelta,
29+
SemanticDeltaChange,
2930
SemanticDiffOptions,
3031
SemanticDiffResult,
3132
SemanticObjectDiff,
3233
} from './semantic-diff-driver';
3334
export {
3435
diffChangeSets,
3536
diffSchemas,
36-
dropStatementFor,
3737
} from './semantic-diff-driver';
3838
export type {
3939
PartitionConfig,

pgpm/transform/src/semantic-diff-driver.ts

Lines changed: 51 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,15 @@
1616
* modified objects routed through the granularity pipeline
1717
* (`restructureChanges`), so names come from the naming spec and `requires`
1818
* from the statement graph — the same derivation every other projection uses.
19+
* Every emitted change carries a full deploy/revert/verify triple: drops are
20+
* `@pgsql/scripts`' `revertFor` inverses of the object's own deploy
21+
* statements (never hand-templated), reverts recreate what was dropped, and
22+
* verifies come from `verifyFor`.
1923
*
2024
* Requires `loadModule()` from `plpgsql-parser` before use.
2125
*/
2226
import { pathFor, PathStyle } from '@pgpmjs/naming-spec';
27+
import { revertFor, verifyFor } from '@pgsql/scripts';
2328
import type { ObjectIdentity, StatementFacts } from '@pgsql/transform';
2429
import {
2530
buildStatementGraph,
@@ -53,6 +58,12 @@ export interface SemanticDiffOptions {
5358
style?: PathStyle;
5459
}
5560

61+
/** One emitted delta change: a full deploy/revert/verify triple. */
62+
export interface SemanticDeltaChange extends GranularityChange {
63+
revert: string;
64+
verify: string;
65+
}
66+
5667
export interface SemanticDiffResult {
5768
/** True when both sides describe the same objects with the same shapes. */
5869
identical: boolean;
@@ -63,7 +74,7 @@ export interface SemanticDiffResult {
6374
* topological order), then CREATE/ALTER changes named and ordered by the
6475
* granularity pipeline.
6576
*/
66-
changes: GranularityChange[];
77+
changes: SemanticDeltaChange[];
6778
warnings: string[];
6879
}
6980

@@ -229,53 +240,6 @@ function tableShape(unit: ObjectUnit): TableShape {
229240
const deparseStmt = (node: Record<string, unknown>): string =>
230241
`${Deparser.deparse(node, { pretty: false })};`;
231242

232-
const nameList = (parts: string[]): unknown[] =>
233-
parts.map(sval => ({ String: { sval } }));
234-
235-
const DROP_REMOVE_TYPE: Partial<Record<ObjectIdentity['kind'], string>> = {
236-
schema: 'OBJECT_SCHEMA',
237-
table: 'OBJECT_TABLE',
238-
view: 'OBJECT_VIEW',
239-
sequence: 'OBJECT_SEQUENCE',
240-
type: 'OBJECT_TYPE',
241-
index: 'OBJECT_INDEX',
242-
trigger: 'OBJECT_TRIGGER',
243-
policy: 'OBJECT_POLICY'
244-
};
245-
246-
/** Synthesize the DROP statement for an object identity, or null if unsupported. */
247-
export function dropStatementFor(identity: ObjectIdentity): string | null {
248-
const { kind, schema, name, table } = identity;
249-
if (kind === 'function') {
250-
return deparseStmt({
251-
DropStmt: {
252-
objects: [{
253-
ObjectWithArgs: {
254-
objname: nameList(schema ? [schema, name] : [name]),
255-
args_unspecified: true
256-
}
257-
}],
258-
removeType: 'OBJECT_FUNCTION',
259-
behavior: 'DROP_RESTRICT'
260-
}
261-
});
262-
}
263-
const removeType = DROP_REMOVE_TYPE[kind];
264-
if (!removeType) return null;
265-
266-
const scoped = kind === 'trigger' || kind === 'policy';
267-
const parts = scoped
268-
? [...(schema ? [schema] : []), table ?? '', name]
269-
: [...(schema ? [schema] : []), name];
270-
return deparseStmt({
271-
DropStmt: {
272-
objects: [{ List: { items: nameList(parts.filter(Boolean)) } }],
273-
removeType,
274-
behavior: 'DROP_RESTRICT'
275-
}
276-
});
277-
}
278-
279243
/** Column-level ALTERs between two table shapes. */
280244
function diffTable(
281245
from: TableShape,
@@ -377,9 +341,22 @@ export function diffSchemas(
377341

378342
const objects: SemanticObjectDiff[] = [];
379343
const forwardSql: string[] = [];
380-
const modifiedChanges: GranularityChange[] = [];
344+
const modifiedChanges: SemanticDeltaChange[] = [];
381345
const dropUnits: ObjectUnit[] = [];
382346

347+
/** `@pgsql/scripts` inverse of a deploy script (drops in reverse topo order). */
348+
const inverseOf = (deploy: string, context: string): string => {
349+
const { sql, warnings: w } = revertFor(classifyStatements(deploy));
350+
warnings.push(...w.map(x => `${context}: ${x}`));
351+
return sql;
352+
};
353+
/** `@pgsql/scripts` existence checks for a deploy script. */
354+
const verifyOf = (deploy: string, context: string): string => {
355+
const { sql, warnings: w } = verifyFor(classifyStatements(deploy));
356+
warnings.push(...w.map(x => `${context}: ${x}`));
357+
return sql;
358+
};
359+
383360
for (const key of b.order) {
384361
const unit = b.units.get(key)!;
385362
const prior = a.units.get(key);
@@ -399,25 +376,30 @@ export function diffSchemas(
399376
if (stmts.length > 0 || columnsTouched) {
400377
objects.push(diff);
401378
if (stmts.length > 0) {
379+
// The inverse alteration is the same diff with the sides swapped.
380+
const scratch: SemanticObjectDiff = { identity: unit.identity, path, delta: 'modified' };
381+
const deploy = stmts.join('\n');
402382
modifiedChanges.push({
403383
name: `${path}/alter`,
404384
dependencies: [],
405-
deploy: stmts.join('\n')
385+
deploy,
386+
revert: diffTable(shapeTo, shapeFrom, scratch, []).join('\n'),
387+
verify: verifyOf(deploy, path)
406388
});
407389
}
408390
}
409391
continue;
410392
}
411393
if (unitFingerprint(prior) !== unitFingerprint(unit)) {
412394
objects.push({ identity: unit.identity, path, delta: 'modified' });
413-
const drop = dropStatementFor(unit.identity);
414-
if (!drop) {
415-
warnings.push(`${path}: no DROP synthesized for kind "${unit.identity.kind}"; recreate manually`);
416-
}
395+
const priorDeploy = prior.texts.join('\n');
396+
const newDeploy = unit.texts.join('\n');
417397
modifiedChanges.push({
418398
name: path,
419399
dependencies: [],
420-
deploy: [...(drop ? [drop] : []), ...unit.texts].join('\n')
400+
deploy: [inverseOf(priorDeploy, path), newDeploy].filter(Boolean).join('\n'),
401+
revert: [inverseOf(newDeploy, path), priorDeploy].filter(Boolean).join('\n'),
402+
verify: verifyOf(newDeploy, path)
421403
});
422404
}
423405
}
@@ -433,28 +415,35 @@ export function diffSchemas(
433415
// (dependents before dependencies), one change per object.
434416
const position = new Map(dropUnits.map(u => [u, Math.min(...u.statements.map(s => a.graph.order.indexOf(s)))]));
435417
dropUnits.sort((x, y) => position.get(y)! - position.get(x)!);
436-
const dropChanges: GranularityChange[] = [];
418+
const dropChanges: SemanticDeltaChange[] = [];
437419
for (const unit of dropUnits) {
438-
const drop = dropStatementFor(unit.identity);
439420
const path = pathFor(unit.identity, { style });
440-
if (!drop) {
441-
warnings.push(`${path}: no DROP synthesized for kind "${unit.identity.kind}"; drop manually`);
421+
const recreate = unit.texts.join('\n');
422+
const drop = inverseOf(recreate, path);
423+
if (!drop.trim() || drop.trim().startsWith('--')) {
424+
warnings.push(`${path}: no DROP derivable for kind "${unit.identity.kind}"; drop manually`);
442425
continue;
443426
}
444427
dropChanges.push({
445428
name: `${path}/drop`,
446429
dependencies: dropChanges.length > 0 ? [dropChanges[dropChanges.length - 1].name] : [],
447-
deploy: drop
430+
deploy: drop,
431+
revert: recreate,
432+
verify: ''
448433
});
449434
}
450435

451-
let forwardChanges: GranularityChange[] = [];
436+
let forwardChanges: SemanticDeltaChange[] = [];
452437
if (forwardSql.length > 0) {
453438
const { changes, warnings: w } = restructureChanges(
454439
[{ name: 'delta', dependencies: [], deploy: forwardSql.join('\n\n') }],
455440
{ granularity: options.granularity ?? 'object' }
456441
);
457-
forwardChanges = changes;
442+
forwardChanges = changes.map(c => ({
443+
...c,
444+
revert: inverseOf(c.deploy, c.name),
445+
verify: verifyOf(c.deploy, c.name)
446+
}));
458447
warnings.push(...w);
459448
}
460449

0 commit comments

Comments
 (0)