From cb86caba9e96904b276a2c144571054daa74b348 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 6 Sep 2026 13:46:31 +0800 Subject: [PATCH 01/19] docs: define 0.8.0 scope and reorganize ROADMAP.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0.8.0: schema diff/compare, multiple result sets, result streaming, MariaDB, TimescaleDB, PlanetScale preset (Turso dropped โ€” needs a libsql driver path, stays in the queue) - Reorganized: roadmap items up top, all shipped sections consolidated below; removed entries redundant with versioned sections - Fixed stale references (settings import/export shipped in 0.7.8, tense fixes in 0.7.8 section, trimmed 0.7.0 deferred list) --- ROADMAP.md | 97 +++++++++++++++++++++++++----------------------------- 1 file changed, 45 insertions(+), 52 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 61ad821..3ba1652 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,38 +6,22 @@ This file is the **source of truth** for what Gridline is building. [AGENTS.md]( --- -## โœ… Shipped (0.7.10) +## ๐ŸŽฏ Next up (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. -- **Version bump** 0.7.9 โ†’ **0.7.10**. +- **Schema diff / compare** โ€” two-database structure diff (tables, columns, indexes, constraints, views) with generated sync SQL; pairs naturally with DB-to-DB sync +- **Query Workbench: multiple result sets** โ€” one query, stacked result tabs instead of only the last result *(deferred from 0.7.0)* +- **Query Workbench: result streaming to file** โ€” export 500k+ rows without loading them all into memory +- **MariaDB support** โ€” wire-compatible with MySQL; largely falls out of the v0.7.0 MySQL work (new `db_type`, provider card, version/introspection differences) +- **TimescaleDB support** โ€” PostgreSQL extension; connects as regular PG, detect + surface hypertables/compression in the object tree +- **PlanetScale preset** โ€” managed MySQL (Vitess) provider card following the Supabase/NeonDB pattern -## โœ… 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**. - -## ๐ŸŽฏ 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**. +Suggested build order: MariaDB โ†’ PlanetScale + TimescaleDB โ†’ query workbench (streaming, then multiple result sets) โ†’ schema diff. ## ๐Ÿ“‹ In the queue ### 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) - Value editors per type: string, list, hash, set, zset, stream, JSON @@ -46,28 +30,26 @@ Connection + test work today; **key browsing is explicitly not part of 0.7.0** ### Additional Database Types -- **MariaDB** (wire-compatible with MySQL โ€” should largely fall out of the 0.7.0 MySQL work) -- **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) +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). ### 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) +- **Turso** (libSQL โ€” not SQLite over the wire; needs a `libsql` driver path in the Rust backend, not just a connection preset) - 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 -- **Multiple result sets** โ€” one query, multiple result tabs (stacked/scrollable) instead of only the last result *(deferred from 0.7.0)* -- **Result streaming to file** โ€” export 500k+ rows without loading them all into memory +Multiple result sets and result streaming to file moved to [0.8.0](#-next-up-080). Remaining: + - **Visual query builder** โ€” drag-and-drop tables/joins/filters that generate SQL (TablePlus has one; DB Pro plans one) ### Schema & Data Tooling -- **Schema diff / compare** โ€” two-database structure diff that pairs naturally with DB-to-DB sync -- **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)* +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 *(PostgreSQL-only today)* - **More export formats** โ€” JSONL, Parquet alongside CSV/JSON/SQL/Markdown/Excel ## ๐Ÿ”ฎ Planned @@ -99,15 +81,37 @@ Continuous polish, tracked as issues rather than one-off milestones: - 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 -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 -- **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) - **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.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) ### Admin follow-up @@ -151,24 +155,13 @@ Deferred from 0.7.0, slated for this bucket: - **Input styling sweep** โ€” `rounded-full` โ†’ `rounded-lg` on all form controls. - **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 -- 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 +- PostgreSQL and SQLite browse/query support - Workspace tree, folders, tags, favorites, recents - Multi-tab DB viewer with virtualized grid, server-side filtering/sorting - FK preview, JSON popover, inline cell editing, changes queue @@ -177,4 +170,4 @@ Deferred from 0.7.0, slated for this bucket: - Full PostgreSQL object explorer + schema visualizer - Backup, restore, and DB-to-DB sync tools - Settings redesign with theme, accent color, editor options -- Built-in **Gridline Demo (SQLite)** database \ No newline at end of file +- Built-in **Gridline Demo (SQLite)** database -- 2.54.0 From 9e54240cd6418393b07573e5b23c83aa406e7ccb Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 6 Sep 2026 13:48:12 +0800 Subject: [PATCH 02/19] docs: reflect live website in roadmap (landing + changelog shipped, docs remain) --- ROADMAP.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3ba1652..bf8633b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -66,9 +66,9 @@ Bring-your-own-key โ€” no bundled model, no paywall, key stored in the OS keycha ### 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) -- Blog / changelog feed ### UI/UX Improvements (rolling) -- 2.54.0 From 0db68d12b7ee8575fa44ffab3396852945dffa33 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 17:58:40 +0800 Subject: [PATCH 03/19] feat: accept mariadb as a db_type across validation, test-connection, and db_connect (Task 1) --- desktop/src-tauri/src/commands/connections.rs | 6 ++- .../src/commands/connections.test.rs | 47 ++++++++++++++++ desktop/src-tauri/src/commands/db_viewer.rs | 2 +- .../src-tauri/src/commands/import_export.rs | 2 +- .../src-tauri/src/commands/test_connection.rs | 7 ++- .../src/commands/test_connection.test.rs | 54 +++++++++++++++++++ 6 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 desktop/src-tauri/src/commands/connections.test.rs create mode 100644 desktop/src-tauri/src/commands/test_connection.test.rs diff --git a/desktop/src-tauri/src/commands/connections.rs b/desktop/src-tauri/src/commands/connections.rs index 378ed65..a50b27f 100644 --- a/desktop/src-tauri/src/commands/connections.rs +++ b/desktop/src-tauri/src/commands/connections.rs @@ -2,7 +2,7 @@ use crate::models::{Connection, ConnectionInput}; use crate::store::Store; 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> { 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); } } + +#[cfg(test)] +#[path = "connections.test.rs"] +mod connections_tests; diff --git a/desktop/src-tauri/src/commands/connections.test.rs b/desktop/src-tauri/src/commands/connections.test.rs new file mode 100644 index 0000000..839de56 --- /dev/null +++ b/desktop/src-tauri/src/commands/connections.test.rs @@ -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()); +} diff --git a/desktop/src-tauri/src/commands/db_viewer.rs b/desktop/src-tauri/src/commands/db_viewer.rs index 59fc72a..9ed0b73 100644 --- a/desktop/src-tauri/src/commands/db_viewer.rs +++ b/desktop/src-tauri/src/commands/db_viewer.rs @@ -1198,7 +1198,7 @@ pub async fn db_connect( } 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( &connection_id, &config, diff --git a/desktop/src-tauri/src/commands/import_export.rs b/desktop/src-tauri/src/commands/import_export.rs index 89a374c..8db0e18 100644 --- a/desktop/src-tauri/src/commands/import_export.rs +++ b/desktop/src-tauri/src/commands/import_export.rs @@ -3,7 +3,7 @@ use crate::store::Store; use serde::{Deserialize, Serialize}; 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)] pub(crate) struct ImportRecord { diff --git a/desktop/src-tauri/src/commands/test_connection.rs b/desktop/src-tauri/src/commands/test_connection.rs index 7df38f2..ba56551 100644 --- a/desktop/src-tauri/src/commands/test_connection.rs +++ b/desktop/src-tauri/src/commands/test_connection.rs @@ -107,7 +107,7 @@ fn url_scheme_end(msg: &str, i: usize) -> Option { pub fn validate_test_input(config: &DbConfig) -> Option { 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()) { return Some(format!( "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() { "postgresql" => test_pg_connection(config, ssh).await, "mysql" => test_mysql_connection(config, ssh).await, + "mariadb" => test_mysql_connection(config, ssh).await, "sqlite" => test_sqlite_connection(config), "redis" => test_redis_connection(config, ssh).await, other => TestConnectionResult { @@ -724,3 +725,7 @@ mod tests { ); } } + +#[cfg(test)] +#[path = "test_connection.test.rs"] +mod test_connection_tests; diff --git a/desktop/src-tauri/src/commands/test_connection.test.rs b/desktop/src-tauri/src/commands/test_connection.test.rs new file mode 100644 index 0000000..0e1413b --- /dev/null +++ b/desktop/src-tauri/src/commands/test_connection.test.rs @@ -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) -> 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::().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:?}" + ); +} -- 2.54.0 From 28ce32ce9101bd9c0aaf9b5b73897a9fa8dcf22f Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 18:01:16 +0800 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20MariaDB=20type=20layer=20?= =?UTF-8?q?=E2=80=94=20union,=20capabilities,=20icons,=20ports,=20URI=20pa?= =?UTF-8?q?rse/copy,=20filter=20(Task=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../connections/NewConnectionScreen.tsx | 1 + .../src/components/editor/QueryToolbar.tsx | 1 + .../layout/DbTypeFilterDropdown.tsx | 1 + desktop/src/lib/connectionString.test.ts | 40 +++++++++++++++++++ desktop/src/lib/connectionString.ts | 4 ++ desktop/src/lib/dbCapabilities.test.ts | 10 +++++ desktop/src/lib/dbCapabilities.ts | 1 + desktop/src/lib/dbIcons.tsx | 6 ++- desktop/src/lib/providers.test.ts | 11 ++++- desktop/src/lib/providers.ts | 3 +- desktop/src/lib/types.ts | 2 +- desktop/src/lib/utils.ts | 2 +- 12 files changed, 76 insertions(+), 6 deletions(-) diff --git a/desktop/src/components/connections/NewConnectionScreen.tsx b/desktop/src/components/connections/NewConnectionScreen.tsx index 2ce0407..224a4f5 100644 --- a/desktop/src/components/connections/NewConnectionScreen.tsx +++ b/desktop/src/components/connections/NewConnectionScreen.tsx @@ -31,6 +31,7 @@ type Stage = "entry" | "configured"; const FALLBACK_PORTS: Record = { postgresql: 5432, mysql: 3306, + mariadb: 3306, redis: 6379, }; diff --git a/desktop/src/components/editor/QueryToolbar.tsx b/desktop/src/components/editor/QueryToolbar.tsx index 4d78224..b94c1d8 100644 --- a/desktop/src/components/editor/QueryToolbar.tsx +++ b/desktop/src/components/editor/QueryToolbar.tsx @@ -9,6 +9,7 @@ import type { DbType } from "../../lib/types"; const DB_TYPE_LABELS: Record = { postgresql: "PostgreSQL", mysql: "MySQL", + mariadb: "MariaDB", sqlite: "SQLite", redis: "Redis", }; diff --git a/desktop/src/components/layout/DbTypeFilterDropdown.tsx b/desktop/src/components/layout/DbTypeFilterDropdown.tsx index c9524dd..b11b92b 100644 --- a/desktop/src/components/layout/DbTypeFilterDropdown.tsx +++ b/desktop/src/components/layout/DbTypeFilterDropdown.tsx @@ -7,6 +7,7 @@ import type { DbType } from "../../lib/types"; const DB_TYPES: { value: DbType; label: string }[] = [ { value: "postgresql", label: "PostgreSQL" }, { value: "mysql", label: "MySQL" }, + { value: "mariadb", label: "MariaDB" }, { value: "sqlite", label: "SQLite" }, { value: "redis", label: "Redis" }, ]; diff --git a/desktop/src/lib/connectionString.test.ts b/desktop/src/lib/connectionString.test.ts index 2ee320c..fd8ce99 100644 --- a/desktop/src/lib/connectionString.test.ts +++ b/desktop/src/lib/connectionString.test.ts @@ -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", () => { const result = parseConnectionString("redis://localhost"); expect(result).toEqual({ @@ -212,6 +236,22 @@ describe("buildConnectionUrl", () => { 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", () => { const url = buildConnectionUrl( makeConn({ db_type: "redis", host: "localhost", port: 6379, username: null, database: "0" }), diff --git a/desktop/src/lib/connectionString.ts b/desktop/src/lib/connectionString.ts index a3740e8..cd550b1 100644 --- a/desktop/src/lib/connectionString.ts +++ b/desktop/src/lib/connectionString.ts @@ -13,6 +13,7 @@ const DB_PROTOCOLS: Record = { "postgresql:": "postgresql", "postgres:": "postgresql", "mysql:": "mysql", + "mariadb:": "mariadb", "sqlite:": "sqlite", "file:": "sqlite", "redis:": "redis", @@ -22,6 +23,7 @@ const DB_PROTOCOLS: Record = { const DEFAULT_PORTS: Record = { postgresql: 5432, mysql: 3306, + mariadb: 3306, sqlite: null, redis: 6379, }; @@ -127,6 +129,8 @@ export function buildConnectionUrl(conn: Connection, password: string | null): s } case "mysql": return `mysql://${auth}${conn.host}${port}${db}`; + case "mariadb": + return `mariadb://${auth}${conn.host}${port}${db}`; case "sqlite": // host stores the file path; `sqlite:///abs/path` round-trips through // parseConnectionString (pathname โ†’ host). diff --git a/desktop/src/lib/dbCapabilities.test.ts b/desktop/src/lib/dbCapabilities.test.ts index e45b1d9..6209467 100644 --- a/desktop/src/lib/dbCapabilities.test.ts +++ b/desktop/src/lib/dbCapabilities.test.ts @@ -23,6 +23,16 @@ describe("dbCapabilities", () => { 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", () => { const c = DB_CAPABILITIES.sqlite; expect(c.explorer).toBe(true); diff --git a/desktop/src/lib/dbCapabilities.ts b/desktop/src/lib/dbCapabilities.ts index 9302643..2543c18 100644 --- a/desktop/src/lib/dbCapabilities.ts +++ b/desktop/src/lib/dbCapabilities.ts @@ -36,6 +36,7 @@ const ALL_FALSE: DbCapabilities = { export const DB_CAPABILITIES: Record = { 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 }, + 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 }, redis: { ...ALL_FALSE }, }; diff --git a/desktop/src/lib/dbIcons.tsx b/desktop/src/lib/dbIcons.tsx index 6013cba..8bd8c21 100644 --- a/desktop/src/lib/dbIcons.tsx +++ b/desktop/src/lib/dbIcons.tsx @@ -1,10 +1,11 @@ -import { siPostgresql, siMysql, siSqlite, siRedis, siSupabase, siNeon } from "simple-icons"; +import { siPostgresql, siMysql, siMariadb, siSqlite, siRedis, siSupabase, siNeon } from "simple-icons"; import type { DbType } from "./types"; // Brand colors from simple-icons const DB_COLORS: Record = { postgresql: `#${siPostgresql.hex}`, mysql: `#${siMysql.hex}`, + mariadb: `#${siMariadb.hex}`, redis: `#${siRedis.hex}`, sqlite: `#${siSqlite.hex}`, }; @@ -13,6 +14,7 @@ const DB_COLORS: Record = { const DB_PATHS: Record = { postgresql: siPostgresql.path, mysql: siMysql.path, + mariadb: siMariadb.path, redis: siRedis.path, sqlite: siSqlite.path, }; @@ -20,6 +22,7 @@ const DB_PATHS: Record = { export const DB_LABELS: Record = { postgresql: "PostgreSQL", mysql: "MySQL", + mariadb: "MariaDB", redis: "Redis", sqlite: "SQLite", }; @@ -54,6 +57,7 @@ export function DbIcon({ type, size = 20, className }: DbIconProps) { export const DB_ICONS: Record = { postgresql: "๐Ÿ˜", mysql: "๐Ÿฌ", + mariadb: "๐Ÿฆญ", redis: "โšก", sqlite: "๐Ÿ—„๏ธ", }; diff --git a/desktop/src/lib/providers.test.ts b/desktop/src/lib/providers.test.ts index 8811ff0..2242b28 100644 --- a/desktop/src/lib/providers.test.ts +++ b/desktop/src/lib/providers.test.ts @@ -2,12 +2,19 @@ import { describe, it, expect } from "vitest"; import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers"; describe("providers", () => { - it("defines exactly 6 provider tabs in order", () => { + it("defines exactly 7 provider tabs in order", () => { expect(PROVIDER_TABS.map((p) => p.id)).toEqual([ - "postgresql", "mysql", "sqlite", "redis", "supabase", "neon", + "postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", ]); }); + 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", () => { const pg = getProviderById("postgresql")!; expect(pg.dbType).toBe("postgresql"); diff --git a/desktop/src/lib/providers.ts b/desktop/src/lib/providers.ts index ab0f427..6789753 100644 --- a/desktop/src/lib/providers.ts +++ b/desktop/src/lib/providers.ts @@ -1,6 +1,6 @@ import type { DbType } from "./types"; -export type ProviderId = "postgresql" | "mysql" | "sqlite" | "redis" | "supabase" | "neon"; +export type ProviderId = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis" | "supabase" | "neon"; export interface ProviderTab { id: ProviderId; @@ -15,6 +15,7 @@ export interface ProviderTab { export const PROVIDER_TABS: ProviderTab[] = [ { id: "postgresql", label: "PostgreSQL", dbType: "postgresql", 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: "redis", label: "Redis", dbType: "redis", isManagedPreset: false }, { id: "supabase", label: "Supabase", dbType: "postgresql", isManagedPreset: true }, diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index 6c9d206..721472a 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -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 FontSize = "small" | "medium" | "large"; diff --git a/desktop/src/lib/utils.ts b/desktop/src/lib/utils.ts index 7851b29..d6cd31b 100644 --- a/desktop/src/lib/utils.ts +++ b/desktop/src/lib/utils.ts @@ -31,7 +31,7 @@ export function abbreviateType(dataType: string): string { 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 { if (!input.name || input.name.length === 0) return { ok: false, error: "name is required" }; -- 2.54.0 From 45e3327db4480831bf514561f5da5bd37e1a387e Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 18:05:13 +0800 Subject: [PATCH 05/19] feat: PlanetScale managed-MySQL preset with setup guide and host detection (Task 3) --- .../connections/DetailedConnectionForm.tsx | 2 +- .../src/components/connections/GeneralTab.tsx | 4 ++-- .../connections/NewConnectionScreen.tsx | 11 ++++++---- .../connections/ProviderSetupGuide.tsx | 5 +++-- .../connections/ProviderTabsGrid.tsx | 2 +- desktop/src/lib/connectionString.test.ts | 13 ++++++++++++ desktop/src/lib/connectionString.ts | 5 ++++- desktop/src/lib/dbIcons.tsx | 6 ++++-- desktop/src/lib/providers.test.ts | 20 +++++++++++++++++-- desktop/src/lib/providers.ts | 16 +++++++++++++-- 10 files changed, 67 insertions(+), 17 deletions(-) diff --git a/desktop/src/components/connections/DetailedConnectionForm.tsx b/desktop/src/components/connections/DetailedConnectionForm.tsx index 53d5f00..9a54b88 100644 --- a/desktop/src/components/connections/DetailedConnectionForm.tsx +++ b/desktop/src/components/connections/DetailedConnectionForm.tsx @@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData"; export interface DetailedConnectionFormProps { form: ConnectionFormData; onChange: (updates: Partial) => void; - managedPreset?: "supabase" | "neon" | null; + managedPreset?: "supabase" | "neon" | "planetscale" | null; } export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) { diff --git a/desktop/src/components/connections/GeneralTab.tsx b/desktop/src/components/connections/GeneralTab.tsx index c5f1aa5..3dc4a0d 100644 --- a/desktop/src/components/connections/GeneralTab.tsx +++ b/desktop/src/components/connections/GeneralTab.tsx @@ -7,7 +7,7 @@ import type { ConnectionFormData } from "./connectionFormData"; export interface GeneralTabProps { form: ConnectionFormData; onChange: (updates: Partial) => void; - managedPreset?: "supabase" | "neon" | null; + managedPreset?: "supabase" | "neon" | "planetscale" | null; } const AUTH_OPTIONS = ["User & Password"]; @@ -32,7 +32,7 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) { )} {managedPreset && (

- {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.

)} diff --git a/desktop/src/components/connections/NewConnectionScreen.tsx b/desktop/src/components/connections/NewConnectionScreen.tsx index 224a4f5..eac2a9b 100644 --- a/desktop/src/components/connections/NewConnectionScreen.tsx +++ b/desktop/src/components/connections/NewConnectionScreen.tsx @@ -74,7 +74,7 @@ export function NewConnectionScreen({ }: NewConnectionScreenProps) { const [stage, setStage] = useState("entry"); const [managedPreset, setManagedPreset] = useState< - "supabase" | "neon" | null + "supabase" | "neon" | "planetscale" | null >(null); const [form, setForm] = useState(() => createEmptyForm( @@ -99,7 +99,7 @@ export function NewConnectionScreen({ const parsed = parseConnectionString(value); if (parsed) { const provider = - parsed.db_type === "postgresql" + parsed.db_type === "postgresql" || parsed.db_type === "mysql" ? detectProviderFromHost(parsed.host) : null; setManagedPreset(provider); @@ -139,7 +139,9 @@ export function NewConnectionScreen({ (id: ProviderId) => { const provider = getProviderById(id)!; setManagedPreset( - provider.isManagedPreset ? (id as "supabase" | "neon") : null, + provider.isManagedPreset + ? (id as "supabase" | "neon" | "planetscale") + : null, ); revealConfigured({ db_type: provider.dbType, @@ -148,6 +150,7 @@ export function NewConnectionScreen({ ? null : getDefaultPort(provider.dbType), ...(provider.dbType === "sqlite" ? { host: "" } : {}), + ...(id === "planetscale" ? { ssl_mode: "verify-full" } : {}), }); }, [revealConfigured], @@ -164,7 +167,7 @@ export function NewConnectionScreen({ const parsed = parseConnectionString(value); if (parsed) { const provider = - parsed.db_type === "postgresql" + parsed.db_type === "postgresql" || parsed.db_type === "mysql" ? detectProviderFromHost(parsed.host) : null; setManagedPreset(provider); diff --git a/desktop/src/components/connections/ProviderSetupGuide.tsx b/desktop/src/components/connections/ProviderSetupGuide.tsx index 1ad52a6..a818b6b 100644 --- a/desktop/src/components/connections/ProviderSetupGuide.tsx +++ b/desktop/src/components/connections/ProviderSetupGuide.tsx @@ -3,12 +3,13 @@ import { ChevronDown } from "lucide-react"; import { SETUP_GUIDES } from "../../lib/providers"; 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.", neon: "Neon requires SSL.", + planetscale: "PlanetScale requires SSL with identity verification.", }; export function ProviderSetupGuide({ provider }: ProviderSetupGuideProps) { diff --git a/desktop/src/components/connections/ProviderTabsGrid.tsx b/desktop/src/components/connections/ProviderTabsGrid.tsx index a4fc9fb..d9b4fdd 100644 --- a/desktop/src/components/connections/ProviderTabsGrid.tsx +++ b/desktop/src/components/connections/ProviderTabsGrid.tsx @@ -24,7 +24,7 @@ export function ProviderTabsGrid({ selectedId, onSelect }: ProviderTabsGridProps }`} > - {p.isManagedPreset ? : } + {p.isManagedPreset ? : } {p.label} {p.isManagedPreset && ( diff --git a/desktop/src/lib/connectionString.test.ts b/desktop/src/lib/connectionString.test.ts index fd8ce99..9f0dc4c 100644 --- a/desktop/src/lib/connectionString.test.ts +++ b/desktop/src/lib/connectionString.test.ts @@ -194,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", () => { it("builds a PostgreSQL URL with password and sslmode", () => { const url = buildConnectionUrl( diff --git a/desktop/src/lib/connectionString.ts b/desktop/src/lib/connectionString.ts index cd550b1..be2eae9 100644 --- a/desktop/src/lib/connectionString.ts +++ b/desktop/src/lib/connectionString.ts @@ -85,10 +85,13 @@ export function looksLikeConnectionString(input: string): boolean { /** Detect a managed-PostgreSQL provider from a connection host, purely for * UI highlighting. Returns `"supabase"` / `"neon"` / `null`. db_type is * 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(); if (h.endsWith(".supabase.co")) return "supabase"; if (h.endsWith(".neon.tech")) return "neon"; + if (h.endsWith(".psdb.cloud")) return "planetscale"; return null; } diff --git a/desktop/src/lib/dbIcons.tsx b/desktop/src/lib/dbIcons.tsx index 8bd8c21..7e80fbd 100644 --- a/desktop/src/lib/dbIcons.tsx +++ b/desktop/src/lib/dbIcons.tsx @@ -1,4 +1,4 @@ -import { siPostgresql, siMysql, siMariadb, siSqlite, siRedis, siSupabase, siNeon } from "simple-icons"; +import { siPostgresql, siMysql, siMariadb, siSqlite, siRedis, siSupabase, siNeon, siPlanetscale } from "simple-icons"; import type { DbType } from "./types"; // Brand colors from simple-icons @@ -64,16 +64,18 @@ export const DB_ICONS: Record = { // โ”€โ”€ Managed-PostgreSQL provider icons (Supabase, NeonDB) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // 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 = { supabase: { hex: `#${siSupabase.hex}`, path: siSupabase.path }, neon: { hex: `#${siNeon.hex}`, path: siNeon.path }, + planetscale: { hex: `#${siPlanetscale.hex}`, path: siPlanetscale.path }, }; export const PROVIDER_LABELS: Record = { supabase: "Supabase", neon: "NeonDB", + planetscale: "PlanetScale", }; interface ProviderIconProps { diff --git a/desktop/src/lib/providers.test.ts b/desktop/src/lib/providers.test.ts index 2242b28..4811aaa 100644 --- a/desktop/src/lib/providers.test.ts +++ b/desktop/src/lib/providers.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect } from "vitest"; import { PROVIDER_TABS, getProviderById, SETUP_GUIDES } from "./providers"; describe("providers", () => { - it("defines exactly 7 provider tabs in order", () => { + it("defines exactly 8 provider tabs in order", () => { expect(PROVIDER_TABS.map((p) => p.id)).toEqual([ - "postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", + "postgresql", "mysql", "mariadb", "sqlite", "redis", "supabase", "neon", "planetscale", ]); }); @@ -40,4 +40,20 @@ describe("providers", () => { expect(SETUP_GUIDES.supabase.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); + }); }); \ No newline at end of file diff --git a/desktop/src/lib/providers.ts b/desktop/src/lib/providers.ts index 6789753..015b6e6 100644 --- a/desktop/src/lib/providers.ts +++ b/desktop/src/lib/providers.ts @@ -1,6 +1,6 @@ import type { DbType } from "./types"; -export type ProviderId = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis" | "supabase" | "neon"; +export type ProviderId = "postgresql" | "mysql" | "mariadb" | "sqlite" | "redis" | "supabase" | "neon" | "planetscale"; export interface ProviderTab { id: ProviderId; @@ -20,6 +20,7 @@ export const PROVIDER_TABS: ProviderTab[] = [ { id: "redis", label: "Redis", dbType: "redis", isManagedPreset: false }, { id: "supabase", label: "Supabase", 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 { @@ -40,7 +41,7 @@ export interface SetupGuide { /** Static, researched setup instructions. No network calls at runtime. * 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: { blurb: "Managed PostgreSQL with a pooled connection string.", sslRequired: true, @@ -66,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: { blurb: "Serverless Postgres with pooled or direct endpoints.", sslRequired: true, -- 2.54.0 From 1d0bf3870036dcce08ff7277c8f19c33879cbf68 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 18:08:42 +0800 Subject: [PATCH 06/19] feat: TimescaleDB detection + get_hypertables command with graceful unavailable state (Task 4) --- desktop/src-tauri/src/commands/db_viewer.rs | 60 ++++++++++++++- .../src-tauri/src/commands/db_viewer.test.rs | 73 +++++++++++++++++++ desktop/src-tauri/src/db/introspection.rs | 25 +++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/models/db_viewer.rs | 17 +++++ 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 desktop/src-tauri/src/commands/db_viewer.test.rs diff --git a/desktop/src-tauri/src/commands/db_viewer.rs b/desktop/src-tauri/src/commands/db_viewer.rs index 9ed0b73..61d7e8c 100644 --- a/desktop/src-tauri/src/commands/db_viewer.rs +++ b/desktop/src-tauri/src/commands/db_viewer.rs @@ -5,8 +5,8 @@ use crate::db::pool::{ConnectionPoolManager, DbConfig, DbHandle}; use crate::models::db_viewer::{ - Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo, - QueryResult, SequenceInfo, TableInfo, TriggerInfo, + Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, HypertableInfo, + HypertableListResponse, IndexInfo, QueryResult, SequenceInfo, TableInfo, TriggerInfo, }; use bytes::BytesMut; use std::collections::HashMap; @@ -2640,6 +2640,58 @@ pub async fn get_functions( } } +#[tauri::command] +pub async fn get_hypertables( + connection_id: String, + schema: Option, + state: State<'_, crate::AppState>, +) -> Result { + 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>(2).unwrap_or(0), + compression_enabled: r.get::<_, Option>(3).unwrap_or(false), + num_chunks: r.get::<_, Option>(4).unwrap_or(0), + total_size_bytes: r.get::<_, Option>(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] pub async fn get_indexes( connection_id: String, @@ -3637,3 +3689,7 @@ mod tests { assert!(!pg_dump_available_at("/nonexistent/pg_dump_999999")); } } + +#[cfg(test)] +#[path = "db_viewer.test.rs"] +mod db_viewer_extra_tests; diff --git a/desktop/src-tauri/src/commands/db_viewer.test.rs b/desktop/src-tauri/src/commands/db_viewer.test.rs new file mode 100644 index 0000000..8debc19 --- /dev/null +++ b/desktop/src-tauri/src/commands/db_viewer.test.rs @@ -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"); +} diff --git a/desktop/src-tauri/src/db/introspection.rs b/desktop/src-tauri/src/db/introspection.rs index b8c6602..22832f5 100644 --- a/desktop/src-tauri/src/db/introspection.rs +++ b/desktop/src-tauri/src/db/introspection.rs @@ -887,3 +887,28 @@ mod tests { 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() +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2d7394b..e8f75ff 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -136,6 +136,7 @@ pub fn run() { db_viewer::get_extensions, db_viewer::get_indexes, db_viewer::get_constraints, + db_viewer::get_hypertables, objects::create_schema, objects::rename_schema, objects::drop_schema, diff --git a/desktop/src-tauri/src/models/db_viewer.rs b/desktop/src-tauri/src/models/db_viewer.rs index ecf72a7..371af06 100644 --- a/desktop/src-tauri/src/models/db_viewer.rs +++ b/desktop/src-tauri/src/models/db_viewer.rs @@ -132,6 +132,23 @@ pub struct ExtensionInfo { pub comment: Option, } +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HypertableListResponse { + pub available: bool, + pub reason: Option, + pub items: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObjectSearchHit { pub name: String, -- 2.54.0 From 3e3881cfd5753a9f9f22349efa497a108762b424 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 18:14:18 +0800 Subject: [PATCH 07/19] feat: hypertables object type with runtime availability gating in Objects view (Task 5) --- .../db-viewer/ObjectExplorerPage.tsx | 37 ++++++++++++++++--- .../components/db-viewer/ObjectTree.test.tsx | 17 +++++++++ .../src/components/db-viewer/ObjectTree.tsx | 32 ++++++++++++---- .../db-viewer/objects/ObjectDetail.tsx | 4 ++ desktop/src/lib/commands.ts | 9 ++++- desktop/src/lib/types.ts | 16 ++++++++ desktop/src/stores/dbViewerStore.ts | 10 ++++- 7 files changed, 111 insertions(+), 14 deletions(-) create mode 100644 desktop/src/components/db-viewer/ObjectTree.test.tsx diff --git a/desktop/src/components/db-viewer/ObjectExplorerPage.tsx b/desktop/src/components/db-viewer/ObjectExplorerPage.tsx index aa52cd5..525610e 100644 --- a/desktop/src/components/db-viewer/ObjectExplorerPage.tsx +++ b/desktop/src/components/db-viewer/ObjectExplorerPage.tsx @@ -22,10 +22,6 @@ interface ObjectExplorerPageProps { 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. */ function emptyPlural(type: ObjectType): string { const singular = SINGULAR_LABELS[type]; @@ -75,6 +71,7 @@ function typeToDdlType(type: ObjectType): string { sequences: "sequence", enums: "enum", extensions: "extension", + hypertables: "table", indexes: "index", constraints: "constraint", roles: "role", @@ -125,6 +122,19 @@ export function ObjectExplorerPage({ const changesQueue = useDbViewerStore((s) => s.changesQueue); const openObjectTab = useDbViewerStore((s) => s.openObjectTab); 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 const [items, setItems] = useState(null); @@ -218,6 +228,23 @@ export function ObjectExplorerPage({ } }, [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 // object-type list + refresh the schema tree so newly created/dropped // objects show up without a manual refresh. @@ -325,7 +352,7 @@ export function ObjectExplorerPage({ handleTypeChange(v as ObjectType)} - options={OBJECT_TYPE_OPTIONS} + options={objectTypeOptions} variant="ghost" aria-label="Object type" /> diff --git a/desktop/src/components/db-viewer/ObjectTree.test.tsx b/desktop/src/components/db-viewer/ObjectTree.test.tsx new file mode 100644 index 0000000..83cf3e5 --- /dev/null +++ b/desktop/src/components/db-viewer/ObjectTree.test.tsx @@ -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; + expect(visibleObjectTypes(null)).not.toContain("hypertables"); + expect(visibleObjectTypes(false)).not.toContain("hypertables"); + expect(visibleObjectTypes(true)).toContain("hypertables"); + expect(visibleObjectTypes(true).length).toBe(base.length); + }); +}); diff --git a/desktop/src/components/db-viewer/ObjectTree.tsx b/desktop/src/components/db-viewer/ObjectTree.tsx index 5bbeb4d..a70a168 100644 --- a/desktop/src/components/db-viewer/ObjectTree.tsx +++ b/desktop/src/components/db-viewer/ObjectTree.tsx @@ -1,22 +1,23 @@ 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 * 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"; -type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions"; +type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions" | "hypertables"; interface ObjectTreeProps { type: ObjectType; connectionId: string; } -const TYPE_LABELS: Record = { +export const TYPE_LABELS: Record = { functions: "functions", triggers: "triggers", sequences: "sequences", enums: "enums", extensions: "extensions", + hypertables: "hypertables", }; const ICONS: Record = { @@ -25,8 +26,18 @@ const ICONS: Record = { sequences: , enums: , extensions: , + hypertables: , }; +/** 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 }) { const [expanded, setExpanded] = useState(false); const maxLen = 500; @@ -59,11 +70,13 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { const sequences = useDbViewerStore((s) => s.sequences); const enums = useDbViewerStore((s) => s.enums); const extensions = useDbViewerStore((s) => s.extensions); + const hypertables = useDbViewerStore((s) => s.hypertables); const setFunctions = useDbViewerStore((s) => s.setFunctions); const setTriggers = useDbViewerStore((s) => s.setTriggers); const setSequences = useDbViewerStore((s) => s.setSequences); const setEnums = useDbViewerStore((s) => s.setEnums); const setExtensions = useDbViewerStore((s) => s.setExtensions); + const setHypertables = useDbViewerStore((s) => s.setHypertables); const [loading, setLoading] = useState(false); const [expandedKeys, setExpandedKeys] = useState>(new Set()); @@ -77,8 +90,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { case "sequences": return sequences; case "enums": return enums; case "extensions": return extensions; + case "hypertables": return hypertables; } - }, [type, functions, triggers, sequences, enums, extensions]); + }, [type, functions, triggers, sequences, enums, extensions, hypertables]); const setter = useMemo(() => { switch (type) { @@ -87,8 +101,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { case "sequences": return setSequences; case "enums": return setEnums; 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 useEffect(() => { @@ -113,6 +128,9 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { } else if (type === "enums") { const result = await cmd.getEnums(connectionId, currentSchema ?? undefined); 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 { // Silently fail โ€” store remains null, we show the empty state @@ -123,7 +141,7 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { fetchData(); 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(); diff --git a/desktop/src/components/db-viewer/objects/ObjectDetail.tsx b/desktop/src/components/db-viewer/objects/ObjectDetail.tsx index c929f2c..2d6577f 100644 --- a/desktop/src/components/db-viewer/objects/ObjectDetail.tsx +++ b/desktop/src/components/db-viewer/objects/ObjectDetail.tsx @@ -6,6 +6,7 @@ import { ListChecks, ListOrdered, Puzzle, + Layers, ShieldCheck, SquareFunction, Tag, @@ -29,6 +30,7 @@ export const TYPE_LABELS: Record = { sequences: "Sequences", enums: "Enums", extensions: "Extensions", + hypertables: "Hypertables", indexes: "Indexes", constraints: "Constraints", procedures: "Procedures", @@ -41,6 +43,7 @@ export const SINGULAR_LABELS: Record = { sequences: "sequence", enums: "enum", extensions: "extension", + hypertables: "hypertable", indexes: "index", constraints: "constraint", procedures: "procedure", @@ -55,6 +58,7 @@ export const OBJECT_ICONS: Record = { sequences: , enums: , extensions: , + hypertables: , indexes: , constraints: , procedures: , diff --git a/desktop/src/lib/commands.ts b/desktop/src/lib/commands.ts index 06b821c..d24d942 100644 --- a/desktop/src/lib/commands.ts +++ b/desktop/src/lib/commands.ts @@ -1,5 +1,5 @@ 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 } from "./types"; import type { FilterRule, SortRule } from "../stores/dbViewerStore"; import type { ChangePayload } from "./changePayload"; import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud"; @@ -226,6 +226,13 @@ export async function getExtensions(connectionId: string): Promise("get_extensions", { connectionId }); } +export async function getHypertables( + connectionId: string, + schema?: string, +): Promise { + return invoke("get_hypertables", { connectionId, schema }); +} + export async function getSchemaGraph( connectionId: string, schema?: string, diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index 721472a..fee94ab 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -367,6 +367,7 @@ export type ObjectType = | "sequences" | "enums" | "extensions" + | "hypertables" | "indexes" | "constraints" | "procedures" @@ -378,6 +379,21 @@ export interface ObjectSearchHit { 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 { deptype: string; class: string; diff --git a/desktop/src/stores/dbViewerStore.ts b/desktop/src/stores/dbViewerStore.ts index 70d6fda..6ea9732 100644 --- a/desktop/src/stores/dbViewerStore.ts +++ b/desktop/src/stores/dbViewerStore.ts @@ -1,5 +1,5 @@ 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 } from "../lib/types"; import { getDatabases, getSchemas, getTables } from "../lib/commands"; import type { ObjectKind, DdlParams } from "../lib/objectCrud"; @@ -117,6 +117,8 @@ interface DbViewerState { sequences: SequenceInfo[] | null; enums: EnumInfo[] | null; extensions: ExtensionInfo[] | null; + hypertables: HypertableInfo[] | null; + hypertablesAvailable: boolean | null; indexes: IndexInfo[] | null; setSchemaTreeLoading: (loading: boolean) => void; constraints: ConstraintInfo[] | null; @@ -182,6 +184,8 @@ interface DbViewerState { setSequences: (sequences: SequenceInfo[]) => void; setEnums: (enums: EnumInfo[]) => void; setExtensions: (extensions: ExtensionInfo[]) => void; + setHypertables: (hypertables: HypertableInfo[]) => void; + setHypertablesAvailable: (v: boolean | null) => void; setIndexes: (indexes: IndexInfo[]) => void; setConstraints: (constraints: ConstraintInfo[]) => void; setRoles: (roles: RoleInfo[]) => void; @@ -224,6 +228,8 @@ const initialState = { sequences: null as SequenceInfo[] | null, enums: null as EnumInfo[] | null, extensions: null as ExtensionInfo[] | null, + hypertables: null as HypertableInfo[] | null, + hypertablesAvailable: null as boolean | null, indexes: null as IndexInfo[] | null, constraints: null as ConstraintInfo[] | null, roles: [] as RoleInfo[], @@ -586,6 +592,8 @@ export const useDbViewerStore = create((set, get) => ({ setSequences: (sequences) => set({ sequences }), setEnums: (enums) => set({ enums }), setExtensions: (extensions) => set({ extensions }), + setHypertables: (hypertables) => set({ hypertables }), + setHypertablesAvailable: (v) => set({ hypertablesAvailable: v }), setIndexes: (indexes) => set({ indexes }), setConstraints: (constraints) => set({ constraints }), setRoles: (roles) => set({ roles }), -- 2.54.0 From 1f2ae7348be36991f027cdea12b03729f3087b1b Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 18:16:33 +0800 Subject: [PATCH 08/19] feat: hypertable detail view with metadata, view-table action, and DDL caveat (Task 6) --- .../db-viewer/HypertableDetail.test.tsx | 43 +++++++++++++++++ .../components/db-viewer/HypertableDetail.tsx | 48 +++++++++++++++++++ .../src/components/db-viewer/ObjectTree.tsx | 11 +++++ 3 files changed, 102 insertions(+) create mode 100644 desktop/src/components/db-viewer/HypertableDetail.test.tsx create mode 100644 desktop/src/components/db-viewer/HypertableDetail.tsx diff --git a/desktop/src/components/db-viewer/HypertableDetail.test.tsx b/desktop/src/components/db-viewer/HypertableDetail.test.tsx new file mode 100644 index 0000000..c49576e --- /dev/null +++ b/desktop/src/components/db-viewer/HypertableDetail.test.tsx @@ -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(); + 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(); + expect(screen.getByText(/create_hypertable/)).toBeTruthy(); + }); + + it("opens the underlying table on click", () => { + const onOpenTable = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /view table/i })); + expect(onOpenTable).toHaveBeenCalledWith("public", "metrics"); + }); + + it("marks compression as disabled when off", () => { + render( + , + ); + expect(screen.getByText(/compression disabled/)).toBeTruthy(); + }); +}); diff --git a/desktop/src/components/db-viewer/HypertableDetail.tsx b/desktop/src/components/db-viewer/HypertableDetail.tsx new file mode 100644 index 0000000..b7d5e54 --- /dev/null +++ b/desktop/src/components/db-viewer/HypertableDetail.tsx @@ -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 ( +
+
{item.name}
+
+ {item.num_dimensions} dimensions + + {item.compression_enabled ? "compression enabled" : "compression disabled"} + + {item.num_chunks} chunks + {humanBytes(item.total_size_bytes)} +
+ +

+ 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. +

+
+ ); +} diff --git a/desktop/src/components/db-viewer/ObjectTree.tsx b/desktop/src/components/db-viewer/ObjectTree.tsx index a70a168..37ebc42 100644 --- a/desktop/src/components/db-viewer/ObjectTree.tsx +++ b/desktop/src/components/db-viewer/ObjectTree.tsx @@ -3,6 +3,7 @@ import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, import { useDbViewerStore } from "../../stores/dbViewerStore"; import * as cmd from "../../lib/commands"; import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo } from "../../lib/types"; +import { HypertableDetail } from "./HypertableDetail"; type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions" | "hypertables"; @@ -336,6 +337,16 @@ export function ObjectTree({ type, connectionId }: ObjectTreeProps) { ); })()} + + {type === "hypertables" && (() => { + const h = item as HypertableInfo; + return ( + useDbViewerStore.getState().openTab(schema, table)} + /> + ); + })()} )} -- 2.54.0 From 2f2e145cea14625939d0b9a39d6a1783d777a662 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 18:19:06 +0800 Subject: [PATCH 09/19] feat: hand-rolled SQL statement splitter with quoting/comment/dollar-quote awareness (Task 7) --- desktop/src-tauri/src/db/mod.rs | 1 + desktop/src-tauri/src/db/sql_split.rs | 212 +++++++++++++++++++++ desktop/src-tauri/src/db/sql_split.test.rs | 86 +++++++++ 3 files changed, 299 insertions(+) create mode 100644 desktop/src-tauri/src/db/sql_split.rs create mode 100644 desktop/src-tauri/src/db/sql_split.test.rs diff --git a/desktop/src-tauri/src/db/mod.rs b/desktop/src-tauri/src/db/mod.rs index b4d919e..22b7da8 100644 --- a/desktop/src-tauri/src/db/mod.rs +++ b/desktop/src-tauri/src/db/mod.rs @@ -1,5 +1,6 @@ pub mod introspection; pub mod mysql; +pub mod sql_split; pub mod object_crud; pub mod object_ddl; pub mod pool; diff --git a/desktop/src-tauri/src/db/sql_split.rs b/desktop/src-tauri/src/db/sql_split.rs new file mode 100644 index 0000000..c1e0b07 --- /dev/null +++ b/desktop/src-tauri/src/db/sql_split.rs @@ -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 { + let chars: Vec = sql.chars().collect(); + let mut stmts: Vec = 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 { + 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 { + 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 { + let closing: Vec = 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 = 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; diff --git a/desktop/src-tauri/src/db/sql_split.test.rs b/desktop/src-tauri/src/db/sql_split.test.rs new file mode 100644 index 0000000..c7f5f64 --- /dev/null +++ b/desktop/src-tauri/src/db/sql_split.test.rs @@ -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::::new()); +} + +#[test] +fn empty_and_whitespace_input() { + assert_eq!(split_statements(""), Vec::::new()); + assert_eq!(split_statements(" ; ; "), Vec::::new()); +} -- 2.54.0 From c85d16d0db72abe41e182367481e395888fe00d0 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 18:23:49 +0800 Subject: [PATCH 10/19] =?UTF-8?q?feat:=20execute=5Fquery=5Fmulti=20?= =?UTF-8?q?=E2=80=94=20stacked=20result=20sets,=20DML=20notices,=20first-e?= =?UTF-8?q?rror=20stop=20(Task=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/src-tauri/src/commands/query.rs | 507 ++++++++++++++++++- desktop/src-tauri/src/commands/query.test.rs | 134 +++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/models/db_viewer.rs | 16 + 4 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/commands/query.test.rs diff --git a/desktop/src-tauri/src/commands/query.rs b/desktop/src-tauri/src/commands/query.rs index 783b42f..b4743a6 100644 --- a/desktop/src-tauri/src/commands/query.rs +++ b/desktop/src-tauri/src/commands/query.rs @@ -10,7 +10,7 @@ //! 3. Every query is recorded in the local `query_history` table. 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 sqlx::{Column, Row}; 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, + }, +} + +/// 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::() + .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 { + tag.split_whitespace().last()?.parse::().ok() +} + +fn notice_for( + idx: usize, + kind: &str, + text: String, + affected: Option, +) -> 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 { + 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> = 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, + notices: &mut Vec, +) -> Option { + 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 { + 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 = 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> = 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 = Vec::new(); + let mut all_rows: Vec> = Vec::new(); + let mut last_tag: Option = 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 = (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(); + 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> = 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, + notices: &mut Vec, +) -> Option { + 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 { + 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 = 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> = 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, + notices: &mut Vec, + cancel_registry: &crate::cancel::CancelRegistry, + connection_id: &str, +) -> Option { + 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, + page_size: Option, + state: State<'_, crate::AppState>, +) -> Result { + 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 = Vec::new(); + let mut notices: Vec = Vec::new(); + + let err_text: Option; + 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 // --------------------------------------------------------------------------- @@ -1378,3 +1879,7 @@ mod tests { assert!(res.unwrap_err().contains("Query cancelled")); } } + +#[cfg(test)] +#[path = "query.test.rs"] +mod query_tests; diff --git a/desktop/src-tauri/src/commands/query.test.rs b/desktop/src-tauri/src/commands/query.test.rs new file mode 100644 index 0000000..54ffb59 --- /dev/null +++ b/desktop/src-tauri/src/commands/query.test.rs @@ -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; +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e8f75ff..777d131 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -174,6 +174,7 @@ pub fn run() { settings::import_settings, schema_graph::get_schema_graph, query::execute_query, + query::execute_query_multi, query::cancel_query, query::get_query_history, query::clear_query_history, diff --git a/desktop/src-tauri/src/models/db_viewer.rs b/desktop/src-tauri/src/models/db_viewer.rs index 371af06..f884ea3 100644 --- a/desktop/src-tauri/src/models/db_viewer.rs +++ b/desktop/src-tauri/src/models/db_viewer.rs @@ -72,6 +72,22 @@ pub struct QueryResult { pub execution_time_ms: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatementNotice { + pub statement_index: usize, + /// "dml" | "ddl" | "error" + pub kind: String, + pub text: String, + pub affected: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MultiQueryResult { + pub result_sets: Vec, + pub notices: Vec, + pub execution_time_ms: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Pagination { pub page: i64, -- 2.54.0 From 5e8107982ba71355a9accd60079819d7d4247ef1 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 18:31:12 +0800 Subject: [PATCH 11/19] feat: stacked result-set panels with DML/error notices in the query workspace (Task 9) --- .../db-viewer/DbViewerScreen.test.tsx | 42 +++--- .../components/db-viewer/DbViewerScreen.tsx | 141 +++++++++--------- .../db-viewer/ResultSetPanels.test.tsx | 38 +++++ .../components/db-viewer/ResultSetPanels.tsx | 42 ++++++ desktop/src/lib/commands.ts | 11 +- desktop/src/lib/types.ts | 13 ++ desktop/src/stores/dbViewerStore.ts | 11 +- 7 files changed, 205 insertions(+), 93 deletions(-) create mode 100644 desktop/src/components/db-viewer/ResultSetPanels.test.tsx create mode 100644 desktop/src/components/db-viewer/ResultSetPanels.tsx diff --git a/desktop/src/components/db-viewer/DbViewerScreen.test.tsx b/desktop/src/components/db-viewer/DbViewerScreen.test.tsx index 3a4ac6e..03ab12a 100644 --- a/desktop/src/components/db-viewer/DbViewerScreen.test.tsx +++ b/desktop/src/components/db-viewer/DbViewerScreen.test.tsx @@ -281,8 +281,8 @@ describe("DbViewerScreen", () => { it("executes a non-destructive query when Run is clicked", async () => { const executeQuery = vi - .spyOn(commands, "executeQuery") - .mockResolvedValue(mockQueryResult as any); + .spyOn(commands, "executeQueryMulti") + .mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any); render( { it("shows a destructive-query confirmation dialog and executes on confirm", async () => { const executeQuery = vi - .spyOn(commands, "executeQuery") + .spyOn(commands, "executeQueryMulti") .mockResolvedValue({ - columns: [], - rows: [], - total_rows: 0, - page: 1, - page_size: 50, + result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }], + notices: [], + execution_time_ms: 42, } as any); render( { }); it("refreshes the schema tree after a DDL query runs", async () => { - vi.spyOn(commands, "executeQuery").mockResolvedValue( - mockQueryResult as any, + vi.spyOn(commands, "executeQueryMulti").mockResolvedValue( + { result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any, ); const getSchemas = vi .spyOn(commands, "getSchemas") @@ -377,8 +375,8 @@ describe("DbViewerScreen", () => { }); it("does not refresh the schema tree after a SELECT", async () => { - vi.spyOn(commands, "executeQuery").mockResolvedValue( - mockQueryResult as any, + vi.spyOn(commands, "executeQueryMulti").mockResolvedValue( + { result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any, ); const getSchemas = vi .spyOn(commands, "getSchemas") @@ -396,7 +394,7 @@ describe("DbViewerScreen", () => { ); fireEvent.change(textarea, { target: { value: "SELECT 1" } }); 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(); }); @@ -423,8 +421,8 @@ describe("DbViewerScreen", () => { it("runs the current query when the Cmd+Enter action fires", async () => { const executeQuery = vi - .spyOn(commands, "executeQuery") - .mockResolvedValue(mockQueryResult as any); + .spyOn(commands, "executeQueryMulti") + .mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any); render( { const pending = new Promise((r) => { resolveRun = r; }); - vi.spyOn(commands, "executeQuery").mockReturnValue(pending as any); + vi.spyOn(commands, "executeQueryMulti").mockReturnValue(pending as any); render( { await waitFor(() => expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(), ); - resolveRun(mockQueryResult); + resolveRun({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 }); await waitFor(() => 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 () => { const executeQuery = vi - .spyOn(commands, "executeQuery") - .mockResolvedValue(mockQueryResult as any); + .spyOn(commands, "executeQueryMulti") + .mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any); render( { }); it("resizes the results panel with a drag handle, clamped to min/max", async () => { - vi.spyOn(commands, "executeQuery").mockResolvedValue( - mockQueryResult as any, + vi.spyOn(commands, "executeQueryMulti").mockResolvedValue( + { result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any, ); render( { }); 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, "getDatabases").mockResolvedValue(["mydb"]); vi.spyOn(commands, "getTables").mockResolvedValue([] as any); diff --git a/desktop/src/components/db-viewer/DbViewerScreen.tsx b/desktop/src/components/db-viewer/DbViewerScreen.tsx index cd2a518..04c79aa 100644 --- a/desktop/src/components/db-viewer/DbViewerScreen.tsx +++ b/desktop/src/components/db-viewer/DbViewerScreen.tsx @@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip"; import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar"; import { DbViewerToolbar } from "./DbViewerToolbar"; import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils"; -import { executeQuery, cancelQuery } from "../../lib/commands"; +import { executeQueryMulti, cancelQuery } from "../../lib/commands"; import { getCapabilities } from "../../lib/dbCapabilities"; const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor }))); @@ -19,6 +19,7 @@ import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail"; import { ObjectFormTab } from "./objects/ObjectFormTab"; import { TabBar } from "./TabBar"; import { VirtualDataGrid } from "../grid/VirtualDataGrid"; +import { ResultSetPanels } from "./ResultSetPanels"; import { RowDetailDrawer } from "../grid/RowDetailDrawer"; import { TableControls } from "./TableControls"; import { EditConnectionModal } from "./EditConnectionModal"; @@ -260,6 +261,7 @@ export function DbViewerScreen({ const setTabData = useDbViewerStore((s) => s.setTabData); const setTabError = useDbViewerStore((s) => s.setTabError); + const setTabMulti = useDbViewerStore((s) => s.setTabMulti); const setTabLoading = useDbViewerStore((s) => s.setTabLoading); const databases = useDbViewerStore((s) => s.databases); const currentDatabase = useDbViewerStore((s) => s.currentDatabase); @@ -311,8 +313,24 @@ export function DbViewerScreen({ if (!tab) return; setTabLoading(tabId, true); try { - const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize); - setTabData(tabId, result); + const multi = await executeQueryMulti(connectionId, sql, tab.page, tab.pageSize); + 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); if (isSchemaModifyingQuery(sql)) { const st = useDbViewerStore.getState(); @@ -1017,6 +1035,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 (
@@ -1212,71 +1272,16 @@ const onQueriesPanelResizeStart = useCallback( /> )}
- { - 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, - ), - ); - }, - ); - }} - /> + {activeTab?.tabType === "query" && activeTab.multi && (activeTab.multi.result_sets.length > 1 || activeTab.multi.notices.length > 0) ? ( + ( + + )} + /> + ) : ( + + )}
diff --git a/desktop/src/components/db-viewer/ResultSetPanels.test.tsx b/desktop/src/components/db-viewer/ResultSetPanels.test.tsx new file mode 100644 index 0000000..58243fb --- /dev/null +++ b/desktop/src/components/db-viewer/ResultSetPanels.test.tsx @@ -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(); + 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(); + 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(() =>
); + render(); + expect(renderGrid).toHaveBeenCalledTimes(2); + }); +}); diff --git a/desktop/src/components/db-viewer/ResultSetPanels.tsx b/desktop/src/components/db-viewer/ResultSetPanels.tsx new file mode 100644 index 0000000..fc2067f --- /dev/null +++ b/desktop/src/components/db-viewer/ResultSetPanels.tsx @@ -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 ( +
+ {multi.result_sets.map((set, i) => ( +
+
+ Result {i + 1} + โ€” {set.total_rows} row{set.total_rows === 1 ? "" : "s"} +
+
+ {renderGrid(set, i)} +
+
+ ))} + {multi.notices.map((n, i) => ( +
+ {n.kind === "error" ? : } + Statement {n.statement_index + 1} + {n.text} + {n.affected !== null && n.affected !== undefined && ( + ({n.affected} rows affected) + )} +
+ ))} +
+ ); +} diff --git a/desktop/src/lib/commands.ts b/desktop/src/lib/commands.ts index d24d942..254ab99 100644 --- a/desktop/src/lib/commands.ts +++ b/desktop/src/lib/commands.ts @@ -1,5 +1,5 @@ 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, HypertableListResponse } 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 { ChangePayload } from "./changePayload"; import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud"; @@ -286,6 +286,15 @@ export async function executeQuery( return invoke("execute_query", { connectionId, query, page, pageSize }); } +export async function executeQueryMulti( + connectionId: string, + query: string, + page: number, + pageSize: number, +): Promise { + return invoke("execute_query_multi", { connectionId, query, page, pageSize }); +} + export async function getQueryHistory( connectionId: string | null, limit: number, diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index fee94ab..477fef9 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -189,6 +189,19 @@ export interface QueryResult { 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 ChangeItemType = diff --git a/desktop/src/stores/dbViewerStore.ts b/desktop/src/stores/dbViewerStore.ts index 6ea9732..acfa360 100644 --- a/desktop/src/stores/dbViewerStore.ts +++ b/desktop/src/stores/dbViewerStore.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types"; +import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo, MultiQueryResult } from "../lib/types"; import { getDatabases, getSchemas, getTables } from "../lib/commands"; import type { ObjectKind, DdlParams } from "../lib/objectCrud"; @@ -58,6 +58,7 @@ export interface ViewerTab { loading: boolean; error: string | null; data: QueryResult | null; + multi?: MultiQueryResult | null; columnFilter?: { column: string; value: string }; filterRules: FilterRule[]; sortRules: SortRule[]; @@ -146,6 +147,7 @@ interface DbViewerState { setPage: (tabId: string, page: number) => void; setPageSize: (tabId: string, pageSize: number) => void; setTabData: (tabId: string, data: QueryResult) => void; + setTabMulti: (tabId: string, multi: MultiQueryResult | null) => void; setTabLoading: (tabId: string, loading: boolean) => void; setTabError: (tabId: string, error: string) => void; setColumnFilter: (tabId: string, column: string, value: string) => void; @@ -449,10 +451,15 @@ export const useDbViewerStore = create((set, get) => ({ setTabData: (tabId, data) => set((state) => ({ 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) => set((state) => ({ tabs: state.tabs.map((t) => -- 2.54.0 From f7c77119ea811b6397d9141e3fb2c5338c29e5ba Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 18:43:10 +0800 Subject: [PATCH 12/19] feat: streaming export to CSV/JSONL/JSON with cursor fetch, cancel, and fail-closed cleanup (Task 10) --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/commands/mod.rs | 1 + .../src-tauri/src/commands/query_export.rs | 539 ++++++++++++++++++ .../src/commands/query_export.test.rs | 235 ++++++++ desktop/src-tauri/src/lib.rs | 3 +- 6 files changed, 779 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/commands/query_export.rs create mode 100644 desktop/src-tauri/src/commands/query_export.test.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ef6df9d..a45dd72 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1789,6 +1789,7 @@ dependencies = [ "chrono", "core-foundation 0.10.1", "deadpool-postgres", + "futures-util", "indexmap 2.14.0", "redis", "rusqlite", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 86d0289..3f177a1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ tauri-plugin-fs = "2" tokio = { version = "1", features = ["full"] } tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] } bytes = "1" +futures-util = "0.3" sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] } 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 diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 3614e2f..01e38da 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod keychain; pub mod maintenance; pub mod objects; pub mod query; +pub mod query_export; pub mod schema_graph; pub mod settings; pub mod ssh; diff --git a/desktop/src-tauri/src/commands/query_export.rs b/desktop/src-tauri/src/commands/query_export.rs new file mode 100644 index 0000000..2293fb1 --- /dev/null +++ b/desktop/src-tauri/src/commands/query_export.rs @@ -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 { + 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, +} + +/// 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::() + .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::>() + .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: &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(path: &PathBuf, w: W, f: F) -> Result +where + W: Write, + F: FnOnce(&mut std::io::BufWriter) -> Result, +{ + 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 { + 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 = (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 = (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 { + 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 = 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::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 = (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 { + 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 = 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 = + (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 { + 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 { + 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; diff --git a/desktop/src-tauri/src/commands/query_export.test.rs b/desktop/src-tauri/src/commands/query_export.test.rs new file mode 100644 index 0000000..a09c78c --- /dev/null +++ b/desktop/src-tauri/src/commands/query_export.test.rs @@ -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; +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 777d131..def56de 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -24,7 +24,7 @@ pub struct AppState { use commands::{ backup, connections, db_viewer, demo, folders, import_export, keychain, maintenance, objects, query, - schema_graph, settings, tags, + query_export, schema_graph, settings, tags, }; // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ @@ -175,6 +175,7 @@ pub fn run() { schema_graph::get_schema_graph, query::execute_query, query::execute_query_multi, + query_export::export_query_to_file, query::cancel_query, query::get_query_history, query::clear_query_history, -- 2.54.0 From fc31d5614ea41719a05cc4ff379778c48ba3ce04 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 20:50:42 +0800 Subject: [PATCH 13/19] feat: full-result export dialog with progress/cancel + client CSV injection parity (Task 11) --- .../components/db-viewer/DbViewerScreen.tsx | 34 ++++ .../db-viewer/StreamExportDialog.test.tsx | 44 +++++ .../db-viewer/StreamExportDialog.tsx | 160 ++++++++++++++++++ .../components/db-viewer/TableControls.tsx | 18 ++ desktop/src/lib/commands.ts | 9 + desktop/src/lib/exportData.test.ts | 24 ++- desktop/src/lib/exportData.ts | 30 +++- 7 files changed, 311 insertions(+), 8 deletions(-) create mode 100644 desktop/src/components/db-viewer/StreamExportDialog.test.tsx create mode 100644 desktop/src/components/db-viewer/StreamExportDialog.tsx diff --git a/desktop/src/components/db-viewer/DbViewerScreen.tsx b/desktop/src/components/db-viewer/DbViewerScreen.tsx index 04c79aa..b6a741e 100644 --- a/desktop/src/components/db-viewer/DbViewerScreen.tsx +++ b/desktop/src/components/db-viewer/DbViewerScreen.tsx @@ -22,6 +22,8 @@ import { VirtualDataGrid } from "../grid/VirtualDataGrid"; import { ResultSetPanels } from "./ResultSetPanels"; import { RowDetailDrawer } from "../grid/RowDetailDrawer"; import { TableControls } from "./TableControls"; +import { StreamExportDialog } from "./StreamExportDialog"; +import { qualifiedTableSql } from "../../lib/exportData"; import { EditConnectionModal } from "./EditConnectionModal"; import { PasswordPromptDialog } from "./PasswordPromptDialog"; import { useDbConnection } from "../../hooks/useDbConnection"; @@ -178,6 +180,7 @@ export function DbViewerScreen({ const [rowDetailIdx, setRowDetailIdx] = useState(null); const [editModalOpen, setEditModalOpen] = useState(false); const [destructiveQuery, setDestructiveQuery] = useState(null); + const [streamExport, setStreamExport] = useState<{ sql: string } | null>(null); const connections = useConnectionStore((s) => s.connections); const currentConnection = connections.find((c) => c.id === connectionId) ?? null; @@ -1269,6 +1272,18 @@ const onQueriesPanelResizeStart = useCallback( } variant="query" isMatview={readOnlyTable} + onExportToFile={() => + setStreamExport({ + sql: + activeTab?.tabType === "query" + ? activeTab.query ?? "" + : qualifiedTableSql( + currentConnection?.db_type ?? "postgresql", + activeSchema, + activeTable, + ), + }) + } /> )}
@@ -1380,6 +1395,18 @@ const onQueriesPanelResizeStart = useCallback( setSelectedRows(new Set()) } isMatview={readOnlyTable} + onExportToFile={() => + setStreamExport({ + sql: + activeTab?.tabType === "query" + ? activeTab.query ?? "" + : qualifiedTableSql( + currentConnection?.db_type ?? "postgresql", + activeSchema, + activeTable, + ), + }) + } /> )}
@@ -1572,6 +1599,13 @@ const onQueriesPanelResizeStart = useCallback( {capabilities.objects && ( )} + {streamExport && ( + setStreamExport(null)} + /> + )}
); diff --git a/desktop/src/components/db-viewer/StreamExportDialog.test.tsx b/desktop/src/components/db-viewer/StreamExportDialog.test.tsx new file mode 100644 index 0000000..5f28443 --- /dev/null +++ b/desktop/src/components/db-viewer/StreamExportDialog.test.tsx @@ -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(); + 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(); + 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).mockRejectedValueOnce(new Error("boom")); + render(); + fireEvent.click(screen.getByRole("button", { name: /export/i })); + await waitFor(() => expect(screen.getByText(/boom/i)).toBeTruthy()); + }); +}); diff --git a/desktop/src/components/db-viewer/StreamExportDialog.tsx b/desktop/src/components/db-viewer/StreamExportDialog.tsx new file mode 100644 index 0000000..c926124 --- /dev/null +++ b/desktop/src/components/db-viewer/StreamExportDialog.tsx @@ -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 = { + 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(null); + const [error, setError] = useState(null); + const jobIdRef = useRef(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 ( + +
+

Export all rows to file

+ +
+ void pickSource(e.target.value)} + className="rounded-lg bg-surface-raised border border-border px-2 py-1" + aria-label="Source connection" + > + + {sameFamilySources.map((c) => ( + + ))} + + + โ†’ + + + {report && stageableCount > 0 && ( + + )} + {report && report.items.length > 0 && ( + + )} +
+ {report?.truncated && ( +
+ Snapshot capped at 5,000 objects per side โ€” report is truncated. +
+ )} + {error &&
{error}
} +
+ +
+ {report && report.items.length === 0 && ( +
Schemas are identical โ€” no differences found.
+ )} + {shouldVirtualize ? ( +
+ {virtualizer.getVirtualItems().map((vi) => ( +
+ +
+ ))} +
+ ) : ( + grouped.map(([type, items]) => ( +
+

{type}s

+
+ {items.map((it) => ( + + ))} +
+
+ )) + )} +
+
+ ); +} diff --git a/desktop/src/components/db-viewer/ToolsPage.tsx b/desktop/src/components/db-viewer/ToolsPage.tsx index 055a8ee..62673aa 100644 --- a/desktop/src/components/db-viewer/ToolsPage.tsx +++ b/desktop/src/components/db-viewer/ToolsPage.tsx @@ -3,13 +3,15 @@ import { SelectDropdown } from "../ui/SelectDropdown"; import { BackupPage } from "./BackupPage"; import { RestorePage } from "./RestorePage"; import { SyncPage } from "./SyncPage"; +import { SchemaDiffPage } from "./SchemaDiffPage"; -type ToolOperation = "backup" | "restore" | "sync"; +type ToolOperation = "backup" | "restore" | "sync" | "diff"; const OPERATION_OPTIONS = [ { value: "backup", label: "Backup" }, { value: "restore", label: "Restore" }, { value: "sync", label: "DB Sync" }, + { value: "diff", label: "Schema Diff" }, ]; export function ToolsPage({ connectionId }: { connectionId: string }) { @@ -33,6 +35,7 @@ export function ToolsPage({ connectionId }: { connectionId: string }) { {operation === "backup" && } {operation === "restore" && } {operation === "sync" && } + {operation === "diff" && }
); -- 2.54.0 From 4d7be1b192d88618da8f68bcceb215f4b22b8969 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Wed, 9 Sep 2026 21:10:23 +0800 Subject: [PATCH 18/19] chore: bump version to 0.8.0 across app shell, landing page, README (Task 17) --- README.md | 30 +++++++++++++-------------- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- desktop/src/lib/bundle-config.test.ts | 6 +++--- desktop/src/lib/docs-coverage.test.ts | 18 ++++++++-------- desktop/src/lib/version.test.ts | 4 ++-- package.json | 2 +- www/public/landing.js | 2 +- www/src/pages/index.astro | 2 +- 11 files changed, 36 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 9f1032f..b088773 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,16 @@ ## 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.15**): +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 | | :--------------------------- | :--------------------------- | :----------------------------------------------------------------------------------------------------------------------------------- | -| **macOS** | Apple Silicon (M1/M2/M3/M4โ€ฆ) | [Gridline_0.7.15_aarch64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_aarch64.dmg) | -| **macOS** | Intel | [Gridline_0.7.15_x64.dmg](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64.dmg) | -| **Windows** | x64 | [Gridline_0.7.15_x64-setup.exe](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_x64-setup.exe) | -| **Debian / Ubuntu** | amd64 | [Gridline_0.7.15_amd64.deb](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.deb) | -| **Fedora / RHEL / openSUSE** | x86_64 | [Gridline-0.7.15-1.x86_64.rpm](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline-0.7.15-1.x86_64.rpm) | -| **Other Linux** | amd64 | [Gridline_0.7.15_amd64.AppImage](https://git.ranio.xyz/adrianbonpin/gridline/releases/download/v0.7.15/Gridline_0.7.15_amd64.AppImage) | +| **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.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.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.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.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.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): @@ -57,7 +57,7 @@ The cask installs the app directly. The DMGs are Developer-ID signed and notariz > **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.