diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d05679a05..ae04040f1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1548,7 +1548,7 @@ dependencies = [ [[package]] name = "dbx" -version = "0.3.4" +version = "0.3.5" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/src/db/mysql.rs b/src-tauri/src/db/mysql.rs index f6b926de2..e7871f578 100644 --- a/src-tauri/src/db/mysql.rs +++ b/src-tauri/src/db/mysql.rs @@ -30,6 +30,39 @@ fn get_opt_str(row: &MySqlRow, name: &str) -> Option { }) } +fn numeric_metadata_u64_to_i32(value: Option) -> Option { + value.and_then(|v| i32::try_from(v).ok()) +} + +fn numeric_metadata_i64_to_i32(value: Option) -> Option { + value.and_then(|v| i32::try_from(v).ok()) +} + +fn numeric_metadata_str_to_i32(value: Option) -> Option { + value.and_then(|v| v.parse::().ok()) + .and_then(|v| i32::try_from(v).ok()) +} + +fn get_opt_i32(row: &MySqlRow, name: &str) -> Option { + if row.try_get_raw(name).map(|v| v.is_null()).unwrap_or(true) { + return None; + } + + row.try_get::, _>(name) + .ok() + .flatten() + .or_else(|| numeric_metadata_i64_to_i32(row.try_get::, _>(name).ok().flatten())) + .or_else(|| numeric_metadata_u64_to_i32(row.try_get::, _>(name).ok().flatten())) + .or_else(|| numeric_metadata_str_to_i32(row.try_get::, _>(name).ok().flatten())) + .or_else(|| { + row.try_get::>, _>(name) + .ok() + .flatten() + .and_then(|b| String::from_utf8(b).ok()) + .and_then(|v| numeric_metadata_str_to_i32(Some(v))) + }) +} + fn mysql_temporal_to_json_value(row: &MySqlRow, idx: usize) -> Option { if let Ok(v) = row.try_get::(idx) { return Some(serde_json::Value::String(v.to_string())); @@ -193,8 +226,8 @@ pub async fn get_columns( is_primary_key: row.get::("IS_PK") == 1, extra: get_opt_str(row, "EXTRA"), comment: get_opt_str(row, "COLUMN_COMMENT").filter(|s| !s.is_empty()), - numeric_precision: row.get::, _>("NUMERIC_PRECISION"), - numeric_scale: row.get::, _>("NUMERIC_SCALE"), + numeric_precision: get_opt_i32(row, "NUMERIC_PRECISION"), + numeric_scale: get_opt_i32(row, "NUMERIC_SCALE"), }) .collect()) } @@ -326,3 +359,19 @@ pub async fn list_triggers(pool: &MySqlPool, database: &str, table: &str) -> Res }) .collect()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numeric_metadata_accepts_unsigned_information_schema_values() { + assert_eq!(numeric_metadata_u64_to_i32(Some(65)), Some(65)); + } + + #[test] + fn numeric_metadata_ignores_values_outside_frontend_range() { + assert_eq!(numeric_metadata_u64_to_i32(Some(i32::MAX as u64 + 1)), None); + assert_eq!(numeric_metadata_u64_to_i32(None), None); + } +} diff --git a/src/App.vue b/src/App.vue index b4848bfbd..33680acb2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -36,6 +36,7 @@ import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue"; import DataTransferDialog from "@/components/transfer/DataTransferDialog.vue"; import SchemaDiffDialog from "@/components/diff/SchemaDiffDialog.vue"; import SqlFileExecutionDialog from "@/components/sql-file/SqlFileExecutionDialog.vue"; +import SchemaDiagramDialog from "@/components/diagram/SchemaDiagramDialog.vue"; import type { ConnectionConfig } from "@/types/database"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; @@ -117,12 +118,17 @@ const showDangerDialog = ref(false); const showTransferDialog = ref(false); const showSchemaDiffDialog = ref(false); const showSqlFileDialog = ref(false); +const showDiagramDialog = ref(false); const transferPrefillConnectionId = ref(""); const transferPrefillDatabase = ref(""); const schemaDiffPrefillConnectionId = ref(""); const schemaDiffPrefillDatabase = ref(""); const sqlFilePrefillConnectionId = ref(""); const sqlFilePrefillDatabase = ref(""); +const diagramPrefillConnectionId = ref(""); +const diagramPrefillDatabase = ref(""); +const diagramPrefillSchema = ref(""); +const diagramFocusTableName = ref(""); const databaseOptions = ref>({}); const loadingDatabaseOptions = ref>({}); const checkingUpdates = ref(false); @@ -177,6 +183,17 @@ watch(() => connectionStore.sqlFileSource, (v) => { } }); +watch(() => connectionStore.diagramSource, (v) => { + if (v) { + diagramPrefillConnectionId.value = v.connectionId; + diagramPrefillDatabase.value = v.database; + diagramPrefillSchema.value = v.schema ?? ""; + diagramFocusTableName.value = v.tableName ?? ""; + showDiagramDialog.value = true; + connectionStore.diagramSource = null; + } +}); + function onConnectionConnectStarted(name: string) { toast(t("connection.connecting", { name }), 30000); } @@ -1210,6 +1227,13 @@ async function setupFileDrop() { :prefill-connection-id="sqlFilePrefillConnectionId" :prefill-database="sqlFilePrefillDatabase" /> + diff --git a/src/components/diagram/SchemaDiagramDialog.vue b/src/components/diagram/SchemaDiagramDialog.vue new file mode 100644 index 000000000..b677eaa85 --- /dev/null +++ b/src/components/diagram/SchemaDiagramDialog.vue @@ -0,0 +1,956 @@ + + + diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index 1957b1932..05c8c02ab 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -5,7 +5,7 @@ import { Database, Table, Columns3, Eye, ChevronRight, ChevronDown, Loader2, FolderOpen, Trash2, TerminalSquare, RefreshCw, Copy, TableProperties, Key, Link, Zap, ListTree, Pencil, Plug, Unplug, - Pin, ArrowRightLeft, Download, FileCode, + Pin, ArrowRightLeft, Download, FileCode, Network, } from "lucide-vue-next"; import { ContextMenu, ContextMenuContent, ContextMenuItem, @@ -34,6 +34,7 @@ const props = defineProps<{ }>(); const sqlFileUnsupportedTypes = new Set(["redis", "mongodb", "elasticsearch"]); +const diagramSupportedTypes = new Set(["mysql", "postgres", "sqlite", "sqlserver", "oracle", "redshift"]); function quoteIdent(name: string): string { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; @@ -348,12 +349,27 @@ function openSqlFileExecution() { } } +function openDiagram() { + const node = props.node; + if (!node.connectionId || !node.database) return; + connectionStore.diagramSource = { + connectionId: node.connectionId, + database: node.database, + schema: node.schema, + tableName: node.type === "table" ? node.label : undefined, + }; +} + const canExpand = !leafTypes.has(props.node.type); const canPin = computed(() => pinnableTypes.has(props.node.type)); const canOpenSqlFileExecution = computed(() => { const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; return !!config && !sqlFileUnsupportedTypes.has(config.db_type); }); +const canOpenDiagram = computed(() => { + const config = props.node.connectionId ? connectionStore.getConfig(props.node.connectionId) : undefined; + return !!props.node.database && !!config && diagramSupportedTypes.has(config.db_type); +}); const isPinned = computed(() => props.node.pinned || connectionStore.isTreeNodePinned(props.node.id)); const hasTypeMenu = computed(() => { const t = props.node.type; @@ -486,6 +502,9 @@ async function showMore() { {{ t('sqlFile.title') }} + + {{ t('diagram.open') }} + {{ t('contextMenu.refreshChildren') }} @@ -505,6 +524,9 @@ async function showMore() { {{ t('contextMenu.newQuery') }} + + {{ t('diagram.open') }} + diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8802f1943..0f08e620c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -218,6 +218,34 @@ export default { foreignKeys: "Foreign Keys", triggers: "Triggers", }, + diagram: { + title: "Relationship Diagram", + open: "View Diagram", + selectConnection: "Select connection", + selectDatabase: "Select database", + selectSchema: "Select schema", + searchTables: "Search tables, columns, keys...", + refresh: "Refresh diagram", + loading: "Reading relationships...", + loadingProgress: "Reading relationships... {loaded}/{total}", + partialError: "Skipped metadata for {count} tables that failed to load", + selectTarget: "Select a connection and database", + empty: "No tables to display", + noMatches: "No matching tables", + tablesCount: "{count} tables", + relationshipsCount: "{count} relationships", + tableMode: "Table View", + engineeringMode: "Engineering ER", + allTables: "All Tables", + relatedTables: "Related Tables", + moreColumns: "+ {count} columns", + exportSvg: "Export SVG", + exportedSvg: "SVG exported", + exportSvgFailed: "Failed to export SVG: {message}", + zoomIn: "Zoom in", + zoomOut: "Zoom out", + resetLayout: "Reset layout", + }, redis: { selectKey: "Select a key to view its value", noKeys: "No keys found", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index b66973236..e1787ab73 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -218,6 +218,34 @@ export default { foreignKeys: "外键", triggers: "触发器", }, + diagram: { + title: "关系图", + open: "查看关系图", + selectConnection: "选择连接", + selectDatabase: "选择数据库", + selectSchema: "选择模式", + searchTables: "搜索表/字段/外键...", + refresh: "刷新关系图", + loading: "正在读取关系...", + loadingProgress: "正在读取关系... {loaded}/{total}", + partialError: "{count} 张表的元数据读取失败,已跳过", + selectTarget: "请选择连接和数据库", + empty: "暂无可展示的表", + noMatches: "没有匹配的表", + tablesCount: "{count} 张表", + relationshipsCount: "{count} 条关系", + tableMode: "表结构图", + engineeringMode: "工程 ER 图", + allTables: "全部表", + relatedTables: "相关表", + moreColumns: "+ {count} 个字段", + exportSvg: "导出 SVG", + exportedSvg: "SVG 已导出", + exportSvgFailed: "导出 SVG 失败:{message}", + zoomIn: "放大", + zoomOut: "缩小", + resetLayout: "重置布局", + }, redis: { selectKey: "选择一个 key 查看值", noKeys: "未找到 key", diff --git a/src/lib/diagramSvgExport.ts b/src/lib/diagramSvgExport.ts new file mode 100644 index 000000000..b089d2cec --- /dev/null +++ b/src/lib/diagramSvgExport.ts @@ -0,0 +1,261 @@ +import type { EngineeringDiagram, EngineeringEntityNode } from "./engineeringDiagram"; +import type { DiagramPosition, DiagramRelationship, DiagramTable } from "./erDiagram"; + +type DiagramSvgMode = "table" | "engineering"; + +interface DiagramCanvas { + width: number; + height: number; +} + +export interface TableDiagramSvgOptions { + tables: DiagramTable[]; + relationships: DiagramRelationship[]; + positions: Record; + relationshipPaths: Record; + canvas: DiagramCanvas; + cardWidth: number; + cardHeaderHeight: number; + columnRowHeight: number; + maxVisibleColumns: number; + cardBottomPadding?: number; + moreColumnsLabel?: (count: number) => string; +} + +function escapeXml(value: string | number): string { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function svgNumber(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, ""); +} + +function svgHeader(canvas: DiagramCanvas): string { + return [ + ``, + "", + ].join(""); +} + +function svgText( + label: string, + x: number, + y: number, + options: { + size?: number; + fill?: string; + weight?: string; + anchor?: "start" | "middle" | "end"; + family?: string; + decoration?: string; + } = {}, +): string { + const attrs = [ + `x="${svgNumber(x)}"`, + `y="${svgNumber(y)}"`, + `fill="${options.fill ?? "#18181b"}"`, + `font-size="${options.size ?? 12}"`, + `font-family="${options.family ?? "Arial, Helvetica, sans-serif"}"`, + "dominant-baseline=\"middle\"", + ]; + if (options.weight) attrs.push(`font-weight="${options.weight}"`); + if (options.anchor) attrs.push(`text-anchor="${options.anchor}"`); + if (options.decoration) attrs.push(`text-decoration="${options.decoration}"`); + return `${escapeXml(label)}`; +} + +function tableHeight(table: DiagramTable, options: TableDiagramSvgOptions): number { + const visibleCount = Math.min(table.columns.length, options.maxVisibleColumns); + const overflowHeight = table.columns.length > options.maxVisibleColumns ? options.columnRowHeight : 0; + return options.cardHeaderHeight + + visibleCount * options.columnRowHeight + + overflowHeight + + (options.cardBottomPadding ?? 12); +} + +function tableDiagramDefs(): string { + return [ + "", + "", + "", + "", + "", + ].join(""); +} + +function isForeignKeyColumn(table: DiagramTable, columnName: string): boolean { + return table.foreignKeys.some((fk) => fk.column === columnName); +} + +export function buildTableDiagramSvg(options: TableDiagramSvgOptions): string { + const parts = [ + svgHeader(options.canvas), + tableDiagramDefs(), + "", + ]; + + for (const relationship of options.relationships) { + const path = options.relationshipPaths[relationship.id]; + if (!path) continue; + parts.push( + `` + + `${escapeXml(`${relationship.sourceTable}.${relationship.sourceColumn} -> ${relationship.targetTable}.${relationship.targetColumn}`)}` + + "", + ); + } + parts.push(""); + + for (const table of options.tables) { + const position = options.positions[table.name] ?? { x: 0, y: 0 }; + const height = tableHeight(table, options); + const visibleColumns = table.columns.slice(0, options.maxVisibleColumns); + const hiddenCount = Math.max(0, table.columns.length - options.maxVisibleColumns); + parts.push(``); + parts.push(``); + parts.push(``); + parts.push(``); + parts.push(svgText(table.name, 36, options.cardHeaderHeight / 2, { size: 13, weight: "600" })); + parts.push(svgText(String(table.columns.length), options.cardWidth - 18, options.cardHeaderHeight / 2, { + size: 10, + anchor: "end", + fill: "#52525b", + })); + + visibleColumns.forEach((column, index) => { + const rowTop = options.cardHeaderHeight + index * options.columnRowHeight; + const rowCenter = rowTop + options.columnRowHeight / 2; + parts.push(``); + if (column.is_primary_key) { + parts.push(svgText("PK", 14, rowCenter, { size: 9, fill: "#d97706", weight: "700" })); + } else if (isForeignKeyColumn(table, column.name)) { + parts.push(svgText("FK", 14, rowCenter, { size: 9, fill: "#2563eb", weight: "700" })); + } + parts.push(svgText(column.name, 38, rowCenter, { size: 11, family: "Menlo, Consolas, monospace" })); + parts.push(svgText(column.data_type, options.cardWidth - 12, rowCenter, { + size: 10, + fill: "#71717a", + anchor: "end", + })); + }); + + if (hiddenCount > 0) { + const y = options.cardHeaderHeight + visibleColumns.length * options.columnRowHeight + options.columnRowHeight / 2; + parts.push(svgText(options.moreColumnsLabel?.(hiddenCount) ?? `+ ${hiddenCount} columns`, 12, y, { + size: 11, + fill: "#71717a", + })); + } + parts.push(""); + } + + parts.push(""); + return parts.join(""); +} + +function nodeCenter(node: { x: number; y: number; width: number; height: number }): DiagramPosition { + return { + x: node.x + node.width / 2, + y: node.y + node.height / 2, + }; +} + +function cardinalityPoint(from: DiagramPosition, to: DiagramPosition): DiagramPosition { + return { + x: from.x + (to.x - from.x) * 0.72, + y: from.y + (to.y - from.y) * 0.72, + }; +} + +function entityCenterMap(entities: EngineeringEntityNode[]): Map { + return new Map(entities.map((entity) => [entity.name, nodeCenter(entity)])); +} + +export function buildEngineeringDiagramSvg(diagram: EngineeringDiagram): string { + const parts = [svgHeader(diagram.canvas)]; + const centers = entityCenterMap(diagram.entities); + + parts.push(""); + for (const attribute of diagram.attributes) { + const from = centers.get(attribute.tableName); + if (!from) continue; + const to = nodeCenter(attribute); + parts.push(``); + } + for (const relationship of diagram.relationships) { + const source = centers.get(relationship.sourceTable); + const target = centers.get(relationship.targetTable); + if (!source || !target) continue; + const middle = nodeCenter(relationship); + parts.push(``); + parts.push(``); + const sourceLabel = cardinalityPoint(middle, source); + const targetLabel = cardinalityPoint(middle, target); + parts.push(svgText(relationship.sourceCardinality, sourceLabel.x, sourceLabel.y - 8, { size: 13, weight: "700", anchor: "middle" })); + parts.push(svgText(relationship.targetCardinality, targetLabel.x, targetLabel.y - 8, { size: 13, weight: "700", anchor: "middle" })); + } + parts.push(""); + + for (const attribute of diagram.attributes) { + parts.push( + ``, + ); + parts.push(svgText(attribute.label, attribute.x + attribute.width / 2, attribute.y + attribute.height / 2, { + size: 11, + fill: "#052e16", + weight: attribute.primaryKey ? "700" : undefined, + anchor: "middle", + decoration: attribute.primaryKey ? "underline" : undefined, + })); + } + + for (const relationship of diagram.relationships) { + const cx = relationship.x + relationship.width / 2; + const cy = relationship.y + relationship.height / 2; + const points = [ + [cx, relationship.y], + [relationship.x + relationship.width, cy], + [cx, relationship.y + relationship.height], + [relationship.x, cy], + ].map(([x, y]) => `${svgNumber(x)},${svgNumber(y)}`).join(" "); + parts.push(``); + parts.push(svgText(relationship.label, cx, cy, { + size: 11, + fill: "#450a0a", + weight: "600", + anchor: "middle", + })); + } + + for (const entity of diagram.entities) { + parts.push(``); + parts.push(svgText(entity.name, entity.x + entity.width / 2, entity.y + entity.height / 2, { + size: 13, + fill: "#172554", + weight: "700", + anchor: "middle", + })); + } + + parts.push(""); + return parts.join(""); +} + +function fileToken(value: string): string { + return value + .trim() + .replace(/[^\p{L}\p{N}._-]+/gu, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +export function diagramSvgFileName(connectionName: string, databaseName: string, mode: DiagramSvgMode): string { + const context = [connectionName, databaseName].map(fileToken).filter(Boolean); + const suffix = mode === "engineering" ? "engineering-er" : "table-structure"; + return ["dbx", ...(context.length > 0 ? context : ["diagram"]), suffix].join("-") + ".svg"; +} diff --git a/src/lib/diagramZoom.ts b/src/lib/diagramZoom.ts new file mode 100644 index 000000000..dd76d3cda --- /dev/null +++ b/src/lib/diagramZoom.ts @@ -0,0 +1,16 @@ +export const DIAGRAM_MIN_ZOOM = 0.6; +export const DIAGRAM_MAX_ZOOM = 1.5; +const WHEEL_ZOOM_SENSITIVITY = 0.003; + +export function clampDiagramZoom(value: number): number { + const clamped = Math.min(DIAGRAM_MAX_ZOOM, Math.max(DIAGRAM_MIN_ZOOM, value)); + return Number(clamped.toFixed(2)); +} + +export function zoomFromWheelDelta(currentZoom: number, deltaY: number): number { + return clampDiagramZoom(currentZoom * Math.exp(-deltaY * WHEEL_ZOOM_SENSITIVITY)); +} + +export function zoomFromGestureScale(startZoom: number, scale: number): number { + return clampDiagramZoom(startZoom * scale); +} diff --git a/src/lib/engineeringDiagram.ts b/src/lib/engineeringDiagram.ts new file mode 100644 index 000000000..41745539c --- /dev/null +++ b/src/lib/engineeringDiagram.ts @@ -0,0 +1,379 @@ +import type { DiagramPosition, DiagramRelationship, DiagramTable } from "./erDiagram"; + +export const ENGINEERING_ENTITY_WIDTH = 184; +export const ENGINEERING_ENTITY_HEIGHT = 58; +export const ENGINEERING_ATTRIBUTE_HEIGHT = 34; +export const ENGINEERING_RELATIONSHIP_WIDTH = 104; +export const ENGINEERING_RELATIONSHIP_HEIGHT = 58; + +const ATTRIBUTE_MIN_WIDTH = 96; +const ATTRIBUTE_MAX_WIDTH = 156; +const ATTRIBUTE_GAP_X = 18; +const ATTRIBUTE_GAP_Y = 12; +const ATTRIBUTE_ENTITY_GAP = 38; +const HORIZONTAL_ATTRIBUTE_COLUMNS = 4; +const ENGINEERING_CLUSTER_GAP_X = 120; +const ENGINEERING_CLUSTER_GAP_Y = 100; +const CANVAS_PADDING = 80; + +type AttributeSide = "top" | "right" | "bottom" | "left"; +type EngineeringColumn = DiagramTable["columns"][number]; + +interface AttributeDraft { + column: EngineeringColumn; + width: number; +} + +interface BlockSize { + width: number; + height: number; +} + +interface EngineeringCluster { + tableName: string; + width: number; + height: number; + entityX: number; + entityY: number; + attributes: EngineeringAttributeNode[]; +} + +export interface EngineeringEntityNode { + id: string; + name: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface EngineeringAttributeNode { + id: string; + tableName: string; + columnName: string; + label: string; + dataType: string; + primaryKey: boolean; + foreignKey: boolean; + x: number; + y: number; + width: number; + height: number; +} + +export interface EngineeringRelationshipNode { + id: string; + label: string; + sourceTable: string; + targetTable: string; + sourceCardinality: "1" | "N"; + targetCardinality: "1" | "N"; + x: number; + y: number; + width: number; + height: number; +} + +export interface EngineeringDiagram { + entities: EngineeringEntityNode[]; + attributes: EngineeringAttributeNode[]; + relationships: EngineeringRelationshipNode[]; + canvas: { + width: number; + height: number; + }; +} + +function attributeWidth(label: string): number { + return Math.min(ATTRIBUTE_MAX_WIDTH, Math.max(ATTRIBUTE_MIN_WIDTH, label.length * 10 + 30)); +} + +function relationshipLabel(relationship: DiagramRelationship): string { + if (relationship.name && relationship.name.length <= 16) return relationship.name; + return relationship.sourceColumn || "rel"; +} + +function entityCenter(entity: EngineeringEntityNode): DiagramPosition { + return { + x: entity.x + entity.width / 2, + y: entity.y + entity.height / 2, + }; +} + +function chunkAttributes(items: AttributeDraft[], size: number): AttributeDraft[][] { + const rows: AttributeDraft[][] = []; + for (let index = 0; index < items.length; index += size) { + rows.push(items.slice(index, index + size)); + } + return rows; +} + +function rowWidth(items: AttributeDraft[]): number { + if (items.length === 0) return 0; + return items.reduce((width, item) => width + item.width, 0) + + (items.length - 1) * ATTRIBUTE_GAP_X; +} + +function horizontalBlockSize(items: AttributeDraft[]): BlockSize { + if (items.length === 0) return { width: 0, height: 0 }; + const rows = chunkAttributes(items, HORIZONTAL_ATTRIBUTE_COLUMNS); + return { + width: Math.max(...rows.map(rowWidth)), + height: rows.length * ENGINEERING_ATTRIBUTE_HEIGHT + (rows.length - 1) * ATTRIBUTE_GAP_Y, + }; +} + +function verticalBlockSize(items: AttributeDraft[]): BlockSize { + if (items.length === 0) return { width: 0, height: 0 }; + return { + width: Math.max(...items.map((item) => item.width)), + height: items.length * ENGINEERING_ATTRIBUTE_HEIGHT + (items.length - 1) * ATTRIBUTE_GAP_Y, + }; +} + +function sideGap(block: BlockSize): number { + return block.width > 0 && block.height > 0 ? ATTRIBUTE_ENTITY_GAP : 0; +} + +function distributeAttributes(table: DiagramTable): Record { + const sides: AttributeSide[] = ["top", "right", "bottom", "left"]; + const groups: Record = { + top: [], + right: [], + bottom: [], + left: [], + }; + + table.columns.forEach((column, index) => { + groups[sides[index % sides.length]].push({ + column, + width: attributeWidth(column.name), + }); + }); + + return groups; +} + +function localAttributeNode( + table: DiagramTable, + item: AttributeDraft, + x: number, + y: number, +): EngineeringAttributeNode { + return { + id: `${table.name}:${item.column.name}`, + tableName: table.name, + columnName: item.column.name, + label: item.column.name, + dataType: item.column.data_type, + primaryKey: item.column.is_primary_key, + foreignKey: table.foreignKeys.some((fk) => fk.column === item.column.name), + x, + y, + width: item.width, + height: ENGINEERING_ATTRIBUTE_HEIGHT, + }; +} + +function buildEngineeringCluster(table: DiagramTable): EngineeringCluster { + const groups = distributeAttributes(table); + const topSize = horizontalBlockSize(groups.top); + const rightSize = verticalBlockSize(groups.right); + const bottomSize = horizontalBlockSize(groups.bottom); + const leftSize = verticalBlockSize(groups.left); + + const leftGap = sideGap(leftSize); + const rightGap = sideGap(rightSize); + const topGap = sideGap(topSize); + const bottomGap = sideGap(bottomSize); + const centerWidth = Math.max(ENGINEERING_ENTITY_WIDTH, topSize.width, bottomSize.width); + const centerHeight = Math.max(ENGINEERING_ENTITY_HEIGHT, leftSize.height, rightSize.height); + const centerX = leftSize.width + leftGap; + const centerY = topSize.height + topGap; + const entityX = centerX + centerWidth / 2 - ENGINEERING_ENTITY_WIDTH / 2; + const entityY = centerY + centerHeight / 2 - ENGINEERING_ENTITY_HEIGHT / 2; + const attributes: EngineeringAttributeNode[] = []; + + chunkAttributes(groups.top, HORIZONTAL_ATTRIBUTE_COLUMNS).forEach((row, rowIndex) => { + let x = centerX + (centerWidth - rowWidth(row)) / 2; + const y = rowIndex * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y); + row.forEach((item) => { + attributes.push(localAttributeNode(table, item, x, y)); + x += item.width + ATTRIBUTE_GAP_X; + }); + }); + + groups.right.forEach((item, index) => { + attributes.push(localAttributeNode( + table, + item, + centerX + centerWidth + rightGap + (rightSize.width - item.width) / 2, + centerY + (centerHeight - rightSize.height) / 2 + index * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y), + )); + }); + + chunkAttributes(groups.bottom, HORIZONTAL_ATTRIBUTE_COLUMNS).forEach((row, rowIndex) => { + let x = centerX + (centerWidth - rowWidth(row)) / 2; + const y = centerY + centerHeight + bottomGap + rowIndex * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y); + row.forEach((item) => { + attributes.push(localAttributeNode(table, item, x, y)); + x += item.width + ATTRIBUTE_GAP_X; + }); + }); + + groups.left.forEach((item, index) => { + attributes.push(localAttributeNode( + table, + item, + (leftSize.width - item.width) / 2, + centerY + (centerHeight - leftSize.height) / 2 + index * (ENGINEERING_ATTRIBUTE_HEIGHT + ATTRIBUTE_GAP_Y), + )); + }); + + return { + tableName: table.name, + width: leftSize.width + leftGap + centerWidth + rightGap + rightSize.width, + height: topSize.height + topGap + centerHeight + bottomGap + bottomSize.height, + entityX, + entityY, + attributes, + }; +} + +function positionKey(value: number): string { + return value.toFixed(3); +} + +function orderedTableRows(tables: DiagramTable[], positions: Record): DiagramTable[][] { + const columnsPerRow = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(Math.max(tables.length, 1))))); + const ordered = tables.map((table, fallbackIndex) => ({ + table, + position: positions[table.name] ?? { + x: fallbackIndex % columnsPerRow, + y: Math.floor(fallbackIndex / columnsPerRow), + }, + })); + const ys = [...new Set(ordered.map((item) => positionKey(item.position.y)))] + .sort((left, right) => Number(left) - Number(right)); + + return ys.map((y) => + ordered + .filter((item) => positionKey(item.position.y) === y) + .sort((left, right) => left.position.x - right.position.x) + .map((item) => item.table) + ); +} + +function normalizeDiagram(diagram: Omit): EngineeringDiagram { + const rects = [ + ...diagram.entities, + ...diagram.attributes, + ...diagram.relationships, + ]; + if (rects.length === 0) { + return { + ...diagram, + canvas: { + width: CANVAS_PADDING * 2, + height: CANVAS_PADDING * 2, + }, + }; + } + + const minX = Math.min(...rects.map((rect) => rect.x)); + const minY = Math.min(...rects.map((rect) => rect.y)); + const maxX = Math.max(...rects.map((rect) => rect.x + rect.width)); + const maxY = Math.max(...rects.map((rect) => rect.y + rect.height)); + const dx = CANVAS_PADDING - minX; + const dy = CANVAS_PADDING - minY; + + const shift = (node: T): T => ({ + ...node, + x: node.x + dx, + y: node.y + dy, + }); + + return { + entities: diagram.entities.map(shift), + attributes: diagram.attributes.map(shift), + relationships: diagram.relationships.map(shift), + canvas: { + width: maxX + dx + CANVAS_PADDING, + height: maxY + dy + CANVAS_PADDING, + }, + }; +} + +export function buildEngineeringDiagram( + tables: DiagramTable[], + relationships: DiagramRelationship[], + positions: Record, +): EngineeringDiagram { + const clusters = new Map(tables.map((table) => [table.name, buildEngineeringCluster(table)])); + const rows = orderedTableRows(tables, positions); + const entities: EngineeringEntityNode[] = []; + const attributes: EngineeringAttributeNode[] = []; + let nextRowY = 0; + + rows.forEach((row) => { + const rowClusters = row + .map((table) => clusters.get(table.name)) + .filter((cluster): cluster is EngineeringCluster => cluster !== undefined); + const rowHeight = Math.max(...rowClusters.map((cluster) => cluster.height), ENGINEERING_ENTITY_HEIGHT); + let nextX = 0; + + rowClusters.forEach((cluster) => { + const originX = nextX; + const originY = nextRowY + (rowHeight - cluster.height) / 2; + + entities.push({ + id: cluster.tableName, + name: cluster.tableName, + x: originX + cluster.entityX, + y: originY + cluster.entityY, + width: ENGINEERING_ENTITY_WIDTH, + height: ENGINEERING_ENTITY_HEIGHT, + }); + attributes.push(...cluster.attributes.map((attribute) => ({ + ...attribute, + x: originX + attribute.x, + y: originY + attribute.y, + }))); + + nextX += cluster.width + ENGINEERING_CLUSTER_GAP_X; + }); + + nextRowY += rowHeight + ENGINEERING_CLUSTER_GAP_Y; + }); + const entityMap = new Map(entities.map((entity) => [entity.name, entity])); + const orderedEntities = tables + .map((table) => entityMap.get(table.name)) + .filter((entity): entity is EngineeringEntityNode => entity !== undefined); + + const relationshipNodes: EngineeringRelationshipNode[] = relationships.flatMap((relationship) => { + const source = entityMap.get(relationship.sourceTable); + const target = entityMap.get(relationship.targetTable); + if (!source || !target) return []; + + const sourceCenter = entityCenter(source); + const targetCenter = entityCenter(target); + return [{ + id: relationship.id, + label: relationshipLabel(relationship), + sourceTable: relationship.sourceTable, + targetTable: relationship.targetTable, + sourceCardinality: "N", + 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, + height: ENGINEERING_RELATIONSHIP_HEIGHT, + }]; + }); + + return normalizeDiagram({ + entities: orderedEntities, + attributes, + relationships: relationshipNodes, + }); +} diff --git a/src/lib/erDiagram.ts b/src/lib/erDiagram.ts new file mode 100644 index 000000000..593db676e --- /dev/null +++ b/src/lib/erDiagram.ts @@ -0,0 +1,100 @@ +import type { ColumnInfo, ForeignKeyInfo } from "../types/database"; + +export interface DiagramTable { + name: string; + columns: ColumnInfo[]; + foreignKeys: ForeignKeyInfo[]; +} + +export interface DiagramRelationship { + id: string; + name: string; + sourceTable: string; + sourceColumn: string; + targetTable: string; + targetColumn: string; +} + +export interface DiagramPosition { + x: number; + y: number; +} + +export interface DiagramLayoutOptions { + columnsPerRow?: number; + cardWidth?: number; + rowHeight?: number; + gapX?: number; + gapY?: number; + margin?: number; +} + +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)); + + return tables.flatMap((table) => + table.foreignKeys + .filter((fk) => visibleTableNames.has(fk.ref_table)) + .map((fk) => ({ + id: relationshipId(table.name, fk), + name: fk.name, + sourceTable: table.name, + sourceColumn: fk.column, + targetTable: fk.ref_table, + targetColumn: fk.ref_column, + })), + ); +} + +export function filterDiagramTables(tables: DiagramTable[], query: string): DiagramTable[] { + const q = query.trim().toLowerCase(); + if (!q) return tables; + + return tables.filter((table) => { + if (table.name.toLowerCase().includes(q)) return true; + if (table.columns.some((column) => + column.name.toLowerCase().includes(q) || + column.data_type.toLowerCase().includes(q) + )) return true; + return table.foreignKeys.some((fk) => + fk.name.toLowerCase().includes(q) || + fk.column.toLowerCase().includes(q) || + fk.ref_table.toLowerCase().includes(q) || + fk.ref_column.toLowerCase().includes(q) + ); + }); +} + +export function layoutDiagramTables( + tables: Pick[], + options: DiagramLayoutOptions = {}, +): Record { + const columnsPerRow = Math.max(1, options.columnsPerRow ?? Math.ceil(Math.sqrt(Math.max(tables.length, 1)))); + const cardWidth = options.cardWidth ?? 260; + const rowHeight = options.rowHeight ?? 220; + const gapX = options.gapX ?? 56; + const gapY = options.gapY ?? 40; + const margin = options.margin ?? 40; + + return Object.fromEntries(tables.map((table, index) => { + const col = index % columnsPerRow; + const row = Math.floor(index / columnsPerRow); + return [ + table.name, + { + x: margin + col * (cardWidth + gapX), + y: margin + row * (rowHeight + gapY), + }, + ]; + })); +} diff --git a/src/lib/sqlCompletion.ts b/src/lib/sqlCompletion.ts index acb477bce..3681dfe32 100644 --- a/src/lib/sqlCompletion.ts +++ b/src/lib/sqlCompletion.ts @@ -146,7 +146,7 @@ function buildTableItems(prefix: string, tables: SqlCompletionTable[]): SqlCompl label: table.name, type: "table" as const, detail: table.schema ? `${table.schema}.${table.name}` : table.type, - boost: computeBoost(table.name, prefix), + boost: computeBoost(table.name, prefix) + 1000, })); } diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index b8cd22162..1b41d5036 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -19,6 +19,7 @@ export const useConnectionStore = defineStore("connection", () => { const transferSource = ref<{ connectionId: string; database: string } | null>(null); const schemaDiffSource = ref<{ connectionId: string; database: string } | null>(null); const sqlFileSource = ref<{ connectionId: string; database: string } | null>(null); + const diagramSource = ref<{ connectionId: string; database: string; schema?: string; tableName?: string } | null>(null); function startEditing(id: string) { editingConnectionId.value = id; @@ -655,5 +656,6 @@ export const useConnectionStore = defineStore("connection", () => { transferSource, schemaDiffSource, sqlFileSource, + diagramSource, }; }); diff --git a/tests/diagramSvgExport.test.ts b/tests/diagramSvgExport.test.ts new file mode 100644 index 000000000..1feaa9c19 --- /dev/null +++ b/tests/diagramSvgExport.test.ts @@ -0,0 +1,87 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { buildEngineeringDiagram } from "../src/lib/engineeringDiagram.ts"; +import { + buildEngineeringDiagramSvg, + buildTableDiagramSvg, + diagramSvgFileName, +} from "../src/lib/diagramSvgExport.ts"; +import { buildDiagramRelationships, type DiagramTable } from "../src/lib/erDiagram.ts"; + +const tables: DiagramTable[] = [ + { + name: "users", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "name & note", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null }, + ], + foreignKeys: [], + }, + { + name: "orders", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "user_id", data_type: "bigint", 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" }, + ], + }, +]; + +test("exports the table diagram as standalone SVG", () => { + const relationships = buildDiagramRelationships(tables); + const svg = buildTableDiagramSvg({ + tables, + relationships, + positions: { + users: { x: 40, y: 40 }, + orders: { x: 360, y: 40 }, + }, + relationshipPaths: { + [relationships[0].id]: "M 360 96 L 310 96", + }, + canvas: { width: 720, height: 320 }, + cardWidth: 270, + cardHeaderHeight: 44, + columnRowHeight: 24, + maxVisibleColumns: 9, + moreColumnsLabel: (count) => `+ ${count} columns`, + }); + + assert.match(svg, /^usersordersname & note { + const relationships = buildDiagramRelationships(tables); + const diagram = buildEngineeringDiagram(tables, relationships, { + users: { x: 40, y: 40 }, + orders: { x: 360, y: 40 }, + }); + const svg = buildEngineeringDiagramSvg(diagram); + + assert.match(svg, /^N1 { + assert.equal( + diagramSvgFileName("prod/main", "billing db", "engineering"), + "dbx-prod-main-billing-db-engineering-er.svg", + ); + assert.equal( + diagramSvgFileName("", "", "table"), + "dbx-diagram-table-structure.svg", + ); +}); diff --git a/tests/diagramZoom.test.ts b/tests/diagramZoom.test.ts new file mode 100644 index 000000000..c552b8523 --- /dev/null +++ b/tests/diagramZoom.test.ts @@ -0,0 +1,23 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + clampDiagramZoom, + zoomFromGestureScale, + zoomFromWheelDelta, +} from "../src/lib/diagramZoom.ts"; + +test("clamps diagram zoom to supported bounds", () => { + assert.equal(clampDiagramZoom(0.2), 0.6); + assert.equal(clampDiagramZoom(2), 1.5); + assert.equal(clampDiagramZoom(1.234), 1.23); +}); + +test("maps trackpad pinch wheel delta to smooth zoom changes", () => { + assert.ok(zoomFromWheelDelta(1, -120) > 1); + assert.ok(zoomFromWheelDelta(1, 120) < 1); +}); + +test("maps WebKit gesture scale from the gesture start zoom", () => { + assert.equal(zoomFromGestureScale(1, 1.25), 1.25); + assert.equal(zoomFromGestureScale(1, 4), 1.5); +}); diff --git a/tests/engineeringDiagram.test.ts b/tests/engineeringDiagram.test.ts new file mode 100644 index 000000000..047d11b91 --- /dev/null +++ b/tests/engineeringDiagram.test.ts @@ -0,0 +1,107 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { buildEngineeringDiagram } from "../src/lib/engineeringDiagram.ts"; +import type { DiagramRelationship, DiagramTable } from "../src/lib/erDiagram.ts"; + +const tables: DiagramTable[] = [ + { + name: "orders", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "user_id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + { name: "status", data_type: "varchar", 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" }, + ], + }, + { + name: "users", + columns: [ + { name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: true, extra: null }, + { name: "name", data_type: "varchar", is_nullable: false, column_default: null, is_primary_key: false, extra: null }, + ], + foreignKeys: [], + }, +]; + +const relationships: DiagramRelationship[] = [ + { + id: "orders:orders_user_id_fk:user_id:users:id", + name: "orders_user_id_fk", + sourceTable: "orders", + sourceColumn: "user_id", + targetTable: "users", + targetColumn: "id", + }, +]; + +test("builds engineering ER nodes from tables, columns, and relationships", () => { + const diagram = buildEngineeringDiagram(tables, relationships, { + orders: { x: 300, y: 200 }, + users: { x: 40, y: 200 }, + }); + + assert.deepEqual(diagram.entities.map((entity) => entity.name), ["orders", "users"]); + assert.equal(diagram.attributes.filter((attr) => attr.tableName === "orders").length, 3); + assert.equal(diagram.relationships[0]?.sourceCardinality, "N"); + assert.equal(diagram.relationships[0]?.targetCardinality, "1"); +}); + +test("sizes the engineering canvas around attributes and relationship diamonds", () => { + const diagram = buildEngineeringDiagram(tables, relationships, { + orders: { x: 300, y: 200 }, + users: { x: 40, y: 200 }, + }); + + assert.ok(diagram.canvas.width > 500); + assert.ok(diagram.canvas.height > 300); +}); + +test("keeps dense attribute clouds from overlapping", () => { + const denseTables: DiagramTable[] = [{ + name: "roles", + columns: Array.from({ length: 36 }, (_, index) => ({ + name: `column_${index + 1}`, + data_type: "varchar", + is_nullable: true, + column_default: null, + is_primary_key: index === 0, + extra: null, + })), + foreignKeys: [], + }]; + + const diagram = buildEngineeringDiagram(denseTables, [], { + roles: { x: 40, y: 40 }, + }); + const rects = [ + ...diagram.entities, + ...diagram.attributes, + ]; + + for (let i = 0; i < rects.length; i++) { + for (let j = i + 1; j < rects.length; j++) { + const left = rects[i]; + const right = rects[j]; + const overlaps = left.x < right.x + right.width && + left.x + left.width > right.x && + left.y < right.y + right.height && + left.y + left.height > right.y; + assert.equal(overlaps, false, `${left.id} overlaps ${right.id}`); + } + } +}); + +test("keeps adjacent entity centers reasonably close", () => { + const diagram = buildEngineeringDiagram(tables, relationships, { + users: { x: 40, y: 40 }, + orders: { x: 360, y: 40 }, + }); + const users = diagram.entities.find((entity) => entity.name === "users")!; + const orders = diagram.entities.find((entity) => entity.name === "orders")!; + const userCenter = users.x + users.width / 2; + const orderCenter = orders.x + orders.width / 2; + + assert.ok(Math.abs(orderCenter - userCenter) <= 560); +}); diff --git a/tests/erDiagram.test.ts b/tests/erDiagram.test.ts new file mode 100644 index 000000000..ac75084be --- /dev/null +++ b/tests/erDiagram.test.ts @@ -0,0 +1,72 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + buildDiagramRelationships, + filterDiagramTables, + layoutDiagramTables, +} from "../src/lib/erDiagram.ts"; + +test("builds relationships only between tables in the diagram", () => { + const relationships = buildDiagramRelationships([ + { + name: "orders", + columns: [], + foreignKeys: [ + { name: "orders_user_id_fk", column: "user_id", ref_table: "users", ref_column: "id" }, + { name: "orders_external_fk", column: "external_id", ref_table: "external_accounts", ref_column: "id" }, + ], + }, + { + name: "users", + columns: [], + foreignKeys: [], + }, + ]); + + assert.deepEqual(relationships, [ + { + id: "orders:orders_user_id_fk:user_id:users:id", + name: "orders_user_id_fk", + sourceTable: "orders", + sourceColumn: "user_id", + targetTable: "users", + targetColumn: "id", + }, + ]); +}); + +test("filters diagram tables by table, column, and foreign key names", () => { + const tables = [ + { + 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" }], + }, + { + name: "audit_log", + columns: [{ name: "payload", data_type: "json", is_nullable: true, column_default: null, is_primary_key: false, extra: null }], + foreignKeys: [], + }, + ]; + + assert.deepEqual(filterDiagramTables(tables, "payload").map((table) => table.name), ["audit_log"]); + assert.deepEqual(filterDiagramTables(tables, "orders_user").map((table) => table.name), ["orders"]); + assert.deepEqual(filterDiagramTables(tables, "").map((table) => table.name), ["orders", "audit_log"]); +}); + +test("lays out diagram tables in stable rows", () => { + const positions = layoutDiagramTables( + [ + { name: "users", columns: [] }, + { name: "orders", columns: [] }, + { name: "line_items", columns: [] }, + ], + { columnsPerRow: 2, cardWidth: 240, rowHeight: 180, gapX: 40, gapY: 30 }, + ); + + assert.deepEqual(positions, { + users: { x: 40, y: 40 }, + orders: { x: 320, y: 40 }, + line_items: { x: 40, y: 250 }, + }); +});