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-26 - O(1) Decoded Column Extraction from Edge Handles
**Learning:** During ERD export flows, edge connection metadata was extracted by iterating over every column array (`O(N)`) on source and target nodes while dynamically re-generating encoded string handles (`sourceColumnHandleId`) to match against raw React Flow connections. This led to heavy GC allocation spikes from split and mapping logic executed per edge during exports.
**Action:** Introduced a `parseColumnNameFromHandle` O(1) reverse-parsing utility that decodes the React Flow edge handles back into their raw column strings directly, eliminating the need to iterate over table schemas to find source/target relations and greatly optimizing operations like `exportDbml` and `exportMermaid` handling loops.
20 changes: 18 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,23 @@ 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-', ''))];
let srcCol: string | null = null;
let tgtCol: string | null = null;
if (edge.sourceHandle.startsWith('src-c-')) {
srcCol = parseColumnNameFromHandle(edge.sourceHandle);
}
if (edge.targetHandle.startsWith('tgt-c-')) {
tgtCol = parseColumnNameFromHandle(edge.targetHandle);
}

if (srcCol !== null && tgtCol !== null) {
sourceCols = [safeId(srcCol)];
targetCols = [safeId(tgtCol)];
} else {
// Fallback logic, should not be reached with parsed handles
sourceCols = [safeId(edge.sourceHandle.replace('src-', ''))];
targetCols = [safeId(edge.targetHandle.replace('tgt-', ''))];
}
}

if (sourceCols.length > 0 && targetCols.length > 0) {
Expand Down
26 changes: 19 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,24 @@ 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;
let sourceHandleColumn: string | undefined;
if (edge.sourceHandle && edge.sourceHandle.startsWith('src-c-')) {
sourceHandleColumn = parseColumnNameFromHandle(edge.sourceHandle) ?? undefined;
} else {
sourceHandleColumn = (sourceNode.data.columns || [])
.find((column) => sourceColumnHandleId(column.column_name) === edge.sourceHandle)
?.column_name;
}

let targetHandleColumn: string | undefined;
if (edge.targetHandle && edge.targetHandle.startsWith('tgt-c-')) {
targetHandleColumn = parseColumnNameFromHandle(edge.targetHandle) ?? undefined;
} else {
targetHandleColumn = (targetNode.data.columns || [])
.find((column) => targetColumnHandleId(column.column_name) === edge.targetHandle)
?.column_name;
}

if (sourceHandleColumn && targetHandleColumn) {
return { sourceColumns: [sourceHandleColumn], targetColumns: [targetHandleColumn] };
}
Expand Down
13 changes: 11 additions & 2 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 { sourceColumnHandleId, parseColumnNameFromHandle } from './handleUtils';

const CONTROL_TEXT_RE = /[\u0000-\u001f\u007f]+/g;
const CSV_FORMULA_RE = /^[=+\-@]/;
Expand Down Expand Up @@ -59,7 +59,16 @@ function foreignKeyColumnsByNode(edges: Edge[]): Map<string, ForeignKeyNodeInfo>
}

if (edge.sourceHandle) {
info.handles.add(edge.sourceHandle);
if (edge.sourceHandle.startsWith('src-c-')) {
const parsedName = parseColumnNameFromHandle(edge.sourceHandle);
if (parsedName !== null) {
info.columns.add(parsedName);
} else {
info.handles.add(edge.sourceHandle);
}
} else {
info.handles.add(edge.sourceHandle);
}
}
}

Expand Down
69 changes: 42 additions & 27 deletions frontend/src/erd/handleUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,53 @@
import { describe, it, expect } from 'vitest';
import { sanitizeHandleId, sourceColumnHandleId, targetColumnHandleId } from './handleUtils';
import { expect, test, describe } from 'vitest';

describe('handleUtils', () => {
describe('sanitizeHandleId', () => {
it('should encode a simple ascii string', () => {
expect(sanitizeHandleId('id')).toBe('c-0069-0064');
});
import { sanitizeHandleId, sourceColumnHandleId, targetColumnHandleId, parseColumnNameFromHandle } from './handleUtils';

it('should handle empty string', () => {
expect(sanitizeHandleId('')).toBe('c-empty');
});
describe('sanitizeHandleId', () => {
test('handles empty strings', () => {
expect(sanitizeHandleId('')).toBe('c-empty');
});

test('encodes regular strings to hex code points', () => {
// 'id' -> 0x69 0x64 -> 0069-0064
expect(sanitizeHandleId('id')).toBe('c-0069-0064');
});

test('encodes snake_case strings', () => {
// 'user_id' -> u=0075, s=0073, e=0065, r=0072, _=005f, i=0069, d=0064
expect(sanitizeHandleId('user_id')).toBe('c-0075-0073-0065-0072-005f-0069-0064');
});
});

describe('sourceColumnHandleId', () => {
test('prefixes the sanitized id with src-', () => {
expect(sourceColumnHandleId('id')).toBe('src-c-0069-0064');
});
});

it('should handle special characters', () => {
expect(sanitizeHandleId('user_id')).toBe('c-0075-0073-0065-0072-005f-0069-0064');
});
describe('targetColumnHandleId', () => {
test('prefixes the sanitized id with tgt-', () => {
expect(targetColumnHandleId('id')).toBe('tgt-c-0069-0064');
});
});

it('should handle unicode characters', () => {
expect(sanitizeHandleId('id_가')).toBe('c-0069-0064-005f-ac00');
});
describe('parseColumnNameFromHandle', () => {
test('parses source handle correctly', () => {
expect(parseColumnNameFromHandle('src-c-0075-0073-0065-0072-005f-0069-0064')).toBe('user_id');
});

test('parses target handle correctly', () => {
expect(parseColumnNameFromHandle('tgt-c-0075-0073-0065-0072-005f-0069-0064')).toBe('user_id');
});

it('should handle emojis', () => {
expect(sanitizeHandleId('id_🚀')).toBe('c-0069-0064-005f-1f680');
});
test('parses naked handle correctly', () => {
expect(parseColumnNameFromHandle('c-0075-0073-0065-0072-005f-0069-0064')).toBe('user_id');
});

describe('sourceColumnHandleId', () => {
it('should prepend src- to sanitized id', () => {
expect(sourceColumnHandleId('id')).toBe('src-c-0069-0064');
});
test('returns empty string for c-empty', () => {
Comment on lines +42 to +46
expect(parseColumnNameFromHandle('src-c-empty')).toBe('');
});

describe('targetColumnHandleId', () => {
it('should prepend tgt- to sanitized id', () => {
expect(targetColumnHandleId('id')).toBe('tgt-c-0069-0064');
});
test('returns null for invalid format', () => {
expect(parseColumnNameFromHandle('invalid')).toBeNull();
});
});
16 changes: 16 additions & 0 deletions frontend/src/erd/handleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,19 @@ export function sourceColumnHandleId(columnName: string): string {
export function targetColumnHandleId(columnName: string): string {
return `tgt-${sanitizeHandleId(columnName)}`
}

export function parseColumnNameFromHandle(handleId: string): string | null {
const match = handleId.match(/^(?:src-|tgt-)?c-(.+)$/);
if (!match) return null;
const encoded = match[1];
if (encoded === 'empty') return '';

try {
return encoded
.split('-')
.map((hex) => String.fromCodePoint(parseInt(hex, 16)))
.join('');
} catch (e) {
return null;
}
Comment on lines +19 to +31
}
Loading