@pgpmjs/transform 0.18.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/bundle-driver.d.ts +1 -1
- package/catalog-check.d.ts +47 -0
- package/catalog-check.js +130 -0
- package/constraint-names.d.ts +36 -0
- package/constraint-names.js +75 -0
- package/esm/catalog-check.js +124 -0
- package/esm/constraint-names.js +71 -0
- package/esm/fixture-closure.js +1 -1
- package/esm/granularity-driver.js +84 -21
- package/esm/index.js +13 -6
- package/esm/module-emit.js +135 -0
- package/esm/module-source.js +54 -0
- package/esm/partition.js +89 -0
- package/esm/restructure.js +67 -0
- package/esm/semantic-diff-driver.js +124 -19
- package/esm/sub-object.js +80 -0
- package/fixture-closure.d.ts +1 -1
- package/fixture-closure.js +1 -1
- package/granularity-driver.d.ts +57 -0
- package/granularity-driver.js +86 -21
- package/index.d.ts +23 -10
- package/index.js +43 -16
- package/module-emit.d.ts +65 -0
- package/module-emit.js +175 -0
- package/module-source.d.ts +33 -0
- package/module-source.js +92 -0
- package/package.json +8 -7
- package/partition.d.ts +32 -0
- package/partition.js +127 -0
- package/restructure.d.ts +45 -0
- package/restructure.js +72 -0
- package/semantic-diff-driver.d.ts +3 -1
- package/semantic-diff-driver.js +124 -19
- package/sub-object.d.ts +31 -0
- package/sub-object.js +84 -0
package/esm/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
export * from '@pgsql/transform';
|
|
2
|
-
export { loadModule } from 'plpgsql-parser';
|
|
3
1
|
export { buildExtensionRouter, buildSchemaRouter, makeNamespaceValidator, makeSchemaTranspiler, } from './bundle-driver';
|
|
4
|
-
export {
|
|
5
|
-
export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
|
|
6
|
-
export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
|
|
2
|
+
export { diffCatalogSnapshots, snapshotCatalog, withoutColumnOrder } from './catalog-check';
|
|
7
3
|
export { buildCategoryOf, categorizeChange, TIER_PROFILE, } from './categorize';
|
|
4
|
+
export { defaultConstraintName, firstColumnRef } from './constraint-names';
|
|
8
5
|
export { resolveFixtureClosure } from './fixture-closure';
|
|
9
|
-
export {
|
|
6
|
+
export { CHANGE_GRANULARITIES, defaultChangeName, isChangeGranularity, restructureChanges, } from './granularity-driver';
|
|
7
|
+
export { appendModule, checkOverwrite, writeControlFile, writeModule } from './module-emit';
|
|
8
|
+
export { loadModuleSource, stripTransactionWrapper } from './module-source';
|
|
9
|
+
export { parsePartitionConfig, partitionExportRows } from './partition';
|
|
10
|
+
export { PartitionCycleError, partitionUnits, RESIDUAL_UNIT_PATH, } from './partition-driver';
|
|
10
11
|
export { emitSqlProgram, parseSqlProgram } from './program';
|
|
12
|
+
export { classifyScript, isStubScript, regenerateScripts, } from './regen';
|
|
13
|
+
export { EXPORT_GRANULARITIES, isExportGranularity, restructureExportRows } from './restructure';
|
|
14
|
+
export { diffChangeSets, diffSchemas, } from './semantic-diff-driver';
|
|
15
|
+
export { nameUnnamedConstraints, subObjectIdentityOf } from './sub-object';
|
|
16
|
+
export * from '@pgsql/transform';
|
|
17
|
+
export { loadModule } from 'plpgsql-parser';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module emission — the single seam every dials command renders through.
|
|
3
|
+
*
|
|
4
|
+
* The engine (semantic diff, granularity, partition) produces plan-ordered
|
|
5
|
+
* {@link PgpmRow}s; a {@link PgpmModuleModel} is just those rows plus the
|
|
6
|
+
* module's name and control-file requires. `writeModule` is the one writer
|
|
7
|
+
* that turns a model into an on-disk pgpm module (`.control` + `pgpm.plan` +
|
|
8
|
+
* deploy/revert/verify trees) — so `pgpm diff`, `pgpm transform`, and
|
|
9
|
+
* `pgpm import` all emit byte-identical module layouts instead of each
|
|
10
|
+
* carrying its own copy of the writer.
|
|
11
|
+
*/
|
|
12
|
+
import { parseAuthor, parsePlanFile, writePgpmFiles, writePgpmPlan, writePlanFile } from '@pgpmjs/ast';
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
/** Write a module's Postgres extension `.control` file. */
|
|
16
|
+
export const writeControlFile = (dir, name, requires) => {
|
|
17
|
+
const lines = [
|
|
18
|
+
`# ${name} extension`,
|
|
19
|
+
`comment = '${name} extension'`,
|
|
20
|
+
`default_version = '0.0.1'`,
|
|
21
|
+
`relocatable = false`,
|
|
22
|
+
`superuser = false`
|
|
23
|
+
];
|
|
24
|
+
if (requires.length) {
|
|
25
|
+
lines.push(`requires = '${requires.join(',')}'`);
|
|
26
|
+
}
|
|
27
|
+
fs.writeFileSync(path.join(dir, `${name}.control`), lines.join('\n') + '\n');
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Write a {@link PgpmModuleModel} to `<outBase>/<name>` as a full pgpm module:
|
|
31
|
+
* a `.control` file, `pgpm.plan`, and deploy/revert/verify script trees.
|
|
32
|
+
* The deploy/revert/verify directories are cleared first so a re-emit never
|
|
33
|
+
* leaves stale scripts behind. Returns the module directory.
|
|
34
|
+
*/
|
|
35
|
+
export const writeModule = (outBase, model, extraRequires = []) => {
|
|
36
|
+
const dir = path.join(outBase, model.name);
|
|
37
|
+
fs.rmSync(path.join(dir, 'deploy'), { recursive: true, force: true });
|
|
38
|
+
fs.rmSync(path.join(dir, 'revert'), { recursive: true, force: true });
|
|
39
|
+
fs.rmSync(path.join(dir, 'verify'), { recursive: true, force: true });
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
writeControlFile(dir, model.name, [...extraRequires, ...model.requires]);
|
|
42
|
+
const opts = {
|
|
43
|
+
outdir: outBase,
|
|
44
|
+
name: model.name,
|
|
45
|
+
replacer: (str) => str.split('constructive-extension-name').join(model.name)
|
|
46
|
+
};
|
|
47
|
+
writePgpmPlan(model.rows, opts);
|
|
48
|
+
writePgpmFiles(model.rows, opts);
|
|
49
|
+
return dir;
|
|
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
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Guard against clobbering: writing into the source directory requires
|
|
123
|
+
* `--write`, as does overwriting any existing package directory.
|
|
124
|
+
*/
|
|
125
|
+
export const checkOverwrite = (targetDir, sourcePath, write) => {
|
|
126
|
+
const resolvedTarget = path.resolve(targetDir);
|
|
127
|
+
const resolvedSource = path.resolve(sourcePath);
|
|
128
|
+
if (resolvedTarget === resolvedSource && !write) {
|
|
129
|
+
return `Refusing to overwrite the source module at ${resolvedSource} (pass --write to allow).`;
|
|
130
|
+
}
|
|
131
|
+
if (resolvedTarget !== resolvedSource && fs.existsSync(resolvedTarget) && !write) {
|
|
132
|
+
return `Output directory ${resolvedTarget} already exists (pass --write to overwrite).`;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module ingestion for the dials pipeline: load an on-disk pgpm module into
|
|
3
|
+
* the flattened, classified change list the transform/partition drivers
|
|
4
|
+
* consume. `pgpm transform` uses this today; a future `pgpm import` (dumps)
|
|
5
|
+
* and `pgpm diff` are expected to reuse the same seam.
|
|
6
|
+
*/
|
|
7
|
+
import { parsePgpmHeader, parsePlanFile, readScript } from '@pgpmjs/ast';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
const TX_LINE = /^\s*(BEGIN|COMMIT|ROLLBACK)\s*;\s*$/i;
|
|
10
|
+
/**
|
|
11
|
+
* Strip the transaction wrapper some modules carry inside their scripts
|
|
12
|
+
* (standalone `BEGIN;` / `COMMIT;` / `ROLLBACK;` lines). The caller owns
|
|
13
|
+
* transaction control.
|
|
14
|
+
*/
|
|
15
|
+
export const stripTransactionWrapper = (sql) => sql
|
|
16
|
+
.split('\n')
|
|
17
|
+
.filter(line => !TX_LINE.test(line))
|
|
18
|
+
.join('\n')
|
|
19
|
+
.trim();
|
|
20
|
+
/**
|
|
21
|
+
* Load a pgpm module from disk: parse pgpm.plan and read each change's
|
|
22
|
+
* deploy script, stripping pgpm headers and transaction wrappers so the
|
|
23
|
+
* result can be flattened straight into the dials pipeline
|
|
24
|
+
* (`restructureChanges` / `partitionUnits`).
|
|
25
|
+
*/
|
|
26
|
+
export const loadModuleSource = (moduleDir) => {
|
|
27
|
+
const modulePath = path.resolve(moduleDir);
|
|
28
|
+
const planResult = parsePlanFile(path.join(modulePath, 'pgpm.plan'));
|
|
29
|
+
if (!planResult.data) {
|
|
30
|
+
const reasons = planResult.errors.map(e => e.message).join(', ');
|
|
31
|
+
throw new Error(`Failed to parse pgpm.plan in ${modulePath}: ${reasons}`);
|
|
32
|
+
}
|
|
33
|
+
const warnings = [];
|
|
34
|
+
const changes = [];
|
|
35
|
+
for (const change of planResult.data.changes) {
|
|
36
|
+
const raw = readScript(modulePath, 'deploy', change.name);
|
|
37
|
+
if (!raw) {
|
|
38
|
+
warnings.push(`${change.name}: no deploy script found, skipping`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const { body } = parsePgpmHeader(raw);
|
|
42
|
+
changes.push({
|
|
43
|
+
name: change.name,
|
|
44
|
+
dependencies: change.dependencies ?? [],
|
|
45
|
+
deploy: stripTransactionWrapper(body)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
name: planResult.data.package,
|
|
50
|
+
modulePath,
|
|
51
|
+
changes,
|
|
52
|
+
warnings
|
|
53
|
+
};
|
|
54
|
+
};
|
package/esm/partition.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { loadModule } from 'plpgsql-parser';
|
|
3
|
+
import { partitionUnits } from './partition-driver';
|
|
4
|
+
import { regenerateScripts } from './regen';
|
|
5
|
+
const PATH_STYLES = ['directory', 'flat'];
|
|
6
|
+
/**
|
|
7
|
+
* Parse and validate a partition config file (JSON matching
|
|
8
|
+
* `PartitionConfig`: rules, defaultPackage, optional style/splitRiders).
|
|
9
|
+
* Throws with a message naming the offending field.
|
|
10
|
+
*/
|
|
11
|
+
export const parsePartitionConfig = (filePath) => {
|
|
12
|
+
let raw;
|
|
13
|
+
try {
|
|
14
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error(`Partition config not found: ${filePath}`);
|
|
18
|
+
}
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
throw new Error(`Partition config is not valid JSON (${filePath}): ${err?.message ?? err}`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
27
|
+
throw new Error(`Partition config must be a JSON object (${filePath})`);
|
|
28
|
+
}
|
|
29
|
+
if (typeof parsed.defaultPackage !== 'string' || !parsed.defaultPackage) {
|
|
30
|
+
throw new Error('Partition config: "defaultPackage" must be a non-empty string');
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(parsed.rules)) {
|
|
33
|
+
throw new Error('Partition config: "rules" must be an array');
|
|
34
|
+
}
|
|
35
|
+
parsed.rules.forEach((rule, i) => {
|
|
36
|
+
if (typeof rule !== 'object' || rule === null) {
|
|
37
|
+
throw new Error(`Partition config: rules[${i}] must be an object`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof rule.package !== 'string' || !rule.package) {
|
|
40
|
+
throw new Error(`Partition config: rules[${i}].package must be a non-empty string`);
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(rule.select) || rule.select.length === 0) {
|
|
43
|
+
throw new Error(`Partition config: rules[${i}].select must be a non-empty array of selectors`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
if (parsed.style !== undefined && !PATH_STYLES.includes(parsed.style)) {
|
|
47
|
+
throw new Error(`Partition config: "style" must be one of: ${PATH_STYLES.join(', ')}`);
|
|
48
|
+
}
|
|
49
|
+
if (parsed.splitRiders !== undefined && !Array.isArray(parsed.splitRiders)) {
|
|
50
|
+
throw new Error('Partition config: "splitRiders" must be an array of statement kinds');
|
|
51
|
+
}
|
|
52
|
+
return parsed;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Partition changes into packages and generate revert/verify for every
|
|
56
|
+
* emitted change. Cross-package dependencies keep the `<pkg>:<path>`
|
|
57
|
+
* convention in `deps`. May throw `PartitionCycleError`.
|
|
58
|
+
*/
|
|
59
|
+
export const partitionExportRows = async (rows, config) => {
|
|
60
|
+
await loadModule();
|
|
61
|
+
const input = rows.map(row => ({
|
|
62
|
+
name: row.deploy,
|
|
63
|
+
dependencies: row.deps ?? [],
|
|
64
|
+
deploy: row.content
|
|
65
|
+
}));
|
|
66
|
+
const { packages, warnings } = partitionUnits(input, config);
|
|
67
|
+
const result = packages.map(pkg => ({
|
|
68
|
+
name: pkg.name,
|
|
69
|
+
requires: pkg.requires,
|
|
70
|
+
rows: pkg.changes.map(change => {
|
|
71
|
+
const scripts = regenerateScripts(change.deploy);
|
|
72
|
+
for (const warning of scripts.revert.warnings) {
|
|
73
|
+
warnings.push(`${pkg.name}:${change.name}: ${warning}`);
|
|
74
|
+
}
|
|
75
|
+
for (const warning of scripts.verify.warnings) {
|
|
76
|
+
warnings.push(`${pkg.name}:${change.name}: ${warning}`);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
name: change.name,
|
|
80
|
+
deploy: change.name,
|
|
81
|
+
deps: change.dependencies,
|
|
82
|
+
content: change.deploy,
|
|
83
|
+
revert: scripts.revert.sql,
|
|
84
|
+
verify: scripts.verify.sql
|
|
85
|
+
};
|
|
86
|
+
})
|
|
87
|
+
}));
|
|
88
|
+
return { packages: result, warnings };
|
|
89
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { alterationPathFor, pathFor } from '@pgpmjs/naming-spec';
|
|
2
|
+
import { identityOf } from '@pgsql/transform';
|
|
3
|
+
import { loadModule } from 'plpgsql-parser';
|
|
4
|
+
import { restructureChanges } from './granularity-driver';
|
|
5
|
+
export const EXPORT_GRANULARITIES = ['atomic', 'object', 'consolidated'];
|
|
6
|
+
export const isExportGranularity = (value) => typeof value === 'string' && EXPORT_GRANULARITIES.includes(value);
|
|
7
|
+
/**
|
|
8
|
+
* Restructure exported migration rows to the target granularity.
|
|
9
|
+
*
|
|
10
|
+
* The rows' deploy SQL is flattened in export order, restructured, and
|
|
11
|
+
* re-sliced into per-object changes whose paths come from the naming spec and
|
|
12
|
+
* whose deps come from the statement graph. When two changes derive the same
|
|
13
|
+
* path (the same object altered again), later occurrences get the spec's
|
|
14
|
+
* alteration convention via `alterationPathFor` (monotonic per-parent
|
|
15
|
+
* counter), with the parent added to their deps.
|
|
16
|
+
*
|
|
17
|
+
* Revert/verify scripts are not carried through from the original rows (the
|
|
18
|
+
* restructured grouping no longer matches them one-to-one); instead each
|
|
19
|
+
* change gets scripts generated from its own statements via
|
|
20
|
+
* `revertFor`/`verifyFor` (drops in reverse topological order, one existence
|
|
21
|
+
* check per created object).
|
|
22
|
+
*/
|
|
23
|
+
export const restructureExportRows = async (rows, granularity, options = {}) => {
|
|
24
|
+
await loadModule();
|
|
25
|
+
const style = options.naming ?? 'directory';
|
|
26
|
+
const changeName = (facts) => {
|
|
27
|
+
const identity = identityOf(facts);
|
|
28
|
+
if (identity)
|
|
29
|
+
return pathFor(identity, { style });
|
|
30
|
+
return 'misc/statements';
|
|
31
|
+
};
|
|
32
|
+
const { changes, warnings } = restructureChanges(rows.map(row => ({
|
|
33
|
+
name: row.deploy,
|
|
34
|
+
dependencies: row.deps ?? [],
|
|
35
|
+
deploy: row.content
|
|
36
|
+
})), {
|
|
37
|
+
granularity,
|
|
38
|
+
changeGranularity: options.changeGranularity,
|
|
39
|
+
changeName,
|
|
40
|
+
subObjectName: identity => pathFor(identity, { style })
|
|
41
|
+
});
|
|
42
|
+
const counters = new Map();
|
|
43
|
+
const taken = new Set();
|
|
44
|
+
const restructured = changes.map(change => {
|
|
45
|
+
let deploy = change.name;
|
|
46
|
+
const deps = [...change.dependencies];
|
|
47
|
+
if (taken.has(deploy)) {
|
|
48
|
+
let n = (counters.get(change.name) ?? 0) + 1;
|
|
49
|
+
while (taken.has(alterationPathFor(change.name, n)))
|
|
50
|
+
n++;
|
|
51
|
+
counters.set(change.name, n);
|
|
52
|
+
deploy = alterationPathFor(change.name, n);
|
|
53
|
+
if (!deps.includes(change.name))
|
|
54
|
+
deps.push(change.name);
|
|
55
|
+
}
|
|
56
|
+
taken.add(deploy);
|
|
57
|
+
return {
|
|
58
|
+
name: deploy,
|
|
59
|
+
deploy,
|
|
60
|
+
deps,
|
|
61
|
+
content: change.deploy,
|
|
62
|
+
revert: change.revert,
|
|
63
|
+
verify: change.verify
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
return { rows: restructured, warnings };
|
|
67
|
+
};
|
|
@@ -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 })};`;
|
|
@@ -210,32 +297,46 @@ function diffTable(from, to, diff, warnings) {
|
|
|
210
297
|
stmts.push(alter({ subtype: 'AT_DropColumn', name }));
|
|
211
298
|
}
|
|
212
299
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
}
|
|
300
|
+
// Self-inverse ALTER TABLE subtypes: a removed extra with one of these
|
|
301
|
+
// undoes with the paired subtype rather than a DROP CONSTRAINT.
|
|
302
|
+
const INVERSE_SUBTYPE = {
|
|
303
|
+
AT_EnableRowSecurity: 'AT_DisableRowSecurity',
|
|
304
|
+
AT_DisableRowSecurity: 'AT_EnableRowSecurity',
|
|
305
|
+
AT_ForceRowSecurity: 'AT_NoForceRowSecurity',
|
|
306
|
+
AT_NoForceRowSecurity: 'AT_ForceRowSecurity'
|
|
307
|
+
};
|
|
308
|
+
// Removed extras drop before added ones: a changed constraint that keeps
|
|
309
|
+
// its name must DROP CONSTRAINT before the replacement ADD CONSTRAINT.
|
|
226
310
|
for (const [print, extra] of from.extras) {
|
|
227
311
|
if (to.extras.has(print))
|
|
228
312
|
continue;
|
|
229
313
|
const conname = extra.Constraint?.conname
|
|
230
314
|
?? extra
|
|
231
315
|
.AlterTableCmd?.def?.Constraint?.conname;
|
|
316
|
+
const subtype = extra.AlterTableCmd?.subtype;
|
|
232
317
|
if (conname) {
|
|
233
318
|
stmts.push(alter({ subtype: 'AT_DropConstraint', name: conname }));
|
|
234
319
|
}
|
|
320
|
+
else if (subtype && INVERSE_SUBTYPE[subtype]) {
|
|
321
|
+
stmts.push(alter({ subtype: INVERSE_SUBTYPE[subtype] }));
|
|
322
|
+
}
|
|
235
323
|
else {
|
|
236
324
|
warnings.push(`${diff.path}: removed table element has no name to drop; emit a manual ALTER`);
|
|
237
325
|
}
|
|
238
326
|
}
|
|
327
|
+
for (const [print, extra] of to.extras) {
|
|
328
|
+
if (!from.extras.has(print)) {
|
|
329
|
+
const at = extra.AlterTableCmd;
|
|
330
|
+
if (at)
|
|
331
|
+
stmts.push(alter(at));
|
|
332
|
+
else if (extra.Constraint) {
|
|
333
|
+
stmts.push(alter({ subtype: 'AT_AddConstraint', def: extra }));
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
warnings.push(`${diff.path}: unrecognized added table element; emit a manual ALTER`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
239
340
|
return stmts;
|
|
240
341
|
}
|
|
241
342
|
/**
|
|
@@ -371,7 +472,11 @@ export function diffSchemas(from, to, options = {}) {
|
|
|
371
472
|
}
|
|
372
473
|
let forwardChanges = [];
|
|
373
474
|
if (forwardSql.length > 0) {
|
|
374
|
-
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
|
+
});
|
|
375
480
|
forwardChanges = changes.map(c => ({
|
|
376
481
|
...c,
|
|
377
482
|
revert: inverseOf(c.deploy, c.name),
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Deparser, parseSync } from 'plpgsql-parser';
|
|
2
|
+
import { defaultConstraintName } from './constraint-names';
|
|
3
|
+
/** The single AlterTableCmd of a one-command ALTER TABLE, or null. */
|
|
4
|
+
function singleCmd(stmt) {
|
|
5
|
+
const cmds = stmt.cmds ?? [];
|
|
6
|
+
if (cmds.length !== 1)
|
|
7
|
+
return null;
|
|
8
|
+
return cmds[0].AlterTableCmd ?? null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The column or constraint a one-command `ALTER TABLE .. ADD COLUMN` /
|
|
12
|
+
* `ADD CONSTRAINT` statement adds, as a naming-spec identity
|
|
13
|
+
* (`kind: 'column' | 'constraint'`, `table` set). Unnamed constraints get
|
|
14
|
+
* their Postgres default name. Returns `null` for anything else — multi-
|
|
15
|
+
* command ALTERs, other subtypes, non-ALTER statements — so callers fall
|
|
16
|
+
* back to object-level grouping.
|
|
17
|
+
*/
|
|
18
|
+
export function subObjectIdentityOf(facts) {
|
|
19
|
+
const stmt = facts.stmt?.AlterTableStmt;
|
|
20
|
+
if (!stmt?.relation?.relname)
|
|
21
|
+
return null;
|
|
22
|
+
const cmd = singleCmd(stmt);
|
|
23
|
+
if (!cmd)
|
|
24
|
+
return null;
|
|
25
|
+
const schema = stmt.relation.schemaname ?? null;
|
|
26
|
+
const table = stmt.relation.relname;
|
|
27
|
+
if (cmd.subtype === 'AT_AddColumn' && cmd.def?.ColumnDef?.colname) {
|
|
28
|
+
return { kind: 'column', schema, name: cmd.def.ColumnDef.colname, table };
|
|
29
|
+
}
|
|
30
|
+
if (cmd.subtype === 'AT_AddConstraint' && cmd.def?.Constraint) {
|
|
31
|
+
const constraint = cmd.def.Constraint;
|
|
32
|
+
const name = constraint.conname ?? defaultConstraintName(table, constraint);
|
|
33
|
+
if (!name)
|
|
34
|
+
return null;
|
|
35
|
+
return { kind: 'constraint', schema, name, table };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Rewrite a script so every unnamed `ALTER TABLE .. ADD <constraint>` carries
|
|
41
|
+
* its Postgres default name (`ADD PRIMARY KEY (id)` becomes `ADD CONSTRAINT
|
|
42
|
+
* {table}_pkey PRIMARY KEY (id)`) — the exact name the catalog would assign
|
|
43
|
+
* anyway. Naming them makes each constraint change independently revertible
|
|
44
|
+
* (`DROP CONSTRAINT <name>`) and verifiable. Statements that need no rewrite
|
|
45
|
+
* keep their original text.
|
|
46
|
+
*/
|
|
47
|
+
export function nameUnnamedConstraints(sql) {
|
|
48
|
+
const parsed = parseSync(sql);
|
|
49
|
+
const pieces = [];
|
|
50
|
+
for (const raw of parsed.sql.stmts) {
|
|
51
|
+
const node = raw.stmt;
|
|
52
|
+
const stmt = node.AlterTableStmt;
|
|
53
|
+
const table = stmt?.relation?.relname;
|
|
54
|
+
let rewritten = false;
|
|
55
|
+
if (stmt && table) {
|
|
56
|
+
for (const cmd of stmt.cmds ?? []) {
|
|
57
|
+
const at = cmd.AlterTableCmd;
|
|
58
|
+
const constraint = at?.subtype === 'AT_AddConstraint' ? at.def?.Constraint : undefined;
|
|
59
|
+
if (constraint && !constraint.conname) {
|
|
60
|
+
const name = defaultConstraintName(table, constraint);
|
|
61
|
+
if (name) {
|
|
62
|
+
constraint.conname = name;
|
|
63
|
+
rewritten = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (rewritten) {
|
|
69
|
+
pieces.push(`${Deparser.deparse(node, { pretty: true })};`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const start = raw.stmt_location ?? 0;
|
|
73
|
+
const len = raw.stmt_len;
|
|
74
|
+
const text = len === undefined ? sql.slice(start) : sql.slice(start, start + len);
|
|
75
|
+
const trimmed = text.trim();
|
|
76
|
+
pieces.push(trimmed.endsWith(';') ? trimmed : `${trimmed};`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return pieces.join('\n\n');
|
|
80
|
+
}
|