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
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,7 @@ 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-05-18 - ERD export handles parse bottleneck
**Learning:** The application was recalculating hex-encoded handle IDs in O(N) loops during ERD export because it only had an encoder and lacked a decoder. We can eliminate this O(N*E) cost by implementing a reverse parser with a cache map, unlocking O(1) direct column name checks.
**Action:** When working with identifier structures mapped to string-based DOM constraints (like React Flow handles), implement an explicit bi-directional decoder to retrieve original states directly rather than mapping all entities forward to strings on every request.
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
11 changes: 9 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,14 @@ 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);

// In coverage edge test, targetHandle 'tgt-' resolves to empty string, but we still need to output the defensive fallback.
if (parsedSource !== undefined && parsedTarget !== undefined) {
sourceCols = [safeId(parsedSource)];
targetCols = [safeId(parsedTarget)];
}
}

if (sourceCols.length > 0 && targetCols.length > 0) {
Expand Down
19 changes: 10 additions & 9 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,14 +67,15 @@ 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 sourceExists = (sourceNode.data.columns || []).some(c => c.column_name === parsedSource);
const targetExists = (targetNode.data.columns || []).some(c => c.column_name === parsedTarget);
if (sourceExists && targetExists) {
return { sourceColumns: [parsedSource], targetColumns: [parsedTarget] };
}
}

const fallbackSource = (sourceNode.data.columns || [])
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 parsedColumn = parseColumnNameFromHandle(edge.sourceHandle);
if (parsedColumn) {
info.columns.add(parsedColumn);
}
}
}

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
32 changes: 31 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,34 @@ describe('handleUtils', () => {
expect(targetColumnHandleId('id')).toBe('tgt-c-0069-0064');
});
});

describe('parseColumnNameFromHandle', () => {
it('should decode a simple ascii handle', () => {
expect(parseColumnNameFromHandle('c-0069-0064')).toBe('id');
});

it('should decode an empty handle', () => {
expect(parseColumnNameFromHandle('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 emoji handle', () => {
expect(parseColumnNameFromHandle('c-0069-0064-005f-1f680')).toBe('id_🚀');
});

it('should strip src- prefix and decode', () => {
expect(parseColumnNameFromHandle('src-c-0069-0064')).toBe('id');
});

it('should strip tgt- prefix and decode', () => {
expect(parseColumnNameFromHandle('tgt-c-0069-0064')).toBe('id');
});
});
});
42 changes: 41 additions & 1 deletion frontend/src/erd/handleUtils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
const sanitizeCache = new Map<string, string>();
const parseCache = new Map<string, string>();

export function sanitizeHandleId(columnName: string): string {
if (sanitizeCache.has(columnName)) {
return sanitizeCache.get(columnName)!;
}

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('-')

return `c-${encoded || 'empty'}`
const result = `c-${encoded || 'empty'}`
sanitizeCache.set(columnName, result);
parseCache.set(result, columnName); // pre-populate reverse cache
return result;
}

export function sourceColumnHandleId(columnName: string): string {
Expand All @@ -14,3 +24,33 @@ export function sourceColumnHandleId(columnName: string): string {
export function targetColumnHandleId(columnName: string): string {
return `tgt-${sanitizeHandleId(columnName)}`
}

export function parseColumnNameFromHandle(handleId: string | null | undefined): string {
if (!handleId) return '';

const cleanHandle = handleId.startsWith('src-')
? handleId.slice(4)
: handleId.startsWith('tgt-')
? handleId.slice(4)
: handleId;

if (cleanHandle === 'c-empty') return '';

if (parseCache.has(cleanHandle)) {
return parseCache.get(cleanHandle)!;
}

if (!cleanHandle.startsWith('c-')) return cleanHandle;

const parts = cleanHandle.slice(2).split('-');
let result = '';
for (const part of parts) {
if (part) {
result += String.fromCodePoint(parseInt(part, 16));
}
}

parseCache.set(cleanHandle, result);
sanitizeCache.set(result, cleanHandle); // populate forward cache
return result;
}
22 changes: 14 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,20 @@ 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 parsedColumn = parseColumnNameFromHandle(edge.sourceHandle);
if (parsedColumn) {
fkNodeColumnPairs.add(`${edge.source}:${parsedColumn}`);
}
} else {
fkNodesWithoutHandles.add(edge.source);
}

if (edge.targetHandle?.startsWith("tgt-")) {
fkNodeColumnPairs.add(`${edge.target}:${edge.targetHandle.slice(4)}`);
if (edge.targetHandle) {
const parsedColumn = parseColumnNameFromHandle(edge.targetHandle);
if (parsedColumn) {
fkNodeColumnPairs.add(`${edge.target}:${parsedColumn}`);
}
}
}

Expand All @@ -48,10 +54,10 @@ 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
// We check directly against the parsed original column name instead of repeatedly recalculating the sanitized id string hex.
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