Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7401e91a12 | ||
|
|
0c74d77e75 | ||
|
|
8d8ed78202 |
@@ -176,9 +176,7 @@ jobs:
|
|||||||
releaseName: 'Gridline ${{ github.ref_name }}'
|
releaseName: 'Gridline ${{ github.ref_name }}'
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
# Version-free asset names (see README Download section): the README
|
# Assets use tauri-action's default versioned naming (e.g.
|
||||||
# links via GitHub's releases/latest/download/<file> redirect, which
|
# Gridline_0.7.5_aarch64.dmg, Gridline-0.7.5-1.x86_64.rpm) and the
|
||||||
# only works if filenames are identical across releases. Omitting
|
# README download tables link to them statically per release —
|
||||||
# [version] gives stable names: Gridline_darwin_aarch64.dmg,
|
# remember to update both README tables when cutting a new version.
|
||||||
# Gridline_windows_x64-setup.exe, Gridline_linux_amd64.deb, etc.
|
|
||||||
releaseAssetNamePattern: '[name]_[platform]_[arch][setup][ext]'
|
|
||||||
@@ -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 stay version-free** — both download tables (top **Download** section + **Which file should I download?**) link via GitHub's `releases/latest/download/<file>` redirect, which only works because `release.yml` sets `releaseAssetNamePattern` to a version-free pattern (`[name]_[platform]_[arch][setup][ext]`). Do NOT re-add the version to these filenames on release.
|
- **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
|
||||||
@@ -210,10 +210,12 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
||||||
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
||||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
|
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
|
||||||
|
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
||||||
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
||||||
| 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 |
|
||||||
@@ -261,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 |
|
||||||
@@ -272,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 |
|
||||||
@@ -290,7 +304,11 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Global object search | ✅ | Cmd+K palette in the DB viewer, current-schema scope, all object types; results open a table tab or jump to the Objects view (v0.7.5) |
|
| Global object search | ✅ | Cmd+K palette in the DB viewer, current-schema scope, all object types; results open a table tab or jump to the Objects view (v0.7.5) |
|
||||||
| Copy as DDL for any object | ✅ | `CREATE` DDL for every browsable type (tables via pg_dump; pg_get_*def passthrough; sequences/enums/extensions/views synthesized) (v0.7.5) |
|
| Copy as DDL for any object | ✅ | `CREATE` DDL for every browsable type (tables via pg_dump; pg_get_*def passthrough; sequences/enums/extensions/views synthesized) (v0.7.5) |
|
||||||
| Object dependencies | ✅ | `pg_depend` "what depends on this?" view, shown before destructive drops (table drop, schema drop) (v0.7.5) |
|
| Object dependencies | ✅ | `pg_depend` "what depends on this?" view, shown before destructive drops (table drop, schema drop) (v0.7.5) |
|
||||||
|
| 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) |
|
||||||
| 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 |
|
||||||
|
|||||||
@@ -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,26 +30,27 @@
|
|||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — links always point to the newest build:
|
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_darwin_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_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_darwin_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_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_windows_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_windows_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_linux_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_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_linux_x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_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_linux_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_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: The download links use GitHub's releases/latest/download/<file>
|
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.7
|
||||||
redirect, so they always point at the newest published release. This only
|
release assets, not at a moving "latest" target. On every new release,
|
||||||
works because release.yml sets releaseAssetNamePattern to a version-free
|
update BOTH tables here (Download + Which file should I download?) to the
|
||||||
pattern ([name]_[platform]_[arch][setup][ext]). If the asset naming ever
|
new version's asset names, which are tauri-action's default naming:
|
||||||
changes, update BOTH tables here to match the new filenames. No version
|
Gridline_<version>_aarch64.dmg / _x64.dmg / _x64-setup.exe /
|
||||||
bump is needed on release — do NOT re-add the version to these filenames.
|
_amd64.deb / _amd64.AppImage and Gridline-<version>-1.x86_64.rpm
|
||||||
|
The .msi (Gridline_<version>_x64_en-US.msi) is not linked below.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -82,6 +82,13 @@ 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.
|
||||||
|
|
||||||
- **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.
|
||||||
- **2026-08-04:** v0.7.5 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy).
|
- **2026-08-04:** v0.7.5 — revamped the New Connection screen into a two-stage flow with a 6-provider grid (PostgreSQL, MySQL, SQLite, Redis, Supabase, NeonDB; managed presets ship with setup guides + SSL hints) and added full MySQL DB viewer support (connect, browse, query, inline cell editing + changes queue, DDL copy).
|
||||||
- **2026-08-04:** Revamped the built-in SQLite demo database with realistic e-commerce data (20 users, 24 products, 50 orders, 100 page views, 500 audit rows) and renamed it to **Gridline Demo (SQLite)**.
|
- **2026-08-04:** Revamped the built-in SQLite demo database with realistic e-commerce data (20 users, 24 products, 50 orders, 100 page views, 500 audit rows) and renamed it to **Gridline Demo (SQLite)**.
|
||||||
@@ -94,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.
|
||||||
@@ -108,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.
|
||||||
@@ -119,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.
|
||||||
@@ -132,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`.
|
||||||
@@ -141,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>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -197,41 +236,41 @@ 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** |
|
||||||
| Data export (CSV, JSON, SQL) | ✅ (unlimited = paid) | Basic only | ✅ | **JSON, CSV, SQL, Markdown** |
|
| Data export (CSV, JSON, SQL) | ✅ (unlimited = paid) | Basic only | ✅ | **JSON, CSV, SQL, Markdown** |
|
||||||
| `pg_dump` / `pg_restore` GUI | ❌ | ❌ paid only | ✅ | **First-class UI** |
|
| `pg_dump` / `pg_restore` GUI | ❌ | ❌ paid only | ✅ | **First-class UI** |
|
||||||
| DB-to-DB sync | ❌ | ❌ | ❌ | **Built-in pipe sync** |
|
| DB-to-DB sync | ❌ | ❌ | ❌ | **Built-in pipe sync** |
|
||||||
| Object explorer depth | Tables, views, indexes, enums | Tables, views, routines, triggers | Tables, views, functions, procedures | **Functions, Triggers, Sequences, Enums, Extensions + full detail** |
|
| Object explorer depth | Tables, views, indexes, enums | Tables, views, routines, triggers | Tables, views, functions, procedures | **Functions, Triggers, Sequences, Enums, Extensions + full detail** |
|
||||||
| ER diagram / schema visualizer | ✅ view-only | ❌ paid only | ❌ | **✅ React Flow + dagre** |
|
| ER diagram / schema visualizer | ✅ view-only | ❌ paid only | ❌ | **✅ React Flow + dagre** |
|
||||||
| SSH tunneling | ❌ paid only | ✅ | ✅ | **✅ Password + key auth, keychain** |
|
| SSH tunneling | ❌ paid only | ✅ | ✅ | **✅ Password + key auth, keychain** |
|
||||||
| OS credential vault | ✅ | ✅ | ✅ | **Keychain / Secret Service / Credential Manager** |
|
| OS credential vault | ✅ | ✅ | ✅ | **Keychain / Secret Service / Credential Manager** |
|
||||||
| Workspace / folder hierarchy | ✅ query/dash folders + tags | ✅ (5.7+, local) | ❌ | **Multi-level tree + tags** |
|
| Workspace / folder hierarchy | ✅ query/dash folders + tags | ✅ (5.7+, local) | ❌ | **Multi-level tree + tags** |
|
||||||
| Changes queue (stage → commit) | ❌ | ✅ Apply/Discard | ✅ Safe mode | **✅ Queue → Commit All** |
|
| Changes queue (stage → commit) | ❌ | ✅ Apply/Discard | ✅ Safe mode | **✅ Queue → Commit All** |
|
||||||
| 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 |
|
||||||
| **CLI integration** | `std::process::Command` | Wraps bundled or system `pg_dump` / `pg_restore` |
|
| **CLI integration** | `std::process::Command` | Wraps bundled or system `pg_dump` / `pg_restore` |
|
||||||
| **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI |
|
| **Frontend** | [React 19](https://react.dev) + [TypeScript](https://www.typescriptlang.org) | Component-based UI |
|
||||||
| **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
|
| **Styling** | [Tailwind CSS](https://tailwindcss.com) | Utility-first, dark mode, glassmorphic design |
|
||||||
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores |
|
| **State** | [Zustand](https://zustand.docs.pmnd.rs) / Jotai | Domain stores |
|
||||||
| **Editor** | [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing |
|
| **Editor** | [Monaco Editor](https://microsoft.github.io/monaco-editor/) | IDE-grade SQL editing |
|
||||||
| **Data grid** | [TanStack Virtual](https://tanstack.com/virtual) | 100k+ row virtualization |
|
| **Data grid** | [TanStack Virtual](https://tanstack.com/virtual) | 100k+ row virtualization |
|
||||||
| **Local store** | SQLite via [rusqlite](https://github.com/rusqlite/rusqlite) | Settings, saved queries, workspace state |
|
| **Local store** | SQLite via [rusqlite](https://github.com/rusqlite/rusqlite) | Settings, saved queries, workspace state |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -255,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 always point at the newest build (the asset names are version-free, so they never go stale):
|
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_darwin_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_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_darwin_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_darwin_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_windows_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_windows_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_linux_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_amd64.deb) | Install: `sudo apt install ./Gridline_linux_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_linux_x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_x86_64.rpm) | Install: `sudo dnf install Gridline_linux_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_linux_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/latest/download/Gridline_linux_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.
|
||||||
|
|
||||||
@@ -274,13 +313,13 @@ 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.5
|
git tag v0.7.7
|
||||||
git push origin v0.7.5
|
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**.
|
||||||
|
|
||||||
Before tagging, make sure the version number is in sync across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`.
|
Before tagging, make sure the version number is in sync across `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json`, and update the **README download tables** (Download + Which file should I download?) to the new version's asset names.
|
||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
@@ -359,14 +398,14 @@ gridline/
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
The full plan — in-development (v0.7.5), 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:
|
||||||
|
|
||||||
- **Full Object Management** — CRUD on functions, triggers, sequences, enums, extensions, and views without the Query tab (schema CRUD, global object search, copy-as-DDL, and object dependencies shipped in v0.7.5)
|
- **Admin follow-up** — PostgreSQL users/roles + grants management, and table maintenance actions (VACUUM / ANALYZE / REINDEX)
|
||||||
- **Full Redis support** — key browser, type-aware value editors, TTL management
|
- **Full Redis support** — key browser, type-aware value editors, TTL management
|
||||||
- **More database types** — MariaDB, TimescaleDB, and friends
|
- **More database types** — MariaDB, TimescaleDB, and friends
|
||||||
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.5)
|
- **Managed DB support** — PlanetScale, Turso (Supabase/Neon presets shipped in v0.7.0)
|
||||||
- **AI integration (BYOK)** — natural-language → SQL, chat, summaries, charts
|
- **AI integration (BYOK)** — natural-language → SQL, chat, summaries, charts
|
||||||
|
|
||||||
✅ **[View the full roadmap →](./ROADMAP.md)**
|
✅ **[View the full roadmap →](./ROADMAP.md)**
|
||||||
|
|||||||
+33
-31
@@ -6,6 +6,29 @@ 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)
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **Enable Keychain toggle** — wired end-to-end (default ON, opt-out): OFF = don't persist the DB password (session-only, re-prompt on connect) + purge the existing keychain entry; SSH secrets stay keychain-only.
|
||||||
|
- **Objects view tabbed workspace** — object details open as tabs in the shared tabbed workspace (per-type icons), with an inline manual query tab and the changes queue reachable from the tab bar.
|
||||||
|
- **⌘K object-search fixes** — clicking a result now switches to the Objects view / opens the table tab; arrow-key navigation + Enter to pick.
|
||||||
|
- **Version bump** 0.7.5 → **0.7.6**.
|
||||||
|
|
||||||
## ✅ Shipped (0.7.5)
|
## ✅ Shipped (0.7.5)
|
||||||
|
|
||||||
- **Bundled PG client tools** — static `pg_dump`/`pg_restore`/`psql` ship with the app (system-first, bundled fallback) so backup/restore/sync work with no separate install.
|
- **Bundled PG client tools** — static `pg_dump`/`pg_restore`/`psql` ship with the app (system-first, bundled fallback) so backup/restore/sync work with no separate install.
|
||||||
@@ -28,39 +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
|
||||||
|
|
||||||
### Full Object Management (PostgreSQL)
|
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.
|
||||||
|
|
||||||
Gridline can already **browse** every PostgreSQL object type (functions, triggers, sequences, enums, extensions, views, materialized views, procedures, indexes, constraints). Next up: full **CRUD** on those objects without ever touching the Query tab.
|
- **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back)
|
||||||
|
- **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling)
|
||||||
- Right-click any object → create / edit / drop with a generated-SQL preview before applying
|
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
|
||||||
- Enums: add/remove values, rename types
|
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
|
||||||
- Functions & procedures: edit signature + body, drop overloads by signature
|
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
||||||
- Triggers: create/edit/disable/enable, timing + event pickers
|
|
||||||
- Sequences: alter increment/start/min/max/cycle, restart
|
|
||||||
- Extensions: install/uninstall, schema reassignment
|
|
||||||
- Views & materialized views: edit definition, refresh matviews
|
|
||||||
- Indexes & constraints: create/drop per table with column pickers
|
|
||||||
- Staged through the changes queue (with confirmation) — never a surprise DDL
|
|
||||||
- **Out of scope this release:** the MySQL "Objects" view stays deferred (see In the queue) — this iteration is PostgreSQL-only
|
|
||||||
|
|
||||||
### Admin follow-up (after object management)
|
|
||||||
|
|
||||||
- **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)
|
|
||||||
- **Maintenance actions** — right-click table → VACUUM / ANALYZE / REINDEX
|
|
||||||
|
|
||||||
### Connection & credentials
|
|
||||||
|
|
||||||
- **Wire up the "Enable Keychain" toggle** — currently a form-only placeholder: the flag is submitted and stored with the connection record, but the Rust backend never reads it and the frontend store unconditionally calls `saveConnectionPassword`. Decide the intended behavior (e.g. off = store the password with the connection record / don't persist at all, on = OS keychain as today) and implement the conditional path + migration for existing records.
|
|
||||||
|
|
||||||
### DB viewer: ⌘K object search (0.7.6)
|
|
||||||
|
|
||||||
- **Fix: clicking a search result does nothing** — reported from 0.7.5 testing. `ObjectSearchPalette.handleSelect` sets store state but the UI doesn't react:
|
|
||||||
- *Non-table results* (function/enum/sequence/…) set `selectedObjectType` but **never switch the view to the Objects page** — the palette needs to also call the view switch (e.g. `setCurrentView("objects")` or the DbViewerScreen equivalent) so `ObjectExplorerPage` mounts and consumes `selectedObjectType`.
|
|
||||||
- *Table/view results* call `openTab(schema, name)` but nothing visibly happens from the Explorer — verify `openTab` reaches the tab system from the palette's context (tab store + active-view wiring) and opens the tab.
|
|
||||||
- Add component tests that render the palette inside DbViewerScreen (or a harness) and assert the view/tab actually changes on click.
|
|
||||||
- **Add arrow-key navigation + Enter to pick** — currently mouse-click only. Move the highlighted row with ↑/↓ (wrapping), Enter selects the highlighted hit (same `handleSelect` path), Esc still closes. Optional: home/end + typeahead on the result list.
|
|
||||||
|
|
||||||
## 📋 In the queue
|
## 📋 In the queue
|
||||||
|
|
||||||
@@ -142,6 +141,9 @@ Deferred from 0.7.0, slated for this bucket:
|
|||||||
|
|
||||||
## ✅ Shipped
|
## ✅ Shipped
|
||||||
|
|
||||||
|
- Full PostgreSQL object management — create/edit/drop staged through the changes queue (v0.7.6)
|
||||||
|
- Enable Keychain toggle — opt-out password persistence (v0.7.6)
|
||||||
|
- Objects view tabbed workspace (v0.7.6)
|
||||||
- Bundled PG client tools — `pg_dump`/`pg_restore`/`psql` shipped with the app, system-first with bundled fallback (v0.7.5)
|
- Bundled PG client tools — `pg_dump`/`pg_restore`/`psql` shipped with the app, system-first with bundled fallback (v0.7.5)
|
||||||
- Schema CRUD — create/rename/drop schemas with CASCADE + dependency warning (v0.7.5)
|
- Schema CRUD — create/rename/drop schemas with CASCADE + dependency warning (v0.7.5)
|
||||||
- Global object search — Cmd+K across all object types in the current schema (v0.7.5)
|
- Global object search — Cmd+K across all object types in the current schema (v0.7.5)
|
||||||
|
|||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gridline",
|
"name": "gridline",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.5",
|
"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": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -1783,7 +1783,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.5"
|
version = "0.7.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.5"
|
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"
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
let result = create_connection_inner(&st, input.clone()).unwrap();
|
let result = create_connection_inner(&st, input.clone()).unwrap();
|
||||||
assert_eq!(result.name, "Prod");
|
assert_eq!(result.name, "Prod");
|
||||||
@@ -254,6 +255,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
assert!(create_connection_inner(&st, input).is_err());
|
assert!(create_connection_inner(&st, input).is_err());
|
||||||
}
|
}
|
||||||
@@ -283,6 +285,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
let conn = create_connection_inner(&st, input).unwrap();
|
let conn = create_connection_inner(&st, input).unwrap();
|
||||||
delete_connection_inner(&st, &conn.id).unwrap();
|
delete_connection_inner(&st, &conn.id).unwrap();
|
||||||
@@ -300,6 +303,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
password: None,
|
password: None,
|
||||||
database: None,
|
database: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
@@ -331,6 +335,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
password: None,
|
password: None,
|
||||||
database: None,
|
database: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -2155,6 +2210,27 @@ 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::Ddl { sql, .. } => {
|
||||||
|
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||||
|
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,
|
||||||
@@ -2247,6 +2323,12 @@ pub async fn execute_change(
|
|||||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Change::Ddl { .. } => {
|
||||||
|
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,
|
||||||
@@ -2319,6 +2401,12 @@ pub async fn execute_change(
|
|||||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
Change::Ddl { .. } => {
|
||||||
|
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,
|
||||||
@@ -2374,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,
|
||||||
@@ -2820,6 +2917,33 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify that a `Change::Ddl` serializes with the snake_case `ddl` tag.
|
||||||
|
#[test]
|
||||||
|
fn change_ddl_serialization_uses_snake_case_tag() {
|
||||||
|
let change = Change::Ddl {
|
||||||
|
id: "chg-ddl-1".to_string(),
|
||||||
|
sql: "CREATE TYPE public.role AS ENUM ('admin')".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&change).unwrap();
|
||||||
|
assert!(
|
||||||
|
json.contains(r#""type":"ddl""#),
|
||||||
|
"serialized Change::Ddl should use snake_case tag 'ddl'; got: {}",
|
||||||
|
json
|
||||||
|
);
|
||||||
|
assert!(json.contains(r#""id":"chg-ddl-1""#));
|
||||||
|
assert!(json.contains(r#""sql":"CREATE TYPE public.role AS ENUM ('admin')""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that `Change::id()` returns the identifier of a `Change::Ddl`.
|
||||||
|
#[test]
|
||||||
|
fn change_ddl_id_is_accessible() {
|
||||||
|
let change = Change::Ddl {
|
||||||
|
id: "chg-ddl-42".to_string(),
|
||||||
|
sql: "DROP INDEX public.i".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(change.id(), "chg-ddl-42");
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify that `build_update_sql` produces valid SQL with all required
|
/// Verify that `build_update_sql` produces valid SQL with all required
|
||||||
/// clauses.
|
/// clauses.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ fn demo_connection_input(db_path: &Path) -> ConnectionInput {
|
|||||||
database: None,
|
database: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
environment: Some("development".to_string()),
|
environment: Some("development".to_string()),
|
||||||
ssh_host: None,
|
ssh_host: None,
|
||||||
ssh_port: None,
|
ssh_port: None,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ pub fn import_connections_inner(
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
||||||
|
use_keychain: true,
|
||||||
};
|
};
|
||||||
match store.create_connection(input) {
|
match store.create_connection(input) {
|
||||||
Ok(_) => imported += 1,
|
Ok(_) => imported += 1,
|
||||||
@@ -214,6 +215,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
});
|
});
|
||||||
let json = export_connections_inner(&st).unwrap();
|
let json = export_connections_inner(&st).unwrap();
|
||||||
assert!(json.contains("\"name\""));
|
assert!(json.contains("\"name\""));
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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, 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};
|
use crate::models::db_viewer::{ObjectSearchHit, DependencyInfo, ExtensionInfo};
|
||||||
|
|
||||||
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
|
fn sanitize(e: &str) -> String { crate::commands::db_viewer::sanitize_error(e) }
|
||||||
|
|
||||||
@@ -146,6 +147,315 @@ pub async fn get_object_dependencies(connection_id: String, schema: String, obje
|
|||||||
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
|
get_object_dependencies_inner(&state.pool_manager, &connection_id, &schema, &object_type, &name).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build SQL for an object CRUD operation. The pool is resolved only to enforce
|
||||||
|
/// PostgreSQL-only / present-connection; the SQL itself is built by the pure
|
||||||
|
/// `crate::db::object_crud::build_ddl` dispatcher (one statement per String).
|
||||||
|
pub(crate) async fn build_object_ddl_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str, kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(_, _)) => build_ddl(kind, params),
|
||||||
|
Some(_) => Err("Object management is PostgreSQL-only".into()),
|
||||||
|
None => Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn build_object_ddl(connection_id: String, kind: String, params: serde_json::Value, state: State<'_, crate::AppState>) -> Result<Vec<String>, String> {
|
||||||
|
build_object_ddl_inner(&state.pool_manager, &connection_id, &kind, params).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List extensions installable on this server (`pg_available_extensions`):
|
||||||
|
/// name + default version + comment. The picker uses name/version only;
|
||||||
|
/// `schema` is left empty (available extensions are schema-wide).
|
||||||
|
pub(crate) async fn get_available_extensions_inner(pm: &tokio::sync::Mutex<ConnectionPoolManager>, connection_id: &str) -> Result<Vec<ExtensionInfo>, String> {
|
||||||
|
let mut pm = pm.lock().await;
|
||||||
|
let client = match pm.get(connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(c, _)) => c,
|
||||||
|
Some(_) => return Err("Extensions are PostgreSQL-only".into()),
|
||||||
|
None => return Err("Connection not found".into()),
|
||||||
|
};
|
||||||
|
let rows = client.query(&crate::db::introspection::pg_available_extensions_query(), &[]).await.map_err(|e| sanitize(&e.to_string()))?;
|
||||||
|
Ok(rows.iter().map(|r| ExtensionInfo {
|
||||||
|
name: r.get(0),
|
||||||
|
schema: String::new(),
|
||||||
|
version: r.get(1),
|
||||||
|
comment: r.get(2),
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_available_extensions(connection_id: String, state: State<'_, crate::AppState>) -> Result<Vec<ExtensionInfo>, String> {
|
||||||
|
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;
|
||||||
@@ -57,6 +57,53 @@ async fn object_ddl_for_sequence_enum_function() {
|
|||||||
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
|
assert!(f.clone().unwrap().contains("CREATE FUNCTION") || f.unwrap().contains("CREATE OR REPLACE FUNCTION"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn build_object_ddl_inner_guards_postgresql_only() {
|
||||||
|
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||||
|
// Missing connection -> Connection not found
|
||||||
|
let err = build_object_ddl_inner(&pm, "missing", "sequence", serde_json::json!({
|
||||||
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
|
})).await.unwrap_err();
|
||||||
|
assert!(err.contains("Connection not found"), "{err}");
|
||||||
|
// Non-PostgreSQL handle -> PostgreSQL-only error
|
||||||
|
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||||
|
let err = build_object_ddl_inner(&pm, "sqlite", "sequence", serde_json::json!({
|
||||||
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
|
})).await.unwrap_err();
|
||||||
|
assert!(err.contains("PostgreSQL-only"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn build_object_ddl_inner_on_postgresql_pool() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let sql = build_object_ddl_inner(&pm, &id, "sequence", serde_json::json!({
|
||||||
|
"schema": "public", "name": "s", "action": { "op": "drop" }
|
||||||
|
})).await.unwrap();
|
||||||
|
assert_eq!(sql, vec!["DROP SEQUENCE \"public\".\"s\""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_available_extensions_inner_guards_postgresql_only() {
|
||||||
|
let pm = tokio::sync::Mutex::new(ConnectionPoolManager::new());
|
||||||
|
// Missing connection -> Connection not found
|
||||||
|
let err = get_available_extensions_inner(&pm, "missing").await.unwrap_err();
|
||||||
|
assert!(err.contains("Connection not found"), "{err}");
|
||||||
|
// Non-PostgreSQL handle -> PostgreSQL-only error
|
||||||
|
pm.lock().await.register("sqlite", DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()));
|
||||||
|
let err = get_available_extensions_inner(&pm, "sqlite").await.unwrap_err();
|
||||||
|
assert!(err.contains("PostgreSQL-only"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn get_available_extensions_on_postgresql_pool() {
|
||||||
|
let (pm, id) = pool().await;
|
||||||
|
let exts = get_available_extensions_inner(&pm, &id).await.unwrap();
|
||||||
|
assert!(!exts.is_empty(), "pg_available_extensions should list built-ins");
|
||||||
|
assert!(exts.iter().all(|e| !e.name.is_empty() && !e.version.is_empty()), "every extension needs name + default version: {exts:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
async fn object_dependencies_for_table_includes_view() {
|
async fn object_dependencies_for_table_includes_view() {
|
||||||
@@ -67,4 +114,56 @@ async fn object_dependencies_for_table_includes_view() {
|
|||||||
// schema contents path
|
// schema contents path
|
||||||
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:?}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1060,6 +1060,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
folder_id: None,
|
folder_id: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
password: None,
|
password: None,
|
||||||
database: None,
|
database: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
|
|||||||
@@ -292,6 +292,14 @@ pub fn pg_extensions_query() -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Query available (installable) extensions with default version + comment.
|
||||||
|
/// Schema-wide: `pg_available_extensions` is not schema-scoped.
|
||||||
|
pub fn pg_available_extensions_query() -> String {
|
||||||
|
"SELECT name, default_version::text AS version, comment \
|
||||||
|
FROM pg_available_extensions ORDER BY name"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Query indexes in a schema.
|
/// Query indexes in a schema.
|
||||||
///
|
///
|
||||||
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
|
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
|
||||||
@@ -347,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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -624,4 +791,99 @@ mod tests {
|
|||||||
let sql = pg_extensions_query();
|
let sql = pg_extensions_query();
|
||||||
assert!(sql.contains("pg_extension"));
|
assert!(sql.contains("pg_extension"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_available_extensions_query_has_name_default_version_comment() {
|
||||||
|
let sql = pg_available_extensions_query();
|
||||||
|
assert!(sql.contains("pg_available_extensions"));
|
||||||
|
assert!(sql.contains("default_version"), "should select default version; got: {sql}");
|
||||||
|
assert!(sql.contains("comment"), "should select comment; 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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod introspection;
|
pub mod introspection;
|
||||||
pub mod mysql;
|
pub mod mysql;
|
||||||
|
pub mod object_crud;
|
||||||
pub mod object_ddl;
|
pub mod object_ddl;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod tls;
|
pub mod tls;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
@@ -122,6 +123,11 @@ pub fn run() {
|
|||||||
objects::search_objects,
|
objects::search_objects,
|
||||||
objects::get_object_ddl,
|
objects::get_object_ddl,
|
||||||
objects::get_object_dependencies,
|
objects::get_object_dependencies,
|
||||||
|
objects::build_object_ddl,
|
||||||
|
objects::build_rebuild_script,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub struct Connection {
|
|||||||
pub ssl_cert_path: Option<String>,
|
pub ssl_cert_path: Option<String>,
|
||||||
pub ssl_key_path: Option<String>,
|
pub ssl_key_path: Option<String>,
|
||||||
pub tag_ids: Vec<String>,
|
pub tag_ids: Vec<String>,
|
||||||
|
pub use_keychain: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,12 @@ pub struct ConnectionInput {
|
|||||||
pub ssl_ca_path: Option<String>,
|
pub ssl_ca_path: Option<String>,
|
||||||
pub ssl_cert_path: Option<String>,
|
pub ssl_cert_path: Option<String>,
|
||||||
pub ssl_key_path: Option<String>,
|
pub ssl_key_path: Option<String>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub use_keychain: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -66,6 +73,7 @@ mod tests {
|
|||||||
username: Some("admin".to_string()),
|
username: Some("admin".to_string()),
|
||||||
folder_id: Some("folder1".to_string()),
|
folder_id: Some("folder1".to_string()),
|
||||||
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
|
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
|
||||||
|
use_keychain: true,
|
||||||
password: Some("secret123".to_string()),
|
password: Some("secret123".to_string()),
|
||||||
database: Some("mydb".to_string()),
|
database: Some("mydb".to_string()),
|
||||||
ssh_host: Some("jumphost.example.com".to_string()),
|
ssh_host: Some("jumphost.example.com".to_string()),
|
||||||
@@ -133,6 +141,7 @@ mod tests {
|
|||||||
environment: None,
|
environment: None,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
database: Some("mydb".to_string()),
|
database: Some("mydb".to_string()),
|
||||||
@@ -177,6 +186,7 @@ mod tests {
|
|||||||
ssl_cert_path: None,
|
ssl_cert_path: None,
|
||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
favorite: true,
|
favorite: true,
|
||||||
created_at: "2024-01-01T00:00:00Z".into(),
|
created_at: "2024-01-01T00:00:00Z".into(),
|
||||||
updated_at: "2024-01-01T00:00:00Z".into(),
|
updated_at: "2024-01-01T00:00:00Z".into(),
|
||||||
@@ -184,4 +194,18 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&conn).unwrap();
|
let json = serde_json::to_string(&conn).unwrap();
|
||||||
assert!(json.contains("\"favorite\":true"));
|
assert!(json.contains("\"favorite\":true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_input_use_keychain_defaults_true_when_absent() {
|
||||||
|
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[]}"#;
|
||||||
|
let input: ConnectionInput = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(input.use_keychain, "absent use_keychain defaults to true (opt-out)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connection_input_use_keychain_preserves_false() {
|
||||||
|
let json = r#"{"name":"n","db_type":"postgresql","host":"h","port":5432,"tag_ids":[],"use_keychain":false}"#;
|
||||||
|
let input: ConnectionInput = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!input.use_keychain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -193,6 +242,14 @@ pub enum Change {
|
|||||||
schema: String,
|
schema: String,
|
||||||
table: String,
|
table: String,
|
||||||
},
|
},
|
||||||
|
Ddl {
|
||||||
|
id: String,
|
||||||
|
sql: String,
|
||||||
|
},
|
||||||
|
RebuildTable {
|
||||||
|
id: String,
|
||||||
|
sql: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Change {
|
impl Change {
|
||||||
@@ -204,7 +261,9 @@ impl Change {
|
|||||||
| Change::AlterTable { id, .. }
|
| Change::AlterTable { id, .. }
|
||||||
| Change::BulkInsert { id, .. }
|
| Change::BulkInsert { id, .. }
|
||||||
| Change::DropTable { id, .. }
|
| Change::DropTable { id, .. }
|
||||||
| Change::EmptyTable { id, .. } => id,
|
| Change::EmptyTable { id, .. }
|
||||||
|
| Change::Ddl { id, .. }
|
||||||
|
| Change::RebuildTable { id, .. } => id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -242,6 +242,30 @@ pub fn run_migrations(conn: &Connection) -> Result<(), String> {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v8: use_keychain opt-out flag on connections
|
||||||
|
if current_ver < 8 {
|
||||||
|
let existing: Vec<String> = {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("PRAGMA table_info(connections)")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !existing.iter().any(|c| c == "use_keychain") {
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE connections ADD COLUMN use_keychain INTEGER NOT NULL DEFAULT 1",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO schema_version (version) VALUES (8)", [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +316,49 @@ mod tests {
|
|||||||
let count: i64 = conn
|
let count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 6);
|
assert_eq!(count, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v8_adds_use_keychain_column_default_1() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
// v7 baseline
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
// simulate an existing connection row (pre-v8 shape had no use_keychain)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES ('c1','n','postgresql','h',5432,'u','d',NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,'t','t','t')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
let uses: i64 = conn
|
||||||
|
.query_row("SELECT use_keychain FROM connections WHERE id='c1'", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(uses, 1, "existing connections default to use_keychain=1 (ON)");
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(count, 1, "schema_version row 8 inserted exactly once");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v8_is_idempotent() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
run_migrations(&conn).unwrap();
|
||||||
|
let v8: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM schema_version WHERE version=8", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(v8, 1);
|
||||||
|
// PRAGMA confirms exactly one use_keychain column
|
||||||
|
let cols: Vec<String> = conn
|
||||||
|
.prepare("PRAGMA table_info(connections)")
|
||||||
|
.unwrap()
|
||||||
|
.query_map([], |r| r.get::<_, String>(1))
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(cols.iter().filter(|c| c == &"use_keychain").count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -541,12 +607,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn v7_bumps_schema_version_to_7() {
|
fn v8_bumps_schema_version_to_8() {
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
run_migrations(&conn).unwrap();
|
run_migrations(&conn).unwrap();
|
||||||
let ver: i64 = conn
|
let ver: i64 = conn
|
||||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(ver, 7);
|
assert_eq!(ver, 8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ impl Store {
|
|||||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at FROM connections ORDER BY name",
|
"SELECT id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, favorite, created_at, updated_at, use_keychain FROM connections ORDER BY name",
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
@@ -286,6 +286,7 @@ impl Store {
|
|||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
created_at: row.get(20)?,
|
created_at: row.get(20)?,
|
||||||
updated_at: row.get(21)?,
|
updated_at: row.get(21)?,
|
||||||
|
use_keychain: row.get(22)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -308,8 +309,8 @@ impl Store {
|
|||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
let now = Self::now();
|
let now = Self::now();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
|
"INSERT INTO connections (id, name, db_type, host, port, username, database, folder_id, keychain_ref, ssh_host, ssh_port, ssh_user, ssh_auth_method, ssh_private_key_path, ssl_mode, ssl_ca_path, ssl_cert_path, ssl_key_path, environment, created_at, updated_at, use_keychain) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)",
|
||||||
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now],
|
params![id, input.name, input.db_type, input.host, input.port, input.username, input.database, input.folder_id, input.ssh_host, input.ssh_port, input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path, input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path, input.environment, now, now, input.use_keychain],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
for tag_id in &input.tag_ids {
|
for tag_id in &input.tag_ids {
|
||||||
@@ -341,6 +342,7 @@ impl Store {
|
|||||||
ssl_cert_path: input.ssl_cert_path,
|
ssl_cert_path: input.ssl_cert_path,
|
||||||
ssl_key_path: input.ssl_key_path,
|
ssl_key_path: input.ssl_key_path,
|
||||||
tag_ids: input.tag_ids,
|
tag_ids: input.tag_ids,
|
||||||
|
use_keychain: input.use_keychain,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
@@ -419,13 +421,13 @@ impl Store {
|
|||||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||||
let now = Self::now();
|
let now = Self::now();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, updated_at=?18 WHERE id=?19",
|
"UPDATE connections SET name=?1, db_type=?2, host=?3, port=?4, username=?5, database=?6, folder_id=?7, ssh_host=?8, ssh_port=?9, ssh_user=?10, ssh_auth_method=?11, ssh_private_key_path=?12, ssl_mode=?13, ssl_ca_path=?14, ssl_cert_path=?15, ssl_key_path=?16, environment=?17, use_keychain=?18, updated_at=?19 WHERE id=?20",
|
||||||
params![
|
params![
|
||||||
input.name, input.db_type, input.host, input.port, input.username,
|
input.name, input.db_type, input.host, input.port, input.username,
|
||||||
input.database, input.folder_id, input.ssh_host, input.ssh_port,
|
input.database, input.folder_id, input.ssh_host, input.ssh_port,
|
||||||
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
|
input.ssh_user, input.ssh_auth_method, input.ssh_private_key_path,
|
||||||
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
|
input.ssl_mode, input.ssl_ca_path, input.ssl_cert_path, input.ssl_key_path,
|
||||||
input.environment, now, id
|
input.environment, input.use_keychain, now, id
|
||||||
],
|
],
|
||||||
).map_err(|e| e.to_string())?;
|
).map_err(|e| e.to_string())?;
|
||||||
// Update tags
|
// Update tags
|
||||||
@@ -463,6 +465,7 @@ impl Store {
|
|||||||
ssl_cert_path: input.ssl_cert_path,
|
ssl_cert_path: input.ssl_cert_path,
|
||||||
ssl_key_path: input.ssl_key_path,
|
ssl_key_path: input.ssl_key_path,
|
||||||
tag_ids: input.tag_ids.clone(),
|
tag_ids: input.tag_ids.clone(),
|
||||||
|
use_keychain: input.use_keychain,
|
||||||
created_at: String::new(), // not updated
|
created_at: String::new(), // not updated
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
})
|
})
|
||||||
@@ -954,6 +957,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let got = store.get_connections().unwrap();
|
let got = store.get_connections().unwrap();
|
||||||
@@ -1002,6 +1006,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![t1.id.clone(), t2.id.clone()],
|
tag_ids: vec![t1.id.clone(), t2.id.clone()],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let got = store.get_connections().unwrap();
|
let got = store.get_connections().unwrap();
|
||||||
@@ -1043,6 +1048,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store.delete_folder(&folder.id).unwrap();
|
store.delete_folder(&folder.id).unwrap();
|
||||||
@@ -1082,6 +1088,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![tag.id.clone()],
|
tag_ids: vec![tag.id.clone()],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store.delete_tag(&tag.id).unwrap();
|
store.delete_tag(&tag.id).unwrap();
|
||||||
@@ -1142,6 +1149,7 @@ mod tests {
|
|||||||
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
|
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let got = store.get_connections().unwrap();
|
let got = store.get_connections().unwrap();
|
||||||
@@ -1194,6 +1202,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1278,6 +1287,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Insert 510 rows — should trigger pruning beyond 500
|
// Insert 510 rows — should trigger pruning beyond 500
|
||||||
@@ -1334,6 +1344,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
@@ -1378,6 +1389,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
@@ -1480,6 +1492,7 @@ mod tests {
|
|||||||
ssl_key_path: None,
|
ssl_key_path: None,
|
||||||
environment: None,
|
environment: None,
|
||||||
tag_ids: vec![],
|
tag_ids: vec![],
|
||||||
|
use_keychain: true,
|
||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.5",
|
"version": "0.7.7",
|
||||||
"identifier": "com.adrianbonpin.gridline",
|
"identifier": "com.adrianbonpin.gridline",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData";
|
|||||||
const BASE_FORM: ConnectionFormData = {
|
const BASE_FORM: ConnectionFormData = {
|
||||||
name: "", environment: null, folder_id: null, tag_ids: [],
|
name: "", environment: null, folder_id: null, tag_ids: [],
|
||||||
connection_string: "", db_type: "postgresql", host: "", port: 5432,
|
connection_string: "", db_type: "postgresql", host: "", port: 5432,
|
||||||
username: null, password: null, database: null, use_keychain: false, ssh_password: null,
|
username: null, password: null, database: null, use_keychain: true, ssh_password: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("ConnectionMetadataRow", () => {
|
describe("ConnectionMetadataRow", () => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const BASE_FORM: ConnectionFormData = {
|
|||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
use_keychain: false,
|
use_keychain: true,
|
||||||
ssh_password: null,
|
ssh_password: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const BASE_FORM: ConnectionFormData = {
|
|||||||
name: "My DB", environment: null, folder_id: null, tag_ids: [],
|
name: "My DB", environment: null, folder_id: null, tag_ids: [],
|
||||||
connection_string: "postgresql://u:p@localhost:5432/db", db_type: "postgresql",
|
connection_string: "postgresql://u:p@localhost:5432/db", db_type: "postgresql",
|
||||||
host: "localhost", port: 5432, username: "u", password: "p", database: "db",
|
host: "localhost", port: 5432, username: "u", password: "p", database: "db",
|
||||||
use_keychain: false, ssh_password: null,
|
use_keychain: true, ssh_password: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("GeneralTab", () => {
|
describe("GeneralTab", () => {
|
||||||
@@ -54,4 +54,15 @@ describe("GeneralTab", () => {
|
|||||||
render(<GeneralTab form={{ ...BASE_FORM, db_type: "postgresql" }} managedPreset="supabase" onChange={() => {}} />);
|
render(<GeneralTab form={{ ...BASE_FORM, db_type: "postgresql" }} managedPreset="supabase" onChange={() => {}} />);
|
||||||
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
|
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults the keychain toggle to ON (opt-out)", () => {
|
||||||
|
render(<GeneralTab form={{ ...BASE_FORM, use_keychain: true }} onChange={() => {}} />);
|
||||||
|
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
|
||||||
|
expect(cb.checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the DB-password-only tooltip text", () => {
|
||||||
|
render(<GeneralTab form={{ ...BASE_FORM, use_keychain: true }} onChange={() => {}} />);
|
||||||
|
expect(screen.getByText(/DB password only/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -84,6 +84,9 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
|
|||||||
<input type="checkbox" checked={form.use_keychain} onChange={(e) => onChange({ use_keychain: e.target.checked })} aria-label="Enable keychain" className="rounded border-border bg-surface text-accent focus:ring-accent" />
|
<input type="checkbox" checked={form.use_keychain} onChange={(e) => onChange({ use_keychain: e.target.checked })} aria-label="Enable keychain" className="rounded border-border bg-surface text-accent focus:ring-accent" />
|
||||||
Enable keychain
|
Enable keychain
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-xs text-text-muted -mt-1 mb-2">
|
||||||
|
Saves the DB password to the OS keychain (DB password only; SSH secrets always use the keychain). Uncheck to never persist the password — you'll re-enter it each session.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ function createEmptyForm(
|
|||||||
username: null,
|
username: null,
|
||||||
password: null,
|
password: null,
|
||||||
database: null,
|
database: null,
|
||||||
use_keychain: false,
|
use_keychain: true,
|
||||||
ssh_password: null,
|
ssh_password: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ describe("ChangesQueuePanel", () => {
|
|||||||
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
|
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a ddl change with a DDL badge + description (visual) and SQL preview (sql view)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "ddl",
|
||||||
|
sql: "DROP INDEX public.i",
|
||||||
|
description: "Drop index i",
|
||||||
|
} as any);
|
||||||
|
render(<ChangesQueuePanel />);
|
||||||
|
expect(screen.getByText("DDL")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Drop index i")).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: /sql/i }));
|
||||||
|
expect(screen.getByText(/DROP INDEX public\.i/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("commit calls executeChange with buildChangePayload output for insert", async () => {
|
it("commit calls executeChange with buildChangePayload output for insert", async () => {
|
||||||
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||||
useDbViewerStore.getState().addChange({
|
useDbViewerStore.getState().addChange({
|
||||||
@@ -130,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({
|
||||||
@@ -208,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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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,10 @@ 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":
|
||||||
|
return change.description ?? "DDL";
|
||||||
default:
|
default:
|
||||||
return change.table ?? "-";
|
return change.table ?? "-";
|
||||||
}
|
}
|
||||||
@@ -50,6 +55,13 @@ 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/rebuild render uppercase. */
|
||||||
|
function badgeLabel(type: string): string {
|
||||||
|
if (type === "ddl") return "DDL";
|
||||||
|
if (type === "rebuild_table") return "REBUILD";
|
||||||
|
return capitalizeType(type);
|
||||||
|
}
|
||||||
|
|
||||||
function tableRef(change: QueueItem): string {
|
function tableRef(change: QueueItem): string {
|
||||||
if (change.schema && change.table) return `${change.schema}.${change.table}`;
|
if (change.schema && change.table) return `${change.schema}.${change.table}`;
|
||||||
return change.table ?? "-";
|
return change.table ?? "-";
|
||||||
@@ -90,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);
|
||||||
@@ -170,8 +187,15 @@ 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
|
||||||
{capitalizeType(change.type)}
|
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)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-text truncate">
|
<span className="text-sm text-text truncate">
|
||||||
{tableRef(change)}
|
{tableRef(change)}
|
||||||
@@ -197,19 +221,32 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-text-muted truncate">
|
{change.type === "rebuild_table" ? (
|
||||||
{formatChangeLabel(change)}
|
<>
|
||||||
</div>
|
<div className="mt-1 text-xs text-text-muted truncate">
|
||||||
{formatValueDiff(change) && (
|
{formatChangeLabel(change)}
|
||||||
<div className="mt-0.5 font-mono text-xs text-text">
|
</div>
|
||||||
<span className="text-text-muted line-through">
|
<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">
|
||||||
{formatValueDiff(change)!.split(" → ")[0]}
|
{buildChangeSql(change)}
|
||||||
</span>
|
</pre>
|
||||||
<span className="mx-1 text-text-muted">→</span>
|
</>
|
||||||
<span className="text-accent">
|
) : (
|
||||||
{formatValueDiff(change)!.split(" → ")[1]}
|
<>
|
||||||
</span>
|
<div className="mt-1 text-xs text-text-muted truncate">
|
||||||
</div>
|
{formatChangeLabel(change)}
|
||||||
|
</div>
|
||||||
|
{formatValueDiff(change) && (
|
||||||
|
<div className="mt-0.5 font-mono text-xs text-text">
|
||||||
|
<span className="text-text-muted line-through">
|
||||||
|
{formatValueDiff(change)!.split(" → ")[0]}
|
||||||
|
</span>
|
||||||
|
<span className="mx-1 text-text-muted">→</span>
|
||||||
|
<span className="text-accent">
|
||||||
|
{formatValueDiff(change)!.split(" → ")[1]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ vi.mock("../../hooks/useDbConnection", () => ({
|
|||||||
useDbConnection: (_connectionId: string) => ({
|
useDbConnection: (_connectionId: string) => ({
|
||||||
connectionError: null,
|
connectionError: null,
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
|
passwordPromptOpen: false,
|
||||||
|
submitPassword: vi.fn(),
|
||||||
|
cancelPassword: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -896,7 +899,66 @@ describe("DbViewerScreen", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("guards the Objects view for MySQL (capability false)", () => {
|
it("objects view renders the sidebar + the tabbed workspace (New query + Changes)", () => {
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /new query/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Changes queue" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("objects view renders ObjectDetail for an open object tab", () => {
|
||||||
|
useDbViewerStore.getState().openObjectTab("enums", "public", "role", { name: "role", schema: "public", labels: ["admin"] });
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
|
||||||
|
expect(screen.getByText("admin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an objectForm tab with the Visual/SQL toggle in the objects view", async () => {
|
||||||
|
vi.spyOn(commands, "executeQuery").mockResolvedValue({ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 } as any);
|
||||||
|
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||||
|
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||||
|
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "sequence",
|
||||||
|
schema: "public",
|
||||||
|
name: "",
|
||||||
|
title: "Create sequence",
|
||||||
|
description: "Create sequence",
|
||||||
|
mode: "create",
|
||||||
|
params: { schema: "public", name: "", action: { op: "create" } },
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<DbViewerScreen
|
||||||
|
connectionId="c1"
|
||||||
|
onHome={() => {}}
|
||||||
|
onSettings={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "Visual" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "SQL" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards the Objects view for MySQL (capability false)", () => {
|
||||||
useConnectionStore.setState({
|
useConnectionStore.setState({
|
||||||
connections: [
|
connections: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ const DestructiveQueryDialog = lazy(() =>
|
|||||||
);
|
);
|
||||||
import { TableTree } from "./TableTree";
|
import { TableTree } from "./TableTree";
|
||||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||||
|
import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
|
||||||
|
import { ObjectFormTab } from "./objects/ObjectFormTab";
|
||||||
import { TabBar } from "./TabBar";
|
import { TabBar } from "./TabBar";
|
||||||
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
||||||
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
||||||
import { TableControls } from "./TableControls";
|
import { TableControls } from "./TableControls";
|
||||||
import { EditConnectionModal } from "./EditConnectionModal";
|
import { EditConnectionModal } from "./EditConnectionModal";
|
||||||
|
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
||||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
@@ -162,7 +165,8 @@ export function DbViewerScreen({
|
|||||||
onHome,
|
onHome,
|
||||||
onSettings,
|
onSettings,
|
||||||
}: DbViewerScreenProps) {
|
}: DbViewerScreenProps) {
|
||||||
const { connectionError, connect } = useDbConnection(connectionId);
|
const { connectionError, connect, passwordPromptOpen, submitPassword, cancelPassword } =
|
||||||
|
useDbConnection(connectionId);
|
||||||
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
||||||
const [currentView, setCurrentView] = useState<string>("db-viewer");
|
const [currentView, setCurrentView] = useState<string>("db-viewer");
|
||||||
const [tablePanelWidth, setTablePanelWidth] = useState(280);
|
const [tablePanelWidth, setTablePanelWidth] = useState(280);
|
||||||
@@ -984,15 +988,30 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
||||||
{currentView === "queries" ? (
|
{currentView === "queries" ? (
|
||||||
<Terminal size={32} />
|
<Terminal size={32} />
|
||||||
|
) : currentView === "objects" ? (
|
||||||
|
<Database size={32} />
|
||||||
) : (
|
) : (
|
||||||
<Table2 size={32} />
|
<Table2 size={32} />
|
||||||
)}
|
)}
|
||||||
<span>
|
<span>
|
||||||
{currentView === "queries"
|
{currentView === "queries"
|
||||||
? "Open a new query tab or run a query from the history"
|
? "Open a new query tab or run a query from the history"
|
||||||
: "Select a table from the tree to browse its data, or open a new query tab"}
|
: currentView === "objects"
|
||||||
|
? "Open an object from the list, or open a new query tab"
|
||||||
|
: "Select a table from the tree to browse its data, or open a new query tab"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : activeTab?.tabType === "objectForm" ? (
|
||||||
|
<ObjectFormTab
|
||||||
|
connectionId={connectionId}
|
||||||
|
tab={activeTab}
|
||||||
|
/>
|
||||||
|
) : activeTab?.tabType === "object" ? (
|
||||||
|
<ObjectDetail
|
||||||
|
connectionId={connectionId}
|
||||||
|
type={activeTab.objectType!}
|
||||||
|
item={activeTab.objectItem as AnyObject}
|
||||||
|
/>
|
||||||
) : activeTab?.tabType === "query" ? (
|
) : activeTab?.tabType === "query" ? (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
@@ -1420,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}
|
||||||
/>
|
/>
|
||||||
@@ -1427,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 */}
|
||||||
@@ -1439,7 +1459,20 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
{renderQueryWorkspace()}
|
{renderQueryWorkspace()}
|
||||||
</div>
|
</div>
|
||||||
) : currentView === "objects" ? (
|
) : currentView === "objects" ? (
|
||||||
<ObjectExplorerPage connectionId={connectionId} />
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="border-r border-border flex flex-col shrink-0"
|
||||||
|
style={{ width: tablePanelWidth }}
|
||||||
|
>
|
||||||
|
<ObjectExplorerPage connectionId={connectionId} sidebarMode />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
|
||||||
|
onMouseDown={onPanelResizeStart}
|
||||||
|
onDoubleClick={() => setTablePanelWidth(280)}
|
||||||
|
/>
|
||||||
|
{renderQueryWorkspace()}
|
||||||
|
</div>
|
||||||
) : currentView === "tools" ? (
|
) : currentView === "tools" ? (
|
||||||
<ToolsPage connectionId={connectionId} />
|
<ToolsPage connectionId={connectionId} />
|
||||||
) : currentView === "queries" ? (
|
) : currentView === "queries" ? (
|
||||||
@@ -1473,6 +1506,12 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
onSaved={() => {}}
|
onSaved={() => {}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<PasswordPromptDialog
|
||||||
|
open={passwordPromptOpen}
|
||||||
|
connectionName={currentConnection?.name ?? ""}
|
||||||
|
onConnect={submitPassword}
|
||||||
|
onCancel={cancelPassword}
|
||||||
|
/>
|
||||||
{capabilities.objects && (
|
{capabilities.objects && (
|
||||||
<ObjectSearchPalette connectionId={connectionId} />
|
<ObjectSearchPalette connectionId={connectionId} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
<Tooltip content="Create Table" side="bottom">
|
{showCreateTable && (
|
||||||
<button
|
<Tooltip content="Create Table" side="bottom">
|
||||||
aria-label="Create Table"
|
<button
|
||||||
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"
|
aria-label="Create Table"
|
||||||
>
|
onClick={handleCreateTable}
|
||||||
<Plus size={14} />
|
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||||
</button>
|
>
|
||||||
</Tooltip>
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip content="Search Tables" side="bottom">
|
<Tooltip content="Search Tables" side="bottom">
|
||||||
<button
|
<button
|
||||||
aria-label="Search Tables"
|
aria-label="Search Tables"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { EditConnectionModal } from "./EditConnectionModal";
|
import { EditConnectionModal } from "./EditConnectionModal";
|
||||||
|
import * as commands from "../../lib/commands";
|
||||||
import type { Connection } from "../../lib/types";
|
import type { Connection } from "../../lib/types";
|
||||||
|
|
||||||
const { updateConnection, loadAll } = vi.hoisted(() => ({
|
const { updateConnection, loadAll } = vi.hoisted(() => ({
|
||||||
@@ -12,13 +13,18 @@ vi.mock("../../stores/connectionStore", () => ({
|
|||||||
useConnectionStore: (sel: (s: any) => any) =>
|
useConnectionStore: (sel: (s: any) => any) =>
|
||||||
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../lib/commands", () => ({
|
vi.mock("../../lib/commands", async (importOriginal) => {
|
||||||
updateConnection: vi.fn(),
|
const actual = await importOriginal<typeof import("../../lib/commands")>();
|
||||||
testConnection: vi.fn(),
|
return {
|
||||||
saveConnectionPassword: vi.fn(),
|
...actual,
|
||||||
saveConnectionSshPassword: vi.fn(),
|
updateConnection: vi.fn(),
|
||||||
saveConnectionSshPassphrase: vi.fn(),
|
testConnection: vi.fn(),
|
||||||
}));
|
saveConnectionPassword: vi.fn(),
|
||||||
|
saveConnectionSshPassword: vi.fn(),
|
||||||
|
saveConnectionSshPassphrase: vi.fn(),
|
||||||
|
deleteConnectionPassword: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock("../../stores/notificationStore", () => ({
|
vi.mock("../../stores/notificationStore", () => ({
|
||||||
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
|
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
@@ -68,4 +74,51 @@ describe("EditConnectionModal", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
|
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefills the keychain toggle from the connection (use_keychain=true)", () => {
|
||||||
|
render(
|
||||||
|
<EditConnectionModal
|
||||||
|
connection={{ ...baseConn, use_keychain: true }}
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSaved={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
|
||||||
|
expect(cb.checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves to keychain when use_keychain=true", async () => {
|
||||||
|
render(
|
||||||
|
<EditConnectionModal
|
||||||
|
connection={{ ...baseConn, use_keychain: true }}
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSaved={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vi.mocked(commands.saveConnectionPassword)).toHaveBeenCalledWith("c1", "secret")
|
||||||
|
);
|
||||||
|
expect(vi.mocked(commands.deleteConnectionPassword)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges keychain when use_keychain=false", async () => {
|
||||||
|
render(
|
||||||
|
<EditConnectionModal
|
||||||
|
connection={{ ...baseConn, use_keychain: false }}
|
||||||
|
open={true}
|
||||||
|
onClose={() => {}}
|
||||||
|
onSaved={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vi.mocked(commands.deleteConnectionPassword)).toHaveBeenCalledWith("c1")
|
||||||
|
);
|
||||||
|
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,7 +4,8 @@ import { Button } from "../ui/Button";
|
|||||||
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
||||||
import { useConnectionStore } from "../../stores/connectionStore";
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { updateConnection, testConnection, saveConnectionPassword, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
|
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
|
||||||
|
import { persistDbPassword } from "../../lib/keychain";
|
||||||
import { detectProviderFromHost } from "../../lib/connectionString";
|
import { detectProviderFromHost } from "../../lib/connectionString";
|
||||||
import type { Connection, ConnectionInput } from "../../lib/types";
|
import type { Connection, ConnectionInput } from "../../lib/types";
|
||||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||||
@@ -36,7 +37,7 @@ export function EditConnectionModal({
|
|||||||
username: connection.username,
|
username: connection.username,
|
||||||
password: null,
|
password: null,
|
||||||
database: connection.database ?? null,
|
database: connection.database ?? null,
|
||||||
use_keychain: false,
|
use_keychain: connection.use_keychain ?? true,
|
||||||
ssh_host: connection.ssh_host ?? null,
|
ssh_host: connection.ssh_host ?? null,
|
||||||
ssh_port: connection.ssh_port ?? null,
|
ssh_port: connection.ssh_port ?? null,
|
||||||
ssh_user: connection.ssh_user ?? null,
|
ssh_user: connection.ssh_user ?? null,
|
||||||
@@ -76,9 +77,7 @@ export function EditConnectionModal({
|
|||||||
ssh_passphrase: form.ssh_passphrase ?? null,
|
ssh_passphrase: form.ssh_passphrase ?? null,
|
||||||
};
|
};
|
||||||
const updated = await updateConnection(connection.id, input);
|
const updated = await updateConnection(connection.id, input);
|
||||||
if (form.password) {
|
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
|
||||||
await saveConnectionPassword(connection.id, form.password).catch(() => {});
|
|
||||||
}
|
|
||||||
// Persist SSH secrets to the OS keychain (not SQLite)
|
// Persist SSH secrets to the OS keychain (not SQLite)
|
||||||
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
|
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
|
||||||
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
|
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ describe("ObjectExplorerPage", () => {
|
|||||||
fireEvent.click(screen.getByLabelText("Object type"));
|
fireEvent.click(screen.getByLabelText("Object type"));
|
||||||
fireEvent.click(screen.getByText("Enums"));
|
fireEvent.click(screen.getByText("Enums"));
|
||||||
await waitFor(() => screen.getByText("role"));
|
await waitFor(() => screen.getByText("role"));
|
||||||
fireEvent.click(screen.getAllByLabelText(/options/i)[0]);
|
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
|
||||||
fireEvent.click(screen.getByText(/copy ddl/i));
|
fireEvent.click(screen.getByText(/copy ddl/i));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"),
|
expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"),
|
||||||
@@ -231,12 +231,159 @@ describe("ObjectExplorerPage", () => {
|
|||||||
]);
|
]);
|
||||||
render(<ObjectExplorerPage connectionId="c1" />);
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
await waitFor(() => screen.getByText("add_one(int)"));
|
await waitFor(() => screen.getByText("add_one(int)"));
|
||||||
fireEvent.click(screen.getAllByLabelText(/options/i)[0]);
|
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
|
||||||
fireEvent.click(screen.getByText(/dependencies/i));
|
fireEvent.click(screen.getByText(/dependencies/i));
|
||||||
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled());
|
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled());
|
||||||
await waitFor(() => expect(screen.getByText("v")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("v")).toBeTruthy());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("per-item menu offers Create…/Edit…/Drop… and Edit opens an objectForm tab", async () => {
|
||||||
|
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||||
|
{ name: "role", schema: "public", labels: ["admin"] },
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText("Object type"));
|
||||||
|
fireEvent.click(screen.getByText("Enums"));
|
||||||
|
await waitFor(() => screen.getByText("role"));
|
||||||
|
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
|
||||||
|
expect(screen.getByText("Create…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Edit…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Drop…")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText("Edit…"));
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(st.tabs).toHaveLength(1);
|
||||||
|
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||||
|
expect(st.tabs[0].form?.mode).toBe("edit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("right-click on a list row opens the context menu", async () => {
|
||||||
|
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||||
|
{ name: "role", schema: "public", labels: ["admin"] },
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText("Object type"));
|
||||||
|
fireEvent.click(screen.getByText("Enums"));
|
||||||
|
const row = await screen.findByText("role");
|
||||||
|
fireEvent.contextMenu(row);
|
||||||
|
expect(screen.getByText("Create…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Edit…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("header + create button opens an objectForm create tab for the current type", async () => {
|
||||||
|
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||||
|
{ name: "role", schema: "public", labels: ["admin"] },
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText("Object type"));
|
||||||
|
fireEvent.click(screen.getByText("Enums"));
|
||||||
|
await waitFor(() => screen.getByText("role"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /create enum/i }));
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(st.tabs).toHaveLength(1);
|
||||||
|
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||||
|
expect(st.tabs[0].form?.mode).toBe("create");
|
||||||
|
expect(st.tabs[0].form?.kind).toBe("enum");
|
||||||
|
expect(st.tabs[0].form?.params?.schema).toBe("public");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches the current object list after a ddl commit succeeds", async () => {
|
||||||
|
const getFunctions = vi
|
||||||
|
.spyOn(commands, "getFunctions")
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" />);
|
||||||
|
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(1));
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "ddl",
|
||||||
|
sql: "DROP INDEX public.i",
|
||||||
|
description: "Drop index i",
|
||||||
|
} as any);
|
||||||
|
useDbViewerStore.getState().markChangeCommitted("ch-1");
|
||||||
|
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sidebarMode: clicking a list row opens an object tab instead of an inline detail", async () => {
|
||||||
|
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "add",
|
||||||
|
schema: "public",
|
||||||
|
return_type: "int",
|
||||||
|
argument_types: [],
|
||||||
|
argument_names: [],
|
||||||
|
argument_modes: [],
|
||||||
|
language: "plpgsql",
|
||||||
|
source: "BEGIN RETURN 1; END",
|
||||||
|
kind: "f",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("add")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("add"));
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(
|
||||||
|
st.tabs.some(
|
||||||
|
(t) => t.tabType === "object" && t.table === "add",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sidebarMode: does not render the inline detail pane", async () => {
|
||||||
|
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||||
|
{ name: "role", schema: "public", labels: ["admin"] },
|
||||||
|
]);
|
||||||
|
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
|
||||||
|
fireEvent.click(screen.getByLabelText("Object type"));
|
||||||
|
fireEvent.click(screen.getByText("Enums"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("role")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("role"));
|
||||||
|
// clicking opened an object tab (no inline detail selected)
|
||||||
|
expect(
|
||||||
|
useDbViewerStore
|
||||||
|
.getState()
|
||||||
|
.tabs.some((t) => t.tabType === "object"),
|
||||||
|
).toBe(true);
|
||||||
|
// detail pane is gone; only the list remains
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Select an enum to view details"),
|
||||||
|
).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" />);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
||||||
|
|
||||||
|
describe("PasswordPromptDialog", () => {
|
||||||
|
it("renders nothing when closed", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PasswordPromptDialog
|
||||||
|
open={false}
|
||||||
|
connectionName="n"
|
||||||
|
onConnect={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the connection name and a password field when open", () => {
|
||||||
|
render(
|
||||||
|
<PasswordPromptDialog
|
||||||
|
open={true}
|
||||||
|
connectionName="Prod DB"
|
||||||
|
onConnect={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/Prod DB/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onConnect with the typed value", () => {
|
||||||
|
const onConnect = vi.fn();
|
||||||
|
render(
|
||||||
|
<PasswordPromptDialog
|
||||||
|
open={true}
|
||||||
|
connectionName="X"
|
||||||
|
onConnect={onConnect}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/password/i), {
|
||||||
|
target: { value: "p@ss" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /connect/i }));
|
||||||
|
expect(onConnect).toHaveBeenCalledWith("p@ss");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onCancel on cancel", () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<PasswordPromptDialog
|
||||||
|
open={true}
|
||||||
|
connectionName="X"
|
||||||
|
onConnect={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||||
|
expect(onCancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
connectionName: string;
|
||||||
|
onConnect: (password: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordPromptDialog({
|
||||||
|
open,
|
||||||
|
connectionName,
|
||||||
|
onConnect,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
|
const [pw, setPw] = useState("");
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<AnimatedModal open={open} onClose={onCancel}>
|
||||||
|
<div className="p-5 w-80">
|
||||||
|
<h2 className="text-sm font-medium text-text mb-1">Enter password</h2>
|
||||||
|
<p className="text-xs text-text-muted mb-3">
|
||||||
|
“{connectionName}” has keychain disabled. Enter the password for this
|
||||||
|
session (it will not be saved).
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
aria-label="Password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={pw}
|
||||||
|
onChange={(e) => setPw(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && pw) onConnect(pw);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border-border bg-surface px-3 py-2 text-sm text-text mb-3"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-lg text-text-muted hover:bg-surface"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pw && onConnect(pw)}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,6 +83,22 @@ describe("TabBar", () => {
|
|||||||
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the per-type icon on an object tab", () => {
|
||||||
|
useDbViewerStore.getState().openObjectTab("functions", "public", "add", { name: "add", schema: "public" });
|
||||||
|
render(<TabBar />);
|
||||||
|
const icon = screen.getByLabelText(/object icon: functions/i);
|
||||||
|
const svg = icon.querySelector("svg");
|
||||||
|
expect(svg).toBeTruthy();
|
||||||
|
// Regression: the icon must use the SAME handling as the query/table icons —
|
||||||
|
// the svg itself is display:inline with the shared optical-centering classes.
|
||||||
|
// That defeats preflight svg{display:block} (no stacking) and lets
|
||||||
|
// vertical-align:middle center it with the tab name.
|
||||||
|
const cls = svg!.getAttribute("class") ?? "";
|
||||||
|
expect(cls).toContain("inline");
|
||||||
|
expect(cls).toContain("-mt-0.5");
|
||||||
|
expect(screen.getByText("add")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a view icon on view tabs", () => {
|
it("renders a view icon on view tabs", () => {
|
||||||
useDbViewerStore.getState().openTab("main", "order_summary");
|
useDbViewerStore.getState().openTab("main", "order_summary");
|
||||||
useDbViewerStore.setState({
|
useDbViewerStore.setState({
|
||||||
@@ -95,6 +111,35 @@ describe("TabBar", () => {
|
|||||||
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a create icon and title on an objectForm create tab", () => {
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "sequence",
|
||||||
|
schema: "public",
|
||||||
|
name: "",
|
||||||
|
title: "Create sequence",
|
||||||
|
description: "Create sequence",
|
||||||
|
mode: "create",
|
||||||
|
params: { schema: "public", name: "", action: { op: "create" } },
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByTestId("tab-icon-form-create")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Create sequence")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an edit icon on an objectForm edit tab", () => {
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "sequence",
|
||||||
|
schema: "public",
|
||||||
|
name: "s",
|
||||||
|
title: "Edit sequence",
|
||||||
|
description: "Edit sequence",
|
||||||
|
mode: "edit",
|
||||||
|
params: { schema: "public", name: "s", action: { op: "alter" } },
|
||||||
|
});
|
||||||
|
render(<TabBar />);
|
||||||
|
expect(screen.getByTestId("tab-icon-form-edit")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders a layers icon on materialized view tabs", () => {
|
it("renders a layers icon on materialized view tabs", () => {
|
||||||
useDbViewerStore.getState().openTab("public", "mv_products");
|
useDbViewerStore.getState().openTab("public", "mv_products");
|
||||||
useDbViewerStore.setState({
|
useDbViewerStore.setState({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { cloneElement, useEffect, useRef, type ReactElement, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -16,9 +16,10 @@ import {
|
|||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X } from "lucide-react";
|
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X, Plus, Pencil } from "lucide-react";
|
||||||
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
|
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
|
||||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||||
|
import { OBJECT_ICONS } from "./objects/ObjectDetail";
|
||||||
|
|
||||||
function SortableTab({
|
function SortableTab({
|
||||||
tab,
|
tab,
|
||||||
@@ -181,6 +182,39 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
|||||||
data-testid="tab-icon-query"
|
data-testid="tab-icon-query"
|
||||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||||
/>
|
/>
|
||||||
|
) : tab.tabType === "object" ? (
|
||||||
|
<span
|
||||||
|
aria-label={`object icon: ${tab.objectType}`}
|
||||||
|
className="contents"
|
||||||
|
>
|
||||||
|
{cloneElement(
|
||||||
|
OBJECT_ICONS[tab.objectType!] as ReactElement<{
|
||||||
|
className?: string;
|
||||||
|
}>,
|
||||||
|
{
|
||||||
|
// Same handling as the query/table icons: the svg
|
||||||
|
// itself is display:inline (preflight vertical-align:
|
||||||
|
// middle centers it with the text) with the same
|
||||||
|
// optical-centering nudge. `display: contents` on the
|
||||||
|
// labelled span renders no box, so the geometry is
|
||||||
|
// identical to the bare Terminal/Table2 icons.
|
||||||
|
className:
|
||||||
|
"mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : tab.tabType === "objectForm" ? (
|
||||||
|
tab.form?.mode === "create" ? (
|
||||||
|
<Plus
|
||||||
|
data-testid="tab-icon-form-create"
|
||||||
|
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Pencil
|
||||||
|
data-testid="tab-icon-form-edit"
|
||||||
|
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : objectType === "VIEW" ? (
|
) : objectType === "VIEW" ? (
|
||||||
<Eye
|
<Eye
|
||||||
data-testid="tab-icon-view"
|
data-testid="tab-icon-view"
|
||||||
|
|||||||
@@ -19,6 +19,31 @@ describe("TableOverflowMenu", () => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers Create Index… and Create Constraint…", () => {
|
||||||
|
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
|
expect(screen.getByText("Create Index…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Create Constraint…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens objectForm create tabs for Create Index… and Create Constraint…", async () => {
|
||||||
|
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||||
|
fireEvent.click(screen.getByText("Create Index…"));
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(st.tabs).toHaveLength(1);
|
||||||
|
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||||
|
expect(st.tabs[0].form?.kind).toBe("index");
|
||||||
|
expect(st.tabs[0].form?.mode).toBe("create");
|
||||||
|
|
||||||
|
useDbViewerStore.getState().reset();
|
||||||
|
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||||
|
fireEvent.click(screen.getAllByLabelText(/table options/i)[1]);
|
||||||
|
fireEvent.click(screen.getByText("Create Constraint…"));
|
||||||
|
expect(useDbViewerStore.getState().tabs[0].tabType).toBe("objectForm");
|
||||||
|
expect(useDbViewerStore.getState().tabs[0].form?.kind).toBe("constraint");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders menu trigger button", () => {
|
it("renders menu trigger button", () => {
|
||||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
||||||
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
|
||||||
@@ -92,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" }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,13 +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 type { ColumnInfo, DependencyInfo } from "../../lib/types";
|
import { initialCrudParams } from "../../lib/objectCrud";
|
||||||
|
import type { ColumnInfo, DependencyInfo, MaintenanceResult } from "../../lib/types";
|
||||||
|
|
||||||
interface TableOverflowMenuProps {
|
interface TableOverflowMenuProps {
|
||||||
schema: string;
|
schema: string;
|
||||||
@@ -16,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({
|
||||||
@@ -31,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(() => {
|
||||||
@@ -62,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":
|
||||||
@@ -96,6 +145,66 @@ 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":
|
||||||
|
if (!connectionId) break;
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "index",
|
||||||
|
schema,
|
||||||
|
name: "",
|
||||||
|
title: "Create Index",
|
||||||
|
description: `Create index on ${schema}.${table}`,
|
||||||
|
mode: "create",
|
||||||
|
params: initialCrudParams("index", { schema, table, name: "" }, "create"),
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
break;
|
||||||
|
case "create_constraint":
|
||||||
|
if (!connectionId) break;
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind: "constraint",
|
||||||
|
schema,
|
||||||
|
name: "",
|
||||||
|
title: "Create Constraint",
|
||||||
|
description: `Create constraint on ${schema}.${table}`,
|
||||||
|
mode: "create",
|
||||||
|
params: initialCrudParams("constraint", { schema, table, name: "" }, "create"),
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
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);
|
||||||
@@ -125,6 +234,17 @@ export function TableOverflowMenu({
|
|||||||
{ id: "export-sql", label: "Export data (SQL)" },
|
{ id: "export-sql", label: "Export data (SQL)" },
|
||||||
{ id: "export-md", label: "Export data (Markdown)" },
|
{ id: "export-md", label: "Export data (Markdown)" },
|
||||||
{ id: "import", label: "Import data (CSV/JSON)" },
|
{ id: "import", label: "Import data (CSV/JSON)" },
|
||||||
|
{ id: "create_index", label: "Create Index…" },
|
||||||
|
{ 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 },
|
||||||
];
|
];
|
||||||
@@ -140,22 +260,69 @@ 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) =>
|
||||||
<button
|
item.divider ? (
|
||||||
key={item.id}
|
<div key={item.id} className="border-t border-border my-1" />
|
||||||
type="button"
|
) : (
|
||||||
onClick={() => handleAction(item.id)}
|
<button
|
||||||
className={[
|
key={item.id}
|
||||||
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
type="button"
|
||||||
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
onClick={() => handleAction(item.id)}
|
||||||
].join(" ")}
|
className={[
|
||||||
>
|
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
||||||
<span>{item.label}</span>
|
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
||||||
</button>
|
].join(" ")}
|
||||||
))}
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
interface Props {
|
||||||
|
cols: string[];
|
||||||
|
selected: string[];
|
||||||
|
onToggle: (col: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnPicker({ cols, selected, onToggle }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1" data-testid="column-picker">
|
||||||
|
{cols.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(c)}
|
||||||
|
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
||||||
|
selected.includes(c)
|
||||||
|
? "bg-accent text-white border-accent"
|
||||||
|
: "border-border text-text hover:border-text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { ConstraintForm } from "./ConstraintForm";
|
||||||
|
import * as cmd from "../../../lib/commands";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = {
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: "orders",
|
||||||
|
schema: "public",
|
||||||
|
table_type: "BASE TABLE",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ConstraintForm", () => {
|
||||||
|
it("check: emits the expression", () => {
|
||||||
|
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConstraintForm
|
||||||
|
connectionId="c1"
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
table: "orders",
|
||||||
|
name: "ck",
|
||||||
|
action: { op: "check", expression: "" },
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("CHECK expression"), {
|
||||||
|
target: { value: "amount > 0" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ expression: "amount > 0" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("foreign_key: picks referenced table + column", async () => {
|
||||||
|
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConstraintForm
|
||||||
|
connectionId="c1"
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
table: "orders",
|
||||||
|
name: "fk",
|
||||||
|
action: {
|
||||||
|
op: "foreign_key",
|
||||||
|
columns: ["user_id"],
|
||||||
|
ref_schema: "",
|
||||||
|
ref_table: "",
|
||||||
|
ref_columns: [],
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const kindSelect = screen.getByLabelText("Kind");
|
||||||
|
fireEvent.change(kindSelect, { target: { value: "foreign_key" } });
|
||||||
|
await screen.findByText("user_id");
|
||||||
|
const refTable = await screen.findByLabelText("Referenced table");
|
||||||
|
fireEvent.change(refTable, { target: { value: "public.users" } });
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({
|
||||||
|
ref_schema: "public",
|
||||||
|
ref_table: "users",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
import * as cmd from "../../../lib/commands";
|
||||||
|
import type { SchemaGraph } from "../../../lib/types";
|
||||||
|
import { ColumnPicker } from "./ColumnPicker";
|
||||||
|
import { FormRow, inputClass, controlClass, monoInputClass } from "./formRow";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
params: DdlParams;
|
||||||
|
schemas?: string[];
|
||||||
|
onChange: (p: DdlParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
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) {
|
||||||
|
const p = params as Record<string, unknown>;
|
||||||
|
const action = (p.action ?? {}) as Record<string, unknown>;
|
||||||
|
const kind = (action.op as string) ?? "check";
|
||||||
|
|
||||||
|
const [graph, setGraph] = useState<SchemaGraph>({ tables: [], relationships: [] });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cmd
|
||||||
|
.getSchemaGraph(connectionId, undefined)
|
||||||
|
.then((g: SchemaGraph) => setGraph(g))
|
||||||
|
.catch(() => setGraph({ tables: [], relationships: [] }));
|
||||||
|
}, [connectionId]);
|
||||||
|
|
||||||
|
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
|
||||||
|
|
||||||
|
const tableCols =
|
||||||
|
graph.tables
|
||||||
|
.find((t) => t.name === (p.table as string) && t.schema === (p.schema as string))
|
||||||
|
?.columns.map((c) => c.name) ?? [];
|
||||||
|
|
||||||
|
const refCols =
|
||||||
|
graph.tables
|
||||||
|
.find(
|
||||||
|
(t) =>
|
||||||
|
t.name === (action.ref_table as string) &&
|
||||||
|
t.schema === (action.ref_schema as string),
|
||||||
|
)
|
||||||
|
?.columns.map((c) => c.name) ?? [];
|
||||||
|
|
||||||
|
const selected: string[] = (action.columns as string[]) ?? [];
|
||||||
|
const refSelected: string[] = (action.ref_columns as string[]) ?? [];
|
||||||
|
|
||||||
|
const pick = (list: string[], c: string) =>
|
||||||
|
list.includes(c) ? list.filter((x) => x !== c) : [...list, c];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormRow label="Schema">
|
||||||
|
{schemas && schemas.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={(p.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||||
|
aria-label="Schema"
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Schema</option>
|
||||||
|
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Schema"
|
||||||
|
value={(p.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Table">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Table"
|
||||||
|
value={(p.table as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, table: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Constraint name"
|
||||||
|
value={(p.name as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, name: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Kind">
|
||||||
|
<select
|
||||||
|
aria-label="Kind"
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => onChange({ ...p, action: { op: e.target.value } })}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
{KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{k.replace(/_/g, " ")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
{(kind === "unique" || kind === "primary_key") && (
|
||||||
|
<FormRow label="Columns" className="items-stretch">
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||||
|
<ColumnPicker
|
||||||
|
cols={tableCols}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={(c) => setAction({ columns: pick(selected, c) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === "check" && (
|
||||||
|
<FormRow label="Expression">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="CHECK expression"
|
||||||
|
value={(action.expression as string) ?? ""}
|
||||||
|
onChange={(e) => setAction({ expression: e.target.value })}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === "foreign_key" && (
|
||||||
|
<>
|
||||||
|
<FormRow label="Columns" className="items-stretch">
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||||
|
<ColumnPicker
|
||||||
|
cols={tableCols}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={(c) => setAction({ columns: pick(selected, c) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Referenced table">
|
||||||
|
<select
|
||||||
|
aria-label="Referenced table"
|
||||||
|
value={refKey(
|
||||||
|
(action.ref_schema as string) ?? "",
|
||||||
|
(action.ref_table as string) ?? "",
|
||||||
|
)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const { schema, table } = parseRefKey(e.target.value);
|
||||||
|
const t = graph.tables.find(
|
||||||
|
(tbl) => tbl.name === table && tbl.schema === schema,
|
||||||
|
);
|
||||||
|
setAction({
|
||||||
|
ref_table: table,
|
||||||
|
ref_schema: schema,
|
||||||
|
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={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 label="Referenced columns" className="items-stretch">
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||||
|
<ColumnPicker
|
||||||
|
cols={refCols}
|
||||||
|
selected={refSelected}
|
||||||
|
onToggle={(c) => setAction({ ref_columns: pick(refSelected, c) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { EnumForm } from "./EnumForm";
|
||||||
|
|
||||||
|
describe("EnumForm", () => {
|
||||||
|
it("create: add/remove labels", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<EnumForm
|
||||||
|
params={{ schema: "public", name: "role", action: { op: "create", labels: ["admin"] } }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /add value/i }));
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ labels: ["admin", ""] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("add_value op shows value + position + the no-removal note", () => {
|
||||||
|
render(
|
||||||
|
<EnumForm
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
name: "color",
|
||||||
|
action: { op: "add_value", value: "orange", if_not_exists: false, before: null, after: null },
|
||||||
|
}}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText("New value")).toHaveValue("orange");
|
||||||
|
expect(screen.getByText(/no ALTER TYPE … DROP VALUE/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rename_value op shows from + to", () => {
|
||||||
|
render(
|
||||||
|
<EnumForm
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
name: "color",
|
||||||
|
action: { op: "rename_value", from: "purple", to: "mauve" },
|
||||||
|
}}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText("From")).toHaveValue("purple");
|
||||||
|
expect(screen.getByPlaceholderText("To")).toHaveValue("mauve");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
import { FormRow, inputClass, controlClass, monoInputClass } from "./formRow";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: DdlParams;
|
||||||
|
schemas?: string[];
|
||||||
|
onChange: (p: DdlParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnumOp = "create" | "rename_type" | "add_value" | "rename_value";
|
||||||
|
|
||||||
|
const OP_LABELS: Record<EnumOp, string> = {
|
||||||
|
create: "Create",
|
||||||
|
rename_type: "Rename type",
|
||||||
|
add_value: "Add value",
|
||||||
|
rename_value: "Rename value",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getOp(params: DdlParams): EnumOp {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
const op = action.op;
|
||||||
|
if (op === "rename_type" || op === "add_value" || op === "rename_value") return op;
|
||||||
|
return "create";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
return { ...params, action: { ...action, ...patch } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchActionResetOp(params: DdlParams, op: EnumOp): DdlParams {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
const labels = (action.labels as string[]) ?? [];
|
||||||
|
return { ...params, action: { op, labels } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoRemovalNote() {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
PostgreSQL has no ALTER TYPE … DROP VALUE. To remove a value, drop and recreate the type.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnumForm({ params, schemas, onChange }: Props) {
|
||||||
|
const op = getOp(params);
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
const labels = (action.labels as string[]) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormRow label="Schema">
|
||||||
|
{schemas && schemas.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={(params.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||||
|
aria-label="Schema"
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Schema</option>
|
||||||
|
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Schema"
|
||||||
|
value={(params.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enum name"
|
||||||
|
value={(params.name as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, name: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Operation">
|
||||||
|
<select
|
||||||
|
aria-label="Operation"
|
||||||
|
value={op}
|
||||||
|
onChange={(e) => onChange(patchActionResetOp(params, e.target.value as EnumOp))}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
{(Object.keys(OP_LABELS) as EnumOp[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{OP_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
{op === "create" && (
|
||||||
|
<FormRow label="Values" className="items-stretch">
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||||
|
{labels.map((l, i) => (
|
||||||
|
<div key={i} className="flex gap-1 items-center">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={`Value ${i + 1}`}
|
||||||
|
value={l}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(
|
||||||
|
patchAction(params, {
|
||||||
|
labels: labels.map((x, j) => (j === i ? e.target.value : x)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onChange(patchAction(params, { labels: labels.filter((_, j) => j !== i) }))
|
||||||
|
}
|
||||||
|
className="text-text-muted hover:text-red-400 px-2"
|
||||||
|
aria-label={`Remove value ${i + 1}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(patchAction(params, { labels: [...labels, ""] }))}
|
||||||
|
className="self-start text-xs text-accent hover:text-accent/80"
|
||||||
|
>
|
||||||
|
+ Add value
|
||||||
|
</button>
|
||||||
|
<NoRemovalNote />
|
||||||
|
</div>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{op === "rename_type" && (
|
||||||
|
<FormRow label="New name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="New name"
|
||||||
|
value={(action.new_name as string) ?? ""}
|
||||||
|
onChange={(e) => onChange(patchAction(params, { new_name: e.target.value }))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{op === "add_value" && (
|
||||||
|
<>
|
||||||
|
<FormRow label="New value">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="New value"
|
||||||
|
value={(action.value as string) ?? ""}
|
||||||
|
onChange={(e) => onChange(patchAction(params, { value: e.target.value }))}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="If not exists">
|
||||||
|
<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={!!action.if_not_exists}
|
||||||
|
onChange={(e) => onChange(patchAction(params, { if_not_exists: e.target.checked }))}
|
||||||
|
aria-label="IF NOT EXISTS"
|
||||||
|
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<span>IF NOT EXISTS</span>
|
||||||
|
</label>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Before">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="BEFORE (optional)"
|
||||||
|
value={(action.before as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchAction(params, { before: e.target.value || null, after: null }))
|
||||||
|
}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="After">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="AFTER (optional)"
|
||||||
|
value={(action.after as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchAction(params, { after: e.target.value || null, before: null }))
|
||||||
|
}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<NoRemovalNote />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{op === "rename_value" && (
|
||||||
|
<>
|
||||||
|
<FormRow label="From">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="From"
|
||||||
|
value={(action.from as string) ?? ""}
|
||||||
|
onChange={(e) => onChange(patchAction(params, { from: e.target.value }))}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="To">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="To"
|
||||||
|
value={(action.to as string) ?? ""}
|
||||||
|
onChange={(e) => onChange(patchAction(params, { to: e.target.value }))}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { ExtensionForm } from "./ExtensionForm";
|
||||||
|
import * as objectCrud from "../../../lib/objectCrud";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/objectCrud", () => ({
|
||||||
|
getAvailableExtensions: vi.fn(),
|
||||||
|
buildObjectDdl: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ExtensionForm", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create: lists available extensions and emits name on pick", async () => {
|
||||||
|
vi.mocked(objectCrud.getAvailableExtensions).mockResolvedValue([
|
||||||
|
{ name: "pgcrypto", version: "1.3", comment: null },
|
||||||
|
]);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ExtensionForm
|
||||||
|
connectionId="c1"
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
name: "",
|
||||||
|
action: { op: "create", version: null },
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("pgcrypto")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText("pgcrypto"));
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "pgcrypto",
|
||||||
|
action: expect.objectContaining({ version: "1.3" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set_schema op emits new_schema", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ExtensionForm
|
||||||
|
connectionId="c1"
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
name: "pgcrypto",
|
||||||
|
action: { op: "set_schema", new_schema: "" },
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("New schema"), {
|
||||||
|
target: { value: "utils" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ new_schema: "utils" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
import {
|
||||||
|
getAvailableExtensions,
|
||||||
|
type AvailableExtension,
|
||||||
|
} from "../../../lib/objectCrud";
|
||||||
|
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
params: DdlParams;
|
||||||
|
schemas?: string[];
|
||||||
|
onChange: (p: DdlParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExtensionOp = "create" | "set_schema";
|
||||||
|
|
||||||
|
const OP_LABELS: Record<ExtensionOp, string> = {
|
||||||
|
create: "Install",
|
||||||
|
set_schema: "Set schema",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ExtensionForm({ connectionId, params, schemas, onChange }: Props) {
|
||||||
|
const p = params as Record<string, unknown>;
|
||||||
|
const action = (p.action ?? {}) as Record<string, unknown>;
|
||||||
|
const op = (action.op as ExtensionOp) ?? "create";
|
||||||
|
|
||||||
|
const [available, setAvailable] = useState<AvailableExtension[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAvailableExtensions(connectionId)
|
||||||
|
.then(setAvailable)
|
||||||
|
.catch(() => setAvailable([]));
|
||||||
|
}, [connectionId]);
|
||||||
|
|
||||||
|
const setAction = (patch: Record<string, unknown>) => {
|
||||||
|
onChange({ ...p, action: { ...action, ...patch } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickExtension = (ext: AvailableExtension) => {
|
||||||
|
onChange({
|
||||||
|
...p,
|
||||||
|
name: ext.name,
|
||||||
|
action: { op: "create", version: ext.version || null },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormRow label="Schema">
|
||||||
|
{schemas && schemas.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={(p.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||||
|
aria-label="Schema"
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Schema</option>
|
||||||
|
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Schema"
|
||||||
|
value={(p.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
<FormRow label="Operation">
|
||||||
|
<select
|
||||||
|
aria-label="Operation"
|
||||||
|
value={op}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...p, action: { op: e.target.value as ExtensionOp } })
|
||||||
|
}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
{(Object.keys(OP_LABELS) as ExtensionOp[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{OP_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
{op === "create" && (
|
||||||
|
<>
|
||||||
|
<FormRow label="Extension">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Extension name"
|
||||||
|
value={(p.name as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, name: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
{available.length > 0 && (
|
||||||
|
<FormRow label="Available" className="items-stretch">
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-0.5 px-4 py-2">
|
||||||
|
{available.map((ext) => (
|
||||||
|
<button
|
||||||
|
key={ext.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickExtension(ext)}
|
||||||
|
className="text-left text-xs font-medium text-accent hover:text-accent-hover hover:underline"
|
||||||
|
>
|
||||||
|
<span>{ext.name}</span>
|
||||||
|
{ext.version ? (
|
||||||
|
<span className="text-text-muted"> ({ext.version})</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormRow label="Version">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Version (optional)"
|
||||||
|
value={(action.version as string) ?? ""}
|
||||||
|
onChange={(e) => setAction({ version: e.target.value || null })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{op === "set_schema" && (
|
||||||
|
<FormRow label="New schema">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="New schema"
|
||||||
|
value={(action.new_schema as string) ?? ""}
|
||||||
|
onChange={(e) => setAction({ new_schema: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { FunctionForm } from "./FunctionForm";
|
||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
|
||||||
|
vi.mock("../../editor/SqlEditorField", () => ({
|
||||||
|
SqlEditorField: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||||
|
<textarea data-testid="sql-editor" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("FunctionForm", () => {
|
||||||
|
it("regression: Body row opts out of focus-within outline; normal rows keep it", async () => {
|
||||||
|
const params: DdlParams = {
|
||||||
|
schema: "public",
|
||||||
|
name: "add",
|
||||||
|
is_procedure: false,
|
||||||
|
action: {
|
||||||
|
op: "create_or_replace",
|
||||||
|
args: [],
|
||||||
|
return_type: "int",
|
||||||
|
language: "plpgsql",
|
||||||
|
body: "",
|
||||||
|
volatility: null,
|
||||||
|
strict: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<FunctionForm kind="function" params={params} onChange={() => {}} />);
|
||||||
|
|
||||||
|
// Monaco rows must NOT get the amber in-cell-editing outline.
|
||||||
|
const editor = await screen.findByTestId("sql-editor");
|
||||||
|
const bodyRow = editor.closest("div.flex.flex-row");
|
||||||
|
expect(bodyRow?.className ?? "").not.toContain("focus-within:outline");
|
||||||
|
|
||||||
|
// Normal rows still carry the outline — the opt-out must be scoped.
|
||||||
|
const operationRow = screen
|
||||||
|
.getByLabelText("Operation")
|
||||||
|
.closest("div.flex.flex-row");
|
||||||
|
expect(operationRow?.className ?? "").toContain("focus-within:outline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("function: renders args grid + return type; emits body", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const params: DdlParams = {
|
||||||
|
schema: "public",
|
||||||
|
name: "add",
|
||||||
|
is_procedure: false,
|
||||||
|
action: {
|
||||||
|
op: "create_or_replace",
|
||||||
|
args: [{ mode: "in", name: "a", type: "int" }],
|
||||||
|
return_type: "int",
|
||||||
|
language: "plpgsql",
|
||||||
|
body: "",
|
||||||
|
volatility: null,
|
||||||
|
strict: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(<FunctionForm kind="function" params={params} onChange={onChange} />);
|
||||||
|
fireEvent.change(await screen.findByTestId("sql-editor"), {
|
||||||
|
target: { value: "BEGIN RETURN a; END" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ body: "BEGIN RETURN a; END" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a schema dropdown when schemas are provided", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const params: DdlParams = {
|
||||||
|
schema: "public",
|
||||||
|
name: "add",
|
||||||
|
is_procedure: false,
|
||||||
|
action: {
|
||||||
|
op: "create_or_replace",
|
||||||
|
args: [],
|
||||||
|
return_type: "int",
|
||||||
|
language: "plpgsql",
|
||||||
|
body: "",
|
||||||
|
volatility: null,
|
||||||
|
strict: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<FunctionForm
|
||||||
|
kind="function"
|
||||||
|
params={params}
|
||||||
|
schemas={["public", "utils"]}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("Schema");
|
||||||
|
expect(select).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("option", { name: "utils" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(select, { target: { value: "utils" } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ schema: "utils" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("procedure: hides return type", () => {
|
||||||
|
const params: DdlParams = {
|
||||||
|
schema: "public",
|
||||||
|
name: "p",
|
||||||
|
is_procedure: true,
|
||||||
|
action: {
|
||||||
|
op: "create_or_replace",
|
||||||
|
args: [],
|
||||||
|
return_type: null,
|
||||||
|
language: "plpgsql",
|
||||||
|
body: "",
|
||||||
|
volatility: null,
|
||||||
|
strict: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<FunctionForm kind="procedure" params={params} onChange={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByPlaceholderText("Return type")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drop op: renders arg_types list", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const params: DdlParams = {
|
||||||
|
schema: "public",
|
||||||
|
name: "add",
|
||||||
|
is_procedure: false,
|
||||||
|
action: { op: "drop", arg_types: ["int"] },
|
||||||
|
};
|
||||||
|
render(<FunctionForm kind="function" params={params} onChange={onChange} />);
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Arg types (comma-separated)"), {
|
||||||
|
target: { value: "int, int" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ arg_types: ["int", "int"] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
import { FormRow, FormSectionHeader, inputClass, controlClass, monoInputClass } from "./formRow";
|
||||||
|
|
||||||
|
const SqlEditorField = lazy(() =>
|
||||||
|
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
kind: "function" | "procedure";
|
||||||
|
params: DdlParams;
|
||||||
|
schemas?: string[];
|
||||||
|
onChange: (p: DdlParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Arg {
|
||||||
|
mode: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunctionOp = "create_or_replace" | "drop";
|
||||||
|
|
||||||
|
const MODES = ["in", "out", "inout", "variadic"];
|
||||||
|
const LANGS = ["plpgsql", "sql", "c"];
|
||||||
|
const VOL = ["", "IMMUTABLE", "STABLE", "VOLATILE"];
|
||||||
|
|
||||||
|
function getOp(params: DdlParams): FunctionOp {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
return action.op === "drop" ? "drop" : "create_or_replace";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchAction(
|
||||||
|
params: DdlParams,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): DdlParams {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
return { ...params, action: { ...action, ...patch } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchTopLevel(
|
||||||
|
params: DdlParams,
|
||||||
|
kind: "function" | "procedure",
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): DdlParams {
|
||||||
|
return {
|
||||||
|
...params,
|
||||||
|
...patch,
|
||||||
|
is_procedure: kind === "procedure" ? true : params.is_procedure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FunctionForm({ kind, params, schemas, onChange }: Props) {
|
||||||
|
const op = getOp(params);
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
const args = (action.args as Arg[]) ?? [];
|
||||||
|
|
||||||
|
const setAction = (patch: Record<string, unknown>) =>
|
||||||
|
onChange(patchAction(params, patch));
|
||||||
|
|
||||||
|
const setArg = (i: number, patch: Partial<Arg>) =>
|
||||||
|
setAction({
|
||||||
|
args: args.map((a, j) => (j === i ? { ...a, ...patch } : a)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeholder = kind === "procedure" ? "Procedure name" : "Function name";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormRow label="Schema">
|
||||||
|
{schemas && schemas.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={(params.schema as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchTopLevel(params, kind, { schema: e.target.value }))
|
||||||
|
}
|
||||||
|
aria-label="Schema"
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Schema</option>
|
||||||
|
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Schema"
|
||||||
|
value={(params.schema as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchTopLevel(params, kind, { schema: e.target.value }))
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={(params.name as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchTopLevel(params, kind, { name: e.target.value }))
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Operation">
|
||||||
|
<select
|
||||||
|
aria-label="Operation"
|
||||||
|
value={op}
|
||||||
|
onChange={(e) => onChange({ ...params, action: { op: e.target.value } })}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="create_or_replace">Create / replace</option>
|
||||||
|
<option value="drop">Drop by signature</option>
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
{op === "create_or_replace" && (
|
||||||
|
<>
|
||||||
|
<FormSectionHeader label="Arguments" count={args.length} />
|
||||||
|
{args.map((a, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b border-border px-4 py-2 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
value={a.mode}
|
||||||
|
onChange={(e) => setArg(i, { mode: e.target.value })}
|
||||||
|
aria-label={`Argument ${i + 1} mode`}
|
||||||
|
className="w-24 shrink-0 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none"
|
||||||
|
>
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-text-muted w-8 shrink-0 font-mono">
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="name"
|
||||||
|
value={a.name}
|
||||||
|
onChange={(e) => setArg(i, { name: e.target.value })}
|
||||||
|
className={`${monoInputClass} flex-1`}
|
||||||
|
/>
|
||||||
|
<span className="text-border">:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="type"
|
||||||
|
value={a.type}
|
||||||
|
onChange={(e) => setArg(i, { type: e.target.value })}
|
||||||
|
className={`${monoInputClass} flex-1`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAction({ args: args.filter((_, j) => j !== i) })}
|
||||||
|
className="text-text-muted px-2"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setAction({
|
||||||
|
args: [...args, { mode: "in", name: "", type: "" }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-xs text-accent hover:text-accent-hover"
|
||||||
|
>
|
||||||
|
+ Add argument
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kind === "function" && (
|
||||||
|
<FormRow label="Return type">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Return type"
|
||||||
|
value={(action.return_type as string | null) ?? ""}
|
||||||
|
onChange={(e) => setAction({ return_type: e.target.value })}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
<FormRow label="Language">
|
||||||
|
<select
|
||||||
|
aria-label="Language"
|
||||||
|
value={(action.language as string) ?? "plpgsql"}
|
||||||
|
onChange={(e) => setAction({ language: e.target.value })}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
{LANGS.map((l) => (
|
||||||
|
<option key={l} value={l}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Volatility">
|
||||||
|
<select
|
||||||
|
aria-label="Volatility"
|
||||||
|
value={(action.volatility as string) ?? ""}
|
||||||
|
onChange={(e) => setAction({ volatility: e.target.value || null })}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
{VOL.map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{v || "(default VOLATILE)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Strict">
|
||||||
|
<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={!!action.strict}
|
||||||
|
onChange={(e) => setAction({ strict: e.target.checked })}
|
||||||
|
aria-label="STRICT"
|
||||||
|
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<span>STRICT (RETURNS NULL ON NULL INPUT)</span>
|
||||||
|
</label>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Body" className="items-stretch" outline={false}>
|
||||||
|
<div className="min-w-0 flex-1 py-2" style={{ minHeight: 140 }}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<textarea
|
||||||
|
rows={6}
|
||||||
|
value={(action.body as string) ?? ""}
|
||||||
|
onChange={(e) => setAction({ body: e.target.value })}
|
||||||
|
className="w-full h-full bg-transparent px-3 font-mono text-xs text-text outline-none resize-none"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full font-mono">
|
||||||
|
<SqlEditorField
|
||||||
|
value={(action.body as string) ?? ""}
|
||||||
|
onChange={(v) => setAction({ body: v })}
|
||||||
|
height={140}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</FormRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{op === "drop" && (
|
||||||
|
<FormRow label="Arg types">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Arg types (comma-separated)"
|
||||||
|
value={(action.arg_types as string[] | undefined)?.join(", ") ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAction({
|
||||||
|
arg_types: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { IndexForm } from "./IndexForm";
|
||||||
|
import * as cmd from "../../../lib/commands";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/commands", () => ({ getSchemaGraph: vi.fn() }));
|
||||||
|
|
||||||
|
const graph = {
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
name: "users",
|
||||||
|
schema: "public",
|
||||||
|
table_type: "BASE TABLE",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
data_type: "int",
|
||||||
|
is_pk: true,
|
||||||
|
is_fk: false,
|
||||||
|
is_unique: false,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("IndexForm", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.mocked(cmd.getSchemaGraph).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads table columns and toggles a column into the index", async () => {
|
||||||
|
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<IndexForm
|
||||||
|
connectionId="c1"
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
name: "i",
|
||||||
|
action: { op: "create", unique: false, method: "btree", columns: [], predicate: null },
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("email")).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByText("email"));
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ action: expect.objectContaining({ columns: ["email"] }) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles unique", () => {
|
||||||
|
vi.mocked(cmd.getSchemaGraph).mockResolvedValue({
|
||||||
|
tables: [{ name: "users", schema: "public", table_type: "BASE TABLE", columns: [] }],
|
||||||
|
relationships: [],
|
||||||
|
} as any);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<IndexForm
|
||||||
|
connectionId="c1"
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
name: "i",
|
||||||
|
action: { op: "create", unique: false, method: "btree", columns: [], predicate: null },
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText("Unique"));
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ action: expect.objectContaining({ unique: true }) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
import * as cmd from "../../../lib/commands";
|
||||||
|
import type { SchemaGraph } from "../../../lib/types";
|
||||||
|
import { ColumnPicker } from "./ColumnPicker";
|
||||||
|
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
params: DdlParams;
|
||||||
|
schemas?: string[];
|
||||||
|
onChange: (p: DdlParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHODS = ["", "btree", "hash", "gist", "gin", "brin"];
|
||||||
|
|
||||||
|
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
return { ...params, action: { ...action, ...patch } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IndexForm({ connectionId, params, schemas, onChange }: Props) {
|
||||||
|
const p = params as Record<string, unknown>;
|
||||||
|
const action = (p.action ?? {}) as Record<string, unknown>;
|
||||||
|
const [cols, setCols] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cmd
|
||||||
|
.getSchemaGraph(connectionId, (p.schema as string) || undefined)
|
||||||
|
.then((g: SchemaGraph) => {
|
||||||
|
const t = g.tables.find(
|
||||||
|
(t) => t.name === (p.table as string) && t.schema === (p.schema as string),
|
||||||
|
);
|
||||||
|
setCols(t ? t.columns.map((c) => c.name) : []);
|
||||||
|
})
|
||||||
|
.catch(() => setCols([]));
|
||||||
|
}, [connectionId, p.schema, p.table]);
|
||||||
|
|
||||||
|
const selected: string[] = (action.columns as string[]) ?? [];
|
||||||
|
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormRow label="Schema">
|
||||||
|
{schemas && schemas.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={(p.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||||
|
aria-label="Schema"
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Schema</option>
|
||||||
|
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Schema"
|
||||||
|
value={(p.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Table">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Table"
|
||||||
|
value={(p.table as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, table: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Index name"
|
||||||
|
value={(p.name as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...p, name: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Unique">
|
||||||
|
<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={!!action.unique}
|
||||||
|
onChange={(e) => setAction({ unique: e.target.checked })}
|
||||||
|
aria-label="Unique"
|
||||||
|
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<span>Unique</span>
|
||||||
|
</label>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Method">
|
||||||
|
<select
|
||||||
|
aria-label="Method"
|
||||||
|
value={(action.method as string) ?? ""}
|
||||||
|
onChange={(e) => setAction({ method: e.target.value })}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
{METHODS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m || "(default btree)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Columns" className="items-stretch">
|
||||||
|
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||||
|
<ColumnPicker
|
||||||
|
cols={cols}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={(c) =>
|
||||||
|
setAction({
|
||||||
|
columns: selected.includes(c)
|
||||||
|
? selected.filter((x) => x !== c)
|
||||||
|
: [...selected, c],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Predicate">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="WHERE predicate (optional)"
|
||||||
|
value={(action.predicate as string) ?? ""}
|
||||||
|
onChange={(e) => setAction({ predicate: e.target.value || null })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { SequenceForm } from "./SequenceForm";
|
||||||
|
import { EnumForm } from "./EnumForm";
|
||||||
|
import { ExtensionForm } from "./ExtensionForm";
|
||||||
|
import { ViewForm } from "./ViewForm";
|
||||||
|
import { IndexForm } from "./IndexForm";
|
||||||
|
import { ConstraintForm } from "./ConstraintForm";
|
||||||
|
import { FunctionForm } from "./FunctionForm";
|
||||||
|
import { TriggerForm } from "./TriggerForm";
|
||||||
|
import { RoleForm } from "./RoleForm";
|
||||||
|
import type { ObjectKind, DdlParams } from "../../../lib/objectCrud";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
kind: ObjectKind;
|
||||||
|
params: DdlParams;
|
||||||
|
schemas?: string[];
|
||||||
|
onChange: (p: DdlParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders the matching CRUD form for a kind (shared by the context menu and the form tab). */
|
||||||
|
export function KindForm({ connectionId, kind, params, schemas, onChange }: Props) {
|
||||||
|
switch (kind) {
|
||||||
|
case "sequence":
|
||||||
|
return <SequenceForm params={params} schemas={schemas} onChange={onChange} />;
|
||||||
|
case "enum":
|
||||||
|
return <EnumForm params={params} schemas={schemas} onChange={onChange} />;
|
||||||
|
case "extension":
|
||||||
|
return (
|
||||||
|
<ExtensionForm
|
||||||
|
connectionId={connectionId}
|
||||||
|
params={params}
|
||||||
|
schemas={schemas}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "view":
|
||||||
|
return <ViewForm params={params} schemas={schemas} onChange={onChange} />;
|
||||||
|
case "index":
|
||||||
|
return (
|
||||||
|
<IndexForm
|
||||||
|
connectionId={connectionId}
|
||||||
|
params={params}
|
||||||
|
schemas={schemas}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "constraint":
|
||||||
|
return (
|
||||||
|
<ConstraintForm
|
||||||
|
connectionId={connectionId}
|
||||||
|
params={params}
|
||||||
|
schemas={schemas}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "function":
|
||||||
|
case "procedure":
|
||||||
|
return <FunctionForm kind={kind} params={params} schemas={schemas} onChange={onChange} />;
|
||||||
|
case "trigger":
|
||||||
|
return (
|
||||||
|
<TriggerForm
|
||||||
|
connectionId={connectionId}
|
||||||
|
params={params}
|
||||||
|
schemas={schemas}
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { ObjectContextMenu, DROP_TITLE } from "./ObjectContextMenu";
|
||||||
|
import * as objectCrud from "../../../lib/objectCrud";
|
||||||
|
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||||
|
|
||||||
|
describe("ObjectContextMenu", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
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 () => {
|
||||||
|
render(
|
||||||
|
<ObjectContextMenu
|
||||||
|
connectionId="c1"
|
||||||
|
objectType="sequence"
|
||||||
|
item={{ schema: "public", name: "s" }}
|
||||||
|
onRefresh={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
|
||||||
|
fireEvent.click(screen.getByText("Edit…"));
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(st.tabs).toHaveLength(1);
|
||||||
|
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||||
|
expect(st.tabs[0].form?.mode).toBe("edit");
|
||||||
|
expect(st.tabs[0].form?.kind).toBe("sequence");
|
||||||
|
expect(st.tabs[0].form?.params?.name).toBe("s");
|
||||||
|
expect(st.tabs[0].form?.params?.schema).toBe("public");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Create on a sequence opens an objectForm tab in create mode", async () => {
|
||||||
|
render(
|
||||||
|
<ObjectContextMenu
|
||||||
|
connectionId="c1"
|
||||||
|
objectType="sequence"
|
||||||
|
item={{ schema: "public", name: "s" }}
|
||||||
|
onRefresh={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
|
||||||
|
fireEvent.click(screen.getByText("Create…"));
|
||||||
|
const st = useDbViewerStore.getState();
|
||||||
|
expect(st.tabs).toHaveLength(1);
|
||||||
|
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||||
|
expect(st.tabs[0].form?.mode).toBe("create");
|
||||||
|
expect(st.tabs[0].form?.kind).toBe("sequence");
|
||||||
|
expect(st.tabs[0].form?.params?.name).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Drop fetches dependencies then stages the drop", async () => {
|
||||||
|
vi.spyOn(objectCrud, "buildObjectDdl").mockResolvedValue([
|
||||||
|
'DROP SEQUENCE "public"."s"',
|
||||||
|
]);
|
||||||
|
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
|
||||||
|
render(
|
||||||
|
<ObjectContextMenu
|
||||||
|
connectionId="c1"
|
||||||
|
objectType="sequence"
|
||||||
|
item={{ schema: "public", name: "s" }}
|
||||||
|
onRefresh={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
|
||||||
|
fireEvent.click(screen.getByText("Drop…"));
|
||||||
|
// DependencyDialog with no deps → Proceed is enabled
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("button", { name: /proceed/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /proceed/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(addChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "ddl",
|
||||||
|
sql: 'DROP SEQUENCE "public"."s"',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { DependencyDialog } from "../DependencyDialog";
|
||||||
|
import {
|
||||||
|
buildObjectDdl,
|
||||||
|
dropCrudParams,
|
||||||
|
initialCrudParams,
|
||||||
|
type CrudItem,
|
||||||
|
type ObjectKind,
|
||||||
|
} from "../../../lib/objectCrud";
|
||||||
|
import { getObjectDependencies } from "../../../lib/commands";
|
||||||
|
import type { DependencyInfo } from "../../../lib/types";
|
||||||
|
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
objectType: ObjectKind;
|
||||||
|
item: CrudItem;
|
||||||
|
onRefresh: () => void;
|
||||||
|
/** Controlled open state (e.g. driven by a row's right-click). */
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
/** Extra non-CRUD actions appended below the divider (Copy DDL, Dependencies…). */
|
||||||
|
extraItems?: { id: string; label: string; danger?: boolean; onClick: () => void }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DROP_TITLE: Record<ObjectKind, string> = {
|
||||||
|
sequence: "sequence",
|
||||||
|
enum: "type",
|
||||||
|
view: "view",
|
||||||
|
extension: "extension",
|
||||||
|
index: "index",
|
||||||
|
constraint: "constraint",
|
||||||
|
function: "function",
|
||||||
|
procedure: "procedure",
|
||||||
|
trigger: "trigger",
|
||||||
|
table: "table",
|
||||||
|
role: "role",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObjectContextMenu({
|
||||||
|
connectionId,
|
||||||
|
objectType,
|
||||||
|
item,
|
||||||
|
onRefresh,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
extraItems,
|
||||||
|
}: Props) {
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const [deps, setDeps] = useState<DependencyInfo[] | null>(null);
|
||||||
|
const addChange = useDbViewerStore((s) => s.addChange);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isOpen = open ?? internalOpen;
|
||||||
|
const setOpen = (v: boolean) => {
|
||||||
|
if (onOpenChange) onOpenChange(v);
|
||||||
|
else setInternalOpen(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track the current open state in a ref so the document-level outside-click
|
||||||
|
// handler (registered once) only closes a menu that is actually open. Without
|
||||||
|
// this, closed instances would fire onOpenChange(false) on every mousedown
|
||||||
|
// and clobber the shared open key in the controlled (row right-click) case.
|
||||||
|
const openRef = useRef(isOpen);
|
||||||
|
openRef.current = isOpen;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
openRef.current &&
|
||||||
|
menuRef.current &&
|
||||||
|
!menuRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", h);
|
||||||
|
return () => document.removeEventListener("mousedown", h);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const kind = objectType;
|
||||||
|
|
||||||
|
const openFormTab = (mode: "create" | "edit") => {
|
||||||
|
const title = `${mode === "create" ? "Create" : "Edit"} ${kind}`;
|
||||||
|
const description = `${mode === "create" ? "Create" : "Edit"} ${
|
||||||
|
item.name || kind
|
||||||
|
}`;
|
||||||
|
useDbViewerStore.getState().openFormTab({
|
||||||
|
kind,
|
||||||
|
schema:
|
||||||
|
mode === "create"
|
||||||
|
? useDbViewerStore.getState().currentSchema ?? item.schema ?? "public"
|
||||||
|
: item.schema,
|
||||||
|
name: mode === "edit" ? item.name : "",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
mode,
|
||||||
|
params: initialCrudParams(kind, item, mode),
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrop = async () => {
|
||||||
|
setOpen(false);
|
||||||
|
let d: DependencyInfo[] = [];
|
||||||
|
try {
|
||||||
|
d = await getObjectDependencies(connectionId, item.schema, kind, item.name);
|
||||||
|
} catch {
|
||||||
|
d = [];
|
||||||
|
}
|
||||||
|
setDeps(d);
|
||||||
|
};
|
||||||
|
const confirmDrop = async () => {
|
||||||
|
const sqls = await buildObjectDdl(
|
||||||
|
connectionId,
|
||||||
|
kind,
|
||||||
|
dropCrudParams(kind, item),
|
||||||
|
);
|
||||||
|
sqls.forEach((sql) =>
|
||||||
|
addChange({
|
||||||
|
type: "ddl",
|
||||||
|
sql,
|
||||||
|
description: `Drop ${DROP_TITLE[kind]} ${item.name}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setDeps(null);
|
||||||
|
onRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menuRef} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!isOpen)}
|
||||||
|
aria-label="actions"
|
||||||
|
className="text-text-muted hover:text-text cursor-pointer"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 top-6 z-20 w-40 rounded-lg border border-border bg-surface py-1 text-sm text-text shadow-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => openFormTab("create")}
|
||||||
|
className="block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer"
|
||||||
|
>
|
||||||
|
Create…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openFormTab("edit")}
|
||||||
|
className="block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer"
|
||||||
|
>
|
||||||
|
Edit…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={startDrop}
|
||||||
|
className="block w-full text-left px-3 py-1.5 text-red-400 hover:bg-border/30 cursor-pointer"
|
||||||
|
>
|
||||||
|
Drop…
|
||||||
|
</button>
|
||||||
|
{extraItems && extraItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
{extraItems.map((it) => (
|
||||||
|
<button
|
||||||
|
key={it.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
it.onClick();
|
||||||
|
}}
|
||||||
|
className={`block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer ${
|
||||||
|
it.danger ? "text-red-400" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DependencyDialog
|
||||||
|
open={!!deps}
|
||||||
|
deps={deps ?? []}
|
||||||
|
onProceed={confirmDrop}
|
||||||
|
onCancel={() => setDeps(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
ObjectDetail,
|
||||||
|
OBJECT_ICONS,
|
||||||
|
SINGULAR_LABELS,
|
||||||
|
TYPE_LABELS,
|
||||||
|
} from "./ObjectDetail";
|
||||||
|
import * as commands from "../../../lib/commands";
|
||||||
|
|
||||||
|
describe("ObjectDetail", () => {
|
||||||
|
it("renders an enum's labels as list items", () => {
|
||||||
|
render(
|
||||||
|
<ObjectDetail
|
||||||
|
connectionId="c1"
|
||||||
|
type="enums"
|
||||||
|
item={{ name: "role", schema: "public", labels: ["admin", "user"] }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("admin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("user")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a function's return type and language", () => {
|
||||||
|
render(
|
||||||
|
<ObjectDetail
|
||||||
|
connectionId="c1"
|
||||||
|
type="functions"
|
||||||
|
item={{
|
||||||
|
name: "add",
|
||||||
|
schema: "public",
|
||||||
|
return_type: "int",
|
||||||
|
argument_types: ["integer", "text"],
|
||||||
|
argument_names: ["a", "b"],
|
||||||
|
argument_modes: ["IN", "IN"],
|
||||||
|
language: "plpgsql",
|
||||||
|
source: "BEGIN RETURN a+b; END",
|
||||||
|
kind: "f",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("int")).toBeInTheDocument();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,983 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
BookMarked,
|
||||||
|
FunctionSquare,
|
||||||
|
GitBranch,
|
||||||
|
ListChecks,
|
||||||
|
ListOrdered,
|
||||||
|
Puzzle,
|
||||||
|
ShieldCheck,
|
||||||
|
SquareFunction,
|
||||||
|
Tag,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type {
|
||||||
|
ObjectType,
|
||||||
|
FunctionInfo,
|
||||||
|
TriggerInfo,
|
||||||
|
SequenceInfo,
|
||||||
|
EnumInfo,
|
||||||
|
ExtensionInfo,
|
||||||
|
IndexInfo,
|
||||||
|
ConstraintInfo,
|
||||||
|
RoleInfo,
|
||||||
|
} from "../../../lib/types";
|
||||||
|
import { RoleDetail } from "./RoleDetail";
|
||||||
|
|
||||||
|
export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||||
|
functions: "Functions",
|
||||||
|
triggers: "Triggers",
|
||||||
|
sequences: "Sequences",
|
||||||
|
enums: "Enums",
|
||||||
|
extensions: "Extensions",
|
||||||
|
indexes: "Indexes",
|
||||||
|
constraints: "Constraints",
|
||||||
|
procedures: "Procedures",
|
||||||
|
roles: "Roles",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||||
|
functions: "function",
|
||||||
|
triggers: "trigger",
|
||||||
|
sequences: "sequence",
|
||||||
|
enums: "enum",
|
||||||
|
extensions: "extension",
|
||||||
|
indexes: "index",
|
||||||
|
constraints: "constraint",
|
||||||
|
procedures: "procedure",
|
||||||
|
roles: "role",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
|
||||||
|
functions: (
|
||||||
|
<FunctionSquare size={14} className="text-text-muted shrink-0" />
|
||||||
|
),
|
||||||
|
triggers: <GitBranch size={14} className="text-text-muted shrink-0" />,
|
||||||
|
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
|
||||||
|
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||||
|
extensions: <Puzzle 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" />,
|
||||||
|
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
|
||||||
|
roles: <ShieldCheck size={14} className="text-text-muted shrink-0" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AnyObject =
|
||||||
|
| FunctionInfo
|
||||||
|
| TriggerInfo
|
||||||
|
| SequenceInfo
|
||||||
|
| EnumInfo
|
||||||
|
| ExtensionInfo
|
||||||
|
| IndexInfo
|
||||||
|
| ConstraintInfo
|
||||||
|
| RoleInfo;
|
||||||
|
|
||||||
|
// ─── syntax highlighting for PL/pgSQL / SQL ──────────────
|
||||||
|
|
||||||
|
const SQL_KEYWORDS = new Set([
|
||||||
|
"ADD",
|
||||||
|
"ALL",
|
||||||
|
"ALTER",
|
||||||
|
"AND",
|
||||||
|
"ANY",
|
||||||
|
"AS",
|
||||||
|
"ASC",
|
||||||
|
"BEGIN",
|
||||||
|
"BETWEEN",
|
||||||
|
"BY",
|
||||||
|
"CALL",
|
||||||
|
"CASCADE",
|
||||||
|
"CASE",
|
||||||
|
"CAST",
|
||||||
|
"CHECK",
|
||||||
|
"CLOSE",
|
||||||
|
"COLLATE",
|
||||||
|
"COLUMN",
|
||||||
|
"COMMIT",
|
||||||
|
"CONSTRAINT",
|
||||||
|
"CONTINUE",
|
||||||
|
"CREATE",
|
||||||
|
"CROSS",
|
||||||
|
"CURRENT",
|
||||||
|
"CURSOR",
|
||||||
|
"DECLARE",
|
||||||
|
"DEFAULT",
|
||||||
|
"DELETE",
|
||||||
|
"DESC",
|
||||||
|
"DISTINCT",
|
||||||
|
"DO",
|
||||||
|
"DROP",
|
||||||
|
"ELSE",
|
||||||
|
"ELSIF",
|
||||||
|
"END",
|
||||||
|
"EXCEPTION",
|
||||||
|
"EXECUTE",
|
||||||
|
"EXISTS",
|
||||||
|
"EXIT",
|
||||||
|
"FETCH",
|
||||||
|
"FOR",
|
||||||
|
"FOREIGN",
|
||||||
|
"FROM",
|
||||||
|
"FULL",
|
||||||
|
"FUNCTION",
|
||||||
|
"GRANT",
|
||||||
|
"GROUP",
|
||||||
|
"HAVING",
|
||||||
|
"IF",
|
||||||
|
"IN",
|
||||||
|
"INDEX",
|
||||||
|
"INNER",
|
||||||
|
"INSERT",
|
||||||
|
"INTO",
|
||||||
|
"IS",
|
||||||
|
"JOIN",
|
||||||
|
"KEY",
|
||||||
|
"LANGUAGE",
|
||||||
|
"LEFT",
|
||||||
|
"LIMIT",
|
||||||
|
"LOOP",
|
||||||
|
"NOT",
|
||||||
|
"NULL",
|
||||||
|
"OF",
|
||||||
|
"OFFSET",
|
||||||
|
"ON",
|
||||||
|
"OPEN",
|
||||||
|
"OR",
|
||||||
|
"ORDER",
|
||||||
|
"OUTER",
|
||||||
|
"OVER",
|
||||||
|
"PERFORM",
|
||||||
|
"PLPGSQL",
|
||||||
|
"PRIMARY",
|
||||||
|
"PROCEDURE",
|
||||||
|
"QUERY",
|
||||||
|
"RAISE",
|
||||||
|
"REFERENCES",
|
||||||
|
"REPLACE",
|
||||||
|
"RETURN",
|
||||||
|
"RETURNS",
|
||||||
|
"REVOKE",
|
||||||
|
"RIGHT",
|
||||||
|
"ROLLBACK",
|
||||||
|
"ROW",
|
||||||
|
"ROWS",
|
||||||
|
"SCHEMA",
|
||||||
|
"SELECT",
|
||||||
|
"SET",
|
||||||
|
"STRICT",
|
||||||
|
"TABLE",
|
||||||
|
"THEN",
|
||||||
|
"TO",
|
||||||
|
"TRIGGER",
|
||||||
|
"UNION",
|
||||||
|
"UPDATE",
|
||||||
|
"USING",
|
||||||
|
"VALUES",
|
||||||
|
"VIEW",
|
||||||
|
"WHEN",
|
||||||
|
"WHERE",
|
||||||
|
"WHILE",
|
||||||
|
"WITH",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SQL_TYPES = new Set([
|
||||||
|
"BIGINT",
|
||||||
|
"BIGSERIAL",
|
||||||
|
"BIT",
|
||||||
|
"BOOL",
|
||||||
|
"BOOLEAN",
|
||||||
|
"BPCHAR",
|
||||||
|
"BYTEA",
|
||||||
|
"CHAR",
|
||||||
|
"CHARACTER",
|
||||||
|
"DATE",
|
||||||
|
"DECIMAL",
|
||||||
|
"DOUBLE",
|
||||||
|
"FLOAT",
|
||||||
|
"FLOAT4",
|
||||||
|
"FLOAT8",
|
||||||
|
"INT",
|
||||||
|
"INT2",
|
||||||
|
"INT4",
|
||||||
|
"INT8",
|
||||||
|
"INTEGER",
|
||||||
|
"INTERVAL",
|
||||||
|
"JSON",
|
||||||
|
"JSONB",
|
||||||
|
"MONEY",
|
||||||
|
"NAME",
|
||||||
|
"NUMERIC",
|
||||||
|
"OID",
|
||||||
|
"REAL",
|
||||||
|
"SERIAL",
|
||||||
|
"SMALLINT",
|
||||||
|
"TEXT",
|
||||||
|
"TIME",
|
||||||
|
"TIMESTAMP",
|
||||||
|
"TIMESTAMPTZ",
|
||||||
|
"UUID",
|
||||||
|
"VARBIT",
|
||||||
|
"VARCHAR",
|
||||||
|
"VOID",
|
||||||
|
"XML",
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
text: string;
|
||||||
|
kind:
|
||||||
|
| "keyword"
|
||||||
|
| "type"
|
||||||
|
| "string"
|
||||||
|
| "comment"
|
||||||
|
| "number"
|
||||||
|
| "operator"
|
||||||
|
| "plain";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeLine(line: string): Token[] {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < line.length) {
|
||||||
|
if (/\s/.test(line[i])) {
|
||||||
|
let ws = "";
|
||||||
|
while (i < line.length && /\s/.test(line[i])) {
|
||||||
|
ws += line[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
tokens.push({ text: ws, kind: "plain" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line[i] === "-" && line[i + 1] === "-") {
|
||||||
|
tokens.push({ text: line.slice(i), kind: "comment" });
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
if (line[i] === "/" && line[i + 1] === "*") {
|
||||||
|
const end = line.indexOf("*/", i + 2);
|
||||||
|
if (end !== -1) {
|
||||||
|
tokens.push({ text: line.slice(i, end + 2), kind: "comment" });
|
||||||
|
i = end + 2;
|
||||||
|
} else {
|
||||||
|
tokens.push({ text: line.slice(i), kind: "comment" });
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line[i] === "$") {
|
||||||
|
let dollar = "";
|
||||||
|
const start = i;
|
||||||
|
while (i < line.length && line[i] === "$") {
|
||||||
|
dollar += "$";
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
let tag = "";
|
||||||
|
if (dollar.length === 1 && i < line.length && line[i] !== "$") {
|
||||||
|
while (i < line.length && line[i] !== "$") {
|
||||||
|
tag += line[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (line[i] === "$") {
|
||||||
|
i++;
|
||||||
|
dollar = `$${tag}$`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const endTag = dollar;
|
||||||
|
const endIdx = line.indexOf(endTag, i);
|
||||||
|
if (endIdx !== -1) {
|
||||||
|
tokens.push({
|
||||||
|
text: line.slice(start, endIdx + endTag.length),
|
||||||
|
kind: "string",
|
||||||
|
});
|
||||||
|
i = endIdx + endTag.length;
|
||||||
|
} else {
|
||||||
|
tokens.push({ text: line.slice(start), kind: "string" });
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line[i] === "'") {
|
||||||
|
let str = "'";
|
||||||
|
i++;
|
||||||
|
while (i < line.length) {
|
||||||
|
if (line[i] === "'" && line[i + 1] === "'") {
|
||||||
|
str += "''";
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line[i] === "'") {
|
||||||
|
str += "'";
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
str += line[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
tokens.push({ text: str, kind: "string" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/[0-9]/.test(line[i])) {
|
||||||
|
let num = "";
|
||||||
|
while (i < line.length && /[0-9.]/.test(line[i])) {
|
||||||
|
num += line[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
tokens.push({ text: num, kind: "number" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/[=<>!+\-*/%&|^~@#;,.[\](){}]/.test(line[i])) {
|
||||||
|
let op = line[i];
|
||||||
|
i++;
|
||||||
|
if (i < line.length) {
|
||||||
|
const pair = op + line[i];
|
||||||
|
if ([":=", "=>", "<=", ">=", "<>", "||", "::"].includes(pair)) {
|
||||||
|
op = pair;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.push({ text: op, kind: "operator" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let word = "";
|
||||||
|
while (i < line.length && /[a-zA-Z_]/.test(line[i])) {
|
||||||
|
word += line[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (word) {
|
||||||
|
const upper = word.toUpperCase();
|
||||||
|
if (SQL_KEYWORDS.has(upper)) {
|
||||||
|
tokens.push({ text: word, kind: "keyword" });
|
||||||
|
} else if (SQL_TYPES.has(upper)) {
|
||||||
|
tokens.push({ text: word, kind: "type" });
|
||||||
|
} else {
|
||||||
|
tokens.push({ text: word, kind: "plain" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Catch-all for any character not matched above (non-ASCII, symbols, etc.)
|
||||||
|
tokens.push({ text: line[i], kind: "plain" });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyntaxCode({
|
||||||
|
source,
|
||||||
|
language: _language,
|
||||||
|
}: {
|
||||||
|
source: string;
|
||||||
|
language?: string;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const maxLines = 60;
|
||||||
|
|
||||||
|
// Memoize the tokenized output — source doesn't change while viewing
|
||||||
|
const { displayLines, maxLineNum, truncated, totalLines } = useMemo(() => {
|
||||||
|
const lines: string[] = source.split("\n");
|
||||||
|
const total: number = lines.length;
|
||||||
|
const isTruncated: boolean = !expanded && total > maxLines;
|
||||||
|
const display: string[] = isTruncated
|
||||||
|
? lines.slice(0, maxLines)
|
||||||
|
: lines;
|
||||||
|
const maxNum: number = String(display.length).length;
|
||||||
|
const tokenized = display.map((line: string) => ({
|
||||||
|
tokens: tokenizeLine(line),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
displayLines: tokenized,
|
||||||
|
maxLineNum: maxNum,
|
||||||
|
truncated: isTruncated,
|
||||||
|
totalLines: total,
|
||||||
|
};
|
||||||
|
}, [source, expanded]);
|
||||||
|
|
||||||
|
const TOKEN_COLORS: Record<string, string> = {
|
||||||
|
keyword: "text-blue-400",
|
||||||
|
type: "text-emerald-400",
|
||||||
|
string: "text-amber-300",
|
||||||
|
comment: "text-text-subtle italic",
|
||||||
|
number: "text-purple-400",
|
||||||
|
operator: "text-text-muted",
|
||||||
|
plain: "text-text",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="overflow-x-auto overscroll-x-none">
|
||||||
|
<pre className="text-xs leading-6 font-mono whitespace-pre w-max min-w-full">
|
||||||
|
{displayLines.map(
|
||||||
|
(entry: { tokens: Token[] }, i: number) => {
|
||||||
|
const { tokens } = entry;
|
||||||
|
const num = String(i + 1).padStart(maxLineNum, " ");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex hover:bg-surface/30"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block text-right select-none text-text-subtle border-r border-border pr-3 mx-3 shrink-0"
|
||||||
|
style={{
|
||||||
|
minWidth: `${maxLineNum + 2}ch`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 whitespace-pre">
|
||||||
|
{tokens.length === 1 &&
|
||||||
|
tokens[0].text.trim() === ""
|
||||||
|
? "\u00A0"
|
||||||
|
: tokens.map((t, j) => (
|
||||||
|
<span
|
||||||
|
key={j}
|
||||||
|
className={
|
||||||
|
TOKEN_COLORS[t.kind]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
{truncated && (
|
||||||
|
<div className="flex items-center justify-center py-1.5 border-t border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
className="text-xs text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Show all {totalLines} lines…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{expanded && totalLines > maxLines && (
|
||||||
|
<div className="flex items-center justify-center py-1.5 border-t border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
className="text-xs text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Collapse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFunctionDetail(f: FunctionInfo) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Signature
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Returns
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{f.return_type || "void"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Language
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text">
|
||||||
|
{f.language}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Kind
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text">
|
||||||
|
{f.kind === "f" ? "Function" : "Procedure"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Schema
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{f.schema}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{f.argument_names.length > 0 && (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
Arguments
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
{f.argument_names.length} total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{f.argument_names.map((name, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b border-border px-4 py-2 flex items-center"
|
||||||
|
>
|
||||||
|
<div className="w-24 shrink-0">
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
{f.argument_modes?.[i] &&
|
||||||
|
f.argument_modes[i] !==
|
||||||
|
"IN" && (
|
||||||
|
<span className="text-amber-400 font-medium mr-1">
|
||||||
|
{f.argument_modes[i]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span className="mx-2 text-border">:</span>
|
||||||
|
<span className="text-sm text-text-muted font-mono">
|
||||||
|
{f.argument_types?.[i] || "unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{f.source && (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
Source
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
{f.language}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SyntaxCode
|
||||||
|
source={f.source}
|
||||||
|
language={f.language}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(connectionId: string, type: ObjectType, item: AnyObject) {
|
||||||
|
switch (type) {
|
||||||
|
case "roles":
|
||||||
|
return <RoleDetail connectionId={connectionId} item={item as RoleInfo} />;
|
||||||
|
case "functions":
|
||||||
|
return renderFunctionDetail(item as FunctionInfo);
|
||||||
|
case "procedures":
|
||||||
|
return renderFunctionDetail(item as FunctionInfo);
|
||||||
|
case "indexes": {
|
||||||
|
const idx = item as IndexInfo;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Index
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Table
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{idx.table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Method
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{idx.method}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Unique
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${idx.is_unique ? "text-emerald-400" : "text-text-muted"}`}
|
||||||
|
>
|
||||||
|
{idx.is_unique ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Size
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{idx.size_bytes ?? "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{idx.columns.length > 0 && (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
Columns
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
{idx.columns.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{idx.columns.map((col, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b border-border px-4 py-2 flex items-center"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{idx.definition && (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
Definition
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
SQL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SyntaxCode source={idx.definition} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "constraints": {
|
||||||
|
const c = item as ConstraintInfo;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Constraint
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Type
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{c.contype}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Table
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{c.table}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Deferrable
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${c.deferrable ? "text-amber-400" : "text-text-muted"}`}
|
||||||
|
>
|
||||||
|
{c.deferrable ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Validated
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${c.validated ? "text-emerald-400" : "text-text-muted"}`}
|
||||||
|
>
|
||||||
|
{c.validated ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{c.columns.length > 0 && (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
Columns
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
{c.columns.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{c.columns.map((col, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b border-border px-4 py-2 flex items-center"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{col}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{c.definition && (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
Definition
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
SQL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SyntaxCode source={c.definition} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "triggers": {
|
||||||
|
const t = item as TriggerInfo;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Details
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border flex-2 px-4 py-2 flex items-center">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Table
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{t.table_schema}.{t.table_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-2 px-4 py-2 flex items-center">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Event
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text">
|
||||||
|
{t.event_manipulation}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border flex-2 px-4 py-2 flex items-center">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Timing
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text">
|
||||||
|
{t.action_timing} {t.action_orientation}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-2 px-4 py-2 flex items-center">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Status
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${t.enabled === "O" ? "text-emerald-400" : "text-red-400"}`}
|
||||||
|
>
|
||||||
|
{t.enabled === "O"
|
||||||
|
? "Enabled"
|
||||||
|
: t.enabled === "D"
|
||||||
|
? "Disabled"
|
||||||
|
: t.enabled}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border px-4 py-2 flex items-center">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Schema
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{t.schema}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{t.action_statement && (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
Definition
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
SQL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SyntaxCode source={t.action_statement} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "sequences": {
|
||||||
|
const s = item as SequenceInfo;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Sequence Values
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-28 shrink-0">
|
||||||
|
Current Value
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{s.current_value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-2 px-4 py-2 flex items-center">
|
||||||
|
<span className="text-xs text-text-muted w-28 shrink-0">
|
||||||
|
Increment
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{s.increment}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-28 shrink-0">
|
||||||
|
Start
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{s.start_value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-2 px-4 py-2 flex items-center">
|
||||||
|
<span className="text-xs text-text-muted w-28 shrink-0">
|
||||||
|
Min / Max
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{s.min_value} / {s.max_value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
Cycle
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${s.cycle ? "text-amber-400" : "text-text-muted"}`}
|
||||||
|
>
|
||||||
|
{s.cycle ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "enums": {
|
||||||
|
const e = item as EnumInfo;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Details
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border px-4 py-2 flex items-center">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Schema
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{e.schema}
|
||||||
|
</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">
|
||||||
|
Values
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-text-subtle">
|
||||||
|
{e.labels.length} labels
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{e.labels.map((label, i) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="border-b border-border px-4 py-2 flex items-center"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||||
|
#{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-accent font-mono">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "extensions": {
|
||||||
|
const e = item as ExtensionInfo;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
Extension
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-border flex flex-row">
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Version
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{e.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-2 px-4 py-2 flex items-center">
|
||||||
|
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||||
|
Schema
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-text font-mono">
|
||||||
|
{e.schema}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{e.comment && (
|
||||||
|
<div className="border-b border-border px-4 py-2.5">
|
||||||
|
<span className="text-xs text-text-muted block mb-1">
|
||||||
|
Comment
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-text leading-relaxed">
|
||||||
|
{e.comment}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObjectDetailProps {
|
||||||
|
connectionId: string;
|
||||||
|
type: ObjectType;
|
||||||
|
item: AnyObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObjectDetail({ connectionId, type, item }: ObjectDetailProps) {
|
||||||
|
return <>{renderDetail(connectionId, type, item)}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { ObjectFormTab } from "./ObjectFormTab";
|
||||||
|
import * as objectCrud from "../../../lib/objectCrud";
|
||||||
|
import {
|
||||||
|
useDbViewerStore,
|
||||||
|
type ViewerTab,
|
||||||
|
} from "../../../stores/dbViewerStore";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/objectCrud", () => ({
|
||||||
|
buildObjectDdl: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../editor/SqlEditorField", () => ({
|
||||||
|
SqlEditorField: ({ value, readOnly }: { value: string; readOnly?: boolean }) => (
|
||||||
|
<textarea data-testid="sql-editor" readOnly={readOnly} value={value} onChange={() => {}} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ObjectFormTab", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useDbViewerStore.getState().reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionId = "c1";
|
||||||
|
const baseTab: ViewerTab = {
|
||||||
|
id: "form-tab-1",
|
||||||
|
schema: "public",
|
||||||
|
table: "Create sequence",
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
data: null,
|
||||||
|
filterRules: [],
|
||||||
|
sortRules: [],
|
||||||
|
hiddenColumns: [],
|
||||||
|
smartSortApplied: false,
|
||||||
|
tabType: "objectForm",
|
||||||
|
objectType: null,
|
||||||
|
form: {
|
||||||
|
kind: "sequence",
|
||||||
|
title: "Create sequence",
|
||||||
|
description: "Create my_seq",
|
||||||
|
mode: "create",
|
||||||
|
params: { schema: "public", name: "my_seq", action: { op: "create" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders the form for a sequence kind in Visual view", () => {
|
||||||
|
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
|
||||||
|
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
|
||||||
|
]);
|
||||||
|
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
|
||||||
|
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles to SQL and shows the generated SQL, then back to Visual", async () => {
|
||||||
|
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
|
||||||
|
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
|
||||||
|
]);
|
||||||
|
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "SQL" }));
|
||||||
|
const ed = await screen.findByTestId("sql-editor");
|
||||||
|
expect(ed).toHaveValue('CREATE SEQUENCE "public"."my_seq" START WITH 1;');
|
||||||
|
expect(ed).toHaveProperty("readOnly", true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Visual" }));
|
||||||
|
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stages one ddl change and closes the tab", async () => {
|
||||||
|
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
|
||||||
|
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
|
||||||
|
]);
|
||||||
|
useDbViewerStore.setState({
|
||||||
|
tabs: [baseTab],
|
||||||
|
activeTabId: baseTab.id,
|
||||||
|
});
|
||||||
|
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
|
||||||
|
|
||||||
|
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /stage/i }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(addChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "ddl",
|
||||||
|
sql: 'CREATE SEQUENCE "public"."my_seq" START WITH 1;',
|
||||||
|
description: "Create my_seq",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stages multiple statements with (n/total) descriptions", async () => {
|
||||||
|
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
|
||||||
|
"DROP SEQUENCE \"public\".\"my_seq\";",
|
||||||
|
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
|
||||||
|
]);
|
||||||
|
useDbViewerStore.setState({
|
||||||
|
tabs: [baseTab],
|
||||||
|
activeTabId: baseTab.id,
|
||||||
|
});
|
||||||
|
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
|
||||||
|
|
||||||
|
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /stage/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addChange).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.objectContaining({ description: "Create my_seq (1/2)" }),
|
||||||
|
);
|
||||||
|
expect(addChange).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expect.objectContaining({ description: "Create my_seq (2/2)" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a builder error and disables Stage", async () => {
|
||||||
|
vi.mocked(objectCrud.buildObjectDdl).mockRejectedValueOnce(
|
||||||
|
"Name is required",
|
||||||
|
);
|
||||||
|
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/Name is required/)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: /stage/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders only the uppercase kicker, not a duplicate title heading", () => {
|
||||||
|
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([]);
|
||||||
|
render(
|
||||||
|
<ObjectFormTab
|
||||||
|
connectionId={connectionId}
|
||||||
|
tab={{
|
||||||
|
...baseTab,
|
||||||
|
table: "Create Function",
|
||||||
|
form: {
|
||||||
|
...baseTab.form!,
|
||||||
|
kind: "function",
|
||||||
|
title: "Create Function",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Kicker: lowercase DOM text uppercased by CSS
|
||||||
|
expect(screen.getByText("create function")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Create Function")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
|
import { KindForm } from "./KindForm";
|
||||||
|
import { TableForm } from "./TableForm";
|
||||||
|
import {
|
||||||
|
buildObjectDdl,
|
||||||
|
type DdlParams,
|
||||||
|
} from "../../../lib/objectCrud";
|
||||||
|
import { useDbViewerStore, type ViewerTab } from "../../../stores/dbViewerStore";
|
||||||
|
|
||||||
|
const SqlEditorField = lazy(() =>
|
||||||
|
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
tab: ViewerTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObjectFormTab({ connectionId, tab }: Props) {
|
||||||
|
const form = tab.form;
|
||||||
|
if (!form) return null;
|
||||||
|
|
||||||
|
if (form.kind === "table") {
|
||||||
|
return <TableForm connectionId={connectionId} tab={tab} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { kind, params, description, mode } = form;
|
||||||
|
|
||||||
|
const [view, setView] = useState<"visual" | "sql">("visual");
|
||||||
|
const [preview, setPreview] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const open = true;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let active = true;
|
||||||
|
buildObjectDdl(connectionId, kind, params)
|
||||||
|
.then((sqls) => {
|
||||||
|
if (active) {
|
||||||
|
setPreview(sqls.join("\n;\n"));
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (active) {
|
||||||
|
setPreview("");
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [open, connectionId, kind, params]);
|
||||||
|
|
||||||
|
const stage = async () => {
|
||||||
|
const sqls = await buildObjectDdl(connectionId, kind, params);
|
||||||
|
sqls.forEach((sql, i) =>
|
||||||
|
useDbViewerStore.getState().addChange({
|
||||||
|
type: "ddl",
|
||||||
|
sql,
|
||||||
|
description:
|
||||||
|
sqls.length > 1
|
||||||
|
? `${description} (${i + 1}/${sqls.length})`
|
||||||
|
: description,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
useDbViewerStore.getState().closeTab(tab.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const schemas = useDbViewerStore((s) => s.schemas);
|
||||||
|
|
||||||
|
const handleChange = (next: DdlParams) => {
|
||||||
|
useDbViewerStore.getState().updateFormTabParams(tab.id, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
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} {kind}
|
||||||
|
</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}
|
||||||
|
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">
|
||||||
|
{view === "visual" ? (
|
||||||
|
<KindForm
|
||||||
|
connectionId={connectionId}
|
||||||
|
kind={kind}
|
||||||
|
params={params}
|
||||||
|
schemas={schemas}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<pre className="text-xs leading-6 font-mono whitespace-pre-wrap text-text">
|
||||||
|
{preview}
|
||||||
|
</pre>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SqlEditorField
|
||||||
|
value={preview}
|
||||||
|
onChange={() => {}}
|
||||||
|
readOnly
|
||||||
|
height={420}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="border-t border-border px-4 py-2">
|
||||||
|
<p className="text-xs text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { SequenceForm } from "./SequenceForm";
|
||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
|
||||||
|
function StatefulSequenceForm({ initialParams }: { initialParams: DdlParams }) {
|
||||||
|
const [params, setParams] = useState(initialParams);
|
||||||
|
return <SequenceForm params={params} onChange={setParams} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SequenceForm", () => {
|
||||||
|
it("renders create fields and emits params on change", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<SequenceForm
|
||||||
|
params={{
|
||||||
|
schema: "public",
|
||||||
|
name: "s",
|
||||||
|
action: {
|
||||||
|
op: "create",
|
||||||
|
increment: "1",
|
||||||
|
min_value: "1",
|
||||||
|
max_value: "9",
|
||||||
|
start: "1",
|
||||||
|
cycle: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText("Sequence name")).toHaveValue("s");
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Sequence name"), {
|
||||||
|
target: { value: "s2" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ name: "s2" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a schema dropdown when schemas are provided", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<SequenceForm
|
||||||
|
params={{ schema: "public", name: "s", action: { op: "create" } }}
|
||||||
|
schemas={["public", "utils"]}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText("Schema");
|
||||||
|
expect(select).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("option", { name: "public" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("option", { name: "utils" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(select, { target: { value: "utils" } });
|
||||||
|
expect(onChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ schema: "utils" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the editing outline wraps only the value area, not the label cell", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<SequenceForm
|
||||||
|
params={{ schema: "public", name: "s", action: { op: "create" } }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The children wrapper (direct parent of the input) carries the amber
|
||||||
|
// focus-within editing outline, exactly like a grid editing cell.
|
||||||
|
const input = screen.getByPlaceholderText("Sequence name");
|
||||||
|
const valueArea = input.parentElement;
|
||||||
|
expect(valueArea).not.toBeNull();
|
||||||
|
expect(valueArea!.className).toContain("focus-within:outline");
|
||||||
|
expect(valueArea!.className).toContain("focus-within:outline-amber-400/20");
|
||||||
|
expect(valueArea!.className).toContain("focus-within:outline-offset-[-2px]");
|
||||||
|
|
||||||
|
// The label cell must stay clean: no ancestor of the label may carry
|
||||||
|
// the editing outline (regression: the old row-level outline lit up the
|
||||||
|
// whole row, label cell included).
|
||||||
|
const label = screen.getByText("Name");
|
||||||
|
expect(label.closest('[class*="focus-within:outline"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switching to restart shows only the with-field", () => {
|
||||||
|
render(
|
||||||
|
<StatefulSequenceForm
|
||||||
|
initialParams={{
|
||||||
|
schema: "public",
|
||||||
|
name: "s",
|
||||||
|
action: {
|
||||||
|
op: "create",
|
||||||
|
increment: "1",
|
||||||
|
min_value: "1",
|
||||||
|
max_value: "9",
|
||||||
|
start: "1",
|
||||||
|
cycle: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const opSelect = screen.getByLabelText("Operation");
|
||||||
|
fireEvent.change(opSelect, { target: { value: "restart" } });
|
||||||
|
expect(screen.getByPlaceholderText("Restart with")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByPlaceholderText("Increment")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByPlaceholderText("Start")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("CYCLE")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: DdlParams;
|
||||||
|
schemas?: string[];
|
||||||
|
onChange: (p: DdlParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SequenceOp = "create" | "alter" | "restart";
|
||||||
|
|
||||||
|
const OP_LABELS: Record<SequenceOp, string> = {
|
||||||
|
create: "Create",
|
||||||
|
alter: "Alter",
|
||||||
|
restart: "Restart",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getOp(params: DdlParams): SequenceOp {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
const op = action.op;
|
||||||
|
if (op === "alter" || op === "restart") return op;
|
||||||
|
return "create";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchAction(
|
||||||
|
params: DdlParams,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): DdlParams {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
return { ...params, action: { ...action, ...patch } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SequenceForm({ params, schemas, onChange }: Props) {
|
||||||
|
const op = getOp(params);
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormRow label="Schema">
|
||||||
|
{schemas && schemas.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={(params.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||||
|
aria-label="Schema"
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Schema</option>
|
||||||
|
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Schema"
|
||||||
|
value={(params.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Sequence name"
|
||||||
|
value={(params.name as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, name: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Operation">
|
||||||
|
<select
|
||||||
|
aria-label="Operation"
|
||||||
|
value={op}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchAction(params, { op: e.target.value }))
|
||||||
|
}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
{(Object.keys(OP_LABELS) as SequenceOp[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{OP_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
{(op === "create" || op === "alter") && (
|
||||||
|
<>
|
||||||
|
<FormRow label="Increment">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Increment"
|
||||||
|
value={(action.increment as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchAction(params, { increment: e.target.value }))
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Min value">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Min value"
|
||||||
|
value={(action.min_value as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchAction(params, { min_value: e.target.value }))
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Max value">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Max value"
|
||||||
|
value={(action.max_value as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchAction(params, { max_value: e.target.value }))
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
{op === "create" && (
|
||||||
|
<FormRow label="Start">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Start"
|
||||||
|
value={(action.start as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchAction(params, { start: e.target.value }))
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
<FormRow label="Cycle">
|
||||||
|
<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={!!action.cycle}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchAction(params, { cycle: e.target.checked }))
|
||||||
|
}
|
||||||
|
aria-label="CYCLE"
|
||||||
|
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<span>CYCLE</span>
|
||||||
|
</label>
|
||||||
|
</FormRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{op === "restart" && (
|
||||||
|
<FormRow label="Restart with">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Restart with"
|
||||||
|
value={(action.with as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(patchAction(params, { with: e.target.value }))
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -0,0 +1,93 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { TriggerForm } from "./TriggerForm";
|
||||||
|
import * as cmd from "../../../lib/commands";
|
||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/commands", () => ({ getFunctions: vi.fn() }));
|
||||||
|
|
||||||
|
const baseParams: DdlParams = {
|
||||||
|
schema: "public",
|
||||||
|
name: "tr",
|
||||||
|
action: {
|
||||||
|
op: "create",
|
||||||
|
table: "orders",
|
||||||
|
timing: "BEFORE",
|
||||||
|
events: ["INSERT"],
|
||||||
|
orientation: "ROW",
|
||||||
|
function_schema: "public",
|
||||||
|
function_name: "",
|
||||||
|
function_args: [],
|
||||||
|
when: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("TriggerForm", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists only trigger-returning functions", async () => {
|
||||||
|
(cmd.getFunctions as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{
|
||||||
|
name: "audit_fn",
|
||||||
|
schema: "public",
|
||||||
|
return_type: "trigger",
|
||||||
|
argument_types: [],
|
||||||
|
argument_names: [],
|
||||||
|
argument_modes: [],
|
||||||
|
language: "plpgsql",
|
||||||
|
source: null,
|
||||||
|
kind: "f",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not_a_trigger",
|
||||||
|
schema: "public",
|
||||||
|
return_type: "void",
|
||||||
|
argument_types: [],
|
||||||
|
argument_names: [],
|
||||||
|
argument_modes: [],
|
||||||
|
language: "plpgsql",
|
||||||
|
source: null,
|
||||||
|
kind: "f",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriggerForm
|
||||||
|
connectionId="c1"
|
||||||
|
params={baseParams}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("audit_fn")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("not_a_trigger")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits timing + events", async () => {
|
||||||
|
(cmd.getFunctions as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<TriggerForm connectionId="c1" params={baseParams} onChange={onChange} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(cmd.getFunctions).toHaveBeenCalledWith("c1", "public"));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByDisplayValue("BEFORE"), {
|
||||||
|
target: { value: "AFTER" },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ timing: "AFTER" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("UPDATE"));
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: expect.objectContaining({ events: ["INSERT", "UPDATE"] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getFunctions } from "../../../lib/commands";
|
||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
import type { FunctionInfo } from "../../../lib/types";
|
||||||
|
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
params: DdlParams;
|
||||||
|
schemas?: string[];
|
||||||
|
onChange: (p: DdlParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TriggerOp = "create" | "enable" | "disable";
|
||||||
|
|
||||||
|
const TIMINGS = ["BEFORE", "AFTER", "INSTEAD OF"];
|
||||||
|
const EVENTS = ["INSERT", "UPDATE", "DELETE", "TRUNCATE"];
|
||||||
|
const ORIENT = ["ROW", "STATEMENT"];
|
||||||
|
|
||||||
|
function getOp(params: DdlParams): TriggerOp {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
const op = action.op as string;
|
||||||
|
if (op === "enable" || op === "disable") return op;
|
||||||
|
return "create";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchAction(
|
||||||
|
params: DdlParams,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): DdlParams {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
return { ...params, action: { ...action, ...patch } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TriggerForm({ connectionId, params, schemas, onChange }: Props) {
|
||||||
|
const [fns, setFns] = useState<FunctionInfo[]>([]);
|
||||||
|
const op = getOp(params);
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
const events = (action.events as string[]) ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
getFunctions(connectionId, (params.schema as string) || undefined)
|
||||||
|
.then((all) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setFns(all.filter((f) => f.return_type === "trigger"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFns([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [connectionId, params.schema]);
|
||||||
|
|
||||||
|
const setAction = (patch: Record<string, unknown>) =>
|
||||||
|
onChange(patchAction(params, patch));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormRow label="Schema">
|
||||||
|
{schemas && schemas.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={(params.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||||
|
aria-label="Schema"
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Schema</option>
|
||||||
|
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Schema"
|
||||||
|
value={(params.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Trigger name"
|
||||||
|
value={(params.name as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, name: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Operation">
|
||||||
|
<select
|
||||||
|
aria-label="Operation"
|
||||||
|
value={op}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...params, action: { op: e.target.value } })
|
||||||
|
}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="create">Create</option>
|
||||||
|
<option value="enable">Enable</option>
|
||||||
|
<option value="disable">Disable</option>
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
|
||||||
|
{op === "create" && (
|
||||||
|
<>
|
||||||
|
<FormRow label="Table">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Table"
|
||||||
|
value={(action.table as string) ?? ""}
|
||||||
|
onChange={(e) => setAction({ table: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Timing">
|
||||||
|
<select
|
||||||
|
aria-label="Timing"
|
||||||
|
value={(action.timing as string) ?? "BEFORE"}
|
||||||
|
onChange={(e) => setAction({ timing: e.target.value })}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
{TIMINGS.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Events" className="items-stretch">
|
||||||
|
<div className="min-w-0 flex-1 flex flex-wrap gap-1 px-4 py-2">
|
||||||
|
{EVENTS.map((ev) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={ev}
|
||||||
|
onClick={() =>
|
||||||
|
setAction({
|
||||||
|
events: events.includes(ev)
|
||||||
|
? events.filter((x) => x !== ev)
|
||||||
|
: [...events, ev],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
||||||
|
events.includes(ev)
|
||||||
|
? "bg-accent text-white border-accent"
|
||||||
|
: "border-border text-text hover:border-text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ev}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Orientation">
|
||||||
|
<select
|
||||||
|
aria-label="Orientation"
|
||||||
|
value={(action.orientation as string) ?? "ROW"}
|
||||||
|
onChange={(e) => setAction({ orientation: e.target.value })}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
{ORIENT.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
FOR EACH {o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Function">
|
||||||
|
<select
|
||||||
|
aria-label="Function"
|
||||||
|
value={(action.function_name as string) ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = fns.find((fn) => fn.name === e.target.value);
|
||||||
|
setAction({
|
||||||
|
function_name: e.target.value,
|
||||||
|
function_schema: f?.schema ?? (params.schema as string),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="">(trigger function)</option>
|
||||||
|
{fns.map((f) => (
|
||||||
|
<option key={`${f.schema}.${f.name}`} value={f.name}>
|
||||||
|
{f.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Function args">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Function args (comma-separated)"
|
||||||
|
value={(action.function_args as string[] | undefined)?.join(", ") ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAction({
|
||||||
|
function_args: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="When">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="WHEN (optional)"
|
||||||
|
value={(action.when as string) ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAction({ when: e.target.value || null })
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(op === "enable" || op === "disable") && (
|
||||||
|
<FormRow label="Table">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Table"
|
||||||
|
value={(action.table as string) ?? ""}
|
||||||
|
onChange={(e) => setAction({ table: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { ViewForm } from "./ViewForm";
|
||||||
|
|
||||||
|
vi.mock("../../editor/SqlEditorField", () => ({
|
||||||
|
SqlEditorField: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||||
|
<textarea data-testid="sql-editor" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ViewForm", () => {
|
||||||
|
it("create: emits the definition", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ViewForm
|
||||||
|
params={{ schema: "public", name: "v", materialized: false, action: { op: "create", definition: "SELECT 1" } }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(await screen.findByTestId("sql-editor"), { target: { value: "SELECT 2" } });
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ action: expect.objectContaining({ definition: "SELECT 2" }) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matview replace shows a note about drop+create", () => {
|
||||||
|
render(
|
||||||
|
<ViewForm
|
||||||
|
params={{ schema: "public", name: "mv", materialized: true, action: { op: "replace", definition: "SELECT 1" } }}
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/drop and recreate/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import type { DdlParams } from "../../../lib/objectCrud";
|
||||||
|
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||||
|
|
||||||
|
const SqlEditorField = lazy(() =>
|
||||||
|
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: DdlParams;
|
||||||
|
schemas?: string[];
|
||||||
|
onChange: (p: DdlParams) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewOp = "create" | "replace";
|
||||||
|
|
||||||
|
function getOp(params: DdlParams): ViewOp {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
const op = action.op;
|
||||||
|
if (op === "replace") return "replace";
|
||||||
|
return "create";
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchAction(
|
||||||
|
params: DdlParams,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): DdlParams {
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
return { ...params, action: { ...action, ...patch } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewForm({ params, schemas, onChange }: Props) {
|
||||||
|
const op = getOp(params);
|
||||||
|
const isMat = !!params.materialized;
|
||||||
|
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FormRow label="Schema">
|
||||||
|
{schemas && schemas.length > 0 ? (
|
||||||
|
<select
|
||||||
|
value={(params.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||||
|
aria-label="Schema"
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Schema</option>
|
||||||
|
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Schema"
|
||||||
|
value={(params.schema as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={isMat ? "Materialized view name" : "View name"}
|
||||||
|
value={(params.name as string) ?? ""}
|
||||||
|
onChange={(e) => onChange({ ...params, name: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow label="Operation">
|
||||||
|
<select
|
||||||
|
aria-label="Operation"
|
||||||
|
value={op}
|
||||||
|
onChange={(e) => onChange(patchAction(params, { op: e.target.value }))}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="create">{isMat ? "Create" : "Create or replace"}</option>
|
||||||
|
{isMat && <option value="replace">Replace (drop + create)</option>}
|
||||||
|
</select>
|
||||||
|
</FormRow>
|
||||||
|
{isMat && op === "replace" && (
|
||||||
|
<div className="border-b border-border px-4 py-2">
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Materialized views cannot be CREATE OR REPLACE — this will drop and recreate.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormRow label={isMat ? "Definition" : "Body"} className="items-stretch" outline={false}>
|
||||||
|
<div className="min-w-0 flex-1 py-2" style={{ minHeight: 140 }}>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<textarea
|
||||||
|
rows={6}
|
||||||
|
value={(action.definition as string) ?? ""}
|
||||||
|
onChange={(e) => onChange(patchAction(params, { definition: e.target.value }))}
|
||||||
|
className="w-full h-full bg-transparent px-3 font-mono text-xs text-text outline-none resize-none"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full font-mono">
|
||||||
|
<SqlEditorField
|
||||||
|
value={(action.definition as string) ?? ""}
|
||||||
|
onChange={(v) => onChange(patchAction(params, { definition: v }))}
|
||||||
|
height={140}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</FormRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export const inputClass =
|
||||||
|
"min-w-0 flex-1 bg-transparent px-3 font-heading text-xs text-text outline-none placeholder:text-text-muted";
|
||||||
|
export const controlClass =
|
||||||
|
"min-w-0 flex-1 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none placeholder:text-text-muted";
|
||||||
|
export const monoInputClass =
|
||||||
|
"min-w-0 flex-1 bg-transparent px-3 font-mono text-xs text-text outline-none placeholder:text-text-muted";
|
||||||
|
|
||||||
|
export interface FormRowProps {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
/** When false, the row shows no amber focus outline (e.g. Monaco rows). */
|
||||||
|
outline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormRow({ label, children, className, outline = true }: FormRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"border-b border-border flex flex-row items-stretch",
|
||||||
|
className ?? "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="border-r border-border px-4 py-2 flex items-center w-40 shrink-0">
|
||||||
|
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"flex-1 min-w-0 flex flex-row items-center",
|
||||||
|
outline
|
||||||
|
? "focus-within:outline focus-within:outline-2 focus-within:outline-amber-400/20 focus-within:outline-offset-[-2px]"
|
||||||
|
: "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormSectionHeader({
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
count?: number | string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span className="text-[10px] text-text-subtle">{count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import Editor, { type BeforeMount } from "@monaco-editor/react";
|
||||||
|
import { useSettingsStore } from "../../stores/settingsStore";
|
||||||
|
|
||||||
|
interface SqlEditorFieldProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
height?: number;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SqlEditorField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
height = 140,
|
||||||
|
readOnly = false,
|
||||||
|
}: SqlEditorFieldProps) {
|
||||||
|
const editorFontFamily = useSettingsStore(
|
||||||
|
(s) => s.settings?.editor_font_family ?? "Space Mono",
|
||||||
|
);
|
||||||
|
const editorFontSize = useSettingsStore(
|
||||||
|
(s) => s.settings?.editor_font_size ?? 13,
|
||||||
|
);
|
||||||
|
const editorTabSize = useSettingsStore(
|
||||||
|
(s) => s.settings?.editor_tab_size ?? 4,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
|
||||||
|
monaco.editor.defineTheme("gridline-sql", {
|
||||||
|
base: "vs-dark",
|
||||||
|
inherit: true,
|
||||||
|
rules: [],
|
||||||
|
colors: {
|
||||||
|
"editor.background": "#00000000",
|
||||||
|
"editorGutter.background": "#00000000",
|
||||||
|
"editor.lineHighlightBackground": "#ffffff08",
|
||||||
|
"editorLineNumber.foreground": "#5b5b5e",
|
||||||
|
"editorLineNumber.activeForeground": "#a1a1a6",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0" data-testid="sql-editor-field">
|
||||||
|
<Editor
|
||||||
|
height={height}
|
||||||
|
language="sql"
|
||||||
|
theme="gridline-sql"
|
||||||
|
beforeMount={handleBeforeMount}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => onChange(v ?? "")}
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: editorFontSize,
|
||||||
|
fontFamily: editorFontFamily,
|
||||||
|
lineNumbers: "on",
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: "on",
|
||||||
|
readOnly,
|
||||||
|
automaticLayout: true,
|
||||||
|
tabSize: editorTabSize,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover";
|
|||||||
import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover";
|
import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover";
|
||||||
import { CellEditor, type FkOption } from "./CellEditor";
|
import { CellEditor, type FkOption } from "./CellEditor";
|
||||||
import { CellContextMenu } from "./CellContextMenu";
|
import { CellContextMenu } from "./CellContextMenu";
|
||||||
import { cellToUpdateChange, isCellEditable } from "./gridEditability";
|
import { cellToUpdateChange, isCellEditable, type TabKind } from "./gridEditability";
|
||||||
import { nextCell, type CellPos } from "./keyboardNav";
|
import { nextCell, type CellPos } from "./keyboardNav";
|
||||||
|
|
||||||
interface VirtualDataGridProps {
|
interface VirtualDataGridProps {
|
||||||
@@ -21,7 +21,7 @@ interface VirtualDataGridProps {
|
|||||||
onToggleRow: (rowIndex: number) => void;
|
onToggleRow: (rowIndex: number) => void;
|
||||||
onToggleAll: () => void;
|
onToggleAll: () => void;
|
||||||
dbType?: string;
|
dbType?: string;
|
||||||
tabType?: "table" | "query";
|
tabType?: TabKind;
|
||||||
onStageEdit?: (payload: {
|
onStageEdit?: (payload: {
|
||||||
type: "update";
|
type: "update";
|
||||||
schema: string;
|
schema: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ColumnInfo, ChangeItemType } from "../../lib/types";
|
import type { ColumnInfo, ChangeItemType } from "../../lib/types";
|
||||||
|
|
||||||
export type TabKind = "table" | "query";
|
export type TabKind = "table" | "query" | "object" | "objectForm";
|
||||||
export type EditableDbType = "postgresql" | "sqlite";
|
export type EditableDbType = "postgresql" | "sqlite";
|
||||||
|
|
||||||
/** A cell is editable iff: table tab, PG/SQLite, column flagged editable, not PK, not generated, and not read-only. */
|
/** A cell is editable iff: table tab, PG/SQLite, column flagged editable, not PK, not generated, and not read-only. */
|
||||||
|
|||||||
@@ -38,15 +38,37 @@ const mockConnection = {
|
|||||||
ssl_ca_path: null,
|
ssl_ca_path: null,
|
||||||
ssl_cert_path: null,
|
ssl_cert_path: null,
|
||||||
ssl_key_path: null,
|
ssl_key_path: null,
|
||||||
|
use_keychain: undefined as boolean | undefined,
|
||||||
created_at: "2026-07-26T00:00:00Z",
|
created_at: "2026-07-26T00:00:00Z",
|
||||||
updated_at: "2026-07-26T00:00:00Z",
|
updated_at: "2026-07-26T00:00:00Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mutable stand-in for the connection store's session-password behavior: a
|
||||||
|
// fallback password (default "pw", like a keychain hit) plus a per-connection
|
||||||
|
// session map written by setSessionPassword (like the real store).
|
||||||
|
const mockStoreState = vi.hoisted(() => {
|
||||||
|
const sessionPasswords = new Map<string, string>();
|
||||||
|
let fallbackPassword: string | null = "pw";
|
||||||
|
return {
|
||||||
|
sessionPasswords,
|
||||||
|
setFallbackPassword: (p: string | null) => {
|
||||||
|
fallbackPassword = p;
|
||||||
|
},
|
||||||
|
getConnectionPassword: vi.fn(
|
||||||
|
async (id: string) => sessionPasswords.get(id) ?? fallbackPassword,
|
||||||
|
),
|
||||||
|
setSessionPassword: vi.fn((id: string, pw: string) => {
|
||||||
|
sessionPasswords.set(id, pw);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("../stores/connectionStore", () => ({
|
vi.mock("../stores/connectionStore", () => ({
|
||||||
useConnectionStore: {
|
useConnectionStore: {
|
||||||
getState: () => ({
|
getState: () => ({
|
||||||
connections: [mockConnection],
|
connections: [mockConnection],
|
||||||
getConnectionPassword: async () => "pw",
|
getConnectionPassword: mockStoreState.getConnectionPassword,
|
||||||
|
setSessionPassword: mockStoreState.setSessionPassword,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -60,9 +82,18 @@ function Harness() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PromptHarness() {
|
||||||
|
const { passwordPromptOpen } = useDbConnection("c1");
|
||||||
|
return <div data-testid="prompt">{passwordPromptOpen ? "open" : "closed"}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
describe("useDbConnection", () => {
|
describe("useDbConnection", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Reset the mutable store stand-in + connection for isolation.
|
||||||
|
mockStoreState.sessionPasswords.clear();
|
||||||
|
mockStoreState.setFallbackPassword("pw");
|
||||||
|
(mockConnection as any).use_keychain = undefined;
|
||||||
useDbViewerStore.getState().reset();
|
useDbViewerStore.getState().reset();
|
||||||
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
|
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
|
||||||
mockCommands.getSchemas.mockResolvedValue(["app", "public"]);
|
mockCommands.getSchemas.mockResolvedValue(["app", "public"]);
|
||||||
@@ -182,6 +213,52 @@ describe("useDbConnection", () => {
|
|||||||
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
expect(useDbViewerStore.getState().currentSchema).toBe("public");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the password prompt when use_keychain=false and no session password", async () => {
|
||||||
|
mockConnection.use_keychain = false;
|
||||||
|
mockStoreState.setFallbackPassword(null);
|
||||||
|
render(<PromptHarness />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("prompt")).toHaveTextContent("open"),
|
||||||
|
);
|
||||||
|
// Early return: no Tauri call fires before the user submits a password.
|
||||||
|
expect(mockCommands.dbConnect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not prompt when keychain is enabled (even without a stored password)", async () => {
|
||||||
|
mockConnection.use_keychain = true;
|
||||||
|
mockStoreState.setFallbackPassword(null);
|
||||||
|
render(<PromptHarness />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockCommands.dbConnect).toHaveBeenCalled(),
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("prompt")).toHaveTextContent("closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitPassword stores the session password and reconnects", async () => {
|
||||||
|
mockConnection.use_keychain = false;
|
||||||
|
mockStoreState.setFallbackPassword(null);
|
||||||
|
let submit: ((pw: string) => void) | null = null;
|
||||||
|
function SubmitHarness() {
|
||||||
|
const { passwordPromptOpen, submitPassword } = useDbConnection("c1");
|
||||||
|
submit = submitPassword;
|
||||||
|
return (
|
||||||
|
<div data-testid="prompt">
|
||||||
|
{passwordPromptOpen ? "open" : "closed"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<SubmitHarness />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("prompt")).toHaveTextContent("open"),
|
||||||
|
);
|
||||||
|
act(() => submit!("secret"));
|
||||||
|
expect(mockStoreState.setSessionPassword).toHaveBeenCalledWith("c1", "secret");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("prompt")).toHaveTextContent("closed"),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(mockCommands.dbConnect).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
it("fetches ssh secrets from keychain before connecting when ssh_host is set", async () => {
|
it("fetches ssh secrets from keychain before connecting when ssh_host is set", async () => {
|
||||||
mockConnection.ssh_host = "bastion.example.com";
|
mockConnection.ssh_host = "bastion.example.com";
|
||||||
mockConnection.ssh_auth_method = "password";
|
mockConnection.ssh_auth_method = "password";
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function useDbConnection(connectionId: string) {
|
|||||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||||
const notify = useNotificationStore((s) => s.notify);
|
const notify = useNotificationStore((s) => s.notify);
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
|
const [passwordPromptOpen, setPasswordPromptOpen] = useState(false);
|
||||||
const inputRef = useRef<ConnectionInput | null>(null);
|
const inputRef = useRef<ConnectionInput | null>(null);
|
||||||
// The database the pool is currently connected to. Unlike the selected
|
// The database the pool is currently connected to. Unlike the selected
|
||||||
// `currentDatabase`, this lets us reconnect whenever the selection drifts
|
// `currentDatabase`, this lets us reconnect whenever the selection drifts
|
||||||
@@ -31,6 +32,12 @@ export function useDbConnection(connectionId: string) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const password = await useConnectionStore.getState().getConnectionPassword(conn.id).catch(() => null);
|
const password = await useConnectionStore.getState().getConnectionPassword(conn.id).catch(() => null);
|
||||||
|
// Keychain-off + no session password: prompt the user instead of
|
||||||
|
// connecting with an empty password (early return, no Tauri call).
|
||||||
|
if (conn.use_keychain === false && !password) {
|
||||||
|
setPasswordPromptOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const sshPassword = conn.ssh_host
|
const sshPassword = conn.ssh_host
|
||||||
? await cmd.getConnectionSshPassword(conn.id).catch(() => null)
|
? await cmd.getConnectionSshPassword(conn.id).catch(() => null)
|
||||||
: null;
|
: null;
|
||||||
@@ -160,5 +167,16 @@ export function useDbConnection(connectionId: string) {
|
|||||||
};
|
};
|
||||||
}, [currentDatabase, connectionId, populate, setCurrentSchema, setSchemaTreeLoading, notify]);
|
}, [currentDatabase, connectionId, populate, setCurrentSchema, setSchemaTreeLoading, notify]);
|
||||||
|
|
||||||
return { connectionError, connect };
|
const submitPassword = useCallback(
|
||||||
|
(pw: string) => {
|
||||||
|
useConnectionStore.getState().setSessionPassword(connectionId, pw);
|
||||||
|
setPasswordPromptOpen(false);
|
||||||
|
void connect();
|
||||||
|
},
|
||||||
|
[connectionId, connect],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelPassword = useCallback(() => setPasswordPromptOpen(false), []);
|
||||||
|
|
||||||
|
return { connectionError, connect, passwordPromptOpen, submitPassword, cancelPassword };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.5)", () => {
|
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.5", () => {
|
it("version is 0.7.7", () => {
|
||||||
expect(tauriConf.version).toBe("0.7.5");
|
expect(tauriConf.version).toBe("0.7.7");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -61,4 +61,37 @@ describe("buildChangeSql", () => {
|
|||||||
expect(buildChangeSql({ id: "1", type: "empty_table", schema: "public", table: "t" } as any)).toBe('DELETE FROM "public"."t"');
|
expect(buildChangeSql({ id: "1", type: "empty_table", schema: "public", table: "t" } as any)).toBe('DELETE FROM "public"."t"');
|
||||||
expect(buildChangeSql({ id: "1", type: "drop_table", schema: "public", table: "t" } as any)).toBe('DROP TABLE "public"."t"');
|
expect(buildChangeSql({ id: "1", type: "drop_table", schema: "public", table: "t" } as any)).toBe('DROP TABLE "public"."t"');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
id: "ch-1", type: "ddl",
|
||||||
|
sql: "CREATE TYPE public.role AS ENUM ('admin')",
|
||||||
|
status: "pending", createdAt: 0,
|
||||||
|
};
|
||||||
|
describe("ddl change", () => {
|
||||||
|
it("builds a ddl payload with id + type + sql", () => {
|
||||||
|
expect(buildChangePayload(ddlItem)).toEqual({
|
||||||
|
id: "ch-1", type: "ddl", sql: "CREATE TYPE public.role AS ENUM ('admin')",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("preview SQL is the raw sql", () => {
|
||||||
|
expect(buildChangeSql(ddlItem)).toBe("CREATE TYPE public.role AS ENUM ('admin')");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -57,6 +57,10 @@ export function buildChangeSql(item: QueueItem): string {
|
|||||||
}
|
}
|
||||||
case "empty_table":
|
case "empty_table":
|
||||||
return `DELETE FROM ${t}`;
|
return `DELETE FROM ${t}`;
|
||||||
|
case "ddl":
|
||||||
|
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:
|
||||||
@@ -84,6 +88,10 @@ export function buildChangePayload(item: QueueItem): ChangePayload {
|
|||||||
return { id: item.id, type: "drop_table", schema, table };
|
return { id: item.id, type: "drop_table", schema, table };
|
||||||
case "empty_table":
|
case "empty_table":
|
||||||
return { id: item.id, type: "empty_table", schema, table };
|
return { id: item.id, type: "empty_table", schema, table };
|
||||||
|
case "ddl":
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -332,4 +339,88 @@ describe("object management commands (v0.7.5)", () => {
|
|||||||
await getObjectDependencies("c1", "public", "table", "orders");
|
await getObjectDependencies("c1", "public", "table", "orders");
|
||||||
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
@@ -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
|
||||||
@@ -319,4 +327,28 @@ 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 });
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,8 @@ describe("dbCapabilities", () => {
|
|||||||
const c = DB_CAPABILITIES.postgresql;
|
const c = DB_CAPABILITIES.postgresql;
|
||||||
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,
|
tools: true, editing: true, import: true, ddl: true, objectCrud: true,
|
||||||
|
maintenance: true, roles: true, tableManagement: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ describe("dbCapabilities", () => {
|
|||||||
it("gives Redis nothing (connection+test only)", () => {
|
it("gives Redis nothing (connection+test only)", () => {
|
||||||
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,
|
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
|
||||||
|
maintenance: false, roles: false, tableManagement: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,4 +59,34 @@ describe("dbCapabilities", () => {
|
|||||||
expect(getCapabilities("sqlite").objects).toBe(false);
|
expect(getCapabilities("sqlite").objects).toBe(false);
|
||||||
expect(getCapabilities("redis").objects).toBe(false);
|
expect(getCapabilities("redis").objects).toBe(false);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("objectCrud capability", () => {
|
||||||
|
it("is true only for postgresql", () => {
|
||||||
|
expect(DB_CAPABILITIES.postgresql.objectCrud).toBe(true);
|
||||||
|
expect(DB_CAPABILITIES.mysql.objectCrud).toBe(false);
|
||||||
|
expect(DB_CAPABILITIES.sqlite.objectCrud).toBe(false);
|
||||||
|
expect(DB_CAPABILITIES.redis.objectCrud).toBe(false);
|
||||||
|
});
|
||||||
|
it("unknown types get objectCrud 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -17,15 +17,24 @@ export interface DbCapabilities {
|
|||||||
import: boolean;
|
import: boolean;
|
||||||
/** Copy table schema (DDL). */
|
/** Copy table schema (DDL). */
|
||||||
ddl: boolean;
|
ddl: boolean;
|
||||||
|
/** Create / edit / drop PostgreSQL objects via the changes queue. */
|
||||||
|
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,
|
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 },
|
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 },
|
||||||
|
|||||||
@@ -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.5 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.5 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.5", () => {
|
it("README declares v0.7.7", () => {
|
||||||
expect(readme).toContain("0.7.5");
|
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 \| ✅/);
|
||||||
@@ -50,4 +50,18 @@ describe("v0.7.5 docs coverage", () => {
|
|||||||
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*\|[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
|
expect(readme).toMatch(/Changes queue \(stage → commit\)\s*\|[^|]*❌[^|]*\|[^|]*\|[^|]*\|\s*\*\*✅ Queue → Commit All\*\*/);
|
||||||
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
|
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
|
||||||
});
|
});
|
||||||
|
it("AGENTS.md marks object management CRUD complete", () => {
|
||||||
|
expect(agents).toMatch(/Object management CRUD \| ✅/);
|
||||||
|
});
|
||||||
|
it("AGENTS.md marks the keychain toggle complete", () => {
|
||||||
|
expect(agents).toMatch(/Enable keychain toggle \| ✅/);
|
||||||
|
});
|
||||||
|
it("AGENTS.md marks the Objects view tabbed workspace complete", () => {
|
||||||
|
expect(agents).toMatch(/Objects view tabbed workspace \| ✅/);
|
||||||
|
});
|
||||||
|
it("README links to v0.7.7 assets in both download tables", () => {
|
||||||
|
expect(readme).toContain("releases/download/v0.7.7/");
|
||||||
|
expect(readme).toContain("Gridline_0.7.7_aarch64.dmg");
|
||||||
|
expect(readme).toContain("Gridline-0.7.7-1.x86_64.rpm");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { saveConnectionPassword, deleteConnectionPassword } from "./commands";
|
||||||
|
|
||||||
|
// Persist (or purge) the DB password according to the keychain toggle.
|
||||||
|
// use_keychain defaults to true (opt-out): ON = OS keychain; OFF = purge + session-only.
|
||||||
|
// Shared by connectionStore.createConnection and EditConnectionModal.
|
||||||
|
export async function persistDbPassword(
|
||||||
|
connectionId: string,
|
||||||
|
useKeychain: boolean | undefined,
|
||||||
|
password: string | null | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
const useKc = useKeychain ?? true;
|
||||||
|
if (useKc && password) {
|
||||||
|
await saveConnectionPassword(connectionId, password);
|
||||||
|
} else if (!useKc) {
|
||||||
|
try { await deleteConnectionPassword(connectionId); } catch { /* purge; ignore missing */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
|
invoke: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import {
|
||||||
|
buildObjectDdl,
|
||||||
|
dropCrudParams,
|
||||||
|
getAvailableExtensions,
|
||||||
|
initialCrudParams,
|
||||||
|
} from "./objectCrud";
|
||||||
|
import type { CrudItem } from "./objectCrud";
|
||||||
|
|
||||||
|
describe("buildObjectDdl", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it("invokes build_object_ddl with connectionId, kind, params", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValueOnce([
|
||||||
|
"CREATE TYPE public.role AS ENUM ('admin')",
|
||||||
|
]);
|
||||||
|
const sql = await buildObjectDdl("c1", "enum", {
|
||||||
|
schema: "public",
|
||||||
|
name: "role",
|
||||||
|
action: { op: "create", labels: ["admin"] },
|
||||||
|
});
|
||||||
|
expect(invoke).toHaveBeenCalledWith("build_object_ddl", {
|
||||||
|
connectionId: "c1",
|
||||||
|
kind: "enum",
|
||||||
|
params: {
|
||||||
|
schema: "public",
|
||||||
|
name: "role",
|
||||||
|
action: { op: "create", labels: ["admin"] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(sql).toEqual(["CREATE TYPE public.role AS ENUM ('admin')"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through drop params for sequences", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValueOnce(['DROP SEQUENCE "public"."s"']);
|
||||||
|
const sql = await buildObjectDdl("c1", "sequence", {
|
||||||
|
schema: "public",
|
||||||
|
name: "s",
|
||||||
|
action: { op: "drop" },
|
||||||
|
});
|
||||||
|
expect(invoke).toHaveBeenCalledWith("build_object_ddl", {
|
||||||
|
connectionId: "c1",
|
||||||
|
kind: "sequence",
|
||||||
|
params: { schema: "public", name: "s", action: { op: "drop" } },
|
||||||
|
});
|
||||||
|
expect(sql).toEqual(['DROP SEQUENCE "public"."s"']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAvailableExtensions", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it("invokes get_available_extensions with connectionId", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValueOnce([
|
||||||
|
{ name: "pgcrypto", version: "1.3", comment: null },
|
||||||
|
]);
|
||||||
|
const exts = await getAvailableExtensions("c1");
|
||||||
|
expect(invoke).toHaveBeenCalledWith("get_available_extensions", {
|
||||||
|
connectionId: "c1",
|
||||||
|
});
|
||||||
|
expect(exts).toEqual([{ name: "pgcrypto", version: "1.3", comment: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no extensions available", async () => {
|
||||||
|
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||||
|
const exts = await getAvailableExtensions("c2");
|
||||||
|
expect(exts).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initialCrudParams", () => {
|
||||||
|
it("create shape starts empty with sensible defaults", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"sequence",
|
||||||
|
{ schema: "public", name: "" },
|
||||||
|
"create",
|
||||||
|
);
|
||||||
|
expect(p).toEqual({
|
||||||
|
schema: "public",
|
||||||
|
name: "",
|
||||||
|
action: {
|
||||||
|
op: "create",
|
||||||
|
increment: "1",
|
||||||
|
min_value: "1",
|
||||||
|
max_value: "9223372036854775807",
|
||||||
|
start: "1",
|
||||||
|
cycle: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edit prefills the name from the item", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"sequence",
|
||||||
|
{ schema: "public", name: "s" },
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
schema: "public",
|
||||||
|
name: "s",
|
||||||
|
action: { op: "create", increment: "1" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enum edit carries labels through", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"enum",
|
||||||
|
{ schema: "public", name: "role", labels: ["admin"] },
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p.action).toMatchObject({ op: "create", labels: ["admin"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("index create keeps the owning table and an empty column list", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"index",
|
||||||
|
{ schema: "public", table: "users", name: "" },
|
||||||
|
"create",
|
||||||
|
);
|
||||||
|
expect(p).toEqual({
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
name: "",
|
||||||
|
action: { op: "create", unique: false, method: "", columns: [], predicate: null },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constraint create starts as a CHECK on the owning table", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"constraint",
|
||||||
|
{ schema: "public", table: "users", name: "" },
|
||||||
|
"create",
|
||||||
|
);
|
||||||
|
expect(p).toEqual({
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
name: "",
|
||||||
|
action: { op: "check", expression: "" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("procedure edit prefills is_procedure from kind", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"procedure",
|
||||||
|
{ schema: "public", name: "do_thing" },
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
schema: "public",
|
||||||
|
name: "do_thing",
|
||||||
|
is_procedure: true,
|
||||||
|
action: { op: "create_or_replace", args: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("view edit prefills the definition and marks non-materialized", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"view",
|
||||||
|
{ schema: "public", name: "v", definition: "SELECT 1" },
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
schema: "public",
|
||||||
|
name: "v",
|
||||||
|
materialized: false,
|
||||||
|
action: { op: "create", definition: "SELECT 1" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("function edit prefills body, zipped args, return_type, and language", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"function",
|
||||||
|
{
|
||||||
|
schema: "public",
|
||||||
|
name: "add_one",
|
||||||
|
return_type: "integer",
|
||||||
|
argument_types: ["integer"],
|
||||||
|
argument_names: ["x"],
|
||||||
|
argument_modes: ["IN"],
|
||||||
|
language: "plpgsql",
|
||||||
|
source: "BEGIN RETURN x + 1; END",
|
||||||
|
kind: "f",
|
||||||
|
},
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
schema: "public",
|
||||||
|
name: "add_one",
|
||||||
|
is_procedure: false,
|
||||||
|
action: {
|
||||||
|
op: "create_or_replace",
|
||||||
|
args: [{ mode: "IN", name: "x", type: "integer" }],
|
||||||
|
return_type: "integer",
|
||||||
|
language: "plpgsql",
|
||||||
|
body: "BEGIN RETURN x + 1; END",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("procedure edit prefills source and zips multiple args", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"procedure",
|
||||||
|
{
|
||||||
|
schema: "public",
|
||||||
|
name: "do_thing",
|
||||||
|
return_type: "void",
|
||||||
|
argument_types: ["int", "text"],
|
||||||
|
argument_names: ["a", "b"],
|
||||||
|
argument_modes: ["IN", "OUT"],
|
||||||
|
language: "plpgsql",
|
||||||
|
source: "BEGIN PERFORM a; END",
|
||||||
|
kind: "p",
|
||||||
|
},
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
is_procedure: true,
|
||||||
|
action: {
|
||||||
|
op: "create_or_replace",
|
||||||
|
args: [
|
||||||
|
{ mode: "IN", name: "a", type: "int" },
|
||||||
|
{ mode: "OUT", name: "b", type: "text" },
|
||||||
|
],
|
||||||
|
return_type: "void",
|
||||||
|
body: "BEGIN PERFORM a; END",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("function create keeps empty args/body and the default language", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"function",
|
||||||
|
{ schema: "public", name: "add_one" },
|
||||||
|
"create",
|
||||||
|
);
|
||||||
|
expect(p.action).toMatchObject({
|
||||||
|
op: "create_or_replace",
|
||||||
|
args: [],
|
||||||
|
return_type: null,
|
||||||
|
language: "plpgsql",
|
||||||
|
body: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sequence edit prefills increment/min/max/start/cycle from the item", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"sequence",
|
||||||
|
{
|
||||||
|
schema: "public",
|
||||||
|
name: "s",
|
||||||
|
start_value: "5",
|
||||||
|
min_value: "1",
|
||||||
|
max_value: "999",
|
||||||
|
increment: "2",
|
||||||
|
cycle: true,
|
||||||
|
},
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p.action).toMatchObject({
|
||||||
|
op: "create",
|
||||||
|
increment: "2",
|
||||||
|
min_value: "1",
|
||||||
|
max_value: "999",
|
||||||
|
start: "5",
|
||||||
|
cycle: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trigger edit prefills table, timing, events, and orientation", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"trigger",
|
||||||
|
{
|
||||||
|
schema: "public",
|
||||||
|
name: "trg",
|
||||||
|
table_name: "users",
|
||||||
|
event_manipulation: "INSERT",
|
||||||
|
action_timing: "AFTER",
|
||||||
|
action_orientation: "row",
|
||||||
|
},
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p.action).toMatchObject({
|
||||||
|
op: "create",
|
||||||
|
table: "users",
|
||||||
|
timing: "AFTER",
|
||||||
|
events: ["INSERT"],
|
||||||
|
orientation: "ROW",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trigger edit splits OR-joined event_manipulation into separate events", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"trigger",
|
||||||
|
{
|
||||||
|
schema: "public",
|
||||||
|
name: "trg",
|
||||||
|
table_name: "orders",
|
||||||
|
event_manipulation: "INSERT OR UPDATE",
|
||||||
|
action_timing: "BEFORE",
|
||||||
|
action_orientation: "STATEMENT",
|
||||||
|
},
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p.action).toMatchObject({
|
||||||
|
events: ["INSERT", "UPDATE"],
|
||||||
|
orientation: "STATEMENT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("index edit prefills name, unique, method, and columns", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"index",
|
||||||
|
{
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
name: "idx_users_email",
|
||||||
|
is_unique: true,
|
||||||
|
method: "btree",
|
||||||
|
columns: ["email"],
|
||||||
|
},
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
name: "idx_users_email",
|
||||||
|
action: {
|
||||||
|
op: "create",
|
||||||
|
unique: true,
|
||||||
|
method: "btree",
|
||||||
|
columns: ["email"],
|
||||||
|
predicate: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constraint edit maps CHECK contype to a check action with the definition", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"constraint",
|
||||||
|
{
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
name: "chk_age",
|
||||||
|
contype: "CHECK",
|
||||||
|
definition: "CHECK (age > 0)",
|
||||||
|
columns: ["age"],
|
||||||
|
},
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
name: "chk_age",
|
||||||
|
action: { op: "check", expression: "CHECK (age > 0)" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constraint edit maps UNIQUE contype to a unique action with columns", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"constraint",
|
||||||
|
{
|
||||||
|
schema: "public",
|
||||||
|
table: "users",
|
||||||
|
name: "uniq_email",
|
||||||
|
contype: "UNIQUE",
|
||||||
|
definition: "UNIQUE (email)",
|
||||||
|
columns: ["email"],
|
||||||
|
},
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
name: "uniq_email",
|
||||||
|
action: { op: "unique", columns: ["email"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extension edit prefills the version from the item", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"extension",
|
||||||
|
{ schema: "public", name: "pgcrypto", version: "1.3" },
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
name: "pgcrypto",
|
||||||
|
action: { op: "create", version: "1.3" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("view edit marks materialized when the item is a materialized view", () => {
|
||||||
|
const p = initialCrudParams(
|
||||||
|
"view",
|
||||||
|
{
|
||||||
|
schema: "public",
|
||||||
|
name: "mv",
|
||||||
|
table_type: "MATERIALIZED VIEW",
|
||||||
|
definition: "SELECT 1",
|
||||||
|
},
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
expect(p).toMatchObject({
|
||||||
|
name: "mv",
|
||||||
|
materialized: true,
|
||||||
|
action: { op: "create", definition: "SELECT 1" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dropCrudParams", () => {
|
||||||
|
it("plain kinds build a base drop action", () => {
|
||||||
|
expect(
|
||||||
|
dropCrudParams("sequence", { schema: "public", name: "s" }),
|
||||||
|
).toEqual({ schema: "public", name: "s", action: { op: "drop" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("index and constraint carry the owning table", () => {
|
||||||
|
expect(
|
||||||
|
dropCrudParams("index", { schema: "public", name: "i", table: "users" }),
|
||||||
|
).toEqual({
|
||||||
|
schema: "public",
|
||||||
|
name: "i",
|
||||||
|
table: "users",
|
||||||
|
action: { op: "drop" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trigger uses table_name as a fallback for the table", () => {
|
||||||
|
expect(
|
||||||
|
dropCrudParams("trigger", {
|
||||||
|
schema: "public",
|
||||||
|
name: "trg",
|
||||||
|
table_name: "users",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
schema: "public",
|
||||||
|
name: "trg",
|
||||||
|
action: { op: "drop", table: "users" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("view marks materialized via table_type", () => {
|
||||||
|
expect(
|
||||||
|
dropCrudParams("view", { schema: "public", name: "m", table_type: "m" }),
|
||||||
|
).toMatchObject({ materialized: true, action: { op: "drop" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("function carries argument types and is_procedure false", () => {
|
||||||
|
expect(
|
||||||
|
dropCrudParams("function", {
|
||||||
|
schema: "public",
|
||||||
|
name: "f",
|
||||||
|
argument_types: ["int"],
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
is_procedure: false,
|
||||||
|
action: { op: "drop", arg_types: ["int"] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user