From 1ae420092b87b1ebbb3514b37c53b7b8674941f4 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:48:18 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9A=A1=20Bolt:=20ERD=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=EA=B2=80=EC=83=89=20=EC=B5=9C=EC=A0=81=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=B4=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=ED=97=A4=EB=93=9C=20=EB=B0=8F=20GC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .Jules/bolt.md | 3 ++ frontend/src/erd/search.ts | 62 ++++++++++++++++++++++++++------------ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/.Jules/bolt.md b/.Jules/bolt.md index 9bb054fe..89e61cda 100644 --- a/.Jules/bolt.md +++ b/.Jules/bolt.md @@ -1,3 +1,6 @@ ## 2025-06-27 - [Map Initialization Overhead] **Learning:** Initializing Maps with `new Map(array.map(...))` creates unnecessary intermediate arrays, consuming memory and triggering garbage collection overhead, especially noticeable when dealing with many nodes. **Action:** Use a `for...of` loop to directly `map.set()` elements rather than creating an intermediate array of tuples, especially in frequently executed or rendering paths. +## 2024-05-18 - Optimize string search logic for node properties +**Learning:** Checking multiple fields for string match across a large React array by looping over elements and doing repeated `toLowerCase()` for each element creates significant garbage collection pressure and is a major bottleneck on large datasets. +**Action:** When implementing high-frequency search or filtering operations across large collections of ERD nodes in the frontend, prefer direct string concatenation to consolidate all searchable fields into a single pre-lowercased string per node. Using substring matching on this single string prevents severe garbage collection pressure compared to array methods (`flatMap`, `join`) and avoids the overhead of iteratively lowercasing individual properties. diff --git a/frontend/src/erd/search.ts b/frontend/src/erd/search.ts index 7e169562..6b854c72 100644 --- a/frontend/src/erd/search.ts +++ b/frontend/src/erd/search.ts @@ -2,32 +2,41 @@ import type { Node } from "@xyflow/react"; import type { TableNodeData } from "./convert"; -function fieldIncludes(value: string | null | undefined, term: string): boolean { - return Boolean(value && value.toLocaleLowerCase().includes(term)); -} - -function nodeIncludesTerm(node: Node, term: string): boolean { - if (fieldIncludes(node.data.title, term)) return true; - if (fieldIncludes(node.data.comment, term)) return true; - - for (const column of node.data.columns) { - if (fieldIncludes(column.column_name, term)) return true; - if (fieldIncludes(column.data_type, term)) return true; - if (fieldIncludes(column.column_comment, term)) return true; +// ⚡ Bolt: Consolidate searchable text into a single string per node to avoid redundant +// .toLocaleLowerCase() calls and enable fast substring matching via indexOf. +function nodeSearchText(node: Node): string { + let text = ""; + if (node.data.title) text += node.data.title + " "; + if (node.data.comment) text += node.data.comment + " "; + for (let i = 0; i < node.data.columns.length; i++) { + const col = node.data.columns[i]; + if (col.column_name) text += col.column_name + " "; + if (col.data_type) text += col.data_type + " "; + if (col.column_comment) text += col.column_comment + " "; } + return text.toLocaleLowerCase(); +} - return false; +function getSearchTerms(search: string): string[] { + return Array.from( + new Set(search.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)), + ); } export function tableNodeMatchesSearch( node: Node, search: string, ): boolean { - const terms = Array.from( - new Set(search.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)), - ); + const terms = getSearchTerms(search); if (terms.length === 0) return false; - return terms.every((term) => nodeIncludesTerm(node, term)); + + const searchText = nodeSearchText(node); + for (let i = 0; i < terms.length; i++) { + if (searchText.indexOf(terms[i]) === -1) { + return false; + } + } + return true; } export function findSearchMatchedNodeIds( @@ -35,8 +44,23 @@ export function findSearchMatchedNodeIds( search: string, ): Set { const matches = new Set(); - for (const node of nodes) { - if (tableNodeMatchesSearch(node, search)) { + + // ⚡ Bolt: Parse search terms exactly once before iterating over nodes, + // replacing O(N*M) redundant string parsing overhead with a single O(M) operation. + const terms = getSearchTerms(search); + if (terms.length === 0) return matches; + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const searchText = nodeSearchText(node); + let matched = true; + for (let j = 0; j < terms.length; j++) { + if (searchText.indexOf(terms[j]) === -1) { + matched = false; + break; + } + } + if (matched) { matches.add(node.id); } } From 5481a43f1efdc9be9f31ea744b5caa8660938757 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:11:22 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9A=A1=20Bolt:=20ERD=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=EA=B2=80=EC=83=89=20=EC=B5=9C=EC=A0=81=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=B4=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=ED=97=A4=EB=93=9C=20=EB=B0=8F=20GC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/codeql.yml | 40 ++++ .github/workflows/scorecard.yml | 42 ++++ CHANGELOG.md | 1 - CLAUDE.md | 120 ----------- frontend/src/App.tsx | 21 -- .../components/modals/CardinalityModal.tsx | 1 - .../components/modals/ExportModal.test.tsx | 18 +- .../src/components/modals/ExportModal.tsx | 22 --- .../__tests__/exportDataDictionary.test.ts | 187 ------------------ frontend/src/erd/export.ts | 2 - frontend/src/erd/exportDataDictionary.ts | 162 --------------- 11 files changed, 83 insertions(+), 533 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/scorecard.yml delete mode 100644 CLAUDE.md delete mode 100644 frontend/src/erd/__tests__/exportDataDictionary.test.ts delete mode 100644 frontend/src/erd/exportDataDictionary.ts diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..136cb2f1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: codeql-ci-sast + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "17 2 * * 2" + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: ["javascript-typescript", "python"] + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Initialize CodeQL + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..75c2c990 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,42 @@ +--- +name: scorecard + +"on": + branch_protection_rule: + schedule: + - cron: "30 1 * * 6" + push: + branches: ["main"] + +permissions: + contents: read + +jobs: + analysis: + permissions: + contents: read + # Private repos may need extra job-level reads to avoid GraphQL + # "Resource not accessible by integration" (e.g., ListCommits). + issues: read + pull-requests: read + checks: read + security-events: write + id-token: write + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Run Scorecard + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: results.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md index 519bee6c..9a632832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,4 +6,3 @@ - [FE] 📋 **테이블 복제 기능 추가**: 편집 모달 내에서 기존 테이블의 구조(컬럼 정보 포함)를 그대로 복사하여 새 테이블 노드로 생성하는 '복제' 버튼을 추가했습니다. - [FE] `autoInfer.ts`에 대한 단위 테스트 및 UI 컴포넌트 단위 테스트를 추가하여 100% 테스트 커버리지를 유지합니다. - [FE] ⬇️ **DBML Export**: ERD 다이어그램을 DBML (Database Markup Language) 형식으로 내보낼 수 있는 기능을 추가했습니다. 상단의 DBML 버튼을 클릭하여 다운로드할 수 있습니다. -- [FE] 📚 **Data Dictionary Export**: ERD 테이블/컬럼 메타데이터를 CSV 및 Markdown으로 내보내며, CSV formula injection과 Markdown 렌더링 escape를 적용했습니다. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e13a3f78..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,120 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What this is - -pg-erd-cloud is a PostgreSQL-focused cloud ERD (entity-relationship diagram) collaboration/sharing service — currently a runnable MVP skeleton. It reverse-engineers a target PostgreSQL database (optionally Snowflake) into JSON schema snapshots, renders them as an interactive ERD (React Flow), and forward-engineers snapshots into DDL exports (PostgreSQL or Snowflake dialect), schema diffs/migration SQL, DBML/Mermaid exports, and "DB reversing spec" documents (markdown draft, LLM prompt, or live LLM draft via an OpenAI-compatible provider configured with `LLM_API_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL`). Project owners can create share links for unauthenticated read/export access. - -Repository docs are mixed-language: README.md and CHANGELOG.md are Korean; CONTRIBUTING.md, SECURITY.md, and most of docs/ are English. - -## Common commands - -### Full stack (Docker, dev) - -```bash -cp .env.example .env # set POSTGRES_PASSWORD and APP_SECRET -docker compose up -d --build -``` - -Frontend: http://localhost:5173 · Backend: http://localhost:8000 (health: `/healthz`). Source directories are bind-mounted; backend runs with `--reload` and frontend runs the Vite dev server, so edits hot-reload. - -### Backend (backend/, Python ≥3.10, CI uses 3.10) - -```bash -cd backend -python -m venv .venv && source .venv/bin/activate -pip install -U pip -pip install -e . # add ".[snowflake]" for Snowflake reverse engineering -alembic upgrade head # apply migrations (backend/alembic/versions) -hypercorn --config python:app.hypercorn_config app.main:app \ - --bind 0.0.0.0:8000 --reload --access-logfile - --error-logfile - -``` - -Checks (mirror CI): - -```bash -cd backend -PYTHONPATH=. mypy app -PYTHONPATH=. pytest -q -PYTHONPATH=. pytest -q tests/test_snapshot_job.py # single file -PYTHONPATH=. pytest -q tests/test_snapshot_job.py::test_name # single test -``` - -CI installs backend deps with `pip install --require-hashes -r requirements-dev.lock`. The lockfiles are generated with uv (see header comments in `backend/requirements*.lock`); when changing dependencies in `backend/pyproject.toml`, regenerate both lockfiles, e.g.: - -```bash -uv pip compile backend/pyproject.toml --python-version 3.10 --generate-hashes -o backend/requirements.lock -uv pip compile backend/pyproject.toml --python-version 3.10 --generate-hashes --extra dev -o backend/requirements-dev.lock -``` - -### Frontend (frontend/, Node 26 — see .nvmrc / engines) - -```bash -cd frontend -npm ci -npm run dev # Vite dev server on 5173 -npm run typecheck # tsc --noEmit -npm run test # vitest run -npm run test -- src/erd/__tests__/cardinality.test.ts # single test file -npm run build # tsc -b && vite build -``` - -CI uses npm with `package-lock.json` (a `pnpm-lock.yaml` also exists, but CI caches npm). - -### Production-style (Docker + Traefik) - -```bash -cp .env.example .env -mkdir -p secrets -# write a long random value to secrets/app_secret (never commit; .gitignore covers **/secrets/**) -chmod 600 secrets/app_secret -docker compose -f compose.prod.yaml up -d --build -``` - -App entrypoint: http://localhost:8080 (`TRAEFIK_HTTP_PORT`). - -## Architecture - -Three deployable pieces in one repo: - -- **backend/** — Python FastAPI app (async SQLAlchemy 2 + asyncpg on PostgreSQL 16), served by Hypercorn. The app's own PostgreSQL stores metadata: users, project spaces/members, encrypted DB connections, schema snapshots, job queue, diagram views, annotations, share links, API keys (`app/models.py`). -- **frontend/** — React 19 + TypeScript + Vite SPA. The ERD canvas is built on `@xyflow/react` (React Flow). Core ERD logic lives in `src/erd/` (snapshot→graph conversion, cardinality, FK auto-inference, DBML/Mermaid/SQL export); modal dialogs in `src/components/modals/`. Tests use Vitest + Testing Library (plus fast-check fuzz tests). -- **deploy/traefik/** — Traefik dynamic config used by `compose.prod.yaml`. - -### Backend layout (backend/app/) - -- `api/` — FastAPI routers (projects, connections, snapshots, share, diagram_views, annotations, api_keys, auth_routes, me), all mounted in `main.py` under `/api`. -- `jobs/` — Postgres-backed job queue (`JobQueue` table). Reverse engineering never blocks the request path: the API enqueues a `snapshot` job, and an in-process worker task (started in the FastAPI lifespan in `main.py`) claims and executes it. Optional Valkey/Redis (`valkey_queue.py`) is only a wake-up signal; Postgres remains the source of truth. -- `pg_introspect/` — pg_catalog-based introspection of the *target* PostgreSQL: schemas/tables/columns, PK/FK/UNIQUE/CHECK, indexes. Index access methods are discovered dynamically from `pg_am`/`pg_class.relam` and index DDL is preserved losslessly via `pg_get_indexdef()` — do not hardcode an index-type list (project principle, see README). Also synthesizes safe `example_value` column hints from name/type metadata only (never samples real table data). -- `snowflake_introspect/` — optional Snowflake reverse engineering (INFORMATION_SCHEMA; requires the `snowflake` extra). -- `ddl/` — forward engineering: snapshot → DDL export with dialect mapping, migration SQL, migration-safety checks. -- `diff/` — snapshot-to-snapshot schema diff. -- `spec/` — reversing-spec generation, naming lint, data dictionary, relationship inference, LLM integration. -- Cross-cutting: `auth.py` (OIDC/Casdoor JWT verification when `OIDC_ISSUER` is set, plus API keys and token revocation), `csrf.py`, `rate_limit.py` (in-memory fixed-window; global `/api/*` limit plus a stricter separate limit for public `/api/share/*`), `security_headers.py`, `observability.py` (JSON request logs + Prometheus metrics — see docs/observability.md), `sanitize.py`/`dsn_redaction.py`, `settings.py` (pydantic-settings; env vars are documented in `.env.example`). - -### Data flow - -1. A user registers a target-DB connection; the DSN is encrypted with `APP_SECRET` before being stored in the app DB. -2. Requesting a snapshot enqueues a job; the background worker connects to the target DB, introspects it, and stores a JSON snapshot (`SchemaSnapshot` + `SchemaSnapshotData`). -3. The frontend fetches snapshots via `/api/*` and renders the ERD; all exports (DDL, diff/migration SQL, reversing spec, DBML/Mermaid) are derived from the stored snapshot, not from live DB access. -4. Share links expose read-only snapshot/export routes under `/api/share/{share_uuid}/...` with a tighter rate limit, and sensitive fields (schema comments, example values) are redacted from publicly shared payloads. - -### Dev vs prod compose - -- `compose.yaml` (dev): postgres + backend + frontend. Bind mounts and hot reload; backend on 127.0.0.1:8000, frontend on 127.0.0.1:5173; `APP_SECRET` comes from `.env`; CORS allows http://localhost:5173. -- `compose.prod.yaml`: adds a Traefik edge router on :8080 that routes `/api/*` and `/healthz` to the backend and everything else to a static frontend (`frontend/Dockerfile.prod` builds `dist/` served by `serve-static.mjs`). No bind mounts or reload; `APP_SECRET` is injected as a Docker secret file (`APP_SECRET_FILE=/run/secrets/app_secret`); only Traefik is published. Traefik also applies security-headers middleware (`deploy/traefik/dynamic.yaml`). -- Both compose files run `alembic upgrade head` before starting the backend, so migrations must always be committed alongside model changes. - -## Conventions and gotchas - -- `APP_SECRET` is the encryption key for stored DSNs — changing it breaks decryption of existing data. Prefer `APP_SECRET_FILE` injection in production. -- Never commit `.env`, `secrets/`, credentials, or DSNs. Treat DSNs and generated schema metadata as sensitive (SECURITY.md, docs/api-security-checklist.md). -- Backend code is strictly typed: mypy runs with `disallow_untyped_defs` (see `[tool.mypy]` in backend/pyproject.toml). Public defs need docstrings — interrogate is configured with `fail-under = 100` in setup.cfg and `tests/test_docstrings.py` enforces it for checked modules. -- Long-running work (introspection of target DBs) goes through the job queue, never synchronously in a request handler. -- Middleware registration order in `app/main.py` is deliberate (security headers registered last so they wrap everything, including 429s and CORS preflight) — read the comments there before reordering. -- Do not use nested `${VAR:-${OTHER:-default}}` expressions in compose files; podman-compose mishandles them (noted inline in compose.yaml). -- Supply-chain pinning is enforced (OpenSSF Scorecard): Docker images are pinned by digest, GitHub Actions by commit SHA, and pip installs by `--require-hashes`. Preserve pinning when adding or updating any of these. -- CI (`.github/workflows/ci.yml`) runs backend mypy + pytest (Python 3.10, hash-locked deps) and frontend typecheck + vitest + production build (Node 26). CodeQL, Scorecard, and dependency-review workflows also run. -- User-visible frontend changes are recorded in CHANGELOG.md (Korean) and frontend/CHANGELOG.md. -- Add or update tests when changing behavior; prefer small, focused PRs (CONTRIBUTING.md). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 01e62762..f241e9cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,8 +54,6 @@ import { downloadText, exportDDL, exportDiagramSvg, - exportDictionaryCsv, - exportDictionaryMarkdown, exportPlantUml, } from "./erd/export"; import { exportMermaid } from "./erd/mermaid"; @@ -622,22 +620,6 @@ export default function App() { downloadText("pg-erd-diagram.mermaid", exportMermaid(nodes, edges), "text/plain"); } - function onExportDictionaryCsv() { - downloadText( - "data_dictionary.csv", - exportDictionaryCsv(nodes, edges), - "text/csv;charset=utf-8", - ); - } - - function onExportDictionaryMarkdown() { - downloadText( - "data_dictionary.md", - exportDictionaryMarkdown(nodes, edges), - "text/markdown;charset=utf-8", - ); - } - function onDownloadDbml() { downloadText("pg-erd-diagram.dbml", exportDbml(nodes, edges), "text/plain"); } @@ -1598,7 +1580,6 @@ export default function App() { isOpen={isExportModalOpen} isCopied={isCopied} hasDdlExport={nodes.length > 0} - hasDictionaryExport={nodes.length > 0} hasDiagramExport={nodes.length > 0} shareLinkUrl={shareLinkUrl} isCreatingShareLink={isCreatingShareLink} @@ -1610,8 +1591,6 @@ export default function App() { onDownloadSvg={onDownloadSvg} onDownloadUml={onDownloadUml} onDownloadMermaid={onDownloadMermaid} - onExportDictionaryCsv={onExportDictionaryCsv} - onExportDictionaryMarkdown={onExportDictionaryMarkdown} onCreateShareLink={onCreateShareLink} onCopyShareLink={onCopyShareLink} /> diff --git a/frontend/src/components/modals/CardinalityModal.tsx b/frontend/src/components/modals/CardinalityModal.tsx index f514e994..8d360537 100644 --- a/frontend/src/components/modals/CardinalityModal.tsx +++ b/frontend/src/components/modals/CardinalityModal.tsx @@ -162,7 +162,6 @@ export function CardinalityModal({ { expect(screen.getByText('SVG 이미지')).toBeInTheDocument(); expect(screen.getByText('PlantUML')).toBeInTheDocument(); expect(screen.getByText('Mermaid')).toBeInTheDocument(); - expect(screen.getByText('Data Dictionary CSV')).toBeInTheDocument(); - expect(screen.getByText('Data Dictionary MD')).toBeInTheDocument(); }); it('copies an already generated share link', () => { @@ -76,8 +71,6 @@ describe('ExportModal', () => { const onDownloadSvg = vi.fn(); const onDownloadUml = vi.fn(); const onDownloadMermaid = vi.fn(); - const onExportDictionaryCsv = vi.fn(); - const onExportDictionaryMarkdown = vi.fn(); render( { onDownloadSvg={onDownloadSvg} onDownloadUml={onDownloadUml} onDownloadMermaid={onDownloadMermaid} - onExportDictionaryCsv={onExportDictionaryCsv} - onExportDictionaryMarkdown={onExportDictionaryMarkdown} />, ); @@ -95,15 +86,11 @@ describe('ExportModal', () => { fireEvent.click(screen.getByRole('button', { name: 'SVG 이미지 내보내기' })); fireEvent.click(screen.getByRole('button', { name: 'PlantUML 내보내기' })); fireEvent.click(screen.getByRole('button', { name: 'Mermaid 내보내기' })); - fireEvent.click(screen.getByRole('button', { name: '데이터 사전 CSV 내보내기' })); - fireEvent.click(screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' })); expect(onCopyExportDdl).toHaveBeenCalledOnce(); expect(onDownloadSvg).toHaveBeenCalledOnce(); expect(onDownloadUml).toHaveBeenCalledOnce(); expect(onDownloadMermaid).toHaveBeenCalledOnce(); - expect(onExportDictionaryCsv).toHaveBeenCalledOnce(); - expect(onExportDictionaryMarkdown).toHaveBeenCalledOnce(); }); it('shows share link copy or creation errors', () => { @@ -122,18 +109,15 @@ describe('ExportModal', () => { , ); - expect(screen.getAllByText('먼저 테이블을 추가하세요')).toHaveLength(6); + expect(screen.getAllByText('먼저 테이블을 추가하세요')).toHaveLength(4); expect(screen.getByRole('button', { name: 'SQL DDL 복사' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'SVG 이미지 내보내기' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'PlantUML 내보내기' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Mermaid 내보내기' })).toBeDisabled(); - expect(screen.getByRole('button', { name: '데이터 사전 CSV 내보내기' })).toBeDisabled(); - expect(screen.getByRole('button', { name: '데이터 사전 Markdown 내보내기' })).toBeDisabled(); }); it('exposes access-control guidance for disabled button', () => { diff --git a/frontend/src/components/modals/ExportModal.tsx b/frontend/src/components/modals/ExportModal.tsx index 9feebd03..1d4370a0 100644 --- a/frontend/src/components/modals/ExportModal.tsx +++ b/frontend/src/components/modals/ExportModal.tsx @@ -5,7 +5,6 @@ interface ExportModalProps { isOpen: boolean; isCopied: boolean; hasDdlExport: boolean; - hasDictionaryExport: boolean; hasDiagramExport: boolean; shareLinkUrl: string; isCreatingShareLink: boolean; @@ -17,8 +16,6 @@ interface ExportModalProps { onDownloadSvg: () => void; onDownloadUml: () => void; onDownloadMermaid: () => void; - onExportDictionaryCsv: () => void; - onExportDictionaryMarkdown: () => void; onCreateShareLink: () => void; onCopyShareLink: () => void; } @@ -36,7 +33,6 @@ export function ExportModal({ isOpen, isCopied, hasDdlExport, - hasDictionaryExport, hasDiagramExport, shareLinkUrl, isCreatingShareLink, @@ -48,8 +44,6 @@ export function ExportModal({ onDownloadSvg, onDownloadUml, onDownloadMermaid, - onExportDictionaryCsv, - onExportDictionaryMarkdown, onCreateShareLink, onCopyShareLink, }: ExportModalProps) { @@ -99,22 +93,6 @@ export function ExportModal({ onExport: onDownloadMermaid, ariaLabel: 'Mermaid 내보내기', }, - { - label: 'Data Dictionary CSV', - description: hasDictionaryExport ? '테이블/컬럼 목록' : '먼저 테이블을 추가하세요', - buttonLabel: '내보내기', - disabled: !hasDictionaryExport, - onExport: onExportDictionaryCsv, - ariaLabel: '데이터 사전 CSV 내보내기', - }, - { - label: 'Data Dictionary MD', - description: hasDictionaryExport ? '마크다운 문서' : '먼저 테이블을 추가하세요', - buttonLabel: '내보내기', - disabled: !hasDictionaryExport, - onExport: onExportDictionaryMarkdown, - ariaLabel: '데이터 사전 Markdown 내보내기', - }, ]; return ( diff --git a/frontend/src/erd/__tests__/exportDataDictionary.test.ts b/frontend/src/erd/__tests__/exportDataDictionary.test.ts deleted file mode 100644 index 85fa2737..00000000 --- a/frontend/src/erd/__tests__/exportDataDictionary.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { Edge, Node } from '@xyflow/react'; -import { describe, expect, it } from 'vitest'; - -import type { TableNodeData } from '../convert'; -import { exportDictionaryCsv, exportDictionaryMarkdown } from '../exportDataDictionary'; - -describe('exportDataDictionary', () => { - const nodes: Node[] = [ - { - id: 'users', - type: 'tableNode', - position: { x: 0, y: 0 }, - data: { - title: 'public.users', - comment: 'User accounts', - badges: { pk: true, fk: false }, - columns: [ - { - column_name: 'id', - data_type: 'integer', - is_pk: true, - is_not_null: true, - column_comment: 'Primary Key', - example_value: 1, - }, - { - column_name: 'account_id', - data_type: 'integer', - is_pk: false, - is_not_null: true, - column_comment: null, - example_value: 42, - }, - { - column_name: 'email', - data_type: 'varchar', - is_pk: false, - is_not_null: true, - column_comment: null, - example_value: 'test@example.com', - }, - ], - }, - }, - { - id: 'accounts', - type: 'tableNode', - position: { x: 0, y: 0 }, - data: { - title: 'public.accounts', - comment: null, - badges: { pk: true, fk: false }, - columns: [ - { - column_name: 'id', - data_type: 'integer', - is_pk: true, - is_not_null: true, - column_comment: null, - example_value: 1, - }, - ], - }, - }, - { - id: 'empty_table', - type: 'tableNode', - position: { x: 0, y: 0 }, - data: { - title: 'empty_table', - comment: null, - badges: { pk: false, fk: false }, - columns: [], - }, - }, - ]; - - const edges: Edge[] = [ - { - id: 'fk_users_accounts', - source: 'users', - target: 'accounts', - data: { sourceColumns: ['account_id'], targetColumns: ['id'] }, - }, - ]; - - it('exports table and column metadata to CSV', () => { - const csv = exportDictionaryCsv(nodes, edges); - - expect(csv).toContain('"Table Name","Table Comment","Column Name","Data Type","PK","FK","Not Null","Column Comment","Example Value"'); - expect(csv).toContain('"public.users","User accounts","id","integer","Y","N","Y","Primary Key","1"'); - expect(csv).toContain('"public.users","User accounts","account_id","integer","N","Y","Y","","42"'); - expect(csv).toContain('"public.users","User accounts","email","varchar","N","N","Y","","test@example.com"'); - expect(csv).toContain('"empty_table","","","","","","","",""'); - }); - - it('handles empty CSV exports', () => { - expect(exportDictionaryCsv([], edges)).toBe( - '"Table Name","Table Comment","Column Name","Data Type","PK","FK","Not Null","Column Comment","Example Value"', - ); - }); - - it('neutralizes CSV formula injection and normalizes control characters', () => { - const csv = exportDictionaryCsv( - [ - { - id: 'attack', - type: 'tableNode', - position: { x: 0, y: 0 }, - data: { - title: '=HYPERLINK("https://example.invalid")', - comment: '@note\r\nsecond line', - badges: { pk: false, fk: false }, - columns: [ - { - column_name: '+cmd', - data_type: '-integer', - is_pk: false, - is_not_null: false, - column_comment: '@comment', - example_value: '\t=2+2', - }, - ], - }, - }, - ], - [], - ); - - expect(csv).toContain('"\'=HYPERLINK(""https://example.invalid"")"'); - expect(csv).toContain('"\'@note second line"'); - expect(csv).toContain('"\'+cmd"'); - expect(csv).toContain('"\'-integer"'); - expect(csv).toContain('"\'@comment"'); - expect(csv).toContain('"\' =2+2"'); - }); - - it('exports table and column metadata to Markdown', () => { - const markdown = exportDictionaryMarkdown(nodes, edges); - - expect(markdown).toContain('# Data Dictionary'); - expect(markdown).toContain('## Table: public.users (User accounts)'); - expect(markdown).toContain('| id | integer | Y | N | Y | Primary Key | 1 |'); - expect(markdown).toContain('| account_id | integer | N | Y | Y | | 42 |'); - expect(markdown).toContain('## Table: empty_table'); - expect(markdown).toContain('No columns.'); - }); - - it('escapes Markdown table breakers and HTML-like content', () => { - const markdown = exportDictionaryMarkdown( - [ - { - id: 'danger', - type: 'tableNode', - position: { x: 0, y: 0 }, - data: { - title: 'orders|