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-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.
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 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) {
Expand Down
23 changes: 14 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 { parseColumnNameFromHandle } from './handleUtils';

export * from './exportDataDictionary';

Expand Down Expand Up @@ -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 || [])
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, sourceColumnHandleId } from './handleUtils';

Comment on lines 3 to 5
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
34 changes: 33 additions & 1 deletion frontend/src/erd/handleUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/erd/handleUtils.ts
Original file line number Diff line number Diff line change
@@ -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('');
}
Comment on lines +1 to +8

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.
Expand Down
21 changes: 13 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, sanitizeHandleId } from "./handleUtils";
Comment on lines 1 to +3

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

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