fix: add proper cardinality symbols to DBML (#1055)

* fix: add proper cardinality symbols to DBML export

* fix
This commit is contained in:
Guy Ben-Aharon
2026-01-06 20:20:53 +02:00
committed by GitHub
parent 9fb451ed04
commit 1c46f96eb3
15 changed files with 1275 additions and 186 deletions

View File

@@ -135,11 +135,13 @@ export const exportBaseSQL = ({
targetDatabaseType,
isDBMLFlow = false,
onlyRelationships = false,
skipFKGeneration = false,
}: {
diagram: Diagram;
targetDatabaseType: DatabaseType;
isDBMLFlow?: boolean;
onlyRelationships?: boolean;
skipFKGeneration?: boolean;
}): string => {
const { tables, relationships } = diagram;
@@ -637,77 +639,80 @@ export const exportBaseSQL = ({
}
});
if (nonViewTables.length > 0 && (relationships?.length ?? 0) > 0) {
sqlScript += '\n';
}
// Skip FK generation when requested (e.g., for DBML export which generates Refs directly)
if (!skipFKGeneration) {
if (nonViewTables.length > 0 && (relationships?.length ?? 0) > 0) {
sqlScript += '\n';
}
// Handle relationships (foreign keys)
relationships?.forEach((relationship) => {
const sourceTable = nonViewTables.find(
(table) => table.id === relationship.sourceTableId
);
const targetTable = nonViewTables.find(
(table) => table.id === relationship.targetTableId
);
// Handle relationships (foreign keys)
relationships?.forEach((relationship) => {
const sourceTable = nonViewTables.find(
(table) => table.id === relationship.sourceTableId
);
const targetTable = nonViewTables.find(
(table) => table.id === relationship.targetTableId
);
const sourceTableField = sourceTable?.fields.find(
(field) => field.id === relationship.sourceFieldId
);
const targetTableField = targetTable?.fields.find(
(field) => field.id === relationship.targetFieldId
);
if (
sourceTable &&
targetTable &&
sourceTableField &&
targetTableField
) {
// Determine which table should have the foreign key based on cardinality
// - FK goes on the "many" side when cardinalities differ
// - FK goes on target when cardinalities are the same (one:one, many:many)
// - Many-to-many needs a junction table, skip for SQL export
let fkTable, fkField, refTable, refField;
const sourceTableField = sourceTable?.fields.find(
(field) => field.id === relationship.sourceFieldId
);
const targetTableField = targetTable?.fields.find(
(field) => field.id === relationship.targetFieldId
);
if (
relationship.sourceCardinality === 'many' &&
relationship.targetCardinality === 'many'
sourceTable &&
targetTable &&
sourceTableField &&
targetTableField
) {
// Many-to-many relationships need a junction table, skip
return;
} else if (
relationship.sourceCardinality === 'many' &&
relationship.targetCardinality === 'one'
) {
// FK goes on source table (the many side)
fkTable = sourceTable;
fkField = sourceTableField;
refTable = targetTable;
refField = targetTableField;
} else {
// All other cases: FK goes on target table
// - one:one (same cardinality → target)
// - one:many (target is many side → target)
fkTable = targetTable;
fkField = targetTableField;
refTable = sourceTable;
refField = sourceTableField;
// Determine which table should have the foreign key based on cardinality
// - FK goes on the "many" side when cardinalities differ
// - FK goes on target when cardinalities are the same (one:one, many:many)
// - Many-to-many needs a junction table, skip for SQL export
let fkTable, fkField, refTable, refField;
if (
relationship.sourceCardinality === 'many' &&
relationship.targetCardinality === 'many'
) {
// Many-to-many relationships need a junction table, skip
return;
} else if (
relationship.sourceCardinality === 'many' &&
relationship.targetCardinality === 'one'
) {
// FK goes on source table (the many side)
fkTable = sourceTable;
fkField = sourceTableField;
refTable = targetTable;
refField = targetTableField;
} else {
// All other cases: FK goes on target table
// - one:one (same cardinality → target)
// - one:many (target is many side → target)
fkTable = targetTable;
fkField = targetTableField;
refTable = sourceTable;
refField = sourceTableField;
}
const fkTableName = getQuotedTableName(fkTable, isDBMLFlow);
const refTableName = getQuotedTableName(refTable, isDBMLFlow);
const quotedFkFieldName = getQuotedFieldName(
fkField.name,
isDBMLFlow
);
const quotedRefFieldName = getQuotedFieldName(
refField.name,
isDBMLFlow
);
sqlScript += `ALTER TABLE ${fkTableName} ADD CONSTRAINT ${relationship.name} FOREIGN KEY (${quotedFkFieldName}) REFERENCES ${refTableName} (${quotedRefFieldName});\n`;
}
const fkTableName = getQuotedTableName(fkTable, isDBMLFlow);
const refTableName = getQuotedTableName(refTable, isDBMLFlow);
const quotedFkFieldName = getQuotedFieldName(
fkField.name,
isDBMLFlow
);
const quotedRefFieldName = getQuotedFieldName(
refField.name,
isDBMLFlow
);
sqlScript += `ALTER TABLE ${fkTableName} ADD CONSTRAINT ${relationship.name} FOREIGN KEY (${quotedFkFieldName}) REFERENCES ${refTableName} (${quotedRefFieldName});\n`;
}
});
});
}
return sqlScript;
};

View File

