Skip to content

Commit 06dd907

Browse files
committed
fix(cli): improve db pull for composite FKs and MySQL uniqueness
Enhances database introspection to correctly handle composite foreign keys by mapping columns by position rather than name alone. Improves MySQL introspection by checking statistics tables for single-column unique indexes, ensuring accurate model generation even when column keys are ambiguous. Ensures MySQL synthetic enum names respect requested model casing to prevent unnecessary schema mapping. Adds comprehensive tests for composite relations and database-specific uniqueness detection.
1 parent 62cdd73 commit 06dd907

File tree

7 files changed

+251
-61
lines changed

7 files changed

+251
-61
lines changed

packages/cli/src/actions/db.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ async function runPull(options: PullOptions) {
109109
}
110110

111111
spinner.start('Introspecting database...');
112-
const { enums, tables } = await provider.introspect(datasource.url, { schemas: datasource.allSchemas });
112+
const { enums, tables } = await provider.introspect(datasource.url, { schemas: datasource.allSchemas, modelCasing: options.modelCasing });
113113
spinner.succeed('Database introspected');
114114

115115
console.log(colors.blue('Syncing schema...'));
@@ -156,7 +156,7 @@ async function runPull(options: PullOptions) {
156156
rr.references.schema === relation.references.schema &&
157157
rr.references.table === relation.references.table) ||
158158
(rr.schema === relation.references.schema &&
159-
rr.column === relation.references.column &&
159+
rr.columns[0] === relation.references.columns[0] &&
160160
rr.references.schema === relation.schema &&
161161
rr.references.table === relation.table))
162162
);
@@ -263,10 +263,12 @@ async function runPull(options: PullOptions) {
263263
(d) => getDbName(d.node as any) === getDbName(f.type.reference!.ref as any),
264264
)?.node;
265265
if (ref && f.type.reference) {
266-
(f.type.reference.ref as any) = ref;
267-
// Keep the textual reference in sync with the semantic reference
268-
(f.type.reference as any).$refText =
269-
(ref as any).name ?? (f.type.reference as any).$refText;
266+
// Replace the entire reference object — Langium References
267+
// from parsed documents expose `ref` as a getter-only property.
268+
(f.type as any).reference = {
269+
ref,
270+
$refText: (ref as any).name ?? (f.type.reference as any).$refText,
271+
};
270272
}
271273
}
272274
});
@@ -356,8 +358,12 @@ async function runPull(options: PullOptions) {
356358
const oldRefName = getDbName(oldType.reference.ref);
357359
if (newRefName !== oldRefName) {
358360
fieldUpdates.push(`reference: ${oldType.reference.$refText} -> ${newType.reference.$refText}`);
359-
(oldType.reference as any).ref = newType.reference.ref;
360-
(oldType.reference as any).$refText = newType.reference.$refText;
361+
// Replace the entire reference object — Langium References
362+
// from parsed documents expose `ref` as a getter-only property.
363+
(oldType as any).reference = {
364+
ref: newType.reference.ref,
365+
$refText: newType.reference.$refText,
366+
};
361367
}
362368
} else if (newType.reference?.ref && !oldType.reference) {
363369
// Changed from builtin to reference type
@@ -441,8 +447,12 @@ async function runPull(options: PullOptions) {
441447
(d) => getDbName(d.node as any) === getDbName(f.type.reference!.ref as any),
442448
)?.node as DataModel | undefined;
443449
if (ref) {
444-
(f.type.reference.$refText as any) = ref.name;
445-
(f.type.reference.ref as any) = ref;
450+
// Replace the entire reference object — Langium References
451+
// from parsed documents expose `ref` as a getter-only property.
452+
(f.type as any).reference = {
453+
ref,
454+
$refText: ref.name ?? (f.type.reference as any).$refText,
455+
};
446456
}
447457
}
448458
return;

packages/cli/src/actions/pull/index.ts

Lines changed: 82 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export function syncEnums({
9595
export 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

Comments
 (0)