diff --git a/.jules/bolt.md b/.jules/bolt.md index f1a8c1466..6a72ee4d8 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -77,3 +77,6 @@ Optimized metric route processing to O(N) by creating a mapping of routes direct ## 2024-07-13 - [Optimize Export Dictionary FK lookups] **Learning:** Found O(N * C * E) performance bottleneck in ERD export dictionaries due to repeated array searching with `edges.some()` inside a nested loop over nodes and columns. **Action:** Replace repeated linear array scans for edges by precomputing O(1) Set lookups of foreign key column handles per node before looping. +## 2024-07-20 - [Optimize DDL Export FK lookups] +**Learning:** Found O(N * C * E) performance bottleneck in DDL export generation due to repeated array searching with `.find()` and redundant `sourceColumnHandleId` calculations inside the edge loops. +**Action:** Precompute an O(1) Map of handle IDs to column names, but do so lazily (only for nodes actually involved in handle-based FK resolution) to prevent wasted work/allocations on diagrams with explicit FK columns or zero edges. diff --git a/frontend/src/erd/export.ts b/frontend/src/erd/export.ts index 62ce7219e..3d1ab3ce1 100644 --- a/frontend/src/erd/export.ts +++ b/frontend/src/erd/export.ts @@ -55,10 +55,26 @@ function sqlDataType(value: unknown): string { return SQL_DATA_TYPE_RE.test(text) ? text : 'text'; } +type NodeHandleCache = { + sourceHandles: Map; + targetHandles: Map; +}; + +/** + * Resolves foreign key columns for an edge. + * + * Precedence: + * 1. Explicit composite columns (`edge.data.sourceColumns` / `targetColumns`) + * 2. Handle ID lookup (`edge.sourceHandle` / `targetHandle`) against node columns + * 3. Fallback (all non-PK source columns -> all PK target columns) + * + * @param getHandleCache A lazy cache factory for column handles to avoid O(N*C) upfront allocation when not needed. + */ function fkColumnsForEdge( edge: Edge, sourceNode: Node, targetNode: Node, + getHandleCache: (node: Node) => NodeHandleCache ): { sourceColumns: string[]; targetColumns: string[] } | null { const data = edge.data as ForeignKeyEdgeData | undefined; const sourceColumns = data?.sourceColumns?.filter(Boolean) || []; @@ -67,12 +83,11 @@ function fkColumnsForEdge( return { sourceColumns, targetColumns }; } - const sourceHandleColumn = (sourceNode.data.columns || []) - .find((column) => sourceColumnHandleId(column.column_name) === edge.sourceHandle) - ?.column_name; - const targetHandleColumn = (targetNode.data.columns || []) - .find((column) => targetColumnHandleId(column.column_name) === edge.targetHandle) - ?.column_name; + const sourceCache = getHandleCache(sourceNode); + const targetCache = getHandleCache(targetNode); + const sourceHandleColumn = sourceCache?.sourceHandles.get(edge.sourceHandle || ''); + const targetHandleColumn = targetCache?.targetHandles.get(edge.targetHandle || ''); + if (sourceHandleColumn && targetHandleColumn) { return { sourceColumns: [sourceHandleColumn], targetColumns: [targetHandleColumn] }; } @@ -90,6 +105,13 @@ function fkColumnsForEdge( return null; } +/** + * Generates SQL DDL from the provided nodes and edges. + * + * Note: The precomputed handle maps (via getHandleCache) act as an internal + * performance acceleration (O(1) lookups) for foreign key resolution and + * must not alter the resulting emitted DDL bytes compared to the naive O(N) scan. + */ export function exportDDL(nodes: Node[], edges: Edge[]): string { let ddl = '-- Generated DDL\n\n'; @@ -100,6 +122,22 @@ export function exportDDL(nodes: Node[], edges: Edge[]): string { nodesById.set(n.id, n); } + const nodeHandleCache = new Map(); + const getHandleCache = (node: Node): NodeHandleCache => { + let cache = nodeHandleCache.get(node.id); + if (!cache) { + const sourceHandles = new Map(); + const targetHandles = new Map(); + for (const col of node.data.columns || []) { + sourceHandles.set(sourceColumnHandleId(col.column_name), col.column_name); + targetHandles.set(targetColumnHandleId(col.column_name), col.column_name); + } + cache = { sourceHandles, targetHandles }; + nodeHandleCache.set(node.id, cache); + } + return cache; + }; + // Export tables for (const node of nodes) { const tableTitle = node.data.title || node.id; @@ -133,7 +171,7 @@ export function exportDDL(nodes: Node[], edges: Edge[]): string { const targetNode = nodesById.get(edge.target); if (sourceNode && targetNode) { - const fkColumns = fkColumnsForEdge(edge, sourceNode, targetNode); + const fkColumns = fkColumnsForEdge(edge, sourceNode, targetNode, getHandleCache); const constraintName = edge.label ? edge.label : `fk_${edge.source}_${edge.target}`; const sourceTable = quoteSqlIdentifier(sourceNode.data.title || sourceNode.id); const targetTable = quoteSqlIdentifier(targetNode.data.title || targetNode.id); diff --git a/frontend/src/erd/handleUtils.ts b/frontend/src/erd/handleUtils.ts index 054d5ab2a..d86a4ec66 100644 --- a/frontend/src/erd/handleUtils.ts +++ b/frontend/src/erd/handleUtils.ts @@ -1,5 +1,5 @@ export function sanitizeHandleId(columnName: string): string { - const encoded = Array.from(columnName, (char) => { + const encoded = Array.from(columnName || '', (char) => { // Array.from only yields non-empty Unicode scalars, so codePointAt(0) is defined. return char.codePointAt(0)!.toString(16).padStart(4, '0') }).join('-')