@pgpmjs/transform 0.19.0 → 0.20.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/constraint-names.d.ts +36 -0
- package/constraint-names.js +75 -0
- package/esm/constraint-names.js +71 -0
- package/esm/granularity-driver.js +84 -21
- package/esm/index.js +4 -2
- package/esm/module-emit.js +71 -1
- package/esm/restructure.js +6 -1
- package/esm/semantic-diff-driver.js +97 -6
- package/esm/sub-object.js +80 -0
- package/granularity-driver.d.ts +57 -0
- package/granularity-driver.js +86 -21
- package/index.d.ts +7 -4
- package/index.js +10 -1
- package/module-emit.d.ts +26 -0
- package/module-emit.js +72 -1
- package/package.json +3 -3
- package/restructure.d.ts +7 -0
- package/restructure.js +6 -1
- package/semantic-diff-driver.d.ts +3 -1
- package/semantic-diff-driver.js +97 -6
- package/sub-object.d.ts +31 -0
- package/sub-object.js +84 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres default constraint names.
|
|
3
|
+
*
|
|
4
|
+
* When a constraint is authored without a name, Postgres derives one at
|
|
5
|
+
* deploy time (`ChooseConstraintName` in the server): `{table}_pkey` for
|
|
6
|
+
* primary keys, `{table}_{cols}_key` for unique constraints,
|
|
7
|
+
* `{table}_{col}_fkey` for foreign keys, `{table}_{col}_check` (first
|
|
8
|
+
* referenced column) or `{table}_check` for check constraints. Synthesizing
|
|
9
|
+
* the same names makes unnamed authorship comparable to (and revertible as)
|
|
10
|
+
* what the catalog will actually contain.
|
|
11
|
+
*/
|
|
12
|
+
/** A parsed `Constraint` node (structural — the fields this module reads). */
|
|
13
|
+
export interface ConstraintNode {
|
|
14
|
+
contype?: string;
|
|
15
|
+
conname?: string;
|
|
16
|
+
keys?: {
|
|
17
|
+
String?: {
|
|
18
|
+
sval?: string;
|
|
19
|
+
};
|
|
20
|
+
}[];
|
|
21
|
+
fk_attrs?: {
|
|
22
|
+
String?: {
|
|
23
|
+
sval?: string;
|
|
24
|
+
};
|
|
25
|
+
}[];
|
|
26
|
+
raw_expr?: unknown;
|
|
27
|
+
}
|
|
28
|
+
/** First `ColumnRef` column name reached in an expression tree, DFS order. */
|
|
29
|
+
export declare function firstColumnRef(node: unknown): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* The name Postgres would assign to an unnamed constraint on `table`, given
|
|
32
|
+
* the constraint node and (for column-attached constraints) the owning
|
|
33
|
+
* column. Returns `null` for kinds without a stable derivation (exclusion
|
|
34
|
+
* constraints and anything unrecognized).
|
|
35
|
+
*/
|
|
36
|
+
export declare function defaultConstraintName(table: string, constraint: ConstraintNode, column?: string): string | null;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Postgres default constraint names.
|
|
4
|
+
*
|
|
5
|
+
* When a constraint is authored without a name, Postgres derives one at
|
|
6
|
+
* deploy time (`ChooseConstraintName` in the server): `{table}_pkey` for
|
|
7
|
+
* primary keys, `{table}_{cols}_key` for unique constraints,
|
|
8
|
+
* `{table}_{col}_fkey` for foreign keys, `{table}_{col}_check` (first
|
|
9
|
+
* referenced column) or `{table}_check` for check constraints. Synthesizing
|
|
10
|
+
* the same names makes unnamed authorship comparable to (and revertible as)
|
|
11
|
+
* what the catalog will actually contain.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.firstColumnRef = firstColumnRef;
|
|
15
|
+
exports.defaultConstraintName = defaultConstraintName;
|
|
16
|
+
const svals = (items) => (items ?? []).map(k => k.String?.sval).filter((s) => typeof s === 'string');
|
|
17
|
+
/** First `ColumnRef` column name reached in an expression tree, DFS order. */
|
|
18
|
+
function firstColumnRef(node) {
|
|
19
|
+
if (Array.isArray(node)) {
|
|
20
|
+
for (const item of node) {
|
|
21
|
+
const found = firstColumnRef(item);
|
|
22
|
+
if (found)
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (node && typeof node === 'object') {
|
|
28
|
+
const record = node;
|
|
29
|
+
const ref = record.ColumnRef;
|
|
30
|
+
if (ref?.fields) {
|
|
31
|
+
const names = svals(ref.fields);
|
|
32
|
+
if (names.length > 0)
|
|
33
|
+
return names[names.length - 1];
|
|
34
|
+
}
|
|
35
|
+
for (const value of Object.values(record)) {
|
|
36
|
+
const found = firstColumnRef(value);
|
|
37
|
+
if (found)
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The name Postgres would assign to an unnamed constraint on `table`, given
|
|
45
|
+
* the constraint node and (for column-attached constraints) the owning
|
|
46
|
+
* column. Returns `null` for kinds without a stable derivation (exclusion
|
|
47
|
+
* constraints and anything unrecognized).
|
|
48
|
+
*/
|
|
49
|
+
function defaultConstraintName(table, constraint, column) {
|
|
50
|
+
const cols = () => {
|
|
51
|
+
const keyed = svals(constraint.keys);
|
|
52
|
+
if (keyed.length > 0)
|
|
53
|
+
return keyed;
|
|
54
|
+
return column ? [column] : [];
|
|
55
|
+
};
|
|
56
|
+
switch (constraint.contype) {
|
|
57
|
+
case 'CONSTR_PRIMARY':
|
|
58
|
+
return `${table}_pkey`;
|
|
59
|
+
case 'CONSTR_UNIQUE': {
|
|
60
|
+
const names = cols();
|
|
61
|
+
return names.length > 0 ? `${table}_${names.join('_')}_key` : null;
|
|
62
|
+
}
|
|
63
|
+
case 'CONSTR_FOREIGN': {
|
|
64
|
+
const attrs = svals(constraint.fk_attrs);
|
|
65
|
+
const names = attrs.length > 0 ? attrs : column ? [column] : [];
|
|
66
|
+
return names.length > 0 ? `${table}_${names.join('_')}_fkey` : null;
|
|
67
|
+
}
|
|
68
|
+
case 'CONSTR_CHECK': {
|
|
69
|
+
const col = column ?? firstColumnRef(constraint.raw_expr);
|
|
70
|
+
return col ? `${table}_${col}_check` : `${table}_check`;
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres default constraint names.
|
|
3
|
+
*
|
|
4
|
+
* When a constraint is authored without a name, Postgres derives one at
|
|
5
|
+
* deploy time (`ChooseConstraintName` in the server): `{table}_pkey` for
|
|
6
|
+
* primary keys, `{table}_{cols}_key` for unique constraints,
|
|
7
|
+
* `{table}_{col}_fkey` for foreign keys, `{table}_{col}_check` (first
|
|
8
|
+
* referenced column) or `{table}_check` for check constraints. Synthesizing
|
|
9
|
+
* the same names makes unnamed authorship comparable to (and revertible as)
|
|
10
|
+
* what the catalog will actually contain.
|
|
11
|
+
*/
|
|
12
|
+
const svals = (items) => (items ?? []).map(k => k.String?.sval).filter((s) => typeof s === 'string');
|
|
13
|
+
/** First `ColumnRef` column name reached in an expression tree, DFS order. */
|
|
14
|
+
export function firstColumnRef(node) {
|
|
15
|
+
if (Array.isArray(node)) {
|
|
16
|
+
for (const item of node) {
|
|
17
|
+
const found = firstColumnRef(item);
|
|
18
|
+
if (found)
|
|
19
|
+
return found;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (node && typeof node === 'object') {
|
|
24
|
+
const record = node;
|
|
25
|
+
const ref = record.ColumnRef;
|
|
26
|
+
if (ref?.fields) {
|
|
27
|
+
const names = svals(ref.fields);
|
|
28
|
+
if (names.length > 0)
|
|
29
|
+
return names[names.length - 1];
|
|
30
|
+
}
|
|
31
|
+
for (const value of Object.values(record)) {
|
|
32
|
+
const found = firstColumnRef(value);
|
|
33
|
+
if (found)
|
|
34
|
+
return found;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The name Postgres would assign to an unnamed constraint on `table`, given
|
|
41
|
+
* the constraint node and (for column-attached constraints) the owning
|
|
42
|
+
* column. Returns `null` for kinds without a stable derivation (exclusion
|
|
43
|
+
* constraints and anything unrecognized).
|
|
44
|
+
*/
|
|
45
|
+
export function defaultConstraintName(table, constraint, column) {
|
|
46
|
+
const cols = () => {
|
|
47
|
+
const keyed = svals(constraint.keys);
|
|
48
|
+
if (keyed.length > 0)
|
|
49
|
+
return keyed;
|
|
50
|
+
return column ? [column] : [];
|
|
51
|
+
};
|
|
52
|
+
switch (constraint.contype) {
|
|
53
|
+
case 'CONSTR_PRIMARY':
|
|
54
|
+
return `${table}_pkey`;
|
|
55
|
+
case 'CONSTR_UNIQUE': {
|
|
56
|
+
const names = cols();
|
|
57
|
+
return names.length > 0 ? `${table}_${names.join('_')}_key` : null;
|
|
58
|
+
}
|
|
59
|
+
case 'CONSTR_FOREIGN': {
|
|
60
|
+
const attrs = svals(constraint.fk_attrs);
|
|
61
|
+
const names = attrs.length > 0 ? attrs : column ? [column] : [];
|
|
62
|
+
return names.length > 0 ? `${table}_${names.join('_')}_fkey` : null;
|
|
63
|
+
}
|
|
64
|
+
case 'CONSTR_CHECK': {
|
|
65
|
+
const col = column ?? firstColumnRef(constraint.raw_expr);
|
|
66
|
+
return col ? `${table}_${col}_check` : `${table}_check`;
|
|
67
|
+
}
|
|
68
|
+
default:
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -1,25 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Granularity driver: restructure a pgpm module's deploy surface between the
|
|
3
|
-
* atomic, object, and consolidated shapes.
|
|
4
|
-
*
|
|
5
|
-
* The upstream pass (`restructureSql` in `@pgsql/transform`) rewrites one SQL
|
|
6
|
-
* script between equivalent shapes, guarded by the statement dependency
|
|
7
|
-
* graph. This driver lifts that to the pgpm change model: it flattens a
|
|
8
|
-
* module's deploy scripts in plan order into one program, restructures it to
|
|
9
|
-
* the target granularity, then re-slices the result into changes — one change
|
|
10
|
-
* per created object — with change dependencies recomputed from the statement
|
|
11
|
-
* graph. Like the other drivers in this package it is structurally typed on
|
|
12
|
-
* the bundle seams: no dependency on `@pgpmjs/bundle` or `@pgpmjs/core`.
|
|
13
|
-
*
|
|
14
|
-
* - `atomic` — the machine-emitted shape: bare CREATE TABLE plus one
|
|
15
|
-
* ALTER per column/constraint.
|
|
16
|
-
* - `object` — each table fully baked; cross-object statements
|
|
17
|
-
* (FKs, indexes, triggers, policies) stay separate.
|
|
18
|
-
* - `consolidated` — additionally inlines FKs proven safe by the graph.
|
|
19
|
-
*/
|
|
20
1
|
import { pathFor } from '@pgpmjs/naming-spec';
|
|
21
2
|
import { revertFor, verifyFor } from '@pgsql/scripts';
|
|
22
3
|
import { buildStatementGraph, classifyStatements, identityOf, restructureSql } from '@pgsql/transform';
|
|
4
|
+
import { nameUnnamedConstraints, subObjectIdentityOf } from './sub-object';
|
|
5
|
+
export const CHANGE_GRANULARITIES = ['alteration', 'object', 'single'];
|
|
6
|
+
export const isChangeGranularity = (value) => CHANGE_GRANULARITIES.includes(value);
|
|
23
7
|
const KIND_DIRS = {
|
|
24
8
|
table: 'tables',
|
|
25
9
|
view: 'views',
|
|
@@ -53,22 +37,50 @@ export function defaultChangeName(facts) {
|
|
|
53
37
|
*/
|
|
54
38
|
export function restructureChanges(changes, options) {
|
|
55
39
|
const nameFor = options.changeName ?? defaultChangeName;
|
|
40
|
+
const subNameFor = options.subObjectName ?? ((identity) => pathFor(identity));
|
|
41
|
+
const alteration = options.changeGranularity === 'alteration';
|
|
56
42
|
const flattened = changes
|
|
57
43
|
.map(c => c.deploy.trim())
|
|
58
44
|
.filter(Boolean)
|
|
59
45
|
.join('\n\n');
|
|
60
|
-
const
|
|
46
|
+
const restructured = restructureSql(flattened, {
|
|
61
47
|
granularity: options.granularity
|
|
62
48
|
});
|
|
49
|
+
const warnings = restructured.warnings;
|
|
50
|
+
const sql = alteration ? nameUnnamedConstraints(restructured.sql) : restructured.sql;
|
|
63
51
|
// Re-classify the emitted script; group statements by the object they
|
|
64
52
|
// target (creates[0]), so a table's CREATE and its remaining ALTERs land
|
|
65
53
|
// in the same change regardless of statement kind.
|
|
66
54
|
const facts = classifyStatements(sql);
|
|
55
|
+
if (options.changeGranularity === 'single') {
|
|
56
|
+
const name = options.singleChangeName ?? 'module/init';
|
|
57
|
+
const inputNames = new Set(changes.map(c => c.name));
|
|
58
|
+
const dependencies = [...new Set(changes.flatMap(c => c.dependencies).filter(d => !inputNames.has(d)))].sort();
|
|
59
|
+
const statements = facts
|
|
60
|
+
.map(f => sql.slice(f.span.start, f.span.start + f.span.len).trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.map(text => (text.endsWith(';') ? text : `${text};`));
|
|
63
|
+
const revert = revertFor(facts);
|
|
64
|
+
const verify = verifyFor(facts);
|
|
65
|
+
for (const warning of [...revert.warnings, ...verify.warnings]) {
|
|
66
|
+
warnings.push(`${name}: ${warning}`);
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
changes: statements.length > 0
|
|
70
|
+
? [{ name, dependencies, deploy: statements.join('\n\n'), revert: revert.sql, verify: verify.sql }]
|
|
71
|
+
: [],
|
|
72
|
+
warnings
|
|
73
|
+
};
|
|
74
|
+
}
|
|
67
75
|
const graph = buildStatementGraph(facts);
|
|
68
76
|
const groupOf = new Array(facts.length).fill(-1);
|
|
69
77
|
const groupKeys = [];
|
|
70
78
|
const groupFacts = [];
|
|
71
79
|
const groupKeyToIndex = new Map();
|
|
80
|
+
/** Sub-object path for alteration groups; null means name via `nameFor`. */
|
|
81
|
+
const groupSubName = [];
|
|
82
|
+
/** For alteration groups: the object-group key of the owning table. */
|
|
83
|
+
const groupOwnerKey = [];
|
|
72
84
|
facts.forEach((f, i) => {
|
|
73
85
|
const created = f.creates[0];
|
|
74
86
|
if (!created) {
|
|
@@ -78,12 +90,31 @@ export function restructureChanges(changes, options) {
|
|
|
78
90
|
groupOf[i] = groupOf[i - 1];
|
|
79
91
|
return;
|
|
80
92
|
}
|
|
93
|
+
if (alteration) {
|
|
94
|
+
const sub = subObjectIdentityOf(f);
|
|
95
|
+
if (sub) {
|
|
96
|
+
const key = `sub\u0000${sub.kind}\u0000${sub.schema ?? ''}\u0000${sub.table}\u0000${sub.name}`;
|
|
97
|
+
let g = groupKeyToIndex.get(key);
|
|
98
|
+
if (g === undefined) {
|
|
99
|
+
g = groupKeys.length;
|
|
100
|
+
groupKeys.push(key);
|
|
101
|
+
groupFacts.push(f);
|
|
102
|
+
groupSubName.push(subNameFor(sub));
|
|
103
|
+
groupOwnerKey.push(`${sub.schema ?? ''}.${sub.table}`);
|
|
104
|
+
groupKeyToIndex.set(key, g);
|
|
105
|
+
}
|
|
106
|
+
groupOf[i] = g;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
81
110
|
const key = `${created.schema ?? ''}.${created.name}`;
|
|
82
111
|
let g = groupKeyToIndex.get(key);
|
|
83
112
|
if (g === undefined) {
|
|
84
113
|
g = groupKeys.length;
|
|
85
114
|
groupKeys.push(key);
|
|
86
115
|
groupFacts.push(f);
|
|
116
|
+
groupSubName.push(null);
|
|
117
|
+
groupOwnerKey.push(null);
|
|
87
118
|
groupKeyToIndex.set(key, g);
|
|
88
119
|
}
|
|
89
120
|
else if (!(groupFacts[g].kind in KIND_DIRS) && groupFacts[g].kind !== 'schema' && (f.kind in KIND_DIRS || f.kind === 'schema')) {
|
|
@@ -92,7 +123,7 @@ export function restructureChanges(changes, options) {
|
|
|
92
123
|
}
|
|
93
124
|
groupOf[i] = g;
|
|
94
125
|
});
|
|
95
|
-
const groupNames = groupFacts.map(nameFor);
|
|
126
|
+
const groupNames = groupFacts.map((f, g) => groupSubName[g] ?? nameFor(f));
|
|
96
127
|
// Schema producers, for schema-level change dependencies.
|
|
97
128
|
const schemaGroup = new Map();
|
|
98
129
|
facts.forEach((f, i) => {
|
|
@@ -138,6 +169,38 @@ export function restructureChanges(changes, options) {
|
|
|
138
169
|
groupDeps[from].add(to);
|
|
139
170
|
}
|
|
140
171
|
});
|
|
172
|
+
// Alteration groups depend on their table's own change, and constraint
|
|
173
|
+
// groups additionally on the column changes they key on — the statement
|
|
174
|
+
// graph keys everything by the table, so these finer edges are added here.
|
|
175
|
+
if (alteration) {
|
|
176
|
+
const columnGroup = new Map();
|
|
177
|
+
groupKeys.forEach((key, g) => {
|
|
178
|
+
const parts = key.split('\u0000');
|
|
179
|
+
if (parts[0] === 'sub' && parts[1] === 'column') {
|
|
180
|
+
columnGroup.set(`${parts[2]}.${parts[3]}.${parts[4]}`, g);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
groupKeys.forEach((key, g) => {
|
|
184
|
+
const owner = groupOwnerKey[g];
|
|
185
|
+
if (owner === null)
|
|
186
|
+
return;
|
|
187
|
+
const tableGroup = groupKeyToIndex.get(owner);
|
|
188
|
+
if (tableGroup !== undefined && tableGroup !== g)
|
|
189
|
+
groupDeps[g].add(tableGroup);
|
|
190
|
+
const parts = key.split('\u0000');
|
|
191
|
+
if (parts[1] !== 'constraint')
|
|
192
|
+
return;
|
|
193
|
+
const constraint = groupFacts[g].stmt?.AlterTableStmt?.cmds?.[0]?.AlterTableCmd?.def?.Constraint;
|
|
194
|
+
const referenced = [...(constraint?.keys ?? []), ...(constraint?.fk_attrs ?? [])]
|
|
195
|
+
.map(k => k.String?.sval)
|
|
196
|
+
.filter((s) => typeof s === 'string');
|
|
197
|
+
for (const col of referenced) {
|
|
198
|
+
const to = columnGroup.get(`${owner}.${col}`);
|
|
199
|
+
if (to !== undefined && to !== g)
|
|
200
|
+
groupDeps[g].add(to);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
141
204
|
// Emit groups in first-statement order (already topological).
|
|
142
205
|
const order = [...groupNames.keys()].sort((a, b) => {
|
|
143
206
|
const firstA = groupOf.indexOf(a);
|
package/esm/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export { buildExtensionRouter, buildSchemaRouter, makeNamespaceValidator, makeSchemaTranspiler, } from './bundle-driver';
|
|
2
2
|
export { diffCatalogSnapshots, snapshotCatalog, withoutColumnOrder } from './catalog-check';
|
|
3
3
|
export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
|
|
4
|
+
export { defaultConstraintName, firstColumnRef } from './constraint-names';
|
|
4
5
|
export { resolveFixtureClosure } from './fixture-closure';
|
|
5
|
-
export { defaultChangeName, restructureChanges, } from './granularity-driver';
|
|
6
|
-
export { checkOverwrite, writeControlFile, writeModule } from './module-emit';
|
|
6
|
+
export { CHANGE_GRANULARITIES, defaultChangeName, isChangeGranularity, restructureChanges, } from './granularity-driver';
|
|
7
|
+
export { appendModule, checkOverwrite, writeControlFile, writeModule } from './module-emit';
|
|
7
8
|
export { loadModuleSource, stripTransactionWrapper } from './module-source';
|
|
8
9
|
export { parsePartitionConfig, partitionExportRows } from './partition';
|
|
9
10
|
export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
|
|
@@ -11,5 +12,6 @@ export { emitSqlProgram, parseSqlProgram } from './program';
|
|
|
11
12
|
export { classifyScript, isStubScript, regenerateScripts, } from './regen';
|
|
12
13
|
export { EXPORT_GRANULARITIES, isExportGranularity, restructureExportRows } from './restructure';
|
|
13
14
|
export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
|
|
15
|
+
export { nameUnnamedConstraints, subObjectIdentityOf } from './sub-object';
|
|
14
16
|
export * from '@pgsql/transform';
|
|
15
17
|
export { loadModule } from 'plpgsql-parser';
|
package/esm/module-emit.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* `pgpm import` all emit byte-identical module layouts instead of each
|
|
10
10
|
* carrying its own copy of the writer.
|
|
11
11
|
*/
|
|
12
|
-
import { writePgpmFiles, writePgpmPlan } from '@pgpmjs/ast';
|
|
12
|
+
import { parseAuthor, parsePlanFile, writePgpmFiles, writePgpmPlan, writePlanFile } from '@pgpmjs/ast';
|
|
13
13
|
import * as fs from 'fs';
|
|
14
14
|
import * as path from 'path';
|
|
15
15
|
/** Write a module's Postgres extension `.control` file. */
|
|
@@ -48,6 +48,76 @@ export const writeModule = (outBase, model, extraRequires = []) => {
|
|
|
48
48
|
writePgpmFiles(model.rows, opts);
|
|
49
49
|
return dir;
|
|
50
50
|
};
|
|
51
|
+
/**
|
|
52
|
+
* Append plan-ordered {@link PgpmRow}s into an *existing* module rather than
|
|
53
|
+
* writing a fresh package. Existing changes, their scripts, and the `.control`
|
|
54
|
+
* file are left untouched; only the new changes are written (deploy/revert/
|
|
55
|
+
* verify) and appended to `pgpm.plan` after the current changes.
|
|
56
|
+
*
|
|
57
|
+
* Rows whose change name already exists are skipped (never overwritten).
|
|
58
|
+
* A new change's dependency bracket is filtered to names that resolve within
|
|
59
|
+
* the plan (existing changes, other appended changes, or `pkg:`-external
|
|
60
|
+
* references); a dangling internal dependency is dropped with a warning
|
|
61
|
+
* (plan order still sequences it after the current changes).
|
|
62
|
+
*/
|
|
63
|
+
export const appendModule = (moduleDir, rows, options = {}) => {
|
|
64
|
+
const planPath = path.join(moduleDir, 'pgpm.plan');
|
|
65
|
+
if (!fs.existsSync(planPath)) {
|
|
66
|
+
throw new Error(`No pgpm.plan found at ${planPath}; append mode expects an existing pgpm module directory.`);
|
|
67
|
+
}
|
|
68
|
+
const parsed = parsePlanFile(planPath);
|
|
69
|
+
if (!parsed.data) {
|
|
70
|
+
throw new Error(`Failed to parse ${planPath}: ${parsed.errors
|
|
71
|
+
.map(e => `line ${e.line}: ${e.message}`)
|
|
72
|
+
.join('; ')}`);
|
|
73
|
+
}
|
|
74
|
+
const plan = parsed.data;
|
|
75
|
+
const existingNames = new Set(plan.changes.map(c => c.name));
|
|
76
|
+
const incomingNames = new Set(rows.map(r => r.deploy));
|
|
77
|
+
const appended = new Set();
|
|
78
|
+
const added = [];
|
|
79
|
+
const skipped = [];
|
|
80
|
+
const warnings = [];
|
|
81
|
+
const newRows = [];
|
|
82
|
+
const { fullName, email } = parseAuthor(options.author || 'constructive');
|
|
83
|
+
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
if (existingNames.has(row.deploy) || appended.has(row.deploy)) {
|
|
86
|
+
skipped.push(row.deploy);
|
|
87
|
+
warnings.push(`change ${row.deploy} already exists in ${plan.package}; left untouched`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const deps = (row.deps ?? []).filter(dep => {
|
|
91
|
+
if (dep.includes(':'))
|
|
92
|
+
return true; // cross-package (pkg:change) external
|
|
93
|
+
if (existingNames.has(dep) || incomingNames.has(dep))
|
|
94
|
+
return true;
|
|
95
|
+
warnings.push(`change ${row.deploy}: dropped dependency ${dep} (not present in ${plan.package})`);
|
|
96
|
+
return false;
|
|
97
|
+
});
|
|
98
|
+
plan.changes.push({
|
|
99
|
+
name: row.deploy,
|
|
100
|
+
dependencies: deps,
|
|
101
|
+
timestamp,
|
|
102
|
+
planner: fullName,
|
|
103
|
+
email: email || `${fullName}@constructive.io`,
|
|
104
|
+
comment: `add ${row.name ?? row.deploy}`
|
|
105
|
+
});
|
|
106
|
+
newRows.push({ ...row, deps });
|
|
107
|
+
appended.add(row.deploy);
|
|
108
|
+
added.push(row.deploy);
|
|
109
|
+
}
|
|
110
|
+
if (newRows.length) {
|
|
111
|
+
const opts = {
|
|
112
|
+
outdir: path.dirname(moduleDir),
|
|
113
|
+
name: path.basename(moduleDir),
|
|
114
|
+
replacer: (str) => str
|
|
115
|
+
};
|
|
116
|
+
writePgpmFiles(newRows, opts);
|
|
117
|
+
writePlanFile(planPath, plan);
|
|
118
|
+
}
|
|
119
|
+
return { dir: moduleDir, added, skipped, warnings };
|
|
120
|
+
};
|
|
51
121
|
/**
|
|
52
122
|
* Guard against clobbering: writing into the source directory requires
|
|
53
123
|
* `--write`, as does overwriting any existing package directory.
|
package/esm/restructure.js
CHANGED
|
@@ -33,7 +33,12 @@ export const restructureExportRows = async (rows, granularity, options = {}) =>
|
|
|
33
33
|
name: row.deploy,
|
|
34
34
|
dependencies: row.deps ?? [],
|
|
35
35
|
deploy: row.content
|
|
36
|
-
})), {
|
|
36
|
+
})), {
|
|
37
|
+
granularity,
|
|
38
|
+
changeGranularity: options.changeGranularity,
|
|
39
|
+
changeName,
|
|
40
|
+
subObjectName: identity => pathFor(identity, { style })
|
|
41
|
+
});
|
|
37
42
|
const counters = new Map();
|
|
38
43
|
const taken = new Set();
|
|
39
44
|
const restructured = changes.map(change => {
|
|
@@ -30,6 +30,7 @@ import { pathFor } from '@pgpmjs/naming-spec';
|
|
|
30
30
|
import { existenceCheck, invertStatement, revertFor, verifyFor } from '@pgsql/scripts';
|
|
31
31
|
import { buildStatementGraph, classifyStatements, cleanTree, identityOf } from '@pgsql/transform';
|
|
32
32
|
import { Deparser, parseSync } from 'plpgsql-parser';
|
|
33
|
+
import { defaultConstraintName } from './constraint-names';
|
|
33
34
|
import { restructureChanges } from './granularity-driver';
|
|
34
35
|
const identityKey = (id) => `${id.kind}\u0000${id.schema ?? ''}\u0000${id.table ?? ''}\u0000${id.name}`;
|
|
35
36
|
/**
|
|
@@ -99,7 +100,7 @@ function groupingIdentity(f) {
|
|
|
99
100
|
const identity = identityOf(f);
|
|
100
101
|
if (!identity)
|
|
101
102
|
return null;
|
|
102
|
-
if ((f.kind === 'table' || f.kind === 'constraint' || f.kind === 'rls_enable') &&
|
|
103
|
+
if ((f.kind === 'table' || f.kind === 'constraint' || f.kind === 'fk_constraint' || f.kind === 'rls_enable') &&
|
|
103
104
|
identity.kind !== 'table') {
|
|
104
105
|
return { kind: 'table', schema: identity.schema, name: identity.table ?? identity.name };
|
|
105
106
|
}
|
|
@@ -112,21 +113,61 @@ function fingerprint(text) {
|
|
|
112
113
|
}
|
|
113
114
|
/** Multiset fingerprint of a whole unit. */
|
|
114
115
|
const unitFingerprint = (unit) => unit.texts.map(fingerprint).sort().join('\n');
|
|
116
|
+
/** JSON with recursively sorted object keys — an insertion-order-proof fingerprint. */
|
|
117
|
+
function stablePrint(node) {
|
|
118
|
+
if (Array.isArray(node))
|
|
119
|
+
return `[${node.map(stablePrint).join(',')}]`;
|
|
120
|
+
if (node && typeof node === 'object') {
|
|
121
|
+
const entries = Object.entries(node)
|
|
122
|
+
.filter(([, v]) => v !== undefined)
|
|
123
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
124
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${stablePrint(v)}`);
|
|
125
|
+
return `{${entries.join(',')}}`;
|
|
126
|
+
}
|
|
127
|
+
return JSON.stringify(node);
|
|
128
|
+
}
|
|
129
|
+
/** Constraint kinds that can move between column, table-elt, and ALTER form. */
|
|
130
|
+
const RELOCATABLE = new Set(['CONSTR_PRIMARY', 'CONSTR_UNIQUE', 'CONSTR_FOREIGN', 'CONSTR_CHECK']);
|
|
115
131
|
/**
|
|
116
132
|
* Fold a table unit's statements (CREATE TABLE plus any ALTER TABLE
|
|
117
133
|
* ADD COLUMN / ADD CONSTRAINT) into an effective shape, so atomic and
|
|
118
134
|
* consolidated authorships of the same table compare equal.
|
|
135
|
+
*
|
|
136
|
+
* Constraint placement is representation, not semantics: `id bigint PRIMARY
|
|
137
|
+
* KEY`, a `PRIMARY KEY (id)` table elt, and `ALTER TABLE .. ADD CONSTRAINT
|
|
138
|
+
* t_pkey PRIMARY KEY (id)` all catalog identically. Relocatable constraints
|
|
139
|
+
* (PK/UNIQUE/FK/CHECK) are therefore lifted out of columns and ALTER
|
|
140
|
+
* commands into one canonical table-level set — keyed with the Postgres
|
|
141
|
+
* default name when unnamed, columns filled in from the owning column when
|
|
142
|
+
* column-attached — and PK columns gain the NOT NULL the catalog implies.
|
|
119
143
|
*/
|
|
120
144
|
function tableShape(unit) {
|
|
121
145
|
const shape = { relation: null, columns: new Map(), extras: new Map() };
|
|
146
|
+
const table = unit.identity.name;
|
|
147
|
+
const rawColumns = [];
|
|
148
|
+
const constraints = [];
|
|
149
|
+
const pkColumns = new Set();
|
|
122
150
|
const addColumn = (node) => {
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
151
|
+
const colname = node.colname ?? '';
|
|
152
|
+
const attached = node.constraints ?? [];
|
|
153
|
+
const residual = [];
|
|
154
|
+
for (const item of attached) {
|
|
155
|
+
const c = item.Constraint;
|
|
156
|
+
if (c && RELOCATABLE.has(c.contype ?? '')) {
|
|
157
|
+
constraints.push({ node: c, column: colname });
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
residual.push(item);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
rawColumns.push({ colname, def: { ...node, constraints: residual } });
|
|
164
|
+
};
|
|
165
|
+
const addConstraint = (node, column) => {
|
|
166
|
+
constraints.push({ node: node, column });
|
|
126
167
|
};
|
|
127
168
|
const addExtra = (node) => {
|
|
128
169
|
const clean = cleanTree(node);
|
|
129
|
-
shape.extras.set(
|
|
170
|
+
shape.extras.set(stablePrint(clean), clean);
|
|
130
171
|
};
|
|
131
172
|
for (const text of unit.texts) {
|
|
132
173
|
for (const raw of parseSync(text).sql.stmts) {
|
|
@@ -137,6 +178,9 @@ function tableShape(unit) {
|
|
|
137
178
|
for (const elt of elts) {
|
|
138
179
|
if (elt.ColumnDef)
|
|
139
180
|
addColumn(elt.ColumnDef);
|
|
181
|
+
else if (elt.Constraint && RELOCATABLE.has(elt.Constraint.contype ?? '')) {
|
|
182
|
+
addConstraint(elt.Constraint);
|
|
183
|
+
}
|
|
140
184
|
else
|
|
141
185
|
addExtra(elt);
|
|
142
186
|
}
|
|
@@ -152,6 +196,11 @@ function tableShape(unit) {
|
|
|
152
196
|
if (at.subtype === 'AT_AddColumn' && def?.ColumnDef) {
|
|
153
197
|
addColumn(def.ColumnDef);
|
|
154
198
|
}
|
|
199
|
+
else if (at.subtype === 'AT_AddConstraint' &&
|
|
200
|
+
def?.Constraint &&
|
|
201
|
+
RELOCATABLE.has(def.Constraint.contype ?? '')) {
|
|
202
|
+
addConstraint(def.Constraint);
|
|
203
|
+
}
|
|
155
204
|
else {
|
|
156
205
|
addExtra({ AlterTableCmd: at });
|
|
157
206
|
}
|
|
@@ -162,6 +211,44 @@ function tableShape(unit) {
|
|
|
162
211
|
}
|
|
163
212
|
}
|
|
164
213
|
}
|
|
214
|
+
for (const { node, column } of constraints) {
|
|
215
|
+
const canonical = { ...node };
|
|
216
|
+
canonical.conname = node.conname ?? defaultConstraintName(table, node, column) ?? undefined;
|
|
217
|
+
if (column && (node.keys ?? []).length === 0 && node.contype !== 'CONSTR_FOREIGN' && node.contype !== 'CONSTR_CHECK') {
|
|
218
|
+
canonical.keys = [{ String: { sval: column } }];
|
|
219
|
+
}
|
|
220
|
+
if (column && node.contype === 'CONSTR_FOREIGN' && (node.fk_attrs ?? []).length === 0) {
|
|
221
|
+
canonical.fk_attrs = [{ String: { sval: column } }];
|
|
222
|
+
}
|
|
223
|
+
if (node.contype === 'CONSTR_PRIMARY') {
|
|
224
|
+
const keyed = canonical.keys ?? [];
|
|
225
|
+
for (const k of keyed)
|
|
226
|
+
if (k.String?.sval)
|
|
227
|
+
pkColumns.add(k.String.sval);
|
|
228
|
+
}
|
|
229
|
+
addExtra({ Constraint: canonical });
|
|
230
|
+
}
|
|
231
|
+
for (const { colname, def } of rawColumns) {
|
|
232
|
+
const residual = (def.constraints ?? []).map(c => {
|
|
233
|
+
const constraint = c.Constraint;
|
|
234
|
+
// NOT NULL parses with parser-version-dependent extras (is_enforced,
|
|
235
|
+
// initially_valid); reduce to its semantic core so an authored NOT NULL
|
|
236
|
+
// equals the one a primary key implies.
|
|
237
|
+
if (constraint?.contype === 'CONSTR_NOTNULL') {
|
|
238
|
+
return { Constraint: { contype: 'CONSTR_NOTNULL', conname: constraint.conname } };
|
|
239
|
+
}
|
|
240
|
+
return c;
|
|
241
|
+
});
|
|
242
|
+
const hasNotNull = residual.some(c => c.Constraint?.contype === 'CONSTR_NOTNULL');
|
|
243
|
+
if (pkColumns.has(colname) && !hasNotNull) {
|
|
244
|
+
residual.push({ Constraint: { contype: 'CONSTR_NOTNULL' } });
|
|
245
|
+
}
|
|
246
|
+
const sorted = residual
|
|
247
|
+
.map(c => cleanTree(c))
|
|
248
|
+
.sort((x, y) => stablePrint(x).localeCompare(stablePrint(y)));
|
|
249
|
+
const clean = cleanTree({ ...def, constraints: sorted });
|
|
250
|
+
shape.columns.set(colname, { def: clean, print: stablePrint(clean) });
|
|
251
|
+
}
|
|
165
252
|
return shape;
|
|
166
253
|
}
|
|
167
254
|
const deparseStmt = (node) => `${Deparser.deparse(node, { pretty: false })};`;
|
|
@@ -385,7 +472,11 @@ export function diffSchemas(from, to, options = {}) {
|
|
|
385
472
|
}
|
|
386
473
|
let forwardChanges = [];
|
|
387
474
|
if (forwardSql.length > 0) {
|
|
388
|
-
const { changes, warnings: w } = restructureChanges([{ name: 'delta', dependencies: [], deploy: forwardSql.join('\n\n') }], {
|
|
475
|
+
const { changes, warnings: w } = restructureChanges([{ name: 'delta', dependencies: [], deploy: forwardSql.join('\n\n') }], {
|
|
476
|
+
granularity: options.granularity ?? 'object',
|
|
477
|
+
changeGranularity: options.changeGranularity,
|
|
478
|
+
subObjectName: identity => pathFor(identity, { style })
|
|
479
|
+
});
|
|
389
480
|
forwardChanges = changes.map(c => ({
|
|
390
481
|
...c,
|
|
391
482
|
revert: inverseOf(c.deploy, c.name),
|