v0.7.6: PostgreSQL object management, keychain toggle, tabbed Objects workspace (#12)
* [P1-T1] Change::Ddl Rust variant + execute_change arm * [P1-T2] Frontend ddl change type + objectCrud capability * [P1-T3] use_keychain data model + migration v8 * [P2-T1] object_crud skeleton + validators + build_ddl dispatch * [P2-T2] Sequence builders * [P2-T3] Enum builders (no value removal) * [P2-T4] View / matview / extension builders * [P2-T5] Index + constraint builders * [P2-T6] Function / procedure + trigger builders * [P2-T7] build_object_ddl + get_available_extensions commands + wrappers * [P3-T1] conditional keychain + session passwords * [P3-T2] keychain-off password prompt on connect * [P3-T3] default-ON keychain opt-out + tooltip + modal conditional * [P3-T4] ddl queue card + after-commit refetch * [P4-T1] ObjectCrudDialog shell * [P4-T2] SequenceForm * [P4-T3] EnumForm with no-removal note * [P4-T4] ExtensionForm with available-extensions picker * [P4-T5] ViewForm (view + materialized view) * [P5-T1] IndexForm with column picker * [P5-T2] ConstraintForm (check/unique/pk/fk) + ColumnPicker * [P5-T3] FunctionForm (function + procedure) * [P5-T4] TriggerForm with trigger-function picker * [P5-T5] ObjectContextMenu + Explorer/TableOverflowMenu CRUD wiring * [P6-T1] object tab type + openObjectTab dedup * [P6-T2] extract ObjectDetail for object tabs * [P6-T3] Objects view two-pane sidebar + workspace * [P6-T4] object-tab content + per-type tab icons * [P7-T1] Version bump 0.7.5 -> 0.7.6 * [P7-T2] docs sync README/ROADMAP/AGENTS for v0.7.6 * [P7-T3] chore: Cargo.lock version sync 0.7.5 -> 0.7.6 * fix(ui): object tab icon stacks above name (preflight svg block) * fix(ui): optically center object tab icon with name * fix(ui): object tab icon matches query/table icon handling * [UI-POLISH-1] objectForm tab type + openFormTab store action * [UI-POLISH-2] ObjectFormTab + KindForm with Visual/SQL toggle * [UI-POLISH-3] route create/edit through form tabs; remove modal * docs: create/edit now open as form tabs (AGENTS sync) * [FB-1] follow app styling patterns + schema dropdown in forms * [FB-2] Monaco editor for function body + view definition * [FB-3] form tabs styled like viewers + in-cell editing * [FB-5] focus outline scoped to input area (label excluded) * [FB-6] no amber focus outline on Monaco body/definition rows * [FB-7] header dedupe + schema default + full edit prefill * [FB-8] SQL view in read-only Monaco editor * docs: roadmap — table create/edit + relationships (next) * docs: roadmap — Admin follow-up is 0.7.7 (next after 0.7.6)
This commit is contained in:
@@ -156,7 +156,7 @@ Cut a release from the **`prod`** branch (never feature branches) by tagging it
|
||||
**Before tagging**, keep everything in sync:
|
||||
- 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
|
||||
- **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.5/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
|
||||
- **README download links are static (versioned)** — both download tables (top **Download** section + **Which file should I download?**) link directly to the release-tag assets (`releases/download/v0.7.6/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
|
||||
- **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
|
||||
@@ -210,6 +210,7 @@ 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: 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 |
|
||||
| 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 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 |
|
||||
@@ -290,6 +291,8 @@ 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) |
|
||||
| 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 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. |
|
||||
|
||||
### Query Editor
|
||||
|
||||
@@ -31,21 +31,21 @@
|
||||
|
||||
## Download
|
||||
|
||||
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.5**):
|
||||
Grab the installer for your OS from the [latest release](https://github.com/AdrianBonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.6**):
|
||||
|
||||
| OS | Architecture | Download |
|
||||
| :--- | :--- | :--- |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.5_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline_0.7.5_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_0.7.5_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline_0.7.5_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_0.7.5_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline_0.7.5_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.5_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline_0.7.5_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.5-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline-0.7.5-1.x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_0.7.5_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline_0.7.5_amd64.AppImage) |
|
||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.6_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_aarch64.dmg) |
|
||||
| **macOS** | Intel | [Gridline_0.7.6_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64.dmg) |
|
||||
| **Windows** | x64 | [Gridline_0.7.6_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64-setup.exe) |
|
||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.6_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.deb) |
|
||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.6-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline-0.7.6-1.x86_64.rpm) |
|
||||
| **Other Linux** | amd64 | [Gridline_0.7.6_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.AppImage) |
|
||||
|
||||
> Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below. All installers are **unsigned** — see the [notes](#which-file-should-i-download) on first-launch warnings.
|
||||
|
||||
<!--
|
||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.5
|
||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.6
|
||||
release assets, not at a moving "latest" target. On every new release,
|
||||
update BOTH tables here (Download + Which file should I download?) to the
|
||||
new version's asset names, which are tauri-action's default naming:
|
||||
@@ -83,6 +83,8 @@ Gridline is built for developers and small teams who manage multiple database en
|
||||
|
||||
## Recent Changes
|
||||
|
||||
- **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-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)**.
|
||||
@@ -256,16 +258,16 @@ Code signing **will be added in the future** (Apple Developer Program + a Window
|
||||
|
||||
#### Which file should I download?
|
||||
|
||||
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.5**):
|
||||
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.6**):
|
||||
|
||||
| Your system | Download this | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.5_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline_0.7.5_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_0.7.5_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline_0.7.5_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_0.7.5_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline_0.7.5_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_0.7.5_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline_0.7.5_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.5_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.5-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline-0.7.5-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.5-1.x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_0.7.5_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.5/Gridline_0.7.5_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.6_aarch64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||
| macOS **Intel** | [Gridline_0.7.6_x64.dmg](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64.dmg) | `x64` = Intel/AMD |
|
||||
| **Windows** (most PCs) | [Gridline_0.7.6_x64-setup.exe](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||
| **Debian / Ubuntu** | [Gridline_0.7.6_amd64.deb](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.6_amd64.deb` |
|
||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.6-1.x86_64.rpm](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline-0.7.6-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.6-1.x86_64.rpm` |
|
||||
| **Any other Linux** | [Gridline_0.7.6_amd64.AppImage](https://github.com/AdrianBonpin/gridline/releases/download/v0.7.6/Gridline_0.7.6_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||
|
||||
**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.
|
||||
|
||||
@@ -275,8 +277,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
|
||||
|
||||
```bash
|
||||
git checkout prod && git pull
|
||||
git tag v0.7.5
|
||||
git push origin v0.7.5
|
||||
git tag v0.7.6
|
||||
git push origin v0.7.6
|
||||
```
|
||||
|
||||
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**.
|
||||
@@ -360,14 +362,14 @@ gridline/
|
||||
|
||||
## Roadmap
|
||||
|
||||
The full plan — in-development (v0.7.5), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
|
||||
The full plan — in-development (v0.7.6), next-up, queue, and shipped history — lives in **[ROADMAP.md](./ROADMAP.md)**.
|
||||
|
||||
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
|
||||
- **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
|
||||
|
||||
✅ **[View the full roadmap →](./ROADMAP.md)**
|
||||
|
||||
+17
-26
@@ -6,6 +6,14 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
||||
|
||||
---
|
||||
|
||||
## ✅ 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)
|
||||
|
||||
- **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.
|
||||
@@ -30,37 +38,17 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
||||
|
||||
## 🎯 Next up
|
||||
|
||||
### Full Object Management (PostgreSQL)
|
||||
|
||||
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.
|
||||
|
||||
- Right-click any object → create / edit / drop with a generated-SQL preview before applying
|
||||
- Enums: add/remove values, rename types
|
||||
- Functions & procedures: edit signature + body, drop overloads by signature
|
||||
- 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)
|
||||
### Admin follow-up (0.7.7)
|
||||
|
||||
- **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
|
||||
### Table & relationship management (next)
|
||||
|
||||
- **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.
|
||||
- **Create table** — a "Create Table…" tab in the workspace using the same Visual ⇄ SQL flow as object create/edit: a columns grid (name / type / nullable / default / PK per row, add/remove rows) with a live SQL preview
|
||||
- **Edit table (robust column diff)** — open an existing table's columns in the same grid; on Stage, diff old vs new columns and emit the right statements, one per queue item: `ADD COLUMN`, `DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN … TYPE`, `ALTER COLUMN … SET|DROP DEFAULT`, `SET|DROP NOT NULL`
|
||||
- **Relationships between tables & schemas** — FK create/edit/drop from a table editor (including cross-schema FKs) and schema-level relationship maintenance
|
||||
- **Related niceties** — column reordering (PG requires a table rebuild — decide semantics), table options (tablespace, row-level security), multi-column PRIMARY KEY on create
|
||||
|
||||
## 📋 In the queue
|
||||
|
||||
@@ -142,6 +130,9 @@ Deferred from 0.7.0, slated for this bucket:
|
||||
|
||||
## ✅ 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)
|
||||
- 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)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gridline",
|
||||
"private": true,
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.6",
|
||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -1783,7 +1783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridline"
|
||||
version = "0.7.5"
|
||||
version = "0.7.6"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gridline"
|
||||
version = "0.7.5"
|
||||
version = "0.7.6"
|
||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -223,6 +223,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
let result = create_connection_inner(&st, input.clone()).unwrap();
|
||||
assert_eq!(result.name, "Prod");
|
||||
@@ -254,6 +255,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
assert!(create_connection_inner(&st, input).is_err());
|
||||
}
|
||||
@@ -283,6 +285,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
};
|
||||
let conn = create_connection_inner(&st, input).unwrap();
|
||||
delete_connection_inner(&st, &conn.id).unwrap();
|
||||
@@ -300,6 +303,7 @@ mod tests {
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
@@ -331,6 +335,7 @@ mod tests {
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
|
||||
@@ -2155,6 +2155,10 @@ pub async fn execute_change(
|
||||
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { sql, .. } => {
|
||||
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
@@ -2247,6 +2251,9 @@ pub async fn execute_change(
|
||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
table,
|
||||
columns,
|
||||
@@ -2319,6 +2326,9 @@ pub async fn execute_change(
|
||||
.map_err(|e| sanitize_error(&format!("{e}")))?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::Ddl { .. } => {
|
||||
return Err("Object management is PostgreSQL-only".to_string());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
@@ -2820,6 +2830,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
|
||||
/// clauses.
|
||||
#[test]
|
||||
|
||||
@@ -53,6 +53,7 @@ fn demo_connection_input(db_path: &Path) -> ConnectionInput {
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
|
||||
@@ -102,6 +102,7 @@ pub fn import_connections_inner(
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
||||
use_keychain: true,
|
||||
};
|
||||
match store.create_connection(input) {
|
||||
Ok(_) => imported += 1,
|
||||
@@ -214,6 +215,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
});
|
||||
let json = export_connections_inner(&st).unwrap();
|
||||
assert!(json.contains("\"name\""));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use tauri::State;
|
||||
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||
use crate::db::object_crud::build_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) }
|
||||
|
||||
@@ -146,6 +147,47 @@ 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
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "objects.test.rs"]
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
#[ignore]
|
||||
async fn object_dependencies_for_table_includes_view() {
|
||||
|
||||
@@ -1060,6 +1060,7 @@ mod tests {
|
||||
username: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
|
||||
@@ -292,6 +292,14 @@ pub fn pg_extensions_query() -> 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.
|
||||
///
|
||||
/// Returns index name, schema, table, definition (`pg_get_indexdef`),
|
||||
@@ -624,4 +632,13 @@ mod tests {
|
||||
let sql = pg_extensions_query();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod introspection;
|
||||
pub mod mysql;
|
||||
pub mod object_crud;
|
||||
pub mod object_ddl;
|
||||
pub mod pool;
|
||||
pub mod tls;
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
//! Pure SQL builders for PostgreSQL object CRUD. No DB I/O.
|
||||
|
||||
use crate::db::object_ddl::{quote_ident, validate_object_name};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SequenceParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub action: SequenceAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum SequenceAction {
|
||||
Create { increment: Option<String>, min_value: Option<String>, max_value: Option<String>, start: Option<String>, cycle: bool },
|
||||
Alter { increment: Option<String>, min_value: Option<String>, max_value: Option<String>, cycle: Option<bool> },
|
||||
Restart { with: Option<String> },
|
||||
Drop,
|
||||
}
|
||||
|
||||
fn qual(schema: &str, name: &str) -> Result<String, String> {
|
||||
validate_object_name(schema)?;
|
||||
validate_object_name(name)?;
|
||||
Ok(format!("{}.{}", quote_ident(schema), quote_ident(name)))
|
||||
}
|
||||
|
||||
pub fn sequence_ddl(p: &SequenceParams) -> Result<Vec<String>, String> {
|
||||
let q = qual(&p.schema, &p.name)?;
|
||||
Ok(vec![match &p.action {
|
||||
SequenceAction::Create { increment, min_value, max_value, start, cycle } => {
|
||||
let mut s = format!("CREATE SEQUENCE {}", q);
|
||||
if let Some(v) = increment { s.push_str(&format!("\n INCREMENT BY {}", v)); }
|
||||
if let Some(v) = min_value { s.push_str(&format!("\n MINVALUE {}", v)); }
|
||||
if let Some(v) = max_value { s.push_str(&format!("\n MAXVALUE {}", v)); }
|
||||
if let Some(v) = start { s.push_str(&format!("\n START WITH {}", v)); }
|
||||
s.push_str(if *cycle { "\n CYCLE" } else { "\n NO CYCLE" });
|
||||
s
|
||||
}
|
||||
SequenceAction::Alter { increment, min_value, max_value, cycle } => {
|
||||
let mut s = format!("ALTER SEQUENCE {}", q);
|
||||
if let Some(v) = increment { s.push_str(&format!("\n INCREMENT BY {}", v)); }
|
||||
if let Some(v) = min_value { s.push_str(&format!("\n MINVALUE {}", v)); }
|
||||
if let Some(v) = max_value { s.push_str(&format!("\n MAXVALUE {}", v)); }
|
||||
if let Some(c) = cycle { s.push_str(if *c { "\n CYCLE" } else { "\n NO CYCLE" }); }
|
||||
s
|
||||
}
|
||||
SequenceAction::Restart { with } => match with {
|
||||
Some(v) => format!("ALTER SEQUENCE {} RESTART WITH {}", q, v),
|
||||
None => format!("ALTER SEQUENCE {} RESTART", q),
|
||||
},
|
||||
SequenceAction::Drop => format!("DROP SEQUENCE {}", q),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Escape an enum label as a single-quoted SQL literal (empty rejected).
|
||||
pub fn validate_enum_label(label: &str) -> Result<String, String> {
|
||||
let t = label.trim();
|
||||
if t.is_empty() {
|
||||
return Err("Enum label must not be empty".into());
|
||||
}
|
||||
Ok(format!("'{}'", t.replace('\'', "''")))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EnumParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub action: EnumAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum EnumAction {
|
||||
Create { labels: Vec<String> },
|
||||
RenameType { new_name: String },
|
||||
AddValue { value: String, if_not_exists: bool, before: Option<String>, after: Option<String> },
|
||||
RenameValue { from: String, to: String },
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn enum_ddl(p: &EnumParams) -> Result<Vec<String>, String> {
|
||||
let q = qual(&p.schema, &p.name)?;
|
||||
Ok(vec![match &p.action {
|
||||
EnumAction::Create { labels } => {
|
||||
let mut out = String::new();
|
||||
for l in labels {
|
||||
if !out.is_empty() { out.push_str(", "); }
|
||||
out.push_str(&validate_enum_label(l)?);
|
||||
}
|
||||
format!("CREATE TYPE {} AS ENUM ({})", q, out)
|
||||
}
|
||||
EnumAction::RenameType { new_name } => {
|
||||
validate_object_name(new_name)?;
|
||||
format!("ALTER TYPE {} RENAME TO {}", q, quote_ident(new_name))
|
||||
}
|
||||
EnumAction::AddValue { value, if_not_exists, before, after } => {
|
||||
let v = format!(" {}", validate_enum_label(value)?);
|
||||
let ine = if *if_not_exists { " IF NOT EXISTS" } else { "" };
|
||||
let pos = match (before, after) {
|
||||
(Some(b), None) => format!(" BEFORE {}", validate_enum_label(b)?),
|
||||
(None, Some(a)) => format!(" AFTER {}", validate_enum_label(a)?),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!("ALTER TYPE {} ADD VALUE{}{}{}", q, ine, v, pos)
|
||||
}
|
||||
EnumAction::RenameValue { from, to } => {
|
||||
format!("ALTER TYPE {} RENAME VALUE {} TO {}", q, validate_enum_label(from)?, validate_enum_label(to)?)
|
||||
}
|
||||
EnumAction::Drop => format!("DROP TYPE {}", q),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Validate a SQL expression body: non-empty, no trailing semicolon.
|
||||
pub fn validate_expression(expr: &str) -> Result<String, String> {
|
||||
let t = expr.trim();
|
||||
if t.is_empty() {
|
||||
return Err("Expression must not be empty".into());
|
||||
}
|
||||
if t.ends_with(';') {
|
||||
return Err("Expression must not end with ';'".into());
|
||||
}
|
||||
Ok(t.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ViewParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub materialized: bool,
|
||||
pub action: ViewAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum ViewAction {
|
||||
Create { definition: String },
|
||||
Replace { definition: String },
|
||||
Refresh,
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn view_ddl(p: &ViewParams) -> Result<Vec<String>, String> {
|
||||
let q = qual(&p.schema, &p.name)?;
|
||||
Ok(match &p.action {
|
||||
ViewAction::Create { definition } if !p.materialized =>
|
||||
vec![format!("CREATE OR REPLACE VIEW {} AS\n{}", q, definition)],
|
||||
ViewAction::Create { definition } =>
|
||||
vec![format!("CREATE MATERIALIZED VIEW {} AS\n{}", q, definition)],
|
||||
ViewAction::Replace { definition } if !p.materialized =>
|
||||
vec![format!("CREATE OR REPLACE VIEW {} AS\n{}", q, definition)],
|
||||
ViewAction::Replace { definition } => vec![
|
||||
format!("DROP MATERIALIZED VIEW {}", q),
|
||||
format!("CREATE MATERIALIZED VIEW {} AS\n{}", q, definition),
|
||||
],
|
||||
ViewAction::Refresh if p.materialized => vec![format!("REFRESH MATERIALIZED VIEW {}", q)],
|
||||
ViewAction::Refresh => return Err("Cannot REFRESH a non-materialized view".into()),
|
||||
ViewAction::Drop if p.materialized => vec![format!("DROP MATERIALIZED VIEW {}", q)],
|
||||
ViewAction::Drop => vec![format!("DROP VIEW {}", q)],
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExtensionParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub action: ExtensionAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum ExtensionAction {
|
||||
Create { version: Option<String> },
|
||||
SetSchema { new_schema: String },
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn extension_ddl(p: &ExtensionParams) -> Result<Vec<String>, String> {
|
||||
validate_object_name(&p.schema)?;
|
||||
validate_object_name(&p.name)?;
|
||||
let name = quote_ident(&p.name);
|
||||
let schema = quote_ident(&p.schema);
|
||||
Ok(vec![match &p.action {
|
||||
ExtensionAction::Create { version: None } =>
|
||||
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {}", name, schema),
|
||||
ExtensionAction::Create { version: Some(v) } =>
|
||||
format!("CREATE EXTENSION IF NOT EXISTS {} WITH SCHEMA {} VERSION '{}'", name, schema, v.replace('\'', "''")),
|
||||
ExtensionAction::SetSchema { new_schema } => {
|
||||
validate_object_name(new_schema)?;
|
||||
format!("ALTER EXTENSION {} SET SCHEMA {}", name, quote_ident(new_schema))
|
||||
}
|
||||
ExtensionAction::Drop => format!("DROP EXTENSION {}", name),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Quote a list of identifiers, joined with ", ".
|
||||
fn quoted_cols(v: &[String]) -> String {
|
||||
v.iter().map(|c| quote_ident(c)).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct IndexParams {
|
||||
pub schema: String,
|
||||
pub table: String,
|
||||
pub name: String,
|
||||
pub action: IndexAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum IndexAction {
|
||||
Create { unique: bool, method: String, columns: Vec<String>, predicate: Option<String> },
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn index_ddl(p: &IndexParams) -> Result<Vec<String>, String> {
|
||||
validate_object_name(&p.schema)?;
|
||||
validate_object_name(&p.table)?;
|
||||
validate_object_name(&p.name)?;
|
||||
let table = format!("{}.{}", quote_ident(&p.schema), quote_ident(&p.table));
|
||||
let name = quote_ident(&p.name);
|
||||
Ok(vec![match &p.action {
|
||||
IndexAction::Create { unique, method, columns, predicate } => {
|
||||
if columns.is_empty() { return Err("Index requires at least one column".into()); }
|
||||
let cols = quoted_cols(columns);
|
||||
let unique = if *unique { "UNIQUE " } else { "" };
|
||||
let method = if method.trim().is_empty() { String::new() } else { format!(" USING {}", method.trim()) };
|
||||
let pred = match predicate {
|
||||
Some(p) if !p.trim().is_empty() => format!(" WHERE {}", validate_expression(p)?),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!("CREATE {}INDEX {} ON {}{} ({}){}", unique, name, table, method, cols, pred)
|
||||
}
|
||||
IndexAction::Drop => format!("DROP INDEX {}.{}", quote_ident(&p.schema), name),
|
||||
}])
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConstraintParams {
|
||||
pub schema: String,
|
||||
pub table: String,
|
||||
pub name: String,
|
||||
pub action: ConstraintAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum ConstraintAction {
|
||||
Check { expression: String },
|
||||
Unique { columns: Vec<String> },
|
||||
PrimaryKey { columns: Vec<String> },
|
||||
ForeignKey { columns: Vec<String>, ref_schema: String, ref_table: String, ref_columns: Vec<String> },
|
||||
Drop,
|
||||
}
|
||||
|
||||
pub fn constraint_ddl(p: &ConstraintParams) -> Result<Vec<String>, String> {
|
||||
validate_object_name(&p.schema)?;
|
||||
validate_object_name(&p.table)?;
|
||||
validate_object_name(&p.name)?;
|
||||
let table = format!("{}.{}", quote_ident(&p.schema), quote_ident(&p.table));
|
||||
let name = quote_ident(&p.name);
|
||||
Ok(vec![match &p.action {
|
||||
ConstraintAction::Check { expression } =>
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} CHECK ({})", table, name, validate_expression(expression)?),
|
||||
ConstraintAction::Unique { columns } => {
|
||||
if columns.is_empty() { return Err("UNIQUE constraint requires a column".into()); }
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} UNIQUE ({})", table, name, quoted_cols(columns))
|
||||
}
|
||||
ConstraintAction::PrimaryKey { columns } => {
|
||||
if columns.is_empty() { return Err("PRIMARY KEY requires a column".into()); }
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} PRIMARY KEY ({})", table, name, quoted_cols(columns))
|
||||
}
|
||||
ConstraintAction::ForeignKey { columns, ref_schema, ref_table, ref_columns } => {
|
||||
if columns.is_empty() || ref_columns.is_empty() { return Err("FOREIGN KEY requires source and referenced columns".into()); }
|
||||
validate_object_name(ref_schema)?;
|
||||
validate_object_name(ref_table)?;
|
||||
let refq = format!("{}.{}", quote_ident(ref_schema), quote_ident(ref_table));
|
||||
format!("ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", table, name, quoted_cols(columns), refq, quoted_cols(ref_columns))
|
||||
}
|
||||
ConstraintAction::Drop => format!("ALTER TABLE {} DROP CONSTRAINT {}", table, name),
|
||||
}])
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FunctionArg { pub mode: String, pub name: String, #[serde(rename = "type")] pub type_: String }
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FunctionParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub is_procedure: bool,
|
||||
pub action: FunctionAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum FunctionAction {
|
||||
CreateOrReplace { args: Vec<FunctionArg>, return_type: Option<String>, language: String, body: String, volatility: Option<String>, strict: bool },
|
||||
Drop { arg_types: Vec<String> },
|
||||
}
|
||||
|
||||
fn arg_sql(a: &FunctionArg) -> String {
|
||||
let mode = match a.mode.trim().to_lowercase().as_str() {
|
||||
"in" | "" => String::new(),
|
||||
m => format!("{} ", m.to_uppercase()),
|
||||
};
|
||||
format!("{}{} {}", mode, a.name, a.type_)
|
||||
}
|
||||
|
||||
pub fn function_ddl(p: &FunctionParams) -> Result<Vec<String>, String> {
|
||||
let q = qual(&p.schema, &p.name)?;
|
||||
let kind = if p.is_procedure { "PROCEDURE" } else { "FUNCTION" };
|
||||
Ok(vec![match &p.action {
|
||||
FunctionAction::CreateOrReplace { args, return_type, language, body, volatility, strict } => {
|
||||
let arglist: Vec<String> = args.iter().map(arg_sql).collect();
|
||||
let ret = match (p.is_procedure, return_type) {
|
||||
(false, Some(r)) => format!(" RETURNS {}", r),
|
||||
_ => String::new(),
|
||||
};
|
||||
let vol = match volatility.as_deref() {
|
||||
Some("IMMUTABLE") => " IMMUTABLE", Some("STABLE") => " STABLE", Some("VOLATILE") => " VOLATILE", _ => "",
|
||||
};
|
||||
let strict = if *strict { " STRICT" } else { "" };
|
||||
format!("CREATE OR REPLACE {} {}({}){} LANGUAGE {}{}{} AS $${}$$", kind, q, arglist.join(", "), ret, language, vol, strict, body)
|
||||
}
|
||||
FunctionAction::Drop { arg_types } => format!("DROP {} {}({})", kind, q, arg_types.join(", ")),
|
||||
}])
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TriggerParams {
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub action: TriggerAction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum TriggerAction {
|
||||
Create { table: String, timing: String, events: Vec<String>, orientation: String, function_schema: String, function_name: String, function_args: Vec<String>, when: Option<String> },
|
||||
Enable { table: String },
|
||||
Disable { table: String },
|
||||
Drop { table: String },
|
||||
}
|
||||
|
||||
pub fn trigger_ddl(p: &TriggerParams) -> Result<Vec<String>, String> {
|
||||
validate_object_name(&p.schema)?;
|
||||
validate_object_name(&p.name)?;
|
||||
let schema = quote_ident(&p.schema);
|
||||
let name = quote_ident(&p.name);
|
||||
Ok(vec![match &p.action {
|
||||
TriggerAction::Create { table, timing, events, orientation, function_schema, function_name, function_args, when } => {
|
||||
validate_object_name(table)?;
|
||||
validate_object_name(function_schema)?;
|
||||
validate_object_name(function_name)?;
|
||||
let evs = events.join(" OR ");
|
||||
let orient = match orientation.trim().to_uppercase().as_str() { "STATEMENT" => "FOR EACH STATEMENT", _ => "FOR EACH ROW" };
|
||||
let when_clause = match when {
|
||||
Some(w) if !w.trim().is_empty() => format!(" WHEN ({})", w),
|
||||
_ => String::new(),
|
||||
};
|
||||
let fq = format!("{}.{}", quote_ident(function_schema), quote_ident(function_name));
|
||||
format!("CREATE TRIGGER {} {} {} ON {}.{} {}{} EXECUTE FUNCTION {}({})",
|
||||
name, timing, evs, schema, quote_ident(table), orient, when_clause, fq, function_args.join(", "))
|
||||
}
|
||||
TriggerAction::Enable { table } => format!("ALTER TABLE {}.{} ENABLE TRIGGER {}", schema, quote_ident(table), name),
|
||||
TriggerAction::Disable { table } => format!("ALTER TABLE {}.{} DISABLE TRIGGER {}", schema, quote_ident(table), name),
|
||||
TriggerAction::Drop { table } => format!("DROP TRIGGER {} ON {}.{}", name, schema, quote_ident(table)),
|
||||
}])
|
||||
}
|
||||
|
||||
/// Dispatch a DDL build by kind. `params` is the JSON payload from the frontend.
|
||||
/// Returns one or more single SQL statements.
|
||||
pub fn build_ddl(kind: &str, params: serde_json::Value) -> Result<Vec<String>, String> {
|
||||
match kind {
|
||||
"sequence" => {
|
||||
let p: SequenceParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
sequence_ddl(&p)
|
||||
}
|
||||
"enum" => {
|
||||
let p: EnumParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
enum_ddl(&p)
|
||||
}
|
||||
"view" => {
|
||||
let p: ViewParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
view_ddl(&p)
|
||||
}
|
||||
"extension" => {
|
||||
let p: ExtensionParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
extension_ddl(&p)
|
||||
}
|
||||
"index" => {
|
||||
let p: IndexParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
index_ddl(&p)
|
||||
}
|
||||
"constraint" => {
|
||||
let p: ConstraintParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
constraint_ddl(&p)
|
||||
}
|
||||
"function" | "procedure" => {
|
||||
let mut p: FunctionParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
p.is_procedure = kind == "procedure";
|
||||
function_ddl(&p)
|
||||
}
|
||||
"trigger" => {
|
||||
let p: TriggerParams = serde_json::from_value(params).map_err(|e| e.to_string())?;
|
||||
trigger_ddl(&p)
|
||||
}
|
||||
other => Err(format!("Unsupported object kind: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_enum_label_quotes_and_doubles() {
|
||||
assert_eq!(validate_enum_label("admin").unwrap(), "'admin'");
|
||||
assert_eq!(validate_enum_label("user's").unwrap(), "'user''s'");
|
||||
assert!(validate_enum_label("").is_err());
|
||||
assert!(validate_enum_label(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_expression_rejects_empty_and_trailing_semicolon() {
|
||||
assert_eq!(validate_expression("amount > 0").unwrap(), "amount > 0");
|
||||
assert!(validate_expression("").is_err());
|
||||
assert!(validate_expression("amount > 0;").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ddl_rejects_unknown_kind() {
|
||||
let res = build_ddl("bogus", serde_json::json!({}));
|
||||
assert!(res.is_err());
|
||||
assert!(res.unwrap_err().contains("Unsupported object kind: bogus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_create_full() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "users_id_seq",
|
||||
"action": { "op": "create", "increment": "1", "min_value": "1", "max_value": "9223372036854775807", "start": "1", "cycle": false }
|
||||
});
|
||||
let sql = build_ddl("sequence", p).unwrap();
|
||||
assert_eq!(sql, vec!["CREATE SEQUENCE \"public\".\"users_id_seq\"\n INCREMENT BY 1\n MINVALUE 1\n MAXVALUE 9223372036854775807\n START WITH 1\n NO CYCLE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_alter_partial_and_cycle_option() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "s",
|
||||
"action": { "op": "alter", "increment": "2", "cycle": true }
|
||||
});
|
||||
let sql = build_ddl("sequence", p).unwrap();
|
||||
assert_eq!(sql, vec!["ALTER SEQUENCE \"public\".\"s\"\n INCREMENT BY 2\n CYCLE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_restart_with_value() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "s", "action": { "op": "restart", "with": "100" } });
|
||||
assert_eq!(build_ddl("sequence", p).unwrap(), vec!["ALTER SEQUENCE \"public\".\"s\" RESTART WITH 100"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "s", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("sequence", p).unwrap(), vec!["DROP SEQUENCE \"public\".\"s\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_rejects_bad_name() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "a; DROP", "action": { "op": "drop" } });
|
||||
assert!(build_ddl("sequence", p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_create_quotes_labels() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "role", "action": { "op": "create", "labels": ["admin", "user's"] } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["CREATE TYPE \"public\".\"role\" AS ENUM ('admin', 'user''s')"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_rename_type() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "role", "action": { "op": "rename_type", "new_name": "user_role" } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"role\" RENAME TO \"user_role\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_add_value_with_position() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "add_value", "value": "orange", "if_not_exists": true, "before": "red", "after": null } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"color\" ADD VALUE IF NOT EXISTS 'orange' BEFORE 'red'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_add_value_plain() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "add_value", "value": "green", "if_not_exists": false, "before": null, "after": null } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"color\" ADD VALUE 'green'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_rename_value() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "rename_value", "from": "purple", "to": "mauve" } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["ALTER TYPE \"public\".\"color\" RENAME VALUE 'purple' TO 'mauve'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "role", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("enum", p).unwrap(), vec!["DROP TYPE \"public\".\"role\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_add_value_rejects_empty_label() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "color", "action": { "op": "add_value", "value": "", "if_not_exists": false, "before": null, "after": null } });
|
||||
assert!(build_ddl("enum", p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_create_or_replace() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "v_users", "materialized": false, "action": { "op": "create", "definition": "SELECT * FROM users" } });
|
||||
assert_eq!(build_ddl("view", p).unwrap(), vec!["CREATE OR REPLACE VIEW \"public\".\"v_users\" AS\nSELECT * FROM users"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matview_create() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "mv_sales", "materialized": true, "action": { "op": "create", "definition": "SELECT count(*) FROM sales" } });
|
||||
assert_eq!(build_ddl("view", p).unwrap(), vec!["CREATE MATERIALIZED VIEW \"public\".\"mv_sales\" AS\nSELECT count(*) FROM sales"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matview_replace_is_drop_then_create() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "mv_sales", "materialized": true, "action": { "op": "replace", "definition": "SELECT count(*) FROM sales" } });
|
||||
assert_eq!(build_ddl("view", p).unwrap(), vec![
|
||||
"DROP MATERIALIZED VIEW \"public\".\"mv_sales\"",
|
||||
"CREATE MATERIALIZED VIEW \"public\".\"mv_sales\" AS\nSELECT count(*) FROM sales",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matview_refresh() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "mv_sales", "materialized": true, "action": { "op": "refresh" } });
|
||||
assert_eq!(build_ddl("view", p).unwrap(), vec!["REFRESH MATERIALIZED VIEW \"public\".\"mv_sales\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_refresh_errors() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "v_users", "materialized": false, "action": { "op": "refresh" } });
|
||||
assert!(build_ddl("view", p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "v_users", "materialized": false, "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("view", p).unwrap(), vec!["DROP VIEW \"public\".\"v_users\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_create_with_version() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "create", "version": "1.3" } });
|
||||
assert_eq!(build_ddl("extension", p).unwrap(), vec!["CREATE EXTENSION IF NOT EXISTS \"pgcrypto\" WITH SCHEMA \"public\" VERSION '1.3'"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_create_without_version() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "create", "version": null } });
|
||||
assert_eq!(build_ddl("extension", p).unwrap(), vec!["CREATE EXTENSION IF NOT EXISTS \"pgcrypto\" WITH SCHEMA \"public\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_set_schema() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "set_schema", "new_schema": "utils" } });
|
||||
assert_eq!(build_ddl("extension", p).unwrap(), vec!["ALTER EXTENSION \"pgcrypto\" SET SCHEMA \"utils\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "pgcrypto", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("extension", p).unwrap(), vec!["DROP EXTENSION \"pgcrypto\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_create_unique_btree_with_predicate() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "table": "users", "name": "users_email_key",
|
||||
"action": { "op": "create", "unique": true, "method": "btree", "columns": ["email"], "predicate": "email IS NOT NULL" }
|
||||
});
|
||||
assert_eq!(build_ddl("index", p).unwrap(),
|
||||
vec!["CREATE UNIQUE INDEX \"users_email_key\" ON \"public\".\"users\" USING btree (\"email\") WHERE email IS NOT NULL"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_create_no_method_no_predicate() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "table": "users", "name": "i_name",
|
||||
"action": { "op": "create", "unique": false, "method": "", "columns": ["a", "b"], "predicate": null }
|
||||
});
|
||||
assert_eq!(build_ddl("index", p).unwrap(),
|
||||
vec!["CREATE INDEX \"i_name\" ON \"public\".\"users\" (\"a\", \"b\")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "i_name", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("index", p).unwrap(), vec!["DROP INDEX \"public\".\"i_name\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_create_rejects_empty_columns() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "i", "action": { "op": "create", "unique": false, "method": "", "columns": [], "predicate": null } });
|
||||
assert!(build_ddl("index", p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_check() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "orders", "name": "ck_pos", "action": { "op": "check", "expression": "amount > 0" } });
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"ck_pos\" CHECK (amount > 0)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_unique() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "u_email", "action": { "op": "unique", "columns": ["email"] } });
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"users\" ADD CONSTRAINT \"u_email\" UNIQUE (\"email\")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_primary_key() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "users", "name": "pk_users", "action": { "op": "primary_key", "columns": ["id"] } });
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"users\" ADD CONSTRAINT \"pk_users\" PRIMARY KEY (\"id\")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_foreign_key() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "table": "orders", "name": "fk_user",
|
||||
"action": { "op": "foreign_key", "columns": ["user_id"], "ref_schema": "public", "ref_table": "users", "ref_columns": ["id"] }
|
||||
});
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"orders\" ADD CONSTRAINT \"fk_user\" FOREIGN KEY (\"user_id\") REFERENCES \"public\".\"users\" (\"id\")"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_drop() {
|
||||
let p = serde_json::json!({ "schema": "public", "table": "orders", "name": "ck_pos", "action": { "op": "drop" } });
|
||||
assert_eq!(build_ddl("constraint", p).unwrap(),
|
||||
vec!["ALTER TABLE \"public\".\"orders\" DROP CONSTRAINT \"ck_pos\""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_create_or_replace_basic() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "add", "is_procedure": false,
|
||||
"action": { "op": "create_or_replace",
|
||||
"args": [ { "mode": "in", "name": "a", "type": "int" }, { "mode": "in", "name": "b", "type": "int" } ],
|
||||
"return_type": "int", "language": "plpgsql", "body": "BEGIN RETURN a+b; END",
|
||||
"volatility": "IMMUTABLE", "strict": true }
|
||||
});
|
||||
assert_eq!(build_ddl("function", p).unwrap(), vec![
|
||||
"CREATE OR REPLACE FUNCTION \"public\".\"add\"(a int, b int) RETURNS int LANGUAGE plpgsql IMMUTABLE STRICT AS $$BEGIN RETURN a+b; END$$"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn procedure_create_or_replace_no_returns() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "do_thing", "is_procedure": true,
|
||||
"action": { "op": "create_or_replace",
|
||||
"args": [ { "mode": "in", "name": "x", "type": "int" } ],
|
||||
"return_type": null, "language": "plpgsql", "body": "BEGIN PERFORM x; END",
|
||||
"volatility": null, "strict": false }
|
||||
});
|
||||
assert_eq!(build_ddl("procedure", p).unwrap(), vec![
|
||||
"CREATE OR REPLACE PROCEDURE \"public\".\"do_thing\"(x int) LANGUAGE plpgsql AS $$BEGIN PERFORM x; END$$"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_drop_by_signature() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "add", "is_procedure": false, "action": { "op": "drop", "arg_types": ["int", "int"] } });
|
||||
assert_eq!(build_ddl("function", p).unwrap(), vec!["DROP FUNCTION \"public\".\"add\"(int, int)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn procedure_drop_by_signature() {
|
||||
let p = serde_json::json!({ "schema": "public", "name": "do_thing", "is_procedure": true, "action": { "op": "drop", "arg_types": ["int"] } });
|
||||
assert_eq!(build_ddl("procedure", p).unwrap(), vec!["DROP PROCEDURE \"public\".\"do_thing\"(int)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_create_row() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "tr_audit",
|
||||
"action": { "op": "create", "table": "orders", "timing": "BEFORE",
|
||||
"events": ["INSERT", "UPDATE"], "orientation": "ROW",
|
||||
"function_schema": "public", "function_name": "audit_fn",
|
||||
"function_args": [], "when": null }
|
||||
});
|
||||
assert_eq!(build_ddl("trigger", p).unwrap(), vec![
|
||||
"CREATE TRIGGER \"tr_audit\" BEFORE INSERT OR UPDATE ON \"public\".\"orders\" FOR EACH ROW EXECUTE FUNCTION \"public\".\"audit_fn\"()"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_create_with_when_and_args() {
|
||||
let p = serde_json::json!({
|
||||
"schema": "public", "name": "tr_audit",
|
||||
"action": { "op": "create", "table": "orders", "timing": "AFTER",
|
||||
"events": ["UPDATE"], "orientation": "STATEMENT",
|
||||
"function_schema": "public", "function_name": "audit_fn",
|
||||
"function_args": ["'log'"], "when": "OLD.amount IS DISTINCT FROM NEW.amount" }
|
||||
});
|
||||
assert_eq!(build_ddl("trigger", p).unwrap(), vec![
|
||||
"CREATE TRIGGER \"tr_audit\" AFTER UPDATE ON \"public\".\"orders\" FOR EACH STATEMENT WHEN (OLD.amount IS DISTINCT FROM NEW.amount) EXECUTE FUNCTION \"public\".\"audit_fn\"('log')"
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_enable_disable_drop() {
|
||||
let base = |op: &str| serde_json::json!({ "schema": "public", "name": "tr_audit", "action": { "op": op, "table": "orders" } });
|
||||
assert_eq!(build_ddl("trigger", base("enable")).unwrap(), vec!["ALTER TABLE \"public\".\"orders\" ENABLE TRIGGER \"tr_audit\""]);
|
||||
assert_eq!(build_ddl("trigger", base("disable")).unwrap(), vec!["ALTER TABLE \"public\".\"orders\" DISABLE TRIGGER \"tr_audit\""]);
|
||||
assert_eq!(build_ddl("trigger", base("drop")).unwrap(), vec!["DROP TRIGGER \"tr_audit\" ON \"public\".\"orders\""]);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,8 @@ pub fn run() {
|
||||
objects::search_objects,
|
||||
objects::get_object_ddl,
|
||||
objects::get_object_dependencies,
|
||||
objects::build_object_ddl,
|
||||
objects::get_available_extensions,
|
||||
keychain::save_connection_password,
|
||||
keychain::get_connection_password,
|
||||
keychain::delete_connection_password,
|
||||
|
||||
@@ -23,6 +23,7 @@ pub struct Connection {
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub use_keychain: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -50,6 +51,12 @@ pub struct ConnectionInput {
|
||||
pub ssl_ca_path: Option<String>,
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub use_keychain: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -66,6 +73,7 @@ mod tests {
|
||||
username: Some("admin".to_string()),
|
||||
folder_id: Some("folder1".to_string()),
|
||||
tag_ids: vec!["tag1".to_string(), "tag2".to_string()],
|
||||
use_keychain: true,
|
||||
password: Some("secret123".to_string()),
|
||||
database: Some("mydb".to_string()),
|
||||
ssh_host: Some("jumphost.example.com".to_string()),
|
||||
@@ -133,6 +141,7 @@ mod tests {
|
||||
environment: None,
|
||||
favorite: false,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
database: Some("mydb".to_string()),
|
||||
@@ -177,6 +186,7 @@ mod tests {
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
favorite: true,
|
||||
created_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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,10 @@ pub enum Change {
|
||||
schema: String,
|
||||
table: String,
|
||||
},
|
||||
Ddl {
|
||||
id: String,
|
||||
sql: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Change {
|
||||
@@ -204,7 +208,8 @@ impl Change {
|
||||
| Change::AlterTable { id, .. }
|
||||
| Change::BulkInsert { id, .. }
|
||||
| Change::DropTable { id, .. }
|
||||
| Change::EmptyTable { id, .. } => id,
|
||||
| Change::EmptyTable { id, .. }
|
||||
| Change::Ddl { id, .. } => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,30 @@ pub fn run_migrations(conn: &Connection) -> Result<(), 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(())
|
||||
}
|
||||
|
||||
@@ -292,7 +316,49 @@ mod tests {
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0))
|
||||
.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]
|
||||
@@ -541,12 +607,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v7_bumps_schema_version_to_7() {
|
||||
fn v8_bumps_schema_version_to_8() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
run_migrations(&conn).unwrap();
|
||||
let ver: i64 = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||
.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 mut stmt = conn
|
||||
.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())?;
|
||||
let rows = stmt
|
||||
@@ -286,6 +286,7 @@ impl Store {
|
||||
tag_ids: vec![],
|
||||
created_at: row.get(20)?,
|
||||
updated_at: row.get(21)?,
|
||||
use_keychain: row.get(22)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -308,8 +309,8 @@ impl Store {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = Self::now();
|
||||
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)",
|
||||
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],
|
||||
"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, input.use_keychain],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
for tag_id in &input.tag_ids {
|
||||
@@ -341,6 +342,7 @@ impl Store {
|
||||
ssl_cert_path: input.ssl_cert_path,
|
||||
ssl_key_path: input.ssl_key_path,
|
||||
tag_ids: input.tag_ids,
|
||||
use_keychain: input.use_keychain,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
@@ -419,13 +421,13 @@ impl Store {
|
||||
let conn = self.conn.lock().map_err(|e| e.to_string())?;
|
||||
let now = Self::now();
|
||||
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![
|
||||
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, id
|
||||
input.environment, input.use_keychain, now, id
|
||||
],
|
||||
).map_err(|e| e.to_string())?;
|
||||
// Update tags
|
||||
@@ -463,6 +465,7 @@ impl Store {
|
||||
ssl_cert_path: input.ssl_cert_path,
|
||||
ssl_key_path: input.ssl_key_path,
|
||||
tag_ids: input.tag_ids.clone(),
|
||||
use_keychain: input.use_keychain,
|
||||
created_at: String::new(), // not updated
|
||||
updated_at: now,
|
||||
})
|
||||
@@ -954,6 +957,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
@@ -1002,6 +1006,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![t1.id.clone(), t2.id.clone()],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
@@ -1043,6 +1048,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store.delete_folder(&folder.id).unwrap();
|
||||
@@ -1082,6 +1088,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![tag.id.clone()],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store.delete_tag(&tag.id).unwrap();
|
||||
@@ -1142,6 +1149,7 @@ mod tests {
|
||||
ssl_key_path: Some("/etc/ssl/private/client-key.pem".into()),
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
let got = store.get_connections().unwrap();
|
||||
@@ -1194,6 +1202,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -1278,6 +1287,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
// Insert 510 rows — should trigger pruning beyond 500
|
||||
@@ -1334,6 +1344,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
@@ -1378,6 +1389,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
@@ -1480,6 +1492,7 @@ mod tests {
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
use_keychain: true,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gridline",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.6",
|
||||
"identifier": "com.adrianbonpin.gridline",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData";
|
||||
const BASE_FORM: ConnectionFormData = {
|
||||
name: "", environment: null, folder_id: null, tag_ids: [],
|
||||
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", () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ const BASE_FORM: ConnectionFormData = {
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
use_keychain: false,
|
||||
use_keychain: true,
|
||||
ssh_password: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const BASE_FORM: ConnectionFormData = {
|
||||
name: "My DB", environment: null, folder_id: null, tag_ids: [],
|
||||
connection_string: "postgresql://u:p@localhost:5432/db", db_type: "postgresql",
|
||||
host: "localhost", port: 5432, username: "u", password: "p", database: "db",
|
||||
use_keychain: false, ssh_password: null,
|
||||
use_keychain: true, ssh_password: null,
|
||||
};
|
||||
|
||||
describe("GeneralTab", () => {
|
||||
@@ -54,4 +54,15 @@ describe("GeneralTab", () => {
|
||||
render(<GeneralTab form={{ ...BASE_FORM, db_type: "postgresql" }} managedPreset="supabase" onChange={() => {}} />);
|
||||
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" />
|
||||
Enable keychain
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ function createEmptyForm(
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
use_keychain: false,
|
||||
use_keychain: true,
|
||||
ssh_password: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +93,20 @@ describe("ChangesQueuePanel", () => {
|
||||
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 () => {
|
||||
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
useDbViewerStore.getState().addChange({
|
||||
|
||||
@@ -25,6 +25,8 @@ function formatChangeLabel(change: QueueItem): string {
|
||||
return `Empty Table: ${fullName}`;
|
||||
case "drop_table":
|
||||
return `Drop Table: ${fullName}`;
|
||||
case "ddl":
|
||||
return change.description ?? "DDL";
|
||||
default:
|
||||
return change.table ?? "-";
|
||||
}
|
||||
@@ -50,6 +52,11 @@ function capitalizeType(type: string) {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
/** Badge label for a queue-item type — ddl renders uppercase to match its acronym. */
|
||||
function badgeLabel(type: string): string {
|
||||
return type === "ddl" ? "DDL" : capitalizeType(type);
|
||||
}
|
||||
|
||||
function tableRef(change: QueueItem): string {
|
||||
if (change.schema && change.table) return `${change.schema}.${change.table}`;
|
||||
return change.table ?? "-";
|
||||
@@ -171,7 +178,7 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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">
|
||||
{capitalizeType(change.type)}
|
||||
{badgeLabel(change.type)}
|
||||
</span>
|
||||
<span className="text-sm text-text truncate">
|
||||
{tableRef(change)}
|
||||
|
||||
@@ -16,6 +16,9 @@ vi.mock("../../hooks/useDbConnection", () => ({
|
||||
useDbConnection: (_connectionId: string) => ({
|
||||
connectionError: null,
|
||||
connect: vi.fn(),
|
||||
passwordPromptOpen: false,
|
||||
submitPassword: vi.fn(),
|
||||
cancelPassword: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -896,6 +899,65 @@ describe("DbViewerScreen", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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({
|
||||
connections: [
|
||||
|
||||
@@ -15,11 +15,14 @@ const DestructiveQueryDialog = lazy(() =>
|
||||
);
|
||||
import { TableTree } from "./TableTree";
|
||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||
import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
|
||||
import { ObjectFormTab } from "./objects/ObjectFormTab";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
||||
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
||||
import { TableControls } from "./TableControls";
|
||||
import { EditConnectionModal } from "./EditConnectionModal";
|
||||
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
@@ -162,7 +165,8 @@ export function DbViewerScreen({
|
||||
onHome,
|
||||
onSettings,
|
||||
}: DbViewerScreenProps) {
|
||||
const { connectionError, connect } = useDbConnection(connectionId);
|
||||
const { connectionError, connect, passwordPromptOpen, submitPassword, cancelPassword } =
|
||||
useDbConnection(connectionId);
|
||||
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
||||
const [currentView, setCurrentView] = useState<string>("db-viewer");
|
||||
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">
|
||||
{currentView === "queries" ? (
|
||||
<Terminal size={32} />
|
||||
) : currentView === "objects" ? (
|
||||
<Database size={32} />
|
||||
) : (
|
||||
<Table2 size={32} />
|
||||
)}
|
||||
<span>
|
||||
{currentView === "queries"
|
||||
? "Open a new query tab or run a query from the history"
|
||||
: 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>
|
||||
</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" ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
@@ -1439,7 +1458,20 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
{renderQueryWorkspace()}
|
||||
</div>
|
||||
) : 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" ? (
|
||||
<ToolsPage connectionId={connectionId} />
|
||||
) : currentView === "queries" ? (
|
||||
@@ -1473,6 +1505,12 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
)}
|
||||
<PasswordPromptDialog
|
||||
open={passwordPromptOpen}
|
||||
connectionName={currentConnection?.name ?? ""}
|
||||
onConnect={submitPassword}
|
||||
onCancel={cancelPassword}
|
||||
/>
|
||||
{capabilities.objects && (
|
||||
<ObjectSearchPalette connectionId={connectionId} />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 * as commands from "../../lib/commands";
|
||||
import type { Connection } from "../../lib/types";
|
||||
|
||||
const { updateConnection, loadAll } = vi.hoisted(() => ({
|
||||
@@ -12,13 +13,18 @@ vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (sel: (s: any) => any) =>
|
||||
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
||||
}));
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
vi.mock("../../lib/commands", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/commands")>();
|
||||
return {
|
||||
...actual,
|
||||
updateConnection: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
saveConnectionPassword: vi.fn(),
|
||||
saveConnectionSshPassword: vi.fn(),
|
||||
saveConnectionSshPassphrase: vi.fn(),
|
||||
}));
|
||||
deleteConnectionPassword: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
|
||||
}));
|
||||
@@ -68,4 +74,51 @@ describe("EditConnectionModal", () => {
|
||||
);
|
||||
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 { useConnectionStore } from "../../stores/connectionStore";
|
||||
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 type { Connection, ConnectionInput } from "../../lib/types";
|
||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||
@@ -36,7 +37,7 @@ export function EditConnectionModal({
|
||||
username: connection.username,
|
||||
password: null,
|
||||
database: connection.database ?? null,
|
||||
use_keychain: false,
|
||||
use_keychain: connection.use_keychain ?? true,
|
||||
ssh_host: connection.ssh_host ?? null,
|
||||
ssh_port: connection.ssh_port ?? null,
|
||||
ssh_user: connection.ssh_user ?? null,
|
||||
@@ -76,9 +77,7 @@ export function EditConnectionModal({
|
||||
ssh_passphrase: form.ssh_passphrase ?? null,
|
||||
};
|
||||
const updated = await updateConnection(connection.id, input);
|
||||
if (form.password) {
|
||||
await saveConnectionPassword(connection.id, form.password).catch(() => {});
|
||||
}
|
||||
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
|
||||
// Persist SSH secrets to the OS keychain (not SQLite)
|
||||
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
|
||||
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("ObjectExplorerPage", () => {
|
||||
fireEvent.click(screen.getByLabelText("Object type"));
|
||||
fireEvent.click(screen.getByText("Enums"));
|
||||
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));
|
||||
await waitFor(() =>
|
||||
expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"),
|
||||
@@ -231,12 +231,126 @@ describe("ObjectExplorerPage", () => {
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
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));
|
||||
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled());
|
||||
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("preselects type from store on mount", () => {
|
||||
useDbViewerStore.setState({ selectedObjectType: "sequences" });
|
||||
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();
|
||||
});
|
||||
|
||||
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", () => {
|
||||
useDbViewerStore.getState().openTab("main", "order_summary");
|
||||
useDbViewerStore.setState({
|
||||
@@ -95,6 +111,35 @@ describe("TabBar", () => {
|
||||
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", () => {
|
||||
useDbViewerStore.getState().openTab("public", "mv_products");
|
||||
useDbViewerStore.setState({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { cloneElement, useEffect, useRef, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -16,9 +16,10 @@ import {
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
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 { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||
import { OBJECT_ICONS } from "./objects/ObjectDetail";
|
||||
|
||||
function SortableTab({
|
||||
tab,
|
||||
@@ -181,6 +182,39 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
data-testid="tab-icon-query"
|
||||
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" ? (
|
||||
<Eye
|
||||
data-testid="tab-icon-view"
|
||||
|
||||
@@ -19,6 +19,31 @@ describe("TableOverflowMenu", () => {
|
||||
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", () => {
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
||||
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUiStore } from "../../stores/uiStore";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
import { initialCrudParams } from "../../lib/objectCrud";
|
||||
import type { ColumnInfo, DependencyInfo } from "../../lib/types";
|
||||
|
||||
interface TableOverflowMenuProps {
|
||||
@@ -96,6 +97,32 @@ export function TableOverflowMenu({
|
||||
setImportOpen(true);
|
||||
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 "empty":
|
||||
setConfirmAction("empty");
|
||||
setOpen(false);
|
||||
@@ -125,6 +152,8 @@ export function TableOverflowMenu({
|
||||
{ id: "export-sql", label: "Export data (SQL)" },
|
||||
{ id: "export-md", label: "Export data (Markdown)" },
|
||||
{ id: "import", label: "Import data (CSV/JSON)" },
|
||||
{ id: "create_index", label: "Create Index…" },
|
||||
{ id: "create_constraint", label: "Create Constraint…" },
|
||||
{ id: "empty", label: "Empty Table", danger: true },
|
||||
{ id: "delete", label: "Delete Table", danger: true },
|
||||
];
|
||||
|
||||
@@ -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,111 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { ConstraintForm } from "./ConstraintForm";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
|
||||
vi.mock("../../../lib/commands", () => ({ getSchemaGraph: vi.fn() }));
|
||||
|
||||
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", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(cmd.getSchemaGraph).mockReset();
|
||||
});
|
||||
|
||||
it("check: emits the expression", () => {
|
||||
vi.mocked(cmd.getSchemaGraph).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 () => {
|
||||
vi.mocked(cmd.getSchemaGraph).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.findByPlaceholderText("Referenced table");
|
||||
fireEvent.change(refTable, { target: { value: "users" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({
|
||||
ref_schema: "public",
|
||||
ref_table: "users",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
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"];
|
||||
|
||||
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return { ...params, action: { ...action, ...patch } };
|
||||
}
|
||||
|
||||
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, (p.schema as string) || undefined)
|
||||
.then((g: SchemaGraph) => setGraph(g))
|
||||
.catch(() => setGraph({ tables: [], relationships: [] }));
|
||||
}, [connectionId, p.schema]);
|
||||
|
||||
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">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Referenced table"
|
||||
value={(action.ref_table as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const t = graph.tables.find((t) => t.name === e.target.value);
|
||||
setAction({
|
||||
ref_table: e.target.value,
|
||||
ref_schema: t?.schema ?? "",
|
||||
ref_columns: [],
|
||||
});
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</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,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,68 @@
|
||||
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 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ObjectContextMenu } from "./ObjectContextMenu";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
|
||||
vi.mock("../../../lib/objectCrud", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../lib/objectCrud")>();
|
||||
return {
|
||||
...actual,
|
||||
buildObjectDdl: vi.fn(),
|
||||
getAvailableExtensions: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ObjectContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
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.mocked(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,188 @@
|
||||
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 }[];
|
||||
}
|
||||
|
||||
const DROP_TITLE: Record<ObjectKind, string> = {
|
||||
sequence: "sequence",
|
||||
enum: "type",
|
||||
view: "view",
|
||||
extension: "extension",
|
||||
index: "index",
|
||||
constraint: "constraint",
|
||||
function: "function",
|
||||
procedure: "procedure",
|
||||
trigger: "trigger",
|
||||
};
|
||||
|
||||
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,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ObjectDetail } from "./ObjectDetail";
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,974 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
BookMarked,
|
||||
FunctionSquare,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Puzzle,
|
||||
SquareFunction,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ObjectType,
|
||||
FunctionInfo,
|
||||
TriggerInfo,
|
||||
SequenceInfo,
|
||||
EnumInfo,
|
||||
ExtensionInfo,
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
} from "../../../lib/types";
|
||||
|
||||
export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
functions: "Functions",
|
||||
triggers: "Triggers",
|
||||
sequences: "Sequences",
|
||||
enums: "Enums",
|
||||
extensions: "Extensions",
|
||||
indexes: "Indexes",
|
||||
constraints: "Constraints",
|
||||
procedures: "Procedures",
|
||||
};
|
||||
|
||||
export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
functions: "function",
|
||||
triggers: "trigger",
|
||||
sequences: "sequence",
|
||||
enums: "enum",
|
||||
extensions: "extension",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
procedures: "procedure",
|
||||
};
|
||||
|
||||
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" />,
|
||||
};
|
||||
|
||||
export type AnyObject =
|
||||
| FunctionInfo
|
||||
| TriggerInfo
|
||||
| SequenceInfo
|
||||
| EnumInfo
|
||||
| ExtensionInfo
|
||||
| IndexInfo
|
||||
| ConstraintInfo;
|
||||
|
||||
// ─── 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(type: ObjectType, item: AnyObject) {
|
||||
switch (type) {
|
||||
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: _connectionId, type, item }: ObjectDetailProps) {
|
||||
return <>{renderDetail(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,155 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { KindForm } from "./KindForm";
|
||||
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;
|
||||
|
||||
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,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");
|
||||
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,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 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 { CellEditor, type FkOption } from "./CellEditor";
|
||||
import { CellContextMenu } from "./CellContextMenu";
|
||||
import { cellToUpdateChange, isCellEditable } from "./gridEditability";
|
||||
import { cellToUpdateChange, isCellEditable, type TabKind } from "./gridEditability";
|
||||
import { nextCell, type CellPos } from "./keyboardNav";
|
||||
|
||||
interface VirtualDataGridProps {
|
||||
@@ -21,7 +21,7 @@ interface VirtualDataGridProps {
|
||||
onToggleRow: (rowIndex: number) => void;
|
||||
onToggleAll: () => void;
|
||||
dbType?: string;
|
||||
tabType?: "table" | "query";
|
||||
tabType?: TabKind;
|
||||
onStageEdit?: (payload: {
|
||||
type: "update";
|
||||
schema: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColumnInfo, ChangeItemType } from "../../lib/types";
|
||||
|
||||
export type TabKind = "table" | "query";
|
||||
export type TabKind = "table" | "query" | "object" | "objectForm";
|
||||
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. */
|
||||
|
||||
@@ -38,15 +38,37 @@ const mockConnection = {
|
||||
ssl_ca_path: null,
|
||||
ssl_cert_path: null,
|
||||
ssl_key_path: null,
|
||||
use_keychain: undefined as boolean | undefined,
|
||||
created_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", () => ({
|
||||
useConnectionStore: {
|
||||
getState: () => ({
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
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();
|
||||
mockCommands.getDatabases.mockResolvedValue(["mydb", "otherdb"]);
|
||||
mockCommands.getSchemas.mockResolvedValue(["app", "public"]);
|
||||
@@ -182,6 +213,52 @@ describe("useDbConnection", () => {
|
||||
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 () => {
|
||||
mockConnection.ssh_host = "bastion.example.com";
|
||||
mockConnection.ssh_auth_method = "password";
|
||||
|
||||
@@ -15,6 +15,7 @@ export function useDbConnection(connectionId: string) {
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [passwordPromptOpen, setPasswordPromptOpen] = useState(false);
|
||||
const inputRef = useRef<ConnectionInput | null>(null);
|
||||
// The database the pool is currently connected to. Unlike the selected
|
||||
// `currentDatabase`, this lets us reconnect whenever the selection drifts
|
||||
@@ -31,6 +32,12 @@ export function useDbConnection(connectionId: string) {
|
||||
}
|
||||
try {
|
||||
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
|
||||
? await cmd.getConnectionSshPassword(conn.id).catch(() => null)
|
||||
: null;
|
||||
@@ -160,5 +167,16 @@ export function useDbConnection(connectionId: string) {
|
||||
};
|
||||
}, [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 tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.5)", () => {
|
||||
describe("tauri bundle config (v0.7.6)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.5", () => {
|
||||
expect(tauriConf.version).toBe("0.7.5");
|
||||
it("version is 0.7.6", () => {
|
||||
expect(tauriConf.version).toBe("0.7.6");
|
||||
});
|
||||
});
|
||||
@@ -62,3 +62,19 @@ describe("buildChangeSql", () => {
|
||||
expect(buildChangeSql({ id: "1", type: "drop_table", schema: "public", table: "t" } as any)).toBe('DROP TABLE "public"."t"');
|
||||
});
|
||||
});
|
||||
|
||||
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,8 @@ export function buildChangeSql(item: QueueItem): string {
|
||||
}
|
||||
case "empty_table":
|
||||
return `DELETE FROM ${t}`;
|
||||
case "ddl":
|
||||
return item.sql ?? "";
|
||||
case "drop_table":
|
||||
return `DROP TABLE ${t}`;
|
||||
default:
|
||||
@@ -84,6 +86,8 @@ export function buildChangePayload(item: QueueItem): ChangePayload {
|
||||
return { id: item.id, type: "drop_table", schema, table };
|
||||
case "empty_table":
|
||||
return { id: item.id, type: "empty_table", schema, table };
|
||||
case "ddl":
|
||||
return { id: item.id, type: "ddl", sql: item.sql };
|
||||
default:
|
||||
return { id: item.id, type: item.type, sql: item.sql };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ describe("dbCapabilities", () => {
|
||||
const c = DB_CAPABILITIES.postgresql;
|
||||
expect(c).toEqual<DbCapabilities>({
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("dbCapabilities", () => {
|
||||
it("gives Redis nothing (connection+test only)", () => {
|
||||
expect(DB_CAPABILITIES.redis).toEqual<DbCapabilities>({
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,3 +58,15 @@ describe("dbCapabilities", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -17,15 +17,17 @@ export interface DbCapabilities {
|
||||
import: boolean;
|
||||
/** Copy table schema (DDL). */
|
||||
ddl: boolean;
|
||||
/** Create / edit / drop PostgreSQL objects via the changes queue. */
|
||||
objectCrud: boolean;
|
||||
}
|
||||
|
||||
const ALL_FALSE: DbCapabilities = {
|
||||
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,
|
||||
};
|
||||
|
||||
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 },
|
||||
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 },
|
||||
redis: { ...ALL_FALSE },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../AGENTS.md?raw";
|
||||
import readme from "../../README.md?raw";
|
||||
|
||||
describe("v0.7.5 docs coverage", () => {
|
||||
describe("v0.7.6 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("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(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.5", () => {
|
||||
expect(readme).toContain("0.7.5");
|
||||
it("README declares v0.7.6", () => {
|
||||
expect(readme).toContain("0.7.6");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
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).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.6 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.6/");
|
||||
expect(readme).toContain("Gridline_0.7.6_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.6-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,463 @@
|
||||
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";
|
||||
|
||||
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"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// NOTE on argument key naming: Tauri v2 converts Rust snake_case command
|
||||
// parameter names to camelCase on the IPC boundary (connection_id ->
|
||||
// connectionId). Single-word params (kind, params) are unchanged.
|
||||
|
||||
/// The object kinds supported by the Rust build_ddl dispatcher.
|
||||
export type ObjectKind =
|
||||
| "sequence"
|
||||
| "enum"
|
||||
| "view"
|
||||
| "extension"
|
||||
| "index"
|
||||
| "constraint"
|
||||
| "function"
|
||||
| "procedure"
|
||||
| "trigger";
|
||||
|
||||
/// Opaque payload for a build: `{ schema, name, action: { op, ... } }`.
|
||||
/// The concrete shape is validated server-side by each kind's params struct.
|
||||
export type DdlParams = Record<string, unknown>;
|
||||
|
||||
/// A browsable object row — the per-kind detail fields the CRUD helpers read.
|
||||
/// All fields beyond `schema`/`name` are optional per object kind.
|
||||
/// (The extra fields mirror the real per-kind shapes in `src/lib/types.ts`
|
||||
/// and are consumed by `initialCrudParams` on edit.)
|
||||
export interface CrudItem {
|
||||
schema: string;
|
||||
name: string;
|
||||
table?: string;
|
||||
table_name?: string;
|
||||
table_type?: string;
|
||||
materialized?: boolean;
|
||||
labels?: string[];
|
||||
definition?: string;
|
||||
argument_types?: string[];
|
||||
// function / procedure (FunctionInfo)
|
||||
return_type?: string | null;
|
||||
argument_names?: string[];
|
||||
argument_modes?: string[];
|
||||
language?: string;
|
||||
source?: string | null;
|
||||
kind?: string;
|
||||
// sequence (SequenceInfo)
|
||||
start_value?: string;
|
||||
min_value?: string;
|
||||
max_value?: string;
|
||||
increment?: string;
|
||||
cycle?: boolean;
|
||||
// trigger (TriggerInfo)
|
||||
event_manipulation?: string;
|
||||
action_timing?: string;
|
||||
action_orientation?: string;
|
||||
enabled?: string;
|
||||
// extension (ExtensionInfo)
|
||||
version?: string | null;
|
||||
// index (IndexInfo)
|
||||
is_unique?: boolean;
|
||||
method?: string;
|
||||
columns?: string[];
|
||||
// constraint (ConstraintInfo)
|
||||
contype?: "CHECK" | "UNIQUE" | "EXCLUSION";
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial dialog params for a create/edit operation.
|
||||
* Create starts empty (schema prefilled); edit prefills from the item.
|
||||
*/
|
||||
export function initialCrudParams(
|
||||
kind: ObjectKind,
|
||||
item: CrudItem,
|
||||
mode: "create" | "edit",
|
||||
): DdlParams {
|
||||
const schema = item.schema;
|
||||
switch (kind) {
|
||||
case "sequence":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
increment: mode === "edit" ? (item.increment ?? "1") : "1",
|
||||
min_value: mode === "edit" ? (item.min_value ?? "1") : "1",
|
||||
max_value:
|
||||
mode === "edit"
|
||||
? (item.max_value ?? "9223372036854775807")
|
||||
: "9223372036854775807",
|
||||
start: mode === "edit" ? (item.start_value ?? "1") : "1",
|
||||
cycle: mode === "edit" ? (item.cycle ?? false) : false,
|
||||
},
|
||||
};
|
||||
case "enum":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
labels: mode === "edit" ? (item.labels ?? []) : [],
|
||||
},
|
||||
};
|
||||
case "extension":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
version: mode === "edit" ? (item.version ?? null) : null,
|
||||
},
|
||||
};
|
||||
case "view":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
materialized:
|
||||
mode === "edit"
|
||||
? item.table_type === "MATERIALIZED VIEW" || item.materialized === true
|
||||
: false,
|
||||
action: {
|
||||
op: "create",
|
||||
definition: mode === "edit" ? (item.definition ?? "") : "",
|
||||
},
|
||||
};
|
||||
case "index":
|
||||
return {
|
||||
schema,
|
||||
table: item.table ?? "",
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
unique: mode === "edit" ? (item.is_unique ?? false) : false,
|
||||
method: mode === "edit" ? (item.method ?? "") : "",
|
||||
columns: mode === "edit" ? (item.columns ?? []) : [],
|
||||
predicate: null,
|
||||
},
|
||||
};
|
||||
case "constraint":
|
||||
return {
|
||||
schema,
|
||||
table: item.table ?? "",
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action:
|
||||
mode === "edit"
|
||||
? item.contype === "UNIQUE"
|
||||
? { op: "unique", columns: item.columns ?? [] }
|
||||
: { op: "check", expression: item.definition ?? "" }
|
||||
: { op: "check", expression: "" },
|
||||
};
|
||||
case "function":
|
||||
case "procedure":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
is_procedure: kind === "procedure",
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args:
|
||||
mode === "edit"
|
||||
? (item.argument_names ?? []).map((n, i) => ({
|
||||
mode: item.argument_modes?.[i] ?? "in",
|
||||
name: n,
|
||||
type: item.argument_types?.[i] ?? "",
|
||||
}))
|
||||
: [],
|
||||
return_type: mode === "edit" ? (item.return_type ?? null) : null,
|
||||
language: mode === "edit" ? (item.language ?? "plpgsql") : "plpgsql",
|
||||
body: mode === "edit" ? (item.source ?? "") : "",
|
||||
volatility: null,
|
||||
strict: false,
|
||||
},
|
||||
};
|
||||
case "trigger":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
table: item.table_name ?? "",
|
||||
timing:
|
||||
mode === "edit" ? (item.action_timing ?? "BEFORE") : "BEFORE",
|
||||
events:
|
||||
mode === "edit"
|
||||
? (item.event_manipulation ?? "INSERT")
|
||||
.split(/[,|]|\s+OR\s+/i)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: ["INSERT"],
|
||||
orientation:
|
||||
mode === "edit"
|
||||
? (item.action_orientation ?? "ROW").toUpperCase()
|
||||
: "ROW",
|
||||
function_schema: schema,
|
||||
function_name: "",
|
||||
function_args: [],
|
||||
when: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal params that drop the object, carrying any per-kind context
|
||||
* (owning table, argument types, materialized flag, …).
|
||||
*/
|
||||
export function dropCrudParams(kind: ObjectKind, item: CrudItem): DdlParams {
|
||||
const base = { schema: item.schema, name: item.name };
|
||||
if (kind === "trigger") {
|
||||
return {
|
||||
...base,
|
||||
action: { op: "drop", table: item.table ?? item.table_name ?? "" },
|
||||
};
|
||||
}
|
||||
if (kind === "index" || kind === "constraint") {
|
||||
return {
|
||||
...base,
|
||||
schema: item.schema,
|
||||
table: item.table ?? "",
|
||||
name: item.name,
|
||||
action: { op: "drop" },
|
||||
};
|
||||
}
|
||||
if (kind === "view") {
|
||||
return {
|
||||
...base,
|
||||
materialized: item.table_type === "m" || item.materialized === true,
|
||||
action: { op: "drop" },
|
||||
};
|
||||
}
|
||||
if (kind === "function" || kind === "procedure") {
|
||||
return {
|
||||
...base,
|
||||
is_procedure: kind === "procedure",
|
||||
action: { op: "drop", arg_types: item.argument_types ?? [] },
|
||||
};
|
||||
}
|
||||
return { ...base, action: { op: "drop" } };
|
||||
}
|
||||
|
||||
/// An installable extension from pg_available_extensions.
|
||||
export interface AvailableExtension {
|
||||
name: string;
|
||||
version: string;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
/** Build one or more SQL statements for an object CRUD operation. */
|
||||
export async function buildObjectDdl(
|
||||
connectionId: string,
|
||||
kind: ObjectKind,
|
||||
params: DdlParams,
|
||||
): Promise<string[]> {
|
||||
return invoke<string[]>("build_object_ddl", { connectionId, kind, params });
|
||||
}
|
||||
|
||||
/** List extensions available for install on the current server. */
|
||||
export async function getAvailableExtensions(
|
||||
connectionId: string,
|
||||
): Promise<AvailableExtension[]> {
|
||||
return invoke<AvailableExtension[]>("get_available_extensions", {
|
||||
connectionId,
|
||||
});
|
||||
}
|
||||
+4
-1
@@ -46,6 +46,8 @@ export interface Connection {
|
||||
environment?: string | null;
|
||||
// Favorite flag (v0.5.0 — pinned connection)
|
||||
favorite: boolean;
|
||||
// Whether to save the password to the OS keychain (opt-out, default ON)
|
||||
use_keychain?: boolean;
|
||||
}
|
||||
|
||||
export type NewConnectionMode = "simple" | "detailed";
|
||||
@@ -199,7 +201,8 @@ export type ChangeItemType =
|
||||
| "create_index"
|
||||
| "drop_index"
|
||||
| "bulk_insert"
|
||||
| "empty_table";
|
||||
| "empty_table"
|
||||
| "ddl";
|
||||
|
||||
export interface ChangeItem {
|
||||
type: ChangeItemType;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.5 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.5");
|
||||
it("declares v0.7.6 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.6");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { useConnectionStore } from "./connectionStore";
|
||||
import * as commands from "../lib/commands";
|
||||
import type { Connection, Folder, Tag } from "../lib/types";
|
||||
import type { Connection, ConnectionInput, Folder, Tag } from "../lib/types";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
|
||||
@@ -240,6 +240,8 @@ describe("favorites / recents / move-selection", () => {
|
||||
folder_id: "f1",
|
||||
tag_ids: ["t1"],
|
||||
password: null,
|
||||
// absent use_keychain on the source defaults to true (opt-out)
|
||||
use_keychain: true,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -264,3 +266,94 @@ describe("favorites / recents / move-selection", () => {
|
||||
useConnectionStore.setState({ moveConnection: originalMoveConnection });
|
||||
});
|
||||
});
|
||||
|
||||
describe("keychain toggle + session passwords (Task 3.1)", () => {
|
||||
const baseInput = (overrides: Partial<ConnectionInput> = {}): ConnectionInput => ({
|
||||
name: "n", db_type: "postgresql", host: "h", port: 5432, username: "u",
|
||||
database: "d", folder_id: null, tag_ids: [], password: "pw", use_keychain: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("saves to keychain when use_keychain=true", async () => {
|
||||
const created = makeConn({ id: "c1", use_keychain: true });
|
||||
vi.spyOn(commands, "createConnection").mockResolvedValue(created);
|
||||
const save = vi.spyOn(commands, "saveConnectionPassword").mockResolvedValue(undefined);
|
||||
const purge = vi.spyOn(commands, "deleteConnectionPassword").mockResolvedValue(undefined);
|
||||
await useConnectionStore.getState().createConnection(baseInput({ use_keychain: true }));
|
||||
expect(save).toHaveBeenCalledWith("c1", "pw");
|
||||
expect(purge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("purges keychain and skips save when use_keychain=false", async () => {
|
||||
const created = makeConn({ id: "c2", use_keychain: false });
|
||||
vi.spyOn(commands, "createConnection").mockResolvedValue(created);
|
||||
const save = vi.spyOn(commands, "saveConnectionPassword").mockResolvedValue(undefined);
|
||||
const purge = vi.spyOn(commands, "deleteConnectionPassword").mockResolvedValue(undefined);
|
||||
await useConnectionStore.getState().createConnection(baseInput({ use_keychain: false }));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(purge).toHaveBeenCalledWith("c2");
|
||||
});
|
||||
|
||||
it("use_keychain absent defaults to true (saves to keychain)", async () => {
|
||||
const created = makeConn({ id: "c1", use_keychain: true });
|
||||
vi.spyOn(commands, "createConnection").mockResolvedValue(created);
|
||||
const save = vi.spyOn(commands, "saveConnectionPassword").mockResolvedValue(undefined);
|
||||
const { use_keychain: _kc, ...withoutFlag } = baseInput({ use_keychain: true });
|
||||
await useConnectionStore.getState().createConnection(withoutFlag as ConnectionInput);
|
||||
expect(save).toHaveBeenCalledWith("c1", "pw");
|
||||
});
|
||||
|
||||
it("getConnectionPassword returns the session password when use_keychain=false", async () => {
|
||||
useConnectionStore.setState({ connections: [makeConn({ id: "c3", use_keychain: false })] });
|
||||
useConnectionStore.getState().setSessionPassword("c3", "secret");
|
||||
const kc = vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("kc");
|
||||
expect(await useConnectionStore.getState().getConnectionPassword("c3")).toBe("secret");
|
||||
expect(kc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getConnectionPassword falls back to the keychain when use_keychain=true", async () => {
|
||||
useConnectionStore.setState({ connections: [makeConn({ id: "c4", use_keychain: true })] });
|
||||
const kc = vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("kc");
|
||||
expect(await useConnectionStore.getState().getConnectionPassword("c4")).toBe("kc");
|
||||
expect(kc).toHaveBeenCalledWith("c4");
|
||||
});
|
||||
|
||||
it("cachePassword stays session-only when use_keychain=false", async () => {
|
||||
useConnectionStore.setState({ connections: [makeConn({ id: "c5", use_keychain: false })] });
|
||||
const save = vi.spyOn(commands, "saveConnectionPassword").mockResolvedValue(undefined);
|
||||
await useConnectionStore.getState().cachePassword("c5", "s3cret");
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(await useConnectionStore.getState().getConnectionPassword("c5")).toBe("s3cret");
|
||||
});
|
||||
|
||||
it("cachePassword saves to the keychain when use_keychain=true", async () => {
|
||||
useConnectionStore.setState({ connections: [makeConn({ id: "c6", use_keychain: true })] });
|
||||
const save = vi.spyOn(commands, "saveConnectionPassword").mockResolvedValue(undefined);
|
||||
await useConnectionStore.getState().cachePassword("c6", "kc-pass");
|
||||
expect(save).toHaveBeenCalledWith("c6", "kc-pass");
|
||||
});
|
||||
|
||||
it("setSessionPassword/clearSessionPassword manage the in-memory map", () => {
|
||||
useConnectionStore.setState({ connections: [makeConn({ id: "cx", use_keychain: false })] });
|
||||
useConnectionStore.getState().setSessionPassword("cx", "p");
|
||||
expect(useConnectionStore.getState().getConnectionPassword).toBeDefined();
|
||||
useConnectionStore.getState().clearSessionPassword("cx");
|
||||
});
|
||||
|
||||
it("deleteConnection clears the session password", async () => {
|
||||
useConnectionStore.setState({ connections: [makeConn({ id: "c8", use_keychain: false })] });
|
||||
useConnectionStore.getState().setSessionPassword("c8", "secret");
|
||||
vi.spyOn(commands, "deleteConnection").mockResolvedValue(undefined);
|
||||
await useConnectionStore.getState().deleteConnection("c8");
|
||||
// re-add the same connection so the cleared session map is observable
|
||||
useConnectionStore.setState({ connections: [makeConn({ id: "c8", use_keychain: false })] });
|
||||
expect(await useConnectionStore.getState().getConnectionPassword("c8")).toBeNull();
|
||||
});
|
||||
|
||||
it("duplicateConnection inherits use_keychain from the source", async () => {
|
||||
useConnectionStore.setState({ connections: [makeConn({ id: "c1", use_keychain: true })] });
|
||||
vi.spyOn(commands, "createConnection").mockResolvedValue(makeConn({ id: "c2", name: "P (copy)", use_keychain: true }));
|
||||
await useConnectionStore.getState().duplicateConnection("c1");
|
||||
expect(commands.createConnection).toHaveBeenCalledWith(expect.objectContaining({ use_keychain: true }));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { create } from "zustand";
|
||||
import type { Connection, ConnectionInput, Folder, FolderInput, Tag, TagInput } from "../lib/types";
|
||||
import * as cmd from "../lib/commands";
|
||||
import { persistDbPassword } from "../lib/keychain";
|
||||
|
||||
// In-memory passwords for connections with use_keychain=false (never persisted).
|
||||
const sessionPasswords = new Map<string, string>();
|
||||
|
||||
interface ConnectionState {
|
||||
connections: Connection[]; folders: Folder[]; tags: Tag[];
|
||||
@@ -27,6 +31,8 @@ interface ConnectionState {
|
||||
moveSelectionToFolder: (selectedIds: string[], targetFolderId: string | null) => Promise<void>;
|
||||
cachePassword: (connectionId: string, password: string) => Promise<void>;
|
||||
getConnectionPassword: (connectionId: string) => Promise<string | null>;
|
||||
setSessionPassword: (connectionId: string, password: string) => void;
|
||||
clearSessionPassword: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
@@ -83,10 +89,8 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
},
|
||||
createConnection: async (input) => {
|
||||
const conn = await cmd.createConnection(input);
|
||||
// Persist password to OS keychain (not SQLite)
|
||||
if (input.password) {
|
||||
await cmd.saveConnectionPassword(conn.id, input.password);
|
||||
}
|
||||
// Persist (or purge) the DB password according to the keychain toggle.
|
||||
await persistDbPassword(conn.id, input.use_keychain, input.password);
|
||||
// Persist SSH secrets to OS keychain (not SQLite): password for password
|
||||
// auth, passphrase for private-key auth.
|
||||
if (input.ssh_host && (input.ssh_auth_method ?? "password") === "password" && input.ssh_password) {
|
||||
@@ -102,7 +106,8 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
const source = get().connections.find((c) => c.id === id);
|
||||
if (!source) throw new Error("Connection not found");
|
||||
// Passwords live in the OS keychain and are NEVER copied; the duplicate
|
||||
// starts unkeyed and with no favorite flag.
|
||||
// starts unkeyed and with no favorite flag. The keychain toggle is inherited
|
||||
// from the source (absent defaults to true / opt-out).
|
||||
const input: ConnectionInput = {
|
||||
name: `${source.name} (copy)`,
|
||||
db_type: source.db_type,
|
||||
@@ -123,7 +128,7 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
ssl_cert_path: source.ssl_cert_path ?? null,
|
||||
ssl_key_path: source.ssl_key_path ?? null,
|
||||
password: null,
|
||||
use_keychain: false,
|
||||
use_keychain: source.use_keychain ?? true,
|
||||
};
|
||||
return get().createConnection(input);
|
||||
},
|
||||
@@ -131,6 +136,8 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
await cmd.deleteConnection(id);
|
||||
// Remove password from keychain
|
||||
try { await cmd.deleteConnectionPassword(id); } catch { /* ignore */ }
|
||||
// Drop any session-only password for the connection
|
||||
sessionPasswords.delete(id);
|
||||
set((s) => ({ connections: s.connections.filter((c) => c.id !== id) }));
|
||||
},
|
||||
createFolder: async (input) => {
|
||||
@@ -162,11 +169,26 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
cachePassword: async (connectionId, password) => {
|
||||
const conn = get().connections.find((c) => c.id === connectionId);
|
||||
if (conn && conn.use_keychain === false) {
|
||||
sessionPasswords.set(connectionId, password);
|
||||
return;
|
||||
}
|
||||
await cmd.saveConnectionPassword(connectionId, password);
|
||||
},
|
||||
getConnectionPassword: async (connectionId) => {
|
||||
const conn = get().connections.find((c) => c.id === connectionId);
|
||||
if (conn && conn.use_keychain === false) {
|
||||
return sessionPasswords.get(connectionId) ?? null;
|
||||
}
|
||||
return cmd.getConnectionPassword(connectionId);
|
||||
},
|
||||
setSessionPassword: (connectionId, password) => {
|
||||
sessionPasswords.set(connectionId, password);
|
||||
},
|
||||
clearSessionPassword: (connectionId) => {
|
||||
sessionPasswords.delete(connectionId);
|
||||
},
|
||||
addTagToItems: async (tagId, folderIds, connectionIds) => {
|
||||
await Promise.all([
|
||||
...folderIds.map((fid) => cmd.addFolderTags(fid, [tagId])),
|
||||
|
||||
@@ -440,3 +440,161 @@ describe("tabType discriminator", () => {
|
||||
expect(tab.tabType).toBe("table");
|
||||
});
|
||||
});
|
||||
describe("openObjectTab", () => {
|
||||
beforeEach(() => useDbViewerStore.getState().reset());
|
||||
|
||||
it("opens an object tab carrying the per-type identity + item", () => {
|
||||
useDbViewerStore.getState().openObjectTab("functions", "public", "add", { name: "add", schema: "public" });
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
expect(st.tabs[0]).toMatchObject({ tabType: "object", objectType: "functions", schema: "public", table: "add" });
|
||||
expect(st.tabs[0].objectItem).toEqual({ name: "add", schema: "public" });
|
||||
expect(st.activeTabId).toBe(st.tabs[0].id);
|
||||
});
|
||||
|
||||
it("dedups: re-clicking the same object focuses the existing tab", () => {
|
||||
useDbViewerStore.getState().openObjectTab("enums", "public", "role", { name: "role" });
|
||||
const first = useDbViewerStore.getState().tabs[0];
|
||||
useDbViewerStore.getState().openObjectTab("enums", "public", "role", { name: "role" });
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
expect(st.activeTabId).toBe(first.id);
|
||||
});
|
||||
|
||||
it("does not collide with a table tab of the same name", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
useDbViewerStore.getState().openObjectTab("functions", "public", "users", { name: "users" });
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(2);
|
||||
expect(st.tabs.find((t) => t.tabType === "object")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openFormTab", () => {
|
||||
beforeEach(() => useDbViewerStore.getState().reset());
|
||||
|
||||
const createOpts = (name: string, title = "Create sequence") => ({
|
||||
kind: "sequence" as const,
|
||||
schema: "public",
|
||||
name,
|
||||
title,
|
||||
description: `Create ${name}`,
|
||||
mode: "create" as const,
|
||||
params: { schema: "public", name, action: { op: "create" } },
|
||||
});
|
||||
|
||||
it("opens a create form tab carrying kind/params/title/description/mode", () => {
|
||||
useDbViewerStore.getState().openFormTab(createOpts("my_seq"));
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
const tab = st.tabs[0];
|
||||
expect(tab.tabType).toBe("objectForm");
|
||||
// table holds the display title, used as the TabBar label
|
||||
expect(tab.table).toBe("Create sequence");
|
||||
expect(tab.schema).toBe("public");
|
||||
expect(tab.objectType).toBeNull();
|
||||
expect(tab.form).toBeDefined();
|
||||
expect(tab.form!.kind).toBe("sequence");
|
||||
expect(tab.form!.mode).toBe("create");
|
||||
expect(tab.form!.title).toBe("Create sequence");
|
||||
expect(tab.form!.description).toBe("Create my_seq");
|
||||
expect(tab.form!.params).toEqual({
|
||||
schema: "public",
|
||||
name: "my_seq",
|
||||
action: { op: "create" },
|
||||
});
|
||||
expect(st.activeTabId).toBe(tab.id);
|
||||
});
|
||||
|
||||
it("dedups create tabs per kind regardless of schema/name and does not clobber params", () => {
|
||||
useDbViewerStore.getState().openFormTab(createOpts("first_seq"));
|
||||
const first = useDbViewerStore.getState().tabs[0];
|
||||
useDbViewerStore.getState().openFormTab(createOpts("second_seq"));
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
expect(st.activeTabId).toBe(first.id);
|
||||
// the existing tab keeps its original params (name not clobbered)
|
||||
expect(st.tabs[0].form!.params.name).toBe("first_seq");
|
||||
});
|
||||
|
||||
it("dedups edit tabs by kind+schema+name but a create tab of the same kind coexists", () => {
|
||||
const editOpts = {
|
||||
kind: "enum" as const,
|
||||
schema: "public",
|
||||
name: "role",
|
||||
title: "Edit role",
|
||||
description: "Edit public.role",
|
||||
mode: "edit" as const,
|
||||
params: { schema: "public", name: "role", action: { op: "update" } },
|
||||
};
|
||||
useDbViewerStore.getState().openFormTab(editOpts);
|
||||
const first = useDbViewerStore.getState().tabs[0];
|
||||
useDbViewerStore.getState().openFormTab(editOpts);
|
||||
let st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
expect(st.activeTabId).toBe(first.id);
|
||||
|
||||
// A create tab of the same kind is a distinct tab.
|
||||
useDbViewerStore.getState().openFormTab(createOpts("new_enum", "Create enum"));
|
||||
st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(2);
|
||||
expect(st.tabs.every((t) => t.tabType === "objectForm")).toBe(true);
|
||||
expect(st.tabs.some((t) => t.form!.mode === "edit")).toBe(true);
|
||||
expect(st.tabs.some((t) => t.form!.mode === "create")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not collide with a table tab (distinct types)", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
useDbViewerStore.getState().openFormTab(createOpts("users_id_seq"));
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(2);
|
||||
expect(st.tabs[0].tabType).toBe("table");
|
||||
expect(st.tabs[1].tabType).toBe("objectForm");
|
||||
});
|
||||
|
||||
it("resolves an empty schema to the current schema", () => {
|
||||
useDbViewerStore.setState({ currentSchema: "utils" });
|
||||
useDbViewerStore
|
||||
.getState()
|
||||
.openFormTab({ ...createOpts("my_seq"), schema: "" });
|
||||
const tab = useDbViewerStore.getState().tabs[0];
|
||||
expect(tab.schema).toBe("utils");
|
||||
// params stay untouched — the form owns them
|
||||
expect(tab.form!.params.schema).toBe("public");
|
||||
});
|
||||
|
||||
it("falls back to public when no schema and no currentSchema", () => {
|
||||
useDbViewerStore
|
||||
.getState()
|
||||
.openFormTab({ ...createOpts("my_seq"), schema: "" });
|
||||
const tab = useDbViewerStore.getState().tabs[0];
|
||||
expect(tab.schema).toBe("public");
|
||||
});
|
||||
|
||||
it("keeps an explicit schema even when currentSchema is set", () => {
|
||||
useDbViewerStore.setState({ currentSchema: "utils" });
|
||||
useDbViewerStore
|
||||
.getState()
|
||||
.openFormTab({ ...createOpts("my_seq"), schema: "public" });
|
||||
const tab = useDbViewerStore.getState().tabs[0];
|
||||
expect(tab.schema).toBe("public");
|
||||
});
|
||||
|
||||
it("updateFormTabParams updates params without touching title/description/mode/kind", () => {
|
||||
useDbViewerStore.getState().openFormTab(createOpts("my_seq"));
|
||||
const tabId = useDbViewerStore.getState().tabs[0].id;
|
||||
const before = useDbViewerStore.getState().tabs[0].form;
|
||||
expect(before).toBeDefined();
|
||||
|
||||
useDbViewerStore
|
||||
.getState()
|
||||
.updateFormTabParams(tabId, { schema: "other", name: "renamed", action: { op: "create" } });
|
||||
|
||||
const after = useDbViewerStore.getState().tabs[0].form;
|
||||
expect(after?.params).toEqual({ schema: "other", name: "renamed", action: { op: "create" } });
|
||||
expect(after?.title).toBe(before?.title);
|
||||
expect(after?.description).toBe(before?.description);
|
||||
expect(after?.mode).toBe(before?.mode);
|
||||
expect(after?.kind).toBe(before?.kind);
|
||||
});
|
||||
});
|
||||
|
||||
+123
-1
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType } from "../lib/types";
|
||||
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
||||
import type { ObjectKind, DdlParams } from "../lib/objectCrud";
|
||||
|
||||
// ─── Local types ────────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +39,16 @@ export interface QueueItem {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** Payload carried by an "objectForm" tab — the kind + params for the
|
||||
* object create/edit form and the SQL toggle it renders. */
|
||||
export interface ViewerFormTabPayload {
|
||||
kind: ObjectKind;
|
||||
params: DdlParams;
|
||||
title: string;
|
||||
description: string;
|
||||
mode: "create" | "edit";
|
||||
}
|
||||
|
||||
export interface ViewerTab {
|
||||
id: string;
|
||||
schema: string;
|
||||
@@ -52,8 +63,11 @@ export interface ViewerTab {
|
||||
sortRules: SortRule[];
|
||||
hiddenColumns: string[];
|
||||
smartSortApplied: boolean;
|
||||
tabType: "table" | "query";
|
||||
tabType: "table" | "query" | "object" | "objectForm";
|
||||
query?: string;
|
||||
objectType?: ObjectType | null;
|
||||
objectItem?: unknown;
|
||||
form?: ViewerFormTabPayload;
|
||||
}
|
||||
|
||||
// ─── Auto-increment counters ───────────────────────────────────
|
||||
@@ -110,6 +124,17 @@ interface DbViewerState {
|
||||
// Actions
|
||||
openTab: (schema: string, table: string, forceNew?: boolean) => void;
|
||||
openQueryTab: () => void;
|
||||
openObjectTab: (objectType: ObjectType, schema: string, name: string, item?: unknown) => void;
|
||||
openFormTab: (opts: {
|
||||
kind: ObjectKind;
|
||||
schema: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
mode: "create" | "edit";
|
||||
params: DdlParams;
|
||||
}) => void;
|
||||
updateFormTabParams: (tabId: string, params: DdlParams) => void;
|
||||
setDefaultPageSize: (size: number) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
reorderTab: (fromIndex: number, toIndex: number) => void;
|
||||
@@ -246,6 +271,103 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
set({ tabs: [...tabs, tab], activeTabId: tab.id });
|
||||
},
|
||||
|
||||
openObjectTab: (objectType, schema, name, item) => {
|
||||
const { tabs } = get();
|
||||
|
||||
// Dedup on the object's identity (objectType + schema + name); an object
|
||||
// tab is distinct from a table tab of the same name.
|
||||
const existing = tabs.find(
|
||||
(t) =>
|
||||
t.tabType === "object" &&
|
||||
t.objectType === objectType &&
|
||||
t.schema === schema &&
|
||||
t.table === name,
|
||||
);
|
||||
if (existing) {
|
||||
set({ activeTabId: existing.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const tab: ViewerTab = {
|
||||
id: `tab-${++tabCounter}`,
|
||||
schema,
|
||||
table: name,
|
||||
page: 1,
|
||||
pageSize: get().defaultPageSize,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: false,
|
||||
tabType: "object",
|
||||
objectType,
|
||||
objectItem: item,
|
||||
};
|
||||
set({ tabs: [...tabs, tab], activeTabId: tab.id });
|
||||
},
|
||||
|
||||
openFormTab: ({ kind, schema, name, title, description, mode, params }) => {
|
||||
const { tabs } = get();
|
||||
|
||||
// Resolve the effective schema before building the tab: an explicit schema
|
||||
// wins, otherwise fall back to the viewer's current schema, then "public".
|
||||
// The form payload (params) is left untouched — the tab schema is used for
|
||||
// dedup/identity only.
|
||||
const effSchema = schema || get().currentSchema || "public";
|
||||
|
||||
// Dedup key semantics:
|
||||
// create -> `create:<kind>` (one create form per kind;
|
||||
// schema/name excluded because the user may rename while typing)
|
||||
// edit -> `edit:<kind>:<schema>:<name>`
|
||||
// The key is derived from the stored form payload (params carries
|
||||
// { schema, name } per the objectCrud contract), so no extra field is
|
||||
// needed on ViewerTab.
|
||||
const existing = tabs.find(
|
||||
(t) =>
|
||||
t.tabType === "objectForm" &&
|
||||
t.form?.kind === kind &&
|
||||
t.form?.mode === mode &&
|
||||
(mode === "create" ||
|
||||
(t.schema === schema && t.form?.params?.name === name)),
|
||||
);
|
||||
if (existing) {
|
||||
// Re-clicking the same form focuses the tab and never clobbers its
|
||||
// in-progress params.
|
||||
set({ activeTabId: existing.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const tab: ViewerTab = {
|
||||
id: `tab-${++tabCounter}`,
|
||||
schema: effSchema,
|
||||
table: title,
|
||||
page: 1,
|
||||
pageSize: get().defaultPageSize,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: false,
|
||||
tabType: "objectForm",
|
||||
objectType: null,
|
||||
form: { kind, params, title, description, mode },
|
||||
};
|
||||
set({ tabs: [...tabs, tab], activeTabId: tab.id });
|
||||
},
|
||||
|
||||
updateFormTabParams: (tabId, params) =>
|
||||
set((state) => ({
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.id === tabId && t.tabType === "objectForm" && t.form
|
||||
? { ...t, form: { ...t.form, params } }
|
||||
: t,
|
||||
),
|
||||
})),
|
||||
|
||||
setDefaultPageSize: (size) => set({ defaultPageSize: size }),
|
||||
|
||||
closeTab: (tabId) => {
|
||||
|
||||
Reference in New Issue
Block a user