mirror of
https://github.com/chartdb/chartdb.git
synced 2026-02-09 13:14:31 -06:00
fix: normalize relationship cardinalities so many is always on target (#1051)
This commit is contained in:
@@ -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")'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const { closeRelationshipPopover } = useCanvas();
|
||||
const { selectSidebarSection, openRelationshipFromSidebar } = useLayout();
|
||||
|
||||
useClickAway(popoverRef, closeRelationshipPopover);
|
||||
|
||||
const openRelationshipInSidebar = useCallback(() => {
|
||||
selectSidebarSection('refs');
|
||||
openRelationshipFromSidebar(relationshipId);
|
||||
closeRelationshipPopover();
|
||||
}, [
|
||||
selectSidebarSection,
|
||||
openRelationshipFromSidebar,
|
||||
relationshipId,
|
||||
closeRelationshipPopover,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
@@ -96,6 +110,19 @@ export const EditRelationshipPopover: React.FC<
|
||||
>
|
||||
<ArrowLeftRight className="!size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 p-0 text-slate-500 hover:bg-slate-100 hover:text-slate-700"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openRelationshipInSidebar();
|
||||
}}
|
||||
title="Open in sidebar"
|
||||
>
|
||||
<CircleDotDashed className="!size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -93,18 +93,48 @@ export const RelationshipEdge: React.FC<EdgeProps<RelationshipEdgeType>> =
|
||||
|
||||
const handleSwitchTables = useCallback(async () => {
|
||||
if (!relationship) return;
|
||||
await updateRelationship(
|
||||
id,
|
||||
{
|
||||
sourceTableId: relationship.targetTableId,
|
||||
targetTableId: relationship.sourceTableId,
|
||||
sourceFieldId: relationship.targetFieldId,
|
||||
targetFieldId: relationship.sourceFieldId,
|
||||
sourceCardinality: relationship.targetCardinality,
|
||||
targetCardinality: relationship.sourceCardinality,
|
||||
},
|
||||
{ updateHistory: true }
|
||||
);
|
||||
|
||||
const sameCardinality =
|
||||
relationship.sourceCardinality ===
|
||||
relationship.targetCardinality;
|
||||
|
||||
if (sameCardinality) {
|
||||
// Equal cardinalities: swap everything (tables, fields, cardinalities)
|
||||
await updateRelationship(
|
||||
id,
|
||||
{
|
||||
sourceTableId: relationship.targetTableId,
|
||||
targetTableId: relationship.sourceTableId,
|
||||
sourceFieldId: relationship.targetFieldId,
|
||||
targetFieldId: relationship.sourceFieldId,
|
||||
sourceCardinality: relationship.targetCardinality,
|
||||
targetCardinality: relationship.sourceCardinality,
|
||||
},
|
||||
{ updateHistory: true }
|
||||
);
|
||||
} else if (relationship.sourceCardinality === 'many') {
|
||||
// many:one → one:many (swap cardinalities so "many" moves to target)
|
||||
await updateRelationship(
|
||||
id,
|
||||
{
|
||||
sourceCardinality: 'one',
|
||||
targetCardinality: 'many',
|
||||
},
|
||||
{ updateHistory: true }
|
||||
);
|
||||
} else {
|
||||
// one:many → swap tables/fields (keeps one:many with different tables)
|
||||
await updateRelationship(
|
||||
id,
|
||||
{
|
||||
sourceTableId: relationship.targetTableId,
|
||||
targetTableId: relationship.sourceTableId,
|
||||
sourceFieldId: relationship.targetFieldId,
|
||||
targetFieldId: relationship.sourceFieldId,
|
||||
},
|
||||
{ updateHistory: true }
|
||||
);
|
||||
}
|
||||
|
||||
closeRelationshipPopover();
|
||||
}, [
|
||||
@@ -120,14 +150,37 @@ export const RelationshipEdge: React.FC<EdgeProps<RelationshipEdgeType>> =
|
||||
newTargetCardinality: Cardinality
|
||||
) => {
|
||||
if (!relationship) return;
|
||||
await updateRelationship(
|
||||
id,
|
||||
{
|
||||
sourceCardinality: newSourceCardinality,
|
||||
targetCardinality: newTargetCardinality,
|
||||
},
|
||||
{ updateHistory: true }
|
||||
);
|
||||
|
||||
// Ensure "many" is always on target side when cardinalities differ
|
||||
// If trying to set many:one (N:1), swap tables and set one:many
|
||||
if (
|
||||
newSourceCardinality === 'many' &&
|
||||
newTargetCardinality === 'one'
|
||||
) {
|
||||
await updateRelationship(
|
||||
id,
|
||||
{
|
||||
// Swap tables/fields
|
||||
sourceTableId: relationship.targetTableId,
|
||||
targetTableId: relationship.sourceTableId,
|
||||
sourceFieldId: relationship.targetFieldId,
|
||||
targetFieldId: relationship.sourceFieldId,
|
||||
// Set one:many (many on target)
|
||||
sourceCardinality: 'one',
|
||||
targetCardinality: 'many',
|
||||
},
|
||||
{ updateHistory: true }
|
||||
);
|
||||
} else {
|
||||
await updateRelationship(
|
||||
id,
|
||||
{
|
||||
sourceCardinality: newSourceCardinality,
|
||||
targetCardinality: newTargetCardinality,
|
||||
},
|
||||
{ updateHistory: true }
|
||||
);
|
||||
}
|
||||
closeRelationshipPopover();
|
||||
},
|
||||
[id, relationship, updateRelationship, closeRelationshipPopover]
|
||||
@@ -371,6 +424,7 @@ export const RelationshipEdge: React.FC<EdgeProps<RelationshipEdgeType>> =
|
||||
anchorPosition={
|
||||
editRelationshipPopover.position
|
||||
}
|
||||
relationshipId={id}
|
||||
sourceCardinality={
|
||||
relationship.sourceCardinality ?? 'one'
|
||||
}
|
||||
|
||||
@@ -130,11 +130,28 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
|
||||
}, [relationships, tableNodeId, field.id]);
|
||||
|
||||
const isForeignKey = useMemo(() => {
|
||||
return relationships.some(
|
||||
(rel) =>
|
||||
return relationships.some((rel) => {
|
||||
// FK placement logic:
|
||||
// - FK goes on the "many" side when cardinalities differ
|
||||
// - FK goes on target when cardinalities are the same (one:one, many:many)
|
||||
// The only case where FK goes on source is many:one
|
||||
const fkOnSource =
|
||||
rel.sourceCardinality === 'many' &&
|
||||
rel.targetCardinality === 'one';
|
||||
|
||||
if (fkOnSource) {
|
||||
return (
|
||||
rel.sourceTableId === tableNodeId &&
|
||||
rel.sourceFieldId === field.id
|
||||
);
|
||||
}
|
||||
|
||||
// All other cases: FK on target
|
||||
return (
|
||||
rel.targetTableId === tableNodeId &&
|
||||
rel.targetFieldId === field.id
|
||||
);
|
||||
);
|
||||
});
|
||||
}, [relationships, tableNodeId, field.id]);
|
||||
|
||||
const previousNumberOfEdgesToFieldRef = useRef(numberOfEdgesToField);
|
||||
|
||||
Reference in New Issue
Block a user