diff --git a/.jules/bolt.md b/.jules/bolt.md index f1a8c146..b4838a96 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-25 - Avoid redundant handle generation and O(N*C) string searches during ERD export +**Learning:** ERD export functions (Mermaid, Data Dictionary, DBML, etc.) frequently need to extract column names from edge `sourceHandle` strings. Iterating over all columns in all tables and continuously re-encoding column names with `sourceColumnHandleId` to match against the edge handle is extremely expensive (O(N*C)). +**Action:** When extracting data from heavily encoded UI identifiers (like React Flow handles), implement an O(1) decoding utility (`parseColumnNameFromHandle`) to parse the string back into its original value directly, rather than encoding every possible value to search for a match. diff --git a/frontend/src/erd/__tests__/coverageEdges.test.ts b/frontend/src/erd/__tests__/coverageEdges.test.ts index 0dedc39e..8af6868c 100644 --- a/frontend/src/erd/__tests__/coverageEdges.test.ts +++ b/frontend/src/erd/__tests__/coverageEdges.test.ts @@ -50,7 +50,7 @@ describe('coverage edge contracts', () => { { id: 'missing', source: 'missing', target: 'parent' }, { id: 'partial-data', source: 'child', target: 'parent', data: { sourceColumns: ['parent_id'] } }, { id: 'empty-data', source: 'child', target: 'parent', data: { sourceColumns: [], targetColumns: [] } }, - { id: 'handles', source: 'child', target: 'parent', sourceHandle: 'src-parent_id', targetHandle: 'tgt-' }, + { id: 'handles', source: 'child', target: 'parent', sourceHandle: 'src-c-0070-0061-0072-0065-006e-0074-005f-0069-0064', targetHandle: 'tgt-c-empty' }, ] const dbml = exportDbml([parent, child, node('empty', '', [])], edges) diff --git a/frontend/src/erd/__tests__/dbml.test.ts b/frontend/src/erd/__tests__/dbml.test.ts index 4634f767..c5003793 100644 --- a/frontend/src/erd/__tests__/dbml.test.ts +++ b/frontend/src/erd/__tests__/dbml.test.ts @@ -65,8 +65,8 @@ describe('exportDbml', () => { id: 'e1', source: '2', target: '1', - sourceHandle: 'src-user_id', - targetHandle: 'tgt-id', + sourceHandle: 'src-c-0075-0073-0065-0072-005f-0069-0064', + targetHandle: 'tgt-c-0069-0064', label: 'rel', }, ]; diff --git a/frontend/src/erd/dbml.ts b/frontend/src/erd/dbml.ts index 4a0c2029..294b7319 100644 --- a/frontend/src/erd/dbml.ts +++ b/frontend/src/erd/dbml.ts @@ -1,5 +1,6 @@ import type { Node, Edge } from "@xyflow/react"; import type { TableNodeData, ForeignKeyEdgeData } from "./convert"; +import { parseColumnNameFromHandle } from "./handleUtils"; function escapeString(str: string): string { return str.replace(/'/g, "''"); @@ -89,8 +90,12 @@ export function exportDbml( sourceCols = edgeData.sourceColumns.map(safeId); targetCols = edgeData.targetColumns.map(safeId); } else if (edge.sourceHandle && edge.targetHandle) { - sourceCols = [safeId(edge.sourceHandle.replace('src-', ''))]; - targetCols = [safeId(edge.targetHandle.replace('tgt-', ''))]; + const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); + const parsedTarget = parseColumnNameFromHandle(edge.targetHandle); + if (parsedSource !== null && parsedTarget !== null) { + sourceCols = [safeId(parsedSource)]; + targetCols = [safeId(parsedTarget)]; + } } if (sourceCols.length > 0 && targetCols.length > 0) { diff --git a/frontend/src/erd/export.ts b/frontend/src/erd/export.ts index 62ce7219..75b2730b 100644 --- a/frontend/src/erd/export.ts +++ b/frontend/src/erd/export.ts @@ -2,7 +2,7 @@ import type { Node, Edge } from '@xyflow/react'; import { normalizeBusinessGroupColor } from './businessGroups'; import type { IndexRecommendation } from './cardinality'; import type { ForeignKeyEdgeData, TableNodeData } from './convert'; -import { sourceColumnHandleId, targetColumnHandleId } from './handleUtils'; +import { parseColumnNameFromHandle } from './handleUtils'; export * from './exportDataDictionary'; @@ -67,14 +67,19 @@ 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; - if (sourceHandleColumn && targetHandleColumn) { - return { sourceColumns: [sourceHandleColumn], targetColumns: [targetHandleColumn] }; + const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); + const parsedTarget = parseColumnNameFromHandle(edge.targetHandle); + + if (parsedSource && parsedTarget) { + const sourceHandleColumn = (sourceNode.data.columns || []) + .find((column) => column.column_name === parsedSource) + ?.column_name; + const targetHandleColumn = (targetNode.data.columns || []) + .find((column) => column.column_name === parsedTarget) + ?.column_name; + if (sourceHandleColumn && targetHandleColumn) { + return { sourceColumns: [sourceHandleColumn], targetColumns: [targetHandleColumn] }; + } } const fallbackSource = (sourceNode.data.columns || []) diff --git a/frontend/src/erd/exportDataDictionary.ts b/frontend/src/erd/exportDataDictionary.ts index 0111660d..be53720f 100644 --- a/frontend/src/erd/exportDataDictionary.ts +++ b/frontend/src/erd/exportDataDictionary.ts @@ -1,7 +1,7 @@ import type { Edge, Node } from '@xyflow/react'; import type { ForeignKeyEdgeData, TableNodeData } from './convert'; -import { sourceColumnHandleId } from './handleUtils'; +import { parseColumnNameFromHandle, sourceColumnHandleId } from './handleUtils'; const CONTROL_TEXT_RE = /[\u0000-\u001f\u007f]+/g; const CSV_FORMULA_RE = /^[=+\-@]/; @@ -41,7 +41,6 @@ function sourceColumnsForEdge(edge: Edge): Set { type ForeignKeyNodeInfo = { columns: Set; - handles: Set; }; function foreignKeyColumnsByNode(edges: Edge[]): Map { @@ -50,7 +49,7 @@ function foreignKeyColumnsByNode(edges: Edge[]): Map for (const edge of edges) { let info = map.get(edge.source); if (!info) { - info = { columns: new Set(), handles: new Set() }; + info = { columns: new Set() }; map.set(edge.source, info); } @@ -59,7 +58,10 @@ function foreignKeyColumnsByNode(edges: Edge[]): Map } if (edge.sourceHandle) { - info.handles.add(edge.sourceHandle); + const parsedColumn = parseColumnNameFromHandle(edge.sourceHandle); + if (parsedColumn) { + info.columns.add(parsedColumn); + } } } @@ -74,12 +76,7 @@ function isForeignKeyColumn( const info = edgeColumnsByNode.get(node.id); if (!info) return false; - if (info.columns.has(columnName)) { - return true; - } - - const handleId = sourceColumnHandleId(columnName); - return info.handles.has(handleId); + return info.columns.has(columnName); } function exampleValue(value: TableNodeData['columns'][number]['example_value']): string { diff --git a/frontend/src/erd/handleUtils.test.ts b/frontend/src/erd/handleUtils.test.ts index 0278739e..b6543047 100644 --- a/frontend/src/erd/handleUtils.test.ts +++ b/frontend/src/erd/handleUtils.test.ts @@ -1,7 +1,39 @@ import { describe, it, expect } from 'vitest'; -import { sanitizeHandleId, sourceColumnHandleId, targetColumnHandleId } from './handleUtils'; +import { parseColumnNameFromHandle, sanitizeHandleId, sourceColumnHandleId, targetColumnHandleId } from './handleUtils'; describe('handleUtils', () => { + describe('parseColumnNameFromHandle', () => { + it('should return null for invalid handles', () => { + expect(parseColumnNameFromHandle(null)).toBeNull(); + expect(parseColumnNameFromHandle(undefined)).toBeNull(); + expect(parseColumnNameFromHandle('')).toBeNull(); + expect(parseColumnNameFromHandle('invalid-handle')).toBeNull(); + }); + + it('should decode a simple ascii string handle', () => { + expect(parseColumnNameFromHandle('c-0069-0064')).toBe('id'); + expect(parseColumnNameFromHandle('src-c-0069-0064')).toBe('id'); + expect(parseColumnNameFromHandle('tgt-c-0069-0064')).toBe('id'); + }); + + it('should decode an empty string handle', () => { + expect(parseColumnNameFromHandle('c-empty')).toBe(''); + expect(parseColumnNameFromHandle('src-c-empty')).toBe(''); + }); + + it('should decode special characters handle', () => { + expect(parseColumnNameFromHandle('c-0075-0073-0065-0072-005f-0069-0064')).toBe('user_id'); + }); + + it('should decode unicode characters handle', () => { + expect(parseColumnNameFromHandle('c-0069-0064-005f-ac00')).toBe('id_가'); + }); + + it('should decode emojis handle', () => { + expect(parseColumnNameFromHandle('c-0069-0064-005f-1f680')).toBe('id_🚀'); + }); + }); + describe('sanitizeHandleId', () => { it('should encode a simple ascii string', () => { expect(sanitizeHandleId('id')).toBe('c-0069-0064'); diff --git a/frontend/src/erd/handleUtils.ts b/frontend/src/erd/handleUtils.ts index 054d5ab2..2961024e 100644 --- a/frontend/src/erd/handleUtils.ts +++ b/frontend/src/erd/handleUtils.ts @@ -1,3 +1,12 @@ +export function parseColumnNameFromHandle(handleId: string | null | undefined): string | null { + if (!handleId) return null; + const match = handleId.match(/^(?:src-|tgt-)?c-(.+)$/); + if (!match) return null; + const encoded = match[1]; + if (encoded === 'empty') return ''; + return encoded.split('-').map(hex => String.fromCodePoint(parseInt(hex, 16))).join(''); +} + export function sanitizeHandleId(columnName: string): string { const encoded = Array.from(columnName, (char) => { // Array.from only yields non-empty Unicode scalars, so codePointAt(0) is defined. diff --git a/frontend/src/erd/mermaid.ts b/frontend/src/erd/mermaid.ts index 6e77a9bc..62816322 100644 --- a/frontend/src/erd/mermaid.ts +++ b/frontend/src/erd/mermaid.ts @@ -1,6 +1,6 @@ import type { Node, Edge } from "@xyflow/react"; import type { TableNodeData } from "./convert"; -import { sanitizeHandleId } from "./handleUtils"; +import { parseColumnNameFromHandle, sanitizeHandleId } from "./handleUtils"; function sanitizeString(str: string): string { if (!str) return ""; @@ -29,14 +29,20 @@ export function exportMermaid( const fkNodesWithoutHandles = new Set(); for (const edge of edges) { - if (edge.sourceHandle?.startsWith("src-")) { - fkNodeColumnPairs.add(`${edge.source}:${edge.sourceHandle.slice(4)}`); - } else if (!edge.sourceHandle) { + if (edge.sourceHandle) { + const parsedSource = parseColumnNameFromHandle(edge.sourceHandle); + if (parsedSource) { + fkNodeColumnPairs.add(`${edge.source}:${parsedSource}`); + } + } else { fkNodesWithoutHandles.add(edge.source); } - if (edge.targetHandle?.startsWith("tgt-")) { - fkNodeColumnPairs.add(`${edge.target}:${edge.targetHandle.slice(4)}`); + if (edge.targetHandle) { + const parsedTarget = parseColumnNameFromHandle(edge.targetHandle); + if (parsedTarget) { + fkNodeColumnPairs.add(`${edge.target}:${parsedTarget}`); + } } } @@ -48,10 +54,9 @@ export function exportMermaid( let modifiers = ""; if (col.is_pk) modifiers += " PK"; - const safeId = sanitizeHandleId(col.column_name); // ⚡ Bolt: O(1) lookups instead of O(E) array search for every column const isFk = - fkNodeColumnPairs.has(`${node.id}:${safeId}`) || + fkNodeColumnPairs.has(`${node.id}:${col.column_name}`) || (fkNodesWithoutHandles.has(node.id) && node.data.badges?.fk); if (isFk && !col.is_pk) modifiers += " FK";