diff --git a/src/lib/data/sql-export/__tests__/export-sql.test.ts b/src/lib/data/sql-export/__tests__/export-sql.test.ts index 9e733a7e..632e4ab2 100644 --- a/src/lib/data/sql-export/__tests__/export-sql.test.ts +++ b/src/lib/data/sql-export/__tests__/export-sql.test.ts @@ -315,5 +315,139 @@ ALTER TABLE "public"."user_profiles" ADD CONSTRAINT "fk_user_profiles_user_id_us expect(sql.trim()).toBe(expectedSql.trim()); }); + + it('should place FK on target table for 1:1 relationships in DBML flow', () => { + // This tests the generic code path used by DBML export (isDBMLFlow: true) + const usersTableId = 'users-table-id'; + const profilesTableId = 'profiles-table-id'; + const usersIdFieldId = 'users-id-field'; + const profilesUserIdFieldId = 'profiles-user-id-field'; + + const diagram = createDiagram({ + databaseType: DatabaseType.POSTGRESQL, + tables: [ + createTable({ + id: usersTableId, + name: 'users', + schema: 'public', + fields: [ + createField({ + id: usersIdFieldId, + name: 'id', + type: { id: 'bigint', name: 'bigint' }, + primaryKey: true, + nullable: false, + }), + ], + }), + createTable({ + id: profilesTableId, + name: 'profiles', + schema: 'public', + fields: [ + createField({ + id: profilesUserIdFieldId, + name: 'user_id', + type: { id: 'bigint', name: 'bigint' }, + nullable: true, + }), + ], + }), + ], + relationships: [ + { + id: 'rel-1', + name: 'profiles_user_fk', + sourceSchema: 'public', + sourceTableId: usersTableId, // users is source (parent) + targetSchema: 'public', + targetTableId: profilesTableId, // profiles is target (child with FK) + sourceFieldId: usersIdFieldId, + targetFieldId: profilesUserIdFieldId, + sourceCardinality: 'one', + targetCardinality: 'one', + createdAt: testTime, + }, + ], + }); + + const sql = exportBaseSQL({ + diagram, + targetDatabaseType: DatabaseType.POSTGRESQL, + isDBMLFlow: true, // Use the generic code path + }); + + // For 1:1 relationships, FK should be on target table (profiles) + // The ALTER TABLE should be on profiles, referencing users + expect(sql).toContain( + 'ALTER TABLE "public"."profiles" ADD CONSTRAINT profiles_user_fk FOREIGN KEY ("user_id") REFERENCES "public"."users" ("id")' + ); + }); + + it('should place FK on many side for one-to-many relationships', () => { + const ordersTableId = 'orders-table-id'; + const customersTableId = 'customers-table-id'; + const ordersCustomerIdFieldId = 'orders-customer-id-field'; + const customersIdFieldId = 'customers-id-field'; + + const diagram = createDiagram({ + databaseType: DatabaseType.POSTGRESQL, + tables: [ + createTable({ + id: customersTableId, + name: 'customers', + schema: 'public', + fields: [ + createField({ + id: customersIdFieldId, + name: 'id', + type: { id: 'bigint', name: 'bigint' }, + primaryKey: true, + nullable: false, + }), + ], + }), + createTable({ + id: ordersTableId, + name: 'orders', + schema: 'public', + fields: [ + createField({ + id: ordersCustomerIdFieldId, + name: 'customer_id', + type: { id: 'bigint', name: 'bigint' }, + nullable: true, + }), + ], + }), + ], + relationships: [ + { + id: 'rel-2', + name: 'orders_customer_fk', + sourceSchema: 'public', + sourceTableId: customersTableId, // customers is one + targetSchema: 'public', + targetTableId: ordersTableId, // orders is many + sourceFieldId: customersIdFieldId, + targetFieldId: ordersCustomerIdFieldId, + sourceCardinality: 'one', + targetCardinality: 'many', + createdAt: testTime, + }, + ], + }); + + const sql = exportBaseSQL({ + diagram, + targetDatabaseType: DatabaseType.POSTGRESQL, + isDBMLFlow: true, + }); + + // For one:many, FK should be on the many side (orders) + expect(sql).toContain( + 'ALTER TABLE "public"."orders" ADD CONSTRAINT orders_customer_fk FOREIGN KEY ("customer_id") REFERENCES "public"."customers" ("id")' + ); + }); }); }); diff --git a/src/lib/data/sql-export/cross-dialect/postgresql/to-mssql.ts b/src/lib/data/sql-export/cross-dialect/postgresql/to-mssql.ts index 1d598a71..1c28cbe1 100644 --- a/src/lib/data/sql-export/cross-dialect/postgresql/to-mssql.ts +++ b/src/lib/data/sql-export/cross-dialect/postgresql/to-mssql.ts @@ -641,34 +641,31 @@ export function exportPostgreSQLToMSSQL({ } // Determine FK placement 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) let fkTable, fkField, refTable, refField; if ( - r.sourceCardinality === 'one' && + r.sourceCardinality === 'many' && r.targetCardinality === 'many' ) { - fkTable = targetTable; - fkField = targetField; - refTable = sourceTable; - refField = sourceField; + // Many-to-many relationships need a junction table, skip + return null; } else if ( r.sourceCardinality === 'many' && r.targetCardinality === 'one' ) { - fkTable = sourceTable; - fkField = sourceField; - refTable = targetTable; - refField = targetField; - } else if ( - r.sourceCardinality === 'one' && - r.targetCardinality === 'one' - ) { + // FK goes on source table (the many side) fkTable = sourceTable; fkField = sourceField; refTable = targetTable; refField = targetField; } else { - return null; + // All other cases: FK goes on target table + fkTable = targetTable; + fkField = targetField; + refTable = sourceTable; + refField = sourceField; } const fkTableName = fkTable.schema diff --git a/src/lib/data/sql-export/cross-dialect/postgresql/to-mysql.ts b/src/lib/data/sql-export/cross-dialect/postgresql/to-mysql.ts index cf409f9c..e98d87c5 100644 --- a/src/lib/data/sql-export/cross-dialect/postgresql/to-mysql.ts +++ b/src/lib/data/sql-export/cross-dialect/postgresql/to-mysql.ts @@ -577,35 +577,31 @@ export function exportPostgreSQLToMySQL({ } // 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) let fkTable, fkField, refTable, refField; if ( - r.sourceCardinality === 'one' && + r.sourceCardinality === 'many' && r.targetCardinality === 'many' ) { - fkTable = targetTable; - fkField = targetField; - refTable = sourceTable; - refField = sourceField; + // Many-to-many relationships need a junction table, skip + return ''; } else if ( r.sourceCardinality === 'many' && r.targetCardinality === 'one' ) { - fkTable = sourceTable; - fkField = sourceField; - refTable = targetTable; - refField = targetField; - } else if ( - r.sourceCardinality === 'one' && - r.targetCardinality === 'one' - ) { + // FK goes on source table (the many side) fkTable = sourceTable; fkField = sourceField; refTable = targetTable; refField = targetField; } else { - // Many-to-many relationships need a junction table, skip - return ''; + // All other cases: FK goes on target table + fkTable = targetTable; + fkField = targetField; + refTable = sourceTable; + refField = sourceField; } const fkTableName = fkTable.schema diff --git a/src/lib/data/sql-export/export-per-type/mssql.ts b/src/lib/data/sql-export/export-per-type/mssql.ts index 4cab4ee5..ab30149a 100644 --- a/src/lib/data/sql-export/export-per-type/mssql.ts +++ b/src/lib/data/sql-export/export-per-type/mssql.ts @@ -296,39 +296,31 @@ export function exportMSSQL({ } // 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) let fkTable, fkField, refTable, refField; if ( - r.sourceCardinality === 'one' && + r.sourceCardinality === 'many' && r.targetCardinality === 'many' ) { - // FK goes on target table - fkTable = targetTable; - fkField = targetField; - refTable = sourceTable; - refField = sourceField; + // Many-to-many relationships need a junction table, skip + return ''; } else if ( r.sourceCardinality === 'many' && r.targetCardinality === 'one' ) { - // FK goes on source table - fkTable = sourceTable; - fkField = sourceField; - refTable = targetTable; - refField = targetField; - } else if ( - r.sourceCardinality === 'one' && - r.targetCardinality === 'one' - ) { - // For 1:1, FK can go on either side, but typically goes on the table that references the other - // We'll keep the current behavior for 1:1 + // FK goes on source table (the many side) fkTable = sourceTable; fkField = sourceField; refTable = targetTable; refField = targetField; } else { - // Many-to-many relationships need a junction table, skip for now - return ''; + // All other cases: FK goes on target table + fkTable = targetTable; + fkField = targetField; + refTable = sourceTable; + refField = sourceField; } const fkTableName = fkTable.schema diff --git a/src/lib/data/sql-export/export-per-type/mysql.ts b/src/lib/data/sql-export/export-per-type/mysql.ts index 82a9547c..f8612709 100644 --- a/src/lib/data/sql-export/export-per-type/mysql.ts +++ b/src/lib/data/sql-export/export-per-type/mysql.ts @@ -480,39 +480,31 @@ export function exportMySQL({ } // 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) let fkTable, fkField, refTable, refField; if ( - r.sourceCardinality === 'one' && + r.sourceCardinality === 'many' && r.targetCardinality === 'many' ) { - // FK goes on target table - fkTable = targetTable; - fkField = targetField; - refTable = sourceTable; - refField = sourceField; + // Many-to-many relationships need a junction table, skip + return ''; } else if ( r.sourceCardinality === 'many' && r.targetCardinality === 'one' ) { - // FK goes on source table - fkTable = sourceTable; - fkField = sourceField; - refTable = targetTable; - refField = targetField; - } else if ( - r.sourceCardinality === 'one' && - r.targetCardinality === 'one' - ) { - // For 1:1, FK can go on either side, but typically goes on the table that references the other - // We'll keep the current behavior for 1:1 + // FK goes on source table (the many side) fkTable = sourceTable; fkField = sourceField; refTable = targetTable; refField = targetField; } else { - // Many-to-many relationships need a junction table, skip for now - return ''; + // All other cases: FK goes on target table + fkTable = targetTable; + fkField = targetField; + refTable = sourceTable; + refField = sourceField; } const fkTableName = fkTable.schema diff --git a/src/lib/data/sql-export/export-per-type/postgresql.ts b/src/lib/data/sql-export/export-per-type/postgresql.ts index 15f07207..7520e7f3 100644 --- a/src/lib/data/sql-export/export-per-type/postgresql.ts +++ b/src/lib/data/sql-export/export-per-type/postgresql.ts @@ -484,39 +484,31 @@ export function exportPostgreSQL({ } // 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) let fkTable, fkField, refTable, refField; if ( - r.sourceCardinality === 'one' && + r.sourceCardinality === 'many' && r.targetCardinality === 'many' ) { - // FK goes on target table - fkTable = targetTable; - fkField = targetField; - refTable = sourceTable; - refField = sourceField; + // Many-to-many relationships need a junction table, skip + return ''; } else if ( r.sourceCardinality === 'many' && r.targetCardinality === 'one' ) { - // FK goes on source table + // FK goes on source table (the many side) fkTable = sourceTable; fkField = sourceField; refTable = targetTable; refField = targetField; - } else if ( - r.sourceCardinality === 'one' && - r.targetCardinality === 'one' - ) { - // For 1:1, FK goes on target table (the table with the FK column) - // Source represents the referenced (parent) table, target is the referencing (child) table + } else { + // All other cases: FK goes on target table fkTable = targetTable; fkField = targetField; refTable = sourceTable; refField = sourceField; - } else { - // Many-to-many relationships need a junction table, skip for now - return ''; } const fkTableName = fkTable.schema diff --git a/src/lib/data/sql-export/export-per-type/sqlite.ts b/src/lib/data/sql-export/export-per-type/sqlite.ts index ad4471f2..9c563609 100644 --- a/src/lib/data/sql-export/export-per-type/sqlite.ts +++ b/src/lib/data/sql-export/export-per-type/sqlite.ts @@ -251,39 +251,31 @@ export function exportSQLite({ } // 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) let fkTable, fkField, refTable, refField; if ( - r.sourceCardinality === 'one' && + r.sourceCardinality === 'many' && r.targetCardinality === 'many' ) { - // FK goes on target table - fkTable = targetTable; - fkField = targetField; - refTable = sourceTable; - refField = sourceField; + // Many-to-many relationships need a junction table, skip + return; } else if ( r.sourceCardinality === 'many' && r.targetCardinality === 'one' ) { - // FK goes on source table - fkTable = sourceTable; - fkField = sourceField; - refTable = targetTable; - refField = targetField; - } else if ( - r.sourceCardinality === 'one' && - r.targetCardinality === 'one' - ) { - // For 1:1, FK can go on either side, but typically goes on the table that references the other - // We'll keep the current behavior for 1:1 + // FK goes on source table (the many side) fkTable = sourceTable; fkField = sourceField; refTable = targetTable; refField = targetField; } else { - // Many-to-many relationships need a junction table, skip for now - return; + // All other cases: FK goes on target table + fkTable = targetTable; + fkField = targetField; + refTable = sourceTable; + refField = sourceField; } // If this foreign key belongs to the current table, add it diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index e84f11a1..1baacf8f 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -664,42 +664,34 @@ export const exportBaseSQL = ({ targetTableField ) { // Determine which table should have the foreign key based on cardinality - // In a 1:many relationship, the foreign key goes on the "many" side - // If source is "one" and target is "many", FK goes on target table - // If source is "many" and target is "one", FK goes on source table + // - 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 === 'one' && + relationship.sourceCardinality === 'many' && relationship.targetCardinality === 'many' ) { - // FK goes on target table - fkTable = targetTable; - fkField = targetTableField; - refTable = sourceTable; - refField = sourceTableField; + // Many-to-many relationships need a junction table, skip + return; } else if ( relationship.sourceCardinality === 'many' && relationship.targetCardinality === 'one' ) { - // FK goes on source table - fkTable = sourceTable; - fkField = sourceTableField; - refTable = targetTable; - refField = targetTableField; - } else if ( - relationship.sourceCardinality === 'one' && - relationship.targetCardinality === 'one' - ) { - // For 1:1, FK can go on either side, but typically goes on the table that references the other - // We'll keep the current behavior for 1:1 + // FK goes on source table (the many side) fkTable = sourceTable; fkField = sourceTableField; refTable = targetTable; refField = targetTableField; } else { - // Many-to-many relationships need a junction table, skip for now - return; + // 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); diff --git a/src/lib/dbml/dbml-export/__tests__/cases/7.inline.dbml b/src/lib/dbml/dbml-export/__tests__/cases/7.inline.dbml index 03cf2fb9..1e06ab4d 100644 --- a/src/lib/dbml/dbml-export/__tests__/cases/7.inline.dbml +++ b/src/lib/dbml/dbml-export/__tests__/cases/7.inline.dbml @@ -1,14 +1,14 @@ Table "clean"."wms_item" { - "id" int64 [note: '''| Źródło: [WMS].[dbo].[wms_items].[id] | Tabele docelowe: [BQ].[reporting].[dim_products_history].[wms_prod_id] | Czym jest dana kolumna: jest to \'WMS\'owe\' id produktu | Informacje dodatkowe: brak''', ref: < "reporting"."wms_dim_products_history"."wms_id_prod"] - "symbol" int64 [note: '''| Źródło: [WMS].[dbo].[wms_items].[symbol] | Tabele docelowe: [BQ].[reporting].[dim_products_history].[iai_prod_id] | Czym jest dana kolumna: jest to \'IAI\'owe\' id produktu | Informacje dodatkowe: brak''', ref: < "reporting"."wms_dim_products_history"."iai_id_prod"] - "ean_code" int64 [note: '| Źródło: [WMS].[dbo].[wms_items].[ean_code] | Tabele docelowe: [BQ].[reporting].[dim_products_history].[ean] | Czym jest dana kolumna: jest to kod ean produktu | Informacje dodatkowe: brak', ref: < "reporting"."wms_dim_products_history"."ean"] + "id" int64 [note: '''| Źródło: [WMS].[dbo].[wms_items].[id] | Tabele docelowe: [BQ].[reporting].[dim_products_history].[wms_prod_id] | Czym jest dana kolumna: jest to \'WMS\'owe\' id produktu | Informacje dodatkowe: brak'''] + "symbol" int64 [note: '''| Źródło: [WMS].[dbo].[wms_items].[symbol] | Tabele docelowe: [BQ].[reporting].[dim_products_history].[iai_prod_id] | Czym jest dana kolumna: jest to \'IAI\'owe\' id produktu | Informacje dodatkowe: brak'''] + "ean_code" int64 [note: '| Źródło: [WMS].[dbo].[wms_items].[ean_code] | Tabele docelowe: [BQ].[reporting].[dim_products_history].[ean] | Czym jest dana kolumna: jest to kod ean produktu | Informacje dodatkowe: brak'] "status" string "dwh_created_at" datetime "dwh_modified_at" datetime } Table "reporting"."wms_dim_products_history" { - "iai_id_prod" int64 - "wms_id_prod" int64 - "ean" int64 + "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"] } diff --git a/src/lib/dbml/dbml-export/__tests__/dbml-export-issue-fix.test.ts b/src/lib/dbml/dbml-export/__tests__/dbml-export-issue-fix.test.ts index c479fb7c..b33b50ce 100644 --- a/src/lib/dbml/dbml-export/__tests__/dbml-export-issue-fix.test.ts +++ b/src/lib/dbml/dbml-export/__tests__/dbml-export-issue-fix.test.ts @@ -1266,12 +1266,14 @@ 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 const expectedInlineDBML = `Table "table_1" { - "id" bigint [pk, not null] + "id" bigint [pk, not null, ref: < "table_2"."id"] } Table "table_2" { - "id" bigint [pk, not null, ref: < "table_1"."id"] + "id" bigint [pk, not null] } `; @@ -1283,7 +1285,7 @@ Table "table_2" { "id" bigint [pk, not null] } -Ref "fk_0_table_2_id_fk":"table_1"."id" < "table_2"."id" +Ref "fk_0_table_2_id_fk":"table_2"."id" < "table_1"."id" `; expect(result.inlineDbml).toBe(expectedInlineDBML); diff --git a/src/pages/editor-page/canvas/relationship-edge/edit-relationship-popover.tsx b/src/pages/editor-page/canvas/relationship-edge/edit-relationship-popover.tsx index ae2a3d69..134b9660 100644 --- a/src/pages/editor-page/canvas/relationship-edge/edit-relationship-popover.tsx +++ b/src/pages/editor-page/canvas/relationship-edge/edit-relationship-popover.tsx @@ -1,13 +1,15 @@ -import React, { useRef } from 'react'; -import { Trash2, ArrowLeftRight } from 'lucide-react'; +import React, { useCallback, useRef } from 'react'; +import { Trash2, ArrowLeftRight, CircleDotDashed } from 'lucide-react'; import { Button } from '@/components/button/button'; import type { Cardinality } from '@/lib/domain/db-relationship'; import { cn } from '@/lib/utils'; import { useClickAway } from 'react-use'; import { useCanvas } from '@/hooks/use-canvas'; +import { useLayout } from '@/hooks/use-layout'; export interface EditRelationshipPopoverProps { anchorPosition: { x: number; y: number }; + relationshipId: string; sourceCardinality: Cardinality; targetCardinality: Cardinality; onCardinalityChange: ( @@ -27,13 +29,13 @@ type RelationshipTypeOption = { const relationshipTypes: RelationshipTypeOption[] = [ { label: '1:1', sourceCardinality: 'one', targetCardinality: 'one' }, { label: '1:N', sourceCardinality: 'one', targetCardinality: 'many' }, - { label: 'N:1', sourceCardinality: 'many', targetCardinality: 'one' }, ]; export const EditRelationshipPopover: React.FC< EditRelationshipPopoverProps > = ({ anchorPosition, + relationshipId, sourceCardinality, targetCardinality, onCardinalityChange, @@ -42,9 +44,21 @@ export const EditRelationshipPopover: React.FC< }) => { const popoverRef = useRef(null); const { closeRelationshipPopover } = useCanvas(); + const { selectSidebarSection, openRelationshipFromSidebar } = useLayout(); useClickAway(popoverRef, closeRelationshipPopover); + const openRelationshipInSidebar = useCallback(() => { + selectSidebarSection('refs'); + openRelationshipFromSidebar(relationshipId); + closeRelationshipPopover(); + }, [ + selectSidebarSection, + openRelationshipFromSidebar, + relationshipId, + closeRelationshipPopover, + ]); + return (
+