Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion frontend/src/erd/__tests__/coverageEdges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/erd/__tests__/dbml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
];
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/erd/dbml.ts
Original file line number Diff line number Diff line change
@@ -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, "''");
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 3 additions & 7 deletions frontend/src/erd/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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] };
}
Expand Down
17 changes: 7 additions & 10 deletions frontend/src/erd/exportDataDictionary.ts
Original file line number Diff line number Diff line change
@@ -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 = /^[=+\-@]/;
Expand Down Expand Up @@ -41,7 +41,6 @@ function sourceColumnsForEdge(edge: Edge): Set<string> {

type ForeignKeyNodeInfo = {
columns: Set<string>;
handles: Set<string>;
};

function foreignKeyColumnsByNode(edges: Edge[]): Map<string, ForeignKeyNodeInfo> {
Expand All @@ -50,7 +49,7 @@ function foreignKeyColumnsByNode(edges: Edge[]): Map<string, ForeignKeyNodeInfo>
for (const edge of edges) {
let info = map.get(edge.source);
if (!info) {
info = { columns: new Set<string>(), handles: new Set<string>() };
info = { columns: new Set<string>() };
map.set(edge.source, info);
}

Expand All @@ -59,7 +58,10 @@ function foreignKeyColumnsByNode(edges: Edge[]): Map<string, ForeignKeyNodeInfo>
}

if (edge.sourceHandle) {
info.handles.add(edge.sourceHandle);
const parsedCol = parseColumnNameFromHandle(edge.sourceHandle);
if (parsedCol) {
info.columns.add(parsedCol);
}
}
}

Expand All @@ -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 {
Expand Down
44 changes: 43 additions & 1 deletion frontend/src/erd/handleUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
});
23 changes: 23 additions & 0 deletions frontend/src/erd/handleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
17 changes: 9 additions & 8 deletions frontend/src/erd/mermaid.ts
Original file line number Diff line number Diff line change
@@ -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 "";
Expand Down Expand Up @@ -29,14 +29,16 @@ export function exportMermaid(
const fkNodesWithoutHandles = new Set<string>();

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}`);
}
}

Expand All @@ -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";
Expand Down
Loading