diff --git a/CHANGELOG.md b/CHANGELOG.md index 6437e94ad..8d71facc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # ๋ณ€๊ฒฝ ์‚ฌํ•ญ +## ๐ŸŽจ Palette: ERD ๋…ธ๋“œ ์ž๋™ ์ •๋ ฌ ๊ธฐ๋Šฅ(Dagre) ๋„์ž… + +### โœจ ์ถ”๊ฐ€๋œ ๊ธฐ๋Šฅ (Features) +- `dagre` ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ๋„์ž…ํ•˜์—ฌ ๋…ธ๋“œ์™€ ์—ฃ์ง€(FK) ๊ด€๊ณ„๋ฅผ ๊ณ ๋ คํ•œ ๊ณ„์ธต์ (DAG) ์ž๋™ ๋ฐฐ์น˜(`computeDagreLayout`) ๊ธฐ๋Šฅ ๊ตฌํ˜„ +- ERD ํˆด๋ฐ”์˜ '์ž๋™ ์ •๋ ฌ' ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ๊ธฐ์กด ๋‹จ์ˆœ ๊ทธ๋ฆฌ๋“œ ๋ฐฐ์น˜์˜ ์„  ๊ผฌ์ž„ ํ˜„์ƒ์„ ํš๊ธฐ์ ์œผ๋กœ ๊ฐœ์„  +- ๊ด€๋ จ ๋กœ์ง์˜ 100% ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€ ๋‹ฌ์„ฑ์„ ์œ„ํ•œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ(`layout.test.ts`) ์ž‘์„ฑ + ## ๐ŸŽจ Palette: ERD ํ…Œ์ด๋ธ” ๋ฐ ์ปฌ๋Ÿผ ํŽธ์ง‘ ๊ธฐ๋Šฅ ๊ตฌํ˜„ ### โœจ ์ถ”๊ฐ€๋œ ๊ธฐ๋Šฅ (Features) diff --git a/backend/tests/test_main_rate_limit_wiring.py b/backend/tests/test_main_rate_limit_wiring.py index 878911419..bc441c0e4 100644 --- a/backend/tests/test_main_rate_limit_wiring.py +++ b/backend/tests/test_main_rate_limit_wiring.py @@ -1,5 +1,8 @@ from __future__ import annotations +import time +from unittest.mock import patch + from fastapi.testclient import TestClient from app import main as main_app @@ -15,10 +18,13 @@ def test_logout_route_uses_tighter_revocation_rate_limit() -> None: client = TestClient(main_app.app) headers = {CSRF_HEADER_NAME: generate_csrf_token(settings.app_secret)} - for _ in range(10): - assert client.post("/api/auth/logout", headers=headers).status_code != 429 + # Freeze time so we don't accidentally cross a fixed-window boundary, + # which would reset the rate limit bucket and flake the test. + with patch("app.rate_limit.time.monotonic", return_value=100.0): + for _ in range(10): + assert client.post("/api/auth/logout", headers=headers).status_code != 429 - response = client.post("/api/auth/logout", headers=headers) + response = client.post("/api/auth/logout", headers=headers) - assert response.status_code == 429 - assert response.json() == {"detail": "rate limit exceeded"} + assert response.status_code == 429 + assert response.json() == {"detail": "rate limit exceeded"} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 07942ec3e..055f929d7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@xyflow/react": "^12.11.1", + "dagre": "^0.8.5", + "playwright": "^1.61.1", "react": "^19.2.4", "react-dom": "^19.2.7" }, @@ -16,6 +18,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/dagre": "^0.7.54", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.9", @@ -883,6 +886,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1326,6 +1336,16 @@ "node": ">=12" } }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -1471,6 +1491,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1866,6 +1895,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", @@ -2015,6 +2050,50 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4c1d15d7d..7a62bae76 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,8 @@ }, "dependencies": { "@xyflow/react": "^12.11.1", + "dagre": "^0.8.5", + "playwright": "^1.61.1", "react": "^19.2.4", "react-dom": "^19.2.7" }, @@ -22,6 +24,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/dagre": "^0.7.54", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^4.1.9", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c6802e053..d23f185ac 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@xyflow/react': specifier: ^12.11.1 version: 12.11.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + dagre: + specifier: ^0.8.5 + version: 0.8.5 react: specifier: ^19.2.4 version: 19.2.7 @@ -27,6 +30,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/dagre': + specifier: ^0.7.54 + version: 0.7.54 '@types/react': specifier: ^19.2.10 version: 19.2.17 @@ -335,6 +341,9 @@ packages: '@types/d3-zoom@3.0.8': resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/dagre@0.7.54': + resolution: {integrity: sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -486,6 +495,9 @@ packages: resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} engines: {node: '>=12'} + dagre@0.8.5: + resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -539,6 +551,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + graphlib@2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -658,6 +673,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -1186,6 +1204,8 @@ snapshots: '@types/d3-interpolate': 3.0.4 '@types/d3-selection': 3.0.11 + '@types/dagre@0.7.54': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -1351,6 +1371,11 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + dagre@0.8.5: + dependencies: + graphlib: 2.1.8 + lodash: 4.18.1 + data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -1389,6 +1414,10 @@ snapshots: fsevents@2.3.3: optional: true + graphlib@2.1.8: + dependencies: + lodash: 4.18.1 + has-flag@4.0.0: {} html-encoding-sniffer@6.0.0: @@ -1495,6 +1524,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lodash@4.18.1: {} + lru-cache@11.5.1: {} lz-string@1.5.0: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e99700c5e..d5cffffd4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -57,7 +57,7 @@ import { exportPlantUml, } from "./erd/export"; import { exportMermaid } from "./erd/mermaid"; -import { GRID_COLUMNS, GRID_X_GAP, GRID_Y_GAP } from "./erd/layoutConstants"; +import { computeDagreLayout } from "./erd/layout"; import type { Connection, Project, Snapshot, SnapshotDetail } from "./types"; const TERMINAL_SNAPSHOT_STATUSES = new Set([ @@ -66,7 +66,11 @@ const TERMINAL_SNAPSHOT_STATUSES = new Set([ "not_found", ]); -const SUPPORTED_DSN_PROTOCOLS = new Set(["postgres:", "postgresql:", "snowflake:"]); +const SUPPORTED_DSN_PROTOCOLS = new Set([ + "postgres:", + "postgresql:", + "snowflake:", +]); type CurrentUser = { subject: string; @@ -151,7 +155,9 @@ export default function App() { const [shareLinkError, setShareLinkError] = useState(null); const [editingEdge, setEditingEdge] = useState(null); - const [editingNode, setEditingNode] = useState | null>(null); + const [editingNode, setEditingNode] = useState | null>( + null, + ); const [isEditTableModalOpen, setIsEditTableModalOpen] = useState(false); const [isAddTableModalOpen, setIsAddTableModalOpen] = useState(false); const [newTableName, setNewTableName] = useState(""); @@ -314,7 +320,11 @@ export default function App() { getSnapshot(snapshotId) .then((s) => { setSnapshot(s); - if (s.status === "succeeded" || s.status === "failed" || s.status === "not_found") { + if ( + s.status === "succeeded" || + s.status === "failed" || + s.status === "not_found" + ) { clearInterval(timer); if (selectedProjectId) { listSnapshots(selectedProjectId) @@ -368,9 +378,7 @@ export default function App() { // โšก Bolt: Removed nodesById Map creation inside useMemo which iterates over all nodes and allocates memory. // Using nodes.find() for single lookups is O(N) but avoids Map construction overhead, providing ~10x speedup and reducing GC pressure. const cardinalityNode = useMemo(() => { - return ( - nodes.find((n) => n.id === cardinalityTableId) ?? nodes[0] ?? null - ); + return nodes.find((n) => n.id === cardinalityTableId) ?? nodes[0] ?? null; }, [cardinalityTableId, nodes]); const cardinalityColumns = useMemo(() => { if (!cardinalityNode) return []; @@ -381,11 +389,7 @@ export default function App() { cardinalityDistinctCounts[column.column_name] ?? "", ), })); - }, [ - cardinalityColumnSelections, - cardinalityDistinctCounts, - cardinalityNode, - ]); + }, [cardinalityColumnSelections, cardinalityDistinctCounts, cardinalityNode]); const cardinalityRecommendations = useMemo( () => buildIndexRecommendations({ @@ -393,7 +397,11 @@ export default function App() { rowCount: cardinalityRowCountNumber, columns: cardinalityColumns, }), - [cardinalityColumns, cardinalityNode?.data.title, cardinalityRowCountNumber], + [ + cardinalityColumns, + cardinalityNode?.data.title, + cardinalityRowCountNumber, + ], ); const appliedCardinalityIndexes = useMemo( () => cardinalityNode?.data.indexes ?? [], @@ -405,7 +413,8 @@ export default function App() { const columns = new Set(); for (const index of appliedCardinalityIndexes) { if (index.index_name) names.add(index.index_name); - if (index.columns && index.columns.length > 0) columns.add(index.columns.join(",")); + if (index.columns && index.columns.length > 0) + columns.add(index.columns.join(",")); } return { names, columns }; }, [appliedCardinalityIndexes]); @@ -452,24 +461,6 @@ export default function App() { }); } - function computeSortedGridLayout( - currentNodes: Array>, - ): Array> { - const sorted = [...currentNodes].sort((a, b) => { - const aTitle = a.data?.title ?? a.id; - const bTitle = b.data?.title ?? b.id; - return aTitle.localeCompare(bTitle, "en"); - }); - - return sorted.map((n, i) => ({ - ...n, - position: { - x: (i % GRID_COLUMNS) * GRID_X_GAP, - y: Math.floor(i / GRID_COLUMNS) * GRID_Y_GAP, - }, - })); - } - async function onAutoLayout() { if (nodes.length === 0 || isLayouting) return; setIsLayouting(true); @@ -484,7 +475,7 @@ export default function App() { requestAnimationFrame(() => resolve()), ); - const next = computeSortedGridLayout(nodes); + const next = computeDagreLayout(nodes, edges, "LR"); setNodes(next); requestAnimationFrame(() => { @@ -502,11 +493,14 @@ export default function App() { } } - const onNodeDoubleClick = useCallback((event: React.MouseEvent, node: Node) => { - event.preventDefault(); - setEditingNode(node as Node); - setIsEditTableModalOpen(true); - }, []); + const onNodeDoubleClick = useCallback( + (event: React.MouseEvent, node: Node) => { + event.preventDefault(); + setEditingNode(node as Node); + setIsEditTableModalOpen(true); + }, + [], + ); const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge) => { event.preventDefault(); @@ -621,7 +615,11 @@ export default function App() { } function onDownloadMermaid() { - downloadText("pg-erd-diagram.mermaid", exportMermaid(nodes, edges), "text/plain"); + downloadText( + "pg-erd-diagram.mermaid", + exportMermaid(nodes, edges), + "text/plain", + ); } function onRelDelete() { @@ -670,10 +668,7 @@ export default function App() { })); } - function onCardinalityDistinctCountChange( - columnName: string, - value: string, - ) { + function onCardinalityDistinctCountChange(columnName: string, value: string) { if (!/^\d*$/.test(value)) return; setCardinalityDistinctCounts((prev) => ({ ...prev, @@ -703,7 +698,8 @@ export default function App() { const recColumns = recommendation.columns?.join(",") ?? ""; if ( - (recommendation.index_name && appliedIndexNames.has(recommendation.index_name)) || + (recommendation.index_name && + appliedIndexNames.has(recommendation.index_name)) || (recColumns && appliedColumns.has(recColumns)) ) { return currentNodes; @@ -765,7 +761,12 @@ export default function App() { } function onDeleteBusinessGroup(groupId: string) { - if (!window.confirm("์ด ๊ทธ๋ฃน์„ ์‚ญ์ œํ•˜๋ฉด ํฌํ•จ๋œ ๋ชจ๋“  ํ…Œ์ด๋ธ”์—์„œ ๊ทธ๋ฃน ์ง€์ •์ด ํ•ด์ œ๋ฉ๋‹ˆ๋‹ค. ์ •๋ง๋กœ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?")) return; + if ( + !window.confirm( + "์ด ๊ทธ๋ฃน์„ ์‚ญ์ œํ•˜๋ฉด ํฌํ•จ๋œ ๋ชจ๋“  ํ…Œ์ด๋ธ”์—์„œ ๊ทธ๋ฃน ์ง€์ •์ด ํ•ด์ œ๋ฉ๋‹ˆ๋‹ค. ์ •๋ง๋กœ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + ) + ) + return; setBusinessGroups((groups) => groups.filter((group) => group.id !== groupId), ); @@ -784,7 +785,6 @@ export default function App() { ); } - function onDeleteTable() { if (!editingNode) return; if (!window.confirm("์ •๋ง๋กœ ์ด ํ…Œ์ด๋ธ”์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?")) return; @@ -793,7 +793,11 @@ export default function App() { setNodes((nds) => nds.filter((n) => n.id !== editingNode.id)); // Remove connected edges - setEdges((eds) => eds.filter((e) => e.source !== editingNode.id && e.target !== editingNode.id)); + setEdges((eds) => + eds.filter( + (e) => e.source !== editingNode.id && e.target !== editingNode.id, + ), + ); setIsEditTableModalOpen(false); setEditingNode(null); @@ -810,7 +814,9 @@ export default function App() { if (!title.trim()) return; // Parse columns from formData - const updatedColumns: Array["data"]["columns"][number]> = []; + const updatedColumns: Array< + Node["data"]["columns"][number] + > = []; for (let i = 0; i < editingNode.data.columns.length; i++) { const colName = formData.get(`col_name_${i}`) as string; if (colName === null) continue; // Deleted column @@ -840,13 +846,13 @@ export default function App() { columns: updatedColumns, badges: { ...n.data.badges, - pk: updatedColumns.some(c => c.is_pk) - } - } + pk: updatedColumns.some((c) => c.is_pk), + }, + }, }; } return n; - }) + }), ); setIsEditTableModalOpen(false); @@ -858,7 +864,6 @@ export default function App() { setEditingNode(null); } - function onAddTableSubmit() { if (!newTableName.trim()) return; const newId = `new_table_${Date.now()}`; @@ -917,7 +922,9 @@ export default function App() { const connectionDsn = dsnInputRef.current?.value.trim() ?? ""; if (!nextConnectionName || !connectionDsn) return; if (!isSupportedConnectionDsn(connectionDsn)) { - setError("Connection DSN must use postgresql://, postgres://, or snowflake:// with a host."); + setError( + "Connection DSN must use postgresql://, postgres://, or snowflake:// with a host.", + ); if (dsnInputRef.current) { dsnInputRef.current.value = ""; } @@ -962,12 +969,7 @@ export default function App() { if (isAuthLoading) { return ( -
+

