Skip to content
6 changes: 6 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,9 @@ 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-28 - Optimize ERD dangling edge lookups
**Learning:** Checking for edge validity in ERD exporters by matching edge handle strings against all column arrays nested inside all graph nodes using `Array.some` or `Array.find` takes $O(N * C)$ time for every edge ($O(E * N * C)$). This causes lag on large schemas.
**Action:** Since edge handles are hex-encoded strings representing the original column name, directly parsing the column name out of the edge handle allows for an $O(1)$ column lookup instead of iterating through nodes and re-encoding their column properties to find a match.
## 2026-09-04 - Asynchronous Query Resolution in Test Suites
**Learning:** Tests can fail unpredictably with a "TestingLibraryElementError: Unable to find an element" exception when synchronous queries like `getByText` or `getByRole` are used immediately after user interactions (e.g. `fireEvent.change`) that trigger asynchronous state updates. Even if the state appears to update immediately locally, it can fall victim to race conditions.
**Action:** When validating visual changes tied to state updates dependent on async operations or complex renders (like diagram search results in ERDs), always use asynchronous queries (e.g., `await screen.findByText(...)`) instead of their synchronous equivalents (`screen.getByText(...)`).
4 changes: 2 additions & 2 deletions frontend/src/App.coverage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,9 +326,9 @@ describe('App orchestration coverage', () => {
fireEvent.click(screen.getAllByRole('button', { name: '열기' })[1]!)
expect(screen.getByRole('heading', { name: '다이어그램' })).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('다이어그램 검색'), { target: { value: 'no-match' } })
expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument()
expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('다이어그램 검색'), { target: { value: 'failed' } })
expect(screen.getByText('ERD_all_2')).toBeInTheDocument()
expect(await screen.findByText('ERD_all_2')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '편집기 열기' }))
expect(screen.getByRole('toolbar', { name: 'ERD 캔버스 도구' })).toBeInTheDocument()

Expand Down
8 changes: 4 additions & 4 deletions frontend/src/erd/__tests__/prisma.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ describe('exportPrisma', () => {
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: 'users_posts',
},
];
Expand Down Expand Up @@ -224,8 +224,8 @@ describe('exportPrisma', () => {
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: '1to1',
},
];
Expand Down
24 changes: 17 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 { parseColumnNameFromHandle } from './handleUtils';

export * from './exportDataDictionary';

Expand Down Expand Up @@ -67,12 +67,22 @@ 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 = undefined;
if (edge.sourceHandle && edge.sourceHandle.startsWith('src-')) {
const parsedSource = parseColumnNameFromHandle(edge.sourceHandle.slice(4));
if (parsedSource && (sourceNode.data.columns || []).some(c => c.column_name === parsedSource)) {
sourceHandleColumn = parsedSource;
}
}

let targetHandleColumn: string | undefined = undefined;
if (edge.targetHandle && edge.targetHandle.startsWith('tgt-')) {
const parsedTarget = parseColumnNameFromHandle(edge.targetHandle.slice(4));
if (parsedTarget && (targetNode.data.columns || []).some(c => c.column_name === parsedTarget)) {
targetHandleColumn = parsedTarget;
}
}

if (sourceHandleColumn && targetHandleColumn) {
return { sourceColumns: [sourceHandleColumn], targetColumns: [targetHandleColumn] };
}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/erd/handleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ export function sourceColumnHandleId(columnName: string): string {
export function targetColumnHandleId(columnName: string): string {
return `tgt-${sanitizeHandleId(columnName)}`
}

export function parseColumnNameFromHandle(handleId: string): string {
if (!handleId || handleId === 'c-empty' || !handleId.startsWith('c-')) return '';
const parts = handleId.slice(2).split('-');
return parts.map(p => String.fromCodePoint(parseInt(p, 16))).join('');
}
21 changes: 15 additions & 6 deletions frontend/src/erd/prisma.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 { sanitizeHandleId, parseColumnNameFromHandle } from "./handleUtils";

function sanitizeName(name: string): string {
// Prisma model and field names must start with a letter and contain only alphanumeric characters and underscores
Expand Down Expand Up @@ -69,20 +69,29 @@ export function exportPrisma(
const relName = sanitizeName(String(edge.label || `${sourceNode.data.title}_${targetNode.data.title}`));

let sourceField = "";
if (edge.sourceHandle?.startsWith("src-")) {
sourceField = edge.sourceHandle.slice(4);
if (edge.sourceHandle && edge.sourceHandle.startsWith("src-")) {
const parsedSource = parseColumnNameFromHandle(edge.sourceHandle.slice(4));
if (parsedSource && (sourceNode.data.columns || []).some(c => c.column_name === parsedSource)) {
sourceField = parsedSource;
}
}

if (sourceField) {
fkNodeColumnPairs.add(`${edge.source}:${sourceField}`);
} else if (!edge.sourceHandle) {
fkNodesWithoutHandles.add(edge.source);
}

let targetField = "id"; // fallback
if (edge.targetHandle?.startsWith("tgt-")) {
targetField = edge.targetHandle.slice(4);
if (edge.targetHandle && edge.targetHandle.startsWith("tgt-")) {
const parsedTarget = parseColumnNameFromHandle(edge.targetHandle.slice(4));
if (parsedTarget && (targetNode.data.columns || []).some(c => c.column_name === parsedTarget)) {
targetField = parsedTarget;
}
}

if (sourceField) {
const isUnique = sourceNode.data.columns.find(c => c.column_name === sourceField)?.is_pk || false;
const isUnique = sourceNode.data.columns.some(c => c.column_name === sourceField && c.is_pk);

const relList = incomingRelationsByNode.get(edge.target) || [];
relList.push({
Expand Down
3 changes: 3 additions & 0 deletions plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
1. **Fix Flaky Test in `frontend/src/App.coverage.test.tsx`**: Update the failing test `navigates dashboard, project, and diagram states including empty/search branches` to use asynchronous queries (`findByText`, `findAllByText`, `findByRole`) when checking for elements that render based on asynchronous state updates or API mocks. According to the `.jules/bolt.md` (or general memory) and the traceback, we need to use `await screen.findByText('검색 결과가 없습니다.')` instead of `screen.getByText` because filtering depends on state changes that may not have painted immediately after the user interaction. The test is failing due to a race condition (TestingLibraryElementError: Unable to find an element with the text...).
2. **Run Frontend checks**: `cd frontend && pnpm run typecheck && pnpm test -- --run` to verify the fix works.
3. **Complete pre-commit steps**: Complete pre-commit steps to ensure proper testing, verification, review, and reflection are done.
Loading