v0.7.7: PG roles/grants, table maintenance, Create/Edit Table + atomic column-reorder rebuild, FK management, table options (#13)

* feat(models): Change::RebuildTable + role/privilege/readiness/maintenance structs (Task 1.1)

* feat(introspection): role/privilege/tablespace/rebuild-readiness query builders (Task 1.2)

* feat(lib): rebuild_table payload, table/role object kinds, v0.7.7 capabilities + types (Task 1.3)

* feat(object_crud): table_ddl builder (create/edit-diff/options/drop) + build_ddl table arm (Task 2.1)

* feat(object_crud): role_ddl builder (create/edit/drop/grant/revoke) + build_ddl role arm (Task 2.2)

* feat(object_crud): rebuild_script assembler + fk/grant/sequence introspection queries (Task 2.3)

* feat(commands): transactional RebuildTable arm + build_rebuild_script + get_table_columns commands (Task 2.4)

* feat(commands): get_roles/privileges/readiness/tablespaces + run_maintenance (Task 2.5)

* feat(commands): role/privilege/rebuild/maintenance typed wrappers (Task 3.1)

* feat(store): roles slice + openFormTab table/role dedup (Task 3.2)

* feat(ui): TableForm (create/edit-diff/rebuild) + cross-schema FK with ON DELETE/UPDATE (Task 4.1)

* feat(ui): RoleForm + RoleDetail + RoleGrantsEditor (Task 4.2)

* feat(ui): wire roles into object registry + explorer (Task 4.3)

* feat(ui): Edit Table + maintenance menu + Create Table toolbar + rebuild badge (Task 4.4)

* chore: bump version to 0.7.7 (Task 5.1)

* docs: sync README/ROADMAP/AGENTS to v0.7.7 (Task 5.2)

* test: v0.7.7 manual smoke checklist (Task 5.3)

* chore: sync Cargo.lock to v0.7.7

* style(ui): match TableForm visual layout to shared object-form rows (remove stray padding)

* feat(ui): table creator grid redesign + per-column FK slide-in panel with relationship controls

* style(ui): table-creator columns grid — bordered cells like the data table view

* feat(ui): table creator — type dropdown, PK/Nullable rules, drag-to-reorder columns (vertical), tidy add-column row

* feat(ui): table creator — single-PK exclusivity, name on top, fixed column widths + contained h-scroll, no-wrap constraints

* style(ui): table creator — constraints column sized to content (no clip), overscroll containment

* style(ui): table creator — overscrollBehavior none on both scroll containers, add-column row py-2

* feat(ui): table creator — centered add-column button, schema dropdown auto-selected to current schema

* style(ui): table creator — columns grid fills container width, scrolls only when too narrow

* feat(ui): table creator — shorthand PG types in dropdown, Auto-Increment only for int-family

* feat(ui): FK panel — animate in/out from the right, flush create-table-style rows

* fix(ui): FK panel — smooth slide (willChange + eased), local column options from the form grid

* feat(ui): FK composer (multi-column, type preview, gated sections) + composite PK + constraints cog in actions col + auto-named FKs

* fix(ui): FK panel blocks unnamed table with hint; Foreign keys section lists staged + DB FKs

* style(ui): FK panel — transparent full-width selects, title↔select borders, 2-col column picker

* style(ui): FK panel — vertical divider between local/ref columns

* style(ui): FK panel — section title padding py-2

* style(ui): FK panel — remove row margins + select/text padding in column picker

* style(ui): FK panel — flush column picker (no section padding), border-r + py-1 cells

* style(ui): FK panel — cell padding + row borders on column picker (user polish)

* feat: FK inline into CREATE TABLE (single staged change in create mode); touch up class

* style(ui): hide FK icon on PK columns; fix grid header/row column alignment

* feat(ui): SQL preview in table creator uses read-only Monaco editor

* feat(ui): Foreign keys section — relation rows with icon + Edit/Remove actions

* style(ui): FK relation row — label, icon, table on one line

* style(ui): FormRow selects transparent like FK panel; test matches FK relation label

* style(ui): form-row focus outline amber-400/20 (subtle)

* style(ui): SQL view — remove px-4 py-3 wrapper padding

* feat(ui): Create Table button label; auto tree refresh after schema-modifying commits; table name on staged ddl card

* fix(roles): cast rolconnlimit::int8 to fix panic deserializing int4 into i64

* fix(privileges): cast privilege_type::text to avoid domain-array panic; aclexplode for sequences/schemas (role_sequence_grants removed in PG15, schema_privileges never existed)

* feat(ui): role privileges sections collapsible (collapsed by default) with 5-entry preview + fade + Show more

* feat(ui): role privileges — collapsed state shows first 5 with fade; expand reveals all

* chore(test): drop unused fireEvent import in RoleDetail test

* style(ui): hide chevron when section has 5 or fewer entries

* docs: README/ROADMAP/AGENTS — v0.7.7 shipped details (roles/grants, table editor, FK composer, privilege explorer); ROADMAP adds v0.7.8 Next up (quick wins)

* docs(readme): collapse Recent changes and Key features into details blocks

* docs(readme): move hero caption under image; align download + comparison table columns

* docs(readme): collapse only body content — keep Recent Changes / Key Features headings visible

* docs(readme): per-subsection collapsibles with meaningful summary labels
This commit is contained in:
2026-08-07 05:51:23 +08:00
committed by GitHub
parent 0c74d77e75
commit 7401e91a12
64 changed files with 6255 additions and 200 deletions
+17 -2
View File
@@ -156,7 +156,7 @@ Cut a release from the **`prod`** branch (never feature branches) by tagging it
**Before tagging**, keep everything in sync: **Before tagging**, keep everything in sync:
- Version number across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` - Version number across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`
- `src/lib/version.test.ts` and `src/lib/docs-coverage.test.ts` if they assert the version - `src/lib/version.test.ts` and `src/lib/docs-coverage.test.ts` if they assert the version
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.6/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md). - **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.7/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step. - **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
### Adding a Tauri Command ### Adding a Tauri Command
@@ -215,6 +215,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel | | SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
| SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths | | SSL/TLS config UI | ✅ | Mode (disable/require/verify-ca/verify-full), cert paths |
| SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity) | | SSL/TLS runtime | ✅ | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** — documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full → VerifyIdentity) |
| Roles & grants (PG) | ✅ | Create/edit/drop PostgreSQL roles and GRANT/REVOKE privileges per object class from a UI (v0.7.7) |
### Home Screen & Organization ### Home Screen & Organization
| Feature | Status | Details | | Feature | Status | Details |
@@ -262,7 +263,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Export toolbar (JSON, CSV, SQL, Markdown) | ✅ | Client-side Blob download of visible rows | | Export toolbar (JSON, CSV, SQL, Markdown) | ✅ | Client-side Blob download of visible rows |
| Auto-refresh timer | ✅ | Configurable interval in settings | | Auto-refresh timer | ✅ | Configurable interval in settings |
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table | | Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table |
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing `drop_table` via the queue — no manual refresh needed | | Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All | | Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) | | Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test | | Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
@@ -273,6 +274,18 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu | | Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
| Cell-level copy (right-click or Ctrl+C) | ✅ | CellContextMenu: Copy / Copy JSON (jsonb) / Edit / Set NULL / Open FK reference + **View Row** and **Select Row** items; right-click or Ctrl/Cmd+C on the focused cell; menu closes on outside click/Esc | | Cell-level copy (right-click or Ctrl+C) | ✅ | CellContextMenu: Copy / Copy JSON (jsonb) / Edit / Set NULL / Open FK reference + **View Row** and **Select Row** items; right-click or Ctrl/Cmd+C on the focused cell; menu closes on outside click/Esc |
| FK reference | ✅ | Small ↗ icon at the start of FK cells opens the FK preview popover (also via context menu Open FK reference); plain click on the cell selects/edits and does not open it | | FK reference | ✅ | Small ↗ icon at the start of FK cells opens the FK preview popover (also via context menu Open FK reference); plain click on the cell selects/edits and does not open it |
| Maintenance actions (VACUUM/ANALYZE/REINDEX) | ✅ | Right-click table → VACUUM / ANALYZE / REINDEX (v0.7.7) |
### Table Editor & Roles (v0.7.7)
| Feature | Status | Details |
| :--- | :---: | :--- |
| Create Table (visual editor) | ✅ | "Create Table…" workspace tab: columns grid (name / type dropdown / default / PK per row, drag-to-reorder, add/remove), single + composite PK (first column auto-PK), shorthand PG types (`int`/`int8`/`int2`/`float8`/`bool`), auto-increment gating, live Monaco SQL preview, transparent selects + form-row focus `amber-400/20` |
| Edit Table (column diff) | ✅ | Same grid on existing tables; Stage diffs old vs new columns → one queue item per statement: `ADD COLUMN`, `DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN … TYPE`, `SET|DROP DEFAULT`, `SET|DROP NOT NULL`; stale-write guard (re-fetches live columns, refuses if changed) |
| Column reorder (atomic rebuild) | ✅ | Drag-to-reorder triggers a table rebuild: single transaction, preserves constraints/indexes/FKs/grants/sequences (copied DDL), fail-closed when triggers/RLS policies/inheritance/partitioning/generated columns are present (`get_table_rebuild_readiness`) |
| FK management | ✅ | Multi-column FK composer (local col → ref col pairs, referenced PKs first, type-match preview `localType → refType`, auto-named constraints, ref-table picker with `name (type)` options); cross-schema references; ON DELETE / ON UPDATE actions; create mode inlines FKs into the single CREATE TABLE change, edit mode stages ALTERs; FK rows in the form with Edit/Remove (queued changes revert, DB FKs stage DROP CONSTRAINT) |
| Table options | ✅ | Tablespace picker (non-system, from `pg_tablespace`) + row-level security toggle in the Options section |
| Roles & grants management | ✅ | Objects → Roles: list non-system roles (attributes incl. connection limit — `rolconnlimit::int8` cast fix), role detail with attribute grid + memberships + privilege explorer; create/edit/drop roles staged through the queue |
| Role privilege explorer | ✅ | Per-role privileges grouped by object class (tables/sequences/routines/schemas/databases) with collapsible sections — collapsed shows first 5 + fade, expand shows all, chevron hidden when ≤5; GRANT/REVOKE composer (object class + schema + name, WITH GRANT OPTION); aclexplode-based queries (`role_sequence_grants` removed in PG 15, `schema_privileges` never existed) |
### Object Explorer (non-table objects) ### Object Explorer (non-table objects)
| Feature | Status | Details | | Feature | Status | Details |
@@ -294,6 +307,8 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
| Object management CRUD | ✅ | Right-click / ⋮ create/edit/drop for every PG object type; create/edit open as **workspace tabs** with a Visual ⇄ SQL toggle, staged through the changes queue with generated-SQL preview (v0.7.6). Enum value removal unsupported (PG has no DROP VALUE) | | Object management CRUD | ✅ | Right-click / ⋮ create/edit/drop for every PG object type; create/edit open as **workspace tabs** with a Visual ⇄ SQL toggle, staged through the changes queue with generated-SQL preview (v0.7.6). Enum value removal unsupported (PG has no DROP VALUE) |
| Objects view tabbed workspace | ✅ | Objects view uses the shared tabbed workspace: object details open as tabs (per-type icons), inline manual query tab, and the changes queue reachable from the tab bar (v0.7.6) | | Objects view tabbed workspace | ✅ | Objects view uses the shared tabbed workspace: object details open as tabs (per-type icons), inline manual query tab, and the changes queue reachable from the tab bar (v0.7.6) |
| 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. | | 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. |
| Create/Edit table + column-diff + rebuild | ✅ | Create Table + Edit Table column-diff editor (ADD/DROP/RENAME/ALTER TYPE/SET|DROP DEFAULT/SET|DROP NOT NULL, staged one-per-queue-item); atomic column-reorder table rebuild in a single transaction, preserving constraints/indexes/FKs/grants/sequences (v0.7.7) |
| Relationships (FK CRUD, cross-schema, ON DELETE/UPDATE) | ✅ | FK create/edit/drop with cross-schema references and ON DELETE/UPDATE actions (v0.7.7) |
### Query Editor ### Query Editor
| Feature | Status | Details | | Feature | Status | Details |
+63 -27
View File
@@ -1,6 +1,9 @@
<p align="center"> <p align="center">
<img src="./screenshots/data-grid.png" alt="Gridline data grid with FK preview" width="92%" style="border-radius: 14px;" /> <img src="./screenshots/data-grid.png" alt="Gridline data grid with FK preview" width="92%" style="border-radius: 14px;" />
</p> </p>
<p>
<sub><b>Data Grid & FK Preview</b> — browse 50+ real demo orders and inspect related rows in one click.</sub>
</p>
<h1>Gridline</h1> <h1>Gridline</h1>
@@ -9,10 +12,6 @@
Unlimited connections, tabs, and saved queries — with first-class <code>pg_dump</code>, <code>pg_restore</code>, and DB-to-DB sync. Unlimited connections, tabs, and saved queries — with first-class <code>pg_dump</code>, <code>pg_restore</code>, and DB-to-DB sync.
</p> </p>
<p>
<sub><b>Data Grid & FK Preview</b> — browse 50+ real demo orders and inspect related rows in one click.</sub>
</p>
<p> <p>
<a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-24C8DB?style=for-the-badge&logo=tauri&logoColor=white" alt="Tauri" /></a> <a href="https://tauri.app/"><img src="https://img.shields.io/badge/Tauri-24C8DB?style=for-the-badge&logo=tauri&logoColor=white" alt="Tauri" /></a>
<a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white" alt="Rust" /></a> <a href="https://www.rust-lang.org/"><img src="https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white" alt="Rust" /></a>
@@ -31,21 +30,21 @@
## Download ## Download
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.6**): Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.7**):
| OS | Architecture | Download | | OS | Architecture | Download |
| :--- | :--- | :--- | | :--------------------------- | :--------------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.6_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_aarch64.dmg) | | **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.7_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_aarch64.dmg) |
| **macOS** | Intel | [Gridline_0.7.6_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64.dmg) | | **macOS** | Intel | [Gridline_0.7.7_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64.dmg) |
| **Windows** | x64 | [Gridline_0.7.6_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64-setup.exe) | | **Windows** | x64 | [Gridline_0.7.7_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64-setup.exe) |
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.6_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.deb) | | **Debian / Ubuntu** | amd64 | [Gridline_0.7.7_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.deb) |
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.6-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline-0.7.6-1.x86_64.rpm) | | **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.7-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline-0.7.7-1.x86_64.rpm) |
| **Other Linux** | amd64 | [Gridline_0.7.6_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.AppImage) | | **Other Linux** | amd64 | [Gridline_0.7.7_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.AppImage) |
> Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings. > Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings.
<!-- <!--
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.6 MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.7
release assets, not at a moving "latest" target. On every new release, release assets, not at a moving "latest" target. On every new release,
update BOTH tables here (Download + Which file should I download?) to the update BOTH tables here (Download + Which file should I download?) to the
new version's asset names, which are tauri-action's default naming: new version's asset names, which are tauri-action's default naming:
@@ -83,6 +82,11 @@ Gridline is built for developers and small teams who manage multiple database en
## Recent Changes ## Recent Changes
<details>
<summary>View the full changelog</summary>
- **v0.7.7** — PostgreSQL **roles & grants** management (create/edit/drop roles with attributes + a per-role privilege explorer across tables/sequences/routines/schemas/databases — collapsible grouped lists with GRANT/REVOKE staging) and **table maintenance** (VACUUM / ANALYZE / REINDEX from the table menu). **Create Table / Edit Table** visual editor: columns grid with type dropdowns and drag-to-reorder, single + composite primary keys, column-diff staging (ADD / DROP / RENAME COLUMN, ALTER TYPE, SET | DROP DEFAULT, SET | DROP NOT NULL — one statement per queue item), atomic column-reorder table rebuild (single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/inheritance/partitioning). **FK management** — multi-column composer with cross-schema references and ON DELETE/UPDATE, inlined into CREATE TABLE; **table options** (tablespace, row-level security); changes queue auto-refreshes the object tree after schema-modifying commits; version bump.
- **v0.7.6** — Full PostgreSQL object management (create/edit/drop for enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints) staged through the changes queue with generated-SQL previews; Enable Keychain toggle wired (default ON, opt-out; OFF = session-only); Objects view upgraded to the shared tabbed workspace (object detail tabs with per-type icons, inline manual query + changes queue); ⌘K object search fixes; version bump. - **v0.7.6** — Full PostgreSQL object management (create/edit/drop for enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints) staged through the changes queue with generated-SQL previews; Enable Keychain toggle wired (default ON, opt-out; OFF = session-only); Objects view upgraded to the shared tabbed workspace (object detail tabs with per-type icons, inline manual query + changes queue); ⌘K object search fixes; version bump.
- **2026-08-05:** v0.7.5 — bundled `pg_dump`/`pg_restore`/`psql` (system-first, bundled fallback) so admin features work with no separate install; schema CRUD (create/rename/drop with CASCADE + dependency warning); Cmd+K object search (current schema, all object types); copy-as-DDL for every browsable object type; `pg_depend` object-dependency view shown before destructive drops. - **2026-08-05:** v0.7.5 — bundled `pg_dump`/`pg_restore`/`psql` (system-first, bundled fallback) so admin features work with no separate install; schema CRUD (create/rename/drop with CASCADE + dependency warning); Cmd+K object search (current schema, all object types); copy-as-DDL for every browsable object type; `pg_depend` object-dependency view shown before destructive drops.
@@ -97,12 +101,17 @@ Gridline is built for developers and small teams who manage multiple database en
> See the full history in the [roadmap](#roadmap) below or the [git log](./commits). > See the full history in the [roadmap](#roadmap) below or the [git log](./commits).
</details>
--- ---
## Key Features ## Key Features
### Connections & Workspace ### Connections & Workspace
<details>
<summary>Show features</summary>
- **URI auto-fill** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` strings and have all fields populate automatically. - **URI auto-fill** — paste `postgres://`, `mysql://`, `sqlite://`, or `redis://` strings and have all fields populate automatically.
- **Provider grid** — pick PostgreSQL, MySQL, SQLite, Redis, Supabase, or NeonDB; managed presets surface in-app setup guides and an SSL hint. - **Provider grid** — pick PostgreSQL, MySQL, SQLite, Redis, Supabase, or NeonDB; managed presets surface in-app setup guides and an SSL hint.
- **Workspace tree** — multi-level folders, color-coded tags, favorites, and recent connections. - **Workspace tree** — multi-level folders, color-coded tags, favorites, and recent connections.
@@ -111,8 +120,13 @@ Gridline is built for developers and small teams who manage multiple database en
- **TLS / SSL** — full PostgreSQL/MySQL TLS modes plus client-certificate support. - **TLS / SSL** — full PostgreSQL/MySQL TLS modes plus client-certificate support.
- **Connection status** — on-demand per-card test with real server version and latency. - **Connection status** — on-demand per-card test with real server version and latency.
</details>
### Schema Explorer ### Schema Explorer
<details>
<summary>Show features</summary>
- **Tables, views, and materialized views** — column metadata, PK/FK, defaults, nullable flags. - **Tables, views, and materialized views** — column metadata, PK/FK, defaults, nullable flags.
- **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation. - **Functions & procedures** — syntax-highlighted source, argument signatures, overload disambiguation.
- **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher. - **Triggers, sequences, enums, extensions** — unified **Objects** view with type switcher.
@@ -122,9 +136,17 @@ Gridline is built for developers and small teams who manage multiple database en
- **Copy as DDL** — `CREATE` DDL for every browsable object type. - **Copy as DDL** — `CREATE` DDL for every browsable object type.
- **Object dependencies** — `pg_depend` "what depends on this?" view before destructive drops. - **Object dependencies** — `pg_depend` "what depends on this?" view before destructive drops.
- **Schema visualizer** — interactive ER diagram with auto-layout, cardinality legend, and collapsible columns. - **Schema visualizer** — interactive ER diagram with auto-layout, cardinality legend, and collapsible columns.
- **Table creator & editor** — build or alter tables from a visual columns grid: type dropdowns, drag-to-reorder, single/composite primary keys, defaults, nullability — with a live SQL preview and column-diff staging.
- **FK management** — create/edit/drop foreign keys with cross-schema references and ON DELETE/UPDATE actions, inlined into CREATE TABLE.
- **Table options** — tablespace and row-level security per table.
</details>
### Data Grid ### Data Grid
<details>
<summary>Show features</summary>
- **Virtualized rows** — handles 100k+ rows via `@tanstack/react-virtual`. - **Virtualized rows** — handles 100k+ rows via `@tanstack/react-virtual`.
- **Server-side filtering & sorting** — pushed to SQL `WHERE`/`ORDER BY`. - **Server-side filtering & sorting** — pushed to SQL `WHERE`/`ORDER BY`.
- **FK preview** — click the ↗ icon on a foreign-key cell to inspect the referenced row or open a filtered tab. - **FK preview** — click the ↗ icon on a foreign-key cell to inspect the referenced row or open a filtered tab.
@@ -135,8 +157,13 @@ Gridline is built for developers and small teams who manage multiple database en
- **Export** — JSON, CSV, SQL, and Markdown downloads of visible rows. - **Export** — JSON, CSV, SQL, and Markdown downloads of visible rows.
- **Auto-refresh** — configurable interval timer. - **Auto-refresh** — configurable interval timer.
</details>
### Query Workbench ### Query Workbench
<details>
<summary>Show features</summary>
- **Monaco SQL editor** — lazy-loaded, with keywords + table/column autocomplete. - **Monaco SQL editor** — lazy-loaded, with keywords + table/column autocomplete.
- **Custom query execution** — arbitrary SQL with destructive-query confirmation. - **Custom query execution** — arbitrary SQL with destructive-query confirmation.
- **Query tabs** — unlimited tabs, close with `Cmd/Ctrl+W`. - **Query tabs** — unlimited tabs, close with `Cmd/Ctrl+W`.
@@ -144,12 +171,21 @@ Gridline is built for developers and small teams who manage multiple database en
- **Saved queries** — name, folder, and manage them in the Queries view. - **Saved queries** — name, folder, and manage them in the Queries view.
- **Changes queue** — stage edits, review generated SQL, revert per change, then commit all. - **Changes queue** — stage edits, review generated SQL, revert per change, then commit all.
</details>
### PostgreSQL Admin Tools ### PostgreSQL Admin Tools
<details>
<summary>Show features</summary>
- **Visual Backup** — `pg_dump` wrapper with format selector, schema filter, no-owner toggle, real-time progress. - **Visual Backup** — `pg_dump` wrapper with format selector, schema filter, no-owner toggle, real-time progress.
- **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation. - **Visual Restore** — `pg_restore` wrapper with clean toggle and destructive confirmation.
- **DB-to-DB Sync** — pipe `pg_dump``pg_restore` between two connections. - **DB-to-DB Sync** — pipe `pg_dump``pg_restore` between two connections.
- **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` ship with the app; system tools are preferred when present, bundled tools are the fallback. - **Bundled client tools** — `pg_dump`/`pg_restore`/`psql` ship with the app; system tools are preferred when present, bundled tools are the fallback.
- **Roles & grants** — create/edit/drop roles with attributes; a per-role privilege explorer grouped by object class (tables, sequences, routines, schemas, databases) with collapsible lists and GRANT/REVOKE staging.
- **Table maintenance** — VACUUM, ANALYZE, and REINDEX from the table menu.
</details>
--- ---
@@ -201,7 +237,7 @@ Gridline is built for developers and small teams who manage multiple database en
Capabilities below are fact-checked against each vendor's official docs and pricing (May 2026). Capabilities below are fact-checked against each vendor's official docs and pricing (May 2026).
| Capability | DB Pro (Free) | Beekeeper (Free) | TablePlus (Free) | Gridline | | Capability | DB Pro (Free) | Beekeeper (Free) | TablePlus (Free) | Gridline |
| :----------------------------- | :--------------------------: | :-----------------------------: | :-------------------------: | :--------------------------------------------------------: | | :----------------------------- | :---------------------------: | :-------------------------------: | :----------------------------------: | :-----------------------------------------------------------------: |
| Open tabs | 3 | Unlimited | 2 | **Unlimited** | | Open tabs | 3 | Unlimited | 2 | **Unlimited** |
| Saved connections | 2 | Unlimited | Unlimited | **Unlimited** | | Saved connections | 2 | Unlimited | Unlimited | **Unlimited** |
| Saved queries | 5 | Unlimited | Unlimited | **Unlimited** | | Saved queries | 5 | Unlimited | Unlimited | **Unlimited** |
@@ -217,14 +253,14 @@ Capabilities below are fact-checked against each vendor's official docs and pric
| Desktop shell size | Electron | Electron (~250 MB) | Native | **Tauri 2.0 (~40 MB)** | | Desktop shell size | Electron | Electron (~250 MB) | Native | **Tauri 2.0 (~40 MB)** |
| Open source | ❌ | ✅ GPLv3 | ❌ | **✅ Apache 2.0** | | Open source | ❌ | ✅ GPLv3 | ❌ | **✅ Apache 2.0** |
*Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overclaims — its free plan caps connections/tabs/saved queries (FAQ inconsistently claims "unlimited local connections") and gates data imports + SSH tunneling to paid, though CSV/JSON export does work on the free tier; Beekeeper's free Community edition genuinely offers unlimited tabs/connections/queries but gates backup/restore, file import, multi-table export, ERD, AI, and premium DB connectors behind paid tiers; TablePlus's free tier includes every feature but caps you at 2 open tabs / 2 windows / 2 advanced filters.* _Notes: DB Pro is an Electron app (launched Nov 2025) whose marketing copy overclaims — its free plan caps connections/tabs/saved queries (FAQ inconsistently claims "unlimited local connections") and gates data imports + SSH tunneling to paid, though CSV/JSON export does work on the free tier; Beekeeper's free Community edition genuinely offers unlimited tabs/connections/queries but gates backup/restore, file import, multi-table export, ERD, AI, and premium DB connectors behind paid tiers; TablePlus's free tier includes every feature but caps you at 2 open tabs / 2 windows / 2 advanced filters._
--- ---
## Tech Stack ## Tech Stack
| Layer | Technology | Role | | Layer | Technology | Role |
| :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------- | | :------------------ | :-------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------- |
| **Desktop shell** | [Tauri 2.0](https://tauri.app) | Native webview container (~40 MB baseline) | | **Desktop shell** | [Tauri 2.0](https://tauri.app) | Native webview container (~40 MB baseline) |
| **Backend** | Rust + [tokio](https://tokio.rs) | Async runtime, connection pooling, CLI execution | | **Backend** | Rust + [tokio](https://tokio.rs) | Async runtime, connection pooling, CLI execution |
| **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | PostgreSQL via tokio-postgres; MySQL via sqlx; SQLite via rusqlite | | **DB drivers** | [sqlx](https://github.com/launchbadge/sqlx) / [tokio-postgres](https://github.com/sfackler/rust-postgres) | PostgreSQL via tokio-postgres; MySQL via sqlx; SQLite via rusqlite |
@@ -258,16 +294,16 @@ Code signing **will be added in the future** (Apple Developer Program + a Window
#### Which file should I download? #### Which file should I download?
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.6**): Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.7**):
| Your system | Download this | Notes | | Your system | Download this | Notes |
| :--- | :--- | :--- | | :------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.6_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_aarch64.dmg) | `aarch64` = Apple's own chip | | macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.7_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_aarch64.dmg) | `aarch64` = Apple's own chip |
| macOS **Intel** | [Gridline_0.7.6_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64.dmg) | `x64` = Intel/AMD | | macOS **Intel** | [Gridline_0.7.7_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64.dmg) | `x64` = Intel/AMD |
| **Windows** (most PCs) | [Gridline_0.7.6_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) | | **Windows** (most PCs) | [Gridline_0.7.7_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
| **Debian / Ubuntu** | [Gridline_0.7.6_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.6_amd64.deb` | | **Debian / Ubuntu** | [Gridline_0.7.7_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.7_amd64.deb` |
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.6-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline-0.7.6-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.6-1.x86_64.rpm` | | **Fedora / RHEL / openSUSE** | [Gridline-0.7.7-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline-0.7.7-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.7-1.x86_64.rpm` |
| **Any other Linux** | [Gridline_0.7.6_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it | | **Any other Linux** | [Gridline_0.7.7_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.7/Gridline_0.7.7_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu****About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run. **Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu****About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
@@ -277,8 +313,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
```bash ```bash
git checkout prod && git pull git checkout prod && git pull
git tag v0.7.6 git tag v0.7.7
git push origin v0.7.6 git push origin v0.7.7
``` ```
GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**. GitHub Actions (`.github/workflows/release.yml`) builds installers for **Apple Silicon, Intel Macs, Windows, and Linux**, then opens a **draft release** on the [Releases](https://github.com/adrianbonpin/gridline/releases) page — review it and hit **Publish release**.
@@ -362,7 +398,7 @@ gridline/
## Roadmap ## Roadmap
The full plan — in-development (v0.7.6), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**. The full plan — next-up (v0.7.7), queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
Highlights of what's next: Highlights of what's next:
+22 -11
View File
@@ -6,6 +6,21 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
--- ---
## ✅ Shipped (0.7.7)
### Admin follow-up
- **PostgreSQL users/roles + grants management** — create/edit/drop roles with attributes; a per-role **privilege explorer** grouped by object class (tables, sequences, routines, schemas, databases) with collapsible sections (5-entry preview + fade, expand to show all) and GRANT/REVOKE staging through the changes queue (DB Pro has this at 0% on their roadmap — a differentiator to hold)
- **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX
### Table & relationship management
- **Create table** — a "Create Table…" tab in the workspace using the same Visual ⇄ SQL flow as object create/edit: a columns grid (name / type / nullable / default / PK per row, type dropdowns with shorthand PG types, drag-to-reorder, add/remove rows) with a live Monaco SQL preview; multi-column PRIMARY KEY on create
- **Edit table (robust column diff)** — open an existing table's columns in the same grid; on Stage, diff old vs new columns and emit the right statements, one per queue item: `ADD COLUMN`, `DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN … TYPE`, `ALTER COLUMN … SET|DROP DEFAULT`, `SET|DROP NOT NULL`
- **FK management** — multi-column FK composer (local column → referenced column pairs, referenced PKs first, type-match preview) with cross-schema references and ON DELETE / ON UPDATE actions; inlined into CREATE TABLE in create mode, separate ALTER staging in edit mode; FK rows in the form with Edit/Remove
- **Related niceties** — column reordering via atomic table rebuild (single transaction, preserves constraints/indexes/FKs/grants/sequences, fail-closed for triggers/RLS/inheritance/partitioning), table options (tablespace, row-level security), changes queue auto-refreshes the object tree after schema-modifying commits
- **Version bump** 0.7.6 → **0.7.7**.
## ✅ Shipped (0.7.6) ## ✅ Shipped (0.7.6)
- **Full Object Management (PostgreSQL)** — create/edit/drop for every PostgreSQL object type (enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints), staged through the changes queue with generated-SQL previews. - **Full Object Management (PostgreSQL)** — create/edit/drop for every PostgreSQL object type (enums, functions, procedures, triggers, sequences, extensions, views, materialized views, indexes, constraints), staged through the changes queue with generated-SQL previews.
@@ -36,19 +51,15 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
**Not in 0.7.0** (deferred, tracked below): Redis key browsing, MySQL Objects/ERD views, backup/restore/sync for MySQL + SQLite, multiple result sets, SSH key-file management, settings import/export, onboarding tour. **Not in 0.7.0** (deferred, tracked below): Redis key browsing, MySQL Objects/ERD views, backup/restore/sync for MySQL + SQLite, multiple result sets, SSH key-file management, settings import/export, onboarding tour.
## 🎯 Next up ## 🎯 Next up (0.7.8) — Quick wins
### Admin follow-up (0.7.7) Scope: small, individually shippable items that round out tooling gaps. Picked to follow the v0.7.7 features milestone; tag **v0.7.8** when done.
- **PostgreSQL users/roles + grants management** — create roles and set privileges from a UI (DB Pro has this at 0% on their roadmap — a differentiator to hold) - **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back)
- **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX - **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling)
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
### Table & relationship management (next) - **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
- **Create table** — a "Create Table…" tab in the workspace using the same Visual ⇄ SQL flow as object create/edit: a columns grid (name / type / nullable / default / PK per row, add/remove rows) with a live SQL preview
- **Edit table (robust column diff)** — open an existing table's columns in the same grid; on Stage, diff old vs new columns and emit the right statements, one per queue item: `ADD COLUMN`, `DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN … TYPE`, `ALTER COLUMN … SET|DROP DEFAULT`, `SET|DROP NOT NULL`
- **Relationships between tables & schemas** — FK create/edit/drop from a table editor (including cross-schema FKs) and schema-level relationship maintenance
- **Related niceties** — column reordering (PG requires a table rebuild — decide semantics), table options (tablespace, row-level security), multi-column PRIMARY KEY on create
## 📋 In the queue ## 📋 In the queue
@@ -0,0 +1,28 @@
# v0.7.7 — Manual Smoke Checklist (release gate)
> Run against a **scratch PostgreSQL** instance. Mark each item only after verifying it live in the app. This checklist gates the release after `feat/v0.7.7-next-up` squash-merges to `prod` and before tagging `v0.7.7`. Source: architectural spec §8 (verbatim).
## Roles & grants
- [ ] Create a role with all options (LOGIN, SUPERUSER, CREATEDB, CREATEROLE, INHERIT, REPLICATION, BYPASSRLS, CONNECTION LIMIT, VALID UNTIL, PASSWORD) + a membership grant to another role.
- [ ] Grant / revoke each privilege class (table, sequence, routine, schema, database) from the role detail grants editor.
- [ ] Drop a role that has dependencies — the confirm dialog lists the dependencies before executing.
## Tables & relationships
- [ ] Create a table with a multi-column PRIMARY KEY.
- [ ] Edit a table: rename a column, add a column, drop a column, change a column type, change default, toggle nullable — each change stages as its own queue item with an `(i/n)` description.
- [ ] Reorder columns → rebuild succeeds (single transaction: constraints, indexes, FKs both directions, grants, owned sequences preserved).
- [ ] Reorder columns on a trigger-bearing table → rebuild refused with the blocker copy (fail-closed).
- [ ] Set tablespace + row-level security via table options.
- [ ] Create a cross-schema FK with ON DELETE CASCADE (referenced table picked from another schema).
- [ ] Edit an FK → 2-item commit (drop + re-add) lands cleanly.
## Maintenance
- [ ] VACUUM, ANALYZE, REINDEX run on a table via the overflow menu — confirm dialog shows the lock-warning copy; success reports duration.
## Regression
- [ ] Demo SQLite database opens and behaves unaffected.
- [ ] Stale-guard: after an external `ALTER TABLE`, editing the table in the app blocks staging with the stale guard.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "gridline", "name": "gridline",
"private": true, "private": true,
"version": "0.7.6", "version": "0.7.7",
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond", "description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1783,7 +1783,7 @@ dependencies = [
[[package]] [[package]]
name = "gridline" name = "gridline"
version = "0.7.6" version = "0.7.7"
dependencies = [ dependencies = [
"chrono", "chrono",
"deadpool-postgres", "deadpool-postgres",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gridline" name = "gridline"
version = "0.7.6" version = "0.7.7"
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond" description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+93 -6
View File
@@ -1864,6 +1864,60 @@ pub async fn get_table_data(
.await .await
} }
/// Headless column introspection for a table, shared by the `get_table_columns`
/// command and integration tests (thin-command principle — no Tauri `State`).
/// Mirrors the `pg_columns_query` row mapping `get_table_data` uses.
pub(crate) async fn get_table_columns_inner(
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
connection_id: &str,
schema: &str,
table: &str,
) -> Result<Vec<crate::models::ColumnInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(crate::db::pool::DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Table columns are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let rows = client
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
Ok(rows
.iter()
.map(|r| {
let is_fk: bool = r.get::<_, Option<String>>(10).is_some();
let fk_schema: Option<String> = r.get(9);
let fk_table: Option<String> = r.get(10);
crate::models::ColumnInfo {
name: r.get(0),
data_type: r.get(1),
is_nullable: r.get::<_, String>(2) == "YES",
is_pk: r.get::<_, Option<String>>(8).as_deref() == Some("PRIMARY KEY"),
is_fk,
fk_ref: if is_fk {
Some((fk_schema.unwrap_or_default(), fk_table.unwrap_or_default()))
} else {
None
},
default_value: r.get::<_, Option<String>>(6),
editable: true,
is_generated: false,
}
})
.collect())
}
#[tauri::command]
pub async fn get_table_columns(
connection_id: String,
schema: String,
table: String,
state: State<'_, crate::AppState>,
) -> Result<Vec<crate::models::ColumnInfo>, String> {
get_table_columns_inner(&state.pool_manager, &connection_id, &schema, &table).await
}
#[tauri::command] #[tauri::command]
pub async fn get_fk_preview( pub async fn get_fk_preview(
connection_id: String, connection_id: String,
@@ -2110,14 +2164,15 @@ pub(crate) fn affected_count_error(n: u64) -> Option<String> {
} }
} }
#[tauri::command] /// Headless change-application shared by the `execute_change` command and
pub async fn execute_change( /// integration tests (thin-command principle — no Tauri `State`).
connection_id: String, pub(crate) async fn execute_change_inner(
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
connection_id: &str,
change: Change, change: Change,
state: State<'_, crate::AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut pm = state.pool_manager.lock().await; let mut pm = pm.lock().await;
match pm.get(&connection_id) { match pm.get(connection_id) {
Some(crate::db::pool::DbHandle::Postgresql(client, _)) => { Some(crate::db::pool::DbHandle::Postgresql(client, _)) => {
// Build the parameterized SQL + bound values from the change. // Build the parameterized SQL + bound values from the change.
let (sql, params): (String, Vec<serde_json::Value>) = match &change { let (sql, params): (String, Vec<serde_json::Value>) = match &change {
@@ -2159,6 +2214,23 @@ pub async fn execute_change(
client.execute(sql, &[]).await.map_err(|e| e.to_string())?; client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
return Ok(()); return Ok(());
} }
Change::RebuildTable { sql, .. } => {
// Single transaction: all-or-nothing rebuild (multi-statement
// DDL only; VACUUM never reaches here). Rolls back on any
// statement failure.
let tx = client
.build_transaction()
.start()
.await
.map_err(|e| e.to_string())?;
tx.batch_execute(sql)
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
tx.commit()
.await
.map_err(|e| sanitize_error(&format!("{e}")))?;
return Ok(());
}
Change::BulkInsert { Change::BulkInsert {
schema, schema,
table, table,
@@ -2254,6 +2326,9 @@ pub async fn execute_change(
Change::Ddl { .. } => { Change::Ddl { .. } => {
return Err("Object management is PostgreSQL-only".to_string()); return Err("Object management is PostgreSQL-only".to_string());
} }
Change::RebuildTable { .. } => {
return Err("Object management is PostgreSQL-only".to_string());
}
Change::BulkInsert { Change::BulkInsert {
table, table,
columns, columns,
@@ -2329,6 +2404,9 @@ pub async fn execute_change(
Change::Ddl { .. } => { Change::Ddl { .. } => {
return Err("Object management is PostgreSQL-only".to_string()); return Err("Object management is PostgreSQL-only".to_string());
} }
Change::RebuildTable { .. } => {
return Err("Object management is PostgreSQL-only".to_string());
}
Change::BulkInsert { Change::BulkInsert {
schema, schema,
table, table,
@@ -2384,6 +2462,15 @@ pub async fn execute_change(
} }
} }
#[tauri::command]
pub async fn execute_change(
connection_id: String,
change: Change,
state: State<'_, crate::AppState>,
) -> Result<(), String> {
execute_change_inner(&state.pool_manager, &connection_id, change).await
}
#[tauri::command] #[tauri::command]
pub async fn refresh_connection( pub async fn refresh_connection(
connection_id: String, connection_id: String,
+39
View File
@@ -0,0 +1,39 @@
use tauri::State;
use crate::db::pool::{ConnectionPoolManager, DbHandle};
use crate::models::MaintenanceResult;
/// Run a table-scoped maintenance command via the SIMPLE query protocol
/// (never inside a transaction — VACUUM cannot run in a transaction block).
pub(crate) async fn run_maintenance_inner(
pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str, action: &str,
) -> Result<MaintenanceResult, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Maintenance is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
crate::db::object_ddl::validate_object_name(&schema)?;
crate::db::object_ddl::validate_object_name(&table)?;
let verb = match action {
"vacuum" => "VACUUM",
"analyze" => "ANALYZE",
"reindex" => "REINDEX TABLE",
_ => return Err(format!("Unknown maintenance action: {action}")),
};
let sql = format!("{} {}.{}", verb,
crate::db::object_ddl::quote_ident(&schema), crate::db::object_ddl::quote_ident(&table));
let start = std::time::Instant::now();
client.simple_query(&sql).await
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
Ok(MaintenanceResult { duration_ms: start.elapsed().as_millis() as i64, message: format!("{} completed on {}.{}", verb, schema, table) })
}
#[tauri::command]
pub async fn run_maintenance(connection_id: String, schema: String, table: String, action: String, state: State<'_, crate::AppState>) -> Result<MaintenanceResult, String> {
run_maintenance_inner(&state.pool_manager, &connection_id, &schema, &table, &action).await
}
#[cfg(test)]
#[path = "maintenance.test.rs"]
mod maintenance_test;
@@ -0,0 +1,28 @@
use crate::db::pool::{ConnectionPoolManager, DbHandle};
/// Connect directly via tokio-postgres (no tunnel) so tests are Tauri-free.
async fn pool() -> (tokio::sync::Mutex<ConnectionPoolManager>, String) {
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap();
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
let (client, conn) = tokio_postgres::connect(
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
tokio_postgres::NoTls,
).await.expect("connect to test PG");
let handle = tokio::spawn(async move { let _ = conn.await; });
let mut pm = ConnectionPoolManager::new();
let id = "test-conn".to_string();
pm.register(&id, DbHandle::Postgresql(client, handle));
(tokio::sync::Mutex::new(pm), id)
}
#[tokio::test]
#[ignore]
async fn run_maintenance_analyze_succeeds() {
let (pm, id) = pool().await;
let res = crate::commands::maintenance::run_maintenance_inner(&pm, &id, "public", "pg_type", "analyze").await.unwrap();
assert!(res.duration_ms >= 0);
assert!(res.message.to_lowercase().contains("analyze"));
}
+1
View File
@@ -5,6 +5,7 @@ pub mod demo;
pub mod folders; pub mod folders;
pub mod import_export; pub mod import_export;
pub mod keychain; pub mod keychain;
pub mod maintenance;
pub mod objects; pub mod objects;
pub mod query; pub mod query;
pub mod schema_graph; pub mod schema_graph;
+269 -1
View File
@@ -1,6 +1,6 @@
use tauri::State; use tauri::State;
use crate::db::pool::{ConnectionPoolManager, DbHandle}; use crate::db::pool::{ConnectionPoolManager, DbHandle};
use crate::db::object_crud::build_ddl; use crate::db::object_crud::{build_ddl, rebuild_script, RebuildConstraint, RebuildFk, RebuildFkIn, RebuildGrant, RebuildIndex, RebuildInput, RebuildOwnedSequence, TableColumn};
use crate::db::object_ddl::*; use crate::db::object_ddl::*;
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo}; use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
@@ -188,6 +188,274 @@ pub async fn get_available_extensions(connection_id: String, state: State<'_, cr
get_available_extensions_inner(&state.pool_manager, &connection_id).await get_available_extensions_inner(&state.pool_manager, &connection_id).await
} }
/// Build a reorder-only table rebuild script (executed transactionally via
/// `execute_change`'s `RebuildTable` arm). Headless inner: locks the pool once,
/// validates the new column list against the live snapshot (names + types must
/// be preserved), assembles a `RebuildInput` from live introspection, and
/// delegates to `rebuild_script`.
pub(crate) async fn build_rebuild_script_inner(
pm: &tokio::sync::Mutex<ConnectionPoolManager>,
connection_id: &str,
schema: &str,
table: &str,
new_columns: serde_json::Value,
) -> Result<String, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let new_cols: Vec<TableColumn> = serde_json::from_value(new_columns).map_err(|e| e.to_string())?;
// 1. live columns — validate reorder-only: the (name,type) multiset must be
// unchanged (attribute edits belong in the diff path, not the rebuild).
let live = client
.query(&crate::db::introspection::pg_columns_query(schema, table), &[])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let mut live_pairs: Vec<(String, String)> = live
.iter()
.map(|r| (r.get::<_, String>(0), r.get::<_, String>(1).trim().to_string()))
.collect();
let mut new_pairs: Vec<(String, String)> = new_cols
.iter()
.map(|c| (c.name.clone(), c.type_.trim().to_string()))
.collect();
live_pairs.sort();
new_pairs.sort();
if live_pairs != new_pairs {
return Err(
"Reorder must preserve column names and types; undo attribute changes or stage a diff"
.into(),
);
}
// 2. assemble RebuildInput from live introspection (one client, all sub-queries).
let fk_out_rows = client
.query(&crate::db::introspection::pg_table_fk_out_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let fk_in_rows = client
.query(&crate::db::introspection::pg_table_fk_in_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let grant_rows = client
.query(&crate::db::introspection::pg_table_grants_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let seq_rows = client
.query(&crate::db::introspection::pg_table_owned_sequences_query(), &[&schema, &table])
.await
.map_err(|e| sanitize(&e.to_string()))?;
let index_rows = client
.query(&crate::db::introspection::pg_indexes_query(schema), &[&schema])
.await
.map_err(|e| sanitize(&e.to_string()))?;
// PK/UNIQUE/CHECK (contype p/u/c) scoped to this table; FKs are carried
// separately as fks_out/fks_in so they are not double-applied.
let constraint_rows = client
.query(
"SELECT c.conname AS name, ns.nspname AS schema, cl.relname AS table_name, \
c.contype::text, pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c \
JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace ns ON cl.relnamespace = ns.oid \
WHERE ns.nspname = $1 AND cl.relname = $2 AND c.contype IN ('p','u','c') \
ORDER BY c.conname",
&[&schema, &table],
)
.await
.map_err(|e| sanitize(&e.to_string()))?;
let input = RebuildInput {
schema: schema.to_string(),
name: table.to_string(),
constraints: constraint_rows
.iter()
.map(|r| RebuildConstraint {
name: r.get(0),
definition: r.get(4),
})
.collect(),
indexes: index_rows
.iter()
.filter(|r| r.get::<_, String>(2) == table)
.map(|r| RebuildIndex {
name: r.get(0),
definition: r.get(3),
})
.collect(),
fks_out: fk_out_rows
.iter()
.map(|r| RebuildFk {
name: r.get(0),
definition: r.get(1),
})
.collect(),
fks_in: fk_in_rows
.iter()
.map(|r| RebuildFkIn {
name: r.get(0),
own_schema: r.get(1),
own_table: r.get(2),
definition: r.get(3),
})
.collect(),
grants: grant_rows
.iter()
.map(|r| RebuildGrant {
grantee: r.get(0),
privileges: r.get(1),
grantable: r.get(2),
})
.collect(),
owned_sequences: seq_rows
.iter()
.map(|r| RebuildOwnedSequence {
seq_schema: r.get(0),
seq_name: r.get(1),
column: r.get(2),
})
.collect(),
};
rebuild_script(&input, &new_cols)
}
#[tauri::command]
pub async fn build_rebuild_script(
connection_id: String,
schema: String,
table: String,
new_columns: serde_json::Value,
state: State<'_, crate::AppState>,
) -> Result<String, String> {
build_rebuild_script_inner(&state.pool_manager, &connection_id, &schema, &table, new_columns)
.await
}
// ---------------------------------------------------------------------------
// Roles, privileges, rebuild readiness, tablespaces
// ---------------------------------------------------------------------------
/// List non-system roles with all attributes, memberships grouped by member role.
pub(crate) async fn get_roles_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::RoleInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Roles are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let roles = client.query(&crate::db::introspection::pg_roles_query(), &[]).await
.map_err(|e| sanitize(&e.to_string()))?;
let mems = client.query(&crate::db::introspection::pg_role_memberships_query(), &[]).await
.map_err(|e| sanitize(&e.to_string()))?;
let mut by_name: std::collections::HashMap<String, crate::models::RoleInfo> = std::collections::HashMap::new();
for r in roles {
let name: String = r.get("rolname");
by_name.insert(name.clone(), crate::models::RoleInfo {
name, superuser: r.get("rolsuper"), inherit: r.get("rolinherit"),
create_db: r.get("rolcreatedb"), create_role: r.get("rolcreaterole"),
can_login: r.get("rolcanlogin"), replication: r.get("rolreplication"),
bypass_rls: r.get("rolbypassrls"), connection_limit: r.get("rolconnlimit"),
valid_until: { let v: String = r.get("rolvaliduntil"); if v.is_empty() { None } else { Some(v) } },
memberships: vec![],
});
}
for m in mems {
let member: String = m.get("member");
if let Some(ri) = by_name.get_mut(&member) {
ri.memberships.push(crate::models::RoleMembership {
role: m.get("role"), member, grantor: m.get("grantor"), admin_option: m.get("admin_option"),
});
}
}
Ok(by_name.into_values().collect())
}
#[tauri::command]
pub async fn get_roles(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::RoleInfo>, String> {
get_roles_inner(&state.pool_manager, &connection_id).await
}
/// All privilege grants for a role across tables, sequences, routines, schemas, and databases.
pub(crate) async fn get_role_privileges_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, role: &str) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Privileges are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
validate_object_name(role)?; // role is interpolated into the privilege queries
let mut out: Vec<crate::models::PrivilegeEntry> = Vec::new();
let push = |out: &mut Vec<crate::models::PrivilegeEntry>, class: &str, schema: Option<String>, name: String, privileges: Vec<String>, grantable: bool| {
out.push(crate::models::PrivilegeEntry { object_class: class.into(), schema, name, privileges, grantable });
};
for row in client.query(&crate::db::introspection::pg_table_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "table", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_sequence_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "sequence", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_routine_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "routine", Some(row.get("schema")), row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_schema_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "schema", None, row.get("name"), row.get("privileges"), row.get("grantable"));
}
for row in client.query(&crate::db::introspection::pg_database_privileges_query(role), &[]).await.map_err(|e| sanitize(&e.to_string()))? {
push(&mut out, "database", None, row.get("name"), row.get("privileges"), row.get("grantable"));
}
Ok(out)
}
#[tauri::command]
pub async fn get_role_privileges(connection_id: String, role: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::PrivilegeEntry>, String> {
get_role_privileges_inner(&state.pool_manager, &connection_id, &role).await
}
/// Check whether a table can be rebuilt (no triggers, policies, inheritance, partitioning, generated columns).
pub(crate) async fn get_table_rebuild_readiness_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, schema: &str, table: &str) -> Result<crate::models::RebuildReadiness, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Rebuild is PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let row = client.query_one(&crate::db::introspection::pg_rebuild_readiness_query(), &[&schema, &table]).await
.map_err(|e| sanitize(&e.to_string()))?;
let mut reasons = Vec::new();
if row.get::<_, bool>("has_triggers") { reasons.push("table has triggers".into()); }
if row.get::<_, bool>("has_policies") { reasons.push("table has RLS policies".into()); }
if row.get::<_, bool>("is_inherits") { reasons.push("table participates in inheritance".into()); }
if row.get::<_, bool>("is_partitioned") { reasons.push("table is partitioned".into()); }
if row.get::<_, bool>("has_generated") { reasons.push("table has generated/identity columns".into()); }
Ok(crate::models::RebuildReadiness { ok: reasons.is_empty(), reasons })
}
#[tauri::command]
pub async fn get_table_rebuild_readiness(connection_id: String, schema: String, table: String, state: State<'_, crate::AppState>) -> Result<crate::models::RebuildReadiness, String> {
get_table_rebuild_readiness_inner(&state.pool_manager, &connection_id, &schema, &table).await
}
/// List non-system tablespaces for the table-options picker.
pub(crate) async fn get_tablespaces_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<crate::models::TablespaceInfo>, String> {
let mut pm = pm.lock().await;
let client = match pm.get(connection_id) {
Some(DbHandle::Postgresql(c, _)) => c,
Some(_) => return Err("Tablespaces are PostgreSQL-only".into()),
None => return Err("Connection not found".into()),
};
let rows = client.query(&crate::db::introspection::pg_tablespaces_query(), &[]).await
.map_err(|e| sanitize(&e.to_string()))?;
Ok(rows.into_iter().map(|r| crate::models::TablespaceInfo { name: r.get("spcname") }).collect())
}
#[tauri::command]
pub async fn get_tablespaces(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<crate::models::TablespaceInfo>, String> {
get_tablespaces_inner(&state.pool_manager, &connection_id).await
}
#[cfg(test)] #[cfg(test)]
#[path = "objects.test.rs"] #[path = "objects.test.rs"]
mod tests; mod tests;
+52
View File
@@ -115,3 +115,55 @@ async fn object_dependencies_for_table_includes_view() {
let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap(); let contents = get_object_dependencies_inner(&pm, &id, "public", "schema", "public").await.unwrap();
assert!(!contents.is_empty(), "public schema should list contents"); assert!(!contents.is_empty(), "public schema should list contents");
} }
#[tokio::test]
#[ignore]
async fn get_roles_returns_current_role_and_memberships() {
let (pm, id) = pool().await;
let roles = crate::commands::objects::get_roles_inner(&pm, &id).await.unwrap();
assert!(roles.iter().any(|r| r.can_login), "at least one login role (the test user)");
}
#[tokio::test]
#[ignore]
async fn rebuild_table_rolls_back_on_failure() {
let (pm, id) = pool().await;
// setup: a table with a PK + one row
{
let mut g = pm.lock().await;
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
c.batch_execute("DROP TABLE IF EXISTS rebuild_t; CREATE TABLE rebuild_t (id int PRIMARY KEY, v text); INSERT INTO rebuild_t VALUES (1,'a');").await.unwrap();
}
}
// build a rebuild script whose final statement intentionally fails (syntax error)
// so the whole transaction rolls back and rebuild_t keeps its row.
let bad_script = "CREATE TABLE _gridline_rb_rebuild_t (id int PRIMARY KEY, v text); \
INSERT INTO _gridline_rb_rebuild_t (id, v) SELECT id, v FROM rebuild_t; \
DROP TABLE rebuild_t; \
ALTER TABLE _gridline_rb_rebuild_t RENAME TO rebuild_t; \
THIS IS NOT SQL;";
let change = crate::models::db_viewer::Change::RebuildTable { id: "rb".into(), sql: bad_script.to_string() };
let res = crate::commands::db_viewer::execute_change_inner(&pm, &id, change).await;
assert!(res.is_err(), "expected rollback (transaction should fail on bad SQL)");
// table still intact
let mut g = pm.lock().await;
if let crate::db::pool::DbHandle::Postgresql(c, _) = g.get(&id).unwrap() {
let row = c.query_one("SELECT count(*) FROM rebuild_t", &[]).await.unwrap();
assert_eq!(row.get::<_, i64>(0), 1, "rollback must preserve the original table");
c.batch_execute("DROP TABLE rebuild_t").await.unwrap();
}
}
#[tokio::test]
#[ignore = "requires live PG"]
async fn get_role_privileges_returns_grants_across_object_classes() {
let (pm, id) = pool().await;
let out = crate::commands::objects::get_role_privileges_inner(&pm, &id, "read_only").await
.unwrap_or_else(|e| panic!("privileges command failed: {e}"));
let classes: std::collections::HashSet<String> =
out.iter().map(|p| p.object_class.clone()).collect();
assert!(classes.contains("table"), "expected a table grant: {out:?}");
assert!(classes.contains("routine"), "expected a routine grant: {out:?}");
assert!(classes.contains("schema"), "expected a schema grant: {out:?}");
assert!(classes.contains("database"), "expected a database grant: {out:?}");
}
+245
View File
@@ -355,6 +355,165 @@ pub fn pg_constraints_query(_schema: &str) -> String {
) )
} }
// ---------------------------------------------------------------------------
// Roles, privileges, tablespaces, rebuild readiness
// ---------------------------------------------------------------------------
/// List non-system roles with all attributes used by the role form.
pub fn pg_roles_query() -> String {
"SELECT rolname, rolsuper, rolinherit, rolcreatedb, rolcreaterole, rolcanlogin, \
rolreplication, rolbypassrls, rolconnlimit::int8, COALESCE(rolvaliduntil::text, '') AS rolvaliduntil \
FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY rolname"
.to_string()
}
/// All role-to-role memberships (member/admin/grantor).
pub fn pg_role_memberships_query() -> String {
"SELECT roleid::regrole::text AS role, member::regrole::text AS member, \
grantor::regrole::text AS grantor, admin_option \
FROM pg_auth_members ORDER BY role"
.to_string()
}
/// Table/view/matview privileges for a grantee, grouped one row per object.
pub fn pg_table_privileges_query(role: &str) -> String {
format!(
"SELECT table_schema AS schema, table_name AS name, \
array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.table_privileges \
WHERE grantee = '{}' AND table_schema NOT IN ('pg_catalog','information_schema') \
GROUP BY table_schema, table_name \
ORDER BY table_schema, table_name",
role
)
}
/// Sequence privileges for a grantee, grouped one row per sequence.
/// aclexplode-based (role_sequence_grants was removed in PG 15).
pub fn pg_sequence_privileges_query(role: &str) -> String {
format!(
"SELECT n.nspname AS schema, c.relname AS name, \
array_agg(p.privilege_type::text) AS privileges, \
bool_or(p.is_grantable) AS grantable \
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace, \
LATERAL aclexplode(c.relacl) p \
WHERE c.relkind = 'S' AND n.nspname NOT IN ('pg_catalog','information_schema') \
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
GROUP BY n.nspname, c.relname \
ORDER BY n.nspname, c.relname",
role
)
}
/// Routine (function/procedure) privileges for a grantee.
pub fn pg_routine_privileges_query(role: &str) -> String {
format!(
"SELECT routine_schema AS schema, routine_name AS name, \
array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.routine_privileges \
WHERE grantee = '{}' \
GROUP BY routine_schema, routine_name \
ORDER BY routine_schema, routine_name",
role
)
}
/// Schema privileges for a grantee (USAGE/CREATE). aclexplode-based —
/// information_schema has no schema_privileges view.
pub fn pg_schema_privileges_query(role: &str) -> String {
format!(
"SELECT n.nspname AS name, \
array_agg(p.privilege_type::text) AS privileges, \
bool_or(p.is_grantable) AS grantable \
FROM pg_namespace n, LATERAL aclexplode(n.nspacl) p \
WHERE n.nspname NOT IN ('pg_catalog','information_schema') \
AND p.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
GROUP BY n.nspname ORDER BY n.nspname",
role
)
}
/// Database privileges for a grantee via aclexplode (broadly compatible; avoids PG15-only view).
pub fn pg_database_privileges_query(role: &str) -> String {
format!(
"SELECT d.datname AS name, array_agg(a.privilege_type) AS privileges, \
bool_or(a.is_grantable) AS grantable \
FROM pg_database d, LATERAL aclexplode(d.datacl) a \
WHERE a.grantee = (SELECT oid FROM pg_roles WHERE rolname = '{}') \
AND d.datistemplate = false \
GROUP BY d.datname \
ORDER BY d.datname",
role
)
}
/// Non-system tablespaces for the table-options picker.
pub fn pg_tablespaces_query() -> String {
"SELECT spcname FROM pg_tablespace WHERE spcname !~ '^pg_' ORDER BY spcname".to_string()
}
/// Single query returning boolean blockers for a table rebuild (triggers, policies,
/// inheritance, partitioning, generated/identity columns). Parameterized via $1/$2.
pub fn pg_rebuild_readiness_query() -> String {
"SELECT \
EXISTS(SELECT 1 FROM pg_trigger t JOIN pg_class c ON t.tgrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2 AND NOT t.tgisinternal) AS has_triggers, \
EXISTS(SELECT 1 FROM pg_policy p JOIN pg_class c ON p.polrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS has_policies, \
EXISTS(SELECT 1 FROM pg_inherits i JOIN pg_class c ON i.inhrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS is_inherits, \
EXISTS(SELECT 1 FROM pg_partitioned_table pt JOIN pg_class c ON pt.partrelid = c.oid \
JOIN pg_namespace n ON c.relnamespace = n.oid \
WHERE n.nspname = $1 AND c.relname = $2) AS is_partitioned, \
EXISTS(SELECT 1 FROM information_schema.columns \
WHERE table_schema = $1 AND table_name = $2 AND is_generated <> '') AS has_generated"
.to_string()
}
/// FKs owned by this table (contype='f'). Parameterized $1 schema, $2 table.
pub fn pg_table_fk_out_query() -> String {
"SELECT c.conname, pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace n ON cl.relnamespace = n.oid \
WHERE n.nspname = $1 AND cl.relname = $2 AND c.contype = 'f'".to_string()
}
/// FKs from other tables referencing this table. Parameterized $1 schema, $2 table.
pub fn pg_table_fk_in_query() -> String {
"SELECT c.conname, cn.nspname AS own_schema, cl.relname AS own_table, \
pg_get_constraintdef(c.oid) AS definition \
FROM pg_constraint c JOIN pg_class cl ON c.conrelid = cl.oid \
JOIN pg_namespace cn ON cl.relnamespace = cn.oid \
JOIN pg_class r ON c.confrelid = r.oid \
JOIN pg_namespace rn ON r.relnamespace = rn.oid \
WHERE rn.nspname = $1 AND r.relname = $2 AND c.contype = 'f'".to_string()
}
/// Grants on this table (all grantees), grouped. Parameterized $1 schema, $2 table.
pub fn pg_table_grants_query() -> String {
"SELECT grantee, array_agg(privilege_type::text) AS privileges, \
bool_or(is_grantable = 'YES') AS grantable \
FROM information_schema.table_privileges \
WHERE table_schema = $1 AND table_name = $2 AND grantee <> 'PUBLIC' \
GROUP BY grantee".to_string()
}
/// Sequences owned by this table's columns (via pg_depend). Parameterized $1 schema, $2 table.
pub fn pg_table_owned_sequences_query() -> String {
"SELECT sn.nspname AS seq_schema, s.relname AS seq_name, a.attname AS column \
FROM pg_depend d JOIN pg_class s ON d.objid = s.oid \
JOIN pg_namespace sn ON s.relnamespace = sn.oid \
JOIN pg_class t ON d.refobjid = t.oid \
JOIN pg_namespace tn ON t.relnamespace = tn.oid \
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid \
WHERE tn.nspname = $1 AND t.relname = $2 AND d.classid = 'pg_class'::regclass AND s.relkind = 'S'".to_string()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -641,4 +800,90 @@ mod tests {
assert!(sql.contains("comment"), "should select comment; got: {sql}"); assert!(sql.contains("comment"), "should select comment; got: {sql}");
assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}"); assert!(sql.contains("ORDER BY name"), "should order by name; got: {sql}");
} }
// ---------------------------------------------------------------
// Roles, privileges, tablespaces, rebuild readiness
// ---------------------------------------------------------------
#[test]
fn pg_roles_query_filters_pg_prefix() {
let sql = pg_roles_query();
assert!(sql.contains("pg_roles"));
assert!(sql.contains("rolname !~ '^pg_'"));
assert!(sql.contains("rolcanlogin"));
assert!(sql.contains("rolconnlimit::int8"));
}
#[test]
fn pg_role_memberships_query_uses_pg_auth_members() {
let sql = pg_role_memberships_query();
assert!(sql.contains("pg_auth_members"));
assert!(sql.contains("admin_option"));
}
#[test]
fn pg_table_privileges_query_filters_grantee_and_aggregates() {
let sql = pg_table_privileges_query("appuser");
assert!(sql.contains("information_schema.table_privileges"));
assert!(sql.contains("grantee = 'appuser'"));
assert!(sql.contains("array_agg"));
assert!(sql.contains("GROUP BY"));
}
#[test]
fn pg_database_privileges_query_uses_aclexplode() {
let sql = pg_database_privileges_query("appuser");
assert!(sql.contains("aclexplode"));
assert!(sql.contains("pg_database"));
assert!(sql.contains("appuser"));
}
#[test]
fn pg_rebuild_readiness_query_checks_blockers() {
let sql = pg_rebuild_readiness_query();
assert!(sql.contains("pg_trigger"));
assert!(sql.contains("pg_policy"));
assert!(sql.contains("pg_inherits"));
assert!(sql.contains("pg_partitioned_table"));
assert!(sql.contains("is_generated"));
}
#[test]
fn pg_tablespaces_query_filters_pg_prefix() {
let sql = pg_tablespaces_query();
assert!(sql.contains("pg_tablespace"));
assert!(sql.contains("spcname !~ '^pg_'"));
}
#[test]
fn pg_table_fk_out_query_uses_pg_constraint_f() {
let sql = pg_table_fk_out_query();
assert!(sql.contains("pg_constraint"));
assert!(sql.contains("contype = 'f'"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
#[test]
fn pg_table_fk_in_query_finds_referencing_tables() {
let sql = pg_table_fk_in_query();
assert!(sql.contains("pg_constraint"));
assert!(sql.contains("confrel"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
#[test]
fn pg_table_grants_query_uses_table_privileges() {
let sql = pg_table_grants_query();
assert!(sql.contains("information_schema.table_privileges"));
assert!(sql.contains("$1") && sql.contains("$2"));
assert!(sql.contains("array_agg"));
}
#[test]
fn pg_table_owned_sequences_query_uses_pg_depend() {
let sql = pg_table_owned_sequences_query();
assert!(sql.contains("pg_depend"));
assert!(sql.contains("pg_class"));
assert!(sql.contains("$1") && sql.contains("$2"));
}
} }
+716 -10
View File
@@ -249,35 +249,55 @@ pub enum ConstraintAction {
Check { expression: String }, Check { expression: String },
Unique { columns: Vec<String> }, Unique { columns: Vec<String> },
PrimaryKey { columns: Vec<String> }, PrimaryKey { columns: Vec<String> },
ForeignKey { columns: Vec<String>, ref_schema: String, ref_table: String, ref_columns: Vec<String> }, ForeignKey { columns: Vec<String>, ref_schema: String, ref_table: String, ref_columns: Vec<String>, on_delete: Option<String>, on_update: Option<String>, deferrable: Option<bool>, initially_deferred: Option<bool> },
Drop, Drop,
} }
pub fn constraint_ddl(p: &ConstraintParams) -> Result<Vec<String>, String> { pub fn constraint_ddl(p: &ConstraintParams) -> Result<Vec<String>, String> {
validate_object_name(&p.schema)?; validate_object_name(&p.schema)?;
validate_object_name(&p.table)?; validate_object_name(&p.table)?;
validate_object_name(&p.name)?;
let table = format!("{}.{}", quote_ident(&p.schema), quote_ident(&p.table)); let table = format!("{}.{}", quote_ident(&p.schema), quote_ident(&p.table));
let name = quote_ident(&p.name);
Ok(vec![match &p.action { Ok(vec![match &p.action {
ConstraintAction::Check { expression } => ConstraintAction::Check { expression } => {
format!("ALTER TABLE {} ADD CONSTRAINT {} CHECK ({})", table, name, validate_expression(expression)?), validate_object_name(&p.name)?;
format!("ALTER TABLE {} ADD CONSTRAINT {} CHECK ({})", table, quote_ident(&p.name), validate_expression(expression)?)
}
ConstraintAction::Unique { columns } => { ConstraintAction::Unique { columns } => {
if columns.is_empty() { return Err("UNIQUE constraint requires a column".into()); } if columns.is_empty() { return Err("UNIQUE constraint requires a column".into()); }
format!("ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})", table, name, quoted_cols(columns)) validate_object_name(&p.name)?;
format!("ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})", table, quote_ident(&p.name), quoted_cols(columns))
} }
ConstraintAction::PrimaryKey { columns } => { ConstraintAction::PrimaryKey { columns } => {
if columns.is_empty() { return Err("PRIMARY KEY requires a column".into()); } if columns.is_empty() { return Err("PRIMARY KEY requires a column".into()); }
format!("ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({})", table, name, quoted_cols(columns)) validate_object_name(&p.name)?;
format!("ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({})", table, quote_ident(&p.name), quoted_cols(columns))
} }
ConstraintAction::ForeignKey { columns, ref_schema, ref_table, ref_columns } => { ConstraintAction::ForeignKey { columns, ref_schema, ref_table, ref_columns, on_delete, on_update, deferrable, initially_deferred } => {
if columns.is_empty() || ref_columns.is_empty() { return Err("FOREIGN KEY requires source and referenced columns".into()); } if columns.is_empty() || ref_columns.is_empty() { return Err("FOREIGN KEY requires source and referenced columns".into()); }
validate_object_name(ref_schema)?; validate_object_name(ref_schema)?;
validate_object_name(ref_table)?; validate_object_name(ref_table)?;
let refq = format!("{}.{}", quote_ident(ref_schema), quote_ident(ref_table)); let refq = format!("{}.{}", quote_ident(ref_schema), quote_ident(ref_table));
format!("ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", table, name, quoted_cols(columns), refq, quoted_cols(ref_columns)) // An empty name is allowed for FKs — PG auto-generates it (ADD FOREIGN KEY …).
let name_clause = if p.name.trim().is_empty() {
String::new()
} else {
validate_object_name(&p.name)?;
format!("CONSTRAINT {} ", quote_ident(&p.name))
};
let mut sql = format!("ALTER TABLE {} ADD {}FOREIGN KEY ({}) REFERENCES {} ({})", table, name_clause, quoted_cols(columns), refq, quoted_cols(ref_columns));
if let Some(d) = on_delete { sql.push_str(&format!(" ON DELETE {}", d)); }
if let Some(u) = on_update { sql.push_str(&format!(" ON UPDATE {}", u)); }
match (deferrable, initially_deferred) {
(Some(true), Some(true)) => sql.push_str(" DEFERRABLE INITIALLY DEFERRED"),
(Some(true), _) => sql.push_str(" DEFERRABLE INITIALLY IMMEDIATE"),
_ => {}
}
sql
}
ConstraintAction::Drop => {
validate_object_name(&p.name)?;
format!("ALTER TABLE {} DROP CONSTRAINT {}", table, quote_ident(&p.name))
} }
ConstraintAction::Drop => format!("ALTER TABLE {} DROP CONSTRAINT {}", table, name),
}]) }])
} }
@@ -369,10 +389,403 @@ pub fn trigger_ddl(p: &TriggerParams) -> Result<Vec<String>, String> {
}]) }])
} }
use std::collections::HashSet;
fn validate_type(t: &str) -> Result<String, String> {
let s = t.trim();
if s.is_empty() { return Err("Column type must not be empty".into()); }
if s.ends_with(';') { return Err("Column type must not end with ';'".into()); }
if s.contains("--") || s.contains("/*") { return Err("Column type must not contain comments".into()); }
Ok(s.to_string())
}
#[derive(Deserialize, Clone)]
pub struct TableColumn {
pub name: String,
#[serde(rename = "type")] pub type_: String,
pub nullable: bool,
pub default: Option<Option<String>>, // null | Some(null) | Some(expr)
pub is_pk: bool,
pub unique: Option<bool>,
}
impl TableColumn {
fn default_sql(&self) -> Option<&str> {
match &self.default { Some(Some(d)) => Some(d.as_str()), _ => None }
}
}
#[derive(Deserialize)]
pub struct TableParams { pub schema: String, pub name: String, pub action: TableAction }
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum TableAction {
Create {
columns: Vec<TableColumn>,
tablespace: Option<String>,
#[serde(default)]
foreign_keys: Vec<TableForeignKey>,
},
Edit { columns: Vec<TableColumn>, old_columns: Vec<TableColumn> },
Options { tablespace: Option<String>, rls: Option<String> },
Drop,
}
#[derive(Deserialize, Clone)]
pub struct TableForeignKey {
pub columns: Vec<String>,
pub ref_schema: String,
pub ref_table: String,
pub ref_columns: Vec<String>,
pub on_delete: Option<String>,
pub on_update: Option<String>,
pub deferrable: Option<bool>,
pub initially_deferred: Option<bool>,
}
fn col_def(c: &TableColumn) -> Result<String, String> {
validate_object_name(&c.name)?;
let ty = validate_type(&c.type_)?;
let mut s = format!("{} {}", quote_ident(&c.name), ty);
if c.unique == Some(true) { s.push_str(" UNIQUE"); }
if !c.nullable { s.push_str(" NOT NULL"); }
if let Some(d) = c.default_sql() { s.push_str(&format!(" DEFAULT {}", validate_expression(d)?)); }
Ok(s)
}
pub fn table_ddl(p: &TableParams) -> Result<Vec<String>, String> {
let q = qual(&p.schema, &p.name)?;
Ok(match &p.action {
TableAction::Create { columns, tablespace, foreign_keys } => {
if columns.is_empty() { return Err("CREATE TABLE requires at least one column".into()); }
let mut seen = HashSet::new();
for c in columns {
validate_object_name(&c.name)?;
if !seen.insert(c.name.clone()) { return Err(format!("Duplicate column name: {}", c.name)); }
}
let mut defs: Vec<String> = columns.iter().map(col_def).collect::<Result<_, _>>()?;
let pk: Vec<String> = columns.iter().filter(|c| c.is_pk).map(|c| quote_ident(&c.name)).collect();
if !pk.is_empty() { defs.push(format!("PRIMARY KEY ({})", pk.join(", "))); }
for fk in foreign_keys {
if fk.columns.is_empty() || fk.ref_columns.is_empty() {
return Err("FOREIGN KEY requires source and referenced columns".into());
}
for c in &fk.columns { validate_object_name(c)?; }
for c in &fk.ref_columns { validate_object_name(c)?; }
validate_object_name(&fk.ref_schema)?;
validate_object_name(&fk.ref_table)?;
let refq = format!("{}.{}", quote_ident(&fk.ref_schema), quote_ident(&fk.ref_table));
let mut s = format!("FOREIGN KEY ({}) REFERENCES {} ({})", quoted_cols(&fk.columns), refq, quoted_cols(&fk.ref_columns));
if let Some(d) = &fk.on_delete { s.push_str(&format!(" ON DELETE {}", d)); }
if let Some(u) = &fk.on_update { s.push_str(&format!(" ON UPDATE {}", u)); }
match (fk.deferrable, fk.initially_deferred) {
(Some(true), Some(true)) => s.push_str(" DEFERRABLE INITIALLY DEFERRED"),
(Some(true), _) => s.push_str(" DEFERRABLE"),
_ => {}
}
defs.push(s);
}
let mut sql = format!("CREATE TABLE {} (\n {}\n)", q, defs.join(",\n "));
if let Some(ts) = tablespace { validate_object_name(ts)?; sql.push_str(&format!(" TABLESPACE {}", quote_ident(ts))); }
vec![sql]
}
TableAction::Edit { columns, old_columns } => table_diff(&q, old_columns, columns)?,
TableAction::Options { tablespace, rls } => {
let mut out = Vec::new();
if let Some(ts) = tablespace { validate_object_name(ts)?; out.push(format!("ALTER TABLE {} SET TABLESPACE {}", q, quote_ident(ts))); }
match rls.as_deref() {
Some("enable") | Some("force") => out.push(format!("ALTER TABLE {} ENABLE ROW LEVEL SECURITY", q)),
_ => {}
}
if rls.as_deref() == Some("force") { out.push(format!("ALTER TABLE {} FORCE ROW LEVEL SECURITY", q)); }
if rls.as_deref() == Some("disable") { out.push(format!("ALTER TABLE {} DISABLE ROW LEVEL SECURITY", q)); }
out
}
TableAction::Drop => vec![format!("DROP TABLE {}", q)],
})
}
fn table_diff(q: &str, old: &[TableColumn], new: &[TableColumn]) -> Result<Vec<String>, String> {
for c in new { validate_object_name(&c.name)?; validate_type(&c.type_)?; }
let mut old_by_name: std::collections::HashMap<&str, &TableColumn> = old.iter().map(|c| (c.name.as_str(), c)).collect();
let mut out: Vec<String> = Vec::new();
let mut used_old: HashSet<usize> = HashSet::new();
// 1. RENAMEs: unmatched new col at ordinal i matching an unmatched old col at ordinal i with identical type/nullable/default
let mut renames: Vec<(usize, String, String)> = Vec::new(); // (old_idx, old_name, new_name)
for (i, nc) in new.iter().enumerate() {
if old_by_name.contains_key(nc.name.as_str()) { continue; }
if let Some(oc) = old.get(i) {
if !used_old.contains(&i) && oc.type_.trim() == nc.type_.trim()
&& oc.nullable == nc.nullable && oc.default_sql() == nc.default_sql() {
renames.push((i, oc.name.clone(), nc.name.clone()));
used_old.insert(i);
old_by_name.remove(oc.name.as_str());
}
}
}
for (_, oldn, newn) in &renames {
out.push(format!("ALTER TABLE {} RENAME COLUMN \"{}\" TO \"{}\"", q, oldn, newn));
}
// 2. ADDs: remaining unmatched new cols
for nc in new {
if old_by_name.contains_key(nc.name.as_str()) || renames.iter().any(|r| r.2 == nc.name) { continue; }
let s = format!("ALTER TABLE {} ADD COLUMN {}", q, col_def(nc)?);
out.push(s);
}
// 3. ALTERs for kept (name-matched) cols
for nc in new {
let Some(oc) = old.iter().find(|c| c.name == nc.name) else { continue; };
if oc.type_.trim() != nc.type_.trim() {
out.push(format!("ALTER TABLE {} ALTER COLUMN {} TYPE {}", q, quote_ident(&nc.name), validate_type(&nc.type_)?));
}
match (oc.default_sql(), nc.default_sql()) {
(None, Some(d)) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", q, quote_ident(&nc.name), validate_expression(d)?)),
(Some(_), None) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", q, quote_ident(&nc.name))),
(Some(a), Some(b)) if a != b => out.push(format!("ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", q, quote_ident(&nc.name), validate_expression(b)?)),
_ => {}
}
match (oc.nullable, nc.nullable) {
(true, false) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", q, quote_ident(&nc.name))),
(false, true) => out.push(format!("ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL", q, quote_ident(&nc.name))),
_ => {}
}
}
// 4. DROPs last: old cols not present in new and not renamed-away
let new_names: HashSet<&str> = new.iter().map(|c| c.name.as_str()).collect();
for oc in old {
if !new_names.contains(oc.name.as_str()) && !renames.iter().any(|r| r.1 == oc.name) {
out.push(format!("ALTER TABLE {} DROP COLUMN {}", q, quote_ident(&oc.name)));
}
}
Ok(out)
}
#[derive(Debug, Clone)]
pub struct RebuildConstraint { pub name: String, pub definition: String }
#[derive(Debug, Clone)]
pub struct RebuildIndex { pub name: String, pub definition: String }
#[derive(Debug, Clone)]
pub struct RebuildFk { pub name: String, pub definition: String }
#[derive(Debug, Clone)]
pub struct RebuildFkIn {
pub name: String,
pub own_schema: String,
pub own_table: String,
pub definition: String,
}
#[derive(Debug, Clone)]
pub struct RebuildGrant {
pub grantee: String,
pub privileges: Vec<String>,
pub grantable: bool,
}
#[derive(Debug, Clone)]
pub struct RebuildOwnedSequence {
pub seq_schema: String,
pub seq_name: String,
pub column: String,
}
/// Everything needed to reconstruct a table's metadata after a reorder-only rebuild.
/// Rebuild is reorder-only: column names + types in `new_columns` must equal the live
/// snapshot (validated by the Task 2.4 command). These structs are assembled in Rust
/// from introspection rows, so they derive Clone/Debug only (no IPC Deserialize).
#[derive(Debug, Clone)]
pub struct RebuildInput {
pub schema: String,
pub name: String,
pub constraints: Vec<RebuildConstraint>,
pub indexes: Vec<RebuildIndex>,
pub fks_out: Vec<RebuildFk>,
pub fks_in: Vec<RebuildFkIn>,
pub grants: Vec<RebuildGrant>,
pub owned_sequences: Vec<RebuildOwnedSequence>,
}
/// Assemble the full rebuild script for a reorder-only table rebuild.
///
/// Order (transactional at the caller — the script itself is BEGIN-free):
/// detach owned sequences → drop inbound FKs → create temp table in the new column
/// order → INSERT..SELECT (cast-free: names/types unchanged) → DROP original → RENAME
/// temp → recreate PK/unique/check constraints → recreate indexes → re-add outbound
/// FKs → re-add inbound FKs → re-grant privileges → re-attach owned sequences.
///
/// The caller executes this via transactional `batch_execute` (Task 2.4), so the
/// script must NOT contain BEGIN/COMMIT.
pub fn rebuild_script(input: &RebuildInput, new_columns: &[TableColumn]) -> Result<String, String> {
validate_object_name(&input.schema)?;
validate_object_name(&input.name)?;
for c in new_columns {
validate_object_name(&c.name)?;
validate_type(&c.type_)?;
}
let q = qual(&input.schema, &input.name)?;
let tmp = qual(&input.schema, &format!("_gridline_rb_{}", input.name))?;
let mut s = String::new();
// 1. detach owned sequences (OWNED BY NONE keeps the sequence alive but decoupled)
for seq in &input.owned_sequences {
validate_object_name(&seq.seq_schema)?;
validate_object_name(&seq.seq_name)?;
s.push_str(&format!("ALTER SEQUENCE \"{}\".\"{}\" OWNED BY NONE;\n", seq.seq_schema, seq.seq_name));
}
// 2. drop FKs IN (from other tables) before DROP TABLE
for fk in &input.fks_in {
validate_object_name(&fk.own_schema)?;
validate_object_name(&fk.own_table)?;
validate_object_name(&fk.name)?;
s.push_str(&format!("ALTER TABLE \"{}\".\"{}\" DROP CONSTRAINT \"{}\";\n", fk.own_schema, fk.own_table, fk.name));
}
// 3. create temp table (new order, NOT NULL + DEFAULT only)
let defs: Vec<String> = new_columns.iter().map(col_def).collect::<Result<_, _>>()?;
s.push_str(&format!("CREATE TABLE {} (\n {}\n);\n", tmp, defs.join(",\n ")));
// 4. copy data (names unchanged = cast-free SELECT in new order)
let cols: Vec<String> = new_columns.iter().map(|c| quote_ident(&c.name)).collect();
s.push_str(&format!("INSERT INTO {} ({}) SELECT {} FROM {};\n", tmp, cols.join(", "), cols.join(", "), q));
// 5. drop old + 6. rename temp
s.push_str(&format!("DROP TABLE {};\n", q));
s.push_str(&format!("ALTER TABLE {} RENAME TO \"{}\";\n", tmp, input.name));
// 7. recreate constraints (PK/unique/check)
for c in &input.constraints {
validate_object_name(&c.name)?;
s.push_str(&format!("ALTER TABLE {} ADD CONSTRAINT \"{}\" {};\n", q, c.name, c.definition));
}
// 8. recreate indexes (pg_get_indexdef references schema.name = the renamed table)
for idx in &input.indexes {
s.push_str(&format!("{};\n", idx.definition));
}
// 9. recreate FKs OUT (this table's FKs)
for fk in &input.fks_out {
validate_object_name(&fk.name)?;
s.push_str(&format!("ALTER TABLE {} ADD CONSTRAINT \"{}\" {};\n", q, fk.name, fk.definition));
}
// 10. recreate FKs IN (other tables)
for fk in &input.fks_in {
s.push_str(&format!("ALTER TABLE \"{}\".\"{}\" ADD CONSTRAINT \"{}\" {};\n", fk.own_schema, fk.own_table, fk.name, fk.definition));
}
// 11. re-apply grants
for g in &input.grants {
validate_object_name(&g.grantee)?;
let opt = if g.grantable { " WITH GRANT OPTION" } else { "" };
s.push_str(&format!("GRANT {} ON {} TO {}{};\n", g.privileges.join(", "), q, quote_ident(&g.grantee), opt));
}
// 12. re-attach owned sequences
for seq in &input.owned_sequences {
validate_object_name(&seq.column)?;
s.push_str(&format!("ALTER SEQUENCE \"{}\".\"{}\" OWNED BY {}.\"{}\";\n", seq.seq_schema, seq.seq_name, q, seq.column));
}
Ok(s.trim_end().to_string())
}
#[derive(Deserialize)]
pub struct RoleParams {
pub schema: String,
pub name: String,
pub action: RoleAction,
}
#[derive(Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum RoleAction {
Create { login: bool, superuser: bool, createdb: bool, createrole: bool, inherit: bool,
replication: bool, bypassrls: bool, connection_limit: i64, valid_until: String,
password: String, members: Vec<String> },
Edit { login: bool, superuser: bool, createdb: bool, createrole: bool, inherit: bool,
replication: bool, bypassrls: bool, connection_limit: i64, valid_until: String,
password: String, members: Vec<String> },
Drop,
Grant { object_class: String, object_schema: Option<String>, object_name: String,
privileges: Vec<String>, grantee: String, grant_option: bool },
Revoke { object_class: String, object_schema: Option<String>, object_name: String,
privileges: Vec<String>, grantee: String, grant_option: bool },
}
/// Compose the option-clause portion of CREATE/ALTER ROLE.
fn role_options(a: &RoleAction) -> String {
let (login, superuser, createdb, createrole, inherit, replication, bypassrls, conn, until, pw) = match a {
RoleAction::Create { login, superuser, createdb, createrole, inherit, replication, bypassrls, connection_limit, valid_until, password, .. }
| RoleAction::Edit { login, superuser, createdb, createrole, inherit, replication, bypassrls, connection_limit, valid_until, password, .. } =>
(*login, *superuser, *createdb, *createrole, *inherit, *replication, *bypassrls, *connection_limit, valid_until.clone(), password.clone()),
_ => return String::new(),
};
let mut o: Vec<String> = Vec::new();
if login { o.push("LOGIN".into()); }
if superuser { o.push("SUPERUSER".into()); }
if createdb { o.push("CREATEDB".into()); }
if createrole { o.push("CREATEROLE".into()); }
o.push(if inherit { "INHERIT".into() } else { "NOINHERIT".into() });
if replication { o.push("REPLICATION".into()); }
if bypassrls { o.push("BYPASSRLS".into()); }
o.push(format!("CONNECTION LIMIT {}", conn));
if !pw.is_empty() { o.push(format!("PASSWORD '{}'", pw.replace('\'', "''"))); }
if !until.is_empty() { o.push(format!("VALID UNTIL '{}'", until.replace('\'', "''"))); }
o.join(" ")
}
/// Qualified object reference for GRANT/REVOKE. Schemaless classes (schema, database)
/// quote the bare name; everything else is emitted as schema.name.
fn grant_object_ref(class: &str, schema: Option<&str>, name: &str) -> Result<String, String> {
validate_object_name(name)?;
match class {
"schema" | "database" => Ok(quote_ident(name)),
_ => {
let s = match schema {
Some(s) => { validate_object_name(s)?; quote_ident(s) }
None => String::new(),
};
Ok(format!("{}.{}", s, quote_ident(name)))
}
}
}
pub fn role_ddl(p: &RoleParams) -> Result<Vec<String>, String> {
if !p.name.is_empty() { validate_object_name(&p.name)?; }
let name = quote_ident(&p.name);
Ok(match &p.action {
RoleAction::Create { members, .. } => {
let mut out = vec![format!("CREATE ROLE {} {}", name, role_options(&p.action))];
for m in members {
validate_object_name(m)?;
out.push(format!("GRANT {} TO {}", quote_ident(m), name));
}
out
}
RoleAction::Edit { members, .. } => {
// membership edits are advisory in v1 (no diff); skip
let _ = members;
vec![format!("ALTER ROLE {} {}", name, role_options(&p.action))]
}
RoleAction::Drop => vec![format!("DROP ROLE {}", name)],
RoleAction::Grant { object_class, object_schema, object_name, privileges, grantee, grant_option } => {
validate_object_name(grantee)?;
let obj = grant_object_ref(object_class, object_schema.as_deref(), object_name)?;
let privs = privileges.join(", ");
let opt = if *grant_option { " WITH GRANT OPTION" } else { "" };
vec![format!("GRANT {} ON {} TO {}{}", privs, obj, quote_ident(grantee), opt)]
}
RoleAction::Revoke { object_class, object_schema, object_name, privileges, grantee, grant_option } => {
validate_object_name(grantee)?;
let obj = grant_object_ref(object_class, object_schema.as_deref(), object_name)?;
let privs = privileges.join(", ");
let opt = if *grant_option { " GRANT OPTION FOR" } else { "" };
vec![format!("REVOKE{} {} ON {} FROM {}", opt, privs, obj, quote_ident(grantee))]
}
})
}
/// Dispatch a DDL build by kind. `params` is the JSON payload from the frontend. /// Dispatch a DDL build by kind. `params` is the JSON payload from the frontend.
/// Returns one or more single SQL statements. /// Returns one or more single SQL statements.
pub fn build_ddl(kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> { pub fn build_ddl(kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
match kind { match kind {
"role" => {
let p: RoleParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
role_ddl(&p)
}
"sequence" => { "sequence" => {
let p: SequenceParams = serde_json::from_value(params).map_err(|e| e.to_string())?; let p: SequenceParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
sequence_ddl(&p) sequence_ddl(&p)
@@ -406,6 +819,10 @@ pub fn build_ddl(kind: &str, params: serde_json::Value) -> Result<Vec<String>, S
let p: TriggerParams = serde_json::from_value(params).map_err(|e| e.to_string())?; let p: TriggerParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
trigger_ddl(&p) trigger_ddl(&p)
} }
"table" => {
let p: TableParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
table_ddl(&p)
}
other => Err(format!("Unsupported object kind: {other}")), other => Err(format!("Unsupported object kind: {other}")),
} }
} }
@@ -642,6 +1059,43 @@ mod tests {
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\")"]); vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\")"]);
} }
#[test]
fn constraint_foreign_key_with_actions() {
let p = serde_json::json!({
"schema": "public", "table": "orders", "name": "fk_user",
"action": {
"op": "foreign_key",
"columns": ["user_id"],
"ref_schema": "public",
"ref_table": "users",
"ref_columns": ["id"],
"on_delete": "CASCADE",
"on_update": "SET NULL",
"deferrable": true,
"initially_deferred": true
}
});
assert_eq!(build_ddl("constraint", p).unwrap(),
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\") ON DELETE CASCADE ON UPDATE SET NULL DEFERRABLE INITIALLY DEFERRED"]);
}
#[test]
fn constraint_foreign_key_empty_name_auto_names() {
let p = serde_json::json!({
"schema": "public", "table": "orders", "name": "",
"action": {
"op": "foreign_key",
"columns": ["user_id"],
"ref_schema": "public",
"ref_table": "users",
"ref_columns": ["id"],
"on_delete": "CASCADE"
}
});
assert_eq!(build_ddl("constraint", p).unwrap(),
vec!["ALTER TABLE \"public\".\"orders\" ADD FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\") ON DELETE CASCADE"]);
}
#[test] #[test]
fn constraint_drop() { fn constraint_drop() {
let p = serde_json::json!({ "schema": "public", "table": "orders", "name": "ck_pos", "action": { "op": "drop" } }); let p = serde_json::json!({ "schema": "public", "table": "orders", "name": "ck_pos", "action": { "op": "drop" } });
@@ -724,4 +1178,256 @@ mod tests {
assert_eq!(build_ddl("trigger", base("disable")).unwrap(), vec!["ALTER TABLE \"public\".\"orders\" DISABLE TRIGGER \"tr_audit\""]); assert_eq!(build_ddl("trigger", base("disable")).unwrap(), vec!["ALTER TABLE \"public\".\"orders\" DISABLE TRIGGER \"tr_audit\""]);
assert_eq!(build_ddl("trigger", base("drop")).unwrap(), vec!["DROP TRIGGER \"tr_audit\" ON \"public\".\"orders\""]); assert_eq!(build_ddl("trigger", base("drop")).unwrap(), vec!["DROP TRIGGER \"tr_audit\" ON \"public\".\"orders\""]);
} }
#[test]
fn table_create_multi_col_pk_and_default() {
let p = serde_json::json!({
"schema": "public", "name": "users",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
{ "name": "email", "type": "text", "nullable": false, "default": null, "is_pk": false }
], "tablespace": null }
});
let sql = build_ddl("table", p).unwrap();
assert_eq!(sql, vec![
"CREATE TABLE \"public\".\"users\" (\n \"id\" integer NOT NULL,\n \"email\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)"
]);
}
#[test]
fn table_create_with_tablespace() {
let p = serde_json::json!({
"schema": "public", "name": "t",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true }
], "tablespace": "fastdisk" }
});
let sql = build_ddl("table", p).unwrap();
assert!(sql[0].ends_with(" TABLESPACE \"fastdisk\""));
}
#[test]
fn table_create_unique_column_emits_unique_keyword() {
let p = serde_json::json!({
"schema": "public", "name": "users",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
{ "name": "email", "type": "text", "nullable": false, "default": null, "is_pk": false, "unique": true }
], "tablespace": null }
});
let sql = build_ddl("table", p).unwrap();
assert!(sql[0].contains("\"email\" text UNIQUE NOT NULL"), "expected UNIQUE in column def; got: {}", sql[0]);
}
#[test]
fn table_column_deserialization_ignores_unknown_fields() {
let p = serde_json::json!({
"schema": "public", "name": "t",
"action": { "op": "create", "columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true, "params": "(50)", "auto_increment": true }
], "tablespace": null }
});
let sql = build_ddl("table", p).unwrap();
assert!(sql[0].contains("\"id\" integer NOT NULL"), "expected column def; got: {}", sql[0]);
}
#[test]
fn table_create_rejects_empty_and_duplicate_columns() {
let empty = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "create", "columns": [], "tablespace": null } });
assert!(build_ddl("table", empty).is_err());
let dup = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "create", "columns": [
{ "name": "id", "type": "int", "nullable": false, "default": null, "is_pk": true },
{ "name": "id", "type": "int", "nullable": false, "default": null, "is_pk": false }
], "tablespace": null } });
assert!(build_ddl("table", dup).is_err());
}
#[test]
fn table_create_with_inline_foreign_key() {
let p = serde_json::json!({
"schema": "public", "name": "products",
"action": { "op": "create",
"columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
{ "name": "category_id", "type": "integer", "nullable": true, "default": null, "is_pk": false }
],
"tablespace": null,
"foreign_keys": [
{ "columns": ["category_id"], "ref_schema": "public", "ref_table": "categories",
"ref_columns": ["id"], "on_delete": "CASCADE", "on_update": "SET NULL",
"deferrable": true, "initially_deferred": false }
]
}
});
assert_eq!(build_ddl("table", p).unwrap(), vec![
"CREATE TABLE \"public\".\"products\" (\n \"id\" integer NOT NULL,\n \"category_id\" integer,\n PRIMARY KEY (\"id\"),\n FOREIGN KEY (\"category_id\") REFERENCES \"public\".\"categories\" (\"id\") ON DELETE CASCADE ON UPDATE SET NULL DEFERRABLE\n)"
]);
}
#[test]
fn table_edit_emits_rename_add_alter_drop_in_order() {
let p = serde_json::json!({
"schema": "public", "name": "users",
"action": { "op": "edit",
"old_columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
{ "name": "name", "type": "text", "nullable": true, "default": null, "is_pk": false },
{ "name": "age", "type": "int", "nullable": true, "default": null, "is_pk": false }
],
"columns": [
{ "name": "id", "type": "integer", "nullable": false, "default": null, "is_pk": true },
{ "name": "label", "type": "text", "nullable": true, "default": null, "is_pk": false },
{ "name": "email", "type": "text", "nullable": false, "default": "'x'", "is_pk": false }
]
}
});
let sql = build_ddl("table", p).unwrap();
let joined = sql.join("\n");
let rename_idx = joined.find("ALTER TABLE \"public\".\"users\" RENAME COLUMN \"name\" TO \"label\"").unwrap();
let add_idx = joined.find("ALTER TABLE \"public\".\"users\" ADD COLUMN \"email\" text NOT NULL DEFAULT 'x'").unwrap();
let drop_idx = joined.find("ALTER TABLE \"public\".\"users\" DROP COLUMN \"age\"").unwrap();
assert!(rename_idx < add_idx, "RENAME before ADD; got: {}", joined);
assert!(add_idx < drop_idx, "ADD before DROP; got: {}", joined);
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"users\" RENAME COLUMN \"name\" TO \"label\""));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"users\" ADD COLUMN \"email\" text NOT NULL DEFAULT 'x'"));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"users\" DROP COLUMN \"age\""));
}
#[test]
fn table_edit_emits_alter_type_default_notnull() {
let p = serde_json::json!({
"schema": "public", "name": "t",
"action": { "op": "edit",
"old_columns": [ { "name": "c", "type": "int", "nullable": true, "default": null, "is_pk": false } ],
"columns": [ { "name": "c", "type": "bigint", "nullable": false, "default": "0", "is_pk": false } ]
}
});
let sql = build_ddl("table", p).unwrap();
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"c\" TYPE bigint"));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"c\" SET DEFAULT 0"));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"c\" SET NOT NULL"));
}
#[test]
fn table_options_rls_and_tablespace() {
let p = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "options", "tablespace": "fastdisk", "rls": "force" } });
let sql = build_ddl("table", p).unwrap();
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" SET TABLESPACE \"fastdisk\""));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" ENABLE ROW LEVEL SECURITY"));
assert!(sql.iter().any(|s| s == "ALTER TABLE \"public\".\"t\" FORCE ROW LEVEL SECURITY"));
}
#[test]
fn table_drop() {
let p = serde_json::json!({ "schema": "public", "name": "t", "action": { "op": "drop" } });
assert_eq!(build_ddl("table", p).unwrap(), vec!["DROP TABLE \"public\".\"t\""]);
}
#[test]
fn role_create_with_options_and_membership() {
let p = serde_json::json!({
"schema": "", "name": "app",
"action": { "op": "create", "login": true, "superuser": false, "createdb": true,
"createrole": false, "inherit": true, "replication": false, "bypassrls": false,
"connection_limit": 10, "valid_until": "", "password": "s3cr3t", "members": ["reader"] }
});
let sql = build_ddl("role", p).unwrap();
assert_eq!(sql[0], "CREATE ROLE \"app\" LOGIN CREATEDB INHERIT CONNECTION LIMIT 10 PASSWORD 's3cr3t'");
assert!(sql.iter().any(|s| s == "GRANT \"reader\" TO \"app\""));
}
#[test]
fn role_create_escapes_password_literal() {
let p = serde_json::json!({ "schema": "", "name": "r", "action": { "op": "create",
"login": false, "superuser": false, "createdb": false, "createrole": false, "inherit": true,
"replication": false, "bypassrls": false, "connection_limit": -1, "valid_until": "", "password": "a'b", "members": [] } });
let sql = build_ddl("role", p).unwrap();
assert!(sql[0].contains("PASSWORD 'a''b'"));
}
#[test]
fn role_edit_blank_password_omits_password_clause() {
let p = serde_json::json!({ "schema": "", "name": "app",
"action": { "op": "edit", "login": true, "superuser": false, "createdb": false,
"createrole": false, "inherit": true, "replication": false, "bypassrls": false,
"connection_limit": -1, "valid_until": "2027-01-01", "password": "", "members": [] } });
let sql = build_ddl("role", p).unwrap();
assert!(sql[0].contains("ALTER ROLE \"app\""));
assert!(!sql[0].contains("PASSWORD")); // blank = keep
assert!(sql[0].contains("VALID UNTIL '2027-01-01'"));
}
#[test]
fn role_drop() {
let p = serde_json::json!({ "schema": "", "name": "app", "action": { "op": "drop" } });
assert_eq!(build_ddl("role", p).unwrap(), vec!["DROP ROLE \"app\""]);
}
#[test]
fn role_grant_and_revoke() {
let g = serde_json::json!({ "schema": "", "name": "",
"action": { "op": "grant", "object_class": "table", "object_schema": "public", "object_name": "users",
"privileges": ["SELECT", "INSERT"], "grantee": "app", "grant_option": false } });
assert_eq!(build_ddl("role", g).unwrap(), vec!["GRANT SELECT, INSERT ON \"public\".\"users\" TO \"app\""]);
let r = serde_json::json!({ "schema": "", "name": "",
"action": { "op": "revoke", "object_class": "table", "object_schema": "public", "object_name": "users",
"privileges": ["SELECT"], "grantee": "app", "grant_option": false } });
assert_eq!(build_ddl("role", r).unwrap(), vec!["REVOKE SELECT ON \"public\".\"users\" FROM \"app\""]);
}
#[test]
fn role_grant_rejects_bad_identifier() {
let p = serde_json::json!({ "schema": "", "name": "",
"action": { "op": "grant", "object_class": "table", "object_schema": "public", "object_name": "a; DROP",
"privileges": ["SELECT"], "grantee": "app", "grant_option": false } });
assert!(build_ddl("role", p).is_err());
}
#[test]
fn rebuild_script_preserves_order_and_recreates_fk_index_grant() {
let input = RebuildInput {
schema: "public".into(), name: "users".into(),
constraints: vec![ RebuildConstraint { name: "users_pkey".into(), definition: "PRIMARY KEY (id)".into() } ],
indexes: vec![ RebuildIndex { name: "users_email_key".into(), definition: "CREATE UNIQUE INDEX users_email_key ON public.users (email)".into() } ],
fks_out: vec![],
fks_in: vec![ RebuildFkIn { name: "orders_user_fk".into(), own_schema: "public".into(), own_table: "orders".into(), definition: "FOREIGN KEY (user_id) REFERENCES public.users(id)".into() } ],
grants: vec![ RebuildGrant { grantee: "reader".into(), privileges: vec!["SELECT".into()], grantable: false } ],
owned_sequences: vec![],
};
let new = vec![ TableColumn { name: "id".into(), type_: "integer".into(), nullable: false, default: None, is_pk: false, unique: None },
TableColumn { name: "email".into(), type_: "text".into(), nullable: true, default: None, is_pk: false, unique: None } ];
let script = rebuild_script(&input, &new).unwrap();
assert!(script.contains("ALTER TABLE \"public\".\"orders\" DROP CONSTRAINT \"orders_user_fk\""), "drop fks_in first; got: {script}");
assert!(script.contains("CREATE TABLE \"public\".\"_gridline_rb_users\" ("));
assert!(script.contains("INSERT INTO \"public\".\"_gridline_rb_users\" (\"id\", \"email\") SELECT \"id\", \"email\" FROM \"public\".\"users\""));
assert!(script.contains("DROP TABLE \"public\".\"users\""));
assert!(script.contains("ALTER TABLE \"public\".\"_gridline_rb_users\" RENAME TO \"users\""));
assert!(script.contains("ALTER TABLE \"public\".\"users\" ADD CONSTRAINT \"users_pkey\" PRIMARY KEY (id)"));
assert!(script.contains("CREATE UNIQUE INDEX users_email_key ON public.users (email)"));
assert!(script.contains("ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"orders_user_fk\" FOREIGN KEY (user_id) REFERENCES public.users(id)"));
assert!(script.contains("GRANT SELECT ON \"public\".\"users\" TO \"reader\""));
// ordering: drop fks_in before DROP TABLE; DROP before RENAME; RENAME before recreate
let d_fk = script.find("DROP CONSTRAINT \"orders_user_fk\"").unwrap();
let drop = script.find("DROP TABLE \"public\".\"users\"").unwrap();
let rename = script.find("RENAME TO \"users\"").unwrap();
let addcon = script.find("ADD CONSTRAINT \"users_pkey\"").unwrap();
assert!(d_fk < drop && drop < rename && rename < addcon, "ordering wrong; got: {script}");
}
#[test]
fn rebuild_script_detaches_and_reattaches_owned_sequence() {
let input = RebuildInput {
schema: "public".into(), name: "t".into(), constraints: vec![], indexes: vec![],
fks_out: vec![], fks_in: vec![], grants: vec![],
owned_sequences: vec![ RebuildOwnedSequence { seq_schema: "public".into(), seq_name: "t_id_seq".into(), column: "id".into() } ],
};
let new = vec![ TableColumn { name: "id".into(), type_: "integer".into(), nullable: false, default: Some(Some("nextval('t_id_seq'::regclass)".into())), is_pk: false, unique: None } ];
let script = rebuild_script(&input, &new).unwrap();
assert!(script.contains("ALTER SEQUENCE \"public\".\"t_id_seq\" OWNED BY NONE"));
assert!(script.contains("ALTER SEQUENCE \"public\".\"t_id_seq\" OWNED BY \"public\".\"t\".\"id\""));
let detach = script.find("OWNED BY NONE").unwrap();
let drop = script.find("DROP TABLE").unwrap();
let attach = script.find("OWNED BY \"public\".\"t\".\"id\"").unwrap();
assert!(detach < drop && drop < attach, "detach before drop before reattach; got: {script}");
}
} }
+5 -1
View File
@@ -20,7 +20,7 @@ pub struct AppState {
} }
use commands::{ use commands::{
backup, connections, db_viewer, demo, folders, import_export, keychain, objects, query, backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
schema_graph, settings, tags, schema_graph, settings, tags,
}; };
@@ -105,6 +105,7 @@ pub fn run() {
db_viewer::get_schemas, db_viewer::get_schemas,
db_viewer::get_tables, db_viewer::get_tables,
db_viewer::get_table_data, db_viewer::get_table_data,
db_viewer::get_table_columns,
db_viewer::get_fk_preview, db_viewer::get_fk_preview,
db_viewer::execute_change, db_viewer::execute_change,
db_viewer::get_table_ddl, db_viewer::get_table_ddl,
@@ -123,7 +124,10 @@ pub fn run() {
objects::get_object_ddl, objects::get_object_ddl,
objects::get_object_dependencies, objects::get_object_dependencies,
objects::build_object_ddl, objects::build_object_ddl,
objects::build_rebuild_script,
objects::get_available_extensions, objects::get_available_extensions,
objects::get_roles, objects::get_role_privileges, objects::get_table_rebuild_readiness, objects::get_tablespaces,
maintenance::run_maintenance,
keychain::save_connection_password, keychain::save_connection_password,
keychain::get_connection_password, keychain::get_connection_password,
keychain::delete_connection_password, keychain::delete_connection_password,
+112 -1
View File
@@ -146,6 +146,55 @@ pub struct DependencyInfo {
pub name: String, // resolved dependent object name pub name: String, // resolved dependent object name
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleMembership {
pub role: String,
pub member: String,
pub grantor: String,
pub admin_option: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleInfo {
pub name: String,
pub superuser: bool,
pub inherit: bool,
pub create_db: bool,
pub create_role: bool,
pub can_login: bool,
pub replication: bool,
pub bypass_rls: bool,
pub connection_limit: i64,
pub valid_until: Option<String>,
pub memberships: Vec<RoleMembership>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivilegeEntry {
pub object_class: String, // "table" | "sequence" | "routine" | "schema" | "database"
pub schema: Option<String>,
pub name: String,
pub privileges: Vec<String>,
pub grantable: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RebuildReadiness {
pub ok: bool,
pub reasons: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MaintenanceResult {
pub duration_ms: i64,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TablespaceInfo {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum Change { pub enum Change {
@@ -197,6 +246,10 @@ pub enum Change {
id: String, id: String,
sql: String, sql: String,
}, },
RebuildTable {
id: String,
sql: String,
},
} }
impl Change { impl Change {
@@ -209,7 +262,8 @@ impl Change {
| Change::BulkInsert { id, .. } | Change::BulkInsert { id, .. }
| Change::DropTable { id, .. } | Change::DropTable { id, .. }
| Change::EmptyTable { id, .. } | Change::EmptyTable { id, .. }
| Change::Ddl { id, .. } => id, | Change::Ddl { id, .. }
| Change::RebuildTable { id, .. } => id,
} }
} }
} }
@@ -619,4 +673,61 @@ mod tests {
assert!(json.contains("\"deptype\":\"n\"")); assert!(json.contains("\"deptype\":\"n\""));
assert!(json.contains("v_users")); assert!(json.contains("v_users"));
} }
#[test]
fn change_rebuild_table_serializes_with_snake_case_tag() {
let change = Change::RebuildTable {
id: "chg-rb1".to_string(),
sql: "CREATE TABLE _t ...".to_string(),
};
let json = serde_json::to_string(&change).unwrap();
assert!(
json.contains(r#""type":"rebuild_table""#),
"RebuildTable should serialize with tag 'rebuild_table'; got: {json}"
);
assert_eq!(change.id(), "chg-rb1");
}
#[test]
fn change_rebuild_table_roundtrip() {
let json = serde_json::json!({
"type": "rebuild_table", "id": "rb", "sql": "SELECT 1"
});
let c: Change = serde_json::from_value(json).unwrap();
match c {
Change::RebuildTable { sql, .. } => assert_eq!(sql, "SELECT 1"),
_ => panic!("expected RebuildTable"),
}
}
#[test]
fn role_info_roundtrip() {
let r = RoleInfo {
name: "app".into(), superuser: false, inherit: true, create_db: false,
create_role: false, can_login: true, replication: false, bypass_rls: false,
connection_limit: -1, valid_until: None, memberships: vec![RoleMembership {
role: "parent".into(), member: "app".into(), grantor: "admin".into(), admin_option: false,
}],
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains(r#""name":"app""#));
assert!(json.contains(r#""can_login":true"#));
let back: RoleInfo = serde_json::from_str(&json).unwrap();
assert_eq!(back.memberships.len(), 1);
}
#[test]
fn privilege_entry_and_readiness_roundtrip() {
let pe = PrivilegeEntry {
object_class: "table".into(), schema: Some("public".into()), name: "users".into(),
privileges: vec!["SELECT".into(), "INSERT".into()], grantable: false,
};
assert!(serde_json::to_string(&pe).unwrap().contains(r#""object_class":"table""#));
let rr = RebuildReadiness { ok: false, reasons: vec!["has triggers".into()] };
assert!(serde_json::to_string(&rr).unwrap().contains(r#""ok":false"#));
let mr = MaintenanceResult { duration_ms: 42, message: "ok".into() };
assert!(serde_json::to_string(&mr).unwrap().contains(r#""duration_ms":42"#));
let ts = TablespaceInfo { name: "pg_default".into() };
assert!(serde_json::to_string(&ts).unwrap().contains("pg_default"));
}
} }
+1 -1
View File
@@ -9,7 +9,7 @@ pub mod tag;
pub use connection::{Connection, ConnectionInput}; pub use connection::{Connection, ConnectionInput};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo}; pub use db_viewer::{Change, ColumnInfo, FilterRule, MaintenanceResult, Pagination, PrivilegeEntry, QueryResult, RebuildReadiness, RoleInfo, RoleMembership, SortRule, TableInfo, TablespaceInfo};
pub use folder::{Folder, FolderInput}; pub use folder::{Folder, FolderInput};
pub use recent::RecentConnection; pub use recent::RecentConnection;
pub use settings::Settings; pub use settings::Settings;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gridline", "productName": "Gridline",
"version": "0.7.6", "version": "0.7.7",
"identifier": "com.adrianbonpin.gridline", "identifier": "com.adrianbonpin.gridline",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
@@ -144,6 +144,23 @@ describe("ChangesQueuePanel", () => {
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1")); await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
}); });
it("refreshes the schema tree after committing a schema-modifying ddl change", async () => {
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
useDbViewerStore.getState().addChange({
type: "ddl",
schema: "public",
table: "products",
sql: 'CREATE TABLE "public"."products" ("id" integer NOT NULL)',
description: "Create Table",
});
render(<ChangesQueuePanel />);
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
});
it("SQL toggle shows the generated SQL", async () => { it("SQL toggle shows the generated SQL", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
useDbViewerStore.getState().addChange({ useDbViewerStore.getState().addChange({
@@ -222,4 +239,18 @@ describe("ChangesQueuePanel", () => {
expect(tabs.some((t) => t.table === "posts")).toBe(true); expect(tabs.some((t) => t.table === "posts")).toBe(true);
}); });
}); });
it("renders a rebuild_table change with an amber REBUILD badge and SQL preview", () => {
useDbViewerStore.getState().addChange({
type: "rebuild_table",
schema: "public",
table: "users",
sql: "BEGIN; ALTER TABLE \"public\".\"users\" ...; COMMIT;",
description: "Rebuild public.users",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText("REBUILD")).toBeInTheDocument();
expect(screen.getByText(/rebuild public\.users/i)).toBeInTheDocument();
expect(screen.getByText(/BEGIN;/)).toBeInTheDocument();
});
}); });
+33 -3
View File
@@ -5,6 +5,7 @@ import { useUiStore } from "../../stores/uiStore";
import { useNotificationStore } from "../../stores/notificationStore"; import { useNotificationStore } from "../../stores/notificationStore";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
import { buildChangePayload, buildChangeSql } from "../../lib/changePayload"; import { buildChangePayload, buildChangeSql } from "../../lib/changePayload";
import { isSchemaModifyingQuery } from "../../lib/utils";
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore"; import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
const statusBg: Record<QueueStatus, string> = { const statusBg: Record<QueueStatus, string> = {
@@ -25,6 +26,8 @@ function formatChangeLabel(change: QueueItem): string {
return `Empty Table: ${fullName}`; return `Empty Table: ${fullName}`;
case "drop_table": case "drop_table":
return `Drop Table: ${fullName}`; return `Drop Table: ${fullName}`;
case "rebuild_table":
return change.description ?? `Rebuild ${fullName}`;
case "ddl": case "ddl":
return change.description ?? "DDL"; return change.description ?? "DDL";
default: default:
@@ -52,9 +55,11 @@ function capitalizeType(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1); return type.charAt(0).toUpperCase() + type.slice(1);
} }
/** Badge label for a queue-item type — ddl renders uppercase to match its acronym. */ /** Badge label for a queue-item type — ddl/rebuild render uppercase. */
function badgeLabel(type: string): string { function badgeLabel(type: string): string {
return type === "ddl" ? "DDL" : capitalizeType(type); if (type === "ddl") return "DDL";
if (type === "rebuild_table") return "REBUILD";
return capitalizeType(type);
} }
function tableRef(change: QueueItem): string { function tableRef(change: QueueItem): string {
@@ -97,6 +102,11 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
treeDirty = true; treeDirty = true;
const st = useDbViewerStore.getState(); const st = useDbViewerStore.getState();
st.closeTabsForTable(change.schema ?? "", change.table ?? ""); st.closeTabsForTable(change.schema ?? "", change.table ?? "");
} else if (
change.type === "rebuild_table" ||
(change.type === "ddl" && change.sql && isSchemaModifyingQuery(change.sql))
) {
treeDirty = true;
} }
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
@@ -177,7 +187,14 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-xs font-medium text-text-muted"> <span
className={[
"rounded px-1.5 py-0.5 text-xs font-medium",
change.type === "rebuild_table"
? "bg-amber-500/10 text-amber-400"
: "bg-surface-raised text-text-muted",
].join(" ")}
>
{badgeLabel(change.type)} {badgeLabel(change.type)}
</span> </span>
<span className="text-sm text-text truncate"> <span className="text-sm text-text truncate">
@@ -204,6 +221,17 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
</span> </span>
) : null} ) : null}
</div> </div>
{change.type === "rebuild_table" ? (
<>
<div className="mt-1 text-xs text-text-muted truncate">
{formatChangeLabel(change)}
</div>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-canvas border border-border p-2 text-xs text-text-muted font-mono">
{buildChangeSql(change)}
</pre>
</>
) : (
<>
<div className="mt-1 text-xs text-text-muted truncate"> <div className="mt-1 text-xs text-text-muted truncate">
{formatChangeLabel(change)} {formatChangeLabel(change)}
</div> </div>
@@ -218,6 +246,8 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
</span> </span>
</div> </div>
)} )}
</>
)}
</div> </div>
)) ))
) : ( ) : (
+2 -1
View File
@@ -1439,6 +1439,7 @@ const onQueriesPanelResizeStart = useCallback(
setCurrentSchema={setCurrentSchema} setCurrentSchema={setCurrentSchema}
onEdit={() => setEditModalOpen(true)} onEdit={() => setEditModalOpen(true)}
connectionId={connectionId} connectionId={connectionId}
dbType={currentConnection?.db_type}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
/> />
@@ -1446,7 +1447,7 @@ const onQueriesPanelResizeStart = useCallback(
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
style={{ overscrollBehavior: "none" }} style={{ overscrollBehavior: "none" }}
> >
<TableTree searchQuery={searchQuery} /> <TableTree searchQuery={searchQuery} dbType={currentConnection?.db_type} />
</div> </div>
</div> </div>
{/* panel resize handle */} {/* panel resize handle */}
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent } from "@testing-library/react";
import { DbViewerToolbar } from "./DbViewerToolbar"; import { DbViewerToolbar } from "./DbViewerToolbar";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
@@ -84,6 +84,17 @@ describe("DbViewerToolbar", () => {
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument(); expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
}); });
it("opens a create table form tab when Create Table is clicked", () => {
const openFormTab = vi.spyOn(useDbViewerStore.getState(), "openFormTab");
render(
<TooltipProvider>
<DbViewerToolbar {...defaultProps} currentSchema="public" />
</TooltipProvider>,
);
fireEvent.click(screen.getByLabelText(/create table/i));
expect(openFormTab).toHaveBeenCalledWith(expect.objectContaining({ kind: "table", mode: "create" }));
});
it("shows a disabled schema loading indicator while schema tree is loading", () => { it("shows a disabled schema loading indicator while schema tree is loading", () => {
useDbViewerStore.setState({ schemaTreeLoading: true }); useDbViewerStore.setState({ schemaTreeLoading: true });
render( render(
+23 -1
View File
@@ -13,6 +13,8 @@ import { Tooltip } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
import { SchemaMenu } from "./SchemaMenu"; import { SchemaMenu } from "./SchemaMenu";
import { getCapabilities } from "../../lib/dbCapabilities";
import type { DbType } from "../../lib/types";
export function DbViewerToolbar({ export function DbViewerToolbar({
databases, databases,
@@ -23,6 +25,7 @@ export function DbViewerToolbar({
setCurrentSchema, setCurrentSchema,
onEdit, onEdit,
connectionId, connectionId,
dbType,
searchQuery, searchQuery,
onSearchChange, onSearchChange,
}: { }: {
@@ -34,6 +37,7 @@ export function DbViewerToolbar({
setCurrentSchema: (schema: string | null) => void; setCurrentSchema: (schema: string | null) => void;
onEdit?: () => void; onEdit?: () => void;
connectionId?: string; connectionId?: string;
dbType?: DbType;
searchQuery: string; searchQuery: string;
onSearchChange: (q: string) => void; onSearchChange: (q: string) => void;
}) { }) {
@@ -78,6 +82,21 @@ export function DbViewerToolbar({
}; };
}, []); }, []);
const handleCreateTable = useCallback(() => {
const schema = currentSchema ?? "public";
useDbViewerStore.getState().openFormTab({
kind: "table",
schema,
name: "",
title: "Create Table",
description: "Create Table",
mode: "create",
params: { schema, name: "", action: { op: "create", columns: [] } },
});
}, [currentSchema]);
const showCreateTable = getCapabilities(dbType ?? "postgresql").tableManagement;
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
if (!connectionId || refreshing) return; if (!connectionId || refreshing) return;
setRefreshing(true); setRefreshing(true);
@@ -147,14 +166,17 @@ export function DbViewerToolbar({
)} )}
</button> </button>
</Tooltip> </Tooltip>
{showCreateTable && (
<Tooltip content="Create Table" side="bottom"> <Tooltip content="Create Table" side="bottom">
<button <button
aria-label="Create Table" aria-label="Create Table"
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-50" onClick={handleCreateTable}
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
> >
<Plus size={14} /> <Plus size={14} />
</button> </button>
</Tooltip> </Tooltip>
)}
<Tooltip content="Search Tables" side="bottom"> <Tooltip content="Search Tables" side="bottom">
<button <button
aria-label="Search Tables" aria-label="Search Tables"
@@ -351,6 +351,39 @@ describe("ObjectExplorerPage", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("roles fetch ignores the schema filter", async () => {
const user = userEvent.setup();
const getRoles = vi.spyOn(commands, "getRoles").mockResolvedValue([
{
name: "app",
superuser: false,
inherit: true,
create_db: false,
create_role: false,
can_login: true,
replication: false,
bypass_rls: false,
connection_limit: -1,
valid_until: null,
memberships: [],
},
]);
render(<ObjectExplorerPage connectionId="c1" />);
await user.click(screen.getByLabelText("Object type"));
await user.click(screen.getByText("Roles"));
await waitFor(() =>
expect(screen.getByText("app")).toBeInTheDocument(),
);
// Roles are cluster-scoped: no schema argument is ever passed, even when
// the schema changes (no schema-change refetch for roles).
expect(getRoles).toHaveBeenCalledTimes(1);
expect(getRoles).toHaveBeenCalledWith("c1");
expect(getRoles).not.toHaveBeenCalledWith("c1", "public");
useDbViewerStore.setState({ currentSchema: "analytics" });
await waitFor(() => expect(getRoles).toHaveBeenCalledTimes(1));
expect(getRoles).toHaveBeenCalledWith("c1");
});
it("preselects type from store on mount", () => { it("preselects type from store on mount", () => {
useDbViewerStore.setState({ selectedObjectType: "sequences" }); useDbViewerStore.setState({ selectedObjectType: "sequences" });
render(<ObjectExplorerPage connectionId="c1" />); render(<ObjectExplorerPage connectionId="c1" />);
@@ -59,7 +59,8 @@ function itemLabel(item: AnyObject): string {
} }
function schemaOf(item: AnyObject): string { function schemaOf(item: AnyObject): string {
return item.schema; // Roles are cluster-scoped and carry no schema — default to "".
return (item as { schema?: string }).schema ?? "";
} }
function objectName(item: AnyObject): string { function objectName(item: AnyObject): string {
@@ -76,6 +77,7 @@ function typeToDdlType(type: ObjectType): string {
extensions: "extension", extensions: "extension",
indexes: "index", indexes: "index",
constraints: "constraint", constraints: "constraint",
roles: "role",
}; };
return map[type]; return map[type];
} }
@@ -149,7 +151,10 @@ export function ObjectExplorerPage({
setError(null); setError(null);
try { try {
let result: AnyObject[]; let result: AnyObject[];
if (type === "extensions") { if (type === "roles") {
// Roles are cluster-scoped — no schema filter.
result = await cmd.getRoles(connectionId);
} else if (type === "extensions") {
result = await cmd.getExtensions(connectionId); result = await cmd.getExtensions(connectionId);
} else if (type === "functions") { } else if (type === "functions") {
result = (await cmd.getFunctions( result = (await cmd.getFunctions(
@@ -201,8 +206,14 @@ export function ObjectExplorerPage({
}, [type, connectionId, currentSchema]); }, [type, connectionId, currentSchema]);
useEffect(() => { useEffect(() => {
// Only re-fetch if schema actually changed (or first load) // Only re-fetch if schema actually changed (or first load). Roles are
if (lastSchemaRef.current !== (currentSchema ?? undefined)) { // cluster-scoped — skip the refetch when the schema changes (the mount
// fetch still runs via lastSchemaRef === undefined).
const schema = currentSchema ?? undefined;
const schemaChanged = lastSchemaRef.current !== schema;
const skipForRoles =
schemaChanged && type === "roles" && lastSchemaRef.current !== undefined;
if (schemaChanged && !skipForRoles) {
fetch(); fetch();
} }
}, [currentSchema, fetch]); }, [currentSchema, fetch]);
@@ -488,7 +499,7 @@ export function ObjectExplorerPage({
sidebarMode sidebarMode
? openObjectTab( ? openObjectTab(
type, type,
item.schema, schemaOf(item),
item.name, item.name,
item, item,
) )
@@ -517,7 +528,10 @@ export function ObjectExplorerPage({
objectType={ objectType={
typeToDdlType(type) as ObjectKind typeToDdlType(type) as ObjectKind
} }
item={item} item={{
...item,
schema: schemaOf(item),
}}
onRefresh={fetch} onRefresh={fetch}
open={openKey === key} open={openKey === key}
onOpenChange={(o) => onOpenChange={(o) =>
@@ -117,4 +117,22 @@ describe("TableOverflowMenu", () => {
fireEvent.click(screen.getByText(/export data \(csv\)/i)); fireEvent.click(screen.getByText(/export data \(csv\)/i));
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t")); await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t"));
}); });
it("maintenance items are gated by capability and run via confirm", async () => {
vi.spyOn(commands, "runMaintenance").mockResolvedValue({ duration_ms: 3, message: "VACUUM completed" });
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Table options"));
fireEvent.click(screen.getByText("VACUUM"));
fireEvent.click(screen.getByRole("button", { name: /vacuum/i }));
await screen.findByText(/completed/i);
expect(commands.runMaintenance).toHaveBeenCalledWith("c1", "public", "users", "vacuum");
});
it("Edit Table opens a table form tab", () => {
const openFormTab = vi.spyOn(useDbViewerStore.getState(), "openFormTab");
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" columns={[{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }]} />);
fireEvent.click(screen.getByLabelText("Table options"));
fireEvent.click(screen.getByText("Edit Table…"));
expect(openFormTab).toHaveBeenCalledWith(expect.objectContaining({ kind: "table", mode: "edit" }));
});
}); });
+143 -5
View File
@@ -1,14 +1,18 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { MoreVertical } from "lucide-react"; import { MoreVertical, RefreshCw } from "lucide-react";
import { ConfirmDialog } from "../ui/ConfirmDialog"; import { ConfirmDialog } from "../ui/ConfirmDialog";
import { AnimatedModal } from "../ui/AnimatedModal";
import { Button } from "../ui/Button";
import { ImportDialog } from "./ImportDialog"; import { ImportDialog } from "./ImportDialog";
import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useUiStore } from "../../stores/uiStore"; import { useUiStore } from "../../stores/uiStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { exportData } from "../../lib/exportData"; import { exportData } from "../../lib/exportData";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
import { getCapabilities } from "../../lib/dbCapabilities";
import { DependencyDialog } from "./DependencyDialog"; import { DependencyDialog } from "./DependencyDialog";
import { initialCrudParams } from "../../lib/objectCrud"; import { initialCrudParams } from "../../lib/objectCrud";
import type { ColumnInfo, DependencyInfo } from "../../lib/types"; import type { ColumnInfo, DependencyInfo, MaintenanceResult } from "../../lib/types";
interface TableOverflowMenuProps { interface TableOverflowMenuProps {
schema: string; schema: string;
@@ -17,12 +21,26 @@ interface TableOverflowMenuProps {
connectionId?: string; connectionId?: string;
columns?: ColumnInfo[]; columns?: ColumnInfo[];
rows?: unknown[][]; rows?: unknown[][];
dbType?: string;
} }
interface MenuItem { interface MenuItem {
id: string; id: string;
label: string; label?: string;
danger?: boolean; danger?: boolean;
divider?: boolean;
}
type MaintenanceAction = "vacuum" | "analyze" | "reindex";
const maintenanceLockCopy: Record<MaintenanceAction, string> = {
vacuum: "VACUUM blocks concurrent DDL only on this table.",
analyze: "ANALYZE blocks concurrent DDL only on this table.",
reindex: "REINDEX takes an ACCESS EXCLUSIVE lock — blocks reads and writes on this table until complete.",
};
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
} }
export function TableOverflowMenu({ export function TableOverflowMenu({
@@ -32,15 +50,23 @@ export function TableOverflowMenu({
connectionId: connectionIdProp, connectionId: connectionIdProp,
columns, columns,
rows, rows,
dbType,
}: TableOverflowMenuProps) { }: TableOverflowMenuProps) {
const storeConnectionId = useUiStore((s) => s.activeConnectionId); const storeConnectionId = useUiStore((s) => s.activeConnectionId);
const connectionId = connectionIdProp ?? storeConnectionId; const connectionId = connectionIdProp ?? storeConnectionId;
const addChange = useDbViewerStore((s) => s.addChange); const addChange = useDbViewerStore((s) => s.addChange);
const notify = useNotificationStore((s) => s.notify);
const caps = getCapabilities(dbType ?? "postgresql");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null); const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
const [dropDeps, setDropDeps] = useState<DependencyInfo[]>([]); const [dropDeps, setDropDeps] = useState<DependencyInfo[]>([]);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [maintenance, setMaintenance] = useState<{ action: MaintenanceAction; lockCopy: string } | null>(null);
const [maintenanceRunning, setMaintenanceRunning] = useState(false);
const [maintenanceResult, setMaintenanceResult] = useState<
{ type: "success" | "error"; message: string; duration_ms: number } | null
>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -63,6 +89,28 @@ export function TableOverflowMenu({
}; };
}, [open]); }, [open]);
const handleMaintenanceConfirm = async () => {
if (!connectionId || !maintenance) return;
setMaintenanceRunning(true);
setMaintenanceResult(null);
try {
const result: MaintenanceResult = await cmd.runMaintenance(
connectionId,
schema,
table,
maintenance.action,
);
setMaintenanceResult({ type: "success", message: result.message, duration_ms: result.duration_ms });
notify(`${result.message} · ${result.duration_ms}ms`, "success");
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
setMaintenanceResult({ type: "error", message, duration_ms: 0 });
notify(message, "error");
} finally {
setMaintenanceRunning(false);
}
};
const handleAction = async (id: string) => { const handleAction = async (id: string) => {
switch (id) { switch (id) {
case "open": case "open":
@@ -97,6 +145,34 @@ export function TableOverflowMenu({
setImportOpen(true); setImportOpen(true);
setOpen(false); setOpen(false);
break; break;
case "edit_table": {
const columnMeta = (columns ?? []).map((c) => ({
name: c.name,
type: c.data_type,
nullable: c.is_nullable,
default: c.default_value,
is_pk: c.is_pk,
}));
useDbViewerStore.getState().openFormTab({
kind: "table",
schema,
name: table,
title: "Edit Table",
description: `Edit ${schema}.${table}`,
mode: "edit",
params: {
schema,
name: table,
action: {
op: "edit",
columns: columnMeta,
old_columns: columnMeta,
},
},
});
setOpen(false);
break;
}
case "create_index": case "create_index":
if (!connectionId) break; if (!connectionId) break;
useDbViewerStore.getState().openFormTab({ useDbViewerStore.getState().openFormTab({
@@ -123,6 +199,12 @@ export function TableOverflowMenu({
}); });
setOpen(false); setOpen(false);
break; break;
case "vacuum":
case "analyze":
case "reindex":
setMaintenance({ action: id, lockCopy: maintenanceLockCopy[id] });
setOpen(false);
break;
case "empty": case "empty":
setConfirmAction("empty"); setConfirmAction("empty");
setOpen(false); setOpen(false);
@@ -154,6 +236,15 @@ export function TableOverflowMenu({
{ id: "import", label: "Import data (CSV/JSON)" }, { id: "import", label: "Import data (CSV/JSON)" },
{ id: "create_index", label: "Create Index…" }, { id: "create_index", label: "Create Index…" },
{ id: "create_constraint", label: "Create Constraint…" }, { id: "create_constraint", label: "Create Constraint…" },
...(caps.tableManagement ? [{ id: "edit_table", label: "Edit Table…" }] : []),
...(caps.maintenance
? [
{ id: "maintenance-divider", divider: true },
{ id: "vacuum", label: "VACUUM" },
{ id: "analyze", label: "ANALYZE" },
{ id: "reindex", label: "REINDEX", danger: true },
]
: []),
{ id: "empty", label: "Empty Table", danger: true }, { id: "empty", label: "Empty Table", danger: true },
{ id: "delete", label: "Delete Table", danger: true }, { id: "delete", label: "Delete Table", danger: true },
]; ];
@@ -169,7 +260,10 @@ export function TableOverflowMenu({
</button> </button>
{open && ( {open && (
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg"> <div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
{items.map((item) => ( {items.map((item) =>
item.divider ? (
<div key={item.id} className="border-t border-border my-1" />
) : (
<button <button
key={item.id} key={item.id}
type="button" type="button"
@@ -181,10 +275,54 @@ export function TableOverflowMenu({
> >
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
))} ),
)}
</div> </div>
)} )}
{maintenance && (
<AnimatedModal open onClose={() => setMaintenance(null)}>
<div className="w-80">
<h3 className="font-heading text-text text-lg mb-3">
{maintenanceResult
? maintenanceResult.type === "success"
? "Maintenance Complete"
: "Maintenance Failed"
: `${capitalize(maintenance.action)}: ${schema}.${table}`}
</h3>
<p className="text-sm text-text-muted mb-4">
{maintenanceResult
? `${maintenanceResult.message} · ${maintenanceResult.duration_ms}ms`
: maintenance.lockCopy}
</p>
{maintenanceRunning && (
<div className="flex items-center gap-2 text-sm text-text-muted mb-4">
<RefreshCw size={14} className="animate-spin" />
<span>Running {maintenance.action}</span>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => setMaintenance(null)}
disabled={maintenanceRunning}
>
{maintenanceResult ? "Close" : "Cancel"}
</Button>
{!maintenanceResult && (
<Button
variant="primary"
onClick={handleMaintenanceConfirm}
disabled={maintenanceRunning}
>
{maintenanceRunning ? "Running…" : capitalize(maintenance.action)}
</Button>
)}
</div>
</div>
</AnimatedModal>
)}
{confirmAction === "delete" && dropDeps.length > 0 && ( {confirmAction === "delete" && dropDeps.length > 0 && (
<DependencyDialog <DependencyDialog
open open
+2 -1
View File
@@ -7,7 +7,7 @@ import { abbreviateType } from "../../lib/utils";
import type { ColumnInfo } from "../../lib/types"; import type { ColumnInfo } from "../../lib/types";
import * as cmd from "../../lib/commands"; import * as cmd from "../../lib/commands";
export function TableTree({ searchQuery }: { searchQuery?: string }) { export function TableTree({ searchQuery, dbType }: { searchQuery?: string; dbType?: string }) {
const tables = useDbViewerStore((s) => s.tables); const tables = useDbViewerStore((s) => s.tables);
const currentSchema = useDbViewerStore((s) => s.currentSchema); const currentSchema = useDbViewerStore((s) => s.currentSchema);
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading); const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
@@ -113,6 +113,7 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
schema={table.schema} schema={table.schema}
table={table.name} table={table.name}
connectionId={connectionId ?? undefined} connectionId={connectionId ?? undefined}
dbType={dbType}
columns={cols} columns={cols}
onOpenTab={handleOpenTab} onOpenTab={handleOpenTab}
/> />
@@ -1,9 +1,15 @@
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { ConstraintForm } from "./ConstraintForm"; import { ConstraintForm } from "./ConstraintForm";
import * as cmd from "../../../lib/commands"; import * as cmd from "../../../lib/commands";
vi.mock("../../../lib/commands", () => ({ getSchemaGraph: vi.fn() })); beforeEach(() => {
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
});
afterEach(() => {
vi.restoreAllMocks();
});
const graph = { const graph = {
tables: [ tables: [
@@ -44,12 +50,8 @@ const graph = {
}; };
describe("ConstraintForm", () => { describe("ConstraintForm", () => {
afterEach(() => {
vi.mocked(cmd.getSchemaGraph).mockReset();
});
it("check: emits the expression", () => { it("check: emits the expression", () => {
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any); (cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
const onChange = vi.fn(); const onChange = vi.fn();
render( render(
<ConstraintForm <ConstraintForm
@@ -74,7 +76,7 @@ describe("ConstraintForm", () => {
}); });
it("foreign_key: picks referenced table + column", async () => { it("foreign_key: picks referenced table + column", async () => {
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any); (cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
const onChange = vi.fn(); const onChange = vi.fn();
render( render(
<ConstraintForm <ConstraintForm
@@ -97,8 +99,8 @@ describe("ConstraintForm", () => {
const kindSelect = screen.getByLabelText("Kind"); const kindSelect = screen.getByLabelText("Kind");
fireEvent.change(kindSelect, { target: { value: "foreign_key" } }); fireEvent.change(kindSelect, { target: { value: "foreign_key" } });
await screen.findByText("user_id"); await screen.findByText("user_id");
const refTable = await screen.findByPlaceholderText("Referenced table"); const refTable = await screen.findByLabelText("Referenced table");
fireEvent.change(refTable, { target: { value: "users" } }); fireEvent.change(refTable, { target: { value: "public.users" } });
expect(onChange).toHaveBeenLastCalledWith( expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ expect.objectContaining({
action: expect.objectContaining({ action: expect.objectContaining({
@@ -108,4 +110,59 @@ describe("ConstraintForm", () => {
}), }),
); );
}); });
it("FK form includes ON DELETE/UPDATE + deferrable and cross-schema ref picker", async () => {
// mock getSchemaGraph to return tables across two schemas
(cmd.getSchemaGraph as any).mockResolvedValue({
tables: [
{
name: "users",
schema: "public",
table_type: "TABLE",
columns: [
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
],
},
{
name: "orgs",
schema: "auth",
table_type: "TABLE",
columns: [
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
],
},
],
relationships: [],
} as any);
render(
<ConstraintForm
connectionId="c1"
schemas={["public", "auth"]}
params={{
schema: "public",
table: "orders",
name: "fk1",
action: {
op: "foreign_key",
columns: ["org_id"],
ref_schema: "auth",
ref_table: "orgs",
ref_columns: ["id"],
on_delete: "CASCADE",
on_update: "NO ACTION",
deferrable: false,
initially_deferred: false,
},
}}
onChange={() => {}}
/>,
);
expect(screen.getByLabelText("On delete")).toBeInTheDocument();
expect(screen.getByLabelText("On update")).toBeInTheDocument();
// referenced-table picker includes the auth.orgs option
const ref = screen.getByLabelText("Referenced table") as HTMLSelectElement;
await waitFor(() => {
expect([...ref.options].map((o) => o.value)).toContain("auth.orgs");
});
});
}); });
@@ -13,12 +13,23 @@ interface Props {
} }
const KINDS = ["check", "unique", "primary_key", "foreign_key"]; const KINDS = ["check", "unique", "primary_key", "foreign_key"];
const FK_ACTIONS = ["NO ACTION", "RESTRICT", "CASCADE", "SET NULL", "SET DEFAULT"];
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams { function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>; const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } }; return { ...params, action: { ...action, ...patch } };
} }
function refKey(schema: string, table: string): string {
return `${schema}.${table}`;
}
function parseRefKey(key: string): { schema: string; table: string } {
const parts = key.split(".");
if (parts.length >= 2) return { schema: parts[0] ?? "", table: parts.slice(1).join(".") };
return { schema: "", table: key };
}
export function ConstraintForm({ connectionId, params, schemas, onChange }: Props) { export function ConstraintForm({ connectionId, params, schemas, onChange }: Props) {
const p = params as Record<string, unknown>; const p = params as Record<string, unknown>;
const action = (p.action ?? {}) as Record<string, unknown>; const action = (p.action ?? {}) as Record<string, unknown>;
@@ -28,10 +39,10 @@ export function ConstraintForm({ connectionId, params, schemas, onChange }: Prop
useEffect(() => { useEffect(() => {
cmd cmd
.getSchemaGraph(connectionId, (p.schema as string) || undefined) .getSchemaGraph(connectionId, undefined)
.then((g: SchemaGraph) => setGraph(g)) .then((g: SchemaGraph) => setGraph(g))
.catch(() => setGraph({ tables: [], relationships: [] })); .catch(() => setGraph({ tables: [], relationships: [] }));
}, [connectionId, p.schema]); }, [connectionId]);
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch)); const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
@@ -147,20 +158,43 @@ export function ConstraintForm({ connectionId, params, schemas, onChange }: Prop
</div> </div>
</FormRow> </FormRow>
<FormRow label="Referenced table"> <FormRow label="Referenced table">
<input <select
type="text" aria-label="Referenced table"
placeholder="Referenced table" value={refKey(
value={(action.ref_table as string) ?? ""} (action.ref_schema as string) ?? "",
(action.ref_table as string) ?? "",
)}
onChange={(e) => { onChange={(e) => {
const t = graph.tables.find((t) => t.name === e.target.value); const { schema, table } = parseRefKey(e.target.value);
const t = graph.tables.find(
(tbl) => tbl.name === table && tbl.schema === schema,
);
setAction({ setAction({
ref_table: e.target.value, ref_table: table,
ref_schema: t?.schema ?? "", ref_schema: schema,
ref_columns: [], ref_columns: [],
}); });
if (!t) return;
const pkCols = t.columns.filter((c) => c.is_pk).map((c) => c.name);
if (pkCols.length > 0 && selected.length > 0) {
setAction({
ref_table: table,
ref_schema: schema,
ref_columns: pkCols.slice(0, selected.length),
});
}
}} }}
className={inputClass} className={controlClass}
/> >
<option value="" disabled>
Select a table
</option>
{graph.tables.map((t) => (
<option key={refKey(t.schema, t.name)} value={refKey(t.schema, t.name)}>
{t.schema}.{t.name}
</option>
))}
</select>
</FormRow> </FormRow>
<FormRow label="Referenced columns" className="items-stretch"> <FormRow label="Referenced columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2"> <div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
@@ -171,6 +205,65 @@ export function ConstraintForm({ connectionId, params, schemas, onChange }: Prop
/> />
</div> </div>
</FormRow> </FormRow>
<FormRow label="On delete">
<select
aria-label="On delete"
value={(action.on_delete as string) ?? "NO ACTION"}
onChange={(e) => setAction({ on_delete: e.target.value })}
className={controlClass}
>
{FK_ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</FormRow>
<FormRow label="On update">
<select
aria-label="On update"
value={(action.on_update as string) ?? "NO ACTION"}
onChange={(e) => setAction({ on_update: e.target.value })}
className={controlClass}
>
{FK_ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</FormRow>
<FormRow label="Deferrable">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
aria-label="DEFERRABLE"
checked={!!action.deferrable}
onChange={(e) =>
setAction({
deferrable: e.target.checked,
initially_deferred: e.target.checked ? action.initially_deferred ?? false : false,
})
}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>DEFERRABLE</span>
</label>
</FormRow>
{!!action.deferrable && (
<FormRow label="Initially deferred">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
aria-label="INITIALLY DEFERRED"
checked={!!action.initially_deferred}
onChange={(e) => setAction({ initially_deferred: e.target.checked })}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>INITIALLY DEFERRED</span>
</label>
</FormRow>
)}
</> </>
)} )}
</div> </div>
@@ -0,0 +1,223 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { FkPanel } from "./FkPanel";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import * as cmd from "../../../lib/commands";
import * as objectCrud from "../../../lib/objectCrud";
const graph = {
tables: [
{
name: "orders",
schema: "public",
table_type: "BASE TABLE",
columns: [
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
{ name: "user_id", data_type: "int", is_pk: false, is_fk: true, is_unique: false, is_nullable: true, fk_ref: ["public", "users", "id"] },
],
},
{
name: "users",
schema: "public",
table_type: "BASE TABLE",
columns: [
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
],
},
{
name: "orgs",
schema: "auth",
table_type: "BASE TABLE",
columns: [
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
],
},
],
relationships: [],
};
beforeEach(() => {
useDbViewerStore.getState().reset();
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue(graph as any);
vi.spyOn(objectCrud, "buildObjectDdl").mockReset().mockResolvedValue(["ALTER TABLE ... ADD CONSTRAINT ..."]);
});
afterEach(() => {
vi.restoreAllMocks();
});
function renderPanel(props: Partial<React.ComponentProps<typeof FkPanel>> = {}) {
const onClose = vi.fn();
const onStaged = vi.fn();
const utils = render(
<FkPanel
connectionId="c1"
schema="public"
table="orders"
column="user_id"
mode="edit"
localColumns={[
{ name: "user_id", data_type: "int" },
{ name: "amount", data_type: "numeric" },
{ name: "created_at", data_type: "timestamp" },
]}
onClose={onClose}
onStaged={onStaged}
{...props}
/>,
);
return { ...utils, onClose, onStaged };
}
/** The columns/actions sections only render once a referenced table is chosen. */
async function waitForTable() {
await screen.findByLabelText("Table");
await waitFor(() => {
expect(screen.queryByLabelText("Local column 1")).not.toBeNull();
});
}
describe("FkPanel", () => {
it("renders cascading dropdowns with schemas, tables, and PK-first columns", async () => {
renderPanel();
expect(await screen.findByText("Foreign key")).toBeInTheDocument();
// Schema select contains public and auth
const schema = screen.getByLabelText("Schema") as HTMLSelectElement;
await waitFor(() => {
expect([...schema.options].map((o) => o.value)).toContain("public");
expect([...schema.options].map((o) => o.value)).toContain("auth");
});
// Table select contains public tables by default
const table = screen.getByLabelText("Table") as HTMLSelectElement;
await waitFor(() => {
expect([...table.options].map((o) => o.value)).toContain("users");
expect([...table.options].map((o) => o.value)).not.toContain("orgs");
});
// Once a table is referenced, the local column pair appears, preselected
await waitForTable();
const local = screen.getByLabelText("Local column 1") as HTMLSelectElement;
expect(local.value).toBe("user_id");
expect([...local.options].map((o) => o.textContent)).toContain("user_id (int)");
// Referenced column select lists PKs first and marks them
const refCol = screen.getByLabelText("Referenced column 1") as HTMLSelectElement;
await waitFor(() => {
const opts = [...refCol.options].map((o) => o.textContent);
expect(opts[0]).toMatch(/id/);
});
});
it("updates table and column lists when schema changes", async () => {
renderPanel();
const schema = await screen.findByLabelText("Schema");
fireEvent.change(schema, { target: { value: "auth" } });
const table = screen.getByLabelText("Table") as HTMLSelectElement;
await waitFor(() => {
expect([...table.options].map((o) => o.value)).toContain("orgs");
expect([...table.options].map((o) => o.value)).not.toContain("users");
});
});
it("hides the column-pairs and action sections until a referenced table is chosen", async () => {
let resolveGraph: (g: unknown) => void = () => {};
(cmd.getSchemaGraph as any).mockReturnValue(
new Promise((resolve) => {
resolveGraph = resolve;
}),
);
renderPanel();
await screen.findByText("Foreign key");
// while the graph is still loading, the rest is hidden
expect(screen.queryByLabelText("Local column 1")).toBeNull();
// resolve the graph → table auto-selects → sections appear
resolveGraph(graph);
await waitFor(() => {
expect(screen.getByLabelText("Local column 1")).toBeInTheDocument();
});
expect(screen.getByLabelText("On update")).toBeInTheDocument();
expect(screen.getByLabelText("On delete")).toBeInTheDocument();
});
it("supports adding multiple column pairs", async () => {
renderPanel();
await waitForTable();
fireEvent.click(screen.getByLabelText("Add another column"));
expect(screen.getAllByLabelText(/Local column/)).toHaveLength(2);
expect(screen.getAllByLabelText(/Referenced column/)).toHaveLength(2);
// removing a pair works
fireEvent.click(screen.getByLabelText("Remove pair 2"));
expect(screen.getAllByLabelText(/Local column/)).toHaveLength(1);
});
it("stages a foreign key DDL change and closes the panel", async () => {
const { onStaged } = renderPanel();
await screen.findByText("Foreign key");
await waitForTable();
fireEvent.change(screen.getByLabelText("On delete"), { target: { value: "CASCADE" } });
fireEvent.click(screen.getByRole("button", { name: "Add FK" }));
await waitFor(() => {
expect(objectCrud.buildObjectDdl).toHaveBeenCalledWith(
"c1",
"constraint",
expect.objectContaining({
schema: "public",
table: "orders",
name: "",
action: expect.objectContaining({
op: "foreign_key",
columns: ["user_id"],
ref_schema: "public",
ref_table: "orders",
ref_columns: ["id"],
on_delete: "CASCADE",
on_update: "NO ACTION",
}),
}),
);
});
const q = useDbViewerStore.getState().changesQueue;
expect(q).toHaveLength(1);
expect(q[0].type).toBe("ddl");
expect(q[0].sql).toBe("ALTER TABLE ... ADD CONSTRAINT ...");
// hands back the local column + the referenced column's type (for auto-matching)
expect(onStaged).toHaveBeenCalledWith({
pairs: [{ localCol: "user_id", refType: "int" }],
});
});
it("create mode returns the FK inline instead of staging a separate ALTER", async () => {
const { onStaged } = renderPanel({ mode: "create" });
await screen.findByText("Foreign key");
await waitForTable();
const refCol = (await screen.findByLabelText("Referenced column 1")) as HTMLSelectElement;
await waitFor(() => {
expect([...refCol.options].map((o) => o.value)).toContain("id");
});
fireEvent.click(screen.getByRole("button", { name: "Add FK" }));
// no separate ALTER change is staged
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
expect(objectCrud.buildObjectDdl).not.toHaveBeenCalled();
// the FK definition is handed back for inlining into the CREATE TABLE
expect(onStaged).toHaveBeenCalledWith({
pairs: [{ localCol: "user_id", refType: "int" }],
fk: expect.objectContaining({
columns: ["user_id"],
ref_schema: "public",
ref_table: "orders",
ref_columns: ["id"],
on_delete: "NO ACTION",
}),
});
});
});
@@ -0,0 +1,566 @@
import { useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { X, Link, Plus } from "lucide-react";
import { motion } from "motion/react";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import * as cmd from "../../../lib/commands";
import { buildObjectDdl } from "../../../lib/objectCrud";
import type { SchemaGraph } from "../../../lib/types";
// Panel selects: transparent, fill the available width (no surface bg like the grid).
const panelSelect =
"w-full bg-transparent font-heading text-xs text-text outline-none placeholder:text-text-muted cursor-pointer";
export interface FkColumn {
name: string;
data_type: string;
}
export interface FkStagedPair {
localCol: string;
refType: string;
}
export interface FkDefinition {
columns: string[];
ref_schema: string;
ref_table: string;
ref_columns: string[];
on_delete?: string;
on_update?: string;
deferrable?: boolean;
initially_deferred?: boolean;
}
interface FkPair {
localCol: string;
refCol: string;
}
interface Props {
connectionId: string;
schema: string;
table: string;
column: string | null;
/** Columns of the table being edited (from the form grid — works even when the table isn't created yet). */
localColumns: FkColumn[];
mode: "create" | "edit";
onClose: () => void;
/** Called after staging: pairs (for type auto-match); in create mode the FK definition is returned
* so the form can inline it into the CREATE TABLE instead of a separate ALTER change. */
onStaged?: (result: { pairs: FkStagedPair[]; fk?: FkDefinition }) => void;
}
const FK_ACTIONS = [
"NO ACTION",
"RESTRICT",
"CASCADE",
"SET NULL",
"SET DEFAULT",
];
function Section({
label,
children,
flush = false,
}: {
label: string;
children: ReactNode;
flush?: boolean;
}) {
return (
<div className="border-b border-border">
<div className="border-b border-border px-4 py-2 text-[11px] font-semibold text-text-muted uppercase tracking-wider">
{label}
</div>
<div className={flush ? "" : "px-2 py-2"}>{children}</div>
</div>
);
}
export function FkPanel({
connectionId,
schema,
table,
column,
localColumns,
mode,
onClose,
onStaged,
}: Props) {
const [graph, setGraph] = useState<SchemaGraph>({
tables: [],
relationships: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [staging, setStaging] = useState(false);
const [pairs, setPairs] = useState<FkPair[]>(() =>
column
? [{ localCol: column, refCol: "" }]
: [{ localCol: "", refCol: "" }],
);
const [refSchema, setRefSchema] = useState<string>(schema);
const [refTable, setRefTable] = useState<string>("");
const [onDelete, setOnDelete] = useState<string>("NO ACTION");
const [onUpdate, setOnUpdate] = useState<string>("NO ACTION");
useEffect(() => {
let active = true;
setLoading(true);
cmd.getSchemaGraph(connectionId, undefined)
.then((g) => {
if (!active) return;
setGraph(g);
setLoading(false);
})
.catch((e) => {
if (!active) return;
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
return () => {
active = false;
};
}, [connectionId]);
const localNames = useMemo(
() => localColumns.map((c) => c.name),
[localColumns],
);
const schemas = useMemo(
() => Array.from(new Set(graph.tables.map((t) => t.schema))).sort(),
[graph],
);
const tablesInSchema = useMemo(
() =>
graph.tables
.filter((t) => t.schema === refSchema)
.sort((a, b) => a.name.localeCompare(b.name)),
[graph, refSchema],
);
const refTableInfo = useMemo(
() => tablesInSchema.find((t) => t.name === refTable),
[tablesInSchema, refTable],
);
const refColumns = useMemo(() => {
if (!refTableInfo) return [];
return [...refTableInfo.columns].sort((a, b) => {
if (a.is_pk && !b.is_pk) return -1;
if (!a.is_pk && b.is_pk) return 1;
return a.name.localeCompare(b.name);
});
}, [refTableInfo]);
// Defaults: keep selections valid as the graph loads / inputs change.
useEffect(() => {
if (schemas.length > 0 && !schemas.includes(refSchema)) {
setRefSchema(schemas[0] ?? "");
}
}, [schemas, refSchema]);
useEffect(() => {
if (
tablesInSchema.length > 0 &&
!tablesInSchema.some((t) => t.name === refTable)
) {
setRefTable(tablesInSchema[0]?.name ?? "");
} else if (tablesInSchema.length === 0) {
setRefTable("");
}
}, [tablesInSchema, refTable]);
useEffect(() => {
if (localNames.length === 0) return;
setPairs((prev) =>
prev.map((p) =>
localNames.includes(p.localCol)
? p
: { ...p, localCol: localNames[0] ?? "" },
),
);
}, [localNames]);
useEffect(() => {
setPairs((prev) =>
prev.map((p) =>
p.refCol && refColumns.some((c) => c.name === p.refCol)
? p
: { ...p, refCol: refColumns[0]?.name ?? "" },
),
);
}, [refColumns]);
const setPair = (i: number, key: keyof FkPair, value: string) =>
setPairs((prev) =>
prev.map((p, j) => (j === i ? { ...p, [key]: value } : p)),
);
const addPair = () =>
setPairs((prev) => [
...prev,
{
localCol: localNames[0] ?? "",
refCol: refColumns[0]?.name ?? "",
},
]);
const removePair = (i: number) =>
setPairs((prev) =>
prev.length > 1 ? prev.filter((_, j) => j !== i) : prev,
);
const typeMappings = pairs
.map((p) => ({
localType:
localColumns.find((c) => c.name === p.localCol)?.data_type ??
"",
refType:
refTableInfo?.columns.find((c) => c.name === p.refCol)
?.data_type ?? "",
}))
.filter((m) => m.localType !== "" || m.refType !== "");
const tableLabel = table.trim() === "" ? "unnamed table" : table;
const addFk = async () => {
const cols = pairs.map((p) => p.localCol);
const refCols = pairs.map((p) => p.refCol);
if (
cols.length === 0 ||
cols.some((c) => c === "") ||
refCols.some((c) => c === "")
)
return;
setStaging(true);
setError(null);
const refTypes: Record<string, string> = {};
for (const c of refTableInfo?.columns ?? []) refTypes[c.name] = c.data_type;
const pairsResult = pairs.map((p) => ({
localCol: p.localCol,
refType: refTypes[p.refCol] ?? "",
}));
const fk: FkDefinition = {
columns: cols,
ref_schema: refSchema,
ref_table: refTable,
ref_columns: refCols,
on_delete: onDelete,
on_update: onUpdate,
deferrable: false,
initially_deferred: false,
};
try {
if (mode === "create") {
// Inline the FK into the CREATE TABLE — no separate ALTER change.
onStaged?.({ pairs: pairsResult, fk });
onClose();
return;
}
const sqls = await buildObjectDdl(connectionId, "constraint", {
schema,
table,
name: "",
action: {
op: "foreign_key",
columns: cols,
ref_schema: refSchema,
ref_table: refTable,
ref_columns: refCols,
on_delete: onDelete,
on_update: onUpdate,
},
});
sqls.forEach((sql, i) =>
useDbViewerStore.getState().addChange({
type: "ddl",
sql,
description:
sqls.length > 1
? `Add FK ${cols.join(", ")}${refSchema}.${refTable} (${i + 1}/${sqls.length})`
: `Add FK ${cols.join(", ")}${refSchema}.${refTable}`,
}),
);
onStaged?.({ pairs: pairsResult });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setStaging(false);
}
};
return (
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
style={{ willChange: "transform" }}
className="fixed top-0 right-0 h-full w-[420px] bg-canvas border-l border-border z-40 flex flex-col"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2 text-text">
<Link size={14} />
<span className="text-sm font-semibold">Foreign key</span>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="text-text-muted hover:text-text"
>
<X size={16} />
</button>
</div>
<div
className="flex-1 overflow-auto"
style={{ overscrollBehavior: "none" }}
>
{table.trim() === "" && (
<p className="border-b border-border px-4 py-2 text-xs text-amber-400">
Enter a table name in the form first a foreign key
needs a named table.
</p>
)}
{loading && (
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
Loading schema graph
</p>
)}
{!loading && graph.tables.length === 0 && (
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
No tables available for foreign key reference.
</p>
)}
{!loading && graph.tables.length > 0 && (
<>
<Section label="Select a Schema">
<select
aria-label="Schema"
value={refSchema}
onChange={(e) => setRefSchema(e.target.value)}
className={panelSelect}
>
{schemas.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</Section>
<Section label="Select a Table to reference to">
<select
aria-label="Table"
value={refTable}
onChange={(e) => setRefTable(e.target.value)}
className={panelSelect}
>
{tablesInSchema.map((t) => (
<option key={t.name} value={t.name}>
{t.name}
</option>
))}
</select>
</Section>
{refSchema && refTable && (
<>
<Section
flush
label={`Select columns from ${schema}.${tableLabel} to reference to`}
>
<div className="grid grid-cols-2 text-xs text-text-muted border-b border-border">
<div className="border-r border-border px-4 py-1">
<span className="truncate">
{schema}.{tableLabel}
</span>
</div>
<div className="px-4 py-1">
<span className="truncate">
{refSchema}.{refTable || "—"}
</span>
</div>
</div>
{pairs.map((pair, i) => (
<div
key={i}
className="grid grid-cols-2 items-center border-b border-border"
>
<div className="border-r border-border px-2 py-1">
<select
aria-label={`Local column ${i + 1}`}
value={pair.localCol}
onChange={(e) =>
setPair(
i,
"localCol",
e.target.value,
)
}
className={panelSelect}
>
{localColumns.map((c) => (
<option
key={c.name}
value={c.name}
>
{c.name} (
{c.data_type})
</option>
))}
</select>
</div>
<div className="flex items-center gap-1 px-2 py-1">
<select
aria-label={`Referenced column ${i + 1}`}
value={pair.refCol}
onChange={(e) =>
setPair(
i,
"refCol",
e.target.value,
)
}
className={panelSelect}
>
{refColumns.map((c) => (
<option
key={c.name}
value={c.name}
>
{c.name} (
{c.data_type})
{c.is_pk
? " (PK)"
: ""}
</option>
))}
</select>
{pairs.length > 1 && (
<button
type="button"
aria-label={`Remove pair ${i + 1}`}
onClick={() =>
removePair(i)
}
className="shrink-0 text-text-muted hover:text-red-400"
>
<X size={12} />
</button>
)}
</div>
</div>
))}
<button
type="button"
aria-label="Add another column"
onClick={addPair}
className="text-xs text-accent hover:text-accent-hover px-4 py-2 cursor-pointer"
>
<Plus size={12} className="inline" />{" "}
Add another column
</button>
</Section>
{typeMappings.length > 0 && (
<Section label="Types will be updated">
<ul className="space-y-0.5">
{typeMappings.map((m, i) => (
<li
key={i}
className="px-2 font-mono text-xs text-text"
>
<span className="text-text-muted mr-2">
{m.localType || "?"}
</span>
<span className="text-accent">
</span>{" "}
<span className="">
{m.refType || "?"}
</span>
</li>
))}
</ul>
</Section>
)}
<Section label="Action if referenced row is updated">
<select
aria-label="On update"
value={onUpdate}
onChange={(e) =>
setOnUpdate(e.target.value)
}
className={panelSelect}
>
{FK_ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</Section>
<Section label="Action if referenced row is removed">
<select
aria-label="On delete"
value={onDelete}
onChange={(e) =>
setOnDelete(e.target.value)
}
className={panelSelect}
>
{FK_ACTIONS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</Section>
</>
)}
</>
)}
{error && (
<p className="border-b border-border px-4 py-2 text-xs text-red-400">
{error}
</p>
)}
</div>
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-xs font-medium text-text hover:text-text"
>
Cancel
</button>
<button
type="button"
onClick={addFk}
disabled={
staging ||
table.trim() === "" ||
pairs.length === 0 ||
pairs.some((p) => p.localCol === "" || p.refCol === "")
}
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Add FK
</button>
</div>
</motion.div>
);
}
@@ -6,6 +6,7 @@ import { IndexForm } from "./IndexForm";
import { ConstraintForm } from "./ConstraintForm"; import { ConstraintForm } from "./ConstraintForm";
import { FunctionForm } from "./FunctionForm"; import { FunctionForm } from "./FunctionForm";
import { TriggerForm } from "./TriggerForm"; import { TriggerForm } from "./TriggerForm";
import { RoleForm } from "./RoleForm";
import type { ObjectKind, DdlParams } from "../../../lib/objectCrud"; import type { ObjectKind, DdlParams } from "../../../lib/objectCrud";
interface Props { interface Props {
@@ -64,5 +65,11 @@ export function KindForm({ connectionId, kind, params, schemas, onChange }: Prop
onChange={onChange} onChange={onChange}
/> />
); );
case "table":
// Unreachable in the form-tab flow (ObjectFormTab delegates table to
// TableForm directly); kept only so the switch is exhaustive.
return null;
case "role":
return <RoleForm connectionId={connectionId} params={params} onChange={onChange} />;
} }
} }
@@ -1,24 +1,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ObjectContextMenu } from "./ObjectContextMenu"; import { ObjectContextMenu, DROP_TITLE } from "./ObjectContextMenu";
import * as objectCrud from "../../../lib/objectCrud"; import * as objectCrud from "../../../lib/objectCrud";
import { useDbViewerStore } from "../../../stores/dbViewerStore"; import { useDbViewerStore } from "../../../stores/dbViewerStore";
vi.mock("../../../lib/objectCrud", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../lib/objectCrud")>();
return {
...actual,
buildObjectDdl: vi.fn(),
getAvailableExtensions: vi.fn(),
};
});
describe("ObjectContextMenu", () => { describe("ObjectContextMenu", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
useDbViewerStore.getState().reset(); useDbViewerStore.getState().reset();
}); });
it("DROP_TITLE covers the table and role kinds (exhaustiveness)", () => {
expect(DROP_TITLE.table).toBe("table");
expect(DROP_TITLE.role).toBe("role");
});
it("Edit on a sequence opens an objectForm tab with prefilled edit params", async () => { it("Edit on a sequence opens an objectForm tab with prefilled edit params", async () => {
render( render(
<ObjectContextMenu <ObjectContextMenu
@@ -59,7 +55,7 @@ describe("ObjectContextMenu", () => {
}); });
it("Drop fetches dependencies then stages the drop", async () => { it("Drop fetches dependencies then stages the drop", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([ vi.spyOn(objectCrud, "buildObjectDdl").mockResolvedValue([
'DROP SEQUENCE "public"."s"', 'DROP SEQUENCE "public"."s"',
]); ]);
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange"); const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
@@ -23,7 +23,7 @@ interface Props {
extraItems?: { id: string; label: string; danger?: boolean; onClick: () => void }[]; extraItems?: { id: string; label: string; danger?: boolean; onClick: () => void }[];
} }
const DROP_TITLE: Record<ObjectKind, string> = { export const DROP_TITLE: Record<ObjectKind, string> = {
sequence: "sequence", sequence: "sequence",
enum: "type", enum: "type",
view: "view", view: "view",
@@ -33,6 +33,8 @@ const DROP_TITLE: Record<ObjectKind, string> = {
function: "function", function: "function",
procedure: "procedure", procedure: "procedure",
trigger: "trigger", trigger: "trigger",
table: "table",
role: "role",
}; };
export function ObjectContextMenu({ export function ObjectContextMenu({
@@ -1,6 +1,12 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { ObjectDetail } from "./ObjectDetail"; import {
ObjectDetail,
OBJECT_ICONS,
SINGULAR_LABELS,
TYPE_LABELS,
} from "./ObjectDetail";
import * as commands from "../../../lib/commands";
describe("ObjectDetail", () => { describe("ObjectDetail", () => {
it("renders an enum's labels as list items", () => { it("renders an enum's labels as list items", () => {
@@ -36,4 +42,34 @@ describe("ObjectDetail", () => {
expect(screen.getByText("int")).toBeInTheDocument(); expect(screen.getByText("int")).toBeInTheDocument();
expect(screen.getAllByText(/plpgsql/i).length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText(/plpgsql/i).length).toBeGreaterThanOrEqual(1);
}); });
it("roles detail renders RoleDetail", () => {
vi.spyOn(commands, "getRolePrivileges").mockResolvedValue([]);
render(
<ObjectDetail
connectionId="c1"
type="roles"
item={{
name: "app",
superuser: false,
inherit: true,
create_db: false,
create_role: false,
can_login: true,
replication: false,
bypass_rls: false,
connection_limit: -1,
valid_until: null,
memberships: [],
}}
/>,
);
expect(screen.getByText("app")).toBeInTheDocument();
});
it("registries include roles", () => {
expect(TYPE_LABELS.roles).toBe("Roles");
expect(SINGULAR_LABELS.roles).toBe("role");
expect(OBJECT_ICONS.roles).toBeTruthy();
});
}); });
@@ -6,6 +6,7 @@ import {
ListChecks, ListChecks,
ListOrdered, ListOrdered,
Puzzle, Puzzle,
ShieldCheck,
SquareFunction, SquareFunction,
Tag, Tag,
} from "lucide-react"; } from "lucide-react";
@@ -18,7 +19,9 @@ import type {
ExtensionInfo, ExtensionInfo,
IndexInfo, IndexInfo,
ConstraintInfo, ConstraintInfo,
RoleInfo,
} from "../../../lib/types"; } from "../../../lib/types";
import { RoleDetail } from "./RoleDetail";
export const TYPE_LABELS: Record<ObjectType, string> = { export const TYPE_LABELS: Record<ObjectType, string> = {
functions: "Functions", functions: "Functions",
@@ -29,6 +32,7 @@ export const TYPE_LABELS: Record<ObjectType, string> = {
indexes: "Indexes", indexes: "Indexes",
constraints: "Constraints", constraints: "Constraints",
procedures: "Procedures", procedures: "Procedures",
roles: "Roles",
}; };
export const SINGULAR_LABELS: Record<ObjectType, string> = { export const SINGULAR_LABELS: Record<ObjectType, string> = {
@@ -40,6 +44,7 @@ export const SINGULAR_LABELS: Record<ObjectType, string> = {
indexes: "index", indexes: "index",
constraints: "constraint", constraints: "constraint",
procedures: "procedure", procedures: "procedure",
roles: "role",
}; };
export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = { export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
@@ -53,6 +58,7 @@ export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />, indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />, constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />, procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
roles: <ShieldCheck size={14} className="text-text-muted shrink-0" />,
}; };
export type AnyObject = export type AnyObject =
@@ -62,7 +68,8 @@ export type AnyObject =
| EnumInfo | EnumInfo
| ExtensionInfo | ExtensionInfo
| IndexInfo | IndexInfo
| ConstraintInfo; | ConstraintInfo
| RoleInfo;
// ─── syntax highlighting for PL/pgSQL / SQL ────────────── // ─── syntax highlighting for PL/pgSQL / SQL ──────────────
@@ -562,8 +569,10 @@ function renderFunctionDetail(f: FunctionInfo) {
); );
} }
function renderDetail(type: ObjectType, item: AnyObject) { function renderDetail(connectionId: string, type: ObjectType, item: AnyObject) {
switch (type) { switch (type) {
case "roles":
return <RoleDetail connectionId={connectionId} item={item as RoleInfo} />;
case "functions": case "functions":
return renderFunctionDetail(item as FunctionInfo); return renderFunctionDetail(item as FunctionInfo);
case "procedures": case "procedures":
@@ -969,6 +978,6 @@ interface ObjectDetailProps {
item: AnyObject; item: AnyObject;
} }
export function ObjectDetail({ connectionId: _connectionId, type, item }: ObjectDetailProps) { export function ObjectDetail({ connectionId, type, item }: ObjectDetailProps) {
return <>{renderDetail(type, item)}</>; return <>{renderDetail(connectionId, type, item)}</>;
} }
@@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { KindForm } from "./KindForm"; import { KindForm } from "./KindForm";
import { TableForm } from "./TableForm";
import { import {
buildObjectDdl, buildObjectDdl,
type DdlParams, type DdlParams,
@@ -19,6 +20,10 @@ export function ObjectFormTab({ connectionId, tab }: Props) {
const form = tab.form; const form = tab.form;
if (!form) return null; if (!form) return null;
if (form.kind === "table") {
return <TableForm connectionId={connectionId} tab={tab} />;
}
const { kind, params, description, mode } = form; const { kind, params, description, mode } = form;
const [view, setView] = useState<"visual" | "sql">("visual"); const [view, setView] = useState<"visual" | "sql">("visual");
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { RoleDetail } from "./RoleDetail";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import * as cmd from "../../../lib/commands";
import type { RoleInfo } from "../../../lib/types";
beforeEach(() => {
useDbViewerStore.getState().reset();
vi.spyOn(cmd, "getRolePrivileges").mockReset().mockResolvedValue([
{ object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false },
]);
});
const baseRole: RoleInfo = {
name: "app",
superuser: false,
inherit: true,
create_db: false,
create_role: false,
can_login: true,
replication: false,
bypass_rls: false,
connection_limit: -1,
valid_until: null,
memberships: [],
};
describe("RoleDetail", () => {
it("renders role attributes + memberships + grants editor", async () => {
render(<RoleDetail connectionId="c1" item={baseRole} />);
expect(screen.getByText("app")).toBeInTheDocument();
expect(screen.getByText("Can login")).toBeInTheDocument();
expect(await screen.findByText("public.users")).toBeInTheDocument();
});
it("renders memberships when present", () => {
render(
<RoleDetail
connectionId="c1"
item={{
...baseRole,
memberships: [{ role: "app", member: "other", grantor: "postgres", admin_option: true }],
}}
/>,
);
expect(screen.getByText("other")).toBeInTheDocument();
});
});
@@ -0,0 +1,75 @@
import type { RoleInfo } from "../../../lib/types";
import { FormSectionHeader } from "./formRow";
import { RoleGrantsEditor } from "./RoleGrantsEditor";
interface Props {
connectionId: string;
item: RoleInfo;
}
interface AttributeRow {
label: string;
value: string;
highlight?: boolean;
}
export function RoleDetail({ connectionId, item }: Props) {
const attributes: AttributeRow[] = [
{ label: "Can login", value: item.can_login ? "Yes" : "No", highlight: item.can_login },
{ label: "Superuser", value: item.superuser ? "Yes" : "No", highlight: item.superuser },
{ label: "Create databases", value: item.create_db ? "Yes" : "No", highlight: item.create_db },
{ label: "Create roles", value: item.create_role ? "Yes" : "No", highlight: item.create_role },
{ label: "Inherit privileges", value: item.inherit ? "Yes" : "No", highlight: item.inherit },
{ label: "Replication", value: item.replication ? "Yes" : "No", highlight: item.replication },
{ label: "Bypass RLS", value: item.bypass_rls ? "Yes" : "No", highlight: item.bypass_rls },
{ label: "Connection limit", value: String(item.connection_limit) },
{ label: "Valid until", value: item.valid_until ?? "Never" },
];
return (
<div className="h-full overflow-auto">
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">Role</span>
</div>
<div className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">Name</span>
<span className="text-sm text-accent font-mono">{item.name}</span>
</div>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">Attributes</span>
</div>
{attributes.map((attr) => (
<div key={attr.label} className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center w-40 shrink-0">
<span className="text-xs text-text-muted">{attr.label}</span>
</div>
<div className="px-4 py-2 flex items-center flex-1">
<span className={`text-sm ${attr.highlight ? "text-emerald-400" : "text-text-muted"}`}>
{attr.value}
</span>
</div>
</div>
))}
<div className="border-b border-border">
<FormSectionHeader label="Member of" count={item.memberships.length} />
{item.memberships.length === 0 && (
<p className="px-4 py-2 text-xs text-text-muted">Not a member of any role.</p>
)}
{item.memberships.map((m, i) => (
<div key={`${m.role}-${m.member}-${i}`} className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">Role</span>
<span className="text-sm text-accent font-mono">{m.member}</span>
<span className="ml-2 text-xs text-text-muted">of {m.role}</span>
{m.admin_option && (
<span className="ml-2 text-[10px] text-amber-400 uppercase tracking-wider">admin</span>
)}
</div>
))}
</div>
<RoleGrantsEditor connectionId={connectionId} role={item.name} />
</div>
);
}
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { RoleForm } from "./RoleForm";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import * as cmd from "../../../lib/commands";
beforeEach(() => {
useDbViewerStore.getState().reset();
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue(["CREATE ROLE app;"]);
vi.spyOn(cmd, "getRoles").mockReset().mockResolvedValue([]);
});
function baseParams(mode: "create" | "edit") {
return {
schema: "",
name: mode === "edit" ? "app" : "",
action: {
op: mode === "edit" ? "edit" : "create",
login: mode === "edit",
superuser: false,
createdb: false,
createrole: false,
inherit: true,
replication: false,
bypassrls: false,
connection_limit: -1,
valid_until: "",
password: "",
members: [],
},
} as any;
}
describe("RoleForm", () => {
it("create role with options writes params", () => {
const onChange = vi.fn();
render(
<RoleForm
connectionId="c1"
params={baseParams("create")}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByPlaceholderText("Role name"), { target: { value: "app" } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: "app" }));
fireEvent.click(screen.getByLabelText("LOGIN"));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ action: expect.objectContaining({ login: true }) }),
);
});
it("edit mode password field is blank with keep hint", () => {
render(
<RoleForm
connectionId="c1"
mode="edit"
params={baseParams("edit")}
onChange={() => {}}
/>,
);
expect(screen.getByPlaceholderText(/leave blank to keep/i)).toBeInTheDocument();
});
it("stages a create role ddl via the queue", async () => {
render(
<RoleForm
connectionId="c1"
params={{
schema: "",
name: "app",
action: {
op: "create",
login: true,
superuser: false,
createdb: false,
createrole: false,
inherit: true,
replication: false,
bypassrls: false,
connection_limit: -1,
valid_until: "",
password: "secret",
members: [],
},
}}
onChange={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
await waitFor(() => {
const q = useDbViewerStore.getState().changesQueue;
expect(q).toHaveLength(1);
expect(q[0].type).toBe("ddl");
expect(q[0].sql).toBe("CREATE ROLE app;");
});
});
});
@@ -0,0 +1,300 @@
import { useEffect, useMemo, useState } from "react";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import * as cmd from "../../../lib/commands";
import type { RoleInfo } from "../../../lib/types";
import { FormRow, FormSectionHeader, inputClass, controlClass } from "./formRow";
interface RoleMember {
name: string;
admin: boolean;
}
interface RoleAction {
op: "create" | "edit" | "drop";
login: boolean;
superuser: boolean;
createdb: boolean;
createrole: boolean;
inherit: boolean;
replication: boolean;
bypassrls: boolean;
connection_limit: number;
valid_until: string;
password: string;
members: RoleMember[];
}
interface RoleParams {
schema: string;
name: string;
action: RoleAction;
}
interface Props {
connectionId: string;
mode?: "create" | "edit";
params: Record<string, unknown>;
onChange: (p: Record<string, unknown>) => void;
}
const OPTIONS: { key: keyof Omit<RoleAction, "op" | "connection_limit" | "valid_until" | "password" | "members">; label: string }[] = [
{ key: "login", label: "LOGIN" },
{ key: "superuser", label: "SUPERUSER" },
{ key: "createdb", label: "CREATEDB" },
{ key: "createrole", label: "CREATEROLE" },
{ key: "inherit", label: "INHERIT" },
{ key: "replication", label: "REPLICATION" },
{ key: "bypassrls", label: "BYPASSRLS" },
];
function patchAction(params: RoleParams, patch: Partial<RoleAction>): RoleParams {
return { ...params, action: { ...params.action, ...patch } };
}
export function RoleForm({ connectionId, mode = "create", params, onChange }: Props) {
const typed = params as unknown as RoleParams;
const action = typed.action;
const [view, setView] = useState<"visual" | "sql">("visual");
const [preview, setPreview] = useState("");
const [error, setError] = useState<string | null>(null);
const [staging, setStaging] = useState(false);
const [roles, setRoles] = useState<RoleInfo[]>([]);
const setParams = (next: RoleParams) => onChange(next as unknown as Record<string, unknown>);
useEffect(() => {
let active = true;
cmd
.getRoles(connectionId)
.then((list) => {
if (active) setRoles(list);
})
.catch(() => {
if (active) setRoles([]);
});
return () => {
active = false;
};
}, [connectionId]);
useEffect(() => {
let active = true;
setError(null);
cmd
.buildObjectDdl(connectionId, "role", params)
.then((sqls) => {
if (active) setPreview(Array.isArray(sqls) ? sqls.join("\n;\n") : (sqls as string));
})
.catch((e: unknown) => {
if (active) {
setPreview("");
setError(e instanceof Error ? e.message : String(e));
}
});
return () => {
active = false;
};
}, [connectionId, params]);
const availableRoles = useMemo(
() => roles.filter((r) => r.name !== typed.name),
[roles, typed.name],
);
const toggleMember = (name: string, checked: boolean) => {
const next = checked
? [...action.members, { name, admin: false }]
: action.members.filter((m) => m.name !== name);
setParams(patchAction(typed, { members: next }));
};
const setMemberAdmin = (name: string, admin: boolean) => {
const next = action.members.map((m) => (m.name === name ? { ...m, admin } : m));
setParams(patchAction(typed, { members: next }));
};
const stage = async () => {
setStaging(true);
setError(null);
try {
const sqls = await cmd.buildObjectDdl(connectionId, "role", params);
const list = Array.isArray(sqls) ? sqls : [sqls];
list.forEach((sql) =>
useDbViewerStore.getState().addChange({
type: "ddl",
sql,
description: `${mode === "edit" ? "Edit" : "Create"} role ${typed.name}`,
}),
);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setStaging(false);
}
};
return (
<div className="flex h-full flex-col bg-transparent">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
{mode} role
</span>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-border overflow-hidden">
<button
type="button"
aria-label="Visual"
onClick={() => setView("visual")}
className={[
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
view === "visual" ? "bg-surface-raised text-text" : "text-text-muted hover:text-text",
].join(" ")}
>
Visual
</button>
<button
type="button"
aria-label="SQL"
onClick={() => setView("sql")}
className={[
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
view === "sql" ? "bg-surface-raised text-text" : "text-text-muted hover:text-text",
].join(" ")}
>
SQL
</button>
</div>
<button
type="button"
onClick={stage}
disabled={!!error || staging || !typed.name.trim()}
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Stage
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4 space-y-3">
{view === "visual" ? (
<>
<FormRow label="Name">
<input
type="text"
placeholder="Role name"
value={typed.name}
onChange={(e) => setParams({ ...typed, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<div className="border-b border-border">
<FormSectionHeader label="Options" />
<div className="px-4 py-2 grid grid-cols-2 gap-2">
{OPTIONS.map(({ key, label }) => (
<label
key={key}
className="flex items-center gap-2 text-xs text-text cursor-pointer"
>
<input
type="checkbox"
aria-label={label}
checked={!!action[key]}
onChange={(e) =>
setParams(patchAction(typed, { [key]: e.target.checked } as Partial<RoleAction>))
}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>{label}</span>
</label>
))}
</div>
</div>
<FormRow label="Connection limit">
<input
type="number"
placeholder="-1 for unlimited"
value={action.connection_limit}
onChange={(e) =>
setParams(
patchAction(typed, {
connection_limit: e.target.value === "" ? -1 : Number(e.target.value),
}),
)
}
className={inputClass}
/>
</FormRow>
<FormRow label="Valid until">
<input
type="datetime-local"
value={action.valid_until}
onChange={(e) => setParams(patchAction(typed, { valid_until: e.target.value }))}
className={controlClass}
/>
</FormRow>
<FormRow label="Password">
<input
type="password"
placeholder={
mode === "edit"
? "leave blank to keep current password"
: "Password"
}
value={action.password}
onChange={(e) => setParams(patchAction(typed, { password: e.target.value }))}
className={inputClass}
/>
</FormRow>
<div className="border-b border-border">
<FormSectionHeader label="Member of" count={action.members.length} />
{availableRoles.length === 0 && (
<p className="px-4 py-2 text-xs text-text-muted">No other roles available.</p>
)}
<div className="px-4 py-2 space-y-1">
{availableRoles.map((r) => {
const member = action.members.find((m) => m.name === r.name);
return (
<div key={r.name} className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-text cursor-pointer">
<input
type="checkbox"
checked={!!member}
onChange={(e) => toggleMember(r.name, e.target.checked)}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span className="font-mono">{r.name}</span>
</label>
{member && (
<label className="flex items-center gap-1 text-xs text-text-muted cursor-pointer">
<input
type="checkbox"
checked={member.admin}
onChange={(e) => setMemberAdmin(r.name, e.target.checked)}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
ADMIN
</label>
)}
</div>
);
})}
</div>
</div>
</>
) : (
<pre className="text-xs leading-6 font-mono whitespace-pre-wrap text-text">{preview}</pre>
)}
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { RoleGrantsEditor } from "./RoleGrantsEditor";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import * as cmd from "../../../lib/commands";
import type { PrivilegeEntry } from "../../../lib/types";
beforeEach(() => {
useDbViewerStore.getState().reset();
vi.spyOn(cmd, "getRolePrivileges").mockReset().mockResolvedValue([]);
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue(["GRANT SELECT ON TABLE public.users TO app;"]);
});
describe("RoleGrantsEditor", () => {
it("toggling a privilege stages a GRANT ddl change", async () => {
render(<RoleGrantsEditor connectionId="c1" role="app" />);
fireEvent.change(screen.getByLabelText("Object class"), { target: { value: "table" } });
fireEvent.change(screen.getByPlaceholderText("Schema"), { target: { value: "public" } });
fireEvent.change(screen.getByPlaceholderText("Object name"), { target: { value: "users" } });
fireEvent.click(screen.getByLabelText("SELECT"));
fireEvent.click(screen.getByRole("button", { name: /grant/i }));
await waitFor(() => {
const q = useDbViewerStore.getState().changesQueue;
expect(q.some((c) => c.type === "ddl" && c.sql.startsWith("GRANT"))).toBe(true);
});
});
it("shows first 5 privileges while collapsed, expands to show all", async () => {
vi.spyOn(cmd, "getRolePrivileges").mockResolvedValue([
{ object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false },
]);
render(<RoleGrantsEditor connectionId="c1" role="app" />);
// Fewer than 5 entries — visible immediately without expanding.
expect(await screen.findByText("public.users")).toBeInTheDocument();
expect((await screen.findAllByText("SELECT")).length).toBeGreaterThanOrEqual(1);
});
it("truncates long sections to 5 entries with a fade and Show more", async () => {
const entries: PrivilegeEntry[] = Array.from({ length: 7 }, (_, i) => ({
object_class: "table",
schema: "public",
name: `t${i + 1}`,
privileges: ["SELECT"],
grantable: false,
}));
vi.spyOn(cmd, "getRolePrivileges").mockResolvedValue(entries);
render(<RoleGrantsEditor connectionId="c1" role="app" />);
// Collapsed by default: first 5 visible, rest hidden.
expect(await screen.findByText("public.t1")).toBeInTheDocument();
expect(screen.getByText("public.t5")).toBeInTheDocument();
expect(screen.queryByText("public.t6")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /show 2 more/i }));
expect(await screen.findByText("public.t6")).toBeInTheDocument();
expect(screen.getByText("public.t7")).toBeInTheDocument();
// Header chevron can collapse it back.
fireEvent.click(screen.getByRole("button", { name: /toggle table privileges/i }));
expect(screen.queryByText("public.t6")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,270 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown } from "lucide-react";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import * as cmd from "../../../lib/commands";
import type { PrivilegeEntry } from "../../../lib/types";
import { FormRow, FormSectionHeader, inputClass, controlClass } from "./formRow";
type ObjectClass = "table" | "sequence" | "routine" | "schema" | "database";
interface Props {
connectionId: string;
role: string;
}
const CLASS_LABELS: Record<ObjectClass, string> = {
table: "Table",
sequence: "Sequence",
routine: "Routine",
schema: "Schema",
database: "Database",
};
const PRIVILEGES: Record<ObjectClass, string[]> = {
table: ["SELECT", "INSERT", "UPDATE", "TRUNCATE", "REFERENCES", "TRIGGER"],
sequence: ["USAGE", "SELECT", "UPDATE"],
routine: ["EXECUTE"],
schema: ["USAGE", "CREATE"],
database: ["CONNECT", "CREATE", "TEMP"],
};
export function RoleGrantsEditor({ connectionId, role }: Props) {
const [privileges, setPrivileges] = useState<PrivilegeEntry[]>([]);
const [objectClass, setObjectClass] = useState<ObjectClass>("table");
const [objectSchema, setObjectSchema] = useState("");
const [objectName, setObjectName] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [grantOption, setGrantOption] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const PREVIEW_COUNT = 5;
useEffect(() => {
let active = true;
setLoading(true);
setError(null);
cmd
.getRolePrivileges(connectionId, role)
.then((list) => {
if (active) {
setPrivileges(Array.isArray(list) ? list : []);
}
})
.catch((e: unknown) => {
if (active) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [connectionId, role]);
const grouped = useMemo(() => {
const map = new Map<ObjectClass, PrivilegeEntry[]>();
privileges.forEach((p) => {
const list = map.get(p.object_class) ?? [];
list.push(p);
map.set(p.object_class, list);
});
return map;
}, [privileges]);
const togglePrivilege = (priv: string) => {
const next = new Set(selected);
if (next.has(priv)) next.delete(priv);
else next.add(priv);
setSelected(next);
};
const submit = async (op: "grant" | "revoke") => {
setError(null);
try {
const sqls = await cmd.buildObjectDdl(connectionId, "role", {
schema: "",
name: "",
action: {
op,
object_class: objectClass,
object_schema: objectSchema || null,
object_name: objectName,
privileges: Array.from(selected),
grantee: role,
grant_option: grantOption,
},
});
const list = Array.isArray(sqls) ? sqls : [sqls];
list.forEach((sql) =>
useDbViewerStore.getState().addChange({
type: "ddl",
sql,
description: `${op.toUpperCase()} ${Array.from(selected).join(",")} on ${objectClass} ${objectSchema ? `${objectSchema}.` : ""}${objectName} to ${role}`,
}),
);
setSelected(new Set());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<div className="space-y-3">
<FormSectionHeader label="Current privileges" count={privileges.length} />
{loading && <p className="px-4 py-2 text-xs text-text-muted">Loading...</p>}
{grouped.size === 0 && !loading && (
<p className="px-4 py-2 text-xs text-text-muted">No privileges found for this role.</p>
)}
{Array.from(grouped.entries()).map(([cls, list]) => {
const isExpanded = !!expanded[cls];
const hasMore = list.length > PREVIEW_COUNT;
const visible = !isExpanded && hasMore ? list.slice(0, PREVIEW_COUNT) : list;
return (
<div key={cls} className="border-b border-border">
<button
type="button"
aria-label={`Toggle ${CLASS_LABELS[cls]} privileges`}
onClick={() => setExpanded((e) => ({ ...e, [cls]: !e[cls] }))}
className="w-full px-4 py-1 flex items-center justify-between hover:bg-surface-raised/40 cursor-pointer"
>
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
{CLASS_LABELS[cls]}
</span>
<span className="flex items-center gap-1.5">
<span className="text-[11px] text-text-muted tabular-nums">
{list.length}
</span>
{hasMore && (
<ChevronDown
className={`h-3.5 w-3.5 text-text-muted transition-transform duration-150 ${
isExpanded ? "-rotate-180" : ""
}`}
/>
)}
</span>
</button>
<div className="relative overflow-hidden">
{visible.map((entry) => (
<div
key={`${entry.object_class}:${entry.schema ?? ""}:${entry.name}`}
className="px-4 py-1.5 flex items-center gap-2 text-xs text-text"
>
<span className="font-mono text-accent">
{entry.schema ? `${entry.schema}.` : ""}
{entry.name}
</span>
<span className="text-text-muted">
{entry.privileges.join(", ")}
</span>
</div>
))}
{!isExpanded && hasMore && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-canvas to-transparent" />
)}
</div>
{!isExpanded && hasMore && (
<button
type="button"
onClick={() => setExpanded((e) => ({ ...e, [cls]: true }))}
className="px-4 pb-2 text-[11px] text-accent hover:text-accent-hover cursor-pointer"
>
Show {list.length - PREVIEW_COUNT} more
</button>
)}
</div>
);
})}
<FormSectionHeader label="Edit privileges" />
<FormRow label="Object class">
<select
aria-label="Object class"
value={objectClass}
onChange={(e) => {
setObjectClass(e.target.value as ObjectClass);
setSelected(new Set());
}}
className={controlClass}
>
{(Object.keys(CLASS_LABELS) as ObjectClass[]).map((c) => (
<option key={c} value={c}>
{CLASS_LABELS[c]}
</option>
))}
</select>
</FormRow>
<FormRow label="Schema">
<input
type="text"
placeholder="Schema"
value={objectSchema}
onChange={(e) => setObjectSchema(e.target.value)}
className={inputClass}
/>
</FormRow>
<FormRow label="Object name">
<input
type="text"
placeholder="Object name"
value={objectName}
onChange={(e) => setObjectName(e.target.value)}
className={inputClass}
/>
</FormRow>
<FormRow label="Privileges">
<div className="flex-1 px-4 py-2 grid grid-cols-2 gap-2">
{PRIVILEGES[objectClass].map((priv) => (
<label key={priv} className="flex items-center gap-2 text-xs text-text cursor-pointer">
<input
type="checkbox"
aria-label={priv}
checked={selected.has(priv)}
onChange={() => togglePrivilege(priv)}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>{priv}</span>
</label>
))}
</div>
</FormRow>
<FormRow label="Grant option">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
checked={grantOption}
onChange={(e) => setGrantOption(e.target.checked)}
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>WITH GRANT OPTION</span>
</label>
</FormRow>
<div className="flex items-center gap-2 px-4 py-2">
<button
type="button"
onClick={() => submit("grant")}
disabled={selected.size === 0 || !objectName.trim()}
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Grant
</button>
<button
type="button"
onClick={() => submit("revoke")}
disabled={selected.size === 0 || !objectName.trim()}
className="rounded-lg bg-surface px-3 py-1.5 text-xs font-medium text-text border border-border hover:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Revoke
</button>
</div>
{error && <p className="px-4 text-xs text-red-400">{error}</p>}
</div>
);
}
@@ -75,7 +75,7 @@ describe("SequenceForm", () => {
const valueArea = input.parentElement; const valueArea = input.parentElement;
expect(valueArea).not.toBeNull(); expect(valueArea).not.toBeNull();
expect(valueArea!.className).toContain("focus-within:outline"); expect(valueArea!.className).toContain("focus-within:outline");
expect(valueArea!.className).toContain("focus-within:outline-amber-400"); expect(valueArea!.className).toContain("focus-within:outline-amber-400/20");
expect(valueArea!.className).toContain("focus-within:outline-offset-[-2px]"); expect(valueArea!.className).toContain("focus-within:outline-offset-[-2px]");
// The label cell must stay clean: no ancestor of the label may carry // The label cell must stay clean: no ancestor of the label may carry
@@ -0,0 +1,516 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TableForm } from "./TableForm";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
import * as cmd from "../../../lib/commands";
import * as objectCrud from "../../../lib/objectCrud";
beforeEach(() => {
useDbViewerStore.getState().reset();
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue([]);
vi.spyOn(cmd, "buildRebuildScript").mockReset().mockResolvedValue("");
vi.spyOn(cmd, "getTableRebuildReadiness").mockReset().mockResolvedValue({ ok: true, reasons: [] });
vi.spyOn(cmd, "getTableColumns").mockReset().mockResolvedValue([]);
vi.spyOn(cmd, "getTablespaces").mockReset().mockResolvedValue([]);
vi.spyOn(cmd, "getConstraints").mockReset().mockResolvedValue([]);
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
});
afterEach(() => {
vi.restoreAllMocks();
});
function baseParams(mode: "create" | "edit") {
return {
schema: "public",
name: mode === "edit" ? "users" : "",
action: {
op: mode === "edit" ? "edit" : "create",
columns: [],
old_columns:
mode === "edit"
? [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }]
: undefined,
},
} as any;
}
function seedFormTab(tab: any) {
useDbViewerStore.setState({ tabs: [{ ...tab, tabType: tab.tabType ?? "objectForm" }], activeTabId: tab.id });
}
describe("TableForm", () => {
it("add/remove rows; composite PK allowed", () => {
const tab = {
id: "t1",
form: {
kind: "table",
params: baseParams("create"),
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
fireEvent.click(screen.getByLabelText("Add column"));
fireEvent.click(screen.getByLabelText("Add column"));
const cols = () => (useDbViewerStore.getState().tabs[0].form?.params.action as any).columns;
expect(cols()).toHaveLength(2);
// the first added column auto-starts as the PK
expect(cols()[0].is_pk).toBe(true);
// marking a second column as PK keeps both (composite key)
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
fireEvent.click(screen.getByLabelText("PK"));
expect(cols().filter((c: any) => c.is_pk)).toHaveLength(2);
});
it("reorder triggers rebuild path and readiness refusal blocks staging", async () => {
(cmd.getTableRebuildReadiness as any).mockResolvedValue({ ok: false, reasons: ["table has triggers"] });
render(
<TableForm
connectionId="c1"
tab={{
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "users",
action: {
op: "edit",
columns: [
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
],
old_columns: [
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
],
},
},
title: "Edit Table",
description: "Edit Table",
mode: "edit",
},
title: "Edit Table",
} as any}
/>,
);
// order differs => rebuild; readiness not ok => refusal copy + Stage disabled
expect(await screen.findByText(/has triggers/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Stage" })).toBeDisabled();
});
it("rebuild ok stages a single rebuild_table change", async () => {
(cmd.getTableRebuildReadiness as any).mockResolvedValue({ ok: true, reasons: [] });
(cmd.buildRebuildScript as any).mockResolvedValue("CREATE TABLE _t();");
render(
<TableForm
connectionId="c1"
tab={{
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "users",
action: {
op: "edit",
columns: [
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
],
old_columns: [
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
],
},
},
title: "Edit Table",
description: "Edit Table",
mode: "edit",
},
title: "Edit Table",
} as any}
/>,
);
await screen.findByLabelText(/SQL/i);
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
await screen.findByText(/Rebuild/i).catch(() => null); // staging is sync-ish
const q = useDbViewerStore.getState().changesQueue;
expect(q).toHaveLength(1);
expect(q[0].type).toBe("rebuild_table");
expect(q[0].sql).toBe("CREATE TABLE _t();");
});
it("edit diff (no reorder) stages ddl per statement after stale guard passes", async () => {
(cmd.getTableColumns as any).mockResolvedValue([
{
name: "id",
data_type: "integer",
is_nullable: false,
is_pk: true,
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
},
]);
(cmd.buildObjectDdl as any).mockResolvedValue([
'ALTER TABLE "public"."users" ADD COLUMN "email" text',
]);
render(
<TableForm
connectionId="c1"
tab={{
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "users",
action: {
op: "edit",
columns: [
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
],
old_columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
},
},
title: "Edit Table",
description: "Edit users",
mode: "edit",
},
title: "Edit Table",
} as any}
/>,
);
await screen.findByLabelText(/SQL/i);
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
await screen.findByText(/committed/i).catch(() => null); // staging is sync-ish
const q = useDbViewerStore.getState().changesQueue;
expect(q).toHaveLength(1);
expect(q[0].type).toBe("ddl");
});
it("stale guard blocks staging when live columns differ", async () => {
(cmd.getTableColumns as any).mockResolvedValue([
{
name: "id",
data_type: "bigint",
is_nullable: false,
is_pk: true,
is_fk: false,
fk_ref: null,
default_value: null,
editable: true,
is_generated: false,
},
]);
render(
<TableForm
connectionId="c1"
tab={{
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "users",
action: {
op: "edit",
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
old_columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
},
},
title: "Edit Table",
description: "Edit users",
mode: "edit",
},
title: "Edit Table",
} as any}
/>,
);
await screen.findByLabelText(/SQL/i);
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
expect(await screen.findByText(/changed since you opened/i)).toBeInTheDocument();
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
});
it("create-mode grid renders header and cog settings menus", () => {
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "products",
action: {
op: "create",
columns: [
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
{ name: "sku", type: "text", nullable: true, default: null, is_pk: false },
],
},
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
const headers = screen.getAllByText("Name");
expect(headers.length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("Type")).toBeInTheDocument();
expect(screen.getByText("Parameters")).toBeInTheDocument();
expect(screen.getByText("Default Value")).toBeInTheDocument();
// first row (id, integer): Auto-Increment is available
fireEvent.click(screen.getAllByLabelText("Column settings")[0]);
expect(screen.getByLabelText("PK")).toBeInTheDocument();
expect(screen.getByLabelText("Auto-Increment")).toBeInTheDocument();
expect(screen.getByLabelText("Unique")).toBeInTheDocument();
expect(screen.getByLabelText("Nullable")).toBeInTheDocument();
fireEvent.click(screen.getAllByLabelText("Column settings")[0]); // close
// second row (sku, text): no Auto-Increment
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
expect(screen.getByLabelText("PK")).toBeInTheDocument();
expect(screen.queryByLabelText("Auto-Increment")).toBeNull();
expect(screen.getByLabelText("Unique")).toBeInTheDocument();
expect(screen.getByLabelText("Nullable")).toBeInTheDocument();
});
it("folds auto_increment integer to serial in the DDL payload", async () => {
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "products",
action: {
op: "create",
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
},
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
fireEvent.click(screen.getByLabelText("Column settings"));
fireEvent.click(screen.getByLabelText("Auto-Increment"));
await waitFor(() => {
expect((useDbViewerStore.getState().tabs[0].form?.params.action as any).columns[0].auto_increment).toBe(true);
});
fireEvent.click(screen.getByLabelText("Column settings")); // close menu
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
await waitFor(() => expect(cmd.buildObjectDdl).toHaveBeenCalled());
const calls = (cmd.buildObjectDdl as any).mock.calls;
const stageCall = calls.find((call: any) => call[2].action.columns.some((c: any) => c.type === "serial"));
expect(stageCall).toBeTruthy();
expect(stageCall[2].action.columns).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "id", type: "serial", nullable: false, is_pk: true })]),
);
});
it("folds unique to the DDL payload column", async () => {
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "products",
action: {
op: "create",
columns: [
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
{ name: "sku", type: "text", nullable: false, default: null, is_pk: false },
],
},
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
fireEvent.click(screen.getByLabelText("Unique"));
await waitFor(() => {
expect((useDbViewerStore.getState().tabs[0].form?.params.action as any).columns[1].unique).toBe(true);
});
fireEvent.click(screen.getAllByLabelText("Column settings")[1]); // close menu
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
await waitFor(() => expect(cmd.buildObjectDdl).toHaveBeenCalled());
const calls = (cmd.buildObjectDdl as any).mock.calls;
const stageCall = calls.find((call: any) => call[2].action.columns.some((c: any) => c.name === "sku" && c.unique === true));
expect(stageCall).toBeTruthy();
expect(stageCall[2].action.columns).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "sku", unique: true })]),
);
});
it("schema select auto-selects the current schema in create mode", () => {
useDbViewerStore.setState({ schemas: ["public", "audit"] });
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "audit",
name: "",
action: { op: "create", columns: [] },
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
const sel = screen.getByLabelText("Schema") as HTMLSelectElement;
expect(sel.value).toBe("audit");
fireEvent.change(sel, { target: { value: "public" } });
expect((useDbViewerStore.getState().tabs[0].form?.params as any).schema).toBe("public");
});
it("staging an FK applies the referenced type to the local column", async () => {
(cmd.getSchemaGraph as any).mockResolvedValue({
tables: [
{ name: "products", schema: "public", table_type: "BASE TABLE", columns: [{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null }] },
{ name: "categories", schema: "public", table_type: "BASE TABLE", columns: [{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null }] },
],
relationships: [],
});
vi.spyOn(objectCrud, "buildObjectDdl").mockResolvedValue(["ALTER TABLE \"public\".\"products\" ADD FOREIGN KEY (\"category_id\") REFERENCES \"public\".\"categories\" (\"id\")"]);
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "products",
action: {
op: "create",
columns: [
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
{ name: "category_id", type: "integer", nullable: true, default: null, is_pk: false },
],
},
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
fireEvent.click(screen.getByLabelText("Set foreign key")); // category_id (id is PK → no FK icon)
await screen.findByText("Foreign key");
const refColSel = (await screen.findByLabelText("Referenced column 1")) as HTMLSelectElement;
await waitFor(() => {
expect([...refColSel.options].map((o) => o.value)).toContain("id");
});
const addFkButtons = screen.getAllByRole("button", { name: "Add FK" });
fireEvent.click(addFkButtons[addFkButtons.length - 1]);
await waitFor(() => {
const cols = (useDbViewerStore.getState().tabs[0].form?.params.action as any).columns;
const col = cols.find((c: any) => c.name === "category_id");
expect(col.type).toBe("int");
expect(col.fk).toBe(true);
});
// the FK is staged inline into the CREATE TABLE (single change), not as a separate ALTER
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
await waitFor(() => {
const stageCall = (cmd.buildObjectDdl as any).mock.calls.find((call: any) =>
call[1] === "table" &&
call[2]?.action?.foreign_keys?.some((fk: any) => fk.columns[0] === "category_id"),
);
expect(stageCall).toBeTruthy();
});
});
it("shows staged FK changes in the Foreign keys section", async () => {
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "products",
action: { op: "create", columns: [] },
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
useDbViewerStore.getState().addChange({
type: "ddl",
sql: 'ALTER TABLE "public"."products" ADD FOREIGN KEY ("category_id") REFERENCES "public"."categories" ("id") ON DELETE CASCADE',
description: "Add FK category_id → public.categories",
});
render(<TableForm connectionId="c1" tab={tab} />);
expect(await screen.findByText(/Foreign key relation to/)).toBeInTheDocument();
expect(screen.getByText("public.categories")).toBeInTheDocument();
expect(screen.getByText(/products.category_id → categories.id/)).toBeInTheDocument();
expect(screen.getByText(/· CASCADE/)).toBeInTheDocument();
});
it("opens the FK panel with the column preselected", async () => {
(cmd.getSchemaGraph as any).mockResolvedValue({
tables: [
{ name: "products", schema: "public", table_type: "BASE TABLE", columns: [{ name: "category_id", data_type: "int", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null }] },
{ name: "categories", schema: "public", table_type: "BASE TABLE", columns: [{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null }] },
],
relationships: [],
});
const tab = {
id: "t1",
form: {
kind: "table",
params: {
schema: "public",
name: "products",
action: {
op: "create",
columns: [
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
{ name: "category_id", type: "integer", nullable: true, default: null, is_pk: false },
],
},
},
title: "Create Table",
description: "Create Table",
mode: "create",
},
title: "Create Table",
} as any;
seedFormTab(tab);
render(<TableForm connectionId="c1" tab={tab} />);
const fkButtons = screen.getAllByLabelText("Set foreign key");
// id is the PK column → its FK icon is hidden; category_id is the only one
expect(fkButtons).toHaveLength(1);
fireEvent.click(fkButtons[0]);
expect(await screen.findByText("Foreign key")).toBeInTheDocument();
const local = (await screen.findByLabelText("Local column 1")) as HTMLSelectElement;
expect(local.value).toBe("category_id");
});
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,7 +32,7 @@ export function FormRow({ label, children, className, outline = true }: FormRowP
className={[ className={[
"flex-1 min-w-0 flex flex-row items-center", "flex-1 min-w-0 flex flex-row items-center",
outline outline
? "focus-within:outline focus-within:outline-2 focus-within:outline-amber-400 focus-within:outline-offset-[-2px]" ? "focus-within:outline focus-within:outline-2 focus-within:outline-amber-400/20 focus-within:outline-offset-[-2px]"
: "", : "",
].join(" ")} ].join(" ")}
> >
+3 -3
View File
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import tauriConf from "../../src-tauri/tauri.conf.json"; import tauriConf from "../../src-tauri/tauri.conf.json";
describe("tauri bundle config (v0.7.6)", () => { describe("tauri bundle config (v0.7.7)", () => {
it("declares bundled pg_tools resources", () => { it("declares bundled pg_tools resources", () => {
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*"); expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
}); });
it("version is 0.7.6", () => { it("version is 0.7.7", () => {
expect(tauriConf.version).toBe("0.7.6"); expect(tauriConf.version).toBe("0.7.7");
}); });
}); });
+17
View File
@@ -63,6 +63,23 @@ describe("buildChangeSql", () => {
}); });
}); });
function rebuildItem(sql: string): QueueItem {
return {
id: "ch-rb1", type: "rebuild_table", sql, status: "pending",
createdAt: 0,
} as unknown as QueueItem;
}
describe("rebuild_table payload", () => {
it("builds a rebuild_table payload with the script", () => {
const p = buildChangePayload(rebuildItem("CREATE TABLE _t();"));
expect(p).toEqual({ id: "ch-rb1", type: "rebuild_table", sql: "CREATE TABLE _t();" });
});
it("buildChangeSql returns the script verbatim", () => {
expect(buildChangeSql(rebuildItem("SELECT 1;"))).toBe("SELECT 1;");
});
});
const ddlItem: QueueItem = { const ddlItem: QueueItem = {
id: "ch-1", type: "ddl", id: "ch-1", type: "ddl",
sql: "CREATE TYPE public.role AS ENUM ('admin')", sql: "CREATE TYPE public.role AS ENUM ('admin')",
+4
View File
@@ -59,6 +59,8 @@ export function buildChangeSql(item: QueueItem): string {
return `DELETE FROM ${t}`; return `DELETE FROM ${t}`;
case "ddl": case "ddl":
return item.sql ?? ""; return item.sql ?? "";
case "rebuild_table":
return item.sql ?? "";
case "drop_table": case "drop_table":
return `DROP TABLE ${t}`; return `DROP TABLE ${t}`;
default: default:
@@ -88,6 +90,8 @@ export function buildChangePayload(item: QueueItem): ChangePayload {
return { id: item.id, type: "empty_table", schema, table }; return { id: item.id, type: "empty_table", schema, table };
case "ddl": case "ddl":
return { id: item.id, type: "ddl", sql: item.sql }; return { id: item.id, type: "ddl", sql: item.sql };
case "rebuild_table":
return { id: item.id, type: "rebuild_table", sql: item.sql };
default: default:
return { id: item.id, type: item.type, sql: item.sql }; return { id: item.id, type: item.type, sql: item.sql };
} }
+91
View File
@@ -7,6 +7,13 @@ vi.mock("@tauri-apps/api/core", () => ({
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { import {
testConnection, testConnection,
getRoles,
getRolePrivileges,
getTableColumns,
getTableRebuildReadiness,
runMaintenance,
getTablespaces,
buildRebuildScript,
dbConnect, dbConnect,
dbDisconnect, dbDisconnect,
getDatabases, getDatabases,
@@ -333,3 +340,87 @@ describe("object management commands (v0.7.5)", () => {
expect(invoke).toHaveBeenCalledWith("get_object_dependencies", { connectionId: "c1", schema: "public", objectType: "table", name: "orders" }); expect(invoke).toHaveBeenCalledWith("get_object_dependencies", { connectionId: "c1", schema: "public", objectType: "table", name: "orders" });
}); });
}); });
describe("v0.7.7 command wrappers", () => {
it("getRoles calls get_roles", async () => {
const mockRoles = [
{
name: "postgres",
superuser: true,
inherit: true,
create_db: true,
create_role: true,
can_login: true,
replication: true,
bypass_rls: false,
connection_limit: -1,
valid_until: null,
memberships: [],
},
];
vi.mocked(invoke).mockResolvedValueOnce(mockRoles);
const result = await getRoles("c1");
expect(invoke).toHaveBeenCalledWith("get_roles", { connectionId: "c1" });
expect(result).toEqual(mockRoles);
});
it("getRolePrivileges calls get_role_privileges with role", async () => {
vi.mocked(invoke).mockResolvedValueOnce([]);
const result = await getRolePrivileges("c1", "app");
expect(invoke).toHaveBeenCalledWith("get_role_privileges", { connectionId: "c1", role: "app" });
expect(result).toEqual([]);
});
it("getTableColumns calls get_table_columns", async () => {
vi.mocked(invoke).mockResolvedValueOnce([]);
const result = await getTableColumns("c1", "public", "users");
expect(invoke).toHaveBeenCalledWith("get_table_columns", { connectionId: "c1", schema: "public", table: "users" });
expect(result).toEqual([]);
});
it("getTableRebuildReadiness calls get_table_rebuild_readiness", async () => {
const mockReadiness = { ok: true, reasons: [] };
vi.mocked(invoke).mockResolvedValueOnce(mockReadiness);
const result = await getTableRebuildReadiness("c1", "public", "users");
expect(invoke).toHaveBeenCalledWith("get_table_rebuild_readiness", { connectionId: "c1", schema: "public", table: "users" });
expect(result).toEqual(mockReadiness);
});
it("runMaintenance calls run_maintenance with action", async () => {
const mockResult = { duration_ms: 5, message: "ok" };
vi.mocked(invoke).mockResolvedValueOnce(mockResult);
const result = await runMaintenance("c1", "public", "users", "vacuum");
expect(invoke).toHaveBeenCalledWith("run_maintenance", { connectionId: "c1", schema: "public", table: "users", action: "vacuum" });
expect(result).toEqual(mockResult);
});
it("getTablespaces calls get_tablespaces", async () => {
vi.mocked(invoke).mockResolvedValueOnce([]);
const result = await getTablespaces("c1");
expect(invoke).toHaveBeenCalledWith("get_tablespaces", { connectionId: "c1" });
expect(result).toEqual([]);
});
it("buildRebuildScript calls build_rebuild_script with newColumns", async () => {
const mockScript = "CREATE TABLE _t();";
const cols = [{ name: "id", type: "int", nullable: false, default: null, is_pk: true }];
vi.mocked(invoke).mockResolvedValueOnce(mockScript);
const result = await buildRebuildScript("c1", "public", "users", cols);
expect(invoke).toHaveBeenCalledWith("build_rebuild_script", { connectionId: "c1", schema: "public", table: "users", newColumns: cols });
expect(result).toEqual(mockScript);
});
});
+33 -1
View File
@@ -1,7 +1,15 @@
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, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo } from "./types"; import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload"; import type { ChangePayload } from "./changePayload";
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
export { type ObjectKind, type DdlParams };
/** Build one or more SQL statements for an object CRUD operation. */
export function buildObjectDdl(connectionId: string, kind: ObjectKind, params: DdlParams): Promise<string[]> {
return buildObjectDdlImpl(connectionId, kind, params);
}
// NOTE on argument key naming: // NOTE on argument key naming:
// Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names // Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names
@@ -320,3 +328,27 @@ export async function getObjectDdl(connectionId: string, schema: string, objectT
export async function getObjectDependencies(connectionId: string, schema: string, objectType: string, name: string): Promise<DependencyInfo[]> { export async function getObjectDependencies(connectionId: string, schema: string, objectType: string, name: string): Promise<DependencyInfo[]> {
return invoke<DependencyInfo[]>("get_object_dependencies", { connectionId, schema, objectType, name }); return invoke<DependencyInfo[]>("get_object_dependencies", { connectionId, schema, objectType, name });
} }
// ─── v0.7.7: Roles / privileges / rebuild / maintenance ──────────
export async function getRoles(connectionId: string): Promise<RoleInfo[]> {
return invoke<RoleInfo[]>("get_roles", { connectionId });
}
export async function getRolePrivileges(connectionId: string, role: string): Promise<PrivilegeEntry[]> {
return invoke<PrivilegeEntry[]>("get_role_privileges", { connectionId, role });
}
export async function getTableColumns(connectionId: string, schema: string, table: string): Promise<ColumnInfo[]> {
return invoke<ColumnInfo[]>("get_table_columns", { connectionId, schema, table });
}
export async function getTableRebuildReadiness(connectionId: string, schema: string, table: string): Promise<RebuildReadiness> {
return invoke<RebuildReadiness>("get_table_rebuild_readiness", { connectionId, schema, table });
}
export async function runMaintenance(connectionId: string, schema: string, table: string, action: "vacuum" | "analyze" | "reindex"): Promise<MaintenanceResult> {
return invoke<MaintenanceResult>("run_maintenance", { connectionId, schema, table, action });
}
export async function getTablespaces(connectionId: string): Promise<TablespaceInfo[]> {
return invoke<TablespaceInfo[]>("get_tablespaces", { connectionId });
}
export async function buildRebuildScript<C extends { name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean }[]>(connectionId: string, schema: string, table: string, newColumns: C): Promise<string> {
return invoke<string>("build_rebuild_script", { connectionId, schema, table, newColumns });
}
+20
View File
@@ -7,6 +7,7 @@ describe("dbCapabilities", () => {
expect(c).toEqual<DbCapabilities>({ expect(c).toEqual<DbCapabilities>({
explorer: true, queries: true, objects: true, visualizer: true, explorer: true, queries: true, objects: true, visualizer: true,
tools: true, editing: true, import: true, ddl: true, objectCrud: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true,
maintenance: true, roles: true, tableManagement: true,
}); });
}); });
@@ -38,6 +39,7 @@ describe("dbCapabilities", () => {
expect(DB_CAPABILITIES.redis).toEqual<DbCapabilities>({ expect(DB_CAPABILITIES.redis).toEqual<DbCapabilities>({
explorer: false, queries: false, objects: false, visualizer: false, explorer: false, queries: false, objects: false, visualizer: false,
tools: false, editing: false, import: false, ddl: false, objectCrud: false, tools: false, editing: false, import: false, ddl: false, objectCrud: false,
maintenance: false, roles: false, tableManagement: false,
}); });
}); });
@@ -70,3 +72,21 @@ describe("objectCrud capability", () => {
expect(getCapabilities("oracle").objectCrud).toBe(false); expect(getCapabilities("oracle").objectCrud).toBe(false);
}); });
}); });
describe("v0.7.7 capabilities", () => {
it("postgresql has maintenance, roles, tableManagement", () => {
expect(DB_CAPABILITIES.postgresql.maintenance).toBe(true);
expect(DB_CAPABILITIES.postgresql.roles).toBe(true);
expect(DB_CAPABILITIES.postgresql.tableManagement).toBe(true);
});
it("mysql/sqlite/redis disable maintenance, roles, tableManagement", () => {
for (const t of ["mysql", "sqlite", "redis"] as const) {
expect(DB_CAPABILITIES[t].maintenance).toBe(false);
expect(DB_CAPABILITIES[t].roles).toBe(false);
expect(DB_CAPABILITIES[t].tableManagement).toBe(false);
}
});
it("getCapabilities is safe for unknown types", () => {
expect(getCapabilities("bogus").maintenance).toBe(false);
});
});
+8 -1
View File
@@ -19,15 +19,22 @@ export interface DbCapabilities {
ddl: boolean; ddl: boolean;
/** Create / edit / drop PostgreSQL objects via the changes queue. */ /** Create / edit / drop PostgreSQL objects via the changes queue. */
objectCrud: boolean; objectCrud: boolean;
/** Table maintenance (VACUUM/ANALYZE/REINDEX) + rebuild-table. */
maintenance: boolean;
/** Role browsing/management. */
roles: boolean;
/** Rebuild-table (rewrite in place) support. */
tableManagement: boolean;
} }
const ALL_FALSE: DbCapabilities = { const ALL_FALSE: DbCapabilities = {
explorer: false, queries: false, objects: false, visualizer: false, explorer: false, queries: false, objects: false, visualizer: false,
tools: false, editing: false, import: false, ddl: false, objectCrud: false, tools: false, editing: false, import: false, ddl: false, objectCrud: false,
maintenance: false, roles: false, tableManagement: false,
}; };
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = { export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true }, postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true },
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true }, mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true },
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true }, sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true },
redis: { ...ALL_FALSE }, redis: { ...ALL_FALSE },
+7 -7
View File
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
import agents from "../../AGENTS.md?raw"; import agents from "../../AGENTS.md?raw";
import readme from "../../README.md?raw"; import readme from "../../README.md?raw";
describe("v0.7.6 docs coverage", () => { describe("v0.7.7 docs coverage", () => {
it("AGENTS.md marks inline cell editing complete", () => { it("AGENTS.md marks inline cell editing complete", () => {
expect(agents).toContain("Inline cell editing"); expect(agents).toContain("Inline cell editing");
expect(agents).toMatch(/Inline cell editing \| ✅/); expect(agents).toMatch(/Inline cell editing \| ✅/);
@@ -24,8 +24,8 @@ describe("v0.7.6 docs coverage", () => {
expect(agents).toMatch(/Connection status indicator on cards \| ✅/); expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/); expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
}); });
it("README declares v0.7.6", () => { it("README declares v0.7.7", () => {
expect(readme).toContain("0.7.6"); expect(readme).toContain("0.7.7");
}); });
it("AGENTS.md marks schema CRUD complete", () => { it("AGENTS.md marks schema CRUD complete", () => {
expect(agents).toMatch(/Schema CRUD \| ✅/); expect(agents).toMatch(/Schema CRUD \| ✅/);
@@ -59,9 +59,9 @@ describe("v0.7.6 docs coverage", () => {
it("AGENTS.md marks the Objects view tabbed workspace complete", () => { it("AGENTS.md marks the Objects view tabbed workspace complete", () => {
expect(agents).toMatch(/Objects view tabbed workspace \| ✅/); expect(agents).toMatch(/Objects view tabbed workspace \| ✅/);
}); });
it("README links to v0.7.6 assets in both download tables", () => { it("README links to v0.7.7 assets in both download tables", () => {
expect(readme).toContain("releases/download/v0.7.6/"); expect(readme).toContain("releases/download/v0.7.7/");
expect(readme).toContain("Gridline_0.7.6_aarch64.dmg"); expect(readme).toContain("Gridline_0.7.7_aarch64.dmg");
expect(readme).toContain("Gridline-0.7.6-1.x86_64.rpm"); expect(readme).toContain("Gridline-0.7.7-1.x86_64.rpm");
}); });
}); });
+28
View File
@@ -11,6 +11,7 @@ import {
getAvailableExtensions, getAvailableExtensions,
initialCrudParams, initialCrudParams,
} from "./objectCrud"; } from "./objectCrud";
import type { CrudItem } from "./objectCrud";
describe("buildObjectDdl", () => { describe("buildObjectDdl", () => {
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
@@ -461,3 +462,30 @@ describe("dropCrudParams", () => {
}); });
}); });
}); });
describe("table/role crud params", () => {
it("initialCrudParams(table, create) starts empty with columns", () => {
const p = initialCrudParams("table", { schema: "public", name: "" }, "create");
expect(p).toEqual({ schema: "public", name: "", action: { op: "create", columns: [] } });
});
it("initialCrudParams(table, edit) prefills columns", () => {
const item: CrudItem = { schema: "public", name: "users", columns: ["id"], columnMeta: [
{ name: "id", type: "integer", nullable: false, is_pk: true, default: null },
] };
const p = initialCrudParams("table", item, "edit");
expect((p as any).action.op).toBe("edit");
expect((p as any).action.old_columns).toEqual(item.columnMeta);
});
it("initialCrudParams(role, create) starts empty with options", () => {
const p = initialCrudParams("role", { schema: "", name: "" }, "create");
expect(p).toEqual({ schema: "", name: "", action: { op: "create", login: false, superuser: false, createdb: false, createrole: false, inherit: true, replication: false, bypassrls: false, connection_limit: -1, valid_until: "", password: "", members: [] } });
});
it("dropCrudParams(role) drops by name", () => {
const p = dropCrudParams("role", { schema: "", name: "app" });
expect(p).toEqual({ schema: "", name: "app", action: { op: "drop" } });
});
it("dropCrudParams(table) drops the table", () => {
const p = dropCrudParams("table", { schema: "public", name: "users" });
expect(p).toEqual({ schema: "public", name: "users", action: { op: "drop" } });
});
});
+52 -1
View File
@@ -14,7 +14,9 @@ export type ObjectKind =
| "constraint" | "constraint"
| "function" | "function"
| "procedure" | "procedure"
| "trigger"; | "trigger"
| "table"
| "role";
/// Opaque payload for a build: `{ schema, name, action: { op, ... } }`. /// Opaque payload for a build: `{ schema, name, action: { op, ... } }`.
/// The concrete shape is validated server-side by each kind's params struct. /// The concrete shape is validated server-side by each kind's params struct.
@@ -60,6 +62,18 @@ export interface CrudItem {
columns?: string[]; columns?: string[];
// constraint (ConstraintInfo) // constraint (ConstraintInfo)
contype?: "CHECK" | "UNIQUE" | "EXCLUSION"; contype?: "CHECK" | "UNIQUE" | "EXCLUSION";
// table (columnMeta carries the snapshot for edit diffs)
columnMeta?: { name: string; type: string; nullable: boolean; is_pk: boolean; default: string | null }[];
// role (RoleInfo shape used for prefill)
superuser?: boolean;
inherit?: boolean;
create_db?: boolean;
create_role?: boolean;
can_login?: boolean;
replication?: boolean;
bypass_rls?: boolean;
connection_limit?: number;
valid_until?: string | null;
} }
/** /**
@@ -194,6 +208,40 @@ export function initialCrudParams(
when: null, when: null,
}, },
}; };
case "table":
return {
schema,
name: mode === "edit" ? item.name : "",
action: {
op: mode === "edit" ? "edit" : "create",
columns: mode === "edit"
? (item.columnMeta ?? []).map((c) => ({
name: c.name, type: c.type, nullable: c.nullable,
default: c.default ?? null, is_pk: c.is_pk,
}))
: [],
old_columns: mode === "edit" ? (item.columnMeta ?? []) : undefined,
},
};
case "role":
return {
schema,
name: mode === "edit" ? item.name : "",
action: {
op: "create",
login: mode === "edit" ? (item.can_login ?? false) : false,
superuser: mode === "edit" ? (item.superuser ?? false) : false,
createdb: mode === "edit" ? (item.create_db ?? false) : false,
createrole: mode === "edit" ? (item.create_role ?? false) : false,
inherit: mode === "edit" ? (item.inherit ?? true) : true,
replication: mode === "edit" ? (item.replication ?? false) : false,
bypassrls: mode === "edit" ? (item.bypass_rls ?? false) : false,
connection_limit: mode === "edit" ? (item.connection_limit ?? -1) : -1,
valid_until: mode === "edit" ? (item.valid_until ?? "") : "",
password: "",
members: [],
},
};
} }
} }
@@ -203,6 +251,9 @@ export function initialCrudParams(
*/ */
export function dropCrudParams(kind: ObjectKind, item: CrudItem): DdlParams { export function dropCrudParams(kind: ObjectKind, item: CrudItem): DdlParams {
const base = { schema: item.schema, name: item.name }; const base = { schema: item.schema, name: item.name };
if (kind === "table" || kind === "role") {
return { ...base, action: { op: "drop" } };
}
if (kind === "trigger") { if (kind === "trigger") {
return { return {
...base, ...base,
+32
View File
@@ -19,6 +19,11 @@ import type {
IndexInfo, IndexInfo,
ConstraintInfo, ConstraintInfo,
RecentConnection, RecentConnection,
RoleInfo,
PrivilegeEntry,
RebuildReadiness,
MaintenanceResult,
ObjectType,
} from "./types"; } from "./types";
describe("ActiveView", () => { describe("ActiveView", () => {
@@ -496,3 +501,30 @@ describe("v0.5.0 types", () => {
expectTypeOf(c.favorite).toEqualTypeOf<boolean>(); expectTypeOf(c.favorite).toEqualTypeOf<boolean>();
}); });
}); });
describe("v0.7.7 types", () => {
it("ObjectType includes roles", () => {
const t: ObjectType = "roles";
expect(t).toBe("roles");
});
it("ChangeItemType includes rebuild_table", () => {
const t: ChangeItemType = "rebuild_table";
expect(t).toBe("rebuild_table");
});
it("RoleInfo shape", () => {
const r: RoleInfo = {
name: "app", superuser: false, inherit: true, create_db: false, create_role: false,
can_login: true, replication: false, bypass_rls: false, connection_limit: -1,
valid_until: null, memberships: [],
};
expect(r.name).toBe("app");
});
it("PrivilegeEntry/RebuildReadiness/MaintenanceResult shapes", () => {
const pe: PrivilegeEntry = { object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false };
const rr: RebuildReadiness = { ok: true, reasons: [] };
const mr: MaintenanceResult = { duration_ms: 1, message: "ok" };
expect(pe.object_class).toBe("table");
expect(rr.ok).toBe(true);
expect(mr.duration_ms).toBe(1);
});
});
+48 -1
View File
@@ -202,6 +202,7 @@ export type ChangeItemType =
| "drop_index" | "drop_index"
| "bulk_insert" | "bulk_insert"
| "empty_table" | "empty_table"
| "rebuild_table"
| "ddl"; | "ddl";
export interface ChangeItem { export interface ChangeItem {
@@ -324,7 +325,8 @@ export type ObjectType =
| "extensions" | "extensions"
| "indexes" | "indexes"
| "constraints" | "constraints"
| "procedures"; | "procedures"
| "roles";
export interface ObjectSearchHit { export interface ObjectSearchHit {
name: string; name: string;
@@ -352,6 +354,51 @@ export interface BackupJob {
completed_at: string | null; completed_at: string | null;
} }
// ─── Roles / Privileges / Maintenance (v0.7.7) ──────────────────
export interface RoleMembership {
role: string;
member: string;
grantor: string;
admin_option: boolean;
}
export interface RoleInfo {
name: string;
superuser: boolean;
inherit: boolean;
create_db: boolean;
create_role: boolean;
can_login: boolean;
replication: boolean;
bypass_rls: boolean;
connection_limit: number;
valid_until: string | null;
memberships: RoleMembership[];
}
export interface PrivilegeEntry {
object_class: "table" | "sequence" | "routine" | "schema" | "database";
schema: string | null;
name: string;
privileges: string[];
grantable: boolean;
}
export interface RebuildReadiness {
ok: boolean;
reasons: string[];
}
export interface MaintenanceResult {
duration_ms: number;
message: string;
}
export interface TablespaceInfo {
name: string;
}
// ─── Schema Visualizer Types ──────────────────────────────────── // ─── Schema Visualizer Types ────────────────────────────────────
export interface GraphColumn { export interface GraphColumn {
+2 -2
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import pkg from "../../package.json"; import pkg from "../../package.json";
describe("version", () => { describe("version", () => {
it("declares v0.7.6 across the app shell", () => { it("declares v0.7.7 across the app shell", () => {
expect(pkg.version).toBe("0.7.6"); expect(pkg.version).toBe("0.7.7");
}); });
}); });
+31 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { useDbViewerStore } from "./dbViewerStore"; import { useDbViewerStore } from "./dbViewerStore";
import type { QueryResult, TableInfo } from "../lib/types"; import type { QueryResult, TableInfo, RoleInfo } from "../lib/types";
import * as commands from "../lib/commands"; import * as commands from "../lib/commands";
vi.mock("../lib/commands", () => ({ vi.mock("../lib/commands", () => ({
@@ -598,3 +598,33 @@ describe("openFormTab", () => {
expect(after?.kind).toBe(before?.kind); expect(after?.kind).toBe(before?.kind);
}); });
}); });
describe("roles slice", () => {
it("setRoles stores roles", () => {
useDbViewerStore.getState().reset();
const r: RoleInfo = {
name: "app", superuser: false, inherit: true, create_db: false, create_role: false,
can_login: true, replication: false, bypass_rls: false, connection_limit: -1,
valid_until: null, memberships: [],
};
useDbViewerStore.getState().setRoles([r]);
expect(useDbViewerStore.getState().roles).toEqual([r]);
});
it("openFormTab accepts kind table and role", () => {
useDbViewerStore.getState().reset();
useDbViewerStore.getState().openFormTab({
kind: "table", schema: "public", name: "", title: "Create Table",
description: "Create Table", mode: "create", params: { schema: "public", name: "", action: { op: "create", columns: [] } },
});
const t = useDbViewerStore.getState().tabs[0];
expect(t.tabType).toBe("objectForm");
expect(t.form?.kind).toBe("table");
// dedup: second create of same kind focuses existing
useDbViewerStore.getState().openFormTab({
kind: "table", schema: "public", name: "", title: "Create Table",
description: "Create Table", mode: "create", params: { schema: "public", name: "", action: { op: "create", columns: [] } },
});
expect(useDbViewerStore.getState().tabs.filter((x) => x.form?.kind === "table").length).toBe(1);
});
});
+5 -1
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType } from "../lib/types"; import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types";
import { getDatabases, getSchemas, getTables } from "../lib/commands"; import { getDatabases, getSchemas, getTables } from "../lib/commands";
import type { ObjectKind, DdlParams } from "../lib/objectCrud"; import type { ObjectKind, DdlParams } from "../lib/objectCrud";
@@ -120,6 +120,7 @@ interface DbViewerState {
indexes: IndexInfo[] | null; indexes: IndexInfo[] | null;
setSchemaTreeLoading: (loading: boolean) => void; setSchemaTreeLoading: (loading: boolean) => void;
constraints: ConstraintInfo[] | null; constraints: ConstraintInfo[] | null;
roles: RoleInfo[];
// Actions // Actions
openTab: (schema: string, table: string, forceNew?: boolean) => void; openTab: (schema: string, table: string, forceNew?: boolean) => void;
@@ -182,6 +183,7 @@ interface DbViewerState {
setExtensions: (extensions: ExtensionInfo[]) => void; setExtensions: (extensions: ExtensionInfo[]) => void;
setIndexes: (indexes: IndexInfo[]) => void; setIndexes: (indexes: IndexInfo[]) => void;
setConstraints: (constraints: ConstraintInfo[]) => void; setConstraints: (constraints: ConstraintInfo[]) => void;
setRoles: (roles: RoleInfo[]) => void;
stageCellEdit: (input: { stageCellEdit: (input: {
tabId: string; tabId: string;
schema: string; schema: string;
@@ -223,6 +225,7 @@ const initialState = {
extensions: null as ExtensionInfo[] | null, extensions: null as ExtensionInfo[] | null,
indexes: null as IndexInfo[] | null, indexes: null as IndexInfo[] | null,
constraints: null as ConstraintInfo[] | null, constraints: null as ConstraintInfo[] | null,
roles: [] as RoleInfo[],
schemaTreeLoading: false, schemaTreeLoading: false,
}; };
@@ -577,6 +580,7 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setExtensions: (extensions) => set({ extensions }), setExtensions: (extensions) => set({ extensions }),
setIndexes: (indexes) => set({ indexes }), setIndexes: (indexes) => set({ indexes }),
setConstraints: (constraints) => set({ constraints }), setConstraints: (constraints) => set({ constraints }),
setRoles: (roles) => set({ roles }),
setSchemaTreeLoading: (loading) => set({ schemaTreeLoading: loading }), setSchemaTreeLoading: (loading) => set({ schemaTreeLoading: loading }),
stageCellEdit: (input) => { stageCellEdit: (input) => {