@@ -95,7 +95,7 @@ export function syncEnums({
9595export type Relation = {
9696 schema : string ;
9797 table : string ;
98- column : string ;
98+ columns : string [ ] ;
9999 type : 'one' | 'many' ;
100100 fk_name : string ;
101101 foreign_key_on_update : Cascade ;
@@ -104,7 +104,7 @@ export type Relation = {
104104 references : {
105105 schema : string | null ;
106106 table : string | null ;
107- column : string | null ;
107+ columns : ( string | null ) [ ] ;
108108 type : 'one' | 'many' ;
109109 } ;
110110} ;
@@ -145,28 +145,42 @@ export function syncTable({
145145 builder . setDecl ( tableMapAttribute ) . addArg ( ( argBuilder ) => argBuilder . StringLiteral . setValue ( table . name ) ) ,
146146 ) ;
147147 }
148+ // Group FK columns by constraint name to handle composite foreign keys.
149+ // Each FK constraint (identified by fk_name) may span multiple columns.
150+ const fkGroups = new Map < string , typeof table . columns > ( ) ;
148151 table . columns . forEach ( ( column ) => {
149- if ( column . foreign_key_table ) {
150- // Check if this FK column is the table's single-column primary key
151- // If so, it should be treated as a one-to-one relation
152- const isSingleColumnPk = ! multiPk && column . pk ;
153- relations . push ( {
154- schema : table . schema ,
155- table : table . name ,
156- column : column . name ,
157- type : 'one' ,
158- fk_name : column . foreign_key_name ! ,
159- foreign_key_on_delete : column . foreign_key_on_delete ,
160- foreign_key_on_update : column . foreign_key_on_update ,
161- nullable : column . nullable ,
162- references : {
163- schema : column . foreign_key_schema ,
164- table : column . foreign_key_table ,
165- column : column . foreign_key_column ,
166- type : column . unique || isSingleColumnPk ? 'one' : 'many' ,
167- } ,
168- } ) ;
152+ if ( column . foreign_key_table && column . foreign_key_name ) {
153+ const group = fkGroups . get ( column . foreign_key_name ) ?? [ ] ;
154+ group . push ( column ) ;
155+ fkGroups . set ( column . foreign_key_name , group ) ;
169156 }
157+ } ) ;
158+
159+ for ( const [ fkName , fkColumns ] of fkGroups ) {
160+ const firstCol = fkColumns [ 0 ] ! ;
161+ // For single-column FKs, check if the column is the table's single-column PK (one-to-one)
162+ const isSingleColumnPk = fkColumns . length === 1 && ! multiPk && firstCol . pk ;
163+ // A single-column FK with unique constraint means one-to-one on the opposite side
164+ const isUniqueRelation = ( fkColumns . length === 1 && firstCol . unique ) || isSingleColumnPk ;
165+ relations . push ( {
166+ schema : table . schema ,
167+ table : table . name ,
168+ columns : fkColumns . map ( ( c ) => c . name ) ,
169+ type : 'one' ,
170+ fk_name : fkName ,
171+ foreign_key_on_delete : firstCol . foreign_key_on_delete ,
172+ foreign_key_on_update : firstCol . foreign_key_on_update ,
173+ nullable : firstCol . nullable ,
174+ references : {
175+ schema : firstCol . foreign_key_schema ,
176+ table : firstCol . foreign_key_table ,
177+ columns : fkColumns . map ( ( c ) => c . foreign_key_column ) ,
178+ type : isUniqueRelation ? 'one' : 'many' ,
179+ } ,
180+ } ) ;
181+ }
182+
183+ table . columns . forEach ( ( column ) => {
170184
171185 const { name, modified } = resolveNameCasing ( options . fieldCasing , column . name ) ;
172186
@@ -397,25 +411,39 @@ export function syncRelation({
397411 | undefined ;
398412 if ( ! sourceModel ) return ;
399413
400- const sourceFieldId = sourceModel . fields . findIndex ( ( f ) => getDbName ( f ) === relation . column ) ;
401- const sourceField = sourceModel . fields [ sourceFieldId ] as DataField | undefined ;
402- if ( ! sourceField ) return ;
414+ // Resolve all source and target fields for the relation (supports composite FKs)
415+ const sourceFields : { field : DataField ; index : number } [ ] = [ ] ;
416+ for ( const colName of relation . columns ) {
417+ const idx = sourceModel . fields . findIndex ( ( f ) => getDbName ( f ) === colName ) ;
418+ const field = sourceModel . fields [ idx ] as DataField | undefined ;
419+ if ( ! field ) return ;
420+ sourceFields . push ( { field, index : idx } ) ;
421+ }
403422
404423 const targetModel = model . declarations . find (
405424 ( d ) => d . $type === 'DataModel' && getDbName ( d ) === relation . references . table ,
406425 ) as DataModel | undefined ;
407426 if ( ! targetModel ) return ;
408427
409- const targetField = targetModel . fields . find ( ( f ) => getDbName ( f ) === relation . references . column ) ;
410- if ( ! targetField ) return ;
428+ const targetFields : DataField [ ] = [ ] ;
429+ for ( const colName of relation . references . columns ) {
430+ const field = targetModel . fields . find ( ( f ) => getDbName ( f ) === colName ) ;
431+ if ( ! field ) return ;
432+ targetFields . push ( field ) ;
433+ }
434+
435+ // Use the first source field for naming heuristics
436+ const firstSourceField = sourceFields [ 0 ] ! . field ;
437+ const firstSourceFieldId = sourceFields [ 0 ] ! . index ;
438+ const firstColumn = relation . columns [ 0 ] ! ;
411439
412440 const fieldPrefix = / [ 0 - 9 ] / g. test ( sourceModel . name . charAt ( 0 ) ) ? '_' : '' ;
413441
414- const relationName = `${ relation . table } ${ similarRelations > 0 ? `_${ relation . column } ` : '' } To${ relation . references . table } ` ;
442+ const relationName = `${ relation . table } ${ similarRelations > 0 ? `_${ firstColumn } ` : '' } To${ relation . references . table } ` ;
415443
416444 // Derive a relation field name from the FK scalar field: if the field ends with "Id",
417445 // strip the suffix and use the remainder (e.g., "authorId" -> "author").
418- const sourceNameFromReference = sourceField . name . toLowerCase ( ) . endsWith ( 'id' ) ? `${ resolveNameCasing ( options . fieldCasing , sourceField . name . slice ( 0 , - 2 ) ) . name } ${ relation . type === 'many' ? 's' : '' } ` : undefined ;
446+ const sourceNameFromReference = firstSourceField . name . toLowerCase ( ) . endsWith ( 'id' ) ? `${ resolveNameCasing ( options . fieldCasing , firstSourceField . name . slice ( 0 , - 2 ) ) . name } ${ relation . type === 'many' ? 's' : '' } ` : undefined ;
419447
420448 // Check if the derived name would clash with an existing field
421449 const sourceFieldFromReference = sourceModel . fields . find ( ( f ) => f . name === sourceNameFromReference ) ;
@@ -426,12 +454,12 @@ export function syncRelation({
426454 let { name : sourceFieldName } = resolveNameCasing (
427455 options . fieldCasing ,
428456 similarRelations > 0
429- ? `${ fieldPrefix } ${ lowerCaseFirst ( sourceModel . name ) } _${ relation . column } `
457+ ? `${ fieldPrefix } ${ lowerCaseFirst ( sourceModel . name ) } _${ firstColumn } `
430458 : `${ ( ! sourceFieldFromReference ? sourceNameFromReference : undefined ) || lowerCaseFirst ( resolveNameCasing ( options . fieldCasing , targetModel . name ) . name ) } ${ relation . type === 'many' ? 's' : '' } ` ,
431459 ) ;
432460
433461 if ( sourceModel . fields . find ( ( f ) => f . name === sourceFieldName ) ) {
434- sourceFieldName = `${ sourceFieldName } To${ lowerCaseFirst ( targetModel . name ) } _${ relation . references . column } ` ;
462+ sourceFieldName = `${ sourceFieldName } To${ lowerCaseFirst ( targetModel . name ) } _${ relation . references . columns [ 0 ] } ` ;
435463 }
436464
437465 const sourceFieldFactory = new DataFieldFactory ( )
@@ -446,10 +474,24 @@ export function syncRelation({
446474 sourceFieldFactory . addAttribute ( ( ab ) => {
447475 ab . setDecl ( relationAttribute ) ;
448476 if ( includeRelationName ) ab . addArg ( ( ab ) => ab . StringLiteral . setValue ( relationName ) ) ;
449- ab . addArg ( ( ab ) => ab . ArrayExpr . addItem ( ( aeb ) => aeb . ReferenceExpr . setTarget ( sourceField ) ) , 'fields' ) . addArg (
450- ( ab ) => ab . ArrayExpr . addItem ( ( aeb ) => aeb . ReferenceExpr . setTarget ( targetField ) ) ,
451- 'references' ,
452- ) ;
477+
478+ // Build fields array (all source FK columns)
479+ ab . addArg ( ( ab ) => {
480+ const arrayExpr = ab . ArrayExpr ;
481+ for ( const { field } of sourceFields ) {
482+ arrayExpr . addItem ( ( aeb ) => aeb . ReferenceExpr . setTarget ( field ) ) ;
483+ }
484+ return arrayExpr ;
485+ } , 'fields' ) ;
486+
487+ // Build references array (all target columns)
488+ ab . addArg ( ( ab ) => {
489+ const arrayExpr = ab . ArrayExpr ;
490+ for ( const field of targetFields ) {
491+ arrayExpr . addItem ( ( aeb ) => aeb . ReferenceExpr . setTarget ( field ) ) ;
492+ }
493+ return arrayExpr ;
494+ } , 'references' ) ;
453495
454496 // Prisma defaults: onDelete is SetNull for optional, Restrict for mandatory
455497 const onDeleteDefault = relation . nullable ? 'SET NULL' : 'RESTRICT' ;
@@ -474,18 +516,20 @@ export function syncRelation({
474516 ab . addArg ( ( a ) => a . ReferenceExpr . setTarget ( enumFieldRef ) , 'onUpdate' ) ;
475517 }
476518
477- if ( relation . fk_name && relation . fk_name !== `${ relation . table } _${ relation . column } _fkey` ) ab . addArg ( ( ab ) => ab . StringLiteral . setValue ( relation . fk_name ) , 'map' ) ;
519+ // Check if the FK constraint name differs from the default pattern
520+ const defaultFkName = `${ relation . table } _${ relation . columns . join ( '_' ) } _fkey` ;
521+ if ( relation . fk_name && relation . fk_name !== defaultFkName ) ab . addArg ( ( ab ) => ab . StringLiteral . setValue ( relation . fk_name ) , 'map' ) ;
478522
479523 return ab ;
480524 } ) ;
481525
482- sourceModel . fields . splice ( sourceFieldId , 0 , sourceFieldFactory . node ) ; // Insert the relation field before the FK scalar fie
526+ sourceModel . fields . splice ( firstSourceFieldId , 0 , sourceFieldFactory . node ) ; // Insert the relation field before the first FK scalar field
483527
484528 const oppositeFieldPrefix = / [ 0 - 9 ] / g. test ( targetModel . name . charAt ( 0 ) ) ? '_' : '' ;
485529 const { name : oppositeFieldName } = resolveNameCasing (
486530 options . fieldCasing ,
487531 similarRelations > 0
488- ? `${ oppositeFieldPrefix } ${ lowerCaseFirst ( sourceModel . name ) } _${ relation . column } `
532+ ? `${ oppositeFieldPrefix } ${ lowerCaseFirst ( sourceModel . name ) } _${ firstColumn } `
489533 : `${ lowerCaseFirst ( resolveNameCasing ( options . fieldCasing , sourceModel . name ) . name ) } ${ relation . references . type === 'many' ? 's' : '' } ` ,
490534 ) ;
491535
0 commit comments