Compare commits
16
Commits
5a45391d91
...
v0.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5607773299 | ||
|
|
60d472dde2 | ||
|
|
bdf2bb543b | ||
|
|
67f3532938 | ||
|
|
1d8dfce99a | ||
|
|
8bcdc0888f | ||
|
|
fd451d219d | ||
|
|
a86f5f7199 | ||
|
|
2fccb7b405 | ||
|
|
0efabbdc2e | ||
|
|
294e72e6ff | ||
|
|
15f4dc9bac | ||
|
|
867df98958 | ||
|
|
e66d7ac102 | ||
|
|
84a6f8d41c | ||
|
|
08b0f79f07 |
@@ -7,16 +7,11 @@ name: Release
|
|||||||
# git checkout prod && git pull
|
# git checkout prod && git pull
|
||||||
# git tag v0.5.0 && git push origin v0.5.0
|
# git tag v0.5.0 && git push origin v0.5.0
|
||||||
#
|
#
|
||||||
# SIGNING STATUS: macOS bundles are AD-HOC signed via tauri.conf.json
|
# SIGNING STATUS: macOS is Developer-ID signed + notarized via
|
||||||
# (bundle.macOS.signingIdentity: "-", hardenedRuntime: false). That replaces
|
# scripts/release-mac.sh (built manually on Adrian's machine). NOTE: this
|
||||||
# the linker-only signature that made macOS report the app as "damaged" and
|
# GitHub workflow is NOT the active release path — Gitea Actions uses
|
||||||
# refuse Finder launches — users now get the standard "Open Anyway" prompt
|
# .gitea/workflows/release.yml (Linux/Windows only). Linux/Windows installers
|
||||||
# (or run `sudo xattr -dr com.apple.quarantine` for the damaged-error case, see
|
# remain unsigned.
|
||||||
# README). Still NOT Developer-ID signed / notarized (no certs yet).
|
|
||||||
# tauri-action automatically signs + notarizes when the signing secrets are
|
|
||||||
# present, so the moment we add APPLE_CERTIFICATE / APPLE_API_KEY /
|
|
||||||
# WINDOWS_CERTIFICATE (or Azure Trusted Signing) to repo secrets, future
|
|
||||||
# builds are properly signed — no changes to this file required.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ Cut a release by tagging the **`prod`** branch once the PR is merged — `git ta
|
|||||||
- `desktop/src/lib/version.test.ts` and `desktop/src/lib/docs-coverage.test.ts` if they assert the version
|
- `desktop/src/lib/version.test.ts` and `desktop/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.9/<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.9/<file>`). tauri-action uses default versioned asset names (`Gridline_<ver>_aarch64.dmg`, `Gridline-<ver>-1.x86_64.rpm`, etc.) — update BOTH tables to the new names on every release (see the MAINTENANCE comment in README.md).
|
||||||
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
|
- **Bundled pg tools:** `tauri.conf.json` `bundle.resources` lists `resources/pg_tools/*`; the `release.yml` matrix builds/downloads + checksum-verifies the static binaries before the Tauri build step.
|
||||||
- **Release notes must include the macOS first-launch instructions** — until a Developer ID signing certificate is secured, releases are ad-hoc signed only (not notarized), so every macOS user hits a Gatekeeper prompt. The draft release body must include: right-click → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**) for "developer cannot be verified", and `sudo xattr -dr com.apple.quarantine /Applications/Gridline.app` (with the re-run-after-every-upgrade note) for "damaged and can't be opened".
|
- **macOS is Developer-ID signed + notarized** via `scripts/release-mac.sh` (built manually on Adrian's machine; not in CI). The script auto-detects the Developer ID identity from the login keychain, reads notarization credentials from `~/.config/gridline/notarize.env` (App Store Connect API key), and verifies codesign + stapled notarization before uploading. If the cert or credentials are missing it fails with setup instructions (see the README's "macOS signing & notarization" section).
|
||||||
|
|
||||||
### Adding a Tauri Command
|
### Adding a Tauri Command
|
||||||
|
|
||||||
@@ -237,14 +237,15 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
### Connection Management
|
### Connection Management
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
| :--- | :---: | :--- |
|
| :--- | :---: | :--- |
|
||||||
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis) | ✅ | Full create/read/update/delete with form validation |
|
| Connections CRUD (PostgreSQL, MySQL, SQLite, Redis, MariaDB) | ✅ | Full create/read/update/delete with form validation |
|
||||||
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints; SQLite swaps the URI field for a file-path + Browse input (v0.7.0) |
|
| MariaDB connections | ✅ | First-class db_type on the MySQL driver path (v0.8.0) |
|
||||||
|
| New Connection screen (revamped) | ✅ | Two-stage entry → configured flow: Connection URI + 6-card provider grid (PostgreSQL / MySQL / SQLite / Redis / Supabase / NeonDB) with an OR divider → expands into label + tags/env/folder + General|SSH·SSL tabs. Supabase & NeonDB are managed-PostgreSQL presets (persist as `postgresql`) with in-app setup guides + SSL hints; PlanetScale is a managed-MySQL (Vitess) preset (persists as `mysql`) with a setup guide + `.psdb.cloud` host detection (v0.8.0); SQLite swaps the URI field for a file-path + Browse input (v0.7.0) |
|
||||||
| Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable |
|
| Connection testing (all DB types) | ✅ | PostgreSQL, MySQL, SQLite, Redis all testable |
|
||||||
| DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
|
| DB Viewer: PostgreSQL browse + query | ✅ | Schemas, tables, paginated data, FK preview, JSON viewer |
|
||||||
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
| DB Viewer: SQLite browse + query | ✅ | Full support via rusqlite |
|
||||||
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
| DB Viewer: MySQL browse + query + edit | ✅ | Full viewer: connect (SSL + SSH tunnel), databases/tables/columns/FKs, query + pagination, inline cell editing + changes queue, DDL copy (`SHOW CREATE TABLE`), CSV/JSON import — added in v0.7.0. PK-only editing (no ctid equivalent); VARBINARY `information_schema` columns decoded correctly |
|
||||||
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
| DB Viewer: Redis browse | ❌ | Connection + test only; browsing gated off with a clean "not supported" state (v0.7.0) |
|
||||||
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager |
|
| Password storage in OS keychain | ✅ | macOS Keychain, Linux Secret Service, Windows Credential Manager; macOS items get an explicit `SecAccess` ACL pinning them to the app's code-signature requirement on every save (`keychain_acl.rs`) — items survive re-signs/upgrades without keychain permission prompts |
|
||||||
| 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) |
|
| Enable keychain toggle | ✅ | Default ON (opt-out); OFF = don't persist the DB password (session-only, re-prompt on connect) + purge existing keychain entry; SSH secrets stay keychain-only (v0.7.6) |
|
||||||
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
| SSH tunnel config UI | ✅ | Host, port, user, auth method, key path, passphrase fields |
|
||||||
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
| SSH tunnel runtime | ✅ | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:<id>` / `ssh_passphrase:<id>`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel |
|
||||||
@@ -290,21 +291,21 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
| Smart default sort | ✅ | 12-tier priority: updated_at → created_at → *_at → *_id → seq/rank/version |
|
||||||
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
| Data grid pagination | ✅ | Page nav, page size selector persisted in settings |
|
||||||
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
|
| Column filtering (server-side) | ✅ | eq, neq, contains, starts, ends, gt, lt, null, notnull pushed to SQL WHERE |
|
||||||
| Visual filter builder | ✅ | Drag-and-drop column palette (@dnd-kit) with type-aware operators (textish → contains, else eq), AND semantics, persists in tab `filterRules` |
|
| Visual filter builder | ✅ | Column Filters popover with a "+ Add filter" row editor (column / operator / value selects, per-rule remove, AND semantics, persists in tab `filterRules`). Free-text values are debounced (400ms) so a query only fires after the user stops typing; column/operator changes apply immediately. The filter button is a plain toggle (open when closed, close when open) |
|
||||||
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
| Column sorting (server-side) | ✅ | Multi-column asc/desc pushed to SQL ORDER BY |
|
||||||
| Column show/hide | ✅ | Toggle visibility per column |
|
| Column show/hide | ✅ | Toggle visibility per column |
|
||||||
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
| Column resize (drag handle) | ✅ | Double-click to auto-fit |
|
||||||
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
|
| Row selection (checkboxes + select all) | ✅ | Bulk copy (JSON/CSV/SQL) and delete |
|
||||||
| Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8) |
|
| Export toolbar (JSON, CSV, SQL, Markdown, Excel (.xlsx)) | ✅ | Client-side Blob download of visible rows; Excel (.xlsx) via client-side workbook generation (v0.7.8); plus Rust-side **Export all rows to file…** (full-result CSV/JSONL/JSON streaming with progress + cancel) (v0.8.0) |
|
||||||
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
|
| Excel (.xlsx) export | ✅ | Client-side .xlsx export of visible rows alongside JSON/CSV/SQL/Markdown (v0.7.8) |
|
||||||
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
| Auto-refresh timer | ✅ | Configurable interval in settings |
|
||||||
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table |
|
| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | ✅ | Stage → **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **⌘S/Ctrl+S** shortcut. Committed cards show a green ✓ (failed ✗); committing `drop_table` auto-closes open tabs of that table. **Insert Row** adds an editable pending row at the top of the grid (accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply |
|
||||||
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
|
| Auto schema-tree refresh | ✅ | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing schema-modifying queue changes — `drop_table`, schema-modifying `ddl` (e.g. `CREATE TABLE`), and `rebuild_table` — no manual refresh needed |
|
||||||
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
|
| Data import (CSV/JSON) | ✅ | Table overflow menu → ImportDialog: file pick, parse, preview (first 100 rows), header→column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue → Commit All |
|
||||||
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
|
| Table menu actions | ✅ | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) |
|
||||||
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
| Edit connection modal (from DB viewer) | ✅ | AnimatedModal with keychain password fetch on test |
|
||||||
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
| Connection drop banner | ✅ | Auto-detects broken connections with reconnect prompt |
|
||||||
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `text`/`json`/`jsonb` use a single-line scrolling textarea. |
|
| Inline cell editing | ✅ | Double-click/Enter edits a cell; commit stages an `update` change in the queue → Commit All. No-PK tables use ctid/rowid locator; PK/generated/identity columns and views/matviews are read-only. Stale-write protection via affected-row-count check. **Optimistic UI**: the changes queue is the single source of truth — `deriveStagedValues` feeds staged values + a pending amber dot back into the grid (dot clears on commit, values survive until refetch); re-editing the same cell replaces the queue entry (original `oldData` kept); Clear All removes dots instantly; queue cards show an old → new value diff. **Smart editors**: PG enum columns render a `<select>` of enum labels; FK columns render a searchable dropdown of referenced rows (one row per option showing the first 4 referenced columns, FK-popover styling, 360px fixed, portal to body); `date`/`time`/`timestamp` columns use native date/time/datetime pickers, `boolean` a true/false select, and numeric types a number input; `text`/`json`/`jsonb` use a single-line scrolling textarea. |
|
||||||
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
|
| Virtualized data grid | ✅ | Row-level virtualization via @tanstack/react-virtual `useVirtualizer`; handles 100k+ rows |
|
||||||
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
|
| Row detail / expandable row view | ✅ | RowDetailDrawer: right-drawer per row, opened via the cell context menu **View Row** |
|
||||||
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
|
| Keyboard cell navigation (arrow keys, Tab) | ✅ | Arrow keys + Tab/Shift+Tab wrap (`keyboardNav`); Enter opens the editor; **Esc cancels editing even after the editor lost focus** (document-level listener) and closes the context menu |
|
||||||
@@ -346,6 +347,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. |
|
| Schema visualizer (ER diagram) | ✅ | Full React Flow ER diagram with dagre auto-layout, crow's foot notation, schema selector, legend with cardinality colors, collapsible columns (PK/FK/unique-only), cross-schema FK support. PostgreSQL (single round-trip LATERAL query) + SQLite (PRAGMA). Uses @xyflow/react + dagre. |
|
||||||
| Create/Edit table + column-diff + rebuild | ✅ | Create Table + Edit Table column-diff editor (ADD/DROP/RENAME/ALTER TYPE/SET|DROP DEFAULT/SET|DROP NOT NULL, staged one-per-queue-item); atomic column-reorder table rebuild in a single transaction, preserving constraints/indexes/FKs/grants/sequences (v0.7.7) |
|
| Create/Edit table + column-diff + rebuild | ✅ | Create Table + Edit Table column-diff editor (ADD/DROP/RENAME/ALTER TYPE/SET|DROP DEFAULT/SET|DROP NOT NULL, staged one-per-queue-item); atomic column-reorder table rebuild in a single transaction, preserving constraints/indexes/FKs/grants/sequences (v0.7.7) |
|
||||||
| Relationships (FK CRUD, cross-schema, ON DELETE/UPDATE) | ✅ | FK create/edit/drop with cross-schema references and ON DELETE/UPDATE actions (v0.7.7) |
|
| Relationships (FK CRUD, cross-schema, ON DELETE/UPDATE) | ✅ | FK create/edit/drop with cross-schema references and ON DELETE/UPDATE actions (v0.7.7) |
|
||||||
|
| TimescaleDB hypertables | ✅ | Objects view type on PG connections with the extension; runtime availability gating (v0.8.0) |
|
||||||
|
|
||||||
### Query Editor
|
### Query Editor
|
||||||
| Feature | Status | Details |
|
| Feature | Status | Details |
|
||||||
@@ -355,7 +357,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Destructive query guard | ✅ | Confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE (`isDestructiveQuery` + `DestructiveQueryDialog`) |
|
| Destructive query guard | ✅ | Confirmation dialog for INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE (`isDestructiveQuery` + `DestructiveQueryDialog`) |
|
||||||
| Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) |
|
| Query history / recent queries | ✅ | v5 `query_history` table + v6 `favorite` column; consecutive-identical dedup + retention pruning (500/connection); `get_query_history`/`clear_query_history`/`set_history_favorite` commands; **QueryHistoryDropdown** in the query toolbar (load / run / favorite / clear, lazy fetch, loading + error states) |
|
||||||
| SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) |
|
| SQL autocomplete (keywords, tables, columns) | ✅ | Completion provider in `src/lib/monacoSetup.ts` backed by `src/lib/sqlCompletion.ts` (pure, unit-tested): keywords (~60) + table names from the active schema; typing `table.` or `schema.table.` suggests that table's columns (introspected via `get_schema_graph`, cached per schema in memory, `incomplete: true` warm-up on first use) |
|
||||||
| Multiple result sets | ❌ | |
|
| Multiple result sets | ✅ | Stacked panels per statement via execute_query_multi; DML/DDL notices; stop-at-first-error (v0.8.0) |
|
||||||
| Cancel long-running queries | ✅ | Per-connection cancel from the query toolbar (`pg_cancel_backend` / MySQL `KILL` / SQLite interruption) instead of waiting or killing the app (v0.7.8) |
|
| Cancel long-running queries | ✅ | Per-connection cancel from the query toolbar (`pg_cancel_backend` / MySQL `KILL` / SQLite interruption) instead of waiting or killing the app (v0.7.8) |
|
||||||
| Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab |
|
| Saved queries (named, organized) | ✅ | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name ≤200, folder ≤100, text ≤1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab |
|
||||||
| Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab |
|
| Query favorites / pinning | ✅ | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab |
|
||||||
@@ -374,6 +376,7 @@ Planned work is prioritized in the [Project Roadmap](./ROADMAP.md) (source of tr
|
|||||||
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) |
|
| Bundled PostgreSQL client tools | ✅ | Static pg_dump/pg_restore/psql shipped as Tauri resources; system-first, bundled-fallback resolution via resource_dir (v0.7.5) |
|
||||||
| SQLite .dump (backup/restore) | ✅ | Backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX (v0.7.8) |
|
| SQLite .dump (backup/restore) | ✅ | Backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX (v0.7.8) |
|
||||||
| Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL backup/restore via mysqldump (system-first); DB-to-DB sync extended beyond PostgreSQL (v0.7.8) |
|
| Backup / Restore / Sync for MySQL & SQLite | ✅ | MySQL backup/restore via mysqldump (system-first); DB-to-DB sync extended beyond PostgreSQL (v0.7.8) |
|
||||||
|
| Schema diff / compare | ✅ | Tools → Schema Diff; same-engine snapshot diff; safe items stage into the changes queue (v0.8.0) |
|
||||||
| Table structure export (DDL) | ❌ | |
|
| Table structure export (DDL) | ❌ | |
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
|
|||||||
@@ -30,31 +30,34 @@
|
|||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.7.12**):
|
Grab the installer for your OS from the [latest release](https://git.ranio.xyz/adrianbonpin/gridline/releases/latest) — the links below point at the current release (**v0.8.0**):
|
||||||
|
|
||||||
| OS | Architecture | Download |
|
| OS | Architecture | Download |
|
||||||
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
| :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.7.12_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_aarch64.dmg) |
|
| **macOS** | Apple Silicon (M1/M2/M3/M4…) | [Gridline_0.8.0_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_aarch64.dmg) |
|
||||||
| **macOS** | Intel | [Gridline_0.7.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64.dmg) |
|
| **macOS** | Intel | [Gridline_0.8.0_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64.dmg) |
|
||||||
| **Windows** | x64 | [Gridline_0.7.12_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64-setup.exe) |
|
| **Windows** | x64 | [Gridline_0.8.0_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64-setup.exe) |
|
||||||
| **Debian / Ubuntu** | amd64 | [Gridline_0.7.12_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.deb) |
|
| **Debian / Ubuntu** | amd64 | [Gridline_0.8.0_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.deb) |
|
||||||
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.12-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline-0.7.12-1.x86_64.rpm) |
|
| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.8.0-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline-0.8.0-1.x86_64.rpm) |
|
||||||
| **Other Linux** | amd64 | [Gridline_0.7.12_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.AppImage) |
|
| **Other Linux** | amd64 | [Gridline_0.8.0_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.AppImage) |
|
||||||
|
|
||||||
**macOS via Homebrew** (recommended for Mac users):
|
**macOS via Homebrew** (recommended for Mac users):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install --cask AdrianBonpin/gridline/gridline
|
# one-time setup (the tap is hosted on Gitea, not GitHub):
|
||||||
# or, after a one-time `brew tap AdrianBonpin/gridline`:
|
brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git
|
||||||
|
brew trust adrianbonpin/gridline
|
||||||
|
|
||||||
|
# install / upgrade:
|
||||||
brew install --cask gridline
|
brew install --cask gridline
|
||||||
```
|
```
|
||||||
|
|
||||||
The cask strips the macOS quarantine flag automatically during install, so **no manual `xattr` step is needed** on the Homebrew path — the app just launches. (Direct-DMG downloads still need the [first-launch instructions](#installers-are-unsigned-for-now) below.)
|
The cask installs the app directly. The DMGs are Developer-ID signed and notarized, so macOS trusts them on first launch — no `xattr` or "Open Anyway" step needed.
|
||||||
|
|
||||||
> **macOS first launch:** macOS may say *"Gridline is damaged and can't be opened"* or *"the developer cannot be verified"* — this is expected; the app isn't Developer-ID signed/notarized yet. See the [macOS first-launch instructions](#installers-are-unsigned-for-now) (right-click → Open, or the one-time `xattr` fix). Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
|
> **macOS first launch:** Gridline is Developer-ID signed and notarized, so macOS opens it without a Gatekeeper prompt. Not sure if your Mac is Intel or Apple Silicon? See [Which file should I download?](#which-file-should-i-download) below.
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.7.12
|
MAINTENANCE: These links are STATIC (versioned) — they point at the v0.8.0
|
||||||
release assets, not at a moving "latest" target. On every new release,
|
release assets, not at a moving "latest" target. On every new release,
|
||||||
update BOTH tables here (Download + Which file should I download?) to the
|
update BOTH tables here (Download + Which file should I download?) to the
|
||||||
new version's asset names, which are tauri-action's default naming:
|
new version's asset names, which are tauri-action's default naming:
|
||||||
@@ -99,6 +102,12 @@ Gridline is built for developers and small teams who manage multiple database en
|
|||||||
<details>
|
<details>
|
||||||
<summary>View the full changelog</summary>
|
<summary>View the full changelog</summary>
|
||||||
|
|
||||||
|
- **v0.7.15** — **Support channel on the landing page**: optional coffee via [Ko-fi](https://ko-fi.com/adrianbonpin), never gates a feature. Landing copy refreshed (em dashes dropped, OSS section simplified, funding FAQ); version bump.
|
||||||
|
|
||||||
|
- **v0.7.14** — **No more repeated macOS keychain prompts**: saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature on every save (`keychain_acl`), so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from pre-signing builds see one final prompt — choose **Always Allow**). **Install via Homebrew**: `brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git` then `brew install --cask gridline`; version bump.
|
||||||
|
|
||||||
|
- **v0.7.13** — **macOS is now Developer-ID signed and notarized**: the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround. The release script (`scripts/release-mac.sh`) auto-detects the signing identity from the keychain, notarizes + staples both architectures, and verifies the result before uploading; version bump.
|
||||||
|
|
||||||
- **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
|
- **v0.7.12** — **Reliable cell editing for non-text columns**: editing a PostgreSQL cell whose type isn't `text` (integers, booleans, UUIDs, `json`/`jsonb`, timestamps, enums, numerics, …) previously failed with a Rust serialization error; edited values are now sent to PostgreSQL in text wire format so the server parses them into the column's own type. **Recent connections fix**: the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet. **Full CI release pipeline for Windows**: self-hosted Windows runner builds the `.exe`/`.msi` installers (bundled `pg_dump`/`pg_restore`/`psql` + MariaDB clients) and uploads them to the release alongside Linux; version bump.
|
||||||
|
|
||||||
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump.
|
- **v0.7.10** — **Copy connection URL**: from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools — **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended; version bump.
|
||||||
@@ -307,37 +316,40 @@ Pre-built installers for macOS, Windows, and Linux are published on the [Release
|
|||||||
|
|
||||||
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them.
|
> ⚠️ Gridline is under active development. Expect rough edges and please [open issues](https://git.ranio.xyz/adrianbonpin/gridline/issues/new) when you hit them.
|
||||||
|
|
||||||
#### Installers are unsigned (for now)
|
#### macOS signing & notarization
|
||||||
|
|
||||||
Gridline is currently distributed **unsigned** — it doesn't pay for code-signing certificates yet (macOS builds are *ad-hoc signed*, so they pass Apple Silicon's launch checks but aren't Developer-ID signed or notarized). Your OS will warn you the first time you open it. This is expected — the app is safe, it just hasn't paid the signing fee:
|
Gridline's macOS builds are **Developer-ID signed and notarized**, so macOS opens them without a Gatekeeper prompt. This is configured on the machine that runs `scripts/release-mac.sh` (macOS is built manually, not in CI).
|
||||||
|
|
||||||
> **Homebrew users:** the [Homebrew cask](#download) strips the quarantine flag automatically during install, so you skip all of the steps below — the app just launches.
|
**One-time setup** (only the repo owner needs to do this):
|
||||||
|
|
||||||
- **macOS:** if you see *"Gridline can't be opened because the developer cannot be verified"* (or the app simply won't open from Finder), right-click the app → **Open** → **Open** (or System Settings → Privacy & Security → **Open Anyway**). Do this once per version.
|
1. **Create a Developer ID Application certificate** — [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → **+** → **Developer ID Application**. Generate a Certificate Signing Request from Keychain Access (Certificate Assistant → *Request a Certificate From a Certificate Authority*), upload it, download the `.cer`, and double-click to install into your login keychain. Verify with `security find-identity -v -p codesigning`.
|
||||||
- **macOS — "Gridline is damaged and can't be opened":** remove the quarantine flag macOS attaches to downloaded apps, then launch normally:
|
2. **Create an App Store Connect API key** for notarization — [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → Users and Access → **Integrations** → **App Store Connect API** → **+** (name it, *Developer* access). Download the `.p8` (shown once) and note the **Key ID** and **Issuer ID**.
|
||||||
|
3. **Save the notarization credentials** to `~/.config/gridline/notarize.env` (never committed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo xattr -dr com.apple.quarantine /Applications/Gridline.app
|
APPLE_API_KEY=<Key ID>
|
||||||
```
|
APPLE_API_ISSUER=<Issuer ID>
|
||||||
|
APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8
|
||||||
|
```
|
||||||
|
|
||||||
(`sudo` is required — some files inside the bundle are read-only, so removing the flag needs admin rights.) Re-run after **every upgrade** — each freshly-downloaded copy gets re-quarantined.
|
(Alternatively, drop the `.p8` at `~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8` and omit `APPLE_API_KEY_PATH`.)
|
||||||
- **Windows:** on the SmartScreen prompt, click **More info** → **Run anyway**.
|
|
||||||
- **Linux:** no warning — install and run normally.
|
|
||||||
|
|
||||||
Code signing **will be added in the future** (Apple Developer Program + a Windows signing cert, e.g. Azure Trusted Signing) — the CI workflow is already wired to pick up the signing secrets automatically the moment they exist, no workflow changes needed.
|
`scripts/release-mac.sh` auto-detects the Developer ID identity from your keychain, signs both architectures, notarizes + staples them, and verifies the result before uploading. If the certificate or credentials are missing it fails with instructions (set `GRIDLINE_SKIP_NOTARIZE=1` to build signed-but-unnotarized for testing only).
|
||||||
|
|
||||||
|
**Windows** still shows a SmartScreen prompt (no Windows signing cert yet); **Linux** installs without a warning.
|
||||||
|
|
||||||
#### Which file should I download?
|
#### Which file should I download?
|
||||||
|
|
||||||
Each release contains **one file per platform** — you only need the one that matches your computer. The links below point at the current release (**v0.7.12**):
|
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.8.0**):
|
||||||
|
|
||||||
| Your system | Download this | Notes |
|
| Your system | Download this | Notes |
|
||||||
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
| :------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------- |
|
||||||
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.7.12_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_aarch64.dmg) | `aarch64` = Apple's own chip |
|
| macOS **Apple Silicon** (M1/M2/M3/M4…) | [Gridline_0.8.0_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_aarch64.dmg) | `aarch64` = Apple's own chip |
|
||||||
| macOS **Intel** | [Gridline_0.7.12_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64.dmg) | `x64` = Intel/AMD |
|
| macOS **Intel** | [Gridline_0.8.0_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64.dmg) | `x64` = Intel/AMD |
|
||||||
| **Windows** (most PCs) | [Gridline_0.7.12_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
| **Windows** (most PCs) | [Gridline_0.8.0_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_x64-setup.exe) | The `.msi` is an alternate installer (for enterprises/IT admins) |
|
||||||
| **Debian / Ubuntu** | [Gridline_0.7.12_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.deb) | Install: `sudo apt install ./Gridline_0.7.12_amd64.deb` |
|
| **Debian / Ubuntu** | [Gridline_0.8.0_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.deb) | Install: `sudo apt install ./Gridline_0.8.0_amd64.deb` |
|
||||||
| **Fedora / RHEL / openSUSE** | [Gridline-0.7.12-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline-0.7.12-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.7.12-1.x86_64.rpm` |
|
| **Fedora / RHEL / openSUSE** | [Gridline-0.8.0-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline-0.8.0-1.x86_64.rpm) | Install: `sudo dnf install Gridline-0.8.0-1.x86_64.rpm` |
|
||||||
| **Any other Linux** | [Gridline_0.7.12_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.12/Gridline_0.7.12_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
| **Any other Linux** | [Gridline_0.8.0_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.8.0/Gridline_0.8.0_amd64.AppImage) | Works on every distro: `chmod +x` the file, then double-click it |
|
||||||
|
|
||||||
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
|
**Not sure if your Mac is Intel or Apple Silicon?** Click the **Apple menu** → **About This Mac**. If it shows "Apple M1/M2/M3/M4…" download the `aarch64` file; if it shows an Intel chip, download `x64`. Downloading the wrong one won't run.
|
||||||
|
|
||||||
@@ -347,8 +359,8 @@ Cutting a release is one command — CI builds everything. **Releases are cut fr
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout prod && git pull
|
git checkout prod && git pull
|
||||||
git tag v0.7.12
|
git tag v0.7.15
|
||||||
git push origin v0.7.12
|
git push origin v0.7.15
|
||||||
```
|
```
|
||||||
|
|
||||||
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**.
|
Gitea Actions (`.gitea/workflows/release.yml`) builds the **Linux and Windows** installers on the self-hosted runners, then opens a **draft release** on the [Releases](https://git.ranio.xyz/adrianbonpin/gridline/releases) page. **macOS is built manually** on the dev Mac and its DMGs uploaded to the same release via `scripts/release-mac.sh`. Review the draft and hit **Publish release**.
|
||||||
|
|||||||
+73
-55
@@ -6,38 +6,21 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md](
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Shipped (0.7.10)
|
## ✅ Shipped (0.8.0)
|
||||||
|
|
||||||
- **Copy connection URL** — from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools: **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended.
|
- **Schema diff / compare** — same-engine structural diff (PG↔PG, MySQL-family↔MySQL-family, SQLite↔SQLite) with per-object sync SQL; non-destructive items stage through the changes queue, destructive items are copy-only. Target = current connection; source = any saved same-family connection.
|
||||||
- **Version bump** 0.7.9 → **0.7.10**.
|
- **Query Workbench: multiple result sets** — stacked result panels per statement with DML/DDL affected-count notices and stop-at-first-error *(deferred from 0.7.0)*
|
||||||
|
- **Query Workbench: result streaming to file** — full-result CSV/JSONL/JSON export with progress + cancel, constant memory (PG cursor fetch, MySQL/SQLite native streams)
|
||||||
## ✅ Shipped (0.7.9)
|
- **MariaDB support** — first-class `db_type` reusing the MySQL driver path; provider card, filter, URI schemes, backup via the already-bundled mariadb tools
|
||||||
|
- **TimescaleDB support** — runtime extension detection; hypertables in the Objects view (dimensions, compression, chunks, size), read-only
|
||||||
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started).
|
- **PlanetScale preset** — managed MySQL (Vitess) provider card + setup guide + `.psdb.cloud` host detection; persists as `db_type: mysql`
|
||||||
- **macOS ad-hoc code signing** — bundles are signed at build time (`bundle.macOS.signingIdentity "-"`, hardened runtime off), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusal). Users still get a one-time Gatekeeper prompt; README documents the `sudo xattr -dr com.apple.quarantine` workaround.
|
- **Version bump** 0.7.15 → **0.8.0**.
|
||||||
- **Vendored OpenSSL for `ssh2`** — the release binary no longer links an absolute Homebrew `/opt/homebrew/.../libssl.3.dylib` path (dyld aborted on machines without it).
|
|
||||||
- **Version bump** 0.7.8 → **0.7.9**.
|
|
||||||
|
|
||||||
## 🎯 Next up (0.8.0) — TBD
|
|
||||||
|
|
||||||
Scope to be determined.
|
|
||||||
|
|
||||||
## ✅ Shipped (0.7.8)
|
|
||||||
|
|
||||||
- **SQLite `.dump` support** — match the pg_dump UX for SQLite (backup a SQLite database to a portable SQL dump, restore it back)
|
|
||||||
- **Backup / Restore / Sync for MySQL & SQLite** — extend the pg-only tooling today: SQLite backup/restore + MySQL via `mysqldump` (system-first; decide bundling)
|
|
||||||
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
|
|
||||||
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
|
|
||||||
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
|
||||||
- **Windows/Linux title bar fix** — the macOS "Overlay" drag strip (`h-7` in `App.tsx`) renders on every Tauri platform, so Windows/Linux show a blank grabbable bar between the native title bar and the page; gate the strip to macOS only (native title bar already handles dragging elsewhere)
|
|
||||||
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow is PostgreSQL-only today (`tableManagement` capability + PG-flavored SQL gen in `TableForm`); extend to SQLite: SQLite-aware type mapping (no `serial` — `INTEGER PRIMARY KEY AUTOINCREMENT` instead), `TEXT`/`REAL`/`BLOB`, and ALTER TABLE limits (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
|
|
||||||
- **Version bump** 0.7.7 → **0.7.8**.
|
|
||||||
|
|
||||||
## 📋 In the queue
|
## 📋 In the queue
|
||||||
|
|
||||||
### Full Redis Support
|
### Full Redis Support
|
||||||
|
|
||||||
Connection + test work today; **key browsing is explicitly not part of 0.7.0** — it stays gated behind an "unsupported" state. Goal: first-class Redis like the PG/SQLite/MySQL viewers.
|
Connection + test only today — browsing stays gated behind a clean "unsupported" state. Goal: first-class Redis like the PG/SQLite/MySQL viewers.
|
||||||
|
|
||||||
- Key browser (pattern search, filter by type, TTL display, key count)
|
- Key browser (pattern search, filter by type, TTL display, key count)
|
||||||
- Value editors per type: string, list, hash, set, zset, stream, JSON
|
- Value editors per type: string, list, hash, set, zset, stream, JSON
|
||||||
@@ -46,28 +29,26 @@ Connection + test work today; **key browsing is explicitly not part of 0.7.0**
|
|||||||
|
|
||||||
### Additional Database Types
|
### Additional Database Types
|
||||||
|
|
||||||
- **MariaDB** (wire-compatible with MySQL — should largely fall out of the 0.7.0 MySQL work)
|
MariaDB and TimescaleDB moved to [0.8.0](#-next-up-080). Remaining candidates: CockroachDB, DuckDB, SQL Server, MongoDB (drivers are heavier lifts — revisit with demand).
|
||||||
- **TimescaleDB** (PostgreSQL extension — largely free once PG browsing is solid; surface hypertables/compression in the object tree)
|
|
||||||
- Candidates after that: CockroachDB, DuckDB, SQL Server, MongoDB (drivers are heavier lifts — revisit with demand)
|
|
||||||
|
|
||||||
### Managed Database Support (beyond Supabase / Neon)
|
### Managed Database Support (beyond Supabase / Neon)
|
||||||
|
|
||||||
Supabase and NeonDB presets shipped in v0.7.0. Remaining candidates:
|
Supabase and NeonDB presets shipped in v0.7.0; PlanetScale moved to [0.8.0](#-next-up-080). Remaining:
|
||||||
|
|
||||||
- **PlanetScale** (Vitess/MySQL)
|
- **Turso** (libSQL — not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset)
|
||||||
- **Turso** (libSQL)
|
|
||||||
- Provider detection guidance: paste a provider URL → Gridline auto-fills host/port/SSL mode; provider "connect" docs linked from the connection form
|
- Provider detection guidance: paste a provider URL → Gridline auto-fills host/port/SSL mode; provider "connect" docs linked from the connection form
|
||||||
|
|
||||||
### Query Workbench Upgrades
|
### Query Workbench Upgrades
|
||||||
|
|
||||||
- **Multiple result sets** — one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)*
|
Multiple result sets and result streaming to file moved to [0.8.0](#-next-up-080). Remaining:
|
||||||
- **Result streaming to file** — export 500k+ rows without loading them all into memory
|
|
||||||
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
|
- **Visual query builder** — drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one)
|
||||||
|
|
||||||
### Schema & Data Tooling
|
### Schema & Data Tooling
|
||||||
|
|
||||||
- **Schema diff / compare** — two-database structure diff that pairs naturally with DB-to-DB sync
|
Schema diff / compare moved to [0.8.0](#-next-up-080). Remaining:
|
||||||
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(deferred from 0.7.0 and out of scope for the object-management release — PostgreSQL-only for now)*
|
|
||||||
|
- **MySQL Objects view + schema visualizer** — functions/triggers/sequences/enums/extensions browsing and ER diagram for MySQL *(PostgreSQL-only today)*
|
||||||
- **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
|
- **More export formats** — JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel
|
||||||
|
|
||||||
## 🔮 Planned
|
## 🔮 Planned
|
||||||
@@ -84,9 +65,9 @@ Bring-your-own-key — no bundled model, no paywall, key stored in the OS keycha
|
|||||||
|
|
||||||
### Website & Docs
|
### Website & Docs
|
||||||
|
|
||||||
- Landing page with screenshots, feature tour, and download links
|
The marketing site (`www/`, Astro) is live: landing page with screenshots, FAQ, and a changelog feed. Remaining:
|
||||||
|
|
||||||
- User documentation (connection setup, SSH/TLS, backup/sync, changes queue)
|
- User documentation (connection setup, SSH/TLS, backup/sync, changes queue)
|
||||||
- Blog / changelog feed
|
|
||||||
|
|
||||||
### UI/UX Improvements (rolling)
|
### UI/UX Improvements (rolling)
|
||||||
|
|
||||||
@@ -99,15 +80,63 @@ Continuous polish, tracked as issues rather than one-off milestones:
|
|||||||
- Session restore — reopen tabs and query state from the last session
|
- Session restore — reopen tabs and query state from the last session
|
||||||
- Light-theme parity pass — dark is first-class; polish the light theme to match
|
- Light-theme parity pass — dark is first-class; polish the light theme to match
|
||||||
|
|
||||||
Deferred from 0.7.0, slated for this bucket:
|
Still deferred from 0.7.0, slated for this bucket:
|
||||||
|
|
||||||
- **Onboarding tour** — first-run walkthrough built around the Gridline Demo database; contextual tooltips per screen
|
- **Onboarding tour** — first-run walkthrough built around the Gridline Demo database; contextual tooltips per screen
|
||||||
- **Settings export/import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
|
||||||
- **SSH key management** — read/generate key pairs and paste private keys directly in the SSH tab (today: path inputs only)
|
- **SSH key management** — read/generate key pairs and paste private keys directly in the SSH tab (today: path inputs only)
|
||||||
- **In-app changelog** — "What's new" panel fed from bundled release notes
|
- **In-app changelog** — "What's new" panel fed from bundled release notes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.10)
|
||||||
|
|
||||||
|
- **Copy connection URL** — from a connection card's ⋮ menu, copy the connection string for use in env vars / other tools: **Copy connection URL** (includes the password, fetched from the OS keychain on demand) and **Copy connection URL (no password)** (safe to share). Builds `postgresql://` / `mysql://` / `sqlite://` / `redis://` strings with percent-encoded credentials and the PG `sslmode` appended.
|
||||||
|
- **Version bump** 0.7.9 → **0.7.10**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.15)
|
||||||
|
|
||||||
|
- **Insert Row** — adds an editable pending row at the top of the data grid (visible accent outline); committing inserts only the filled columns so serial/identity/generated defaults apply
|
||||||
|
- **Smart cell editors** — date/time/datetime pickers, a boolean select, and number inputs for the matching column types (enums and FKs already had dropdowns), applied to both new rows and inline editing
|
||||||
|
- **Landing page support channel** — coffee via Ko-fi (ko-fi.com/adrianbonpin), always optional, never gates a feature
|
||||||
|
- **Landing page copy refresh** — em dashes dropped, OSS section simplified, FAQ added for how the project is funded
|
||||||
|
- **Version bump** 0.7.14 → **0.7.15**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.14)
|
||||||
|
|
||||||
|
- **No more repeated macOS keychain prompts** — saved connection passwords and SSH secrets are ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow)
|
||||||
|
- **Install via Homebrew** — add the Gitea tap (`brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git`) and run `brew install --cask gridline`
|
||||||
|
- **Version bump** 0.7.13 → **0.7.14**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.13)
|
||||||
|
|
||||||
|
- **Developer-ID signed + notarized macOS** — the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper "Open Anyway" prompt or the `xattr` quarantine workaround
|
||||||
|
- **Version bump** 0.7.12 → **0.7.13**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.12) *(includes 0.7.11: release-pipeline/docs refresh — README download links moved from GitHub to the self-hosted Gitea `git.ranio.xyz`)*
|
||||||
|
|
||||||
|
- **Reliable cell editing for non-text columns** — editing a PostgreSQL cell whose type isn't text (integers, booleans, UUIDs, json/jsonb, timestamps, enums, numerics) now works; edited values are sent to PostgreSQL in text wire format so the server parses them into the column's own type
|
||||||
|
- **Recent connections fix** — the Recent strip no longer renders empty on first launch when the connections list hadn't loaded yet
|
||||||
|
- **Full CI release pipeline for Windows** — the self-hosted Windows runner now builds the .exe/.msi installers (bundled pg_dump/pg_restore/psql + MariaDB clients) and uploads them to the release alongside Linux
|
||||||
|
- **Version bump** 0.7.11 → **0.7.12**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.9)
|
||||||
|
|
||||||
|
- **macOS Finder launch fix** — the local store opens under the OS app-data directory instead of a cwd-relative `gridline.db` (Finder/LaunchServices launches run with cwd `/`, so the old path made the app silently exit with a Rust panic before the UI started).
|
||||||
|
- **macOS ad-hoc code signing** — bundles are signed at build time (`bundle.macOS.signingIdentity "-"`, hardened runtime off), replacing the Xcode linker-only signature that macOS treated as unsigned ("damaged and can't be opened", silent Finder refusal). Users still get a one-time Gatekeeper prompt; README documents the `sudo xattr -dr com.apple.quarantine` workaround.
|
||||||
|
- **Vendored OpenSSL for `ssh2`** — the release binary no longer links an absolute Homebrew `/opt/homebrew/.../libssl.3.dylib` path (dyld aborted on machines without it).
|
||||||
|
- **Version bump** 0.7.8 → **0.7.9**.
|
||||||
|
|
||||||
|
## ✅ Shipped (0.7.8)
|
||||||
|
|
||||||
|
- **SQLite `.dump` support** — backup a SQLite database to a portable SQL dump and restore it back, matching the pg_dump UX
|
||||||
|
- **Backup / Restore / Sync for MySQL & SQLite** — the pg-only tooling extended: MySQL backup/restore via `mysqldump` (system-first) and DB-to-DB sync beyond PostgreSQL
|
||||||
|
- **Excel (.xlsx) export** — alongside CSV/JSON/SQL/Markdown in the grid export toolbar
|
||||||
|
- **Cancel long-running queries** — per-connection cancel button (`pg_cancel_backend` and equivalents) instead of waiting or killing the app
|
||||||
|
- **Settings export / import** — share theme, accent, editor options, page sizes, and defaults across machines (JSON file)
|
||||||
|
- **Windows/Linux title bar fix** — the macOS overlay drag strip (`h-7` in `App.tsx`) is now gated to macOS; Windows/Linux use the native title bar for dragging
|
||||||
|
- **SQLite table editor (Create/Edit Table)** — the visual Create Table / Edit Table flow extended to SQLite: SQLite-aware type mapping (`INTEGER PRIMARY KEY AUTOINCREMENT` instead of `serial`, `TEXT`/`REAL`/`BLOB`) and ALTER TABLE limits respected (`ADD COLUMN` can't add PK/UNIQUE, `DROP COLUMN` needs SQLite ≥3.35)
|
||||||
|
- **Version bump** 0.7.7 → **0.7.8**.
|
||||||
|
|
||||||
## ✅ Shipped (0.7.7)
|
## ✅ Shipped (0.7.7)
|
||||||
|
|
||||||
### Admin follow-up
|
### Admin follow-up
|
||||||
@@ -151,24 +180,13 @@ Deferred from 0.7.0, slated for this bucket:
|
|||||||
- **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls.
|
- **Input styling sweep** — `rounded-full` → `rounded-lg` on all form controls.
|
||||||
- **Version bump** 0.6.0 → **0.7.0**.
|
- **Version bump** 0.6.0 → **0.7.0**.
|
||||||
|
|
||||||
**Not in 0.7.0** (deferred, tracked above): Redis key browsing, MySQL Objects/ERD views, backup/restore/sync for MySQL + SQLite, multiple result sets, SSH key-file management, settings import/export, onboarding tour.
|
**Deferred from 0.7.0**: Redis key browsing and MySQL Objects/ERD views (still in the queue); SSH key management and onboarding tour (planned). Since shipped elsewhere: backup/restore/sync for MySQL & SQLite and settings import/export (0.7.8); multiple result sets (slated for 0.8.0).
|
||||||
|
|
||||||
## ✅ Shipped
|
## ✅ Shipped — earlier releases (pre-0.7.0)
|
||||||
|
|
||||||
- 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)
|
|
||||||
- Copy as DDL for any object (v0.7.5)
|
|
||||||
- Object dependencies — `pg_depend` view before destructive drops (v0.7.5)
|
|
||||||
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
|
- Tauri 2.0 + React 19 + TypeScript 5.8 project shell
|
||||||
- PostgreSQL and SQLite browse/query support
|
|
||||||
- Full MySQL DB viewer — connect, browse, query, edit + changes queue (v0.7.0)
|
|
||||||
- New Connection screen revamp — provider grid + Supabase/NeonDB managed-pg presets (v0.7.0)
|
|
||||||
- DB viewer capability gating + Redis unsupported state (v0.7.0)
|
|
||||||
- Connection management with URI parser, SSH tunnels, TLS, OS keychain
|
- Connection management with URI parser, SSH tunnels, TLS, OS keychain
|
||||||
|
- PostgreSQL and SQLite browse/query support
|
||||||
- Workspace tree, folders, tags, favorites, recents
|
- Workspace tree, folders, tags, favorites, recents
|
||||||
- Multi-tab DB viewer with virtualized grid, server-side filtering/sorting
|
- Multi-tab DB viewer with virtualized grid, server-side filtering/sorting
|
||||||
- FK preview, JSON popover, inline cell editing, changes queue
|
- FK preview, JSON popover, inline cell editing, changes queue
|
||||||
@@ -177,4 +195,4 @@ Deferred from 0.7.0, slated for this bucket:
|
|||||||
- Full PostgreSQL object explorer + schema visualizer
|
- Full PostgreSQL object explorer + schema visualizer
|
||||||
- Backup, restore, and DB-to-DB sync tools
|
- Backup, restore, and DB-to-DB sync tools
|
||||||
- Settings redesign with theme, accent color, editor options
|
- Settings redesign with theme, accent color, editor options
|
||||||
- Built-in **Gridline Demo (SQLite)** database
|
- Built-in **Gridline Demo (SQLite)** database
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gridline-desktop",
|
"name": "gridline-desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.12",
|
"version": "0.8.0",
|
||||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+5
-1
@@ -1783,11 +1783,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.12"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
|
"futures-util",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"redis",
|
"redis",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -1795,6 +1797,8 @@ dependencies = [
|
|||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"rustls-pemfile 2.2.0",
|
"rustls-pemfile 2.2.0",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
"security-framework 3.7.0",
|
||||||
|
"security-framework-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gridline"
|
name = "gridline"
|
||||||
version = "0.7.12"
|
version = "0.8.0"
|
||||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -32,6 +32,7 @@ tauri-plugin-fs = "2"
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
|
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
|
futures-util = "0.3"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
|
||||||
redis = { version = "0.27", features = ["tokio-comp"] }
|
redis = { version = "0.27", features = ["tokio-comp"] }
|
||||||
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
|
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
|
||||||
@@ -49,3 +50,12 @@ rustls-pemfile = "2"
|
|||||||
rustls-pki-types = "1"
|
rustls-pki-types = "1"
|
||||||
rustls-native-certs = "0.7"
|
rustls-native-certs = "0.7"
|
||||||
|
|
||||||
|
# Keychain ACL normalization (explicit SecAccess on stored secrets so items
|
||||||
|
# survive app re-signs without permission prompts) — macOS only.
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
security-framework = "3.7"
|
||||||
|
security-framework-sys = "2.17"
|
||||||
|
# core-foundation: CFString/CFArray/TCFType helpers for the SecAccess FFI
|
||||||
|
# (already a transitive dep of security-framework; pinned to the same 0.10 line)
|
||||||
|
core-foundation = "0.10"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Required for the WKWebView (JIT-compiled JavaScript). Without this the
|
||||||
|
app crashes under the hardened runtime. -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<!-- The WebView allocates executable memory for JIT; allow it. -->
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Tauri's dev server / debug builds rely on DYLD_* environment variables. -->
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -2,7 +2,7 @@ use crate::models::{Connection, ConnectionInput};
|
|||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
const VALID_DB_TYPES: [&str; 5] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||||
|
|
||||||
fn validate(input: &ConnectionInput) -> Result<(), String> {
|
fn validate(input: &ConnectionInput) -> Result<(), String> {
|
||||||
if input.name.is_empty() || input.name.chars().count() > 100 {
|
if input.name.is_empty() || input.name.chars().count() > 100 {
|
||||||
@@ -360,3 +360,7 @@ mod tests {
|
|||||||
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
|
assert_eq!(get_recent_connections_inner(&st, 10).unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "connections.test.rs"]
|
||||||
|
mod connections_tests;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mariadb_input() -> ConnectionInput {
|
||||||
|
ConnectionInput {
|
||||||
|
name: "Maria local".into(),
|
||||||
|
db_type: "mariadb".into(),
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port: Some(3306),
|
||||||
|
username: Some("root".into()),
|
||||||
|
folder_id: None,
|
||||||
|
tag_ids: vec![],
|
||||||
|
password: None,
|
||||||
|
database: Some("test".into()),
|
||||||
|
environment: None,
|
||||||
|
ssh_host: None,
|
||||||
|
ssh_port: None,
|
||||||
|
ssh_user: None,
|
||||||
|
ssh_auth_method: None,
|
||||||
|
ssh_private_key_path: None,
|
||||||
|
ssh_password: None,
|
||||||
|
ssh_passphrase: None,
|
||||||
|
ssl_mode: None,
|
||||||
|
ssl_ca_path: None,
|
||||||
|
ssl_cert_path: None,
|
||||||
|
ssl_key_path: None,
|
||||||
|
use_keychain: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_accepts_mariadb_with_port() {
|
||||||
|
assert!(validate(&mariadb_input()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_mariadb_without_port() {
|
||||||
|
let mut input = mariadb_input();
|
||||||
|
input.port = None;
|
||||||
|
assert!(validate(&input).is_err(), "mariadb requires a port like mysql");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_rejects_unknown_db_type() {
|
||||||
|
let mut input = mariadb_input();
|
||||||
|
input.db_type = "oracle".into();
|
||||||
|
assert!(validate(&input).is_err());
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||||
use crate::models::db_viewer::{
|
use crate::models::db_viewer::{
|
||||||
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
|
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, HypertableInfo,
|
||||||
QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
HypertableListResponse, IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
||||||
};
|
};
|
||||||
use bytes::BytesMut;
|
use bytes::BytesMut;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -566,6 +566,14 @@ pub fn build_pg_insert_sql(
|
|||||||
table: &str,
|
table: &str,
|
||||||
columns: &[(String, serde_json::Value)],
|
columns: &[(String, serde_json::Value)],
|
||||||
) -> (String, Vec<serde_json::Value>) {
|
) -> (String, Vec<serde_json::Value>) {
|
||||||
|
// A table with no editable columns (e.g. only a serial/identity PK) inserts
|
||||||
|
// with all defaults — `INSERT ... () VALUES ()` is invalid SQL.
|
||||||
|
if columns.is_empty() {
|
||||||
|
return (
|
||||||
|
format!("INSERT INTO \"{}\".\"{}\" DEFAULT VALUES", schema, table),
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
}
|
||||||
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
|
let cols: Vec<String> = columns.iter().map(|(c, _)| format!("\"{}\"", c)).collect();
|
||||||
let mut params: Vec<serde_json::Value> = Vec::new();
|
let mut params: Vec<serde_json::Value> = Vec::new();
|
||||||
let placeholders: Vec<String> = columns
|
let placeholders: Vec<String> = columns
|
||||||
@@ -1190,7 +1198,7 @@ pub async fn db_connect(
|
|||||||
}
|
}
|
||||||
Err(e) => Err(format!("Connection failed: {}", e)),
|
Err(e) => Err(format!("Connection failed: {}", e)),
|
||||||
}
|
}
|
||||||
} else if config.db_type == "mysql" {
|
} else if config.db_type == "mysql" || config.db_type == "mariadb" {
|
||||||
run_mysql_connect(
|
run_mysql_connect(
|
||||||
&connection_id,
|
&connection_id,
|
||||||
&config,
|
&config,
|
||||||
@@ -2632,6 +2640,58 @@ pub async fn get_functions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_hypertables(
|
||||||
|
connection_id: String,
|
||||||
|
schema: Option<String>,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<HypertableListResponse, String> {
|
||||||
|
let mut pm = state.pool_manager.lock().await;
|
||||||
|
match pm.get(&connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(client, _)) => {
|
||||||
|
let detected: bool = client
|
||||||
|
.query_one(&crate::db::introspection::pg_timescale_detect_query(), &[])
|
||||||
|
.await
|
||||||
|
.map(|r| r.get::<_, bool>(0))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !detected {
|
||||||
|
return Ok(HypertableListResponse {
|
||||||
|
available: false,
|
||||||
|
reason: Some("TimescaleDB extension is not installed".into()),
|
||||||
|
items: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let schema = schema.unwrap_or_else(|| "public".to_string());
|
||||||
|
let query = crate::db::introspection::pg_hypertables_query(&schema);
|
||||||
|
let rows = client
|
||||||
|
.query(&query, &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("TimescaleDB introspection failed: {e}"))?;
|
||||||
|
Ok(HypertableListResponse {
|
||||||
|
available: true,
|
||||||
|
reason: None,
|
||||||
|
items: rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| HypertableInfo {
|
||||||
|
name: r.get(0),
|
||||||
|
schema: r.get(1),
|
||||||
|
num_dimensions: r.get::<_, Option<i64>>(2).unwrap_or(0),
|
||||||
|
compression_enabled: r.get::<_, Option<bool>>(3).unwrap_or(false),
|
||||||
|
num_chunks: r.get::<_, Option<i64>>(4).unwrap_or(0),
|
||||||
|
total_size_bytes: r.get::<_, Option<i64>>(5),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some(DbHandle::Sqlite(_)) | Some(DbHandle::MySql(_)) => Ok(HypertableListResponse {
|
||||||
|
available: false,
|
||||||
|
reason: Some("Hypertables are a TimescaleDB (PostgreSQL) feature".into()),
|
||||||
|
items: vec![],
|
||||||
|
}),
|
||||||
|
None => Err("Connection not found".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_indexes(
|
pub async fn get_indexes(
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
@@ -3102,6 +3162,15 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A table with no editable columns (only a serial/identity PK) must insert
|
||||||
|
/// with `DEFAULT VALUES`, not the invalid `INSERT ... () VALUES ()`.
|
||||||
|
#[test]
|
||||||
|
fn build_pg_insert_sql_empty_columns_uses_default_values() {
|
||||||
|
let (sql, params) = build_pg_insert_sql("public", "t", &[]);
|
||||||
|
assert_eq!(sql, "INSERT INTO \"public\".\"t\" DEFAULT VALUES");
|
||||||
|
assert!(params.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
|
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
|
||||||
/// from `sqlite_master`.
|
/// from `sqlite_master`.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3620,3 +3689,7 @@ mod tests {
|
|||||||
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
|
assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "db_viewer.test.rs"]
|
||||||
|
mod db_viewer_extra_tests;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use crate::db::pool::{ConnectionPoolManager, DbHandle};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hypertables_query_targets_timescale_information_schema() {
|
||||||
|
let q = crate::db::introspection::pg_hypertables_query("public");
|
||||||
|
assert!(q.contains("timescaledb_information.hypertables"));
|
||||||
|
assert!(q.contains("hypertable_schema = $1"));
|
||||||
|
assert!(q.contains("timescaledb_information.chunks"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timescale_detect_query_checks_pg_extension() {
|
||||||
|
let q = crate::db::introspection::pg_timescale_detect_query();
|
||||||
|
assert!(q.contains("pg_extension"));
|
||||||
|
assert!(q.contains("'timescaledb'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hypertable_response_degrades_gracefully() {
|
||||||
|
let resp = crate::models::db_viewer::HypertableListResponse {
|
||||||
|
available: false,
|
||||||
|
reason: Some("TimescaleDB extension is not installed".into()),
|
||||||
|
items: vec![],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&resp).expect("serializes");
|
||||||
|
assert!(json.contains("\"available\":false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live test. On a plain PostgreSQL connection (no TimescaleDB) the detection
|
||||||
|
/// helper must report `available: false`, never an error. Follows the exact
|
||||||
|
/// env-var + client-construction convention used by the live PG tests in
|
||||||
|
/// `commands/objects.test.rs`.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn hypertables_unavailable_on_plain_pg() {
|
||||||
|
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
|
||||||
|
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
|
||||||
|
.unwrap_or_else(|_| "5432".into())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||||
|
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||||
|
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||||
|
let (client, conn) = tokio_postgres::connect(
|
||||||
|
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("connect to test PG");
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let _ = conn.await;
|
||||||
|
});
|
||||||
|
let mut pm = ConnectionPoolManager::new();
|
||||||
|
let id = "test-pg".to_string();
|
||||||
|
pm.register(&id, DbHandle::Postgresql(client, handle));
|
||||||
|
let pm = tokio::sync::Mutex::new(pm);
|
||||||
|
|
||||||
|
// Exercise the detection helper directly (the command needs `State`, which
|
||||||
|
// is not constructible in tests).
|
||||||
|
let detected = {
|
||||||
|
let mut g = pm.lock().await;
|
||||||
|
match g.get(&id) {
|
||||||
|
Some(DbHandle::Postgresql(c, _)) => {
|
||||||
|
c.query_one(&crate::db::introspection::pg_timescale_detect_query(), &[])
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => panic!("expected a postgresql handle"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert!(detected.is_ok(), "detection query must run on plain PG");
|
||||||
|
let available: bool = detected.unwrap().get(0);
|
||||||
|
assert!(!available, "plain PG must report available=false");
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ use crate::store::Store;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
const VALID_DB_TYPES: [&str; 5] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(crate) struct ImportRecord {
|
pub(crate) struct ImportRecord {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::keychain_acl;
|
||||||
use tauri_plugin_keyring_store::KeyringExt;
|
use tauri_plugin_keyring_store::KeyringExt;
|
||||||
|
|
||||||
/// Store a connection password in the OS keychain.
|
/// Store a connection password in the OS keychain.
|
||||||
@@ -11,7 +12,10 @@ pub fn save_connection_password(
|
|||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.set_password(&connection_id, &password)
|
.set_password(&connection_id, &password)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
// Best-effort: pin the item's ACL to this app so re-signed builds never prompt.
|
||||||
|
keychain_acl::after_store(&app, &connection_id);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve a connection password from the OS keychain.
|
/// Retrieve a connection password from the OS keychain.
|
||||||
@@ -55,7 +59,9 @@ pub fn save_connection_ssh_password(
|
|||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.set_password(&ssh_account("password", &connection_id), &password)
|
.set_password(&ssh_account("password", &connection_id), &password)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
keychain_acl::after_store(&app, &ssh_account("password", &connection_id));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve an SSH tunnel password from the OS keychain.
|
/// Retrieve an SSH tunnel password from the OS keychain.
|
||||||
@@ -93,7 +99,9 @@ pub fn save_connection_ssh_passphrase(
|
|||||||
app.keyring()
|
app.keyring()
|
||||||
.store
|
.store
|
||||||
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())?;
|
||||||
|
keychain_acl::after_store(&app, &ssh_account("passphrase", &connection_id));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ pub mod keychain;
|
|||||||
pub mod maintenance;
|
pub mod maintenance;
|
||||||
pub mod objects;
|
pub mod objects;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
|
pub mod query_export;
|
||||||
|
pub mod schema_diff;
|
||||||
pub mod schema_graph;
|
pub mod schema_graph;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod ssh;
|
pub mod ssh;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
//! 3. Every query is recorded in the local `query_history` table.
|
//! 3. Every query is recorded in the local `query_history` table.
|
||||||
|
|
||||||
use crate::db::pool::DbHandle;
|
use crate::db::pool::DbHandle;
|
||||||
use crate::models::db_viewer::{ColumnInfo, QueryResult};
|
use crate::models::db_viewer::{ColumnInfo, MultiQueryResult, QueryResult, StatementNotice};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{Column, Row};
|
use sqlx::{Column, Row};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
@@ -708,6 +708,507 @@ async fn execute_mysql_raw(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multiple result sets (execute_query_multi)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per the spec: statements execute sequentially; SELECT-producing statements
|
||||||
|
// get wrapped pagination (first page per set + total count — per-set paging is
|
||||||
|
// deliberately omitted because re-running a script would re-execute DML);
|
||||||
|
// DML/DDL produce notices; the first error stops the run, prior sets survive.
|
||||||
|
|
||||||
|
const MAX_STATEMENTS: usize = 50;
|
||||||
|
|
||||||
|
pub(crate) enum StatementOutcome {
|
||||||
|
Set(QueryResult),
|
||||||
|
Notice {
|
||||||
|
kind: &'static str,
|
||||||
|
text: String,
|
||||||
|
affected: Option<i64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a statement by its first significant keyword (execution-time
|
||||||
|
/// classification per spec §4 — NOT in the tokenizer).
|
||||||
|
pub(crate) fn statement_kind(stmt: &str) -> &'static str {
|
||||||
|
let k: String = stmt
|
||||||
|
.trim_start()
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_alphabetic())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
|
match k.as_str() {
|
||||||
|
"select" | "with" | "values" | "table" => "select",
|
||||||
|
"insert" | "update" | "delete" => "dml",
|
||||||
|
_ => "ddl",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a PG CommandComplete tag ("INSERT 0 5" → 5; "CREATE TABLE" → None).
|
||||||
|
pub(crate) fn affected_from_tag(tag: &str) -> Option<i64> {
|
||||||
|
tag.split_whitespace().last()?.parse::<i64>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notice_for(
|
||||||
|
idx: usize,
|
||||||
|
kind: &str,
|
||||||
|
text: String,
|
||||||
|
affected: Option<i64>,
|
||||||
|
) -> StatementNotice {
|
||||||
|
StatementNotice {
|
||||||
|
statement_index: idx,
|
||||||
|
kind: kind.to_string(),
|
||||||
|
text,
|
||||||
|
affected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SQLite walker ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(crate) fn execute_sqlite_statement(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
stmt: &str,
|
||||||
|
page: i64,
|
||||||
|
page_size: i64,
|
||||||
|
) -> Result<StatementOutcome, String> {
|
||||||
|
match conn.execute(stmt, []) {
|
||||||
|
Ok(n) => Ok(StatementOutcome::Notice {
|
||||||
|
kind: if statement_kind(stmt) == "ddl" { "ddl" } else { "dml" },
|
||||||
|
text: format!("OK — {n} rows affected"),
|
||||||
|
affected: Some(n as i64),
|
||||||
|
}),
|
||||||
|
Err(rusqlite::Error::ExecuteReturnedResults) => {
|
||||||
|
let (columns, all_rows) = execute_sqlite_with_query(conn, stmt)?;
|
||||||
|
let total_rows = all_rows.len() as i64;
|
||||||
|
let rows: Vec<Vec<serde_json::Value>> = all_rows
|
||||||
|
.into_iter()
|
||||||
|
.take(page_size.max(0) as usize)
|
||||||
|
.collect();
|
||||||
|
Ok(StatementOutcome::Set(QueryResult {
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
total_rows,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
execution_time_ms: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(rusqlite::Error::SqliteFailure(e, _))
|
||||||
|
if e.code == rusqlite::ErrorCode::OperationInterrupted =>
|
||||||
|
{
|
||||||
|
Err("Query cancelled".to_string())
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn run_sqlite_statements(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
statements: &[String],
|
||||||
|
page: i64,
|
||||||
|
page_size: i64,
|
||||||
|
result_sets: &mut Vec<QueryResult>,
|
||||||
|
notices: &mut Vec<StatementNotice>,
|
||||||
|
) -> Option<String> {
|
||||||
|
for (idx, stmt) in statements.iter().enumerate() {
|
||||||
|
match execute_sqlite_statement(conn, stmt, page, page_size) {
|
||||||
|
Ok(StatementOutcome::Set(qr)) => result_sets.push(qr),
|
||||||
|
Ok(StatementOutcome::Notice { kind, text, affected }) => {
|
||||||
|
notices.push(notice_for(idx, kind, text, affected))
|
||||||
|
}
|
||||||
|
Err(e) if e == "Query cancelled" => return Some(e),
|
||||||
|
Err(e) => {
|
||||||
|
notices.push(StatementNotice {
|
||||||
|
statement_index: idx,
|
||||||
|
kind: "error".into(),
|
||||||
|
text: e.clone(),
|
||||||
|
affected: None,
|
||||||
|
});
|
||||||
|
return Some(format!("Statement {} failed: {e}", idx + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PostgreSQL walker ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(crate) async fn execute_pg_statement(
|
||||||
|
client: &tokio_postgres::Client,
|
||||||
|
stmt: &str,
|
||||||
|
page: i64,
|
||||||
|
page_size: i64,
|
||||||
|
) -> Result<StatementOutcome, String> {
|
||||||
|
let trimmed = stmt.trim();
|
||||||
|
let off = (page.saturating_sub(1).max(0)) * page_size;
|
||||||
|
|
||||||
|
// 1. Try wrapped pagination — a parse error here executes nothing.
|
||||||
|
let wrapped_count = format!("SELECT COUNT(*) FROM ({}) AS _gridline_cnt", trimmed);
|
||||||
|
if let Ok(row) = client.query_one(&wrapped_count, &[]).await {
|
||||||
|
let total_rows: i64 = row.get(0);
|
||||||
|
let wrapped_data = format!(
|
||||||
|
"SELECT * FROM ({}) AS _gridline_data LIMIT $1 OFFSET $2",
|
||||||
|
trimmed
|
||||||
|
);
|
||||||
|
let data_rows = client
|
||||||
|
.query(&wrapped_data, &[&page_size, &off])
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if is_pg_cancel_error(&e) {
|
||||||
|
"Query cancelled".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{e}")
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let columns: Vec<ColumnInfo> = match data_rows.first() {
|
||||||
|
Some(first) => first
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.map(|c| ColumnInfo {
|
||||||
|
name: c.name().to_string(),
|
||||||
|
data_type: format!("{:?}", c.type_()),
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: false,
|
||||||
|
fk_ref: None,
|
||||||
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
let rows: Vec<Vec<serde_json::Value>> = data_rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
(0..row.len())
|
||||||
|
.map(|i| crate::commands::db_viewer::pg_value_to_json(row, i))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
return Ok(StatementOutcome::Set(QueryResult {
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
total_rows,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
execution_time_ms: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Raw single-statement execution via simple_query (captures the
|
||||||
|
// CommandComplete tag so DML notices carry affected counts).
|
||||||
|
let messages = client
|
||||||
|
.simple_query(trimmed)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
if is_pg_cancel_error(&e) {
|
||||||
|
"Query cancelled".to_string()
|
||||||
|
} else {
|
||||||
|
crate::commands::db_viewer::pg_error_message(&e)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut columns: Vec<ColumnInfo> = Vec::new();
|
||||||
|
let mut all_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||||
|
let mut last_tag: Option<String> = None;
|
||||||
|
for msg in messages {
|
||||||
|
match msg {
|
||||||
|
tokio_postgres::SimpleQueryMessage::Row(row) => {
|
||||||
|
if columns.is_empty() {
|
||||||
|
columns = row
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.map(|c| ColumnInfo {
|
||||||
|
name: c.name().to_string(),
|
||||||
|
data_type: "text".to_string(),
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: false,
|
||||||
|
fk_ref: None,
|
||||||
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
let values: Vec<serde_json::Value> = (0..row.len())
|
||||||
|
.map(|i| match row.try_get::<usize>(i) {
|
||||||
|
Ok(Some(s)) => serde_json::Value::String(s.to_string()),
|
||||||
|
_ => serde_json::Value::Null,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
all_rows.push(values);
|
||||||
|
}
|
||||||
|
tokio_postgres::SimpleQueryMessage::CommandComplete(tag) => {
|
||||||
|
last_tag = Some(tag.to_string());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_rows.is_empty() {
|
||||||
|
let tag = last_tag.clone().unwrap_or_else(|| "OK".to_string());
|
||||||
|
let affected = last_tag.as_deref().and_then(affected_from_tag);
|
||||||
|
return Ok(StatementOutcome::Notice {
|
||||||
|
kind: if statement_kind(stmt) == "ddl" { "ddl" } else { "dml" },
|
||||||
|
text: tag,
|
||||||
|
affected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_rows = all_rows.len() as i64;
|
||||||
|
let rows: Vec<Vec<serde_json::Value>> = all_rows
|
||||||
|
.into_iter()
|
||||||
|
.take(page_size.max(0) as usize)
|
||||||
|
.collect();
|
||||||
|
Ok(StatementOutcome::Set(QueryResult {
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
total_rows,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
execution_time_ms: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn run_pg_statements(
|
||||||
|
client: &tokio_postgres::Client,
|
||||||
|
statements: &[String],
|
||||||
|
page: i64,
|
||||||
|
page_size: i64,
|
||||||
|
result_sets: &mut Vec<QueryResult>,
|
||||||
|
notices: &mut Vec<StatementNotice>,
|
||||||
|
) -> Option<String> {
|
||||||
|
for (idx, stmt) in statements.iter().enumerate() {
|
||||||
|
match execute_pg_statement(client, stmt, page, page_size).await {
|
||||||
|
Ok(StatementOutcome::Set(qr)) => result_sets.push(qr),
|
||||||
|
Ok(StatementOutcome::Notice { kind, text, affected }) => {
|
||||||
|
notices.push(notice_for(idx, kind, text, affected))
|
||||||
|
}
|
||||||
|
Err(e) if e == "Query cancelled" => return Some(e),
|
||||||
|
Err(e) => {
|
||||||
|
notices.push(StatementNotice {
|
||||||
|
statement_index: idx,
|
||||||
|
kind: "error".into(),
|
||||||
|
text: e.clone(),
|
||||||
|
affected: None,
|
||||||
|
});
|
||||||
|
return Some(format!("Statement {} failed: {e}", idx + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MySQL walker ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn execute_mysql_statement_on(
|
||||||
|
conn: &mut sqlx::mysql::MySqlConnection,
|
||||||
|
stmt: &str,
|
||||||
|
page: i64,
|
||||||
|
page_size: i64,
|
||||||
|
) -> Result<StatementOutcome, String> {
|
||||||
|
let trimmed = stmt.trim();
|
||||||
|
let off = (page.saturating_sub(1).max(0)) * page_size;
|
||||||
|
|
||||||
|
let total_rows: i64 = match sqlx::query_scalar::<_, i64>(&mysql_wrap_count(trimmed))
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||||
|
Err(_) => {
|
||||||
|
return execute_mysql_raw(conn, trimmed, page, page_size, off)
|
||||||
|
.await
|
||||||
|
.map(|qr| {
|
||||||
|
if qr.columns.is_empty() {
|
||||||
|
StatementOutcome::Notice {
|
||||||
|
kind: if statement_kind(stmt) == "ddl" { "ddl" } else { "dml" },
|
||||||
|
text: "OK".to_string(),
|
||||||
|
affected: None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StatementOutcome::Set(qr)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let data_rows = match sqlx::query(&mysql_wrap_data(trimmed))
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(off)
|
||||||
|
.fetch_all(&mut *conn)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(e) if is_mysql_cancel_error(&e) => return Err("Query cancelled".to_string()),
|
||||||
|
Err(e) => return Err(crate::commands::db_viewer::sanitize_error(&format!("{e}"))),
|
||||||
|
};
|
||||||
|
let columns: Vec<ColumnInfo> = match data_rows.first() {
|
||||||
|
Some(first) => first
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.map(|c| ColumnInfo {
|
||||||
|
name: c.name().to_string(),
|
||||||
|
data_type: c.type_info().to_string(),
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: false,
|
||||||
|
fk_ref: None,
|
||||||
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
let rows: Vec<Vec<serde_json::Value>> = data_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| (0..r.len()).map(|i| mysql_cell_to_json(r, i)).collect())
|
||||||
|
.collect();
|
||||||
|
Ok(StatementOutcome::Set(QueryResult {
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
total_rows,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
execution_time_ms: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_mysql_statements(
|
||||||
|
conn: &mut sqlx::mysql::MySqlConnection,
|
||||||
|
statements: &[String],
|
||||||
|
page: i64,
|
||||||
|
page_size: i64,
|
||||||
|
result_sets: &mut Vec<QueryResult>,
|
||||||
|
notices: &mut Vec<StatementNotice>,
|
||||||
|
cancel_registry: &crate::cancel::CancelRegistry,
|
||||||
|
connection_id: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap_or(-1);
|
||||||
|
cancel_registry.set_mysql_conn_id(connection_id, Some(conn_id));
|
||||||
|
for (idx, stmt) in statements.iter().enumerate() {
|
||||||
|
match execute_mysql_statement_on(conn, stmt, page, page_size).await {
|
||||||
|
Ok(StatementOutcome::Set(qr)) => result_sets.push(qr),
|
||||||
|
Ok(StatementOutcome::Notice { kind, text, affected }) => {
|
||||||
|
notices.push(notice_for(idx, kind, text, affected))
|
||||||
|
}
|
||||||
|
Err(e) if e == "Query cancelled" => {
|
||||||
|
cancel_registry.set_mysql_conn_id(connection_id, None);
|
||||||
|
return Some(e);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
notices.push(StatementNotice {
|
||||||
|
statement_index: idx,
|
||||||
|
kind: "error".into(),
|
||||||
|
text: e.clone(),
|
||||||
|
affected: None,
|
||||||
|
});
|
||||||
|
cancel_registry.set_mysql_conn_id(connection_id, None);
|
||||||
|
return Some(format!("Statement {} failed: {e}", idx + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cancel_registry.set_mysql_conn_id(connection_id, None);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a multi-statement script, returning every result set and notices.
|
||||||
|
/// `execute_query` is untouched; the query workspace calls this instead.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn execute_query_multi(
|
||||||
|
connection_id: String,
|
||||||
|
query: String,
|
||||||
|
page: Option<i64>,
|
||||||
|
page_size: Option<i64>,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<MultiQueryResult, String> {
|
||||||
|
let start = Instant::now();
|
||||||
|
let p = page.unwrap_or(1);
|
||||||
|
let ps = page_size.unwrap_or(50);
|
||||||
|
|
||||||
|
let statements = crate::db::sql_split::split_statements(&query);
|
||||||
|
if statements.is_empty() {
|
||||||
|
return Err("Query cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
if statements.len() > MAX_STATEMENTS {
|
||||||
|
return Err(format!(
|
||||||
|
"Too many statements: {} (max {MAX_STATEMENTS})",
|
||||||
|
statements.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result_sets: Vec<QueryResult> = Vec::new();
|
||||||
|
let mut notices: Vec<StatementNotice> = Vec::new();
|
||||||
|
|
||||||
|
let err_text: Option<String>;
|
||||||
|
let mut pm = state.pool_manager.lock().await;
|
||||||
|
match pm.get(&connection_id) {
|
||||||
|
Some(DbHandle::Postgresql(client, _)) => {
|
||||||
|
err_text =
|
||||||
|
run_pg_statements(client, &statements, p, ps, &mut result_sets, &mut notices).await;
|
||||||
|
}
|
||||||
|
Some(DbHandle::Sqlite(conn)) => {
|
||||||
|
err_text = run_sqlite_statements(conn, &statements, p, ps, &mut result_sets, &mut notices);
|
||||||
|
}
|
||||||
|
Some(DbHandle::MySql(pool)) => {
|
||||||
|
let mut conn = pool
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||||
|
err_text = run_mysql_statements(
|
||||||
|
&mut *conn,
|
||||||
|
&statements,
|
||||||
|
p,
|
||||||
|
ps,
|
||||||
|
&mut result_sets,
|
||||||
|
&mut notices,
|
||||||
|
&state.cancel_registry,
|
||||||
|
&connection_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
None => return Err("Connection not found".to_string()),
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = start.elapsed().as_millis() as i64;
|
||||||
|
let row_count = result_sets.first().map(|q| q.rows.len() as i64).unwrap_or(0);
|
||||||
|
let history_id = Uuid::new_v4().to_string();
|
||||||
|
if let Some(e) = &err_text {
|
||||||
|
insert_history(
|
||||||
|
&state.db_store,
|
||||||
|
&history_id,
|
||||||
|
&connection_id,
|
||||||
|
&query,
|
||||||
|
Some(elapsed),
|
||||||
|
Some(row_count),
|
||||||
|
"error",
|
||||||
|
Some(e.as_str()),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
insert_history(
|
||||||
|
&state.db_store,
|
||||||
|
&history_id,
|
||||||
|
&connection_id,
|
||||||
|
&query,
|
||||||
|
Some(elapsed),
|
||||||
|
Some(row_count),
|
||||||
|
"success",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(MultiQueryResult {
|
||||||
|
result_sets,
|
||||||
|
notices,
|
||||||
|
execution_time_ms: elapsed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Query history commands
|
// Query history commands
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1378,3 +1879,7 @@ mod tests {
|
|||||||
assert!(res.unwrap_err().contains("Query cancelled"));
|
assert!(res.unwrap_err().contains("Query cancelled"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "query.test.rs"]
|
||||||
|
mod query_tests;
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── statement_kind / affected_from_tag (pure) ──────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn statement_kind_classifies_by_first_keyword() {
|
||||||
|
assert_eq!(statement_kind("SELECT 1"), "select");
|
||||||
|
assert_eq!(statement_kind(" with c as (select 1) select * from c"), "select");
|
||||||
|
assert_eq!(statement_kind("insert into t values (1)"), "dml");
|
||||||
|
assert_eq!(statement_kind("UPDATE t SET a=1"), "dml");
|
||||||
|
assert_eq!(statement_kind("DELETE FROM t"), "dml");
|
||||||
|
assert_eq!(statement_kind("CREATE TABLE t (a int)"), "ddl");
|
||||||
|
assert_eq!(statement_kind("-- c\nDROP TABLE t"), "ddl");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn affected_from_tag_parses_pg_command_tags() {
|
||||||
|
assert_eq!(affected_from_tag("INSERT 0 5"), Some(5));
|
||||||
|
assert_eq!(affected_from_tag("UPDATE 3"), Some(3));
|
||||||
|
assert_eq!(affected_from_tag("CREATE TABLE"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SQLite walker — real in-memory database, no env vars, not ignored ──
|
||||||
|
|
||||||
|
fn mem_conn() -> rusqlite::Connection {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE t (a INTEGER, b TEXT);
|
||||||
|
INSERT INTO t VALUES (1, 'x'), (2, 'y'), (3, 'z');",
|
||||||
|
)
|
||||||
|
.expect("seed");
|
||||||
|
conn
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_walker_yields_notice_for_dml_with_affected() {
|
||||||
|
let conn = mem_conn();
|
||||||
|
let out = execute_sqlite_statement(&conn, "INSERT INTO t VALUES (4, 'w')", 1, 50)
|
||||||
|
.expect("statement runs");
|
||||||
|
match out {
|
||||||
|
StatementOutcome::Notice { kind, affected, .. } => {
|
||||||
|
assert_eq!(kind, "dml");
|
||||||
|
assert_eq!(affected, Some(1));
|
||||||
|
}
|
||||||
|
_ => panic!("expected Notice, got Set"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_walker_yields_set_for_select() {
|
||||||
|
let conn = mem_conn();
|
||||||
|
let out = execute_sqlite_statement(&conn, "SELECT a FROM t ORDER BY a", 1, 2)
|
||||||
|
.expect("statement runs");
|
||||||
|
match out {
|
||||||
|
StatementOutcome::Set(qr) => {
|
||||||
|
assert_eq!(qr.columns.len(), 1);
|
||||||
|
assert_eq!(qr.rows.len(), 2, "page size caps rows");
|
||||||
|
assert_eq!(qr.total_rows, 3, "total counts all rows");
|
||||||
|
}
|
||||||
|
_ => panic!("expected Set, got Notice"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_walker_notices_ddl() {
|
||||||
|
let conn = mem_conn();
|
||||||
|
let out = execute_sqlite_statement(&conn, "CREATE TABLE u (x INT)", 1, 50).unwrap();
|
||||||
|
match out {
|
||||||
|
StatementOutcome::Notice { kind, .. } => assert_eq!(kind, "ddl"),
|
||||||
|
_ => panic!("expected Notice"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_sqlite_statements_stops_at_first_error_with_prior_sets() {
|
||||||
|
let conn = mem_conn();
|
||||||
|
let statements = crate::db::sql_split::split_statements(
|
||||||
|
"SELECT a FROM t; SELECT * FROM missing; SELECT 1",
|
||||||
|
);
|
||||||
|
let mut sets = Vec::new();
|
||||||
|
let mut notices = Vec::new();
|
||||||
|
let err = run_sqlite_statements(&conn, &statements, 1, 50, &mut sets, &mut notices);
|
||||||
|
assert_eq!(sets.len(), 1, "first set is returned");
|
||||||
|
assert!(err.is_some(), "the failed statement stops the run");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live PG walker test — mirror the env/client conventions of the live PG
|
||||||
|
/// tests in `commands/objects.test.rs`. Assert: `SELECT 1; SELECT 2` (split
|
||||||
|
/// via sql_split) produces two Sets via `run_pg_statements`, and
|
||||||
|
/// `UPDATE`-on-missing-table produces a first-error stop with the error
|
||||||
|
/// notice attached.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn pg_multi_statement_live() {
|
||||||
|
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
|
||||||
|
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
|
||||||
|
.unwrap_or_else(|_| "5432".into())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||||
|
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||||
|
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||||
|
let (client, conn) = tokio_postgres::connect(
|
||||||
|
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("connect to test PG");
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let _ = conn.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two SELECTs → two Sets, no notices.
|
||||||
|
let stmts = crate::db::sql_split::split_statements("SELECT 1; SELECT 2");
|
||||||
|
let mut sets = vec![];
|
||||||
|
let mut notices = vec![];
|
||||||
|
let err = run_pg_statements(&client, &stmts, 1, 50, &mut sets, &mut notices).await;
|
||||||
|
assert!(err.is_none());
|
||||||
|
assert_eq!(sets.len(), 2);
|
||||||
|
assert!(notices.is_empty());
|
||||||
|
|
||||||
|
// UPDATE on a missing table → first-error stop with an error notice.
|
||||||
|
let bad = crate::db::sql_split::split_statements("UPDATE gridline_missing SET a=1");
|
||||||
|
let mut sets2 = vec![];
|
||||||
|
let mut notices2 = vec![];
|
||||||
|
let err2 = run_pg_statements(&client, &bad, 1, 50, &mut sets2, &mut notices2).await;
|
||||||
|
assert!(err2.is_some(), "missing table must stop the run");
|
||||||
|
assert!(
|
||||||
|
notices2.iter().any(|n| n.kind == "error"),
|
||||||
|
"an error notice must be attached"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = handle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
//! Streaming export of a full query result to a local file (spec §4/§5C).
|
||||||
|
//!
|
||||||
|
//! Headless `run_*_export` functions (testable without Tauri State) + one
|
||||||
|
//! thin command that owns the pool lock, the cancel registration, and the
|
||||||
|
//! `export-progress` events. Fail-closed: any error deletes the partial file.
|
||||||
|
|
||||||
|
use crate::db::pool::DbHandle;
|
||||||
|
use crate::models::db_viewer::ColumnInfo;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{Column, Row};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
const PROGRESS_ROWS: u64 = 5_000;
|
||||||
|
const CURSOR_NAME: &str = "_gridline_export";
|
||||||
|
const CURSOR_FETCH: usize = 1_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ExportFormat {
|
||||||
|
Csv,
|
||||||
|
Jsonl,
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExportFormat {
|
||||||
|
pub fn from_str(s: &str) -> Result<Self, String> {
|
||||||
|
match s {
|
||||||
|
"csv" => Ok(ExportFormat::Csv),
|
||||||
|
"jsonl" => Ok(ExportFormat::Jsonl),
|
||||||
|
"json" => Ok(ExportFormat::Json),
|
||||||
|
other => Err(format!("unsupported export format: {other} (csv | jsonl | json)")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ExportSummary {
|
||||||
|
pub rows_written: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub struct ExportProgressEvent {
|
||||||
|
pub job_id: String,
|
||||||
|
/// "running" | "done" | "error"
|
||||||
|
pub status: String,
|
||||||
|
pub rows: u64,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only heuristic mirroring the frontend `isDestructiveQuery` set (spec §2:
|
||||||
|
/// never trust the frontend guard alone).
|
||||||
|
pub fn is_read_only_sql(sql: &str) -> bool {
|
||||||
|
let k: String = sql
|
||||||
|
.trim_start()
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_ascii_alphabetic())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_uppercase();
|
||||||
|
!matches!(
|
||||||
|
k.as_str(),
|
||||||
|
"INSERT" | "UPDATE" | "DELETE" | "DROP" | "ALTER" | "TRUNCATE" | "CREATE" | "REPLACE"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate export inputs: single, read-only statement + absolute path.
|
||||||
|
fn validate_export_inputs(sql: &str, path: &Path) -> Result<(), String> {
|
||||||
|
if !path.is_absolute() {
|
||||||
|
return Err("Export path must be absolute".to_string());
|
||||||
|
}
|
||||||
|
if !is_read_only_sql(sql) {
|
||||||
|
return Err("Export accepts read-only queries only (INSERT/UPDATE/DELETE/DROP/ALTER/TRUNCATE/CREATE/REPLACE are rejected)".to_string());
|
||||||
|
}
|
||||||
|
let statements = crate::db::sql_split::split_statements(sql);
|
||||||
|
if statements.len() != 1 {
|
||||||
|
return Err("Export accepts a single query".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Format writers (pure) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Quote one CSV cell with formula-injection protection (spec §2) — mirrors
|
||||||
|
/// the client-side `csvCell` brought to parity in Task 11.
|
||||||
|
pub fn csv_cell(value: &serde_json::Value) -> String {
|
||||||
|
let raw = match value {
|
||||||
|
serde_json::Value::Null => String::new(),
|
||||||
|
serde_json::Value::String(s) => s.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
let guarded = match raw.chars().next() {
|
||||||
|
Some('=') | Some('+') | Some('-') | Some('@') | Some('\t') | Some('\r') => {
|
||||||
|
format!("'{raw}")
|
||||||
|
}
|
||||||
|
_ => raw.clone(),
|
||||||
|
};
|
||||||
|
if guarded.contains(',') || guarded.contains('"') || guarded.contains('\n') || guarded.contains('\r') {
|
||||||
|
format!("\"{}\"", guarded.replace('"', "\"\""))
|
||||||
|
} else {
|
||||||
|
guarded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn csv_line(columns: &[ColumnInfo], row: &[serde_json::Value]) -> String {
|
||||||
|
let mut line = String::new();
|
||||||
|
for (i, _) in columns.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
line.push(',');
|
||||||
|
}
|
||||||
|
line.push_str(&csv_cell(row.get(i).unwrap_or(&serde_json::Value::Null)));
|
||||||
|
}
|
||||||
|
line
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn csv_header(columns: &[ColumnInfo]) -> String {
|
||||||
|
columns
|
||||||
|
.iter()
|
||||||
|
.map(|c| csv_cell(&serde_json::Value::String(c.name.clone())))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn jsonl_line(columns: &[ColumnInfo], row: &[serde_json::Value]) -> String {
|
||||||
|
let mut obj = serde_json::Map::new();
|
||||||
|
for (i, c) in columns.iter().enumerate() {
|
||||||
|
obj.insert(c.name.clone(), row.get(i).cloned().unwrap_or(serde_json::Value::Null));
|
||||||
|
}
|
||||||
|
serde_json::to_string(&serde_json::Value::Object(obj)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_row<W: Write>(
|
||||||
|
w: &mut W,
|
||||||
|
format: ExportFormat,
|
||||||
|
columns: &[ColumnInfo],
|
||||||
|
row: &[serde_json::Value],
|
||||||
|
first: bool,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
match format {
|
||||||
|
ExportFormat::Csv => writeln!(w, "{}", csv_line(columns, row)),
|
||||||
|
ExportFormat::Jsonl => writeln!(w, "{}", jsonl_line(columns, row)),
|
||||||
|
ExportFormat::Json => {
|
||||||
|
if !first {
|
||||||
|
writeln!(w, ",")?;
|
||||||
|
}
|
||||||
|
write!(w, "{}", jsonl_line(columns, row))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the export, deleting the partial file on any failure (fail-closed).
|
||||||
|
fn with_partial_cleanup<W, F>(path: &PathBuf, w: W, f: F) -> Result<ExportSummary, String>
|
||||||
|
where
|
||||||
|
W: Write,
|
||||||
|
F: FnOnce(&mut std::io::BufWriter<W>) -> Result<ExportSummary, String>,
|
||||||
|
{
|
||||||
|
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, w);
|
||||||
|
match f(&mut writer) {
|
||||||
|
Ok(summary) => {
|
||||||
|
writer.flush().map_err(|e| e.to_string())?;
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SQLite (native streaming) ─────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn run_sqlite_export(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
sql: &str,
|
||||||
|
format: ExportFormat,
|
||||||
|
path: &PathBuf,
|
||||||
|
progress: &mut (dyn FnMut(u64) + Send),
|
||||||
|
) -> Result<ExportSummary, String> {
|
||||||
|
validate_export_inputs(sql, path)?;
|
||||||
|
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
|
||||||
|
|
||||||
|
with_partial_cleanup(path, file, |writer| {
|
||||||
|
let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
|
||||||
|
let columns: Vec<ColumnInfo> = (0..stmt.column_count())
|
||||||
|
.map(|i| ColumnInfo {
|
||||||
|
name: stmt.column_name(i).unwrap_or("?").to_string(),
|
||||||
|
data_type: "TEXT".to_string(),
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: false,
|
||||||
|
fk_ref: None,
|
||||||
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if format == ExportFormat::Csv {
|
||||||
|
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
|
||||||
|
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
write!(writer, "[").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let col_count = columns.len();
|
||||||
|
let mut rows_written: u64 = 0;
|
||||||
|
let mut first = true;
|
||||||
|
let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
|
||||||
|
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
||||||
|
let values: Vec<serde_json::Value> = (0..col_count)
|
||||||
|
.map(|i| sqlite_ref_to_json(row, i))
|
||||||
|
.collect();
|
||||||
|
write_row(writer, format, &columns, &values, first).map_err(|e| e.to_string())?;
|
||||||
|
first = false;
|
||||||
|
rows_written += 1;
|
||||||
|
if rows_written % PROGRESS_ROWS == 0 {
|
||||||
|
progress(rows_written);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
writeln!(writer, "]").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
progress(rows_written);
|
||||||
|
Ok(ExportSummary { rows_written })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sqlite_ref_to_json(row: &rusqlite::Row, i: usize) -> serde_json::Value {
|
||||||
|
use rusqlite::types::ValueRef;
|
||||||
|
match row.get_ref(i) {
|
||||||
|
Ok(ValueRef::Null) => serde_json::Value::Null,
|
||||||
|
Ok(ValueRef::Integer(v)) => serde_json::json!(v),
|
||||||
|
Ok(ValueRef::Real(v)) => serde_json::json!(v),
|
||||||
|
Ok(ValueRef::Text(v)) => serde_json::Value::String(String::from_utf8_lossy(v).to_string()),
|
||||||
|
Ok(ValueRef::Blob(v)) => serde_json::Value::String(format!("[{}B blob]", v.len())),
|
||||||
|
Err(_) => serde_json::Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PostgreSQL (cursor FETCH loop — constant memory, stable order) ──
|
||||||
|
|
||||||
|
pub async fn run_pg_export(
|
||||||
|
client: &tokio_postgres::Client,
|
||||||
|
sql: &str,
|
||||||
|
format: ExportFormat,
|
||||||
|
path: &PathBuf,
|
||||||
|
progress: &mut (dyn FnMut(u64) + Send),
|
||||||
|
) -> Result<ExportSummary, String> {
|
||||||
|
validate_export_inputs(sql, path)?;
|
||||||
|
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
|
||||||
|
let trimmed = sql.trim();
|
||||||
|
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
|
||||||
|
|
||||||
|
// LINEAR FORM: run the cursor loop inline; on any error drop the writer
|
||||||
|
// and remove the partial file.
|
||||||
|
let run = async {
|
||||||
|
client.simple_query("BEGIN").await.map_err(|e| format!("export begin failed: {e}"))?;
|
||||||
|
if let Err(e) = client
|
||||||
|
.simple_query(&format!("DECLARE {CURSOR_NAME} CURSOR FOR ({trimmed})"))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = client.simple_query("ROLLBACK").await;
|
||||||
|
return Err(format!("export declare failed: {e}"));
|
||||||
|
}
|
||||||
|
let mut columns: Vec<ColumnInfo> = Vec::new();
|
||||||
|
let mut rows_written: u64 = 0;
|
||||||
|
let mut first = true;
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
write!(writer, "[").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let msgs = client
|
||||||
|
.simple_query(&format!("FETCH FORWARD {CURSOR_FETCH} FROM {CURSOR_NAME}"))
|
||||||
|
.await;
|
||||||
|
let msgs = match msgs {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = client.simple_query("ROLLBACK").await;
|
||||||
|
return Err(format!("export fetch failed: {e}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut batch_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
||||||
|
for msg in msgs {
|
||||||
|
if let tokio_postgres::SimpleQueryMessage::Row(row) = msg {
|
||||||
|
if columns.is_empty() {
|
||||||
|
columns = row
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.map(|c| ColumnInfo {
|
||||||
|
name: c.name().to_string(),
|
||||||
|
data_type: "text".to_string(),
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: false,
|
||||||
|
fk_ref: None,
|
||||||
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
// Simple-query protocol returns every value as text; decode
|
||||||
|
// as &str (usize has no FromSql impl) and NULL as None.
|
||||||
|
let values: Vec<serde_json::Value> = (0..row.len())
|
||||||
|
.map(|i| match row.try_get(i) {
|
||||||
|
Ok(Some(s)) => serde_json::Value::String(s.to_string()),
|
||||||
|
_ => serde_json::Value::Null,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
batch_rows.push(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if batch_rows.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if format == ExportFormat::Csv && rows_written == 0 {
|
||||||
|
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
|
||||||
|
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
for row in batch_rows {
|
||||||
|
write_row(&mut writer, format, &columns, &row, first).map_err(|e| e.to_string())?;
|
||||||
|
first = false;
|
||||||
|
rows_written += 1;
|
||||||
|
if rows_written % PROGRESS_ROWS == 0 {
|
||||||
|
progress(rows_written);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
writeln!(writer, "]").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let _ = client.simple_query(&format!("CLOSE {CURSOR_NAME}")).await;
|
||||||
|
let _ = client.simple_query("ROLLBACK").await;
|
||||||
|
progress(rows_written);
|
||||||
|
Ok(ExportSummary { rows_written })
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match run {
|
||||||
|
Ok(summary) => {
|
||||||
|
writer.flush().map_err(|e| e.to_string())?;
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
drop(writer);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MySQL (sqlx stream — native streaming) ─────────────────────────
|
||||||
|
|
||||||
|
pub async fn run_mysql_export(
|
||||||
|
conn: &mut sqlx::mysql::MySqlConnection,
|
||||||
|
sql: &str,
|
||||||
|
format: ExportFormat,
|
||||||
|
path: &PathBuf,
|
||||||
|
progress: &mut (dyn FnMut(u64) + Send),
|
||||||
|
) -> Result<ExportSummary, String> {
|
||||||
|
validate_export_inputs(sql, path)?;
|
||||||
|
let file = std::fs::File::create(path).map_err(|e| format!("cannot create file: {e}"))?;
|
||||||
|
use futures_util::TryStreamExt;
|
||||||
|
let mut writer = std::io::BufWriter::with_capacity(8 * 1024 * 1024, file);
|
||||||
|
|
||||||
|
let run = async {
|
||||||
|
let mut stream = sqlx::query(sql).fetch(&mut *conn);
|
||||||
|
let mut columns: Vec<ColumnInfo> = Vec::new();
|
||||||
|
let mut rows_written: u64 = 0;
|
||||||
|
let mut first = true;
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
write!(writer, "[").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
while let Some(row) = stream.try_next().await.map_err(|e| {
|
||||||
|
crate::commands::db_viewer::sanitize_error(&format!("{e}"))
|
||||||
|
})? {
|
||||||
|
if columns.is_empty() {
|
||||||
|
columns = row
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.map(|c| ColumnInfo {
|
||||||
|
name: c.name().to_string(),
|
||||||
|
data_type: c.type_info().to_string(),
|
||||||
|
is_nullable: true,
|
||||||
|
is_pk: false,
|
||||||
|
is_fk: false,
|
||||||
|
fk_ref: None,
|
||||||
|
default_value: None,
|
||||||
|
editable: true,
|
||||||
|
is_generated: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if format == ExportFormat::Csv {
|
||||||
|
write!(writer, "\u{FEFF}").map_err(|e| e.to_string())?; // UTF-8 BOM
|
||||||
|
writeln!(writer, "{}", csv_header(&columns)).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let values: Vec<serde_json::Value> =
|
||||||
|
(0..row.len()).map(|i| crate::commands::query::mysql_cell_to_json(&row, i)).collect();
|
||||||
|
write_row(&mut writer, format, &columns, &values, first).map_err(|e| e.to_string())?;
|
||||||
|
first = false;
|
||||||
|
rows_written += 1;
|
||||||
|
if rows_written % PROGRESS_ROWS == 0 {
|
||||||
|
progress(rows_written);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if format == ExportFormat::Json {
|
||||||
|
writeln!(writer, "]").map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
progress(rows_written);
|
||||||
|
Ok(ExportSummary { rows_written })
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match run {
|
||||||
|
Ok(summary) => {
|
||||||
|
writer.flush().map_err(|e| e.to_string())?;
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
drop(writer);
|
||||||
|
let _ = std::fs::remove_file(path);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dispatcher + thin command ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Dispatch across engines. The caller (command) owns the pool lock and the
|
||||||
|
/// cancel registration; MySQL callers must hold a dedicated connection.
|
||||||
|
pub(crate) async fn run_export_query(
|
||||||
|
handle: &mut DbHandle,
|
||||||
|
sql: &str,
|
||||||
|
format: ExportFormat,
|
||||||
|
path: &PathBuf,
|
||||||
|
progress: &mut (dyn FnMut(u64) + Send),
|
||||||
|
) -> Result<ExportSummary, String> {
|
||||||
|
match handle {
|
||||||
|
DbHandle::Sqlite(conn) => run_sqlite_export(conn, sql, format, path, progress),
|
||||||
|
DbHandle::Postgresql(client, _) => run_pg_export(client, sql, format, path, progress).await,
|
||||||
|
DbHandle::MySql(pool) => {
|
||||||
|
let mut conn = pool
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||||
|
run_mysql_export(&mut *conn, sql, format, path, progress).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_query_to_file(
|
||||||
|
connection_id: String,
|
||||||
|
sql: String,
|
||||||
|
format: String,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
let fmt = ExportFormat::from_str(&format)?;
|
||||||
|
let path_buf = PathBuf::from(&path);
|
||||||
|
let job_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
let mut last_emit = std::time::Instant::now();
|
||||||
|
let job = job_id.clone();
|
||||||
|
let mut progress_cb = |rows: u64| {
|
||||||
|
if last_emit.elapsed() >= std::time::Duration::from_millis(250) {
|
||||||
|
last_emit = std::time::Instant::now();
|
||||||
|
let _ = app.emit(
|
||||||
|
"export-progress",
|
||||||
|
ExportProgressEvent {
|
||||||
|
job_id: job.clone(),
|
||||||
|
status: "running".into(),
|
||||||
|
rows,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut pm = state.pool_manager.lock().await;
|
||||||
|
let handle = pm
|
||||||
|
.get(&connection_id)
|
||||||
|
.ok_or_else(|| "Connection not found".to_string())?;
|
||||||
|
|
||||||
|
// MySQL: the STREAMING connection must be the one registered for
|
||||||
|
// KILL QUERY cancellation — acquire it once here, register its id,
|
||||||
|
// stream through it, then clear the registration.
|
||||||
|
let result = match handle {
|
||||||
|
DbHandle::MySql(pool) => {
|
||||||
|
let mut conn = pool
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::commands::db_viewer::sanitize_error(&format!("{e}")))?;
|
||||||
|
let conn_id: i64 = sqlx::query_scalar("SELECT CAST(CONNECTION_ID() AS SIGNED)")
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap_or(-1);
|
||||||
|
state.cancel_registry.set_mysql_conn_id(&connection_id, Some(conn_id));
|
||||||
|
let r = run_mysql_export(&mut *conn, &sql, fmt, &path_buf, &mut progress_cb).await;
|
||||||
|
state.cancel_registry.set_mysql_conn_id(&connection_id, None);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
other => run_export_query(other, &sql, fmt, &path_buf, &mut progress_cb).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(summary) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"export-progress",
|
||||||
|
ExportProgressEvent {
|
||||||
|
job_id: job_id.clone(),
|
||||||
|
status: "done".into(),
|
||||||
|
rows: summary.rows_written,
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(serde_json::json!({ "rows_written": summary.rows_written, "path": path }))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = app.emit(
|
||||||
|
"export-progress",
|
||||||
|
ExportProgressEvent {
|
||||||
|
job_id: job_id.clone(),
|
||||||
|
status: "error".into(),
|
||||||
|
rows: 0,
|
||||||
|
error: Some(e.clone()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "query_export.test.rs"]
|
||||||
|
mod query_export_tests;
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
use super::*;
|
||||||
|
use sqlx::Connection;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_only_heuristic_matches_frontend_guard_set() {
|
||||||
|
for sql in ["SELECT 1", " with x as (select 1) select * from x", "-- note\nSELECT 1"] {
|
||||||
|
assert!(is_read_only_sql(sql), "should allow: {sql}");
|
||||||
|
}
|
||||||
|
for sql in [
|
||||||
|
"INSERT INTO t VALUES (1)",
|
||||||
|
"update t set a = 1",
|
||||||
|
"DELETE FROM t",
|
||||||
|
"DROP TABLE t",
|
||||||
|
"ALTER TABLE t ADD c int",
|
||||||
|
"TRUNCATE t",
|
||||||
|
"CREATE TABLE t (a int)",
|
||||||
|
"REPLACE INTO t VALUES (1)",
|
||||||
|
] {
|
||||||
|
assert!(!is_read_only_sql(sql), "should reject: {sql}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_cell_quotes_and_guards_formula_injection() {
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("plain".into())), "plain");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("a,b".into())), "\"a,b\"");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("a\"b".into())), "\"a\"\"b\"");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("a\nb".into())), "\"a\nb\"");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("=SUM(A1)".into())), "'=SUM(A1)");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("+x".into())), "'+x");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("-1".into())), "'-1");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::String("@cmd".into())), "'@cmd");
|
||||||
|
assert_eq!(csv_cell(&serde_json::Value::Null), "");
|
||||||
|
assert_eq!(csv_cell(&serde_json::json!(42)), "42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_line_bom_is_written_by_header_only() {
|
||||||
|
let cols = vec![
|
||||||
|
crate::models::db_viewer::ColumnInfo { name: "a".into(), data_type: "int".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false },
|
||||||
|
crate::models::db_viewer::ColumnInfo { name: "b".into(), data_type: "text".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false },
|
||||||
|
];
|
||||||
|
let line = csv_line(&cols, &[serde_json::json!("1"), serde_json::Value::Null]);
|
||||||
|
assert_eq!(line, "1,");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jsonl_line_encodes_row_object() {
|
||||||
|
let cols = vec![crate::models::db_viewer::ColumnInfo { name: "a".into(), data_type: "int".into(), is_nullable: true, is_pk: false, is_fk: false, fk_ref: None, default_value: None, editable: true, is_generated: false }];
|
||||||
|
let line = jsonl_line(&cols, &[serde_json::json!("x\"y")]);
|
||||||
|
assert_eq!(line, "{\"a\":\"x\\\"y\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_export_streams_all_rows_with_bom_and_guard() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE t (a INTEGER, b TEXT);
|
||||||
|
INSERT INTO t VALUES (1, 'plain');
|
||||||
|
INSERT INTO t VALUES (2, '=SUM(A1)');
|
||||||
|
INSERT INTO t VALUES (3, NULL);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!("gridline-export-test-{}.csv", std::process::id()));
|
||||||
|
let mut rows_seen = 0u64;
|
||||||
|
let summary = run_sqlite_export(&conn, "SELECT a, b FROM t ORDER BY a", ExportFormat::Csv, &path, &mut |n| rows_seen = n)
|
||||||
|
.expect("export runs");
|
||||||
|
assert_eq!(summary.rows_written, 3);
|
||||||
|
assert_eq!(rows_seen, 3);
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
|
||||||
|
let text = String::from_utf8_lossy(&bytes[3..]);
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
assert_eq!(lines[0], "a,b");
|
||||||
|
assert_eq!(lines[1], "1,plain");
|
||||||
|
assert_eq!(lines[2], "2,'=SUM(A1)");
|
||||||
|
assert_eq!(lines[3], "3,");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_export_rejects_write_statements_and_multi_statement() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
let path = std::env::temp_dir().join("gridline-export-should-not-exist.csv");
|
||||||
|
let err = run_sqlite_export(&conn, "INSERT INTO t VALUES (1)", ExportFormat::Csv, &path, &mut |_| {})
|
||||||
|
.expect_err("write SQL must be rejected");
|
||||||
|
assert!(err.contains("read-only"), "got: {err}");
|
||||||
|
let err = run_sqlite_export(&conn, "SELECT 1; SELECT 2", ExportFormat::Csv, &path, &mut |_| {})
|
||||||
|
.expect_err("multi-statement must be rejected");
|
||||||
|
assert!(err.contains("single"), "got: {err}");
|
||||||
|
assert!(!path.exists(), "no partial file may be left behind");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_export_error_deletes_partial_file() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch("CREATE TABLE t (a INTEGER); INSERT INTO t VALUES (1),(2),(3);").unwrap();
|
||||||
|
let path = std::env::temp_dir().join(format!("gridline-export-partial-{}.csv", std::process::id()));
|
||||||
|
let err = run_sqlite_export(&conn, "SELECT a, b FROM t", ExportFormat::Csv, &path, &mut |_| {})
|
||||||
|
.expect_err("bad column must fail");
|
||||||
|
assert!(!err.is_empty());
|
||||||
|
assert!(!path.exists(), "partial file must be deleted on error");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live PG export — mirror the env/client conventions of the live PG tests in
|
||||||
|
/// `commands/objects.test.rs`. Assert a cursor export of a 3-row table
|
||||||
|
/// produces the same 4 CSV lines as the SQLite test (BOM + header + rows).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn pg_export_live() {
|
||||||
|
let h = match std::env::var("GRIDLINE_TEST_PG_HOST") {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT")
|
||||||
|
.unwrap_or_else(|_| "5432".into())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||||
|
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||||
|
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||||
|
let (client, conn) = tokio_postgres::connect(
|
||||||
|
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("connect to test PG");
|
||||||
|
let _conn_task = tokio::spawn(async move {
|
||||||
|
let _ = conn.await;
|
||||||
|
});
|
||||||
|
client
|
||||||
|
.batch_execute(
|
||||||
|
"DROP TABLE IF EXISTS gridline_export_test;
|
||||||
|
CREATE TABLE gridline_export_test (a INTEGER, b TEXT);
|
||||||
|
INSERT INTO gridline_export_test VALUES (1,'plain'),(2,'=SUM(A1)'),(3,NULL);",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("setup");
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!("gridline-export-pg-{}.csv", std::process::id()));
|
||||||
|
let mut rows_seen = 0u64;
|
||||||
|
let summary = run_pg_export(
|
||||||
|
&client,
|
||||||
|
"SELECT a, b FROM gridline_export_test ORDER BY a",
|
||||||
|
ExportFormat::Csv,
|
||||||
|
&path,
|
||||||
|
&mut |n| rows_seen = n,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("export runs");
|
||||||
|
assert_eq!(summary.rows_written, 3);
|
||||||
|
assert_eq!(rows_seen, 3);
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
|
||||||
|
let text = String::from_utf8_lossy(&bytes[3..]);
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
assert_eq!(lines[0], "a,b");
|
||||||
|
assert_eq!(lines[1], "1,plain");
|
||||||
|
assert_eq!(lines[2], "2,'=SUM(A1)");
|
||||||
|
assert_eq!(lines[3], "3,");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = client.batch_execute("DROP TABLE IF EXISTS gridline_export_test").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live MySQL export — mirror the env conventions of the MySQL live tests in
|
||||||
|
/// `commands/db_viewer.rs` (GRIDLINE_TEST_MYSQL_*). Same assertions.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn mysql_export_live() {
|
||||||
|
let host = match std::env::var("GRIDLINE_TEST_MYSQL_HOST") {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let port: u16 = std::env::var("GRIDLINE_TEST_MYSQL_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(3306);
|
||||||
|
let user = std::env::var("GRIDLINE_TEST_MYSQL_USER").unwrap_or_else(|_| "root".into());
|
||||||
|
let pass = std::env::var("GRIDLINE_TEST_MYSQL_PASS").unwrap_or_default();
|
||||||
|
let db = match std::env::var("GRIDLINE_TEST_MYSQL_DB") {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let opts = sqlx::mysql::MySqlConnectOptions::new()
|
||||||
|
.host(&host)
|
||||||
|
.port(port)
|
||||||
|
.username(&user)
|
||||||
|
.password(&pass)
|
||||||
|
.database(&db);
|
||||||
|
let mut conn = sqlx::mysql::MySqlConnection::connect_with(&opts)
|
||||||
|
.await
|
||||||
|
.expect("connect to test MySQL");
|
||||||
|
sqlx::query("DROP TABLE IF EXISTS gridline_export_test")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
sqlx::query("CREATE TABLE gridline_export_test (a INT, b TEXT)")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
sqlx::query("INSERT INTO gridline_export_test VALUES (1,'plain'),(2,'=SUM(A1)'),(3,NULL)")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await
|
||||||
|
.expect("insert");
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!("gridline-export-mysql-{}.csv", std::process::id()));
|
||||||
|
let mut rows_seen = 0u64;
|
||||||
|
let summary = run_mysql_export(
|
||||||
|
&mut conn,
|
||||||
|
"SELECT a, b FROM gridline_export_test ORDER BY a",
|
||||||
|
ExportFormat::Csv,
|
||||||
|
&path,
|
||||||
|
&mut |n| rows_seen = n,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("export runs");
|
||||||
|
assert_eq!(summary.rows_written, 3);
|
||||||
|
assert_eq!(rows_seen, 3);
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&path).unwrap();
|
||||||
|
assert!(bytes.starts_with(&[0xEF, 0xBB, 0xBF]), "UTF-8 BOM first");
|
||||||
|
let text = String::from_utf8_lossy(&bytes[3..]);
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
assert_eq!(lines[0], "a,b");
|
||||||
|
assert_eq!(lines[1], "1,plain");
|
||||||
|
assert_eq!(lines[2], "2,'=SUM(A1)");
|
||||||
|
assert_eq!(lines[3], "3,");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = sqlx::query("DROP TABLE IF EXISTS gridline_export_test")
|
||||||
|
.execute(&mut conn)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//! Thin `compare_schemas` command (spec §4: two pool lookups → snapshots →
|
||||||
|
//! diff; errors name the failing side; staging never happens here).
|
||||||
|
|
||||||
|
use crate::models::db_viewer::DiffReport;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
/// Engine family for diff pairing: mysql and mariadb are one family.
|
||||||
|
pub fn engine_family(db_type: &str) -> String {
|
||||||
|
match db_type {
|
||||||
|
"mysql" | "mariadb" => "mysql".to_string(),
|
||||||
|
"sqlite" => "sqlite".to_string(),
|
||||||
|
"postgresql" => "postgresql".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_same_family(source_db_type: &str, target_db_type: &str) -> Result<(), String> {
|
||||||
|
let (s, t) = (engine_family(source_db_type), engine_family(target_db_type));
|
||||||
|
if s != t {
|
||||||
|
return Err(format!(
|
||||||
|
"Schema diff requires the same engine family — source is {s}, target is {t}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connection_db_type(
|
||||||
|
db_store: &std::sync::Mutex<crate::store::Store>,
|
||||||
|
connection_id: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let store = db_store.lock().map_err(|e| e.to_string())?;
|
||||||
|
let conns = store.get_connections().map_err(|e| e.to_string())?;
|
||||||
|
conns
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == connection_id)
|
||||||
|
.map(|c| c.db_type.clone())
|
||||||
|
.ok_or_else(|| format!("connection {connection_id} not found"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn compare_schemas(
|
||||||
|
source_connection_id: String,
|
||||||
|
source_schema: String,
|
||||||
|
target_connection_id: String,
|
||||||
|
target_schema: String,
|
||||||
|
state: State<'_, crate::AppState>,
|
||||||
|
) -> Result<DiffReport, String> {
|
||||||
|
let source_db_type = connection_db_type(&state.db_store, &source_connection_id)
|
||||||
|
.map_err(|e| format!("Source connection failed: {e}"))?;
|
||||||
|
let target_db_type = connection_db_type(&state.db_store, &target_connection_id)
|
||||||
|
.map_err(|e| format!("Target connection failed: {e}"))?;
|
||||||
|
validate_same_family(&source_db_type, &target_db_type)?;
|
||||||
|
|
||||||
|
let mut pm = state.pool_manager.lock().await;
|
||||||
|
|
||||||
|
let source = {
|
||||||
|
let handle = pm
|
||||||
|
.get(&source_connection_id)
|
||||||
|
.ok_or_else(|| "Source connection failed: not connected (open it in the DB viewer first)".to_string())?;
|
||||||
|
crate::db::schema_diff::capture_snapshot(handle, &source_db_type, &source_schema)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Source connection failed: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let target = {
|
||||||
|
let handle = pm
|
||||||
|
.get(&target_connection_id)
|
||||||
|
.ok_or_else(|| "Target connection failed: not connected".to_string())?;
|
||||||
|
crate::db::schema_diff::capture_snapshot(handle, &target_db_type, &target_schema)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Target connection failed: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut report = crate::db::schema_diff::diff_snapshots(&source, &target);
|
||||||
|
report.source_label = format!("{} · {}", report.source_label, source_connection_id);
|
||||||
|
report.target_label = format!("{} · {}", report.target_label, target_connection_id);
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "schema_diff.test.rs"]
|
||||||
|
mod schema_diff_tests;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_family_pairs_mysql_with_mariadb() {
|
||||||
|
assert_eq!(engine_family("mysql"), engine_family("mariadb"));
|
||||||
|
assert_eq!(engine_family("mysql"), "mysql");
|
||||||
|
assert_eq!(engine_family("postgresql"), "postgresql");
|
||||||
|
assert_eq!(engine_family("sqlite"), "sqlite");
|
||||||
|
assert_ne!(engine_family("postgresql"), engine_family("mysql"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn family_check_rejects_cross_engine_pairs() {
|
||||||
|
let err = validate_same_family("postgresql", "mysql").expect_err("must reject");
|
||||||
|
assert!(err.contains("same engine"), "got: {err}");
|
||||||
|
assert!(validate_same_family("mariadb", "mysql").is_ok());
|
||||||
|
assert!(validate_same_family("mysql", "mariadb").is_ok());
|
||||||
|
assert!(validate_same_family("postgresql", "postgresql").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live end-to-end: register two connections and diff their schemas through
|
||||||
|
/// the real pool path.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "needs the pool manager wiring from the command; run via the UI or the live harness"]
|
||||||
|
fn compare_command_live_pg() {}
|
||||||
@@ -107,7 +107,7 @@ fn url_scheme_end(msg: &str, i: usize) -> Option<usize> {
|
|||||||
pub fn validate_test_input(config: &DbConfig) -> Option<String> {
|
pub fn validate_test_input(config: &DbConfig) -> Option<String> {
|
||||||
let db_type = config.db_type.to_lowercase();
|
let db_type = config.db_type.to_lowercase();
|
||||||
|
|
||||||
let valid_types = ["postgresql", "mysql", "sqlite", "redis"];
|
let valid_types = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||||
if !valid_types.contains(&db_type.as_str()) {
|
if !valid_types.contains(&db_type.as_str()) {
|
||||||
return Some(format!(
|
return Some(format!(
|
||||||
"unsupported database type: {}. Supported types: {}",
|
"unsupported database type: {}. Supported types: {}",
|
||||||
@@ -220,6 +220,7 @@ pub async fn test_database_connection(config: &DbConfig, ssh: &SshManager) -> Te
|
|||||||
let result = match config.db_type.to_lowercase().as_str() {
|
let result = match config.db_type.to_lowercase().as_str() {
|
||||||
"postgresql" => test_pg_connection(config, ssh).await,
|
"postgresql" => test_pg_connection(config, ssh).await,
|
||||||
"mysql" => test_mysql_connection(config, ssh).await,
|
"mysql" => test_mysql_connection(config, ssh).await,
|
||||||
|
"mariadb" => test_mysql_connection(config, ssh).await,
|
||||||
"sqlite" => test_sqlite_connection(config),
|
"sqlite" => test_sqlite_connection(config),
|
||||||
"redis" => test_redis_connection(config, ssh).await,
|
"redis" => test_redis_connection(config, ssh).await,
|
||||||
other => TestConnectionResult {
|
other => TestConnectionResult {
|
||||||
@@ -724,3 +725,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "test_connection.test.rs"]
|
||||||
|
mod test_connection_tests;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::commands::ssh::{Ssh2Backend, SshTunnelManager};
|
||||||
|
use crate::db::pool::DbConfig;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn mariadb_config(port: Option<i64>) -> DbConfig {
|
||||||
|
DbConfig {
|
||||||
|
db_type: "mariadb".into(),
|
||||||
|
host: "127.0.0.1".into(),
|
||||||
|
port,
|
||||||
|
username: Some("root".into()),
|
||||||
|
password: None,
|
||||||
|
database: Some("test".into()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_test_input_accepts_mariadb_with_port() {
|
||||||
|
assert_eq!(validate_test_input(&mariadb_config(Some(3306))), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_test_input_rejects_mariadb_without_port() {
|
||||||
|
assert!(validate_test_input(&mariadb_config(None)).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live test against a real MariaDB server. Run with:
|
||||||
|
/// `GRIDLINE_TEST_MARIADB_HOST=… GRIDLINE_TEST_MARIADB_PORT=3306 \
|
||||||
|
/// GRIDLINE_TEST_MARIADB_USER=… GRIDLINE_TEST_MARIADB_PASSWORD=… \
|
||||||
|
/// GRIDLINE_TEST_MARIADB_DB=… cargo test -- --ignored mariadb_live`
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn mariadb_live_test_connection_reports_mariadb_version() {
|
||||||
|
let config = DbConfig {
|
||||||
|
host: std::env::var("GRIDLINE_TEST_MARIADB_HOST").expect("GRIDLINE_TEST_MARIADB_HOST"),
|
||||||
|
port: std::env::var("GRIDLINE_TEST_MARIADB_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parse::<i64>().ok()),
|
||||||
|
username: std::env::var("GRIDLINE_TEST_MARIADB_USER").ok(),
|
||||||
|
password: std::env::var("GRIDLINE_TEST_MARIADB_PASSWORD").ok(),
|
||||||
|
database: std::env::var("GRIDLINE_TEST_MARIADB_DB").ok(),
|
||||||
|
..mariadb_config(None)
|
||||||
|
};
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
let ssh = std::sync::Mutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend)));
|
||||||
|
let result = rt.block_on(async { test_mysql_connection(&config, &ssh).await });
|
||||||
|
assert!(result.ok, "connection failed: {:?}", result.error);
|
||||||
|
let version = result.server_version.unwrap_or_default().to_lowercase();
|
||||||
|
assert!(
|
||||||
|
version.contains("mariadb"),
|
||||||
|
"expected a MariaDB server, got version: {version:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -887,3 +887,123 @@ mod tests {
|
|||||||
assert!(sql.contains("$1") && sql.contains("$2"));
|
assert!(sql.contains("$1") && sql.contains("$2"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TimescaleDB (hypertables)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Detect whether the timescaledb extension is installed on this database.
|
||||||
|
pub fn pg_timescale_detect_query() -> String {
|
||||||
|
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query hypertables in a schema (TimescaleDB 2.x information views).
|
||||||
|
/// Columns: name, schema, num_dimensions, compression_enabled, num_chunks, total_size_bytes.
|
||||||
|
pub fn pg_hypertables_query(_schema: &str) -> String {
|
||||||
|
"SELECT h.hypertable_name, h.hypertable_schema, \
|
||||||
|
h.num_dimensions, \
|
||||||
|
COALESCE(h.compression_enabled, false), \
|
||||||
|
(SELECT COUNT(*) FROM timescaledb_information.chunks c \
|
||||||
|
WHERE c.hypertable_schema = h.hypertable_schema \
|
||||||
|
AND c.hypertable_name = h.hypertable_name), \
|
||||||
|
pg_total_relation_size(format('%I.%I', h.hypertable_schema, h.hypertable_name)::regclass)::bigint \
|
||||||
|
FROM timescaledb_information.hypertables h \
|
||||||
|
WHERE h.hypertable_schema = $1 \
|
||||||
|
ORDER BY h.hypertable_name"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schema-diff snapshot queries (0.8.0) — one set-based query per object class
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Table columns with resolved types, nullability, and defaults.
|
||||||
|
/// Columns: table_name, column_name, data_type, is_nullable, default_expr
|
||||||
|
pub fn pg_diff_columns_query() -> String {
|
||||||
|
"SELECT c.relname, a.attname, \
|
||||||
|
pg_catalog.format_type(a.atttypid, a.atttypmod), \
|
||||||
|
NOT a.attnotnull, \
|
||||||
|
pg_catalog.pg_get_expr(d.adbin, d.adrelid) \
|
||||||
|
FROM pg_catalog.pg_class c \
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
|
||||||
|
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped \
|
||||||
|
LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum \
|
||||||
|
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p') \
|
||||||
|
ORDER BY c.relname, a.attnum"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constraints (PK/UNIQUE/CHECK/FK) with exact column lists and FK metadata.
|
||||||
|
/// Columns: table_name, constraint_name, contype, cols, ref_cols, ref_table, confdeltype, confupdtype, definition
|
||||||
|
pub fn pg_diff_constraints_query() -> String {
|
||||||
|
"SELECT cl.relname, con.conname, con.contype::text, \
|
||||||
|
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||||
|
FROM unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum), \
|
||||||
|
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||||
|
FROM unnest(con.confkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = k.attnum), \
|
||||||
|
(SELECT n2.nspname || '.' || cl2.relname \
|
||||||
|
FROM pg_class cl2 JOIN pg_namespace n2 ON n2.oid = cl2.relnamespace \
|
||||||
|
WHERE cl2.oid = con.confrelid), \
|
||||||
|
con.confdeltype::text, con.confupdtype::text, \
|
||||||
|
pg_get_constraintdef(con.oid, true) \
|
||||||
|
FROM pg_catalog.pg_constraint con \
|
||||||
|
JOIN pg_catalog.pg_class cl ON cl.oid = con.conrelid \
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace \
|
||||||
|
WHERE n.nspname = $1 AND con.contype IN ('p', 'u', 'c', 'f') \
|
||||||
|
ORDER BY cl.relname, con.contype, con.conname"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary indexes (constraint-backed indexes excluded), with column lists.
|
||||||
|
/// Columns: table_name, index_name, is_unique, cols
|
||||||
|
pub fn pg_diff_indexes_query() -> String {
|
||||||
|
"SELECT t.relname, i.relname, ix.indisunique, \
|
||||||
|
(SELECT string_agg(a.attname, ', ' ORDER BY k.ord) \
|
||||||
|
FROM unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = ix.indrelid AND a.attnum = k.attnum \
|
||||||
|
WHERE k.attnum > 0) \
|
||||||
|
FROM pg_index ix \
|
||||||
|
JOIN pg_class i ON i.oid = ix.indexrelid \
|
||||||
|
JOIN pg_class t ON t.oid = ix.indrelid \
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
||||||
|
WHERE n.nspname = $1 AND NOT ix.indisprimary \
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = i.oid) \
|
||||||
|
ORDER BY t.relname, i.relname"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Views + materialized views. Columns: schema, name, definition, materialized
|
||||||
|
pub fn pg_diff_views_query() -> String {
|
||||||
|
"SELECT v.schemaname, v.viewname, v.definition, false \
|
||||||
|
FROM pg_views v WHERE v.schemaname = $1 \
|
||||||
|
UNION ALL \
|
||||||
|
SELECT v.schemaname, v.matviewname, v.definition, true \
|
||||||
|
FROM pg_matviews v WHERE v.schemaname = $1 \
|
||||||
|
ORDER BY 2"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequences. Columns: schema, name, start, increment, min, max, cycle
|
||||||
|
pub fn pg_diff_sequences_query() -> String {
|
||||||
|
"SELECT sequence_schema, sequence_name, \
|
||||||
|
COALESCE(start_value::text, '1'), COALESCE(increment::text, '1'), \
|
||||||
|
COALESCE(minimum_value::text, '1'), COALESCE(maximum_value::text, '9223372036854775807'), \
|
||||||
|
cycle_option = 'YES' \
|
||||||
|
FROM information_schema.sequences \
|
||||||
|
WHERE sequence_schema = $1 \
|
||||||
|
ORDER BY sequence_name"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enums with ordered labels. Columns: schema, name, label (row per label)
|
||||||
|
pub fn pg_diff_enums_query() -> String {
|
||||||
|
"SELECT n.nspname, t.typname, e.enumlabel \
|
||||||
|
FROM pg_type t \
|
||||||
|
JOIN pg_namespace n ON n.oid = t.typnamespace \
|
||||||
|
JOIN pg_enum e ON e.enumtypid = t.oid \
|
||||||
|
WHERE t.typtype = 'e' AND n.nspname = $1 \
|
||||||
|
ORDER BY t.typname, e.enumsortorder"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
pub mod introspection;
|
pub mod introspection;
|
||||||
pub mod mysql;
|
pub mod mysql;
|
||||||
|
pub mod sql_split;
|
||||||
pub mod object_crud;
|
pub mod object_crud;
|
||||||
pub mod object_ddl;
|
pub mod object_ddl;
|
||||||
pub mod pool;
|
pub mod pool;
|
||||||
pub mod tls;
|
pub mod tls;
|
||||||
|
pub mod schema_diff;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||||
|
|||||||
@@ -323,4 +323,64 @@ mod tests {
|
|||||||
assert_eq!(mysql_default_sort(&["name".into()]), "name");
|
assert_eq!(mysql_default_sort(&["name".into()]), "name");
|
||||||
assert_eq!(mysql_default_sort(&[]), "");
|
assert_eq!(mysql_default_sort(&[]), "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ── Schema-diff snapshot queries (0.8.0) ──────────────────────────────
|
||||||
|
|
||||||
|
/// Columns: table_name, column_name, column_type, is_nullable, column_default
|
||||||
|
pub fn mysql_snapshot_columns_query(schema: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT table_name, column_name, column_type, is_nullable, \
|
||||||
|
COALESCE(column_default, '') \
|
||||||
|
FROM information_schema.columns \
|
||||||
|
WHERE table_schema = '{}' \
|
||||||
|
ORDER BY table_name, ordinal_position",
|
||||||
|
schema.replace('\'', "''")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constraints (one row per constraint-column). Columns: table_name,
|
||||||
|
/// constraint_name, constraint_type, column_name, referenced_table_name,
|
||||||
|
/// delete_rule, update_rule, check_clause
|
||||||
|
pub fn mysql_snapshot_constraints_query(schema: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT kcu.table_name, kcu.constraint_name, tc.constraint_type, kcu.column_name, \
|
||||||
|
COALESCE(kcu.referenced_table_name, ''), \
|
||||||
|
COALESCE(rc.delete_rule, ''), COALESCE(rc.update_rule, ''), \
|
||||||
|
COALESCE((SELECT cc.check_clause FROM information_schema.check_constraints cc \
|
||||||
|
WHERE cc.constraint_schema = kcu.constraint_schema \
|
||||||
|
AND cc.constraint_name = kcu.constraint_name), '') \
|
||||||
|
FROM information_schema.key_column_usage kcu \
|
||||||
|
LEFT JOIN information_schema.table_constraints tc \
|
||||||
|
ON tc.constraint_schema = kcu.constraint_schema \
|
||||||
|
AND tc.constraint_name = kcu.constraint_name \
|
||||||
|
AND tc.table_name = kcu.table_name \
|
||||||
|
LEFT JOIN information_schema.referential_constraints rc \
|
||||||
|
ON rc.constraint_schema = kcu.constraint_schema \
|
||||||
|
AND rc.constraint_name = kcu.constraint_name \
|
||||||
|
WHERE kcu.constraint_schema = '{}' AND tc.constraint_type IS NOT NULL \
|
||||||
|
ORDER BY kcu.table_name, kcu.constraint_name, kcu.ordinal_position",
|
||||||
|
schema.replace('\'', "''")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary indexes (one row per index-column). Columns: table_name,
|
||||||
|
/// index_name, non_unique, column_name
|
||||||
|
pub fn mysql_snapshot_indexes_query(schema: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT table_name, index_name, non_unique, column_name \
|
||||||
|
FROM information_schema.statistics \
|
||||||
|
WHERE table_schema = '{}' AND index_name != 'PRIMARY' \
|
||||||
|
ORDER BY table_name, index_name, seq_in_index",
|
||||||
|
schema.replace('\'', "''")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Views. Columns: table_schema, table_name, view_definition
|
||||||
|
pub fn mysql_snapshot_views_query(schema: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"SELECT table_schema, table_name, view_definition \
|
||||||
|
FROM information_schema.views \
|
||||||
|
WHERE table_schema = '{}'",
|
||||||
|
schema.replace('\'', "''")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,992 @@
|
|||||||
|
//! Schema snapshot capture + the pure diff engine (engine lands in Task 14).
|
||||||
|
//!
|
||||||
|
//! Snapshots are read-only, single-use, and capped (5,000 objects — spec §2).
|
||||||
|
//! Sync SQL is generated in the TARGET's dialect with identifiers quoted
|
||||||
|
//! per engine (spec §2: never raw interpolation).
|
||||||
|
|
||||||
|
use crate::db::pool::DbHandle;
|
||||||
|
use crate::models::db_viewer::*;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
pub(crate) const SNAPSHOT_CAP: usize = 5_000;
|
||||||
|
|
||||||
|
/// Map a PG `conf*type` action code to its SQL name.
|
||||||
|
pub(crate) fn pg_fk_action(code: &str) -> &'static str {
|
||||||
|
match code {
|
||||||
|
"r" => "RESTRICT",
|
||||||
|
"c" => "CASCADE",
|
||||||
|
"n" => "SET NULL",
|
||||||
|
"d" => "SET DEFAULT",
|
||||||
|
_ => "NO ACTION",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforce the 5,000-object cap, setting `truncated` (never silent).
|
||||||
|
pub(crate) fn apply_snapshot_cap(snap: &mut SchemaSnapshot) {
|
||||||
|
let mut total = snap.tables.len() + snap.views.len() + snap.sequences.len() + snap.enums.len();
|
||||||
|
let over = total > SNAPSHOT_CAP;
|
||||||
|
if over {
|
||||||
|
let mut remaining = SNAPSHOT_CAP;
|
||||||
|
fn take<T>(v: &mut Vec<T>, budget: &mut usize) {
|
||||||
|
if v.len() > *budget {
|
||||||
|
v.truncate(*budget);
|
||||||
|
}
|
||||||
|
*budget -= v.len();
|
||||||
|
}
|
||||||
|
take(&mut snap.tables, &mut remaining);
|
||||||
|
take(&mut snap.views, &mut remaining);
|
||||||
|
take(&mut snap.sequences, &mut remaining);
|
||||||
|
take(&mut snap.enums, &mut remaining);
|
||||||
|
total = SNAPSHOT_CAP;
|
||||||
|
}
|
||||||
|
snap.object_count = total;
|
||||||
|
snap.truncated = over;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn csv_names(s: &str) -> Vec<String> {
|
||||||
|
s.split(", ").map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a PostgreSQL schema snapshot (six set-based queries, one per class).
|
||||||
|
pub(crate) async fn capture_pg(
|
||||||
|
client: &tokio_postgres::Client,
|
||||||
|
engine: &str,
|
||||||
|
schema: &str,
|
||||||
|
) -> Result<SchemaSnapshot, String> {
|
||||||
|
let mut tables: BTreeMap<String, SnapshotTable> = BTreeMap::new();
|
||||||
|
let mut views: Vec<SnapshotView> = Vec::new();
|
||||||
|
let mut sequences: Vec<SnapshotSequence> = Vec::new();
|
||||||
|
let mut enums: BTreeMap<String, SnapshotEnum> = BTreeMap::new();
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
let rows = client
|
||||||
|
.query(&crate::db::introspection::pg_diff_columns_query(), &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("columns query failed: {e}"))?;
|
||||||
|
for r in rows {
|
||||||
|
let tname: String = r.get(0);
|
||||||
|
let entry = tables.entry(tname.clone()).or_insert_with(|| SnapshotTable {
|
||||||
|
schema: schema.to_string(),
|
||||||
|
name: tname,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
entry.columns.push(SnapshotColumn {
|
||||||
|
name: r.get(1),
|
||||||
|
data_type: r.get(2),
|
||||||
|
nullable: r.get(3),
|
||||||
|
default_value: r.get::<_, Option<String>>(4),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constraints (PK / UNIQUE / CHECK / FK)
|
||||||
|
// Column order: table_name(0), constraint_name(1), contype(2), cols(3),
|
||||||
|
// ref_cols(4), ref_table(5), confdeltype(6), confupdtype(7), definition(8)
|
||||||
|
let rows = client
|
||||||
|
.query(&crate::db::introspection::pg_diff_constraints_query(), &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("constraints query failed: {e}"))?;
|
||||||
|
for r in rows {
|
||||||
|
let tname: String = r.get(0);
|
||||||
|
let cname: String = r.get(1);
|
||||||
|
let contype: String = r.get(2);
|
||||||
|
let Some(entry) = tables.get_mut(&tname) else { continue };
|
||||||
|
match contype.as_str() {
|
||||||
|
"p" => entry.primary_key = csv_names(&r.get::<_, Option<String>>(3).unwrap_or_default()),
|
||||||
|
"u" => entry.uniques.push(SnapshotConstraint {
|
||||||
|
name: cname,
|
||||||
|
kind: "unique".into(),
|
||||||
|
definition: r.get::<_, Option<String>>(3).unwrap_or_default(),
|
||||||
|
}),
|
||||||
|
"c" => entry.checks.push(SnapshotConstraint {
|
||||||
|
name: cname,
|
||||||
|
kind: "check".into(),
|
||||||
|
definition: r.get::<_, Option<String>>(8).unwrap_or_default(),
|
||||||
|
}),
|
||||||
|
"f" => {
|
||||||
|
let ref_qualified: Option<String> = r.get(5);
|
||||||
|
let (ref_schema, ref_table) = ref_qualified
|
||||||
|
.map(|q| match q.split_once('.') {
|
||||||
|
Some((s, t)) => (s.to_string(), t.to_string()),
|
||||||
|
None => (String::new(), q.clone()),
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
entry.foreign_keys.push(SnapshotFk {
|
||||||
|
name: cname,
|
||||||
|
columns: csv_names(&r.get::<_, Option<String>>(3).unwrap_or_default()),
|
||||||
|
ref_schema,
|
||||||
|
ref_table,
|
||||||
|
ref_columns: csv_names(&r.get::<_, Option<String>>(4).unwrap_or_default()),
|
||||||
|
on_delete: pg_fk_action(&r.get::<_, Option<String>>(6).unwrap_or_default()).to_string(),
|
||||||
|
on_update: pg_fk_action(&r.get::<_, Option<String>>(7).unwrap_or_default()).to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
let rows = client
|
||||||
|
.query(&crate::db::introspection::pg_diff_indexes_query(), &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("indexes query failed: {e}"))?;
|
||||||
|
for r in rows {
|
||||||
|
let tname: String = r.get(0);
|
||||||
|
let Some(entry) = tables.get_mut(&tname) else { continue };
|
||||||
|
entry.indexes.push(SnapshotIndex {
|
||||||
|
name: r.get(1),
|
||||||
|
columns: csv_names(&r.get::<_, Option<String>>(3).unwrap_or_default()),
|
||||||
|
unique: r.get(2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Views + matviews
|
||||||
|
let rows = client
|
||||||
|
.query(&crate::db::introspection::pg_diff_views_query(), &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("views query failed: {e}"))?;
|
||||||
|
for r in rows {
|
||||||
|
views.push(SnapshotView {
|
||||||
|
schema: r.get(0),
|
||||||
|
name: r.get(1),
|
||||||
|
definition: r.get(2),
|
||||||
|
materialized: r.get(3),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sequences
|
||||||
|
let rows = client
|
||||||
|
.query(&crate::db::introspection::pg_diff_sequences_query(), &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("sequences query failed: {e}"))?;
|
||||||
|
for r in rows {
|
||||||
|
sequences.push(SnapshotSequence {
|
||||||
|
schema: r.get(0),
|
||||||
|
name: r.get(1),
|
||||||
|
start: r.get(2),
|
||||||
|
increment: r.get(3),
|
||||||
|
minimum: r.get(4),
|
||||||
|
maximum: r.get(5),
|
||||||
|
cycle: r.get(6),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enums (one row per label — aggregate by name)
|
||||||
|
let rows = client
|
||||||
|
.query(&crate::db::introspection::pg_diff_enums_query(), &[&schema])
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("enums query failed: {e}"))?;
|
||||||
|
for r in rows {
|
||||||
|
let ename: String = r.get(1);
|
||||||
|
let entry = enums.entry(ename.clone()).or_insert_with(|| SnapshotEnum {
|
||||||
|
schema: r.get(0),
|
||||||
|
name: ename,
|
||||||
|
labels: Vec::new(),
|
||||||
|
});
|
||||||
|
entry.labels.push(r.get(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut snap = SchemaSnapshot {
|
||||||
|
engine: engine.to_string(),
|
||||||
|
schema: schema.to_string(),
|
||||||
|
tables: tables.into_values().collect(),
|
||||||
|
views,
|
||||||
|
sequences,
|
||||||
|
enums: enums.into_values().collect(),
|
||||||
|
object_count: 0,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
apply_snapshot_cap(&mut snap);
|
||||||
|
Ok(snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a SQLite (in-file/in-memory) snapshot.
|
||||||
|
pub(crate) fn capture_sqlite(conn: &rusqlite::Connection) -> Result<SchemaSnapshot, String> {
|
||||||
|
crate::db::schema_diff::capture::sqlite(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a MySQL/MariaDB snapshot.
|
||||||
|
pub(crate) async fn capture_mysql(
|
||||||
|
conn: &mut sqlx::mysql::MySqlConnection,
|
||||||
|
engine: &str,
|
||||||
|
schema: &str,
|
||||||
|
) -> Result<SchemaSnapshot, String> {
|
||||||
|
crate::db::schema_diff::capture::mysql(conn, engine, schema).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch a snapshot capture across engines. `engine` is the CONNECTION's
|
||||||
|
/// db_type (mariadb vs mysql matters for the diff dialect).
|
||||||
|
pub(crate) async fn capture_snapshot(
|
||||||
|
handle: &mut DbHandle,
|
||||||
|
engine: &str,
|
||||||
|
schema: &str,
|
||||||
|
) -> Result<SchemaSnapshot, String> {
|
||||||
|
match handle {
|
||||||
|
DbHandle::Postgresql(client, _) => capture_pg(client, engine, schema).await,
|
||||||
|
DbHandle::MySql(pool) => {
|
||||||
|
let mut conn = pool
|
||||||
|
.acquire()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("source/target connection failed: {e}"))?;
|
||||||
|
capture_mysql(&mut *conn, engine, schema).await
|
||||||
|
}
|
||||||
|
DbHandle::Sqlite(conn) => capture_sqlite(conn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) mod capture {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(crate) fn sqlite(conn: &rusqlite::Connection) -> Result<SchemaSnapshot, String> {
|
||||||
|
let mut snap = SchemaSnapshot {
|
||||||
|
engine: "sqlite".into(),
|
||||||
|
schema: "main".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND type IN ('table', 'view') ORDER BY name")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let objects: Vec<(String, String, Option<String>)> = stmt
|
||||||
|
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (otype, name, sql) in objects {
|
||||||
|
if otype == "view" {
|
||||||
|
snap.views.push(SnapshotView {
|
||||||
|
schema: "main".into(),
|
||||||
|
name,
|
||||||
|
definition: sql.unwrap_or_default(),
|
||||||
|
materialized: false,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut table = SnapshotTable {
|
||||||
|
schema: "main".into(),
|
||||||
|
name: name.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
// Columns + PK + defaults
|
||||||
|
let mut cols = conn
|
||||||
|
.prepare(&format!("PRAGMA table_info({})", quote_double(&name)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let col_rows: Vec<(String, String, i64, Option<String>, i64)> = cols
|
||||||
|
.query_map([], |r| Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
drop(cols);
|
||||||
|
for (cname, ctype, notnull, default, pk) in col_rows {
|
||||||
|
if pk > 0 {
|
||||||
|
table.primary_key.push(cname.clone());
|
||||||
|
}
|
||||||
|
table.columns.push(SnapshotColumn {
|
||||||
|
name: cname,
|
||||||
|
data_type: ctype,
|
||||||
|
nullable: notnull == 0,
|
||||||
|
default_value: default,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Indexes (skip the PK-mandated sqlite_autoindex_*)
|
||||||
|
let mut idx = conn
|
||||||
|
.prepare(&format!("PRAGMA index_list({})", quote_double(&name)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let idx_rows: Vec<(String, i64)> = idx
|
||||||
|
.query_map([], |r| Ok((r.get(1)?, r.get(2)?)))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
drop(idx);
|
||||||
|
for (iname, unique) in idx_rows {
|
||||||
|
if iname.starts_with("sqlite_autoindex") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut info = conn
|
||||||
|
.prepare(&format!("PRAGMA index_info({})", quote_double(&iname)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let icols: Vec<String> = info
|
||||||
|
.query_map([], |r| r.get::<_, String>(2))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
drop(info);
|
||||||
|
table.indexes.push(SnapshotIndex {
|
||||||
|
name: iname,
|
||||||
|
columns: icols,
|
||||||
|
unique: unique != 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Foreign keys
|
||||||
|
let mut fks = conn
|
||||||
|
.prepare(&format!("PRAGMA foreign_key_list({})", quote_double(&name)))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let fk_rows: Vec<(i64, i64, String, String, String, String, String)> = fks
|
||||||
|
.query_map([], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?))
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
drop(fks);
|
||||||
|
// (id, seq, table, from, to, on_update, on_delete) — group by id
|
||||||
|
let mut grouped: BTreeMap<i64, SnapshotFk> = BTreeMap::new();
|
||||||
|
for (id, _seq, reftable, from, to, on_update, on_delete) in fk_rows {
|
||||||
|
let fk = grouped.entry(id).or_insert_with(|| SnapshotFk {
|
||||||
|
name: format!("fk_{name}_{id}"),
|
||||||
|
columns: Vec::new(),
|
||||||
|
ref_schema: "main".into(),
|
||||||
|
ref_table: reftable,
|
||||||
|
ref_columns: Vec::new(),
|
||||||
|
on_delete: String::new(),
|
||||||
|
on_update: String::new(),
|
||||||
|
});
|
||||||
|
fk.columns.push(from);
|
||||||
|
if !to.is_empty() {
|
||||||
|
fk.ref_columns.push(to);
|
||||||
|
}
|
||||||
|
if !on_delete.is_empty() {
|
||||||
|
fk.on_delete = on_delete.to_uppercase();
|
||||||
|
}
|
||||||
|
if !on_update.is_empty() {
|
||||||
|
fk.on_update = on_update.to_uppercase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
table.foreign_keys = grouped.into_values().collect();
|
||||||
|
snap.tables.push(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_snapshot_cap(&mut snap);
|
||||||
|
Ok(snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn mysql(
|
||||||
|
conn: &mut sqlx::mysql::MySqlConnection,
|
||||||
|
engine: &str,
|
||||||
|
schema: &str,
|
||||||
|
) -> Result<SchemaSnapshot, String> {
|
||||||
|
use crate::db::mysql::{mysql_row_string, mysql_snapshot_columns_query, mysql_snapshot_constraints_query, mysql_snapshot_indexes_query, mysql_snapshot_views_query};
|
||||||
|
|
||||||
|
let mut tables: BTreeMap<String, SnapshotTable> = BTreeMap::new();
|
||||||
|
let mut views: Vec<SnapshotView> = Vec::new();
|
||||||
|
|
||||||
|
let rows = sqlx::query(&mysql_snapshot_columns_query(schema))
|
||||||
|
.fetch_all(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("columns query failed: {e}"))?;
|
||||||
|
for r in rows {
|
||||||
|
let tname = mysql_row_string(&r, 0);
|
||||||
|
let entry = tables.entry(tname.clone()).or_insert_with(|| SnapshotTable {
|
||||||
|
schema: schema.to_string(),
|
||||||
|
name: tname,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
entry.columns.push(SnapshotColumn {
|
||||||
|
name: mysql_row_string(&r, 1),
|
||||||
|
data_type: mysql_row_string(&r, 2),
|
||||||
|
nullable: mysql_row_string(&r, 3) == "YES",
|
||||||
|
default_value: (!mysql_row_string(&r, 4).is_empty()).then(|| mysql_row_string(&r, 4)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query(&mysql_snapshot_constraints_query(schema))
|
||||||
|
.fetch_all(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("constraints query failed: {e}"))?;
|
||||||
|
for r in rows {
|
||||||
|
let tname = mysql_row_string(&r, 0);
|
||||||
|
let cname = mysql_row_string(&r, 1);
|
||||||
|
let ctype = mysql_row_string(&r, 2);
|
||||||
|
let col = mysql_row_string(&r, 3);
|
||||||
|
let Some(entry) = tables.get_mut(&tname) else { continue };
|
||||||
|
match ctype.as_str() {
|
||||||
|
"PRIMARY KEY" => {
|
||||||
|
if !entry.primary_key.contains(&col) {
|
||||||
|
entry.primary_key.push(col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"UNIQUE" => {
|
||||||
|
if let Some(u) = entry.uniques.iter_mut().find(|u| u.name == cname) {
|
||||||
|
u.definition = if u.definition.is_empty() { col.clone() } else { format!("{}, {col}", u.definition) };
|
||||||
|
} else {
|
||||||
|
entry.uniques.push(SnapshotConstraint {
|
||||||
|
name: cname,
|
||||||
|
kind: "unique".into(),
|
||||||
|
definition: col,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"FOREIGN KEY" => {
|
||||||
|
let ref_table = mysql_row_string(&r, 4);
|
||||||
|
let on_delete = mysql_row_string(&r, 5).to_uppercase();
|
||||||
|
let on_update = mysql_row_string(&r, 6).to_uppercase();
|
||||||
|
if let Some(fk) = entry.foreign_keys.iter_mut().find(|f| f.name == cname) {
|
||||||
|
fk.columns.push(col);
|
||||||
|
} else {
|
||||||
|
entry.foreign_keys.push(SnapshotFk {
|
||||||
|
name: cname,
|
||||||
|
columns: vec![col],
|
||||||
|
ref_schema: String::new(),
|
||||||
|
ref_table,
|
||||||
|
ref_columns: Vec::new(),
|
||||||
|
on_delete,
|
||||||
|
on_update,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"CHECK" => {
|
||||||
|
if let Some(c) = entry.checks.iter_mut().find(|c| c.name == cname) {
|
||||||
|
c.definition = format!("{} {}", c.definition, mysql_row_string(&r, 7));
|
||||||
|
} else {
|
||||||
|
entry.checks.push(SnapshotConstraint {
|
||||||
|
name: cname,
|
||||||
|
kind: "check".into(),
|
||||||
|
definition: mysql_row_string(&r, 7),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query(&mysql_snapshot_indexes_query(schema))
|
||||||
|
.fetch_all(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("indexes query failed: {e}"))?;
|
||||||
|
for r in rows {
|
||||||
|
let tname = mysql_row_string(&r, 0);
|
||||||
|
let iname = mysql_row_string(&r, 1);
|
||||||
|
let unique = mysql_row_string(&r, 2) == "0";
|
||||||
|
let col = mysql_row_string(&r, 3);
|
||||||
|
let Some(entry) = tables.get_mut(&tname) else { continue };
|
||||||
|
if let Some(ix) = entry.indexes.iter_mut().find(|i| i.name == iname) {
|
||||||
|
ix.columns.push(col);
|
||||||
|
} else {
|
||||||
|
entry.indexes.push(SnapshotIndex {
|
||||||
|
name: iname,
|
||||||
|
columns: vec![col],
|
||||||
|
unique,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query(&mysql_snapshot_views_query(schema))
|
||||||
|
.fetch_all(&mut *conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("views query failed: {e}"))?;
|
||||||
|
for r in rows {
|
||||||
|
views.push(SnapshotView {
|
||||||
|
schema: mysql_row_string(&r, 0),
|
||||||
|
name: mysql_row_string(&r, 1),
|
||||||
|
definition: mysql_row_string(&r, 2),
|
||||||
|
materialized: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut snap = SchemaSnapshot {
|
||||||
|
engine: engine.to_string(),
|
||||||
|
schema: schema.to_string(),
|
||||||
|
tables: tables.into_values().collect(),
|
||||||
|
views,
|
||||||
|
sequences: Vec::new(),
|
||||||
|
enums: Vec::new(),
|
||||||
|
object_count: 0,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
apply_snapshot_cap(&mut snap);
|
||||||
|
Ok(snap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Double-quote an identifier (PG/SQLite) with embedded-quote doubling.
|
||||||
|
pub(crate) fn quote_double(name: &str) -> String {
|
||||||
|
format!("\"{}\"", name.replace('"', "\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure diff engine (spec §4/§5A)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Destructive classification (binding): DROP TABLE/VIEW/INDEX/SEQUENCE/TYPE/
|
||||||
|
// CONSTRAINT, DROP COLUMN, and ALTER COLUMN TYPE are destructive (copy-only);
|
||||||
|
// everything else (CREATE, ADD COLUMN, ADD CONSTRAINT, RENAME, SET/DROP
|
||||||
|
// DEFAULT, SET/DROP NOT NULL) is stageable.
|
||||||
|
|
||||||
|
pub(crate) fn engine_family(engine: &str) -> &'static str {
|
||||||
|
match engine {
|
||||||
|
"mysql" | "mariadb" => "mysql",
|
||||||
|
"sqlite" => "sqlite",
|
||||||
|
_ => "postgresql",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote(family: &str, name: &str) -> String {
|
||||||
|
match family {
|
||||||
|
"mysql" => format!("`{}`", name.replace('`', "``")),
|
||||||
|
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qualified(family: &str, schema: &str, name: &str) -> String {
|
||||||
|
match family {
|
||||||
|
"mysql" => format!("`{}`.`{}`", schema.replace('`', "``"), name.replace('`', "``")),
|
||||||
|
"sqlite" => quote(family, name),
|
||||||
|
_ => format!("\"{}\".\"{}\"", schema.replace('"', "\"\""), name.replace('"', "\"\"")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detail_line(label: &str, old: &str, new: &str) -> DiffDetailLine {
|
||||||
|
DiffDetailLine { label: label.into(), old: old.into(), new: new.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item(ty: &str, name: &str, kind: &str, detail: Vec<DiffDetailLine>, sql: Vec<String>, destructive: bool) -> DiffItem {
|
||||||
|
DiffItem {
|
||||||
|
object_type: ty.into(),
|
||||||
|
name: name.into(),
|
||||||
|
kind: kind.into(),
|
||||||
|
detail,
|
||||||
|
sync_sql: sql,
|
||||||
|
destructive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff two snapshots into a report. Sync SQL is in the TARGET's dialect.
|
||||||
|
pub fn diff_snapshots(source: &SchemaSnapshot, target: &SchemaSnapshot) -> DiffReport {
|
||||||
|
let family = engine_family(&target.engine);
|
||||||
|
let schema = &target.schema;
|
||||||
|
let mut items: Vec<DiffItem> = Vec::new();
|
||||||
|
|
||||||
|
// ── Enums (PG) — must come first (tables may depend on them) ──────────
|
||||||
|
for (s_enum, t_enum) in enum_pairs(&source.enums, &target.enums) {
|
||||||
|
match (s_enum, t_enum) {
|
||||||
|
(Some(s), None) => items.push(item(
|
||||||
|
"enum", &s.name, "added", vec![],
|
||||||
|
vec![format!(
|
||||||
|
"CREATE TYPE {}.{} AS ENUM ({});",
|
||||||
|
quote(family, &s.schema),
|
||||||
|
quote(family, &s.name),
|
||||||
|
s.labels.iter().map(|l| format!("'{}'", l.replace('\'', "''"))).collect::<Vec<_>>().join(", ")
|
||||||
|
)],
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
(None, Some(t)) => items.push(item(
|
||||||
|
"enum", &t.name, "removed", vec![],
|
||||||
|
vec![format!("DROP TYPE {}.{};", quote(family, &t.schema), quote(family, &t.name))],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
(Some(s), Some(t)) => {
|
||||||
|
let mut added = Vec::new();
|
||||||
|
let mut removed_or_reordered = false;
|
||||||
|
for l in &s.labels {
|
||||||
|
if !t.labels.contains(l) { added.push(l.clone()); }
|
||||||
|
}
|
||||||
|
if t.labels.iter().any(|l| !s.labels.contains(l)) || !common_prefix(&s.labels, &t.labels) {
|
||||||
|
removed_or_reordered = true;
|
||||||
|
}
|
||||||
|
if !added.is_empty() {
|
||||||
|
if removed_or_reordered {
|
||||||
|
items.push(item("enum", &s.name, "changed", vec![detail_line("labels", &t.labels.join(", "), &s.labels.join(", "))], vec![], true));
|
||||||
|
} else {
|
||||||
|
items.push(item(
|
||||||
|
"enum", &s.name, "changed",
|
||||||
|
vec![detail_line("added labels", "", &added.join(", "))],
|
||||||
|
added.iter().map(|l| format!("ALTER TYPE {}.{} ADD VALUE '{}';", quote(family, &s.schema), quote(family, &s.name), l.replace('\'', "''"))).collect(),
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sequences (PG) ─────────────────────────────────────────────────────
|
||||||
|
for (s_seq, t_seq) in pair_by(&source.sequences, &target.sequences, |s| s.name.clone()) {
|
||||||
|
match (s_seq, t_seq) {
|
||||||
|
(Some(s), None) => items.push(item(
|
||||||
|
"sequence", &s.name, "added", vec![],
|
||||||
|
vec![format!(
|
||||||
|
"CREATE SEQUENCE {q} START WITH {start} INCREMENT BY {inc} MINVALUE {min} MAXVALUE {max} {cycle};",
|
||||||
|
q = qualified(family, schema, &s.name),
|
||||||
|
start = s.start, inc = s.increment, min = s.minimum, max = s.maximum,
|
||||||
|
cycle = if s.cycle { "CYCLE" } else { "NO CYCLE" }
|
||||||
|
)],
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
(None, Some(t)) => items.push(item(
|
||||||
|
"sequence", &t.name, "removed", vec![],
|
||||||
|
vec![format!("DROP SEQUENCE {};", qualified(family, schema, &t.name))],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
(Some(s), Some(t)) => {
|
||||||
|
let mut sql = String::new();
|
||||||
|
let mut detail = Vec::new();
|
||||||
|
for (label, sv, tv) in [
|
||||||
|
("start", &s.start, &t.start),
|
||||||
|
("increment", &s.increment, &t.increment),
|
||||||
|
("minimum", &s.minimum, &t.minimum),
|
||||||
|
("maximum", &s.maximum, &t.maximum),
|
||||||
|
] {
|
||||||
|
if sv != tv {
|
||||||
|
detail.push(detail_line(label, tv, sv));
|
||||||
|
if !sql.is_empty() { sql.push(' '); }
|
||||||
|
match label {
|
||||||
|
"start" => sql.push_str(&format!("START WITH {sv}")),
|
||||||
|
"increment" => sql.push_str(&format!("INCREMENT BY {sv}")),
|
||||||
|
"minimum" => sql.push_str(&format!("MINVALUE {sv}")),
|
||||||
|
"maximum" => sql.push_str(&format!("MAXVALUE {sv}")),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.cycle != t.cycle {
|
||||||
|
detail.push(detail_line("cycle", &t.cycle.to_string(), &s.cycle.to_string()));
|
||||||
|
if !sql.is_empty() { sql.push(' '); }
|
||||||
|
sql.push_str(if s.cycle { "CYCLE" } else { "NO CYCLE" });
|
||||||
|
}
|
||||||
|
if !sql.is_empty() {
|
||||||
|
items.push(item("sequence", &s.name, "changed", detail, vec![format!("ALTER SEQUENCE {} {};", qualified(family, schema, &s.name), sql)], false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tables ─────────────────────────────────────────────────────────────
|
||||||
|
for (s_t, t_t) in pair_by(&source.tables, &target.tables, |t| t.name.clone()) {
|
||||||
|
match (s_t, t_t) {
|
||||||
|
(Some(s), None) => {
|
||||||
|
let mut defs: Vec<String> = s.columns.iter().map(|c| {
|
||||||
|
let mut d = format!("{} {}", quote(family, &c.name), c.data_type);
|
||||||
|
if !c.nullable { d.push_str(" NOT NULL"); }
|
||||||
|
if let Some(def) = &c.default_value { d.push_str(&format!(" DEFAULT {def}")); }
|
||||||
|
d
|
||||||
|
}).collect();
|
||||||
|
if !s.primary_key.is_empty() {
|
||||||
|
defs.push(format!("PRIMARY KEY ({})", s.primary_key.iter().map(|p| quote(family, p)).collect::<Vec<_>>().join(", ")));
|
||||||
|
}
|
||||||
|
items.push(item(
|
||||||
|
"table", &s.name, "added", vec![],
|
||||||
|
vec![format!("CREATE TABLE {} ({});", qualified(family, schema, &s.name), defs.join(", "))],
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
push_constraint_items(&mut items, family, schema, s, "added");
|
||||||
|
}
|
||||||
|
(None, Some(t)) => items.push(item(
|
||||||
|
"table", &t.name, "removed", vec![],
|
||||||
|
vec![format!("DROP TABLE {};", qualified(family, schema, &t.name))],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
(Some(s), Some(t)) => diff_table(&mut items, family, schema, s, t),
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Views ──────────────────────────────────────────────────────────────
|
||||||
|
for (s_v, t_v) in pair_by(&source.views, &target.views, |v| v.name.clone()) {
|
||||||
|
match (s_v, t_v) {
|
||||||
|
(Some(s), None) => items.push(item(
|
||||||
|
"view", &s.name, "added", vec![],
|
||||||
|
vec![format!("CREATE {}VIEW {} AS {};", if s.materialized { "MATERIALIZED " } else { "" }, qualified(family, schema, &s.name), s.definition.trim_end_matches(';'))],
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
(None, Some(t)) => items.push(item(
|
||||||
|
"view", &t.name, "removed", vec![],
|
||||||
|
vec![format!("DROP {}VIEW {};", if t.materialized { "MATERIALIZED " } else { "" }, qualified(family, schema, &t.name))],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
(Some(s), Some(t)) if s.definition.trim() != t.definition.trim() => {
|
||||||
|
if family == "postgresql" || family == "mysql" {
|
||||||
|
if s.materialized || t.materialized {
|
||||||
|
items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)],
|
||||||
|
vec![format!("DROP MATERIALIZED VIEW {q}; CREATE MATERIALIZED VIEW {q} AS {d};", q = qualified(family, schema, &s.name), d = s.definition.trim_end_matches(';'))], true));
|
||||||
|
} else {
|
||||||
|
items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)],
|
||||||
|
vec![format!("CREATE OR REPLACE VIEW {} AS {};", qualified(family, schema, &s.name), s.definition.trim_end_matches(';'))], false));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items.push(item("view", &s.name, "changed", vec![detail_line("definition", &t.definition, &s.definition)], vec![], true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DiffReport {
|
||||||
|
items,
|
||||||
|
source_label: format!("{} ({})", source.schema, source.engine),
|
||||||
|
target_label: format!("{} ({})", target.schema, target.engine),
|
||||||
|
truncated: source.truncated || target.truncated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Items safe to stage: non-destructive AND carrying SQL.
|
||||||
|
pub fn stageable_items(report: &DiffReport) -> Vec<&DiffItem> {
|
||||||
|
report.items.iter().filter(|i| !i.destructive && !i.sync_sql.is_empty()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn common_prefix(source: &[String], target: &[String]) -> bool {
|
||||||
|
let mut ti = 0usize;
|
||||||
|
for l in target {
|
||||||
|
if source.contains(l) {
|
||||||
|
if ti >= source.len() || &source[ti] != l {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ti += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pair two keyed lists into (source, target) options by key.
|
||||||
|
fn pair_by<'a, T, K: Ord + Clone + Eq>(src: &'a [T], tgt: &'a [T], key: impl Fn(&T) -> K) -> Vec<(Option<&'a T>, Option<&'a T>)> {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
let mut map: BTreeMap<K, (Option<&T>, Option<&T>)> = BTreeMap::new();
|
||||||
|
for s in src { map.entry(key(s)).or_insert((None, None)).0 = Some(s); }
|
||||||
|
for t in tgt { map.entry(key(t)).or_insert((None, None)).1 = Some(t); }
|
||||||
|
map.into_values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enum_pairs<'a>(src: &'a [SnapshotEnum], tgt: &'a [SnapshotEnum]) -> Vec<(Option<&'a SnapshotEnum>, Option<&'a SnapshotEnum>)> {
|
||||||
|
pair_by(src, tgt, |e| e.name.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_constraint_items(items: &mut Vec<DiffItem>, family: &str, schema: &str, table: &SnapshotTable, kind: &str) {
|
||||||
|
let qtn = qualified(family, schema, &table.name);
|
||||||
|
for fk in &table.foreign_keys {
|
||||||
|
items.push(item(
|
||||||
|
"constraint", &format!("{}.{}", table.name, fk.name), kind, vec![],
|
||||||
|
vec![constraint_fk_sql(family, schema, &qtn, fk)],
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for u in &table.uniques {
|
||||||
|
let cols = u.definition.split(", ").map(|c| quote(family, c.trim())).collect::<Vec<_>>().join(", ");
|
||||||
|
items.push(item(
|
||||||
|
"constraint", &format!("{}.{}", table.name, u.name), kind, vec![],
|
||||||
|
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});", n = quote(family, &u.name), cols = cols)],
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for c in &table.checks {
|
||||||
|
items.push(item(
|
||||||
|
"constraint", &format!("{}.{}", table.name, c.name), kind, vec![],
|
||||||
|
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});", n = quote(family, &c.name), d = c.definition.trim())],
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for ix in &table.indexes {
|
||||||
|
let cols = ix.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", ");
|
||||||
|
items.push(item(
|
||||||
|
"index", &format!("{}.{}", table.name, ix.name), kind, vec![],
|
||||||
|
vec![format!("CREATE {u}INDEX {n} ON {qtn} ({cols});", u = if ix.unique { "UNIQUE " } else { "" }, n = quote(family, &ix.name), cols = cols)],
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constraint_fk_sql(family: &str, _schema: &str, qtn: &str, fk: &SnapshotFk) -> String {
|
||||||
|
let cols = fk.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", ");
|
||||||
|
let ref_cols = fk.ref_columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", ");
|
||||||
|
let ref_qualified = match family {
|
||||||
|
"mysql" => format!("`{}`", fk.ref_table.replace('`', "``")),
|
||||||
|
"sqlite" => quote(family, &fk.ref_table),
|
||||||
|
_ => qualified(family, &fk.ref_schema, &fk.ref_table),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"ALTER TABLE {qtn} ADD CONSTRAINT {n} FOREIGN KEY ({cols}) REFERENCES {r} ({rc}) ON DELETE {od} ON UPDATE {ou};",
|
||||||
|
n = quote(family, &fk.name), cols = cols, r = ref_qualified, rc = ref_cols,
|
||||||
|
od = fk.on_delete, ou = fk.on_update
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff_table(items: &mut Vec<DiffItem>, family: &str, schema: &str, s: &SnapshotTable, t: &SnapshotTable) {
|
||||||
|
let qtn = qualified(family, schema, &s.name);
|
||||||
|
|
||||||
|
for (s_c, t_c) in pair_by(&s.columns, &t.columns, |c| c.name.clone()) {
|
||||||
|
match (s_c, t_c) {
|
||||||
|
(Some(c), None) => {
|
||||||
|
let mut sql = format!("ALTER TABLE {qtn} ADD COLUMN {n} {ty}", n = quote(family, &c.name), ty = c.data_type);
|
||||||
|
if !c.nullable {
|
||||||
|
if family == "sqlite" && c.default_value.is_none() {
|
||||||
|
items.push(item("column", &format!("{}.{}", s.name, c.name), "added",
|
||||||
|
vec![detail_line("nullable", "false", "true (SQLite cannot ADD COLUMN NOT NULL without DEFAULT)")],
|
||||||
|
vec![format!("{sql};")], false));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sql.push_str(" NOT NULL");
|
||||||
|
}
|
||||||
|
if let Some(d) = &c.default_value { sql.push_str(&format!(" DEFAULT {d}")); }
|
||||||
|
items.push(item("column", &format!("{}.{}", s.name, c.name), "added", vec![], vec![format!("{sql};")], false));
|
||||||
|
}
|
||||||
|
(None, Some(c)) => items.push(item(
|
||||||
|
"column", &format!("{}.{}", s.name, c.name), "removed", vec![],
|
||||||
|
vec![format!("ALTER TABLE {qtn} DROP COLUMN {n};", n = quote(family, &c.name))],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
(Some(sc), Some(tc)) => {
|
||||||
|
if sc.data_type != tc.data_type {
|
||||||
|
let sql = match family {
|
||||||
|
"postgresql" => format!("ALTER TABLE {qtn} ALTER COLUMN {n} TYPE {ty} USING {n}::{ty};", n = quote(family, &sc.name), ty = sc.data_type),
|
||||||
|
"mysql" => format!("ALTER TABLE {qtn} MODIFY COLUMN {n} {ty}{nn};", n = quote(family, &sc.name), ty = sc.data_type, nn = if sc.nullable { String::new() } else { " NOT NULL".into() }),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed",
|
||||||
|
vec![detail_line("type", &tc.data_type, &sc.data_type)],
|
||||||
|
if sql.is_empty() { vec![] } else { vec![sql] }, true));
|
||||||
|
}
|
||||||
|
if sc.nullable != tc.nullable {
|
||||||
|
let sql = match family {
|
||||||
|
"postgresql" => format!("ALTER TABLE {qtn} ALTER COLUMN {n} {op};", n = quote(family, &sc.name), op = if sc.nullable { "DROP NOT NULL" } else { "SET NOT NULL" }),
|
||||||
|
"mysql" => format!("ALTER TABLE {qtn} MODIFY COLUMN {n} {ty}{nn};", n = quote(family, &sc.name), ty = sc.data_type, nn = if sc.nullable { String::new() } else { " NOT NULL".into() }),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
if !sql.is_empty() {
|
||||||
|
items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed",
|
||||||
|
vec![detail_line("nullable", &tc.nullable.to_string(), &sc.nullable.to_string())],
|
||||||
|
vec![sql], false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sc.default_value != tc.default_value {
|
||||||
|
let sql = match family {
|
||||||
|
"postgresql" => match &sc.default_value {
|
||||||
|
Some(d) => format!("ALTER TABLE {qtn} ALTER COLUMN {n} SET DEFAULT {d};", n = quote(family, &sc.name)),
|
||||||
|
None => format!("ALTER TABLE {qtn} ALTER COLUMN {n} DROP DEFAULT;", n = quote(family, &sc.name)),
|
||||||
|
},
|
||||||
|
"mysql" => match &sc.default_value {
|
||||||
|
Some(d) => format!("ALTER TABLE {qtn} ALTER COLUMN {n} SET DEFAULT {d};", n = quote(family, &sc.name)),
|
||||||
|
None => format!("ALTER TABLE {qtn} ALTER COLUMN {n} DROP DEFAULT;", n = quote(family, &sc.name)),
|
||||||
|
},
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
if !sql.is_empty() {
|
||||||
|
items.push(item("column", &format!("{}.{}", s.name, sc.name), "changed",
|
||||||
|
vec![detail_line("default", &tc.default_value.as_deref().unwrap_or_default(), &sc.default_value.clone().unwrap_or_default())],
|
||||||
|
vec![sql], false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.primary_key != t.primary_key {
|
||||||
|
items.push(item("table", &s.name, "changed",
|
||||||
|
vec![detail_line("primary key", &t.primary_key.join(", "), &s.primary_key.join(", "))],
|
||||||
|
vec![], true));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (s_c, t_c) in pair_by(&s.uniques, &t.uniques, |c| c.name.clone()) {
|
||||||
|
push_constraint_delta(items, family, s, "UNIQUE", s_c, t_c);
|
||||||
|
}
|
||||||
|
for (s_c, t_c) in pair_by(&s.checks, &t.checks, |c| c.name.clone()) {
|
||||||
|
push_constraint_delta(items, family, s, "CHECK", s_c, t_c);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (s_f, t_f) in pair_by(&s.foreign_keys, &t.foreign_keys, |f| f.name.clone()) {
|
||||||
|
let qtn2 = qualified(family, schema, &s.name);
|
||||||
|
match (s_f, t_f) {
|
||||||
|
(Some(f), None) => {
|
||||||
|
if family == "sqlite" {
|
||||||
|
items.push(item("constraint", &format!("{}.{}", s.name, f.name), "added",
|
||||||
|
vec![detail_line("note", "", "SQLite cannot add foreign keys to existing tables — table rebuild required")],
|
||||||
|
vec![], false));
|
||||||
|
} else {
|
||||||
|
items.push(item("constraint", &format!("{}.{}", s.name, f.name), "added", vec![],
|
||||||
|
vec![constraint_fk_sql(family, schema, &qtn2, f)], false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, Some(f)) => items.push(item(
|
||||||
|
"constraint", &format!("{}.{}", s.name, f.name), "removed", vec![],
|
||||||
|
vec![format!("ALTER TABLE {qtn2} DROP CONSTRAINT {n};", n = quote(family, &f.name))],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
(Some(sf), Some(tf)) if sf.definition_differs(tf) => {
|
||||||
|
items.push(item("constraint", &format!("{}.{}", s.name, sf.name), "changed",
|
||||||
|
vec![detail_line("definition", &tf.on_delete, &sf.on_delete)],
|
||||||
|
vec![format!("ALTER TABLE {qtn2} DROP CONSTRAINT {n}; ALTER TABLE {qtn2} ADD CONSTRAINT {n2} FOREIGN KEY ({cols}) REFERENCES {r} ({rc}) ON DELETE {od} ON UPDATE {ou};",
|
||||||
|
n = quote(family, &sf.name), n2 = quote(family, &sf.name),
|
||||||
|
cols = sf.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "),
|
||||||
|
r = qualified(family, &sf.ref_schema, &sf.ref_table),
|
||||||
|
rc = sf.ref_columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "),
|
||||||
|
od = sf.on_delete, ou = sf.on_update)],
|
||||||
|
true));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (s_i, t_i) in pair_by(&s.indexes, &t.indexes, |i| i.name.clone()) {
|
||||||
|
match (s_i, t_i) {
|
||||||
|
(Some(ix), None) => items.push(item(
|
||||||
|
"index", &format!("{}.{}", s.name, ix.name), "added", vec![],
|
||||||
|
vec![format!("CREATE {u}INDEX {n} ON {qtn} ({cols});", u = if ix.unique { "UNIQUE " } else { "" }, n = qualified(family, schema, &ix.name), cols = ix.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "))],
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
(None, Some(ix)) => items.push(item(
|
||||||
|
"index", &format!("{}.{}", s.name, ix.name), "removed", vec![],
|
||||||
|
vec![format!("DROP INDEX {n};", n = quote(family, &ix.name))],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
(Some(si), Some(ti)) if si.columns != ti.columns || si.unique != ti.unique => {
|
||||||
|
items.push(item("index", &format!("{}.{}", s.name, si.name), "changed",
|
||||||
|
vec![detail_line("columns", &ti.columns.join(", "), &si.columns.join(", "))],
|
||||||
|
vec![format!("DROP INDEX {n}; CREATE {u}INDEX {n} ON {qtn} ({cols});", n = qualified(family, schema, &si.name), u = if si.unique { "UNIQUE " } else { "" }, cols = si.columns.iter().map(|c| quote(family, c)).collect::<Vec<_>>().join(", "))],
|
||||||
|
true));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_constraint_delta(items: &mut Vec<DiffItem>, family: &str, table: &SnapshotTable, kind: &str,
|
||||||
|
s_c: Option<&SnapshotConstraint>, t_c: Option<&SnapshotConstraint>) {
|
||||||
|
let qtn = qualified(family, &table.schema, &table.name);
|
||||||
|
match (s_c, t_c) {
|
||||||
|
(Some(c), None) if kind == "UNIQUE" => items.push(item(
|
||||||
|
"constraint", &format!("{}.{}", table.name, c.name), "added", vec![],
|
||||||
|
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});", n = quote(family, &c.name), cols = c.definition.split(", ").map(|x| quote(family, x.trim())).collect::<Vec<_>>().join(", "))],
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
(Some(c), None) if kind == "CHECK" => items.push(item(
|
||||||
|
"constraint", &format!("{}.{}", table.name, c.name), "added", vec![],
|
||||||
|
vec![format!("ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});", n = quote(family, &c.name), d = c.definition.trim())],
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
(Some(c), None) if family == "sqlite" => items.push(item(
|
||||||
|
"constraint", &format!("{}.{}", table.name, c.name), "added",
|
||||||
|
vec![detail_line("note", "", "SQLite cannot add constraints to existing tables — table rebuild required")],
|
||||||
|
vec![], false)),
|
||||||
|
(None, Some(c)) => items.push(item(
|
||||||
|
"constraint", &format!("{}.{}", table.name, c.name), "removed", vec![],
|
||||||
|
vec![format!("ALTER TABLE {qtn} DROP CONSTRAINT {n};", n = quote(family, &c.name))],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
(Some(sc), Some(tc)) if sc.definition.trim() != tc.definition.trim() => {
|
||||||
|
let sql = if kind == "UNIQUE" {
|
||||||
|
format!("ALTER TABLE {qtn} DROP CONSTRAINT {n}; ALTER TABLE {qtn} ADD CONSTRAINT {n} UNIQUE ({cols});",
|
||||||
|
n = quote(family, &sc.name), cols = sc.definition.split(", ").map(|x| quote(family, x.trim())).collect::<Vec<_>>().join(", "))
|
||||||
|
} else {
|
||||||
|
format!("ALTER TABLE {qtn} DROP CONSTRAINT {n}; ALTER TABLE {qtn} ADD CONSTRAINT {n} CHECK ({d});",
|
||||||
|
n = quote(family, &sc.name), d = sc.definition.trim())
|
||||||
|
};
|
||||||
|
items.push(item("constraint", &format!("{}.{}", table.name, sc.name), "changed",
|
||||||
|
vec![detail_line("definition", &tc.definition, &sc.definition)],
|
||||||
|
vec![sql], true));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SnapshotFk {
|
||||||
|
fn definition_differs(&self, other: &SnapshotFk) -> bool {
|
||||||
|
self.columns != other.columns
|
||||||
|
|| self.ref_table != other.ref_table
|
||||||
|
|| self.ref_columns != other.ref_columns
|
||||||
|
|| self.on_delete != other.on_delete
|
||||||
|
|| self.on_update != other.on_update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "schema_diff.test.rs"]
|
||||||
|
mod schema_diff_tests;
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── Pure helpers (PG constraint row mapping) ────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pg_fk_action_maps_codes() {
|
||||||
|
assert_eq!(pg_fk_action("a"), "NO ACTION");
|
||||||
|
assert_eq!(pg_fk_action("r"), "RESTRICT");
|
||||||
|
assert_eq!(pg_fk_action("c"), "CASCADE");
|
||||||
|
assert_eq!(pg_fk_action("n"), "SET NULL");
|
||||||
|
assert_eq!(pg_fk_action("d"), "SET DEFAULT");
|
||||||
|
assert_eq!(pg_fk_action("x"), "NO ACTION");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_cap_sets_truncated() {
|
||||||
|
// 5,001 objects → truncated report, never silent (spec §2)
|
||||||
|
let mut snap = SchemaSnapshot::default();
|
||||||
|
snap.engine = "postgresql".into();
|
||||||
|
snap.schema = "s".into();
|
||||||
|
for i in 0..5_001 {
|
||||||
|
snap.tables.push(SnapshotTable {
|
||||||
|
schema: "s".into(),
|
||||||
|
name: format!("t{i}"),
|
||||||
|
columns: vec![],
|
||||||
|
primary_key: vec![],
|
||||||
|
uniques: vec![],
|
||||||
|
checks: vec![],
|
||||||
|
indexes: vec![],
|
||||||
|
foreign_keys: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
apply_snapshot_cap(&mut snap);
|
||||||
|
assert!(snap.truncated);
|
||||||
|
assert!(snap.object_count <= 5_000);
|
||||||
|
assert_eq!(snap.tables.len(), snap.object_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_builders_target_the_right_catalogs() {
|
||||||
|
let cols = crate::db::introspection::pg_diff_columns_query();
|
||||||
|
assert!(cols.contains("pg_attribute") && cols.contains("format_type"));
|
||||||
|
let cons = crate::db::introspection::pg_diff_constraints_query();
|
||||||
|
assert!(cons.contains("pg_constraint") && cons.contains("contype"));
|
||||||
|
let idx = crate::db::introspection::pg_diff_indexes_query();
|
||||||
|
assert!(idx.contains("pg_index") && idx.contains("indisunique"));
|
||||||
|
let views = crate::db::introspection::pg_diff_views_query();
|
||||||
|
assert!(views.contains("pg_views") && views.contains("pg_matviews"));
|
||||||
|
let seqs = crate::db::introspection::pg_diff_sequences_query();
|
||||||
|
assert!(seqs.contains("information_schema.sequences"));
|
||||||
|
let enums = crate::db::introspection::pg_diff_enums_query();
|
||||||
|
assert!(enums.contains("pg_type") && enums.contains("pg_enum"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SQLite capture (in-memory, real, NOT ignored) ──────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_capture_reads_pragmas() {
|
||||||
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL DEFAULT 'x', email TEXT);
|
||||||
|
CREATE UNIQUE INDEX idx_users_email ON users(email);
|
||||||
|
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE);
|
||||||
|
CREATE VIEW v_users AS SELECT id, name FROM users;",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snap = capture_sqlite(&conn).expect("capture");
|
||||||
|
assert_eq!(snap.engine, "sqlite");
|
||||||
|
assert_eq!(snap.tables.len(), 2);
|
||||||
|
let users = snap.tables.iter().find(|t| t.name == "users").unwrap();
|
||||||
|
assert_eq!(users.primary_key, vec!["id"]);
|
||||||
|
assert_eq!(users.columns.len(), 3);
|
||||||
|
assert!(users.columns.iter().any(|c| c.name == "name" && !c.nullable));
|
||||||
|
assert!(users.columns.iter().any(|c| c.name == "email" && c.nullable));
|
||||||
|
assert!(users.indexes.iter().any(|i| i.name == "idx_users_email" && i.unique));
|
||||||
|
let orders = snap.tables.iter().find(|t| t.name == "orders").unwrap();
|
||||||
|
assert_eq!(orders.foreign_keys.len(), 1);
|
||||||
|
let fk = &orders.foreign_keys[0];
|
||||||
|
assert!(fk.ref_table.contains("users"));
|
||||||
|
assert_eq!(fk.on_delete, "CASCADE");
|
||||||
|
assert_eq!(snap.views.len(), 1);
|
||||||
|
assert!(snap.views[0].definition.contains("SELECT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live PG capture — mirror the env/client conventions of the live PG tests in
|
||||||
|
/// `commands/objects.test.rs`. Create two diverged tables in a temp schema,
|
||||||
|
/// assert capture shape (columns with defaults, PK, FK with actions, indexes,
|
||||||
|
/// views, a sequence, an enum).
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn pg_capture_live() {
|
||||||
|
let h = std::env::var("GRIDLINE_TEST_PG_HOST").expect("set GRIDLINE_TEST_PG_HOST");
|
||||||
|
let p: u16 = std::env::var("GRIDLINE_TEST_PG_PORT").unwrap_or_else(|_| "5432".into()).parse().unwrap();
|
||||||
|
let u = std::env::var("GRIDLINE_TEST_PG_USER").expect("set GRIDLINE_TEST_PG_USER");
|
||||||
|
let d = std::env::var("GRIDLINE_TEST_PG_DB").expect("set GRIDLINE_TEST_PG_DB");
|
||||||
|
let pw = std::env::var("GRIDLINE_TEST_PG_PASSWORD").unwrap_or_default();
|
||||||
|
let (client, conn) = tokio_postgres::connect(
|
||||||
|
&format!("host={h} port={p} user={u} dbname={d} password={pw}"),
|
||||||
|
tokio_postgres::NoTls,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("connect to test PG");
|
||||||
|
let handle = tokio::spawn(async move { let _ = conn.await; });
|
||||||
|
|
||||||
|
let schema = "gridline_diff_test";
|
||||||
|
client
|
||||||
|
.batch_execute(&format!(
|
||||||
|
"DROP SCHEMA IF EXISTS {schema} CASCADE; CREATE SCHEMA {schema};"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("create temp schema");
|
||||||
|
|
||||||
|
// Two diverged tables: one with PK/FK/index/default, one plain.
|
||||||
|
client
|
||||||
|
.batch_execute(&format!(
|
||||||
|
"CREATE TABLE {schema}.parent (id serial PRIMARY KEY, code text UNIQUE);
|
||||||
|
CREATE TABLE {schema}.child (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
parent_id integer REFERENCES {schema}.parent(id) ON DELETE CASCADE ON UPDATE RESTRICT,
|
||||||
|
name text NOT NULL DEFAULT 'x',
|
||||||
|
email text
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_child_email ON {schema}.child(email);
|
||||||
|
CREATE VIEW {schema}.v_child AS SELECT id, name FROM {schema}.child;
|
||||||
|
CREATE SEQUENCE {schema}.seq_test START 5 INCREMENT 2;
|
||||||
|
CREATE TYPE {schema}.mood AS ENUM ('happy', 'sad');"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("create objects");
|
||||||
|
|
||||||
|
let snap = capture_pg(&client, "postgresql", schema).await.expect("capture");
|
||||||
|
assert_eq!(snap.engine, "postgresql");
|
||||||
|
assert_eq!(snap.schema, schema);
|
||||||
|
assert_eq!(snap.tables.len(), 2);
|
||||||
|
|
||||||
|
let child = snap.tables.iter().find(|t| t.name == "child").expect("child table");
|
||||||
|
assert_eq!(child.primary_key, vec!["id"]);
|
||||||
|
assert!(child.columns.iter().any(|c| c.name == "name" && !c.nullable && c.default_value.is_some()));
|
||||||
|
assert!(child.columns.iter().any(|c| c.name == "email" && c.nullable));
|
||||||
|
assert!(child.indexes.iter().any(|i| i.name == "idx_child_email"));
|
||||||
|
assert_eq!(child.foreign_keys.len(), 1);
|
||||||
|
let fk = &child.foreign_keys[0];
|
||||||
|
assert!(fk.ref_table.contains("parent"));
|
||||||
|
assert_eq!(fk.on_delete, "CASCADE");
|
||||||
|
assert_eq!(fk.on_update, "RESTRICT");
|
||||||
|
|
||||||
|
let parent = snap.tables.iter().find(|t| t.name == "parent").expect("parent table");
|
||||||
|
assert!(parent.uniques.iter().any(|u| u.kind == "unique"));
|
||||||
|
|
||||||
|
assert!(snap.views.iter().any(|v| v.name == "v_child"));
|
||||||
|
assert!(snap.sequences.iter().any(|s| s.name == "seq_test" && s.start == "5"));
|
||||||
|
assert!(snap.enums.iter().any(|e| e.name == "mood" && e.labels == vec!["happy", "sad"]));
|
||||||
|
|
||||||
|
client
|
||||||
|
.batch_execute(&format!("DROP SCHEMA {schema} CASCADE;"))
|
||||||
|
.await
|
||||||
|
.expect("drop temp schema");
|
||||||
|
let _ = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task 14: pure diff engine + sync-SQL generation ─────────────────
|
||||||
|
|
||||||
|
fn col(name: &str, ty: &str, nullable: bool, default: Option<&str>) -> SnapshotColumn {
|
||||||
|
SnapshotColumn {
|
||||||
|
name: name.into(),
|
||||||
|
data_type: ty.into(),
|
||||||
|
nullable,
|
||||||
|
default_value: default.map(|d| d.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table(name: &str, columns: Vec<SnapshotColumn>) -> SnapshotTable {
|
||||||
|
SnapshotTable {
|
||||||
|
schema: "s".into(),
|
||||||
|
name: name.into(),
|
||||||
|
columns,
|
||||||
|
primary_key: vec![],
|
||||||
|
uniques: vec![],
|
||||||
|
checks: vec![],
|
||||||
|
indexes: vec![],
|
||||||
|
foreign_keys: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pg_snap(tables: Vec<SnapshotTable>) -> SchemaSnapshot {
|
||||||
|
let object_count = tables.len();
|
||||||
|
SchemaSnapshot {
|
||||||
|
engine: "postgresql".into(),
|
||||||
|
schema: "s".into(),
|
||||||
|
tables,
|
||||||
|
views: vec![],
|
||||||
|
sequences: vec![],
|
||||||
|
enums: vec![],
|
||||||
|
object_count,
|
||||||
|
truncated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn added_table_generates_create_with_pk_and_is_stageable() {
|
||||||
|
let mut src = pg_snap(vec![]);
|
||||||
|
let mut t = table("users", vec![col("id", "integer", false, None), col("email", "text", true, Some("''"))]);
|
||||||
|
t.primary_key = vec!["id".into()];
|
||||||
|
src.tables.push(t);
|
||||||
|
let tgt = pg_snap(vec![]);
|
||||||
|
let r = diff_snapshots(&src, &tgt);
|
||||||
|
let item = r.items.iter().find(|i| i.object_type == "table" && i.name == "users").unwrap();
|
||||||
|
assert_eq!(item.kind, "added");
|
||||||
|
assert!(!item.destructive);
|
||||||
|
assert_eq!(item.sync_sql.len(), 1);
|
||||||
|
let sql = &item.sync_sql[0];
|
||||||
|
assert!(sql.starts_with("CREATE TABLE \"s\".\"users\""), "got: {sql}");
|
||||||
|
assert!(sql.contains("\"id\" integer NOT NULL"));
|
||||||
|
assert!(sql.contains("\"email\" text DEFAULT ''"));
|
||||||
|
assert!(sql.contains("PRIMARY KEY (\"id\")"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removed_table_is_destructive_copy_only() {
|
||||||
|
let tgt = pg_snap(vec![table("old", vec![col("id", "integer", false, None)])]);
|
||||||
|
let src = pg_snap(vec![]);
|
||||||
|
let r = diff_snapshots(&src, &tgt);
|
||||||
|
let item = r.items.iter().find(|i| i.name == "old").unwrap();
|
||||||
|
assert_eq!(item.kind, "removed");
|
||||||
|
assert!(item.destructive);
|
||||||
|
assert_eq!(item.sync_sql, vec!["DROP TABLE \"s\".\"old\";"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn column_diffs_classify_exactly_per_spec() {
|
||||||
|
let mut src = pg_snap(vec![table(
|
||||||
|
"t",
|
||||||
|
vec![
|
||||||
|
col("kept", "int", true, None),
|
||||||
|
col("added", "int", true, None),
|
||||||
|
col("retyped", "bigint", true, None),
|
||||||
|
col("tightened", "int", false, None),
|
||||||
|
col("defchanged", "int", true, Some("2")),
|
||||||
|
],
|
||||||
|
)]);
|
||||||
|
src.tables[0].name = "t".into();
|
||||||
|
let tgt = pg_snap(vec![table(
|
||||||
|
"t",
|
||||||
|
vec![
|
||||||
|
col("kept", "int", true, None),
|
||||||
|
col("gone", "int", true, None),
|
||||||
|
col("retyped", "int", true, None),
|
||||||
|
col("tightened", "int", true, None),
|
||||||
|
col("defchanged", "int", true, Some("1")),
|
||||||
|
],
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let r = diff_snapshots(&src, &tgt);
|
||||||
|
|
||||||
|
let add = r.items.iter().find(|i| i.name == "t.added").unwrap();
|
||||||
|
assert_eq!((add.kind.as_str(), add.destructive), ("added", false));
|
||||||
|
assert_eq!(add.sync_sql[0], "ALTER TABLE \"s\".\"t\" ADD COLUMN \"added\" int;");
|
||||||
|
|
||||||
|
let gone = r.items.iter().find(|i| i.name == "t.gone").unwrap();
|
||||||
|
assert_eq!((gone.kind.as_str(), gone.destructive), ("removed", true));
|
||||||
|
assert_eq!(gone.sync_sql[0], "ALTER TABLE \"s\".\"t\" DROP COLUMN \"gone\";");
|
||||||
|
|
||||||
|
let retyped = r.items.iter().find(|i| i.name == "t.retyped").unwrap();
|
||||||
|
assert!(retyped.destructive, "ALTER COLUMN TYPE is destructive per spec");
|
||||||
|
assert!(retyped.sync_sql[0].contains("ALTER COLUMN \"retyped\" TYPE bigint"));
|
||||||
|
|
||||||
|
let tight = r.items.iter().find(|i| i.name == "t.tightened").unwrap();
|
||||||
|
assert!(!tight.destructive);
|
||||||
|
assert!(tight.sync_sql[0].contains("SET NOT NULL"));
|
||||||
|
|
||||||
|
let def = r.items.iter().find(|i| i.name == "t.defchanged").unwrap();
|
||||||
|
assert!(!def.destructive);
|
||||||
|
assert!(def.sync_sql[0].contains("SET DEFAULT 2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constraint_index_fk_view_sequence_enum_diffs() {
|
||||||
|
let mut src = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]);
|
||||||
|
let mut tgt = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]);
|
||||||
|
|
||||||
|
src.tables[0].foreign_keys.push(SnapshotFk {
|
||||||
|
name: "fk_t_ref".into(),
|
||||||
|
columns: vec!["id".into()],
|
||||||
|
ref_schema: "s".into(),
|
||||||
|
ref_table: "other".into(),
|
||||||
|
ref_columns: vec!["id".into()],
|
||||||
|
on_delete: "CASCADE".into(),
|
||||||
|
on_update: "NO ACTION".into(),
|
||||||
|
});
|
||||||
|
src.tables[0].indexes.push(SnapshotIndex { name: "ix_t".into(), columns: vec!["id".into()], unique: false });
|
||||||
|
src.views.push(SnapshotView { schema: "s".into(), name: "v".into(), definition: "SELECT 1".into(), materialized: false });
|
||||||
|
src.sequences.push(SnapshotSequence { schema: "s".into(), name: "seq".into(), start: "1".into(), increment: "1".into(), minimum: "1".into(), maximum: "100".into(), cycle: false });
|
||||||
|
src.enums.push(SnapshotEnum { schema: "s".into(), name: "color".into(), labels: vec!["red".into(), "green".into()] });
|
||||||
|
tgt.enums.push(SnapshotEnum { schema: "s".into(), name: "color".into(), labels: vec!["red".into()] });
|
||||||
|
|
||||||
|
let r = diff_snapshots(&src, &tgt);
|
||||||
|
|
||||||
|
let fk = r.items.iter().find(|i| i.object_type == "constraint" && i.name == "t.fk_t_ref").unwrap();
|
||||||
|
assert!(!fk.destructive);
|
||||||
|
assert!(fk.sync_sql[0].contains("ADD CONSTRAINT \"fk_t_ref\" FOREIGN KEY (\"id\")"));
|
||||||
|
assert!(fk.sync_sql[0].contains("REFERENCES \"s\".\"other\" (\"id\")"));
|
||||||
|
assert!(fk.sync_sql[0].contains("ON DELETE CASCADE"));
|
||||||
|
|
||||||
|
let ix = r.items.iter().find(|i| i.object_type == "index" && i.name == "t.ix_t").unwrap();
|
||||||
|
assert_eq!(ix.sync_sql[0], "CREATE INDEX \"s\".\"ix_t\" ON \"s\".\"t\" (\"id\");");
|
||||||
|
|
||||||
|
let view = r.items.iter().find(|i| i.object_type == "view" && i.name == "v").unwrap();
|
||||||
|
assert!(view.sync_sql[0].starts_with("CREATE VIEW \"s\".\"v\" AS"));
|
||||||
|
|
||||||
|
let seq = r.items.iter().find(|i| i.object_type == "sequence" && i.name == "seq").unwrap();
|
||||||
|
assert!(seq.sync_sql[0].starts_with("CREATE SEQUENCE \"s\".\"seq\""));
|
||||||
|
|
||||||
|
let en = r.items.iter().find(|i| i.object_type == "enum" && i.name == "color").unwrap();
|
||||||
|
assert!(!en.destructive);
|
||||||
|
assert!(en.sync_sql[0].contains("ALTER TYPE \"s\".\"color\" ADD VALUE 'green'"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mysql_family_uses_backticks() {
|
||||||
|
let mut src = SchemaSnapshot { engine: "mariadb".into(), schema: "app".into(), ..Default::default() };
|
||||||
|
src.tables.push(SnapshotTable {
|
||||||
|
schema: "app".into(),
|
||||||
|
name: "users".into(),
|
||||||
|
columns: vec![SnapshotColumn { name: "id".into(), data_type: "int".into(), nullable: false, default_value: None }],
|
||||||
|
primary_key: vec!["id".into()],
|
||||||
|
uniques: vec![],
|
||||||
|
checks: vec![],
|
||||||
|
indexes: vec![],
|
||||||
|
foreign_keys: vec![],
|
||||||
|
});
|
||||||
|
let tgt = SchemaSnapshot { engine: "mysql".into(), schema: "app".into(), ..Default::default() };
|
||||||
|
let r = diff_snapshots(&src, &tgt);
|
||||||
|
let item = r.items.iter().find(|i| i.name == "users").unwrap();
|
||||||
|
assert!(item.sync_sql[0].starts_with("CREATE TABLE `app`.`users`"), "got: {}", item.sync_sql[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_changed_view_is_destructive_note_only() {
|
||||||
|
let mut src = SchemaSnapshot { engine: "sqlite".into(), schema: "main".into(), ..Default::default() };
|
||||||
|
src.views.push(SnapshotView { schema: "main".into(), name: "v".into(), definition: "SELECT 2".into(), materialized: false });
|
||||||
|
let mut tgt = SchemaSnapshot { engine: "sqlite".into(), schema: "main".into(), ..Default::default() };
|
||||||
|
tgt.views.push(SnapshotView { schema: "main".into(), name: "v".into(), definition: "SELECT 1".into(), materialized: false });
|
||||||
|
let r = diff_snapshots(&src, &tgt);
|
||||||
|
let item = r.items.iter().find(|i| i.object_type == "view").unwrap();
|
||||||
|
assert_eq!(item.kind, "changed");
|
||||||
|
assert!(item.destructive);
|
||||||
|
assert!(item.sync_sql.is_empty(), "SQLite cannot CREATE OR REPLACE a view");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identical_snapshots_diff_empty_and_stageable_filter() {
|
||||||
|
let snap = pg_snap(vec![table("t", vec![col("id", "int", false, None)])]);
|
||||||
|
let r = diff_snapshots(&snap, &snap);
|
||||||
|
assert!(r.items.is_empty());
|
||||||
|
assert!(stageable_items(&r).is_empty());
|
||||||
|
|
||||||
|
let mut src2 = pg_snap(vec![]);
|
||||||
|
src2.tables.push(table("new", vec![col("id", "int", false, None)]));
|
||||||
|
src2.tables.push(table("dropme", vec![col("id", "int", false, None)]));
|
||||||
|
let r2 = diff_snapshots(&src2, &snap);
|
||||||
|
assert!(stageable_items(&r2).iter().all(|i| !i.destructive && !i.sync_sql.is_empty()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
//! Pure SQL statement splitter shared by multi-result execution and the
|
||||||
|
//! streaming export (single-statement validation).
|
||||||
|
//!
|
||||||
|
//! A hand-rolled state machine (no new dependencies). It understands:
|
||||||
|
//! single/double/backtick-quoted strings with doubled-quote escapes,
|
||||||
|
//! `--` line comments, `/* */` block comments, and PostgreSQL dollar-quoted
|
||||||
|
//! bodies (`$$…$$`, `$tag$…$tag$`). Conservative by design: anything it
|
||||||
|
//! cannot confidently close is kept inside the current statement rather
|
||||||
|
//! than split — a naive semicolon split that could execute fragmentary SQL
|
||||||
|
//! is unacceptable (spec §2).
|
||||||
|
//!
|
||||||
|
//! Statement classification (SELECT vs DML vs DDL) happens at execution
|
||||||
|
//! time, not here.
|
||||||
|
|
||||||
|
/// Split `sql` into ordered statement texts. Comment-only and empty
|
||||||
|
/// fragments are dropped. Statement text keeps its comments verbatim.
|
||||||
|
pub fn split_statements(sql: &str) -> Vec<String> {
|
||||||
|
let chars: Vec<char> = sql.chars().collect();
|
||||||
|
let mut stmts: Vec<String> = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut i = 0usize;
|
||||||
|
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
match c {
|
||||||
|
'\'' | '"' | '`' => {
|
||||||
|
copy_quoted(&chars, &mut i, &mut current, c);
|
||||||
|
}
|
||||||
|
'-' if peek(&chars, i + 1) == Some('-') => {
|
||||||
|
copy_until(&chars, &mut i, &mut current, |ch| ch == '\n');
|
||||||
|
}
|
||||||
|
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||||
|
copy_block_comment(&chars, &mut i, &mut current);
|
||||||
|
}
|
||||||
|
'$' => {
|
||||||
|
if let Some(end) = dollar_quote_end(&chars, i) {
|
||||||
|
for _ in i..end {
|
||||||
|
if i < chars.len() {
|
||||||
|
current.push(chars[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.push(c);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
';' => {
|
||||||
|
if has_executable_text(¤t) {
|
||||||
|
stmts.push(current.trim().to_string());
|
||||||
|
}
|
||||||
|
current.clear();
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
current.push(c);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_executable_text(¤t) {
|
||||||
|
stmts.push(current.trim().to_string());
|
||||||
|
}
|
||||||
|
stmts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek(chars: &[char], idx: usize) -> Option<char> {
|
||||||
|
chars.get(idx).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a quoted region starting at the opening `quote` char. Doubled quotes
|
||||||
|
/// are escapes and stay inside the region. Unterminated quotes consume the
|
||||||
|
/// rest of the input (conservative).
|
||||||
|
fn copy_quoted(chars: &[char], i: &mut usize, out: &mut String, quote: char) {
|
||||||
|
out.push(chars[*i]);
|
||||||
|
*i += 1;
|
||||||
|
while *i < chars.len() {
|
||||||
|
let c = chars[*i];
|
||||||
|
out.push(c);
|
||||||
|
*i += 1;
|
||||||
|
if c == quote {
|
||||||
|
if peek(chars, *i) == Some(quote) {
|
||||||
|
// doubled quote — escaped, keep it and continue
|
||||||
|
out.push(quote);
|
||||||
|
*i += 1;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy characters until `stop` matches (inclusive for `\n` handled by the
|
||||||
|
/// caller's predicate) or input ends.
|
||||||
|
fn copy_until(chars: &[char], i: &mut usize, out: &mut String, stop: impl Fn(char) -> bool) {
|
||||||
|
while *i < chars.len() {
|
||||||
|
let c = chars[*i];
|
||||||
|
out.push(c);
|
||||||
|
*i += 1;
|
||||||
|
if stop(c) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy a `/* … */` block comment (no nesting per the SQL standard).
|
||||||
|
fn copy_block_comment(chars: &[char], i: &mut usize, out: &mut String) {
|
||||||
|
out.push(chars[*i]); // '/'
|
||||||
|
*i += 1;
|
||||||
|
if *i < chars.len() {
|
||||||
|
out.push(chars[*i]); // '*'
|
||||||
|
*i += 1;
|
||||||
|
}
|
||||||
|
while *i < chars.len() {
|
||||||
|
let c = chars[*i];
|
||||||
|
out.push(c);
|
||||||
|
*i += 1;
|
||||||
|
if c == '*' && peek(chars, *i) == Some('/') {
|
||||||
|
out.push('/');
|
||||||
|
*i += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At `chars[start] == '$'`, try to match `$[A-Za-z_][A-Za-z0-9_]*$` and find
|
||||||
|
/// the matching closing delimiter. Returns the index one past the closing
|
||||||
|
/// delimiter, or None if this is not a well-formed (and closable) dollar
|
||||||
|
/// quote — in which case the `$` is ordinary text.
|
||||||
|
fn dollar_quote_end(chars: &[char], start: usize) -> Option<usize> {
|
||||||
|
let mut j = start + 1;
|
||||||
|
if j < chars.len() && chars[j] == '$' {
|
||||||
|
// bare $$ body
|
||||||
|
let tag = String::new();
|
||||||
|
return find_dollar_close(chars, j + 1, &tag);
|
||||||
|
}
|
||||||
|
let mut tag = String::new();
|
||||||
|
while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') {
|
||||||
|
tag.push(chars[j]);
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if tag.is_empty() || j >= chars.len() || chars[j] != '$' {
|
||||||
|
return None; // not $tag$
|
||||||
|
}
|
||||||
|
find_dollar_close(chars, j + 1, &tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_dollar_close(chars: &[char], from: usize, tag: &str) -> Option<usize> {
|
||||||
|
let closing: Vec<char> = format!("${tag}$").chars().collect();
|
||||||
|
let mut j = from;
|
||||||
|
while j + closing.len() <= chars.len() {
|
||||||
|
if chars[j..j + closing.len()] == closing[..] {
|
||||||
|
return Some(j + closing.len());
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the fragment contains anything other than whitespace and comments
|
||||||
|
/// (comments are preserved in statement text, but a fragment that is ONLY
|
||||||
|
/// comments/whitespace is not a statement).
|
||||||
|
pub fn has_executable_text(stmt: &str) -> bool {
|
||||||
|
let chars: Vec<char> = stmt.chars().collect();
|
||||||
|
let mut i = 0usize;
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
match c {
|
||||||
|
'\'' | '"' | '`' => {
|
||||||
|
let mut probe = String::new();
|
||||||
|
let mut j = i;
|
||||||
|
copy_quoted(&chars, &mut j, &mut probe, c);
|
||||||
|
return true; // a quoted region IS executable text
|
||||||
|
}
|
||||||
|
'-' if peek(&chars, i + 1) == Some('-') => {
|
||||||
|
skip_until(&chars, &mut i, |ch| ch == '\n');
|
||||||
|
}
|
||||||
|
'/' if peek(&chars, i + 1) == Some('*') => {
|
||||||
|
skip_block_comment(&chars, &mut i);
|
||||||
|
}
|
||||||
|
c if c.is_whitespace() => i += 1,
|
||||||
|
_ => return true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_until(chars: &[char], i: &mut usize, stop: impl Fn(char) -> bool) {
|
||||||
|
while *i < chars.len() {
|
||||||
|
let c = chars[*i];
|
||||||
|
*i += 1;
|
||||||
|
if stop(c) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn skip_block_comment(chars: &[char], i: &mut usize) {
|
||||||
|
*i += 2; // "/*"
|
||||||
|
while *i < chars.len() {
|
||||||
|
if chars[*i] == '*' && peek(chars, *i + 1) == Some('/') {
|
||||||
|
*i += 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "sql_split.test.rs"]
|
||||||
|
mod sql_split_tests;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splits_two_statements() {
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT 1; SELECT 2"),
|
||||||
|
vec!["SELECT 1", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_trailing_semicolon_is_fine() {
|
||||||
|
assert_eq!(split_statements("SELECT 1"), vec!["SELECT 1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semicolon_inside_string_is_not_a_boundary() {
|
||||||
|
assert_eq!(split_statements("SELECT 'a;b'"), vec!["SELECT 'a;b'"]);
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT 'a;b'; SELECT 2"),
|
||||||
|
vec!["SELECT 'a;b'", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn doubled_quote_escapes() {
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT 'it''s'; SELECT 2"),
|
||||||
|
vec!["SELECT 'it''s'", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_quoted_and_backtick_identifiers() {
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT \"a;b\"; SELECT `x;y`"),
|
||||||
|
vec!["SELECT \"a;b\"", "SELECT `x;y`"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT `a``b`; SELECT 2"),
|
||||||
|
vec!["SELECT `a``b`", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_comments_do_not_break_statements() {
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT 1 -- ; not a boundary\n; SELECT 2"),
|
||||||
|
vec!["SELECT 1 -- ; not a boundary", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn block_comments_do_not_break_statements() {
|
||||||
|
assert_eq!(split_statements("SELECT /* ; */ 1"), vec!["SELECT /* ; */ 1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dollar_quoted_bodies() {
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT $$a;b$$; SELECT 2"),
|
||||||
|
vec!["SELECT $$a;b$$", "SELECT 2"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
split_statements("SELECT $fn$x;y$fn$; SELECT 2"),
|
||||||
|
vec!["SELECT $fn$x;y$fn$", "SELECT 2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unclosed_dollar_is_literal() {
|
||||||
|
assert_eq!(split_statements("SELECT a$b"), vec!["SELECT a$b"]);
|
||||||
|
assert_eq!(split_statements("SELECT 1$"), vec!["SELECT 1$"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_only_statements_are_skipped() {
|
||||||
|
assert_eq!(split_statements("-- nothing\n; SELECT 1"), vec!["SELECT 1"]);
|
||||||
|
assert_eq!(split_statements("/* c */"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_whitespace_input() {
|
||||||
|
assert_eq!(split_statements(""), Vec::<String>::new());
|
||||||
|
assert_eq!(split_statements(" ; ; "), Vec::<String>::new());
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
//! Keychain ACL normalization for stored secrets (macOS).
|
||||||
|
//!
|
||||||
|
//! # Why this exists
|
||||||
|
//!
|
||||||
|
//! Generic-password items stored via the legacy keychain API get a default ACL
|
||||||
|
//! that grants access to "the application that created the item". When a Tauri
|
||||||
|
//! app is rebuilt, its code signature changes (dev builds are ad-hoc signed, so
|
||||||
|
//! the identity differs on every build), and the new binary no longer matches
|
||||||
|
//! the item's ACL — macOS then shows the "Gridline wants to access your
|
||||||
|
//! keychain" prompt.
|
||||||
|
//!
|
||||||
|
//! The fix: after storing a secret, replace the item's ACL with an explicit
|
||||||
|
//! `SecAccess` whose trusted-application list contains exactly *this app*,
|
||||||
|
//! resolved by `SecTrustedApplicationCreateFromPath(NULL)` — the current
|
||||||
|
//! process's code-signature requirement. For a Developer ID-signed release
|
||||||
|
//! build that requirement is stable across versions (anchored to the signing
|
||||||
|
//! certificate), so items stay accessible without prompts through upgrades.
|
||||||
|
//!
|
||||||
|
//! # Prompt behavior
|
||||||
|
//!
|
||||||
|
//! - Item created by this build → the ACL set is silent.
|
||||||
|
//! - Item created by an older/differently-signed build → macOS shows the
|
||||||
|
//! standard one-time authorization dialog; "Always Allow" replaces the ACL
|
||||||
|
//! and the prompt never reappears (the same dialog the app would show on the
|
||||||
|
//! first read anyway — this just makes the result permanent and clean).
|
||||||
|
//!
|
||||||
|
//! All operations are best-effort: `after_store` swallows errors so keychain
|
||||||
|
//! writes never fail because of ACL normalization.
|
||||||
|
|
||||||
|
/// Best-effort ACL normalization after a successful keychain write.
|
||||||
|
///
|
||||||
|
/// Resolves the service name the same way `tauri-plugin-keyring-store` does
|
||||||
|
/// (the Tauri bundle identifier) and never fails the caller's operation.
|
||||||
|
pub fn after_store(app: &tauri::AppHandle, account: &str) {
|
||||||
|
let service = app.config().identifier.clone();
|
||||||
|
if let Err(e) = ensure_item_acl(&service, account) {
|
||||||
|
eprintln!("[keychain_acl] skipped for {account}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grant this app (by its code-signature requirement) access to a keychain item.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn ensure_item_acl(service: &str, account: &str) -> Result<(), String> {
|
||||||
|
use core_foundation::array::CFArray;
|
||||||
|
use core_foundation::base::{CFTypeRef, TCFType};
|
||||||
|
use core_foundation::string::CFString;
|
||||||
|
use security_framework::item::{ItemClass, ItemSearchOptions, Reference, SearchResult};
|
||||||
|
use security_framework::os::macos::access::SecAccess;
|
||||||
|
use security_framework_sys::base::SecAccessRef;
|
||||||
|
|
||||||
|
// Locate the item WITHOUT reading its secret: a ref-only lookup
|
||||||
|
// (`kSecReturnRef`, no data/attributes) never triggers an authorization
|
||||||
|
// prompt, so merely probing an item we can't yet access is safe.
|
||||||
|
let found = ItemSearchOptions::new()
|
||||||
|
.class(ItemClass::generic_password())
|
||||||
|
.service(service)
|
||||||
|
.account(account)
|
||||||
|
.load_refs(true)
|
||||||
|
.load_data(false)
|
||||||
|
.load_attributes(false)
|
||||||
|
.search()
|
||||||
|
.map_err(|e| format!("keychain item lookup failed: {e}"))?;
|
||||||
|
|
||||||
|
let item = match found.first() {
|
||||||
|
Some(SearchResult::Ref(Reference::KeychainItem(item))) => item,
|
||||||
|
// No item (yet) or an unexpected result shape — nothing to normalize.
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
fn cvt(status: i32) -> Result<(), String> {
|
||||||
|
if status == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("security framework error {status}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an access object whose trusted list is exactly this app. A NULL
|
||||||
|
// path resolves the trusted application to the current process's code
|
||||||
|
// signature requirement.
|
||||||
|
unsafe {
|
||||||
|
let mut trusted_app_ref: *mut OpaqueSecTrustedApplication = std::ptr::null_mut();
|
||||||
|
cvt(SecTrustedApplicationCreateFromPath(std::ptr::null(), &mut trusted_app_ref))?;
|
||||||
|
let trusted_app = SecTrustedApplication::wrap_under_create_rule(trusted_app_ref);
|
||||||
|
let trusted_list = CFArray::from_CFTypes(&[trusted_app]);
|
||||||
|
|
||||||
|
let descriptor = CFString::new("Gridline");
|
||||||
|
let mut access_ref: SecAccessRef = std::ptr::null_mut();
|
||||||
|
cvt(SecAccessCreate(
|
||||||
|
descriptor.as_concrete_TypeRef() as CFTypeRef,
|
||||||
|
trusted_list.as_concrete_TypeRef() as CFTypeRef,
|
||||||
|
&mut access_ref,
|
||||||
|
))?;
|
||||||
|
let access = SecAccess::wrap_under_create_rule(access_ref);
|
||||||
|
|
||||||
|
// Replace the item's ACL. Silent when we already own the item;
|
||||||
|
// one-time authorization dialog when the item was created by a
|
||||||
|
// differently-signed build.
|
||||||
|
cvt(SecKeychainItemSetAccess(
|
||||||
|
item.as_concrete_TypeRef(),
|
||||||
|
access.as_concrete_TypeRef(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-macOS platforms have no keychain ACL concept — nothing to do.
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub fn ensure_item_acl(_service: &str, _account: &str) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macos_ffi {
|
||||||
|
use core_foundation::base::{CFTypeRef, CFTypeID};
|
||||||
|
|
||||||
|
/// Minimal `TCFType` wrapper for `SecTrustedApplication` — the
|
||||||
|
/// security-framework crate exposes no high-level API for it.
|
||||||
|
#[repr(C)]
|
||||||
|
pub struct OpaqueSecTrustedApplication {
|
||||||
|
_priv: [u8; 0],
|
||||||
|
}
|
||||||
|
pub type SecTrustedApplicationRef = *mut OpaqueSecTrustedApplication;
|
||||||
|
|
||||||
|
use core_foundation::{declare_TCFType, impl_TCFType};
|
||||||
|
declare_TCFType!(SecTrustedApplication, SecTrustedApplicationRef);
|
||||||
|
impl_TCFType!(
|
||||||
|
SecTrustedApplication,
|
||||||
|
SecTrustedApplicationRef,
|
||||||
|
SecTrustedApplicationGetTypeID
|
||||||
|
);
|
||||||
|
unsafe impl Send for SecTrustedApplication {}
|
||||||
|
unsafe impl Sync for SecTrustedApplication {}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
// SecAccess/SecTrustedApplication APIs are deprecated in the macOS SDK
|
||||||
|
// but remain fully functional (the legacy keychain store this app uses
|
||||||
|
// via tauri-plugin-keyring-store relies on the same API family).
|
||||||
|
pub fn SecTrustedApplicationGetTypeID() -> CFTypeID;
|
||||||
|
pub fn SecTrustedApplicationCreateFromPath(
|
||||||
|
path: CFTypeRef,
|
||||||
|
app: *mut SecTrustedApplicationRef,
|
||||||
|
) -> i32;
|
||||||
|
pub fn SecAccessCreate(
|
||||||
|
descriptor: CFTypeRef,
|
||||||
|
trustedlist: CFTypeRef,
|
||||||
|
access: *mut security_framework_sys::base::SecAccessRef,
|
||||||
|
) -> i32;
|
||||||
|
pub fn SecKeychainItemSetAccess(
|
||||||
|
item: security_framework_sys::base::SecKeychainItemRef,
|
||||||
|
access: security_framework_sys::base::SecAccessRef,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use macos_ffi::{
|
||||||
|
OpaqueSecTrustedApplication, SecAccessCreate, SecKeychainItemSetAccess, SecTrustedApplication,
|
||||||
|
SecTrustedApplicationCreateFromPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(all(test, target_os = "macos"))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use security_framework::os::macos::keychain::SecKeychain;
|
||||||
|
use security_framework::os::macos::passwords::find_generic_password;
|
||||||
|
|
||||||
|
/// Real-keychain integration test: creates a generic-password item the
|
||||||
|
/// same way the keyring store does, normalizes its ACL, verifies the
|
||||||
|
/// secret is still readable, and cleans up.
|
||||||
|
///
|
||||||
|
/// Ignored by default (`cargo test`) so CI/dev runs never touch the login
|
||||||
|
/// keychain. Run explicitly with:
|
||||||
|
/// `cargo test -- --ignored keychain_acl`
|
||||||
|
#[test]
|
||||||
|
#[ignore = "touches the real login keychain"]
|
||||||
|
fn keychain_acl_normalizes_item() {
|
||||||
|
let keychain = SecKeychain::default().expect("default keychain");
|
||||||
|
let service = "com.adrianbonpin.gridline.acl-test";
|
||||||
|
let account = format!("acl-test-{}", std::process::id());
|
||||||
|
|
||||||
|
// Create the item exactly like apple-native-keyring-store does
|
||||||
|
// (legacy SecKeychain generic-password API).
|
||||||
|
keychain
|
||||||
|
.set_generic_password(service, &account, b"test-secret")
|
||||||
|
.expect("create test item");
|
||||||
|
|
||||||
|
// ACL normalization must succeed on an item we just created.
|
||||||
|
ensure_item_acl(service, &account).expect("normalize ACL");
|
||||||
|
|
||||||
|
// The item must remain readable with the correct secret.
|
||||||
|
let (password, item) =
|
||||||
|
find_generic_password(Some(&[keychain]), service, &account).expect("read back");
|
||||||
|
assert_eq!(password.as_ref(), b"test-secret");
|
||||||
|
|
||||||
|
// Cleanup: delete the test item.
|
||||||
|
item.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
mod cancel;
|
mod cancel;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod keychain_acl;
|
||||||
mod models;
|
mod models;
|
||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ pub struct AppState {
|
|||||||
|
|
||||||
use commands::{
|
use commands::{
|
||||||
backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
|
backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query,
|
||||||
schema_graph, settings, tags,
|
query_export, schema_diff, schema_graph, settings, tags,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||||
@@ -135,6 +136,7 @@ pub fn run() {
|
|||||||
db_viewer::get_extensions,
|
db_viewer::get_extensions,
|
||||||
db_viewer::get_indexes,
|
db_viewer::get_indexes,
|
||||||
db_viewer::get_constraints,
|
db_viewer::get_constraints,
|
||||||
|
db_viewer::get_hypertables,
|
||||||
objects::create_schema,
|
objects::create_schema,
|
||||||
objects::rename_schema,
|
objects::rename_schema,
|
||||||
objects::drop_schema,
|
objects::drop_schema,
|
||||||
@@ -171,7 +173,10 @@ pub fn run() {
|
|||||||
settings::export_settings,
|
settings::export_settings,
|
||||||
settings::import_settings,
|
settings::import_settings,
|
||||||
schema_graph::get_schema_graph,
|
schema_graph::get_schema_graph,
|
||||||
|
schema_diff::compare_schemas,
|
||||||
query::execute_query,
|
query::execute_query,
|
||||||
|
query::execute_query_multi,
|
||||||
|
query_export::export_query_to_file,
|
||||||
query::cancel_query,
|
query::cancel_query,
|
||||||
query::get_query_history,
|
query::get_query_history,
|
||||||
query::clear_query_history,
|
query::clear_query_history,
|
||||||
|
|||||||
@@ -72,6 +72,22 @@ pub struct QueryResult {
|
|||||||
pub execution_time_ms: Option<i64>,
|
pub execution_time_ms: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StatementNotice {
|
||||||
|
pub statement_index: usize,
|
||||||
|
/// "dml" | "ddl" | "error"
|
||||||
|
pub kind: String,
|
||||||
|
pub text: String,
|
||||||
|
pub affected: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MultiQueryResult {
|
||||||
|
pub result_sets: Vec<QueryResult>,
|
||||||
|
pub notices: Vec<StatementNotice>,
|
||||||
|
pub execution_time_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Pagination {
|
pub struct Pagination {
|
||||||
pub page: i64,
|
pub page: i64,
|
||||||
@@ -132,6 +148,23 @@ pub struct ExtensionInfo {
|
|||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HypertableInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub schema: String,
|
||||||
|
pub num_dimensions: i64,
|
||||||
|
pub compression_enabled: bool,
|
||||||
|
pub num_chunks: i64,
|
||||||
|
pub total_size_bytes: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct HypertableListResponse {
|
||||||
|
pub available: bool,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub items: Vec<HypertableInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ObjectSearchHit {
|
pub struct ObjectSearchHit {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -312,6 +345,125 @@ pub struct Relationship {
|
|||||||
pub cardinality: String,
|
pub cardinality: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schema diff (0.8.0)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct SnapshotColumn {
|
||||||
|
pub name: String,
|
||||||
|
pub data_type: String,
|
||||||
|
pub nullable: bool,
|
||||||
|
pub default_value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct SnapshotIndex {
|
||||||
|
pub name: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub unique: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct SnapshotFk {
|
||||||
|
pub name: String,
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub ref_schema: String,
|
||||||
|
pub ref_table: String,
|
||||||
|
pub ref_columns: Vec<String>,
|
||||||
|
pub on_delete: String,
|
||||||
|
pub on_update: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct SnapshotConstraint {
|
||||||
|
pub name: String,
|
||||||
|
/// "unique" | "check"
|
||||||
|
pub kind: String,
|
||||||
|
/// check: expression; unique: comma-joined columns
|
||||||
|
pub definition: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct SnapshotTable {
|
||||||
|
pub schema: String,
|
||||||
|
pub name: String,
|
||||||
|
pub columns: Vec<SnapshotColumn>,
|
||||||
|
pub primary_key: Vec<String>,
|
||||||
|
pub uniques: Vec<SnapshotConstraint>,
|
||||||
|
pub checks: Vec<SnapshotConstraint>,
|
||||||
|
pub indexes: Vec<SnapshotIndex>,
|
||||||
|
pub foreign_keys: Vec<SnapshotFk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct SnapshotView {
|
||||||
|
pub schema: String,
|
||||||
|
pub name: String,
|
||||||
|
pub definition: String,
|
||||||
|
pub materialized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct SnapshotSequence {
|
||||||
|
pub schema: String,
|
||||||
|
pub name: String,
|
||||||
|
pub start: String,
|
||||||
|
pub increment: String,
|
||||||
|
pub minimum: String,
|
||||||
|
pub maximum: String,
|
||||||
|
pub cycle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct SnapshotEnum {
|
||||||
|
pub schema: String,
|
||||||
|
pub name: String,
|
||||||
|
pub labels: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct SchemaSnapshot {
|
||||||
|
/// "postgresql" | "mysql" | "mariadb" | "sqlite" — the CONNECTION's db_type
|
||||||
|
pub engine: String,
|
||||||
|
pub schema: String,
|
||||||
|
pub tables: Vec<SnapshotTable>,
|
||||||
|
pub views: Vec<SnapshotView>,
|
||||||
|
pub sequences: Vec<SnapshotSequence>,
|
||||||
|
pub enums: Vec<SnapshotEnum>,
|
||||||
|
pub object_count: usize,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct DiffDetailLine {
|
||||||
|
pub label: String,
|
||||||
|
pub old: String,
|
||||||
|
pub new: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct DiffItem {
|
||||||
|
/// "table" | "column" | "constraint" | "index" | "view" | "sequence" | "enum"
|
||||||
|
pub object_type: String,
|
||||||
|
/// For column/constraint/index items: "<table>.<name>"; else the object name
|
||||||
|
pub name: String,
|
||||||
|
/// "added" | "removed" | "changed"
|
||||||
|
pub kind: String,
|
||||||
|
pub detail: Vec<DiffDetailLine>,
|
||||||
|
/// Target-side statements (usually exactly one per item)
|
||||||
|
pub sync_sql: Vec<String>,
|
||||||
|
pub destructive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DiffReport {
|
||||||
|
pub items: Vec<DiffItem>,
|
||||||
|
pub source_label: String,
|
||||||
|
pub target_label: String,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Gridline",
|
"productName": "Gridline",
|
||||||
"version": "0.7.12",
|
"version": "0.8.0",
|
||||||
"identifier": "com.adrianbonpin.gridline",
|
"identifier": "com.adrianbonpin.gridline",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
|
"resources": ["resources/pg_tools/*", "resources/mysql_tools/*"],
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"signingIdentity": "-",
|
"signingIdentity": "-",
|
||||||
"hardenedRuntime": false
|
"hardenedRuntime": true,
|
||||||
|
"entitlements": "entitlements.plist"
|
||||||
},
|
},
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData";
|
|||||||
export interface DetailedConnectionFormProps {
|
export interface DetailedConnectionFormProps {
|
||||||
form: ConnectionFormData;
|
form: ConnectionFormData;
|
||||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||||
managedPreset?: "supabase" | "neon" | null;
|
managedPreset?: "supabase" | "neon" | "planetscale" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
|
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { ConnectionFormData } from "./connectionFormData";
|
|||||||
export interface GeneralTabProps {
|
export interface GeneralTabProps {
|
||||||
form: ConnectionFormData;
|
form: ConnectionFormData;
|
||||||
onChange: (updates: Partial<ConnectionFormData>) => void;
|
onChange: (updates: Partial<ConnectionFormData>) => void;
|
||||||
managedPreset?: "supabase" | "neon" | null;
|
managedPreset?: "supabase" | "neon" | "planetscale" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTH_OPTIONS = ["User & Password"];
|
const AUTH_OPTIONS = ["User & Password"];
|
||||||
@@ -32,7 +32,7 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
|
|||||||
)}
|
)}
|
||||||
{managedPreset && (
|
{managedPreset && (
|
||||||
<p className="text-xs text-accent-muted mt-1.5">
|
<p className="text-xs text-accent-muted mt-1.5">
|
||||||
{managedPreset === "supabase" ? "Supabase" : "NeonDB"} requires SSL — enable it under SSH / SSL.
|
{managedPreset === "supabase" ? "Supabase" : managedPreset === "planetscale" ? "PlanetScale" : "NeonDB"} requires SSL — enable it under SSH / SSL.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type Stage = "entry" | "configured";
|
|||||||
const FALLBACK_PORTS: Record<string, number> = {
|
const FALLBACK_PORTS: Record<string, number> = {
|
||||||
postgresql: 5432,
|
postgresql: 5432,
|
||||||
mysql: 3306,
|
mysql: 3306,
|
||||||
|
mariadb: 3306,
|
||||||
redis: 6379,
|
redis: 6379,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ export function NewConnectionScreen({
|
|||||||
}: NewConnectionScreenProps) {
|
}: NewConnectionScreenProps) {
|
||||||
const [stage, setStage] = useState<Stage>("entry");
|
const [stage, setStage] = useState<Stage>("entry");
|
||||||
const [managedPreset, setManagedPreset] = useState<
|
const [managedPreset, setManagedPreset] = useState<
|
||||||
"supabase" | "neon" | null
|
"supabase" | "neon" | "planetscale" | null
|
||||||
>(null);
|
>(null);
|
||||||
const [form, setForm] = useState<ConnectionFormData>(() =>
|
const [form, setForm] = useState<ConnectionFormData>(() =>
|
||||||
createEmptyForm(
|
createEmptyForm(
|
||||||
@@ -98,7 +99,7 @@ export function NewConnectionScreen({
|
|||||||
const parsed = parseConnectionString(value);
|
const parsed = parseConnectionString(value);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const provider =
|
const provider =
|
||||||
parsed.db_type === "postgresql"
|
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
||||||
? detectProviderFromHost(parsed.host)
|
? detectProviderFromHost(parsed.host)
|
||||||
: null;
|
: null;
|
||||||
setManagedPreset(provider);
|
setManagedPreset(provider);
|
||||||
@@ -138,7 +139,9 @@ export function NewConnectionScreen({
|
|||||||
(id: ProviderId) => {
|
(id: ProviderId) => {
|
||||||
const provider = getProviderById(id)!;
|
const provider = getProviderById(id)!;
|
||||||
setManagedPreset(
|
setManagedPreset(
|
||||||
provider.isManagedPreset ? (id as "supabase" | "neon") : null,
|
provider.isManagedPreset
|
||||||
|
? (id as "supabase" | "neon" | "planetscale")
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
revealConfigured({
|
revealConfigured({
|
||||||
db_type: provider.dbType,
|
db_type: provider.dbType,
|
||||||
@@ -147,6 +150,7 @@ export function NewConnectionScreen({
|
|||||||
? null
|
? null
|
||||||
: getDefaultPort(provider.dbType),
|
: getDefaultPort(provider.dbType),
|
||||||
...(provider.dbType === "sqlite" ? { host: "" } : {}),
|
...(provider.dbType === "sqlite" ? { host: "" } : {}),
|
||||||
|
...(id === "planetscale" ? { ssl_mode: "verify-full" } : {}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[revealConfigured],
|
[revealConfigured],
|
||||||
@@ -163,7 +167,7 @@ export function NewConnectionScreen({
|
|||||||
const parsed = parseConnectionString(value);
|
const parsed = parseConnectionString(value);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
const provider =
|
const provider =
|
||||||
parsed.db_type === "postgresql"
|
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
|
||||||
? detectProviderFromHost(parsed.host)
|
? detectProviderFromHost(parsed.host)
|
||||||
: null;
|
: null;
|
||||||
setManagedPreset(provider);
|
setManagedPreset(provider);
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { ChevronDown } from "lucide-react";
|
|||||||
import { SETUP_GUIDES } from "../../lib/providers";
|
import { SETUP_GUIDES } from "../../lib/providers";
|
||||||
|
|
||||||
interface ProviderSetupGuideProps {
|
interface ProviderSetupGuideProps {
|
||||||
provider: "supabase" | "neon";
|
provider: "supabase" | "neon" | "planetscale";
|
||||||
}
|
}
|
||||||
|
|
||||||
const SSL_NOTE: Record<"supabase" | "neon", string> = {
|
const SSL_NOTE: Record<"supabase" | "neon" | "planetscale", string> = {
|
||||||
supabase: "SSL is required by Supabase.",
|
supabase: "SSL is required by Supabase.",
|
||||||
neon: "Neon requires SSL.",
|
neon: "Neon requires SSL.",
|
||||||
|
planetscale: "PlanetScale requires SSL with identity verification.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) {
|
export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function ProviderTabsGrid({ selectedId, onSelect }: ProviderTabsGridProps
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="w-8 h-8 flex items-center justify-center">
|
<span className="w-8 h-8 flex items-center justify-center">
|
||||||
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
|
{p.isManagedPreset ? <ProviderIcon id={p.id as "supabase" | "neon" | "planetscale"} size={24} /> : <DbIcon type={p.dbType} size={24} />}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium">{p.label}</span>
|
<span className="text-sm font-medium">{p.label}</span>
|
||||||
{p.isManagedPreset && (
|
{p.isManagedPreset && (
|
||||||
|
|||||||
@@ -281,8 +281,8 @@ describe("DbViewerScreen", () => {
|
|||||||
|
|
||||||
it("executes a non-destructive query when Run is clicked", async () => {
|
it("executes a non-destructive query when Run is clicked", async () => {
|
||||||
const executeQuery = vi
|
const executeQuery = vi
|
||||||
.spyOn(commands, "executeQuery")
|
.spyOn(commands, "executeQueryMulti")
|
||||||
.mockResolvedValue(mockQueryResult as any);
|
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
|
||||||
render(
|
render(
|
||||||
<DbViewerScreen
|
<DbViewerScreen
|
||||||
connectionId="c1"
|
connectionId="c1"
|
||||||
@@ -306,13 +306,11 @@ describe("DbViewerScreen", () => {
|
|||||||
|
|
||||||
it("shows a destructive-query confirmation dialog and executes on confirm", async () => {
|
it("shows a destructive-query confirmation dialog and executes on confirm", async () => {
|
||||||
const executeQuery = vi
|
const executeQuery = vi
|
||||||
.spyOn(commands, "executeQuery")
|
.spyOn(commands, "executeQueryMulti")
|
||||||
.mockResolvedValue({
|
.mockResolvedValue({
|
||||||
columns: [],
|
result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }],
|
||||||
rows: [],
|
notices: [],
|
||||||
total_rows: 0,
|
execution_time_ms: 42,
|
||||||
page: 1,
|
|
||||||
page_size: 50,
|
|
||||||
} as any);
|
} as any);
|
||||||
render(
|
render(
|
||||||
<DbViewerScreen
|
<DbViewerScreen
|
||||||
@@ -345,8 +343,8 @@ describe("DbViewerScreen", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes the schema tree after a DDL query runs", async () => {
|
it("refreshes the schema tree after a DDL query runs", async () => {
|
||||||
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
|
||||||
mockQueryResult as any,
|
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
|
||||||
);
|
);
|
||||||
const getSchemas = vi
|
const getSchemas = vi
|
||||||
.spyOn(commands, "getSchemas")
|
.spyOn(commands, "getSchemas")
|
||||||
@@ -377,8 +375,8 @@ describe("DbViewerScreen", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not refresh the schema tree after a SELECT", async () => {
|
it("does not refresh the schema tree after a SELECT", async () => {
|
||||||
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
|
||||||
mockQueryResult as any,
|
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
|
||||||
);
|
);
|
||||||
const getSchemas = vi
|
const getSchemas = vi
|
||||||
.spyOn(commands, "getSchemas")
|
.spyOn(commands, "getSchemas")
|
||||||
@@ -396,7 +394,7 @@ describe("DbViewerScreen", () => {
|
|||||||
);
|
);
|
||||||
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
|
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
|
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
|
||||||
await waitFor(() => expect(commands.executeQuery).toHaveBeenCalled());
|
await waitFor(() => expect(commands.executeQueryMulti).toHaveBeenCalled());
|
||||||
expect(getSchemas).not.toHaveBeenCalled();
|
expect(getSchemas).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -423,8 +421,8 @@ describe("DbViewerScreen", () => {
|
|||||||
|
|
||||||
it("runs the current query when the Cmd+Enter action fires", async () => {
|
it("runs the current query when the Cmd+Enter action fires", async () => {
|
||||||
const executeQuery = vi
|
const executeQuery = vi
|
||||||
.spyOn(commands, "executeQuery")
|
.spyOn(commands, "executeQueryMulti")
|
||||||
.mockResolvedValue(mockQueryResult as any);
|
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
|
||||||
render(
|
render(
|
||||||
<DbViewerScreen
|
<DbViewerScreen
|
||||||
connectionId="c1"
|
connectionId="c1"
|
||||||
@@ -452,7 +450,7 @@ describe("DbViewerScreen", () => {
|
|||||||
const pending = new Promise<unknown>((r) => {
|
const pending = new Promise<unknown>((r) => {
|
||||||
resolveRun = r;
|
resolveRun = r;
|
||||||
});
|
});
|
||||||
vi.spyOn(commands, "executeQuery").mockReturnValue(pending as any);
|
vi.spyOn(commands, "executeQueryMulti").mockReturnValue(pending as any);
|
||||||
render(
|
render(
|
||||||
<DbViewerScreen
|
<DbViewerScreen
|
||||||
connectionId="c1"
|
connectionId="c1"
|
||||||
@@ -469,7 +467,7 @@ describe("DbViewerScreen", () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(),
|
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
resolveRun(mockQueryResult);
|
resolveRun({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 });
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByTestId("query-run-pulse")).toBeNull(),
|
expect(screen.queryByTestId("query-run-pulse")).toBeNull(),
|
||||||
);
|
);
|
||||||
@@ -477,8 +475,8 @@ describe("DbViewerScreen", () => {
|
|||||||
|
|
||||||
it("collapses and re-expands the query results via the caret", async () => {
|
it("collapses and re-expands the query results via the caret", async () => {
|
||||||
const executeQuery = vi
|
const executeQuery = vi
|
||||||
.spyOn(commands, "executeQuery")
|
.spyOn(commands, "executeQueryMulti")
|
||||||
.mockResolvedValue(mockQueryResult as any);
|
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
|
||||||
render(
|
render(
|
||||||
<DbViewerScreen
|
<DbViewerScreen
|
||||||
connectionId="c1"
|
connectionId="c1"
|
||||||
@@ -505,8 +503,8 @@ describe("DbViewerScreen", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resizes the results panel with a drag handle, clamped to min/max", async () => {
|
it("resizes the results panel with a drag handle, clamped to min/max", async () => {
|
||||||
vi.spyOn(commands, "executeQuery").mockResolvedValue(
|
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
|
||||||
mockQueryResult as any,
|
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
|
||||||
);
|
);
|
||||||
render(
|
render(
|
||||||
<DbViewerScreen
|
<DbViewerScreen
|
||||||
@@ -930,7 +928,7 @@ describe("DbViewerScreen", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders an objectForm tab with the Visual/SQL toggle in the objects view", async () => {
|
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, "executeQueryMulti").mockResolvedValue({ result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }], notices: [], execution_time_ms: 42 } as any);
|
||||||
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||||
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||||
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip";
|
|||||||
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
|
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
|
||||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||||
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
|
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
|
||||||
import { executeQuery, cancelQuery } from "../../lib/commands";
|
import { executeQueryMulti, cancelQuery } from "../../lib/commands";
|
||||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||||
|
|
||||||
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
|
||||||
@@ -19,8 +19,11 @@ import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
|
|||||||
import { ObjectFormTab } from "./objects/ObjectFormTab";
|
import { ObjectFormTab } from "./objects/ObjectFormTab";
|
||||||
import { TabBar } from "./TabBar";
|
import { TabBar } from "./TabBar";
|
||||||
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
||||||
|
import { ResultSetPanels } from "./ResultSetPanels";
|
||||||
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
||||||
import { TableControls } from "./TableControls";
|
import { TableControls } from "./TableControls";
|
||||||
|
import { StreamExportDialog } from "./StreamExportDialog";
|
||||||
|
import { qualifiedTableSql } from "../../lib/exportData";
|
||||||
import { EditConnectionModal } from "./EditConnectionModal";
|
import { EditConnectionModal } from "./EditConnectionModal";
|
||||||
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
||||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||||
@@ -177,6 +180,7 @@ export function DbViewerScreen({
|
|||||||
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
|
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
||||||
|
const [streamExport, setStreamExport] = useState<{ sql: string } | null>(null);
|
||||||
const connections = useConnectionStore((s) => s.connections);
|
const connections = useConnectionStore((s) => s.connections);
|
||||||
const currentConnection =
|
const currentConnection =
|
||||||
connections.find((c) => c.id === connectionId) ?? null;
|
connections.find((c) => c.id === connectionId) ?? null;
|
||||||
@@ -231,6 +235,7 @@ export function DbViewerScreen({
|
|||||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||||
const tables = useDbViewerStore((s) => s.tables);
|
const tables = useDbViewerStore((s) => s.tables);
|
||||||
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
||||||
|
const updateChange = useDbViewerStore((s) => s.updateChange);
|
||||||
|
|
||||||
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
|
const isRunning = activeTab?.tabType === "query" && !!activeTab?.loading;
|
||||||
|
|
||||||
@@ -259,6 +264,7 @@ export function DbViewerScreen({
|
|||||||
|
|
||||||
const setTabData = useDbViewerStore((s) => s.setTabData);
|
const setTabData = useDbViewerStore((s) => s.setTabData);
|
||||||
const setTabError = useDbViewerStore((s) => s.setTabError);
|
const setTabError = useDbViewerStore((s) => s.setTabError);
|
||||||
|
const setTabMulti = useDbViewerStore((s) => s.setTabMulti);
|
||||||
const setTabLoading = useDbViewerStore((s) => s.setTabLoading);
|
const setTabLoading = useDbViewerStore((s) => s.setTabLoading);
|
||||||
const databases = useDbViewerStore((s) => s.databases);
|
const databases = useDbViewerStore((s) => s.databases);
|
||||||
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
||||||
@@ -310,8 +316,24 @@ export function DbViewerScreen({
|
|||||||
if (!tab) return;
|
if (!tab) return;
|
||||||
setTabLoading(tabId, true);
|
setTabLoading(tabId, true);
|
||||||
try {
|
try {
|
||||||
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
|
const multi = await executeQueryMulti(connectionId, sql, tab.page, tab.pageSize);
|
||||||
setTabData(tabId, result);
|
const first = multi.result_sets[0] ?? null;
|
||||||
|
// Stamp the run's execution time onto the first set so the toolbar
|
||||||
|
// (TableControls reads activeTab.data.execution_time_ms) still shows it.
|
||||||
|
if (first) {
|
||||||
|
setTabData(tabId, { ...first, execution_time_ms: multi.execution_time_ms });
|
||||||
|
} else {
|
||||||
|
// Lone DML/DDL run with no result set — keep the empty-state behavior.
|
||||||
|
setTabData(tabId, {
|
||||||
|
columns: [],
|
||||||
|
rows: [],
|
||||||
|
total_rows: 0,
|
||||||
|
page: tab.page,
|
||||||
|
page_size: tab.pageSize,
|
||||||
|
execution_time_ms: multi.execution_time_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setTabMulti(tabId, multi);
|
||||||
useQueryStore.getState().invalidateHistory(connectionId);
|
useQueryStore.getState().invalidateHistory(connectionId);
|
||||||
if (isSchemaModifyingQuery(sql)) {
|
if (isSchemaModifyingQuery(sql)) {
|
||||||
const st = useDbViewerStore.getState();
|
const st = useDbViewerStore.getState();
|
||||||
@@ -339,6 +361,19 @@ export function DbViewerScreen({
|
|||||||
[activeTab, stageCellEdit],
|
[activeTab, stageCellEdit],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleStageInsertCell = useCallback(
|
||||||
|
(changeId: string, column: string, value: string | null) => {
|
||||||
|
const change = useDbViewerStore
|
||||||
|
.getState()
|
||||||
|
.changesQueue.find((c) => c.id === changeId);
|
||||||
|
if (!change) return;
|
||||||
|
const newData = { ...(change.newData ?? {}) };
|
||||||
|
newData[column] = value;
|
||||||
|
updateChange(changeId, { newData });
|
||||||
|
},
|
||||||
|
[updateChange],
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenRowDetail = useCallback((rowIndex: number) => {
|
const handleOpenRowDetail = useCallback((rowIndex: number) => {
|
||||||
setRowDetailIdx(rowIndex);
|
setRowDetailIdx(rowIndex);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -832,6 +867,24 @@ export function DbViewerScreen({
|
|||||||
// Data is already filtered and sorted server-side; no client-side transform needed.
|
// Data is already filtered and sorted server-side; no client-side transform needed.
|
||||||
const processedRows = rawRows;
|
const processedRows = rawRows;
|
||||||
|
|
||||||
|
// Pending insert rows (from the changes queue) are prepended to the grid as
|
||||||
|
// editable rows. Only pending inserts for the active table tab count.
|
||||||
|
const pendingInserts =
|
||||||
|
activeTab?.tabType === "table"
|
||||||
|
? changesQueue.filter(
|
||||||
|
(c) =>
|
||||||
|
c.type === "insert" &&
|
||||||
|
c.status === "pending" &&
|
||||||
|
c.schema === activeTab.schema &&
|
||||||
|
c.table === activeTab.table,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const pendingInsertChangeIds = pendingInserts.map((c) => c.id);
|
||||||
|
const pendingInsertRows = pendingInserts.map((c) =>
|
||||||
|
columns.map((col) => c.newData?.[col.name] ?? null),
|
||||||
|
);
|
||||||
|
const displayRows = [...pendingInsertRows, ...processedRows];
|
||||||
|
|
||||||
const onPanelResizeStart = useCallback(
|
const onPanelResizeStart = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -985,6 +1038,48 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
)
|
)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
// Shared VirtualDataGrid props for the query-tab results region. The
|
||||||
|
// multi-result panels reuse these (overriding rows/columns per set);
|
||||||
|
// single-set runs render the grid directly with today's props.
|
||||||
|
const gridPropsForQueryTab = {
|
||||||
|
connectionId,
|
||||||
|
schema: activeSchema,
|
||||||
|
table: activeTable,
|
||||||
|
rows: displayRows,
|
||||||
|
columns,
|
||||||
|
hiddenColumns,
|
||||||
|
selectedRows,
|
||||||
|
dbType: currentConnection?.db_type ?? "postgresql",
|
||||||
|
tabType: activeTab?.tabType ?? "table",
|
||||||
|
getLocator,
|
||||||
|
onStageEdit: readOnlyTable ? undefined : handleStageEdit,
|
||||||
|
onStageInsertCell: readOnlyTable ? undefined : handleStageInsertCell,
|
||||||
|
onOpenRowDetail: handleOpenRowDetail,
|
||||||
|
readOnly: readOnlyTable,
|
||||||
|
enumValues: editorOptions?.enums,
|
||||||
|
fkOptions: editorOptions?.fks,
|
||||||
|
fkPlaceholders: editorOptions?.fkPlaceholders,
|
||||||
|
stagedValues,
|
||||||
|
pendingKeys,
|
||||||
|
pendingInsertChangeIds,
|
||||||
|
onToggleRow: (rowIndex: number) => {
|
||||||
|
setSelectedRows((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(rowIndex)) next.delete(rowIndex);
|
||||||
|
else next.add(rowIndex);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onToggleAll: () => {
|
||||||
|
setSelectedRows((prev) => {
|
||||||
|
if (prev.size === processedRows.length && processedRows.length > 0) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
return new Set(processedRows.map((_, i) => i));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
||||||
<TabBar onCommitted={handleCommitted} />
|
<TabBar onCommitted={handleCommitted} />
|
||||||
@@ -1177,72 +1272,31 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
}
|
}
|
||||||
variant="query"
|
variant="query"
|
||||||
isMatview={readOnlyTable}
|
isMatview={readOnlyTable}
|
||||||
|
onExportToFile={() =>
|
||||||
|
setStreamExport({
|
||||||
|
sql:
|
||||||
|
activeTab?.tabType === "query"
|
||||||
|
? activeTab.query ?? ""
|
||||||
|
: qualifiedTableSql(
|
||||||
|
currentConnection?.db_type ?? "postgresql",
|
||||||
|
activeSchema,
|
||||||
|
activeTable,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<VirtualDataGrid
|
{activeTab?.tabType === "query" && activeTab.multi && (activeTab.multi.result_sets.length > 1 || activeTab.multi.notices.length > 0) ? (
|
||||||
connectionId={connectionId}
|
<ResultSetPanels
|
||||||
schema={activeSchema}
|
multi={activeTab.multi}
|
||||||
table={activeTable}
|
renderGrid={(set) => (
|
||||||
rows={processedRows}
|
<VirtualDataGrid {...gridPropsForQueryTab} rows={set.rows} columns={set.columns} />
|
||||||
columns={columns}
|
)}
|
||||||
hiddenColumns={hiddenColumns}
|
/>
|
||||||
selectedRows={selectedRows}
|
) : (
|
||||||
dbType={currentConnection?.db_type ?? "postgresql"}
|
<VirtualDataGrid {...gridPropsForQueryTab} />
|
||||||
tabType={activeTab?.tabType ?? "table"}
|
)}
|
||||||
getLocator={getLocator}
|
|
||||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
|
||||||
onOpenRowDetail={handleOpenRowDetail}
|
|
||||||
readOnly={readOnlyTable}
|
|
||||||
enumValues={editorOptions?.enums}
|
|
||||||
fkOptions={editorOptions?.fks}
|
|
||||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
|
||||||
stagedValues={stagedValues}
|
|
||||||
pendingKeys={pendingKeys}
|
|
||||||
onToggleRow={(rowIndex) => {
|
|
||||||
setSelectedRows(
|
|
||||||
(prev) => {
|
|
||||||
const next =
|
|
||||||
new Set(
|
|
||||||
prev,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
next.has(
|
|
||||||
rowIndex,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
next.delete(
|
|
||||||
rowIndex,
|
|
||||||
);
|
|
||||||
else
|
|
||||||
next.add(
|
|
||||||
rowIndex,
|
|
||||||
);
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onToggleAll={() => {
|
|
||||||
setSelectedRows(
|
|
||||||
(prev) => {
|
|
||||||
if (
|
|
||||||
prev.size ===
|
|
||||||
processedRows.length &&
|
|
||||||
processedRows.length >
|
|
||||||
0
|
|
||||||
) {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
return new Set(
|
|
||||||
processedRows.map(
|
|
||||||
(_, i) =>
|
|
||||||
i,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1341,6 +1395,18 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
setSelectedRows(new Set())
|
setSelectedRows(new Set())
|
||||||
}
|
}
|
||||||
isMatview={readOnlyTable}
|
isMatview={readOnlyTable}
|
||||||
|
onExportToFile={() =>
|
||||||
|
setStreamExport({
|
||||||
|
sql:
|
||||||
|
activeTab?.tabType === "query"
|
||||||
|
? activeTab.query ?? ""
|
||||||
|
: qualifiedTableSql(
|
||||||
|
currentConnection?.db_type ?? "postgresql",
|
||||||
|
activeSchema,
|
||||||
|
activeTable,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
@@ -1348,7 +1414,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
schema={activeSchema}
|
schema={activeSchema}
|
||||||
table={activeTable}
|
table={activeTable}
|
||||||
rows={processedRows}
|
rows={displayRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
hiddenColumns={hiddenColumns}
|
hiddenColumns={hiddenColumns}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
@@ -1356,6 +1422,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
tabType={activeTab?.tabType ?? "table"}
|
tabType={activeTab?.tabType ?? "table"}
|
||||||
getLocator={getLocator}
|
getLocator={getLocator}
|
||||||
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
|
||||||
|
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
|
||||||
onOpenRowDetail={handleOpenRowDetail}
|
onOpenRowDetail={handleOpenRowDetail}
|
||||||
readOnly={readOnlyTable}
|
readOnly={readOnlyTable}
|
||||||
enumValues={editorOptions?.enums}
|
enumValues={editorOptions?.enums}
|
||||||
@@ -1363,6 +1430,7 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||||
stagedValues={stagedValues}
|
stagedValues={stagedValues}
|
||||||
pendingKeys={pendingKeys}
|
pendingKeys={pendingKeys}
|
||||||
|
pendingInsertChangeIds={pendingInsertChangeIds}
|
||||||
onToggleRow={(rowIndex) => {
|
onToggleRow={(rowIndex) => {
|
||||||
setSelectedRows((prev) => {
|
setSelectedRows((prev) => {
|
||||||
const next = new Set(
|
const next = new Set(
|
||||||
@@ -1531,6 +1599,13 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
{capabilities.objects && (
|
{capabilities.objects && (
|
||||||
<ObjectSearchPalette connectionId={connectionId} />
|
<ObjectSearchPalette connectionId={connectionId} />
|
||||||
)}
|
)}
|
||||||
|
{streamExport && (
|
||||||
|
<StreamExportDialog
|
||||||
|
connectionId={connectionId}
|
||||||
|
sql={streamExport.sql}
|
||||||
|
onClose={() => setStreamExport(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import { FilterBuilder } from "./FilterBuilder";
|
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
|
||||||
|
|
||||||
const cols: ColumnInfo[] = [
|
|
||||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
|
|
||||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("FilterBuilder", () => {
|
|
||||||
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
|
|
||||||
const onChange = vi.fn();
|
|
||||||
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
|
|
||||||
fireEvent.click(screen.getByText("name"));
|
|
||||||
expect(onChange).toHaveBeenCalledWith([
|
|
||||||
expect.objectContaining({ column: "name", operator: "contains" }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it("removing a rule calls onChange without it", () => {
|
|
||||||
const onChange = vi.fn();
|
|
||||||
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
|
|
||||||
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
|
|
||||||
fireEvent.click(screen.getByLabelText("Remove filter name"));
|
|
||||||
expect(onChange).toHaveBeenCalledWith([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { defaultFilterOperator } from "../grid/gridEditability";
|
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
|
||||||
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
columns: ColumnInfo[];
|
|
||||||
rules: FilterRule[];
|
|
||||||
onChange: (rules: FilterRule[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
|
|
||||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
|
||||||
id: `col-${col.name}`,
|
|
||||||
data: { column: col },
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={setNodeRef}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
onClick={onAdd}
|
|
||||||
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
|
|
||||||
isDragging ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{col.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPERATORS: FilterOperator[] = [
|
|
||||||
"eq",
|
|
||||||
"neq",
|
|
||||||
"contains",
|
|
||||||
"starts",
|
|
||||||
"ends",
|
|
||||||
"gt",
|
|
||||||
"lt",
|
|
||||||
"null",
|
|
||||||
"notnull",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function FilterBuilder({ columns, rules, onChange }: Props) {
|
|
||||||
const [val, setVal] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const addRule = (col: ColumnInfo) => {
|
|
||||||
const op = defaultFilterOperator(col.data_type);
|
|
||||||
onChange([
|
|
||||||
...rules,
|
|
||||||
{
|
|
||||||
id: `f-${Date.now()}-${col.name}`,
|
|
||||||
column: col.name,
|
|
||||||
operator: op,
|
|
||||||
value: "",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
|
|
||||||
|
|
||||||
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
|
||||||
const update = (id: string, patch: Partial<FilterRule>) =>
|
|
||||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
onDragEnd={(e) => {
|
|
||||||
const id = e.active.id as string;
|
|
||||||
const colName = id.replace(/^col-/, "");
|
|
||||||
const col = columns.find((c) => c.name === colName);
|
|
||||||
if (col && e.over?.id === "filter-dropzone") addRule(col);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-1 mb-2">
|
|
||||||
{columns.map((c) => (
|
|
||||||
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
|
|
||||||
isOver ? "border-accent bg-surface" : "border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{rules.length === 0 && (
|
|
||||||
<span className="text-xs text-text-muted">
|
|
||||||
Drop columns here to add filters
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{rules.map((r) => (
|
|
||||||
<div key={r.id} className="flex items-center gap-2 text-xs">
|
|
||||||
<span className="text-text font-semibold">{r.column}</span>
|
|
||||||
<select
|
|
||||||
value={r.operator}
|
|
||||||
onChange={(e) =>
|
|
||||||
update(r.id, { operator: e.target.value as FilterOperator })
|
|
||||||
}
|
|
||||||
className="bg-surface border border-border rounded px-1"
|
|
||||||
>
|
|
||||||
{OPERATORS.map((o) => (
|
|
||||||
<option key={o} value={o}>
|
|
||||||
{o}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{!["null", "notnull"].includes(r.operator) && (
|
|
||||||
<input
|
|
||||||
value={val[r.id] ?? r.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
setVal({ ...val, [r.id]: e.target.value });
|
|
||||||
update(r.id, { value: e.target.value });
|
|
||||||
}}
|
|
||||||
className="bg-surface border border-border rounded px-1 flex-1"
|
|
||||||
placeholder="value"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
aria-label={`Remove filter ${r.column}`}
|
|
||||||
onClick={() => remove(r.id)}
|
|
||||||
className="text-text-muted hover:text-red-400"
|
|
||||||
>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { HypertableDetail } from "./HypertableDetail";
|
||||||
|
import type { HypertableInfo } from "../../lib/types";
|
||||||
|
|
||||||
|
const item: HypertableInfo = {
|
||||||
|
name: "metrics",
|
||||||
|
schema: "public",
|
||||||
|
num_dimensions: 2,
|
||||||
|
compression_enabled: true,
|
||||||
|
num_chunks: 42,
|
||||||
|
total_size_bytes: 1048576,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("HypertableDetail", () => {
|
||||||
|
it("renders dimensions, compression, chunk count and human size", () => {
|
||||||
|
render(<HypertableDetail item={item} onOpenTable={vi.fn()} />);
|
||||||
|
expect(screen.getByText("metrics")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/2 dimensions/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/compression enabled/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/42 chunks/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/1\.0 MB/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the DDL/restore caveat", () => {
|
||||||
|
render(<HypertableDetail item={item} onOpenTable={vi.fn()} />);
|
||||||
|
expect(screen.getByText(/create_hypertable/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the underlying table on click", () => {
|
||||||
|
const onOpenTable = vi.fn();
|
||||||
|
render(<HypertableDetail item={item} onOpenTable={onOpenTable} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /view table/i }));
|
||||||
|
expect(onOpenTable).toHaveBeenCalledWith("public", "metrics");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks compression as disabled when off", () => {
|
||||||
|
render(
|
||||||
|
<HypertableDetail item={{ ...item, compression_enabled: false }} onOpenTable={vi.fn()} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/compression disabled/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Table2 } from "lucide-react";
|
||||||
|
import type { HypertableInfo } from "../../lib/types";
|
||||||
|
|
||||||
|
function humanBytes(bytes: number | null): string {
|
||||||
|
if (bytes === null) return "unknown size";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let v = bytes;
|
||||||
|
let i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return `${v >= 10 || i === 0 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HypertableDetail({
|
||||||
|
item,
|
||||||
|
onOpenTable,
|
||||||
|
}: {
|
||||||
|
item: HypertableInfo;
|
||||||
|
onOpenTable: (schema: string, table: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-1 space-y-2">
|
||||||
|
<div className="text-sm text-text">{item.name}</div>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs text-text-muted">
|
||||||
|
<span className="rounded bg-surface-raised px-2 py-1">{item.num_dimensions} dimensions</span>
|
||||||
|
<span className="rounded bg-surface-raised px-2 py-1">
|
||||||
|
{item.compression_enabled ? "compression enabled" : "compression disabled"}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-surface-raised px-2 py-1">{item.num_chunks} chunks</span>
|
||||||
|
<span className="rounded bg-surface-raised px-2 py-1">{humanBytes(item.total_size_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenTable(item.schema, item.name)}
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Table2 size={13} /> View table
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-text-subtle">
|
||||||
|
Note: Copy DDL exports the table as plain SQL plus its create_hypertable call. A
|
||||||
|
full-fidelity restore needs TimescaleDB pre/post-restore hooks, which Gridline does
|
||||||
|
not run.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,10 +22,6 @@ interface ObjectExplorerPageProps {
|
|||||||
sidebarMode?: boolean;
|
sidebarMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
|
|
||||||
(t) => ({ value: t, label: TYPE_LABELS[t] }),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
|
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
|
||||||
function emptyPlural(type: ObjectType): string {
|
function emptyPlural(type: ObjectType): string {
|
||||||
const singular = SINGULAR_LABELS[type];
|
const singular = SINGULAR_LABELS[type];
|
||||||
@@ -75,6 +71,7 @@ function typeToDdlType(type: ObjectType): string {
|
|||||||
sequences: "sequence",
|
sequences: "sequence",
|
||||||
enums: "enum",
|
enums: "enum",
|
||||||
extensions: "extension",
|
extensions: "extension",
|
||||||
|
hypertables: "table",
|
||||||
indexes: "index",
|
indexes: "index",
|
||||||
constraints: "constraint",
|
constraints: "constraint",
|
||||||
roles: "role",
|
roles: "role",
|
||||||
@@ -125,6 +122,19 @@ export function ObjectExplorerPage({
|
|||||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||||
const openObjectTab = useDbViewerStore((s) => s.openObjectTab);
|
const openObjectTab = useDbViewerStore((s) => s.openObjectTab);
|
||||||
const refreshTree = useDbViewerStore((s) => s.refreshTree);
|
const refreshTree = useDbViewerStore((s) => s.refreshTree);
|
||||||
|
const hypertablesAvailable = useDbViewerStore((s) => s.hypertablesAvailable);
|
||||||
|
const setHypertablesAvailable = useDbViewerStore((s) => s.setHypertablesAvailable);
|
||||||
|
// Build the dropdown from the full object-type union, gating hypertables
|
||||||
|
// on confirmed TimescaleDB availability (visibleObjectTypes in ObjectTree
|
||||||
|
// is scoped to that tree's smaller type set).
|
||||||
|
const objectTypeOptions = useMemo(() => {
|
||||||
|
const all = Object.keys(TYPE_LABELS) as ObjectType[];
|
||||||
|
const visible =
|
||||||
|
hypertablesAvailable === true
|
||||||
|
? all
|
||||||
|
: all.filter((t) => t !== "hypertables");
|
||||||
|
return visible.map((t) => ({ value: t, label: TYPE_LABELS[t] }));
|
||||||
|
}, [hypertablesAvailable]);
|
||||||
|
|
||||||
// Use store for persistence, but allow re-fetching when schema changes
|
// Use store for persistence, but allow re-fetching when schema changes
|
||||||
const [items, setItems] = useState<AnyObject[] | null>(null);
|
const [items, setItems] = useState<AnyObject[] | null>(null);
|
||||||
@@ -218,6 +228,23 @@ export function ObjectExplorerPage({
|
|||||||
}
|
}
|
||||||
}, [currentSchema, fetch]);
|
}, [currentSchema, fetch]);
|
||||||
|
|
||||||
|
// Probe TimescaleDB availability once per connection so the hypertables
|
||||||
|
// object type only appears in the dropdown when it is actually supported.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await cmd.getHypertables(connectionId);
|
||||||
|
if (!cancelled) setHypertablesAvailable(res.available);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setHypertablesAvailable(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [connectionId, setHypertablesAvailable]);
|
||||||
|
|
||||||
// After a commit containing ddl items succeeds, refetch the current
|
// After a commit containing ddl items succeeds, refetch the current
|
||||||
// object-type list + refresh the schema tree so newly created/dropped
|
// object-type list + refresh the schema tree so newly created/dropped
|
||||||
// objects show up without a manual refresh.
|
// objects show up without a manual refresh.
|
||||||
@@ -325,7 +352,7 @@ export function ObjectExplorerPage({
|
|||||||
<SelectDropdown
|
<SelectDropdown
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(v) => handleTypeChange(v as ObjectType)}
|
onChange={(v) => handleTypeChange(v as ObjectType)}
|
||||||
options={OBJECT_TYPE_OPTIONS}
|
options={objectTypeOptions}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
aria-label="Object type"
|
aria-label="Object type"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { TYPE_LABELS, visibleObjectTypes } from "./ObjectTree";
|
||||||
|
|
||||||
|
describe("hypertables object type", () => {
|
||||||
|
it("registers label and stays in the type union", () => {
|
||||||
|
expect(TYPE_LABELS.hypertables).toBe("hypertables");
|
||||||
|
expect(Object.keys(TYPE_LABELS)).toContain("hypertables");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides hypertables until availability is confirmed true", () => {
|
||||||
|
const base = Object.keys(TYPE_LABELS) as ReturnType<typeof visibleObjectTypes>;
|
||||||
|
expect(visibleObjectTypes(null)).not.toContain("hypertables");
|
||||||
|
expect(visibleObjectTypes(false)).not.toContain("hypertables");
|
||||||
|
expect(visibleObjectTypes(true)).toContain("hypertables");
|
||||||
|
expect(visibleObjectTypes(true).length).toBe(base.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search } from "lucide-react";
|
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Layers, Search } from "lucide-react";
|
||||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
import * as cmd from "../../lib/commands";
|
import * as cmd from "../../lib/commands";
|
||||||
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../../lib/types";
|
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo } from "../../lib/types";
|
||||||
|
import { HypertableDetail } from "./HypertableDetail";
|
||||||
|
|
||||||
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions";
|
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions" | "hypertables";
|
||||||
|
|
||||||
interface ObjectTreeProps {
|
interface ObjectTreeProps {
|
||||||
type: ObjectType;
|
type: ObjectType;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<ObjectType, string> = {
|
export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||||
functions: "functions",
|
functions: "functions",
|
||||||
triggers: "triggers",
|
triggers: "triggers",
|
||||||
sequences: "sequences",
|
sequences: "sequences",
|
||||||
enums: "enums",
|
enums: "enums",
|
||||||
extensions: "extensions",
|
extensions: "extensions",
|
||||||
|
hypertables: "hypertables",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ICONS: Record<ObjectType, React.ReactNode> = {
|
const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||||
@@ -25,8 +27,18 @@ const ICONS: Record<ObjectType, React.ReactNode> = {
|
|||||||
sequences: <ListOrdered 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" />,
|
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||||
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
||||||
|
hypertables: <Layers size={14} className="text-text-muted shrink-0" />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Object types visible in the dropdown. Hypertables only show when the
|
||||||
|
* connection confirmed TimescaleDB availability (available === true). */
|
||||||
|
export function visibleObjectTypes(hypertablesAvailable: boolean | null): ObjectType[] {
|
||||||
|
const all = Object.keys(TYPE_LABELS) as ObjectType[];
|
||||||
|
return hypertablesAvailable === true
|
||||||
|
? all
|
||||||
|
: all.filter((t) => t !== "hypertables");
|
||||||
|
}
|
||||||
|
|
||||||
function SourceCode({ source }: { source: string }) {
|
function SourceCode({ source }: { source: string }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const maxLen = 500;
|
const maxLen = 500;
|
||||||
@@ -59,11 +71,13 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
|||||||
const sequences = useDbViewerStore((s) => s.sequences);
|
const sequences = useDbViewerStore((s) => s.sequences);
|
||||||
const enums = useDbViewerStore((s) => s.enums);
|
const enums = useDbViewerStore((s) => s.enums);
|
||||||
const extensions = useDbViewerStore((s) => s.extensions);
|
const extensions = useDbViewerStore((s) => s.extensions);
|
||||||
|
const hypertables = useDbViewerStore((s) => s.hypertables);
|
||||||
const setFunctions = useDbViewerStore((s) => s.setFunctions);
|
const setFunctions = useDbViewerStore((s) => s.setFunctions);
|
||||||
const setTriggers = useDbViewerStore((s) => s.setTriggers);
|
const setTriggers = useDbViewerStore((s) => s.setTriggers);
|
||||||
const setSequences = useDbViewerStore((s) => s.setSequences);
|
const setSequences = useDbViewerStore((s) => s.setSequences);
|
||||||
const setEnums = useDbViewerStore((s) => s.setEnums);
|
const setEnums = useDbViewerStore((s) => s.setEnums);
|
||||||
const setExtensions = useDbViewerStore((s) => s.setExtensions);
|
const setExtensions = useDbViewerStore((s) => s.setExtensions);
|
||||||
|
const setHypertables = useDbViewerStore((s) => s.setHypertables);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
||||||
@@ -77,8 +91,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
|||||||
case "sequences": return sequences;
|
case "sequences": return sequences;
|
||||||
case "enums": return enums;
|
case "enums": return enums;
|
||||||
case "extensions": return extensions;
|
case "extensions": return extensions;
|
||||||
|
case "hypertables": return hypertables;
|
||||||
}
|
}
|
||||||
}, [type, functions, triggers, sequences, enums, extensions]);
|
}, [type, functions, triggers, sequences, enums, extensions, hypertables]);
|
||||||
|
|
||||||
const setter = useMemo(() => {
|
const setter = useMemo(() => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -87,8 +102,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
|||||||
case "sequences": return setSequences;
|
case "sequences": return setSequences;
|
||||||
case "enums": return setEnums;
|
case "enums": return setEnums;
|
||||||
case "extensions": return setExtensions;
|
case "extensions": return setExtensions;
|
||||||
|
case "hypertables": return setHypertables;
|
||||||
}
|
}
|
||||||
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
|
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions, setHypertables]);
|
||||||
|
|
||||||
// Fetch on mount if not in store
|
// Fetch on mount if not in store
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -113,6 +129,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
|||||||
} else if (type === "enums") {
|
} else if (type === "enums") {
|
||||||
const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
|
const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
|
||||||
if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result);
|
if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result);
|
||||||
|
} else if (type === "hypertables") {
|
||||||
|
const res = await cmd.getHypertables(connectionId, currentSchema ?? undefined);
|
||||||
|
if (!cancelled) (setHypertables as (v: HypertableInfo[]) => void)(res.available ? res.items : []);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail — store remains null, we show the empty state
|
// Silently fail — store remains null, we show the empty state
|
||||||
@@ -123,7 +142,7 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
|||||||
|
|
||||||
fetchData();
|
fetchData();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
|
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions, setHypertables]);
|
||||||
|
|
||||||
const q = search.toLowerCase().trim();
|
const q = search.toLowerCase().trim();
|
||||||
|
|
||||||
@@ -318,6 +337,16 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{type === "hypertables" && (() => {
|
||||||
|
const h = item as HypertableInfo;
|
||||||
|
return (
|
||||||
|
<HypertableDetail
|
||||||
|
item={h}
|
||||||
|
onOpenTable={(schema, table) => useDbViewerStore.getState().openTab(schema, table)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { ResultSetPanels } from "./ResultSetPanels";
|
||||||
|
import type { MultiQueryResult } from "../../lib/types";
|
||||||
|
|
||||||
|
const multi: MultiQueryResult = {
|
||||||
|
result_sets: [
|
||||||
|
{ columns: [{ name: "a", data_type: "int", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [[1], [2]], total_rows: 2, page: 1, page_size: 50 },
|
||||||
|
{ columns: [{ name: "b", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [], total_rows: 0, page: 1, page_size: 50 },
|
||||||
|
],
|
||||||
|
notices: [
|
||||||
|
{ statement_index: 1, kind: "dml", text: "UPDATE 3", affected: 3 },
|
||||||
|
{ statement_index: 3, kind: "error", text: "relation \"nope\" does not exist", affected: null },
|
||||||
|
],
|
||||||
|
execution_time_ms: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ResultSetPanels", () => {
|
||||||
|
it("renders one panel header per result set with row counts", () => {
|
||||||
|
render(<ResultSetPanels multi={multi} renderGrid={vi.fn()} />);
|
||||||
|
expect(screen.getByText(/Result 1/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Result 2/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/2 rows/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders notice cards for DML and errors", () => {
|
||||||
|
render(<ResultSetPanels multi={multi} renderGrid={vi.fn()} />);
|
||||||
|
expect(screen.getByText(/UPDATE 3/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/3 rows affected/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/relation "nope" does not exist/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates each set to the grid renderer with its index", () => {
|
||||||
|
const renderGrid = vi.fn(() => <div />);
|
||||||
|
render(<ResultSetPanels multi={multi} renderGrid={renderGrid} />);
|
||||||
|
expect(renderGrid).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { AlertCircle, Info } from "lucide-react";
|
||||||
|
import type { MultiQueryResult, QueryResult } from "../../lib/types";
|
||||||
|
|
||||||
|
export function ResultSetPanels({
|
||||||
|
multi,
|
||||||
|
renderGrid,
|
||||||
|
}: {
|
||||||
|
multi: MultiQueryResult;
|
||||||
|
renderGrid: (set: QueryResult, index: number) => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||||
|
{multi.result_sets.map((set, i) => (
|
||||||
|
<section key={i} className="border-b border-border flex flex-col min-h-0">
|
||||||
|
<header className="px-3 py-1.5 text-xs text-text-muted bg-surface-raised border-b border-border flex items-center gap-2 shrink-0">
|
||||||
|
<span className="font-medium text-text">Result {i + 1}</span>
|
||||||
|
<span>— {set.total_rows} row{set.total_rows === 1 ? "" : "s"}</span>
|
||||||
|
</header>
|
||||||
|
<div className="min-h-0" style={{ height: Math.min(Math.max(set.rows.length * 28 + 32, 160), 480) }}>
|
||||||
|
{renderGrid(set, i)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{multi.notices.map((n, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
role={n.kind === "error" ? "alert" : "status"}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 text-xs border-b border-border ${
|
||||||
|
n.kind === "error" ? "text-red-400" : "text-text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n.kind === "error" ? <AlertCircle size={13} /> : <Info size={13} />}
|
||||||
|
<span className="font-medium">Statement {n.statement_index + 1}</span>
|
||||||
|
<span className="truncate">{n.text}</span>
|
||||||
|
{n.affected !== null && n.affected !== undefined && (
|
||||||
|
<span>({n.affected} rows affected)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("../../lib/commands", () => ({
|
||||||
|
compareSchemas: vi.fn(),
|
||||||
|
getSchemas: vi.fn().mockResolvedValue(["public"]),
|
||||||
|
getDatabases: vi.fn().mockResolvedValue(["app"]),
|
||||||
|
}));
|
||||||
|
vi.mock("../../stores/connectionStore", () => ({
|
||||||
|
useConnectionStore: (sel: (s: unknown) => unknown) =>
|
||||||
|
sel({ connections: [{ id: "src1", name: "Source PG", db_type: "postgresql" }] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SchemaDiffPage } from "./SchemaDiffPage";
|
||||||
|
import { compareSchemas } from "../../lib/commands";
|
||||||
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
|
import type { DiffReport } from "../../lib/types";
|
||||||
|
|
||||||
|
const report: DiffReport = {
|
||||||
|
source_label: "public · src1",
|
||||||
|
target_label: "public · tgt",
|
||||||
|
truncated: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
object_type: "table", name: "users", kind: "added", detail: [],
|
||||||
|
sync_sql: ['CREATE TABLE "users" (id int);'], destructive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
object_type: "table", name: "old", kind: "removed", detail: [],
|
||||||
|
sync_sql: ["DROP TABLE old;"], destructive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("SchemaDiffPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useDbViewerStore.getState().clearChanges();
|
||||||
|
(compareSchemas as ReturnType<typeof vi.fn>).mockResolvedValue(report);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the diff and renders grouped items with kind badges", async () => {
|
||||||
|
render(<SchemaDiffPage connectionId="tgt" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
|
||||||
|
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
|
||||||
|
expect(screen.getByText(/added/i)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/removed/i)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Destructive/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stages only safe items into the queue", async () => {
|
||||||
|
render(<SchemaDiffPage connectionId="tgt" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
|
||||||
|
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /stage 1 safe item/i }));
|
||||||
|
const queue = useDbViewerStore.getState().changesQueue;
|
||||||
|
expect(queue).toHaveLength(1);
|
||||||
|
expect(queue[0].sql).toContain("CREATE TABLE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the identical empty state", async () => {
|
||||||
|
(compareSchemas as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
source_label: "a", target_label: "b", truncated: false, items: [],
|
||||||
|
});
|
||||||
|
render(<SchemaDiffPage connectionId="tgt" />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /run diff/i }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/identical/i)).toBeTruthy());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Play, Copy, AlertTriangle, GitCompare } from "lucide-react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { compareSchemas, getSchemas, getDatabases } from "../../lib/commands";
|
||||||
|
import { useConnectionStore } from "../../stores/connectionStore";
|
||||||
|
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||||
|
import type { DiffItem, DiffReport } from "../../lib/types";
|
||||||
|
|
||||||
|
const TYPE_ORDER = ["enum", "sequence", "table", "column", "constraint", "index", "view"];
|
||||||
|
|
||||||
|
const KIND_BADGE: Record<string, string> = {
|
||||||
|
added: "text-emerald-400 border-emerald-400/40",
|
||||||
|
removed: "text-red-400 border-red-400/40",
|
||||||
|
changed: "text-amber-400 border-amber-400/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Threshold above which the diff report is rendered through a virtualizer
|
||||||
|
* (the snapshot cap can reach ~10k cards). Small diffs render plain, which
|
||||||
|
* keeps jsdom tests deterministic and behavior-neutral below the threshold. */
|
||||||
|
const VIRTUALIZE_THRESHOLD = 200;
|
||||||
|
|
||||||
|
function familyOf(dbType: string): string {
|
||||||
|
return dbType === "mysql" || dbType === "mariadb" ? "mysql" : dbType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SchemaDiffItemCard({ item }: { item: DiffItem }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-surface p-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`rounded border px-1.5 py-0.5 ${KIND_BADGE[item.kind]}`}>{item.kind}</span>
|
||||||
|
<span className="text-text">{item.name}</span>
|
||||||
|
{item.destructive && (
|
||||||
|
<span className="flex items-center gap-1 text-text-muted">
|
||||||
|
<AlertTriangle size={12} className="text-red-400" /> Destructive — copy only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.detail.map((d, di) => (
|
||||||
|
<div key={di} className="mt-1 text-text-muted">
|
||||||
|
{d.label}: <span className="line-through">{d.old || "∅"}</span> → {d.new || "∅"}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{item.sync_sql.length > 0 && (
|
||||||
|
<details className="mt-1.5">
|
||||||
|
<summary className="cursor-pointer text-text-muted">SQL</summary>
|
||||||
|
<pre className="mt-1 rounded bg-surface-raised p-2 overflow-x-auto text-[11px]">{item.sync_sql.join("\n")}</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigator.clipboard?.writeText(item.sync_sql.join("\n"))}
|
||||||
|
className="mt-1 text-text-muted hover:text-text cursor-pointer"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemaDiffPage({ connectionId }: { connectionId: string }) {
|
||||||
|
const connections = useConnectionStore((s) => s.connections);
|
||||||
|
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||||
|
const currentConnection = connections.find((c) => c.id === connectionId);
|
||||||
|
const family = familyOf(currentConnection?.db_type ?? "postgresql");
|
||||||
|
|
||||||
|
const [sourceId, setSourceId] = useState<string>("");
|
||||||
|
const [sourceSchemas, setSourceSchemas] = useState<string[]>([]);
|
||||||
|
const [sourceSchema, setSourceSchema] = useState<string>("");
|
||||||
|
const [targetSchema, setTargetSchema] = useState<string>(currentSchema ?? "public");
|
||||||
|
const [report, setReport] = useState<DiffReport | null>(null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const sameFamilySources = useMemo(
|
||||||
|
() => connections.filter((c) => c.id !== connectionId && familyOf(c.db_type) === family),
|
||||||
|
[connections, connectionId, family],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pickSource = async (id: string) => {
|
||||||
|
setSourceId(id);
|
||||||
|
setSourceSchema("");
|
||||||
|
setSourceSchemas([]);
|
||||||
|
if (!id) return;
|
||||||
|
// Set a synchronous default so "Run diff" works before the async schema
|
||||||
|
// fetch resolves (the source must already be connected). The fetch below
|
||||||
|
// refines it to the source's actual first schema.
|
||||||
|
setSourceSchema(family === "mysql" ? "" : family === "sqlite" ? "main" : "public");
|
||||||
|
try {
|
||||||
|
// The source must already be connected (opened in the DB viewer) for
|
||||||
|
// capture — getSchemas/getDatabases and compare_schemas error clearly
|
||||||
|
// if not. We do not auto-connect here.
|
||||||
|
const schemas =
|
||||||
|
family === "mysql"
|
||||||
|
? await getDatabases(id)
|
||||||
|
: family === "sqlite"
|
||||||
|
? ["main"]
|
||||||
|
: await getSchemas(id);
|
||||||
|
setSourceSchemas(schemas);
|
||||||
|
setSourceSchema(schemas[0] ?? "");
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Source connection failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-select the first same-family source on mount so "Run diff" works
|
||||||
|
// without manual selection (the source must already be connected).
|
||||||
|
useEffect(() => {
|
||||||
|
if (sourceId === "" && sameFamilySources.length > 0) {
|
||||||
|
void pickSource(sameFamilySources[0].id);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sameFamilySources, sourceId]);
|
||||||
|
|
||||||
|
const runDiff = async () => {
|
||||||
|
if (!sourceId || !sourceSchema || !targetSchema) return;
|
||||||
|
setRunning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await compareSchemas(sourceId, sourceSchema, connectionId, targetSchema);
|
||||||
|
setReport(r);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stageAll = () => {
|
||||||
|
if (!report) return;
|
||||||
|
useDbViewerStore.getState().addDiffItems(report.items);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAll = () => {
|
||||||
|
if (!report) return;
|
||||||
|
const sql = report.items.flatMap((i) => i.sync_sql).join("\n");
|
||||||
|
void navigator.clipboard?.writeText(sql);
|
||||||
|
};
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
if (!report) return [] as [string, DiffItem[]][];
|
||||||
|
const byType = new Map<string, DiffItem[]>();
|
||||||
|
for (const it of report.items) {
|
||||||
|
const list = byType.get(it.object_type) ?? [];
|
||||||
|
list.push(it);
|
||||||
|
byType.set(it.object_type, list);
|
||||||
|
}
|
||||||
|
return [...byType.entries()].sort(
|
||||||
|
(a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]),
|
||||||
|
);
|
||||||
|
}, [report]);
|
||||||
|
|
||||||
|
const flattenedItems = report ? report.items : [];
|
||||||
|
const shouldVirtualize = flattenedItems.length > VIRTUALIZE_THRESHOLD;
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: flattenedItems.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => 96,
|
||||||
|
overscan: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stageableCount = report
|
||||||
|
? report.items.filter((i) => !i.destructive && i.sync_sql.length > 0).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
|
||||||
|
<div className="px-3 pt-3 pb-3 border-b border-border shrink-0 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<GitCompare size={14} className="text-text-muted" />
|
||||||
|
<span className="text-text-muted">Compare a source connection's schema against this connection (same engine family).</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<select
|
||||||
|
value={sourceId}
|
||||||
|
onChange={(e) => void pickSource(e.target.value)}
|
||||||
|
className="rounded-lg bg-surface-raised border border-border px-2 py-1"
|
||||||
|
aria-label="Source connection"
|
||||||
|
>
|
||||||
|
<option value="">Source connection…</option>
|
||||||
|
{sameFamilySources.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={sourceSchema}
|
||||||
|
onChange={(e) => setSourceSchema(e.target.value)}
|
||||||
|
disabled={!sourceId}
|
||||||
|
className="rounded-lg bg-surface-raised border border-border px-2 py-1 disabled:opacity-50"
|
||||||
|
aria-label="Source schema"
|
||||||
|
>
|
||||||
|
{sourceSchemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
<span className="text-text-muted">→</span>
|
||||||
|
<select
|
||||||
|
value={targetSchema}
|
||||||
|
onChange={(e) => setTargetSchema(e.target.value)}
|
||||||
|
className="rounded-lg bg-surface-raised border border-border px-2 py-1"
|
||||||
|
aria-label="Target schema"
|
||||||
|
>
|
||||||
|
<option value={targetSchema}>{targetSchema}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void runDiff()}
|
||||||
|
disabled={running || !sourceId || !sourceSchema}
|
||||||
|
className="flex items-center gap-1 rounded-lg bg-accent px-2.5 py-1 text-accent-foreground disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Play size={13} /> {running ? "Diffing…" : "Run diff"}
|
||||||
|
</button>
|
||||||
|
{report && stageableCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stageAll}
|
||||||
|
className="rounded-lg bg-surface-raised border border-border px-2.5 py-1 hover:bg-surface cursor-pointer"
|
||||||
|
>
|
||||||
|
Stage {stageableCount} safe item{stageableCount === 1 ? "" : "s"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{report && report.items.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyAll}
|
||||||
|
className="flex items-center gap-1 rounded-lg bg-surface-raised border border-border px-2.5 py-1 hover:bg-surface cursor-pointer"
|
||||||
|
>
|
||||||
|
<Copy size={13} /> Copy all SQL
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{report?.truncated && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-amber-400">
|
||||||
|
<AlertTriangle size={13} /> Snapshot capped at 5,000 objects per side — report is truncated.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="text-xs text-red-400" role="alert">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-3 space-y-4">
|
||||||
|
{report && report.items.length === 0 && (
|
||||||
|
<div className="text-xs text-text-muted">Schemas are identical — no differences found.</div>
|
||||||
|
)}
|
||||||
|
{shouldVirtualize ? (
|
||||||
|
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||||
|
{virtualizer.getVirtualItems().map((vi) => (
|
||||||
|
<div
|
||||||
|
key={vi.key}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${vi.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SchemaDiffItemCard item={flattenedItems[vi.index]} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
grouped.map(([type, items]) => (
|
||||||
|
<section key={type}>
|
||||||
|
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wide mb-1.5">{type}s</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{items.map((it) => (
|
||||||
|
<SchemaDiffItemCard key={`${it.object_type}-${it.name}`} item={it} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,7 +99,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
|
|||||||
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
|
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1" ref={menuRef}>
|
<div className="relative flex items-center gap-1" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
aria-label="New schema"
|
aria-label="New schema"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -123,7 +123,7 @@ export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps)
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute right-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
|
<div className="absolute left-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||||
|
save: vi.fn().mockResolvedValue("/tmp/export.csv"),
|
||||||
|
}));
|
||||||
|
vi.mock("../../lib/commands", () => ({
|
||||||
|
exportQueryToFile: vi.fn().mockResolvedValue({ rows_written: 3 }),
|
||||||
|
cancelQuery: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
|
listen: vi.fn().mockResolvedValue(() => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { StreamExportDialog } from "./StreamExportDialog";
|
||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { exportQueryToFile, cancelQuery } from "../../lib/commands";
|
||||||
|
|
||||||
|
describe("StreamExportDialog", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("picks a path, invokes the export, and reports the row count", async () => {
|
||||||
|
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||||
|
expect(save).toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /export/i }));
|
||||||
|
await waitFor(() => expect(exportQueryToFile).toHaveBeenCalledWith("c1", "SELECT 1", "csv", "/tmp/export.csv"));
|
||||||
|
await waitFor(() => expect(screen.getByText(/3 rows written/i)).toBeTruthy());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels via the existing query-cancel wrapper", async () => {
|
||||||
|
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /export/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||||
|
await waitFor(() => expect(cancelQuery).toHaveBeenCalledWith("c1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces export errors", async () => {
|
||||||
|
(exportQueryToFile as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
|
||||||
|
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /export/i }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/boom/i)).toBeTruthy());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||||
|
import { Button } from "../ui/Button";
|
||||||
|
import { Select } from "../ui/Select";
|
||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
import { exportQueryToFile, cancelQuery } from "../../lib/commands";
|
||||||
|
|
||||||
|
interface StreamExportDialogProps {
|
||||||
|
connectionId: string;
|
||||||
|
sql: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_OPTIONS = [
|
||||||
|
{ value: "csv", label: "CSV" },
|
||||||
|
{ value: "jsonl", label: "JSON Lines" },
|
||||||
|
{ value: "json", label: "JSON" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXTENSIONS: Record<string, string> = {
|
||||||
|
csv: "csv",
|
||||||
|
jsonl: "jsonl",
|
||||||
|
json: "json",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StreamExportDialog({ connectionId, sql, onClose }: StreamExportDialogProps) {
|
||||||
|
const [format, setFormat] = useState("csv");
|
||||||
|
const [path, setPath] = useState("");
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [rowsWritten, setRowsWritten] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const jobIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Filter export-progress events to this job so concurrent exports don't
|
||||||
|
// clobber each other's progress bar.
|
||||||
|
useEffect(() => {
|
||||||
|
const unlisten = listen<{ job_id: string; percent: number }>("export-progress", (e) => {
|
||||||
|
if (e.payload.job_id === jobIdRef.current) {
|
||||||
|
setProgress(e.payload.percent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unlisten.then((fn) => fn());
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChooseFile = async () => {
|
||||||
|
const ext = EXTENSIONS[format] ?? "csv";
|
||||||
|
const picked = await save({
|
||||||
|
defaultPath: `export.${ext}`,
|
||||||
|
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
|
||||||
|
});
|
||||||
|
if (picked) setPath(picked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
setError(null);
|
||||||
|
setDone(false);
|
||||||
|
setProgress(0);
|
||||||
|
try {
|
||||||
|
let target = path;
|
||||||
|
if (!target) {
|
||||||
|
const ext = EXTENSIONS[format] ?? "csv";
|
||||||
|
target = (await save({
|
||||||
|
defaultPath: `export.${ext}`,
|
||||||
|
filters: [{ name: format.toUpperCase(), extensions: [ext] }],
|
||||||
|
})) ?? "";
|
||||||
|
if (!target) {
|
||||||
|
setRunning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPath(target);
|
||||||
|
}
|
||||||
|
jobIdRef.current = `export-${Date.now()}`;
|
||||||
|
const res = await exportQueryToFile(connectionId, sql, format, target);
|
||||||
|
setDone(true);
|
||||||
|
setProgress(100);
|
||||||
|
setRowsWritten(res.rows_written);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
try {
|
||||||
|
await cancelQuery(connectionId);
|
||||||
|
} catch {
|
||||||
|
// ignore — the export may already have finished
|
||||||
|
}
|
||||||
|
setRunning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedModal open onClose={onClose}>
|
||||||
|
<div className="w-[420px] flex flex-col gap-4">
|
||||||
|
<h2 className="text-base font-semibold text-text">Export all rows to file</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select
|
||||||
|
label="Format"
|
||||||
|
value={format}
|
||||||
|
onChange={setFormat}
|
||||||
|
options={FORMAT_OPTIONS}
|
||||||
|
disabled={running}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" onClick={handleChooseFile} disabled={running}>
|
||||||
|
Choose file…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{path && (
|
||||||
|
<p className="text-xs text-text-muted break-all" data-testid="export-path">
|
||||||
|
{path}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{running && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-surface-raised overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all"
|
||||||
|
style={{ width: `${Math.max(progress, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-muted">{Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{done && rowsWritten !== null && (
|
||||||
|
<p className="text-xs text-emerald-400" data-testid="export-done">
|
||||||
|
{rowsWritten} rows written
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-400" data-testid="export-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{running && (
|
||||||
|
<Button variant="secondary" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleExport} disabled={running}>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -325,13 +325,115 @@ describe("TableControls", () => {
|
|||||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the FilterBuilder inside the filter popover", () => {
|
it("stages an insert with only editable columns when Insert Row is clicked", () => {
|
||||||
|
const cols = [
|
||||||
|
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: "nextval('users_id_seq')", editable: false, is_generated: false },
|
||||||
|
{ name: "name", data_type: "text", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||||
|
{ name: "created_at", data_type: "timestamp", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: "now()", editable: true, is_generated: false },
|
||||||
|
];
|
||||||
|
seed([makeTab({ data: { columns: cols, rows: [], total_rows: 0, page: 1, page_size: 50 } })], "tab-1");
|
||||||
|
renderControls({ columns: cols, rows: [] });
|
||||||
|
fireEvent.click(screen.getByLabelText(/insert row/i));
|
||||||
|
const queue = useDbViewerStore.getState().changesQueue;
|
||||||
|
expect(queue).toHaveLength(1);
|
||||||
|
expect(queue[0].type).toBe("insert");
|
||||||
|
expect(queue[0].newData).toEqual({ name: null, created_at: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the column filter popover with the + Add filter action", () => {
|
||||||
seed([makeTab()], "tab-1");
|
seed([makeTab()], "tab-1");
|
||||||
renderControls();
|
renderControls();
|
||||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
expect(
|
expect(screen.getByText("Column Filters")).toBeInTheDocument();
|
||||||
screen.getByText("Drop columns here to add filters"),
|
expect(screen.getByText("+ Add filter")).toBeInTheDocument();
|
||||||
).toBeInTheDocument();
|
});
|
||||||
|
|
||||||
|
it("debounces filter value edits so a query only fires after the user stops typing", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
const onFilterChange = vi.fn();
|
||||||
|
renderControls({
|
||||||
|
onFilterChange,
|
||||||
|
filterRules: [
|
||||||
|
{ id: "r1", column: "id", operator: "contains", value: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("value");
|
||||||
|
fireEvent.change(input, { target: { value: "a" } });
|
||||||
|
fireEvent.change(input, { target: { value: "ab" } });
|
||||||
|
fireEvent.change(input, { target: { value: "abc" } });
|
||||||
|
|
||||||
|
// No commit yet — the debounce hasn't elapsed.
|
||||||
|
expect(onFilterChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the final value is committed, once.
|
||||||
|
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onFilterChange).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ value: "abc" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the filter popover when the button is clicked while it is open", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
|
expect(screen.getByText("Column Filters")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Clicking the button while open closes it (plain toggle).
|
||||||
|
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||||
|
expect(screen.queryByText("Column Filters")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the sort rules popover open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/sort rules/i));
|
||||||
|
expect(screen.getByText("Sort Rules")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/sort rules/i));
|
||||||
|
expect(screen.queryByText("Sort Rules")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the auto-refresh dropdown open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
|
||||||
|
expect(screen.getByText("Off")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/auto-refresh/i));
|
||||||
|
expect(screen.queryByText("Off")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the show/hide columns dropdown open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/toggle columns/i));
|
||||||
|
expect(screen.getByText("Visible columns")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/toggle columns/i));
|
||||||
|
expect(screen.queryByText("Visible columns")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the export dropdown open and closed", () => {
|
||||||
|
seed([makeTab()], "tab-1");
|
||||||
|
renderControls();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/export/i));
|
||||||
|
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/export/i));
|
||||||
|
expect(screen.queryByText("JSON")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("export dropdown includes the Excel option", () => {
|
it("export dropdown includes the Excel option", () => {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
||||||
import { useNotificationStore } from "../../stores/notificationStore";
|
import { useNotificationStore } from "../../stores/notificationStore";
|
||||||
import { FilterBuilder } from "./FilterBuilder";
|
|
||||||
import { Tooltip } from "../ui/Tooltip";
|
import { Tooltip } from "../ui/Tooltip";
|
||||||
import { exportData } from "../../lib/exportData";
|
import { exportData } from "../../lib/exportData";
|
||||||
import type { ColumnInfo } from "../../lib/types";
|
import type { ColumnInfo } from "../../lib/types";
|
||||||
@@ -49,22 +48,28 @@ function DropdownMenu({
|
|||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
align,
|
align,
|
||||||
|
wrapRef,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (v: boolean) => void;
|
setOpen: (v: boolean) => void;
|
||||||
align?: "left" | "right";
|
align?: "left" | "right";
|
||||||
|
/** When provided, outside-click detection uses this wrapper (which should
|
||||||
|
* contain both the toggle button and the menu) so the button never fights
|
||||||
|
* the outside-click handler. */
|
||||||
|
wrapRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = (e: MouseEvent) => {
|
const close = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
const container = wrapRef?.current ?? ref.current;
|
||||||
|
if (container && !container.contains(e.target as Node)) setOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", close);
|
document.addEventListener("mousedown", close);
|
||||||
return () => document.removeEventListener("mousedown", close);
|
return () => document.removeEventListener("mousedown", close);
|
||||||
}, [open, setOpen]);
|
}, [open, setOpen, wrapRef]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
return (
|
||||||
@@ -79,28 +84,84 @@ function DropdownMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced free-text input for filter values. The value is committed to the
|
||||||
|
* rules (and thus triggers a server-side refetch) only after the user stops
|
||||||
|
* typing for `DEBOUNCE_MS`. Without this, every keystroke fires a query and
|
||||||
|
* the in-flight guard in `fetchData` silently drops intermediate fetches, so
|
||||||
|
* the final filter value can end up not being applied.
|
||||||
|
*/
|
||||||
|
const FILTER_DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
function DebouncedValueInput({
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onCommit: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState(value);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Keep the draft in sync when the committed value changes externally
|
||||||
|
// (e.g. a rule is replaced or removed while this input is mounted).
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setDraft(v);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => onCommit(v), FILTER_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilterModal({
|
function FilterModal({
|
||||||
columns,
|
columns,
|
||||||
rules,
|
rules,
|
||||||
onChange,
|
onChange,
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
|
wrapRef,
|
||||||
}: {
|
}: {
|
||||||
columns: ColumnInfo[];
|
columns: ColumnInfo[];
|
||||||
rules: FilterRule[];
|
rules: FilterRule[];
|
||||||
onChange: (rules: FilterRule[]) => void;
|
onChange: (rules: FilterRule[]) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (v: boolean) => void;
|
setOpen: (v: boolean) => void;
|
||||||
|
wrapRef: React.RefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
// Close on outside click, but treat the toggle button (which lives inside
|
||||||
|
// the same wrapper) as part of the modal so it never fights the toggle.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = (e: MouseEvent) => {
|
const close = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", close);
|
document.addEventListener("mousedown", close);
|
||||||
return () => document.removeEventListener("mousedown", close);
|
return () => document.removeEventListener("mousedown", close);
|
||||||
}, [open, setOpen]);
|
}, [open, setOpen, wrapRef]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -116,17 +177,13 @@ function FilterModal({
|
|||||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3">
|
||||||
ref={ref}
|
|
||||||
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-text">Column Filters</span>
|
<span className="text-xs font-semibold text-text">Column Filters</span>
|
||||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
|
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||||
<select
|
<select
|
||||||
@@ -152,12 +209,10 @@ function FilterModal({
|
|||||||
<option value="notnull">not null</option>
|
<option value="notnull">not null</option>
|
||||||
</select>
|
</select>
|
||||||
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
||||||
<input
|
<DebouncedValueInput
|
||||||
type="text"
|
|
||||||
value={rule.value}
|
value={rule.value}
|
||||||
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
|
onCommit={(v) => updateRule(rule.id, { value: v })}
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||||
@@ -182,22 +237,27 @@ function SortModal({
|
|||||||
onChange,
|
onChange,
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
|
wrapRef,
|
||||||
}: {
|
}: {
|
||||||
columns: ColumnInfo[];
|
columns: ColumnInfo[];
|
||||||
rules: SortRule[];
|
rules: SortRule[];
|
||||||
onChange: (rules: SortRule[]) => void;
|
onChange: (rules: SortRule[]) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (v: boolean) => void;
|
setOpen: (v: boolean) => void;
|
||||||
|
wrapRef: React.RefObject<HTMLDivElement | null>;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
// Close on outside click, but treat the toggle button (which lives inside
|
||||||
|
// the same wrapper) as part of the modal so it never fights the toggle.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const close = (e: MouseEvent) => {
|
const close = (e: MouseEvent) => {
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", close);
|
document.addEventListener("mousedown", close);
|
||||||
return () => document.removeEventListener("mousedown", close);
|
return () => document.removeEventListener("mousedown", close);
|
||||||
}, [open, setOpen]);
|
}, [open, setOpen, wrapRef]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -213,10 +273,7 @@ function SortModal({
|
|||||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3">
|
||||||
ref={ref}
|
|
||||||
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-semibold text-text">Sort Rules</span>
|
<span className="text-xs font-semibold text-text">Sort Rules</span>
|
||||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||||
@@ -272,6 +329,7 @@ function BulkActionsDropdown({
|
|||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const addChange = useDbViewerStore((s) => s.addChange);
|
const addChange = useDbViewerStore((s) => s.addChange);
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
const copyToClipboard = (text: string) => {
|
||||||
@@ -338,7 +396,7 @@ function BulkActionsDropdown({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative" ref={wrapRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
@@ -347,7 +405,7 @@ function BulkActionsDropdown({
|
|||||||
<span className="text-xs font-medium">Actions</span>
|
<span className="text-xs font-medium">Actions</span>
|
||||||
<ChevronDown size={12} />
|
<ChevronDown size={12} />
|
||||||
</button>
|
</button>
|
||||||
<DropdownMenu open={open} setOpen={setOpen} align="right">
|
<DropdownMenu open={open} setOpen={setOpen} align="right" wrapRef={wrapRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyJSON}
|
onClick={handleCopyJSON}
|
||||||
@@ -409,6 +467,8 @@ interface TableControlsProps {
|
|||||||
isMatview?: boolean;
|
isMatview?: boolean;
|
||||||
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
|
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
|
||||||
variant?: "table" | "query";
|
variant?: "table" | "query";
|
||||||
|
/** Open the full-result streamed export dialog. */
|
||||||
|
onExportToFile?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableControls({
|
export function TableControls({
|
||||||
@@ -430,6 +490,7 @@ export function TableControls({
|
|||||||
defaultRefreshRate = 0,
|
defaultRefreshRate = 0,
|
||||||
isMatview = false,
|
isMatview = false,
|
||||||
variant = "table",
|
variant = "table",
|
||||||
|
onExportToFile,
|
||||||
}: TableControlsProps) {
|
}: TableControlsProps) {
|
||||||
const isQuery = variant === "query";
|
const isQuery = variant === "query";
|
||||||
const notify = useNotificationStore((s) => s.notify);
|
const notify = useNotificationStore((s) => s.notify);
|
||||||
@@ -445,11 +506,16 @@ export function TableControls({
|
|||||||
|
|
||||||
// local state
|
// local state
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const filterWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [sortOpen, setSortOpen] = useState(false);
|
const [sortOpen, setSortOpen] = useState(false);
|
||||||
|
const sortWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
||||||
|
const columnMenuWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
const exportWrapRef = useRef<HTMLDivElement>(null);
|
||||||
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
||||||
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
||||||
|
const autoRefreshWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Auto-refresh: a self-restarting timer that only counts down while the tab
|
// Auto-refresh: a self-restarting timer that only counts down while the tab
|
||||||
// is idle. Fires a refresh, waits for it to complete (loading → false),
|
// is idle. Fires a refresh, waits for it to complete (loading → false),
|
||||||
@@ -484,8 +550,14 @@ export function TableControls({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInsertRow = () => {
|
const handleInsertRow = () => {
|
||||||
|
// Build the insert template from editable columns only. PK / generated /
|
||||||
|
// identity columns are omitted so their defaults (serial, identity,
|
||||||
|
// generated) apply on commit — an explicit NULL would bypass them and
|
||||||
|
// fail with a NOT NULL violation.
|
||||||
const newData: Record<string, unknown> = {};
|
const newData: Record<string, unknown> = {};
|
||||||
columns.forEach((c) => { newData[c.name] = null; });
|
columns.forEach((c) => {
|
||||||
|
if (c.editable && !c.is_pk && !c.is_generated) newData[c.name] = null;
|
||||||
|
});
|
||||||
addChange({
|
addChange({
|
||||||
type: "insert",
|
type: "insert",
|
||||||
schema,
|
schema,
|
||||||
@@ -545,7 +617,7 @@ export function TableControls({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const exportControl = (
|
const exportControl = (
|
||||||
<div className="relative">
|
<div className="relative" ref={exportWrapRef}>
|
||||||
<Tooltip content="Export" side="bottom">
|
<Tooltip content="Export" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -556,7 +628,7 @@ export function TableControls({
|
|||||||
<Download size={14} />
|
<Download size={14} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
|
<DropdownMenu open={exportOpen} setOpen={setExportOpen} wrapRef={exportWrapRef}>
|
||||||
{EXPORT_FORMATS.map((fmt) => (
|
{EXPORT_FORMATS.map((fmt) => (
|
||||||
<button
|
<button
|
||||||
key={fmt.ext}
|
key={fmt.ext}
|
||||||
@@ -567,12 +639,27 @@ export function TableControls({
|
|||||||
{fmt.label}
|
{fmt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{onExportToFile && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 h-px bg-border" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setExportOpen(false);
|
||||||
|
onExportToFile();
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Export all rows to file…
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const columnsControl = (
|
const columnsControl = (
|
||||||
<div className="relative">
|
<div className="relative" ref={columnMenuWrapRef}>
|
||||||
<Tooltip content="Show/hide columns" side="bottom">
|
<Tooltip content="Show/hide columns" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -587,6 +674,7 @@ export function TableControls({
|
|||||||
open={columnMenuOpen}
|
open={columnMenuOpen}
|
||||||
setOpen={setColumnMenuOpen}
|
setOpen={setColumnMenuOpen}
|
||||||
align={isQuery ? "left" : "right"}
|
align={isQuery ? "left" : "right"}
|
||||||
|
wrapRef={columnMenuWrapRef}
|
||||||
>
|
>
|
||||||
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
||||||
Visible columns
|
Visible columns
|
||||||
@@ -658,7 +746,7 @@ export function TableControls({
|
|||||||
{refreshControl}
|
{refreshControl}
|
||||||
|
|
||||||
{/* Auto-refresh */}
|
{/* Auto-refresh */}
|
||||||
<div className="relative">
|
<div className="relative" ref={autoRefreshWrapRef}>
|
||||||
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -672,7 +760,7 @@ export function TableControls({
|
|||||||
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
|
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen} wrapRef={autoRefreshWrapRef}>
|
||||||
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -692,7 +780,7 @@ export function TableControls({
|
|||||||
<div className="w-px h-4 bg-border mx-1" />
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<div className="relative">
|
<div className="relative" ref={filterWrapRef}>
|
||||||
<Tooltip content="Column filters" side="bottom">
|
<Tooltip content="Column filters" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -716,11 +804,12 @@ export function TableControls({
|
|||||||
onChange={onFilterChange}
|
onChange={onFilterChange}
|
||||||
open={filterOpen}
|
open={filterOpen}
|
||||||
setOpen={setFilterOpen}
|
setOpen={setFilterOpen}
|
||||||
|
wrapRef={filterWrapRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort */}
|
{/* Sort */}
|
||||||
<div className="relative">
|
<div className="relative" ref={sortWrapRef}>
|
||||||
<Tooltip content="Sort rules" side="bottom">
|
<Tooltip content="Sort rules" side="bottom">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -744,6 +833,7 @@ export function TableControls({
|
|||||||
onChange={onSortChange}
|
onChange={onSortChange}
|
||||||
open={sortOpen}
|
open={sortOpen}
|
||||||
setOpen={setSortOpen}
|
setOpen={setSortOpen}
|
||||||
|
wrapRef={sortWrapRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import { SelectDropdown } from "../ui/SelectDropdown";
|
|||||||
import { BackupPage } from "./BackupPage";
|
import { BackupPage } from "./BackupPage";
|
||||||
import { RestorePage } from "./RestorePage";
|
import { RestorePage } from "./RestorePage";
|
||||||
import { SyncPage } from "./SyncPage";
|
import { SyncPage } from "./SyncPage";
|
||||||
|
import { SchemaDiffPage } from "./SchemaDiffPage";
|
||||||
|
|
||||||
type ToolOperation = "backup" | "restore" | "sync";
|
type ToolOperation = "backup" | "restore" | "sync" | "diff";
|
||||||
|
|
||||||
const OPERATION_OPTIONS = [
|
const OPERATION_OPTIONS = [
|
||||||
{ value: "backup", label: "Backup" },
|
{ value: "backup", label: "Backup" },
|
||||||
{ value: "restore", label: "Restore" },
|
{ value: "restore", label: "Restore" },
|
||||||
{ value: "sync", label: "DB Sync" },
|
{ value: "sync", label: "DB Sync" },
|
||||||
|
{ value: "diff", label: "Schema Diff" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ToolsPage({ connectionId }: { connectionId: string }) {
|
export function ToolsPage({ connectionId }: { connectionId: string }) {
|
||||||
@@ -33,6 +35,7 @@ export function ToolsPage({ connectionId }: { connectionId: string }) {
|
|||||||
{operation === "backup" && <BackupPage connectionId={connectionId} />}
|
{operation === "backup" && <BackupPage connectionId={connectionId} />}
|
||||||
{operation === "restore" && <RestorePage connectionId={connectionId} />}
|
{operation === "restore" && <RestorePage connectionId={connectionId} />}
|
||||||
{operation === "sync" && <SyncPage />}
|
{operation === "sync" && <SyncPage />}
|
||||||
|
{operation === "diff" && <SchemaDiffPage connectionId={connectionId} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
|
Layers,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
SquareFunction,
|
SquareFunction,
|
||||||
Tag,
|
Tag,
|
||||||
@@ -29,6 +30,7 @@ export const TYPE_LABELS: Record<ObjectType, string> = {
|
|||||||
sequences: "Sequences",
|
sequences: "Sequences",
|
||||||
enums: "Enums",
|
enums: "Enums",
|
||||||
extensions: "Extensions",
|
extensions: "Extensions",
|
||||||
|
hypertables: "Hypertables",
|
||||||
indexes: "Indexes",
|
indexes: "Indexes",
|
||||||
constraints: "Constraints",
|
constraints: "Constraints",
|
||||||
procedures: "Procedures",
|
procedures: "Procedures",
|
||||||
@@ -41,6 +43,7 @@ export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
|||||||
sequences: "sequence",
|
sequences: "sequence",
|
||||||
enums: "enum",
|
enums: "enum",
|
||||||
extensions: "extension",
|
extensions: "extension",
|
||||||
|
hypertables: "hypertable",
|
||||||
indexes: "index",
|
indexes: "index",
|
||||||
constraints: "constraint",
|
constraints: "constraint",
|
||||||
procedures: "procedure",
|
procedures: "procedure",
|
||||||
@@ -55,6 +58,7 @@ export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
|
|||||||
sequences: <ListOrdered 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" />,
|
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||||
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
||||||
|
hypertables: <Layers size={14} className="text-text-muted shrink-0" />,
|
||||||
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
|
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
|
||||||
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
|
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
|
||||||
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
|
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { DbType } from "../../lib/types";
|
|||||||
const DB_TYPE_LABELS: Record<DbType, string> = {
|
const DB_TYPE_LABELS: Record<DbType, string> = {
|
||||||
postgresql: "PostgreSQL",
|
postgresql: "PostgreSQL",
|
||||||
mysql: "MySQL",
|
mysql: "MySQL",
|
||||||
|
mariadb: "MariaDB",
|
||||||
sqlite: "SQLite",
|
sqlite: "SQLite",
|
||||||
redis: "Redis",
|
redis: "Redis",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ interface Props {
|
|||||||
isJson: boolean;
|
isJson: boolean;
|
||||||
isFk: boolean;
|
isFk: boolean;
|
||||||
nullable: boolean;
|
nullable: boolean;
|
||||||
|
/** When true, hide row-scoped actions (View Row / Select Row) — pending insert rows have no backing row. */
|
||||||
|
isInsertRow?: boolean;
|
||||||
onCopy: () => void; onCopyJson: () => void; onEdit: () => void;
|
onCopy: () => void; onCopyJson: () => void; onEdit: () => void;
|
||||||
onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
|
onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
|
||||||
onViewRow: () => void; onSelectRow: () => void;
|
onViewRow: () => void; onSelectRow: () => void;
|
||||||
}
|
}
|
||||||
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
|
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, isInsertRow = false, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
|
||||||
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
|
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
|
||||||
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
|
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
|
||||||
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
|
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
|
||||||
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: true },
|
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: !isInsertRow },
|
||||||
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: true },
|
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: !isInsertRow },
|
||||||
{ label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable },
|
{ label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable },
|
||||||
{ label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable },
|
{ label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable },
|
||||||
{ label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
|
{ label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
|
||||||
|
|||||||
@@ -13,6 +13,42 @@ describe("CellEditor", () => {
|
|||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
expect(onCommit).toHaveBeenCalledWith("Alicia");
|
expect(onCommit).toHaveBeenCalledWith("Alicia");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a native date input for date columns", () => {
|
||||||
|
render(<CellEditor initialValue="2024-01-15" dataType="date" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
const input = screen.getByDisplayValue("2024-01-15");
|
||||||
|
expect(input).toHaveAttribute("type", "date");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a datetime-local input for timestamp columns", () => {
|
||||||
|
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
const input = screen.getByDisplayValue("2024-01-15T10:30");
|
||||||
|
expect(input).toHaveAttribute("type", "datetime-local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits a datetime-local value in canonical DB format (space separator + seconds)", () => {
|
||||||
|
const onCommit = vi.fn();
|
||||||
|
render(<CellEditor initialValue="2024-01-15 10:30:00" dataType="timestamp without time zone" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||||
|
const input = screen.getByDisplayValue("2024-01-15T10:30");
|
||||||
|
fireEvent.change(input, { target: { value: "2024-01-15T11:45" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
expect(onCommit).toHaveBeenCalledWith("2024-01-15 11:45:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a boolean select with true/false options", () => {
|
||||||
|
render(<CellEditor initialValue="true" dataType="boolean" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
const select = screen.getByRole("combobox");
|
||||||
|
expect(select).toHaveValue("true");
|
||||||
|
expect(screen.getByRole("option", { name: "true" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("option", { name: "false" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a number input for integer columns", () => {
|
||||||
|
render(<CellEditor initialValue="42" dataType="integer" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
const input = screen.getByRole("spinbutton");
|
||||||
|
expect(input).toHaveAttribute("type", "number");
|
||||||
|
expect(input).toHaveAttribute("step", "1");
|
||||||
|
});
|
||||||
it("commits null when a nullable cell is emptied", () => {
|
it("commits null when a nullable cell is emptied", () => {
|
||||||
const onCommit = vi.fn();
|
const onCommit = vi.fn();
|
||||||
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
|
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
|
||||||
@@ -40,7 +76,7 @@ describe("CellEditor", () => {
|
|||||||
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
|
it("blocks empty commits on non-nullable non-text columns and shows an error", () => {
|
||||||
const onCommit = vi.fn();
|
const onCommit = vi.fn();
|
||||||
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||||
const input = screen.getByRole("textbox");
|
const input = screen.getByRole("spinbutton");
|
||||||
fireEvent.change(input, { target: { value: "" } });
|
fireEvent.change(input, { target: { value: "" } });
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
expect(onCommit).not.toHaveBeenCalled();
|
expect(onCommit).not.toHaveBeenCalled();
|
||||||
@@ -57,7 +93,7 @@ describe("CellEditor", () => {
|
|||||||
it("clears the validation error as soon as the user types again", () => {
|
it("clears the validation error as soon as the user types again", () => {
|
||||||
const onCommit = vi.fn();
|
const onCommit = vi.fn();
|
||||||
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
render(<CellEditor initialValue="42" dataType="integer" onCommit={onCommit} onCancel={vi.fn()} />);
|
||||||
const input = screen.getByRole("textbox");
|
const input = screen.getByRole("spinbutton");
|
||||||
fireEvent.change(input, { target: { value: "" } });
|
fireEvent.change(input, { target: { value: "" } });
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
|
expect(screen.getByTestId("cell-editor-error")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -30,6 +30,45 @@ const controlClass =
|
|||||||
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
|
/** Types that tolerate an empty string when NOT NULL ('' is a valid value). */
|
||||||
const TEXT_LIKE = ["char", "text", "uuid", "bit"];
|
const TEXT_LIKE = ["char", "text", "uuid", "bit"];
|
||||||
|
|
||||||
|
/** Map a column data type to a "smart" input kind. */
|
||||||
|
type InputKind = "date" | "datetime" | "time" | "boolean" | "number" | "text";
|
||||||
|
|
||||||
|
const NUMERIC_TYPES = new Set([
|
||||||
|
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
|
||||||
|
"tinyint", "mediumint", "numeric", "decimal", "real", "double",
|
||||||
|
"double precision", "float", "float4", "float8", "money",
|
||||||
|
"serial", "bigserial", "smallserial",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const INTEGER_TYPES = new Set([
|
||||||
|
"integer", "bigint", "smallint", "int", "int2", "int4", "int8",
|
||||||
|
"tinyint", "mediumint", "serial", "bigserial", "smallserial",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function inputKindForType(dataType: string): InputKind {
|
||||||
|
const t = dataType.toLowerCase().trim();
|
||||||
|
if (t === "date") return "date";
|
||||||
|
if (t.includes("timestamp") || t === "datetime") return "datetime";
|
||||||
|
if (t === "time" || t === "timetz" || t.startsWith("time ")) return "time";
|
||||||
|
if (t === "boolean" || t === "bool") return "boolean";
|
||||||
|
if (NUMERIC_TYPES.has(t)) return "number";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a DB timestamp ("2024-01-15 10:30:00") to datetime-local ("2024-01-15T10:30"). */
|
||||||
|
function toDatetimeLocal(v: string): string {
|
||||||
|
const m = v.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
|
||||||
|
return m ? `${m[1]}T${m[2]}` : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a datetime-local value ("2024-01-15T10:30") to the canonical DB
|
||||||
|
* format ("2024-01-15 10:30:00") accepted by both PostgreSQL and MySQL. */
|
||||||
|
function fromDatetimeLocal(v: string): string {
|
||||||
|
const m = v.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?/);
|
||||||
|
if (m) return `${m[1]} ${m[2]}:${m[3] ?? "00"}`;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
export function CellEditor({
|
export function CellEditor({
|
||||||
initialValue,
|
initialValue,
|
||||||
dataType,
|
dataType,
|
||||||
@@ -40,7 +79,11 @@ export function CellEditor({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: CellEditorProps) {
|
}: CellEditorProps) {
|
||||||
const [value, setValue] = useState(initialValue);
|
const kind = inputKindForType(dataType);
|
||||||
|
const isInteger = INTEGER_TYPES.has(dataType.toLowerCase().trim());
|
||||||
|
const [value, setValue] = useState(
|
||||||
|
kind === "datetime" ? toDatetimeLocal(initialValue) : initialValue,
|
||||||
|
);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -73,6 +116,8 @@ export function CellEditor({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enumValues && enumValues.length > 0) {
|
if (enumValues && enumValues.length > 0) {
|
||||||
enumRef.current?.focus();
|
enumRef.current?.focus();
|
||||||
|
} else if (kind === "boolean") {
|
||||||
|
enumRef.current?.focus();
|
||||||
} else if (fkOptions && fkOptions.length > 0) {
|
} else if (fkOptions && fkOptions.length > 0) {
|
||||||
searchRef.current?.focus();
|
searchRef.current?.focus();
|
||||||
searchRef.current?.select();
|
searchRef.current?.select();
|
||||||
@@ -258,6 +303,63 @@ export function CellEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind === "boolean") {
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={`flex h-full w-full items-center gap-1.5 px-1.5 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}>
|
||||||
|
<select
|
||||||
|
ref={enumRef}
|
||||||
|
className={controlClass}
|
||||||
|
value={initialValue}
|
||||||
|
onChange={(e) => commitRaw(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{nullable ? "NULL" : "—"}</option>
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
{errorBubble}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "date" || kind === "datetime" || kind === "time" || kind === "number") {
|
||||||
|
const inputType = kind === "datetime" ? "datetime-local" : kind;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
data-testid="cell-editor"
|
||||||
|
className={`flex h-full w-full items-center gap-2 ${error ? "ring-1 ring-inset ring-red-500/60" : ""}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={ref as any}
|
||||||
|
type={inputType}
|
||||||
|
className={inputClass}
|
||||||
|
value={value}
|
||||||
|
step={kind === "number" ? (isInteger ? "1" : "any") : undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commitRaw(kind === "datetime" ? fromDatetimeLocal(value) : value);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errorBubble}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const cls = large
|
const cls = large
|
||||||
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
|
? `${inputClass} h-4 resize-none overflow-y-auto whitespace-pre leading-none py-0.5`
|
||||||
: inputClass;
|
: inputClass;
|
||||||
|
|||||||
@@ -48,6 +48,21 @@ describe("VirtualDataGrid", () => {
|
|||||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a pending insert row with an 'auto' placeholder and no selection checkbox", () => {
|
||||||
|
const rows = [[null, null], ...mockRows];
|
||||||
|
mockGetTotalSize.mockReturnValue(rows.length * 36);
|
||||||
|
mockGetVirtualItems.mockReturnValue(rows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||||
|
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={rows} columns={mockColumns}
|
||||||
|
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||||
|
dbType="postgresql" tabType="table" pendingInsertChangeIds={["ch-1"]} />);
|
||||||
|
// Non-editable PK column in the insert row shows "auto" instead of NULL.
|
||||||
|
expect(screen.getByText("auto")).toBeInTheDocument();
|
||||||
|
// The insert row shows a "+" marker instead of a selection checkbox.
|
||||||
|
expect(screen.getByTitle("New row")).toBeInTheDocument();
|
||||||
|
// Real rows still render their checkboxes (2 real rows).
|
||||||
|
expect(screen.getAllByRole("checkbox")).toHaveLength(3); // 1 select-all + 2 row checkboxes
|
||||||
|
});
|
||||||
|
|
||||||
it("shows staged values passed from the parent + a pending outline", () => {
|
it("shows staged values passed from the parent + a pending outline", () => {
|
||||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ interface VirtualDataGridProps {
|
|||||||
stagedValues?: Record<string, string | null>;
|
stagedValues?: Record<string, string | null>;
|
||||||
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
|
/** Keys of cells with a PENDING (not yet committed) update → drives the pulsing orange outline. */
|
||||||
pendingKeys?: Record<string, boolean>;
|
pendingKeys?: Record<string, boolean>;
|
||||||
|
/** Ordered change ids of pending insert rows prepended to `rows`. */
|
||||||
|
pendingInsertChangeIds?: string[];
|
||||||
|
/** Stage a cell edit on a pending insert row (updates the insert's newData). */
|
||||||
|
onStageInsertCell?: (changeId: string, column: string, value: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROW_HEIGHT = 36;
|
const ROW_HEIGHT = 36;
|
||||||
@@ -74,20 +78,24 @@ export function VirtualDataGrid({
|
|||||||
fkPlaceholders,
|
fkPlaceholders,
|
||||||
stagedValues,
|
stagedValues,
|
||||||
pendingKeys,
|
pendingKeys,
|
||||||
|
pendingInsertChangeIds,
|
||||||
|
onStageInsertCell,
|
||||||
}: VirtualDataGridProps) {
|
}: VirtualDataGridProps) {
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
|
const visibleColumns = columns.filter((c) => !hiddenColumns.has(c.name));
|
||||||
const hasColumns = columns.length > 0;
|
const hasColumns = columns.length > 0;
|
||||||
const allSelected = rows.length > 0 && selectedRows.size === rows.length;
|
const pendingInsertCount = pendingInsertChangeIds?.length ?? 0;
|
||||||
|
const realRowCount = rows.length - pendingInsertCount;
|
||||||
|
const allSelected = realRowCount > 0 && selectedRows.size === realRowCount;
|
||||||
const selectAllRef = useRef<HTMLInputElement>(null);
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Indeterminate state for partial selection
|
// Indeterminate state for partial selection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectAllRef.current) {
|
if (selectAllRef.current) {
|
||||||
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < rows.length;
|
selectAllRef.current.indeterminate = selectedRows.size > 0 && selectedRows.size < realRowCount;
|
||||||
}
|
}
|
||||||
}, [selectedRows.size, rows.length]);
|
}, [selectedRows.size, realRowCount]);
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
@@ -282,6 +290,8 @@ export function VirtualDataGrid({
|
|||||||
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
|
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
|
||||||
const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
|
const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
|
||||||
const editable = isCellEditable(col, tabType, dbType, readOnly);
|
const editable = isCellEditable(col, tabType, dbType, readOnly);
|
||||||
|
const isInsertRow = rowIndex < pendingInsertCount;
|
||||||
|
const insertChangeId = isInsertRow ? pendingInsertChangeIds?.[rowIndex] : undefined;
|
||||||
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
|
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
|
||||||
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
|
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
|
||||||
const isPending =
|
const isPending =
|
||||||
@@ -307,23 +317,27 @@ export function VirtualDataGrid({
|
|||||||
// revert/display stays correct even after repeated edits of the same cell.
|
// revert/display stays correct even after repeated edits of the same cell.
|
||||||
const dbValue = ci >= 0 ? row[ci] : undefined;
|
const dbValue = ci >= 0 ? row[ci] : undefined;
|
||||||
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
|
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
|
||||||
const locator = getLocator?.(row) ?? {};
|
if (isInsertRow && insertChangeId) {
|
||||||
onStageEdit?.(
|
onStageInsertCell?.(insertChangeId, col.name, committed);
|
||||||
cellToUpdateChange({
|
} else {
|
||||||
schema,
|
const locator = getLocator?.(row) ?? {};
|
||||||
table,
|
onStageEdit?.(
|
||||||
primaryKey: locator,
|
cellToUpdateChange({
|
||||||
oldData: { [col.name]: dbValue },
|
schema,
|
||||||
newData: { [col.name]: committed },
|
table,
|
||||||
}) as {
|
primaryKey: locator,
|
||||||
type: "update";
|
oldData: { [col.name]: dbValue },
|
||||||
schema: string;
|
newData: { [col.name]: committed },
|
||||||
table: string;
|
}) as {
|
||||||
primaryKey: Record<string, unknown>;
|
type: "update";
|
||||||
oldData: Record<string, unknown>;
|
schema: string;
|
||||||
newData: Record<string, unknown>;
|
table: string;
|
||||||
},
|
primaryKey: Record<string, unknown>;
|
||||||
);
|
oldData: Record<string, unknown>;
|
||||||
|
newData: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setPendingCellKey(cellKey);
|
setPendingCellKey(cellKey);
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
@@ -355,13 +369,15 @@ export function VirtualDataGrid({
|
|||||||
}
|
}
|
||||||
style={{ width: getWidth(col.name), flexShrink: 0 }}
|
style={{ width: getWidth(col.name), flexShrink: 0 }}
|
||||||
title={
|
title={
|
||||||
isNull
|
isInsertRow && !editable
|
||||||
? "NULL"
|
? "auto (default)"
|
||||||
: isFk
|
: isNull
|
||||||
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
|
? "NULL"
|
||||||
: isJson
|
: isFk
|
||||||
? "Click to view JSON"
|
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
|
||||||
: String(displayCell)
|
: isJson
|
||||||
|
? "Click to view JSON"
|
||||||
|
: String(displayCell)
|
||||||
}
|
}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
setActiveCell({ row: rowIndex, col: colIndex });
|
setActiveCell({ row: rowIndex, col: colIndex });
|
||||||
@@ -390,6 +406,8 @@ export function VirtualDataGrid({
|
|||||||
onCancel={() => setEditingCell(null)}
|
onCancel={() => setEditingCell(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : isInsertRow && !editable ? (
|
||||||
|
<span className="italic text-text-muted/60">auto</span>
|
||||||
) : isNull ? (
|
) : isNull ? (
|
||||||
<span className="italic text-text-muted">NULL</span>
|
<span className="italic text-text-muted">NULL</span>
|
||||||
) : isJson ? (
|
) : isJson ? (
|
||||||
@@ -420,7 +438,7 @@ export function VirtualDataGrid({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey],
|
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, onStageInsertCell, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey, pendingInsertCount, pendingInsertChangeIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── context menu helpers ──────────────────────────────
|
// ── context menu helpers ──────────────────────────────
|
||||||
@@ -446,6 +464,11 @@ export function VirtualDataGrid({
|
|||||||
const ci = columns.findIndex((c) => c.name === column.name);
|
const ci = columns.findIndex((c) => c.name === column.name);
|
||||||
const value = rows[row]?.[ci];
|
const value = rows[row]?.[ci];
|
||||||
if (value === null || value === undefined) return;
|
if (value === null || value === undefined) return;
|
||||||
|
if (row < pendingInsertCount) {
|
||||||
|
const changeId = pendingInsertChangeIds?.[row];
|
||||||
|
if (changeId) onStageInsertCell?.(changeId, column.name, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const locator = getLocator?.(rows[row]) ?? {};
|
const locator = getLocator?.(rows[row]) ?? {};
|
||||||
onStageEdit?.(
|
onStageEdit?.(
|
||||||
cellToUpdateChange({
|
cellToUpdateChange({
|
||||||
@@ -464,7 +487,7 @@ export function VirtualDataGrid({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns],
|
[columns, dbType, getLocator, onStageEdit, onStageInsertCell, readOnly, rows, schema, table, tabType, visibleColumns, pendingInsertCount, pendingInsertChangeIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -528,15 +551,18 @@ export function VirtualDataGrid({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const row = rows[virtualRow.index];
|
const gridIndex = virtualRow.index;
|
||||||
const isSelected = selectedRows.has(virtualRow.index);
|
const isInsertRow = gridIndex < pendingInsertCount;
|
||||||
|
const realIndex = isInsertRow ? -1 : gridIndex - pendingInsertCount;
|
||||||
|
const row = rows[gridIndex];
|
||||||
|
const isSelected = !isInsertRow && selectedRows.has(realIndex);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={virtualRow.key}
|
key={virtualRow.key}
|
||||||
data-index={virtualRow.index}
|
data-index={gridIndex}
|
||||||
className={`flex items-center border-b border-border ${
|
className={`flex items-center border-b border-border ${
|
||||||
isSelected ? "bg-accent/5" : ""
|
isSelected ? "bg-accent/5" : ""
|
||||||
} hover:bg-surface/50`}
|
} ${isInsertRow ? "bg-accent/[0.03] ring-1 ring-inset ring-accent/60" : ""} hover:bg-surface/50`}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -546,20 +572,28 @@ export function VirtualDataGrid({
|
|||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasColumns && (
|
{hasColumns &&
|
||||||
<div
|
(isInsertRow ? (
|
||||||
style={{ width: 40, minWidth: 40 }}
|
<div
|
||||||
className="flex items-center justify-center border-r border-border self-stretch"
|
style={{ width: 40, minWidth: 40 }}
|
||||||
>
|
className="flex items-center justify-center border-r border-border self-stretch"
|
||||||
<input
|
>
|
||||||
type="checkbox"
|
<span className="text-[10px] text-accent/70" title="New row">+</span>
|
||||||
checked={isSelected}
|
</div>
|
||||||
onChange={() => onToggleRow(virtualRow.index)}
|
) : (
|
||||||
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
<div
|
||||||
/>
|
style={{ width: 40, minWidth: 40 }}
|
||||||
</div>
|
className="flex items-center justify-center border-r border-border self-stretch"
|
||||||
)}
|
>
|
||||||
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => onToggleRow(realIndex)}
|
||||||
|
className="w-3.5 h-3.5 rounded border-border cursor-pointer accent-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{visibleColumns.map((col, i) => renderCell(col, row, gridIndex, i))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -601,6 +635,7 @@ export function VirtualDataGrid({
|
|||||||
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
|
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
|
||||||
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
|
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
|
||||||
nullable={ctxCol.is_nullable}
|
nullable={ctxCol.is_nullable}
|
||||||
|
isInsertRow={ctxMenu.row < pendingInsertCount}
|
||||||
onCopy={() => {
|
onCopy={() => {
|
||||||
void copyCellValue(ctxMenu.row, ctxMenu.col);
|
void copyCellValue(ctxMenu.row, ctxMenu.col);
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
@@ -610,11 +645,11 @@ export function VirtualDataGrid({
|
|||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
}}
|
}}
|
||||||
onViewRow={() => {
|
onViewRow={() => {
|
||||||
onOpenRowDetail?.(ctxMenu.row);
|
onOpenRowDetail?.(ctxMenu.row - pendingInsertCount);
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
}}
|
}}
|
||||||
onSelectRow={() => {
|
onSelectRow={() => {
|
||||||
onToggleRow(ctxMenu.row);
|
onToggleRow(ctxMenu.row - pendingInsertCount);
|
||||||
setCtxMenu(null);
|
setCtxMenu(null);
|
||||||
}}
|
}}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { DbType } from "../../lib/types";
|
|||||||
const DB_TYPES: { value: DbType; label: string }[] = [
|
const DB_TYPES: { value: DbType; label: string }[] = [
|
||||||
{ value: "postgresql", label: "PostgreSQL" },
|
{ value: "postgresql", label: "PostgreSQL" },
|
||||||
{ value: "mysql", label: "MySQL" },
|
{ value: "mysql", label: "MySQL" },
|
||||||
|
{ value: "mariadb", label: "MariaDB" },
|
||||||
{ value: "sqlite", label: "SQLite" },
|
{ value: "sqlite", label: "SQLite" },
|
||||||
{ value: "redis", label: "Redis" },
|
{ value: "redis", label: "Redis" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||||
|
|
||||||
describe("tauri bundle config (v0.7.12)", () => {
|
describe("tauri bundle config (v0.8.0)", () => {
|
||||||
it("declares bundled pg_tools resources", () => {
|
it("declares bundled pg_tools resources", () => {
|
||||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||||
});
|
});
|
||||||
it("version is 0.7.12", () => {
|
it("version is 0.8.0", () => {
|
||||||
expect(tauriConf.version).toBe("0.7.12");
|
expect(tauriConf.version).toBe("0.8.0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -45,6 +45,10 @@ describe("buildChangeSql", () => {
|
|||||||
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
|
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: { a: 1, b: "x" } } as any;
|
||||||
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
|
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" ("a", "b") VALUES (1, \'x\')');
|
||||||
});
|
});
|
||||||
|
it("insert with no columns falls back to DEFAULT VALUES", () => {
|
||||||
|
const item = { id: "1", type: "insert", schema: "public", table: "t", newData: {} } as any;
|
||||||
|
expect(buildChangeSql(item)).toBe('INSERT INTO "public"."t" DEFAULT VALUES');
|
||||||
|
});
|
||||||
it("update", () => {
|
it("update", () => {
|
||||||
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
|
const item = { id: "1", type: "update", schema: "public", table: "t", primaryKey: { id: 5 }, newData: { name: "O'Brien" } } as any;
|
||||||
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
|
expect(buildChangeSql(item)).toBe('UPDATE "public"."t" SET "name" = \'O\'\'Brien\' WHERE "id" = 5');
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function buildChangeSql(item: QueueItem): string {
|
|||||||
case "insert": {
|
case "insert": {
|
||||||
const data = item.newData ?? {};
|
const data = item.newData ?? {};
|
||||||
const cols = Object.keys(data);
|
const cols = Object.keys(data);
|
||||||
|
if (cols.length === 0) return `INSERT INTO ${t} DEFAULT VALUES`;
|
||||||
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
|
return `INSERT INTO ${t} (${cols.map(q).join(", ")}) VALUES (${cols.map((c) => lit(data[c])).join(", ")})`;
|
||||||
}
|
}
|
||||||
case "update": {
|
case "update": {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types";
|
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse, MultiQueryResult } from "./types";
|
||||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||||
import type { ChangePayload } from "./changePayload";
|
import type { ChangePayload } from "./changePayload";
|
||||||
|
import type { DiffReport } from "./types";
|
||||||
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
|
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
|
||||||
|
|
||||||
export { type ObjectKind, type DdlParams };
|
export { type ObjectKind, type DdlParams };
|
||||||
@@ -226,6 +227,13 @@ export async function getExtensions(connectionId: string): Promise<ExtensionInfo
|
|||||||
return invoke<ExtensionInfo[]>("get_extensions", { connectionId });
|
return invoke<ExtensionInfo[]>("get_extensions", { connectionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getHypertables(
|
||||||
|
connectionId: string,
|
||||||
|
schema?: string,
|
||||||
|
): Promise<HypertableListResponse> {
|
||||||
|
return invoke<HypertableListResponse>("get_hypertables", { connectionId, schema });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSchemaGraph(
|
export async function getSchemaGraph(
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
schema?: string,
|
schema?: string,
|
||||||
@@ -279,6 +287,24 @@ export async function executeQuery(
|
|||||||
return invoke<QueryResult>("execute_query", { connectionId, query, page, pageSize });
|
return invoke<QueryResult>("execute_query", { connectionId, query, page, pageSize });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function executeQueryMulti(
|
||||||
|
connectionId: string,
|
||||||
|
query: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): Promise<MultiQueryResult> {
|
||||||
|
return invoke<MultiQueryResult>("execute_query_multi", { connectionId, query, page, pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportQueryToFile(
|
||||||
|
connectionId: string,
|
||||||
|
sql: string,
|
||||||
|
format: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<{ rows_written: number; path: string }> {
|
||||||
|
return invoke("export_query_to_file", { connectionId, sql, format, path });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getQueryHistory(
|
export async function getQueryHistory(
|
||||||
connectionId: string | null,
|
connectionId: string | null,
|
||||||
limit: number,
|
limit: number,
|
||||||
@@ -393,4 +419,14 @@ export async function getTablespaces(connectionId: string): Promise<TablespaceIn
|
|||||||
}
|
}
|
||||||
export async function buildRebuildScript<C extends { name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean }[]>(connectionId: string, schema: string, table: string, newColumns: C): Promise<string> {
|
export async function buildRebuildScript<C extends { name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean }[]>(connectionId: string, schema: string, table: string, newColumns: C): Promise<string> {
|
||||||
return invoke<string>("build_rebuild_script", { connectionId, schema, table, newColumns });
|
return invoke<string>("build_rebuild_script", { connectionId, schema, table, newColumns });
|
||||||
}
|
}
|
||||||
|
export async function compareSchemas(
|
||||||
|
sourceConnectionId: string,
|
||||||
|
sourceSchema: string,
|
||||||
|
targetConnectionId: string,
|
||||||
|
targetSchema: string,
|
||||||
|
): Promise<DiffReport> {
|
||||||
|
return invoke<DiffReport>("compare_schemas", {
|
||||||
|
sourceConnectionId, sourceSchema, targetConnectionId, targetSchema,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,6 +125,30 @@ describe("parseConnectionString", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses mariadb:// with the 3306 default port", () => {
|
||||||
|
const result = parseConnectionString("mariadb://root:pw@localhost/app");
|
||||||
|
expect(result).toEqual({
|
||||||
|
db_type: "mariadb",
|
||||||
|
host: "localhost",
|
||||||
|
port: 3306,
|
||||||
|
username: "root",
|
||||||
|
password: "pw",
|
||||||
|
database: "app",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps mysql:// mapped to the mysql db_type", () => {
|
||||||
|
const result = parseConnectionString("mysql://root:pw@localhost:3307/app");
|
||||||
|
expect(result).toEqual({
|
||||||
|
db_type: "mysql",
|
||||||
|
host: "localhost",
|
||||||
|
port: 3307,
|
||||||
|
username: "root",
|
||||||
|
password: "pw",
|
||||||
|
database: "app",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults Redis port to 6379 when omitted", () => {
|
it("defaults Redis port to 6379 when omitted", () => {
|
||||||
const result = parseConnectionString("redis://localhost");
|
const result = parseConnectionString("redis://localhost");
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -170,6 +194,19 @@ describe("detectProviderFromHost", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("detectProviderFromHost", () => {
|
||||||
|
it("detects PlanetScale access hosts", () => {
|
||||||
|
expect(detectProviderFromHost("xxxx.us-east-2.psdb.cloud")).toBe("planetscale");
|
||||||
|
expect(detectProviderFromHost("aws.connect.psdb.cloud")).toBe("planetscale");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still detects supabase and neon", () => {
|
||||||
|
expect(detectProviderFromHost("db.abc.supabase.co")).toBe("supabase");
|
||||||
|
expect(detectProviderFromHost("ep-1-pooler.eu.aws.neon.tech")).toBe("neon");
|
||||||
|
expect(detectProviderFromHost("example.com")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildConnectionUrl", () => {
|
describe("buildConnectionUrl", () => {
|
||||||
it("builds a PostgreSQL URL with password and sslmode", () => {
|
it("builds a PostgreSQL URL with password and sslmode", () => {
|
||||||
const url = buildConnectionUrl(
|
const url = buildConnectionUrl(
|
||||||
@@ -212,6 +249,22 @@ describe("buildConnectionUrl", () => {
|
|||||||
expect(url).toBe("mysql://root:pw@127.0.0.1:3306/app");
|
expect(url).toBe("mysql://root:pw@127.0.0.1:3306/app");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("emits mariadb:// for a mariadb connection", () => {
|
||||||
|
const url = buildConnectionUrl(
|
||||||
|
makeConn({ db_type: "mariadb", host: "127.0.0.1", port: 3306, username: "root", database: "app" }),
|
||||||
|
"pw",
|
||||||
|
);
|
||||||
|
expect(url).toBe("mariadb://root:pw@127.0.0.1:3306/app");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the password in the mariadb no-password variant", () => {
|
||||||
|
const url = buildConnectionUrl(
|
||||||
|
makeConn({ db_type: "mariadb", host: "127.0.0.1", port: 3306, username: "root", database: "app" }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(url).toBe("mariadb://root@127.0.0.1:3306/app");
|
||||||
|
});
|
||||||
|
|
||||||
it("builds a Redis URL with password but no username", () => {
|
it("builds a Redis URL with password but no username", () => {
|
||||||
const url = buildConnectionUrl(
|
const url = buildConnectionUrl(
|
||||||
makeConn({ db_type: "redis", host: "localhost", port: 6379, username: null, database: "0" }),
|
makeConn({ db_type: "redis", host: "localhost", port: 6379, username: null, database: "0" }),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const DB_PROTOCOLS: Record<string, DbType> = {
|
|||||||
"postgresql:": "postgresql",
|
"postgresql:": "postgresql",
|
||||||
"postgres:": "postgresql",
|
"postgres:": "postgresql",
|
||||||
"mysql:": "mysql",
|
"mysql:": "mysql",
|
||||||
|
"mariadb:": "mariadb",
|
||||||
"sqlite:": "sqlite",
|
"sqlite:": "sqlite",
|
||||||
"file:": "sqlite",
|
"file:": "sqlite",
|
||||||
"redis:": "redis",
|
"redis:": "redis",
|
||||||
@@ -22,6 +23,7 @@ const DB_PROTOCOLS: Record<string, DbType> = {
|
|||||||
const DEFAULT_PORTS: Record<DbType, number | null> = {
|
const DEFAULT_PORTS: Record<DbType, number | null> = {
|
||||||
postgresql: 5432,
|
postgresql: 5432,
|
||||||
mysql: 3306,
|
mysql: 3306,
|
||||||
|
mariadb: 3306,
|
||||||
sqlite: null,
|
sqlite: null,
|
||||||
redis: 6379,
|
redis: 6379,
|
||||||
};
|
};
|
||||||
@@ -83,10 +85,13 @@ export function looksLikeConnectionString(input: string): boolean {
|
|||||||
/** Detect a managed-PostgreSQL provider from a connection host, purely for
|
/** Detect a managed-PostgreSQL provider from a connection host, purely for
|
||||||
* UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is
|
* UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is
|
||||||
* unaffected (both presets persist as `postgresql`). */
|
* unaffected (both presets persist as `postgresql`). */
|
||||||
export function detectProviderFromHost(host: string): "supabase" | "neon" | null {
|
export function detectProviderFromHost(
|
||||||
|
host: string,
|
||||||
|
): "supabase" | "neon" | "planetscale" | null {
|
||||||
const h = host.toLowerCase();
|
const h = host.toLowerCase();
|
||||||
if (h.endsWith(".supabase.co")) return "supabase";
|
if (h.endsWith(".supabase.co")) return "supabase";
|
||||||
if (h.endsWith(".neon.tech")) return "neon";
|
if (h.endsWith(".neon.tech")) return "neon";
|
||||||
|
if (h.endsWith(".psdb.cloud")) return "planetscale";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +132,8 @@ export function buildConnectionUrl(conn: Connection, password: string | null): s
|
|||||||
}
|
}
|
||||||
case "mysql":
|
case "mysql":
|
||||||
return `mysql://${auth}${conn.host}${port}${db}`;
|
return `mysql://${auth}${conn.host}${port}${db}`;
|
||||||
|
case "mariadb":
|
||||||
|
return `mariadb://${auth}${conn.host}${port}${db}`;
|
||||||
case "sqlite":
|
case "sqlite":
|
||||||
// host stores the file path; `sqlite:///abs/path` round-trips through
|
// host stores the file path; `sqlite:///abs/path` round-trips through
|
||||||
// parseConnectionString (pathname → host).
|
// parseConnectionString (pathname → host).
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ describe("dbCapabilities", () => {
|
|||||||
expect(c.visualizer).toBe(false);
|
expect(c.visualizer).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives MariaDB the same capability set as MySQL", () => {
|
||||||
|
expect(DB_CAPABILITIES.mariadb).toEqual(DB_CAPABILITIES.mysql);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers exactly the five DbTypes", () => {
|
||||||
|
expect(Object.keys(DB_CAPABILITIES).sort()).toEqual(
|
||||||
|
["mariadb", "mysql", "postgresql", "redis", "sqlite"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives SQLite explorer/queries/visualizer/editing/import/ddl/tools/tableManagement but not objects", () => {
|
it("gives SQLite explorer/queries/visualizer/editing/import/ddl/tools/tableManagement but not objects", () => {
|
||||||
const c = DB_CAPABILITIES.sqlite;
|
const c = DB_CAPABILITIES.sqlite;
|
||||||
expect(c.explorer).toBe(true);
|
expect(c.explorer).toBe(true);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const ALL_FALSE: DbCapabilities = {
|
|||||||
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
|
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
|
||||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true },
|
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true },
|
||||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
|
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
|
||||||
|
mariadb: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true, tools: true },
|
||||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true, tools: true, tableManagement: true },
|
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true, tools: true, tableManagement: true },
|
||||||
redis: { ...ALL_FALSE },
|
redis: { ...ALL_FALSE },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { siPostgresql, siMysql, siSqlite, siRedis, siSupabase, siNeon } from "simple-icons";
|
import { siPostgresql, siMysql, siMariadb, siSqlite, siRedis, siSupabase, siNeon, siPlanetscale } from "simple-icons";
|
||||||
import type { DbType } from "./types";
|
import type { DbType } from "./types";
|
||||||
|
|
||||||
// Brand colors from simple-icons
|
// Brand colors from simple-icons
|
||||||
const DB_COLORS: Record<DbType, string> = {
|
const DB_COLORS: Record<DbType, string> = {
|
||||||
postgresql: `#${siPostgresql.hex}`,
|
postgresql: `#${siPostgresql.hex}`,
|
||||||
mysql: `#${siMysql.hex}`,
|
mysql: `#${siMysql.hex}`,
|
||||||
|
mariadb: `#${siMariadb.hex}`,
|
||||||
redis: `#${siRedis.hex}`,
|
redis: `#${siRedis.hex}`,
|
||||||
sqlite: `#${siSqlite.hex}`,
|
sqlite: `#${siSqlite.hex}`,
|
||||||
};
|
};
|
||||||
@@ -13,6 +14,7 @@ const DB_COLORS: Record<DbType, string> = {
|
|||||||
const DB_PATHS: Record<DbType, string> = {
|
const DB_PATHS: Record<DbType, string> = {
|
||||||
postgresql: siPostgresql.path,
|
postgresql: siPostgresql.path,
|
||||||
mysql: siMysql.path,
|
mysql: siMysql.path,
|
||||||
|
mariadb: siMariadb.path,
|
||||||
redis: siRedis.path,
|
redis: siRedis.path,
|
||||||
sqlite: siSqlite.path,
|
sqlite: siSqlite.path,
|
||||||
};
|
};
|
||||||
@@ -20,6 +22,7 @@ const DB_PATHS: Record<DbType, string> = {
|
|||||||
export const DB_LABELS: Record<DbType, string> = {
|
export const DB_LABELS: Record<DbType, string> = {
|
||||||
postgresql: "PostgreSQL",
|
postgresql: "PostgreSQL",
|
||||||
mysql: "MySQL",
|
mysql: "MySQL",
|
||||||
|
mariadb: "MariaDB",
|
||||||
redis: "Redis",
|
redis: "Redis",
|
||||||
sqlite: "SQLite",
|
sqlite: "SQLite",
|
||||||
};
|
};
|
||||||
@@ -54,22 +57,25 @@ export function DbIcon({ type, size = 20, className }: DbIconProps) {
|
|||||||
export const DB_ICONS: Record<DbType, string> = {
|
export const DB_ICONS: Record<DbType, string> = {
|
||||||
postgresql: "🐘",
|
postgresql: "🐘",
|
||||||
mysql: "🐬",
|
mysql: "🐬",
|
||||||
|
mariadb: "🦭",
|
||||||
redis: "⚡",
|
redis: "⚡",
|
||||||
sqlite: "🗄️",
|
sqlite: "🗄️",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ──────────
|
// ── Managed-PostgreSQL provider icons (Supabase, NeonDB) ──────────
|
||||||
// These are NOT DbType values; connections persist as db_type="postgresql".
|
// These are NOT DbType values; connections persist as db_type="postgresql".
|
||||||
type ProviderIconId = "supabase" | "neon";
|
type ProviderIconId = "supabase" | "neon" | "planetscale";
|
||||||
|
|
||||||
const PROVIDER_ICON_DATA: Record<ProviderIconId, { hex: string; path: string }> = {
|
const PROVIDER_ICON_DATA: Record<ProviderIconId, { hex: string; path: string }> = {
|
||||||
supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path },
|
supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path },
|
||||||
neon: { hex: `#${siNeon.hex}`, path: siNeon.path },
|
neon: { hex: `#${siNeon.hex}`, path: siNeon.path },
|
||||||
|
planetscale: { hex: `#${siPlanetscale.hex}`, path: siPlanetscale.path },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PROVIDER_LABELS: Record<ProviderIconId, string> = {
|
export const PROVIDER_LABELS: Record<ProviderIconId, string> = {
|
||||||
supabase: "Supabase",
|
supabase: "Supabase",
|
||||||
neon: "NeonDB",
|
neon: "NeonDB",
|
||||||
|
planetscale: "PlanetScale",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ProviderIconProps {
|
interface ProviderIconProps {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { describe, it, expect } from "vitest";
|
|||||||
// test free of a `node:fs` dependency so `tsc` (bun run build) stays clean.
|
// test free of a `node:fs` dependency so `tsc` (bun run build) stays clean.
|
||||||
import agents from "../../../AGENTS.md?raw";
|
import agents from "../../../AGENTS.md?raw";
|
||||||
import readme from "../../../README.md?raw";
|
import readme from "../../../README.md?raw";
|
||||||
|
import landing from "../../../www/public/landing.js?raw";
|
||||||
|
|
||||||
describe("v0.7.12 docs coverage", () => {
|
describe("v0.8.0 docs coverage", () => {
|
||||||
it("AGENTS.md marks inline cell editing complete", () => {
|
it("AGENTS.md marks inline cell editing complete", () => {
|
||||||
expect(agents).toContain("Inline cell editing");
|
expect(agents).toContain("Inline cell editing");
|
||||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||||
@@ -24,8 +25,8 @@ describe("v0.7.12 docs coverage", () => {
|
|||||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||||
});
|
});
|
||||||
it("README declares v0.7.12", () => {
|
it("README declares v0.8.0", () => {
|
||||||
expect(readme).toContain("0.7.12");
|
expect(readme).toContain("0.8.0");
|
||||||
});
|
});
|
||||||
it("AGENTS.md marks schema CRUD complete", () => {
|
it("AGENTS.md marks schema CRUD complete", () => {
|
||||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||||
@@ -64,9 +65,12 @@ describe("v0.7.12 docs coverage", () => {
|
|||||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||||
});
|
});
|
||||||
it("README links to v0.7.12 assets in both download tables", () => {
|
it("README links to v0.8.0 assets in both download tables", () => {
|
||||||
expect(readme).toContain("releases/download/v0.7.12/");
|
expect(readme).toContain("releases/download/v0.8.0/");
|
||||||
expect(readme).toContain("Gridline_0.7.12_aarch64.dmg");
|
expect(readme).toContain("Gridline_0.8.0_aarch64.dmg");
|
||||||
expect(readme).toContain("Gridline-0.7.12-1.x86_64.rpm");
|
expect(readme).toContain("Gridline-0.8.0-1.x86_64.rpm");
|
||||||
|
});
|
||||||
|
it("landing.js platform download links point to v0.8.0", () => {
|
||||||
|
expect(landing).toContain('const version = "0.8.0"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { exportData } from "./exportData";
|
import { exportData, csvCell, qualifiedTableSql } from "./exportData";
|
||||||
import type { ColumnInfo } from "./types";
|
import type { ColumnInfo } from "./types";
|
||||||
|
|
||||||
const columns: ColumnInfo[] = [
|
const columns: ColumnInfo[] = [
|
||||||
@@ -48,4 +48,26 @@ describe("exportData", () => {
|
|||||||
globalThis.URL.revokeObjectURL = origRevokeObjectURL;
|
globalThis.URL.revokeObjectURL = origRevokeObjectURL;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("csvCell (client CSV parity with streamed export)", () => {
|
||||||
|
it("quotes and guards like the Rust writer", () => {
|
||||||
|
expect(csvCell("plain")).toBe("plain");
|
||||||
|
expect(csvCell("a,b")).toBe('"a,b"');
|
||||||
|
expect(csvCell('a"b')).toBe('"a""b"');
|
||||||
|
expect(csvCell("=SUM(A1)")).toBe("'=SUM(A1)");
|
||||||
|
expect(csvCell("+x")).toBe("'+x");
|
||||||
|
expect(csvCell("-1")).toBe("'-1");
|
||||||
|
expect(csvCell("@cmd")).toBe("'@cmd");
|
||||||
|
expect(csvCell(null)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("qualifiedTableSql", () => {
|
||||||
|
it("double-quotes for PG/SQLite and backticks for MySQL/MariaDB", () => {
|
||||||
|
expect(qualifiedTableSql("postgresql", "public", "users")).toBe('SELECT * FROM "public"."users"');
|
||||||
|
expect(qualifiedTableSql("sqlite", "main", "users")).toBe('SELECT * FROM "main"."users"');
|
||||||
|
expect(qualifiedTableSql("mysql", "app", "users")).toBe("SELECT * FROM `app`.`users`");
|
||||||
|
expect(qualifiedTableSql("mariadb", "app", "users")).toBe("SELECT * FROM `app`.`users`");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,27 @@
|
|||||||
import { buildXlsx } from "./xlsx";
|
import { buildXlsx } from "./xlsx";
|
||||||
import type { ColumnInfo } from "./types";
|
import type { ColumnInfo } from "./types";
|
||||||
|
|
||||||
|
/** Quote one CSV cell with formula-injection protection — mirrors the Rust
|
||||||
|
* `csv_cell` from the streaming export (Task 10) byte-for-byte. */
|
||||||
|
export function csvCell(value: unknown): string {
|
||||||
|
const s = value === null || value === undefined ? "" : String(value);
|
||||||
|
const guarded = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
|
||||||
|
if (/[,"\n\r]/.test(guarded)) {
|
||||||
|
return `"${guarded.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return guarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SQL selecting every row of a table, dialect-quoted. */
|
||||||
|
export function qualifiedTableSql(dbType: string, schema: string, table: string): string {
|
||||||
|
if (dbType === "mysql" || dbType === "mariadb") {
|
||||||
|
const q = (s: string) => s.replace(/`/g, "``");
|
||||||
|
return `SELECT * FROM \`${q(schema)}\`.\`${q(table)}\``;
|
||||||
|
}
|
||||||
|
const q = (s: string) => s.replace(/"/g, '""');
|
||||||
|
return `SELECT * FROM "${q(schema)}"."${q(table)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
export function exportData(
|
export function exportData(
|
||||||
rows: unknown[][],
|
rows: unknown[][],
|
||||||
columns: ColumnInfo[],
|
columns: ColumnInfo[],
|
||||||
@@ -34,14 +55,9 @@ export function exportData(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "csv": {
|
case "csv": {
|
||||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
const csvRows = [headers.map((h) => csvCell(h)).join(",")];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
csvRows.push(
|
csvRows.push(row.map((cell) => csvCell(cell)).join(","));
|
||||||
row.map((cell) => {
|
|
||||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
|
||||||
return `"${s.replace(/"/g, '""')}"`;
|
|
||||||
}).join(","),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
content = csvRows.join("\n");
|
content = csvRows.join("\n");
|
||||||
mime = "text/csv";
|
mime = "text/csv";
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers";
|
import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers";
|
||||||
|
|
||||||
describe("providers", () => {
|
describe("providers", () => {
|
||||||
it("defines exactly 6 provider tabs in order", () => {
|
it("defines exactly 8 provider tabs in order", () => {
|
||||||
expect(PROVIDER_TABS.map((p) => p.id)).toEqual([
|
expect(PROVIDER_TABS.map((p) => p.id)).toEqual([
|
||||||
"postgresql", "mysql", "sqlite", "redis", "supabase", "neon",
|
"postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", "planetscale",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes a plain (non-managed) MariaDB tab persisting db_type mariadb", () => {
|
||||||
|
const tab = getProviderById("mariadb")!;
|
||||||
|
expect(tab.dbType).toBe("mariadb");
|
||||||
|
expect(tab.isManagedPreset).toBe(false);
|
||||||
|
expect(tab.label).toBe("MariaDB");
|
||||||
|
});
|
||||||
|
|
||||||
it("marks only supabase and neon as managed presets mapping to postgresql", () => {
|
it("marks only supabase and neon as managed presets mapping to postgresql", () => {
|
||||||
const pg = getProviderById("postgresql")!;
|
const pg = getProviderById("postgresql")!;
|
||||||
expect(pg.dbType).toBe("postgresql");
|
expect(pg.dbType).toBe("postgresql");
|
||||||
@@ -33,4 +40,20 @@ describe("providers", () => {
|
|||||||
expect(SETUP_GUIDES.supabase.sslRequired).toBe(true);
|
expect(SETUP_GUIDES.supabase.sslRequired).toBe(true);
|
||||||
expect(SETUP_GUIDES.neon.sslRequired).toBe(true);
|
expect(SETUP_GUIDES.neon.sslRequired).toBe(true);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PlanetScale preset", () => {
|
||||||
|
it("registers as a managed MySQL preset persisting db_type mysql", () => {
|
||||||
|
const tab = PROVIDER_TABS.find((p) => p.id === "planetscale");
|
||||||
|
expect(tab).toBeDefined();
|
||||||
|
expect(tab!.dbType).toBe("mysql");
|
||||||
|
expect(tab!.isManagedPreset).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ships a setup guide requiring SSL with concrete steps", () => {
|
||||||
|
const guide = SETUP_GUIDES.planetscale;
|
||||||
|
expect(guide.sslRequired).toBe(true);
|
||||||
|
expect(guide.steps.length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(guide.steps.some((s) => s.detail.includes("psdb.cloud"))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { DbType } from "./types";
|
import type { DbType } from "./types";
|
||||||
|
|
||||||
export type ProviderId = "postgresql" | "mysql" | "sqlite" | "redis" | "supabase" | "neon";
|
export type ProviderId = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis" | "supabase" | "neon" | "planetscale";
|
||||||
|
|
||||||
export interface ProviderTab {
|
export interface ProviderTab {
|
||||||
id: ProviderId;
|
id: ProviderId;
|
||||||
@@ -15,10 +15,12 @@ export interface ProviderTab {
|
|||||||
export const PROVIDER_TABS: ProviderTab[] = [
|
export const PROVIDER_TABS: ProviderTab[] = [
|
||||||
{ id: "postgresql", label: "PostgreSQL", dbType: "postgresql", isManagedPreset: false },
|
{ id: "postgresql", label: "PostgreSQL", dbType: "postgresql", isManagedPreset: false },
|
||||||
{ id: "mysql", label: "MySQL", dbType: "mysql", isManagedPreset: false },
|
{ id: "mysql", label: "MySQL", dbType: "mysql", isManagedPreset: false },
|
||||||
|
{ id: "mariadb", label: "MariaDB", dbType: "mariadb", isManagedPreset: false },
|
||||||
{ id: "sqlite", label: "SQLite", dbType: "sqlite", isManagedPreset: false },
|
{ id: "sqlite", label: "SQLite", dbType: "sqlite", isManagedPreset: false },
|
||||||
{ id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false },
|
{ id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false },
|
||||||
{ id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true },
|
{ id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true },
|
||||||
{ id: "neon", label: "NeonDB", dbType: "postgresql", isManagedPreset: true },
|
{ id: "neon", label: "NeonDB", dbType: "postgresql", isManagedPreset: true },
|
||||||
|
{ id: "planetscale", label: "PlanetScale", dbType: "mysql", isManagedPreset: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getProviderById(id: string): ProviderTab | undefined {
|
export function getProviderById(id: string): ProviderTab | undefined {
|
||||||
@@ -39,7 +41,7 @@ export interface SetupGuide {
|
|||||||
|
|
||||||
/** Static, researched setup instructions. No network calls at runtime.
|
/** Static, researched setup instructions. No network calls at runtime.
|
||||||
* Content reviewed against the official docs (fetched 2026-08-04, see spec §9). */
|
* Content reviewed against the official docs (fetched 2026-08-04, see spec §9). */
|
||||||
export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = {
|
export const SETUP_GUIDES: Record<"supabase" | "neon" | "planetscale", SetupGuide> = {
|
||||||
supabase: {
|
supabase: {
|
||||||
blurb: "Managed PostgreSQL with a pooled connection string.",
|
blurb: "Managed PostgreSQL with a pooled connection string.",
|
||||||
sslRequired: true,
|
sslRequired: true,
|
||||||
@@ -65,6 +67,17 @@ export const SETUP_GUIDES: Record<"supabase" | "neon", SetupGuide> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
planetscale: {
|
||||||
|
blurb: "Managed MySQL (Vitess) with database branching.",
|
||||||
|
sslRequired: true,
|
||||||
|
steps: [
|
||||||
|
{ title: "Create a branch password", detail: "In the PlanetScale dashboard open your database → Connect → pick a branch → Create password. The password (pscale_pw_…) is shown ONCE and works only for that branch." },
|
||||||
|
{ title: "Copy the credentials", detail: "Note the Access Host URL (e.g. aws.connect.psdb.cloud or xxxx.us-east-2.psdb.cloud), the username, the password, and your database name." },
|
||||||
|
{ title: "Fill Host / Port / Database", detail: "Host is the access host, port is 3306, Database is your database name. Save stores the password in your OS keychain." },
|
||||||
|
{ title: "SSL is mandatory", detail: "Gridline pre-selects Verify Full — PlanetScale requires TLS with identity verification." },
|
||||||
|
{ title: "Vitess caveats", detail: "Passwords are branch-scoped; deleting one disconnects its clients within 5 minutes. With Safe Migrations ON, schema changes go through deploy requests, so the changes queue may be blocked on production branches. Foreign keys follow your database's foreign_key_constraint setting." },
|
||||||
|
],
|
||||||
|
},
|
||||||
neon: {
|
neon: {
|
||||||
blurb: "Serverless Postgres with pooled or direct endpoints.",
|
blurb: "Serverless Postgres with pooled or direct endpoints.",
|
||||||
sslRequired: true,
|
sslRequired: true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type DbType = "postgresql" | "mysql" | "sqlite" | "redis";
|
export type DbType = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis";
|
||||||
|
|
||||||
export type Theme = "dark" | "light" | "system";
|
export type Theme = "dark" | "light" | "system";
|
||||||
export type FontSize = "small" | "medium" | "large";
|
export type FontSize = "small" | "medium" | "large";
|
||||||
@@ -189,6 +189,19 @@ export interface QueryResult {
|
|||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatementNotice {
|
||||||
|
statement_index: number;
|
||||||
|
kind: "dml" | "ddl" | "error" | string;
|
||||||
|
text: string;
|
||||||
|
affected: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultiQueryResult {
|
||||||
|
result_sets: QueryResult[];
|
||||||
|
notices: StatementNotice[];
|
||||||
|
execution_time_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ChangeStatus = "pending" | "applied" | "error";
|
export type ChangeStatus = "pending" | "applied" | "error";
|
||||||
|
|
||||||
export type ChangeItemType =
|
export type ChangeItemType =
|
||||||
@@ -367,6 +380,7 @@ export type ObjectType =
|
|||||||
| "sequences"
|
| "sequences"
|
||||||
| "enums"
|
| "enums"
|
||||||
| "extensions"
|
| "extensions"
|
||||||
|
| "hypertables"
|
||||||
| "indexes"
|
| "indexes"
|
||||||
| "constraints"
|
| "constraints"
|
||||||
| "procedures"
|
| "procedures"
|
||||||
@@ -378,6 +392,21 @@ export interface ObjectSearchHit {
|
|||||||
object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
|
object_type: string; // TABLE | VIEW | MATERIALIZED VIEW | FUNCTION | PROCEDURE | TRIGGER | SEQUENCE | ENUM | EXTENSION | INDEX | CONSTRAINT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HypertableInfo {
|
||||||
|
name: string;
|
||||||
|
schema: string;
|
||||||
|
num_dimensions: number;
|
||||||
|
compression_enabled: boolean;
|
||||||
|
num_chunks: number;
|
||||||
|
total_size_bytes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HypertableListResponse {
|
||||||
|
available: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
items: HypertableInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DependencyInfo {
|
export interface DependencyInfo {
|
||||||
deptype: string;
|
deptype: string;
|
||||||
class: string;
|
class: string;
|
||||||
@@ -477,4 +506,25 @@ export interface Relationship {
|
|||||||
export interface SchemaGraph {
|
export interface SchemaGraph {
|
||||||
tables: TableNode[];
|
tables: TableNode[];
|
||||||
relationships: Relationship[];
|
relationships: Relationship[];
|
||||||
}
|
}
|
||||||
|
export interface DiffDetailLine {
|
||||||
|
label: string;
|
||||||
|
old: string;
|
||||||
|
new: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffItem {
|
||||||
|
object_type: string;
|
||||||
|
name: string;
|
||||||
|
kind: "added" | "removed" | "changed";
|
||||||
|
detail: DiffDetailLine[];
|
||||||
|
sync_sql: string[];
|
||||||
|
destructive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiffReport {
|
||||||
|
items: DiffItem[];
|
||||||
|
source_label: string;
|
||||||
|
target_label: string;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function abbreviateType(dataType: string): string {
|
|||||||
return TYPE_ABBREV[lower] ?? dataType;
|
return TYPE_ABBREV[lower] ?? dataType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_DB_TYPES: DbType[] = ["postgresql", "mysql", "sqlite", "redis"];
|
const VALID_DB_TYPES: DbType[] = ["postgresql", "mysql", "mariadb", "sqlite", "redis"];
|
||||||
|
|
||||||
export function validateConnectionInput(input: ConnectionInput): ValidationResult {
|
export function validateConnectionInput(input: ConnectionInput): ValidationResult {
|
||||||
if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" };
|
if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||||||
import pkg from "../../package.json";
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
describe("version", () => {
|
describe("version", () => {
|
||||||
it("declares v0.7.12 across the app shell", () => {
|
it("declares v0.8.0 across the app shell", () => {
|
||||||
expect(pkg.version).toBe("0.7.12");
|
expect(pkg.version).toBe("0.8.0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { useDbViewerStore } from "./dbViewerStore";
|
||||||
|
import type { DiffItem } from "../lib/types";
|
||||||
|
|
||||||
|
const item = (name: string, sql: string, destructive: boolean): DiffItem => ({
|
||||||
|
object_type: "table",
|
||||||
|
name,
|
||||||
|
kind: "added",
|
||||||
|
detail: [],
|
||||||
|
sync_sql: [sql],
|
||||||
|
destructive,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addDiffItems", () => {
|
||||||
|
beforeEach(() => useDbViewerStore.getState().clearChanges());
|
||||||
|
|
||||||
|
it("stages one ddl change per non-destructive item, skipping destructive and empty", () => {
|
||||||
|
const items = [
|
||||||
|
item("users", "CREATE TABLE users (id int);", false),
|
||||||
|
item("old", "DROP TABLE old;", true),
|
||||||
|
{ ...item("weird", "", false), sync_sql: [] },
|
||||||
|
];
|
||||||
|
useDbViewerStore.getState().addDiffItems(items);
|
||||||
|
const queue = useDbViewerStore.getState().changesQueue;
|
||||||
|
expect(queue).toHaveLength(1);
|
||||||
|
expect(queue[0].type).toBe("ddl");
|
||||||
|
expect(queue[0].sql).toBe("CREATE TABLE users (id int);");
|
||||||
|
expect(queue[0].description).toContain("users");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -247,6 +247,16 @@ describe("dbViewerStore", () => {
|
|||||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updateChange patches a single change in place", () => {
|
||||||
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: null }, description: "x" } as any);
|
||||||
|
const id = useDbViewerStore.getState().changesQueue[0].id;
|
||||||
|
useDbViewerStore.getState().updateChange(id, { newData: { a: "hello" } });
|
||||||
|
const item = useDbViewerStore.getState().changesQueue[0];
|
||||||
|
expect(item.newData).toEqual({ a: "hello" });
|
||||||
|
expect(item.type).toBe("insert");
|
||||||
|
expect(item.description).toBe("x");
|
||||||
|
});
|
||||||
|
|
||||||
it("clearChanges empties the queue", () => {
|
it("clearChanges empties the queue", () => {
|
||||||
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "x" } as any);
|
||||||
useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any);
|
useDbViewerStore.getState().addChange({ type: "delete", schema: "public", table: "t", primaryKey: { id: 1 }, description: "y" } as any);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types";
|
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo, MultiQueryResult, DiffItem } from "../lib/types";
|
||||||
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
||||||
import type { ObjectKind, DdlParams } from "../lib/objectCrud";
|
import type { ObjectKind, DdlParams } from "../lib/objectCrud";
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ export interface ViewerTab {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
data: QueryResult | null;
|
data: QueryResult | null;
|
||||||
|
multi?: MultiQueryResult | null;
|
||||||
columnFilter?: { column: string; value: string };
|
columnFilter?: { column: string; value: string };
|
||||||
filterRules: FilterRule[];
|
filterRules: FilterRule[];
|
||||||
sortRules: SortRule[];
|
sortRules: SortRule[];
|
||||||
@@ -117,6 +118,8 @@ interface DbViewerState {
|
|||||||
sequences: SequenceInfo[] | null;
|
sequences: SequenceInfo[] | null;
|
||||||
enums: EnumInfo[] | null;
|
enums: EnumInfo[] | null;
|
||||||
extensions: ExtensionInfo[] | null;
|
extensions: ExtensionInfo[] | null;
|
||||||
|
hypertables: HypertableInfo[] | null;
|
||||||
|
hypertablesAvailable: boolean | null;
|
||||||
indexes: IndexInfo[] | null;
|
indexes: IndexInfo[] | null;
|
||||||
setSchemaTreeLoading: (loading: boolean) => void;
|
setSchemaTreeLoading: (loading: boolean) => void;
|
||||||
constraints: ConstraintInfo[] | null;
|
constraints: ConstraintInfo[] | null;
|
||||||
@@ -144,6 +147,7 @@ interface DbViewerState {
|
|||||||
setPage: (tabId: string, page: number) => void;
|
setPage: (tabId: string, page: number) => void;
|
||||||
setPageSize: (tabId: string, pageSize: number) => void;
|
setPageSize: (tabId: string, pageSize: number) => void;
|
||||||
setTabData: (tabId: string, data: QueryResult) => void;
|
setTabData: (tabId: string, data: QueryResult) => void;
|
||||||
|
setTabMulti: (tabId: string, multi: MultiQueryResult | null) => void;
|
||||||
setTabLoading: (tabId: string, loading: boolean) => void;
|
setTabLoading: (tabId: string, loading: boolean) => void;
|
||||||
setTabError: (tabId: string, error: string) => void;
|
setTabError: (tabId: string, error: string) => void;
|
||||||
setColumnFilter: (tabId: string, column: string, value: string) => void;
|
setColumnFilter: (tabId: string, column: string, value: string) => void;
|
||||||
@@ -165,8 +169,10 @@ interface DbViewerState {
|
|||||||
rows?: unknown[][];
|
rows?: unknown[][];
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
addDiffItems: (items: DiffItem[]) => void;
|
||||||
cancelChange: (changeId: string) => void;
|
cancelChange: (changeId: string) => void;
|
||||||
removeChange: (changeId: string) => void;
|
removeChange: (changeId: string) => void;
|
||||||
|
updateChange: (changeId: string, patch: Partial<QueueItem>) => void;
|
||||||
clearChanges: () => void;
|
clearChanges: () => void;
|
||||||
markChangeCommitted: (changeId: string) => void;
|
markChangeCommitted: (changeId: string) => void;
|
||||||
markChangeFailed: (changeId: string, error: string) => void;
|
markChangeFailed: (changeId: string, error: string) => void;
|
||||||
@@ -181,6 +187,8 @@ interface DbViewerState {
|
|||||||
setSequences: (sequences: SequenceInfo[]) => void;
|
setSequences: (sequences: SequenceInfo[]) => void;
|
||||||
setEnums: (enums: EnumInfo[]) => void;
|
setEnums: (enums: EnumInfo[]) => void;
|
||||||
setExtensions: (extensions: ExtensionInfo[]) => void;
|
setExtensions: (extensions: ExtensionInfo[]) => void;
|
||||||
|
setHypertables: (hypertables: HypertableInfo[]) => void;
|
||||||
|
setHypertablesAvailable: (v: boolean | null) => void;
|
||||||
setIndexes: (indexes: IndexInfo[]) => void;
|
setIndexes: (indexes: IndexInfo[]) => void;
|
||||||
setConstraints: (constraints: ConstraintInfo[]) => void;
|
setConstraints: (constraints: ConstraintInfo[]) => void;
|
||||||
setRoles: (roles: RoleInfo[]) => void;
|
setRoles: (roles: RoleInfo[]) => void;
|
||||||
@@ -223,6 +231,8 @@ const initialState = {
|
|||||||
sequences: null as SequenceInfo[] | null,
|
sequences: null as SequenceInfo[] | null,
|
||||||
enums: null as EnumInfo[] | null,
|
enums: null as EnumInfo[] | null,
|
||||||
extensions: null as ExtensionInfo[] | null,
|
extensions: null as ExtensionInfo[] | null,
|
||||||
|
hypertables: null as HypertableInfo[] | null,
|
||||||
|
hypertablesAvailable: null as boolean | null,
|
||||||
indexes: null as IndexInfo[] | null,
|
indexes: null as IndexInfo[] | null,
|
||||||
constraints: null as ConstraintInfo[] | null,
|
constraints: null as ConstraintInfo[] | null,
|
||||||
roles: [] as RoleInfo[],
|
roles: [] as RoleInfo[],
|
||||||
@@ -442,10 +452,15 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
|||||||
setTabData: (tabId, data) =>
|
setTabData: (tabId, data) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
tabs: state.tabs.map((t) =>
|
tabs: state.tabs.map((t) =>
|
||||||
t.id === tabId ? { ...t, data, loading: false, error: null } : t,
|
t.id === tabId ? { ...t, data, multi: null, loading: false, error: null } : t,
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
setTabMulti: (tabId, multi) =>
|
||||||
|
set((s) => ({
|
||||||
|
tabs: s.tabs.map((t) => (t.id === tabId ? { ...t, multi } : t)),
|
||||||
|
})),
|
||||||
|
|
||||||
setTabLoading: (tabId, loading) =>
|
setTabLoading: (tabId, loading) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
tabs: state.tabs.map((t) =>
|
tabs: state.tabs.map((t) =>
|
||||||
@@ -535,6 +550,19 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
|||||||
set((state) => ({ changesQueue: [...state.changesQueue, item] }));
|
set((state) => ({ changesQueue: [...state.changesQueue, item] }));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addDiffItems: (items) => {
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.destructive || it.sync_sql.length === 0) continue;
|
||||||
|
for (const sql of it.sync_sql) {
|
||||||
|
get().addChange({
|
||||||
|
type: "ddl",
|
||||||
|
sql,
|
||||||
|
description: `Schema diff: ${it.object_type} ${it.name} (${it.kind})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
cancelChange: (changeId) =>
|
cancelChange: (changeId) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
changesQueue: state.changesQueue.map((c) =>
|
changesQueue: state.changesQueue.map((c) =>
|
||||||
@@ -547,6 +575,13 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
|||||||
changesQueue: state.changesQueue.filter((c) => c.id !== changeId),
|
changesQueue: state.changesQueue.filter((c) => c.id !== changeId),
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
updateChange: (changeId, patch) =>
|
||||||
|
set((state) => ({
|
||||||
|
changesQueue: state.changesQueue.map((c) =>
|
||||||
|
c.id === changeId ? { ...c, ...patch } : c,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
clearChanges: () => set({ changesQueue: [] }),
|
clearChanges: () => set({ changesQueue: [] }),
|
||||||
|
|
||||||
markChangeCommitted: (changeId) =>
|
markChangeCommitted: (changeId) =>
|
||||||
@@ -578,6 +613,8 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
|||||||
setSequences: (sequences) => set({ sequences }),
|
setSequences: (sequences) => set({ sequences }),
|
||||||
setEnums: (enums) => set({ enums }),
|
setEnums: (enums) => set({ enums }),
|
||||||
setExtensions: (extensions) => set({ extensions }),
|
setExtensions: (extensions) => set({ extensions }),
|
||||||
|
setHypertables: (hypertables) => set({ hypertables }),
|
||||||
|
setHypertablesAvailable: (v) => set({ hypertablesAvailable: v }),
|
||||||
setIndexes: (indexes) => set({ indexes }),
|
setIndexes: (indexes) => set({ indexes }),
|
||||||
setConstraints: (constraints) => set({ constraints }),
|
setConstraints: (constraints) => set({ constraints }),
|
||||||
setRoles: (roles) => set({ roles }),
|
setRoles: (roles) => set({ roles }),
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gridline",
|
"name": "gridline",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.7.10",
|
"version": "0.8.0",
|
||||||
"workspaces": ["desktop", "www"],
|
"workspaces": ["desktop", "www"],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:desktop": "bun run --filter gridline-desktop dev",
|
"dev:desktop": "bun run --filter gridline-desktop dev",
|
||||||
|
|||||||
@@ -11,13 +11,8 @@
|
|||||||
#
|
#
|
||||||
# brew install --cask gridline
|
# brew install --cask gridline
|
||||||
#
|
#
|
||||||
# QUARANTINE: Gridline is ad-hoc signed (not Developer-ID signed/notarized),
|
# SIGNING: Gridline is Developer-ID signed + notarized, so macOS trusts it
|
||||||
# so Homebrew casks (which deliberately set com.apple.quarantine on the
|
# and no quarantine-stripping postflight is needed.
|
||||||
# downloaded artifact) would make Gatekeeper refuse to launch it. The
|
|
||||||
# `postflight` block strips the quarantine flag automatically — the standard
|
|
||||||
# GoReleaser-documented pattern for unsigned casks — so `brew install` works
|
|
||||||
# with no manual `xattr` step. This is a Gatekeeper bypass; drop the
|
|
||||||
# postflight once the app is properly signed + notarized.
|
|
||||||
|
|
||||||
cask "gridline" do
|
cask "gridline" do
|
||||||
arch arm: "aarch64", intel: "x64"
|
arch arm: "aarch64", intel: "x64"
|
||||||
@@ -26,24 +21,15 @@ cask "gridline" do
|
|||||||
sha256 arm: "@ARM_SHA256@",
|
sha256 arm: "@ARM_SHA256@",
|
||||||
intel: "@INTEL_SHA256@"
|
intel: "@INTEL_SHA256@"
|
||||||
|
|
||||||
url "https://github.com/AdrianBonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
|
url "https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v#{version}/Gridline_#{version}_#{arch}.dmg"
|
||||||
name "Gridline"
|
name "Gridline"
|
||||||
desc "Open-source, cross-platform database GUI client for PostgreSQL"
|
desc "Open-source, cross-platform database GUI client for PostgreSQL"
|
||||||
homepage "https://github.com/AdrianBonpin/gridline"
|
homepage "https://git.ranio.xyz/adrianbonpin/gridline"
|
||||||
|
|
||||||
depends_on macos: ">= :monterey"
|
depends_on macos: :monterey
|
||||||
|
|
||||||
app "Gridline.app"
|
app "Gridline.app"
|
||||||
|
|
||||||
# The "installation step" — strip the quarantine flag so the unsigned
|
|
||||||
# (ad-hoc signed) app launches without the Gatekeeper prompt.
|
|
||||||
postflight do
|
|
||||||
if OS.mac?
|
|
||||||
system_command "/usr/bin/xattr",
|
|
||||||
args: ["-dr", "com.apple.quarantine", "#{appdir}/Gridline.app"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
zap trash: [
|
zap trash: [
|
||||||
"~/Library/Application Support/com.adrianbonpin.gridline",
|
"~/Library/Application Support/com.adrianbonpin.gridline",
|
||||||
"~/Library/Caches/com.adrianbonpin.gridline",
|
"~/Library/Caches/com.adrianbonpin.gridline",
|
||||||
|
|||||||
+103
-47
@@ -7,13 +7,12 @@
|
|||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# TAG=v0.7.11 ./scripts/release-mac.sh
|
# TAG=v0.7.11 ./scripts/release-mac.sh
|
||||||
# (optionally set GITEA_TOKEN env var to override token lookup)
|
|
||||||
#
|
#
|
||||||
# The release is keyed by tag. The Linux/Windows job creates it on the first
|
# The release is keyed by tag. The Linux/Windows job creates it on the first
|
||||||
# tag push; this script finds it (creating it only if it somehow doesn't exist)
|
# tag push; this script finds it (creating it only if it somehow doesn't exist)
|
||||||
# and attaches the two DMGs: Gridline_<ver>_aarch64.dmg and Gridline_<ver>_x64.dmg
|
# and attaches the two DMGs: Gridline_<ver>_aarch64.dmg and Gridline_<ver>_x64.dmg
|
||||||
#
|
#
|
||||||
# Token: reads $GITEA_TOKEN, else tries the tea CLI config, else prompts.
|
# Auth: uses the `tea` CLI (OAuth token, auto-refreshed).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -28,28 +27,67 @@ if [ -z "$TAG" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---- resolve auth token ----------------------------------------------------
|
# ---- resolve auth (tea CLI) ------------------------------------------------
|
||||||
get_token() {
|
# The `tea` CLI carries the OAuth token and refreshes it automatically, so the
|
||||||
if [ -n "${GITEA_TOKEN:-}" ]; then
|
# release upload below uses `tea` rather than a raw curl + token. (The git
|
||||||
echo "$GITEA_TOKEN"; return
|
# credential store can hold a stale access token and 401.)
|
||||||
fi
|
if ! command -v tea >/dev/null 2>&1; then
|
||||||
# Try tea's stored credentials if available.
|
echo "error: tea CLI not found (needed to upload release assets)" >&2
|
||||||
if command -v tea >/dev/null 2>&1; then
|
echo " install it: https://gitea.com/gitea/tea" >&2
|
||||||
# tea stores its token in a config under the platform app-support dir.
|
exit 1
|
||||||
local cfg
|
fi
|
||||||
cfg="$(find "$HOME/Library/Application Support" "$HOME/.config" \
|
|
||||||
-maxdepth 2 -name 'config.yml' -path '*tea*' 2>/dev/null | head -1 || true)"
|
|
||||||
if [ -n "$cfg" ]; then
|
|
||||||
local tok
|
|
||||||
tok="$(sed -n 's/.*token:[" ]*\([^" ]*\).*/\1/p' "$cfg" 2>/dev/null | head -1 || true)"
|
|
||||||
if [ -n "$tok" ]; then echo "$tok"; return; fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
read -rsp "Gitea personal access token (repo write): " GITEA_TOKEN
|
|
||||||
echo "$GITEA_TOKEN"
|
|
||||||
}
|
|
||||||
|
|
||||||
GITEA_TOKEN="$(get_token)"
|
# ---- macOS signing + notarization -----------------------------------------
|
||||||
|
# Requires a "Developer ID Application" certificate in the login keychain and
|
||||||
|
# an App Store Connect API key for notarization. See the README's "macOS
|
||||||
|
# signing" section for how to create both.
|
||||||
|
#
|
||||||
|
# Notarization credentials are read from ~/.config/gridline/notarize.env
|
||||||
|
# (never committed). Create it with:
|
||||||
|
# APPLE_API_KEY=<Key ID>
|
||||||
|
# APPLE_API_ISSUER=<Issuer ID>
|
||||||
|
# APPLE_API_KEY_PATH=/absolute/path/to/AuthKey_<KeyID>.p8
|
||||||
|
# (or drop the .p8 at ~/.appstoreconnect/private_keys/AuthKey_<KeyID>.p8 and
|
||||||
|
# omit APPLE_API_KEY_PATH — tauri auto-searches that location.)
|
||||||
|
|
||||||
|
# 1. Resolve the Developer ID Application signing identity from the keychain.
|
||||||
|
SIGNING_IDENTITY="$(security find-identity -v -p codesigning 2>/dev/null \
|
||||||
|
| grep -i 'Developer ID Application' \
|
||||||
|
| sed -E 's/.*"([^"]+)".*/\1/' \
|
||||||
|
| head -1 || true)"
|
||||||
|
if [ -z "$SIGNING_IDENTITY" ]; then
|
||||||
|
echo "ERROR: No 'Developer ID Application' certificate found in the login keychain." >&2
|
||||||
|
echo " Create one at https://developer.apple.com/account/resources/certificates/list" >&2
|
||||||
|
echo " (Certificates, IDs & Profiles -> + -> Developer ID Application), download the" >&2
|
||||||
|
echo " .cer, and double-click it to install into your login keychain." >&2
|
||||||
|
echo " Verify with: security find-identity -v -p codesigning" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export APPLE_SIGNING_IDENTITY="$SIGNING_IDENTITY"
|
||||||
|
echo ">> Signing identity: $SIGNING_IDENTITY"
|
||||||
|
|
||||||
|
# 2. Load notarization credentials.
|
||||||
|
NOTARIZE_ENV="${HOME}/.config/gridline/notarize.env"
|
||||||
|
if [ -f "$NOTARIZE_ENV" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$NOTARIZE_ENV"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
if [ -z "${APPLE_API_KEY:-}" ] || [ -z "${APPLE_API_ISSUER:-}" ]; then
|
||||||
|
if [ "${GRIDLINE_SKIP_NOTARIZE:-0}" = "1" ]; then
|
||||||
|
echo ">> WARNING: notarization credentials missing; GRIDLINE_SKIP_NOTARIZE=1 set." >&2
|
||||||
|
echo " Building signed but NOT notarized (test-only — users will still see a prompt)." >&2
|
||||||
|
else
|
||||||
|
echo "ERROR: Notarization credentials not configured." >&2
|
||||||
|
echo " Create ${NOTARIZE_ENV} with APPLE_API_KEY and APPLE_API_ISSUER" >&2
|
||||||
|
echo " (and APPLE_API_KEY_PATH, or place the .p8 at ~/.appstoreconnect/private_keys/)." >&2
|
||||||
|
echo " See the README's 'macOS signing' section for the App Store Connect API key setup." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo ">> Notarization: API key ${APPLE_API_KEY} (issuer ${APPLE_API_ISSUER})"
|
||||||
|
fi
|
||||||
|
|
||||||
# ---- build bundled DB tools (macOS) ----------------------------------------
|
# ---- build bundled DB tools (macOS) ----------------------------------------
|
||||||
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
@@ -60,6 +98,8 @@ echo ">> Building PostgreSQL client tools..."
|
|||||||
mkdir -p "$OUT_PG"
|
mkdir -p "$OUT_PG"
|
||||||
PG_VER="16.4"
|
PG_VER="16.4"
|
||||||
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
|
curl -fsSL "https://ftp.postgresql.org/pub/source/v${PG_VER}/postgresql-${PG_VER}.tar.bz2" -o /tmp/pg.tar.bz2
|
||||||
|
# Idempotent re-runs: clear stale build dirs left by previous invocations.
|
||||||
|
rm -rf "/tmp/postgresql-${PG_VER}" /tmp/pgbuild
|
||||||
tar -xf /tmp/pg.tar.bz2 -C /tmp
|
tar -xf /tmp/pg.tar.bz2 -C /tmp
|
||||||
cd /tmp/postgresql-${PG_VER}
|
cd /tmp/postgresql-${PG_VER}
|
||||||
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
|
ac_cv_func_strchrnul=no ./configure --prefix=/tmp/pgbuild --without-readline --without-icu CFLAGS="-O2"
|
||||||
@@ -75,10 +115,19 @@ for b in pg_dump pg_restore psql; do
|
|||||||
libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}')
|
libpq=$(otool -L "$OUT_PG/$b" | awk '/libpq/ {print $1; exit}')
|
||||||
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b"
|
install_name_tool -change "$libpq" "@loader_path/libpq.5.dylib" "$OUT_PG/$b"
|
||||||
done
|
done
|
||||||
|
# install_name_tool invalidates the linker's signature; re-sign with the
|
||||||
|
# Developer ID identity + hardened runtime + secure timestamp so the bundle
|
||||||
|
# passes notarization (Apple rejects ad-hoc-signed nested executables).
|
||||||
|
for f in "$OUT_PG"/pg_dump "$OUT_PG"/pg_restore "$OUT_PG"/psql "$OUT_PG"/libpq.5.dylib; do
|
||||||
|
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$f"
|
||||||
|
done
|
||||||
|
|
||||||
echo ">> Building MariaDB client tools..."
|
echo ">> Building MariaDB client tools..."
|
||||||
mkdir -p "$OUT_MY"
|
mkdir -p "$OUT_MY"
|
||||||
MARIADB_VER="11.4.5"
|
MARIADB_VER="11.4.5"
|
||||||
|
# Idempotent re-runs: git clone fails if the destination is non-empty (e.g.
|
||||||
|
# left over from an earlier build on the same machine). Clean it first.
|
||||||
|
rm -rf /tmp/mariadb-server
|
||||||
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
|
git clone --depth 1 --branch "mariadb-${MARIADB_VER}" https://github.com/MariaDB/server.git /tmp/mariadb-server
|
||||||
cd /tmp/mariadb-server
|
cd /tmp/mariadb-server
|
||||||
if [ "$(uname -m)" = arm64 ]; then
|
if [ "$(uname -m)" = arm64 ]; then
|
||||||
@@ -112,7 +161,7 @@ for pass in 1 2 3 4 5 6; do
|
|||||||
if [ ! -f "$OUT_MY/$base" ]; then cp "$lib" "$OUT_MY/$base" 2>/dev/null || true; fi
|
if [ ! -f "$OUT_MY/$base" ]; then cp "$lib" "$OUT_MY/$base" 2>/dev/null || true; fi
|
||||||
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
|
install_name_tool -change "$lib" "@loader_path/$base" "$dylib" 2>/dev/null || true
|
||||||
done || true
|
done || true
|
||||||
codesign --force --sign - "$dylib" 2>/dev/null || true
|
codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$dylib"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -130,33 +179,40 @@ VERSION="${TAG#v}"
|
|||||||
BUNDLE_ARM="${WORKSPACE}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle"
|
BUNDLE_ARM="${WORKSPACE}/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle"
|
||||||
BUNDLE_INTEL="${WORKSPACE}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle"
|
BUNDLE_INTEL="${WORKSPACE}/desktop/src-tauri/target/x86_64-apple-darwin/release/bundle"
|
||||||
|
|
||||||
|
# ---- verify signing + notarization -----------------------------------------
|
||||||
|
# Confirm both .app bundles are properly signed and carry a stapled
|
||||||
|
# notarization ticket before we upload them. spctl can lag right after
|
||||||
|
# notarization (assessment cache), so stapler validate is the authoritative
|
||||||
|
# check; a pending spctl is only a warning.
|
||||||
|
for APP in \
|
||||||
|
"${BUNDLE_ARM}/macos/Gridline.app" \
|
||||||
|
"${BUNDLE_INTEL}/macos/Gridline.app"; do
|
||||||
|
[ -d "$APP" ] || { echo "missing $APP" >&2; exit 1; }
|
||||||
|
echo ">> Verifying $APP"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$APP" \
|
||||||
|
|| { echo "codesign verify FAILED" >&2; exit 1; }
|
||||||
|
xcrun stapler validate "$APP" \
|
||||||
|
|| { echo "notarization ticket not stapled" >&2; exit 1; }
|
||||||
|
spctl --assess --type execute --verbose=4 "$APP" \
|
||||||
|
|| echo " (spctl assessment pending — normal right after notarization; stapler validate passed)"
|
||||||
|
done
|
||||||
|
|
||||||
# ---- create/ensure release + upload DMGs -----------------------------------
|
# ---- create/ensure release + upload DMGs -----------------------------------
|
||||||
API="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
|
# Use `tea` for release management — it handles OAuth token refresh, unlike a
|
||||||
AUTH="Authorization: token ${GITEA_TOKEN}"
|
# raw curl with a token from the git credential store (which can 401).
|
||||||
|
cd "${WORKSPACE}"
|
||||||
|
|
||||||
echo ">> Ensuring release ${TAG} exists..."
|
echo ">> Ensuring release ${TAG} exists..."
|
||||||
REL_ID=$(curl -fsS -H "$AUTH" "${API}/releases/tags/${TAG}" \
|
if ! tea release list -o json 2>/dev/null | grep -q "\"${TAG}\""; then
|
||||||
| python3 -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
tea release create --tag "${TAG}" --title "Gridline ${VERSION}" \
|
||||||
if [ -z "$REL_ID" ]; then
|
--note "Gridline ${VERSION} — macOS (built manually)." --draft
|
||||||
BODY="Gridline ${VERSION} — macOS (built manually)."
|
else
|
||||||
REL_ID=$(curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/json" \
|
echo ">> Release ${TAG} already exists."
|
||||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Gridline ${VERSION}\",\"body\":\"${BODY}\",\"draft\":true}" \
|
|
||||||
"${API}/releases" \
|
|
||||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['id'])")
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
upload() {
|
echo ">> Uploading macOS DMGs..."
|
||||||
local file="$1"
|
tea releases assets create "${TAG}" \
|
||||||
[ -f "$file" ] || { echo "missing $file" >&2; exit 1; }
|
"${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg" \
|
||||||
local name
|
"${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg"
|
||||||
name=$(basename "$file")
|
|
||||||
echo ">> uploading $name"
|
|
||||||
curl -fsS -X POST -H "$AUTH" -H "Content-Type: application/octet-stream" \
|
|
||||||
--data-binary "@$file" \
|
|
||||||
"${API}/releases/${REL_ID}/assets?name=${name}"
|
|
||||||
}
|
|
||||||
|
|
||||||
upload "${BUNDLE_ARM}/dmg/Gridline_${VERSION}_aarch64.dmg"
|
|
||||||
upload "${BUNDLE_INTEL}/dmg/Gridline_${VERSION}_x64.dmg"
|
|
||||||
|
|
||||||
echo "Done. Release: ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG}"
|
echo "Done. Release: ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG}"
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ export default defineConfig({
|
|||||||
compressHTML: true,
|
compressHTML: true,
|
||||||
trailingSlash: 'ignore',
|
trailingSlash: 'ignore',
|
||||||
integrations: [
|
integrations: [
|
||||||
sitemap({
|
sitemap(),
|
||||||
lastmod: new Date(),
|
|
||||||
}),
|
|
||||||
react(),
|
react(),
|
||||||
],
|
],
|
||||||
vite: {
|
vite: {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/* /404.html 404
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
+3
-20
@@ -60,7 +60,7 @@
|
|||||||
const keepLabel = downloadBtn.hasAttribute("data-download-label");
|
const keepLabel = downloadBtn.hasAttribute("data-download-label");
|
||||||
|
|
||||||
const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases";
|
const releasesUrl = "https://git.ranio.xyz/adrianbonpin/gridline/releases";
|
||||||
const version = "0.7.12";
|
const version = "0.8.0";
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
downloadBtn.href = releasesUrl;
|
downloadBtn.href = releasesUrl;
|
||||||
@@ -68,9 +68,6 @@
|
|||||||
} else if (isMac) {
|
} else if (isMac) {
|
||||||
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`;
|
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_aarch64.dmg`;
|
||||||
if (!keepLabel) downloadBtn.textContent = "Download for macOS";
|
if (!keepLabel) downloadBtn.textContent = "Download for macOS";
|
||||||
if (downloadBtn.id === "download") {
|
|
||||||
document.getElementById("macos-hint")?.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
} else if (isWin) {
|
} else if (isWin) {
|
||||||
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_x64-setup.exe`;
|
downloadBtn.href = `${releasesUrl}/download/v${version}/Gridline_${version}_x64-setup.exe`;
|
||||||
if (!keepLabel) downloadBtn.textContent = "Download for Windows";
|
if (!keepLabel) downloadBtn.textContent = "Download for Windows";
|
||||||
@@ -83,18 +80,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("copy-quarantine")?.addEventListener("click", async () => {
|
|
||||||
const command = "sudo xattr -dr com.apple.quarantine /Applications/Gridline.app";
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(command);
|
|
||||||
const btn = document.getElementById("copy-quarantine");
|
|
||||||
if (!btn) return;
|
|
||||||
const original = btn.textContent;
|
|
||||||
btn.textContent = "Copied";
|
|
||||||
setTimeout(() => (btn.textContent = original), 1500);
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nav scroll background
|
// Nav scroll background
|
||||||
const nav = document.getElementById("nav");
|
const nav = document.getElementById("nav");
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
@@ -141,24 +126,22 @@
|
|||||||
const subtitle = hero.querySelector(".hero-animate > p:nth-of-type(2)");
|
const subtitle = hero.querySelector(".hero-animate > p:nth-of-type(2)");
|
||||||
const buttons = hero.querySelector(".hero-animate > div");
|
const buttons = hero.querySelector(".hero-animate > div");
|
||||||
const screenshot = hero.querySelector(".hero-screenshot");
|
const screenshot = hero.querySelector(".hero-screenshot");
|
||||||
const macosHint = hero.querySelector("#macos-hint");
|
|
||||||
|
|
||||||
if (reducedMotion) {
|
if (reducedMotion) {
|
||||||
// Reduced motion: reveal the hero immediately (CSS hides it at first
|
// Reduced motion: reveal the hero immediately (CSS hides it at first
|
||||||
// paint to avoid flicker, but the timeline below is skipped). Section
|
// paint to avoid flicker, but the timeline below is skipped). Section
|
||||||
// reveals below still run through the IntersectionObserver.
|
// reveals below still run through the IntersectionObserver.
|
||||||
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 1, translateY: 0 });
|
anime.set([eyebrow, h1, subtitle, buttons, screenshot], { opacity: 1, translateY: 0 });
|
||||||
} else {
|
} else {
|
||||||
const heroTl = anime.timeline({ easing: "easeOutExpo" });
|
const heroTl = anime.timeline({ easing: "easeOutExpo" });
|
||||||
|
|
||||||
anime.set([eyebrow, h1, subtitle, buttons, screenshot, macosHint], { opacity: 0, translateY: 20 });
|
anime.set([eyebrow, h1, subtitle, buttons, screenshot], { opacity: 0, translateY: 20 });
|
||||||
anime.set(screenshot, { opacity: 0, scale: 0.96, translateY: 0 });
|
anime.set(screenshot, { opacity: 0, scale: 0.96, translateY: 0 });
|
||||||
|
|
||||||
if (eyebrow) heroTl.add({ targets: eyebrow, opacity: [0, 1], translateY: [15, 0] }, 0);
|
if (eyebrow) heroTl.add({ targets: eyebrow, opacity: [0, 1], translateY: [15, 0] }, 0);
|
||||||
if (h1) heroTl.add({ targets: h1, opacity: [0, 1], translateY: [30, 0], duration: 800 }, 100);
|
if (h1) heroTl.add({ targets: h1, opacity: [0, 1], translateY: [30, 0], duration: 800 }, 100);
|
||||||
if (subtitle) heroTl.add({ targets: subtitle, opacity: [0, 1], translateY: [20, 0] }, 250);
|
if (subtitle) heroTl.add({ targets: subtitle, opacity: [0, 1], translateY: [20, 0] }, 250);
|
||||||
if (buttons) heroTl.add({ targets: buttons, opacity: [0, 1], translateY: [20, 0] }, 350);
|
if (buttons) heroTl.add({ targets: buttons, opacity: [0, 1], translateY: [20, 0] }, 350);
|
||||||
if (macosHint) heroTl.add({ targets: macosHint, opacity: [0, 1], translateY: [20, 0] }, 400);
|
|
||||||
if (screenshot) heroTl.add({ targets: screenshot, opacity: [0, 1], scale: [0.96, 1], duration: 900 }, 450);
|
if (screenshot) heroTl.add({ targets: screenshot, opacity: [0, 1], scale: [0.96, 1], duration: 900 }, 450);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/apple-touch-icon.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,43 @@ export interface ChangelogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const changelogEntries: ChangelogEntry[] = [
|
export const changelogEntries: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.8.0",
|
||||||
|
date: "2026-09-09",
|
||||||
|
highlights: [
|
||||||
|
"Schema diff / compare — pick a source connection of the same engine family and diff it against the current one: added/removed/changed tables, columns, constraints, indexes, views, sequences, and enums, each with generated same-dialect sync SQL. Non-destructive items stage into the changes queue; destructive changes (DROPs, ALTER TYPE) are copy-only with a warning.",
|
||||||
|
"Multiple result sets — run a script and see every statement's results stacked in order, with affected-count notices for DML/DDL and a stop-at-first-error card that keeps prior results visible.",
|
||||||
|
"Export all rows to file — stream the full result of any query (no page limit, constant memory) to CSV, JSONL, or JSON, with progress and cancel.",
|
||||||
|
"MariaDB — first-class database type: provider card, type filter, connection URL copy/paste, and full viewer/editing/import/backup parity with MySQL.",
|
||||||
|
"TimescaleDB — hypertables surface in the Objects view on PostgreSQL connections with the extension installed: dimensions, compression status, chunk count, and size.",
|
||||||
|
"PlanetScale preset — managed MySQL (Vitess) provider card with a researched setup guide and .psdb.cloud host detection.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.7.15",
|
||||||
|
date: "2026-09-08",
|
||||||
|
highlights: [
|
||||||
|
"Insert Row now works — it adds an editable pending row at the top of the data grid (with a visible accent outline), and committing inserts only the columns you filled in so serial/identity/generated defaults apply.",
|
||||||
|
"Smart cell editors — date/time/datetime pickers, a boolean select, and number inputs for the matching column types (enums and foreign keys already had dropdowns), applied to both new rows and inline editing.",
|
||||||
|
"Landing page adds a support channel: coffee via Ko-fi (ko-fi.com/adrianbonpin), always optional, never gates a feature.",
|
||||||
|
"Landing page copy refreshed: em dashes dropped, OSS section simplified, FAQ added for how the project is funded.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.7.14",
|
||||||
|
date: "2026-09-04",
|
||||||
|
highlights: [
|
||||||
|
"No more repeated macOS keychain prompts — saved connection passwords and SSH secrets are now ACL-pinned to the app's code signature when stored, so app upgrades never re-trigger the macOS keychain access dialog (users upgrading from older builds see one final prompt — choose Always Allow).",
|
||||||
|
"Install via Homebrew — add the Gitea tap (brew tap AdrianBonpin/gridline https://git.ranio.xyz/adrianbonpin/homebrew-gridline.git) and run brew install --cask gridline.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.7.13",
|
||||||
|
date: "2026-09-04",
|
||||||
|
highlights: [
|
||||||
|
"macOS is now Developer-ID signed and notarized — the app is properly code-signed with a Developer ID Application certificate and notarized by Apple, so macOS opens it without the Gatekeeper 'Open Anyway' prompt or the xattr quarantine workaround.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.7.12",
|
version: "0.7.12",
|
||||||
date: "2026-09-01",
|
date: "2026-09-01",
|
||||||
|
|||||||
+9
-4
@@ -7,12 +7,12 @@ export const faqItems: FaqItem[] = [
|
|||||||
{
|
{
|
||||||
question: "Is Gridline really free for commercial use?",
|
question: "Is Gridline really free for commercial use?",
|
||||||
answer:
|
answer:
|
||||||
"Yes. Gridline is released under the Apache 2.0 license. You can use it for personal, commercial, or internal company work without paying or signing up.",
|
"Yes. Gridline is Apache 2.0. You can use it for personal, commercial, or internal work without paying or signing up.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Which databases are supported?",
|
question: "Which databases are supported?",
|
||||||
answer:
|
answer:
|
||||||
"PostgreSQL and SQLite are fully supported today, including browsing, querying, editing, backups, and visual tools. MySQL and Redis connections are in active development.",
|
"PostgreSQL and SQLite are fully supported today (browsing, querying, editing, backups, and the visual tools). MySQL and Redis connections are in active development.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
|
question: "How is this different from TablePlus, Beekeeper Studio, or DB Pro?",
|
||||||
@@ -22,10 +22,15 @@ export const faqItems: FaqItem[] = [
|
|||||||
{
|
{
|
||||||
question: "Do you store my database credentials?",
|
question: "Do you store my database credentials?",
|
||||||
answer:
|
answer:
|
||||||
"Passwords are stored in your operating system keychain — macOS Keychain, Linux Secret Service, or Windows Credential Manager — never in Gridline's local SQLite database.",
|
"No. Passwords live in your operating system keychain (macOS Keychain, Linux Secret Service, or Windows Credential Manager), never in Gridline's local SQLite database.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Can I self-host or contribute?",
|
question: "How do you make money?",
|
||||||
|
answer:
|
||||||
|
"Gridline is free and isn't built around a paywall. If it saves you time, you can buy a coffee at ko-fi.com/adrianbonpin. It's entirely optional and never gates a feature. Otherwise, issues, docs, and contributions all help.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Can I contribute or self-host?",
|
||||||
answer:
|
answer:
|
||||||
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
|
"Gridline is a desktop app, not a SaaS, so there's nothing to self-host. The repo is open for contributions: code, docs, translations, and issue reports are all welcome.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface Props {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
description = "Gridline — the open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.",
|
description = "Gridline, the open-source database GUI for PostgreSQL, MySQL, SQLite, and Redis. No paywalls, no tab limits, fully open source.",
|
||||||
ogImage = "/og.png",
|
ogImage = "/og.png",
|
||||||
noIndex = false,
|
noIndex = false,
|
||||||
jsonLd = [],
|
jsonLd = [],
|
||||||
@@ -36,8 +36,13 @@ const isProd = import.meta.env.PROD;
|
|||||||
<link rel="canonical" href={canonicalURL} />
|
<link rel="canonical" href={canonicalURL} />
|
||||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/gridline-icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/gridline-icon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/gridline-icon.svg" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<link rel="preload" as="image" href="/og.png" />
|
||||||
|
<link rel="preconnect" href="https://stat.ranio.xyz" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://git.ranio.xyz" />
|
||||||
|
<link rel="dns-prefetch" href="https://stat.ranio.xyz" />
|
||||||
|
<link rel="dns-prefetch" href="https://git.ranio.xyz" />
|
||||||
|
|
||||||
<meta property="og:title" content={title} />
|
<meta property="og:title" content={title} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
@@ -46,7 +51,7 @@ const isProd = import.meta.env.PROD;
|
|||||||
<meta property="og:image" content={ogImageURL} />
|
<meta property="og:image" content={ogImageURL} />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<meta property="og:image:alt" content="Gridline — the open-source database GUI" />
|
<meta property="og:image:alt" content="Gridline, the open-source database GUI" />
|
||||||
<meta property="og:site_name" content="Gridline" />
|
<meta property="og:site_name" content="Gridline" />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
|
|
||||||
@@ -54,6 +59,7 @@ const isProd = import.meta.env.PROD;
|
|||||||
<meta name="twitter:title" content={title} />
|
<meta name="twitter:title" content={title} />
|
||||||
<meta name="twitter:description" content={description} />
|
<meta name="twitter:description" content={description} />
|
||||||
<meta name="twitter:image" content={ogImageURL} />
|
<meta name="twitter:image" content={ogImageURL} />
|
||||||
|
<meta name="twitter:image:alt" content="Gridline, the open-source database GUI" />
|
||||||
<meta name="twitter:site" content="@AdrianBonpin" />
|
<meta name="twitter:site" content="@AdrianBonpin" />
|
||||||
<meta name="twitter:creator" content="@AdrianBonpin" />
|
<meta name="twitter:creator" content="@AdrianBonpin" />
|
||||||
|
|
||||||
@@ -143,12 +149,13 @@ const isProd = import.meta.env.PROD;
|
|||||||
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
<ul class="space-y-2 text-sm text-[var(--text-muted)]">
|
||||||
<li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li>
|
<li><a href="https://x.com/AdrianBonpin" target="_blank" rel="noopener" class="footer-link">X / Twitter</a></li>
|
||||||
<li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li>
|
<li><a href="mailto:adrianbonpin@gmail.com" class="footer-link">Email</a></li>
|
||||||
|
<li><a href="https://ko-fi.com/adrianbonpin" target="_blank" rel="noopener" class="footer-link">Support on Ko-fi ☕</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono">
|
<div class="mt-12 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between gap-3 text-xs text-[var(--text-muted)] font-mono">
|
||||||
<span>© 2026 Gridline contributors · Apache 2.0</span>
|
<span>© 2026 Gridline contributors · Apache 2.0</span>
|
||||||
<span>Made with care for databases.</span>
|
<span>Coffee-powered.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildWebSiteJsonLd,
|
buildWebSiteJsonLd,
|
||||||
buildBreadcrumbJsonLd,
|
buildBreadcrumbJsonLd,
|
||||||
buildTitle,
|
buildTitle,
|
||||||
|
buildProductJsonLd,
|
||||||
} from "./seo";
|
} from "./seo";
|
||||||
|
|
||||||
describe("seo builders", () => {
|
describe("seo builders", () => {
|
||||||
@@ -70,4 +71,18 @@ describe("seo builders", () => {
|
|||||||
it("buildTitle appends the brand", () => {
|
it("buildTitle appends the brand", () => {
|
||||||
expect(buildTitle("PostgreSQL GUI")).toBe("PostgreSQL GUI | Gridline");
|
expect(buildTitle("PostgreSQL GUI")).toBe("PostgreSQL GUI | Gridline");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("buildProductJsonLd returns valid Product schema", () => {
|
||||||
|
const ld = buildProductJsonLd({
|
||||||
|
name: "Gridline",
|
||||||
|
url: "https://getgridline.app/",
|
||||||
|
description: "Open-source database GUI",
|
||||||
|
image: "https://getgridline.app/og.png",
|
||||||
|
offersPrice: 0,
|
||||||
|
offersCurrency: "USD",
|
||||||
|
});
|
||||||
|
expect(ld["@type"]).toBe("Product");
|
||||||
|
expect(ld.name).toBe("Gridline");
|
||||||
|
expect((ld.offers as { price: number }).price).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,3 +108,26 @@ export function buildBreadcrumbJsonLd(
|
|||||||
export function buildTitle(primary: string, brand = "Gridline"): string {
|
export function buildTitle(primary: string, brand = "Gridline"): string {
|
||||||
return `${primary} | ${brand}`;
|
return `${primary} | ${brand}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildProductJsonLd(opts: {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
offersPrice: number;
|
||||||
|
offersCurrency: string;
|
||||||
|
}): JsonLd {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Product",
|
||||||
|
name: opts.name,
|
||||||
|
url: opts.url,
|
||||||
|
description: opts.description,
|
||||||
|
image: opts.image,
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: opts.offersPrice,
|
||||||
|
priceCurrency: opts.offersCurrency,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
|
||||||
const title = "Page not found — Gridline";
|
const title = "Page not found · Gridline";
|
||||||
const description = "That page doesn’t exist. Head back to Gridline, the open-source database GUI.";
|
const description = "That page doesn’t exist. Head back to Gridline, the open-source database GUI.";
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user