From 2b99cd3b626540eb666e371fe64d7651e8d3d7d7 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 26 Jun 2026 18:44:47 +0800 Subject: [PATCH] feat(diagram): add modeled ERD relationships --- .../diagram/SchemaDiagramDialog.vue | 554 +++++++++++++----- apps/desktop/src/i18n/locales/en.ts | 20 + apps/desktop/src/i18n/locales/ja.ts | 20 + apps/desktop/src/i18n/locales/zh-CN.ts | 20 + apps/desktop/src/lib/engineeringDiagram.ts | 4 +- apps/desktop/src/lib/erDiagram.ts | 113 +++- packages/app-tests/erDiagram.test.ts | 133 ++++- 7 files changed, 717 insertions(+), 147 deletions(-) diff --git a/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue b/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue index 290430bb2..266cdd3fc 100644 --- a/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue +++ b/apps/desktop/src/components/diagram/SchemaDiagramDialog.vue @@ -11,13 +11,14 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue"; import * as api from "@/lib/api"; import { DIAGRAM_SQL_TYPES, isSchemaAware as isSchemaAwareDatabase } from "@/lib/databaseCapabilities"; import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions"; -import { buildDiagramRelationships, filterDiagramTables, layoutDiagramTables, type DiagramPosition, type DiagramRelationship, type DiagramTable } from "@/lib/erDiagram"; +import { buildDiagramJoinSql, buildDiagramRelationships, filterDiagramTables, layoutDiagramTables, normalizeCustomDiagramRelationship, type CustomDiagramRelationship, type DiagramPosition, type DiagramRelationship, type DiagramTable } from "@/lib/erDiagram"; import { buildEngineeringDiagram } from "@/lib/engineeringDiagram"; import { buildEngineeringDiagramSvg, buildTableDiagramSvg, diagramSvgFileName } from "@/lib/diagramSvgExport"; import { clampDiagramZoom, zoomFromGestureScale, zoomFromWheelDelta } from "@/lib/diagramZoom"; -import { Download, KeyRound, Link2, Loader2, Maximize2, Network, RefreshCw, Search, Table2, ZoomIn, ZoomOut } from "@lucide/vue"; +import { Copy, Download, KeyRound, Link2, Loader2, Maximize2, Network, Plus, RefreshCw, Search, Table2, Trash2, X, ZoomIn, ZoomOut } from "@lucide/vue"; import { useToast } from "@/composables/useToast"; import { isTauriRuntime } from "@/lib/tauriRuntime"; +import { copyToClipboard } from "@/lib/clipboard"; const { t } = useI18n(); const { toast } = useToast(); @@ -46,6 +47,7 @@ const schema = ref(""); const databases = ref([]); const schemas = ref([]); const tables = ref([]); +const customRelationships = ref([]); const tableSearch = ref(""); const loadingDatabases = ref(false); const loadingSchemas = ref(false); @@ -57,6 +59,7 @@ const positions = ref>({}); const showAllTables = ref(false); const diagramViewport = ref(null); const diagramMode = ref<"table" | "engineering">("table"); +const showRelationshipPanel = ref(false); const zoom = ref(1); const gestureStartZoom = ref(1); const dragging = ref<{ @@ -66,6 +69,14 @@ const dragging = ref<{ originX: number; originY: number; } | null>(null); +const relationshipDraft = ref({ + name: "", + sourceTable: "", + sourceColumn: "", + targetTable: "", + targetColumn: "", + cardinality: "one-to-many" as "one-to-one" | "one-to-many" | "many-to-one", +}); const sqlConnections = computed(() => store.connections.filter((connection) => DIAGRAM_SQL_TYPES.has(connection.db_type))); @@ -73,7 +84,9 @@ const selectedConnection = computed(() => (connectionId.value ? store.getConfig( const isSchemaAware = computed(() => isSchemaAwareDatabase(selectedConnection.value?.db_type)); -const allRelationships = computed(() => buildDiagramRelationships(tables.value)); +const tableMap = computed(() => new Map(tables.value.map((table) => [table.name, table]))); + +const allRelationships = computed(() => buildDiagramRelationships(tables.value, customRelationships.value)); const relatedTableNames = computed(() => { const focus = props.focusTableName; @@ -97,12 +110,20 @@ const visibleTables = computed(() => { const visibleTableMap = computed(() => new Map(visibleTables.value.map((table) => [table.name, table]))); -const visibleRelationships = computed(() => buildDiagramRelationships(visibleTables.value)); +const visibleRelationships = computed(() => buildDiagramRelationships(visibleTables.value, customRelationships.value)); const diagramReady = computed(() => !!connectionId.value && !!database.value && (!isSchemaAware.value || !!schema.value)); const loadingText = computed(() => (totalTableCount.value > 0 ? t("diagram.loadingProgress", { loaded: loadedTableCount.value, total: totalTableCount.value }) : t("diagram.loading"))); +const sourceColumns = computed(() => tableMap.value.get(relationshipDraft.value.sourceTable)?.columns ?? []); + +const targetColumns = computed(() => tableMap.value.get(relationshipDraft.value.targetTable)?.columns ?? []); + +const generatedJoinSql = computed(() => buildDiagramJoinSql(visibleRelationships.value)); + +const customRelationshipCount = computed(() => customRelationships.value.length); + function connectionIconType(id: string) { const config = store.getConfig(id); return config?.driver_profile || config?.db_type || "mysql"; @@ -154,8 +175,130 @@ function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean { return table.foreignKeys.some((fk) => fk.column === columnName); } +function isRelationshipColumn(table: DiagramTable, columnName: string): boolean { + return visibleRelationships.value.some((relationship) => (relationship.sourceTable === table.name && relationship.sourceColumn === columnName) || (relationship.targetTable === table.name && relationship.targetColumn === columnName)); +} + function relationshipTitle(relationship: DiagramRelationship): string { - return `${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`; + return `${relationship.sourceTable}.${relationship.sourceColumn} (${relationship.sourceCardinality}:${relationship.targetCardinality}) -> ${relationship.targetTable}.${relationship.targetColumn}`; +} + +function relationshipStorageKey(): string { + if (!connectionId.value || !database.value) return ""; + return ["dbx", "diagram", "relationships", "v1", connectionId.value, database.value, schema.value || ""].join(":"); +} + +function isStoredRelationship(value: unknown): value is CustomDiagramRelationship { + const relationship = value as Partial; + return ( + typeof relationship?.id === "string" && + typeof relationship.name === "string" && + typeof relationship.sourceTable === "string" && + typeof relationship.sourceColumn === "string" && + typeof relationship.targetTable === "string" && + typeof relationship.targetColumn === "string" && + (relationship.sourceCardinality === "1" || relationship.sourceCardinality === "N") && + (relationship.targetCardinality === "1" || relationship.targetCardinality === "N") + ); +} + +function loadCustomRelationships() { + const key = relationshipStorageKey(); + if (!key || typeof localStorage === "undefined") { + customRelationships.value = []; + return; + } + try { + const parsed = JSON.parse(localStorage.getItem(key) || "[]"); + customRelationships.value = Array.isArray(parsed) ? parsed.filter(isStoredRelationship) : []; + } catch { + customRelationships.value = []; + } +} + +function saveCustomRelationships() { + const key = relationshipStorageKey(); + if (!key || typeof localStorage === "undefined") return; + localStorage.setItem(key, JSON.stringify(customRelationships.value)); +} + +function defaultRelationshipName(relationship: Omit): string { + return `${relationship.sourceTable}_${relationship.sourceColumn}_${relationship.targetTable}_${relationship.targetColumn}`; +} + +function relationshipCardinality(): Pick { + if (relationshipDraft.value.cardinality === "one-to-one") return { sourceCardinality: "1", targetCardinality: "1" }; + if (relationshipDraft.value.cardinality === "many-to-one") return { sourceCardinality: "N", targetCardinality: "1" }; + return { sourceCardinality: "1", targetCardinality: "N" }; +} + +function updateRelationshipDraftDefaults() { + const availableTables = tables.value.filter((table) => table.columns.length > 0); + if (availableTables.length === 0) return; + + if (!tableMap.value.has(relationshipDraft.value.sourceTable)) { + relationshipDraft.value.sourceTable = availableTables[0].name; + } + if (!tableMap.value.has(relationshipDraft.value.targetTable)) { + relationshipDraft.value.targetTable = availableTables[1]?.name ?? availableTables[0].name; + } + if (!sourceColumns.value.some((column) => column.name === relationshipDraft.value.sourceColumn)) { + relationshipDraft.value.sourceColumn = sourceColumns.value[0]?.name ?? ""; + } + if (!targetColumns.value.some((column) => column.name === relationshipDraft.value.targetColumn)) { + relationshipDraft.value.targetColumn = targetColumns.value[0]?.name ?? ""; + } +} + +function addCustomRelationship() { + updateRelationshipDraftDefaults(); + const { sourceTable, sourceColumn, targetTable, targetColumn } = relationshipDraft.value; + if (!sourceTable || !sourceColumn || !targetTable || !targetColumn) { + toast(t("diagram.relationshipIncomplete"), 3000); + return; + } + if (sourceTable === targetTable && sourceColumn === targetColumn) { + toast(t("diagram.relationshipSelfInvalid"), 3000); + return; + } + + const cardinality = relationshipCardinality(); + const relationship = normalizeCustomDiagramRelationship({ + name: relationshipDraft.value.name.trim() || defaultRelationshipName({ sourceTable, sourceColumn, targetTable, targetColumn, ...cardinality }), + sourceTable, + sourceColumn, + targetTable, + targetColumn, + ...cardinality, + }); + + if (customRelationships.value.some((item) => item.id === relationship.id)) { + toast(t("diagram.relationshipExists"), 3000); + return; + } + + customRelationships.value = [...customRelationships.value, relationship]; + relationshipDraft.value.name = ""; + saveCustomRelationships(); + toast(t("diagram.relationshipAdded"), 2000); +} + +function removeCustomRelationship(id: string) { + customRelationships.value = customRelationships.value.filter((relationship) => relationship.id !== id); + saveCustomRelationships(); +} + +async function copyJoinSql() { + if (!generatedJoinSql.value.trim()) { + toast(t("diagram.noJoinSql"), 3000); + return; + } + try { + await copyToClipboard(generatedJoinSql.value); + toast(t("grid.copied")); + } catch (e: any) { + toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000); + } } function columnAnchorY(tableName: string, columnName: string): number { @@ -347,6 +490,7 @@ async function setConnection(id: string) { database.value = ""; schema.value = ""; tables.value = []; + customRelationships.value = []; positions.value = {}; await loadDatabases(id); if (databases.value.length === 1) { @@ -357,6 +501,7 @@ async function setConnection(id: string) { async function setDatabase(value: string) { database.value = value; tables.value = []; + customRelationships.value = []; positions.value = {}; await loadSchemas(); if (diagramReady.value) await loadDiagram(); @@ -365,6 +510,7 @@ async function setDatabase(value: string) { async function setSchema(value: string) { schema.value = value; tables.value = []; + customRelationships.value = []; positions.value = {}; if (diagramReady.value) await loadDiagram(); } @@ -405,6 +551,8 @@ async function loadDiagram() { } tables.value = loadedTables; + loadCustomRelationships(); + updateRelationshipDraftDefaults(); showAllTables.value = false; await nextTick(); resetLayout(); @@ -425,8 +573,10 @@ async function initialize() { databases.value = []; schemas.value = []; tables.value = []; + customRelationships.value = []; tableSearch.value = ""; showAllTables.value = false; + showRelationshipPanel.value = false; diagramMode.value = "table"; zoom.value = 1; positions.value = {}; @@ -599,6 +749,24 @@ watch( }, ); +watch( + () => relationshipDraft.value.sourceTable, + () => { + if (!sourceColumns.value.some((column) => column.name === relationshipDraft.value.sourceColumn)) { + relationshipDraft.value.sourceColumn = sourceColumns.value[0]?.name ?? ""; + } + }, +); + +watch( + () => relationshipDraft.value.targetTable, + () => { + if (!targetColumns.value.some((column) => column.name === relationshipDraft.value.targetColumn)) { + relationshipDraft.value.targetColumn = targetColumns.value[0]?.name ?? ""; + } + }, +); + onUnmounted(stopDrag); @@ -665,6 +833,16 @@ onUnmounted(stopDrag); + + + + @@ -675,6 +853,9 @@ onUnmounted(stopDrag); {{ t("diagram.relationshipsCount", { count: visibleRelationships.length }) }} + + {{ t("diagram.customRelationshipsCount", { count: customRelationshipCount }) }} + -
-
- - {{ loadingText }} +
+
+
+
+
{{ t("diagram.relationshipName") }}
+ +
+ +
+
{{ t("diagram.sourceTable") }}
+ +
+ +
+
{{ t("diagram.sourceColumn") }}
+ +
+ +
+
{{ t("diagram.cardinality") }}
+ +
+ +
+
{{ t("diagram.targetTable") }}
+ +
+ +
+
{{ t("diagram.targetColumn") }}
+ +
+ + + +
+ +
+ + {{ relationship.sourceTable }}.{{ relationship.sourceColumn }} {{ relationship.sourceCardinality }}:{{ relationship.targetCardinality }} {{ relationship.targetTable }}.{{ relationship.targetColumn }} + + +
-
- {{ t("diagram.selectTarget") }} -
-
- {{ t("diagram.empty") }} -
-
- {{ t("diagram.noMatches") }} -
-
-
+ +
+
+ + {{ loadingText }} +
+
+ {{ t("diagram.selectTarget") }} +
+
+ {{ t("diagram.empty") }} +
+
+ {{ t("diagram.noMatches") }} +
+
- - +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 69d873f17..63c9bad0a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1566,6 +1566,26 @@ export default { relationshipsCount: "{count} relationships", tableMode: "Table View", engineeringMode: "Engineering ER", + modelRelationships: "Model Relationships", + copyJoinSql: "Copy SQL", + customRelationshipsCount: "{count} modeled", + relationshipName: "Name", + relationshipNamePlaceholder: "Optional", + sourceTable: "Source table", + sourceColumn: "Source column", + targetTable: "Target table", + targetColumn: "Target column", + cardinality: "Cardinality", + cardinalityOneToMany: "1:N", + cardinalityManyToOne: "N:1", + cardinalityOneToOne: "1:1", + addRelationship: "Add relationship", + removeRelationship: "Remove relationship", + relationshipIncomplete: "Select both source and target columns", + relationshipSelfInvalid: "A relationship cannot target the same column", + relationshipExists: "This relationship already exists", + relationshipAdded: "Relationship added", + noJoinSql: "No relationship SQL to copy", allTables: "All Tables", relatedTables: "Related Tables", moreColumns: "+ {count} columns", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index b140cde3f..497876689 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1540,6 +1540,26 @@ export default { relationshipsCount: "{count}リレーション", tableMode: "テーブルビュー", engineeringMode: "エンジニアリングER", + modelRelationships: "モデル関係", + copyJoinSql: "SQLをコピー", + customRelationshipsCount: "{count}件モデル化", + relationshipName: "名前", + relationshipNamePlaceholder: "任意", + sourceTable: "ソーステーブル", + sourceColumn: "ソース列", + targetTable: "ターゲットテーブル", + targetColumn: "ターゲット列", + cardinality: "カーディナリティ", + cardinalityOneToMany: "1:N", + cardinalityManyToOne: "N:1", + cardinalityOneToOne: "1:1", + addRelationship: "関係を追加", + removeRelationship: "関係を削除", + relationshipIncomplete: "ソース列とターゲット列を選択してください", + relationshipSelfInvalid: "同じ列への関係は作成できません", + relationshipExists: "この関係はすでに存在します", + relationshipAdded: "関係を追加しました", + noJoinSql: "コピーできる関係SQLがありません", allTables: "全テーブル", relatedTables: "関連テーブル", moreColumns: "+ {count}列", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 356748cc3..e44a03dab 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1565,6 +1565,26 @@ export default { relationshipsCount: "{count} 条关系", tableMode: "表结构图", engineeringMode: "工程 ER 图", + modelRelationships: "建模关系", + copyJoinSql: "复制 SQL", + customRelationshipsCount: "{count} 条建模", + relationshipName: "名称", + relationshipNamePlaceholder: "可选", + sourceTable: "源表", + sourceColumn: "源字段", + targetTable: "目标表", + targetColumn: "目标字段", + cardinality: "基数", + cardinalityOneToMany: "1:N", + cardinalityManyToOne: "N:1", + cardinalityOneToOne: "1:1", + addRelationship: "添加关系", + removeRelationship: "删除关系", + relationshipIncomplete: "请选择源字段和目标字段", + relationshipSelfInvalid: "关系不能指向同一个字段", + relationshipExists: "该关系已存在", + relationshipAdded: "关系已添加", + noJoinSql: "暂无可复制的关系 SQL", allTables: "全部表", relatedTables: "相关表", moreColumns: "+ {count} 个字段", diff --git a/apps/desktop/src/lib/engineeringDiagram.ts b/apps/desktop/src/lib/engineeringDiagram.ts index 2185522c3..f859908af 100644 --- a/apps/desktop/src/lib/engineeringDiagram.ts +++ b/apps/desktop/src/lib/engineeringDiagram.ts @@ -336,8 +336,8 @@ export function buildEngineeringDiagram(tables: DiagramTable[], relationships: D label: relationshipLabel(relationship), sourceTable: relationship.sourceTable, targetTable: relationship.targetTable, - sourceCardinality: "N", - targetCardinality: "1", + sourceCardinality: relationship.sourceCardinality ?? "N", + targetCardinality: relationship.targetCardinality ?? "1", x: (sourceCenter.x + targetCenter.x) / 2 - ENGINEERING_RELATIONSHIP_WIDTH / 2, y: (sourceCenter.y + targetCenter.y) / 2 - ENGINEERING_RELATIONSHIP_HEIGHT / 2, width: ENGINEERING_RELATIONSHIP_WIDTH, diff --git a/apps/desktop/src/lib/erDiagram.ts b/apps/desktop/src/lib/erDiagram.ts index 4a7365193..fd61c935b 100644 --- a/apps/desktop/src/lib/erDiagram.ts +++ b/apps/desktop/src/lib/erDiagram.ts @@ -9,10 +9,29 @@ export interface DiagramTable { export interface DiagramRelationship { id: string; name: string; + kind: "foreign-key" | "custom"; sourceTable: string; sourceColumn: string; targetTable: string; targetColumn: string; + sourceCardinality: "1" | "N"; + targetCardinality: "1" | "N"; +} + +export interface CustomDiagramRelationship { + id: string; + name: string; + sourceTable: string; + sourceColumn: string; + targetTable: string; + targetColumn: string; + sourceCardinality: "1" | "N"; + targetCardinality: "1" | "N"; +} + +export interface DiagramJoinSqlOptions { + joinType?: "INNER JOIN" | "LEFT JOIN"; + rootTable?: string; } export interface DiagramPosition { @@ -33,21 +52,50 @@ function relationshipId(sourceTable: string, fk: ForeignKeyInfo): string { return [sourceTable, fk.name || "foreign_key", fk.column, fk.ref_table, fk.ref_column].join(":"); } -export function buildDiagramRelationships(tables: DiagramTable[]): DiagramRelationship[] { - const visibleTableNames = new Set(tables.map((table) => table.name)); +function columnExists(table: DiagramTable | undefined, columnName: string): boolean { + return !!table?.columns.some((column) => column.name === columnName); +} - return tables.flatMap((table) => +function customRelationshipId(relationship: Omit): string { + return ["custom", relationship.sourceTable, relationship.sourceColumn, relationship.targetTable, relationship.targetColumn, relationship.sourceCardinality, relationship.targetCardinality].join(":"); +} + +export function normalizeCustomDiagramRelationship(input: Omit & { id?: string }): CustomDiagramRelationship { + return { + ...input, + id: input.id || customRelationshipId(input), + }; +} + +export function buildDiagramRelationships(tables: DiagramTable[], customRelationships: CustomDiagramRelationship[] = []): DiagramRelationship[] { + const visibleTableNames = new Set(tables.map((table) => table.name)); + const tableMap = new Map(tables.map((table) => [table.name, table])); + + const foreignKeyRelationships = tables.flatMap((table) => table.foreignKeys .filter((fk) => visibleTableNames.has(fk.ref_table)) .map((fk) => ({ id: relationshipId(table.name, fk), name: fk.name, + kind: "foreign-key" as const, sourceTable: table.name, sourceColumn: fk.column, targetTable: fk.ref_table, targetColumn: fk.ref_column, + sourceCardinality: "N" as const, + targetCardinality: "1" as const, })), ); + + const custom = customRelationships + .filter((relationship) => visibleTableNames.has(relationship.sourceTable) && visibleTableNames.has(relationship.targetTable)) + .filter((relationship) => columnExists(tableMap.get(relationship.sourceTable), relationship.sourceColumn) && columnExists(tableMap.get(relationship.targetTable), relationship.targetColumn)) + .map((relationship) => ({ + ...relationship, + kind: "custom" as const, + })); + + return [...foreignKeyRelationships, ...custom]; } export function filterDiagramTables(tables: DiagramTable[], query: string): DiagramTable[] { @@ -83,3 +131,62 @@ export function layoutDiagramTables(tables: Pick): string { + const sourceAlias = aliases.get(relationship.sourceTable) ?? quoteIdentifier(relationship.sourceTable); + const targetAlias = aliases.get(relationship.targetTable) ?? quoteIdentifier(relationship.targetTable); + return `${sourceAlias}.${quoteIdentifier(relationship.sourceColumn)} = ${targetAlias}.${quoteIdentifier(relationship.targetColumn)}`; +} + +function nextJoinableRelationship(relationships: DiagramRelationship[], joinedTables: Set, consumedRelationships: Set): DiagramRelationship | undefined { + return relationships.find((relationship) => { + if (consumedRelationships.has(relationship.id)) return false; + const sourceJoined = joinedTables.has(relationship.sourceTable); + const targetJoined = joinedTables.has(relationship.targetTable); + return sourceJoined !== targetJoined || (!sourceJoined && !targetJoined && joinedTables.size === 0); + }); +} + +export function buildDiagramJoinSql(relationships: DiagramRelationship[], options: DiagramJoinSqlOptions = {}): string { + const joinableRelationships = relationships.filter((relationship) => relationship.sourceTable && relationship.sourceColumn && relationship.targetTable && relationship.targetColumn); + if (joinableRelationships.length === 0) return ""; + + const joinType = options.joinType ?? "LEFT JOIN"; + const rootTable = options.rootTable && joinableRelationships.some((relationship) => relationship.sourceTable === options.rootTable || relationship.targetTable === options.rootTable) ? options.rootTable : joinableRelationships[0].sourceTable; + const joinedTables = new Set([rootTable]); + const aliases = new Map([[rootTable, "t1"]]); + const consumedRelationships = new Set(); + const joinLines: string[] = []; + + while (consumedRelationships.size < joinableRelationships.length) { + const relationship = nextJoinableRelationship(joinableRelationships, joinedTables, consumedRelationships); + if (!relationship) break; + + const sourceJoined = joinedTables.has(relationship.sourceTable); + const targetJoined = joinedTables.has(relationship.targetTable); + const tableToJoin = sourceJoined && !targetJoined ? relationship.targetTable : relationship.sourceTable; + if (!joinedTables.has(tableToJoin)) { + const previousJoinedTables = new Set(joinedTables); + aliases.set(tableToJoin, `t${aliases.size + 1}`); + joinedTables.add(tableToJoin); + const joinConditions = joinableRelationships.filter((item) => !consumedRelationships.has(item.id) && ((item.sourceTable === tableToJoin && previousJoinedTables.has(item.targetTable)) || (item.targetTable === tableToJoin && previousJoinedTables.has(item.sourceTable)))); + joinConditions.forEach((item) => consumedRelationships.add(item.id)); + joinLines.push(`${joinType} ${quoteIdentifier(tableToJoin)} ${aliases.get(tableToJoin)} ON ${joinConditions.map((item) => relationshipCondition(item, aliases)).join(" AND ")}`); + } + consumedRelationships.add(relationship.id); + } + + const whereRelationships = joinableRelationships.filter((relationship) => !consumedRelationships.has(relationship.id) && joinedTables.has(relationship.sourceTable) && joinedTables.has(relationship.targetTable)); + whereRelationships.forEach((relationship) => consumedRelationships.add(relationship.id)); + const whereLine = whereRelationships.length > 0 ? `WHERE ${whereRelationships.map((relationship) => relationshipCondition(relationship, aliases)).join(" AND ")}` : ""; + const selectList = [...joinedTables].map((table) => ` ${aliases.get(table)}.*`).join(",\n"); + const disconnectedRelationships = joinableRelationships.filter((relationship) => !consumedRelationships.has(relationship.id)); + const disconnectedNotes = disconnectedRelationships.map((relationship) => `-- Disconnected relationship skipped: ${relationship.sourceTable}.${relationship.sourceColumn} = ${relationship.targetTable}.${relationship.targetColumn}`); + + return [`SELECT`, selectList, `FROM ${quoteIdentifier(rootTable)} ${aliases.get(rootTable)}`, ...joinLines, whereLine, ...disconnectedNotes].filter(Boolean).join("\n"); +} diff --git a/packages/app-tests/erDiagram.test.ts b/packages/app-tests/erDiagram.test.ts index cc49bd3d6..3c30421b0 100644 --- a/packages/app-tests/erDiagram.test.ts +++ b/packages/app-tests/erDiagram.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { buildDiagramRelationships, filterDiagramTables, layoutDiagramTables } from "../../apps/desktop/src/lib/erDiagram.ts"; +import { buildDiagramJoinSql, buildDiagramRelationships, filterDiagramTables, layoutDiagramTables, normalizeCustomDiagramRelationship } from "../../apps/desktop/src/lib/erDiagram.ts"; test("builds relationships only between tables in the diagram", () => { const relationships = buildDiagramRelationships([ @@ -23,14 +23,86 @@ test("builds relationships only between tables in the diagram", () => { { id: "orders:orders_user_id_fk:user_id:users:id", name: "orders_user_id_fk", + kind: "foreign-key", sourceTable: "orders", sourceColumn: "user_id", targetTable: "users", targetColumn: "id", + sourceCardinality: "N", + targetCardinality: "1", }, ]); }); +test("merges valid custom relationships with foreign key relationships", () => { + const relationship = normalizeCustomDiagramRelationship({ + name: "users_audit", + sourceTable: "users", + sourceColumn: "email", + targetTable: "audit_log", + targetColumn: "actor_email", + sourceCardinality: "1", + targetCardinality: "N", + }); + + const relationships = buildDiagramRelationships( + [ + { + name: "users", + columns: [{ name: "email", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + foreignKeys: [], + }, + { + name: "audit_log", + columns: [{ name: "actor_email", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null }], + foreignKeys: [], + }, + ], + [relationship], + ); + + assert.deepEqual(relationships, [ + { + ...relationship, + kind: "custom", + }, + ]); +}); + +test("ignores custom relationships with missing tables or columns", () => { + const relationships = buildDiagramRelationships( + [ + { + name: "users", + columns: [{ name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + foreignKeys: [], + }, + ], + [ + normalizeCustomDiagramRelationship({ + name: "missing_table", + sourceTable: "users", + sourceColumn: "id", + targetTable: "orders", + targetColumn: "user_id", + sourceCardinality: "1", + targetCardinality: "N", + }), + normalizeCustomDiagramRelationship({ + name: "missing_column", + sourceTable: "users", + sourceColumn: "email", + targetTable: "users", + targetColumn: "id", + sourceCardinality: "1", + targetCardinality: "1", + }), + ], + ); + + assert.deepEqual(relationships, []); +}); + test("filters diagram tables by table, column, and foreign key names", () => { const tables = [ { @@ -75,3 +147,62 @@ test("lays out diagram tables in stable rows", () => { line_items: { x: 40, y: 250 }, }); }); + +test("generates join SQL from diagram relationships", () => { + const relationships = buildDiagramRelationships( + [ + { + name: "users", + columns: [{ name: "id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: true, extra: null }], + foreignKeys: [], + }, + { + name: "orders", + columns: [{ name: "user_id", data_type: "int", is_nullable: false, column_default: null, is_primary_key: false, extra: null }], + foreignKeys: [{ name: "orders_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" }], + }, + ], + [], + ); + + assert.equal( + buildDiagramJoinSql(relationships), + `SELECT + t1.*, + t2.* +FROM orders t1 +LEFT JOIN users t2 ON t1.user_id = t2.id`, + ); +}); + +test("combines multiple relationship conditions between joined tables", () => { + const relationships = [ + normalizeCustomDiagramRelationship({ + name: "orders_customer_id", + sourceTable: "orders", + sourceColumn: "customer_id", + targetTable: "customers", + targetColumn: "id", + sourceCardinality: "N", + targetCardinality: "1", + }), + normalizeCustomDiagramRelationship({ + name: "orders_customer_region", + sourceTable: "orders", + sourceColumn: "customer_region", + targetTable: "customers", + targetColumn: "region", + sourceCardinality: "N", + targetCardinality: "1", + }), + ].map((relationship) => ({ ...relationship, kind: "custom" as const })); + + assert.equal( + buildDiagramJoinSql(relationships), + `SELECT + t1.*, + t2.* +FROM orders t1 +LEFT JOIN customers t2 ON t1.customer_id = t2.id AND t1.customer_region = t2.region`, + ); +});