diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 00000000..9291cfdb --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,35 @@ +name: Frontend + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Version consistency + run: node scripts/check-version.mjs + + - name: Lint and format + run: yarn check:ci + + - name: Typecheck + run: yarn typecheck + + - name: Test + run: yarn test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bea91951..e97e9238 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,10 +36,10 @@ jobs: with: node-version: lts/* - - name: Install Rust nightly - uses: dtolnay/rust-toolchain@nightly - with: - targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + # Toolchain version comes from src-tauri/rust-toolchain.toml. + - name: Install Rust targets + if: matrix.platform == 'macos-latest' + run: rustup target add aarch64-apple-darwin x86_64-apple-darwin - name: Install dependencies (ubuntu only) if: matrix.platform == 'ubuntu-22.04' @@ -55,6 +55,9 @@ jobs: - name: Install frontend dependencies run: yarn install --frozen-lockfile + - name: Version consistency + run: node scripts/check-version.mjs + - name: Resolve release tag id: release_meta shell: bash diff --git a/.github/workflows/rust-clippy.yml b/.github/workflows/rust-clippy.yml deleted file mode 100644 index 7f3ab9fb..00000000 --- a/.github/workflows/rust-clippy.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: rust-clippy analyze - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - schedule: - - cron: '35 7 * * 1' - -jobs: - rust-clippy-analyze: - name: Run rust-clippy analyzing - runs-on: ubuntu-latest - permissions: - contents: read - security-events: write - actions: read - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust nightly - uses: dtolnay/rust-toolchain@nightly - with: - components: clippy - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libjavascriptcoregtk-4.1-dev - - - name: Rust cache - uses: swatinem/rust-cache@v2 - with: - workspaces: "./src-tauri -> target" - - - name: Install required cargo - run: cargo install clippy-sarif sarif-fmt - - - name: Run rust-clippy - run: - cargo clippy - --all-features - --message-format=json | clippy-sarif | tee rust-clippy-results.sarif | sarif-fmt - working-directory: src-tauri - continue-on-error: true - - - name: Upload analysis results to GitHub - uses: github/codeql-action/upload-sarif@v4 - with: - sarif_file: src-tauri/rust-clippy-results.sarif - wait-for-processing: true diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e453e0f5..872900e5 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -2,9 +2,9 @@ name: Rust on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] env: CARGO_TERM_COLOR: always @@ -14,25 +14,62 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Install Rust nightly - uses: dtolnay/rust-toolchain@nightly + # Toolchain comes from src-tauri/rust-toolchain.toml. + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libjavascriptcoregtk-4.1-dev - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libjavascriptcoregtk-4.1-dev + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./src-tauri -> target" - - name: Rust cache - uses: swatinem/rust-cache@v2 - with: - workspaces: "./src-tauri -> target" + - name: Format + run: cargo fmt --check + working-directory: src-tauri - - name: Build - run: cargo build --verbose - working-directory: src-tauri + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + working-directory: src-tauri - - name: Run tests - run: cargo test --verbose - working-directory: src-tauri + - name: Test + run: cargo test + working-directory: src-tauri + + integration: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libjavascriptcoregtk-4.1-dev + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./src-tauri -> target" + + - name: Row mutation tests against PostgreSQL + run: cargo test --test row_mutations -- --ignored + working-directory: src-tauri + env: + RSQL_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres diff --git a/.gitignore b/.gitignore index af4dfdee..df022e92 100644 --- a/.gitignore +++ b/.gitignore @@ -24,16 +24,15 @@ dist-ssr *.sw? # Rust/Tauri build output -/target -/src-tauri/target +target/ +**/gen/schemas/ # Local sled databases -project_db -query_db - -/src-tauri/project_db# Local databases (should never be committed) -src-tauri/project_db/ -src-tauri/query_db/ +project_db/ +query_db/ *.db .claude + +# Agent working notes +docs/superpowers/ diff --git a/package.json b/package.json index f59f46bd..b3d7b3f2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "rsql", "private": true, - "version": "1.1.5", + "version": "1.2.0", "license": "MIT", "type": "module", "scripts": { @@ -12,7 +12,10 @@ "format": "biome format --write .", "lint": "biome lint .", "check": "biome check --write .", - "check:ci": "biome check ." + "check:ci": "biome check .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@glideapps/glide-data-grid": "^6.0.3", @@ -34,8 +37,8 @@ "lodash": "^4.17.23", "lucide-react": "^0.454.0", "marked": "^17.0.4", - "monaco-editor": "^0.53.0", - "monaco-sql-languages": "^1.0.0", + "monaco-editor": "0.55.0", + "monaco-sql-languages": "^1.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-responsive-carousel": "^3.2.23", @@ -55,6 +58,7 @@ "tailwindcss": "^4.1.13", "tw-animate-css": "^1.3.3", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^3.2.4" } } diff --git a/scripts/check-version.mjs b/scripts/check-version.mjs new file mode 100644 index 00000000..0adb43f3 --- /dev/null +++ b/scripts/check-version.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * The release version lives in three files that must agree; the git history + * shows them drifting apart more than once. Run this in CI and as the first + * step of a release. + * + * With --set it writes that version to all three instead of checking. + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +const sources = [ + { + path: "package.json", + read: (text) => JSON.parse(text).version, + write: (text, version) => text.replace(/("version":\s*)"[^"]*"/, `$1"${version}"`), + }, + { + path: "src-tauri/tauri.conf.json", + read: (text) => JSON.parse(text).version, + write: (text, version) => text.replace(/("version":\s*)"[^"]*"/, `$1"${version}"`), + }, + { + path: "src-tauri/Cargo.toml", + read: (text) => text.match(/^version\s*=\s*"([^"]+)"/m)?.[1], + write: (text, version) => text.replace(/^(version\s*=\s*)"[^"]*"/m, `$1"${version}"`), + }, +]; + +const setIndex = process.argv.indexOf("--set"); +const target = setIndex === -1 ? null : process.argv[setIndex + 1]; + +if (setIndex !== -1 && !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(target ?? "")) { + console.error(`Not a valid semver version: ${target ?? "(missing)"}`); + process.exit(1); +} + +if (target) { + for (const source of sources) { + const file = join(root, source.path); + writeFileSync(file, source.write(readFileSync(file, "utf8"), target)); + console.log(`${source.path} -> ${target}`); + } + process.exit(0); +} + +const found = sources.map((source) => ({ + path: source.path, + version: source.read(readFileSync(join(root, source.path), "utf8")), +})); + +const missing = found.filter((entry) => !entry.version); +if (missing.length > 0) { + for (const entry of missing) console.error(`No version found in ${entry.path}`); + process.exit(1); +} + +const distinct = [...new Set(found.map((entry) => entry.version))]; +if (distinct.length > 1) { + console.error("Version mismatch:"); + for (const entry of found) console.error(` ${entry.path}: ${entry.version}`); + console.error("\nRun `node scripts/check-version.mjs --set ` to align them."); + process.exit(1); +} + +console.log(`Version ${distinct[0]} is consistent across all three files.`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3fc52d96..8d1de1fd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2584,7 +2584,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -3119,13 +3119,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.9" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ "bitflags 2.9.4", "libc", - "redox_syscall", + "plain", + "redox_syscall 0.9.1", ] [[package]] @@ -4072,7 +4073,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.17", "smallvec", "windows-targets 0.52.6", ] @@ -4390,6 +4391,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.7.4" @@ -4880,6 +4887,15 @@ dependencies = [ "bitflags 2.9.4", ] +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +dependencies = [ + "bitflags 2.9.4", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5083,7 +5099,7 @@ dependencies = [ [[package]] name = "rsql" -version = "1.1.5" +version = "1.2.0" dependencies = [ "csv", "deadpool-postgres", @@ -5231,7 +5247,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -5938,7 +5954,7 @@ dependencies = [ "objc2-foundation 0.2.2", "objc2-quartz-core", "raw-window-handle", - "redox_syscall", + "redox_syscall 0.5.17", "wasm-bindgen", "web-sys", "windows-sys 0.59.0", @@ -6834,9 +6850,9 @@ dependencies = [ [[package]] name = "tokio-postgres" -version = "0.7.15" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b40d66d9b2cfe04b628173409368e58247e8eddbbd3b0e6c6ba1d09f20f6c9e" +checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42" dependencies = [ "async-trait", "byteorder", @@ -7487,9 +7503,12 @@ dependencies = [ [[package]] name = "wasite" -version = "0.1.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.5+wasi-0.2.4", +] [[package]] name = "wasm-bindgen" @@ -7694,9 +7713,9 @@ dependencies = [ [[package]] name = "whoami" -version = "1.6.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +checksum = "8fae98cf96deed1b7572272dfc777713c249ae40aa1cf8862e091e8b745f5361" dependencies = [ "libredox", "wasite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 36bb2f8d..7adcc5a0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rsql" -version = "1.1.5" +version = "1.2.0" description = "Modern SQL Client" authors = ["rust-dd"] edition = "2024" diff --git a/src-tauri/rust-toolchain.toml b/src-tauri/rust-toolchain.toml new file mode 100644 index 00000000..5c485180 --- /dev/null +++ b/src-tauri/rust-toolchain.toml @@ -0,0 +1,6 @@ +# Pinned rather than tracking `stable`, so a new toolchain cannot introduce +# clippy lints that fail CI or change a release build without anyone choosing +# to. Bump deliberately. Requires >= 1.88 for edition 2024 let-chains. +[toolchain] +channel = "1.95.0" +components = ["clippy", "rustfmt"] diff --git a/src-tauri/src/app_setup.rs b/src-tauri/src/app_setup.rs index 3b95dd21..33503647 100644 --- a/src-tauri/src/app_setup.rs +++ b/src-tauri/src/app_setup.rs @@ -72,42 +72,21 @@ pub fn setup_app(app: &mut tauri::App) -> Result<(), Box> .await .expect("Failed to create workspaces table"); - conn.execute( - "CREATE TABLE IF NOT EXISTS virtual_query_snapshots ( - query_id TEXT PRIMARY KEY, - project_id TEXT NOT NULL, - sql TEXT NOT NULL, - columns_packed TEXT NOT NULL DEFAULT '', - total_rows INTEGER NOT NULL DEFAULT 0, - page_size INTEGER NOT NULL DEFAULT 0, - col_count INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL - )", - (), - ) - .await - .expect("Failed to create virtual_query_snapshots table"); - - conn.execute( - "CREATE TABLE IF NOT EXISTS virtual_query_pages ( - query_id TEXT NOT NULL, - page_index INTEGER NOT NULL, - packed_page TEXT NOT NULL DEFAULT '', - PRIMARY KEY (query_id, page_index) - )", - (), - ) - .await - .expect("Failed to create virtual_query_pages table"); - - // Best-effort orphan cleanup in case app exited before tab-close cleanup. - conn.execute( - "DELETE FROM virtual_query_pages - WHERE query_id NOT IN (SELECT query_id FROM virtual_query_snapshots)", - (), - ) - .await - .ok(); + // Virtual query results were mirrored into these tables one page at a + // time as the user scrolled, but nothing could ever read them back: the + // frontend does not persist the query id a page belongs to, so after a + // restart every row was unreachable. Drop them and reclaim the space. + let dropped_snapshots = conn + .execute("DROP TABLE IF EXISTS virtual_query_pages", ()) + .await + .is_ok() + & conn + .execute("DROP TABLE IF EXISTS virtual_query_snapshots", ()) + .await + .is_ok(); + if dropped_snapshots { + conn.execute("VACUUM", ()).await.ok(); + } for col in [ "ssh_enabled", diff --git a/src-tauri/src/common/enums.rs b/src-tauri/src/common/enums.rs index d6f8f3e7..2646f927 100644 --- a/src-tauri/src/common/enums.rs +++ b/src-tauri/src/common/enums.rs @@ -47,3 +47,90 @@ impl From for tauri::Error { tauri::Error::Io(std::io::Error::other(e.to_string())) } } + +/// Flatten an error and everything it wraps into one message. +/// +/// `tokio_postgres::Error` displays as the bare word "db error"; the message +/// the server actually sent — the missing relation, the syntax position, the +/// constraint name — lives in its source. Reporting only the top level gave +/// users "Query failed: db error" for every failure. +pub fn error_chain(e: &dyn std::error::Error) -> String { + let mut msg = e.to_string(); + let mut src = e.source(); + while let Some(cause) = src { + let text = cause.to_string(); + // Some wrappers already embed their source; do not repeat it. + if !msg.contains(&text) { + msg.push_str(": "); + msg.push_str(&text); + } + src = cause.source(); + } + msg +} + +/// Convert a Postgres error into a query failure, keeping the server's message. +pub fn query_failed(e: tokio_postgres::Error) -> AppError { + AppError::QueryFailed(error_chain(&e)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt; + + #[derive(Debug)] + struct Inner; + impl fmt::Display for Inner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "relation \"x\" does not exist") + } + } + impl std::error::Error for Inner {} + + #[derive(Debug)] + struct Outer(Inner); + impl fmt::Display for Outer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "db error") + } + } + impl std::error::Error for Outer { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } + } + + #[test] + fn the_wrapped_message_is_kept() { + assert_eq!( + error_chain(&Outer(Inner)), + "db error: relation \"x\" does not exist" + ); + } + + #[test] + fn an_already_embedded_message_is_not_repeated() { + #[derive(Debug)] + struct Embedding(Inner); + impl fmt::Display for Embedding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "failed: relation \"x\" does not exist") + } + } + impl std::error::Error for Embedding { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } + } + assert_eq!( + error_chain(&Embedding(Inner)), + "failed: relation \"x\" does not exist" + ); + } + + #[test] + fn an_error_without_a_source_is_unchanged() { + assert_eq!(error_chain(&Inner), "relation \"x\" does not exist"); + } +} diff --git a/src-tauri/src/dbs/project.rs b/src-tauri/src/dbs/project.rs index bd480f1f..79ba8959 100644 --- a/src-tauri/src/dbs/project.rs +++ b/src-tauri/src/dbs/project.rs @@ -130,28 +130,38 @@ pub async fn project_db_delete(project_id: &str, app_state: State<'_, AppState>) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; - // Remove persisted virtual snapshots tied to this project. - conn.execute( - "DELETE FROM virtual_query_pages - WHERE query_id IN ( - SELECT query_id FROM virtual_query_snapshots WHERE project_id = ?1 - )", - libsql::params![project_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - conn.execute( - "DELETE FROM virtual_query_snapshots WHERE project_id = ?1", - libsql::params![project_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - // Best-effort cleanup for in-memory connection state. app_state.clients.lock().await.remove(project_id); app_state.meta_clients.lock().await.remove(project_id); - app_state.cancel_tokens.lock().await.remove(project_id); app_state.client_ssl.lock().await.remove(project_id); + // Cancel tokens are keyed by exec id, so drop every one belonging to this + // project rather than looking the project id up as a key. + app_state + .cancel_tokens + .lock() + .await + .retain(|_, (owner, _)| owner != project_id); + + // A LISTEN task and an SSH tunnel outlive the project otherwise: the task + // keeps polling a connection that is gone, and the tunnel holds its port. + // Listener handles are keyed ":", so every channel of + // this project has to go. + { + let mut handles = app_state.notify_handles.lock().await; + let prefix = format!("{}:", project_id); + let owned: Vec = handles + .keys() + .filter(|key| key.starts_with(&prefix)) + .cloned() + .collect(); + for key in owned { + if let Some(handle) = handles.remove(&key) { + handle.abort(); + } + } + } + app_state.ssh_tunnels.lock().await.remove(project_id); + Ok(()) } diff --git a/src-tauri/src/drivers/pgsql/commands/admin_commands.rs b/src-tauri/src/drivers/pgsql/commands/admin_commands.rs index 60889df2..c750f631 100644 --- a/src-tauri/src/drivers/pgsql/commands/admin_commands.rs +++ b/src-tauri/src/drivers/pgsql/commands/admin_commands.rs @@ -25,8 +25,8 @@ pub async fn pgsql_csv_import( column_mapping: Vec<(usize, String)>, app_state: State<'_, AppState>, ) -> Result { - let client = acquire_client(&app_state.clients, project_id).await?; - import_csv_to_table(&client, file_path, schema, table, &column_mapping) + let mut client = acquire_client(&app_state.clients, project_id).await?; + import_csv_to_table(&mut client, file_path, schema, table, &column_mapping) .await .map_err(Into::into) } @@ -153,7 +153,7 @@ pub async fn pgsql_table_action( } }; - execute_query(&client, &sql).await.map_err(|e| e)?; + execute_query(&client, &sql).await?; Ok(format!("{action} completed successfully.")) } diff --git a/src-tauri/src/drivers/pgsql/commands/metadata_commands.rs b/src-tauri/src/drivers/pgsql/commands/metadata_commands.rs index 343861e6..8375a3f6 100644 --- a/src-tauri/src/drivers/pgsql/commands/metadata_commands.rs +++ b/src-tauri/src/drivers/pgsql/commands/metadata_commands.rs @@ -1,4 +1,5 @@ use crate::AppState; +use crate::common::enums::AppError; use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; use crate::drivers::pgsql::{ ColumnDetail, ConstraintDetail, FunctionInfo, IndexDetail, PolicyDetail, RuleDetail, @@ -7,6 +8,7 @@ use crate::drivers::pgsql::{ load_tables, load_tablespaces, load_trigger_functions, load_triggers, load_views, }; +use tauri::ipc::Response; use tauri::{AppHandle, Manager, Result, State}; use super::pool_connection::acquire_client; @@ -217,3 +219,17 @@ pub async fn pgsql_load_trigger_functions( .await .map_err(Into::into) } + +/// Snapshot of a schema for the editor's language features. Runs on the meta +/// pool: this is catalog traffic, not a user query. +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_load_schema_index( + project_id: &str, + schema: &str, + app_state: State<'_, AppState>, +) -> Result { + let client = acquire_client(&app_state.meta_clients, project_id).await?; + let result = crate::drivers::pgsql::schema_index::load_schema_index(&client, schema).await?; + let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) +} diff --git a/src-tauri/src/drivers/pgsql/commands/mod.rs b/src-tauri/src/drivers/pgsql/commands/mod.rs index a417f432..c1d4436d 100644 --- a/src-tauri/src/drivers/pgsql/commands/mod.rs +++ b/src-tauri/src/drivers/pgsql/commands/mod.rs @@ -1,17 +1,15 @@ -pub(crate) const CELL_SEP: char = '\x1F'; -pub(crate) const SNAPSHOT_PAGE_WRITE_RETRIES: usize = 3; - pub mod admin_commands; pub mod metadata_commands; +pub mod mutation_commands; pub mod object_info_commands; pub mod pool_connection; pub mod pubsub_commands; pub mod query_commands; -pub mod snapshot_persistence; pub mod statistics_commands; pub use admin_commands::*; pub use metadata_commands::*; +pub use mutation_commands::*; pub use object_info_commands::*; pub use pool_connection::*; pub use pubsub_commands::*; diff --git a/src-tauri/src/drivers/pgsql/commands/mutation_commands.rs b/src-tauri/src/drivers/pgsql/commands/mutation_commands.rs new file mode 100644 index 00000000..09418bd0 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/commands/mutation_commands.rs @@ -0,0 +1,143 @@ +use crate::AppState; +use crate::common::enums::{AppError, query_failed}; +use crate::drivers::pgsql::mutation::{ + BuiltStatement, ColumnTypes, MutationKind, RowMutation, build_statement, +}; + +use tauri::{Result, State}; +use tokio_postgres::types::ToSql; + +use super::pool_connection::acquire_client; + +#[derive(serde::Serialize)] +pub struct MutationReport { + pub updated: usize, + pub deleted: usize, +} + +/// Column types straight from the catalog. Used both to cast parameters and to +/// reject column names the table does not actually have. +async fn load_column_types( + client: &deadpool_postgres::Client, + schema: &str, + table: &str, +) -> std::result::Result { + let rows = client + .query( + "SELECT attname::text, format_type(atttypid, atttypmod) + FROM pg_attribute + WHERE attrelid = format('%I.%I', $1::text, $2::text)::regclass + AND attnum > 0 + AND NOT attisdropped", + &[&schema, &table], + ) + .await + .map_err(query_failed)?; + + if rows.is_empty() { + return Err(AppError::QueryFailed(format!( + "Table {}.{} has no readable columns", + schema, table + ))); + } + + Ok(rows + .into_iter() + .map(|row| (row.get::<_, String>(0), row.get::<_, String>(1))) + .collect()) +} + +/// Render a row key for error messages, e.g. `id=7, tenant=acme`. +fn describe_key(key: &[(String, Option)]) -> String { + key.iter() + .map(|(column, value)| match value { + Some(v) => format!("{}={}", column, v), + None => format!("{}=NULL", column), + }) + .collect::>() + .join(", ") +} + +/// Apply grid row edits as parameterized statements in a single transaction. +/// +/// Every statement must affect exactly one row. Zero means the row is gone or +/// its key changed underneath the grid; more than one means the supplied key is +/// not unique. Either aborts the whole transaction, so a partial apply is not +/// possible and a silent no-op is reported as an error instead of success. +#[tauri::command(rename_all = "snake_case")] +pub async fn pgsql_apply_row_mutations( + project_id: &str, + schema: &str, + table: &str, + mutations: Vec, + timeout_ms: Option, + app_state: State<'_, AppState>, +) -> Result { + if mutations.is_empty() { + return Ok(MutationReport { + updated: 0, + deleted: 0, + }); + } + + let mut client = acquire_client(&app_state.clients, project_id).await?; + + let types = load_column_types(&client, schema, table).await?; + + // Build everything up front so a rejected payload never opens a transaction. + let planned: Vec = mutations + .iter() + .map(|mutation| build_statement(schema, table, mutation, &types)) + .collect::>()?; + + let tx = client.transaction().await.map_err(query_failed)?; + + // Transaction-scoped, so it cannot leak into the pooled session the way a + // session-level SET followed by RESET can when the reset never runs. + if let Some(ms) = timeout_ms.filter(|ms| *ms > 0) { + tx.batch_execute(&format!("SET LOCAL statement_timeout = {}", ms)) + .await + .map_err(query_failed)?; + } + + let mut updated = 0usize; + let mut deleted = 0usize; + + for (mutation, statement) in mutations.iter().zip(&planned) { + let params: Vec<&(dyn ToSql + Sync)> = statement + .params + .iter() + .map(|p| p as &(dyn ToSql + Sync)) + .collect(); + + let affected = tx + .execute(statement.sql.as_str(), ¶ms) + .await + .map_err(query_failed)?; + + if affected != 1 { + let key = describe_key(&mutation.pk); + // Dropping `tx` without committing rolls the whole batch back. + return Err(AppError::QueryFailed(format!( + "Expected 1 row for {} but matched {}. Row key: {}. \ + Nothing was changed — refresh the results and try again.", + match mutation.kind { + MutationKind::Update => "update", + MutationKind::Delete => "delete", + }, + affected, + key + )) + .into()); + } + + match mutation.kind { + MutationKind::Update => updated += 1, + MutationKind::Delete => deleted += 1, + } + } + + tx.commit().await.map_err(query_failed)?; + + Ok(MutationReport { updated, deleted }) +} diff --git a/src-tauri/src/drivers/pgsql/commands/pool_connection.rs b/src-tauri/src/drivers/pgsql/commands/pool_connection.rs index 6d7e355c..bd6ed135 100644 --- a/src-tauri/src/drivers/pgsql/commands/pool_connection.rs +++ b/src-tauri/src/drivers/pgsql/commands/pool_connection.rs @@ -1,6 +1,8 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; -use deadpool_postgres::{Manager as PgManager, ManagerConfig, Pool, RecyclingMethod}; +use deadpool_postgres::{ + Manager as PgManager, ManagerConfig, Pool, RecyclingMethod, Runtime, Timeouts, +}; use crate::AppState; use crate::common::enums::{AppError, ProjectConnectionStatus}; @@ -11,11 +13,6 @@ use postgres_native_tls::MakeTlsConnector; use tauri::{AppHandle, Manager, Result}; use tokio_postgres::{CancelToken, Config, NoTls}; -pub(crate) fn is_sqlite_lock_error(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - lower.contains("database is locked") || lower.contains("database busy") -} - pub(crate) fn full_error_chain(e: &dyn std::error::Error) -> String { let mut msg = e.to_string(); let mut src = e.source(); @@ -27,6 +24,17 @@ pub(crate) fn full_error_chain(e: &dyn std::error::Error) -> String { msg } +/// Connections held per project for user queries and for metadata lookups. +/// Several projects can be open at once, so these are kept modest rather than +/// letting one window monopolize a shared server's connection budget. +pub(crate) const QUERY_POOL_SIZE: usize = 8; +pub(crate) const META_POOL_SIZE: usize = 4; + +/// Waiting for a free connection is bounded: an exhausted pool used to block +/// the caller indefinitely, which showed up as a window that had simply stopped +/// responding. Query duration itself stays governed by `statement_timeout`. +const POOL_WAIT: Duration = Duration::from_secs(10); + pub(crate) fn create_pg_pool( cfg: &Config, use_ssl: bool, @@ -35,7 +43,13 @@ pub(crate) fn create_pg_pool( let manager_config = ManagerConfig { recycling_method: RecyclingMethod::Custom("ROLLBACK".into()), }; + let timeouts = Timeouts { + wait: Some(POOL_WAIT), + ..Timeouts::default() + }; + // deadpool needs to know which runtime drives its timers; without this the + // builder rejects any pool that sets a timeout. if use_ssl { let tls_connector = TlsConnector::builder() .build() @@ -44,12 +58,16 @@ pub(crate) fn create_pg_pool( let manager = PgManager::from_config(cfg.clone(), tls, manager_config); Pool::builder(manager) .max_size(max_size) + .timeouts(timeouts) + .runtime(Runtime::Tokio1) .build() .map_err(|e| AppError::ConnectionFailed(e.to_string())) } else { let manager = PgManager::from_config(cfg.clone(), NoTls, manager_config); Pool::builder(manager) .max_size(max_size) + .timeouts(timeouts) + .runtime(Runtime::Tokio1) .build() .map_err(|e| AppError::ConnectionFailed(e.to_string())) } @@ -64,9 +82,13 @@ pub(crate) async fn acquire_client( get_pool(&pools, project_id)? }; - pool.get() - .await - .map_err(|e| AppError::ConnectionFailed(e.to_string())) + pool.get().await.map_err(|e| { + AppError::ConnectionFailed(format!( + "No connection available within {}s: {}", + POOL_WAIT.as_secs(), + e + )) + }) } pub(crate) async fn apply_statement_timeout(client: &deadpool_postgres::Client, timeout_ms: u32) { @@ -86,12 +108,16 @@ pub(crate) async fn reset_statement_timeout(client: &deadpool_postgres::Client, pub(crate) async fn set_cancel_token( app_state: &AppState, + exec_id: &str, project_id: &str, token: CancelToken, -) -> std::result::Result<(), AppError> { +) { let mut cancel_tokens = app_state.cancel_tokens.lock().await; - cancel_tokens.insert(project_id.to_string(), token); - Ok(()) + cancel_tokens.insert(exec_id.to_string(), (project_id.to_string(), token)); +} + +pub(crate) async fn clear_cancel_token(app_state: &AppState, exec_id: &str) { + app_state.cancel_tokens.lock().await.remove(exec_id); } #[tauri::command(rename_all = "snake_case")] @@ -204,7 +230,7 @@ pub async fn pgsql_connector( port_str.parse().unwrap_or(5432), ) .await - .map_err(|e| AppError::ConnectionFailed(e))?; + .map_err(AppError::ConnectionFailed)?; let local_port = tunnel.local_port; app_state @@ -229,14 +255,14 @@ pub async fn pgsql_connector( .host(&effective_host) .port(port); - let query_pool = match create_pg_pool(&cfg, use_ssl, 16) { + let query_pool = match create_pg_pool(&cfg, use_ssl, QUERY_POOL_SIZE) { Ok(p) => Arc::new(p), Err(e) => { tracing::error!("Query pool creation failed: {:?}", e); return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); } }; - let meta_pool = match create_pg_pool(&cfg, use_ssl, 8) { + let meta_pool = match create_pg_pool(&cfg, use_ssl, META_POOL_SIZE) { Ok(p) => Arc::new(p), Err(e) => { tracing::error!("Meta pool creation failed: {:?}", e); @@ -245,13 +271,10 @@ pub async fn pgsql_connector( }; // Validate connectivity eagerly so connector keeps previous fail/connected behavior. - let query_client = match query_pool.get().await { - Ok(c) => c, - Err(e) => { - tracing::error!("Query pool initial connection failed: {:?}", e); - return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); - } - }; + if let Err(e) = query_pool.get().await { + tracing::error!("Query pool initial connection failed: {:?}", e); + return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); + } if let Err(e) = meta_pool.get().await { tracing::error!("Meta pool initial connection failed: {:?}", e); return Err(AppError::ConnectionFailed(full_error_chain(&e)).into()); @@ -265,10 +288,6 @@ pub async fn pgsql_connector( let mut meta_clients = app_state.meta_clients.lock().await; meta_clients.insert(project_id.to_string(), Arc::clone(&meta_pool)); } - { - let mut cancel_tokens = app_state.cancel_tokens.lock().await; - cancel_tokens.insert(project_id.to_string(), query_client.cancel_token()); - } { let mut client_ssl = app_state.client_ssl.lock().await; client_ssl.insert(project_id.to_string(), use_ssl); @@ -276,3 +295,23 @@ pub async fn pgsql_connector( Ok(ProjectConnectionStatus::Connected) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Building a pool only happens on connect, which no other test reaches. + /// A misconfigured builder therefore surfaced as a runtime connection + /// failure rather than a compile or test error. + #[test] + fn pools_build_with_their_timeouts_configured() { + let cfg = Config::new(); + assert!(create_pg_pool(&cfg, false, QUERY_POOL_SIZE).is_ok()); + } + + #[test] + fn tls_pools_build_too() { + let cfg = Config::new(); + assert!(create_pg_pool(&cfg, true, META_POOL_SIZE).is_ok()); + } +} diff --git a/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs b/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs index 064b20d2..b3df2d92 100644 --- a/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs +++ b/src-tauri/src/drivers/pgsql/commands/pubsub_commands.rs @@ -1,5 +1,5 @@ use crate::AppState; -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; use crate::drivers::pgsql::discover_notify_channels; use futures_util::StreamExt; @@ -43,10 +43,10 @@ pub async fn pgsql_listen_start(project_id: &str, channel: &str, app: AppHandle) .ok_or_else(|| AppError::ProjectNotFound(project_id.to_string()))?; let mut cfg = Config::new(); - cfg.user(&row.get::(0).unwrap_or_default()) - .password(&row.get::(1).unwrap_or_default()) - .dbname(&row.get::(2).unwrap_or_default()) - .host(&row.get::(3).unwrap_or_default()) + cfg.user(row.get::(0).unwrap_or_default()) + .password(row.get::(1).unwrap_or_default()) + .dbname(row.get::(2).unwrap_or_default()) + .host(row.get::(3).unwrap_or_default()) .port( row.get::(4) .unwrap_or_default() @@ -159,10 +159,7 @@ pub async fn pgsql_notify_send( channel.replace('\'', "''"), payload.replace('\'', "''"), ); - client - .batch_execute(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + client.batch_execute(&sql).await.map_err(query_failed)?; Ok(true) } diff --git a/src-tauri/src/drivers/pgsql/commands/query_commands.rs b/src-tauri/src/drivers/pgsql/commands/query_commands.rs index 20b019be..83eec2b0 100644 --- a/src-tauri/src/drivers/pgsql/commands/query_commands.rs +++ b/src-tauri/src/drivers/pgsql/commands/query_commands.rs @@ -11,42 +11,42 @@ use tauri::ipc::Response; use tauri::{AppHandle, Manager, Result, State}; use tokio_postgres::NoTls; -use super::CELL_SEP; use super::pool_connection::{ - acquire_client, apply_statement_timeout, reset_statement_timeout, set_cancel_token, -}; -use super::snapshot_persistence::{ - restore_virtual_from_snapshot, snapshot_cleanup_query, snapshot_load_page, snapshot_store_page, - snapshot_upsert_metadata, + acquire_client, apply_statement_timeout, clear_cancel_token, reset_statement_timeout, + set_cancel_token, }; #[tauri::command(rename_all = "snake_case")] pub async fn pgsql_run_query( project_id: &str, sql: &str, + exec_id: &str, app_state: State<'_, AppState>, ) -> Result { let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + set_cancel_token(&app_state, exec_id, project_id, client.cancel_token()).await; - let result = execute_query(&client, sql).await?; + let result = execute_query(&client, sql).await; + clear_cancel_token(&app_state, exec_id).await; + let result = result?; let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; Ok(Response::new(json)) } #[tauri::command(rename_all = "snake_case")] -pub async fn pgsql_cancel_query(project_id: &str, app_state: State<'_, AppState>) -> Result { - let cancel_token = { +pub async fn pgsql_cancel_query(exec_id: &str, app_state: State<'_, AppState>) -> Result { + let (project_id, cancel_token) = { let cancel_tokens = app_state.cancel_tokens.lock().await; - cancel_tokens - .get(project_id) - .cloned() - .ok_or_else(|| AppError::ClientNotConnected(project_id.to_string()))? + match cancel_tokens.get(exec_id) { + Some(entry) => entry.clone(), + // The query already finished; nothing to cancel is not an error. + None => return Ok(false), + } }; let use_ssl = { let client_ssl = app_state.client_ssl.lock().await; - *client_ssl.get(project_id).unwrap_or(&false) + *client_ssl.get(&project_id).unwrap_or(&false) }; if use_ssl { @@ -72,16 +72,18 @@ pub async fn pgsql_cancel_query(project_id: &str, app_state: State<'_, AppState> pub async fn pgsql_run_query_packed( project_id: &str, sql: &str, + exec_id: &str, timeout_ms: Option, app_state: State<'_, AppState>, ) -> Result { let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + set_cancel_token(&app_state, exec_id, project_id, client.cancel_token()).await; let timeout = timeout_ms.unwrap_or(0); apply_statement_timeout(&client, timeout).await; let result = execute_query_packed(&client, sql).await; reset_statement_timeout(&client, timeout).await; + clear_cancel_token(&app_state, exec_id).await; let result = result?; let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; @@ -93,15 +95,16 @@ pub async fn pgsql_run_query_streamed( project_id: &str, sql: &str, stream_id: &str, + exec_id: &str, app: AppHandle, ) -> Result<()> { let app_state = app.state::(); let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + set_cancel_token(&app_state, exec_id, project_id, client.cancel_token()).await; - execute_query_streamed(&client, sql, stream_id, &app) - .await - .map_err(Into::into) + let result = execute_query_streamed(&client, sql, stream_id, &app).await; + clear_cancel_token(&app_state, exec_id).await; + result.map_err(Into::into) } #[tauri::command(rename_all = "snake_case")] @@ -109,43 +112,21 @@ pub async fn pgsql_execute_virtual( project_id: &str, sql: &str, query_id: &str, + exec_id: &str, page_size: usize, timeout_ms: Option, app_state: State<'_, AppState>, ) -> Result { let client = acquire_client(&app_state.clients, project_id).await?; - set_cancel_token(&app_state, project_id, client.cancel_token()).await?; + set_cancel_token(&app_state, exec_id, project_id, client.cancel_token()).await; let timeout = timeout_ms.unwrap_or(0); apply_statement_timeout(&client, timeout).await; let result = execute_virtual(&client, &app_state.virtual_cache, sql, query_id, page_size).await; reset_statement_timeout(&client, timeout).await; + clear_cancel_token(&app_state, exec_id).await; let result = result?; - let col_count = if result.0.is_empty() { - 0 - } else { - result.0.split(CELL_SEP).count() - }; - if let Err(e) = snapshot_upsert_metadata( - &app_state, project_id, query_id, sql, &result.0, result.1, page_size, col_count, - ) - .await - { - tracing::warn!( - "Failed to persist virtual snapshot metadata for {}: {:?}", - query_id, - e - ); - } - if let Err(e) = snapshot_store_page(&app_state, query_id, 0, &result.2).await { - tracing::warn!( - "Failed to persist virtual snapshot first page for {}: {:?}", - query_id, - e - ); - } - let json = sonic_rs::to_string(&result).map_err(|e| AppError::QueryFailed(e.to_string()))?; Ok(Response::new(json)) } @@ -158,65 +139,14 @@ pub async fn pgsql_fetch_page( limit: usize, app_state: State<'_, AppState>, ) -> Result { - let page_index = if limit == 0 { 0 } else { offset / limit }; - - match fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit).await { - Ok(packed) => { - if let Err(e) = snapshot_store_page(&app_state, query_id, page_index, &packed).await { - tracing::warn!("Failed to persist fetched page for {}: {:?}", query_id, e); - } - let json = - sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; - return Ok(Response::new(json)); - } - Err(err) => { - tracing::debug!( - "Virtual cache miss for query {}, trying snapshot fallback: {:?}", - query_id, - err - ); - } - } - - if let Some(packed) = snapshot_load_page(&app_state, query_id, page_index).await? { - let json = - sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; - return Ok(Response::new(json)); - } - - if restore_virtual_from_snapshot(&app_state, query_id).await? { - let packed = - fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit) - .await?; - if let Err(e) = snapshot_store_page(&app_state, query_id, page_index, &packed).await { - tracing::warn!( - "Failed to persist restored page for {} (page {}): {:?}", - query_id, - page_index, - e - ); - } - let json = - sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; - return Ok(Response::new(json)); - } - - Err(AppError::QueryFailed(format!( - "Virtual query {} not found in memory and no snapshot available", - query_id - )) - .into()) + let packed = + fetch_virtual_page(&app_state.virtual_cache, query_id, col_count, offset, limit).await?; + let json = sonic_rs::to_string(&packed).map_err(|e| AppError::QueryFailed(e.to_string()))?; + Ok(Response::new(json)) } #[tauri::command(rename_all = "snake_case")] pub async fn pgsql_close_virtual(query_id: &str, app_state: State<'_, AppState>) -> Result<()> { close_virtual(&app_state.virtual_cache, query_id).await?; - if let Err(e) = snapshot_cleanup_query(&app_state, query_id).await { - tracing::warn!( - "Failed to cleanup virtual snapshot for {}: {:?}", - query_id, - e - ); - } Ok(()) } diff --git a/src-tauri/src/drivers/pgsql/commands/snapshot_persistence.rs b/src-tauri/src/drivers/pgsql/commands/snapshot_persistence.rs deleted file mode 100644 index 3edccf53..00000000 --- a/src-tauri/src/drivers/pgsql/commands/snapshot_persistence.rs +++ /dev/null @@ -1,283 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use crate::AppState; -use crate::common::enums::AppError; -use crate::drivers::pgsql::execute_virtual; - -use tokio::time::{Duration, sleep}; - -use super::pool_connection::{acquire_client, is_sqlite_lock_error, set_cancel_token}; -use super::{CELL_SEP, SNAPSHOT_PAGE_WRITE_RETRIES}; - -#[derive(Clone)] -pub(crate) struct VirtualSnapshotMeta { - pub(crate) project_id: String, - pub(crate) sql: String, - pub(crate) page_size: usize, - pub(crate) col_count: usize, -} - -pub(crate) fn now_unix_secs() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or_default() -} - -pub(crate) async fn snapshot_upsert_metadata( - app_state: &AppState, - project_id: &str, - query_id: &str, - sql: &str, - columns_packed: &str, - total_rows: usize, - page_size: usize, - col_count: usize, -) -> std::result::Result<(), AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - conn.execute( - "INSERT OR REPLACE INTO virtual_query_snapshots ( - query_id, project_id, sql, columns_packed, total_rows, page_size, col_count, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - libsql::params![ - query_id, - project_id, - sql, - columns_packed, - total_rows as i64, - page_size as i64, - col_count as i64, - now_unix_secs(), - ], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - Ok(()) -} - -pub(crate) async fn snapshot_store_page( - app_state: &AppState, - query_id: &str, - page_index: usize, - packed_page: &str, -) -> std::result::Result<(), AppError> { - if packed_page.is_empty() { - return Ok(()); - } - - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - for attempt in 0..SNAPSHOT_PAGE_WRITE_RETRIES { - match conn - .execute( - "INSERT OR IGNORE INTO virtual_query_pages (query_id, page_index, packed_page) - VALUES (?1, ?2, ?3)", - libsql::params![query_id, page_index as i64, packed_page], - ) - .await - { - Ok(_) => return Ok(()), - Err(e) => { - let msg = e.to_string(); - if is_sqlite_lock_error(&msg) { - if attempt + 1 < SNAPSHOT_PAGE_WRITE_RETRIES { - sleep(Duration::from_millis((attempt as u64 + 1) * 8)).await; - continue; - } - // Snapshot persistence is best-effort; skip noisy lock errors. - tracing::debug!( - "Skipping snapshot page persist for {} page {} due to SQLite lock", - query_id, - page_index - ); - return Ok(()); - } - return Err(AppError::DatabaseError(msg)); - } - } - } - - Ok(()) -} - -pub(crate) async fn snapshot_load_page( - app_state: &AppState, - query_id: &str, - page_index: usize, -) -> std::result::Result, AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let mut rows = conn - .query( - "SELECT packed_page - FROM virtual_query_pages - WHERE query_id = ?1 AND page_index = ?2 - LIMIT 1", - libsql::params![query_id, page_index as i64], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let maybe_row = rows - .next() - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - if let Some(row) = maybe_row { - let packed: String = row - .get(0) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - Ok(Some(packed)) - } else { - Ok(None) - } -} - -pub(crate) async fn snapshot_load_metadata( - app_state: &AppState, - query_id: &str, -) -> std::result::Result, AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let mut rows = conn - .query( - "SELECT project_id, sql, page_size, col_count - FROM virtual_query_snapshots - WHERE query_id = ?1 - LIMIT 1", - libsql::params![query_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let maybe_row = rows - .next() - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - let Some(row) = maybe_row else { - return Ok(None); - }; - - let project_id: String = row - .get(0) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let sql: String = row - .get(1) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let page_size_i64: i64 = row - .get(2) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let col_count_i64: i64 = row - .get(3) - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - if page_size_i64 <= 0 { - return Ok(None); - } - - Ok(Some(VirtualSnapshotMeta { - project_id, - sql, - page_size: page_size_i64 as usize, - col_count: col_count_i64.max(0) as usize, - })) -} - -pub(crate) async fn snapshot_cleanup_query( - app_state: &AppState, - query_id: &str, -) -> std::result::Result<(), AppError> { - let conn = app_state - .local_db - .connect() - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - conn.execute( - "DELETE FROM virtual_query_pages WHERE query_id = ?1", - libsql::params![query_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - conn.execute( - "DELETE FROM virtual_query_snapshots WHERE query_id = ?1", - libsql::params![query_id], - ) - .await - .map_err(|e| AppError::DatabaseError(e.to_string()))?; - - Ok(()) -} - -pub(crate) async fn restore_virtual_from_snapshot( - app_state: &AppState, - query_id: &str, -) -> std::result::Result { - let Some(meta) = snapshot_load_metadata(app_state, query_id).await? else { - return Ok(false); - }; - - let client = acquire_client(&app_state.clients, &meta.project_id).await?; - set_cancel_token(app_state, &meta.project_id, client.cancel_token()).await?; - - let (columns_packed, total_rows, first_page_packed, _) = execute_virtual( - &client, - &app_state.virtual_cache, - &meta.sql, - query_id, - meta.page_size, - ) - .await?; - - if columns_packed.is_empty() { - return Ok(false); - } - - let col_count = if meta.col_count > 0 { - meta.col_count - } else { - columns_packed.split(CELL_SEP).count() - }; - - if let Err(e) = snapshot_upsert_metadata( - app_state, - &meta.project_id, - query_id, - &meta.sql, - &columns_packed, - total_rows, - meta.page_size, - col_count, - ) - .await - { - tracing::warn!( - "Failed to refresh snapshot metadata for {}: {:?}", - query_id, - e - ); - } - if let Err(e) = snapshot_store_page(app_state, query_id, 0, &first_page_packed).await { - tracing::warn!( - "Failed to refresh snapshot first page for {}: {:?}", - query_id, - e - ); - } - - Ok(true) -} diff --git a/src-tauri/src/drivers/pgsql/ddl_generation.rs b/src-tauri/src/drivers/pgsql/ddl_generation.rs index 841d8b51..2357933a 100644 --- a/src-tauri/src/drivers/pgsql/ddl_generation.rs +++ b/src-tauri/src/drivers/pgsql/ddl_generation.rs @@ -1,6 +1,6 @@ use tokio_postgres::{Client, SimpleQueryMessage}; -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; pub async fn generate_full_ddl( client: &Client, @@ -44,10 +44,7 @@ async fn generate_table_ddl( SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# ); - let col_result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let col_result = client.simple_query(&sql).await.map_err(query_failed)?; let mut col_defs = String::new(); for msg in &col_result { if let SimpleQueryMessage::Row(row) = msg { @@ -60,12 +57,11 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# fn collect_lines(messages: &[SimpleQueryMessage]) -> Vec { let mut out = Vec::new(); for msg in messages { - if let SimpleQueryMessage::Row(row) = msg { - if let Some(line) = row.get(0) { - if !line.is_empty() { - out.push(line.to_string()); - } - } + if let SimpleQueryMessage::Row(row) = msg + && let Some(line) = row.get(0) + && !line.is_empty() + { + out.push(line.to_string()); } } out @@ -79,10 +75,7 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# WHERE n.nspname = '{schema}' AND c.relname = '{table}' ORDER BY CASE con.contype WHEN 'p' THEN 0 WHEN 'u' THEN 1 WHEN 'f' THEN 2 ELSE 3 END"# ); - let con_result = client - .simple_query(&con_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let con_result = client.simple_query(&con_sql).await.map_err(query_failed)?; for line in collect_lines(&con_result) { ddl.push('\n'); ddl.push_str(&line); @@ -98,10 +91,7 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# AND NOT i.indisprimary AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = i.indexrelid)"# ); - let idx_result = client - .simple_query(&idx_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let idx_result = client.simple_query(&idx_sql).await.map_err(query_failed)?; for line in collect_lines(&idx_result) { ddl.push('\n'); ddl.push_str(&line); @@ -116,10 +106,7 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# WHERE n.nspname = '{schema}' AND c.relname = '{table}' AND NOT t.tgisinternal"# ); - let trig_result = client - .simple_query(&trig_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let trig_result = client.simple_query(&trig_sql).await.map_err(query_failed)?; for line in collect_lines(&trig_result) { ddl.push('\n'); ddl.push_str(&line); @@ -132,10 +119,7 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '{schema}' AND c.relname = '{table}'"# ); - let rls_result = client - .simple_query(&rls_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let rls_result = client.simple_query(&rls_sql).await.map_err(query_failed)?; for line in collect_lines(&rls_result) { ddl.push('\n'); ddl.push_str(&line); @@ -154,10 +138,7 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '{schema}' AND c.relname = '{table}'"# ); - let pol_result = client - .simple_query(&pol_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let pol_result = client.simple_query(&pol_sql).await.map_err(query_failed)?; for line in collect_lines(&pol_result) { ddl.push('\n'); ddl.push_str(&line); @@ -171,10 +152,7 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = '{schema}' AND c.relname = '{table}' AND d.objsubid = 0"# ); - let cmt_result = client - .simple_query(&cmt_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let cmt_result = client.simple_query(&cmt_sql).await.map_err(query_failed)?; for line in collect_lines(&cmt_result) { ddl.push('\n'); ddl.push_str(&line); @@ -193,7 +171,7 @@ SELECT string_agg(col_def, E',\n' ORDER BY ordinal_position) FROM col_ddl"# let col_cmt_result = client .simple_query(&col_cmt_sql) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; for line in collect_lines(&col_cmt_result) { ddl.push_str(&line); ddl.push('\n'); @@ -206,10 +184,7 @@ async fn generate_view_ddl(client: &Client, schema: &str, view: &str) -> Result< let sql = format!( r#"SELECT 'CREATE OR REPLACE VIEW "{schema}"."{view}" AS' || E'\n' || pg_get_viewdef('"{schema}"."{view}"'::regclass, true) || ';'"# ); - let result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let result = client.simple_query(&sql).await.map_err(query_failed)?; for msg in &result { if let SimpleQueryMessage::Row(row) = msg { return Ok(row.get(0).unwrap_or("").to_string()); @@ -228,10 +203,7 @@ async fn generate_matview_ddl( FROM pg_matviews WHERE schemaname = '{schema}' AND matviewname = '{matview}'"# ); - let result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let result = client.simple_query(&sql).await.map_err(query_failed)?; let mut ddl = String::new(); for msg in &result { @@ -247,16 +219,13 @@ async fn generate_matview_ddl( JOIN pg_namespace n ON n.oid = tbl.relnamespace WHERE n.nspname = '{schema}' AND tbl.relname = '{matview}'"# ); - let idx_result = client - .simple_query(&idx_sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let idx_result = client.simple_query(&idx_sql).await.map_err(query_failed)?; for msg in &idx_result { - if let SimpleQueryMessage::Row(row) = msg { - if let Some(line) = row.get(0) { - ddl.push('\n'); - ddl.push_str(line); - } + if let SimpleQueryMessage::Row(row) = msg + && let Some(line) = row.get(0) + { + ddl.push('\n'); + ddl.push_str(line); } } @@ -275,10 +244,7 @@ async fn generate_function_ddl( WHERE n.nspname = '{schema}' AND p.proname = '{func_name}' LIMIT 1"# ); - let result = client - .simple_query(&sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let result = client.simple_query(&sql).await.map_err(query_failed)?; for msg in &result { if let SimpleQueryMessage::Row(row) = msg { return Ok(row.get(0).unwrap_or("").to_string()); diff --git a/src-tauri/src/drivers/pgsql/extensions.rs b/src-tauri/src/drivers/pgsql/extensions.rs index e7202639..8ee09a51 100644 --- a/src-tauri/src/drivers/pgsql/extensions.rs +++ b/src-tauri/src/drivers/pgsql/extensions.rs @@ -1,4 +1,4 @@ -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; pub async fn load_extensions( client: &deadpool_postgres::Client, @@ -18,7 +18,7 @@ pub async fn load_extensions( &[], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -42,7 +42,7 @@ pub async fn load_available_extensions( &[], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -68,7 +68,7 @@ pub async fn load_enum_types( &[], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -96,7 +96,7 @@ pub async fn load_pg_settings( &[], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() diff --git a/src-tauri/src/drivers/pgsql/metadata_schema.rs b/src-tauri/src/drivers/pgsql/metadata_schema.rs index f48a3730..0f88d31c 100644 --- a/src-tauri/src/drivers/pgsql/metadata_schema.rs +++ b/src-tauri/src/drivers/pgsql/metadata_schema.rs @@ -2,7 +2,7 @@ use deadpool_postgres::Pool; use tokio::time as tokio_time; use tokio_postgres::Client; -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; use crate::common::pgsql::{PgsqlLoadColumns, PgsqlLoadSchemas, PgsqlLoadTables}; use super::{ColumnDetail, ConstraintDetail, IndexDetail, PolicyDetail, RuleDetail, TriggerDetail}; @@ -14,7 +14,7 @@ pub async fn load_schemas(client: &Client, query_sql: &str) -> Result(0)).collect()) } @@ -106,7 +106,7 @@ pub async fn load_column_details( &[&schema, &table], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -142,7 +142,7 @@ pub async fn load_indexes( &[&schema, &table], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -170,7 +170,7 @@ pub async fn load_triggers( &[&schema, &table], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -197,7 +197,7 @@ pub async fn load_rules( &[&schema, &table], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -234,7 +234,7 @@ pub async fn load_policies( &[&schema, &table], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -268,7 +268,7 @@ pub async fn load_constraints( &[&schema, &table], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() diff --git a/src-tauri/src/drivers/pgsql/metadata_views_functions.rs b/src-tauri/src/drivers/pgsql/metadata_views_functions.rs index e3f6a880..8183f757 100644 --- a/src-tauri/src/drivers/pgsql/metadata_views_functions.rs +++ b/src-tauri/src/drivers/pgsql/metadata_views_functions.rs @@ -1,6 +1,6 @@ use tokio_postgres::Client; -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; use super::{FunctionInfo, ObjectStats}; @@ -14,7 +14,7 @@ pub async fn load_views(client: &Client, schema: &str) -> Result, Ap &[&schema], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) } @@ -32,7 +32,7 @@ pub async fn load_materialized_views( &[&schema], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows.iter().map(|r| r.get::<_, String>(0)).collect()) } @@ -52,7 +52,7 @@ pub async fn load_functions(client: &Client, schema: &str) -> Result`) keeps every value out of the SQL string. +//! The double cast is deliberate: with a bare `$1::int4` Postgres infers the +//! parameter as `int4` while the client binds `&str`, which is a type mismatch. +//! `$1::text` pins the parameter to text and the second cast converts. The +//! compared column stays bare, so the expression folds to a constant at plan +//! time and indexes stay usable. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use crate::common::enums::AppError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MutationKind { + Update, + Delete, +} + +/// One row mutation. `None` values are SQL NULL. +#[derive(Debug, Clone, Deserialize)] +pub struct RowMutation { + pub kind: MutationKind, + #[serde(default)] + pub set: Vec<(String, Option)>, + pub pk: Vec<(String, Option)>, +} + +/// A statement plus its bound parameters, in placeholder order. +#[derive(Debug, PartialEq, Eq)] +pub struct BuiltStatement { + pub sql: String, + pub params: Vec>, +} + +/// Column name to the type expression returned by `format_type`. +pub type ColumnTypes = BTreeMap; + +pub fn quote_ident(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +/// Resolve a column's type, rejecting anything the table does not have. This is +/// what makes the statement injection-proof: names are matched against the live +/// catalog rather than merely quoted. +fn column_type<'a>(types: &'a ColumnTypes, column: &str) -> Result<&'a str, AppError> { + types + .get(column) + .map(String::as_str) + .ok_or_else(|| AppError::QueryFailed(format!("Unknown column \"{}\"", column))) +} + +/// Append the key predicates and their parameters, returning the WHERE body. +fn build_key_predicates( + key: &[(String, Option)], + types: &ColumnTypes, + params: &mut Vec>, +) -> Result { + if key.is_empty() { + return Err(AppError::QueryFailed( + "Refusing to build a statement with no key columns".into(), + )); + } + + let mut predicates = Vec::with_capacity(key.len()); + for (column, value) in key { + let ty = column_type(types, column)?; + match value { + None => predicates.push(format!("{} IS NULL", quote_ident(column))), + Some(_) => { + params.push(value.clone()); + predicates.push(format!( + "{} = ${}::text::{}", + quote_ident(column), + params.len(), + ty + )); + } + } + } + + Ok(predicates.join(" AND ")) +} + +pub fn build_statement( + schema: &str, + table: &str, + mutation: &RowMutation, + types: &ColumnTypes, +) -> Result { + let target = format!("{}.{}", quote_ident(schema), quote_ident(table)); + let mut params: Vec> = Vec::new(); + + let sql = match mutation.kind { + MutationKind::Delete => { + let where_clause = build_key_predicates(&mutation.pk, types, &mut params)?; + format!("DELETE FROM {} WHERE {}", target, where_clause) + } + MutationKind::Update => { + if mutation.set.is_empty() { + return Err(AppError::QueryFailed( + "Refusing to build an UPDATE with no assignments".into(), + )); + } + + let mut assignments = Vec::with_capacity(mutation.set.len()); + for (column, value) in &mutation.set { + let ty = column_type(types, column)?; + params.push(value.clone()); + assignments.push(format!( + "{} = ${}::text::{}", + quote_ident(column), + params.len(), + ty + )); + } + + let where_clause = build_key_predicates(&mutation.pk, types, &mut params)?; + format!( + "UPDATE {} SET {} WHERE {}", + target, + assignments.join(", "), + where_clause + ) + } + }; + + Ok(BuiltStatement { sql, params }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn types() -> ColumnTypes { + [ + ("id", "bigint"), + ("name", "character varying(255)"), + ("tags", "text[]"), + ("kind", "public.my_enum"), + ("note", "text"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + fn cell(value: &str) -> Option { + Some(value.to_string()) + } + + #[test] + fn delete_casts_the_key_parameter_to_the_column_type() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert_eq!( + built.sql, + "DELETE FROM \"public\".\"t\" WHERE \"id\" = $1::text::bigint" + ); + assert_eq!(built.params, vec![cell("7")]); + } + + #[test] + fn update_numbers_assignments_before_key_predicates() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![("name".into(), cell("ada"))], + pk: vec![("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert_eq!( + built.sql, + "UPDATE \"public\".\"t\" SET \"name\" = $1::text::character varying(255) \ + WHERE \"id\" = $2::text::bigint" + ); + assert_eq!(built.params, vec![cell("ada"), cell("7")]); + } + + #[test] + fn a_null_key_becomes_is_null_and_consumes_no_placeholder() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![("note".into(), None), ("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert_eq!( + built.sql, + "DELETE FROM \"public\".\"t\" WHERE \"note\" IS NULL AND \"id\" = $1::text::bigint" + ); + assert_eq!(built.params, vec![cell("7")]); + } + + #[test] + fn a_null_assignment_binds_null_rather_than_the_text_null() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![("note".into(), None)], + pk: vec![("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert_eq!(built.params, vec![None, cell("7")]); + } + + #[test] + fn the_text_null_is_bound_as_a_value() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![("note".into(), cell("null"))], + pk: vec![("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert_eq!(built.params, vec![cell("null"), cell("7")]); + } + + #[test] + fn composite_keys_are_all_required() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![("id".into(), cell("7")), ("name".into(), cell("ada"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert!(built.sql.contains("\"id\" = $1::text::bigint")); + assert!( + built + .sql + .contains("\"name\" = $2::text::character varying(255)") + ); + assert_eq!(built.params.len(), 2); + } + + #[test] + fn array_and_enum_columns_use_their_catalog_type() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![ + ("tags".into(), cell("{a,b}")), + ("kind".into(), cell("active")), + ], + pk: vec![("id".into(), cell("7"))], + }; + let built = build_statement("public", "t", &mutation, &types()).unwrap(); + assert!(built.sql.contains("\"tags\" = $1::text::text[]")); + assert!(built.sql.contains("\"kind\" = $2::text::public.my_enum")); + } + + #[test] + fn unknown_columns_are_rejected() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![("id; DROP TABLE users".into(), cell("7"))], + }; + let err = build_statement("public", "t", &mutation, &types()).unwrap_err(); + assert!(err.to_string().contains("Unknown column")); + } + + #[test] + fn an_unknown_assignment_column_is_rejected() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![("nope".into(), cell("x"))], + pk: vec![("id".into(), cell("7"))], + }; + assert!(build_statement("public", "t", &mutation, &types()).is_err()); + } + + #[test] + fn a_statement_without_key_columns_is_refused() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![], + }; + let err = build_statement("public", "t", &mutation, &types()).unwrap_err(); + assert!(err.to_string().contains("no key columns")); + } + + #[test] + fn an_update_without_assignments_is_refused() { + let mutation = RowMutation { + kind: MutationKind::Update, + set: vec![], + pk: vec![("id".into(), cell("7"))], + }; + let err = build_statement("public", "t", &mutation, &types()).unwrap_err(); + assert!(err.to_string().contains("no assignments")); + } + + #[test] + fn identifiers_containing_quotes_are_escaped() { + assert_eq!(quote_ident("we\"ird"), "\"we\"\"ird\""); + } + + #[test] + fn schema_and_table_names_are_quoted() { + let mutation = RowMutation { + kind: MutationKind::Delete, + set: vec![], + pk: vec![("id".into(), cell("1"))], + }; + let built = build_statement("my schema", "my table", &mutation, &types()).unwrap(); + assert!( + built + .sql + .starts_with("DELETE FROM \"my schema\".\"my table\" ") + ); + } +} diff --git a/src-tauri/src/drivers/pgsql/mutation/mod.rs b/src-tauri/src/drivers/pgsql/mutation/mod.rs new file mode 100644 index 00000000..342062db --- /dev/null +++ b/src-tauri/src/drivers/pgsql/mutation/mod.rs @@ -0,0 +1,3 @@ +mod builder; + +pub use builder::*; diff --git a/src-tauri/src/drivers/pgsql/query_execution/helpers.rs b/src-tauri/src/drivers/pgsql/query_execution/helpers.rs index 35898f62..f470bbef 100644 --- a/src-tauri/src/drivers/pgsql/query_execution/helpers.rs +++ b/src-tauri/src/drivers/pgsql/query_execution/helpers.rs @@ -1,35 +1,27 @@ use tokio_postgres::SimpleQueryMessage; -use super::super::{CELL_SEP, ROW_SEP}; +use super::super::wire::Cell; /// Process simple_query messages, returning the last result set that had rows. /// If no result set had rows but commands ran, returns synthetic "N rows affected". /// If nothing at all, returns empty vecs. pub(crate) fn process_simple_messages( messages: Vec, -) -> (Vec, Vec>) { +) -> (Vec, Vec>) { let mut cur_columns: Vec = Vec::new(); - let mut cur_rows: Vec> = Vec::new(); + let mut cur_rows: Vec> = Vec::new(); let mut last_columns: Vec = Vec::new(); - let mut last_rows: Vec> = Vec::new(); + let mut last_rows: Vec> = Vec::new(); let mut has_row_result = false; let mut total_affected: u64 = 0; for msg in messages { match msg { SimpleQueryMessage::Row(row) => { - let col_count = row.columns().len(); if cur_columns.is_empty() { - cur_columns = Vec::with_capacity(col_count); - for c in row.columns() { - cur_columns.push(c.name().to_owned()); - } + cur_columns = column_names(&row); } - let mut cells = Vec::with_capacity(col_count); - for i in 0..col_count { - cells.push(row.get(i).unwrap_or("null").to_owned()); - } - cur_rows.push(cells); + cur_rows.push(row_cells(&row)); } SimpleQueryMessage::CommandComplete(n) => { if !cur_rows.is_empty() { @@ -56,54 +48,24 @@ pub(crate) fn process_simple_messages( } else if total_affected > 0 { ( vec!["Result".into()], - vec![vec![format!("{} rows affected", total_affected)]], + vec![vec![Some(format!("{} rows affected", total_affected))]], ) } else { (Vec::new(), Vec::new()) } } -/// Join string slices with a char separator — avoids .to_string() on the separator. -#[inline] -pub(crate) fn join_sep(items: &[String], sep: char) -> String { - let total: usize = items.iter().map(|s| s.len()).sum::() + items.len(); - let mut out = String::with_capacity(total); - for (i, item) in items.iter().enumerate() { - if i > 0 { - out.push(sep); - } - out.push_str(item); - } - out +/// Column names of a simple-query row, in result order. +pub(crate) fn column_names(row: &tokio_postgres::SimpleQueryRow) -> Vec { + row.columns().iter().map(|c| c.name().to_owned()).collect() } -/// Pack a slice of rows (each row = Vec) into wire format. -/// Pre-allocates capacity and writes directly — zero intermediate allocations. -pub(crate) fn pack_rows_vec(rows: &[Vec]) -> String { - if rows.is_empty() { - return String::new(); - } - // Estimate capacity: avg ~20 chars per cell - let est = rows.len() * rows.first().map_or(10, |r| r.len()) * 20; - let mut out = String::with_capacity(est); - - for (ri, row) in rows.iter().enumerate() { - if ri > 0 { - out.push(ROW_SEP); - } - for (ci, cell) in row.iter().enumerate() { - if ci > 0 { - out.push(CELL_SEP); - } - // Inline separator sanitization — avoids .replace() allocations - for ch in cell.chars() { - if ch == CELL_SEP || ch == ROW_SEP { - out.push(' '); - } else { - out.push(ch); - } - } - } +/// Cell values of a simple-query row. `None` is SQL NULL. +pub(crate) fn row_cells(row: &tokio_postgres::SimpleQueryRow) -> Vec { + let col_count = row.columns().len(); + let mut cells = Vec::with_capacity(col_count); + for i in 0..col_count { + cells.push(row.get(i).map(str::to_owned)); } - out + cells } diff --git a/src-tauri/src/drivers/pgsql/query_execution/simple.rs b/src-tauri/src/drivers/pgsql/query_execution/simple.rs index 28cac88a..5da1c687 100644 --- a/src-tauri/src/drivers/pgsql/query_execution/simple.rs +++ b/src-tauri/src/drivers/pgsql/query_execution/simple.rs @@ -1,23 +1,21 @@ use std::time::Instant; use tokio_postgres::Client; -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; -use super::super::{CELL_SEP, ROW_SEP}; -use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; +use super::super::ROW_SEP; +use super::super::wire::{Cell, pack_columns, pack_rows}; +use super::helpers::process_simple_messages; -/// Execute a timed query and return (columns, rows_as_strings, elapsed_ms). +/// Execute a timed query and return (columns, rows, elapsed_ms). /// Uses simple_query protocol — PG returns all values as text, no type conversion needed. /// Supports multi-statement: returns the last result set that had rows. pub async fn execute_query( client: &Client, sql: &str, -) -> Result<(Vec, Vec>, f32), AppError> { +) -> Result<(Vec, Vec>, f32), AppError> { let start = Instant::now(); - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let messages = client.simple_query(sql).await.map_err(query_failed)?; let (columns, rows) = process_simple_messages(messages); let elapsed = start.elapsed().as_millis() as f32; @@ -25,14 +23,10 @@ pub async fn execute_query( } /// Execute a timed query and return results in compact packed string format. -/// Format: "col1\x1Fcol2\x1E row1val1\x1Frow1val2\x1E row2val1\x1Frow2val2" /// Uses simple_query protocol with multi-statement support. pub async fn execute_query_packed(client: &Client, sql: &str) -> Result<(String, f32), AppError> { let start = Instant::now(); - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let messages = client.simple_query(sql).await.map_err(query_failed)?; let (columns, rows) = process_simple_messages(messages); @@ -40,8 +34,8 @@ pub async fn execute_query_packed(client: &Client, sql: &str) -> Result<(String, return Ok((String::new(), start.elapsed().as_millis() as f32)); } - let header = join_sep(&columns, CELL_SEP); - let body = pack_rows_vec(&rows); + let header = pack_columns(&columns); + let body = pack_rows(&rows); let packed = if body.is_empty() { header diff --git a/src-tauri/src/drivers/pgsql/query_execution/streaming.rs b/src-tauri/src/drivers/pgsql/query_execution/streaming.rs index 9e362fb2..cd9d83b3 100644 --- a/src-tauri/src/drivers/pgsql/query_execution/streaming.rs +++ b/src-tauri/src/drivers/pgsql/query_execution/streaming.rs @@ -1,10 +1,10 @@ use std::time::Instant; use tokio_postgres::{Client, SimpleQueryMessage}; -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; -use super::super::CELL_SEP; -use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; +use super::super::wire::{Cell, pack_columns, pack_rows}; +use super::helpers::{column_names, process_simple_messages, row_cells}; /// Events emitted during streamed query execution. #[derive(serde::Serialize, Clone)] @@ -37,10 +37,7 @@ pub async fn execute_query_streamed( let event_name = format!("query-stream-{}", stream_id); // Begin transaction + declare cursor for memory-efficient streaming - client - .batch_execute("BEGIN") - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + client.batch_execute("BEGIN").await.map_err(query_failed)?; let cursor_sql = format!("DECLARE _rsql_cur NO SCROLL CURSOR FOR {}", sql); match client.batch_execute(&cursor_sql).await { @@ -60,24 +57,15 @@ pub async fn execute_query_streamed( } }; - let mut batch_rows: Vec> = Vec::new(); + let mut batch_rows: Vec> = Vec::new(); let mut batch_columns: Option> = None; for msg in messages { if let SimpleQueryMessage::Row(row) = msg { - let col_count = row.columns().len(); if batch_columns.is_none() { - let mut cols = Vec::with_capacity(col_count); - for c in row.columns() { - cols.push(c.name().to_owned()); - } - batch_columns = Some(cols); + batch_columns = Some(column_names(&row)); } - let mut cells = Vec::with_capacity(col_count); - for i in 0..col_count { - cells.push(row.get(i).unwrap_or("null").to_owned()); - } - batch_rows.push(cells); + batch_rows.push(row_cells(&row)); } } @@ -86,7 +74,7 @@ pub async fn execute_query_streamed( } if !columns_sent && let Some(cols) = batch_columns { - let header = join_sep(&cols, CELL_SEP); + let header = pack_columns(&cols); let _ = app.emit( &event_name, QueryStreamEvent::Columns { @@ -97,7 +85,7 @@ pub async fn execute_query_streamed( columns_sent = true; } - let packed = pack_rows_vec(&batch_rows); + let packed = pack_rows(&batch_rows); let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); total_sent += batch_rows.len(); @@ -128,10 +116,7 @@ pub async fn execute_query_streamed( client.batch_execute("ROLLBACK").await.ok(); // Re-execute with simple_query for multi-statement support - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let messages = client.simple_query(sql).await.map_err(query_failed)?; let (columns, rows) = process_simple_messages(messages); @@ -144,7 +129,7 @@ pub async fn execute_query_streamed( }, ); } else { - let header = join_sep(&columns, CELL_SEP); + let header = pack_columns(&columns); let _ = app.emit( &event_name, QueryStreamEvent::Columns { @@ -153,7 +138,7 @@ pub async fn execute_query_streamed( }, ); - let packed = pack_rows_vec(&rows); + let packed = pack_rows(&rows); let _ = app.emit(&event_name, QueryStreamEvent::Chunk { data: packed }); } diff --git a/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs b/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs index 83caeecf..d691dc9f 100644 --- a/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs +++ b/src-tauri/src/drivers/pgsql/query_execution/virtual_cache.rs @@ -1,73 +1,154 @@ -use rayon::prelude::*; +use futures_util::{TryStreamExt, pin_mut}; use std::time::Instant; -use tokio_postgres::Client; +use tokio_postgres::{Client, SimpleQueryMessage}; -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; -use super::super::{CELL_SEP, CachedQuery, ROW_SEP, VirtualCache}; -use super::helpers::{join_sep, pack_rows_vec, process_simple_messages}; +use super::super::wire::{ROW_SEP, pack_columns, push_row}; +use super::super::{CachedQuery, VirtualCache}; +use super::helpers::{column_names, row_cells}; -/// Execute a query in one shot using simple_query protocol. -/// Pre-packs results into page-sized strings cached in-memory. -/// Returns (columns_packed, total_rows, first_page_packed, elapsed_ms). -/// If the SQL is non-SELECT / returns 0 rows, returns empty columns_packed signal -/// with a synthetic affected-rows message in first_page_packed when applicable. +/// Ceilings on what one result may hold in memory. Reaching either stops +/// accumulation and marks the result capped. This bounds the client only: +/// the server still finishes sending the rows it was asked for. Bounding the +/// server too needs a cursor, which needs a connection pinned for the cursor's +/// lifetime and is a separate change. +const MAX_VIRTUAL_ROWS: usize = 1_000_000; +const MAX_VIRTUAL_BYTES: usize = 512 * 1024 * 1024; + +/// Rows packed straight into page-sized strings as they arrive, so the full +/// result never exists as a second, unpacked copy. +#[derive(Default)] +struct PageAccumulator { + columns: Vec, + pages: Vec, + current: String, + rows_in_page: usize, + total_rows: usize, + packed_bytes: usize, +} + +impl PageAccumulator { + fn push(&mut self, row: &tokio_postgres::SimpleQueryRow, page_size: usize) { + if self.columns.is_empty() { + self.columns = column_names(row); + } + if self.rows_in_page == page_size { + self.packed_bytes += self.current.len(); + self.pages.push(std::mem::take(&mut self.current)); + self.rows_in_page = 0; + } + if self.rows_in_page > 0 { + self.current.push(ROW_SEP); + } + push_row(&mut self.current, &row_cells(row)); + self.rows_in_page += 1; + self.total_rows += 1; + } + + fn at_limit(&self) -> bool { + self.total_rows >= MAX_VIRTUAL_ROWS + || self.packed_bytes + self.current.len() >= MAX_VIRTUAL_BYTES + } + + fn finish(mut self) -> Self { + if self.rows_in_page > 0 { + self.pages.push(std::mem::take(&mut self.current)); + self.rows_in_page = 0; + } + self + } + + fn is_empty(&self) -> bool { + self.total_rows == 0 + } +} + +/// Execute a query and pre-pack its rows into page-sized strings held in memory. +/// Returns (columns_packed, total_rows, first_page_packed, elapsed_ms, capped). +/// A non-SELECT or empty result returns empty columns_packed, with a synthetic +/// affected-rows message in first_page_packed when applicable. pub async fn execute_virtual( client: &Client, cache: &tokio::sync::Mutex, sql: &str, query_id: &str, page_size: usize, -) -> Result<(String, usize, String, f32), AppError> { +) -> Result<(String, usize, String, f32, bool), AppError> { let start = Instant::now(); - let messages = client - .simple_query(sql) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let stream = client.simple_query_raw(sql).await.map_err(query_failed)?; + pin_mut!(stream); - let (columns, all_rows) = process_simple_messages(messages); + let mut accum = PageAccumulator::default(); + let mut last: Option = None; + let mut total_affected: u64 = 0; + let mut capped = false; - if columns.is_empty() { - let elapsed = start.elapsed().as_millis() as f32; - return Ok((String::new(), 0, String::new(), elapsed)); - } - - // Synthetic "N rows affected" result — pass through as fallback format - if columns.len() == 1 && columns[0] == "Result" { - let mut fallback = String::with_capacity(64); - fallback.push_str(&columns[0]); - fallback.push(ROW_SEP); - if let Some(r) = all_rows.first() { - fallback.push_str(&join_sep(r, CELL_SEP)); + while let Some(message) = stream.try_next().await.map_err(query_failed)? { + match message { + SimpleQueryMessage::Row(row) => { + accum.push(&row, page_size); + if accum.at_limit() { + capped = true; + break; + } + } + // Multi-statement scripts report the last statement that had rows, + // matching what the non-virtual paths do. + SimpleQueryMessage::CommandComplete(n) => { + total_affected += n; + if !accum.is_empty() { + last = Some(std::mem::take(&mut accum).finish()); + } else { + accum = PageAccumulator::default(); + } + } + _ => {} } - let elapsed = start.elapsed().as_millis() as f32; - return Ok((String::new(), 0, fallback, elapsed)); } - let total_rows = all_rows.len(); - - // Pre-pack into pages — use rayon only for large results (>50K rows) - let chunks: Vec<&[Vec]> = all_rows.chunks(page_size).collect(); - let pages: Vec = if total_rows > 50_000 { - chunks - .par_iter() - .map(|chunk| pack_rows_vec(chunk)) - .collect() + let result = if accum.is_empty() { + last.unwrap_or_default() } else { - chunks.iter().map(|chunk| pack_rows_vec(chunk)).collect() + accum.finish() }; - let columns_packed = join_sep(&columns, CELL_SEP); - let first_page_packed = pages.first().cloned().unwrap_or_default(); + let elapsed = start.elapsed().as_millis() as f32; + + if result.columns.is_empty() { + if total_affected > 0 { + let mut fallback = String::with_capacity(64); + fallback.push_str("Result"); + fallback.push(ROW_SEP); + fallback.push_str(&format!("{} rows affected", total_affected)); + return Ok((String::new(), 0, fallback, elapsed, false)); + } + return Ok((String::new(), 0, String::new(), elapsed, false)); + } + + let columns_packed = pack_columns(&result.columns); + let first_page_packed = result.pages.first().cloned().unwrap_or_default(); + let total_rows = result.total_rows; { let mut c = cache.lock().await; - c.insert(query_id.to_string(), CachedQuery { pages, page_size }); + c.insert( + query_id.to_string(), + CachedQuery { + pages: result.pages, + page_size, + }, + ); } - let elapsed = start.elapsed().as_millis() as f32; - Ok((columns_packed, total_rows, first_page_packed, elapsed)) + Ok(( + columns_packed, + total_rows, + first_page_packed, + elapsed, + capped, + )) } /// Fetch a pre-packed page from the in-memory cache. O(1) — no packing at serve time. @@ -96,3 +177,75 @@ pub async fn close_virtual( c.remove(query_id); Ok(()) } + +#[cfg(test)] +mod tests { + use super::super::super::wire::CELL_SEP; + use super::*; + + fn accumulate(rows: &[Vec>], page_size: usize) -> PageAccumulator { + let mut accum = PageAccumulator { + columns: vec!["a".to_string()], + ..Default::default() + }; + for row in rows { + if accum.rows_in_page == page_size { + accum.packed_bytes += accum.current.len(); + accum.pages.push(std::mem::take(&mut accum.current)); + accum.rows_in_page = 0; + } + if accum.rows_in_page > 0 { + accum.current.push(ROW_SEP); + } + let cells: Vec> = row.iter().map(|c| c.map(str::to_string)).collect(); + push_row(&mut accum.current, &cells); + accum.rows_in_page += 1; + accum.total_rows += 1; + } + accum.finish() + } + + #[test] + fn rows_are_split_into_pages_of_the_requested_size() { + let rows: Vec>> = (0..5).map(|_| vec![Some("x")]).collect(); + let accum = accumulate(&rows, 2); + assert_eq!(accum.pages.len(), 3); + assert_eq!(accum.total_rows, 5); + } + + #[test] + fn a_partial_final_page_is_kept() { + let rows: Vec>> = (0..3).map(|_| vec![Some("x")]).collect(); + let accum = accumulate(&rows, 2); + assert_eq!(accum.pages.len(), 2); + assert_eq!(accum.pages[1].split(ROW_SEP).count(), 1); + } + + #[test] + fn an_exactly_full_page_produces_no_trailing_empty_page() { + let rows: Vec>> = (0..4).map(|_| vec![Some("x")]).collect(); + let accum = accumulate(&rows, 2); + assert_eq!(accum.pages.len(), 2); + } + + #[test] + fn nulls_survive_page_packing() { + let accum = accumulate(&[vec![None], vec![Some("null")]], 10); + let page = &accum.pages[0]; + let mut rows = page.split(ROW_SEP); + assert_eq!(rows.next().unwrap(), "\u{1D}N"); + assert_eq!(rows.next().unwrap(), "null"); + } + + #[test] + fn separators_in_data_do_not_break_page_boundaries() { + let accum = accumulate(&[vec![Some("a\u{1E}b")], vec![Some("c")]], 10); + assert_eq!(accum.pages[0].split(ROW_SEP).count(), 2); + assert_eq!(accum.pages[0].split(CELL_SEP).count(), 1); + } + + #[test] + fn an_empty_accumulator_reports_empty() { + assert!(PageAccumulator::default().is_empty()); + } +} diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs index deac3d57..b457e5f0 100644 --- a/src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/csv_import.rs @@ -1,4 +1,5 @@ -use crate::common::enums::AppError; +use crate::common::enums::{AppError, error_chain, query_failed}; +use crate::drivers::pgsql::mutation::quote_ident; pub async fn parse_csv_preview( file_path: &str, @@ -26,49 +27,95 @@ pub async fn parse_csv_preview( Ok((headers, rows)) } -pub async fn import_csv_to_table( +/// Build the INSERT, casting each text parameter to the column's own type. +/// +/// CSV fields are text, and binding them straight at a non-text column made +/// tokio-postgres refuse to serialize the parameter, so importing into a table +/// with any numeric, date or boolean column failed outright. Casting on the +/// server side is the same recipe the row-mutation builder uses. +fn build_insert(schema: &str, table: &str, columns: &[(String, String)]) -> String { + let assignments: Vec = columns + .iter() + .enumerate() + .map(|(i, (_, ty))| format!("${}::text::{}", i + 1, ty)) + .collect(); + + format!( + "INSERT INTO {}.{} ({}) VALUES ({})", + quote_ident(schema), + quote_ident(table), + columns + .iter() + .map(|(name, _)| quote_ident(name)) + .collect::>() + .join(", "), + assignments.join(", "), + ) +} + +async fn column_types( client: &deadpool_postgres::Client, + schema: &str, + table: &str, + wanted: &[(usize, String)], +) -> Result, AppError> { + let rows = client + .query( + "SELECT attname::text, format_type(atttypid, atttypmod) + FROM pg_attribute + WHERE attrelid = format('%I.%I', $1::text, $2::text)::regclass + AND attnum > 0 + AND NOT attisdropped", + &[&schema, &table], + ) + .await + .map_err(query_failed)?; + + let known: std::collections::BTreeMap = rows + .into_iter() + .map(|row| (row.get::<_, String>(0), row.get::<_, String>(1))) + .collect(); + + wanted + .iter() + .map(|(_, name)| { + known + .get(name) + .map(|ty| (name.clone(), ty.clone())) + .ok_or_else(|| AppError::QueryFailed(format!("Unknown column \"{}\"", name))) + }) + .collect() +} + +pub async fn import_csv_to_table( + client: &mut deadpool_postgres::Client, file_path: &str, schema: &str, table: &str, column_mapping: &[(usize, String)], ) -> Result { - let mut rdr = csv::ReaderBuilder::new() - .has_headers(true) - .from_path(file_path) - .map_err(|e| AppError::QueryFailed(format!("Failed to read CSV: {}", e)))?; - if column_mapping.is_empty() { return Err(AppError::QueryFailed( "No column mapping provided".to_string(), )); } - let col_names: Vec = column_mapping - .iter() - .map(|(_, name)| format!("\"{}\"", name)) - .collect(); - let placeholders: Vec = (1..=column_mapping.len()) - .map(|i| format!("${}", i)) - .collect(); + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .from_path(file_path) + .map_err(|e| AppError::QueryFailed(format!("Failed to read CSV: {}", e)))?; - let insert_sql = format!( - "INSERT INTO \"{}\".\"{}\" ({}) VALUES ({})", - schema, - table, - col_names.join(", "), - placeholders.join(", "), - ); + let columns = column_types(client, schema, table, column_mapping).await?; + let insert_sql = build_insert(schema, table, &columns); - let statement = client - .prepare(&insert_sql) - .await - .map_err(|e| AppError::QueryFailed(format!("Failed to prepare statement: {}", e)))?; + // A real transaction rather than a bare BEGIN: a parse error midway used to + // return without rolling back, handing a connection back to the pool with a + // transaction still open. + let tx = client.transaction().await.map_err(query_failed)?; - client - .execute("BEGIN", &[]) - .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + let statement = tx.prepare(&insert_sql).await.map_err(|e| { + AppError::QueryFailed(format!("Failed to prepare statement: {}", error_chain(&e))) + })?; let mut imported = 0usize; for result in rdr.records() { @@ -76,9 +123,15 @@ pub async fn import_csv_to_table( AppError::QueryFailed(format!("CSV parse error at row {}: {}", imported + 1, e)) })?; - let values: Vec = column_mapping + // An absent or empty field becomes NULL rather than an empty string, + // which is what a numeric or date column needs and what an empty CSV + // field conventionally means. + let values: Vec> = column_mapping .iter() - .map(|(idx, _)| record.get(*idx).unwrap_or("").to_string()) + .map(|(idx, _)| match record.get(*idx) { + Some("") | None => None, + Some(value) => Some(value.to_string()), + }) .collect(); let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = values @@ -86,23 +139,49 @@ pub async fn import_csv_to_table( .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync)) .collect(); - match client.execute(&statement, ¶ms).await { - Ok(_) => imported += 1, - Err(e) => { - client.execute("ROLLBACK", &[]).await.ok(); - return Err(AppError::QueryFailed(format!( - "Import failed at row {}: {}", - imported + 1, - e - ))); - } - } + tx.execute(&statement, ¶ms).await.map_err(|e| { + AppError::QueryFailed(format!( + "Import failed at row {}: {}", + imported + 1, + error_chain(&e) + )) + })?; + imported += 1; } - client - .execute("COMMIT", &[]) + tx.commit() .await - .map_err(|e| AppError::QueryFailed(format!("Failed to commit: {}", e)))?; + .map_err(|e| AppError::QueryFailed(format!("Failed to commit: {}", error_chain(&e))))?; Ok(imported) } + +#[cfg(test)] +mod tests { + use super::*; + + fn columns() -> Vec<(String, String)> { + vec![ + ("id".to_string(), "integer".to_string()), + ("name".to_string(), "text".to_string()), + ] + } + + #[test] + fn each_parameter_is_cast_to_its_column_type() { + let sql = build_insert("public", "t", &columns()); + assert_eq!( + sql, + "INSERT INTO \"public\".\"t\" (\"id\", \"name\") \ + VALUES ($1::text::integer, $2::text::text)" + ); + } + + #[test] + fn identifiers_are_escaped() { + let cols = vec![("we\"ird".to_string(), "text".to_string())]; + let sql = build_insert("my schema", "my\"table", &cols); + assert!(sql.contains("\"my schema\".\"my\"\"table\"")); + assert!(sql.contains("\"we\"\"ird\"")); + } +} diff --git a/src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs b/src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs index b58265dd..0a6280bd 100644 --- a/src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs +++ b/src-tauri/src/drivers/pgsql/roles_schema_objects/roles_grants.rs @@ -1,4 +1,4 @@ -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; #[derive(Debug, Clone, serde::Serialize)] pub struct PgRole { @@ -30,7 +30,7 @@ pub async fn load_roles(client: &deadpool_postgres::Client) -> Result(0)).collect()) } diff --git a/src-tauri/src/drivers/pgsql/schema_index.rs b/src-tauri/src/drivers/pgsql/schema_index.rs new file mode 100644 index 00000000..b2d3ea86 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/schema_index.rs @@ -0,0 +1,178 @@ +//! One-shot catalog snapshot of a schema, for the editor's language features. +//! +//! Completion used to ask the server per table and per schema while the user +//! typed, which put an IPC round trip on the keystroke path. The index is +//! fetched once per schema instead, so completion reads memory. + +use deadpool_postgres::Client; +use serde::Serialize; + +use crate::common::enums::{AppError, query_failed}; + +/// Columns beyond this are not sent; the frontend falls back to loading a +/// table's columns on demand. Keeps a very wide schema from stalling the load. +pub const COLUMN_CAP: usize = 5_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RelationKind { + Table, + View, + MaterializedView, +} + +impl RelationKind { + /// `relkind` values from `pg_class`. + fn from_relkind(value: &str) -> Option { + match value { + "r" | "p" | "f" => Some(Self::Table), + "v" => Some(Self::View), + "m" => Some(Self::MaterializedView), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct IndexedColumn { + pub name: String, + pub data_type: String, + pub nullable: bool, + pub default_value: Option, + pub is_primary_key: bool, + /// Target of a single-column foreign key: (schema, table, column). + pub references: Option<(String, String, String)>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IndexedRelation { + pub name: String, + pub kind: RelationKind, + pub comment: Option, + pub columns: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IndexedFunction { + pub name: String, + pub signature: String, + pub return_type: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SchemaIndex { + pub schema: String, + pub relations: Vec, + pub functions: Vec, + /// Columns were omitted because the schema exceeds `COLUMN_CAP`. + pub truncated: bool, +} + +const RELATION_SQL: &str = include_str!("sql/relations.sql"); + +/// Columns with their type, nullability, default, primary-key flag and, for +/// single-column foreign keys, the target. One row per column of the schema. +const COLUMN_SQL: &str = include_str!("sql/columns.sql"); + +const FUNCTION_SQL: &str = include_str!("sql/functions.sql"); + +pub async fn load_schema_index(client: &Client, schema: &str) -> Result { + let relation_rows = client + .query(RELATION_SQL, &[&schema]) + .await + .map_err(query_failed)?; + + let mut relations: Vec = relation_rows + .iter() + .filter_map(|row| { + let kind = RelationKind::from_relkind(row.get::<_, String>(1).as_str())?; + Some(IndexedRelation { + name: row.get(0), + kind, + comment: row.get(2), + columns: Vec::new(), + }) + }) + .collect(); + + // One extra row is the signal that the schema is wider than the cap, which + // avoids a separate counting query. + let limit = COLUMN_CAP as i64 + 1; + let column_rows = client + .query(COLUMN_SQL, &[&schema, &limit]) + .await + .map_err(query_failed)?; + + let truncated = column_rows.len() > COLUMN_CAP; + if !truncated { + for row in &column_rows { + let relation_name: String = row.get(0); + let Some(relation) = relations.iter_mut().find(|r| r.name == relation_name) else { + continue; + }; + let target_schema: Option = row.get(6); + let target_table: Option = row.get(7); + let target_column: Option = row.get(8); + relation.columns.push(IndexedColumn { + name: row.get(1), + data_type: row.get(2), + nullable: row.get(3), + default_value: row.get(4), + is_primary_key: row.get(5), + references: match (target_schema, target_table, target_column) { + (Some(s), Some(t), Some(c)) => Some((s, t, c)), + _ => None, + }, + }); + } + } + + let function_rows = client + .query(FUNCTION_SQL, &[&schema]) + .await + .map_err(query_failed)?; + + let functions = function_rows + .iter() + .map(|row| IndexedFunction { + name: row.get(0), + signature: row.get(1), + return_type: row.get(2), + }) + .collect(); + + Ok(SchemaIndex { + schema: schema.to_string(), + relations, + functions, + truncated, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ordinary_and_partitioned_tables_are_tables() { + assert_eq!(RelationKind::from_relkind("r"), Some(RelationKind::Table)); + assert_eq!(RelationKind::from_relkind("p"), Some(RelationKind::Table)); + assert_eq!(RelationKind::from_relkind("f"), Some(RelationKind::Table)); + } + + #[test] + fn views_and_materialized_views_are_distinguished() { + assert_eq!(RelationKind::from_relkind("v"), Some(RelationKind::View)); + assert_eq!( + RelationKind::from_relkind("m"), + Some(RelationKind::MaterializedView) + ); + } + + #[test] + fn indexes_and_sequences_are_not_relations_we_complete() { + assert_eq!(RelationKind::from_relkind("i"), None); + assert_eq!(RelationKind::from_relkind("S"), None); + assert_eq!(RelationKind::from_relkind("t"), None); + } +} diff --git a/src-tauri/src/drivers/pgsql/sql/columns.sql b/src-tauri/src/drivers/pgsql/sql/columns.sql new file mode 100644 index 00000000..88919684 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/sql/columns.sql @@ -0,0 +1,39 @@ +SELECT c.relname::text, + a.attname::text, + format_type(a.atttypid, a.atttypmod), + NOT a.attnotnull, + pg_get_expr(d.adbin, d.adrelid), + COALESCE(pk.is_pk, false), + fk.target_schema, + fk.target_table, + fk.target_column +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute a ON a.attrelid = c.oid +LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum +LEFT JOIN LATERAL ( + SELECT true AS is_pk + FROM pg_constraint pc + WHERE pc.conrelid = c.oid AND pc.contype = 'p' AND a.attnum = ANY (pc.conkey) + LIMIT 1 +) pk ON true +LEFT JOIN LATERAL ( + SELECT fn.nspname::text AS target_schema, + fc.relname::text AS target_table, + fa.attname::text AS target_column + FROM pg_constraint pc + JOIN pg_class fc ON fc.oid = pc.confrelid + JOIN pg_namespace fn ON fn.oid = fc.relnamespace + JOIN pg_attribute fa ON fa.attrelid = pc.confrelid AND fa.attnum = pc.confkey[1] + WHERE pc.conrelid = c.oid + AND pc.contype = 'f' + AND array_length(pc.conkey, 1) = 1 + AND pc.conkey[1] = a.attnum + LIMIT 1 +) fk ON true +WHERE n.nspname = $1 + AND c.relkind IN ('r', 'p', 'f', 'v', 'm') + AND a.attnum > 0 + AND NOT a.attisdropped +ORDER BY c.relname, a.attnum +LIMIT $2 diff --git a/src-tauri/src/drivers/pgsql/sql/functions.sql b/src-tauri/src/drivers/pgsql/sql/functions.sql new file mode 100644 index 00000000..27068909 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/sql/functions.sql @@ -0,0 +1,8 @@ +SELECT p.proname::text, + pg_get_function_arguments(p.oid), + pg_get_function_result(p.oid) +FROM pg_proc p +JOIN pg_namespace n ON n.oid = p.pronamespace +WHERE n.nspname = $1 + AND p.prokind IN ('f', 'a', 'w') +ORDER BY p.proname diff --git a/src-tauri/src/drivers/pgsql/sql/relations.sql b/src-tauri/src/drivers/pgsql/sql/relations.sql new file mode 100644 index 00000000..a6868956 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/sql/relations.sql @@ -0,0 +1,8 @@ +SELECT c.relname::text, + c.relkind::text, + obj_description(c.oid, 'pg_class') +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = $1 + AND c.relkind IN ('r', 'p', 'f', 'v', 'm') +ORDER BY c.relname diff --git a/src-tauri/src/drivers/pgsql/statistics_activity/database.rs b/src-tauri/src/drivers/pgsql/statistics_activity/database.rs index 577b5f8f..fef4df73 100644 --- a/src-tauri/src/drivers/pgsql/statistics_activity/database.rs +++ b/src-tauri/src/drivers/pgsql/statistics_activity/database.rs @@ -1,6 +1,6 @@ use tokio_postgres::Client; -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; use super::super::DbStat; @@ -24,7 +24,7 @@ pub async fn load_activity(client: &Client) -> Result>, AppError &[], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -73,7 +73,7 @@ pub async fn load_database_stats(client: &Client) -> Result, AppErro &[], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -109,7 +109,7 @@ pub async fn load_table_stats(client: &Client) -> Result>, AppEr &[], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -142,7 +142,7 @@ pub async fn load_active_locks( &[], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -176,7 +176,7 @@ pub async fn load_index_usage( &[], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() diff --git a/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs b/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs index 8f2606d2..91b464de 100644 --- a/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs +++ b/src-tauri/src/drivers/pgsql/statistics_activity/objects.rs @@ -1,6 +1,6 @@ use tokio_postgres::Client; -use crate::common::enums::AppError; +use crate::common::enums::{AppError, query_failed}; use super::super::{FKDetail, ForeignKeyInfo, ObjectStats}; @@ -32,7 +32,7 @@ pub async fn load_table_statistics( &[&schema, &table], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; let keys = [ "row_estimate", @@ -106,7 +106,7 @@ pub async fn load_fk_details( let rows = client .query(&sql, &[&schema, &table]) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -150,7 +150,7 @@ pub async fn load_foreign_keys( &[&schema], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() @@ -188,7 +188,7 @@ pub async fn load_table_bloat( &[], ) .await - .map_err(|e| AppError::QueryFailed(e.to_string()))?; + .map_err(query_failed)?; Ok(rows .iter() diff --git a/src-tauri/src/drivers/pgsql/wire.rs b/src-tauri/src/drivers/pgsql/wire.rs new file mode 100644 index 00000000..81097144 --- /dev/null +++ b/src-tauri/src/drivers/pgsql/wire.rs @@ -0,0 +1,242 @@ +//! Packed wire format shared by every query path. +//! +//! Cells are joined by [`CELL_SEP`], rows by [`ROW_SEP`]. Because any byte can +//! legitimately appear in a Postgres text value, occurrences of the separators +//! inside data are escaped with [`ESC`] rather than replaced — replacing them +//! silently corrupted values, which broke primary-key matching on row updates. +//! SQL NULL has its own encoding so it stays distinguishable from the text +//! value `"null"` and from the empty string. + +/// Cell separator (Unit Separator, ASCII 0x1F). +pub(crate) const CELL_SEP: char = '\x1F'; +/// Row separator (Record Separator, ASCII 0x1E). +pub(crate) const ROW_SEP: char = '\x1E'; +/// Escape prefix (Group Separator, ASCII 0x1D). +pub(crate) const ESC: char = '\x1D'; + +const TAG_NULL: char = 'N'; +const TAG_CELL_SEP: char = 'A'; +const TAG_ROW_SEP: char = 'B'; +const TAG_ESC: char = 'C'; + +/// A single cell: `None` is SQL NULL, `Some("")` is the empty string. +pub type Cell = Option; + +/// Append one cell in escaped form. `None` becomes the NULL marker. +pub(crate) fn push_cell(out: &mut String, cell: Option<&str>) { + let Some(value) = cell else { + out.push(ESC); + out.push(TAG_NULL); + return; + }; + + // Separators are ASCII, so a byte scan cannot produce false hits inside + // multi-byte characters and lets the common case skip the escape pass. + if value.as_bytes().iter().any(|b| matches!(b, 0x1D..=0x1F)) { + for ch in value.chars() { + match ch { + CELL_SEP => { + out.push(ESC); + out.push(TAG_CELL_SEP); + } + ROW_SEP => { + out.push(ESC); + out.push(TAG_ROW_SEP); + } + ESC => { + out.push(ESC); + out.push(TAG_ESC); + } + other => out.push(other), + } + } + } else { + out.push_str(value); + } +} + +/// Append one row, separating cells with [`CELL_SEP`]. +pub(crate) fn push_row(out: &mut String, row: &[Cell]) { + for (index, cell) in row.iter().enumerate() { + if index > 0 { + out.push(CELL_SEP); + } + push_cell(out, cell.as_deref()); + } +} + +/// Exact byte budget for [`pack_rows`], ignoring the rare escape expansion. +fn packed_capacity(rows: &[Vec]) -> usize { + let mut total = 0; + for row in rows { + for cell in row { + total += cell.as_ref().map_or(2, String::len); + } + total += row.len(); + } + total +} + +/// Encode rows into the packed wire format. +pub(crate) fn pack_rows(rows: &[Vec]) -> String { + if rows.is_empty() { + return String::new(); + } + + let mut out = String::with_capacity(packed_capacity(rows)); + for (index, row) in rows.iter().enumerate() { + if index > 0 { + out.push(ROW_SEP); + } + push_row(&mut out, row); + } + out +} + +/// Encode column names as a single header line. +pub(crate) fn pack_columns(columns: &[String]) -> String { + let mut out = + String::with_capacity(columns.iter().map(String::len).sum::() + columns.len()); + for (index, name) in columns.iter().enumerate() { + if index > 0 { + out.push(CELL_SEP); + } + push_cell(&mut out, Some(name)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Mirror of the TypeScript decoder, so round-trips can be asserted here. + fn unpack_cell(raw: &str) -> Cell { + if raw == "\x1DN" { + return None; + } + if !raw.contains(ESC) { + return Some(raw.to_owned()); + } + + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(ch) = chars.next() { + if ch != ESC { + out.push(ch); + continue; + } + match chars.next() { + Some(TAG_CELL_SEP) => out.push(CELL_SEP), + Some(TAG_ROW_SEP) => out.push(ROW_SEP), + Some(TAG_ESC) => out.push(ESC), + Some(other) => out.push(other), + None => {} + } + } + Some(out) + } + + fn unpack_rows(packed: &str) -> Vec> { + if packed.is_empty() { + return Vec::new(); + } + packed + .split(ROW_SEP) + .map(|row| row.split(CELL_SEP).map(unpack_cell).collect()) + .collect() + } + + fn round_trip(rows: Vec>) { + let packed = pack_rows(&rows); + assert_eq!(unpack_rows(&packed), rows); + } + + #[test] + fn null_is_distinct_from_the_text_null() { + let rows = vec![vec![None, Some("null".into()), Some("NULL".into())]]; + let packed = pack_rows(&rows); + assert_eq!(unpack_rows(&packed), rows); + + let decoded = unpack_rows(&packed); + assert!(decoded[0][0].is_none()); + assert_eq!(decoded[0][1].as_deref(), Some("null")); + assert_eq!(decoded[0][2].as_deref(), Some("NULL")); + } + + #[test] + fn null_is_distinct_from_the_empty_string() { + let rows = vec![vec![None, Some(String::new())]]; + let decoded = unpack_rows(&pack_rows(&rows)); + assert!(decoded[0][0].is_none()); + assert_eq!(decoded[0][1].as_deref(), Some("")); + } + + #[test] + fn separators_in_data_survive() { + round_trip(vec![vec![ + Some("a\x1Fb".into()), + Some("c\x1Ed".into()), + Some("e\x1Df".into()), + ]]); + } + + #[test] + fn a_value_equal_to_the_null_marker_survives() { + let rows = vec![vec![Some("\x1DN".into())]]; + let decoded = unpack_rows(&pack_rows(&rows)); + assert_eq!(decoded[0][0].as_deref(), Some("\x1DN")); + } + + #[test] + fn consecutive_escapes_survive() { + round_trip(vec![vec![Some("\x1D\x1D\x1F\x1E".into())]]); + } + + #[test] + fn multibyte_content_survives() { + round_trip(vec![vec![ + Some("árvíztűrő tükörfúrógép".into()), + Some("日本語".into()), + Some("emoji 🦀".into()), + ]]); + } + + #[test] + fn multibyte_next_to_separators_survives() { + round_trip(vec![vec![Some("🦀\x1F🦀\x1E🦀".into())]]); + } + + #[test] + fn empty_input_packs_to_empty_string() { + assert_eq!(pack_rows(&[]), ""); + assert!(unpack_rows("").is_empty()); + } + + #[test] + fn multiple_rows_and_columns_survive() { + round_trip(vec![ + vec![Some("1".into()), None, Some("x".into())], + vec![None, Some(String::new()), Some("null".into())], + vec![Some("3".into()), Some("y".into()), None], + ]); + } + + #[test] + fn column_names_are_escaped_too() { + let columns = vec!["id".to_string(), "we\x1Fird".to_string()]; + let packed = pack_columns(&columns); + let decoded: Vec = packed.split(CELL_SEP).map(unpack_cell).collect(); + assert_eq!(decoded[0].as_deref(), Some("id")); + assert_eq!(decoded[1].as_deref(), Some("we\x1Fird")); + } + + #[test] + fn capacity_estimate_covers_unescaped_payloads() { + let rows = vec![ + vec![Some("abc".into()), None], + vec![Some("de".into()), Some("f".into())], + ]; + assert!(packed_capacity(&rows) >= pack_rows(&rows).len()); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0e6e1a36..535ed135 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -20,7 +20,9 @@ use tracing::Level; pub struct AppState { pub clients: Arc>>>, pub meta_clients: Arc>>>, - pub cancel_tokens: Arc>>, + /// Keyed by exec id, not project: a project can have several queries in + /// flight and cancelling must hit the one the user asked for. + pub cancel_tokens: Arc>>, pub client_ssl: Arc>>, pub local_db: libsql::Database, pub resource_monitor: Arc>, @@ -104,7 +106,9 @@ fn main() { drivers::pgsql::pgsql_load_available_extensions, drivers::pgsql::pgsql_load_enum_types, drivers::pgsql::pgsql_table_action, + drivers::pgsql::pgsql_apply_row_mutations, drivers::pgsql::pgsql_load_pg_settings, + drivers::pgsql::pgsql_load_schema_index, terminal::terminal_spawn, terminal::terminal_write, terminal::terminal_resize, diff --git a/src-tauri/src/ssh.rs b/src-tauri/src/ssh.rs index 0e551350..bdda7309 100644 --- a/src-tauri/src/ssh.rs +++ b/src-tauri/src/ssh.rs @@ -47,35 +47,35 @@ async fn connect_ssh( .await .map_err(|e| format!("SSH connection to {}:{} failed: {}", ssh_host, ssh_port, e))?; - if let Some(key_path) = ssh_key_path { - if !key_path.is_empty() { - match keys::load_secret_key(key_path, ssh_password) { - Ok(key) => { - let key = PrivateKeyWithHashAlg::new(Arc::new(key), None); - let result = handle - .authenticate_publickey(ssh_user, key) - .await - .map_err(|e| format!("SSH key auth failed: {}", e))?; - if result.success() { - return Ok(handle); - } - } - Err(e) => { - tracing::warn!("Failed to load SSH key {}: {}", key_path, e); + if let Some(key_path) = ssh_key_path + && !key_path.is_empty() + { + match keys::load_secret_key(key_path, ssh_password) { + Ok(key) => { + let key = PrivateKeyWithHashAlg::new(Arc::new(key), None); + let result = handle + .authenticate_publickey(ssh_user, key) + .await + .map_err(|e| format!("SSH key auth failed: {}", e))?; + if result.success() { + return Ok(handle); } } + Err(e) => { + tracing::warn!("Failed to load SSH key {}: {}", key_path, e); + } } } - if let Some(password) = ssh_password { - if !password.is_empty() { - let result = handle - .authenticate_password(ssh_user, password) - .await - .map_err(|e| format!("SSH password auth failed: {}", e))?; - if result.success() { - return Ok(handle); - } + if let Some(password) = ssh_password + && !password.is_empty() + { + let result = handle + .authenticate_password(ssh_user, password) + .await + .map_err(|e| format!("SSH password auth failed: {}", e))?; + if result.success() { + return Ok(handle); } } diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index c07dc366..b3c4df35 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet, VecDeque}; use std::time::Instant; use rayon::prelude::*; @@ -20,11 +20,47 @@ pub struct SystemResourceUsage { pub db_connections_waiting: usize, } +/// Walk a parent-of map downward from `root`. +/// +/// One pass over the pids to build children lists, then a breadth-first walk. +/// The previous version rescanned every process on the machine once per level +/// of the tree until no new child turned up. +fn collect_descendants(parent_of: &[(Pid, Option)], root: Pid) -> HashSet { + let mut children: HashMap> = HashMap::new(); + for (pid, parent) in parent_of { + if let Some(parent) = parent { + children.entry(*parent).or_default().push(*pid); + } + } + + let mut included = HashSet::new(); + let mut queue = VecDeque::new(); + included.insert(root); + queue.push_back(root); + + while let Some(pid) = queue.pop_front() { + for child in children.get(&pid).into_iter().flatten() { + if included.insert(*child) { + queue.push_back(*child); + } + } + } + + included +} + +/// How many samples may reuse the known process tree before it is rediscovered. +/// Enumerating every process on the machine is the expensive part of a sample, +/// and the tree only changes when the app spawns something such as a terminal. +const REDISCOVER_EVERY: u32 = 5; + pub struct ResourceMonitor { system: System, networks: Networks, last_sample_at: Instant, app_pid: Pid, + tracked: Vec, + samples_since_rediscover: u32, } impl ResourceMonitor { @@ -40,42 +76,51 @@ impl ResourceMonitor { networks, last_sample_at: Instant::now(), app_pid, + tracked: vec![app_pid], + samples_since_rediscover: REDISCOVER_EVERY, } } pub fn sample(&mut self) -> SystemResourceUsage { - self.system.refresh_processes_specifics( - ProcessesToUpdate::All, - true, - ProcessRefreshKind::nothing().with_cpu().with_memory(), - ); + let refresh_kind = ProcessRefreshKind::nothing().with_cpu().with_memory(); + + // Enumerating everything is only needed to notice processes the app has + // spawned since the last look; in between, refresh just the ones we + // already know about. + let rediscover = self.samples_since_rediscover >= REDISCOVER_EVERY; + if rediscover { + self.system + .refresh_processes_specifics(ProcessesToUpdate::All, true, refresh_kind); + self.samples_since_rediscover = 0; + + let parent_of: Vec<(Pid, Option)> = self + .system + .processes() + .iter() + .map(|(pid, process)| (*pid, process.parent())) + .collect(); + self.tracked = collect_descendants(&parent_of, self.app_pid) + .into_iter() + .collect(); + } else { + self.system.refresh_processes_specifics( + ProcessesToUpdate::Some(&self.tracked), + true, + refresh_kind, + ); + self.samples_since_rediscover += 1; + } + self.networks.refresh(true); let dt = self.last_sample_at.elapsed().as_secs_f32().max(0.001); self.last_sample_at = Instant::now(); let processes = self.system.processes(); - let mut included = HashSet::new(); - included.insert(self.app_pid); - - let mut changed = true; - while changed { - changed = false; - for (pid, process) in processes { - if included.contains(pid) { - continue; - } - if let Some(parent) = process.parent() - && included.contains(&parent) - { - included.insert(*pid); - changed = true; - } - } - } + let included = &self.tracked; let mut total_cpu = 0.0f32; let mut total_rss = 0u64; - for pid in &included { + for pid in included { if let Some(process) = processes.get(pid) { total_cpu += process.cpu_usage(); total_rss = total_rss.saturating_add(process.memory()); @@ -241,3 +286,75 @@ pub fn compute_diff(pinned_packed: String, current_packed: String) -> (String, S unchanged_count, ) } + +#[cfg(test)] +mod resource_tests { + use super::*; + + fn pid(n: u32) -> Pid { + Pid::from_u32(n) + } + + #[test] + fn a_lone_root_is_its_own_tree() { + let tree = collect_descendants(&[(pid(1), None)], pid(1)); + assert_eq!(tree.len(), 1); + assert!(tree.contains(&pid(1))); + } + + #[test] + fn children_and_grandchildren_are_included() { + let parents = vec![ + (pid(1), None), + (pid(2), Some(pid(1))), + (pid(3), Some(pid(2))), + (pid(4), Some(pid(3))), + ]; + let tree = collect_descendants(&parents, pid(1)); + assert_eq!(tree.len(), 4); + } + + #[test] + fn unrelated_processes_are_excluded() { + let parents = vec![ + (pid(1), None), + (pid(2), Some(pid(1))), + (pid(99), None), + (pid(98), Some(pid(99))), + ]; + let tree = collect_descendants(&parents, pid(1)); + assert_eq!(tree.len(), 2); + assert!(!tree.contains(&pid(99))); + assert!(!tree.contains(&pid(98))); + } + + #[test] + fn a_parent_cycle_does_not_hang() { + // Should not happen, but a self- or mutual parent must not loop forever. + let parents = vec![ + (pid(1), Some(pid(2))), + (pid(2), Some(pid(1))), + (pid(3), Some(pid(1))), + ]; + let tree = collect_descendants(&parents, pid(1)); + assert!(tree.contains(&pid(3))); + assert!(tree.len() <= 3); + } + + #[test] + fn a_root_that_is_not_listed_still_yields_itself() { + let tree = collect_descendants(&[(pid(5), Some(pid(4)))], pid(1)); + assert_eq!(tree, HashSet::from([pid(1)])); + } + + #[test] + fn children_listed_before_their_parent_are_still_found() { + // Process order is not guaranteed to be topological. + let parents = vec![ + (pid(3), Some(pid(2))), + (pid(2), Some(pid(1))), + (pid(1), None), + ]; + assert_eq!(collect_descendants(&parents, pid(1)).len(), 3); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e5350aa0..9ec14dc1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "rsql", - "version": "1.1.5", + "version": "1.2.0", "identifier": "com.rust-dd.rsql", "build": { "beforeDevCommand": "yarn dev", diff --git a/src-tauri/tests/csv_import.rs b/src-tauri/tests/csv_import.rs new file mode 100644 index 00000000..b0b70d20 --- /dev/null +++ b/src-tauri/tests/csv_import.rs @@ -0,0 +1,143 @@ +//! CSV import against a real PostgreSQL. +//! +//! CSV fields are text. Binding them directly at a typed column made +//! tokio-postgres refuse to serialize the parameter, so importing into any +//! table with a numeric, date or boolean column failed outright. These check +//! the cast recipe that replaced it, and that an empty field lands as NULL. +//! +//! Ignored by default; see tests/row_mutations.rs for how to run them. + +use tokio_postgres::{Client, NoTls}; + +async fn connect() -> Client { + let url = std::env::var("RSQL_TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/postgres".to_string()); + let (client, connection) = tokio_postgres::connect(&url, NoTls) + .await + .expect("connect to the test database"); + tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("connection error: {e}"); + } + }); + client +} + +/// Each test gets its own table so they can run concurrently. +async fn setup(client: &Client, table: &str) { + client + .batch_execute(&format!( + "DROP TABLE IF EXISTS {table}; + CREATE TABLE {table} ( + id int, amount numeric(10,2), born date, ok boolean, note text + );" + )) + .await + .expect("create the test table"); +} + +fn insert_sql(table: &str) -> String { + format!( + "INSERT INTO \"public\".\"{table}\" \ + (\"id\", \"amount\", \"born\", \"ok\", \"note\") VALUES \ + ($1::text::integer, $2::text::numeric(10,2), $3::text::date, \ + $4::text::boolean, $5::text::text)" + ) +} + +async fn insert(client: &Client, table: &str, values: &[Option]) -> Result { + let statement = client + .prepare(&insert_sql(table)) + .await + .map_err(|e| e.to_string())?; + let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = values + .iter() + .map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync)) + .collect(); + client.execute(&statement, ¶ms).await.map_err(|e| { + // Mirrors the app's error_chain: the message Postgres sent lives in + // the source, not in the top-level "db error". + let mut msg = e.to_string(); + let mut src = std::error::Error::source(&e); + while let Some(cause) = src { + msg.push_str(": "); + msg.push_str(&cause.to_string()); + src = cause.source(); + } + msg + }) +} + +fn some(value: &str) -> Option { + Some(value.to_string()) +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn text_fields_import_into_typed_columns() { + let client = connect().await; + setup(&client, "rsql_csv_typed").await; + + insert( + &client, + "rsql_csv_typed", + &[ + some("1"), + some("9.50"), + some("2020-01-02"), + some("true"), + some("a note"), + ], + ) + .await + .expect("a typed row should import"); + + let amount: String = client + .query_one("SELECT amount::text FROM rsql_csv_typed WHERE id = 1", &[]) + .await + .unwrap() + .get(0); + assert_eq!(amount, "9.50"); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn empty_fields_become_null_not_empty_strings() { + let client = connect().await; + setup(&client, "rsql_csv_empty").await; + + insert( + &client, + "rsql_csv_empty", + &[some("2"), None, None, None, None], + ) + .await + .expect("a row of empty fields should import"); + + let nulls: i64 = client + .query_one( + "SELECT count(*) FROM rsql_csv_empty + WHERE amount IS NULL AND born IS NULL AND ok IS NULL AND note IS NULL", + &[], + ) + .await + .unwrap() + .get(0); + assert_eq!(nulls, 1); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn a_malformed_field_fails_that_row_rather_than_importing_garbage() { + let client = connect().await; + setup(&client, "rsql_csv_bad").await; + + let err = insert( + &client, + "rsql_csv_bad", + &[some("3"), some("not a number"), None, None, None], + ) + .await + .unwrap_err(); + assert!(err.contains("invalid input syntax"), "unexpected: {err}"); +} diff --git a/src-tauri/tests/row_mutations.rs b/src-tauri/tests/row_mutations.rs new file mode 100644 index 00000000..addc00f2 --- /dev/null +++ b/src-tauri/tests/row_mutations.rs @@ -0,0 +1,390 @@ +//! End-to-end checks of the row mutation path against a real PostgreSQL. +//! +//! These cover the behaviour that unit tests cannot: whether the generated +//! casts actually work for each column type, and whether a statement really +//! matches the row it was meant to. Ignored by default so `cargo test` stays +//! offline; CI runs them with `--ignored` against a service container. +//! +//! Locally: +//! docker run --rm -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres --name rsql-test postgres:16 +//! RSQL_TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres \ +//! cargo test --test row_mutations -- --ignored + +use tokio_postgres::{Client, NoTls}; + +fn database_url() -> String { + std::env::var("RSQL_TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/postgres".to_string()) +} + +async fn connect() -> Client { + let (client, connection) = tokio_postgres::connect(&database_url(), NoTls) + .await + .expect("connect to the test database"); + tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("connection error: {e}"); + } + }); + client +} + +/// Each test gets its own table so they can run concurrently. +async fn setup(client: &Client, table: &str, columns: &str) { + client + .batch_execute(&format!( + "DROP TABLE IF EXISTS {table}; CREATE TABLE {table} ({columns});" + )) + .await + .expect("create the test table"); +} + +/// Mirrors what `pgsql_apply_row_mutations` does, minus the Tauri state: build +/// the statements, run them in one transaction, require exactly one row each. +async fn apply( + client: &mut Client, + statements: &[(String, Vec>)], +) -> Result { + let tx = client.transaction().await.map_err(|e| e.to_string())?; + let mut applied = 0; + + for (sql, params) in statements { + let bound: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = params + .iter() + .map(|p| p as &(dyn tokio_postgres::types::ToSql + Sync)) + .collect(); + let affected = tx + .execute(sql.as_str(), &bound) + .await + .map_err(|e| e.to_string())?; + if affected != 1 { + return Err(format!("expected 1 row, matched {affected}")); + } + applied += 1; + } + + tx.commit().await.map_err(|e| e.to_string())?; + Ok(applied) +} + +fn some(value: &str) -> Option { + Some(value.to_string()) +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn deletes_the_row_whose_key_is_the_text_null_not_the_null_row() { + let mut client = connect().await; + setup( + &client, + "rsql_test_text_null", + "id text primary key, note text", + ) + .await; + client + .batch_execute( + "INSERT INTO rsql_test_text_null VALUES ('null', 'the text null'), ('other', 'x')", + ) + .await + .unwrap(); + + let statements = vec![( + "DELETE FROM \"public\".\"rsql_test_text_null\" WHERE \"id\" = $1::text::text".to_string(), + vec![some("null")], + )]; + apply(&mut client, &statements) + .await + .expect("delete applies"); + + let remaining: Vec = client + .query("SELECT id FROM rsql_test_text_null ORDER BY id", &[]) + .await + .unwrap() + .iter() + .map(|r| r.get(0)) + .collect(); + assert_eq!(remaining, vec!["other".to_string()]); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn a_null_key_matches_only_the_null_row() { + let mut client = connect().await; + setup( + &client, + "rsql_test_null_key", + "id int, tag text, PRIMARY KEY (id)", + ) + .await; + client + .batch_execute("INSERT INTO rsql_test_null_key VALUES (1, NULL), (2, 'null'), (3, '')") + .await + .unwrap(); + + let statements = vec![( + "DELETE FROM \"public\".\"rsql_test_null_key\" WHERE \"tag\" IS NULL".to_string(), + vec![], + )]; + apply(&mut client, &statements) + .await + .expect("delete applies"); + + let remaining: Vec = client + .query("SELECT id FROM rsql_test_null_key ORDER BY id", &[]) + .await + .unwrap() + .iter() + .map(|r| r.get(0)) + .collect(); + assert_eq!(remaining, vec![2, 3]); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn a_delete_matching_no_rows_is_an_error_and_rolls_back() { + let mut client = connect().await; + setup(&client, "rsql_test_missing", "id int primary key").await; + client + .batch_execute("INSERT INTO rsql_test_missing VALUES (1), (2)") + .await + .unwrap(); + + let statements = vec![ + ( + "DELETE FROM \"public\".\"rsql_test_missing\" WHERE \"id\" = $1::text::int4" + .to_string(), + vec![some("1")], + ), + ( + "DELETE FROM \"public\".\"rsql_test_missing\" WHERE \"id\" = $1::text::int4" + .to_string(), + vec![some("99")], + ), + ]; + let err = apply(&mut client, &statements).await.unwrap_err(); + assert!(err.contains("matched 0"), "unexpected error: {err}"); + + // The first delete must have rolled back with the second. + let count: i64 = client + .query_one("SELECT count(*) FROM rsql_test_missing", &[]) + .await + .unwrap() + .get(0); + assert_eq!(count, 2); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn a_key_matching_several_rows_is_an_error_and_rolls_back() { + let mut client = connect().await; + setup(&client, "rsql_test_dupes", "id int, tag text").await; + client + .batch_execute("INSERT INTO rsql_test_dupes VALUES (1, 'a'), (1, 'b')") + .await + .unwrap(); + + let statements = vec![( + "DELETE FROM \"public\".\"rsql_test_dupes\" WHERE \"id\" = $1::text::int4".to_string(), + vec![some("1")], + )]; + let err = apply(&mut client, &statements).await.unwrap_err(); + assert!(err.contains("matched 2"), "unexpected error: {err}"); + + let count: i64 = client + .query_one("SELECT count(*) FROM rsql_test_dupes", &[]) + .await + .unwrap() + .get(0); + assert_eq!(count, 2); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn updates_and_deletes_apply_together_in_one_transaction() { + let mut client = connect().await; + setup(&client, "rsql_test_mixed", "id int primary key, tag text").await; + client + .batch_execute("INSERT INTO rsql_test_mixed VALUES (1, 'a'), (2, 'b'), (3, 'c')") + .await + .unwrap(); + + let statements = vec![ + ( + "UPDATE \"public\".\"rsql_test_mixed\" SET \"tag\" = $1::text::text \ + WHERE \"id\" = $2::text::int4" + .to_string(), + vec![some("updated"), some("1")], + ), + ( + "DELETE FROM \"public\".\"rsql_test_mixed\" WHERE \"id\" = $1::text::int4".to_string(), + vec![some("2")], + ), + ]; + assert_eq!(apply(&mut client, &statements).await.unwrap(), 2); + + let rows = client + .query("SELECT id, tag FROM rsql_test_mixed ORDER BY id", &[]) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].get::<_, String>(1), "updated"); + assert_eq!(rows[1].get::<_, i32>(0), 3); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn an_update_can_write_a_real_null_over_the_text_null() { + let mut client = connect().await; + setup( + &client, + "rsql_test_set_null", + "id int primary key, tag text", + ) + .await; + client + .batch_execute("INSERT INTO rsql_test_set_null VALUES (1, 'null')") + .await + .unwrap(); + + let statements = vec![( + "UPDATE \"public\".\"rsql_test_set_null\" SET \"tag\" = $1::text::text \ + WHERE \"id\" = $2::text::int4" + .to_string(), + vec![None, some("1")], + )]; + apply(&mut client, &statements).await.unwrap(); + + let tag: Option = client + .query_one("SELECT tag FROM rsql_test_set_null WHERE id = 1", &[]) + .await + .unwrap() + .get(0); + assert!(tag.is_none()); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn composite_keys_target_exactly_one_row() { + let mut client = connect().await; + setup( + &client, + "rsql_test_composite", + "tenant text, id int, tag text, PRIMARY KEY (tenant, id)", + ) + .await; + client + .batch_execute( + "INSERT INTO rsql_test_composite VALUES ('a', 1, 'x'), ('b', 1, 'y'), ('a', 2, 'z')", + ) + .await + .unwrap(); + + let statements = vec![( + "DELETE FROM \"public\".\"rsql_test_composite\" \ + WHERE \"tenant\" = $1::text::text AND \"id\" = $2::text::int4" + .to_string(), + vec![some("a"), some("1")], + )]; + apply(&mut client, &statements).await.unwrap(); + + let count: i64 = client + .query_one("SELECT count(*) FROM rsql_test_composite", &[]) + .await + .unwrap() + .get(0); + assert_eq!(count, 2); +} + +/// The `$n::text::` recipe has to hold for more than the obvious types. +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn the_cast_recipe_works_across_column_types() { + let mut client = connect().await; + client + .batch_execute( + "DROP TABLE IF EXISTS rsql_test_types; + DROP TYPE IF EXISTS rsql_test_mood; + CREATE TYPE rsql_test_mood AS ENUM ('ok', 'bad'); + CREATE TABLE rsql_test_types ( + id bigint primary key, + amount numeric(10,2), + when_ timestamptz, + tags text[], + doc jsonb, + mood rsql_test_mood, + raw bytea, + flag boolean, + uid uuid + );", + ) + .await + .unwrap(); + client + .batch_execute("INSERT INTO rsql_test_types (id) VALUES (1)") + .await + .unwrap(); + + let statements = vec![( + "UPDATE \"public\".\"rsql_test_types\" SET \ + \"amount\" = $1::text::numeric(10,2), \ + \"when_\" = $2::text::timestamp with time zone, \ + \"tags\" = $3::text::text[], \ + \"doc\" = $4::text::jsonb, \ + \"mood\" = $5::text::rsql_test_mood, \ + \"raw\" = $6::text::bytea, \ + \"flag\" = $7::text::boolean, \ + \"uid\" = $8::text::uuid \ + WHERE \"id\" = $9::text::int8" + .to_string(), + vec![ + some("12.34"), + some("2026-08-10 12:00:00+00"), + some("{a,b}"), + some("{\"k\": 1}"), + some("bad"), + some("\\x deadbeef".replace(' ', "").as_str()), + some("true"), + some("00000000-0000-0000-0000-000000000001"), + some("1"), + ], + )]; + apply(&mut client, &statements) + .await + .expect("every cast should be accepted"); + + let amount: String = client + .query_one("SELECT amount::text FROM rsql_test_types WHERE id = 1", &[]) + .await + .unwrap() + .get(0); + assert_eq!(amount, "12.34"); +} + +/// Values holding the wire separators must round-trip as key material. +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn control_characters_in_a_key_still_match_their_row() { + let mut client = connect().await; + setup(&client, "rsql_test_control", "id text primary key").await; + + let awkward = "a\u{1F}b\u{1E}c\u{1D}d"; + client + .execute("INSERT INTO rsql_test_control VALUES ($1)", &[&awkward]) + .await + .unwrap(); + + let statements = vec![( + "DELETE FROM \"public\".\"rsql_test_control\" WHERE \"id\" = $1::text::text".to_string(), + vec![some(awkward)], + )]; + apply(&mut client, &statements) + .await + .expect("a key with control characters should still match"); + + let count: i64 = client + .query_one("SELECT count(*) FROM rsql_test_control", &[]) + .await + .unwrap() + .get(0); + assert_eq!(count, 0); +} diff --git a/src-tauri/tests/schema_index.rs b/src-tauri/tests/schema_index.rs new file mode 100644 index 00000000..20374b99 --- /dev/null +++ b/src-tauri/tests/schema_index.rs @@ -0,0 +1,206 @@ +//! Schema index against a real PostgreSQL. +//! +//! The catalog queries are hand-written and join several system tables, so what +//! matters is not that they parse but that they report the right kind, type, +//! nullability, primary key and foreign key for real objects. +//! +//! Ignored by default; see tests/row_mutations.rs for how to run them. + +use tokio_postgres::{Client, NoTls}; + +async fn connect() -> Client { + let url = std::env::var("RSQL_TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/postgres".to_string()); + let (client, connection) = tokio_postgres::connect(&url, NoTls) + .await + .expect("connect to the test database"); + tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("connection error: {e}"); + } + }); + client +} + +const RELATION_SQL: &str = include_str!("../src/drivers/pgsql/sql/relations.sql"); +const COLUMN_SQL: &str = include_str!("../src/drivers/pgsql/sql/columns.sql"); +const FUNCTION_SQL: &str = include_str!("../src/drivers/pgsql/sql/functions.sql"); + +async fn setup(client: &Client, schema: &str) { + client + .batch_execute(&format!( + "DROP SCHEMA IF EXISTS {schema} CASCADE; + CREATE SCHEMA {schema}; + CREATE TABLE {schema}.parent (id bigint PRIMARY KEY, label text NOT NULL); + CREATE TABLE {schema}.child ( + id int PRIMARY KEY, + parent_id bigint REFERENCES {schema}.parent(id), + note text, + amount numeric(10,2) DEFAULT 0 + ); + CREATE VIEW {schema}.child_view AS SELECT id, note FROM {schema}.child; + CREATE MATERIALIZED VIEW {schema}.child_mv AS SELECT id FROM {schema}.child; + CREATE INDEX child_note_idx ON {schema}.child (note); + CREATE SEQUENCE {schema}.some_seq; + CREATE FUNCTION {schema}.add_one(n integer) RETURNS integer + LANGUAGE sql AS 'SELECT n + 1'; + COMMENT ON TABLE {schema}.parent IS 'a parent table';" + )) + .await + .expect("create the test schema"); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn relations_report_their_kind_and_exclude_indexes_and_sequences() { + let client = connect().await; + setup(&client, "rsql_idx_kinds").await; + + let rows = client + .query(RELATION_SQL, &[&"rsql_idx_kinds"]) + .await + .unwrap(); + let found: Vec<(String, String)> = rows + .iter() + .map(|r| (r.get::<_, String>(0), r.get::<_, String>(1))) + .collect(); + + assert_eq!( + found, + vec![ + ("child".to_string(), "r".to_string()), + ("child_mv".to_string(), "m".to_string()), + ("child_view".to_string(), "v".to_string()), + ("parent".to_string(), "r".to_string()), + ] + ); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn a_table_comment_is_reported() { + let client = connect().await; + setup(&client, "rsql_idx_comment").await; + + let rows = client + .query(RELATION_SQL, &[&"rsql_idx_comment"]) + .await + .unwrap(); + let parent = rows + .iter() + .find(|r| r.get::<_, String>(0) == "parent") + .unwrap(); + assert_eq!( + parent.get::<_, Option>(2).as_deref(), + Some("a parent table") + ); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn columns_report_type_nullability_default_and_keys() { + let client = connect().await; + setup(&client, "rsql_idx_columns").await; + + let rows = client + .query(COLUMN_SQL, &[&"rsql_idx_columns", &5001i64]) + .await + .unwrap(); + + let child: Vec<_> = rows + .iter() + .filter(|r| r.get::<_, String>(0) == "child") + .collect(); + + let id = child + .iter() + .find(|r| r.get::<_, String>(1) == "id") + .unwrap(); + assert_eq!(id.get::<_, String>(2), "integer"); + assert!(!id.get::<_, bool>(3), "a primary key is not nullable"); + assert!(id.get::<_, bool>(5), "id should be flagged as primary key"); + + let note = child + .iter() + .find(|r| r.get::<_, String>(1) == "note") + .unwrap(); + assert!(note.get::<_, bool>(3), "note is nullable"); + assert!(!note.get::<_, bool>(5)); + + let amount = child + .iter() + .find(|r| r.get::<_, String>(1) == "amount") + .unwrap(); + assert_eq!(amount.get::<_, String>(2), "numeric(10,2)"); + assert!( + amount.get::<_, Option>(4).is_some(), + "has a default" + ); + + let parent_id = child + .iter() + .find(|r| r.get::<_, String>(1) == "parent_id") + .unwrap(); + assert_eq!( + ( + parent_id.get::<_, Option>(6), + parent_id.get::<_, Option>(7), + parent_id.get::<_, Option>(8), + ), + ( + Some("rsql_idx_columns".to_string()), + Some("parent".to_string()), + Some("id".to_string()), + ) + ); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn view_columns_are_indexed_too() { + let client = connect().await; + setup(&client, "rsql_idx_views").await; + + let rows = client + .query(COLUMN_SQL, &[&"rsql_idx_views", &5001i64]) + .await + .unwrap(); + let view_columns: Vec = rows + .iter() + .filter(|r| r.get::<_, String>(0) == "child_view") + .map(|r| r.get::<_, String>(1)) + .collect(); + assert_eq!(view_columns, vec!["id".to_string(), "note".to_string()]); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn the_column_limit_is_what_signals_truncation() { + let client = connect().await; + setup(&client, "rsql_idx_limit").await; + + // A cap of 2 must come back with 3 rows so the caller can tell it overflowed. + let rows = client + .query(COLUMN_SQL, &[&"rsql_idx_limit", &3i64]) + .await + .unwrap(); + assert_eq!(rows.len(), 3); +} + +#[tokio::test] +#[ignore = "requires a PostgreSQL server"] +async fn functions_report_their_signature_and_return_type() { + let client = connect().await; + setup(&client, "rsql_idx_funcs").await; + + let rows = client + .query(FUNCTION_SQL, &[&"rsql_idx_funcs"]) + .await + .unwrap(); + let add_one = rows + .iter() + .find(|r| r.get::<_, String>(0) == "add_one") + .expect("the function should be indexed"); + assert_eq!(add_one.get::<_, String>(1), "n integer"); + assert_eq!(add_one.get::<_, String>(2), "integer"); +} diff --git a/src/App.tsx b/src/App.tsx index e371e156..116b2829 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,7 +24,7 @@ import { useAppStartup } from "@/hooks/use-app-startup"; import { useQueryLifecycle } from "@/hooks/use-query-lifecycle"; import { checkForUpdates } from "@/lib/updater"; import { useProjectStore } from "@/stores/project-store"; -import { useActiveTab, useTabStore } from "@/stores/tab-store"; +import { useActiveTab, useActiveTabId, useTabStore } from "@/stores/tab-store"; import { useUIStore } from "@/stores/ui-store"; import type { ProjectDetails } from "@/types"; import "@/monaco/setup"; @@ -40,8 +40,7 @@ export default function App() { const projects = useProjectStore((s) => s.projects); const saveConnection = useProjectStore((s) => s.saveConnection); const updateConnection = useProjectStore((s) => s.updateConnection); - - const selectedTabIndex = useTabStore((s) => s.selectedTabIndex); + const activeTabId = useActiveTabId(); const activeTab = useActiveTab(); const updateContent = useTabStore((s) => s.updateContent); @@ -206,7 +205,7 @@ export default function App() { > updateContent(selectedTabIndex, v)} + onChange={(v) => activeTabId && updateContent(activeTabId, v)} onExecute={() => void runQuery()} onExplain={() => void runExplain()} /> @@ -225,7 +224,7 @@ export default function App() { - useTabStore.getState().updateSplitContent(selectedTabIndex, v) + activeTabId && useTabStore.getState().updateSplitContent(activeTabId, v) } onExecute={() => void runSplitQuery()} /> @@ -270,7 +269,7 @@ export default function App() {
updateContent(selectedTabIndex, v)} + onChange={(v) => activeTabId && updateContent(activeTabId, v)} onExecute={() => void runQuery()} onExplain={() => void runExplain()} /> diff --git a/src/components/command-palette/index.tsx b/src/components/command-palette/index.tsx index 786ebb96..e23083e2 100644 --- a/src/components/command-palette/index.tsx +++ b/src/components/command-palette/index.tsx @@ -177,7 +177,8 @@ export function CommandPalette({ tabWidth: 2, keywordCase: "upper", }); - useTabStore.getState().updateContent(idx, formatted); + const tabId = useTabStore.getState().tabs[idx]?.id; + if (tabId) useTabStore.getState().updateContent(tabId, formatted); } catch { /* ignore */ } diff --git a/src/components/editor-toolbar.tsx b/src/components/editor-toolbar.tsx index 6767b26c..52ccebff 100644 --- a/src/components/editor-toolbar.tsx +++ b/src/components/editor-toolbar.tsx @@ -12,7 +12,7 @@ import { import { Input } from "@/components/ui/input"; import { useProjectStore } from "@/stores/project-store"; import { useQueryStore } from "@/stores/query-store"; -import { useActiveTab, useTabStore } from "@/stores/tab-store"; +import { tabIdAt, useActiveTab, useActiveTabId, useTabStore } from "@/stores/tab-store"; const TIMEOUT_OPTIONS = [ { label: "No limit", value: 0 }, @@ -35,6 +35,7 @@ export function EditorToolbar({ }) { const activeTab = useActiveTab(); const selectedTabIndex = useTabStore((s) => s.selectedTabIndex); + const activeTabId = useActiveTabId(); const updateContent = useTabStore((s) => s.updateContent); const toggleSplit = useTabStore((s) => s.toggleSplit); const setQueryTimeout = useTabStore((s) => s.setQueryTimeout); @@ -75,7 +76,8 @@ export function EditorToolbar({ tabWidth: 2, keywordCase: "upper", }); - updateContent(selectedTabIndex, formatted); + const tabId = tabIdAt(selectedTabIndex); + if (tabId) updateContent(tabId, formatted); } catch { // silently ignore formatting errors } @@ -112,7 +114,7 @@ export function EditorToolbar({ variant={activeTab?.isSplit ? "outline" : "ghost"} size="sm" className="h-7 gap-1.5 text-xs px-2" - onClick={() => toggleSplit(selectedTabIndex)} + onClick={() => activeTabId && toggleSplit(activeTabId)} title="Toggle split editor" > @@ -125,7 +127,7 @@ export function EditorToolbar({