From f96d8d82a6cb2fe5abca3b611c4009949e81fd13 Mon Sep 17 00:00:00 2001 From: Keenan Simpson Date: Mon, 1 Jun 2026 11:07:13 +0200 Subject: [PATCH 1/2] feat: ER Diagram visualization for PostgreSQL, MySQL, SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Interactive entity-relationship diagram with @xyflow/react + dagre auto-layout - Table selector dialog before generation to prevent freeze on large schemas - Compact view (PK/FK columns only) by default - PostgreSQL: bulk introspection via pg_attribute/pg_index/pg_constraint - MySQL/MariaDB: information_schema-based column and FK extraction - SQLite: batched PRAGMA queries with Promise.all for performance - Schema data cache (30s TTL) for instant re-open - FK relationship rendering with direct system catalog fallback queries - Connection-aware opening — diagram uses the active editor tab's database - Public schema prefix handling for PostgreSQL column/FK matching - SVG export via Tauri native save dialog - Docs page for ER diagram feature - Auto-save now updates originalQuery on write to prevent spurious dirty prompts - Session restore handles null originalQuery migration from old files - Discard on close overwrites session with clean state - Compact view enabled by default --- .github/workflows/beta.yml | 2 + CHANGELOG.md | 11 + package-lock.json | 265 ++++++++- package.json | 3 + src-tauri/.cargo/config.toml | 5 + src-tauri/src/cli.rs | 75 ++- src/components/explorer/DatabaseExplorer.tsx | 82 ++- src/components/help/HelpDialog.tsx | 6 +- src/components/layout/MainContent.tsx | 96 +++- src/components/tools/ActivityMonitor.tsx | 4 +- src/components/tools/ERDCanvas.tsx | 103 ++++ src/components/tools/ERDDialog.tsx | 504 ++++++++++++++++ src/components/tools/RelationshipEdge.tsx | 82 +++ src/components/tools/TableNode.tsx | 132 +++++ src/components/tools/TableSelectorDialog.tsx | 214 +++++++ src/components/tools/useERData.ts | 542 ++++++++++++++++++ src/styles/erd.css | 99 ++++ .../src/content/docs/editor/er-diagram.mdx | 57 ++ 18 files changed, 2223 insertions(+), 59 deletions(-) create mode 100644 src/components/tools/ERDCanvas.tsx create mode 100644 src/components/tools/ERDDialog.tsx create mode 100644 src/components/tools/RelationshipEdge.tsx create mode 100644 src/components/tools/TableNode.tsx create mode 100644 src/components/tools/TableSelectorDialog.tsx create mode 100644 src/components/tools/useERData.ts create mode 100644 src/styles/erd.css create mode 100644 website/src/content/docs/editor/er-diagram.mdx diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 9cedf31..b09c54a 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -183,6 +183,8 @@ jobs: - name: Build app env: + CARGO_PROFILE_RELEASE_LTO: "off" + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: "256" TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: npm run tauri build -- --target ${{ matrix.target }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 911f31e..f98ffd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,15 @@ All notable changes to QueryDen are documented here. This project adheres to [Se - **Create Login Role / Drop Role now throw on disconnected state.** Instead of silently returning when `activeConnection` or `currentDb` is null, both operations throw a clear "Not connected to a database" error shown in the dialog. - **Dialog no longer steals focus from input fields on parent re-render.** The `Dialog` component's auto-focus effect was split into two: focus management depends only on `[open, initialFocusRef]`, and the Escape key listener depends on `[open, dismissOnEsc, onClose]`. This prevents inline `onClose` callbacks from re-triggering the focus effect and jumping the cursor back to the initial element. - **PostgreSQL `regrole` type no longer logs decode warnings.** Added a `"regrole"` decode arm to the patched `tauri-plugin-sql` PostgreSQL decoder (lowercase match, matching the driver's runtime type name), silently handling regrole values from catalog queries like tablespace owner resolution. +- **Auto-save now updates `originalQuery` on write.** When auto-save persists a tab's text to disk, the tab's `originalQuery` is updated to match, preventing a spurious "unsaved changes" prompt on close when auto-save already captured the content. Tabs whose auto-save write failed retain their previous dirty state. +- **Session restore handles null `originalQuery` from existing session files.** The session restore code now reads `t.originalQuery ?? t.query ?? ""`, a migration path for session files written before `originalQuery` was tracked (where the Rust `Option` serializes to JSON `null`). Prevents a false "unsaved changes" prompt on every launch for users upgrading from versions before this field was introduced. +- **"Discard" on close now overwrites session with clean state.** When the user clicks "Discard" in the unsaved-changes prompt, the session file is immediately re-saved with `originalQuery = query` for every tab, so the close prompt doesn't re-fire on next startup. +- **PostgreSQL FK relationships now render in the ER diagram.** The `schemaItems.foreignKeys` list is sometimes null when schema loading fails silently. Added direct `fetchPostgresForeignKeys()` and `fetchMySQLForeignKeys()` fallbacks that query system catalogs directly, plus a `normalizeTableName()` helper to reconcile the `public.` prefix format difference between PostgreSQL's `regclass::text` and the schema items table list. +- **PostgreSQL columns in `public` schema no longer missing from the ER diagram.** Tables with many columns were showing as header-only because the column map keyed by `public.tablename` didn't match the schema items' bare `tablename` format. Stripping the `public.` prefix in the column lookup fixed it. +- **ER diagram no longer freezes when opening on databases with many tables.** The table selector dialog now appears first — users pick which tables to include before any schema data is fetched or dagre layout runs. Selected-only data keeps both the query batch and the layout computation scoped to what's visible. + +### Changed +- **ER diagram compact view is now the default.** The diagram opens in compact mode (PK/FK columns only) instead of showing all columns, reducing visual clutter on first open. Users can toggle back to full-column view via the toolbar button. ### Added - **[#67](https://github.com/openidle-dev/queryden/issues/67) — Master key storage status surfaced in Help → About.** A new "Master Key" info card in the About dialog shows whether the encryption key is stored in the OS keyring, a local file fallback, or is unavailable. When the file fallback is in use, a warning explains how to install a keyring service for OS-level protection. @@ -34,6 +43,8 @@ All notable changes to QueryDen are documented here. This project adheres to [Se - **[#122](https://github.com/openidle-dev/queryden/issues/122) — Auto-save: open query text is periodically written to `.sql` files on disk.** When enabled in Settings → Auto Save, each tab's current text is debounced and saved to `/auto-save/` with the naming convention `{connectionFolder}_{database}_{tabId}.sql`. Files are recoverable from the app data directory and can be backed up like any other `.sql` file. Save interval is configurable (5–300 seconds). - **pgAdmin-style login/group role management.** Right-click the "Login Roles" folder in the PostgreSQL tree to create a new role via dedicated dialog (name, password, connection limit, valid until, privilege checkboxes). Right-click any role leaf node to drop it with a confirmation prompt. Both operations refresh the tree immediately. Login roles are exposed in the tree with a green User icon; group roles with a blue Users icon. - **PostgreSQL operators enabled by default in schema tree.** The `showOperators` setting now defaults to `true`, so operator nodes appear in the schema tree without having to toggle the setting first. +- **ER Diagram visualization for PostgreSQL, MySQL/MariaDB, and SQLite.** A new ER Diagram dialog (toolbar button) renders the current database as an interactive entity-relationship diagram powered by `@xyflow/react` v12 and `dagre` auto-layout. Tables are shown as cards with PK (key icon) and FK (link icon) column markers; relationships are drawn as smooth-step edges with cardinality labels (`*` at the FK end, `1` at the PK end). The dialog opens with a table selector so users pick which tables to include — preventing the freeze that would occur when auto-introspecting many tables. A toolbar provides schema multi-select (PostgreSQL), search/filter with related-table expansion, compact/PK-only column mode, refresh, and SVG export (Tauri `save()` dialog + `writeTextFile`). Schema data is cached with a 30-second TTL so re-opening the same tables is instant. Provider-specific introspection: PostgreSQL queries `pg_attribute`/`pg_index`/`pg_constraint` in bulk, MySQL uses `information_schema`, SQLite uses batched `PRAGMA table_info`/`PRAGMA foreign_key_list` via `Promise.all`. +- **ER Diagram now opens on the correct database.** Previously the diagram always read the global `activeConnection` — if the active editor tab targeted a different database, the diagram showed the wrong schema. The toolbar button now switches the connection to match the tab's target before opening. ## [1.0.23] - 2026-05-26 diff --git a/package-lock.json b/package-lock.json index 8f762c4..23e797e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "queryden", - "version": "1.0.19", + "version": "1.0.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "queryden", - "version": "1.0.19", + "version": "1.0.23", "license": "MIT", "dependencies": { "@glideapps/glide-data-grid": "^6.0.0", @@ -20,7 +20,9 @@ "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-sql": "^2", "@tauri-apps/plugin-updater": "^2", + "@xyflow/react": "^12.10.2", "clsx": "^2.1.1", + "dagre": "^0.8.5", "lucide-react": "^0.469.0", "monaco-editor": "^0.55.0", "react": "^18.3.1", @@ -36,6 +38,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.0.0", "@tauri-apps/cli": "^2", + "@types/dagre": "^0.7.54", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", @@ -2521,6 +2524,62 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@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/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2757,6 +2816,66 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2982,6 +3101,12 @@ "node": ">= 16" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -3026,6 +3151,121 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "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/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3328,6 +3568,15 @@ "dev": true, "license": "ISC" }, + "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/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -3799,8 +4048,7 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/longest-streak": { "version": "3.1.0", @@ -5765,6 +6013,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 3b34e3b..7b48ca3 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,9 @@ "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-sql": "^2", "@tauri-apps/plugin-updater": "^2", + "@xyflow/react": "^12.10.2", "clsx": "^2.1.1", + "dagre": "^0.8.5", "lucide-react": "^0.469.0", "monaco-editor": "^0.55.0", "react": "^18.3.1", @@ -59,6 +61,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.0.0", "@tauri-apps/cli": "^2", + "@types/dagre": "^0.7.54", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml index 274ec79..c33b33c 100644 --- a/src-tauri/.cargo/config.toml +++ b/src-tauri/.cargo/config.toml @@ -1,2 +1,7 @@ # Let cargo-xwin handle the linker and libraries automatically # Static CRT (+crt-static) can sometimes cause linker errors during cross-compilation + +# Use LLVM's lld-link on Windows for faster linking than MSVC link.exe. +# rust-lld ships with the Rust toolchain — no extra install needed. +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index daa80ba..12b782d 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -8,7 +8,8 @@ //! //! - **PostgreSQL**: Auto-download removed. Users must install psql via their //! system package manager or from https://www.postgresql.org/download/. -//! QueryDen detects the system psql on PATH automatically. +//! QueryDen detects the system psql on PATH automatically. On Windows, +//! common install paths in Program Files are checked as a fallback. //! - **MySQL**: https://dev.mysql.com/get/Downloads/ //! - Archives: mysql-{version}-macos{arch}.tar.gz, etc. //! - **MongoDB**: GitHub releases (mongosh) @@ -18,13 +19,15 @@ //! //! 1. User connects via libpq → server responds `SELECT version()` //! 2. App parses server version (e.g. "PostgreSQL 16.5") -//! 3. For psql: checks system PATH. If not found, shows install guide. +//! 3. For psql: checks system PATH, then Windows fallback paths. If not found, shows install guide. //! 4. For other tools: checks ~/queryden/cli-tools/{tool}-{version}/ for cached binaries //! 5. If not cached → dialog asks user to confirm download //! 6. Download + extract → cache forever under the versioned path use serde::{Deserialize, Serialize}; use std::path::PathBuf; +#[cfg(target_os = "windows")] +use std::path::Path; use std::process::Stdio; use tauri::{AppHandle, Emitter, Manager, State}; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -75,7 +78,11 @@ impl ToolKind { Linux (Debian/Ubuntu): sudo apt install postgresql-client\n\ Linux (Fedora/RHEL): sudo dnf install postgresql\n\ macOS: brew install libpq\n\ - Windows: https://www.postgresql.org/download/windows/\n\n\ + Windows (winget): winget install PostgreSQL.PostgreSQL\n\ + Windows (manual): https://www.postgresql.org/download/windows/\n\ + Windows (chocolatey): choco install postgresql\n\n\ + If psql is already installed, make sure it's in your PATH\n\ + or check C:\\Program Files\\PostgreSQL\\\\bin\\.\n\n\ After installation, restart QueryDen.", ToolKind::MySql => "MySQL client not found.\n\n\ Linux (Debian/Ubuntu): sudo apt install mysql-client\n\ @@ -185,7 +192,41 @@ impl CliManager { .join(format!("{}{}", binary, ext)) } - /// Try system PATH, then bundled binaries for all known versions. + /// On Windows, check common PostgreSQL installation paths that aren't always + /// on PATH (EDB/BigSQL installers put psql.exe in Program Files without + /// adding it to the system PATH). + #[cfg(target_os = "windows")] + fn find_windows_psql_path(&self) -> Option { + let candidates = [ + r"C:\Program Files\PostgreSQL\17\bin\psql.exe", + r"C:\Program Files\PostgreSQL\16\bin\psql.exe", + r"C:\Program Files\PostgreSQL\15\bin\psql.exe", + r"C:\Program Files\PostgreSQL\14\bin\psql.exe", + r"C:\Program Files\PostgreSQL\13\bin\psql.exe", + r"C:\Program Files\PostgreSQL\12\bin\psql.exe", + r"C:\Program Files\PostgreSQL\11\bin\psql.exe", + r"C:\Program Files\PostgreSQL\10\bin\psql.exe", + r"C:\Program Files\PostgreSQL\9.6\bin\psql.exe", + r"C:\Program Files (x86)\PostgreSQL\17\bin\psql.exe", + r"C:\Program Files (x86)\PostgreSQL\16\bin\psql.exe", + r"C:\Program Files (x86)\PostgreSQL\15\bin\psql.exe", + r"C:\Program Files (x86)\PostgreSQL\14\bin\psql.exe", + r"C:\Program Files (x86)\PostgreSQL\13\bin\psql.exe", + r"C:\Program Files (x86)\PostgreSQL\12\bin\psql.exe", + r"C:\Program Files (x86)\PostgreSQL\11\bin\psql.exe", + r"C:\Program Files (x86)\PostgreSQL\10\bin\psql.exe", + r"C:\Program Files (x86)\PostgreSQL\9.6\bin\psql.exe", + ]; + for path_str in &candidates { + let p = Path::new(path_str); + if p.exists() { + return Some(p.to_path_buf()); + } + } + None + } + + /// Try system PATH, then Windows fallback paths, then bundled binaries. /// Returns (path, major_version) of the first match. async fn find_available(&self, kind: ToolKind) -> Option<(PathBuf, u32)> { for binary in kind.all_binaries() { @@ -194,6 +235,14 @@ impl CliManager { } } + // Windows: check common install paths (psql.exe often not on PATH) + #[cfg(target_os = "windows")] + if kind == ToolKind::Psql { + if let Some(path) = self.find_windows_psql_path() { + return Some((path, 0)); + } + } + // Check bundled versioned directories let entries = std::fs::read_dir(&self.tools_dir).ok()?; for entry in entries.flatten() { @@ -713,20 +762,14 @@ pub async fn cli_detect_pg_version( database: String, username: String, password: String, + manager: tauri::State<'_, CliManager>, ) -> Result { // Try to use psql to get server version - // First check if psql is available anywhere - let binary = match which::which("psql") { - Ok(p) => p, - Err(_) => { - return Err( - "psql not found in PATH. Install PostgreSQL client first:\n\n\ - Linux: sudo apt install postgresql-client\n\ - macOS: brew install libpq\n\ - Windows: Download from postgresql.org".to_string(), - ); - } - }; + // Uses find_available which checks PATH, Windows fallback paths, and cached dirs + let (binary, _) = manager + .find_available(ToolKind::Psql) + .await + .ok_or_else(|| ToolKind::Psql.system_install_hint().to_string())?; let output = Command::new(&binary) .env("PGPASSWORD", &password) diff --git a/src/components/explorer/DatabaseExplorer.tsx b/src/components/explorer/DatabaseExplorer.tsx index 9290d1d..96a6933 100644 --- a/src/components/explorer/DatabaseExplorer.tsx +++ b/src/components/explorer/DatabaseExplorer.tsx @@ -115,6 +115,8 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database const treeContainerRef = useRef(null); const [dragState, setDragState] = useState<{ kind: "connection" | "folder"; id: string } | null>(null); const [dropTargetId, setDropTargetId] = useState(null); + const [dragOverRoot, setDragOverRoot] = useState(false); + const autoExpandTimerRef = useRef | null>(null); // Listen for jump events from global search useEffect(() => { @@ -1404,15 +1406,40 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database }; const handleDragEnd = () => { + if (autoExpandTimerRef.current) { + clearTimeout(autoExpandTimerRef.current); + autoExpandTimerRef.current = null; + } setDragState(null); setDropTargetId(null); + setDragOverRoot(false); }; const handleDragOver = (e: React.DragEvent, node: TreeNode) => { if (!dragState) return; e.preventDefault(); e.stopPropagation(); - setDropTargetId(node.id); + setDragOverRoot(false); + setDropTargetId((prev) => (prev === node.id ? prev : node.id)); + + // Auto-expand: 600ms timer on collapsed expandable folders + const isCollapsibleFolder = node.contextMenuId?.startsWith("folder:"); + const isCollapsed = !expandedNodes.has(node.id); + const hasChildNodes = node.children && node.children.length > 0; + if (isCollapsibleFolder && isCollapsed && hasChildNodes) { + if (!autoExpandTimerRef.current) { + autoExpandTimerRef.current = setTimeout(() => { + autoExpandTimerRef.current = null; + toggleExpand(node.id); + }, 600); + } + } else { + if (autoExpandTimerRef.current) { + clearTimeout(autoExpandTimerRef.current); + autoExpandTimerRef.current = null; + } + } + if (!isValidDropTarget(node)) { e.dataTransfer.dropEffect = "none"; return; @@ -1424,6 +1451,10 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database // Only clear if actually leaving the node (not entering a child) if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) { setDropTargetId(null); + if (autoExpandTimerRef.current) { + clearTimeout(autoExpandTimerRef.current); + autoExpandTimerRef.current = null; + } } }; @@ -1431,6 +1462,11 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database e.preventDefault(); e.stopPropagation(); setDropTargetId(null); + setDragOverRoot(false); + if (autoExpandTimerRef.current) { + clearTimeout(autoExpandTimerRef.current); + autoExpandTimerRef.current = null; + } if (!dragState) return; const ctxId = node.contextMenuId; if (!ctxId || !ctxId.startsWith("folder:")) return; @@ -1448,6 +1484,11 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database const handleRootDrop = async (e: React.DragEvent) => { e.preventDefault(); setDropTargetId(null); + setDragOverRoot(false); + if (autoExpandTimerRef.current) { + clearTimeout(autoExpandTimerRef.current); + autoExpandTimerRef.current = null; + } if (!dragState) return; // Drop to root: parentId = null if (dragState.kind === "connection") { @@ -1481,7 +1522,7 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database const isDragSource = isUserFolderNode || isServerNode; /** Drop targets: only user folders. */ const isDropTarget = isUserFolderNode; - const isValid = isDropTarget && isValidDropTarget(node); + const isValid = isDropTarget && isValidDropTarget(node) && dropTargetId === node.id; const isInvalid = isDropTarget && !isValid && dropTargetId === node.id; return ( @@ -1500,6 +1541,9 @@ export function DatabaseExplorer({ isAddConnectionDialogOpen = false }: Database
)}
)} + + {dragOverRoot && dragState && ( +
+ Drop here to move to top level +
+ )} diff --git a/src/components/help/HelpDialog.tsx b/src/components/help/HelpDialog.tsx index be89501..a2ec1d0 100644 --- a/src/components/help/HelpDialog.tsx +++ b/src/components/help/HelpDialog.tsx @@ -146,16 +146,16 @@ export function HelpDialog({ isOpen, onClose }: HelpDialogProps) { size="sm" leftIcon={} onClick={async () => { + const url = "https://queryden.openidle.com/docs"; try { const { openUrl } = await import("@tauri-apps/plugin-opener"); - const url = window.location.origin + "/docs.html"; await openUrl(url); } catch (err) { console.error("openUrl failed, trying WebviewWindow:", err); try { const { WebviewWindow } = await import("@tauri-apps/api/webviewWindow"); new WebviewWindow("docs", { - url: "/docs.html", + url, title: `${appName} Documentation Guide`, width: 1100, height: 800, @@ -164,7 +164,7 @@ export function HelpDialog({ isOpen, onClose }: HelpDialogProps) { }); } catch (winErr) { console.error("WebviewWindow also failed:", winErr); - window.open("/docs.html", "_blank"); + window.open(url, "_blank"); } } }} diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index efad366..94f0384 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -7,7 +7,7 @@ import { useSettings } from "../../store/settingsStore"; import { Play, Plus, X, ChevronDown, ChevronRight, Terminal, Database, Sparkles, GitCompare, Save, Square, Activity, Loader2, CheckCircle, XCircle } from "lucide-react"; import { useSavedQueries } from "../../store/savedQueryStore"; import { useConfirmDialog } from "../ui/ConfirmDialog"; -import { Copy, FileText, BarChart2, Activity as ActivityIcon, Layers } from "lucide-react"; +import { Copy, FileText, BarChart2, Activity as ActivityIcon, Layers, Table } from "lucide-react"; import { EmptyStateLauncher } from "./EmptyStateLauncher"; import { logger } from "../../utils/logger"; import { getDefaultDatabaseName } from "../../config/app"; @@ -36,6 +36,7 @@ const DefinitionModal = lazy(() => import("../tools/DefinitionModal").then(m => const CloneDialog = lazy(() => import("../tools/CloneDialog").then(m => ({ default: m.CloneDialog }))); const ActivityMonitor = lazy(() => import("../tools/ActivityMonitor").then(m => ({ default: m.ActivityMonitor }))); const MultiQueryDialog = lazy(() => import("../tools/MultiQueryDialog").then(m => ({ default: m.MultiQueryDialog }))); +const ERDDialog = lazy(() => import("../tools/ERDDialog").then(m => ({ default: m.ERDDialog }))); const PsqlWindow = lazy(() => import("../ui/PsqlWindow").then(m => ({ default: m.PsqlWindow }))); const LocalHistoryDialog = lazy(() => import("../ui/LocalHistoryDialog").then(m => ({ default: m.LocalHistoryDialog }))); @@ -202,6 +203,7 @@ export function MainContent() { const [showCloneDialog, setShowCloneDialog] = useState(false); const [showActivityMonitor, setShowActivityMonitor] = useState(false); const [showMultiQueryDialog, setShowMultiQueryDialog] = useState(false); + const [showERDDialog, setShowERDDialog] = useState(false); const [showAIDialog, setShowAIDialog] = useState(false); const [_showLocalHistory, setShowLocalHistory] = useState(false); const [optimizerData, setOptimizerData] = useState(null); @@ -290,6 +292,7 @@ export function MainContent() { } await mkdir(dir, { recursive: true }); const lastSaved = autoSaveLastRef.current; + const writtenIds: string[] = []; for (const tab of tabs) { if (!tab.query || tab.query.trim() === "") continue; if (lastSaved.get(tab.id) === tab.query) continue; @@ -308,10 +311,16 @@ export function MainContent() { const filePath = await join(dir, `${folderPart}_${dbPart}_${shortId}.sql`); await writeTextFile(filePath, tab.query); lastSaved.set(tab.id, tab.query); + writtenIds.push(tab.id); } catch (e) { logger.error(`Auto-save failed for tab ${tab.id}:`, e); } } + if (writtenIds.length > 0) { + setQueryTabs(prev => prev.map(tab => + writtenIds.includes(tab.id) ? { ...tab, originalQuery: tab.query } : tab + )); + } } catch (e) { logger.error("Auto-save failed:", e); } @@ -336,7 +345,7 @@ export function MainContent() { id: t.id, name: t.name, query: t.query ?? "", - originalQuery: t.originalQuery, + originalQuery: t.originalQuery ?? t.query ?? "", savedQueryName: t.savedQueryName, target: t.targetConnectionId ? { connectionId: t.targetConnectionId, connectionName: t.targetConnectionName || "", database: t.targetDatabase || "" } @@ -820,7 +829,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l } logger.debug("[CLI Path] Final majorVersion to use:", majorVersion); - const toolStatus = await cliStore.checkTool("postgresql", majorVersion); + let toolStatus = await cliStore.checkTool("postgresql", majorVersion); logger.debug("[CLI Path] Tool status (checkTool):", toolStatus); if (toolStatus.needsDownload) { @@ -853,26 +862,34 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l } } else if (!toolStatus.available) { // Tool not available and cannot be auto-downloaded — show install guide + // with a "Check Again" retry loop so users can install and retry + // without closing/reopening the PSQL tab. const installHint = toolStatus.installHint || "PostgreSQL client (psql) not found. Please install it and restart QueryDen."; - const confirmed = await confirmDialog.confirm({ - title: "psql Not Found", - message: installHint, - confirmLabel: "Open Download Page", - type: "info", - }); - if (confirmed) { - try { - const { openUrl } = await import("@tauri-apps/plugin-opener"); - await openUrl("https://www.postgresql.org/download/"); - } catch (e) { - logger.error("[CLI Path] Failed to open URL:", e); + while (!toolStatus.available) { + const shouldOpenPage = await confirmDialog.confirm({ + title: "psql Not Found", + message: installHint, + confirmLabel: "Open Download Page", + cancelLabel: "Check Again", + type: "info", + }); + if (shouldOpenPage) { + try { + const { openUrl } = await import("@tauri-apps/plugin-opener"); + await openUrl("https://www.postgresql.org/download/"); + } catch (e) { + logger.error("[CLI Path] Failed to open URL:", e); + } + appendPsqlOutput([`ERROR: ${installHint}`]); + setError("psql not found"); + setIsExecuting(false); + isExecutingRef.current = false; + return; } + // User clicked "Check Again" — re-check after potential install + toolStatus = await cliStore.checkTool("postgresql", majorVersion); } - appendPsqlOutput([`ERROR: ${installHint}`]); - setError("psql not found"); - setIsExecuting(false); - isExecutingRef.current = false; - return; + // Fall through to execute when psql is now available } const cliHost = actualConnection.host || "localhost"; @@ -1789,7 +1806,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l id: t.id, name: t.name, query: t.query ?? "", - originalQuery: t.originalQuery, + originalQuery: t.originalQuery ?? t.query ?? "", savedQueryName: t.savedQueryName, targetConnectionId: t.target?.connectionId, targetConnectionName: t.target?.connectionName, @@ -1824,6 +1841,25 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l }); if (discard) { + // Overwrite the session with clean state so these tabs don't + // re-prompt on next startup — the user chose to discard, + // so the persisted snapshot should treat current content + // as the new authoritative baseline (issue #121, #138). + try { + const { invokeCmd } = await import("../../lib/ipc"); + const cleanTabs = queryTabsRef.current.map((t) => ({ + id: t.id, + name: t.name, + query: t.query ?? "", + originalQuery: t.query ?? "", + savedQueryName: t.savedQueryName, + targetConnectionId: t.target?.connectionId, + targetConnectionName: t.target?.connectionName, + targetDatabase: t.target?.database, + usePsql: t.usePsql ?? false, + })); + await invokeCmd("save_sessions", { tabs: cleanTabs, activeTabId: activeTabIdRef.current ?? null }); + } catch { /* non-critical */ } try { await getCurrentWindow().destroy(); } catch (err) { @@ -2579,6 +2615,21 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l icon={} className={showMultiQueryDialog ? "bg-[var(--accent-3)] text-[var(--accent-11)] hover:bg-[var(--accent-4)]" : undefined} /> + { + if (activeTab?.target && (activeTab.target.connectionId !== activeConnection?.id || activeTab.target.database !== selectedDatabase)) { + try { + await connectToDatabase(activeTab.target.connectionId, activeTab.target.database); + } catch { + // connection will show error state in dialog + } + } + setShowERDDialog(true); + }} + icon={
} + className={showERDDialog ? "bg-[var(--accent-3)] text-[var(--accent-11)] hover:bg-[var(--accent-4)]" : undefined} + /> { @@ -2893,6 +2944,9 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l {showMultiQueryDialog && ( setShowMultiQueryDialog(false)} /> )} + {showERDDialog && ( + setShowERDDialog(false)} /> + )} {showAIDialog && ( setShowAIDialog(false)} currentQuery={activeTab?.query || ""} onUpdateQuery={updateTabQuery} /> )} diff --git a/src/components/tools/ActivityMonitor.tsx b/src/components/tools/ActivityMonitor.tsx index 807a0cd..31c7558 100644 --- a/src/components/tools/ActivityMonitor.tsx +++ b/src/components/tools/ActivityMonitor.tsx @@ -64,7 +64,7 @@ export const ActivityMonitor: React.FC = ({ isOpen, onClos const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(""); - const [autoRefresh, setAutoRefresh] = useState(false); + const [autoRefresh, setAutoRefresh] = useState(true); const [targetDb, setTargetDb] = useState(""); // Filters const [stateFilter, setStateFilter] = useState("all"); @@ -103,7 +103,7 @@ export const ActivityMonitor: React.FC = ({ isOpen, onClos } catch (err: any) { setError(err.message); } }; - useEffect(() => { if (isOpen) { setTargetDb(""); setSearchTerm(""); setStateFilter("all"); setLongRunningOnly(false); setBackendTypeFilter("all"); fetchStats(); } }, [isOpen]); + useEffect(() => { if (isOpen) { setAutoRefresh(true); setTargetDb(""); setSearchTerm(""); setStateFilter("all"); setLongRunningOnly(false); setBackendTypeFilter("all"); fetchStats(); } }, [isOpen]); useEffect(() => { if (autoRefresh && isOpen) { let i: ReturnType | undefined; i = setInterval(fetchStats, 3000); return () => { if (i) clearInterval(i); }; } }, [autoRefresh, isOpen, fetchStats]); if (!isOpen) return null; diff --git a/src/components/tools/ERDCanvas.tsx b/src/components/tools/ERDCanvas.tsx new file mode 100644 index 0000000..8e191d9 --- /dev/null +++ b/src/components/tools/ERDCanvas.tsx @@ -0,0 +1,103 @@ +import { useEffect, useMemo } from "react"; +import { + ReactFlow, + Controls, + MiniMap, + Background, + BackgroundVariant, + useNodesState, + useEdgesState, + useReactFlow, + ReactFlowProvider, + type NodeTypes, + type EdgeTypes, + type Node, + type Edge, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import "../../styles/erd.css"; +import TableNode, { type TableNodeData } from "./TableNode"; +import RelationshipEdge, { type RelationshipEdgeData } from "./RelationshipEdge"; + +const nodeTypes: NodeTypes = { + tableNode: TableNode, +}; + +const edgeTypes: EdgeTypes = { + relationshipEdge: RelationshipEdge, +}; + +interface ERDCanvasProps { + initialNodes: Node[]; + initialEdges: Edge[]; +} + +function FitViewHelper() { + const { fitView } = useReactFlow(); + useEffect(() => { + requestAnimationFrame(() => fitView({ padding: 0.3, duration: 200 })); + }); + return null; +} + +function ERDFlow({ initialNodes, initialEdges }: ERDCanvasProps) { + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + useEffect(() => { + setNodes(initialNodes); + setEdges(initialEdges); + }, [initialNodes, initialEdges, setNodes, setEdges]); + + const { nodeColor, nodeStroke, bgColor } = useMemo(() => { + const isDark = + document.documentElement.classList.contains("dark") || + document.documentElement.getAttribute("data-theme") === "dark"; + return { + nodeColor: isDark ? "#272a2d" : "#e7e8ec", + nodeStroke: isDark ? "#43484e" : "#cdced7", + bgColor: isDark ? "#111113" : "#f9f9fb", + }; + }, []); + + return ( + + + + + + + ); +} + +export function ERDCanvas({ initialNodes, initialEdges }: ERDCanvasProps) { + return ( + + + + ); +} diff --git a/src/components/tools/ERDDialog.tsx b/src/components/tools/ERDDialog.tsx new file mode 100644 index 0000000..61d06e6 --- /dev/null +++ b/src/components/tools/ERDDialog.tsx @@ -0,0 +1,504 @@ +import { useState, useMemo, useCallback, useEffect } from "react"; +import { + X, + Search, + RotateCw, + Image, + Columns, + Key, + Table, + AlertCircle, + Loader2, + Filter, + Plus, +} from "lucide-react"; +import { useConnections } from "../../contexts/useConnections"; +import { Dialog } from "../ui/Dialog"; +import { IconButton } from "../ui/IconButton"; +import { Input } from "../ui/Input"; +import { ERDCanvas } from "./ERDCanvas"; +import { useERData, type ERTable } from "./useERData"; +import { TableSelectorDialog } from "./TableSelectorDialog"; + +interface ERDDialogProps { + isOpen: boolean; + onClose: () => void; +} + +function extractSchemas(tableNames: string[]): string[] { + const schemas = new Set(); + let hasUnprefixed = false; + for (const t of tableNames) { + const dot = t.indexOf("."); + if (dot > 0) schemas.add(t.slice(0, dot)); + else hasUnprefixed = true; + } + if (hasUnprefixed) schemas.add("public"); + return Array.from(schemas).sort(); +} + +async function fetchSQLiteTables(db: any): Promise { + try { + const rows = await db.select( + `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name`, + ); + return rows.map((r: any) => r.name ?? ""); + } catch { + return []; + } +} + +function filterTables( + tables: ERTable[], + search: string, + schemaFilter: string | undefined, +): Set { + const visible = new Set(); + const lowerSearch = search.toLowerCase(); + for (const t of tables) { + if (schemaFilter && t.schema !== schemaFilter) continue; + if (search && !t.tableName.toLowerCase().includes(lowerSearch)) continue; + visible.add(t.id); + } + return visible; +} + +function getRelatedTableIds( + tableId: string, + edges: { source: string; target: string }[], +): Set { + const related = new Set([tableId]); + const queue = [tableId]; + while (queue.length > 0) { + const current = queue.pop()!; + for (const e of edges) { + if (e.source === current && !related.has(e.target)) { + related.add(e.target); + queue.push(e.target); + } + if (e.target === current && !related.has(e.source)) { + related.add(e.source); + queue.push(e.source); + } + } + } + return related; +} + +export function ERDDialog({ isOpen, onClose }: ERDDialogProps) { + const { currentDb, activeConnection, selectedDatabase, schemaItems } = + useConnections(); + + const [selectedTables, setSelectedTables] = useState([]); + const [showSelector, setShowSelector] = useState(true); + const [search, setSearch] = useState(""); + const [schemaFilter, setSchemaFilter] = useState( + undefined, + ); + const [showAllColumns, setShowAllColumns] = useState(false); + const [pkOnly, setPkOnly] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + const [sqliteTables, setSqliteTables] = useState(null); + const connectionType = activeConnection?.type; + + const isSQLite = connectionType === "sqlite"; + const allTableNames = useMemo(() => { + if (isSQLite && sqliteTables) return sqliteTables; + return schemaItems?.tables ?? []; + }, [isSQLite, sqliteTables, schemaItems?.tables]); + + const selectedSchemaList = useMemo(() => { + if (selectedTables.length > 0) return extractSchemas(selectedTables); + return extractSchemas(allTableNames); + }, [selectedTables, allTableNames]); + + const schemaList = useMemo(() => { + return extractSchemas(allTableNames); + }, [allTableNames]); + + const handleTableSelect = useCallback( + (tables: string[]) => { + setSelectedTables(tables); + setShowSelector(false); + setRefreshKey((k) => k + 1); + }, + [], + ); + + const handleAddTables = useCallback(() => { + setShowSelector(true); + }, []); + + useEffect(() => { + if (isOpen) { + setSelectedTables([]); + setShowSelector(true); + setSearch(""); + setSchemaFilter(undefined); + setPkOnly(false); + setShowAllColumns(false); + setRefreshKey(0); + + if (isSQLite && currentDb) { + fetchSQLiteTables(currentDb).then(setSqliteTables); + } + } + }, [isOpen, isSQLite, currentDb]); + + useEffect(() => { + if ( + allTableNames.length === 1 && + selectedTables.length === 0 && + showSelector + ) { + handleTableSelect([allTableNames[0]]); + } + }, [allTableNames, selectedTables.length, showSelector, handleTableSelect]); + + const { data, isLoading, error } = useERData( + currentDb, + connectionType, + schemaItems, + selectedDatabase, + schemaList, + selectedTables, + refreshKey, + ); + + const refresh = useCallback(() => { + setRefreshKey((k) => k + 1); + setSearch(""); + setSchemaFilter(undefined); + setPkOnly(false); + setShowAllColumns(false); + }, []); + + const visibleTableIds = useMemo(() => { + if (!data?.tables) return new Set(); + const filtered = filterTables(data.tables, search, schemaFilter); + if (!search) return filtered; + const expanded = new Set(); + for (const id of filtered) { + const related = getRelatedTableIds(id, data.edges); + for (const r of related) expanded.add(r); + } + return expanded; + }, [data, search, schemaFilter]); + + const filteredNodes = useMemo(() => { + if (!data?.nodes) return []; + return data.nodes.filter((n) => visibleTableIds.has(n.id)); + }, [data, visibleTableIds]); + + const filteredEdges = useMemo(() => { + if (!data?.edges) return []; + return data.edges.filter( + (e) => visibleTableIds.has(e.source) && visibleTableIds.has(e.target), + ); + }, [data, visibleTableIds]); + + const processedNodes = useMemo(() => { + let nodes = filteredNodes; + if (pkOnly) { + nodes = nodes.map((node) => { + const tableData = data?.tables.find((t) => t.id === node.id); + if (!tableData) return node; + return { + ...node, + data: { + ...node.data, + columns: tableData.columns.filter((c) => c.isPK), + }, + }; + }); + } else if (!showAllColumns) { + nodes = nodes.map((node) => { + const tableData = data?.tables.find((t) => t.id === node.id); + if (!tableData) return node; + return { + ...node, + data: { + ...node.data, + columns: tableData.columns.filter((c) => c.isPK || c.isFK), + }, + }; + }); + } + return nodes; + }, [filteredNodes, pkOnly, showAllColumns, data?.tables]); + + const selectedTableInfo = useMemo(() => { + if (!data?.tables) return null; + for (const id of visibleTableIds) { + const t = data.tables.find((x) => x.id === id); + if (t) return t; + } + return null; + }, [data, visibleTableIds]); + + const exportSVG = useCallback(async () => { + try { + const el = document.querySelector(".react-flow") as HTMLElement; + if (!el) return; + const rect = el.getBoundingClientRect(); + const clone = el.cloneNode(true) as HTMLElement; + const inner = clone.innerHTML; + const svgData = [ + ``, + ``, + ` `, + ` `, + ` `, + ` `, + ` `, + inner, + ` `, + ` `, + ``, + ].join("\n"); + + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const path = await save({ + filters: [{ name: "SVG Image", extensions: ["svg"] }], + defaultPath: `erd-${selectedDatabase || "schema"}.svg`, + }); + if (path) { + await writeTextFile(path, svgData); + } + } catch { + // export failed silently + } + }, [selectedDatabase]); + + const hasFilters = search !== "" || schemaFilter !== undefined; + const visibleCount = visibleTableIds.size; + const totalSelected = data?.tables?.length ?? 0; + + const hasNoConnection = !currentDb || !activeConnection; + const hasNoTables = + !hasNoConnection && + !isLoading && + !error && + data && + data.tables.length === 0; + + return ( + +
+ {showSelector ? ( + <> + {allTableNames.length === 0 && !isLoading && !isSQLite ? ( +
+
+ +

+ No tables found in this database. +

+ +
+
+ ) : allTableNames.length === 1 ? ( +
+ +
+ ) : ( + + )} + + ) : ( + <> +
+
+
+
+ +

+ ER Diagram +

+ + {activeConnection?.name || ""} + {selectedDatabase ? ` / ${selectedDatabase}` : ""} + + {selectedTables.length > 0 && ( + + )} + + +
+ {selectedSchemaList.length > 1 && ( + + )} + +
+ + setSearch(e.target.value)} + className="pl-7 text-[11px] h-7 w-40" + /> +
+ + } + size="sm" + variant="ghost" + className={ + !showAllColumns ? "text-[var(--accent-9)]" : undefined + } + onClick={() => setShowAllColumns(!showAllColumns)} + /> + + } + size="sm" + variant="ghost" + className={pkOnly ? "text-[var(--accent-9)]" : undefined} + onClick={() => setPkOnly(!pkOnly)} + /> + + } + size="sm" + variant="ghost" + onClick={refresh} + /> + + } + size="sm" + variant="ghost" + onClick={exportSVG} + disabled={!data?.nodes.length} + /> + + } + variant="ghost" + size="sm" + onClick={onClose} + /> +
+ + +
+ {hasNoConnection ? ( +
+
+ +

+ Connect to a database to view the ER diagram. +

+
+
+ ) : isLoading ? ( +
+
+ +

+ Loading schema data... +

+
+
+ ) : error ? ( +
+
+ +

{error}

+
+
+ ) : hasNoTables ? ( +
+
+ +

+ {search || schemaFilter + ? "No tables match the current filters." + : "No tables or foreign key relationships found."} +

+
+
+ ) : ( + + )} +
+ +
+ + {hasFilters + ? `${visibleCount} of ${totalSelected} table${totalSelected !== 1 ? "s" : ""}` + : `${totalSelected} table${totalSelected !== 1 ? "s" : ""}`} + {data?.edges?.length + ? ` · ${data.edges.length} relationship${data.edges.length !== 1 ? "s" : ""}` + : ""} + {selectedTables.length > 0 && data?.tables && ( + + (of {selectedTables.length} selected) + + )} + + + {selectedTableInfo + ? `${selectedTableInfo.schema ? `${selectedTableInfo.schema}.` : ""}${selectedTableInfo.tableName} · ${selectedTableInfo.columns.length} column${selectedTableInfo.columns.length !== 1 ? "s" : ""}` + : "Click a table to select"} + +
+ + )} + + + ); +} diff --git a/src/components/tools/RelationshipEdge.tsx b/src/components/tools/RelationshipEdge.tsx new file mode 100644 index 0000000..d35613c --- /dev/null +++ b/src/components/tools/RelationshipEdge.tsx @@ -0,0 +1,82 @@ +import { memo } from "react"; +import { + BaseEdge, + getSmoothStepPath, + EdgeLabelRenderer, + type EdgeProps, + type Edge, +} from "@xyflow/react"; + +export interface RelationshipEdgeData extends Record { + sourceColumn: string; + targetColumn: string; + sourceTable: string; + targetTable: string; +} + +export type RelationshipEdgeType = Edge; + +function RelationshipEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + selected, +}: EdgeProps) { + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 8, + }); + + return ( + <> + + + +
+ * +
+
+ + +
+ 1 +
+
+ + ); +} + +export default memo(RelationshipEdge); diff --git a/src/components/tools/TableNode.tsx b/src/components/tools/TableNode.tsx new file mode 100644 index 0000000..bbe8ce6 --- /dev/null +++ b/src/components/tools/TableNode.tsx @@ -0,0 +1,132 @@ +import { memo } from "react"; +import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; +import { Key, Link2, Variable, Ban } from "lucide-react"; +import type { ERColumn } from "./useERData"; + +export interface TableNodeData extends Record { + id: string; + schema: string; + tableName: string; + displayName: string; + columns: ERColumn[]; + nodeHeight: number; + isJunction: boolean; + connectionType: string; +} + +const ROW_HEIGHT = 28; +const HEADER_HEIGHT = 36; +const NODE_WIDTH = 240; + +function ColumnRow({ + col, + tableId, +}: { + col: ERColumn; + tableId: string; +}) { + const handleId = `${tableId}:${col.name}`; + + return ( +
+ + + + {col.isPK ? ( + + ) : col.isFK ? ( + + ) : ( + + )} + + + + {col.name} + + + + {col.type} + + + {col.nullable && ( + + )} + + +
+ ); +} + +function TableNode({ data }: NodeProps>) { + const { id, displayName, columns, schema, isJunction, nodeHeight } = data; + + return ( +
+
+ {schema && ( + + {schema}. + + )} + {displayName} + {isJunction && ( + + junction + + )} +
+ +
+ {columns.map((col: ERColumn) => ( + + ))} +
+
+ ); +} + +export default memo(TableNode); diff --git a/src/components/tools/TableSelectorDialog.tsx b/src/components/tools/TableSelectorDialog.tsx new file mode 100644 index 0000000..b1e5d1c --- /dev/null +++ b/src/components/tools/TableSelectorDialog.tsx @@ -0,0 +1,214 @@ +import { useState, useMemo, useCallback } from "react"; +import { Search, CheckSquare, Square, Table, AlertCircle } from "lucide-react"; +import { cn } from "../../lib/cn"; + +interface TableSelectorDialogProps { + tables: string[]; + selected: string[]; + onConfirm: (selected: string[]) => void; + onCancel: () => void; +} + +function extractSchemas(tableNames: string[]): string[] { + const schemas = new Set(); + for (const t of tableNames) { + const dot = t.indexOf("."); + if (dot > 0) schemas.add(t.slice(0, dot)); + } + return Array.from(schemas).sort(); +} + +export function TableSelectorDialog({ + tables, + selected, + onConfirm, + onCancel, +}: TableSelectorDialogProps) { + const [search, setSearch] = useState(""); + const [schemaFilter, setSchemaFilter] = useState(); + const [selectedSet, setSelectedSet] = useState>( + () => new Set(selected), + ); + + const schemas = useMemo(() => extractSchemas(tables), [tables]); + + const filteredTables = useMemo(() => { + const lowerSearch = search.toLowerCase(); + return tables.filter((t) => { + if (schemaFilter && !t.startsWith(`${schemaFilter}.`)) return false; + if ( + search && + !t.toLowerCase().includes(lowerSearch) + ) + return false; + return true; + }); + }, [tables, search, schemaFilter]); + + const allFilteredSelected = useMemo( + () => filteredTables.every((t) => selectedSet.has(t)), + [filteredTables, selectedSet], + ); + + const toggleTable = useCallback((table: string) => { + setSelectedSet((prev) => { + const next = new Set(prev); + if (next.has(table)) next.delete(table); + else next.add(table); + return next; + }); + }, []); + + const toggleAll = useCallback(() => { + setSelectedSet((prev) => { + const next = new Set(prev); + if (allFilteredSelected) { + for (const t of filteredTables) next.delete(t); + } else { + for (const t of filteredTables) next.add(t); + } + return next; + }); + }, [filteredTables, allFilteredSelected]); + + const handleConfirm = useCallback(() => { + if (selectedSet.size > 0) onConfirm(Array.from(selectedSet)); + }, [selectedSet, onConfirm]); + + return ( +
+
+
+
+
+ +

+ Select Tables +

+ + + +
+
+
+ + setSearch(e.target.value)} + className="w-full pl-7 text-xs h-8 bg-[var(--surface-base)] border border-[var(--neutral-7)] rounded-md text-[var(--neutral-12)] placeholder:text-[var(--neutral-9)] outline-none focus:border-[var(--accent-8)] focus:ring-1 focus:ring-[var(--accent-8)]/30" + autoFocus + /> +
+ {schemas.length > 1 && ( + + )} +
+ +
+ + + {selectedSet.size} of {tables.length} table + {tables.length !== 1 ? "s" : ""} selected + {filteredTables.length < tables.length && + ` (${filteredTables.length} visible)`} + +
+
+ +
+ {filteredTables.length === 0 ? ( +
+
+ +

+ {search || schemaFilter + ? "No tables match the current filters." + : "No tables found in this database."} +

+
+
+ ) : ( +
+ {filteredTables.map((table) => { + const isSelected = selectedSet.has(table); + const { schema, tableName } = /^(.*?)\.(.*)$/.exec(table)?.groups + ? { + schema: /^(.*?)\.(.*)$/.exec(table)![1], + tableName: /^(.*?)\.(.*)$/.exec(table)![2], + } + : { schema: "", tableName: table }; + return ( + + ); + })} +
+ )} +
+ +
+ + +
+ + ); +} diff --git a/src/components/tools/useERData.ts b/src/components/tools/useERData.ts new file mode 100644 index 0000000..290b302 --- /dev/null +++ b/src/components/tools/useERData.ts @@ -0,0 +1,542 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import dagre from "dagre"; +import type { Node, Edge } from "@xyflow/react"; + +export interface ERColumn { + name: string; + type: string; + isPK: boolean; + isFK: boolean; + nullable: boolean; +} + +export interface ERTable { + id: string; + schema: string; + tableName: string; + displayName: string; + columns: ERColumn[]; +} + +export interface ERRelationship { + sourceTable: string; + sourceColumn: string; + targetTable: string; + targetColumn: string; +} + +export interface ERData { + nodes: Node[]; + edges: Edge[]; + tables: ERTable[]; +} + +const NODE_WIDTH = 240; +const ROW_HEIGHT = 28; +const HEADER_HEIGHT = 36; +const COLUMN_GAP = 2; + +function buildTableId(schema: string, table: string): string { + return schema ? `${schema}.${table}` : table; +} + +function splitSchemaTable(name: string): { schema: string; table: string } { + const dot = name.indexOf("."); + if (dot > 0) { + return { schema: name.slice(0, dot), table: name.slice(dot + 1) }; + } + return { schema: "", table: name }; +} + +function isJunctionTable(columns: ERColumn[]): boolean { + if (columns.length < 2) return false; + const pkCount = columns.filter((c) => c.isPK).length; + const fkCount = columns.filter((c) => c.isFK).length; + if (pkCount < 2 || fkCount < 2) return false; + const nonPkNonFk = columns.filter((c) => !c.isPK && !c.isFK); + return nonPkNonFk.length === 0; +} + +function getNodeHeight(columns: ERColumn[]): number { + const innerHeight = columns.length * (ROW_HEIGHT + COLUMN_GAP) - COLUMN_GAP; + return HEADER_HEIGHT + Math.max(innerHeight, 40) + 8; +} + +function normalizeTableName( + name: string, + selectedSet: Set, +): string | null { + if (selectedSet.has(name)) return name; + const stripped = name.startsWith("public.") ? name.slice(7) : null; + if (stripped && selectedSet.has(stripped)) return stripped; + const prefixed = `public.${name}`; + if (selectedSet.has(prefixed)) return prefixed; + return null; +} + +async function fetchPostgresColumns( + db: any, + schemaFilter?: string[], +): Promise { + const schemaClause = + schemaFilter && schemaFilter.length > 0 + ? `AND n.nspname IN (${schemaFilter.map((s) => `'${s.replace(/'/g, "''")}'`).join(",")})` + : ""; + const rows = await db.select( + `SELECT n.nspname AS schema_name, + c.relname AS table_name, + a.attname AS column_name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, + NOT a.attnotnull AS nullable, + COALESCE(i.indisprimary, false) AS is_pk + FROM pg_attribute a + JOIN pg_class c ON a.attrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + LEFT JOIN pg_index i ON a.attrelid = i.indrelid + AND a.attnum = ANY(i.indkey) + AND i.indisprimary + WHERE a.attnum > 0 + AND NOT a.attisdropped + AND c.relkind = 'r' + AND n.nspname NOT IN ('information_schema', 'pg_catalog', 'topology') + ${schemaClause} + ORDER BY n.nspname, c.relname, a.attnum`, + ); + return rows.map((r: any) => ({ + schema_name: r.schema_name ?? "", + table_name: r.table_name ?? "", + column_name: r.column_name ?? "", + data_type: r.data_type ?? "unknown", + nullable: !!r.nullable, + is_pk: !!r.is_pk, + })); +} + +async function fetchPostgresForeignKeys( + db: any, + schemas?: string[], +): Promise { + const schemaClause = + schemas && schemas.length > 0 + ? `AND c.connamespace::regnamespace::text IN (${schemas.map((s) => `'${s}'`).join(",")})` + : ""; + try { + const rows = await db.select( + `SELECT + conrelid::regclass::text AS source_table, + a.attname AS source_column, + confrelid::regclass::text AS target_table, + af.attname AS target_column + FROM pg_constraint AS c + JOIN pg_attribute AS a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) + JOIN pg_attribute AS af ON af.attrelid = c.confrelid AND af.attnum = ANY(c.confkey) + WHERE c.contype = 'f' + AND c.connamespace::regnamespace::text NOT IN ('information_schema', 'pg_catalog', 'topology') + ${schemaClause}`, + ); + return rows.map((r: any) => ({ + source_table: r.source_table ?? "", + source_column: r.source_column ?? "", + target_table: r.target_table ?? "", + target_column: r.target_column ?? "", + })); + } catch { + return []; + } +} + +async function fetchMySQLColumns( + db: any, + databaseName: string, +): Promise { + const rows = await db.select( + `SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_KEY + FROM information_schema.columns + WHERE TABLE_SCHEMA = ? + ORDER BY TABLE_NAME, ORDINAL_POSITION`, + [databaseName], + ); + return rows.map((r: any) => ({ + table_name: r.TABLE_NAME ?? r.table_name ?? "", + column_name: r.COLUMN_NAME ?? r.column_name ?? "", + data_type: r.DATA_TYPE ?? r.data_type ?? "unknown", + nullable: (r.IS_NULLABLE ?? r.is_nullable ?? "YES") === "YES", + is_pk: (r.COLUMN_KEY ?? r.column_key ?? "") === "PRI", + })); +} + +async function fetchMySQLForeignKeys( + db: any, + databaseName: string, +): Promise { + try { + const rows = await db.select( + `SELECT + kcu.TABLE_NAME AS source_table, + kcu.COLUMN_NAME AS source_column, + kcu.REFERENCED_TABLE_NAME AS target_table, + kcu.REFERENCED_COLUMN_NAME AS target_column + FROM information_schema.KEY_COLUMN_USAGE AS kcu + JOIN information_schema.TABLE_CONSTRAINTS AS tc + ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + WHERE tc.CONSTRAINT_TYPE = 'FOREIGN KEY' + AND kcu.TABLE_SCHEMA = ?`, + [databaseName], + ); + return rows.map((r: any) => ({ + source_table: r.source_table ?? r.SOURCE_TABLE ?? "", + source_column: r.source_column ?? r.SOURCE_COLUMN ?? "", + target_table: r.target_table ?? r.TARGET_TABLE ?? "", + target_column: r.target_column ?? r.TARGET_COLUMN ?? "", + })); + } catch { + return []; + } +} + +async function fetchSQLiteColumns( + db: any, + tableName: string, +): Promise { + const rows = await db.select( + `PRAGMA table_info("${tableName.replace(/"/g, '""')}")`, + ); + return rows.map((r: any) => ({ + table_name: tableName, + column_name: r.name ?? "", + data_type: r.type ?? "unknown", + nullable: !r.notnull, + is_pk: !!r.pk, + })); +} + +async function fetchSQLiteForeignKeys( + db: any, + tableName: string, +): Promise { + const rows = await db.select( + `PRAGMA foreign_key_list("${tableName.replace(/"/g, '""')}")`, + ); + return rows.map((r: any) => ({ + source_table: tableName, + source_column: r.from ?? "", + target_table: r.table ?? "", + target_column: r.to ?? "", + })); +} + +function runDagreLayout(nodes: Node[], edges: Edge[]): Node[] { + const g = new dagre.graphlib.Graph(); + g.setGraph({ + rankdir: "LR", + nodesep: 80, + ranksep: 150, + marginx: 40, + marginy: 40, + }); + g.setDefaultEdgeLabel(() => ({})); + + nodes.forEach((n) => { + const h = ((n.data as any)?.nodeHeight as number) ?? 120; + g.setNode(n.id, { width: NODE_WIDTH, height: h }); + }); + + edges.forEach((e) => { + g.setEdge(e.source, e.target); + }); + + dagre.layout(g); + + return nodes.map((n) => { + const pos = g.node(n.id); + return { + ...n, + position: { + x: pos.x - NODE_WIDTH / 2, + y: pos.y - ((pos.height as number) || 120) / 2, + }, + }; + }); +} + +interface CacheEntry { + data: ERData; + ts: number; +} + +const schemaCache = new Map(); +const CACHE_TTL = 30_000; + +export function useERData( + currentDb: any, + connectionType: string | undefined, + schemaItems: any, + selectedDatabase: string | null | undefined, + schemas: string[], + tableNames: string[], + refreshKey = 0, +): { data: ERData | null; isLoading: boolean; error: string | null } { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(false); + + const dbType = connectionType || ""; + const isPostgres = ["postgres", "supabase", "cockroachdb"].includes(dbType); + const isMySQL = ["mysql", "mariadb"].includes(dbType); + const isSQLite = dbType === "sqlite"; + + const buildERData = useCallback(async () => { + if (!currentDb || !schemaItems || tableNames.length === 0) { + setData(null); + setIsLoading(false); + setError(null); + return; + } + + const sortedNames = [...tableNames].sort(); + const cacheKey = JSON.stringify({ + dbType, + selectedDatabase, + schemas, + tableNames: sortedNames, + refreshKey, + }); + const cached = schemaCache.get(cacheKey); + if (cached && Date.now() - cached.ts < CACHE_TTL && refreshKey === 0) { + setData(cached.data); + setIsLoading(false); + setError(null); + return; + } + + abortRef.current = false; + setIsLoading(true); + setError(null); + + try { + let allColumns: any[] = []; + let fkRelationships: ERRelationship[] = []; + + const selectedSet = new Set(tableNames); + + if (isPostgres) { + const validSchemas = schemas.length > 0 ? schemas : undefined; + const [rows, fks] = await Promise.all([ + fetchPostgresColumns(currentDb, validSchemas), + schemaItems.foreignKeys?.length + ? Promise.resolve(schemaItems.foreignKeys) + : fetchPostgresForeignKeys(currentDb, validSchemas), + ]); + allColumns = rows.filter((r: any) => { + const key = + r.schema_name && r.schema_name !== "public" + ? `${r.schema_name}.${r.table_name}` + : r.table_name; + return selectedSet.has(key); + }); + for (const fk of fks) { + const src = normalizeTableName( + fk.source_table ?? fk.SOURCE_TABLE ?? "", + selectedSet, + ); + const tgt = normalizeTableName( + fk.target_table ?? fk.TARGET_TABLE ?? "", + selectedSet, + ); + if (src || tgt) { + fkRelationships.push({ + sourceTable: src ?? fk.source_table ?? fk.SOURCE_TABLE ?? "", + sourceColumn: fk.source_column ?? fk.SOURCE_COLUMN ?? "", + targetTable: tgt ?? fk.target_table ?? fk.TARGET_TABLE ?? "", + targetColumn: fk.target_column ?? fk.TARGET_COLUMN ?? "", + }); + } + } + } else if (isMySQL) { + const [rows, fks] = await Promise.all([ + fetchMySQLColumns(currentDb, selectedDatabase || ""), + schemaItems.foreignKeys?.length + ? Promise.resolve(schemaItems.foreignKeys) + : fetchMySQLForeignKeys(currentDb, selectedDatabase || ""), + ]); + allColumns = rows.filter((r: any) => selectedSet.has(r.table_name)); + for (const fk of fks) { + if ( + selectedSet.has(fk.source_table ?? fk.SOURCE_TABLE ?? "") || + selectedSet.has(fk.target_table ?? fk.TARGET_TABLE ?? "") + ) { + fkRelationships.push({ + sourceTable: fk.source_table ?? fk.SOURCE_TABLE ?? "", + sourceColumn: fk.source_column ?? fk.SOURCE_COLUMN ?? "", + targetTable: fk.target_table ?? fk.TARGET_TABLE ?? "", + targetColumn: fk.target_column ?? fk.TARGET_COLUMN ?? "", + }); + } + } + } else if (isSQLite) { + const colPromises = tableNames.map(async (t) => { + const { table } = splitSchemaTable(t); + return fetchSQLiteColumns(currentDb, table); + }); + const colResults = await Promise.all(colPromises); + allColumns = colResults.flat(); + + const fkPromises = tableNames.map(async (t) => { + const { table } = splitSchemaTable(t); + return fetchSQLiteForeignKeys(currentDb, table); + }); + const fkResults = await Promise.all(fkPromises); + for (const fk of fkResults.flat()) { + if ( + selectedSet.has(fk.source_table) || + selectedSet.has(fk.target_table) + ) { + fkRelationships.push(fk); + } + } + } + + if (abortRef.current) return; + + const columnMap = new Map(); + for (const col of allColumns) { + const key = + col.schema_name && col.schema_name !== "public" + ? `${col.schema_name}.${col.table_name}` + : col.table_name; + if (!columnMap.has(key)) columnMap.set(key, []); + columnMap.get(key)!.push({ + name: col.column_name, + type: col.data_type, + isPK: !!col.is_pk, + isFK: false, + nullable: !!col.nullable, + }); + } + + const nodeNameSet = new Set(tableNames); + + const fkColumnSet = new Set(); + for (const fk of fkRelationships) { + const src = normalizeTableName(fk.sourceTable, nodeNameSet); + if (src) { + fkColumnSet.add(`${src}:${fk.sourceColumn}`); + } + } + + for (const [key, cols] of columnMap) { + for (const col of cols) { + if (fkColumnSet.has(`${key}:${col.name}`)) { + col.isFK = true; + } + } + } + + const erTables: ERTable[] = []; + for (const t of tableNames) { + const { schema, table } = splitSchemaTable(t); + const id = buildTableId(schema, table); + const cols = columnMap.get(t) || columnMap.get(id) || []; + erTables.push({ + id, + schema, + tableName: table, + displayName: table, + columns: cols, + }); + } + + if (abortRef.current) return; + + const nodes: Node[] = []; + const edges: Edge[] = []; + const edgeSet = new Set(); + + for (const et of erTables) { + const nodeHeight = getNodeHeight(et.columns); + nodes.push({ + id: et.id, + type: "tableNode", + position: { x: 0, y: 0 }, + data: { + id: et.id, + schema: et.schema, + tableName: et.tableName, + displayName: et.displayName, + columns: et.columns, + nodeHeight, + isJunction: isJunctionTable(et.columns), + connectionType: dbType, + }, + }); + } + + const nodeIdSet = new Set(nodes.map((n) => n.id)); + + for (const fk of fkRelationships) { + const src = normalizeTableName(fk.sourceTable, nodeIdSet); + const tgt = normalizeTableName(fk.targetTable, nodeIdSet); + if (!src || !tgt) continue; + const edgeKey = `${src}->${tgt}`; + if (edgeSet.has(edgeKey)) continue; + edgeSet.add(edgeKey); + + edges.push({ + id: `${src}.${fk.sourceColumn}->${tgt}.${fk.targetColumn}`, + source: src, + target: tgt, + sourceHandle: `${src}:${fk.sourceColumn}`, + targetHandle: `${tgt}:${fk.targetColumn}`, + type: "relationshipEdge", + data: { + sourceColumn: fk.sourceColumn, + targetColumn: fk.targetColumn, + sourceTable: fk.sourceTable, + targetTable: fk.targetTable, + }, + }); + } + + const laidOutNodes = runDagreLayout(nodes, edges); + + const result: ERData = { + nodes: laidOutNodes, + edges, + tables: erTables, + }; + + schemaCache.set(cacheKey, { data: result, ts: Date.now() }); + setData(result); + } catch (e: any) { + if (!abortRef.current) { + setError(e?.message || "Failed to load schema data"); + } + } finally { + if (!abortRef.current) { + setIsLoading(false); + } + } + }, [ + currentDb, + schemaItems, + selectedDatabase, + schemas, + tableNames, + isPostgres, + isMySQL, + isSQLite, + dbType, + refreshKey, + ]); + + useEffect(() => { + buildERData(); + return () => { + abortRef.current = true; + }; + }, [buildERData, refreshKey]); + + return { data, isLoading, error }; +} diff --git a/src/styles/erd.css b/src/styles/erd.css new file mode 100644 index 0000000..779853b --- /dev/null +++ b/src/styles/erd.css @@ -0,0 +1,99 @@ +/* ── React Flow theme overrides for ER Diagram ──────────────── */ + +.react-flow { + background: transparent; +} + +.react-flow__viewport { + background: transparent; +} + +.react-flow__node { + cursor: grab; +} + +.react-flow__node:active { + cursor: grabbing; +} + +.react-flow__node.selected > div { + border-color: var(--accent-9); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-9) 30%, transparent), + 0 4px 12px rgba(0,0,0,0.25); +} + +.react-flow__edge-path { + stroke: var(--neutral-7); + stroke-width: 2; + transition: stroke 0.15s, stroke-width 0.15s; +} + +.react-flow__edge.selected .react-flow__edge-path { + stroke: var(--accent-9); + stroke-width: 2.5; +} + +.react-flow__edge:hover .react-flow__edge-path { + stroke: var(--accent-10); + stroke-width: 2.5; +} + +.react-flow__handle { + opacity: 0; + transition: opacity 0.15s; +} + +.react-flow__node:hover .react-flow__handle { + opacity: 0.6; +} + +.react-flow__handle:hover { + opacity: 1 !important; +} + +.react-flow__controls { + display: flex; + gap: 2px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--neutral-6); + background: var(--surface-elevated); +} + +.react-flow__controls-button { + width: 28px; + height: 28px; + fill: var(--neutral-11); + border-bottom: 1px solid var(--neutral-6); + background: var(--surface-elevated); + transition: background 0.1s; +} + +.react-flow__controls-button:hover { + background: var(--neutral-4); +} + +.react-flow__controls-button:last-child { + border-bottom: none; +} + +.react-flow__controls-button svg { + width: 14px; + height: 14px; +} + +.react-flow__minimap { + border-radius: 8px; + border: 1px solid var(--neutral-6); + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} + +.react-flow__minimap-mask { + fill-opacity: 0.7; +} + +.react-flow__background { + background: transparent; +} diff --git a/website/src/content/docs/editor/er-diagram.mdx b/website/src/content/docs/editor/er-diagram.mdx new file mode 100644 index 0000000..fa59f6d --- /dev/null +++ b/website/src/content/docs/editor/er-diagram.mdx @@ -0,0 +1,57 @@ +--- +title: ER Diagram +description: Visualize your database schema as an interactive entity-relationship diagram with auto-layout, search, and SVG export. +section: editor +order: 15 +updated: 2026-06-01 +--- + +import Callout from '../../../components/Callout.astro'; + +The **ER Diagram** tool renders the tables and foreign-key relationships in your database as an interactive entity-relationship diagram. It supports PostgreSQL, MySQL/MariaDB, and SQLite. + +## Opening the diagram + +Click the ER Diagram button in the toolbar (the Table icon) above the query editor. The diagram always reflects the database targeted by the active editor tab — if your current tab is scoped to `production`, the diagram shows `production`, even when another tab targets a different database. + +## Table selection + +A **table selector** dialog appears before the diagram renders. Pick which tables to include: + +- **Check** individual tables, or use **Select All / Deselect All**. +- **Filter by schema** (PostgreSQL multi-schema databases) using the dropdown. +- Click **Show Diagram** to generate the layout for your selection. + +This scoped approach prevents the freeze that would occur when introspecting every table in a large database — dagre auto-layout runs only on the tables you selected. + +### Adding more tables + +Once the diagram is open, click **+ Add tables** in the toolbar to return to the table selector and add more tables to the existing diagram. + +## Controls and toolbar + +| Control | Description | +|---------|-------------| +| **Schema filter** | Filter the displayed tables by schema (PostgreSQL only, shown when multiple schemas are present). | +| **Search** | Type to filter tables by name. Matching tables and their FK-related tables stay visible. | +| **Compact view** | Toggle between all columns and PK/FK columns only. On by default. | +| **PKs only** | Show only primary-key columns. | +| **Refresh** | Re-fetch schema metadata (drops the 30-second in-memory cache). | +| **Export SVG** | Saves the current diagram as an `.svg` file via the native save dialog. | + +## Navigation + +- **Pan** — click and drag the canvas background. +- **Zoom** — scroll wheel, or use the zoom controls in the bottom-right corner. +- **Select** — click a table node to highlight it and its relationships. +- **Mini-map** — the overview in the bottom-right corner shows your position in the full diagram. + +## Supported engines + +| Engine | Introspection method | +|--------|---------------------| +| PostgreSQL | Bulk queries via `pg_attribute`, `pg_index`, `pg_constraint`, and `pg_class`. Handles `public.` schema prefix transparently. | +| MySQL / MariaDB | `information_schema` tables (`COLUMNS`, `KEY_COLUMN_USAGE`, `TABLE_CONSTRAINTS`). | +| SQLite | `PRAGMA table_info()` and `PRAGMA foreign_key_list()` per table, batched with `Promise.all` for performance. | + +Schema data is cached in memory for 30 seconds — re-opening the same set of tables within that window returns instantly without re-querying the database. From e11ca19bafacf386b91b9eeaf89e635ff10b94ad Mon Sep 17 00:00:00 2001 From: Keenan Simpson Date: Mon, 1 Jun 2026 11:24:58 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20code=20review=20=E2=80=94?= =?UTF-8?q?=2011=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli: Windows PSQL path uses %ProgramFiles% env vars (not hardcoded C:\) - cli: ToolKind::Psql.all_binaries narrowed to just ["psql"] - HelpDialog: window.open fallback gets noopener,noreferrer - MainContent: ERD dialog return on failed connection switch - ActivityMonitor: stale targetDb fixed via optional override param + ref - ERDCanvas: FitViewHelper useEffect gets [] deps - useERData: escape schema names in FK query SQL injection - useERData: composite FK query uses unnest WITH ORDINALITY (fix cross-join) - useERData: abortRef -> generationRef to prevent stale writes - useERData: cockroachdb -> cockroach engine string typo - useERData: edge dedup key includes column names (parallel FKs preserved) --- src-tauri/src/cli.rs | 42 ++++++++++-------------- src/components/help/HelpDialog.tsx | 2 +- src/components/layout/MainContent.tsx | 2 +- src/components/tools/ActivityMonitor.tsx | 19 +++++++---- src/components/tools/ERDCanvas.tsx | 2 +- src/components/tools/useERData.ts | 27 ++++++++------- 6 files changed, 48 insertions(+), 46 deletions(-) diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 12b782d..4bf5671 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -64,7 +64,7 @@ impl ToolKind { fn all_binaries(&self) -> &'static [&'static str] { match self { - ToolKind::Psql => &["psql", "pg_dump", "pg_restore", "pg_dumpall"], + ToolKind::Psql => &["psql"], ToolKind::MySql => &["mysql", "mysqldump"], ToolKind::Mongo => &["mongosh"], ToolKind::Redis => &["redis-cli"], @@ -197,30 +197,24 @@ impl CliManager { /// adding it to the system PATH). #[cfg(target_os = "windows")] fn find_windows_psql_path(&self) -> Option { - let candidates = [ - r"C:\Program Files\PostgreSQL\17\bin\psql.exe", - r"C:\Program Files\PostgreSQL\16\bin\psql.exe", - r"C:\Program Files\PostgreSQL\15\bin\psql.exe", - r"C:\Program Files\PostgreSQL\14\bin\psql.exe", - r"C:\Program Files\PostgreSQL\13\bin\psql.exe", - r"C:\Program Files\PostgreSQL\12\bin\psql.exe", - r"C:\Program Files\PostgreSQL\11\bin\psql.exe", - r"C:\Program Files\PostgreSQL\10\bin\psql.exe", - r"C:\Program Files\PostgreSQL\9.6\bin\psql.exe", - r"C:\Program Files (x86)\PostgreSQL\17\bin\psql.exe", - r"C:\Program Files (x86)\PostgreSQL\16\bin\psql.exe", - r"C:\Program Files (x86)\PostgreSQL\15\bin\psql.exe", - r"C:\Program Files (x86)\PostgreSQL\14\bin\psql.exe", - r"C:\Program Files (x86)\PostgreSQL\13\bin\psql.exe", - r"C:\Program Files (x86)\PostgreSQL\12\bin\psql.exe", - r"C:\Program Files (x86)\PostgreSQL\11\bin\psql.exe", - r"C:\Program Files (x86)\PostgreSQL\10\bin\psql.exe", - r"C:\Program Files (x86)\PostgreSQL\9.6\bin\psql.exe", + let prog_files = std::env::var("ProgramFiles") + .or_else(|_| std::env::var("ProgramW6432")) + .unwrap_or_else(|_| r"C:\Program Files".to_string()); + let prog_files_x86 = std::env::var("ProgramFiles(x86)") + .unwrap_or_else(|_| r"C:\Program Files (x86)".to_string()); + let versions = [ + "17", "16", "15", "14", "13", "12", "11", "10", "9.6", ]; - for path_str in &candidates { - let p = Path::new(path_str); - if p.exists() { - return Some(p.to_path_buf()); + for root in [&prog_files, &prog_files_x86] { + for ver in &versions { + let p = Path::new(root) + .join("PostgreSQL") + .join(ver) + .join("bin") + .join("psql.exe"); + if p.exists() { + return Some(p); + } } } None diff --git a/src/components/help/HelpDialog.tsx b/src/components/help/HelpDialog.tsx index a2ec1d0..28c1d1f 100644 --- a/src/components/help/HelpDialog.tsx +++ b/src/components/help/HelpDialog.tsx @@ -164,7 +164,7 @@ export function HelpDialog({ isOpen, onClose }: HelpDialogProps) { }); } catch (winErr) { console.error("WebviewWindow also failed:", winErr); - window.open(url, "_blank"); + window.open(url, "_blank", "noopener,noreferrer"); } } }} diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 94f0384..ef4c277 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -2622,7 +2622,7 @@ const executeQuery = useCallback(async (specificQuery?: any, statementInfo?: { l try { await connectToDatabase(activeTab.target.connectionId, activeTab.target.database); } catch { - // connection will show error state in dialog + return; } } setShowERDDialog(true); diff --git a/src/components/tools/ActivityMonitor.tsx b/src/components/tools/ActivityMonitor.tsx index 31c7558..12c923f 100644 --- a/src/components/tools/ActivityMonitor.tsx +++ b/src/components/tools/ActivityMonitor.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { X, RefreshCw, Activity, Trash2, Search, ShieldAlert, Cpu, Zap, Clock, Filter } from "lucide-react"; import { useConnections } from "../../contexts/useConnections"; import { useConfirmDialog } from "../ui/ConfirmDialog"; @@ -66,6 +66,7 @@ export const ActivityMonitor: React.FC = ({ isOpen, onClos const [searchTerm, setSearchTerm] = useState(""); const [autoRefresh, setAutoRefresh] = useState(true); const [targetDb, setTargetDb] = useState(""); + const latestTargetDbRef = useRef(targetDb); // Filters const [stateFilter, setStateFilter] = useState("all"); const [longRunningOnly, setLongRunningOnly] = useState(false); @@ -74,24 +75,28 @@ export const ActivityMonitor: React.FC = ({ isOpen, onClos const [sortKey, setSortKey] = useState("pid"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); - const fetchStats = useCallback(async () => { + useEffect(() => { latestTargetDbRef.current = targetDb; }, [targetDb]); + + const fetchStats = useCallback(async (overrideTargetDb?: string) => { if (!activeConnection) return; if (activeConnection.type !== 'postgres') { setError("PostgreSQL only"); return; } if (!currentDb) { setError("Not connected"); return; } + const dbTarget = overrideTargetDb ?? latestTargetDbRef.current; + setIsLoading(true); setError(null); try { - const dbFilter = targetDb ? `AND datname = $1` : ""; + const dbFilter = dbTarget ? `AND datname = $1` : ""; const query = `SELECT pid, COALESCE(datname::text, '') as datname, COALESCE(usename::text, '') as usename, COALESCE(application_name::text, '') as application_name, COALESCE(client_addr::text, 'local') as client_addr, COALESCE(client_port::text, '') as client_port, COALESCE(backend_start::text, '') as backend_start, COALESCE(xact_start::text, '') as xact_start, COALESCE(query_start::text, '') as query_start, COALESCE(state_change::text, '') as state_change, COALESCE(wait_event_type::text, '') as wait_event_type, COALESCE(wait_event::text, '') as wait_event, COALESCE(state::text, 'unknown') as state, COALESCE(backend_type::text, '') as backend_type, COALESCE(query::text, '') as query FROM pg_stat_activity WHERE pid <> pg_backend_pid() ${dbFilter} ORDER BY backend_start DESC`; - const result = await (currentDb as any).select(query, targetDb ? [targetDb] : []) as ConnectionStats[]; + const result = await (currentDb as any).select(query, dbTarget ? [dbTarget] : []) as ConnectionStats[]; setStats(result); setError(null); } catch (err: any) { setError(err.message || "Failed to fetch"); } finally { setIsLoading(false); } - }, [currentDb, activeConnection, targetDb]); + }, [currentDb, activeConnection]); const terminateBackend = async (pid: number | string) => { if (!currentDb) return; @@ -103,7 +108,7 @@ export const ActivityMonitor: React.FC = ({ isOpen, onClos } catch (err: any) { setError(err.message); } }; - useEffect(() => { if (isOpen) { setAutoRefresh(true); setTargetDb(""); setSearchTerm(""); setStateFilter("all"); setLongRunningOnly(false); setBackendTypeFilter("all"); fetchStats(); } }, [isOpen]); + useEffect(() => { if (isOpen) { setAutoRefresh(true); setTargetDb(""); setSearchTerm(""); setStateFilter("all"); setLongRunningOnly(false); setBackendTypeFilter("all"); fetchStats(""); } }, [isOpen]); useEffect(() => { if (autoRefresh && isOpen) { let i: ReturnType | undefined; i = setInterval(fetchStats, 3000); return () => { if (i) clearInterval(i); }; } }, [autoRefresh, isOpen, fetchStats]); if (!isOpen) return null; @@ -249,7 +254,7 @@ export const ActivityMonitor: React.FC = ({ isOpen, onClos Long Running - } label="Refresh" variant="ghost" size="sm" onClick={fetchStats} /> + } label="Refresh" variant="ghost" size="sm" onClick={() => fetchStats()} />
{filteredStats.length} / {stats.length} sessions
diff --git a/src/components/tools/ERDCanvas.tsx b/src/components/tools/ERDCanvas.tsx index 8e191d9..2b0292d 100644 --- a/src/components/tools/ERDCanvas.tsx +++ b/src/components/tools/ERDCanvas.tsx @@ -36,7 +36,7 @@ function FitViewHelper() { const { fitView } = useReactFlow(); useEffect(() => { requestAnimationFrame(() => fitView({ padding: 0.3, duration: 200 })); - }); + }, []); return null; } diff --git a/src/components/tools/useERData.ts b/src/components/tools/useERData.ts index 290b302..31f2f93 100644 --- a/src/components/tools/useERData.ts +++ b/src/components/tools/useERData.ts @@ -118,7 +118,7 @@ async function fetchPostgresForeignKeys( ): Promise { const schemaClause = schemas && schemas.length > 0 - ? `AND c.connamespace::regnamespace::text IN (${schemas.map((s) => `'${s}'`).join(",")})` + ? `AND c.connamespace::regnamespace::text IN (${schemas.map((s) => `'${s.replace(/'/g, "''")}'`).join(",")})` : ""; try { const rows = await db.select( @@ -128,9 +128,12 @@ async function fetchPostgresForeignKeys( confrelid::regclass::text AS target_table, af.attname AS target_column FROM pg_constraint AS c - JOIN pg_attribute AS a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) - JOIN pg_attribute AS af ON af.attrelid = c.confrelid AND af.attnum = ANY(c.confkey) + CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS sk(attnum, ord) + CROSS JOIN LATERAL unnest(c.confkey) WITH ORDINALITY AS tk(attnum, ord) + JOIN pg_attribute AS a ON a.attrelid = c.conrelid AND a.attnum = sk.attnum + JOIN pg_attribute AS af ON af.attrelid = c.confrelid AND af.attnum = tk.attnum WHERE c.contype = 'f' + AND sk.ord = tk.ord AND c.connamespace::regnamespace::text NOT IN ('information_schema', 'pg_catalog', 'topology') ${schemaClause}`, ); @@ -280,10 +283,10 @@ export function useERData( const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const abortRef = useRef(false); + const generationRef = useRef(0); const dbType = connectionType || ""; - const isPostgres = ["postgres", "supabase", "cockroachdb"].includes(dbType); + const isPostgres = ["postgres", "supabase", "cockroach"].includes(dbType); const isMySQL = ["mysql", "mariadb"].includes(dbType); const isSQLite = dbType === "sqlite"; @@ -311,7 +314,7 @@ export function useERData( return; } - abortRef.current = false; + const gen = ++generationRef.current; setIsLoading(true); setError(null); @@ -398,7 +401,7 @@ export function useERData( } } - if (abortRef.current) return; + if (gen !== generationRef.current) return; const columnMap = new Map(); for (const col of allColumns) { @@ -448,7 +451,7 @@ export function useERData( }); } - if (abortRef.current) return; + if (gen !== generationRef.current) return; const nodes: Node[] = []; const edges: Edge[] = []; @@ -479,7 +482,7 @@ export function useERData( const src = normalizeTableName(fk.sourceTable, nodeIdSet); const tgt = normalizeTableName(fk.targetTable, nodeIdSet); if (!src || !tgt) continue; - const edgeKey = `${src}->${tgt}`; + const edgeKey = `${src}.${fk.sourceColumn}->${tgt}.${fk.targetColumn}`; if (edgeSet.has(edgeKey)) continue; edgeSet.add(edgeKey); @@ -510,11 +513,11 @@ export function useERData( schemaCache.set(cacheKey, { data: result, ts: Date.now() }); setData(result); } catch (e: any) { - if (!abortRef.current) { + if (gen === generationRef.current) { setError(e?.message || "Failed to load schema data"); } } finally { - if (!abortRef.current) { + if (gen === generationRef.current) { setIsLoading(false); } } @@ -534,7 +537,7 @@ export function useERData( useEffect(() => { buildERData(); return () => { - abortRef.current = true; + generationRef.current += 1; }; }, [buildERData, refreshKey]);