feat: Schema Visualizer, docs update & competitor comparison
* chore: add @xyflow/react, dagre, @types/dagre (Task 1) * feat: add SchemaGraph, TableNode, GraphColumn, Relationship types (Task 2) * feat: add SchemaGraph, TableNode, GraphColumn, Relationship Rust models (Task 3) * feat: add cardinality color/label helpers and legend data (Task 4) * feat: add schema_graph command skeleton with validation (Task 5) * feat: add PostgreSQL schema graph query builder + cardinality inference (Task 6) * feat: implement get_schema_graph for PostgreSQL + SQLite (Task 7) * feat: add getSchemaGraph IPC wrapper (Task 8) * feat: un-stub Schema Visualizer nav + add view branch placeholder (Task 9) * feat: add SchemaVisualizerNode custom React Flow table card (Task 10) * feat: add SchemaVisualizerPage with React Flow canvas (Task 11) * feat: wire SchemaVisualizerPage + error handling + style polish (Tasks 12-14) * perf: replace information_schema with pg_catalog for schema graph query (500x+ faster on remote PG) * fix: add DB selector, ghost-style dropdowns, dark controls, minimap styling, attribution * fix: always show schema selector, move attribution to top-left * fix: SelectDropdown close on outside click works in React Flow (capture phase) * style: remove border from attribution badge * style: restore bg on attribution, no border * fix: lower attribution z-index so dropdowns render above * fix: ensure toolbar + dropdowns stack above canvas attribution * feat: crow's foot markers on edges + collapsible legend * fix: restore missing tableCount state (was overwritten by legendOpen) * fix: move crow's foot marker defs inside ReactFlow SVG, remove duplicate external SVGs * fix: inject crow's foot SVG markers into ReactFlow SVG via DOM ref * fix: use hidden SVG before ReactFlow for crow's foot markers, remove DOM injection * feat: custom CrowsFootEdge component with inline crow's foot markers * feat: custom CrowsFootEdge with zero-or-one/zero-or-many notation + nullability-based cardinality * fix: strip markers, use clean text labels only on edges * fix: add SVG marker defs directly inside ReactFlow + url() references for crow's foot * fix: use custom CrowsFootEdge with BaseEdge + inline SVG symbols (no marker defs needed) * debug: add red/green circles at edge endpoints to verify custom edge renders * fix: remove stale duplicate edge data, use clean data.startMarker/endMarker * fix: use getSmoothStepPath offset points for correct tangent angle at endpoints * fix: thicker strokeWidth, position at handle coords, use path tangents * fix: use straight-line angle (not curve tangent) for marker rotation * fix: compute marker positions directly with raw math, no SVG transforms * fix: fixed-orientation symbols — | always vertical, crow's foot fans toward node * fix: dead simple — | vertical line, ← or → horizontal crow's foot based on edge direction * fix: increase marker gap to 12px so symbols aren't hidden behind handle dots * fix: correct offset direction (away from card into gap), G=4 * fix: remove duplicate G offset inside Mark (was canceling out the call-site offset) * fix: crow's foot back to fork shape — three lines converging to a tip * fix: flip crow's foot direction * fix: wider crow's foot spread (4→6) * feat: add crow's foot symbols to relationship legend * style: cleaner legend — horizontal edge with endpoint symbols + label * feat: handles on both sides, edge builder picks closest side based on dagre layout * fix: compute actual handle distances to pick shortest path * revert: PK always left, FK always right — one handle per column only * fix: lock edge marker direction via origRight, TB layout for horizontal spread, truncate long types * fix: semi-transparent minimap mask, border stroke for viewport visibility * feat: click edge to highlight (amber glow), all others dim to 15% opacity * feat: legend highlights matching cardinality row when edge is clicked * fix: crow's foot symbols now read color from edge style (amber when highlighted) * fix: highlighted edge gets zIndex 1000 to render on top * fix: folder empty message now checks unfiltered store, shows filter hint when connections exist but filtered out * feat: flat SVG DB icons from simple-icons (PostgreSQL, MySQL, SQLite, Redis) replacing emoji * chore: add *.db, *.sqlite, *.sqlite3 to .gitignore * docs: update README with 3-way competitor comparison + current roadmap; update AGENTS.md schema visualizer status
This commit is contained in:
@@ -28,6 +28,11 @@ dist-ssr
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|
||||||
|
# Database (local state — never commit)
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env.*
|
.env.*
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ gridline/
|
|||||||
│ │ │ ├── connections.rs # CRUD for saved connections
|
│ │ │ ├── connections.rs # CRUD for saved connections
|
||||||
│ │ │ ├── query.rs # SQL execution
|
│ │ │ ├── query.rs # SQL execution
|
||||||
│ │ │ ├── schema.rs # Object tree introspection
|
│ │ │ ├── schema.rs # Object tree introspection
|
||||||
|
│ │ │ ├── schema_graph.rs # ER diagram / relationship graph
|
||||||
│ │ │ ├── backup.rs # pg_dump / pg_restore wrappers
|
│ │ │ ├── backup.rs # pg_dump / pg_restore wrappers
|
||||||
│ │ │ └── workspace.rs # Workspace/folder persistence
|
│ │ │ └── workspace.rs # Workspace/folder persistence
|
||||||
│ │ ├── models/ # Serde structs shared across commands
|
│ │ ├── models/ # Serde structs shared across commands
|
||||||
│ │ │ ├── mod.rs
|
│ │ │ ├── mod.rs
|
||||||
│ │ │ ├── connection.rs
|
│ │ │ ├── connection.rs
|
||||||
│ │ │ ├── query.rs
|
│ │ │ ├── query.rs
|
||||||
|
│ │ │ ├── db_viewer.rs # DB viewer types (SchemaGraph, TableNode, etc.)
|
||||||
│ │ │ └── workspace.rs
|
│ │ │ └── workspace.rs
|
||||||
│ │ └── store/ # SQLite local persistence layer
|
│ │ └── store/ # SQLite local persistence layer
|
||||||
│ │ ├── mod.rs
|
│ │ ├── mod.rs
|
||||||
@@ -251,7 +253,7 @@ cargo test # Rust tests
|
|||||||
| Constraints (CHECK, UNIQUE beyond PK/FK) | ❌ | |
|
| Constraints (CHECK, UNIQUE beyond PK/FK) | ❌ | |
|
||||||
| Materialized views | ❌ | Not distinguished from regular views |
|
| Materialized views | ❌ | Not distinguished from regular views |
|
||||||
| Stored procedures | 🟡 | Included in Functions via p.prokind IN ('f','p'); no separate view yet |
|
| 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
|
### Query Editor
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
|
|||||||
@@ -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.
|
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:
|
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 |
|
| Capability | DB Pro (Free) | Beekeeper (Free) | Gridline |
|
||||||
| :--- | :---: | :---: |
|
| :--- | :---: | :---: | :---: |
|
||||||
| Open tabs | 3 | **Unlimited** |
|
| Open tabs | 3 | Unlimited | **Unlimited** |
|
||||||
| Saved connections | 2 | **Unlimited** |
|
| Saved connections | 2 | Unlimited | **Unlimited** |
|
||||||
| Saved queries | 5 | **Unlimited** |
|
| Saved queries | 5 | Unlimited | **Unlimited** |
|
||||||
| Workspace / folder hierarchy | ❌ | **Multi-level tree** |
|
| Data export (CSV, JSON, SQL) | ❌ (paid only) | Basic only | **JSON, CSV, SQL, Markdown** |
|
||||||
| pg_dump / pg_restore GUI | ❌ | **First-class UI** |
|
| Data import (CSV, JSON) | ❌ (paid only) | ✅ | 🟡 *Upcoming* |
|
||||||
| DB-to-DB sync | ❌ | **Built-in diff & migrate** |
|
| pg_dump / pg_restore GUI | ❌ | ❌ (paid only) | **First-class UI** |
|
||||||
| Functions, Triggers, Enums, Sequences | ❌ | **Full object explorer** |
|
| DB-to-DB sync | ❌ | ❌ | **Built-in pipe sync** |
|
||||||
| OS credential vault storage | ❌ | **Keychain / Secret Service** |
|
| Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** |
|
||||||
| Open source | ❌ | **MIT** |
|
| 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
|
- **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
|
- **Sequences & Enums** — current values, increments, cycle flags; enum labels in bordered list view
|
||||||
- **Extensions** — installed extensions with version, schema, and comment
|
- **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
|
### SQL Editor & Query Workbench
|
||||||
- **Multi-Tab Workspace** — unlimited named tabs, close with Cmd/Ctrl+W, session persistence across restarts
|
- **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
|
- **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
|
- **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
|
### Data Grid & Schema Browser
|
||||||
- **Virtualized Grid** — row-level virtualization via `@tanstack/react-virtual` handles 100k+ rows
|
- **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
|
- **FK Preview** — click a foreign key cell to preview the referenced row
|
||||||
- **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button
|
- **JSON/JSONB Viewer** — popover with formatted/raw tabs and copy button
|
||||||
- **Auto-Refresh** — configurable interval timer
|
- **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
|
### 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
|
- **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
|
## Roadmap
|
||||||
|
|
||||||
1. **Phase 1 — Core Shell & Storage**
|
### ✅ Completed
|
||||||
- [x] Tauri 2.0 + React project scaffold
|
- **Phase 1 — Core Shell** — Tauri 2.0 + React project, glassmorphic dark-first UI, Zustand state management, SQLite local persistence, OS keychain credentials
|
||||||
- [ ] SQLite persistence layer for workspaces, folders, connections, saved queries
|
- **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
|
||||||
- [ ] Workspace/folder tree UI
|
- **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**
|
### 🟡 In Progress / Upcoming
|
||||||
- [ ] Rust connection pool manager (`sqlx` / `tokio-postgres`)
|
- **SQL Editor** — Monaco Editor integration with schema-aware SQL autocomplete, query history, saved queries
|
||||||
- [ ] URI parser with auto-population
|
- **SSH/SSL Runtime** — SSH tunnel via `ssh2` crate, SSL/TLS config passed to `sqlx`/`tokio-postgres`
|
||||||
- [ ] OS Keychain credential storage
|
- **Inline Cell Editing** — Edit cells directly in the data grid
|
||||||
|
- **Data Import** — CSV, JSON import with column mapping
|
||||||
|
|
||||||
3. **Phase 3 — Schema Explorer**
|
### 🔮 Future
|
||||||
- [x] PostgreSQL `pg_catalog` / `information_schema` introspection
|
- **Multi-DB Support** — MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL
|
||||||
- [x] Full object tree (Tables, Views, Functions, Triggers, Enums, Sequences, Extensions)
|
- **Query Workbench** — Multiple result sets, query favorites/pinning, visual query builder
|
||||||
- [x] Per-type detail views with source code, arguments, metadata
|
- **Deeper PostgreSQL** — Indexes, constraints, materialized views, stored procedure view, user/role management
|
||||||
|
- **Collaboration** — Team workspaces, shared connections, query sharing
|
||||||
4. **Phase 4 — Query Workbench**
|
- **Notebook Reports** — SQL-backed markdown reports with embedded results
|
||||||
- [ ] 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,13 @@
|
|||||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.26.0",
|
||||||
"motion": "^12.42.2",
|
"motion": "^12.42.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"simple-icons": "^16.27.1",
|
||||||
"tauri-plugin-keyring-store-api": "^0.2.0",
|
"tauri-plugin-keyring-store-api": "^0.2.0",
|
||||||
"zustand": "^5.0.14",
|
"zustand": "^5.0.14",
|
||||||
},
|
},
|
||||||
@@ -27,6 +30,7 @@
|
|||||||
"@testing-library/jest-dom": "^7.0.0",
|
"@testing-library/jest-dom": "^7.0.0",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/dagre": "^0.7.54",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@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/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/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
"@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=="],
|
"@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-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||||
@@ -358,6 +380,8 @@
|
|||||||
|
|
||||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"tauri-plugin-keyring-store-api/@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,13 @@
|
|||||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.26.0",
|
||||||
"motion": "^12.42.2",
|
"motion": "^12.42.2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"simple-icons": "^16.27.1",
|
||||||
"tauri-plugin-keyring-store-api": "^0.2.0",
|
"tauri-plugin-keyring-store-api": "^0.2.0",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
"@testing-library/jest-dom": "^7.0.0",
|
"@testing-library/jest-dom": "^7.0.0",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/dagre": "^0.7.54",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use tokio_postgres::types::ToSql;
|
|||||||
/// tokio-postgres's `Display` only prints "db error", so we walk the
|
/// tokio-postgres's `Display` only prints "db error", so we walk the
|
||||||
/// `std::error::Error::source()` chain and also use `Debug` to surface the
|
/// `std::error::Error::source()` chain and also use `Debug` to surface the
|
||||||
/// real message (e.g. "password authentication failed for user \"x\"").
|
/// 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.
|
// Prefer the Debug representation, which includes severity + message + code.
|
||||||
let raw = format!("{:?}", err);
|
let raw = format!("{:?}", err);
|
||||||
// Redact postgres URL fragments and password=... sequences.
|
// 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
|
// Integer types
|
||||||
if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) {
|
if let Ok(Some(v)) = row.try_get::<_, Option<i32>>(i) {
|
||||||
return serde_json::json!(v);
|
return serde_json::json!(v);
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ pub mod ssh;
|
|||||||
pub mod keychain;
|
pub mod keychain;
|
||||||
pub mod demo;
|
pub mod demo;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
|
pub mod schema_graph;
|
||||||
@@ -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<serde_json::Value>],
|
||||||
|
) -> (Vec<TableNode>, Vec<Relationship>) {
|
||||||
|
let mut table_map: HashMap<(String, String), (String, Vec<GraphColumn>)> = HashMap::new();
|
||||||
|
let mut relationships: Vec<Relationship> = 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<TableNode> = 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<SchemaGraph, String> {
|
||||||
|
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<TableNode> = Vec::new();
|
||||||
|
let mut relationships: Vec<Relationship> = 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<String, (String, String)> = 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<GraphColumn> = 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<String>,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<SchemaGraph, String> {
|
||||||
|
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<Vec<serde_json::Value>> = 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<serde_json::Value>> = 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<serde_json::Value>> = vec![];
|
||||||
|
let (tables, relationships) = parse_pg_schema_rows(&rows);
|
||||||
|
assert!(tables.is_empty());
|
||||||
|
assert!(relationships.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ pub struct AppState {
|
|||||||
pub ssh_manager: StdMutex<SshTunnelManager>,
|
pub ssh_manager: StdMutex<SshTunnelManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
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/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -94,6 +94,7 @@ pub fn run() {
|
|||||||
backup::pg_dump,
|
backup::pg_dump,
|
||||||
backup::pg_restore,
|
backup::pg_restore,
|
||||||
backup::db_sync,
|
backup::db_sync,
|
||||||
|
schema_graph::get_schema_graph,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -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<TableNode>,
|
||||||
|
pub relationships: Vec<Relationship>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<GraphColumn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -335,4 +379,102 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&info).unwrap();
|
let json = serde_json::to_string(&info).unwrap();
|
||||||
assert!(json.contains("pg_stat_statements"));
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import type { Connection, Tag } from "../../lib/types";
|
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 { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
|
||||||
import { TagBadge } from "../tags/TagBadge";
|
import { TagBadge } from "../tags/TagBadge";
|
||||||
import { Check, GripVertical } from "lucide-react";
|
import { Check, GripVertical } from "lucide-react";
|
||||||
@@ -75,8 +75,8 @@ function ConnectionCardBase({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center text-xl">
|
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center overflow-hidden">
|
||||||
{DB_ICONS[connection.db_type] ?? "❓"}
|
<DbIcon type={connection.db_type} size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-semibold truncate text-text">
|
<div className="font-semibold truncate text-text">
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { Connection, Folder, Tag } from "../../lib/types";
|
import type { Connection, Folder, Tag } from "../../lib/types";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
|
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
|
||||||
import { useDroppable } from "@dnd-kit/core";
|
import { useDroppable } from "@dnd-kit/core";
|
||||||
import { ConnectionCard } from "./ConnectionCard";
|
import { ConnectionCard } from "./ConnectionCard";
|
||||||
import { FolderBreadcrumb } from "../folders/FolderBreadcrumb";
|
import { FolderBreadcrumb } from "../folders/FolderBreadcrumb";
|
||||||
import { getChildFolders } from "../../lib/utils";
|
import { getChildFolders, getDescendantFolderIds } from "../../lib/utils";
|
||||||
import { useUiStore } from "../../stores/uiStore";
|
import { useUiStore } from "../../stores/uiStore";
|
||||||
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { TagBadge } from "../tags/TagBadge";
|
import { TagBadge } from "../tags/TagBadge";
|
||||||
|
|
||||||
interface DroppableFolderCardProps {
|
interface DroppableFolderCardProps {
|
||||||
@@ -136,7 +138,14 @@ export function ConnectionGrid({
|
|||||||
const directConnections = connections.filter(
|
const directConnections = connections.filter(
|
||||||
(c) => c.folder_id === currentFolderId,
|
(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 isSelecting = selectedItemIds.length > 0;
|
||||||
const activeFolder = currentFolderId
|
const activeFolder = currentFolderId
|
||||||
? (folders.find((f) => f.id === currentFolderId) ?? null)
|
? (folders.find((f) => f.id === currentFolderId) ?? null)
|
||||||
@@ -199,8 +208,10 @@ export function ConnectionGrid({
|
|||||||
>
|
>
|
||||||
{visibleFolders.map((f) => {
|
{visibleFolders.map((f) => {
|
||||||
const isSelected = selectedItemIds.includes(f.id);
|
const isSelected = selectedItemIds.includes(f.id);
|
||||||
const count = directConnections.filter(
|
// Count all connections in this subfolder (including nested descendants)
|
||||||
(c) => c.folder_id === f.id,
|
const subIds = new Set(getDescendantFolderIds(folders, f.id));
|
||||||
|
const count = allStoreConnections.filter(
|
||||||
|
(c) => c.folder_id !== null && subIds.has(c.folder_id),
|
||||||
).length;
|
).length;
|
||||||
const subfolderCount = getChildFolders(
|
const subfolderCount = getChildFolders(
|
||||||
folders,
|
folders,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<g>
|
||||||
|
<BaseEdge id={id} path={edgePath} style={{ stroke: edgeColor, strokeWidth: 1.5, ...style }} />
|
||||||
|
{sm && <Mark type={sm} cx={sourceX + sOff * G} cy={sourceY} dir={sDir} color={edgeColor} />}
|
||||||
|
{em && <Mark type={em} cx={targetX + tOff * G} cy={targetY} dir={tDir} color={edgeColor} />}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mark({ type, cx, cy, dir, color }: { type: string; cx: number; cy: number; dir: number; color: string }) {
|
||||||
|
if (type === "one") {
|
||||||
|
return <line x1={cx} y1={cy - S} x2={cx} y2={cy + S} stroke={color} strokeWidth={2} strokeLinecap="round" />;
|
||||||
|
}
|
||||||
|
if (type === "many") {
|
||||||
|
const sp = 6;
|
||||||
|
const tx = cx + dir * S;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<line x1={cx} y1={cy - sp} x2={tx} y2={cy} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
<line x1={cx} y1={cy} x2={tx} y2={cy} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
<line x1={cx} y1={cy + sp} x2={tx} y2={cy} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
|||||||
import { BackupPage } from "./BackupPage";
|
import { BackupPage } from "./BackupPage";
|
||||||
import { RestorePage } from "./RestorePage";
|
import { RestorePage } from "./RestorePage";
|
||||||
import { SyncPage } from "./SyncPage";
|
import { SyncPage } from "./SyncPage";
|
||||||
|
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
||||||
import * as cmd from "../../lib/commands";
|
import * as cmd from "../../lib/commands";
|
||||||
|
|
||||||
export interface DbViewerScreenProps {
|
export interface DbViewerScreenProps {
|
||||||
@@ -579,6 +580,13 @@ export function DbViewerScreen({
|
|||||||
<RestorePage connectionId={connectionId} />
|
<RestorePage connectionId={connectionId} />
|
||||||
) : currentView === "sync" ? (
|
) : currentView === "sync" ? (
|
||||||
<SyncPage />
|
<SyncPage />
|
||||||
|
) : currentView === "schema-visualizer" ? (
|
||||||
|
<SchemaVisualizerPage
|
||||||
|
connectionId={connectionId}
|
||||||
|
onSchemaChange={(newSchema) => {
|
||||||
|
useDbViewerStore.getState().setCurrentSchema(newSchema);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{currentView === "db-viewer" && <ChangesQueuePanel />}
|
{currentView === "db-viewer" && <ChangesQueuePanel />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,4 +38,16 @@ describe("DbViewerSidebar", () => {
|
|||||||
await user.click(screen.getByLabelText(/settings/i));
|
await user.click(screen.getByLabelText(/settings/i));
|
||||||
expect(onNavigate).toHaveBeenCalledWith("settings");
|
expect(onNavigate).toHaveBeenCalledWith("settings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders Schema Visualizer nav item (not coming soon)", () => {
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
// Should find the label WITHOUT "coming soon"
|
||||||
|
const btn = screen.getByLabelText(/schema visualizer/i);
|
||||||
|
expect(btn).toBeInTheDocument();
|
||||||
|
expect(btn).not.toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -34,9 +34,8 @@ export function DbViewerSidebar({
|
|||||||
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
|
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
|
||||||
{
|
{
|
||||||
id: "schema-visualizer",
|
id: "schema-visualizer",
|
||||||
label: "Schema Visualizer coming soon",
|
label: "Schema Visualizer",
|
||||||
icon: <Grid2x2 size={20} />,
|
icon: <Grid2x2 size={20} />,
|
||||||
stub: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "functions",
|
id: "functions",
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<ReactFlowProvider>{children}</ReactFlowProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<SchemaVisualizerNode
|
||||||
|
id="node-1"
|
||||||
|
data={{ table: sampleTable, isExternal: false, onExpandExternal: undefined }}
|
||||||
|
selected={false}
|
||||||
|
/>,
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
expect(screen.getByText("public.users")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders all columns by default", () => {
|
||||||
|
render(
|
||||||
|
<SchemaVisualizerNode
|
||||||
|
id="node-1"
|
||||||
|
data={{ table: sampleTable, isExternal: false, onExpandExternal: undefined }}
|
||||||
|
selected={false}
|
||||||
|
/>,
|
||||||
|
{ 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(
|
||||||
|
<SchemaVisualizerNode
|
||||||
|
id="node-1"
|
||||||
|
data={{ table: sampleTable, isExternal: false, onExpandExternal: undefined }}
|
||||||
|
selected={false}
|
||||||
|
/>,
|
||||||
|
{ 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(
|
||||||
|
<SchemaVisualizerNode
|
||||||
|
id="node-ext"
|
||||||
|
data={{ table: sampleTable, isExternal: true, onExpandExternal: vi.fn() }}
|
||||||
|
selected={false}
|
||||||
|
/>,
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
const card = container.firstElementChild;
|
||||||
|
expect(card?.className).toContain("opacity-50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onExpandExternal when external node is clicked", () => {
|
||||||
|
const onExpand = vi.fn();
|
||||||
|
render(
|
||||||
|
<SchemaVisualizerNode
|
||||||
|
id="node-ext"
|
||||||
|
data={{ table: sampleTable, isExternal: true, onExpandExternal: onExpand }}
|
||||||
|
selected={false}
|
||||||
|
/>,
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
const card = screen.getByText("public.users").closest("div");
|
||||||
|
fireEvent.click(card!);
|
||||||
|
expect(onExpand).toHaveBeenCalledWith("public", "users");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<div className={cardClass} onClick={handleClick}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border bg-surface-raised">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isView ? (
|
||||||
|
<Eye size={12} className="text-text-muted" />
|
||||||
|
) : (
|
||||||
|
<Table2 size={12} className="text-text-muted" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold text-text truncate max-w-[160px]">
|
||||||
|
{table.schema}.{table.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isExternal && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={collapsed ? "Expand columns" : "Collapse columns"}
|
||||||
|
className="p-0.5 rounded hover:bg-surface-hover text-text-muted"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCollapsed(!collapsed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column rows */}
|
||||||
|
<div>
|
||||||
|
{visibleColumns.map((col) => (
|
||||||
|
<div
|
||||||
|
key={col.name}
|
||||||
|
className="flex items-center justify-between px-2 py-1 border-b border-border last:border-b-0 hover:bg-surface-hover relative"
|
||||||
|
>
|
||||||
|
{/* Left side: badges + name */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{col.is_pk && <Key size={10} className="text-amber-400 shrink-0" />}
|
||||||
|
{col.is_fk && !col.is_pk && (
|
||||||
|
<ArrowRight size={10} className="text-accent shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-text truncate max-w-[120px]">{col.name}</span>
|
||||||
|
</div>
|
||||||
|
{/* Right side: type */}
|
||||||
|
<span className="text-text-muted text-[10px] shrink-0 ml-2 max-w-[60px] truncate inline-block align-middle">
|
||||||
|
{abbreviateType(col.data_type)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* FK source handle */}
|
||||||
|
{col.is_fk && (
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id={`fk-${col.name}`}
|
||||||
|
className="!w-2 !h-2 !bg-accent !border-2 !border-canvas"
|
||||||
|
style={{ top: "50%", right: -5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* PK target handle */}
|
||||||
|
{col.is_pk && (
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
id={`pk-${col.name}`}
|
||||||
|
className="!w-2 !h-2 !bg-amber-400 !border-2 !border-canvas"
|
||||||
|
style={{ top: "50%", left: -5 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show collapsed count */}
|
||||||
|
{collapsed && table.columns.length > visibleColumns.length && (
|
||||||
|
<div className="px-2 py-1 text-[10px] text-text-muted border-t border-border">
|
||||||
|
+{table.columns.length - visibleColumns.length} more columns
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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: () => <div data-testid="mock-node">Node</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("SchemaVisualizerPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useDbViewerStore.getState().reset();
|
||||||
|
useDbViewerStore.setState({
|
||||||
|
schemas: ["public", "auth"],
|
||||||
|
currentSchema: "public",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the legend panel", () => {
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<SchemaVisualizerPage
|
||||||
|
connectionId="conn-1"
|
||||||
|
onSchemaChange={() => {}}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<TooltipProvider>
|
||||||
|
<SchemaVisualizerPage
|
||||||
|
connectionId="conn-1"
|
||||||
|
onSchemaChange={() => {}}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/loading schema/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Reset Layout button", () => {
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<SchemaVisualizerPage
|
||||||
|
connectionId="conn-1"
|
||||||
|
onSchemaChange={() => {}}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<TooltipProvider>
|
||||||
|
<SchemaVisualizerPage connectionId="conn-1" onSchemaChange={() => {}} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorMsg = await screen.findByText(/failed to load schema/i);
|
||||||
|
expect(errorMsg).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>();
|
||||||
|
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<string | null>(null);
|
||||||
|
const [tableCount, setTableCount] = useState(0);
|
||||||
|
const [legendOpen, setLegendOpen] = useState(true);
|
||||||
|
const [highlightedEdge, setHighlightedEdge] = useState<string | null>(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 (
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 bg-canvas">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 border-b border-border shrink-0 relative z-10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{databases.length > 1 && (
|
||||||
|
<SelectDropdown
|
||||||
|
value={currentDatabase ?? ""}
|
||||||
|
onChange={setCurrentDatabase}
|
||||||
|
options={databases.map((d) => ({ value: d, label: d }))}
|
||||||
|
placeholder="Select database"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{databases.length > 1 && schemas.length > 0 && (
|
||||||
|
<span className="text-border">|</span>
|
||||||
|
)}
|
||||||
|
{schemas.length > 0 && (
|
||||||
|
<SelectDropdown
|
||||||
|
value={currentSchema ?? ""}
|
||||||
|
options={schemaOptions}
|
||||||
|
onChange={handleSchemaChange}
|
||||||
|
placeholder="Select schema"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
{tableCount} {tableCount === 1 ? "table" : "tables"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetLayout}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-surface border border-border text-text-muted hover:text-text hover:bg-surface-raised"
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
Reset Layout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas */}
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center z-10 bg-canvas/80">
|
||||||
|
<p className="text-text-muted text-sm">Loading schema...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center z-10 bg-canvas/80 gap-3">
|
||||||
|
<p className="text-red-400 text-sm">Failed to load schema: {error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fetchGraph}
|
||||||
|
className="px-3 py-1 text-xs rounded-md bg-surface border border-border text-text-muted hover:text-text"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && tableCount === 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||||
|
<p className="text-text-muted text-sm">
|
||||||
|
No tables found in schema "{currentSchema}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={displayEdges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
onEdgeClick={handleEdgeClick}
|
||||||
|
onPaneClick={handlePaneClick}
|
||||||
|
fitView
|
||||||
|
minZoom={0.1}
|
||||||
|
maxZoom={2}
|
||||||
|
className="bg-canvas"
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background variant="dots" gap={20} color="var(--color-border)" />
|
||||||
|
<MiniMap
|
||||||
|
position="bottom-right"
|
||||||
|
nodeStrokeWidth={2}
|
||||||
|
nodeClassName="!fill-accent/20 !stroke-accent"
|
||||||
|
maskColor="rgba(18,18,24,0.85)"
|
||||||
|
maskStrokeColor="var(--color-border)"
|
||||||
|
maskStrokeWidth={1}
|
||||||
|
className="!bg-surface !border !border-border !rounded-none !shadow-lg"
|
||||||
|
/>
|
||||||
|
<Controls
|
||||||
|
position="bottom-left"
|
||||||
|
className="!rounded-none !shadow-lg [&_button]:!bg-surface [&_button]:!text-text-muted [&_button]:!border-border [&_button]:hover:!bg-surface-raised [&_button]:hover:!text-text [&_button]:!shadow-none"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="absolute top-3 right-3 z-10 bg-surface border border-border rounded-none px-3 py-2 text-xs shadow-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLegendOpen(!legendOpen)}
|
||||||
|
className="flex items-center gap-1 font-semibold text-text w-full"
|
||||||
|
>
|
||||||
|
{legendOpen ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
||||||
|
Relationships
|
||||||
|
</button>
|
||||||
|
{legendOpen && (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{LEGEND_ITEMS.map((item) => {
|
||||||
|
const isActive = highlightedCardinality === item.cardinality;
|
||||||
|
return (
|
||||||
|
<div key={item.cardinality} className={`flex items-center gap-2.5 transition-opacity ${highlightedCardinality && !isActive ? "opacity-20" : ""}`}>
|
||||||
|
<svg width="36" height="12" className="shrink-0">
|
||||||
|
<line x1={6} y1={6} x2={30} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} />
|
||||||
|
{/* Start marker */}
|
||||||
|
{item.markerStart === "one" ? (
|
||||||
|
<line x1={6} y1={2} x2={6} y2={10} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1={6} y1={3} x2={12} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||||
|
<line x1={6} y1={6} x2={12} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||||
|
<line x1={6} y1={9} x2={12} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* End marker */}
|
||||||
|
{item.markerEnd === "one" ? (
|
||||||
|
<line x1={30} y1={2} x2={30} y2={10} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1={30} y1={3} x2={24} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||||
|
<line x1={30} y1={6} x2={24} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||||
|
<line x1={30} y1={9} x2={24} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<span className={`text-[11px] ${isActive ? "text-amber-400 font-medium" : "text-text-muted"}`}>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
)})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Powered by React Flow */}
|
||||||
|
<div className="absolute top-0 left-0 z-0 text-[10px] text-text-muted/50 bg-surface/80 px-2 py-0.5 rounded-none pointer-events-none">
|
||||||
|
Powered by React Flow
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export interface LegendItem {
|
||||||
|
cardinality: string;
|
||||||
|
color: string;
|
||||||
|
label: string;
|
||||||
|
markerStart: string;
|
||||||
|
markerEnd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARDINALITY_COLORS: Record<string, string> = {
|
||||||
|
"1:1": "#22c55e",
|
||||||
|
"1:N": "#3b82f6",
|
||||||
|
"N:M": "#f59e0b",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CARDINALITY_LABELS: Record<string, string> = {
|
||||||
|
"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" },
|
||||||
|
];
|
||||||
@@ -40,10 +40,11 @@ export function SelectDropdown({
|
|||||||
setOpen(false);
|
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);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleMouseDown);
|
document.removeEventListener("mousedown", handleMouseDown, true);
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
testConnection,
|
testConnection,
|
||||||
dbConnect,
|
dbConnect,
|
||||||
@@ -9,7 +14,9 @@ import {
|
|||||||
getTableData,
|
getTableData,
|
||||||
executeChange,
|
executeChange,
|
||||||
refreshConnection,
|
refreshConnection,
|
||||||
|
getSchemaGraph,
|
||||||
} from "./commands";
|
} from "./commands";
|
||||||
|
import type { SchemaGraph } from "./types";
|
||||||
|
|
||||||
describe("commands", () => {
|
describe("commands", () => {
|
||||||
it("testConnection has correct signature", () => {
|
it("testConnection has correct signature", () => {
|
||||||
@@ -47,4 +54,17 @@ describe("commands", () => {
|
|||||||
it("refreshConnection returns full tree promise", () => {
|
it("refreshConnection returns full tree promise", () => {
|
||||||
expect(typeof refreshConnection).toBe("function");
|
expect(typeof refreshConnection).toBe("function");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getSchemaGraph", () => {
|
||||||
|
it("is a callable function with correct signature", () => {
|
||||||
|
expect(typeof getSchemaGraph).toBe("function");
|
||||||
|
const result: Promise<SchemaGraph> = getSchemaGraph("conn-1", "public");
|
||||||
|
expect(result).toBeInstanceOf(Promise);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts schema as optional", () => {
|
||||||
|
const result: Promise<SchemaGraph> = getSchemaGraph("conn-1");
|
||||||
|
expect(result).toBeInstanceOf(Promise);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+8
-1
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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";
|
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||||
|
|
||||||
// NOTE on argument key naming:
|
// NOTE on argument key naming:
|
||||||
@@ -140,3 +140,10 @@ export async function getEnums(connectionId: string, schema?: string): Promise<E
|
|||||||
export async function getExtensions(connectionId: string): Promise<ExtensionInfo[]> {
|
export async function getExtensions(connectionId: string): Promise<ExtensionInfo[]> {
|
||||||
return invoke<ExtensionInfo[]>("get_extensions", { connectionId });
|
return invoke<ExtensionInfo[]>("get_extensions", { connectionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSchemaGraph(
|
||||||
|
connectionId: string,
|
||||||
|
schema?: string,
|
||||||
|
): Promise<SchemaGraph> {
|
||||||
|
return invoke<SchemaGraph>("get_schema_graph", { connectionId, schema });
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { DbType } from "./types";
|
|
||||||
|
|
||||||
export const DB_ICONS: Record<DbType, string> = {
|
|
||||||
postgresql: "🐘",
|
|
||||||
mysql: "🐬",
|
|
||||||
redis: "⚡",
|
|
||||||
sqlite: "🗄️",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DB_LABELS: Record<DbType, string> = {
|
|
||||||
postgresql: "PostgreSQL",
|
|
||||||
mysql: "MySQL",
|
|
||||||
redis: "Redis",
|
|
||||||
sqlite: "SQLite",
|
|
||||||
};
|
|
||||||
@@ -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<DbType, string> = {
|
||||||
|
postgresql: `#${siPostgresql.hex}`,
|
||||||
|
mysql: `#${siMysql.hex}`,
|
||||||
|
redis: `#${siRedis.hex}`,
|
||||||
|
sqlite: `#${siSqlite.hex}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// SVG path data for each DB icon
|
||||||
|
const DB_PATHS: Record<DbType, string> = {
|
||||||
|
postgresql: siPostgresql.path,
|
||||||
|
mysql: siMysql.path,
|
||||||
|
redis: siRedis.path,
|
||||||
|
sqlite: siSqlite.path,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DB_LABELS: Record<DbType, string> = {
|
||||||
|
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 <span className="text-lg">❓</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={className}
|
||||||
|
fill={color}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path d={path} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep backward compat for any legacy emoji usage
|
||||||
|
export const DB_ICONS: Record<DbType, string> = {
|
||||||
|
postgresql: "🐘",
|
||||||
|
mysql: "🐬",
|
||||||
|
redis: "⚡",
|
||||||
|
sqlite: "🗄️",
|
||||||
|
};
|
||||||
@@ -11,6 +11,10 @@ import type {
|
|||||||
ChangeStatus,
|
ChangeStatus,
|
||||||
DbViewerTab,
|
DbViewerTab,
|
||||||
ConnectionTestResult,
|
ConnectionTestResult,
|
||||||
|
SchemaGraph,
|
||||||
|
TableNode,
|
||||||
|
GraphColumn,
|
||||||
|
Relationship,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
describe("ActiveView", () => {
|
describe("ActiveView", () => {
|
||||||
@@ -359,3 +363,69 @@ describe("ConnectionTestResult", () => {
|
|||||||
expect(result.error).toBe("Connection refused");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -279,3 +279,39 @@ export interface BackupJob {
|
|||||||
started_at: string;
|
started_at: string;
|
||||||
completed_at: string | null;
|
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[];
|
||||||
|
}
|
||||||
@@ -1 +1,8 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
|
// Polyfill ResizeObserver for jsdom (required by @xyflow/react)
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user