@@ -1908,4 +1908,510 @@ describe('Apply DBML Changes - relationships', () => {
// Check that the new field is added correctly
expect(result).toEqual(expectedResult);
});
it('should handle cardinality update when relationship direction is reversed', () => {
// Source has relationship: table_2.id -> table_1.id (one-to-one)
// Target has relationship: table_1.id -> table_2.id (one-to-many) - reversed direction
// Result should: preserve source direction but swap cardinalities from target
const sourceDiagram: Diagram = {
id: 'mqqwkkodrxxd',
name: 'Diagram 9',
createdAt: new Date('2025-07-30T15:44:53.967Z'),
updatedAt: new Date('2025-07-30T18:18:02.016Z'),
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: 'table1-source-id',
name: 'table_1',
schema: 'public',
x: 260,
y: 80,
fields: [
{
id: 'field1-source-id',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
unique: true,
nullable: false,
primaryKey: true,
createdAt: 1753890297335,
},
],
indexes: [],
color: '#4dee8a',
createdAt: 1753890297335,
isView: false,
order: 0,
},
{
id: 'table2-source-id',
name: 'table_2',
schema: 'public',
x: -163.75,
y: -5,
fields: [
{
id: 'field2-source-id',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
unique: true,
nullable: false,
primaryKey: true,
createdAt: 1753899478715,
},
],
indexes: [],
color: '#9ef07a',
createdAt: 1753899478715,
isView: false,
order: 1,
},
],
relationships: [
{
id: 'rel-source-id',
name: 'table_2_id_fk',
sourceTableId: 'table2-source-id',
targetTableId: 'table1-source-id',
sourceFieldId: 'field2-source-id',
targetFieldId: 'field1-source-id',
sourceCardinality: 'one',
targetCardinality: 'one',
createdAt: 1753899482016,
},
],
dependencies: [],
areas: [],
customTypes: [],
};
// Target has the relationship in reverse direction with different cardinalities
const targetDiagram: Diagram = {
id: 'mqqwkkodrxxd',
name: 'Diagram 9',
createdAt: new Date('2025-07-30T15:44:53.967Z'),
updatedAt: new Date('2025-07-30T18:18:02.016Z'),
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: 'table1-target-id',
name: 'table_1',
schema: 'public',
order: 0,
fields: [
{
id: 'field1-target-id',
name: 'id',
type: { name: 'bigint', id: 'bigint' },
nullable: false,
primaryKey: true,
unique: true,
createdAt: 1753899628831,
},
],
indexes: [],
x: 0,
y: 0,
color: '#b067e9',
isView: false,
createdAt: 1753899628831,
},
{
id: 'table2-target-id',
name: 'table_2',
schema: 'public',
order: 1,
fields: [
{
id: 'field2-target-id',
name: 'id',
type: { name: 'bigint', id: 'bigint' },
nullable: false,
primaryKey: true,
unique: true,
createdAt: 1753899628831,
},
],
indexes: [],
x: 300,
y: 0,
color: '#ff9f74',
isView: false,
createdAt: 1753899628831,
},
],
relationships: [
{
// Relationship defined in reverse direction: table_1 -> table_2
// with cardinalities: source='one', target='many'
id: 'rel-target-id',
name: 'table_1_id_table_2_id',
sourceSchema: 'public',
targetSchema: 'public',
sourceTableId: 'table1-target-id',
targetTableId: 'table2-target-id',
sourceFieldId: 'field1-target-id',
targetFieldId: 'field2-target-id',
sourceCardinality: 'one',
targetCardinality: 'many',
createdAt: 1753899628831,
},
],
dependencies: [],
areas: [],
};
const result = applyDBMLChanges({
sourceDiagram,
targetDiagram,
});
// Result should preserve source's direction (table_2 -> table_1)
// but with SWAPPED cardinalities from target
// Target: table_1(source='one') -> table_2(target='many')
// After swap for reverse match: table_2(source='many') -> table_1(target='one')
expect(result.relationships).toHaveLength(1);
expect(result.relationships![0].id).toBe('rel-source-id');
expect(result.relationships![0].sourceTableId).toBe('table2-source-id');
expect(result.relationships![0].targetTableId).toBe('table1-source-id');
expect(result.relationships![0].sourceCardinality).toBe('many');
expect(result.relationships![0].targetCardinality).toBe('one');
});
it('should update cardinality when relationship direction matches (direct match)', () => {
// Source has relationship: table_2.id -> table_1.id (one-to-one)
// Target has same direction with different cardinalities (many-to-one)
// Result should: preserve source IDs with updated cardinalities from target
const sourceDiagram: Diagram = {
id: 'mqqwkkodrxxd',
name: 'Diagram 9',
createdAt: new Date('2025-07-30T15:44:53.967Z'),
updatedAt: new Date('2025-07-30T18:18:02.016Z'),
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: 'table1-source-id',
name: 'table_1',
schema: 'public',
x: 260,
y: 80,
fields: [
{
id: 'field1-source-id',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
unique: true,
nullable: false,
primaryKey: true,
createdAt: 1753890297335,
},
],
indexes: [],
color: '#4dee8a',
createdAt: 1753890297335,
isView: false,
order: 0,
},
{
id: 'table2-source-id',
name: 'table_2',
schema: 'public',
x: -163.75,
y: -5,
fields: [
{
id: 'field2-source-id',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
unique: false,
nullable: false,
primaryKey: true,
createdAt: 1753899478715,
},
],
indexes: [],
color: '#9ef07a',
createdAt: 1753899478715,
isView: false,
order: 1,
},
],
relationships: [
{
id: 'rel-source-id',
name: 'table_2_id_fk',
sourceTableId: 'table2-source-id',
targetTableId: 'table1-source-id',
sourceFieldId: 'field2-source-id',
targetFieldId: 'field1-source-id',
sourceCardinality: 'one',
targetCardinality: 'one',
createdAt: 1753899482016,
},
],
dependencies: [],
areas: [],
customTypes: [],
};
// Target has same direction with different cardinalities
const targetDiagram: Diagram = {
id: 'mqqwkkodrxxd',
name: 'Diagram 9',
createdAt: new Date('2025-07-30T15:44:53.967Z'),
updatedAt: new Date('2025-07-30T18:18:02.016Z'),
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: 'table1-target-id',
name: 'table_1',
schema: 'public',
order: 0,
fields: [
{
id: 'field1-target-id',
name: 'id',
type: { name: 'bigint', id: 'bigint' },
nullable: false,
primaryKey: true,
unique: true,
createdAt: 1753899628831,
},
],
indexes: [],
x: 0,
y: 0,
color: '#b067e9',
isView: false,
createdAt: 1753899628831,
},
{
id: 'table2-target-id',
name: 'table_2',
schema: 'public',
order: 1,
fields: [
{
id: 'field2-target-id',
name: 'id',
type: { name: 'bigint', id: 'bigint' },
nullable: false,
primaryKey: true,
unique: false,
createdAt: 1753899628831,
},
],
indexes: [],
x: 300,
y: 0,
color: '#ff9f74',
isView: false,
createdAt: 1753899628831,
},
],
relationships: [
{
// Same direction as source: table_2 -> table_1
// with different cardinalities: source='many', target='one'
id: 'rel-target-id',
name: 'table_2_id_table_1_id',
sourceSchema: 'public',
targetSchema: 'public',
sourceTableId: 'table2-target-id',
targetTableId: 'table1-target-id',
sourceFieldId: 'field2-target-id',
targetFieldId: 'field1-target-id',
sourceCardinality: 'many',
targetCardinality: 'one',
createdAt: 1753899628831,
},
],
dependencies: [],
areas: [],
};
const result = applyDBMLChanges({
sourceDiagram,
targetDiagram,
});
// Result should preserve source's IDs and direction
// with cardinalities directly from target (no swap needed)
expect(result.relationships).toHaveLength(1);
expect(result.relationships![0].id).toBe('rel-source-id');
expect(result.relationships![0].sourceTableId).toBe('table2-source-id');
expect(result.relationships![0].targetTableId).toBe('table1-source-id');
expect(result.relationships![0].sourceCardinality).toBe('many');
expect(result.relationships![0].targetCardinality).toBe('one');
});
it('should preserve cardinalities for new relationships', () => {
// Source has no relationships
// Target has a new relationship with specific cardinalities
const sourceDiagram: Diagram = {
id: 'mqqwkkodrxxd',
name: 'Diagram 9',
createdAt: new Date('2025-07-30T15:44:53.967Z'),
updatedAt: new Date('2025-07-30T18:18:02.016Z'),
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: 'table1-source-id',
name: 'orders',
schema: 'public',
x: 260,
y: 80,
fields: [
{
id: 'orders-id-source',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
unique: true,
nullable: false,
primaryKey: true,
createdAt: 1753890297335,
},
{
id: 'orders-customer-id-source',
name: 'customer_id',
type: { id: 'bigint', name: 'bigint' },
unique: false,
nullable: true,
primaryKey: false,
createdAt: 1753890297336,
},
],
indexes: [],
color: '#4dee8a',
createdAt: 1753890297335,
isView: false,
order: 0,
},
{
id: 'table2-source-id',
name: 'customers',
schema: 'public',
x: -163.75,
y: -5,
fields: [
{
id: 'customers-id-source',
name: 'id',
type: { id: 'bigint', name: 'bigint' },
unique: true,
nullable: false,
primaryKey: true,
createdAt: 1753899478715,
},
],
indexes: [],
color: '#9ef07a',
createdAt: 1753899478715,
isView: false,
order: 1,
},
],
relationships: [],
dependencies: [],
areas: [],
customTypes: [],
};
// Target has a new many-to-one relationship
const targetDiagram: Diagram = {
id: 'mqqwkkodrxxd',
name: 'Diagram 9',
createdAt: new Date('2025-07-30T15:44:53.967Z'),
updatedAt: new Date('2025-07-30T18:18:02.016Z'),
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: 'table1-target-id',
name: 'orders',
schema: 'public',
order: 0,
fields: [
{
id: 'orders-id-target',
name: 'id',
type: { name: 'bigint', id: 'bigint' },
nullable: false,
primaryKey: true,
unique: true,
createdAt: 1753899628831,
},
{
id: 'orders-customer-id-target',
name: 'customer_id',
type: { name: 'bigint', id: 'bigint' },
nullable: true,
primaryKey: false,
unique: false,
createdAt: 1753899628832,
},
],
indexes: [],
x: 0,
y: 0,
color: '#b067e9',
isView: false,
createdAt: 1753899628831,
},
{
id: 'table2-target-id',
name: 'customers',
schema: 'public',
order: 1,
fields: [
{
id: 'customers-id-target',
name: 'id',
type: { name: 'bigint', id: 'bigint' },
nullable: false,
primaryKey: true,
unique: true,
createdAt: 1753899628831,
},
],
indexes: [],
x: 300,
y: 0,
color: '#ff9f74',
isView: false,
createdAt: 1753899628831,
},
],
relationships: [
{
// New relationship: customers.id (one) <- orders.customer_id (many)
id: 'new-rel-id',
name: 'orders_customer_id_fk',
sourceSchema: 'public',
targetSchema: 'public',
sourceTableId: 'table2-target-id',
targetTableId: 'table1-target-id',
sourceFieldId: 'customers-id-target',
targetFieldId: 'orders-customer-id-target',
sourceCardinality: 'one',
targetCardinality: 'many',
createdAt: 1753899628831,
},
],
dependencies: [],
areas: [],
};
const result = applyDBMLChanges({
sourceDiagram,
targetDiagram,
});
// Result should have the new relationship with correct cardinalities
expect(result.relationships).toHaveLength(1);
expect(result.relationships![0].sourceCardinality).toBe('one');
expect(result.relationships![0].targetCardinality).toBe('many');
// IDs should be mapped to source IDs
expect(result.relationships![0].sourceTableId).toBe('table2-source-id');
expect(result.relationships![0].targetTableId).toBe('table1-source-id');
});
});

