diff --git a/frontend/src/erd/__tests__/exportDataDictionary.test.ts b/frontend/src/erd/__tests__/exportDataDictionary.test.ts index 85fa2737..b572388c 100644 --- a/frontend/src/erd/__tests__/exportDataDictionary.test.ts +++ b/frontend/src/erd/__tests__/exportDataDictionary.test.ts @@ -146,6 +146,24 @@ describe('exportDataDictionary', () => { expect(markdown).toContain('No columns.'); }); + it('should ignore invalid handles when computing foreign keys', () => { + const edge = { + id: 'edge1', + source: 'node1', + target: 'node2', + sourceHandle: 'invalid-handle', + } as Edge; + + const md = exportDictionaryMarkdown( + [{ id: 'node1', data: { columns: [{ column_name: 'id', data_type: 'int', is_pk: true }] } }] as Node[], + [edge] + ); + + // invalid-handle shouldn't trigger an FK match + expect(md).toContain('| id | int | Y | N | N | | |'); + }); + + it('escapes Markdown table breakers and HTML-like content', () => { const markdown = exportDictionaryMarkdown( [ diff --git a/frontend/src/erd/export.ts b/frontend/src/erd/export.ts index 62ce7219..ae1b1f6a 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 { decodeHandleId } from './handleUtils'; export * from './exportDataDictionary'; @@ -59,6 +59,8 @@ function fkColumnsForEdge( edge: Edge, sourceNode: Node, targetNode: Node, + sourceNodeColumnNames: Set, + targetNodeColumnNames: Set ): { sourceColumns: string[]; targetColumns: string[] } | null { const data = edge.data as ForeignKeyEdgeData | undefined; const sourceColumns = data?.sourceColumns?.filter(Boolean) || []; @@ -67,13 +69,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) { + // ⚡ Bolt: Optimize handle lookup to O(1) by decoding the handle string directly + // and using a precomputed O(1) Set to check for column existence. + const decodedSource = decodeHandleId(edge.sourceHandle); + const sourceHandleColumn = decodedSource !== null && sourceNodeColumnNames.has(decodedSource) + ? decodedSource + : undefined; + + const decodedTarget = decodeHandleId(edge.targetHandle); + const targetHandleColumn = decodedTarget !== null && targetNodeColumnNames.has(decodedTarget) + ? decodedTarget + : undefined; + + if (sourceHandleColumn !== undefined && targetHandleColumn !== undefined) { return { sourceColumns: [sourceHandleColumn], targetColumns: [targetHandleColumn] }; } @@ -96,8 +104,16 @@ export function exportDDL(nodes: Node[], edges: Edge[]): string { // Bolt: Use map for O(1) node lookup instead of O(N) array find // Avoid Map(array.map) to prevent O(N) intermediate tuple array allocation overhead const nodesById = new Map>(); + const nodeColumnSets = new Map>(); for (const n of nodes) { nodesById.set(n.id, n); + const colSet = new Set(); + if (n.data.columns) { + for (const c of n.data.columns) { + colSet.add(c.column_name); + } + } + nodeColumnSets.set(n.id, colSet); } // Export tables @@ -133,7 +149,13 @@ 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, + nodeColumnSets.get(sourceNode.id)!, + nodeColumnSets.get(targetNode.id)! + ); 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/exportDataDictionary.ts b/frontend/src/erd/exportDataDictionary.ts index 0111660d..692615e1 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 { decodeHandleId } 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); } @@ -58,8 +57,13 @@ function foreignKeyColumnsByNode(edges: Edge[]): Map info.columns.add(column); } + // ⚡ Bolt: Directly decode the handle and store the column name, avoiding + // repeatedly encoding every column during the export loop later. if (edge.sourceHandle) { - info.handles.add(edge.sourceHandle); + const decodedColumn = decodeHandleId(edge.sourceHandle); + if (decodedColumn !== null) { + info.columns.add(decodedColumn); + } } } @@ -74,12 +78,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..b9bcc05c 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, decodeHandleId } from './handleUtils'; describe('handleUtils', () => { describe('sanitizeHandleId', () => { @@ -35,4 +35,52 @@ describe('handleUtils', () => { expect(targetColumnHandleId('id')).toBe('tgt-c-0069-0064'); }); }); + + describe('decodeHandleId', () => { + it('should decode a simple ascii string', () => { + expect(decodeHandleId('c-0069-0064')).toBe('id'); + expect(decodeHandleId('src-c-0069-0064')).toBe('id'); + expect(decodeHandleId('tgt-c-0069-0064')).toBe('id'); + }); + + it('should handle empty string', () => { + expect(decodeHandleId('c-empty')).toBe(''); + expect(decodeHandleId('src-c-empty')).toBe(''); + }); + + it('should handle special characters', () => { + expect(decodeHandleId('c-0075-0073-0065-0072-005f-0069-0064')).toBe('user_id'); + }); + + it('should handle unicode characters', () => { + expect(decodeHandleId('c-0069-0064-005f-ac00')).toBe('id_가'); + }); + + it('should handle emojis', () => { + expect(decodeHandleId('c-0069-0064-005f-1f680')).toBe('id_🚀'); + }); + + it('should handle null/undefined/invalid values gracefully', () => { + expect(decodeHandleId(null)).toBeNull(); + expect(decodeHandleId(undefined)).toBeNull(); + expect(decodeHandleId('')).toBeNull(); + expect(decodeHandleId('invalid-format')).toBeNull(); + expect(decodeHandleId('src-c')).toBeNull(); // Missing hex parts + }); + + it('should reject non-canonical hex and prefixes', () => { + // Uppercase hex + expect(decodeHandleId('c-0069-006A')).toBeNull(); + // Junk in hex + expect(decodeHandleId('c-0069junk-0064')).toBeNull(); + // Arbitrary prefix + expect(decodeHandleId('foo-c-0069')).toBeNull(); + // Empty chunk mix + expect(decodeHandleId('c-empty-0069')).toBeNull(); + // Out of bounds code point + expect(decodeHandleId('c-200000')).toBeNull(); + // Missing hex chunk data between separators + expect(decodeHandleId('c-0069--0064')).toBeNull(); + }); + }); }); diff --git a/frontend/src/erd/handleUtils.ts b/frontend/src/erd/handleUtils.ts index 054d5ab2..139506e5 100644 --- a/frontend/src/erd/handleUtils.ts +++ b/frontend/src/erd/handleUtils.ts @@ -1,10 +1,17 @@ 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. - return char.codePointAt(0)!.toString(16).padStart(4, '0') - }).join('-') + if (!columnName) return 'c-empty' - return `c-${encoded || 'empty'}` + let encoded = '' + let isFirst = true + for (const char of columnName) { + if (!isFirst) { + encoded += '-' + } + encoded += char.codePointAt(0)!.toString(16).padStart(4, '0') + isFirst = false + } + + return `c-${encoded}` } export function sourceColumnHandleId(columnName: string): string { @@ -14,3 +21,46 @@ export function sourceColumnHandleId(columnName: string): string { export function targetColumnHandleId(columnName: string): string { return `tgt-${sanitizeHandleId(columnName)}` } + +const HEX_CHUNK_RE = /^[0-9a-f]{4,6}$/ + +export function decodeHandleId(handleId: string | null | undefined): string | null { + if (!handleId) return null; + + const parts = handleId.split('-'); + let payloadIndex = -1; + + if (parts[0] === 'c') { + payloadIndex = 1; + } else if ((parts[0] === 'src' || parts[0] === 'tgt') && parts[1] === 'c') { + payloadIndex = 2; + } + + if (payloadIndex === -1) return null; + + if (parts.length === payloadIndex + 1 && parts[payloadIndex] === 'empty') { + return ''; + } + + const hexParts = parts.slice(payloadIndex); + if (hexParts.length === 0) return null; + + let decoded = ''; + for (const hex of hexParts) { + if (!HEX_CHUNK_RE.test(hex)) { + return null; + } + const codePoint = Number.parseInt(hex, 16); + if (codePoint > 0x10ffff) { + return null; + } + try { + decoded += String.fromCodePoint(codePoint); + } catch { + /* v8 ignore next */ + return null; + } + } + + return decoded; +}