@@ -38,6 +38,7 @@ import {
3838} from '@pgsql/transform' ;
3939import { Deparser , parseSync } from 'plpgsql-parser' ;
4040
41+ import { ConstraintNode , defaultConstraintName } from './constraint-names' ;
4142import { 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 */
199224function 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