View File

@@ -455,6 +455,7 @@ const updateRelationships = (
sourceRelationships.forEach((sourceRel) => {
// Find matching target relationship by checking if the target has a relationship
// between the same tables and fields (using the ID mappings)
let isReverseMatch = false;
const targetRel = targetRelationships.find((tgtRel) => {
const mappedSourceTableId = idMappings.tables[tgtRel.sourceTableId];
const mappedTargetTableId = idMappings.tables[tgtRel.targetTableId];
@@ -474,16 +475,25 @@ const updateRelationships = (
sourceRel.sourceFieldId === mappedTargetFieldId &&
sourceRel.targetFieldId === mappedSourceFieldId;
if (reverseMatch && !directMatch) {
isReverseMatch = true;
}
return directMatch || reverseMatch;
});
if (targetRel) {
matchedTargetRelIds.add(targetRel.id);
// Preserve source relationship but update cardinalities from target
// If the relationship is matched in reverse direction, swap the cardinalities
const result: DBRelationship = {
...sourceRel,
sourceCardinality: targetRel.sourceCardinality,
targetCardinality: targetRel.targetCardinality,
sourceCardinality: isReverseMatch
? targetRel.targetCardinality
: targetRel.sourceCardinality,
targetCardinality: isReverseMatch
? targetRel.sourceCardinality
: targetRel.targetCardinality,
};
// Only include schema fields if they exist in the source relationship

View File

@@ -1124,66 +1124,6 @@ Table "public"."Users_37" {
"name" bigint
}
Ref "fk_0_rel_uobqadav":"data"."Products_8"."created_at" < "data"."Products_3"."updated_at"
Ref "fk_1_rel_tjwkm4vg":"data"."Products_8"."created_at" < "data"."Transactions_10"."updated_at"
Ref "fk_2_rel_1a2cz1fe":"admin"."Transactions_2"."updated_at" < "admin"."Categories_11"."updated_at"
Ref "fk_3_rel_rwfzm2uw":"data"."Products_8"."created_at" < "data"."Users_27"."name"
Ref "fk_4_rel_9m17o0cp":"data"."Products_8"."created_at" < "data"."Inventory_26"."updated_at"
Ref "fk_5_rel_knm2r9qn":"data"."Products_20"."updated_at" < "auth"."Logs_4"."name"
Ref "fk_6_rel_funm318w":"data"."Products_20"."updated_at" < "auth"."Orders_22"."updated_at"
Ref "fk_7_rel_1cum4y9j":"data"."Products_20"."updated_at" < "auth"."Profiles_23"."name"
Ref "fk_8_rel_hdo6sheg":"data"."Products_8"."created_at" < "data"."Metrics_17"."name"
Ref "fk_9_rel_g0i870xr":"data"."Products_8"."created_at" < "data"."Orders_19"."name"
Ref "fk_10_rel_blbldnbz":"admin"."Transactions_14"."updated_at" < "data"."Products_20"."status"
Ref "fk_11_rel_jvmpjimf":"data"."Products_20"."updated_at" < "admin"."Categories_17"."ref_id"
Ref "fk_12_rel_h74tx8lr":"data"."Products_20"."updated_at" < "admin"."Metrics_15"."field_21"
Ref "fk_13_rel_5sgf7la0":"data"."Products_8"."created_at" < "data"."Metrics_8"."updated_at"
Ref "fk_14_rel_u9ksay74":"data"."Products_8"."created_at" < "data"."Orders_27"."name"
Ref "fk_15_rel_mo1t38sr":"data"."Products_8"."created_at" < "data"."Transactions_12"."name"
Ref "fk_16_rel_n62szguj":"data"."Products_8"."created_at" < "data"."Orders_30"."name"
Ref "fk_17_rel_b64pkgtx":"data"."Products_20"."updated_at" < "data"."Products_8"."updated_at"
Ref "fk_18_rel_f99mk7mw":"data"."Products_8"."created_at" < "data"."Inventory_4"."type"
Ref "fk_19_rel_565yqyyg":"data"."Products_8"."created_at" < "data"."Metrics_36"."updated_at"
Ref "fk_20_rel_6sp80jwg":"data"."Products_8"."created_at" < "data"."Categories_15"."updated_at"
Ref "fk_21_rel_i56aygpy":"data"."Products_8"."created_at" < "data"."Categories_33"."updated_at"
Ref "fk_22_rel_0h9cq9mi":"data"."Products_8"."created_at" < "data"."Orders_12"."created_at"
Ref "fk_23_rel_ykx8br8b":"data"."Products_8"."created_at" < "data"."Orders_5"."name"
Ref "fk_24_rel_vgg3z98u":"data"."Products_8"."created_at" < "data"."Profiles_34"."name"
Ref "fk_25_rel_60ivpluf":"data"."Products_8"."created_at" < "data"."Transactions_5"."name"
Ref "fk_26_rel_jdc4q2o1":"data"."Products_8"."created_at" < "data"."Orders_18"."updated_at"
Ref "fk_27_rel_b861xb0o":"data"."Products_8"."created_at" < "data"."Products_11"."name"
Ref "fk_28_rel_8dy39c3n":"data"."Products_8"."created_at" < "data"."Transactions_30"."updated_at"
Ref "fk_29_rel_mpu6lu4f":"data"."Products_8"."created_at" < "data"."Metrics_4"."updated_at"
Table "app"."Products" {
"qgs_fid" int [pk, not null]
"geom" geometry
@@ -7271,3 +7211,63 @@ Table "admin"."Orders_37" {
geom [name: "idx_Orders_37_1"]
}
}
Ref "fk_0_rel_uobqadav":"data"."Products_8"."created_at" < "data"."Products_3"."updated_at"
Ref "fk_1_rel_tjwkm4vg":"data"."Products_8"."created_at" < "data"."Transactions_10"."updated_at"
Ref "fk_2_rel_1a2cz1fe":"admin"."Transactions_2"."updated_at" < "admin"."Categories_11"."updated_at"
Ref "fk_3_rel_rwfzm2uw":"data"."Products_8"."created_at" < "data"."Users_27"."name"
Ref "fk_4_rel_9m17o0cp":"data"."Products_8"."created_at" < "data"."Inventory_26"."updated_at"
Ref "fk_5_rel_knm2r9qn":"data"."Products_20"."updated_at" < "auth"."Logs_4"."name"
Ref "fk_6_rel_funm318w":"data"."Products_20"."updated_at" < "auth"."Orders_22"."updated_at"
Ref "fk_7_rel_1cum4y9j":"data"."Products_20"."updated_at" < "auth"."Profiles_23"."name"
Ref "fk_8_rel_hdo6sheg":"data"."Products_8"."created_at" < "data"."Metrics_17"."name"
Ref "fk_9_rel_g0i870xr":"data"."Products_8"."created_at" < "data"."Orders_19"."name"
Ref "fk_10_rel_blbldnbz":"admin"."Transactions_14"."updated_at" < "data"."Products_20"."status"
Ref "fk_11_rel_jvmpjimf":"data"."Products_20"."updated_at" < "admin"."Categories_17"."ref_id"
Ref "fk_12_rel_h74tx8lr":"data"."Products_20"."updated_at" < "admin"."Metrics_15"."field_21"
Ref "fk_13_rel_5sgf7la0":"data"."Products_8"."created_at" < "data"."Metrics_8"."updated_at"
Ref "fk_14_rel_u9ksay74":"data"."Products_8"."created_at" < "data"."Orders_27"."name"
Ref "fk_15_rel_mo1t38sr":"data"."Products_8"."created_at" < "data"."Transactions_12"."name"
Ref "fk_16_rel_n62szguj":"data"."Products_8"."created_at" < "data"."Orders_30"."name"
Ref "fk_17_rel_b64pkgtx":"data"."Products_20"."updated_at" < "data"."Products_8"."updated_at"
Ref "fk_18_rel_f99mk7mw":"data"."Products_8"."created_at" < "data"."Inventory_4"."type"
Ref "fk_19_rel_565yqyyg":"data"."Products_8"."created_at" < "data"."Metrics_36"."updated_at"
Ref "fk_20_rel_6sp80jwg":"data"."Products_8"."created_at" < "data"."Categories_15"."updated_at"
Ref "fk_21_rel_i56aygpy":"data"."Products_8"."created_at" < "data"."Categories_33"."updated_at"
Ref "fk_22_rel_0h9cq9mi":"data"."Products_8"."created_at" < "data"."Orders_12"."created_at"
Ref "fk_23_rel_ykx8br8b":"data"."Products_8"."created_at" < "data"."Orders_5"."name"
Ref "fk_24_rel_vgg3z98u":"data"."Products_8"."created_at" < "data"."Profiles_34"."name"
Ref "fk_25_rel_60ivpluf":"data"."Products_8"."created_at" < "data"."Transactions_5"."name"
Ref "fk_26_rel_jdc4q2o1":"data"."Products_8"."created_at" < "data"."Orders_18"."updated_at"
Ref "fk_27_rel_b861xb0o":"data"."Products_8"."created_at" < "data"."Products_11"."name"
Ref "fk_28_rel_8dy39c3n":"data"."Products_8"."created_at" < "data"."Transactions_30"."updated_at"
Ref "fk_29_rel_mpu6lu4f":"data"."Products_8"."created_at" < "data"."Metrics_4"."updated_at"

View File

@@ -121,7 +121,7 @@ Enum "tipo_status_agendamento" {
}
Table "public"."organizacao_cfg_impressos" {
"id_organizacao" integer [pk, not null, ref: < "public"."organizacao"."id"]
"id_organizacao" integer [pk, not null, ref: > "public"."organizacao"."id"]
}
Table "public"."organizacao" {

View File

@@ -8,7 +8,7 @@ Table "clean"."wms_item" {
}
Table "reporting"."wms_dim_products_history" {
"iai_id_prod" int64 [ref: < "clean"."wms_item"."symbol"]
"wms_id_prod" int64 [ref: < "clean"."wms_item"."id"]
"ean" int64 [ref: < "clean"."wms_item"."ean_code"]
"iai_id_prod" int64 [ref: - "clean"."wms_item"."symbol"]
"wms_id_prod" int64 [ref: - "clean"."wms_item"."id"]
"ean" int64 [ref: - "clean"."wms_item"."ean_code"]
}

View File

@@ -4,8 +4,8 @@ Table "pokemon"."abilities" {
}
Table "pokemon"."pokemon_abilities" {
"pok_id" int [not null, ref: < "pokemon"."pokemon"."pok_id"]
"abil_id" int [not null, ref: < "pokemon"."abilities"."abil_id"]
"pok_id" int [not null, ref: > "pokemon"."pokemon"."pok_id"]
"abil_id" int [not null, ref: > "pokemon"."abilities"."abil_id"]
"is_hidden" int [not null]
"slot" int [not null]
"music" bigint

View File

@@ -79,8 +79,9 @@ describe('DBML Export - Issue Fixes', () => {
const result = generateDBMLFromDiagram(diagram);
// Check that inline DBML has merged attributes in a single bracket
// Relationship is many-to-one (source=many, target=one), so source field gets ref: >
expect(result.inlineDbml).toContain(
'"id" bigint [pk, not null, ref: < "service_tenant"."tenant_id"]'
'"id" bigint [pk, not null, ref: > "service_tenant"."tenant_id"]'
);
// Should NOT have separate brackets like [pk, not null] [ref: < ...]
@@ -210,8 +211,9 @@ describe('DBML Export - Issue Fixes', () => {
const result = generateDBMLFromDiagram(diagram);
// Check inline DBML preserves schema in references
// The foreign key is on the users.tenant_id field, referencing service.tenant.id
expect(result.inlineDbml).toContain('ref: < "service"."tenant"."id"');
// Relationship is many-to-one (source=many, target=one)
// The inline ref goes on users.tenant_id (source) with ref: > pointing to service.tenant.id (target)
expect(result.inlineDbml).toContain('ref: > "service"."tenant"."id"');
});
it('should wrap table and field names with spaces in quotes instead of replacing with underscores', () => {
@@ -344,8 +346,9 @@ describe('DBML Export - Issue Fixes', () => {
expect(result.standardDbml).not.toContain('idx_user_name');
// Check inline DBML as well - the ref is on the order details table
// Relationship is many-to-one (source=many, target=one), so source field gets ref: >
expect(result.inlineDbml).toContain(
'"user id" bigint [not null, ref: < "user profile"."user id"]'
'"user id" bigint [not null, ref: > "user profile"."user id"]'
);
});
@@ -495,8 +498,9 @@ describe('DBML Export - Issue Fixes', () => {
expect(result.inlineDbml).toContain(
'"email" varchar(255) [unique, not null, note: \'User email address\']'
);
// Relationship is many-to-one (source=many, target=one), so source field gets ref: >
expect(result.inlineDbml).toContain(
'"user_id" bigint [not null, note: \'Reference to the user who created the post\', ref: < "users"."id"]'
'"user_id" bigint [not null, note: \'Reference to the user who created the post\', ref: > "users"."id"]'
);
// In standard DBML, field comments should use the note attribute syntax
@@ -1266,10 +1270,10 @@ describe('DBML Export - Issue Fixes', () => {
const result = generateDBMLFromDiagram(diagram);
// For 1:1 relationships, FK goes on target table (table_1)
// Source (table_2) references target (table_1), so table_1 has FK pointing to table_2
// For 1:1 relationships, symbol is '-' (one-to-one)
// Inline ref goes on target side (table_1) with ref: - pointing to source (table_2)
const expectedInlineDBML = `Table "table_1" {
"id" bigint [pk, not null, ref: < "table_2"."id"]
"id" bigint [pk, not null, ref: - "table_2"."id"]
}
Table "table_2" {
@@ -1277,6 +1281,7 @@ Table "table_2" {
}
`;
// Standard DBML: source - target (one-to-one relationship)
const expectedStandardDBML = `Table "table_1" {
"id" bigint [pk, not null]
}
@@ -1285,7 +1290,7 @@ Table "table_2" {
"id" bigint [pk, not null]
}
Ref "fk_0_table_2_id_fk":"table_2"."id" < "table_1"."id"
Ref "fk_0_table_2_id_fk":"table_2"."id" - "table_1"."id"
`;
expect(result.inlineDbml).toBe(expectedInlineDBML);
@@ -1505,12 +1510,14 @@ Ref "fk_0_table_2_id_fk":"table_2"."id" < "table_1"."id"
expect(result.standardDbml).toContain('Table "user_activities" {');
// Check that the entity_id field in user_activities has multiple relationships in inline DBML
// All relationships are many-to-one (source=many, target=one), so source field gets ref: >
// The field should have both references in a single bracket
expect(result.inlineDbml).toContain(
'"entity_id" integer [not null, ref: < "posts"."id", ref: < "reviews"."id"]'
'"entity_id" integer [not null, ref: > "posts"."id", ref: > "reviews"."id"]'
);
// Check that standard DBML has separate Ref entries for each relationship
// Format: target < source for many-to-one relationships (target is one, source is many)
expect(result.standardDbml).toContain(
'Ref "fk_0_fk_posts_user":"users"."id" < "posts"."user_id"'
);
@@ -1623,8 +1630,9 @@ Ref "fk_0_table_2_id_fk":"table_2"."id" < "table_1"."id"
"id" bigint [pk, not null]
}`);
// Relationship is many-to-one (source=many, target=one), so source field gets ref: >
expect(result.inlineDbml).toContain(`Table "table_2" {
"id" bigint [pk, not null, ref: < "table_1"."id"]
"id" bigint [pk, not null, ref: > "table_1"."id"]
}`);
// The issue was that it would generate:

View File

@@ -37,9 +37,9 @@ Table "finance"."general_ledger" {
// Check inline format
expect(exportResult.inlineDbml).toContain('reversal_id');
// The DBML parser correctly interprets FK as: target < source
// FK fields use ref: > to indicate "I reference other"
expect(exportResult.inlineDbml).toMatch(
/ref:\s*<\s*"finance"\."general_ledger"\."ledger_id"/
/ref:\s*>\s*"finance"\."general_ledger"\."ledger_id"/
);
// Check standard format
@@ -75,8 +75,8 @@ Table "employees" {
// Check that the self-reference is preserved
expect(exportResult.inlineDbml).toContain('manager_id');
// The DBML parser correctly interprets FK as: target < source
expect(exportResult.inlineDbml).toMatch(/ref:\s*<\s*"employees"\."id"/);
// FK fields use ref: > to indicate "I reference other"
expect(exportResult.inlineDbml).toMatch(/ref:\s*>\s*"employees"\."id"/);
});
it('should handle multiple self-referencing relationships', async () => {
@@ -110,8 +110,8 @@ Table "categories" {
expect(exportResult.inlineDbml).toContain('related_id');
// Count the number of ref: statements
// The DBML parser correctly interprets FK as: target < source
const refMatches = exportResult.inlineDbml.match(/ref:\s*</g);
// FK fields use ref: > to indicate "I reference other"
const refMatches = exportResult.inlineDbml.match(/ref:\s*>/g);
expect(refMatches?.length).toBe(2);
});
@@ -135,9 +135,9 @@ Table "hr"."staff" {
const exportResult = generateDBMLFromDiagram(diagram);
// Should preserve the schema in the reference
// The DBML parser correctly interprets FK as: target < source
// FK fields use ref: > to indicate "I reference other"
expect(exportResult.inlineDbml).toMatch(
/ref:\s*<\s*"hr"\."staff"\."staff_id"/
/ref:\s*>\s*"hr"\."staff"\."staff_id"/
);
});

View File

@@ -47,7 +47,7 @@ const testCase = (caseNumber: string) => {
};
describe('DBML Export cases', () => {
it('should handle case 1 diagram', { timeout: 30000 }, async () => {
it('should handle case 1 diagram', { timeout: 60000 }, async () => {
testCase('1');
});

View File

@@ -6,6 +6,7 @@ import type { DBTable } from '@/lib/domain/db-table';
import type { DBCustomType } from '@/lib/domain/db-custom-type';
import { DBCustomTypeKind } from '@/lib/domain/db-custom-type';
import { validateCheckConstraint } from '@/lib/check-constraints/check-constraints-validator';
import type { DBRelationship } from '@/lib/domain/db-relationship';
// Use DBCustomType for generating Enum DBML
const generateEnumsDBML = (customTypes: DBCustomType[] | undefined): string => {
@@ -258,8 +259,9 @@ const findClosingBracket = (str: string, openBracketIndex: number): number => {
const convertToInlineRefs = (dbml: string): string => {
// Extract all Ref statements - Updated pattern to handle schema.table.field format
// Matches both "table"."field" and "schema"."table"."field" formats
// Now supports cardinality symbols: < (one-to-many), > (many-to-one), - (one-to-one), <> (many-to-many)
const refPattern =
/Ref\s+"([^"]+)"\s*:\s*(?:"([^"]+)"\.)?"([^"]+)"\."([^"]+)"\s*([<>*])\s*(?:"([^"]+)"\.)?"([^"]+)"\."([^"]+)"/g;
/Ref\s+"([^"]+)"\s*:\s*(?:"([^"]+)"\.)?"([^"]+)"\."([^"]+)"\s*(<>|[<>\-*])\s*(?:"([^"]+)"\.)?"([^"]+)"\."([^"]+)"/g;
const refs: Array<{
refName: string;
sourceSchema?: string;
@@ -373,24 +375,71 @@ const convertToInlineRefs = (dbml: string): string => {
refs.forEach((ref) => {
let targetTableName, fieldNameToModify, inlineRefSyntax, relatedTable;
// Build the reference strings for both sides
const sourceRef = ref.sourceSchema
? `"${ref.sourceSchema}"."${ref.sourceTable}"."${ref.sourceField}"`
: `"${ref.sourceTable}"."${ref.sourceField}"`;
const targetRef = ref.targetSchema
? `"${ref.targetSchema}"."${ref.targetTable}"."${ref.targetField}"`
: `"${ref.targetTable}"."${ref.targetField}"`;
// After parsing Ref "name":LEFT [symbol] RIGHT:
// ref.sourceTable = LEFT, ref.targetTable = RIGHT
//
// For Ref A < B: A is one, B is many, B has FK pointing to A
// For Ref A > B: A is many, B is one, A has FK pointing to B
//
// Inline ref semantics:
// - ref: > other = "I reference other" (I have FK pointing to other)
// - ref: < other = "other references me" (other has FK pointing to me)
//
// Both one-to-many and many-to-one Refs use '<' symbol (A < B format)
// where A=one, B=many. FK is always on B (the many side).
if (ref.direction === '<') {
// Ref: A < B where A=one, B=many. FK is on B.
// In parsed: ref.sourceTable=A (one), ref.targetTable=B (many)
// Inline ref goes on B (many side) with: ref: > A (B references A)
targetTableName = ref.targetSchema
? `${ref.targetSchema}.${ref.targetTable}`
: ref.targetTable;
fieldNameToModify = ref.targetField;
const sourceRef = ref.sourceSchema
? `"${ref.sourceSchema}"."${ref.sourceTable}"."${ref.sourceField}"`
: `"${ref.sourceTable}"."${ref.sourceField}"`;
inlineRefSyntax = `ref: < ${sourceRef}`;
inlineRefSyntax = `ref: > ${sourceRef}`; // B references A
relatedTable = ref.sourceTable;
} else {
} else if (ref.direction === '>') {
// Ref: A > B where A=many, B=one. FK is on A.
// In parsed: ref.sourceTable=A (many), ref.targetTable=B (one)
// Inline ref goes on A (many side) with: ref: > B (A references B)
targetTableName = ref.sourceSchema
? `${ref.sourceSchema}.${ref.sourceTable}`
: ref.sourceTable;
fieldNameToModify = ref.sourceField;
inlineRefSyntax = `ref: > ${targetRef}`; // A references B
relatedTable = ref.targetTable;
} else if (ref.direction === '-') {
// one-to-one: A - B
// Convention: inline ref on B pointing to A
targetTableName = ref.targetSchema
? `${ref.targetSchema}.${ref.targetTable}`
: ref.targetTable;
fieldNameToModify = ref.targetField;
inlineRefSyntax = `ref: - ${sourceRef}`;
relatedTable = ref.sourceTable;
} else if (ref.direction === '<>') {
// many-to-many: A <> B
// Convention: inline ref on B pointing to A
targetTableName = ref.targetSchema
? `${ref.targetSchema}.${ref.targetTable}`
: ref.targetTable;
fieldNameToModify = ref.targetField;
inlineRefSyntax = `ref: <> ${sourceRef}`;
relatedTable = ref.sourceTable;
} else {
// Default fallback (e.g., '*' or unknown)
targetTableName = ref.sourceSchema
? `${ref.sourceSchema}.${ref.sourceTable}`
: ref.sourceTable;
fieldNameToModify = ref.sourceField;
const targetRef = ref.targetSchema
? `"${ref.targetSchema}"."${ref.targetTable}"."${ref.targetField}"`
: `"${ref.targetTable}"."${ref.targetField}"`;
inlineRefSyntax = `ref: > ${targetRef}`;
relatedTable = ref.targetTable;
}
@@ -982,6 +1031,118 @@ const extractRelationshipsDbml = (dbml: string): string => {
return refLines.join('\n').trim();
};
// Generate Ref statements from diagram relationships with correct cardinality symbols
// Note: relationships should already be processed with sanitized names (fk_N_name format)
// Format: referenced_table [symbol] fk_table (matches @dbml/core order)
// - For many-to-one (source has FK): target [symbol] source
// - For one-to-many (target has FK): source [symbol] target
// - For one-to-one: target [symbol] source (convention)
// - For many-to-many: target [symbol] source (convention)
const generateRelationshipsDbmlFromDiagram = (
relationships: DBRelationship[],
tables: DBTable[]
): string => {
if (!relationships || relationships.length === 0) {
return '';
}
// Build lookup maps once for O(1) access - improves performance for large diagrams
const tableMap = new Map<string, DBTable>();
const fieldMap = new Map<string, { table: DBTable; fieldName: string }>();
for (const table of tables) {
tableMap.set(table.id, table);
for (const field of table.fields) {
fieldMap.set(field.id, { table, fieldName: field.name });
}
}
const refStatements: string[] = [];
for (const rel of relationships) {
const sourceTable = tableMap.get(rel.sourceTableId);
const targetTable = tableMap.get(rel.targetTableId);
const sourceFieldInfo = fieldMap.get(rel.sourceFieldId);
const targetFieldInfo = fieldMap.get(rel.targetFieldId);
// Skip invalid relationships (missing table or field)
if (
!sourceTable ||
!targetTable ||
!sourceFieldInfo ||
!targetFieldInfo
) {
continue;
}
// Build quoted table.field references
const sourceRef = sourceTable.schema
? `"${sourceTable.schema}"."${sourceTable.name}"."${sourceFieldInfo.fieldName}"`
: `"${sourceTable.name}"."${sourceFieldInfo.fieldName}"`;
const targetRef = targetTable.schema
? `"${targetTable.schema}"."${targetTable.name}"."${targetFieldInfo.fieldName}"`
: `"${targetTable.name}"."${targetFieldInfo.fieldName}"`;
// Determine order and symbol based on cardinality
// To preserve @dbml/core output format while adding correct symbols:
// - @dbml/core always outputs: referenced_table < fk_table (regardless of actual cardinality)
// - We preserve the order but use correct symbol based on actual cardinality
//
// DBML semantics:
// - `A < B` means: A is ONE, B is MANY
// - `A > B` means: A is MANY, B is ONE
// - `A - B` means: one-to-one
// - `A <> B` means: many-to-many
let leftRef: string;
let rightRef: string;
let symbol: string;
if (
rel.sourceCardinality === 'one' &&
rel.targetCardinality === 'many'
) {
// one-to-many: source (one) has many target
// Format: source < target (source is one, target is many)
leftRef = sourceRef;
rightRef = targetRef;
symbol = '<';
} else if (
rel.sourceCardinality === 'many' &&
rel.targetCardinality === 'one'
) {
// many-to-one: source (many) belongs to target (one)
// Format: target < source (to match @dbml/core order, target is one, source is many)
leftRef = targetRef;
rightRef = sourceRef;
symbol = '<';
} else if (
rel.sourceCardinality === 'one' &&
rel.targetCardinality === 'one'
) {
// one-to-one
// Format: source - target
leftRef = sourceRef;
rightRef = targetRef;
symbol = '-';
} else {
// many-to-many
// Format: source <> target
leftRef = sourceRef;
rightRef = targetRef;
symbol = '<>';
}
// rel.name is already sanitized (fk_N_name format) by generateDBMLFromDiagram
refStatements.push(
`Ref "${rel.name}":${leftRef} ${symbol} ${rightRef}`
);
}
// Join with blank lines to match @dbml/core format
return refStatements.join('\n\n');
};
export interface DBMLExportResult {
standardDbml: string;
inlineDbml: string;
@@ -1126,6 +1287,7 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
diagram: finalDiagramForExport, // Use final diagram
targetDatabaseType: diagram.databaseType,
isDBMLFlow: true,
skipFKGeneration: true, // We generate Refs directly with correct cardinality
});
baseScript = sanitizeSQLforDBML(baseScript);
@@ -1158,6 +1320,20 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
// Restore check constraints that may have been lost during DBML export
standard = restoreCheckConstraints(standard, tablesWithFields);
// Generate cardinality-aware Ref statements from the diagram relationships
// (FK generation is skipped in SQL, so @dbml/core doesn't generate any Refs)
const cardinalityAwareRefs = generateRelationshipsDbmlFromDiagram(
finalDiagramForExport.relationships ?? [],
finalDiagramForExport.tables ?? []
);
// Append our Ref statements if we have relationships
if (cardinalityAwareRefs) {
// Clean up trailing whitespace/newlines and add proper spacing
standard =
standard.trimEnd() + '\n\n' + cardinalityAwareRefs + '\n';
}
// Prepend Enum DBML to the standard output
if (enumsDBML) {
standard = enumsDBML + '\n\n' + standard;

View File

@@ -3,5 +3,5 @@ Table "table_3" {
}
Table "table_2" {
"id" bigint [pk, not null, ref: < "table_3"."id"]
"id" bigint [pk, not null, ref: - "table_3"."id"]
}

View File

@@ -0,0 +1,389 @@
import { describe, it, expect } from 'vitest';
import { importDBMLToDiagram } from '../dbml-import';
import { generateDBMLFromDiagram } from '../../dbml-export/dbml-export';
import { DatabaseType } from '@/lib/domain/database-type';
describe('DBML Cardinality Import', () => {
describe('Inline ref cardinality symbols', () => {
it('should import many-to-one relationship (ref: >)', async () => {
// ref: > means "I (FK) reference other (PK)" - many-to-one
// Parser returns: [referenced_table (one), table_with_ref (many)]
const dbml = `
Table "orders" {
"id" int [pk]
"customer_id" int [ref: > "customers"."id"]
}
Table "customers" {
"id" int [pk]
"name" varchar(100)
}
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(1);
const rel = diagram.relationships![0];
// source = customers.id (PK/referenced) is one
// target = orders.customer_id (FK/referencing) is many
expect(rel.sourceCardinality).toBe('one');
expect(rel.targetCardinality).toBe('many');
});
it('should import one-to-many relationship (ref: <)', async () => {
// ref: < means "other (FK) references me (PK)" - one-to-many
// Parser returns: [other_table (many), table_with_ref (one)]
const dbml = `
Table "customers" {
"id" int [pk, ref: < "orders"."customer_id"]
"name" varchar(100)
}
Table "orders" {
"id" int [pk]
"customer_id" int
}
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(1);
const rel = diagram.relationships![0];
// source = orders.customer_id (the other/FK field) is many
// target = customers.id (the field with ref/PK) is one
expect(rel.sourceCardinality).toBe('many');
expect(rel.targetCardinality).toBe('one');
});
it('should import one-to-one relationship (ref: -)', async () => {
// ref: - means one-to-one relationship
const dbml = `
Table "users" {
"id" int [pk]
"name" varchar(100)
}
Table "user_profiles" {
"id" int [pk]
"user_id" int [unique, ref: - "users"."id"]
"bio" text
}
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(1);
const rel = diagram.relationships![0];
// Both sides should be one
expect(rel.sourceCardinality).toBe('one');
expect(rel.targetCardinality).toBe('one');
});
it('should import many-to-many relationship (ref: <>)', async () => {
// ref: <> means many-to-many relationship
const dbml = `
Table "students" {
"id" int [pk, ref: <> "courses"."id"]
"name" varchar(100)
}
Table "courses" {
"id" int [pk]
"title" varchar(200)
}
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(1);
const rel = diagram.relationships![0];
// Both sides should be many
expect(rel.sourceCardinality).toBe('many');
expect(rel.targetCardinality).toBe('many');
});
});
describe('Standalone Ref cardinality symbols', () => {
it('should import many-to-one with standalone Ref >', async () => {
const dbml = `
Table "orders" {
"id" int [pk]
"customer_id" int
}
Table "customers" {
"id" int [pk]
"name" varchar(100)
}
Ref: "orders"."customer_id" > "customers"."id"
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(1);
const rel = diagram.relationships![0];
// orders.customer_id (source) is many, customers.id (target) is one
expect(rel.sourceCardinality).toBe('many');
expect(rel.targetCardinality).toBe('one');
});
it('should import one-to-many with standalone Ref <', async () => {
const dbml = `
Table "customers" {
"id" int [pk]
"name" varchar(100)
}
Table "orders" {
"id" int [pk]
"customer_id" int
}
Ref: "customers"."id" < "orders"."customer_id"
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(1);
const rel = diagram.relationships![0];
// customers.id (source) is one, orders.customer_id (target) is many
expect(rel.sourceCardinality).toBe('one');
expect(rel.targetCardinality).toBe('many');
});
it('should import one-to-one with standalone Ref -', async () => {
const dbml = `
Table "users" {
"id" int [pk]
"name" varchar(100)
}
Table "profiles" {
"id" int [pk]
"user_id" int [unique]
}
Ref: "profiles"."user_id" - "users"."id"
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(1);
const rel = diagram.relationships![0];
// Both sides should be one
expect(rel.sourceCardinality).toBe('one');
expect(rel.targetCardinality).toBe('one');
});
it('should import many-to-many with standalone Ref <>', async () => {
const dbml = `
Table "students" {
"id" int [pk]
"name" varchar(100)
}
Table "courses" {
"id" int [pk]
"title" varchar(200)
}
Ref: "students"."id" <> "courses"."id"
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(1);
const rel = diagram.relationships![0];
// Both sides should be many
expect(rel.sourceCardinality).toBe('many');
expect(rel.targetCardinality).toBe('many');
});
});
describe('Round-trip cardinality preservation', () => {
it('should preserve many-to-one cardinality through export and re-import', async () => {
const inputDbml = `
Table "posts" {
"id" int [pk]
"author_id" int [ref: > "authors"."id"]
"title" varchar(200)
}
Table "authors" {
"id" int [pk]
"name" varchar(100)
}
`;
// Import
const diagram = await importDBMLToDiagram(inputDbml, {
databaseType: DatabaseType.POSTGRESQL,
});
// Export
const exportResult = generateDBMLFromDiagram(diagram);
// Re-import
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{ databaseType: DatabaseType.POSTGRESQL }
);
// Verify cardinality preserved (source=PK side, target=FK side)
const rel = reimportedDiagram.relationships![0];
expect(rel.sourceCardinality).toBe('one');
expect(rel.targetCardinality).toBe('many');
});
it('should preserve one-to-one cardinality through export and re-import', async () => {
const inputDbml = `
Table "users" {
"id" int [pk]
}
Table "settings" {
"id" int [pk]
"user_id" int [unique, ref: - "users"."id"]
}
`;
const diagram = await importDBMLToDiagram(inputDbml, {
databaseType: DatabaseType.POSTGRESQL,
});
const exportResult = generateDBMLFromDiagram(diagram);
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{ databaseType: DatabaseType.POSTGRESQL }
);
const rel = reimportedDiagram.relationships![0];
expect(rel.sourceCardinality).toBe('one');
expect(rel.targetCardinality).toBe('one');
});
it('should preserve many-to-many cardinality through export and re-import', async () => {
const inputDbml = `
Table "tags" {
"id" int [pk, ref: <> "articles"."id"]
"name" varchar(50)
}
Table "articles" {
"id" int [pk]
"title" varchar(200)
}
`;
const diagram = await importDBMLToDiagram(inputDbml, {
databaseType: DatabaseType.POSTGRESQL,
});
const exportResult = generateDBMLFromDiagram(diagram);
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{ databaseType: DatabaseType.POSTGRESQL }
);
const rel = reimportedDiagram.relationships![0];
expect(rel.sourceCardinality).toBe('many');
expect(rel.targetCardinality).toBe('many');
});
});
describe('Complex cardinality scenarios', () => {
it('should handle multiple relationships with different cardinalities', async () => {
const dbml = `
Table "comments" {
"id" int [pk]
"post_id" int [ref: > "posts"."id"]
"user_id" int [ref: > "users"."id"]
"parent_id" int [ref: > "comments"."id"]
"content" text
}
Table "posts" {
"id" int [pk]
"title" varchar(200)
}
Table "users" {
"id" int [pk]
"name" varchar(100)
}
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(3);
// All should be one-to-many from source perspective (source=PK, target=FK)
diagram.relationships?.forEach((rel) => {
expect(rel.sourceCardinality).toBe('one');
expect(rel.targetCardinality).toBe('many');
});
});
it('should handle self-referencing with correct cardinality', async () => {
const dbml = `
Table "employees" {
"id" int [pk]
"name" varchar(100)
"manager_id" int [ref: > "employees"."id"]
}
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(1);
const rel = diagram.relationships![0];
// Self-referencing: source=id (one), target=manager_id (many)
expect(rel.sourceCardinality).toBe('one');
expect(rel.targetCardinality).toBe('many');
expect(rel.sourceTableId).toBe(rel.targetTableId);
});
it('should handle schema-qualified tables with cardinality', async () => {
const dbml = `
Table "sales"."orders" {
"id" int [pk]
"customer_id" int [ref: > "crm"."customers"."id"]
}
Table "crm"."customers" {
"id" int [pk]
"name" varchar(100)
}
`;
const diagram = await importDBMLToDiagram(dbml, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(diagram.relationships?.length).toBe(1);
const rel = diagram.relationships![0];
// source=customers.id (one), target=orders.customer_id (many)
expect(rel.sourceCardinality).toBe('one');
expect(rel.targetCardinality).toBe('many');
});
});
});

View File

@@ -1074,7 +1074,7 @@ Table "public_2"."posts" {
"id" varchar(500) [pk]
"title" varchar(500)
"content" text
"user_id" varchar(500) [ref: < "public"."users"."id"]
"user_id" varchar(500) [ref: > "public"."users"."id"]
"created_at" timestamp
Indexes {
@@ -1086,8 +1086,8 @@ Table "public_2"."posts" {
Table "public_3"."comments" {
"id" varchar(500) [pk]
"content" text
"post_id" varchar(500) [ref: < "public_2"."posts"."id"]
"user_id" varchar(500) [ref: < "public"."users"."id"]
"post_id" varchar(500) [ref: > "public_2"."posts"."id"]
"user_id" varchar(500) [ref: > "public"."users"."id"]
"created_at" timestamp
Indexes {

View File

@@ -301,7 +301,7 @@ interface DBMLTable {
interface DBMLEndpoint {
tableName: string;
fieldNames: string[];
relation: string;
relation: '1' | '*'; // '1' = one, '*' = many (from @dbml/core parser)
}
interface DBMLRef {
@@ -355,21 +355,10 @@ const mapDBMLTypeToDataType = (
} satisfies DataTypeData;
};
const determineCardinality = (
field: DBField,
referencedField: DBField
): { sourceCardinality: string; targetCardinality: string } => {
const isSourceUnique = field.unique || field.primaryKey;
const isTargetUnique = referencedField.unique || referencedField.primaryKey;
if (isSourceUnique && isTargetUnique) {
return { sourceCardinality: 'one', targetCardinality: 'one' };
} else if (isSourceUnique) {
return { sourceCardinality: 'one', targetCardinality: 'many' };
} else if (isTargetUnique) {
return { sourceCardinality: 'many', targetCardinality: 'one' };
} else {
return { sourceCardinality: 'many', targetCardinality: 'many' };
}
// Convert @dbml/core relation values to cardinality
// The parser uses '1' for "one" side and '*' for "many" side
const relationToCardinality = (relation: '1' | '*'): Cardinality => {
return relation === '1' ? 'one' : 'many';
};
export const importDBMLToDiagram = async (
@@ -993,8 +982,14 @@ export const importDBMLToDiagram = async (
throw new Error('Invalid relationship: fields not found');
}
const { sourceCardinality, targetCardinality } =
determineCardinality(sourceField, targetField);
// Use the relation values from @dbml/core parser
// These directly represent the cardinality: '1' = one, '*' = many
const sourceCardinality = relationToCardinality(
source.relation
);
const targetCardinality = relationToCardinality(
target.relation
);
return {
id: generateId(),
@@ -1005,8 +1000,8 @@ export const importDBMLToDiagram = async (
targetTableId: targetTable.id,
sourceFieldId: sourceField.id,
targetFieldId: targetField.id,
sourceCardinality: sourceCardinality as Cardinality,
targetCardinality: targetCardinality as Cardinality,
sourceCardinality,
targetCardinality,
createdAt: Date.now(),
};
}