mirror of
https://github.com/chartdb/chartdb.git
synced 2026-02-10 05:29:57 -06:00
fix: add proper cardinality symbols to DBML (#1055)
* fix: add proper cardinality symbols to DBML export * fix
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"/
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user