diff --git a/.jules/bolt.md b/.jules/bolt.md index f1a8c146..0b75d6f8 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-29 - O(1) Handle parsing to avoid string allocations +**Learning:** In ERD exports, repeatedly deriving column handles via string encoding (`sourceColumnHandleId`) during $O(N \times C)$ iteration or using it in $O(C)$ `.find()` lookups creates severe garbage collection pressure and CPU overhead, as it triggers Unicode decoding and hex padding string allocations for every check. +**Action:** Always parse handles back to their original strings in $O(1)$ (`parseColumnNameFromHandle`) directly when reading edge connections, and perform native string equality comparisons instead of repeatedly encoding strings to compare against handles. 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..1bc03499 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 sourceCol = parseColumnNameFromHandle(edge.sourceHandle); + const targetCol = parseColumnNameFromHandle(edge.targetHandle); + if (sourceCol != null && targetCol != null) { + sourceCols = [safeId(sourceCol)]; + targetCols = [safeId(targetCol)]; + } } if (sourceCols.length > 0 && targetCols.length > 0) { diff --git a/frontend/src/erd/export.ts b/frontend/src/erd/export.ts index 62ce7219..32544b9a 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 { sourceColumnHandleId, targetColumnHandleId, parseColumnNameFromHandle } from './handleUtils'; export * from './exportDataDictionary'; @@ -67,12 +67,8 @@ 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 sourceHandleColumn = parseColumnNameFromHandle(edge.sourceHandle); + const targetHandleColumn = parseColumnNameFromHandle(edge.targetHandle); if (sourceHandleColumn && targetHandleColumn) { return { sourceColumns: [sourceHandleColumn], targetColumns: [targetHandleColumn] }; } diff --git a/frontend/src/erd/exportDataDictionary.ts b/frontend/src/erd/exportDataDictionary.ts index 0111660d..88c9eec3 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 } 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 parsedCol = parseColumnNameFromHandle(edge.sourceHandle); + if (parsedCol) { + info.columns.add(parsedCol); + } } } @@ -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..a23d0c64 100644 --- a/frontend/src/erd/handleUtils.test.ts +++ b/frontend/src/erd/handleUtils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { sanitizeHandleId, sourceColumnHandleId, targetColumnHandleId } from './handleUtils'; +import { sanitizeHandleId, sourceColumnHandleId, targetColumnHandleId, parseColumnNameFromHandle } from './handleUtils'; describe('handleUtils', () => { describe('sanitizeHandleId', () => { @@ -35,4 +35,46 @@ describe('handleUtils', () => { expect(targetColumnHandleId('id')).toBe('tgt-c-0069-0064'); }); }); + + describe('parseColumnNameFromHandle', () => { + it('should parse simple ascii string from src handle', () => { + expect(parseColumnNameFromHandle('src-c-0069-0064')).toBe('id'); + }); + + it('should parse simple ascii string from tgt handle', () => { + expect(parseColumnNameFromHandle('tgt-c-0069-0064')).toBe('id'); + }); + + it('should parse simple ascii string from raw handle', () => { + expect(parseColumnNameFromHandle('c-0069-0064')).toBe('id'); + }); + + it('should parse empty string handle', () => { + expect(parseColumnNameFromHandle('c-empty')).toBe(''); + expect(parseColumnNameFromHandle('src-c-empty')).toBe(''); + }); + + it('should handle special characters', () => { + expect(parseColumnNameFromHandle('c-0075-0073-0065-0072-005f-0069-0064')).toBe('user_id'); + }); + + it('should handle unicode characters', () => { + expect(parseColumnNameFromHandle('c-0069-0064-005f-ac00')).toBe('id_가'); + }); + + it('should handle emojis', () => { + expect(parseColumnNameFromHandle('c-0069-0064-005f-1f680')).toBe('id_🚀'); + }); + + it('should return null for invalid handles', () => { + expect(parseColumnNameFromHandle(null)).toBeNull(); + expect(parseColumnNameFromHandle(undefined)).toBeNull(); + expect(parseColumnNameFromHandle('')).toBeNull(); + }); + + it('should return raw string for legacy unencoded handles', () => { + expect(parseColumnNameFromHandle('invalid-format')).toBe('invalid-format'); + expect(parseColumnNameFromHandle('src-invalid-format')).toBe('invalid-format'); + }); + }); }); diff --git a/frontend/src/erd/handleUtils.ts b/frontend/src/erd/handleUtils.ts index 054d5ab2..2e19367f 100644 --- a/frontend/src/erd/handleUtils.ts +++ b/frontend/src/erd/handleUtils.ts @@ -14,3 +14,26 @@ export function sourceColumnHandleId(columnName: string): string { export function targetColumnHandleId(columnName: string): string { return `tgt-${sanitizeHandleId(columnName)}` } + +export function parseColumnNameFromHandle(handleId: string | null | undefined): string | null { + if (!handleId) return null; + + let encoded = handleId; + if (encoded.startsWith('src-')) encoded = encoded.slice(4); + else if (encoded.startsWith('tgt-')) encoded = encoded.slice(4); + + if (!encoded.startsWith('c-')) { + // For tests or legacy handles that might not have the c- prefix encoding + return encoded; + } + encoded = encoded.slice(2); + + if (encoded === 'empty') return ''; + if (!encoded) return ''; + + try { + return encoded.split('-').map(hex => String.fromCodePoint(parseInt(hex, 16))).join(''); + } catch (e) { + return null; + } +} diff --git a/frontend/src/erd/mermaid.ts b/frontend/src/erd/mermaid.ts index 6e77a9bc..6a84e807 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 } from "./handleUtils"; function sanitizeString(str: string): string { if (!str) return ""; @@ -29,14 +29,16 @@ 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 parsed = parseColumnNameFromHandle(edge.sourceHandle); + if (parsed) fkNodeColumnPairs.add(`${edge.source}:${parsed}`); + } else { fkNodesWithoutHandles.add(edge.source); } - if (edge.targetHandle?.startsWith("tgt-")) { - fkNodeColumnPairs.add(`${edge.target}:${edge.targetHandle.slice(4)}`); + if (edge.targetHandle) { + const parsed = parseColumnNameFromHandle(edge.targetHandle); + if (parsed) fkNodeColumnPairs.add(`${edge.target}:${parsed}`); } } @@ -48,10 +50,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";