@pgpmjs/transform 0.16.0 → 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.
- package/esm/index.js +2 -0
- package/esm/regen.js +39 -0
- package/esm/semantic-diff-driver.js +366 -0
- package/index.d.ts +4 -0
- package/index.js +8 -1
- package/package.json +4 -4
- package/regen.d.ts +41 -0
- package/regen.js +44 -0
- package/semantic-diff-driver.d.ts +87 -0
- package/semantic-diff-driver.js +370 -0
package/esm/index.js
CHANGED
|
@@ -2,7 +2,9 @@ export * from '@pgsql/transform';
|
|
|
2
2
|
export { loadModule } from 'plpgsql-parser';
|
|
3
3
|
export { buildExtensionRouter, buildSchemaRouter, makeNamespaceValidator, makeSchemaTranspiler, } from './bundle-driver';
|
|
4
4
|
export { defaultChangeName, restructureChanges, } from './granularity-driver';
|
|
5
|
+
export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
|
|
5
6
|
export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
|
|
6
7
|
export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
|
|
7
8
|
export { resolveFixtureClosure } from './fixture-closure';
|
|
9
|
+
export { classifyScript, isStubScript, regenerateScripts, } from './regen';
|
|
8
10
|
export { emitSqlProgram, parseSqlProgram } from './program';
|
package/esm/regen.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { revertFor, verifyFor } from '@pgsql/scripts';
|
|
2
|
+
import { classifyStatements } from '@pgsql/transform';
|
|
3
|
+
/**
|
|
4
|
+
* Classify a pgpm script's substantive statements: every top-level statement
|
|
5
|
+
* except transaction control (`BEGIN`/`COMMIT`/`ROLLBACK` wrappers).
|
|
6
|
+
*/
|
|
7
|
+
export function classifyScript(sql) {
|
|
8
|
+
return classifyStatements(sql).filter(f => f.nodeTag !== 'TransactionStmt');
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Whether a script is an empty stub: nothing but comments, whitespace, and
|
|
12
|
+
* a transaction wrapper — no substantive statements. Scaffolded
|
|
13
|
+
* `-- Add your revert SQL here` files and bare `BEGIN; ... COMMIT;` shells
|
|
14
|
+
* are stubs. A script that fails to parse is NOT a stub (never treat content
|
|
15
|
+
* we cannot understand as safe to overwrite).
|
|
16
|
+
*/
|
|
17
|
+
export function isStubScript(sql) {
|
|
18
|
+
if (!sql || !sql.trim())
|
|
19
|
+
return true;
|
|
20
|
+
try {
|
|
21
|
+
return classifyScript(sql).length === 0;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Generate revert and verify scripts (headerless, unwrapped) for one
|
|
29
|
+
* change's deploy SQL. Statements with no derivable inverse contribute a
|
|
30
|
+
* `-- revert not derivable: <reason>` comment and a warning; statements
|
|
31
|
+
* whose effect cannot be existence-checked add a verify warning.
|
|
32
|
+
*/
|
|
33
|
+
export function regenerateScripts(deploySql) {
|
|
34
|
+
const facts = classifyScript(deploySql);
|
|
35
|
+
return {
|
|
36
|
+
revert: revertFor(facts),
|
|
37
|
+
verify: verifyFor(facts)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
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 { pathFor } from '@pgpmjs/naming-spec';
|
|
27
|
+
import { revertFor, verifyFor } from '@pgsql/scripts';
|
|
28
|
+
import { buildStatementGraph, classifyStatements, cleanTree, identityOf } from '@pgsql/transform';
|
|
29
|
+
import { Deparser, parseSync } from 'plpgsql-parser';
|
|
30
|
+
import { restructureChanges } from './granularity-driver';
|
|
31
|
+
const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
|
|
32
|
+
/**
|
|
33
|
+
* Group one side's script into identity-keyed object units. Statements with
|
|
34
|
+
* no identity (grants, comments) attach to the unit of the first object they
|
|
35
|
+
* reference, falling back to the preceding statement's unit; truly homeless
|
|
36
|
+
* statements are reported via `warnings`.
|
|
37
|
+
*/
|
|
38
|
+
function readSide(sql, warnings, label) {
|
|
39
|
+
const facts = classifyStatements(sql);
|
|
40
|
+
const graph = buildStatementGraph(facts);
|
|
41
|
+
const units = new Map();
|
|
42
|
+
const order = [];
|
|
43
|
+
const unitOf = new Array(facts.length).fill(null);
|
|
44
|
+
const byObject = new Map();
|
|
45
|
+
facts.forEach((f, i) => {
|
|
46
|
+
const text = sql.slice(f.span.start, f.span.start + f.span.len).trim();
|
|
47
|
+
if (!text)
|
|
48
|
+
return;
|
|
49
|
+
let identity = groupingIdentity(f);
|
|
50
|
+
if (identity && identity.kind === 'other') {
|
|
51
|
+
const asTable = identityKey({ kind: 'table', schema: identity.schema, name: identity.name });
|
|
52
|
+
if (units.has(asTable))
|
|
53
|
+
identity = units.get(asTable).identity;
|
|
54
|
+
}
|
|
55
|
+
let key = null;
|
|
56
|
+
if (identity) {
|
|
57
|
+
key = identityKey(identity);
|
|
58
|
+
if (!units.has(key)) {
|
|
59
|
+
units.set(key, { identity, statements: [], texts: [] });
|
|
60
|
+
order.push(key);
|
|
61
|
+
}
|
|
62
|
+
if (!identity.table) {
|
|
63
|
+
byObject.set(`${identity.schema ?? ''}.${identity.name}`, key);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
for (const r of f.references) {
|
|
68
|
+
const host = byObject.get(`${r.schema ?? ''}.${r.name}`);
|
|
69
|
+
if (host) {
|
|
70
|
+
key = host;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!key && i > 0)
|
|
75
|
+
key = unitOf[i - 1];
|
|
76
|
+
}
|
|
77
|
+
if (!key) {
|
|
78
|
+
warnings.push(`${label}: statement ${i} (${f.kind}) belongs to no object; ignored in the diff`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
unitOf[i] = key;
|
|
82
|
+
const unit = units.get(key);
|
|
83
|
+
unit.statements.push(i);
|
|
84
|
+
unit.texts.push(text.endsWith(';') ? text : `${text};`);
|
|
85
|
+
});
|
|
86
|
+
return { facts, units, order, graph };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The identity a statement groups under. Table-shaping statements — ALTER
|
|
90
|
+
* TABLE column/constraint additions, RLS enablement — fold into their table's
|
|
91
|
+
* unit so that atomic and consolidated authorships of the same table land in
|
|
92
|
+
* the same unit and compare shape-to-shape. Generic ALTERs (`kind: 'other'`)
|
|
93
|
+
* fold in `readSide` when a table unit for the same object already exists.
|
|
94
|
+
*/
|
|
95
|
+
function groupingIdentity(f) {
|
|
96
|
+
const identity = identityOf(f);
|
|
97
|
+
if (!identity)
|
|
98
|
+
return null;
|
|
99
|
+
if ((f.kind === 'table' || f.kind === 'constraint' || f.kind === 'rls_enable') &&
|
|
100
|
+
identity.kind !== 'table') {
|
|
101
|
+
return { kind: 'table', schema: identity.schema, name: identity.table ?? identity.name };
|
|
102
|
+
}
|
|
103
|
+
return identity;
|
|
104
|
+
}
|
|
105
|
+
/** Normalized-AST fingerprint of one statement (formatting/position-proof). */
|
|
106
|
+
function fingerprint(text) {
|
|
107
|
+
const parsed = parseSync(text);
|
|
108
|
+
return JSON.stringify(cleanTree(parsed.sql.stmts.map(s => s.stmt)));
|
|
109
|
+
}
|
|
110
|
+
/** Multiset fingerprint of a whole unit. */
|
|
111
|
+
const unitFingerprint = (unit) => unit.texts.map(fingerprint).sort().join('\n');
|
|
112
|
+
/**
|
|
113
|
+
* Fold a table unit's statements (CREATE TABLE plus any ALTER TABLE
|
|
114
|
+
* ADD COLUMN / ADD CONSTRAINT) into an effective shape, so atomic and
|
|
115
|
+
* consolidated authorships of the same table compare equal.
|
|
116
|
+
*/
|
|
117
|
+
function tableShape(unit) {
|
|
118
|
+
const shape = { relation: null, columns: new Map(), extras: new Map() };
|
|
119
|
+
const addColumn = (node) => {
|
|
120
|
+
const def = node;
|
|
121
|
+
const clean = cleanTree(node);
|
|
122
|
+
shape.columns.set(def.colname ?? '', { def: clean, print: JSON.stringify(clean) });
|
|
123
|
+
};
|
|
124
|
+
const addExtra = (node) => {
|
|
125
|
+
const clean = cleanTree(node);
|
|
126
|
+
shape.extras.set(JSON.stringify(clean), clean);
|
|
127
|
+
};
|
|
128
|
+
for (const text of unit.texts) {
|
|
129
|
+
for (const raw of parseSync(text).sql.stmts) {
|
|
130
|
+
const stmt = raw.stmt;
|
|
131
|
+
if (stmt.CreateStmt) {
|
|
132
|
+
shape.relation = stmt.CreateStmt.relation;
|
|
133
|
+
const elts = stmt.CreateStmt.tableElts ?? [];
|
|
134
|
+
for (const elt of elts) {
|
|
135
|
+
if (elt.ColumnDef)
|
|
136
|
+
addColumn(elt.ColumnDef);
|
|
137
|
+
else
|
|
138
|
+
addExtra(elt);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else if (stmt.AlterTableStmt) {
|
|
142
|
+
shape.relation ??= stmt.AlterTableStmt.relation;
|
|
143
|
+
const cmds = stmt.AlterTableStmt.cmds ?? [];
|
|
144
|
+
for (const cmd of cmds) {
|
|
145
|
+
const at = cmd.AlterTableCmd;
|
|
146
|
+
if (!at)
|
|
147
|
+
continue;
|
|
148
|
+
const def = at.def;
|
|
149
|
+
if (at.subtype === 'AT_AddColumn' && def?.ColumnDef) {
|
|
150
|
+
addColumn(def.ColumnDef);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
addExtra({ AlterTableCmd: at });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
addExtra(stmt);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return shape;
|
|
163
|
+
}
|
|
164
|
+
const deparseStmt = (node) => `${Deparser.deparse(node, { pretty: false })};`;
|
|
165
|
+
/** Column-level ALTERs between two table shapes. */
|
|
166
|
+
function diffTable(from, to, diff, warnings) {
|
|
167
|
+
const stmts = [];
|
|
168
|
+
const relation = to.relation ?? from.relation;
|
|
169
|
+
const alter = (cmd) => deparseStmt({
|
|
170
|
+
AlterTableStmt: {
|
|
171
|
+
relation,
|
|
172
|
+
cmds: [{ AlterTableCmd: { behavior: 'DROP_RESTRICT', ...cmd } }],
|
|
173
|
+
objtype: 'OBJECT_TABLE'
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
diff.columnsAdded = [];
|
|
177
|
+
diff.columnsRemoved = [];
|
|
178
|
+
diff.columnsModified = [];
|
|
179
|
+
for (const [name, col] of to.columns) {
|
|
180
|
+
const prior = from.columns.get(name);
|
|
181
|
+
if (!prior) {
|
|
182
|
+
diff.columnsAdded.push(name);
|
|
183
|
+
stmts.push(alter({ subtype: 'AT_AddColumn', def: { ColumnDef: col.def } }));
|
|
184
|
+
}
|
|
185
|
+
else if (prior.print !== col.print) {
|
|
186
|
+
diff.columnsModified.push(name);
|
|
187
|
+
const fromDef = prior.def;
|
|
188
|
+
const toDef = col.def;
|
|
189
|
+
const typeChanged = JSON.stringify(fromDef.typeName) !== JSON.stringify(toDef.typeName);
|
|
190
|
+
const restChanged = JSON.stringify({ ...fromDef, typeName: null }) !==
|
|
191
|
+
JSON.stringify({ ...toDef, typeName: null });
|
|
192
|
+
if (typeChanged) {
|
|
193
|
+
stmts.push(alter({
|
|
194
|
+
subtype: 'AT_AlterColumnType',
|
|
195
|
+
name,
|
|
196
|
+
def: { ColumnDef: { typeName: toDef.typeName } }
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
if (restChanged) {
|
|
200
|
+
warnings.push(`${diff.path}: column "${name}" changed beyond its type (constraints/defaults); emit a manual ALTER`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const name of from.columns.keys()) {
|
|
205
|
+
if (!to.columns.has(name)) {
|
|
206
|
+
diff.columnsRemoved.push(name);
|
|
207
|
+
stmts.push(alter({ subtype: 'AT_DropColumn', name }));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
for (const [print, extra] of to.extras) {
|
|
211
|
+
if (!from.extras.has(print)) {
|
|
212
|
+
const at = extra.AlterTableCmd;
|
|
213
|
+
if (at)
|
|
214
|
+
stmts.push(alter(at));
|
|
215
|
+
else if (extra.Constraint) {
|
|
216
|
+
stmts.push(alter({ subtype: 'AT_AddConstraint', def: extra }));
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
warnings.push(`${diff.path}: unrecognized added table element; emit a manual ALTER`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const [print, extra] of from.extras) {
|
|
224
|
+
if (to.extras.has(print))
|
|
225
|
+
continue;
|
|
226
|
+
const conname = extra.Constraint?.conname
|
|
227
|
+
?? extra
|
|
228
|
+
.AlterTableCmd?.def?.Constraint?.conname;
|
|
229
|
+
if (conname) {
|
|
230
|
+
stmts.push(alter({ subtype: 'AT_DropConstraint', name: conname }));
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
warnings.push(`${diff.path}: removed table element has no name to drop; emit a manual ALTER`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return stmts;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Diff two deploy surfaces by object identity and emit the migration delta.
|
|
240
|
+
*
|
|
241
|
+
* `from`/`to` are whole deploy scripts (flatten changes in plan order to
|
|
242
|
+
* diff bundles/modules). The result's `changes` deploy `to`'s shape on top
|
|
243
|
+
* of `from`: reverse-topological DROPs, then CREATE/ALTERs named and
|
|
244
|
+
* ordered by `restructureChanges`.
|
|
245
|
+
*/
|
|
246
|
+
export function diffSchemas(from, to, options = {}) {
|
|
247
|
+
const style = options.style ?? 'directory';
|
|
248
|
+
const warnings = [];
|
|
249
|
+
const a = readSide(from, warnings, 'from');
|
|
250
|
+
const b = readSide(to, warnings, 'to');
|
|
251
|
+
const objects = [];
|
|
252
|
+
const forwardSql = [];
|
|
253
|
+
const modifiedChanges = [];
|
|
254
|
+
const dropUnits = [];
|
|
255
|
+
/** `@pgsql/scripts` inverse of a deploy script (drops in reverse topo order). */
|
|
256
|
+
const inverseOf = (deploy, context) => {
|
|
257
|
+
const { sql, warnings: w } = revertFor(classifyStatements(deploy));
|
|
258
|
+
warnings.push(...w.map(x => `${context}: ${x}`));
|
|
259
|
+
return sql;
|
|
260
|
+
};
|
|
261
|
+
/** `@pgsql/scripts` existence checks for a deploy script. */
|
|
262
|
+
const verifyOf = (deploy, context) => {
|
|
263
|
+
const { sql, warnings: w } = verifyFor(classifyStatements(deploy));
|
|
264
|
+
warnings.push(...w.map(x => `${context}: ${x}`));
|
|
265
|
+
return sql;
|
|
266
|
+
};
|
|
267
|
+
for (const key of b.order) {
|
|
268
|
+
const unit = b.units.get(key);
|
|
269
|
+
const prior = a.units.get(key);
|
|
270
|
+
const path = pathFor(unit.identity, { style });
|
|
271
|
+
if (!prior) {
|
|
272
|
+
objects.push({ identity: unit.identity, path, delta: 'added' });
|
|
273
|
+
forwardSql.push(...unit.texts);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (unit.identity.kind === 'table') {
|
|
277
|
+
const shapeFrom = tableShape(prior);
|
|
278
|
+
const shapeTo = tableShape(unit);
|
|
279
|
+
const diff = { identity: unit.identity, path, delta: 'modified' };
|
|
280
|
+
const stmts = diffTable(shapeFrom, shapeTo, diff, warnings);
|
|
281
|
+
const columnsTouched = diff.columnsAdded.length + diff.columnsRemoved.length + diff.columnsModified.length > 0;
|
|
282
|
+
if (stmts.length > 0 || columnsTouched) {
|
|
283
|
+
objects.push(diff);
|
|
284
|
+
if (stmts.length > 0) {
|
|
285
|
+
// The inverse alteration is the same diff with the sides swapped.
|
|
286
|
+
const scratch = { identity: unit.identity, path, delta: 'modified' };
|
|
287
|
+
const deploy = stmts.join('\n');
|
|
288
|
+
modifiedChanges.push({
|
|
289
|
+
name: `${path}/alter`,
|
|
290
|
+
dependencies: [],
|
|
291
|
+
deploy,
|
|
292
|
+
revert: diffTable(shapeTo, shapeFrom, scratch, []).join('\n'),
|
|
293
|
+
verify: verifyOf(deploy, path)
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (unitFingerprint(prior) !== unitFingerprint(unit)) {
|
|
300
|
+
objects.push({ identity: unit.identity, path, delta: 'modified' });
|
|
301
|
+
const priorDeploy = prior.texts.join('\n');
|
|
302
|
+
const newDeploy = unit.texts.join('\n');
|
|
303
|
+
modifiedChanges.push({
|
|
304
|
+
name: path,
|
|
305
|
+
dependencies: [],
|
|
306
|
+
deploy: [inverseOf(priorDeploy, path), newDeploy].filter(Boolean).join('\n'),
|
|
307
|
+
revert: [inverseOf(newDeploy, path), priorDeploy].filter(Boolean).join('\n'),
|
|
308
|
+
verify: verifyOf(newDeploy, path)
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const key of a.order) {
|
|
313
|
+
if (b.units.has(key))
|
|
314
|
+
continue;
|
|
315
|
+
const unit = a.units.get(key);
|
|
316
|
+
objects.push({ identity: unit.identity, path: pathFor(unit.identity, { style }), delta: 'removed' });
|
|
317
|
+
dropUnits.push(unit);
|
|
318
|
+
}
|
|
319
|
+
// Removed objects drop in reverse topological order of the `from` graph
|
|
320
|
+
// (dependents before dependencies), one change per object.
|
|
321
|
+
const position = new Map(dropUnits.map(u => [u, Math.min(...u.statements.map(s => a.graph.order.indexOf(s)))]));
|
|
322
|
+
dropUnits.sort((x, y) => position.get(y) - position.get(x));
|
|
323
|
+
const dropChanges = [];
|
|
324
|
+
for (const unit of dropUnits) {
|
|
325
|
+
const path = pathFor(unit.identity, { style });
|
|
326
|
+
const recreate = unit.texts.join('\n');
|
|
327
|
+
const drop = inverseOf(recreate, path);
|
|
328
|
+
if (!drop.trim() || drop.trim().startsWith('--')) {
|
|
329
|
+
warnings.push(`${path}: no DROP derivable for kind "${unit.identity.kind}"; drop manually`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
dropChanges.push({
|
|
333
|
+
name: `${path}/drop`,
|
|
334
|
+
dependencies: dropChanges.length > 0 ? [dropChanges[dropChanges.length - 1].name] : [],
|
|
335
|
+
deploy: drop,
|
|
336
|
+
revert: recreate,
|
|
337
|
+
verify: ''
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
let forwardChanges = [];
|
|
341
|
+
if (forwardSql.length > 0) {
|
|
342
|
+
const { changes, warnings: w } = restructureChanges([{ name: 'delta', dependencies: [], deploy: forwardSql.join('\n\n') }], { granularity: options.granularity ?? 'object' });
|
|
343
|
+
forwardChanges = changes.map(c => ({
|
|
344
|
+
...c,
|
|
345
|
+
revert: inverseOf(c.deploy, c.name),
|
|
346
|
+
verify: verifyOf(c.deploy, c.name)
|
|
347
|
+
}));
|
|
348
|
+
warnings.push(...w);
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
identical: objects.length === 0,
|
|
352
|
+
objects,
|
|
353
|
+
changes: [...dropChanges, ...forwardChanges, ...modifiedChanges],
|
|
354
|
+
warnings
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Identity-keyed diff over pgpm changes: flattens each side's deploy scripts
|
|
359
|
+
* in plan order and delegates to {@link diffSchemas}. Change names never
|
|
360
|
+
* enter the comparison — regrouping, renaming, or repartitioning the same
|
|
361
|
+
* schema yields an identical diff.
|
|
362
|
+
*/
|
|
363
|
+
export function diffChangeSets(from, to, options = {}) {
|
|
364
|
+
const flatten = (changes) => changes.map(c => c.deploy.trim()).filter(Boolean).join('\n\n');
|
|
365
|
+
return diffSchemas(flatten(from), flatten(to), options);
|
|
366
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -4,11 +4,15 @@ export type { BundleScriptContext, ExtensionRoutingInput, NamespaceValidatorOpti
|
|
|
4
4
|
export { buildExtensionRouter, buildSchemaRouter, makeNamespaceValidator, makeSchemaTranspiler, } from './bundle-driver';
|
|
5
5
|
export type { GranularityChange, RestructuredChange, RestructureModuleOptions, RestructureModuleResult, } from './granularity-driver';
|
|
6
6
|
export { defaultChangeName, restructureChanges, } from './granularity-driver';
|
|
7
|
+
export type { DiffInputChange, ObjectDelta, SemanticDeltaChange, SemanticDiffOptions, SemanticDiffResult, SemanticObjectDiff, } from './semantic-diff-driver';
|
|
8
|
+
export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
|
|
7
9
|
export type { PartitionConfig, PartitionedChange, PartitionedPackage, PartitionInputChange, PartitionRule, PartitionUnit, PartitionUnitsResult, UnitSelector, } from './partition-driver';
|
|
8
10
|
export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
|
|
9
11
|
export type { CategoryProfile, ChangeCategory, } from './categorize';
|
|
10
12
|
export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
|
|
11
13
|
export type { ClosureChange, ClosureInputChange, ClosureReason, FixtureClosure, ResolveFixtureClosureOptions, } from './fixture-closure';
|
|
12
14
|
export { resolveFixtureClosure } from './fixture-closure';
|
|
15
|
+
export type { GeneratedScript, RegeneratedScripts, } from './regen';
|
|
16
|
+
export { classifyScript, isStubScript, regenerateScripts, } from './regen';
|
|
13
17
|
export type { SqlProgram, SqlStatementAst, SqlStatementSpan, } from './program';
|
|
14
18
|
export { emitSqlProgram, parseSqlProgram } from './program';
|
package/index.js
CHANGED
|
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.parseSqlProgram = exports.emitSqlProgram = exports.resolveFixtureClosure = exports.TIER_PROFILE = exports.categorizeChange = exports.buildCategoryOf = exports.RESIDUAL_UNIT_PATH = exports.partitionUnits = exports.PartitionCycleError = exports.restructureChanges = exports.defaultChangeName = exports.makeSchemaTranspiler = exports.makeNamespaceValidator = exports.buildSchemaRouter = exports.buildExtensionRouter = exports.loadModule = void 0;
|
|
17
|
+
exports.parseSqlProgram = exports.emitSqlProgram = exports.regenerateScripts = exports.isStubScript = exports.classifyScript = exports.resolveFixtureClosure = exports.TIER_PROFILE = exports.categorizeChange = exports.buildCategoryOf = exports.RESIDUAL_UNIT_PATH = exports.partitionUnits = exports.PartitionCycleError = exports.diffSchemas = exports.diffChangeSets = exports.restructureChanges = exports.defaultChangeName = exports.makeSchemaTranspiler = exports.makeNamespaceValidator = exports.buildSchemaRouter = exports.buildExtensionRouter = exports.loadModule = void 0;
|
|
18
18
|
__exportStar(require("@pgsql/transform"), exports);
|
|
19
19
|
var plpgsql_parser_1 = require("plpgsql-parser");
|
|
20
20
|
Object.defineProperty(exports, "loadModule", { enumerable: true, get: function () { return plpgsql_parser_1.loadModule; } });
|
|
@@ -26,6 +26,9 @@ Object.defineProperty(exports, "makeSchemaTranspiler", { enumerable: true, get:
|
|
|
26
26
|
var granularity_driver_1 = require("./granularity-driver");
|
|
27
27
|
Object.defineProperty(exports, "defaultChangeName", { enumerable: true, get: function () { return granularity_driver_1.defaultChangeName; } });
|
|
28
28
|
Object.defineProperty(exports, "restructureChanges", { enumerable: true, get: function () { return granularity_driver_1.restructureChanges; } });
|
|
29
|
+
var semantic_diff_driver_1 = require("./semantic-diff-driver");
|
|
30
|
+
Object.defineProperty(exports, "diffChangeSets", { enumerable: true, get: function () { return semantic_diff_driver_1.diffChangeSets; } });
|
|
31
|
+
Object.defineProperty(exports, "diffSchemas", { enumerable: true, get: function () { return semantic_diff_driver_1.diffSchemas; } });
|
|
29
32
|
var partition_driver_1 = require("./partition-driver");
|
|
30
33
|
Object.defineProperty(exports, "PartitionCycleError", { enumerable: true, get: function () { return partition_driver_1.PartitionCycleError; } });
|
|
31
34
|
Object.defineProperty(exports, "partitionUnits", { enumerable: true, get: function () { return partition_driver_1.partitionUnits; } });
|
|
@@ -36,6 +39,10 @@ Object.defineProperty(exports, "categorizeChange", { enumerable: true, get: func
|
|
|
36
39
|
Object.defineProperty(exports, "TIER_PROFILE", { enumerable: true, get: function () { return categorize_1.TIER_PROFILE; } });
|
|
37
40
|
var fixture_closure_1 = require("./fixture-closure");
|
|
38
41
|
Object.defineProperty(exports, "resolveFixtureClosure", { enumerable: true, get: function () { return fixture_closure_1.resolveFixtureClosure; } });
|
|
42
|
+
var regen_1 = require("./regen");
|
|
43
|
+
Object.defineProperty(exports, "classifyScript", { enumerable: true, get: function () { return regen_1.classifyScript; } });
|
|
44
|
+
Object.defineProperty(exports, "isStubScript", { enumerable: true, get: function () { return regen_1.isStubScript; } });
|
|
45
|
+
Object.defineProperty(exports, "regenerateScripts", { enumerable: true, get: function () { return regen_1.regenerateScripts; } });
|
|
39
46
|
var program_1 = require("./program");
|
|
40
47
|
Object.defineProperty(exports, "emitSqlProgram", { enumerable: true, get: function () { return program_1.emitSqlProgram; } });
|
|
41
48
|
Object.defineProperty(exports, "parseSqlProgram", { enumerable: true, get: function () { return program_1.parseSqlProgram; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pgpmjs/transform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Change-aware SQL transformation for PGPM — superset of @pgsql/transform plus change categorization, fixture closure, and bundle transpile/apply drivers",
|
|
6
6
|
"main": "index.js",
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@pgpmjs/naming-spec": "^0.1.2",
|
|
47
|
-
"@pgsql/scripts": "^18.
|
|
47
|
+
"@pgsql/scripts": "^18.1.0",
|
|
48
48
|
"@pgsql/transform": "^18.11.0",
|
|
49
|
-
"plpgsql-parser": "^18.2.
|
|
49
|
+
"plpgsql-parser": "^18.2.3"
|
|
50
50
|
},
|
|
51
|
-
"gitHead": "
|
|
51
|
+
"gitHead": "45cd961cf956d2d28e4d09b20db4204982c33593"
|
|
52
52
|
}
|
package/regen.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regeneration driver: derive revert/verify scripts for a single pgpm
|
|
3
|
+
* change from its deploy SQL.
|
|
4
|
+
*
|
|
5
|
+
* Deploy scripts are classified into statement facts (`classifyStatements`)
|
|
6
|
+
* and fed through `revertFor`/`verifyFor` from `@pgsql/scripts`: revert is
|
|
7
|
+
* the mechanical inverses in reverse topological order, verify is one
|
|
8
|
+
* raise-on-failure existence check per created object. Transaction control
|
|
9
|
+
* statements (the BEGIN/COMMIT wrapper some modules carry inside their
|
|
10
|
+
* scripts) are excluded before classification — the caller owns the wrapper.
|
|
11
|
+
*
|
|
12
|
+
* Requires `loadModule()` from `plpgsql-parser` to have completed.
|
|
13
|
+
*/
|
|
14
|
+
import type { GeneratedScript } from '@pgsql/scripts';
|
|
15
|
+
import type { StatementFacts } from '@pgsql/transform';
|
|
16
|
+
export type { GeneratedScript } from '@pgsql/scripts';
|
|
17
|
+
/** Generated revert and verify scripts for one change's deploy SQL. */
|
|
18
|
+
export interface RegeneratedScripts {
|
|
19
|
+
revert: GeneratedScript;
|
|
20
|
+
verify: GeneratedScript;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Classify a pgpm script's substantive statements: every top-level statement
|
|
24
|
+
* except transaction control (`BEGIN`/`COMMIT`/`ROLLBACK` wrappers).
|
|
25
|
+
*/
|
|
26
|
+
export declare function classifyScript(sql: string): StatementFacts[];
|
|
27
|
+
/**
|
|
28
|
+
* Whether a script is an empty stub: nothing but comments, whitespace, and
|
|
29
|
+
* a transaction wrapper — no substantive statements. Scaffolded
|
|
30
|
+
* `-- Add your revert SQL here` files and bare `BEGIN; ... COMMIT;` shells
|
|
31
|
+
* are stubs. A script that fails to parse is NOT a stub (never treat content
|
|
32
|
+
* we cannot understand as safe to overwrite).
|
|
33
|
+
*/
|
|
34
|
+
export declare function isStubScript(sql: string): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Generate revert and verify scripts (headerless, unwrapped) for one
|
|
37
|
+
* change's deploy SQL. Statements with no derivable inverse contribute a
|
|
38
|
+
* `-- revert not derivable: <reason>` comment and a warning; statements
|
|
39
|
+
* whose effect cannot be existence-checked add a verify warning.
|
|
40
|
+
*/
|
|
41
|
+
export declare function regenerateScripts(deploySql: string): RegeneratedScripts;
|
package/regen.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.classifyScript = classifyScript;
|
|
4
|
+
exports.isStubScript = isStubScript;
|
|
5
|
+
exports.regenerateScripts = regenerateScripts;
|
|
6
|
+
const scripts_1 = require("@pgsql/scripts");
|
|
7
|
+
const transform_1 = require("@pgsql/transform");
|
|
8
|
+
/**
|
|
9
|
+
* Classify a pgpm script's substantive statements: every top-level statement
|
|
10
|
+
* except transaction control (`BEGIN`/`COMMIT`/`ROLLBACK` wrappers).
|
|
11
|
+
*/
|
|
12
|
+
function classifyScript(sql) {
|
|
13
|
+
return (0, transform_1.classifyStatements)(sql).filter(f => f.nodeTag !== 'TransactionStmt');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Whether a script is an empty stub: nothing but comments, whitespace, and
|
|
17
|
+
* a transaction wrapper — no substantive statements. Scaffolded
|
|
18
|
+
* `-- Add your revert SQL here` files and bare `BEGIN; ... COMMIT;` shells
|
|
19
|
+
* are stubs. A script that fails to parse is NOT a stub (never treat content
|
|
20
|
+
* we cannot understand as safe to overwrite).
|
|
21
|
+
*/
|
|
22
|
+
function isStubScript(sql) {
|
|
23
|
+
if (!sql || !sql.trim())
|
|
24
|
+
return true;
|
|
25
|
+
try {
|
|
26
|
+
return classifyScript(sql).length === 0;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Generate revert and verify scripts (headerless, unwrapped) for one
|
|
34
|
+
* change's deploy SQL. Statements with no derivable inverse contribute a
|
|
35
|
+
* `-- revert not derivable: <reason>` comment and a warning; statements
|
|
36
|
+
* whose effect cannot be existence-checked add a verify warning.
|
|
37
|
+
*/
|
|
38
|
+
function regenerateScripts(deploySql) {
|
|
39
|
+
const facts = classifyScript(deploySql);
|
|
40
|
+
return {
|
|
41
|
+
revert: (0, scripts_1.revertFor)(facts),
|
|
42
|
+
verify: (0, scripts_1.verifyFor)(facts)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -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
|
+
}
|