diff --git a/.gitignore b/.gitignore index 3234e8f..a99dd3a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,11 @@ dist-ssr .superpowers/ docs/superpowers/ +# Database (local state — never commit) +*.db +*.sqlite +*.sqlite3 + # Env .env.* .env diff --git a/AGENTS.md b/AGENTS.md index 3ab55bb..b1efc72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,12 +67,14 @@ gridline/ │ │ │ ├── connections.rs # CRUD for saved connections │ │ │ ├── query.rs # SQL execution │ │ │ ├── schema.rs # Object tree introspection +│ │ │ ├── schema_graph.rs # ER diagram / relationship graph │ │ │ ├── backup.rs # pg_dump / pg_restore wrappers │ │ │ └── workspace.rs # Workspace/folder persistence │ │ ├── models/ # Serde structs shared across commands │ │ │ ├── mod.rs │ │ │ ├── connection.rs │ │ │ ├── query.rs +│ │ │ ├── db_viewer.rs # DB viewer types (SchemaGraph, TableNode, etc.) │ │ │ └── workspace.rs │ │ └── store/ # SQLite local persistence layer │ │ ├── mod.rs @@ -251,7 +253,7 @@ cargo test # Rust tests | Constraints (CHECK, UNIQUE beyond PK/FK) | ❌ | | | Materialized views | ❌ | Not distinguished from regular views | | Stored procedures | 🟡 | Included in Functions via p.prokind IN ('f','p'); no separate view yet | -| Schema visualizer (ER diagram) | ❌ | Stub button in sidebar | +| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. | ### Query Editor | Feature | Status | Details | diff --git a/README.md b/README.md index 357492c..cefd090 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A modern, open-source, high-performance database GUI client for PostgreSQL and beyond. Built with Tauri 2.0, Rust, and React — lightweight by design, powerful by default. -> **Inspired by DB Pro's best ideas. Freed from its paywalls.** No caps on tabs, connections, or saved queries. Deep PostgreSQL tooling (`pg_dump`, `pg_restore`, DB-to-DB sync) that commercial alternatives leave to the CLI. +> **Inspired by DB Pro and Beekeeper Studio's best ideas. Freed from their paywalls.** No caps on tabs, connections, or saved queries. Deep PostgreSQL tooling (`pg_dump`, `pg_restore`, DB-to-DB sync) that commercial alternatives lock behind paywalls or leave to the CLI. --- @@ -10,17 +10,28 @@ A modern, open-source, high-performance database GUI client for PostgreSQL and b Most database GUI clients either lock essential productivity features behind paywalls or treat PostgreSQL administration as an afterthought. Gridline is different: -| Capability | DB Pro (Free) | Gridline | -| :--- | :---: | :---: | -| Open tabs | 3 | **Unlimited** | -| Saved connections | 2 | **Unlimited** | -| Saved queries | 5 | **Unlimited** | -| Workspace / folder hierarchy | ❌ | **Multi-level tree** | -| pg_dump / pg_restore GUI | ❌ | **First-class UI** | -| DB-to-DB sync | ❌ | **Built-in diff & migrate** | -| Functions, Triggers, Enums, Sequences | ❌ | **Full object explorer** | -| OS credential vault storage | ❌ | **Keychain / Secret Service** | -| Open source | ❌ | **MIT** | +| Capability | DB Pro (Free) | Beekeeper (Free) | Gridline | +| :--- | :---: | :---: | :---: | +| Open tabs | 3 | Unlimited | **Unlimited** | +| Saved connections | 2 | Unlimited | **Unlimited** | +| Saved queries | 5 | Unlimited | **Unlimited** | +| Data export (CSV, JSON, SQL) | ❌ (paid only) | Basic only | **JSON, CSV, SQL, Markdown** | +| Data import (CSV, JSON) | ❌ (paid only) | ✅ | 🟡 *Upcoming* | +| pg_dump / pg_restore GUI | ❌ | ❌ (paid only) | **First-class UI** | +| DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** | +| Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** | +| ER diagram / schema visualizer | ❌ (planned) | ❌ (paid only) | **✅ Interactive React Flow** | +| Inline cell editing | ✅ | ✅ | 🟡 *Upcoming* | +| SSH tunneling | 🟡 (likely paid) | ✅ | 🟡 *Config UI done* | +| OS credential vault | ✅ | ✅ | **Keychain / Secret Service** | +| Workspace / folder hierarchy | ❌ | ❌ | **Multi-level tree + tags** | +| Changes queue (stage & commit) | ❌ | ❌ | **✅ Queue → Commit All** | +| Query history | ✅ (auto-saved) | ✅ | 🟡 *Upcoming* | +| AI assistant | ✅ (BYO key) | ❌ (paid only) | ❌ | +| Open source | ❌ | ✅ (GPLv3) | **✅ (MIT)** | +| Desktop shell | Native webview | Electron (~250MB) | **Tauri 2.0 (~40MB)** | + +> 🟡 = In progress or planned. **Bold** = Gridline's strongest differentiators. --- @@ -39,12 +50,13 @@ Full tree-view navigation of all native PostgreSQL schema objects: - **Triggers & Rules** — event bindings with inline definition inspection, color-coded enabled/disabled status - **Sequences & Enums** — current values, increments, cycle flags; enum labels in bordered list view - **Extensions** — installed extensions with version, schema, and comment +- **Schema Visualizer (ER Diagram)** — interactive React Flow graph with dagre auto-layout, crow's foot notation (1:1, 1:N, N:M), color-coded relationships, schema selector, zoom controls, collapsible columns (PK/FK/unique-only), cross-schema FK support for PostgreSQL + SQLite ### SQL Editor & Query Workbench - **Multi-Tab Workspace** — unlimited named tabs, close with Cmd/Ctrl+W, session persistence across restarts - **Changes Queue** — queue INSERT/UPDATE/DELETE changes; preview before committing all - **Smart Default Sort** — auto-detects `updated_at`, `created_at`, `_id` columns for logical initial sorting -- *(Monaco Editor with SQL autocomplete, query history, and saved snippets coming soon)* +- *(Monaco Editor with SQL autocomplete, query history, and saved snippets — upcoming)* ### Data Grid & Schema Browser - **Virtualized Grid** — row-level virtualization via `@tanstack/react-virtual` handles 100k+ rows @@ -54,7 +66,7 @@ Full tree-view navigation of all native PostgreSQL schema objects: - **FK Preview** — click a foreign key cell to preview the referenced row - **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button - **Auto-Refresh** — configurable interval timer -- *(Inline cell editing, visual filter builder, and data import coming soon)* +- *(Inline cell editing, visual filter builder, and data import — upcoming)* ### PostgreSQL Administrative Tools - **Visual Backup** — `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar @@ -162,34 +174,27 @@ gridline/ ## Roadmap -1. **Phase 1 — Core Shell & Storage** - - [x] Tauri 2.0 + React project scaffold - - [ ] SQLite persistence layer for workspaces, folders, connections, saved queries - - [ ] Workspace/folder tree UI +### ✅ Completed +- **Phase 1 — Core Shell** — Tauri 2.0 + React project, glassmorphic dark-first UI, Zustand state management, SQLite local persistence, OS keychain credentials +- **Phase 2 — Connection Management** — Rust connection pool (`sqlx`/`tokio-postgres`), PostgreSQL + SQLite browse/query, URI parser with auto-population, SSH/SSL config UI, connection testing for all DB types +- **Phase 3 — Schema Explorer** — Full PostgreSQL `pg_catalog`/`information_schema` introspection, object tree (Tables, Views, Functions, Triggers, Enums, Sequences, Extensions), per-type detail views, FK preview popover, JSON/JSONB viewer +- **Phase 4 — Data Grid & Filters** — Virtualized grid (`@tanstack/react-virtual`, 100k+ rows), server-side sorting/filtering, column show/hide, column resize, export (JSON/CSV/SQL/Markdown), auto-refresh, pagination +- **Phase 5 — Admin Tools** — `pg_dump`/`pg_restore` UI wrappers with real-time progress, DB-to-DB sync, backup/restore format selectors +- **Phase 6 — Schema Visualizer** — Interactive ER diagram with React Flow + dagre, crow's foot notation, schema selector, legend, collapsible column views, PostgreSQL + SQLite support +- **Home Screen & Organization** — Connection cards by folder, folders CRUD, tags CRUD with colors, global search (Cmd+K), import/export connections (JSON), bulk select/delete, DB type filter, demo SQLite database -2. **Phase 2 — Connection Management** - - [ ] Rust connection pool manager (`sqlx` / `tokio-postgres`) - - [ ] URI parser with auto-population - - [ ] OS Keychain credential storage +### 🟡 In Progress / Upcoming +- **SQL Editor** — Monaco Editor integration with schema-aware SQL autocomplete, query history, saved queries +- **SSH/SSL Runtime** — SSH tunnel via `ssh2` crate, SSL/TLS config passed to `sqlx`/`tokio-postgres` +- **Inline Cell Editing** — Edit cells directly in the data grid +- **Data Import** — CSV, JSON import with column mapping -3. **Phase 3 — Schema Explorer** - - [x] PostgreSQL `pg_catalog` / `information_schema` introspection - - [x] Full object tree (Tables, Views, Functions, Triggers, Enums, Sequences, Extensions) - - [x] Per-type detail views with source code, arguments, metadata - -4. **Phase 4 — Query Workbench** - - [ ] Monaco Editor integration with SQL autocomplete - - [x] Virtualized data grid for query results - - [ ] Query history & saved snippets - -5. **Phase 5 — Admin Tools** - - [x] `pg_dump` / `pg_restore` UI wrappers - - [x] DB-to-DB schema & data sync - -6. **Phase 6 — Multi-Database Support** - - [ ] MySQL driver - - [ ] SQLite driver - - [ ] Redis support +### 🔮 Future +- **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL +- **Query Workbench** — Multiple result sets, query favorites/pinning, visual query builder +- **Deeper PostgreSQL** — Indexes, constraints, materialized views, stored procedure view, user/role management +- **Collaboration** — Team workspaces, shared connections, query sharing +- **Notebook Reports** — SQL-backed markdown reports with embedded results --- diff --git a/bun.lock b/bun.lock index 326b5d1..335b452 100644 --- a/bun.lock +++ b/bun.lock @@ -15,10 +15,13 @@ "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-opener": "^2", + "@xyflow/react": "^12.11.2", + "dagre": "^0.8.5", "lucide-react": "^1.26.0", "motion": "^12.42.2", "react": "^19.1.0", "react-dom": "^19.1.0", + "simple-icons": "^16.27.1", "tauri-plugin-keyring-store-api": "^0.2.0", "zustand": "^5.0.14", }, @@ -27,6 +30,7 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/dagre": "^0.7.54", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", @@ -316,6 +320,20 @@ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/dagre": ["@types/dagre@0.7.54", "", {}, "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ=="], + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], @@ -340,6 +358,10 @@ "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + "@xyflow/react": ["@xyflow/react@12.11.2", "", { "dependencies": { "@xyflow/system": "0.0.79", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA=="], + + "@xyflow/system": ["@xyflow/system@0.0.79", "", { "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" } }, "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -358,6 +380,8 @@ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], @@ -366,6 +390,26 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre": ["dagre@0.8.5", "", { "dependencies": { "graphlib": "^2.1.8", "lodash": "^4.17.15" } }, "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -404,6 +448,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphlib": ["graphlib@2.1.8", "", { "dependencies": { "lodash": "^4.17.15" } }, "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], @@ -444,6 +490,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "lucide-react": ["lucide-react@1.26.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg=="], @@ -506,6 +554,8 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "simple-icons": ["simple-icons@16.27.1", "", {}, "sha512-slZF8iKxkv7Lb9SF3L1AcIl5iYLNvDrKKm8yV69cG66XvxJ12SX+bYTgxRwCY4bXSmklGXoXaKEyVmEypOCeqw=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -546,6 +596,8 @@ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], @@ -588,6 +640,8 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "tauri-plugin-keyring-store-api/@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], } } diff --git a/package.json b/package.json index a5d90f4..ed0cae3 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,13 @@ "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-opener": "^2", + "@xyflow/react": "^12.11.2", + "dagre": "^0.8.5", "lucide-react": "^1.26.0", "motion": "^12.42.2", "react": "^19.1.0", "react-dom": "^19.1.0", + "simple-icons": "^16.27.1", "tauri-plugin-keyring-store-api": "^0.2.0", "zustand": "^5.0.14" }, @@ -36,6 +39,7 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/dagre": "^0.7.54", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", diff --git a/src-tauri/src/commands/db_viewer.rs b/src-tauri/src/commands/db_viewer.rs index 1996003..e3ec2f9 100644 --- a/src-tauri/src/commands/db_viewer.rs +++ b/src-tauri/src/commands/db_viewer.rs @@ -23,7 +23,7 @@ use tokio_postgres::types::ToSql; /// tokio-postgres's `Display` only prints "db error", so we walk the /// `std::error::Error::source()` chain and also use `Debug` to surface the /// real message (e.g. "password authentication failed for user \"x\""). -fn pg_error_message(err: &tokio_postgres::Error) -> String { +pub(crate) fn pg_error_message(err: &tokio_postgres::Error) -> String { // Prefer the Debug representation, which includes severity + message + code. let raw = format!("{:?}", err); // Redact postgres URL fragments and password=... sequences. @@ -486,7 +486,7 @@ fn sqlite_value_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value { } } -fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value { +pub(crate) fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_json::Value { // Integer types if let Ok(Some(v)) = row.try_get::<_, Option>(i) { return serde_json::json!(v); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 53e144f..ada8af3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,4 +8,5 @@ pub mod test_connection; pub mod ssh; pub mod keychain; pub mod demo; -pub mod backup; \ No newline at end of file +pub mod backup; +pub mod schema_graph; \ No newline at end of file diff --git a/src-tauri/src/commands/schema_graph.rs b/src-tauri/src/commands/schema_graph.rs new file mode 100644 index 0000000..13724b0 --- /dev/null +++ b/src-tauri/src/commands/schema_graph.rs @@ -0,0 +1,459 @@ +#[allow(unused_imports)] +use crate::db::pool::DbHandle; +use crate::models::db_viewer::{SchemaGraph, TableNode, GraphColumn, Relationship}; +use std::collections::HashMap; +use tauri::State; + +/// Validate a schema name for safe use in parameterized queries. +/// Rejects empty strings and names containing SQL metacharacters. +pub fn validate_schema_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("Schema name cannot be empty".into()); + } + if name.contains(';') + || name.contains("--") + || name.contains("/*") + || name.contains('\'') + || name.contains('"') + || name.contains('\\') + { + return Err(format!("Invalid schema name: {}", name)); + } + Ok(()) +} + +/// Build a parameterized query that fetches all tables, columns, and +/// PK/FK/UNIQUE metadata for a PostgreSQL schema in a single round-trip. +pub fn build_pg_schema_graph_query(_schema: &str) -> String { + // Uses pg_catalog directly instead of information_schema views. + // information_schema views are extremely slow on some servers (remote/ + // cloud) because they scan all databases' catalogs. pg_catalog with + // LATERAL joins is typically 500x+ faster (~200ms vs 120s for 55 tables). + r#"SELECT + c.relname AS table_name, + n.nspname AS table_schema, + CASE WHEN c.relkind = 'v' THEN 'VIEW' ELSE 'BASE TABLE' END AS table_type, + a.attname AS column_name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, + NOT a.attnotnull AS is_nullable, + a.attnum AS ordinal_position, + COALESCE(pk.is_pk, false) AS is_pk, + COALESCE(fk.is_fk, false) AS is_fk, + fk.foreign_table_schema, + fk.foreign_table_name, + fk.foreign_column_name, + COALESCE(uq.is_unique, false) AS is_unique +FROM pg_catalog.pg_class c +JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid +JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid +LEFT JOIN LATERAL ( + SELECT true AS is_pk + FROM pg_catalog.pg_constraint pk2 + WHERE pk2.conrelid = c.oid AND pk2.contype = 'p' AND a.attnum = ANY(pk2.conkey) + LIMIT 1 +) pk ON true +LEFT JOIN LATERAL ( + SELECT true AS is_fk, + ref_n.nspname AS foreign_table_schema, + ref_c.relname AS foreign_table_name, + ref_a.attname AS foreign_column_name + FROM pg_catalog.pg_constraint fk2 + JOIN pg_catalog.pg_class ref_c ON fk2.confrelid = ref_c.oid + JOIN pg_catalog.pg_namespace ref_n ON ref_c.relnamespace = ref_n.oid + JOIN pg_catalog.pg_attribute ref_a + ON ref_a.attrelid = ref_c.oid AND ref_a.attnum = ANY(fk2.confkey) + WHERE fk2.conrelid = c.oid AND fk2.contype = 'f' + AND a.attnum = ANY(fk2.conkey) + LIMIT 1 +) fk ON true +LEFT JOIN LATERAL ( + SELECT true AS is_unique + FROM pg_catalog.pg_constraint uq2 + WHERE uq2.conrelid = c.oid AND uq2.contype = 'u' AND a.attnum = ANY(uq2.conkey) + LIMIT 1 +) uq ON true +WHERE n.nspname = $1 + AND c.relkind IN ('r', 'v', 'p') + AND a.attnum > 0 + AND NOT a.attisdropped +ORDER BY c.relname, a.attnum"#.to_string() +} + +/// Infer relationship cardinality from constraint metadata. +/// +/// - `is_pk`: the FK column is also part of the primary key +/// - `is_unique`: the FK column has a UNIQUE constraint +/// - `is_nullable`: the FK column allows NULL values +/// - `is_join_table_fk`: this FK belongs to a join table +pub fn infer_cardinality( + is_pk: bool, + is_unique: bool, + is_nullable: bool, + is_join_table_fk: bool, +) -> String { + if is_join_table_fk { + return "N:M".into(); + } + let one_side = is_pk || is_unique; + match (one_side, is_nullable) { + (true, false) => "1:1".into(), + (true, true) => "0..1:0..1".into(), + (false, false) => "1:N".into(), + (false, true) => "0..N".into(), + } +} + +pub fn parse_pg_schema_rows( + rows: &[Vec], +) -> (Vec, Vec) { + let mut table_map: HashMap<(String, String), (String, Vec)> = HashMap::new(); + let mut relationships: Vec = Vec::new(); + + for row in rows { + let table_name = row[0].as_str().unwrap_or_default().to_string(); + let table_schema = row[1].as_str().unwrap_or_default().to_string(); + let table_type = row[2].as_str().unwrap_or_default().to_string(); + let col_name = row[3].as_str().unwrap_or_default().to_string(); + let data_type = row[4].as_str().unwrap_or_default().to_string(); + let is_nullable = row[5].as_bool().unwrap_or(false); + let is_pk = row[7].as_bool().unwrap_or(false); + let is_fk = row[8].as_bool().unwrap_or(false); + let fk_schema = row[9].as_str().map(String::from); + let fk_table = row[10].as_str().map(String::from); + let fk_column = row[11].as_str().map(String::from); + let is_unique = row[12].as_bool().unwrap_or(false); + + let fk_ref = if is_fk { + match (&fk_schema, &fk_table, &fk_column) { + (Some(s), Some(t), Some(c)) => Some((s.clone(), t.clone(), c.clone())), + _ => None, + } + } else { + None + }; + + let col = GraphColumn { + name: col_name.clone(), + data_type, + is_pk, + is_fk, + is_unique: is_unique || is_pk, + is_nullable, + fk_ref: fk_ref.clone(), + }; + + let key = (table_schema.clone(), table_name.clone()); + table_map + .entry(key) + .or_insert_with(|| (table_type.clone(), Vec::new())) + .1 + .push(col); + + if let Some((ref_schema, ref_table, ref_column)) = fk_ref { + relationships.push(Relationship { + source_schema: table_schema.clone(), + source_table: table_name.clone(), + source_column: col_name, + target_schema: ref_schema, + target_table: ref_table, + target_column: ref_column, + cardinality: String::new(), + }); + } + } + + // Detect N:M join tables: tables where ALL PK columns are also FK columns + let join_table_keys: Vec<(String, String)> = table_map + .iter() + .filter(|(_, (_, cols))| { + let pk_cols: Vec<&GraphColumn> = cols.iter().filter(|c| c.is_pk).collect(); + !pk_cols.is_empty() && pk_cols.iter().all(|c| c.is_fk) + }) + .map(|(k, _)| k.clone()) + .collect(); + + // Assign cardinality to each relationship + for rel in &mut relationships { + let source_key = (rel.source_schema.clone(), rel.source_table.clone()); + let is_join = join_table_keys.contains(&source_key); + let (is_pk_or_unique, is_nullable) = table_map + .get(&source_key) + .and_then(|(_, cols)| cols.iter().find(|c| c.name == rel.source_column)) + .map(|c| (c.is_pk || c.is_unique, c.is_nullable)) + .unwrap_or((false, false)); + rel.cardinality = infer_cardinality(is_pk_or_unique, is_pk_or_unique, is_nullable, is_join); + } + + let mut tables: Vec = table_map + .into_iter() + .map(|((schema, name), (table_type, columns))| TableNode { + name, + schema, + table_type, + columns, + }) + .collect(); + tables.sort_by(|a, b| a.name.cmp(&b.name)); + + (tables, relationships) +} + +fn build_sqlite_schema_graph( + conn: &rusqlite::Connection, + schema: &str, +) -> Result { + if schema != "main" { + return Err(format!("SQLite only supports schema 'main', got: {}", schema)); + } + + let mut stmt = conn + .prepare("SELECT name, type FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name") + .map_err(|e| e.to_string())?; + let table_rows: Vec<(String, String)> = stmt + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + let mut tables: Vec = Vec::new(); + let mut relationships: Vec = Vec::new(); + + for (table_name, table_type) in &table_rows { + let pragma_sql = format!("PRAGMA table_info('{}')", table_name); + let mut ps = conn.prepare(&pragma_sql).map_err(|e| e.to_string())?; + let col_meta: Vec<(String, String, bool, bool)> = ps + .query_map([], |row| Ok(( + row.get::<_, String>(1)?, row.get::<_, String>(2)?, + row.get::<_, bool>(3)?, row.get::<_, bool>(5)?, + ))) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + let fk_sql = format!("PRAGMA foreign_key_list('{}')", table_name); + let fk_cols: HashMap = if let Ok(mut fs) = conn.prepare(&fk_sql) { + fs.query_map([], |row| Ok(( + row.get::<_, String>(3)?, row.get::<_, String>(2)?, row.get::<_, String>(4)?, + ))) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .map(|(col, ref_t, ref_c)| (col, (ref_t, ref_c))) + .collect() + } else { + HashMap::new() + }; + + let columns: Vec = col_meta.iter().map(|(name, dtype, _nn, is_pk)| { + let fk = fk_cols.get(name); + let is_fk = fk.is_some(); + let fk_ref = fk.map(|(t, c)| ("main".into(), t.clone(), c.clone())); + if let Some((ref_t, ref_c)) = fk { + relationships.push(Relationship { + source_schema: "main".into(), source_table: table_name.clone(), + source_column: name.clone(), + target_schema: "main".into(), target_table: ref_t.clone(), + target_column: ref_c.clone(), + cardinality: infer_cardinality(*is_pk, false, !_nn, false), + }); + } + GraphColumn { + name: name.clone(), + data_type: if dtype.is_empty() { "TEXT".into() } else { dtype.clone() }, + is_pk: *is_pk, is_fk, is_unique: *is_pk, + is_nullable: !_nn, + fk_ref: fk_ref.map(|(s, t, c)| (s, t, c)), + } + }).collect(); + + tables.push(TableNode { + name: table_name.clone(), schema: "main".into(), + table_type: table_type.to_uppercase(), columns, + }); + } + + Ok(SchemaGraph { tables, relationships }) +} + +#[tauri::command] +pub async fn get_schema_graph( + connection_id: String, + schema: Option, + state: State<'_, crate::AppState>, +) -> Result { + let schema = schema.unwrap_or_else(|| "public".to_string()); + validate_schema_name(&schema)?; + + let mut pm = state.pool_manager.lock().await; + match pm.get(&connection_id) { + Some(DbHandle::Postgresql(client, _)) => { + let query = build_pg_schema_graph_query(&schema); + let rows = client + .query(&query, &[&schema]) + .await + .map_err(|e| crate::commands::db_viewer::pg_error_message(&e))?; + + let json_rows: Vec> = rows + .iter() + .map(|row| { + (0..row.len()) + .map(|i| crate::commands::db_viewer::pg_value_to_json(row, i)) + .collect() + }) + .collect(); + + let (tables, relationships) = parse_pg_schema_rows(&json_rows); + Ok(SchemaGraph { tables, relationships }) + } + Some(DbHandle::Sqlite(conn)) => build_sqlite_schema_graph(conn, &schema), + None => Err("Connection not found".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_schema_name_rejects_empty() { + assert!(validate_schema_name("").is_err()); + } + + #[test] + fn validate_schema_name_rejects_semicolon() { + assert!(validate_schema_name("public; DROP TABLE users").is_err()); + } + + #[test] + fn validate_schema_name_rejects_sql_comment() { + assert!(validate_schema_name("public--comment").is_err()); + assert!(validate_schema_name("public/*comment*/").is_err()); + } + + #[test] + fn validate_schema_name_rejects_quotes() { + assert!(validate_schema_name("pub'lic").is_err()); + assert!(validate_schema_name("pub\"lic").is_err()); + } + + #[test] + fn validate_schema_name_rejects_backslash() { + assert!(validate_schema_name("public\\schema").is_err()); + } + + #[test] + fn validate_schema_name_accepts_valid_names() { + assert!(validate_schema_name("public").is_ok()); + assert!(validate_schema_name("my_schema").is_ok()); + assert!(validate_schema_name("schema123").is_ok()); + assert!(validate_schema_name("auth").is_ok()); + } + + #[test] + fn build_pg_schema_graph_query_is_parameterized() { + let sql = build_pg_schema_graph_query("public"); + // Must use $1 for schema parameter (parameterized) + assert!(sql.contains("$1"), "query should use $1 placeholder; got: {}", sql); + // Must not interpolate schema name directly in a potentially unsafe way + assert!(!sql.contains("'public'"), "query should not use literal 'public'"); + } + + #[test] + fn build_pg_schema_graph_query_queries_columns() { + let sql = build_pg_schema_graph_query("myschema"); + assert!(sql.contains("pg_catalog.pg_class"), "should query pg_class"); + assert!(sql.contains("pg_catalog.pg_attribute"), "should query pg_attribute"); + assert!(sql.contains("pg_catalog.pg_constraint"), "should include constraint info"); + } + + #[test] + fn infer_cardinality_one_to_one_pk() { + assert_eq!(infer_cardinality(true, false, false, false), "1:1"); + } + + #[test] + fn infer_cardinality_zero_or_one() { + // UNIQUE + nullable → 0..1:0..1 + assert_eq!(infer_cardinality(false, true, true, false), "0..1:0..1"); + } + + #[test] + fn infer_cardinality_one_to_many() { + assert_eq!(infer_cardinality(false, false, false, false), "1:N"); + } + + #[test] + fn infer_cardinality_zero_or_many() { + // not PK, not UNIQUE, nullable → 0..N + assert_eq!(infer_cardinality(false, false, true, false), "0..N"); + } + + #[test] + fn infer_cardinality_many_to_many() { + assert_eq!(infer_cardinality(false, false, false, true), "N:M"); + } + + #[test] + fn parse_pg_schema_rows_builds_correct_graph() { + let rows: Vec> = vec![ + // users.id (PK) + vec![ + serde_json::json!("users"), serde_json::json!("public"), serde_json::json!("BASE TABLE"), + serde_json::json!("id"), serde_json::json!("integer"), serde_json::json!("NO"), + serde_json::json!(1), serde_json::json!(true), serde_json::json!(false), + serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null, + serde_json::json!(true), + ], + // users.email (non-key) + vec![ + serde_json::json!("users"), serde_json::json!("public"), serde_json::json!("BASE TABLE"), + serde_json::json!("email"), serde_json::json!("text"), serde_json::json!("NO"), + serde_json::json!(2), serde_json::json!(false), serde_json::json!(false), + serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null, + serde_json::json!(true), + ], + // orders.id (PK) + vec![ + serde_json::json!("orders"), serde_json::json!("public"), serde_json::json!("BASE TABLE"), + serde_json::json!("id"), serde_json::json!("integer"), serde_json::json!("NO"), + serde_json::json!(1), serde_json::json!(true), serde_json::json!(false), + serde_json::Value::Null, serde_json::Value::Null, serde_json::Value::Null, + serde_json::json!(true), + ], + // orders.user_id (FK → users.id) + vec![ + serde_json::json!("orders"), serde_json::json!("public"), serde_json::json!("BASE TABLE"), + serde_json::json!("user_id"), serde_json::json!("integer"), serde_json::json!("NO"), + serde_json::json!(2), serde_json::json!(false), serde_json::json!(true), + serde_json::json!("public"), serde_json::json!("users"), serde_json::json!("id"), + serde_json::json!(false), + ], + ]; + + let (tables, relationships) = parse_pg_schema_rows(&rows); + + assert_eq!(tables.len(), 2, "should have 2 tables"); + assert_eq!(relationships.len(), 1, "should have 1 relationship"); + + let users = tables.iter().find(|t| t.name == "users").unwrap(); + assert_eq!(users.columns.len(), 2); + assert!(users.columns[0].is_pk); + + let orders = tables.iter().find(|t| t.name == "orders").unwrap(); + assert_eq!(orders.columns.len(), 2); + + let rel = &relationships[0]; + assert_eq!(rel.source_table, "orders"); + assert_eq!(rel.target_table, "users"); + assert_eq!(rel.source_column, "user_id"); + assert_eq!(rel.target_column, "id"); + assert_eq!(rel.cardinality, "1:N"); + } + + #[test] + fn parse_pg_schema_rows_empty_yields_empty_graph() { + let rows: Vec> = vec![]; + let (tables, relationships) = parse_pg_schema_rows(&rows); + assert!(tables.is_empty()); + assert!(relationships.is_empty()); + } +} \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aeaa8bc..a4e7971 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,7 +19,7 @@ pub struct AppState { pub ssh_manager: StdMutex, } -use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup}; +use commands::{connections, db_viewer, folders, tags, settings, import_export, keychain, demo, backup, schema_graph}; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] @@ -94,6 +94,7 @@ pub fn run() { backup::pg_dump, backup::pg_restore, backup::db_sync, + schema_graph::get_schema_graph, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models/db_viewer.rs b/src-tauri/src/models/db_viewer.rs index 1a14f7c..e6bdc0c 100644 --- a/src-tauri/src/models/db_viewer.rs +++ b/src-tauri/src/models/db_viewer.rs @@ -147,6 +147,50 @@ impl Change { } } +/// Complete schema graph for the ER diagram visualizer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchemaGraph { + pub tables: Vec, + pub relationships: Vec, +} + +/// A table node in the schema graph, including all columns. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TableNode { + pub name: String, + pub schema: String, + pub table_type: String, + pub columns: Vec, +} + +/// Column metadata for schema graph visualization. +/// +/// Includes PK/FK/UNIQUE flags and an optional foreign-key reference +/// (referenced_schema, referenced_table, referenced_column). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphColumn { + pub name: String, + pub data_type: String, + pub is_pk: bool, + pub is_fk: bool, + pub is_unique: bool, + pub is_nullable: bool, + pub fk_ref: Option<(String, String, String)>, +} + +/// A foreign-key relationship between two tables. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Relationship { + pub source_schema: String, + pub source_table: String, + pub source_column: String, + pub target_schema: String, + pub target_table: String, + pub target_column: String, + /// Inferred cardinality: "1:1", "1:N", or "N:M" + pub cardinality: String, +} + #[cfg(test)] mod tests { use super::*; @@ -335,4 +379,102 @@ mod tests { let json = serde_json::to_string(&info).unwrap(); assert!(json.contains("pg_stat_statements")); } + + #[test] + fn schema_graph_serialization() { + let graph = SchemaGraph { + tables: vec![TableNode { + name: "users".into(), + schema: "public".into(), + table_type: "TABLE".into(), + columns: vec![ + GraphColumn { + name: "id".into(), + data_type: "integer".into(), + is_pk: true, + is_fk: false, + is_unique: true, + is_nullable: false, + fk_ref: None, + }, + GraphColumn { + name: "email".into(), + data_type: "text".into(), + is_pk: false, + is_fk: false, + is_unique: true, + is_nullable: false, + fk_ref: None, + }, + ], + }], + relationships: vec![Relationship { + source_schema: "public".into(), + source_table: "orders".into(), + source_column: "user_id".into(), + target_schema: "public".into(), + target_table: "users".into(), + target_column: "id".into(), + cardinality: "1:N".into(), + }], + }; + + let json = serde_json::to_string(&graph).unwrap(); + assert!(json.contains("users"), "should contain table name"); + assert!(json.contains("orders"), "should contain relationship source table"); + assert!(json.contains("1:N"), "should contain cardinality"); + assert!(json.contains("is_pk"), "should contain is_pk field"); + assert!(json.contains("is_fk"), "should contain is_fk field"); + assert!(json.contains("is_unique"), "should contain is_unique field"); + + // Round-trip deserialization + let parsed: SchemaGraph = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.tables.len(), 1); + assert_eq!(parsed.tables[0].columns.len(), 2); + assert_eq!(parsed.relationships.len(), 1); + assert_eq!(parsed.relationships[0].cardinality, "1:N"); + } + + #[test] + fn schema_graph_empty_is_valid() { + let graph = SchemaGraph { + tables: vec![], + relationships: vec![], + }; + let json = serde_json::to_string(&graph).unwrap(); + let parsed: SchemaGraph = serde_json::from_str(&json).unwrap(); + assert!(parsed.tables.is_empty()); + assert!(parsed.relationships.is_empty()); + } + + #[test] + fn graph_column_fk_ref_serialization() { + // fk_ref = None + let col_none = GraphColumn { + name: "name".into(), + data_type: "text".into(), + is_pk: false, + is_fk: false, + is_unique: false, + is_nullable: false, + fk_ref: None, + }; + let json = serde_json::to_string(&col_none).unwrap(); + assert!(json.contains("null"), "fk_ref=None should serialize as null"); + + // fk_ref = Some(...) + let col_some = GraphColumn { + name: "user_id".into(), + data_type: "integer".into(), + is_pk: false, + is_fk: true, + is_unique: false, + is_nullable: false, + fk_ref: Some(("public".into(), "users".into(), "id".into())), + }; + let json = serde_json::to_string(&col_some).unwrap(); + assert!(json.contains("public"), "should contain referenced schema"); + assert!(json.contains("users"), "should contain referenced table"); + assert!(json.contains("id"), "should contain referenced column"); + } } \ No newline at end of file diff --git a/src/components/connections/ConnectionCard.tsx b/src/components/connections/ConnectionCard.tsx index 462a72c..f70d476 100644 --- a/src/components/connections/ConnectionCard.tsx +++ b/src/components/connections/ConnectionCard.tsx @@ -1,6 +1,6 @@ import { memo } from "react"; import type { Connection, Tag } from "../../lib/types"; -import { DB_ICONS, DB_LABELS } from "../../lib/dbIcons"; +import { DbIcon, DB_LABELS } from "../../lib/dbIcons"; import { ENV_LABELS, ENV_COLORS } from "../../lib/environment"; import { TagBadge } from "../tags/TagBadge"; import { Check, GripVertical } from "lucide-react"; @@ -75,8 +75,8 @@ function ConnectionCardBase({
-
- {DB_ICONS[connection.db_type] ?? "❓"} +
+
diff --git a/src/components/connections/ConnectionGrid.tsx b/src/components/connections/ConnectionGrid.tsx index a9b88bb..3da58d3 100644 --- a/src/components/connections/ConnectionGrid.tsx +++ b/src/components/connections/ConnectionGrid.tsx @@ -1,10 +1,12 @@ import type { Connection, Folder, Tag } from "../../lib/types"; +import { useMemo } from "react"; import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react"; import { useDroppable } from "@dnd-kit/core"; import { ConnectionCard } from "./ConnectionCard"; import { FolderBreadcrumb } from "../folders/FolderBreadcrumb"; -import { getChildFolders } from "../../lib/utils"; +import { getChildFolders, getDescendantFolderIds } from "../../lib/utils"; import { useUiStore } from "../../stores/uiStore"; +import { useConnectionStore } from "../../stores/connectionStore"; import { TagBadge } from "../tags/TagBadge"; interface DroppableFolderCardProps { @@ -136,7 +138,14 @@ export function ConnectionGrid({ const directConnections = connections.filter( (c) => c.folder_id === currentFolderId, ); - const hasItems = visibleFolders.length > 0 || directConnections.length > 0; + const allStoreConnections = useConnectionStore((s) => s.connections); + const allStoreFolders = useConnectionStore((s) => s.folders); + // Check if any direct connections OR any subfolder has connections anywhere below + const hasItems = visibleFolders.length > 0 || directConnections.length > 0 || + (currentFolderId && allStoreConnections.some((c) => { + const allowed = new Set(getDescendantFolderIds(allStoreFolders, currentFolderId)); + return c.folder_id !== null && allowed.has(c.folder_id); + })); const isSelecting = selectedItemIds.length > 0; const activeFolder = currentFolderId ? (folders.find((f) => f.id === currentFolderId) ?? null) @@ -199,8 +208,10 @@ export function ConnectionGrid({ > {visibleFolders.map((f) => { const isSelected = selectedItemIds.includes(f.id); - const count = directConnections.filter( - (c) => c.folder_id === f.id, + // Count all connections in this subfolder (including nested descendants) + const subIds = new Set(getDescendantFolderIds(folders, f.id)); + const count = allStoreConnections.filter( + (c) => c.folder_id !== null && subIds.has(c.folder_id), ).length; const subfolderCount = getChildFolders( folders, diff --git a/src/components/db-viewer/CrowsFootEdge.tsx b/src/components/db-viewer/CrowsFootEdge.tsx new file mode 100644 index 0000000..45b3fc5 --- /dev/null +++ b/src/components/db-viewer/CrowsFootEdge.tsx @@ -0,0 +1,62 @@ +import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react"; + +const C = "#3b82f6"; +const S = 10; +const G = 4; + +export function CrowsFootEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + style, +}: EdgeProps) { + const [edgePath] = getSmoothStepPath({ + sourceX, sourceY, sourcePosition, + targetX, targetY, targetPosition, + borderRadius: 8, + }); + + const sm = (data as any)?.startMarker as string; + const em = (data as any)?.endMarker as string; + // Use ORIGINAL layout direction stored in edge data — never re-compute. + // Prevents symbols from flipping when user drags tables around. + const origRight = (data as any)?.origRight as boolean | undefined; + const right = origRight !== undefined ? origRight : targetX < sourceX ? false : true; + const sOff = right ? 1 : -1; + const tOff = right ? -1 : 1; + const sDir = right ? 1 : -1; + const tDir = right ? -1 : 1; + + const edgeColor = (style as any)?.stroke as string || C; + + return ( + + + {sm && } + {em && } + + ); +} + +function Mark({ type, cx, cy, dir, color }: { type: string; cx: number; cy: number; dir: number; color: string }) { + if (type === "one") { + return ; + } + if (type === "many") { + const sp = 6; + const tx = cx + dir * S; + return ( + + + + + + ); + } + return null; +} \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerScreen.tsx b/src/components/db-viewer/DbViewerScreen.tsx index 2f9665f..18e3a2b 100644 --- a/src/components/db-viewer/DbViewerScreen.tsx +++ b/src/components/db-viewer/DbViewerScreen.tsx @@ -18,6 +18,7 @@ import { ConnectionDropBanner } from "./ConnectionDropBanner"; import { BackupPage } from "./BackupPage"; import { RestorePage } from "./RestorePage"; import { SyncPage } from "./SyncPage"; +import { SchemaVisualizerPage } from "./SchemaVisualizerPage"; import * as cmd from "../../lib/commands"; export interface DbViewerScreenProps { @@ -579,6 +580,13 @@ export function DbViewerScreen({ ) : currentView === "sync" ? ( + ) : currentView === "schema-visualizer" ? ( + { + useDbViewerStore.getState().setCurrentSchema(newSchema); + }} + /> ) : null} {currentView === "db-viewer" && }
diff --git a/src/components/db-viewer/DbViewerSidebar.test.tsx b/src/components/db-viewer/DbViewerSidebar.test.tsx index 3c2af31..965f59b 100644 --- a/src/components/db-viewer/DbViewerSidebar.test.tsx +++ b/src/components/db-viewer/DbViewerSidebar.test.tsx @@ -38,4 +38,16 @@ describe("DbViewerSidebar", () => { await user.click(screen.getByLabelText(/settings/i)); expect(onNavigate).toHaveBeenCalledWith("settings"); }); + + it("renders Schema Visualizer nav item (not coming soon)", () => { + render( + + {}} /> + + ); + // Should find the label WITHOUT "coming soon" + const btn = screen.getByLabelText(/schema visualizer/i); + expect(btn).toBeInTheDocument(); + expect(btn).not.toBeDisabled(); + }); }); \ No newline at end of file diff --git a/src/components/db-viewer/DbViewerSidebar.tsx b/src/components/db-viewer/DbViewerSidebar.tsx index 2434957..787c0fe 100644 --- a/src/components/db-viewer/DbViewerSidebar.tsx +++ b/src/components/db-viewer/DbViewerSidebar.tsx @@ -34,9 +34,8 @@ export function DbViewerSidebar({ { id: "db-viewer", label: "Explorer", icon: }, { id: "schema-visualizer", - label: "Schema Visualizer coming soon", + label: "Schema Visualizer", icon: , - stub: true, }, { id: "functions", diff --git a/src/components/db-viewer/SchemaVisualizerNode.test.tsx b/src/components/db-viewer/SchemaVisualizerNode.test.tsx new file mode 100644 index 0000000..0367f91 --- /dev/null +++ b/src/components/db-viewer/SchemaVisualizerNode.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ReactFlowProvider } from "@xyflow/react"; +import { SchemaVisualizerNode } from "./SchemaVisualizerNode"; +import type { TableNode } from "../../lib/types"; + +// React Flow custom nodes must be wrapped in ReactFlowProvider +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const sampleTable: TableNode = { + name: "users", + schema: "public", + table_type: "TABLE", + columns: [ + { name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, fk_ref: null }, + { name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, fk_ref: null }, + { name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: true, fk_ref: null }, + ], +}; + +describe("SchemaVisualizerNode", () => { + it("renders table name in header", () => { + render( + , + { wrapper }, + ); + expect(screen.getByText("public.users")).toBeInTheDocument(); + }); + + it("renders all columns by default", () => { + render( + , + { wrapper }, + ); + expect(screen.getByText("id")).toBeInTheDocument(); + expect(screen.getByText("name")).toBeInTheDocument(); + expect(screen.getByText("email")).toBeInTheDocument(); + }); + + it("collapses non-key columns on chevron click", () => { + render( + , + { wrapper }, + ); + // Find collapse button + const collapseBtn = screen.getByRole("button", { name: /collapse/i }); + fireEvent.click(collapseBtn); + + // After collapse, non-key columns should be hidden + // name is non-key, should not be visible + expect(screen.queryByText(/^name$/)).not.toBeInTheDocument(); + // id and email (PK/UNIQUE) should still be visible + expect(screen.getByText(/^id$/)).toBeInTheDocument(); + expect(screen.getByText(/^email$/)).toBeInTheDocument(); + }); + + it("renders in dimmed style when isExternal is true", () => { + const { container } = render( + , + { wrapper }, + ); + const card = container.firstElementChild; + expect(card?.className).toContain("opacity-50"); + }); + + it("calls onExpandExternal when external node is clicked", () => { + const onExpand = vi.fn(); + render( + , + { wrapper }, + ); + const card = screen.getByText("public.users").closest("div"); + fireEvent.click(card!); + expect(onExpand).toHaveBeenCalledWith("public", "users"); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/SchemaVisualizerNode.tsx b/src/components/db-viewer/SchemaVisualizerNode.tsx new file mode 100644 index 0000000..897f2b9 --- /dev/null +++ b/src/components/db-viewer/SchemaVisualizerNode.tsx @@ -0,0 +1,124 @@ +import { memo, useState } from "react"; +import { Handle, Position } from "@xyflow/react"; +import { Table2, Eye, ChevronUp, ChevronDown, Key, ArrowRight } from "lucide-react"; +import type { TableNode as TableNodeType } from "../../lib/types"; +import { abbreviateType } from "../../lib/utils"; + +export interface SchemaVisualizerNodeData { + table: TableNodeType; + isExternal: boolean; + onExpandExternal?: (schema: string, table: string) => void; +} + +interface SchemaVisualizerNodeProps { + id: string; + data: SchemaVisualizerNodeData; + selected: boolean; +} + +export const SchemaVisualizerNode = memo(function SchemaVisualizerNode({ + data, + selected, +}: SchemaVisualizerNodeProps) { + const { table, isExternal, onExpandExternal } = data; + const [collapsed, setCollapsed] = useState(false); + const isView = table.table_type === "VIEW"; + + const visibleColumns = collapsed + ? table.columns.filter((c) => c.is_pk || c.is_fk || c.is_unique) + : table.columns; + + const handleClick = () => { + if (isExternal && onExpandExternal) { + onExpandExternal(table.schema, table.name); + } + }; + + const cardClass = [ + "rounded-none border bg-surface min-w-[220px] text-xs font-mono", + selected ? "border-accent shadow-lg shadow-accent/10" : "border-border", + isExternal ? "opacity-50 border-dashed cursor-pointer" : "", + ].join(" "); + + return ( +
+ {/* Header */} +
+
+ {isView ? ( + + ) : ( + + )} + + {table.schema}.{table.name} + +
+ {!isExternal && ( + + )} +
+ + {/* Column rows */} +
+ {visibleColumns.map((col) => ( +
+ {/* Left side: badges + name */} +
+ {col.is_pk && } + {col.is_fk && !col.is_pk && ( + + )} + {col.name} +
+ {/* Right side: type */} + + {abbreviateType(col.data_type)} + + + {/* FK source handle */} + {col.is_fk && ( + + )} + {/* PK target handle */} + {col.is_pk && ( + + )} +
+ ))} +
+ + {/* Show collapsed count */} + {collapsed && table.columns.length > visibleColumns.length && ( +
+ +{table.columns.length - visibleColumns.length} more columns +
+ )} +
+ ); +}); \ No newline at end of file diff --git a/src/components/db-viewer/SchemaVisualizerPage.test.tsx b/src/components/db-viewer/SchemaVisualizerPage.test.tsx new file mode 100644 index 0000000..6d68fcb --- /dev/null +++ b/src/components/db-viewer/SchemaVisualizerPage.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SchemaVisualizerPage } from "./SchemaVisualizerPage"; +import { useDbViewerStore } from "../../stores/dbViewerStore"; +import { TooltipProvider } from "../ui/Tooltip"; + +// Mock the Tauri invoke call +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue({ + tables: [], + relationships: [], + }), +})); + +// Mock the SchemaVisualizerNode to avoid React Flow complexity in tests +vi.mock("./SchemaVisualizerNode", () => ({ + SchemaVisualizerNode: () =>
Node
, +})); + +describe("SchemaVisualizerPage", () => { + beforeEach(() => { + useDbViewerStore.getState().reset(); + useDbViewerStore.setState({ + schemas: ["public", "auth"], + currentSchema: "public", + }); + }); + + it("renders the legend panel", () => { + render( + + {}} + /> + , + ); + expect(screen.getByText(/one-to-one/i)).toBeInTheDocument(); + expect(screen.getByText(/one-to-many/i)).toBeInTheDocument(); + expect(screen.getByText(/many-to-many/i)).toBeInTheDocument(); + }); + + it("shows loading state initially", () => { + render( + + {}} + /> + , + ); + expect(screen.getByText(/loading schema/i)).toBeInTheDocument(); + }); + + it("renders Reset Layout button", () => { + render( + + {}} + /> + , + ); + expect(screen.getByText(/reset layout/i)).toBeInTheDocument(); + }); + + it("shows error message when introspection fails", async () => { + const { invoke } = await import("@tauri-apps/api/core"); + (invoke as any).mockRejectedValueOnce(new Error("Connection lost")); + + render( + + {}} /> + , + ); + + const errorMsg = await screen.findByText(/failed to load schema/i); + expect(errorMsg).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/SchemaVisualizerPage.tsx b/src/components/db-viewer/SchemaVisualizerPage.tsx new file mode 100644 index 0000000..271207e --- /dev/null +++ b/src/components/db-viewer/SchemaVisualizerPage.tsx @@ -0,0 +1,394 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ReactFlow, + MiniMap, + Controls, + Background, + useNodesState, + useEdgesState, + type Node, + type Edge, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import dagre from "dagre"; +import { RotateCcw, ChevronUp, ChevronDown } from "lucide-react"; +import { CrowsFootEdge } from "./CrowsFootEdge"; +import { SchemaVisualizerNode } from "./SchemaVisualizerNode"; +import { LEGEND_ITEMS } from "./legendHelpers"; +import { SelectDropdown } from "../ui/SelectDropdown"; +import { getSchemaGraph } from "../../lib/commands"; +import { useDbViewerStore } from "../../stores/dbViewerStore"; +import type { SchemaGraph, TableNode as TableNodeType } from "../../lib/types"; + +const nodeTypes = { tableNode: SchemaVisualizerNode }; +const edgeTypes = { crowsfoot: CrowsFootEdge }; + +const CARD_WIDTH = 240; +const ROW_HEIGHT = 28; +const HEADER_HEIGHT = 32; + +function getNodeHeight(colCount: number): number { + return HEADER_HEIGHT + colCount * ROW_HEIGHT + 4; +} + +function layoutGraph( + tables: TableNodeType[], + relationships: { source_table: string; target_table: string; source_column: string; target_column: string; cardinality: string }[], +): { nodes: Node[]; edges: Edge[] } { + const g = new dagre.graphlib.Graph(); + g.setDefaultEdgeLabel(() => ({})); + g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 100, marginx: 40, marginy: 40 }); + + const cardinalityMap = new Map(); + for (const rel of relationships) { + cardinalityMap.set( + `${rel.source_table}.${rel.source_column}->${rel.target_table}.${rel.target_column}`, + rel.cardinality, + ); + } + + const nodes: Node[] = []; + const edges: Edge[] = []; + + for (const table of tables) { + const height = getNodeHeight(table.columns.length); + g.setNode(table.name, { width: CARD_WIDTH, height }); + nodes.push({ + id: table.name, + type: "tableNode", + position: { x: 0, y: 0 }, + data: { table, isExternal: false }, + style: { width: CARD_WIDTH }, + }); + } + + for (const table of tables) { + for (const col of table.columns) { + if (col.fk_ref) { + const [refSchema, refTable, refColumn] = col.fk_ref; + if (tables.some((t) => t.name === refTable && t.schema === refSchema)) { + const edgeKey = `${table.name}.${col.name}->${refTable}.${refColumn}`; + const cardinality = cardinalityMap.get(edgeKey) ?? "1:N"; + const markers = getEdgeMarkers(cardinality); + + g.setEdge(table.name, refTable, {}); + edges.push({ + id: edgeKey, + source: table.name, + target: refTable, + sourceHandle: `fk-${col.name}`, + targetHandle: `pk-${refColumn}`, + type: "crowsfoot", + label: cardinality, + data: { cardinality, startMarker: markers.markerStart, endMarker: markers.markerEnd, origRight: true }, + style: { stroke: "#3b82f6", strokeWidth: 1.5 }, + labelStyle: { fill: "#9ca3af", fontSize: 9 }, + labelBgStyle: { fill: "#1f2937", fillOpacity: 0.85 }, + labelBgPadding: [3, 1], + labelBorderRadius: 0, + }); + } + } + } + } + + dagre.layout(g); + + for (const node of nodes) { + const dagreNode = g.node(node.id); + if (dagreNode) { + node.position = { + x: dagreNode.x - CARD_WIDTH / 2, + y: dagreNode.y - (dagreNode as any).height / 2, + }; + } + } + + return { nodes, edges }; +} + +function getEdgeMarkers(cardinality: string): { markerStart: string; markerEnd: string } { + switch (cardinality) { + case "1:1": + return { markerStart: "one", markerEnd: "one" }; + case "0..1:0..1": + return { markerStart: "one", markerEnd: "one" }; + case "1:N": + return { markerStart: "many", markerEnd: "one" }; + case "0..N": + return { markerStart: "many", markerEnd: "one" }; + case "N:M": + return { markerStart: "many", markerEnd: "many" }; + default: + return { markerStart: "", markerEnd: "" }; + } +} + +export interface SchemaVisualizerPageProps { + connectionId: string; + onSchemaChange?: (schema: string) => void; +} + +export function SchemaVisualizerPage({ + connectionId, + onSchemaChange, +}: SchemaVisualizerPageProps) { + const databases = useDbViewerStore((s) => s.databases); + const schemas = useDbViewerStore((s) => s.schemas); + const currentDatabase = useDbViewerStore((s) => s.currentDatabase); + const currentSchema = useDbViewerStore((s) => s.currentSchema); + const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase); + const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tableCount, setTableCount] = useState(0); + const [legendOpen, setLegendOpen] = useState(true); + const [highlightedEdge, setHighlightedEdge] = useState(null); + + const fetchGraph = useCallback(async () => { + if (!currentSchema) return; + setLoading(true); + setError(null); + try { + const graph: SchemaGraph = await getSchemaGraph(connectionId, currentSchema); + if (graph.tables.length === 0) { + setNodes([]); + setEdges([]); + setTableCount(0); + } else { + const { nodes: layoutedNodes, edges: layoutedEdges } = layoutGraph(graph.tables, graph.relationships); + setNodes(layoutedNodes); + setEdges(layoutedEdges); + if (graph.tables.length > 200) { + const proceed = window.confirm( + `This schema has ${graph.tables.length} tables. Rendering the full diagram may be slow. Continue?`, + ); + if (!proceed) { + setLoading(false); + return; + } + } + setTableCount(graph.tables.length); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + } finally { + setLoading(false); + } + }, [connectionId, currentSchema, setNodes, setEdges]); + + useEffect(() => { + fetchGraph(); + }, [fetchGraph]); + + const handleResetLayout = useCallback(() => { + fetchGraph(); + setHighlightedEdge(null); + }, [fetchGraph]); + + const handleEdgeClick = useCallback( + (_event: React.MouseEvent, edge: Edge) => { + setHighlightedEdge(edge.id === highlightedEdge ? null : edge.id); + }, + [highlightedEdge], + ); + + const handlePaneClick = useCallback(() => { + setHighlightedEdge(null); + }, []); + + // Derive edges with highlighting applied + const displayEdges = useMemo(() => { + if (!highlightedEdge) return edges; + return edges.map((e) => { + if (e.id === highlightedEdge) { + return { + ...e, + zIndex: 1000, + style: { ...e.style, stroke: "#f59e0b", strokeWidth: 2.5, opacity: 1 }, + labelStyle: { ...e.labelStyle, fill: "#f59e0b" }, + labelBgStyle: { ...e.labelBgStyle, fill: "#1f2937", fillOpacity: 0.95 }, + }; + } + return { ...e, style: { ...e.style, opacity: 0.15 } }; + }); + }, [edges, highlightedEdge]); + + const highlightedCardinality = useMemo(() => { + if (!highlightedEdge) return null; + const edge = edges.find((e) => e.id === highlightedEdge); + return (edge?.data as any)?.cardinality as string | null; + }, [edges, highlightedEdge]); + + const handleSchemaChange = useCallback( + (schema: string) => { + setCurrentSchema(schema); + onSchemaChange?.(schema); + }, + [setCurrentSchema, onSchemaChange], + ); + + const schemaOptions = useMemo( + () => schemas.map((s) => ({ value: s, label: s })), + [schemas], + ); + + return ( +
+ {/* Toolbar */} +
+
+ {databases.length > 1 && ( + ({ value: d, label: d }))} + placeholder="Select database" + variant="ghost" + /> + )} + {databases.length > 1 && schemas.length > 0 && ( + | + )} + {schemas.length > 0 && ( + + )} +
+
+ + {tableCount} {tableCount === 1 ? "table" : "tables"} + + +
+ + {/* Canvas */} +
+ {loading && ( +
+

Loading schema...

+
+ )} + + {error && ( +
+

Failed to load schema: {error}

+ +
+ )} + + {!loading && !error && tableCount === 0 && ( +
+

+ No tables found in schema "{currentSchema}" +

+
+ )} + + + + + + + + {/* Legend */} +
+ + {legendOpen && ( +
+ {LEGEND_ITEMS.map((item) => { + const isActive = highlightedCardinality === item.cardinality; + return ( +
+ + + {/* Start marker */} + {item.markerStart === "one" ? ( + + ) : ( + <> + + + + + )} + {/* End marker */} + {item.markerEnd === "one" ? ( + + ) : ( + <> + + + + + )} + + {item.label} +
+ )})} +
+ )} +
+ + {/* Powered by React Flow */} +
+ Powered by React Flow +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/db-viewer/legendHelpers.test.ts b/src/components/db-viewer/legendHelpers.test.ts new file mode 100644 index 0000000..06e5ae4 --- /dev/null +++ b/src/components/db-viewer/legendHelpers.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { + getCardinalityColor, + getCardinalityLabel, + LEGEND_ITEMS, +} from "./legendHelpers"; + +describe("legendHelpers", () => { + it("getCardinalityColor returns correct colors", () => { + expect(getCardinalityColor("1:1")).toBe("#22c55e"); // green + expect(getCardinalityColor("1:N")).toBe("#3b82f6"); // blue + expect(getCardinalityColor("N:M")).toBe("#f59e0b"); // amber + }); + + it("getCardinalityColor returns fallback for unknown", () => { + expect(getCardinalityColor("unknown")).toBe("#6b7280"); // gray fallback + }); + + it("getCardinalityLabel returns human-readable labels", () => { + expect(getCardinalityLabel("1:1")).toBe("One-to-One"); + expect(getCardinalityLabel("1:N")).toBe("One-to-Many"); + expect(getCardinalityLabel("N:M")).toBe("Many-to-Many"); + }); + + it("getCardinalityLabel returns raw value for unknown", () => { + expect(getCardinalityLabel("unknown")).toBe("unknown"); + }); + + it("LEGEND_ITEMS has three entries", () => { + expect(LEGEND_ITEMS).toHaveLength(3); + expect(LEGEND_ITEMS[0]).toHaveProperty("cardinality"); + expect(LEGEND_ITEMS[0]).toHaveProperty("color"); + expect(LEGEND_ITEMS[0]).toHaveProperty("label"); + }); +}); \ No newline at end of file diff --git a/src/components/db-viewer/legendHelpers.ts b/src/components/db-viewer/legendHelpers.ts new file mode 100644 index 0000000..c028580 --- /dev/null +++ b/src/components/db-viewer/legendHelpers.ts @@ -0,0 +1,33 @@ +export interface LegendItem { + cardinality: string; + color: string; + label: string; + markerStart: string; + markerEnd: string; +} + +const CARDINALITY_COLORS: Record = { + "1:1": "#22c55e", + "1:N": "#3b82f6", + "N:M": "#f59e0b", +}; + +const CARDINALITY_LABELS: Record = { + "1:1": "One-to-One", + "1:N": "One-to-Many", + "N:M": "Many-to-Many", +}; + +export function getCardinalityColor(cardinality: string): string { + return CARDINALITY_COLORS[cardinality] ?? "#6b7280"; +} + +export function getCardinalityLabel(cardinality: string): string { + return CARDINALITY_LABELS[cardinality] ?? cardinality; +} + +export const LEGEND_ITEMS: LegendItem[] = [ + { cardinality: "1:1", color: "#22c55e", label: "One-to-One", markerStart: "one", markerEnd: "one" }, + { cardinality: "1:N", color: "#3b82f6", label: "One-to-Many", markerStart: "many", markerEnd: "one" }, + { cardinality: "N:M", color: "#f59e0b", label: "Many-to-Many", markerStart: "many", markerEnd: "many" }, +]; \ No newline at end of file diff --git a/src/components/ui/SelectDropdown.tsx b/src/components/ui/SelectDropdown.tsx index 4ad1f4a..6761e2a 100644 --- a/src/components/ui/SelectDropdown.tsx +++ b/src/components/ui/SelectDropdown.tsx @@ -40,10 +40,11 @@ export function SelectDropdown({ setOpen(false); } }; - document.addEventListener("mousedown", handleMouseDown); + // Use capture phase so we fire before React Flow's stopPropagation + document.addEventListener("mousedown", handleMouseDown, true); document.addEventListener("keydown", handleKeyDown); return () => { - document.removeEventListener("mousedown", handleMouseDown); + document.removeEventListener("mousedown", handleMouseDown, true); document.removeEventListener("keydown", handleKeyDown); }; }, [open]); diff --git a/src/lib/commands.test.ts b/src/lib/commands.test.ts index b934018..3f2600f 100644 --- a/src/lib/commands.test.ts +++ b/src/lib/commands.test.ts @@ -1,4 +1,9 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn().mockResolvedValue({ tables: [], relationships: [] }), +})); + import { testConnection, dbConnect, @@ -9,7 +14,9 @@ import { getTableData, executeChange, refreshConnection, + getSchemaGraph, } from "./commands"; +import type { SchemaGraph } from "./types"; describe("commands", () => { it("testConnection has correct signature", () => { @@ -47,4 +54,17 @@ describe("commands", () => { it("refreshConnection returns full tree promise", () => { expect(typeof refreshConnection).toBe("function"); }); + + describe("getSchemaGraph", () => { + it("is a callable function with correct signature", () => { + expect(typeof getSchemaGraph).toBe("function"); + const result: Promise = getSchemaGraph("conn-1", "public"); + expect(result).toBeInstanceOf(Promise); + }); + + it("accepts schema as optional", () => { + const result: Promise = getSchemaGraph("conn-1"); + expect(result).toBeInstanceOf(Promise); + }); + }); }); \ No newline at end of file diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 95c9218..b9ee83a 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, ChangeItem, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "./types"; +import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, ChangeItem, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types"; import type { FilterRule, SortRule } from "../stores/dbViewerStore"; // NOTE on argument key naming: @@ -139,4 +139,11 @@ export async function getEnums(connectionId: string, schema?: string): Promise { return invoke("get_extensions", { connectionId }); +} + +export async function getSchemaGraph( + connectionId: string, + schema?: string, +): Promise { + return invoke("get_schema_graph", { connectionId, schema }); } \ No newline at end of file diff --git a/src/lib/dbIcons.ts b/src/lib/dbIcons.ts deleted file mode 100644 index adbc224..0000000 --- a/src/lib/dbIcons.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { DbType } from "./types"; - -export const DB_ICONS: Record = { - postgresql: "🐘", - mysql: "🐬", - redis: "⚡", - sqlite: "🗄️", -}; - -export const DB_LABELS: Record = { - postgresql: "PostgreSQL", - mysql: "MySQL", - redis: "Redis", - sqlite: "SQLite", -}; \ No newline at end of file diff --git a/src/lib/dbIcons.tsx b/src/lib/dbIcons.tsx new file mode 100644 index 0000000..9a9e2fa --- /dev/null +++ b/src/lib/dbIcons.tsx @@ -0,0 +1,59 @@ +import { siPostgresql, siMysql, siSqlite, siRedis } from "simple-icons"; +import type { DbType } from "./types"; + +// Brand colors from simple-icons +const DB_COLORS: Record = { + postgresql: `#${siPostgresql.hex}`, + mysql: `#${siMysql.hex}`, + redis: `#${siRedis.hex}`, + sqlite: `#${siSqlite.hex}`, +}; + +// SVG path data for each DB icon +const DB_PATHS: Record = { + postgresql: siPostgresql.path, + mysql: siMysql.path, + redis: siRedis.path, + sqlite: siSqlite.path, +}; + +export const DB_LABELS: Record = { + postgresql: "PostgreSQL", + mysql: "MySQL", + redis: "Redis", + sqlite: "SQLite", +}; + +interface DbIconProps { + type: DbType; + size?: number; + className?: string; +} + +export function DbIcon({ type, size = 20, className }: DbIconProps) { + const path = DB_PATHS[type]; + const color = DB_COLORS[type]; + if (!path) return ; + + return ( + + + + ); +} + +// Keep backward compat for any legacy emoji usage +export const DB_ICONS: Record = { + postgresql: "🐘", + mysql: "🐬", + redis: "⚡", + sqlite: "🗄️", +}; \ No newline at end of file diff --git a/src/lib/types.test.ts b/src/lib/types.test.ts index be7e9d2..3dd0729 100644 --- a/src/lib/types.test.ts +++ b/src/lib/types.test.ts @@ -11,6 +11,10 @@ import type { ChangeStatus, DbViewerTab, ConnectionTestResult, + SchemaGraph, + TableNode, + GraphColumn, + Relationship, } from "./types"; describe("ActiveView", () => { @@ -358,4 +362,70 @@ describe("ConnectionTestResult", () => { expect(result.ok).toBe(false); expect(result.error).toBe("Connection refused"); }); +}); + +describe("Schema graph types", () => { + it("GraphColumn has correct shape", () => { + const col: GraphColumn = { + name: "user_id", + data_type: "integer", + is_pk: false, + is_fk: true, + is_unique: false, + fk_ref: ["public", "users", "id"], + }; + expect(col.name).toBe("user_id"); + expect(col.is_fk).toBe(true); + expect(col.fk_ref).toEqual(["public", "users", "id"]); + }); + + it("TableNode has correct shape", () => { + const node: TableNode = { + name: "orders", + schema: "public", + table_type: "TABLE", + columns: [ + { name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, fk_ref: null }, + { name: "user_id", data_type: "integer", is_pk: false, is_fk: true, is_unique: false, fk_ref: ["public", "users", "id"] }, + ], + }; + expect(node.name).toBe("orders"); + expect(node.columns).toHaveLength(2); + }); + + it("Relationship has correct shape", () => { + const rel: Relationship = { + source_schema: "public", + source_table: "orders", + source_column: "user_id", + target_schema: "public", + target_table: "users", + target_column: "id", + cardinality: "1:N", + }; + expect(rel.cardinality).toBe("1:N"); + }); + + it("SchemaGraph has correct shape", () => { + const graph: SchemaGraph = { + tables: [ + { name: "users", schema: "public", table_type: "TABLE", columns: [] }, + ], + relationships: [], + }; + expect(graph.tables).toHaveLength(1); + expect(graph.relationships).toHaveLength(0); + }); + + it("fk_ref can be null for non-FK columns", () => { + const col: GraphColumn = { + name: "name", + data_type: "text", + is_pk: false, + is_fk: false, + is_unique: false, + fk_ref: null, + }; + expect(col.fk_ref).toBeNull(); + }); }); \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index a8f78b5..215a62b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -278,4 +278,40 @@ export interface BackupJob { size_bytes: number | null; started_at: string; completed_at: string | null; +} + +// ─── Schema Visualizer Types ──────────────────────────────────── + +export interface GraphColumn { + name: string; + data_type: string; + is_pk: boolean; + is_fk: boolean; + is_unique: boolean; + is_nullable: boolean; + /** [referenced_schema, referenced_table, referenced_column] */ + fk_ref: [string, string, string] | null; +} + +export interface TableNode { + name: string; + schema: string; + table_type: string; + columns: GraphColumn[]; +} + +export interface Relationship { + source_schema: string; + source_table: string; + source_column: string; + target_schema: string; + target_table: string; + target_column: string; + /** Inferred cardinality: "1:1" | "1:N" | "N:M" */ + cardinality: string; +} + +export interface SchemaGraph { + tables: TableNode[]; + relationships: Relationship[]; } \ No newline at end of file diff --git a/src/test/setup.ts b/src/test/setup.ts index 02c423f..3d2eddf 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,8 @@ -import "@testing-library/jest-dom"; \ No newline at end of file +import "@testing-library/jest-dom"; + +// Polyfill ResizeObserver for jsdom (required by @xyflow/react) +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; \ No newline at end of file