@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/bundle-driver.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* purpose — no dependency on `@pgpmjs/bundle`/`@pgpmjs/core` — so the driver
|
|
8
8
|
* stays a pure function factory over `transformSql` and `classifyStatements`.
|
|
9
9
|
*/
|
|
10
|
-
import { ExtensionDefinition,
|
|
10
|
+
import { ExtensionDefinition, ExtensionRouter, ExtensionRouteSpec, ExtensionTransformResult, RoleRouteSpec, RoleTransformResult, SchemaRouter, SchemaTransformResult, TransformSqlOptions } from '@pgsql/transform';
|
|
11
11
|
/** Identity of the script being transformed/validated (matches the bundle seams). */
|
|
12
12
|
export interface BundleScriptContext {
|
|
13
13
|
change: string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural catalog snapshot + equivalence check, used by
|
|
3
|
+
* `pgpm transform --check` to prove a transform is lossless: deploy the
|
|
4
|
+
* original and the transformed module(s) into two scratch databases and
|
|
5
|
+
* assert the resulting catalogs describe the same schema.
|
|
6
|
+
*
|
|
7
|
+
* The snapshot is intentionally order-insensitive per object class (objects
|
|
8
|
+
* are keyed by identity and compared by definition), but column order within
|
|
9
|
+
* a table IS part of the comparison — the dials pipeline preserves it.
|
|
10
|
+
*/
|
|
11
|
+
/** Minimal query surface: satisfied by pg Pool/PoolClient and test clients. */
|
|
12
|
+
export interface CatalogQueryable {
|
|
13
|
+
query(text: string): Promise<{
|
|
14
|
+
rows: any[];
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
/** Catalog snapshot: object identity -> normalized definition. */
|
|
18
|
+
export interface CatalogSnapshot {
|
|
19
|
+
schemas: Record<string, string>;
|
|
20
|
+
tables: Record<string, string>;
|
|
21
|
+
columns: Record<string, string>;
|
|
22
|
+
constraints: Record<string, string>;
|
|
23
|
+
indexes: Record<string, string>;
|
|
24
|
+
functions: Record<string, string>;
|
|
25
|
+
triggers: Record<string, string>;
|
|
26
|
+
policies: Record<string, string>;
|
|
27
|
+
rls: Record<string, string>;
|
|
28
|
+
grants: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Snapshot the user-schema surface of a database: schemas, tables, columns,
|
|
32
|
+
* constraints, indexes, functions, triggers, policies, RLS flags, and table
|
|
33
|
+
* grants. System schemas, `public`, and the pgpm ledger are excluded.
|
|
34
|
+
*/
|
|
35
|
+
export declare const snapshotCatalog: (db: CatalogQueryable) => Promise<CatalogSnapshot>;
|
|
36
|
+
/**
|
|
37
|
+
* Column-order-insensitive view of a snapshot. Migrations that drop and add
|
|
38
|
+
* columns can never reproduce a fresh deploy's physical column order
|
|
39
|
+
* (Postgres appends), so migration-equivalence checks (`pgpm diff --verify`)
|
|
40
|
+
* compare columns by name and definition only.
|
|
41
|
+
*/
|
|
42
|
+
export declare const withoutColumnOrder: (snap: CatalogSnapshot) => CatalogSnapshot;
|
|
43
|
+
/**
|
|
44
|
+
* Compare two catalog snapshots. Returns a flat list of human-readable
|
|
45
|
+
* differences; an empty list means the catalogs are structurally equivalent.
|
|
46
|
+
*/
|
|
47
|
+
export declare const diffCatalogSnapshots: (a: CatalogSnapshot, b: CatalogSnapshot) => string[];
|
package/catalog-check.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.diffCatalogSnapshots = exports.withoutColumnOrder = exports.snapshotCatalog = void 0;
|
|
4
|
+
const SYSTEM_SCHEMAS = `('pg_catalog', 'information_schema', 'pg_toast', 'pgpm_migrate', 'public')`;
|
|
5
|
+
const record = (rows, key, value) => {
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const row of rows)
|
|
8
|
+
out[key(row)] = value(row);
|
|
9
|
+
return out;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Snapshot the user-schema surface of a database: schemas, tables, columns,
|
|
13
|
+
* constraints, indexes, functions, triggers, policies, RLS flags, and table
|
|
14
|
+
* grants. System schemas, `public`, and the pgpm ledger are excluded.
|
|
15
|
+
*/
|
|
16
|
+
const snapshotCatalog = async (db) => {
|
|
17
|
+
const schemas = await db.query(`
|
|
18
|
+
SELECT nspname FROM pg_namespace
|
|
19
|
+
WHERE nspname NOT IN ${SYSTEM_SCHEMAS} AND nspname NOT LIKE 'pg_temp%' AND nspname NOT LIKE 'pg_toast%'
|
|
20
|
+
ORDER BY nspname
|
|
21
|
+
`);
|
|
22
|
+
const tables = await db.query(`
|
|
23
|
+
SELECT table_schema, table_name FROM information_schema.tables
|
|
24
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS} AND table_type = 'BASE TABLE'
|
|
25
|
+
`);
|
|
26
|
+
const columns = await db.query(`
|
|
27
|
+
SELECT table_schema, table_name, column_name, ordinal_position,
|
|
28
|
+
data_type, is_nullable, column_default, character_maximum_length
|
|
29
|
+
FROM information_schema.columns
|
|
30
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS}
|
|
31
|
+
`);
|
|
32
|
+
const constraints = await db.query(`
|
|
33
|
+
SELECT n.nspname AS schema, cl.relname AS table, c.conname,
|
|
34
|
+
pg_get_constraintdef(c.oid) AS def
|
|
35
|
+
FROM pg_constraint c
|
|
36
|
+
JOIN pg_class cl ON cl.oid = c.conrelid
|
|
37
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
38
|
+
WHERE n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
39
|
+
`);
|
|
40
|
+
const indexes = await db.query(`
|
|
41
|
+
SELECT schemaname, tablename, indexname, indexdef
|
|
42
|
+
FROM pg_indexes WHERE schemaname NOT IN ${SYSTEM_SCHEMAS}
|
|
43
|
+
`);
|
|
44
|
+
const functions = await db.query(`
|
|
45
|
+
SELECT n.nspname AS schema, p.proname,
|
|
46
|
+
pg_get_function_identity_arguments(p.oid) AS args,
|
|
47
|
+
pg_get_functiondef(p.oid) AS def
|
|
48
|
+
FROM pg_proc p
|
|
49
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
50
|
+
WHERE n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
51
|
+
`);
|
|
52
|
+
const triggers = await db.query(`
|
|
53
|
+
SELECT n.nspname AS schema, cl.relname AS table, t.tgname,
|
|
54
|
+
pg_get_triggerdef(t.oid) AS def
|
|
55
|
+
FROM pg_trigger t
|
|
56
|
+
JOIN pg_class cl ON cl.oid = t.tgrelid
|
|
57
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
58
|
+
WHERE NOT t.tgisinternal AND n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
59
|
+
`);
|
|
60
|
+
const policies = await db.query(`
|
|
61
|
+
SELECT schemaname, tablename, policyname, permissive, roles, cmd,
|
|
62
|
+
qual, with_check
|
|
63
|
+
FROM pg_policies WHERE schemaname NOT IN ${SYSTEM_SCHEMAS}
|
|
64
|
+
`);
|
|
65
|
+
const rls = await db.query(`
|
|
66
|
+
SELECT n.nspname AS schema, cl.relname AS table,
|
|
67
|
+
cl.relrowsecurity, cl.relforcerowsecurity
|
|
68
|
+
FROM pg_class cl
|
|
69
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
70
|
+
WHERE cl.relkind = 'r' AND n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
71
|
+
`);
|
|
72
|
+
const grants = await db.query(`
|
|
73
|
+
SELECT table_schema, table_name, grantee, privilege_type
|
|
74
|
+
FROM information_schema.role_table_grants
|
|
75
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS}
|
|
76
|
+
AND grantee <> (SELECT current_user)
|
|
77
|
+
`);
|
|
78
|
+
return {
|
|
79
|
+
schemas: record(schemas.rows, r => r.nspname, () => 'schema'),
|
|
80
|
+
tables: record(tables.rows, r => `${r.table_schema}.${r.table_name}`, () => 'table'),
|
|
81
|
+
columns: record(columns.rows, r => `${r.table_schema}.${r.table_name}.${r.column_name}`, r => `#${r.ordinal_position} ${r.data_type}${r.character_maximum_length ? `(${r.character_maximum_length})` : ''} nullable=${r.is_nullable} default=${r.column_default ?? 'none'}`),
|
|
82
|
+
constraints: record(constraints.rows, r => `${r.schema}.${r.table}.${r.conname}`, r => r.def),
|
|
83
|
+
indexes: record(indexes.rows, r => `${r.schemaname}.${r.tablename}.${r.indexname}`, r => r.indexdef),
|
|
84
|
+
functions: record(functions.rows, r => `${r.schema}.${r.proname}(${r.args})`, r => r.def),
|
|
85
|
+
triggers: record(triggers.rows, r => `${r.schema}.${r.table}.${r.tgname}`, r => r.def),
|
|
86
|
+
policies: record(policies.rows, r => `${r.schemaname}.${r.tablename}.${r.policyname}`, r => `permissive=${r.permissive} roles=${[...(r.roles ?? [])].sort().join(',')} cmd=${r.cmd} qual=${r.qual ?? 'none'} check=${r.with_check ?? 'none'}`),
|
|
87
|
+
rls: record(rls.rows, r => `${r.schema}.${r.table}`, r => `enabled=${r.relrowsecurity} forced=${r.relforcerowsecurity}`),
|
|
88
|
+
grants: record(grants.rows, r => `${r.table_schema}.${r.table_name}:${r.grantee}:${r.privilege_type}`, () => 'grant')
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
exports.snapshotCatalog = snapshotCatalog;
|
|
92
|
+
/**
|
|
93
|
+
* Column-order-insensitive view of a snapshot. Migrations that drop and add
|
|
94
|
+
* columns can never reproduce a fresh deploy's physical column order
|
|
95
|
+
* (Postgres appends), so migration-equivalence checks (`pgpm diff --verify`)
|
|
96
|
+
* compare columns by name and definition only.
|
|
97
|
+
*/
|
|
98
|
+
const withoutColumnOrder = (snap) => {
|
|
99
|
+
const columns = {};
|
|
100
|
+
for (const [key, value] of Object.entries(snap.columns)) {
|
|
101
|
+
columns[key] = value.replace(/^#\d+ /, '');
|
|
102
|
+
}
|
|
103
|
+
return { ...snap, columns };
|
|
104
|
+
};
|
|
105
|
+
exports.withoutColumnOrder = withoutColumnOrder;
|
|
106
|
+
/**
|
|
107
|
+
* Compare two catalog snapshots. Returns a flat list of human-readable
|
|
108
|
+
* differences; an empty list means the catalogs are structurally equivalent.
|
|
109
|
+
*/
|
|
110
|
+
const diffCatalogSnapshots = (a, b) => {
|
|
111
|
+
const diffs = [];
|
|
112
|
+
for (const section of Object.keys(a)) {
|
|
113
|
+
const left = a[section];
|
|
114
|
+
const right = b[section];
|
|
115
|
+
for (const key of Object.keys(left).sort()) {
|
|
116
|
+
if (!(key in right)) {
|
|
117
|
+
diffs.push(`${section}: ${key} only in original`);
|
|
118
|
+
}
|
|
119
|
+
else if (left[key] !== right[key]) {
|
|
120
|
+
diffs.push(`${section}: ${key} differs\n original: ${left[key]}\n transformed: ${right[key]}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const key of Object.keys(right).sort()) {
|
|
124
|
+
if (!(key in left))
|
|
125
|
+
diffs.push(`${section}: ${key} only in transformed`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return diffs;
|
|
129
|
+
};
|
|
130
|
+
exports.diffCatalogSnapshots = diffCatalogSnapshots;
|
|
@@ -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,124 @@
|
|
|
1
|
+
const SYSTEM_SCHEMAS = `('pg_catalog', 'information_schema', 'pg_toast', 'pgpm_migrate', 'public')`;
|
|
2
|
+
const record = (rows, key, value) => {
|
|
3
|
+
const out = {};
|
|
4
|
+
for (const row of rows)
|
|
5
|
+
out[key(row)] = value(row);
|
|
6
|
+
return out;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Snapshot the user-schema surface of a database: schemas, tables, columns,
|
|
10
|
+
* constraints, indexes, functions, triggers, policies, RLS flags, and table
|
|
11
|
+
* grants. System schemas, `public`, and the pgpm ledger are excluded.
|
|
12
|
+
*/
|
|
13
|
+
export const snapshotCatalog = async (db) => {
|
|
14
|
+
const schemas = await db.query(`
|
|
15
|
+
SELECT nspname FROM pg_namespace
|
|
16
|
+
WHERE nspname NOT IN ${SYSTEM_SCHEMAS} AND nspname NOT LIKE 'pg_temp%' AND nspname NOT LIKE 'pg_toast%'
|
|
17
|
+
ORDER BY nspname
|
|
18
|
+
`);
|
|
19
|
+
const tables = await db.query(`
|
|
20
|
+
SELECT table_schema, table_name FROM information_schema.tables
|
|
21
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS} AND table_type = 'BASE TABLE'
|
|
22
|
+
`);
|
|
23
|
+
const columns = await db.query(`
|
|
24
|
+
SELECT table_schema, table_name, column_name, ordinal_position,
|
|
25
|
+
data_type, is_nullable, column_default, character_maximum_length
|
|
26
|
+
FROM information_schema.columns
|
|
27
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS}
|
|
28
|
+
`);
|
|
29
|
+
const constraints = await db.query(`
|
|
30
|
+
SELECT n.nspname AS schema, cl.relname AS table, c.conname,
|
|
31
|
+
pg_get_constraintdef(c.oid) AS def
|
|
32
|
+
FROM pg_constraint c
|
|
33
|
+
JOIN pg_class cl ON cl.oid = c.conrelid
|
|
34
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
35
|
+
WHERE n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
36
|
+
`);
|
|
37
|
+
const indexes = await db.query(`
|
|
38
|
+
SELECT schemaname, tablename, indexname, indexdef
|
|
39
|
+
FROM pg_indexes WHERE schemaname NOT IN ${SYSTEM_SCHEMAS}
|
|
40
|
+
`);
|
|
41
|
+
const functions = await db.query(`
|
|
42
|
+
SELECT n.nspname AS schema, p.proname,
|
|
43
|
+
pg_get_function_identity_arguments(p.oid) AS args,
|
|
44
|
+
pg_get_functiondef(p.oid) AS def
|
|
45
|
+
FROM pg_proc p
|
|
46
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
47
|
+
WHERE n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
48
|
+
`);
|
|
49
|
+
const triggers = await db.query(`
|
|
50
|
+
SELECT n.nspname AS schema, cl.relname AS table, t.tgname,
|
|
51
|
+
pg_get_triggerdef(t.oid) AS def
|
|
52
|
+
FROM pg_trigger t
|
|
53
|
+
JOIN pg_class cl ON cl.oid = t.tgrelid
|
|
54
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
55
|
+
WHERE NOT t.tgisinternal AND n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
56
|
+
`);
|
|
57
|
+
const policies = await db.query(`
|
|
58
|
+
SELECT schemaname, tablename, policyname, permissive, roles, cmd,
|
|
59
|
+
qual, with_check
|
|
60
|
+
FROM pg_policies WHERE schemaname NOT IN ${SYSTEM_SCHEMAS}
|
|
61
|
+
`);
|
|
62
|
+
const rls = await db.query(`
|
|
63
|
+
SELECT n.nspname AS schema, cl.relname AS table,
|
|
64
|
+
cl.relrowsecurity, cl.relforcerowsecurity
|
|
65
|
+
FROM pg_class cl
|
|
66
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
67
|
+
WHERE cl.relkind = 'r' AND n.nspname NOT IN ${SYSTEM_SCHEMAS}
|
|
68
|
+
`);
|
|
69
|
+
const grants = await db.query(`
|
|
70
|
+
SELECT table_schema, table_name, grantee, privilege_type
|
|
71
|
+
FROM information_schema.role_table_grants
|
|
72
|
+
WHERE table_schema NOT IN ${SYSTEM_SCHEMAS}
|
|
73
|
+
AND grantee <> (SELECT current_user)
|
|
74
|
+
`);
|
|
75
|
+
return {
|
|
76
|
+
schemas: record(schemas.rows, r => r.nspname, () => 'schema'),
|
|
77
|
+
tables: record(tables.rows, r => `${r.table_schema}.${r.table_name}`, () => 'table'),
|
|
78
|
+
columns: record(columns.rows, r => `${r.table_schema}.${r.table_name}.${r.column_name}`, r => `#${r.ordinal_position} ${r.data_type}${r.character_maximum_length ? `(${r.character_maximum_length})` : ''} nullable=${r.is_nullable} default=${r.column_default ?? 'none'}`),
|
|
79
|
+
constraints: record(constraints.rows, r => `${r.schema}.${r.table}.${r.conname}`, r => r.def),
|
|
80
|
+
indexes: record(indexes.rows, r => `${r.schemaname}.${r.tablename}.${r.indexname}`, r => r.indexdef),
|
|
81
|
+
functions: record(functions.rows, r => `${r.schema}.${r.proname}(${r.args})`, r => r.def),
|
|
82
|
+
triggers: record(triggers.rows, r => `${r.schema}.${r.table}.${r.tgname}`, r => r.def),
|
|
83
|
+
policies: record(policies.rows, r => `${r.schemaname}.${r.tablename}.${r.policyname}`, r => `permissive=${r.permissive} roles=${[...(r.roles ?? [])].sort().join(',')} cmd=${r.cmd} qual=${r.qual ?? 'none'} check=${r.with_check ?? 'none'}`),
|
|
84
|
+
rls: record(rls.rows, r => `${r.schema}.${r.table}`, r => `enabled=${r.relrowsecurity} forced=${r.relforcerowsecurity}`),
|
|
85
|
+
grants: record(grants.rows, r => `${r.table_schema}.${r.table_name}:${r.grantee}:${r.privilege_type}`, () => 'grant')
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Column-order-insensitive view of a snapshot. Migrations that drop and add
|
|
90
|
+
* columns can never reproduce a fresh deploy's physical column order
|
|
91
|
+
* (Postgres appends), so migration-equivalence checks (`pgpm diff --verify`)
|
|
92
|
+
* compare columns by name and definition only.
|
|
93
|
+
*/
|
|
94
|
+
export const withoutColumnOrder = (snap) => {
|
|
95
|
+
const columns = {};
|
|
96
|
+
for (const [key, value] of Object.entries(snap.columns)) {
|
|
97
|
+
columns[key] = value.replace(/^#\d+ /, '');
|
|
98
|
+
}
|
|
99
|
+
return { ...snap, columns };
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Compare two catalog snapshots. Returns a flat list of human-readable
|
|
103
|
+
* differences; an empty list means the catalogs are structurally equivalent.
|
|
104
|
+
*/
|
|
105
|
+
export const diffCatalogSnapshots = (a, b) => {
|
|
106
|
+
const diffs = [];
|
|
107
|
+
for (const section of Object.keys(a)) {
|
|
108
|
+
const left = a[section];
|
|
109
|
+
const right = b[section];
|
|
110
|
+
for (const key of Object.keys(left).sort()) {
|
|
111
|
+
if (!(key in right)) {
|
|
112
|
+
diffs.push(`${section}: ${key} only in original`);
|
|
113
|
+
}
|
|
114
|
+
else if (left[key] !== right[key]) {
|
|
115
|
+
diffs.push(`${section}: ${key} differs\n original: ${left[key]}\n transformed: ${right[key]}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const key of Object.keys(right).sort()) {
|
|
119
|
+
if (!(key in left))
|
|
120
|
+
diffs.push(`${section}: ${key} only in transformed`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return diffs;
|
|
124
|
+
};
|
|
@@ -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
|
+
}
|
package/esm/fixture-closure.js
CHANGED
|
@@ -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);
|