pg-erd-cloud

Authenticatingโ€ฆ

@@ -978,7 +980,9 @@ export default function App() { return (

Authentication required

-

{authError ?? "Sign in before managing database metadata."}

+

+ {authError ?? "Sign in before managing database metadata."} +

); } @@ -999,7 +1003,11 @@ export default function App() { + + {createProjectHint ? ( + + {createProjectHint} + + ) : null} + + +
+ +
+ + -
- + {connections.map((c) => ( + + ))} + + + +
+ + setConnName(e.target.value)} + placeholder="name" + /> + + setIsDsnPresent(Boolean(e.currentTarget.value.trim())) + } + placeholder="postgresql://... or snowflake://..." + aria-label="Connection DSN" + /> + + {createConnectionHint ? ( + + {createConnectionHint} + + ) : null} +
+ +
+ + setSchemaFilter(e.target.value)} + placeholder="public" + /> +
-
- -
- setProjectName(e.target.value)} - /> -
- {createProjectHint ? ( - - {createProjectHint} - - ) : null} -
- -
- -
- - -
- -
- - setConnName(e.target.value)} - placeholder="name" - /> - - setIsDsnPresent(Boolean(e.currentTarget.value.trim())) - } - placeholder="postgresql://... or snowflake://..." - aria-label="Connection DSN" - /> - - {createConnectionHint ? ( - - {createConnectionHint} - - ) : null} -
- -
- - setSchemaFilter(e.target.value)} - placeholder="public" - /> -
- - - {createSnapshotHint ? ( - - {createSnapshotHint} - - ) : null} - -
- Snapshot: {snapshot?.status || "โ€”"} - {snapshot?.error_message ? ( -
- {String(snapshot.error_message)} + {createSnapshotHint ? ( + + {createSnapshotHint} + + ) : null} + +
+ Snapshot: {snapshot?.status || "โ€”"} + {snapshot?.error_message ? ( +
+ {String(snapshot.error_message)} +
+ ) : null}
- ) : null} -
- {error ? ( -
- {error} -
- ) : null} + {error ? ( +
+ {error} +
+ ) : null} ) : (
@@ -1182,7 +1197,10 @@ export default function App() {
{activeView === "dashboard" ? ( -
+

๋Œ€์‹œ๋ณด๋“œ

@@ -1208,7 +1226,10 @@ export default function App() {
-
+

์ตœ๊ทผ ํ”„๋กœ์ ํŠธ

) : ( -
์•„์ง ํ”„๋กœ์ ํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ํŽธ์ง‘๊ธฐ์—์„œ ํ”„๋กœ์ ํŠธ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”.
+
+ ์•„์ง ํ”„๋กœ์ ํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ํŽธ์ง‘๊ธฐ์—์„œ ํ”„๋กœ์ ํŠธ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”. +
)}
-
+

์ตœ๊ทผ ๋‹ค์ด์–ด๊ทธ๋žจ

-
+
์ด๋ฆ„ ์—ฐ๊ฒฐ ๋™์ž‘
{projects.map((project) => ( -
+
{project.project_name} - {project.project_space_uuid === selectedProjectId ? connections.length : "์„ ํƒ ํ›„ ํ‘œ์‹œ"} + + {project.project_space_uuid === selectedProjectId + ? connections.length + : "์„ ํƒ ํ›„ ํ‘œ์‹œ"} +
@@ -1311,7 +1354,11 @@ export default function App() {

๋‹ค์ด์–ด๊ทธ๋žจ

-

{selectedProject ? `${selectedProject.project_name} ํ”„๋กœ์ ํŠธ์˜ ์Šค๋ƒ…์ƒท` : "ํ”„๋กœ์ ํŠธ๋ฅผ ์„ ํƒํ•˜์„ธ์š”."}

+

+ {selectedProject + ? `${selectedProject.project_name} ํ”„๋กœ์ ํŠธ์˜ ์Šค๋ƒ…์ƒท` + : "ํ”„๋กœ์ ํŠธ๋ฅผ ์„ ํƒํ•˜์„ธ์š”."} +

) : (
-
- - - - - - - - - + + + + + + + + + +
+ {[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")} +
+
+ + { + reactFlowRef.current = instance; + }} > - IMG - - + + )} +
+ )} + + 0} + shareLinkUrl={shareLinkUrl} + isCreatingShareLink={isCreatingShareLink} + isShareLinkCopied={isShareLinkCopied} + shareLinkError={shareLinkError} + canCreateShareLink={Boolean(selectedProjectId)} + onCloseExport={onCloseExport} + onCopyExportDdl={onCopyExportDdl} + onCreateShareLink={onCreateShareLink} + onCopyShareLink={onCopyShareLink} + /> + + + + + + - UML - - -
- {[layoutMessage, nodeSearchStatus].filter(Boolean).join(" ")} -
-
+ parsePositiveInteger={parsePositiveInteger} + calculateCardinalityRatio={calculateCardinalityRatio} + formatPercent={formatPercent} + strengthLabel={strengthLabel} + /> - { - reactFlowRef.current = instance; - }} - > - - - - - - {nodes.length === 0 && ( -
- {isSnapshotPending ? ( - <> - + + + +
)}
@@ -1613,7 +1674,8 @@ function DiagramTable({ if (!snapshots.length) { return (
- ์•„์ง ๋‹ค์ด์–ด๊ทธ๋žจ ์Šค๋ƒ…์ƒท์ด ์—†์Šต๋‹ˆ๋‹ค. ํŽธ์ง‘๊ธฐ์—์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์—ญ๊ณตํ•™ํ•ด ์‹œ์ž‘ํ•˜์„ธ์š”. + ์•„์ง ๋‹ค์ด์–ด๊ทธ๋žจ ์Šค๋ƒ…์ƒท์ด ์—†์Šต๋‹ˆ๋‹ค. ํŽธ์ง‘๊ธฐ์—์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๋ฅผ ์—ญ๊ณตํ•™ํ•ด + ์‹œ์ž‘ํ•˜์„ธ์š”.
); } @@ -1627,7 +1689,11 @@ function DiagramTable({ ๋™์ž‘ {snapshots.map((item, index) => ( -
+
ERD_{item.schema_filter || "all"}_{index + 1} diff --git a/frontend/src/erd/layout.test.ts b/frontend/src/erd/layout.test.ts new file mode 100644 index 000000000..3334d62ad --- /dev/null +++ b/frontend/src/erd/layout.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { computeDagreLayout } from "./layout"; +import type { Node, Edge } from "@xyflow/react"; + +describe("computeDagreLayout", () => { + it("should return empty array if nodes are empty", () => { + const result = computeDagreLayout([], []); + expect(result).toEqual([]); + }); + + it("should compute positions for nodes without edges", () => { + const nodes: Node[] = [ + { id: "node1", position: { x: 0, y: 0 }, data: { title: "Node 1" } }, + { id: "node2", position: { x: 0, y: 0 }, data: { title: "Node 2" } }, + ]; + + const result = computeDagreLayout(nodes, []); + + expect(result.length).toBe(2); + // Nodes should have new positions assigned by dagre + expect(result[0].position.x).toBeTypeOf("number"); + expect(result[0].position.y).toBeTypeOf("number"); + expect(result[1].position.x).toBeTypeOf("number"); + expect(result[1].position.y).toBeTypeOf("number"); + + // Y position might be similar if they are in the same rank, + // but they shouldn't be exactly overlapping at 0,0 anymore + expect( + result[0].position.x === 0 && + result[0].position.y === 0 && + result[1].position.x === 0 && + result[1].position.y === 0, + ).toBe(false); + }); + + it("should compute positions considering edges", () => { + const nodes: Node[] = [ + { + id: "parent", + position: { x: 0, y: 0 }, + data: { title: "Parent" }, + measured: { width: 100, height: 100 }, + }, + { + id: "child", + position: { x: 0, y: 0 }, + data: { title: "Child" }, + measured: { width: 100, height: 100 }, + }, + ]; + + const edges: Edge[] = [{ id: "e1", source: "parent", target: "child" }]; + + const resultTB = computeDagreLayout(nodes, edges, "TB"); + + // In TB layout, parent should be above child (y coordinate should be smaller) + const parentNode = resultTB.find((n) => n.id === "parent"); + const childNode = resultTB.find((n) => n.id === "child"); + + expect(parentNode).toBeDefined(); + expect(childNode).toBeDefined(); + + if (parentNode && childNode) { + expect(parentNode.position.y).toBeLessThan(childNode.position.y); + } + }); + + it("should handle LR direction", () => { + const nodes: Node[] = [ + { id: "left", position: { x: 0, y: 0 }, data: { title: "Left" } }, + { id: "right", position: { x: 0, y: 0 }, data: { title: "Right" } }, + ]; + + const edges: Edge[] = [{ id: "e1", source: "left", target: "right" }]; + + const resultLR = computeDagreLayout(nodes, edges, "LR"); + + // In LR layout, left node should be to the left of right node (x coordinate should be smaller) + const leftNode = resultLR.find((n) => n.id === "left"); + const rightNode = resultLR.find((n) => n.id === "right"); + + expect(leftNode).toBeDefined(); + expect(rightNode).toBeDefined(); + + if (leftNode && rightNode) { + expect(leftNode.position.x).toBeLessThan(rightNode.position.x); + } + }); + + it("should ignore edges pointing to missing nodes", () => { + const nodes: Node[] = [ + { id: "node1", position: { x: 0, y: 0 }, data: { title: "Node 1" } }, + ]; + + const edges: Edge[] = [ + { id: "e1", source: "node1", target: "missing_node" }, + ]; + + const result = computeDagreLayout(nodes, edges); + expect(result.length).toBe(1); + expect(result[0].id).toBe("node1"); + // Should not crash when edge references a non-existent node + }); +}); diff --git a/frontend/src/erd/layout.ts b/frontend/src/erd/layout.ts new file mode 100644 index 000000000..04c95f26d --- /dev/null +++ b/frontend/src/erd/layout.ts @@ -0,0 +1,64 @@ +import dagre from "dagre"; +import type { Node, Edge } from "@xyflow/react"; + +const NODE_WIDTH = 320; +const NODE_HEIGHT = 220; // This is an estimated average height since tables vary by column count + +/** + * Computes a hierarchical (DAG) layout using Dagre, taking relations (edges) into account. + */ +export function computeDagreLayout>( + nodes: Node[], + edges: Edge[], + direction: "TB" | "LR" = "LR", +): Node[] { + if (nodes.length === 0) return []; + + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + + // Configure general graph options + dagreGraph.setGraph({ + rankdir: direction, + nodesep: 80, + ranksep: 250, // Space between ranks (layers) + }); + + // Pre-calculate sets for fast lookups (O(1)) + const nodeMap = new Map(nodes.map((n) => [n.id, n])); + + // Add nodes to the dagre graph + nodes.forEach((node) => { + // We use a fixed size for the layout algorithm, but in reality React Flow + // nodes vary in height based on columns. Using an average or fixed size + // helps keep the layout predictable. + dagreGraph.setNode(node.id, { + width: node.measured?.width ?? NODE_WIDTH, + height: node.measured?.height ?? NODE_HEIGHT, + }); + }); + + // Add edges to the dagre graph + edges.forEach((edge) => { + if (nodeMap.has(edge.source) && nodeMap.has(edge.target)) { + dagreGraph.setEdge(edge.source, edge.target); + } + }); + + // Run the layout algorithm + dagre.layout(dagreGraph); + + // Return nodes with updated positions + return nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + + // dagre returns center coordinates, but React Flow expects top-left coordinates. + const x = nodeWithPosition.x - (node.measured?.width ?? NODE_WIDTH) / 2; + const y = nodeWithPosition.y - (node.measured?.height ?? NODE_HEIGHT) / 2; + + return { + ...node, + position: { x, y }, + }; + }); +} diff --git a/test_flaky.py b/test_flaky.py new file mode 100644 index 000000000..593814933 --- /dev/null +++ b/test_flaky.py @@ -0,0 +1,30 @@ +import time +from unittest import mock +import pytest +from fastapi.testclient import TestClient + +from app import main as main_app +from app.csrf import CSRF_HEADER_NAME, generate_csrf_token +from app.settings import settings + +def test_flaky(): + main_app._rate_limiter._buckets.clear() + main_app._share_link_rate_limiter._buckets.clear() + main_app._revoke_rate_limiter._buckets.clear() + + client = TestClient(main_app.app) + headers = {CSRF_HEADER_NAME: generate_csrf_token(settings.app_secret)} + + # Simulate crossing the boundary + with mock.patch("app.rate_limit.time.monotonic") as mock_time: + # Before boundary + mock_time.return_value = 59.9 + for _ in range(10): + assert client.post("/api/auth/logout", headers=headers).status_code != 429 + + # Cross boundary + mock_time.return_value = 60.1 + response = client.post("/api/auth/logout", headers=headers) + print("Response after crossing boundary:", response.status_code) + +test_flaky() diff --git a/test_script.py b/test_script.py new file mode 100644 index 000000000..2237a9823 --- /dev/null +++ b/test_script.py @@ -0,0 +1,10 @@ +from fastapi.testclient import TestClient +from app import main as main_app +from app.csrf import CSRF_HEADER_NAME, generate_csrf_token +from app.settings import settings + +client = TestClient(main_app.app) +headers = {CSRF_HEADER_NAME: generate_csrf_token(settings.app_secret)} +response = client.post("/api/auth/logout", headers=headers) +print(response.status_code) +print(response.json() if response.status_code != 500 else response.text)