@pgpmjs/transform 0.15.3 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Semantic diff driver: identity-keyed schema delta between two deploy
3
+ * surfaces.
4
+ *
5
+ * Where `diffBundles` (in `@pgpmjs/bundle`) compares change names and content
6
+ * digests — which breaks the moment a dial regroups or renames changes — this
7
+ * driver re-keys the comparison on object identity (`identityOf`), so the
8
+ * same schema authored at different granularities or partitions diffs as
9
+ * equal. Objects are compared at the AST level (normalized parse trees, so
10
+ * formatting never matters); tables are compared column-by-column and
11
+ * constraint-by-constraint so an added column emits `ALTER TABLE ADD COLUMN`,
12
+ * not a table rebuild.
13
+ *
14
+ * The emitted delta is a set of pgpm changes: DROPs for removed objects
15
+ * (reverse topological order), then CREATE/ALTER changes for added and
16
+ * modified objects routed through the granularity pipeline
17
+ * (`restructureChanges`), so names come from the naming spec and `requires`
18
+ * 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`.
23
+ *
24
+ * Requires `loadModule()` from `plpgsql-parser` before use.
25
+ */
26
+ import { PathStyle } from '@pgpmjs/naming-spec';
27
+ import type { ObjectIdentity } from '@pgsql/transform';
28
+ import { Granularity } from '@pgsql/transform';
29
+ import { GranularityChange } from './granularity-driver';
30
+ /** How one object differs between the two sides. */
31
+ export type ObjectDelta = 'added' | 'removed' | 'modified';
32
+ export interface SemanticObjectDiff {
33
+ identity: ObjectIdentity;
34
+ /** Canonical naming-spec path for the object. */
35
+ path: string;
36
+ delta: ObjectDelta;
37
+ /** For modified tables: the column/constraint-level detail. */
38
+ columnsAdded?: string[];
39
+ columnsRemoved?: string[];
40
+ columnsModified?: string[];
41
+ }
42
+ export interface SemanticDiffOptions {
43
+ /** Granularity for the emitted CREATE/ALTER changes (default `object`). */
44
+ granularity?: Granularity;
45
+ /** Naming-spec rendering style (default `directory`). */
46
+ style?: PathStyle;
47
+ }
48
+ /** One emitted delta change: a full deploy/revert/verify triple. */
49
+ export interface SemanticDeltaChange extends GranularityChange {
50
+ revert: string;
51
+ verify: string;
52
+ }
53
+ export interface SemanticDiffResult {
54
+ /** True when both sides describe the same objects with the same shapes. */
55
+ identical: boolean;
56
+ /** Per-object dispositions, in `to`-then-removed order. */
57
+ objects: SemanticObjectDiff[];
58
+ /**
59
+ * The migration delta: DROP changes for removed objects first (reverse
60
+ * topological order), then CREATE/ALTER changes named and ordered by the
61
+ * granularity pipeline.
62
+ */
63
+ changes: SemanticDeltaChange[];
64
+ warnings: string[];
65
+ }
66
+ /**
67
+ * Diff two deploy surfaces by object identity and emit the migration delta.
68
+ *
69
+ * `from`/`to` are whole deploy scripts (flatten changes in plan order to
70
+ * diff bundles/modules). The result's `changes` deploy `to`'s shape on top
71
+ * of `from`: reverse-topological DROPs, then CREATE/ALTERs named and
72
+ * ordered by `restructureChanges`.
73
+ */
74
+ export declare function diffSchemas(from: string, to: string, options?: SemanticDiffOptions): SemanticDiffResult;
75
+ /** A change's deploy surface (same seam as the other drivers). */
76
+ export interface DiffInputChange {
77
+ name: string;
78
+ dependencies: string[];
79
+ deploy: string;
80
+ }
81
+ /**
82
+ * Identity-keyed diff over pgpm changes: flattens each side's deploy scripts
83
+ * in plan order and delegates to {@link diffSchemas}. Change names never
84
+ * enter the comparison — regrouping, renaming, or repartitioning the same
85
+ * schema yields an identical diff.
86
+ */
87
+ export declare function diffChangeSets(from: DiffInputChange[], to: DiffInputChange[], options?: SemanticDiffOptions): SemanticDiffResult;
@@ -0,0 +1,370 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.diffSchemas = diffSchemas;
4
+ exports.diffChangeSets = diffChangeSets;
5
+ /**
6
+ * Semantic diff driver: identity-keyed schema delta between two deploy
7
+ * surfaces.
8
+ *
9
+ * Where `diffBundles` (in `@pgpmjs/bundle`) compares change names and content
10
+ * digests — which breaks the moment a dial regroups or renames changes — this
11
+ * driver re-keys the comparison on object identity (`identityOf`), so the
12
+ * same schema authored at different granularities or partitions diffs as
13
+ * equal. Objects are compared at the AST level (normalized parse trees, so
14
+ * formatting never matters); tables are compared column-by-column and
15
+ * constraint-by-constraint so an added column emits `ALTER TABLE ADD COLUMN`,
16
+ * not a table rebuild.
17
+ *
18
+ * The emitted delta is a set of pgpm changes: DROPs for removed objects
19
+ * (reverse topological order), then CREATE/ALTER changes for added and
20
+ * modified objects routed through the granularity pipeline
21
+ * (`restructureChanges`), so names come from the naming spec and `requires`
22
+ * from the statement graph — the same derivation every other projection uses.
23
+ * Every emitted change carries a full deploy/revert/verify triple: drops are
24
+ * `@pgsql/scripts`' `revertFor` inverses of the object's own deploy
25
+ * statements (never hand-templated), reverts recreate what was dropped, and
26
+ * verifies come from `verifyFor`.
27
+ *
28
+ * Requires `loadModule()` from `plpgsql-parser` before use.
29
+ */
30
+ const naming_spec_1 = require("@pgpmjs/naming-spec");
31
+ const scripts_1 = require("@pgsql/scripts");
32
+ const transform_1 = require("@pgsql/transform");
33
+ const plpgsql_parser_1 = require("plpgsql-parser");
34
+ const granularity_driver_1 = require("./granularity-driver");
35
+ const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
36
+ /**
37
+ * Group one side's script into identity-keyed object units. Statements with
38
+ * no identity (grants, comments) attach to the unit of the first object they
39
+ * reference, falling back to the preceding statement's unit; truly homeless
40
+ * statements are reported via `warnings`.
41
+ */
42
+ function readSide(sql, warnings, label) {
43
+ const facts = (0, transform_1.classifyStatements)(sql);
44
+ const graph = (0, transform_1.buildStatementGraph)(facts);
45
+ const units = new Map();
46
+ const order = [];
47
+ const unitOf = new Array(facts.length).fill(null);
48
+ const byObject = new Map();
49
+ facts.forEach((f, i) => {
50
+ const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
51
+ if (!text)
52
+ return;
53
+ let identity = groupingIdentity(f);
54
+ if (identity && identity.kind === 'other') {
55
+ const asTable = identityKey({ kind: 'table', schema: identity.schema, name: identity.name });
56
+ if (units.has(asTable))
57
+ identity = units.get(asTable).identity;
58
+ }
59
+ let key = null;
60
+ if (identity) {
61
+ key = identityKey(identity);
62
+ if (!units.has(key)) {
63
+ units.set(key, { identity, statements: [], texts: [] });
64
+ order.push(key);
65
+ }
66
+ if (!identity.table) {
67
+ byObject.set(`${identity.schema ?? ''}.${identity.name}`, key);
68
+ }
69
+ }
70
+ else {
71
+ for (const r of f.references) {
72
+ const host = byObject.get(`${r.schema ?? ''}.${r.name}`);
73
+ if (host) {
74
+ key = host;
75
+ break;
76
+ }
77
+ }
78
+ if (!key && i > 0)
79
+ key = unitOf[i - 1];
80
+ }
81
+ if (!key) {
82
+ warnings.push(`${label}: statement ${i} (${f.kind}) belongs to no object; ignored in the diff`);
83
+ return;
84
+ }
85
+ unitOf[i] = key;
86
+ const unit = units.get(key);
87
+ unit.statements.push(i);
88
+ unit.texts.push(text.endsWith(';') ? text : `${text};`);
89
+ });
90
+ return { facts, units, order, graph };
91
+ }
92
+ /**
93
+ * The identity a statement groups under. Table-shaping statements — ALTER
94
+ * TABLE column/constraint additions, RLS enablement — fold into their table's
95
+ * unit so that atomic and consolidated authorships of the same table land in
96
+ * the same unit and compare shape-to-shape. Generic ALTERs (`kind: 'other'`)
97
+ * fold in `readSide` when a table unit for the same object already exists.
98
+ */
99
+ function groupingIdentity(f) {
100
+ const identity = (0, transform_1.identityOf)(f);
101
+ if (!identity)
102
+ return null;
103
+ if ((f.kind === 'table' || f.kind === 'constraint' || f.kind === 'rls_enable') &&
104
+ identity.kind !== 'table') {
105
+ return { kind: 'table', schema: identity.schema, name: identity.table ?? identity.name };
106
+ }
107
+ return identity;
108
+ }
109
+ /** Normalized-AST fingerprint of one statement (formatting/position-proof). */
110
+ function fingerprint(text) {
111
+ const parsed = (0, plpgsql_parser_1.parseSync)(text);
112
+ return JSON.stringify((0, transform_1.cleanTree)(parsed.sql.stmts.map(s => s.stmt)));
113
+ }
114
+ /** Multiset fingerprint of a whole unit. */
115
+ const unitFingerprint = (unit) => unit.texts.map(fingerprint).sort().join('\n');
116
+ /**
117
+ * Fold a table unit's statements (CREATE TABLE plus any ALTER TABLE
118
+ * ADD COLUMN / ADD CONSTRAINT) into an effective shape, so atomic and
119
+ * consolidated authorships of the same table compare equal.
120
+ */
121
+ function tableShape(unit) {
122
+ const shape = { relation: null, columns: new Map(), extras: new Map() };
123
+ const addColumn = (node) => {
124
+ const def = node;
125
+ const clean = (0, transform_1.cleanTree)(node);
126
+ shape.columns.set(def.colname ?? '', { def: clean, print: JSON.stringify(clean) });
127
+ };
128
+ const addExtra = (node) => {
129
+ const clean = (0, transform_1.cleanTree)(node);
130
+ shape.extras.set(JSON.stringify(clean), clean);
131
+ };
132
+ for (const text of unit.texts) {
133
+ for (const raw of (0, plpgsql_parser_1.parseSync)(text).sql.stmts) {
134
+ const stmt = raw.stmt;
135
+ if (stmt.CreateStmt) {
136
+ shape.relation = stmt.CreateStmt.relation;
137
+ const elts = stmt.CreateStmt.tableElts ?? [];
138
+ for (const elt of elts) {
139
+ if (elt.ColumnDef)
140
+ addColumn(elt.ColumnDef);
141
+ else
142
+ addExtra(elt);
143
+ }
144
+ }
145
+ else if (stmt.AlterTableStmt) {
146
+ shape.relation ??= stmt.AlterTableStmt.relation;
147
+ const cmds = stmt.AlterTableStmt.cmds ?? [];
148
+ for (const cmd of cmds) {
149
+ const at = cmd.AlterTableCmd;
150
+ if (!at)
151
+ continue;
152
+ const def = at.def;
153
+ if (at.subtype === 'AT_AddColumn' && def?.ColumnDef) {
154
+ addColumn(def.ColumnDef);
155
+ }
156
+ else {
157
+ addExtra({ AlterTableCmd: at });
158
+ }
159
+ }
160
+ }
161
+ else {
162
+ addExtra(stmt);
163
+ }
164
+ }
165
+ }
166
+ return shape;
167
+ }
168
+ const deparseStmt = (node) => `${plpgsql_parser_1.Deparser.deparse(node, { pretty: false })};`;
169
+ /** Column-level ALTERs between two table shapes. */
170
+ function diffTable(from, to, diff, warnings) {
171
+ const stmts = [];
172
+ const relation = to.relation ?? from.relation;
173
+ const alter = (cmd) => deparseStmt({
174
+ AlterTableStmt: {
175
+ relation,
176
+ cmds: [{ AlterTableCmd: { behavior: 'DROP_RESTRICT', ...cmd } }],
177
+ objtype: 'OBJECT_TABLE'
178
+ }
179
+ });
180
+ diff.columnsAdded = [];
181
+ diff.columnsRemoved = [];
182
+ diff.columnsModified = [];
183
+ for (const [name, col] of to.columns) {
184
+ const prior = from.columns.get(name);
185
+ if (!prior) {
186
+ diff.columnsAdded.push(name);
187
+ stmts.push(alter({ subtype: 'AT_AddColumn', def: { ColumnDef: col.def } }));
188
+ }
189
+ else if (prior.print !== col.print) {
190
+ diff.columnsModified.push(name);
191
+ const fromDef = prior.def;
192
+ const toDef = col.def;
193
+ const typeChanged = JSON.stringify(fromDef.typeName) !== JSON.stringify(toDef.typeName);
194
+ const restChanged = JSON.stringify({ ...fromDef, typeName: null }) !==
195
+ JSON.stringify({ ...toDef, typeName: null });
196
+ if (typeChanged) {
197
+ stmts.push(alter({
198
+ subtype: 'AT_AlterColumnType',
199
+ name,
200
+ def: { ColumnDef: { typeName: toDef.typeName } }
201
+ }));
202
+ }
203
+ if (restChanged) {
204
+ warnings.push(`${diff.path}: column "${name}" changed beyond its type (constraints/defaults); emit a manual ALTER`);
205
+ }
206
+ }
207
+ }
208
+ for (const name of from.columns.keys()) {
209
+ if (!to.columns.has(name)) {
210
+ diff.columnsRemoved.push(name);
211
+ stmts.push(alter({ subtype: 'AT_DropColumn', name }));
212
+ }
213
+ }
214
+ for (const [print, extra] of to.extras) {
215
+ if (!from.extras.has(print)) {
216
+ const at = extra.AlterTableCmd;
217
+ if (at)
218
+ stmts.push(alter(at));
219
+ else if (extra.Constraint) {
220
+ stmts.push(alter({ subtype: 'AT_AddConstraint', def: extra }));
221
+ }
222
+ else {
223
+ warnings.push(`${diff.path}: unrecognized added table element; emit a manual ALTER`);
224
+ }
225
+ }
226
+ }
227
+ for (const [print, extra] of from.extras) {
228
+ if (to.extras.has(print))
229
+ continue;
230
+ const conname = extra.Constraint?.conname
231
+ ?? extra
232
+ .AlterTableCmd?.def?.Constraint?.conname;
233
+ if (conname) {
234
+ stmts.push(alter({ subtype: 'AT_DropConstraint', name: conname }));
235
+ }
236
+ else {
237
+ warnings.push(`${diff.path}: removed table element has no name to drop; emit a manual ALTER`);
238
+ }
239
+ }
240
+ return stmts;
241
+ }
242
+ /**
243
+ * Diff two deploy surfaces by object identity and emit the migration delta.
244
+ *
245
+ * `from`/`to` are whole deploy scripts (flatten changes in plan order to
246
+ * diff bundles/modules). The result's `changes` deploy `to`'s shape on top
247
+ * of `from`: reverse-topological DROPs, then CREATE/ALTERs named and
248
+ * ordered by `restructureChanges`.
249
+ */
250
+ function diffSchemas(from, to, options = {}) {
251
+ const style = options.style ?? 'directory';
252
+ const warnings = [];
253
+ const a = readSide(from, warnings, 'from');
254
+ const b = readSide(to, warnings, 'to');
255
+ const objects = [];
256
+ const forwardSql = [];
257
+ const modifiedChanges = [];
258
+ const dropUnits = [];
259
+ /** `@pgsql/scripts` inverse of a deploy script (drops in reverse topo order). */
260
+ const inverseOf = (deploy, context) => {
261
+ const { sql, warnings: w } = (0, scripts_1.revertFor)((0, transform_1.classifyStatements)(deploy));
262
+ warnings.push(...w.map(x => `${context}: ${x}`));
263
+ return sql;
264
+ };
265
+ /** `@pgsql/scripts` existence checks for a deploy script. */
266
+ const verifyOf = (deploy, context) => {
267
+ const { sql, warnings: w } = (0, scripts_1.verifyFor)((0, transform_1.classifyStatements)(deploy));
268
+ warnings.push(...w.map(x => `${context}: ${x}`));
269
+ return sql;
270
+ };
271
+ for (const key of b.order) {
272
+ const unit = b.units.get(key);
273
+ const prior = a.units.get(key);
274
+ const path = (0, naming_spec_1.pathFor)(unit.identity, { style });
275
+ if (!prior) {
276
+ objects.push({ identity: unit.identity, path, delta: 'added' });
277
+ forwardSql.push(...unit.texts);
278
+ continue;
279
+ }
280
+ if (unit.identity.kind === 'table') {
281
+ const shapeFrom = tableShape(prior);
282
+ const shapeTo = tableShape(unit);
283
+ const diff = { identity: unit.identity, path, delta: 'modified' };
284
+ const stmts = diffTable(shapeFrom, shapeTo, diff, warnings);
285
+ const columnsTouched = diff.columnsAdded.length + diff.columnsRemoved.length + diff.columnsModified.length > 0;
286
+ if (stmts.length > 0 || columnsTouched) {
287
+ objects.push(diff);
288
+ if (stmts.length > 0) {
289
+ // The inverse alteration is the same diff with the sides swapped.
290
+ const scratch = { identity: unit.identity, path, delta: 'modified' };
291
+ const deploy = stmts.join('\n');
292
+ modifiedChanges.push({
293
+ name: `${path}/alter`,
294
+ dependencies: [],
295
+ deploy,
296
+ revert: diffTable(shapeTo, shapeFrom, scratch, []).join('\n'),
297
+ verify: verifyOf(deploy, path)
298
+ });
299
+ }
300
+ }
301
+ continue;
302
+ }
303
+ if (unitFingerprint(prior) !== unitFingerprint(unit)) {
304
+ objects.push({ identity: unit.identity, path, delta: 'modified' });
305
+ const priorDeploy = prior.texts.join('\n');
306
+ const newDeploy = unit.texts.join('\n');
307
+ modifiedChanges.push({
308
+ name: path,
309
+ dependencies: [],
310
+ deploy: [inverseOf(priorDeploy, path), newDeploy].filter(Boolean).join('\n'),
311
+ revert: [inverseOf(newDeploy, path), priorDeploy].filter(Boolean).join('\n'),
312
+ verify: verifyOf(newDeploy, path)
313
+ });
314
+ }
315
+ }
316
+ for (const key of a.order) {
317
+ if (b.units.has(key))
318
+ continue;
319
+ const unit = a.units.get(key);
320
+ objects.push({ identity: unit.identity, path: (0, naming_spec_1.pathFor)(unit.identity, { style }), delta: 'removed' });
321
+ dropUnits.push(unit);
322
+ }
323
+ // Removed objects drop in reverse topological order of the `from` graph
324
+ // (dependents before dependencies), one change per object.
325
+ const position = new Map(dropUnits.map(u => [u, Math.min(...u.statements.map(s => a.graph.order.indexOf(s)))]));
326
+ dropUnits.sort((x, y) => position.get(y) - position.get(x));
327
+ const dropChanges = [];
328
+ for (const unit of dropUnits) {
329
+ const path = (0, naming_spec_1.pathFor)(unit.identity, { style });
330
+ const recreate = unit.texts.join('\n');
331
+ const drop = inverseOf(recreate, path);
332
+ if (!drop.trim() || drop.trim().startsWith('--')) {
333
+ warnings.push(`${path}: no DROP derivable for kind "${unit.identity.kind}"; drop manually`);
334
+ continue;
335
+ }
336
+ dropChanges.push({
337
+ name: `${path}/drop`,
338
+ dependencies: dropChanges.length > 0 ? [dropChanges[dropChanges.length - 1].name] : [],
339
+ deploy: drop,
340
+ revert: recreate,
341
+ verify: ''
342
+ });
343
+ }
344
+ let forwardChanges = [];
345
+ if (forwardSql.length > 0) {
346
+ const { changes, warnings: w } = (0, granularity_driver_1.restructureChanges)([{ name: 'delta', dependencies: [], deploy: forwardSql.join('\n\n') }], { granularity: options.granularity ?? 'object' });
347
+ forwardChanges = changes.map(c => ({
348
+ ...c,
349
+ revert: inverseOf(c.deploy, c.name),
350
+ verify: verifyOf(c.deploy, c.name)
351
+ }));
352
+ warnings.push(...w);
353
+ }
354
+ return {
355
+ identical: objects.length === 0,
356
+ objects,
357
+ changes: [...dropChanges, ...forwardChanges, ...modifiedChanges],
358
+ warnings
359
+ };
360
+ }
361
+ /**
362
+ * Identity-keyed diff over pgpm changes: flattens each side's deploy scripts
363
+ * in plan order and delegates to {@link diffSchemas}. Change names never
364
+ * enter the comparison — regrouping, renaming, or repartitioning the same
365
+ * schema yields an identical diff.
366
+ */
367
+ function diffChangeSets(from, to, options = {}) {
368
+ const flatten = (changes) => changes.map(c => c.deploy.trim()).filter(Boolean).join('\n\n');
369
+ return diffSchemas(flatten(from), flatten(to), options);
370
+ }