Skip to content

Commit 2b170ed

Browse files
authored
Merge pull request #1584 from constructive-io/feat/pgpm-constraint-folding
feat(transform): constraint-placement-invariant semantic diff (planning #1341)
2 parents 5c6fdbe + 73d4888 commit 2b170ed

5 files changed

Lines changed: 290 additions & 6 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { loadModule, parseSync } from 'plpgsql-parser';
2+
3+
import { ConstraintNode, defaultConstraintName, firstColumnRef } from '../src/constraint-names';
4+
5+
beforeAll(async () => {
6+
await loadModule();
7+
});
8+
9+
/** The Constraint nodes of a one-statement `ALTER TABLE` script. */
10+
const constraintOf = (sql: string): ConstraintNode => {
11+
const stmt = parseSync(sql).sql.stmts[0].stmt as {
12+
AlterTableStmt: { cmds: { AlterTableCmd: { def: { Constraint: ConstraintNode } } }[] };
13+
};
14+
return stmt.AlterTableStmt.cmds[0].AlterTableCmd.def.Constraint;
15+
};
16+
17+
describe('defaultConstraintName', () => {
18+
it('derives Postgres default names for unnamed table-level constraints', () => {
19+
expect(defaultConstraintName('users', constraintOf('ALTER TABLE users ADD PRIMARY KEY (id);')))
20+
.toBe('users_pkey');
21+
expect(defaultConstraintName('users', constraintOf('ALTER TABLE users ADD UNIQUE (email);')))
22+
.toBe('users_email_key');
23+
expect(defaultConstraintName('users', constraintOf('ALTER TABLE users ADD UNIQUE (a, b);')))
24+
.toBe('users_a_b_key');
25+
expect(defaultConstraintName('posts', constraintOf('ALTER TABLE posts ADD FOREIGN KEY (user_id) REFERENCES users (id);')))
26+
.toBe('posts_user_id_fkey');
27+
expect(defaultConstraintName('users', constraintOf('ALTER TABLE users ADD CHECK (age > 0);')))
28+
.toBe('users_age_check');
29+
});
30+
31+
it('uses the owning column for column-attached constraints', () => {
32+
expect(defaultConstraintName('users', { contype: 'CONSTR_UNIQUE' }, 'email')).toBe('users_email_key');
33+
expect(defaultConstraintName('posts', { contype: 'CONSTR_FOREIGN' }, 'user_id')).toBe('posts_user_id_fkey');
34+
expect(defaultConstraintName('users', { contype: 'CONSTR_CHECK' }, 'age')).toBe('users_age_check');
35+
});
36+
37+
it('returns null when no stable name derives', () => {
38+
expect(defaultConstraintName('users', { contype: 'CONSTR_UNIQUE' })).toBeNull();
39+
expect(defaultConstraintName('users', { contype: 'CONSTR_EXCLUSION' })).toBeNull();
40+
});
41+
});
42+
43+
describe('firstColumnRef', () => {
44+
it('finds the first referenced column in an expression tree', () => {
45+
const check = constraintOf('ALTER TABLE t ADD CHECK (price > 0 AND qty > 0);');
46+
expect(firstColumnRef(check.raw_expr)).toBe('price');
47+
});
48+
49+
it('returns null for column-free expressions', () => {
50+
const check = constraintOf('ALTER TABLE t ADD CHECK (1 = 1);');
51+
expect(firstColumnRef(check.raw_expr)).toBeNull();
52+
});
53+
});

pgpm/transform/__tests__/semantic-diff-driver.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,62 @@ describe('diffSchemas', () => {
138138
expect(result.warnings.some(w => w.includes('column "note" changed beyond its type'))).toBe(true);
139139
});
140140

141+
it('is constraint-placement-invariant: inline, table-elt, and standalone ADD CONSTRAINT unify', () => {
142+
const consolidated = [
143+
'CREATE SCHEMA app;',
144+
'CREATE TABLE app.users (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE, age int CHECK (age > 0));',
145+
'CREATE TABLE app.posts (id bigint PRIMARY KEY, user_id bigint NOT NULL REFERENCES app.users (id));'
146+
].join('\n');
147+
const standalone = [
148+
'CREATE SCHEMA app;',
149+
'CREATE TABLE app.users (id bigint GENERATED ALWAYS AS IDENTITY NOT NULL, email text NOT NULL, age int);',
150+
'ALTER TABLE app.users ADD CONSTRAINT users_pkey PRIMARY KEY (id);',
151+
'ALTER TABLE app.users ADD CONSTRAINT users_email_key UNIQUE (email);',
152+
'ALTER TABLE app.users ADD CONSTRAINT users_age_check CHECK (age > 0);',
153+
'CREATE TABLE app.posts (id bigint NOT NULL, user_id bigint NOT NULL);',
154+
'ALTER TABLE app.posts ADD CONSTRAINT posts_pkey PRIMARY KEY (id);',
155+
'ALTER TABLE app.posts ADD CONSTRAINT posts_user_id_fkey FOREIGN KEY (user_id) REFERENCES app.users (id);'
156+
].join('\n');
157+
const result = diffSchemas(consolidated, standalone);
158+
expect(result.identical).toBe(true);
159+
expect(result.changes).toEqual([]);
160+
});
161+
162+
it('matches unnamed standalone constraints against their Postgres default names', () => {
163+
const inline = 'CREATE TABLE app.users (id bigint PRIMARY KEY, email text UNIQUE);';
164+
const unnamed = [
165+
'CREATE TABLE app.users (id bigint NOT NULL, email text);',
166+
'ALTER TABLE app.users ADD PRIMARY KEY (id);',
167+
'ALTER TABLE app.users ADD UNIQUE (email);'
168+
].join('\n');
169+
expect(diffSchemas(inline, unnamed).identical).toBe(true);
170+
});
171+
172+
it('still surfaces a genuinely changed constraint, dropping it by its default name', () => {
173+
const from = 'CREATE TABLE app.users (id bigint PRIMARY KEY, age int CHECK (age > 0));';
174+
const to = 'CREATE TABLE app.users (id bigint PRIMARY KEY, age int CHECK (age > 1));';
175+
const result = diffSchemas(from, to);
176+
expect(result.identical).toBe(false);
177+
const change = result.changes.find(c => c.name === 'schemas/app/tables/users/table/alter')!;
178+
expect(change.deploy).toContain('DROP CONSTRAINT users_age_check');
179+
expect(change.deploy).toContain('ADD CONSTRAINT users_age_check CHECK (age > 1)');
180+
});
181+
182+
it('folds foreign keys into their table: fk placement never diffs', () => {
183+
const inline = [
184+
'CREATE TABLE app.users (id bigint PRIMARY KEY);',
185+
'CREATE TABLE app.posts (id bigint PRIMARY KEY, user_id bigint REFERENCES app.users (id));'
186+
].join('\n');
187+
const standalone = [
188+
'CREATE TABLE app.users (id bigint PRIMARY KEY);',
189+
'CREATE TABLE app.posts (id bigint PRIMARY KEY, user_id bigint);',
190+
'ALTER TABLE app.posts ADD CONSTRAINT posts_user_id_fkey FOREIGN KEY (user_id) REFERENCES app.users (id);'
191+
].join('\n');
192+
const result = diffSchemas(inline, standalone);
193+
expect(result.identical).toBe(true);
194+
expect(result.objects).toEqual([]);
195+
});
196+
141197
it('drops removed objects in reverse topological order', () => {
142198
const result = diffSchemas(BASE, 'CREATE SCHEMA app;\nCREATE TABLE app.users (id uuid PRIMARY KEY, name text);');
143199

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
13+
/** A parsed `Constraint` node (structural — the fields this module reads). */
14+
export interface ConstraintNode {
15+
contype?: string;
16+
conname?: string;
17+
keys?: { String?: { sval?: string } }[];
18+
fk_attrs?: { String?: { sval?: string } }[];
19+
raw_expr?: unknown;
20+
}
21+
22+
const svals = (items?: { String?: { sval?: string } }[]): string[] =>
23+
(items ?? []).map(k => k.String?.sval).filter((s): s is string => typeof s === 'string');
24+
25+
/** First `ColumnRef` column name reached in an expression tree, DFS order. */
26+
export function firstColumnRef(node: unknown): string | null {
27+
if (Array.isArray(node)) {
28+
for (const item of node) {
29+
const found = firstColumnRef(item);
30+
if (found) return found;
31+
}
32+
return null;
33+
}
34+
if (node && typeof node === 'object') {
35+
const record = node as Record<string, unknown>;
36+
const ref = record.ColumnRef as { fields?: unknown[] } | undefined;
37+
if (ref?.fields) {
38+
const names = svals(ref.fields as { String?: { sval?: string } }[]);
39+
if (names.length > 0) return names[names.length - 1];
40+
}
41+
for (const value of Object.values(record)) {
42+
const found = firstColumnRef(value);
43+
if (found) return found;
44+
}
45+
}
46+
return null;
47+
}
48+
49+
/**
50+
* The name Postgres would assign to an unnamed constraint on `table`, given
51+
* the constraint node and (for column-attached constraints) the owning
52+
* column. Returns `null` for kinds without a stable derivation (exclusion
53+
* constraints and anything unrecognized).
54+
*/
55+
export function defaultConstraintName(
56+
table: string,
57+
constraint: ConstraintNode,
58+
column?: string
59+
): string | null {
60+
const cols = (): string[] => {
61+
const keyed = svals(constraint.keys);
62+
if (keyed.length > 0) return keyed;
63+
return column ? [column] : [];
64+
};
65+
switch (constraint.contype) {
66+
case 'CONSTR_PRIMARY':
67+
return `${table}_pkey`;
68+
case 'CONSTR_UNIQUE': {
69+
const names = cols();
70+
return names.length > 0 ? `${table}_${names.join('_')}_key` : null;
71+
}
72+
case 'CONSTR_FOREIGN': {
73+
const attrs = svals(constraint.fk_attrs);
74+
const names = attrs.length > 0 ? attrs : column ? [column] : [];
75+
return names.length > 0 ? `${table}_${names.join('_')}_fkey` : null;
76+
}
77+
case 'CONSTR_CHECK': {
78+
const col = column ?? firstColumnRef(constraint.raw_expr);
79+
return col ? `${table}_${col}_check` : `${table}_check`;
80+
}
81+
default:
82+
return null;
83+
}
84+
}

pgpm/transform/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export {
2323
categorizeChange,
2424
TIER_PROFILE,
2525
} from './categorize';
26+
export type { ConstraintNode } from './constraint-names';
27+
export { defaultConstraintName, firstColumnRef } from './constraint-names';
2628
export type {
2729
ClosureChange,
2830
ClosureInputChange,

pgpm/transform/src/semantic-diff-driver.ts

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
} from '@pgsql/transform';
3939
import { Deparser, parseSync } from 'plpgsql-parser';
4040

41+
import { ConstraintNode, defaultConstraintName } from './constraint-names';
4142
import { GranularityChange, restructureChanges } from './granularity-driver';
4243

4344
/** How one object differs between the two sides. */
@@ -165,7 +166,7 @@ function groupingIdentity(f: StatementFacts): ObjectIdentity | null {
165166
const identity = identityOf(f);
166167
if (!identity) return null;
167168
if (
168-
(f.kind === 'table' || f.kind === 'constraint' || f.kind === 'rls_enable') &&
169+
(f.kind === 'table' || f.kind === 'constraint' || f.kind === 'fk_constraint' || f.kind === 'rls_enable') &&
169170
identity.kind !== 'table'
170171
) {
171172
return { kind: 'table', schema: identity.schema, name: identity.table ?? identity.name };
@@ -191,22 +192,62 @@ interface TableShape {
191192
extras: Map<string, unknown>;
192193
}
193194

195+
/** JSON with recursively sorted object keys — an insertion-order-proof fingerprint. */
196+
function stablePrint(node: unknown): string {
197+
if (Array.isArray(node)) return `[${node.map(stablePrint).join(',')}]`;
198+
if (node && typeof node === 'object') {
199+
const entries = Object.entries(node as Record<string, unknown>)
200+
.filter(([, v]) => v !== undefined)
201+
.sort(([a], [b]) => a.localeCompare(b))
202+
.map(([k, v]) => `${JSON.stringify(k)}:${stablePrint(v)}`);
203+
return `{${entries.join(',')}}`;
204+
}
205+
return JSON.stringify(node);
206+
}
207+
208+
/** Constraint kinds that can move between column, table-elt, and ALTER form. */
209+
const RELOCATABLE = new Set(['CONSTR_PRIMARY', 'CONSTR_UNIQUE', 'CONSTR_FOREIGN', 'CONSTR_CHECK']);
210+
194211
/**
195212
* Fold a table unit's statements (CREATE TABLE plus any ALTER TABLE
196213
* ADD COLUMN / ADD CONSTRAINT) into an effective shape, so atomic and
197214
* consolidated authorships of the same table compare equal.
215+
*
216+
* Constraint placement is representation, not semantics: `id bigint PRIMARY
217+
* KEY`, a `PRIMARY KEY (id)` table elt, and `ALTER TABLE .. ADD CONSTRAINT
218+
* t_pkey PRIMARY KEY (id)` all catalog identically. Relocatable constraints
219+
* (PK/UNIQUE/FK/CHECK) are therefore lifted out of columns and ALTER
220+
* commands into one canonical table-level set — keyed with the Postgres
221+
* default name when unnamed, columns filled in from the owning column when
222+
* column-attached — and PK columns gain the NOT NULL the catalog implies.
198223
*/
199224
function tableShape(unit: ObjectUnit): TableShape {
200225
const shape: TableShape = { relation: null, columns: new Map(), extras: new Map() };
226+
const table = unit.identity.name;
227+
const rawColumns: { colname: string; def: Record<string, unknown> }[] = [];
228+
const constraints: { node: ConstraintNode & Record<string, unknown>; column?: string }[] = [];
229+
const pkColumns = new Set<string>();
201230

202231
const addColumn = (node: Record<string, unknown>): void => {
203-
const def = node as { colname?: string };
204-
const clean = cleanTree(node);
205-
shape.columns.set(def.colname ?? '', { def: clean, print: JSON.stringify(clean) });
232+
const colname = (node as { colname?: string }).colname ?? '';
233+
const attached = (node.constraints as { Constraint?: Record<string, unknown> }[] | undefined) ?? [];
234+
const residual: unknown[] = [];
235+
for (const item of attached) {
236+
const c = item.Constraint as (ConstraintNode & Record<string, unknown>) | undefined;
237+
if (c && RELOCATABLE.has(c.contype ?? '')) {
238+
constraints.push({ node: c, column: colname });
239+
} else {
240+
residual.push(item);
241+
}
242+
}
243+
rawColumns.push({ colname, def: { ...node, constraints: residual } });
244+
};
245+
const addConstraint = (node: Record<string, unknown>, column?: string): void => {
246+
constraints.push({ node: node as ConstraintNode & Record<string, unknown>, column });
206247
};
207248
const addExtra = (node: unknown): void => {
208249
const clean = cleanTree(node);
209-
shape.extras.set(JSON.stringify(clean), clean);
250+
shape.extras.set(stablePrint(clean), clean);
210251
};
211252

212253
for (const text of unit.texts) {
@@ -217,7 +258,9 @@ function tableShape(unit: ObjectUnit): TableShape {
217258
const elts = (stmt.CreateStmt.tableElts as Record<string, unknown>[] | undefined) ?? [];
218259
for (const elt of elts) {
219260
if (elt.ColumnDef) addColumn(elt.ColumnDef as Record<string, unknown>);
220-
else addExtra(elt);
261+
else if (elt.Constraint && RELOCATABLE.has((elt.Constraint as ConstraintNode).contype ?? '')) {
262+
addConstraint(elt.Constraint as Record<string, unknown>);
263+
} else addExtra(elt);
221264
}
222265
} else if (stmt.AlterTableStmt) {
223266
shape.relation ??= stmt.AlterTableStmt.relation;
@@ -228,6 +271,12 @@ function tableShape(unit: ObjectUnit): TableShape {
228271
const def = at.def as Record<string, unknown> | undefined;
229272
if (at.subtype === 'AT_AddColumn' && def?.ColumnDef) {
230273
addColumn(def.ColumnDef as Record<string, unknown>);
274+
} else if (
275+
at.subtype === 'AT_AddConstraint' &&
276+
def?.Constraint &&
277+
RELOCATABLE.has((def.Constraint as ConstraintNode).contype ?? '')
278+
) {
279+
addConstraint(def.Constraint as Record<string, unknown>);
231280
} else {
232281
addExtra({ AlterTableCmd: at });
233282
}
@@ -237,6 +286,46 @@ function tableShape(unit: ObjectUnit): TableShape {
237286
}
238287
}
239288
}
289+
290+
for (const { node, column } of constraints) {
291+
const canonical: Record<string, unknown> = { ...node };
292+
canonical.conname = node.conname ?? defaultConstraintName(table, node, column) ?? undefined;
293+
if (column && (node.keys ?? []).length === 0 && node.contype !== 'CONSTR_FOREIGN' && node.contype !== 'CONSTR_CHECK') {
294+
canonical.keys = [{ String: { sval: column } }];
295+
}
296+
if (column && node.contype === 'CONSTR_FOREIGN' && (node.fk_attrs ?? []).length === 0) {
297+
canonical.fk_attrs = [{ String: { sval: column } }];
298+
}
299+
if (node.contype === 'CONSTR_PRIMARY') {
300+
const keyed = (canonical.keys as { String?: { sval?: string } }[] | undefined) ?? [];
301+
for (const k of keyed) if (k.String?.sval) pkColumns.add(k.String.sval);
302+
}
303+
addExtra({ Constraint: canonical });
304+
}
305+
306+
for (const { colname, def } of rawColumns) {
307+
const residual = ((def.constraints as unknown[] | undefined) ?? []).map(c => {
308+
const constraint = (c as { Constraint?: ConstraintNode & { conname?: string } }).Constraint;
309+
// NOT NULL parses with parser-version-dependent extras (is_enforced,
310+
// initially_valid); reduce to its semantic core so an authored NOT NULL
311+
// equals the one a primary key implies.
312+
if (constraint?.contype === 'CONSTR_NOTNULL') {
313+
return { Constraint: { contype: 'CONSTR_NOTNULL', conname: constraint.conname } };
314+
}
315+
return c;
316+
});
317+
const hasNotNull = residual.some(
318+
c => (c as { Constraint?: ConstraintNode }).Constraint?.contype === 'CONSTR_NOTNULL'
319+
);
320+
if (pkColumns.has(colname) && !hasNotNull) {
321+
residual.push({ Constraint: { contype: 'CONSTR_NOTNULL' } });
322+
}
323+
const sorted = residual
324+
.map(c => cleanTree(c))
325+
.sort((x, y) => stablePrint(x).localeCompare(stablePrint(y)));
326+
const clean = cleanTree({ ...def, constraints: sorted });
327+
shape.columns.set(colname, { def: clean, print: stablePrint(clean) });
328+
}
240329
return shape;
241330
}
242331

0 commit comments

Comments
 (0)