diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8af1e24..b58067d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [main] pull_request: branches: [main] + schedule: + # Weekly cargo-audit sweep to catch newly-disclosed CVEs in deps that + # haven't otherwise changed. Off-peak minute, not :00/:30. + - cron: "17 6 * * 1" jobs: test: @@ -20,10 +24,89 @@ jobs: run: cargo build - name: Run tests - run: cargo test + # --lib --bins excludes tests/live_db.rs: that's a live-database + # integration test requiring a running PostgreSQL instance, covered + # by the dedicated live-db-integration job below. + run: cargo test --lib --bins - name: Clippy run: cargo clippy --all-targets -- -D warnings - name: Check formatting run: cargo fmt --all -- --check + + validate-manifest: + name: Validate .tabularium manifest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Validate against the live registry schema + run: npx --yes @tabularium/cli validate .tabularium --registry https://registry.tabularis.dev --kind driver + + markdownlint: + name: Markdown lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Run markdownlint + run: npx --yes markdownlint-cli "**/*.md" + + audit: + name: Security audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + # RUSTSEC-2026-0235: vulnerable rkyv 0.7.46, pulled in transitively + # by rust_decimal's own optional "rkyv" feature declaration in its + # Cargo.toml — we never enable that feature (only "db-tokio-postgres" + # and "serde"), and confirmed no rkyv symbols are linked into the + # release binary (`nm -D target/release/postgresql-plugin | grep + # rkyv` — no output). cargo-audit scans the full Cargo.lock graph + # regardless of which optional features are active, so this is a + # lockfile-only entry with no reachable code path in what we ship. + # Re-check this ignore whenever rust_decimal is upgraded, in case a + # newer release changes what's declared as optional. + ignore: RUSTSEC-2026-0235 + + live-db-integration: + name: Live PostgreSQL integration + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + ports: + - 54320:5432 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: testdb + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v7 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Build + run: cargo build + + - name: Run live-database integration test + env: + POSTGRES_PLUGIN_BIN: ${{ github.workspace }}/target/debug/postgresql-plugin + PGHOST: 127.0.0.1 + PGPORT: 54320 + PGUSER: postgres + PGPASSWORD: password + PGDATABASE: testdb + run: cargo test --test live_db -- --test-threads=1 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e68261..668717e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,32 @@ jobs: Copy-Item ".tabularium" $stage Compress-Archive -Path "$stage\*" -DestinationPath "postgresql-plugin-${{ matrix.platform-label }}.zip" + # linux-arm64 is cross-compiled via `cross` on an x86_64 runner — that + # binary is a foreign architecture and cannot execute here without + # QEMU emulation, so this leg is skipped rather than adding that + # complexity for one platform. + - name: Smoke test binary (unix) + if: runner.os != 'Windows' && !matrix.cross + run: | + response=$(echo '{"jsonrpc":"2.0","method":"initialize","id":1}' | ./staging/postgresql-plugin${{ matrix.binary-suffix }}) + echo "$response" + echo "$response" | jq -e 'has("error") | not' > /dev/null || { + echo "::error::Binary did not return a valid initialize response" + exit 1 + } + + - name: Smoke test binary (windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $response = '{"jsonrpc":"2.0","method":"initialize","id":1}' | & ".\staging\postgresql-plugin${{ matrix.binary-suffix }}" + Write-Output $response + $parsed = $response | ConvertFrom-Json + if ($null -ne $parsed.error) { + Write-Error "Binary did not return a valid initialize response" + exit 1 + } + - name: Stash artifact uses: actions/upload-artifact@v7 with: diff --git a/.markdownlint.json b/.markdownlint.json index 1dd8622..f0dfe2e 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,6 +1,8 @@ { "default": true, "MD013": false, + "MD024": { "siblings_only": true }, "MD033": false, - "MD041": false + "MD041": false, + "MD060": false } diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..297fdef --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,2 @@ +target/ +node_modules/ diff --git a/.tabularium b/.tabularium new file mode 100644 index 0000000..580e30f --- /dev/null +++ b/.tabularium @@ -0,0 +1,69 @@ +{ + "$schema": "https://registry.tabularis.dev/manifest.schema.json?kind=driver", + "name": "postgresql", + "version": "0.1.0", + "description": "PostgreSQL plugin driver for Tabularis (parity implementation)", + "kind": "driver", + "engine": "postgresql", + "paradigms": ["relational"], + "default_port": 5432, + "default_username": "postgres", + "executable": "postgresql-plugin", + "capabilities": { + "schemas": true, + "views": true, + "materialized_views": true, + "routines": true, + "routine_management": true, + "triggers": true, + "file_based": false, + "folder_based": false, + "connection_string": true, + "connection_string_example": "postgres://user:pass@localhost:5432/db", + "identifier_quote": "\"", + "sql_dialect": "postgres", + "alter_primary_key": true, + "alter_column": true, + "create_foreign_keys": true, + "manage_tables": true, + "supports_ssl": true, + "explain": true, + "readonly": false, + "no_connection_required": false, + "serial_type": "SERIAL", + "auto_increment_keyword": "", + "inline_pk": false + }, + "type_mappings": { + "DATETIME": "TIMESTAMP", + "JSON": "JSONB" + }, + "data_types": [ + {"name": "SMALLINT", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "INTEGER", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "BIGINT", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "SERIAL", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "BIGSERIAL", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "REAL", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "DOUBLE PRECISION", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "NUMERIC", "category": "numeric", "requires_length": false, "requires_precision": true}, + {"name": "DECIMAL", "category": "numeric", "requires_length": false, "requires_precision": true}, + {"name": "MONEY", "category": "numeric", "requires_length": false, "requires_precision": false}, + {"name": "CHAR", "category": "string", "requires_length": true, "requires_precision": false}, + {"name": "VARCHAR", "category": "string", "requires_length": true, "requires_precision": false}, + {"name": "TEXT", "category": "string", "requires_length": false, "requires_precision": false}, + {"name": "DATE", "category": "date", "requires_length": false, "requires_precision": false}, + {"name": "TIME", "category": "date", "requires_length": false, "requires_precision": false}, + {"name": "TIMESTAMP", "category": "date", "requires_length": false, "requires_precision": false}, + {"name": "TIMESTAMPTZ", "category": "date", "requires_length": false, "requires_precision": false}, + {"name": "INTERVAL", "category": "date", "requires_length": false, "requires_precision": false}, + {"name": "BOOLEAN", "category": "other", "requires_length": false, "requires_precision": false}, + {"name": "UUID", "category": "other", "requires_length": false, "requires_precision": false}, + {"name": "JSON", "category": "json", "requires_length": false, "requires_precision": false}, + {"name": "JSONB", "category": "json", "requires_length": false, "requires_precision": false}, + {"name": "BYTEA", "category": "binary", "requires_length": false, "requires_precision": false}, + {"name": "INET", "category": "other", "requires_length": false, "requires_precision": false}, + {"name": "CIDR", "category": "other", "requires_length": false, "requires_precision": false}, + {"name": "MACADDR", "category": "other", "requires_length": false, "requires_precision": false} + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d05d9a..0a98350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,116 @@ ### Added +- CI hardening — deliberately set a higher bar than the sibling plugin + repos and the org's own documented requirements (no sibling runs + `cargo audit`; only 2 of 11 Rust siblings gate on clippy/fmt at all): + - `.tabularium` manifest validation against the live registry schema via + `@tabularium/cli validate`, catching a malformed manifest automatically + (we got `name`/`id` wrong once by hand earlier this session). + - `markdownlint-cli` as an enforced CI job, not a manually-run habit. + - A release-binary smoke test: pipe a trivial `initialize` JSON-RPC + request into each freshly-built platform binary and assert a valid + (non-error) response before it ships in a zip. Skipped for `linux-arm64` + only, since that leg is cross-compiled and the binary can't execute on + the x86_64 build runner without QEMU emulation. + - `cargo audit` (via `rustsec/audit-check`) for supply-chain + vulnerabilities, on every push/PR and a weekly schedule (catches CVEs + disclosed after merge against unchanged dependencies). + - `tests/live_db.rs`: a self-contained live-`postgres:16`-container + integration test (first top-level `tests/` dir in this repo — existing + tests are all pure unit tests via the `.rules/rust.md` #4/#5 + sibling-file convention). Covers connect, a basic query, an insert, and + the `startup_script`/`connection_string` handlers found completely + uncovered during the security-audit pass — closes the actual biggest + gap in this repo's CI: nothing previously verified the binary against + a real database automatically. Deliberately NOT the cross-repo 82-test + parity suite (that stays a manual/periodic check against `tabularis`, + per the "Repo Extraction" open question in + `docs/planning/02-phase-1-plugin-build.md`). + - Two further hardening ideas — `dependency-review-action` on PRs and + SBOM generation via `cargo-cyclonedx` — were considered and + deliberately deferred rather than implemented now; see + `docs/planning/ci-hardening-deferred.md` for the rationale. + +### Fixed + +- Three of the new CI jobs above failed on their first real run and were + fixed: + - `Test` job: the existing `cargo test` (no target filter) tried to run + `tests/live_db.rs` too, which panics immediately without a running + PostgreSQL instance. Scoped to `cargo test --lib --bins`, leaving the + live-DB test to its own dedicated `live-db-integration` job. + - `Markdown lint` job: `docs/planning/.markdownlint.json`'s scoped + override (`MD024`/`MD060`) only applied when markdownlint was invoked + from within that directory — the CI step's root-level `**/*.md` glob + never picked it up. Merged the scoped overrides into the single root + `.markdownlint.json` instead of maintaining two config files. Also + added `.markdownlintignore` (`target/`) since the glob was + incidentally linting vendored third-party docs copied into build + output by a dependency's build script. + - `Security audit` job: `cargo audit` correctly found a real advisory, + RUSTSEC-2026-0235 (vulnerable `rkyv` 0.7.46) — but it's pulled in only + because `rust_decimal` lists it as an optional dependency behind a + feature (`rkyv`) we never enable; confirmed no `rkyv` symbols are + linked into the release binary. Added a documented `ignore:` entry for + that specific advisory ID, since `cargo audit` scans the full + `Cargo.lock` graph regardless of which optional features are active. + +- `main.rs` rewritten to a worker-pool architecture (4 workers + a single + writer task + a dedicated pool-cleanup task, coordinated via a + `tokio::sync::watch` shutdown signal on stdin EOF), matching the + sqlserver/dynamodb sibling plugins. A slow query on one connection no + longer blocks a concurrent `ping` or metadata call on another; the host + already tolerates out-of-order responses (it correlates by JSON-RPC `id` + via a `HashMap`, not arrival order), so this required no protocol change. +- Periodic idle-pool eviction: every 10 minutes, `client::cleanup_idle_pools()` + drops cached connection pools that currently have no checked-out + connections, so a long-running session that has connected to many + distinct targets doesn't pin idle TCP connections and pool memory for + the plugin's lifetime. Matches the sqlserver/dynamodb sibling plugins' + pattern exactly (`pool.status().size > pool.status().available` as the + keep predicate). Found missing during the same security-audit pass that + flagged "pool cleanup on shutdown" — investigation showed the host never + sends the plugin a `shutdown` RPC call at all (it kills the process + outright), so that specific checklist wording described something + unreachable; comparing sibling plugins surfaced this as the real, + exercisable gap instead. Added test-first (TDD): a unit test asserting + an idle pool gets evicted, written and confirmed RED (`cleanup_idle_pools` + didn't exist) before the function was implemented to GREEN. +- `save_blob_to_file` now validates `file_path` (empty, existing-directory, + or missing-parent-directory) before spending a DB round-trip on a write + that would fail anyway — a clearly attributed `-32602` error instead of + a bare OS error number surfacing after the query already ran. Not a + security boundary (the path comes from the frontend's native save + dialog), just a fast-fail. The builtin driver's identical gap is + untouched; this fix is plugin-only. Found during the security-audit pass. +- `startup_script` support: SQL supplied on the connection now runs on every + new pooled connection via a `deadpool-postgres` `post_create` hook, with a + preflight validation pass so a broken script fails fast with a clearly + attributed `Startup script failed: ...` error instead of a misleading + connection error. Matches the builtin driver's + `run_postgres_startup_script` behavior (`src-tauri/src/pool_manager.rs`). + Found missing during a security-audit pass — no parity test exercises + this field, so the 82/82 parity suite didn't catch the gap. +- `connection_string` support: when present, it's parsed via + `tokio_postgres::Config::from_str` and takes precedence over the discrete + host/port/database/username/password fields, matching the README's + documented behavior. Previously the field was parsed into + `ConnectionParams` but silently never consumed by `build_pool()`. Also + found during the security-audit pass. +- Plugin source (`Cargo.toml`, `Cargo.lock`, `.tabularium`, `src/`) imported + from `TabularisDB/tabularis`'s `plugins/postgres-plugin/` at commit + `ad765f3a` (82/82 parity tests green per that commit). This is a parallel + copy — the in-tree source has not been removed, and the two copies are + kept in sync manually pending a later decision to deprecate the in-tree + copy. +- `docs/planning/`: the 8 design documents that shaped this migration + (phase docs, both migration-plan variants, and the feature-gap audit + feeding Phase 2), copied from `tabularis`'s `.github/planning/`. +- `src/lib.rs` and `src/bin/test_plugin.rs`: extracted the plugin's module + tree into a library crate so the justfile's `repl` recipe (a local + JSON-RPC REPL) has a real binary to run, matching the oracle/dynamodb + sibling plugins' structure. - Repo scaffolding: `LICENSE` (Apache-2.0), `.gitignore`, `.editorconfig`, `CODEOWNERS`, `rust-toolchain.toml` (pinning `rustfmt`/`clippy`), `.github/dependabot.yml`, `justfile` (build/test/lint/fmt/dev-install/ @@ -15,6 +125,16 @@ - `README.md` and `CLAUDE.md` describing the plugin's purpose, architecture, and current migration status. -The plugin source itself (`Cargo.toml`, `src/`, `.tabularium`) has not been -migrated from `TabularisDB/tabularis`'s `plugins/postgres-plugin/` yet — this -release holds only the repo-level basics staged ahead of that migration. +### Changed + +- `.tabularium`'s `name` field changed from `postgres-plugin` to + `postgresql` (and the redundant `id` field dropped) to match this repo's + own install-path/executable naming and the sibling-plugin convention of a + bare engine-name slug. This field is a permanent registry slug once + published, so it was fixed before any release. +- README's connection config table: `ssl_ca`/`ssl_cert`/`ssl_key` were + documented as a single group ("If using `verify-ca`/`verify-full`"), but + only `ssl_ca` (custom CA pinning) is actually implemented — matches the + builtin PostgreSQL driver, which also has no client-certificate support + (unlike its MySQL driver). Documentation corrected to describe only what + the plugin (and builtin) actually do. diff --git a/CLAUDE.md b/CLAUDE.md index 3f5b8b5..71fb529 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,16 +2,24 @@ ## Status -This repo currently holds only repo-level scaffolding (license, CI/release -workflow shape, contributor docs). The plugin source itself -(`Cargo.toml`, `src/`, `.tabularium`) has not been migrated yet — it still -lives at `TabularisDB/tabularis`'s `plugins/postgres-plugin/`. Do not assume -the CI workflow passes or that a `cargo build` will succeed until that -migration lands. +This repo is intended to become the **primary home** for the PostgreSQL +plugin, pending sign-off. Once that happens, `TabularisDB/tabularis` PR #577 +pivots from building the plugin in-tree to removing the built-in driver — +see `docs/planning/04-phase-3-deprecate-builtin.md`. For now, leave +`tabularis`'s `plugins/postgres-plugin/` untouched; this repo only receives +additions, nothing is removed from there. -## Build & Test +The plugin source has landed here as a copy of the in-tree implementation +at commit `ad765f3a` (Phase 1 byte-for-byte parity proven: 82/82 parity, +72/72 baseline, 26/26 golden tests, 72 plugin unit tests — see +`docs/planning/02-phase-1-plugin-build.md` and +[`tabularis` PR #577](https://github.com/TabularisDB/tabularis/pull/577)). +Sign-off to promote this repo to primary is still pending several items +from that PR's own checklist: cross-platform build verification (only +macOS ARM confirmed so far), the 24-item manual smoke test, a security +audit pass, and a frontend regression check (`pnpm test` in `tabularis`). -Once the source has been migrated: +## Build & Test ```bash cargo build # Debug build @@ -21,6 +29,35 @@ cargo clippy --all-targets -- -D warnings # Lint cargo fmt --all # Format ``` +## Cross-Repo Parity Check + +This repo has no live-database parity suite of its own — the 82-test +byte-for-byte comparison against the built-in driver lives in +`tabularis`'s `src-tauri/tests/postgres_integration/parity*.rs` and is not +duplicated here (see `docs/planning/02-phase-1-plugin-build.md`'s "Repo +Extraction" section for the open question on where those tests should live +long-term). That suite resolves the plugin binary purely through the +`POSTGRES_PLUGIN_BIN` env var, so it can validate this repo's binary with +zero changes on the `tabularis` side. Re-run this any time this repo's +source diverges from the in-tree copy, to catch parity drift immediately: + +```bash +# 1. Build the release binary from this repo +cargo build --release +STANDALONE_BIN="$PWD/target/release/postgresql-plugin" + +# 2. Point tabularis's existing (unmodified) parity suite at it +cd /path/to/tabularis +bash tests/fixtures/seed_postgres.sh +POSTGRES_PLUGIN_BIN="$STANDALONE_BIN" RUST_TEST_THREADS=1 \ + cargo test --manifest-path src-tauri/Cargo.toml --test postgres_integration parity -- --include-ignored +``` + +Expected result: the same 82/82 that the in-tree binary produces. Any test +going RED here means the extraction changed behavior and must be fixed +before merging — the same red→green discipline used throughout the +migration, now applied across the repo boundary. + ## Architecture Rules - All RPC handlers are async and return `serde_json::Value`. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5a15b1e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2007 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.17", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid 0.10.2", + "crypto-common", + "ctutils", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.10.2", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "array-init", + "bytes", + "chrono", + "fallible-iterator", + "postgres-protocol", + "serde_core", + "serde_json", + "uuid", +] + +[[package]] +name = "postgresql-plugin" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64", + "chrono", + "deadpool-postgres", + "infer", + "log", + "rust_decimal", + "rustls", + "rustls-pemfile", + "rustls-platform-verifier", + "serde", + "serde_json", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "uuid", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "postgres-types", + "rand 0.8.7", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-postgres-rustls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" +dependencies = [ + "const-oid 0.9.6", + "ring", + "rustls", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "tls_codec", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..af9d155 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "postgresql-plugin" +version = "0.1.0" +edition = "2021" +description = "PostgreSQL plugin driver for Tabularis" +license = "Apache-2.0" +publish = false + +[[bin]] +name = "postgresql-plugin" +path = "src/main.rs" + +[[bin]] +name = "test_plugin" +path = "src/bin/test_plugin.rs" + +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "time"] } +tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1", "array-impls"] } +deadpool-postgres = "0.14" +tokio-postgres-rustls = "0.13" +rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } +rustls-platform-verifier = "0.6" +rustls-pemfile = "2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.20", features = ["v4", "serde"] } +rust_decimal = { version = "1.36", features = ["db-tokio-postgres", "serde"] } +async-trait = "0.1" +log = "0.4" +base64 = "0.22.1" +infer = "0.16" + +[profile.release] +lto = true +codegen-units = 1 +strip = "symbols" diff --git a/README.md b/README.md index 78cb4d9..aa5f86a 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,16 @@ directly into the Tabularis application. It is byte-for-byte behaviorally identical to that built-in driver, proven by an 82-test parity suite that runs both drivers against the same live database and compares every response. -> ⚠️ **Work in progress** — this repo currently holds only the repo-level -> basics (license, CI/release workflow shape, contributor docs). The actual -> plugin source (`Cargo.toml`, `src/`, `.tabularium` manifest) has not been -> migrated yet — it still lives at -> [`TabularisDB/tabularis` `plugins/postgres-plugin/`](https://github.com/TabularisDB/tabularis/tree/main/plugins/postgres-plugin) -> pending the CP-4 extraction (see -> [the migration plan](https://github.com/TabularisDB/tabularis/blob/main/.github/planning/postgres-plugin/02-phase-1-plugin-build.md#repo-extraction--timing-and-open-question)). -> The CI workflow in this repo will not pass until that source lands. +> ⚠️ **Work in progress** — this repo is intended to become the **primary +> home** for the PostgreSQL plugin, pending sign-off. The plugin source has +> landed here (Phase 1 byte-for-byte parity proven: 82/82 parity, 72/72 +> baseline, 26/26 golden tests — see +> [the migration plan](./docs/planning/02-phase-1-plugin-build.md)), but +> the [`TabularisDB/tabularis` `plugins/postgres-plugin/`](https://github.com/TabularisDB/tabularis/tree/main/plugins/postgres-plugin) +> copy is left untouched for now. Once this repo is signed off as the beta +> release source of truth, [`tabularis` PR #577](https://github.com/TabularisDB/tabularis/pull/577) +> will pivot from building the plugin in-tree to removing the built-in +> driver. ## Table of Contents @@ -81,8 +83,9 @@ both drivers against the same live database and compares every response. | `username` | Database user | Yes (unless using `connection_string`) | | `password` | Database password | If required by the server | | `ssl_mode` | `disable`, `require`, `verify-ca`, or `verify-full` | No | -| `ssl_ca` / `ssl_cert` / `ssl_key` | Paths to SSL certificate material | If using `verify-ca`/`verify-full` | +| `ssl_ca` | Path to a custom CA bundle PEM file, used to validate the server's certificate under `verify-ca`/`verify-full` instead of the system trust store | No | | `connection_string` | Full `postgres://user:pass@host:port/db` URL, as an alternative to the discrete fields above | No | +| `startup_script` | SQL run on every new pooled connection (e.g. `SET search_path = ...`) before it's handed to a query | No | ## Supported PostgreSQL Data Types diff --git a/docs/planning/00-prerequisites.md b/docs/planning/00-prerequisites.md new file mode 100644 index 0000000..511e9d3 --- /dev/null +++ b/docs/planning/00-prerequisites.md @@ -0,0 +1,201 @@ +# Prerequisites — Tabularis Core PRs + +**Must be merged before Phase 0 testing or Phase 1 building can begin.** + +## Overview + +Three changes to the Tabularis host's `RpcDriver` adapter are required to enable +full feature parity for any PostgreSQL plugin. Without these, certain tests in +Phase 0 will always fail when pointed at a plugin driver, making parity +verification impossible. + +These are small, non-breaking additions to existing code. They follow the same +patterns already used for other forwarded methods (triggers, views, etc.). + +--- + +## PR 1: Forward BLOB Methods + +### What + +Extend `RpcDriver` in `src-tauri/src/plugins/driver.rs` to forward: + +- `save_blob_to_file(params, table, column, pk_column, pk_value, file_path, schema)` +- `fetch_blob_as_data_url(params, table, column, pk_column, pk_value, schema)` + +### Current Behavior + +These methods inherit the trait default which returns: + +```rust +Err("BLOB file export not supported by this driver".into()) +``` + +### Proposed Implementation + +```rust +async fn save_blob_to_file(&self, params: &ConnectionParams, table: &str, + column: &str, pk_column: &str, pk_value: &str, + file_path: &str, schema: Option<&str>) -> Result<(), String> +{ + // Plugin returns base64-encoded blob data + let res = self.process.call("save_blob_to_file", json!({ + "params": params, "table": table, "column": column, + "pk_column": pk_column, "pk_value": pk_value, + "file_path": file_path, "schema": schema + })).await?; + Ok(()) // Plugin writes to file_path directly (local process) +} + +async fn fetch_blob_as_data_url(&self, params: &ConnectionParams, table: &str, + column: &str, pk_column: &str, pk_value: &str, + schema: Option<&str>) -> Result +{ + let res = self.process.call("fetch_blob_as_data_url", json!({ + "params": params, "table": table, "column": column, + "pk_column": pk_column, "pk_value": pk_value, "schema": schema + })).await?; + serde_json::from_value(res).map_err(|e| e.to_string()) +} +``` + +### Testing + +- Verify existing BLOB tests pass with built-in driver (unchanged behavior) +- Verify a plugin returning base64 data works end-to-end + +### Risk + +None — purely additive. Existing plugins that don't implement these methods +will return `-32601` and the host falls back to the existing "not supported" error. + +--- + +## PR 2: Forward Materialized View Methods + +### What + +Extend `RpcDriver` to forward: + +- `get_materialized_views(params, schema)` +- `get_materialized_view_columns(params, view_name, schema)` +- `get_materialized_view_definition(params, view_name, schema)` +- `refresh_materialized_view(params, view_name, schema)` + +### Current Behavior + +These inherit defaults returning `Ok(vec![])` or +`Err("Materialized views are not supported...")`. + +### Proposed Implementation + +Same pattern as `get_views`, `get_triggers`, etc. — straightforward JSON-RPC +forwarding with `serde_json::from_value` deserialization. + +### Risk + +None — same pattern as existing forwarded methods. + +--- + +## PR 3: Resolve `map_inferred_type` from Plugin Manifest + +### What + +The `map_inferred_type` method is **synchronous** (`fn`, not `async fn`) so it +cannot issue an RPC call. Currently returns the input unchanged for plugin drivers. + +The built-in PG driver maps: `DATETIME` → `TIMESTAMP`, `JSON` → `JSONB`. + +### Proposed Solution + +Add an optional `type_mappings` field to `PluginManifest`: + +```rust +// In driver_trait.rs, add to PluginManifest: +pub type_mappings: Option>, +``` + +The `RpcDriver` stores these at construction time and applies them in +`map_inferred_type`: + +```rust +fn map_inferred_type(&self, kind: &str) -> String { + if let Some(mappings) = &self.manifest.type_mappings { + if let Some(mapped) = mappings.get(&kind.to_uppercase()) { + return mapped.clone(); + } + } + kind.to_string() +} +``` + +Plugin manifest declares: + +```json +{ + "type_mappings": { + "DATETIME": "TIMESTAMP", + "JSON": "JSONB" + } +} +``` + +### Risk + +Low — new optional field. Existing plugins without it behave unchanged. + +--- + +## Approach + +### Option A: One Combined PR + +Submit all three changes in a single PR titled: +"feat(plugins): extend RpcDriver for BLOB, materialized views, and type mappings" + +**Pros:** One review cycle, atomic merge, single CI run. +**Cons:** Larger diff, harder to review. + +### Option B: Three Separate PRs + +Submit sequentially, each small and focused. + +**Pros:** Easy to review, bisectable, can merge independently. +**Cons:** Three review cycles. + +### Recommendation + +**Option A** — These are all small, non-breaking additions with zero risk of +conflict. A single PR with clear commit separation (one commit per feature) +gives the reviewer full context of why these are needed (PostgreSQL plugin +migration) without the overhead of three separate review cycles. + +--- + +## Checkpoint: CP-1 + +**When:** After the prerequisites PR is merged into `main`. + +**Verify:** + +- [ ] `cargo test` passes (no regressions in existing drivers) +- [ ] Existing plugin drivers (DuckDB, D1) still work (methods return -32601 gracefully) +- [ ] No changes to MySQL or SQLite drivers +- [ ] New trait fields are `Option` / backward-compatible + +**Communicate to team:** + +- Prerequisites are in place +- Phase 0 can begin (test suite development) +- No user-facing changes yet + +--- + +## Definition of Done + +- [ ] PR merged to `main` +- [ ] CI green +- [ ] No existing test regressions +- [ ] CHANGELOG entry added (under "Plugin System" section) +- [ ] Core team acknowledged at CP-1 diff --git a/docs/planning/01-phase-0-baseline-tests.md b/docs/planning/01-phase-0-baseline-tests.md new file mode 100644 index 0000000..fd42086 --- /dev/null +++ b/docs/planning/01-phase-0-baseline-tests.md @@ -0,0 +1,471 @@ +# Phase 0 — Baseline Test Suite + +**Goal:** Create the comprehensive test infrastructure that proves the built-in +PostgreSQL driver's behavior, establishing the specification that the plugin must +match. This is the foundation of our zero-regression guarantee. + +**Mantra:** _If it isn't tested, it doesn't exist. If it passes on both drivers, +they are equivalent by construction._ + +--- + +## Why This Phase Exists + +| Today's Coverage | What's Missing | +| ---------------- | -------------- | +| 162 unit tests for value extraction | Zero tests for 36 public API methods | +| 96 unit tests for parameter binding | Zero integration tests running in CI | +| 4 integration tests (all `#[ignore]`) | Zero golden file / snapshot tests | +| No parity harness | No multi-database test scenarios | + +Without Phase 0, we have no way to prove the plugin matches the built-in driver. +We'd be shipping on trust, not evidence. + +--- + +## Deliverables (in order) + +### 0.1: CI PostgreSQL Service + +**What:** Add a PostgreSQL 16 service container to the GitHub Actions CI workflow. + +**Why:** Integration tests must run automatically on every PR. Today they're all +`#[ignore]` because no PG instance exists in CI. + +**How:** + +```yaml +# .github/workflows/ci.yml — add to the rust test job +services: + postgres: + image: postgres:16 + ports: + - 54320:5432 + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: tabularis_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 +``` + +**Also:** + +- Create a second test database: `tabularis_test_secondary` +- Add CI step that runs the seed script before tests +- Set environment variable `TABULARIS_TEST_PG=1` so integration tests detect PG is available +- Remove `#[ignore]` from existing 4 integration tests + +**Verify:** CI passes with the 4 existing integration tests (currently un-run). + +--- + +### 0.2: Test Database Seed Script + +**What:** A repeatable SQL script that creates all tables, types, views, +functions, triggers, and indexes needed by the test suite. + +**Why:** Tests need a known schema state. The seed script is the single source of +truth for what exists in the test database. + +**File:** `tests/fixtures/postgres_seed.sql` + +**Contents must include:** + +```sql +-- Core type coverage table +CREATE TABLE test_schema.all_types ( + id SERIAL PRIMARY KEY, + col_text TEXT, col_varchar VARCHAR(255), + col_int INTEGER, col_bigint BIGINT, + col_float REAL, col_double DOUBLE PRECISION, + col_numeric NUMERIC(10,2), col_bool BOOLEAN, + col_date DATE, col_time TIME, + col_timestamp TIMESTAMP, col_timestamptz TIMESTAMPTZ, + col_uuid UUID DEFAULT gen_random_uuid(), + col_json JSON, col_jsonb JSONB, + col_bytea BYTEA, col_inet INET, col_cidr CIDR, + col_macaddr MACADDR, + col_int_array INTEGER[], col_text_array TEXT[], + col_int4range INT4RANGE, col_tsrange TSRANGE +); + +-- Enum type +CREATE TYPE test_schema.mood AS ENUM ('happy', 'sad', 'neutral'); +CREATE TABLE test_schema.with_enum ( + id SERIAL PRIMARY KEY, + current_mood test_schema.mood +); + +-- Foreign key relationships (single and composite PK) +CREATE TABLE test_schema.orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES test_schema.all_types(id) ON DELETE CASCADE, + amount NUMERIC(10,2) +); +CREATE TABLE test_schema.order_items ( + order_id INTEGER, item_no INTEGER, + product TEXT, + PRIMARY KEY (order_id, item_no), + FOREIGN KEY (order_id) REFERENCES test_schema.orders(id) +); + +-- Indexes (btree, unique, partial, composite) +CREATE INDEX idx_all_types_text ON test_schema.all_types (col_text); +CREATE UNIQUE INDEX idx_all_types_uuid ON test_schema.all_types (col_uuid); +CREATE INDEX idx_orders_amount_positive ON test_schema.orders (amount) + WHERE amount > 0; + +-- Views +CREATE VIEW test_schema.active_users AS + SELECT id, col_text AS name FROM test_schema.all_types WHERE col_bool = true; + +-- Materialized views +CREATE MATERIALIZED VIEW test_schema.user_stats AS + SELECT COUNT(*) as total FROM test_schema.all_types; + +-- Functions and procedures +CREATE FUNCTION test_schema.add_numbers(a INTEGER, b INTEGER) + RETURNS INTEGER LANGUAGE SQL AS $$ SELECT a + b $$; + +CREATE FUNCTION test_schema.get_user(p_id INTEGER) + RETURNS TABLE(id INTEGER, name TEXT) LANGUAGE SQL AS $$ + SELECT id, col_text FROM test_schema.all_types WHERE id = p_id +$$; + +-- Overloaded function (same name, different args) +CREATE FUNCTION test_schema.add_numbers(a INTEGER, b INTEGER, c INTEGER) + RETURNS INTEGER LANGUAGE SQL AS $$ SELECT a + b + c $$; + +CREATE PROCEDURE test_schema.reset_data() LANGUAGE SQL AS $$ + DELETE FROM test_schema.order_items; + DELETE FROM test_schema.orders; +$$; + +-- Triggers +CREATE FUNCTION test_schema.audit_trigger_fn() RETURNS trigger + LANGUAGE plpgsql AS $$ +BEGIN + RAISE NOTICE 'Row modified in %', TG_TABLE_NAME; + RETURN NEW; +END $$; + +CREATE TRIGGER trg_audit AFTER UPDATE ON test_schema.all_types + FOR EACH ROW EXECUTE FUNCTION test_schema.audit_trigger_fn(); + +-- Cross-schema FK (for ref_schema testing) +CREATE SCHEMA IF NOT EXISTS other_schema; +CREATE TABLE other_schema.lookup ( + code TEXT PRIMARY KEY, label TEXT +); +CREATE TABLE test_schema.with_cross_schema_fk ( + id SERIAL PRIMARY KEY, + lookup_code TEXT REFERENCES other_schema.lookup(code) +); + +-- SECONDARY DATABASE (for multi-database testing) +-- Must be created via separate connection to maintenance DB +-- CREATE DATABASE tabularis_test_secondary; +-- Then connect to it and run: +-- CREATE SCHEMA secondary_schema; +-- CREATE TABLE secondary_schema.remote_data (id SERIAL PRIMARY KEY, value TEXT); +``` + +**Seed runner script:** `tests/fixtures/run_seed.sh` + +```bash +#!/bin/bash +PGPASSWORD=test psql -h localhost -p 54320 -U postgres -d tabularis_test \ + -f tests/fixtures/postgres_seed.sql + +# Create secondary database +PGPASSWORD=test psql -h localhost -p 54320 -U postgres -c \ + "SELECT 'exists' FROM pg_database WHERE datname='tabularis_test_secondary'" \ + | grep -q exists || \ +PGPASSWORD=test createdb -h localhost -p 54320 -U postgres tabularis_test_secondary + +PGPASSWORD=test psql -h localhost -p 54320 -U postgres -d tabularis_test_secondary -c " + CREATE SCHEMA IF NOT EXISTS secondary_schema; + CREATE TABLE IF NOT EXISTS secondary_schema.remote_data ( + id SERIAL PRIMARY KEY, value TEXT + ); + INSERT INTO secondary_schema.remote_data (value) + SELECT 'row_' || g FROM generate_series(1, 5) g + ON CONFLICT DO NOTHING; +" +``` + +--- + +### 0.3: Parity Test Harness + +**What:** A test infrastructure that runs identical assertions against two +different driver implementations. + +**Why:** This is how we mechanically prove the plugin matches the built-in driver. +In Phase 0, only the built-in driver fills it. In Phase 1, the plugin is added. + +**Design:** + +```rust +// tests/parity/harness.rs + +use std::fmt::Debug; + +pub enum DriverTarget { + Builtin, // Uses the built-in postgres driver via Tauri commands + Plugin(String), // Uses the plugin driver (id = "postgres-plugin") +} + +pub struct ParityHarness { + targets: Vec, + pg_host: String, + pg_port: u16, + pg_user: String, + pg_password: String, + pg_database: String, +} + +impl ParityHarness { + /// Run a test function against all configured targets and assert identical results + pub async fn assert_parity(&self, method_name: &str, test_fn: F) + where + T: PartialEq + Debug + serde::Serialize, + F: Fn(DriverTarget) -> Fut, + Fut: std::future::Future>, + { + let results: Vec<_> = /* run test_fn against each target */; + // Compare all results pairwise + for window in results.windows(2) { + assert_eq!(window[0], window[1], + "Parity failure in '{}': targets returned different results", + method_name); + } + } +} +``` + +**Phase 0 usage:** Only `DriverTarget::Builtin` is registered. Tests pass +trivially (one result, nothing to compare). But the harness is ready for Phase 1 +to add `DriverTarget::Plugin`. + +**Phase 1 usage:** Both targets registered. Tests now compare outputs. + +--- + +### 0.4: Golden File Capture + +**What:** Run every public method against the seeded test database and save the +output as JSON files. These become the parity contract. + +**Why:** Golden files catch subtle differences that `assert_eq` on structs might +miss (field ordering, null vs absent, number precision). + +**Directory:** `tests/parity/golden/` + +**How to capture:** + +```rust +// tests/parity/capture_golden.rs (run once to generate golden files) +#[tokio::test] +#[ignore] // Only run manually to regenerate golden files +async fn capture_golden_files() { + let harness = ParityHarness::builtin_only(); + + let tables = harness.get_tables("test_schema").await; + write_golden("get_tables.json", &tables); + + let columns = harness.get_columns("all_types", "test_schema").await; + write_golden("get_columns_all_types.json", &columns); + + // ... for every method +} +``` + +**Golden files to capture:** + +```text +src-tauri/tests/postgres_integration/golden/ +├── get_databases.json +├── get_schemas.json +├── get_tables.json +├── get_columns_all_types.json +├── get_columns_with_enum.json +├── get_indexes_all_types.json +├── get_foreign_keys_orders.json +├── get_foreign_keys_cross_schema.json +├── get_views.json +├── get_view_definition_active_users.json +├── get_view_columns_active_users.json +├── get_materialized_views.json +├── get_mv_definition.json ← captures known regclass error +├── get_mv_columns.json +├── get_routines.json +├── get_routine_parameters_add_numbers.json +├── get_routine_definition_add_numbers.json +├── get_triggers.json +├── get_trigger_definition_audit.json +├── execute_query_all_types.json +├── execute_query_with_pagination.json +├── explain_simple.json +├── explain_analyze.json +├── count_query.json +└── multi_db/ + ├── get_schemas_secondary.json + └── get_tables_secondary.json +``` + +**Note:** DDL golden files (`ddl/*.sql`) were removed from scope. DDL generation +produces SQL statements whose correctness depends on dialect and formatting — not +on byte-exact reproducibility. The `ddl_generation.rs` tests validate DDL output +structurally (contains correct keywords, types, constraints) which is the right +parity approach. Exact-match golden files for DDL would create brittle tests that +break on whitespace changes without catching real bugs. + +Similarly, `multi_db/get_databases.json` was dropped because `get_databases` is +server-wide (returns the same result regardless of which database you connect to) +— it's already captured at the top level. + +--- + +### 0.5: Integration Test Suite (55+ tests) + +**What:** Dedicated integration tests for every public method, organized by domain. + +**Structure:** + +```text +src-tauri/tests/postgres/ +├── mod.rs # Shared test setup, connection helpers +├── schema_discovery.rs # 4 tests +├── column_metadata.rs # 6 tests +├── foreign_keys.rs # 4 tests +├── indexes.rs # 4 tests +├── views.rs # 6 tests +├── materialized_views.rs # 4 tests +├── routines.rs # 6 tests +├── triggers.rs # 4 tests +├── crud.rs # 9 tests +├── ddl_generation.rs # 7 tests +├── explain.rs # 3 tests +├── blob.rs # 3 tests +├── query_execution.rs # 6 tests +└── multi_database.rs # 7 tests + ───────── + Total: 73 tests +``` + +(Note: 55 was a minimum estimate — full coverage is likely 70+.) + +**Each test follows this structure:** + +```rust +#[tokio::test] +async fn test_get_columns_all_types() { + let harness = test_harness().await; + + let columns = harness.get_columns("all_types", Some("test_schema")).await + .expect("get_columns should succeed"); + + // Structural assertions + assert_eq!(columns.len(), 24, "all_types has 24 columns"); + + // Specific column assertions + let id_col = columns.iter().find(|c| c.name == "id").unwrap(); + assert!(id_col.is_pk); + assert!(id_col.is_auto_increment); + assert_eq!(id_col.data_type, "integer"); + + let uuid_col = columns.iter().find(|c| c.name == "col_uuid").unwrap(); + assert_eq!(uuid_col.data_type, "uuid"); + assert!(!uuid_col.is_nullable); // has DEFAULT but NOT NULL isn't set... verify + + // Golden file comparison + harness.assert_matches_golden("get_columns_all_types.json", &columns); +} +``` + +--- + +### 0.6: Un-ignore Existing Tests + +**What:** Remove `#[ignore]` from the 4 existing integration tests and verify +they pass in CI with the new PG service. + +**Tests:** + +- `test_postgres_integration_flow` +- `test_postgres_batch_preserves_temp_table_and_transaction` +- `test_postgres_affected_rows_reported_correctly` +- `test_postgres_foreign_keys_via_pg_catalog` + +--- + +## Implementation Order + +```text +Week 1: + 0.1 — CI PG service (unblocks everything) + 0.2 — Seed script (needed by all tests) + 0.6 — Un-ignore existing tests (quick win, validates CI setup) + +Week 2: + 0.3 — Parity harness infrastructure + 0.5 — Write integration tests (start with schema_discovery, column_metadata) + +Week 3: + 0.5 — Continue integration tests (crud, ddl, query_execution, multi_database) + 0.4 — Capture golden files (can only run after tests exist) + +Week 4: + 0.5 — Remaining integration tests (views, MVs, routines, triggers, blob, explain) + Final verification — all tests green against built-in driver +``` + +--- + +## Checkpoint: CP-2 + +**When:** All Phase 0 deliverables complete. + +**Verify:** + +- [x] CI runs PG service and all integration tests pass +- [x] 70+ integration tests exist and are GREEN against built-in driver (102 tests) +- [x] Golden files captured for every public method (26 files) +- [x] Parity harness ready to accept a second driver target +- [x] Seed script is idempotent (can run multiple times without error) +- [x] Multi-database tests pass (secondary database accessible) +- [x] CI total time < 5 minutes (~10s test execution + ~6m build) + +**Communicate to team:** + +- Baseline is established — we have objective proof of how the built-in driver behaves +- Phase 1 can begin — the plugin will be built to pass these exact tests +- No user-facing changes — this is all internal test infrastructure +- Share the test count as the "parity contract" the plugin must satisfy + +--- + +## Ship / Release Gate + +**Phase 0 does NOT produce a shippable release.** It is purely internal +infrastructure. However, the CI improvements (PG service, un-ignored tests) DO +improve quality for ALL future PRs touching the PostgreSQL driver. This is value +delivered to the team even if the plugin migration never proceeds. + +--- + +## Definition of Done + +- [x] CI workflow includes PostgreSQL 16 service +- [x] Seed script exists and is run automatically in CI +- [x] 70+ integration tests written and passing (102 total) +- [x] Golden files captured and committed to repo (26 files) +- [x] Parity harness infrastructure committed +- [x] Existing 4 integration tests un-ignored and passing +- [x] Multi-database seed (secondary DB) working +- [x] All tests pass deterministically (sequential execution, 2 consecutive green runs) +- [ ] CP-2 sync completed with core team diff --git a/docs/planning/02-phase-1-plugin-build.md b/docs/planning/02-phase-1-plugin-build.md new file mode 100644 index 0000000..6da2970 --- /dev/null +++ b/docs/planning/02-phase-1-plugin-build.md @@ -0,0 +1,515 @@ +# Phase 1 — Plugin Build (TDD) + +**Goal:** Build the `postgres-plugin` executable that passes all 80 parity tests, +proving byte-for-byte parity with the built-in driver. Implementation follows +strict TDD: tests exist first (written RED), code is written to make them pass. + +**Mantra:** _80/80 green or it's not done. No exceptions, no "close enough."_ + +--- + +## Test Architecture + +The test suite has three layers, each serving a distinct purpose: + +| Layer | Count | What it proves | Runs against | +|-------|-------|----------------|--------------| +| **Parity tests** | 80 | Plugin output == builtin output (byte-perfect JSON comparison) | Both drivers via `ParityHarness` | +| **Baseline tests** | 72 | Builtin driver behavior hasn't regressed | Builtin only (direct `postgres::*` calls) | +| **Golden tests** | 26 | Builtin output matches committed snapshots (drift detection) | Builtin only | + +### Why 80 parity tests (not 102) + +The "102 tests" figure included all three layers. Parity tests only cover the +first layer because: + +1. **Golden tests (26) don't need parity equivalents.** Golden files compare + builtin output against static JSON files. `assert_parity()` is strictly + stronger — it's a live comparison of two running drivers. If the plugin + matches the builtin, it implicitly matches the golden files too. + +2. **Baseline tests (72) are the safety net, not the specification.** They + call the builtin directly via `postgres::get_tables(...)` — they cannot run + against the plugin (it speaks JSON-RPC, not Rust function calls). The parity + tests cover every scenario from the baseline by calling the same methods + through the `DatabaseDriver` trait. + +3. **Parity tests are MORE thorough.** They test additional edge cases beyond + the baseline (composite PKs, cross-schema FKs, NULL updates, batch session + state, etc.) — 80 scenarios covering all 72 baseline behaviors plus extras. + +### CP-4 Gate + +All three layers must pass: + +- 80/80 parity tests GREEN → plugin matches builtin byte-perfectly +- 72/72 baseline tests GREEN → builtin hasn't regressed +- 26/26 golden tests GREEN → no snapshot drift + +--- + +## Approach + +### The Red → Green Cadence + +At the start of Phase 1, point the parity harness at the plugin: + +```bash +cargo test --features parity +# Result: 0/55 GREEN, 55/55 RED (plugin binary doesn't exist) +``` + +Implementation proceeds sprint by sprint. After each sprint: + +```bash +cargo build --release +# Install plugin binary to local plugins directory +cargo test --features parity +# Result: N/55 GREEN — N must only increase, never decrease +``` + +**Rule:** If a previously-GREEN test goes RED, stop everything and fix it before +moving forward. No sprint is "done" with regressions. + +--- + +## Sprint Breakdown + +### Sprint 1: Foundation (Scaffold + Connection) + +**Build:** + +- `main.rs` — tokio runtime, stdin reader, stdout writer, JSON-RPC dispatch loop +- `rpc.rs` — method name → handler routing +- `models.rs` — `ConnectionParams` deserialization from JSON +- `pool.rs` — `deadpool-postgres` pool manager, keyed by `host:port:database:user` + +**Implement RPC methods:** + +- `initialize` — receive settings, acknowledge +- `ping` — acquire connection from pool, run `SELECT 1` +- `test_connection` — same as ping but with full error reporting +- `shutdown` — drain pools, exit cleanly + +**Critical decisions at this point:** + +- Pool configuration: max size, connection timeout, idle timeout +- SSL: `tokio-postgres-rustls` integration +- Startup script: `after_connect` hook that executes `params.startup_script` + +**Security considerations:** + +- `ConnectionParams.password` arrives in plaintext JSON. Store in memory only + for the duration needed to create the pool. Don't log it. +- `connection_string` may contain credentials embedded in URL. Parse carefully. +- SSL certificate paths (`ssl_ca`, `ssl_cert`, `ssl_key`) should be validated + (file exists, readable) before attempting connection. + +**Tests expected to go GREEN:** 3 (connection-related tests) + +--- + +### Sprint 2: Schema Discovery + +**Implement:** + +- `get_databases` — `SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate` +- `get_schemas` — `SELECT schema_name FROM information_schema.schemata WHERE ...` +- `get_tables` — query `pg_class` / `information_schema.tables` + +**Gotchas:** + +- Filter system schemas (`pg_catalog`, `information_schema`, `pg_toast`) +- Handle `schema` param being `None` (return all schemas' tables) vs `Some("public")` +- Table names must include `schema` qualification in responses where expected +- `get_databases` must work when connected to maintenance DB (`"postgres"`) + +**Tests expected to go GREEN:** 8 cumulative + +--- + +### Sprint 3: Column & Key Metadata + +**Implement:** + +- `get_columns` — `information_schema.columns` + PG catalog for extended info +- `get_indexes` — `pg_class` + `pg_index` + `pg_attribute` +- `get_foreign_keys` — `pg_constraint` with JOIN to get column names, ref table, actions + +**Port from built-in:** + +- `extract/` submodules (needed to correctly identify column types) +- Logic for detecting `SERIAL` → `is_auto_increment: true` +- Logic for parsing `character_maximum_length` + +**Gotchas:** + +- Enum type detection: must query `pg_type` + `pg_enum` to identify enum columns +- `default_value` for serial columns shows `nextval('seq')` — preserve as-is +- `ref_schema` in FK results — include from day one for multi-database support +- Composite indexes: `seq_in_index` must be correct for multi-column indexes + +**Security consideration:** + +- Column metadata queries should not expose system catalog internals beyond + what's needed. Don't return `pg_catalog` tables in `get_tables` responses. + +**Tests expected to go GREEN:** 18 cumulative + +--- + +### Sprint 4: Query Execution + +**Implement:** + +- `execute_query` — run arbitrary SQL, return `QueryResult` +- `execute_query_batch` — run multiple statements on SINGLE connection (session state) +- `count_query` — `SELECT COUNT(*) FROM (user_query) AS q` + +**Port from built-in:** + +- Full `extract/` subsystem (all PG types → `serde_json::Value` conversion) +- Pagination: `LIMIT {page_size} OFFSET {(page-1) * page_size}` +- `has_more` detection: query `page_size + 1` rows, return `page_size` + +**Critical: `execute_query_batch` session semantics** + +This is the highest-risk area for behavioral regression: + +```rust +// MUST use a SINGLE connection for the entire batch +let conn = pool.get().await?; +let mut results = vec![]; +for statement in statements { + let result = execute_on_conn(&conn, &statement, limit, page).await?; + results.push(result); +} +``` + +If each statement gets its own connection, `BEGIN`/`COMMIT`, temp tables, and +`SET` commands will break silently. This is a non-negotiable correctness +requirement. + +**Gotchas:** + +- DML statements (INSERT/UPDATE/DELETE) return `affected_rows`, not result set +- `SET` statements return empty result with `affected_rows: 0` +- Multiple result sets: PostgreSQL doesn't support this (unlike MySQL). Each + statement in a batch returns one result. +- Query cancellation: if the host aborts the RPC call mid-batch, the connection + should be returned to the pool (not leaked) + +**Security:** + +- Parameterized queries are NOT used here (user provides raw SQL). This is by + design — the app is a SQL editor. But ensure no metadata queries constructed + internally are injectable. + +**Tests expected to go GREEN:** 26 cumulative + +--- + +### Sprint 5: CRUD Operations + +**Implement:** + +- `insert_record` — generate `INSERT INTO ... VALUES (...)` with typed bindings +- `update_record` — generate `UPDATE ... SET ... WHERE pk = ...` with typed bindings +- `delete_record` — generate `DELETE FROM ... WHERE pk = ...` + +**Port from built-in:** + +- `binding.rs` — the most critical and complex piece: + - Enum column detection → `$N::enum_type` CAST syntax + - UUID string → UUID type binding + - JSON object/array → JSONB binding + - Array values → PostgreSQL array syntax + - Numeric string → appropriate numeric type + - Boolean string → PG boolean literals + - Temporal strings → timestamp/date/time with timezone handling + - DEFAULT sentinel → `DEFAULT` keyword in SQL + - NULL handling + +**This is the highest-risk sprint.** The binding system has subtle type-specific +behavior that, if wrong, causes silent data corruption. For example: + +- Missing enum CAST → PostgreSQL error "column X is of type mood but expression is text" +- Wrong numeric binding → silent precision loss +- Missing UUID detection → type mismatch error + +**Verification approach:** After implementing, run CRUD tests that: + +1. Insert a row with every type +2. Read it back via `execute_query` +3. Compare round-trip values + +**Gotchas:** + +- Composite PKs in WHERE clause: must handle multi-column keys correctly +- UUID PKs: must detect UUID format in PK value and bind as UUID type +- NULL in PK: should be rejected (PKs are NOT NULL by definition) +- Schema-qualified table names in generated SQL + +**Tests expected to go GREEN:** 35 cumulative + +--- + +### Sprint 6: Views & Materialized Views + +**Implement:** + +- `get_views` — query `pg_views` / `information_schema.views` +- `get_view_definition` — `pg_get_viewdef(oid)` +- `get_view_columns` — same as `get_columns` but for view +- `create_view` / `alter_view` / `drop_view` — DDL execution +- `get_materialized_views` — query `pg_matviews` +- `get_materialized_view_definition` — from `pg_matviews.definition` +- `get_materialized_view_columns` — from `pg_attribute` +- `refresh_materialized_view` — `REFRESH MATERIALIZED VIEW ...` + +**Gotchas:** + +- `alter_view` in PG is `CREATE OR REPLACE VIEW` (true ALTER is limited) +- Materialized views have no row count until `ANALYZE` is run +- MV columns query must use `pg_attribute` (not `information_schema`) +- `REFRESH MATERIALIZED VIEW CONCURRENTLY` requires a unique index — don't + assume concurrency is always possible + +**Tests expected to go GREEN:** 41 cumulative + +--- + +### Sprint 7: Routines & Triggers + +**Implement:** + +- `get_routines` — query `pg_proc` + `pg_namespace` +- `get_routine_parameters` — query `pg_proc.proargnames` + `pg_proc.proargtypes` +- `get_routine_definition` — `pg_get_functiondef(oid)` +- `build_routine_call_sql` — generate `SELECT func(...)` or `CALL proc(...)` +- `routine_create_template` — generate `CREATE OR REPLACE FUNCTION/PROCEDURE` +- `get_routine_edit_script` — same as definition (PG functions are re-runnable) +- `drop_routine` — handle overloaded functions (need argument types in DROP) +- `get_triggers` — query `pg_trigger` + `information_schema.triggers` +- `get_trigger_definition` — `pg_get_triggerdef(oid)` +- `create_trigger` / `drop_trigger` / `update_trigger` — DDL execution + +**Gotchas — Routine management is complex in PG:** + +- Overloaded functions: same name, different argument types. `DROP FUNCTION` + requires the argument signature: `DROP FUNCTION add_numbers(integer, integer)` +- Functions vs procedures: different call syntax (`SELECT` vs `CALL`) +- IN/OUT/INOUT parameters: affect call SQL generation +- `SECURITY DEFINER` functions: execute with creator's privileges (security relevant) +- `SET` options on functions: must be preserved in edit scripts + +**Gotchas — Triggers:** + +- PG triggers can fire FOR EACH ROW or FOR EACH STATEMENT +- Trigger functions are separate objects (function must exist before trigger) +- `update_trigger` = DROP + CREATE (PG has no ALTER TRIGGER for body changes) + +**Tests expected to go GREEN:** 48 cumulative + +--- + +### Sprint 8: DDL, EXPLAIN, BLOB + +**Implement:** + +- `get_create_table_sql` — generate `CREATE TABLE` with all columns, PKs, constraints +- `get_add_column_sql` — `ALTER TABLE ADD COLUMN ...` +- `get_alter_column_sql` — `ALTER TABLE ALTER COLUMN ...` (rename, type, null, default) +- `get_create_index_sql` — `CREATE [UNIQUE] INDEX ...` +- `drop_index` — `DROP INDEX schema."index_name"` +- `get_create_foreign_key_sql` — `ALTER TABLE ADD CONSTRAINT ... FOREIGN KEY ...` +- `drop_foreign_key` — `ALTER TABLE DROP CONSTRAINT ...` +- `explain_query_plan` — `EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) ...` +- `save_blob_to_file` — query bytea column, write raw bytes to file path +- `fetch_blob_as_data_url` — query bytea column, return as `BLOB:size:mime:base64` +- `get_ai_schema_context` — return schema DDL as context for AI features + +**Gotchas — DDL:** + +- Schema-qualified identifiers everywhere: `"schema"."table"` +- SERIAL type: `get_create_table_sql` must use `SERIAL` not `INTEGER DEFAULT nextval` +- `ALTER COLUMN TYPE` may require `USING` clause for type casts + +**Gotchas — EXPLAIN:** + +- Parse JSON format explain output into `ExplainNode` tree +- `ANALYZE` actually executes the query — handle DML carefully +- `BUFFERS` option only available with `ANALYZE` +- Cost units are PG-specific (not milliseconds) + +**Gotchas — BLOB:** + +- PostgreSQL uses `bytea` (inline) or Large Objects (OID reference) +- Built-in driver uses `bytea` approach: `SELECT col FROM table WHERE pk = val` +- Return value must match the `BLOB:size:mime:base64` wire format exactly +- File write must handle binary data correctly (no UTF-8 assumptions) + +**Security — BLOB:** + +- `save_blob_to_file` writes to an arbitrary path. The plugin runs locally so + this is the same trust model as any desktop app file write. But validate the + path doesn't escape expected directories if possible. + +**Tests expected to go GREEN:** 53 cumulative + +--- + +### Sprint 9: Multi-Database & Polish + +**Verify:** + +- `get_databases` returns both `tabularis_test` and `tabularis_test_secondary` +- Queries with different `params.database` hit different pools +- Schema discovery on secondary database returns `secondary_schema` +- FK results include `ref_schema` for cross-schema references +- Pool cleanup on shutdown drains all per-database pools + +**Fix:** Any remaining edge cases or test failures from previous sprints. + +**Final verification run:** + +```bash +cargo test --features parity +# Result: 55/55 GREEN ✅ (or 70+/70+ if more tests were written) +``` + +**Tests expected to go GREEN:** 55/55 (ALL) + +--- + +## Security Audit Checklist (End of Phase 1) + +Before declaring Phase 1 complete, verify: + +- [ ] **No credential logging** — `password`, `ssh_password` never appear in stdout/stderr +- [ ] **Pool credentials in memory only** — not written to temp files, not in stack traces +- [ ] **SSL verification working** — `verify-ca` and `verify-full` modes actually validate certs +- [ ] **SQL injection in internal queries** — all metadata queries use parameterized bindings + (not string interpolation with user-provided table/column names) +- [ ] **File path validation** — `save_blob_to_file` validates path is writable +- [ ] **Startup script execution** — runs in a try/catch, error doesn't leak connection +- [ ] **Connection string parsing** — malformed URLs don't crash the plugin +- [ ] **Memory cleanup** — pools are properly drained on shutdown (no leaked connections) + +--- + +## Checkpoint: CP-3 (Mid-Phase Progress Check) + +**When:** Plugin is at approximately 25/55 tests GREEN (after Sprint 4). + +**Purpose:** Early signal to the core team that implementation is on track. + +**Communicate:** + +- Current test count (objective progress metric) +- Any blockers discovered (unexpected RPC limitations, type handling issues) +- Revised timeline estimate if needed +- Demo: connect to PG via plugin, run a SELECT, show results + +**This is NOT a release gate.** It's a progress sync to catch issues early. + +--- + +## Checkpoint: CP-4 (Phase 1 Complete — Beta Release Gate) + +**When:** 80/80 parity tests GREEN + baseline tests pass + manual smoke test complete. + +**This IS a release gate.** After CP-4: + +- The plugin can be published to the Tabularium registry as a **beta** +- Users can install it alongside the built-in driver and test +- Feedback collection begins (does it work with their specific PG setups?) + +**Verify at CP-4:** + +- [ ] 80/80 parity tests GREEN (byte-perfect dual-driver comparison) +- [ ] 72 baseline tests pass (builtin-only safety net) +- [ ] 26 golden snapshot tests pass (no drift) +- [ ] Manual smoke test: all 24 items pass +- [ ] `pnpm test` (frontend): no regressions +- [ ] Security audit checklist: all items verified +- [ ] Plugin binary builds on all 3 platforms (macOS, Linux, Windows) +- [ ] Plugin installs cleanly via Tabularis Settings > Plugins +- [ ] Built-in driver still works unchanged (no interference) + +**Communicate to team:** + +- Feature parity achieved and proven +- Ready for beta testing with real users +- Phase 2 (issue #16 improvements) can begin +- Collect feedback on performance, compatibility, edge cases + +--- + +## Repo Extraction — Timing and Open Question + +**Access to `TabularisDB/tabularis-postgresql-plugin` was granted during Phase 1 +development (2026-08-05).** Decision: stay in-tree through CP-4, then extract. + +**Why wait:** + +- Phase 1 is mid-TDD with a tight build → test → feedback loop within a single + CI run (`pg-integration.yml` builds the plugin and runs all 80 parity tests + against it in one job). Splitting into two repos now means cross-repo CI + (the host would need to clone/build the plugin repo as a dependency, or pull + release artifacts) — friction that actively hurts iteration speed while the + RPC surface and manifest are still shifting commit to commit. +- This matches the plan's original intent: build in-tree through Phase 1, + extract to a standalone repo at the CP-4 beta gate — consistent with how + every other Tabularis plugin (DuckDB, ClickHouse, DynamoDB, etc.) is + structured as an external repo. + +**Open question to resolve before/at CP-4 — where do the 80 parity tests live +post-extraction?** + +The parity tests currently live in `tabularis`'s own test suite +(`src-tauri/tests/postgres_integration/parity*.rs`). They import +`tabularis_lib` types directly (`DatabaseDriver`, `PostgresDriver`, +`ConnectionParams`, etc.) and spawn the plugin binary in-process via +`RpcDriver::new()`. Two options once the plugin moves to its own repo: + +1. **Keep parity tests in `tabularis`.** The host CI would need to build or + fetch the plugin binary from the new repo (e.g. checkout as a step, or + download a release artifact) before running the existing test suite + unchanged. Simpler on the plugin-repo side; adds a cross-repo dependency + to `tabularis`'s CI. +2. **Move parity tests to the plugin repo.** The plugin repo would need a + `tabularis_lib` dependency (path or published crate) to get `DatabaseDriver` + and the builtin `PostgresDriver` for comparison. Keeps the plugin + self-testing but couples it to the host's internal crate — `tabularis_lib` + isn't currently published or designed for external consumption. + +Revisit this when CP-4 is close — by then the RPC surface should be stable +enough that the decision doesn't need to be made twice. + +--- + +## Potential Gaps & Risks Specific to Phase 1 + +| Gap/Risk | Impact | Mitigation | +| -------- | ------ | ---------- | +| `tokio-postgres` type handling differs from `sqlx` | Extraction code must be rewritten, not just copied | Port logic, not code. Test each type individually. | +| Binary wire format vs text format | Built-in uses binary (sqlx default); plugin may start with text. Values might format differently (e.g., float precision). | Golden file tests will catch any formatting differences immediately. | +| Pool exhaustion under load | Plugin has one process for all connections. Deadpool defaults may be too conservative. | Configure max_size based on expected concurrent queries. Monitor in beta. | +| Plugin stderr noise | Accidental stdout writes corrupt JSON-RPC stream | Use `tracing` crate with stderr subscriber. Never use `println!`. Add a CI test that verifies no stdout writes outside JSON-RPC. | +| Cross-platform binary build | Plugin must compile for macOS (ARM+Intel), Linux, Windows | Set up cross-compilation in CI. Test on all platforms before CP-4. | + +--- + +## Definition of Done + +- [ ] 80/80 parity tests GREEN +- [ ] 72 baseline tests pass (builtin-only safety net) +- [ ] 26 golden snapshot tests pass +- [ ] Manual smoke test: 24/24 items pass +- [ ] Security audit checklist: complete +- [ ] Plugin builds on macOS, Linux, Windows +- [ ] Plugin installs and runs cleanly in Tabularis +- [ ] Built-in driver unaffected (both can coexist) +- [ ] CP-4 sync completed with core team +- [ ] Published to Tabularium registry as beta diff --git a/docs/planning/03-phase-2-issue-16.md b/docs/planning/03-phase-2-issue-16.md new file mode 100644 index 0000000..aa39d16 --- /dev/null +++ b/docs/planning/03-phase-2-issue-16.md @@ -0,0 +1,344 @@ +# Phase 2 — Issue #16 Improvements + +**Goal:** Add the PostgreSQL-specific features identified in issue #16 that go +beyond what the built-in driver supports. This is where the plugin exceeds the +built-in driver and becomes the definitively better PostgreSQL experience. + +**Prerequisite:** Phase 1 complete (55/55 parity tests GREEN, beta published). + +--- + +## Approach + +### TDD Continues + +Each new feature follows the same discipline: + +1. Write the test (RED) +2. Implement the feature (GREEN) +3. Verify no regressions (all previous tests still GREEN) + +### Plugin-Only Development + +Phase 2 features go into the plugin only — they do NOT exist in the built-in +driver. This is the first divergence point: the plugin becomes strictly superior. + +### UI Extensions + +Some features may need frontend UI. The plugin manifest supports `ui_extensions` +for injecting custom panels/tabs. However, for Phase 2, most features expose +through existing UI patterns (sidebar tree nodes, query results, context menus). + +### Check for Existing In-Flight Work + +**Before implementing any feature, check for open PRs that already address it.** +Duplicating community work wastes effort and creates merge conflicts. + +**Known in-flight PRs relevant to Phase 2 (as of this writing):** + +| PR | Feature | Author | Status | +| -- | ------- | ------ | ------ | +| [#427](https://github.com/TabularisDB/tabularis/pull/427) | HStore column editing | arturbent0 | Open | +| [#402](https://github.com/TabularisDB/tabularis/pull/402) | Multi-database connections | debba | Draft | +| [#222](https://github.com/TabularisDB/tabularis/pull/222) | Composite PK end-to-end | saurabh500 | Draft | + +**Process for each Phase 2 feature:** + +1. Search open PRs: `gh pr list --repo TabularisDB/tabularis --search ""` +2. If a PR exists and is active → coordinate with the author, don't duplicate +3. If a PR exists but is stale (>2 months inactive) → comment asking if still active; + if no response in 1 week, proceed with your own implementation +4. If no PR exists → proceed + +**For PR #427 (HStore) specifically:** This work already exists. When Phase 2 +reaches hstore support, either: + +- The PR has merged → we port its logic into the plugin (or the plugin + inherits it via the existing driver trait behavior) +- The PR hasn't merged → coordinate with `arturbent0` to align with the plugin + architecture (their work may target the built-in driver and need adaptation) + +--- + +## Features (Priority Order) + +### 2.1: Sequence Management + +**What:** List, inspect, create, alter, reset, and drop PostgreSQL sequences. + +**Why:** Sequences are fundamental to PG (every SERIAL/BIGSERIAL creates one). +Currently invisible in Tabularis — users must write raw SQL to manage them. + +**Implementation:** + +| Method | SQL | +| ------ | --- | +| List sequences | `SELECT * FROM pg_sequences WHERE schemaname = $1` | +| Get sequence details | `SELECT * FROM pg_sequences WHERE sequencename = $1` | +| Get current value | `SELECT currval('schema.seq')` or `last_value` from pg_sequences | +| Reset sequence | `ALTER SEQUENCE schema.seq RESTART WITH $1` | +| Set sequence value | `SELECT setval('schema.seq', $1)` | +| Create sequence | `CREATE SEQUENCE schema.seq [INCREMENT BY ...] [START WITH ...]` | +| Drop sequence | `DROP SEQUENCE schema.seq` | + +**Frontend integration:** Sequences appear in the sidebar under a "Sequences" +node (same level as Tables, Views, Routines). Double-click opens a detail panel. + +**Tests:** + +- `test_get_sequences` — lists all sequences in schema +- `test_get_sequence_details` — returns increment, min, max, start, current +- `test_reset_sequence` — verify value changes +- `test_create_and_drop_sequence` — lifecycle + +--- + +### 2.2: JSONB Inline Editing + +**What:** Edit JSONB column values with structured awareness — add/remove keys, +modify nested values, toggle between raw JSON text and structured editor. + +**Why:** Currently JSONB is edited as a raw text string. This is error-prone for +complex nested objects. A structured editor prevents syntax errors. + +**Implementation approach:** + +This is primarily a **frontend feature** (UI extension). The plugin's role is: + +1. Detect JSONB columns and flag them in `get_columns` response (already done — `data_type: "jsonb"`) +2. Validate JSON on `update_record` — return clear error if invalid JSON is submitted +3. Optionally: expose `jsonb_set`, `jsonb_insert`, `jsonb_delete_path` as helper operations + +**Plugin-side additions:** + +- New RPC method: `validate_jsonb(value)` → returns ok or parse error with position +- New RPC method: `jsonb_patch(params, table, pk, path, operation, value)` → applies a + targeted JSONB modification without overwriting the entire value + +**Frontend UI extension:** + +- JSON tree editor component (expand/collapse nodes, edit values inline) +- Add/remove key buttons +- Path breadcrumb showing current location in the JSON tree +- Raw mode toggle (switch between tree and text editor) + +**Tests:** + +- `test_insert_complex_jsonb` — nested objects, arrays, mixed types +- `test_update_jsonb_full_replace` — overwrite entire value +- `test_jsonb_patch_add_key` — add key to existing object +- `test_jsonb_patch_remove_key` — remove key from object +- `test_jsonb_patch_nested_update` — modify deeply nested value +- `test_invalid_jsonb_rejected` — malformed JSON returns clear error + +--- + +### 2.3: Extension-Aware Type System + +**What:** Detect installed PostgreSQL extensions and expose their types in the +type picker and column handling. + +**Extensions to support initially:** + +- **PostGIS** — geometry, geography, raster types +- **pgvector** — vector(N) type for embeddings +- **ltree** — label tree type +- **hstore** — key-value store (legacy, still common) — **see PR #427 (in-flight)** +- **citext** — case-insensitive text + +**Implementation:** + +```sql +-- Detect installed extensions +SELECT extname, extversion FROM pg_extension WHERE extname IN ( + 'postgis', 'vector', 'ltree', 'hstore', 'citext' +); +``` + +For each detected extension, add its types to the runtime type list. The plugin +can dynamically extend `data_types` after `initialize` by checking what's installed. + +**Gotcha:** The `data_types` in the manifest are static. Dynamic type discovery +requires either: + +- A new RPC method: `get_dynamic_data_types(params)` → returns additional types + based on what's installed +- Or: the plugin returns a comprehensive superset and the UI filters by what's + actually usable + +**Tests:** + +- `test_detect_postgis_extension` (requires PG with PostGIS — optional CI extension) +- `test_vector_type_handling` (requires pgvector) +- `test_ltree_insert_and_query` + +**Note:** These tests may need to be `#[ignore]` in CI unless extensions are +installed in the test container. Consider a separate "extended type" test profile. + +--- + +### 2.4: Partition Table Introspection + +**What:** Show partition hierarchy in the sidebar — parent table with child +partitions listed underneath. Show partition key and bounds. + +**Implementation:** + +```sql +-- Find partitioned tables +SELECT c.relname, pg_get_partkeydef(c.oid) as partition_key +FROM pg_class c +JOIN pg_namespace n ON c.relnamespace = n.oid +WHERE c.relkind = 'p' AND n.nspname = $1; + +-- Find partitions of a parent table +SELECT c.relname, pg_get_expr(c.relpartbound, c.oid) as partition_bound +FROM pg_inherits i +JOIN pg_class c ON i.inhrelid = c.oid +WHERE i.inhparent = (SELECT oid FROM pg_class WHERE relname = $1); +``` + +**Frontend integration:** + +- Partitioned tables show with a special icon in sidebar +- Expanding shows child partitions with their bounds +- Context menu: "Create Partition", "Detach Partition" + +**Tests:** + +- `test_get_partitioned_tables` — identifies partition parents +- `test_get_partitions` — lists children with bounds +- `test_partition_range_bounds` — range partition display +- `test_partition_list_bounds` — list partition display + +--- + +### 2.5: Row-Level Security Policies + +**What:** Display RLS policies on tables. Show which roles they apply to, the +USING and WITH CHECK expressions. + +**Implementation:** + +```sql +SELECT polname, polcmd, polroles, pg_get_expr(polqual, polrelid) as using_expr, + pg_get_expr(polwithcheck, polrelid) as check_expr +FROM pg_policy WHERE polrelid = $1::regclass; +``` + +**Frontend integration:** + +- In the table detail panel, show a "Security Policies" section +- Each policy shows: name, command (SELECT/INSERT/UPDATE/DELETE/ALL), roles, expressions + +**Tests:** + +- `test_get_policies` — lists policies on a table with RLS enabled +- `test_policy_per_command` — distinguishes SELECT vs UPDATE policies + +--- + +### 2.6: Publication/Subscription Visibility + +**What:** Show logical replication publications and subscriptions for monitoring. + +**Implementation:** + +```sql +-- Publications +SELECT pubname, puballtables, pubinsert, pubupdate, pubdelete +FROM pg_publication; + +-- Subscription status +SELECT subname, subenabled, subslotname, subpublications +FROM pg_subscription; +``` + +**Frontend:** New sidebar section "Replication" with Publications and Subscriptions. + +--- + +### 2.7: Advisory Lock Monitoring + +**What:** Show currently held advisory locks for debugging lock contention. + +```sql +SELECT locktype, objid, mode, granted, pid, + (SELECT usename FROM pg_stat_activity WHERE pid = l.pid) as held_by +FROM pg_locks l WHERE locktype = 'advisory'; +``` + +--- + +## Implementation Order + +Prioritized by user impact and implementation complexity: + +```text +Sprint 1: Sequence management (high demand, straightforward) +Sprint 2: JSONB inline editing (high demand, more complex — UI extension) +Sprint 3: Extension type system (high demand for PostGIS/pgvector users) +Sprint 4: Partition introspection (medium demand) +Sprint 5: RLS policies (medium demand, straightforward) +Sprint 6: Pub/Sub + Advisory locks (lower priority, quick wins) +``` + +--- + +## Checkpoint: CP-5 (Phase 2 Complete — Stable Release Gate) + +**When:** Core Phase 2 features complete (at minimum: sequences + JSONB + extensions). + +**This IS a major release gate.** The plugin now exceeds the built-in driver. + +**Verify:** + +- [ ] All Phase 1 parity tests still GREEN (no regressions) +- [ ] New Phase 2 features have dedicated tests (all GREEN) +- [ ] Sequence management works end-to-end +- [ ] JSONB editing works with nested objects +- [ ] At least one extension type (PostGIS or pgvector) is handled +- [ ] Plugin published as **stable** (not beta) to Tabularium registry + +**Communicate to team:** + +- Plugin is now the recommended PostgreSQL driver for power users +- Built-in driver still works but is feature-frozen +- Begin planning Phase 3 (deprecation decision) + +--- + +## Ship / Release Points + +| After | What to ship | Channel | +| ----- | ------------ | ------- | +| Sequences done | Plugin update (minor version bump) | Beta → early adopters | +| JSONB editing done | Plugin update | Beta | +| All Phase 2 core done | Plugin promoted to **stable** | Public registry | +| Extensions done | Plugin update | Stable | + +The plugin architecture enables shipping each feature independently without +waiting for a Tabularis core release. This is a key advantage. + +--- + +## Security Considerations + +| Feature | Security Concern | Mitigation | +| ------- | ---------------- | ---------- | +| Sequence reset | Could disrupt application logic (PK collisions) | Confirmation dialog before reset | +| JSONB patch | Could corrupt data if path is wrong | Validate path exists before patching; show preview | +| RLS policies | Exposing USING expressions might reveal security rules | Only show to connection owner / superuser | +| Advisory locks | Revealing lock holders exposes active sessions | Same visibility as `pg_stat_activity` (requires `pg_monitor` role) | + +--- + +## Definition of Done + +- [ ] Sequences: list, inspect, reset, create, drop — all tested +- [ ] JSONB: structured editing, validation, patch operations — all tested +- [ ] Extensions: at least PostGIS + pgvector types detected and handled +- [ ] Partitions: hierarchy displayed, bounds shown +- [ ] All Phase 1 tests still GREEN (no regressions) +- [ ] Plugin published as stable release +- [ ] CP-5 sync completed with core team diff --git a/docs/planning/04-phase-3-deprecate-builtin.md b/docs/planning/04-phase-3-deprecate-builtin.md new file mode 100644 index 0000000..bc2b745 --- /dev/null +++ b/docs/planning/04-phase-3-deprecate-builtin.md @@ -0,0 +1,140 @@ +# Phase 3 — Deprecate Built-in Driver (Deferred Decision) + +**Goal:** Remove the built-in PostgreSQL driver from Tabularis core, making the +plugin the sole PostgreSQL driver. This is a strategic decision that requires +full team consensus and community readiness. + +**Status:** Deferred until Phases 1 and 2 are complete and proven in production. + +--- + +## When to Revisit This Decision + +This phase should be discussed when ALL of the following are true: + +- [ ] Plugin has been in stable release for at least 2 months +- [ ] No critical bug reports from plugin users +- [ ] Plugin test suite has 100% parity with built-in (Phase 1 proven) +- [ ] Plugin exceeds built-in in features (Phase 2 shipped) +- [ ] Community feedback is positive (no "I want the old driver back" sentiment) +- [ ] Performance benchmarks show no meaningful regression +- [ ] All supported platforms (macOS, Linux, Windows) confirmed working + +--- + +## Decision Points + +The team must agree on: + +### 1. Plugin ID Strategy + +| Option | Effort | User Impact | +| ------ | ------ | ----------- | +| Rename plugin to `"postgres"` (remove guard) | Low — one code change | Zero — saved connections work unchanged | +| Keep `"postgres-plugin"`, add migration UI | Medium — migration dialog + saved connection rewrite | Low — one-time dialog on update | +| Keep both (built-in frozen, plugin recommended) | Zero | Confusing — two PG drivers visible | + +### 2. Bundling Strategy + +| Option | Pros | Cons | +| ------ | ---- | ---- | +| Bundle plugin in app distribution | No install step, guaranteed availability | Larger app binary | +| Auto-install from registry on first launch | Smaller app, always latest version | Requires internet, extra startup time | +| Manual install (user must add via Settings) | Simplest for us | Worst UX, many users won't discover it | + +### 3. Built-in Driver Removal Scope + +What gets removed from `src-tauri/`: + +- `drivers/postgres/` (2420+ lines — mod.rs, binding.rs, client.rs, explain.rs, helpers.rs, types.rs, extract/) +- PostgreSQL pool creation in `pool_manager.rs` +- `"postgres"` entry in `BUILTIN_DRIVER_IDS` +- PostgreSQL-specific code in `commands.rs` (SSH expansion for PG, postgres_dbname helper) + +What stays: + +- The `DatabaseDriver` trait (used by all drivers) +- RPC infrastructure (used by all plugins) +- Frontend driver capability handling (generic, works with any driver) + +### 4. Rollback Plan + +If something goes wrong after removing the built-in driver: + +- **Short-term:** Users can install the last Tabularis version with built-in PG +- **Medium-term:** We can re-add the built-in driver in a patch release (code is in git history) +- **Plugin-side:** If the plugin has a bug, push a new plugin version (no app update needed) + +--- + +## Implementation Steps (When Decided) + +1. Remove `"postgres"` from `BUILTIN_DRIVER_IDS` array +2. If renaming plugin: update `.tabularium` manifest `id` to `"postgres"` +3. Remove `src-tauri/src/drivers/postgres/` directory +4. Remove PG pool logic from `pool_manager.rs` +5. Remove `postgres_dbname()` helper from `commands.rs` +6. Add migration logic: on first launch after update, if no `"postgres"` driver is + registered, auto-install the plugin (or prompt user) +7. Update frontend: remove PG-specific fallback capabilities in `useDrivers.ts` +8. Update documentation: migration guide for users +9. Update CHANGELOG: announce the change prominently +10. Test: full regression suite against plugin-only configuration + +--- + +## Checkpoint: CP-6 + +**When:** Team decides to proceed with deprecation. + +**Stakeholders:** Full team consensus required — not a solo decision. + +**Criteria for proceeding:** + +- [ ] All items in "When to Revisit" section are satisfied +- [ ] Team has unanimously agreed on plugin ID strategy +- [ ] Team has agreed on bundling strategy +- [ ] Rollback plan is documented and tested +- [ ] Migration path verified with test accounts (saved connections survive) +- [ ] Community announcement drafted + +--- + +## Security Consideration + +Removing the built-in driver means all PostgreSQL connections flow through the +plugin process (a separate child process communicating via stdio). This changes +the security boundary: + +| Concern | Built-in | Plugin | +| ------- | -------- | ------ | +| Credential handling | In-process, same memory space | Sent via JSON over stdio (local pipes) | +| Connection lifetime | Managed by Tabularis process | Managed by plugin process (kill_on_drop) | +| Crash isolation | PG driver crash = Tabularis crash | PG driver crash = error message (Tabularis survives) | +| Code audit surface | Part of main codebase | Separate binary (must be audited separately) | + +The security posture is **slightly better** with the plugin (crash isolation) +but introduces a **new trust boundary** (the plugin binary must be verified +as legitimate at install time — already handled by registry SHA-256 verification). + +--- + +## Timeline Estimate + +This phase is purely a coordination and removal exercise. Technical effort is +minimal (< 1 week). The real timeline is governed by: + +- Community confidence building (2+ months of stable plugin usage) +- Team scheduling for the migration release +- Documentation and announcement preparation + +--- + +## Definition of Done + +- [ ] Built-in PG driver code removed from Tabularis core +- [ ] Plugin is the sole PG driver, working identically +- [ ] Existing saved connections work without user action (or clear migration dialog) +- [ ] No user-facing regressions reported within 2 weeks of release +- [ ] CHANGELOG + migration guide published +- [ ] CP-6 sync completed with full team diff --git a/docs/planning/README.md b/docs/planning/README.md new file mode 100644 index 0000000..6642a1e --- /dev/null +++ b/docs/planning/README.md @@ -0,0 +1,35 @@ +# PostgreSQL Plugin Migration — Phase Docs Index + +**Master Plan:** [postgres-plugin-migration.md](./postgres-plugin-migration.md) + +## Phase Documents + +| Phase | Document | Status | +| ----- | -------- | ------ | +| Prerequisites | [00-prerequisites.md](./00-prerequisites.md) | ✅ Complete (PR #576) | +| Phase 0 | [01-phase-0-baseline-tests.md](./01-phase-0-baseline-tests.md) | ✅ Complete | +| Phase 1 | [02-phase-1-plugin-build.md](./02-phase-1-plugin-build.md) | 🟡 In Progress | +| Phase 2 | [03-phase-2-issue-16.md](./03-phase-2-issue-16.md) | Planning | +| Phase 3 | [04-phase-3-deprecate-builtin.md](./04-phase-3-deprecate-builtin.md) | Planning | + +## Test Architecture + +| Layer | Count | Purpose | +| ----- | ----- | ------- | +| Parity tests | 80 | Byte-perfect comparison: plugin output == builtin output | +| Baseline tests | 72 | Safety net: builtin behavior hasn't regressed | +| Golden tests | 26 | Snapshot drift detection | + +See [02-phase-1-plugin-build.md](./02-phase-1-plugin-build.md) for why 80 +parity tests (not 102) is the correct number for the CP-4 gate. + +## Checkpoints & Release Gates + +| Checkpoint | When | Stakeholders | Ship? | +| ---------- | ---- | ------------ | ----- | +| CP-1 | After Prerequisites merged | Core team review | ✅ Done | +| CP-2 | After Phase 0 complete | Core team + QA | ✅ Done (@aesslinger proceeded) | +| CP-3 | Phase 1 metadata parity (13/80) | Core team sync | ✅ Done (@aesslinger proceeded) | +| CP-4 | Phase 1 at 80/80 parity tests green | Core team + QA | **Yes — beta release** | +| CP-5 | After Phase 2 features complete | Core team + community | **Yes — stable release** | +| CP-6 | Phase 3 decision | Full team consensus | Depends on decision | diff --git a/docs/planning/ci-hardening-deferred.md b/docs/planning/ci-hardening-deferred.md new file mode 100644 index 0000000..a831a06 --- /dev/null +++ b/docs/planning/ci-hardening-deferred.md @@ -0,0 +1,44 @@ +# CI hardening — deferred items + +**Status:** not implemented; documented here so the ideas aren't lost. + +While setting a higher CI bar than the sibling plugin repos (see the +`ci.yml`/`release.yml` history around the manifest-validation, markdownlint, +release-binary-smoke-test, `cargo audit`, and live-database-integration-test +additions), two further items were identified and deliberately deferred +rather than implemented immediately: + +## `dependency-review-action` on PRs + +[`actions/dependency-review-action`](https://github.com/actions/dependency-review-action) +flags newly-introduced vulnerable or license-incompatible dependencies +**in the diff of a specific PR**, rather than scanning the whole dependency +tree. This is complementary to, not a replacement for, the `cargo audit` +job already added: `cargo audit` catches every known vulnerability in the +full tree (including ones that predate the PR and ones newly disclosed +against unchanged deps, via its weekly schedule run), while +`dependency-review-action` is specifically useful for catching "this PR +just added a bad dependency" as an inline PR check before merge. + +Not implemented yet because `cargo audit` already covers the core +supply-chain-vulnerability need for a single-crate Rust repo this size; +the PR-diff-specific framing adds most value on repos with frequent +dependency churn or many contributors, which doesn't describe this repo's +current state. + +## SBOM generation (`cargo-cyclonedx`) + +Publishing a [CycloneDX](https://cyclonedx.org/) SBOM (Software Bill of +Materials) alongside each GitHub release, generated via +[`cargo-cyclonedx`](https://github.com/CycloneDX/cyclonedx-rust-cargo), +would let downstream consumers (or automated scanners) inspect this +plugin's exact dependency tree per release without needing to check out +the tagged commit and inspect `Cargo.lock` themselves. + +Not implemented yet because it's genuinely ahead of the curve for this +repo's current maturity — no consumer has asked for it, no sibling plugin +in the org does this, and it doesn't close a gap the way the other four +additions do. Worth revisiting once this repo is signed off as the +primary plugin home (see `CLAUDE.md`'s "Status" section) and/or once +there's an actual downstream consumer or compliance requirement asking +for it. diff --git a/docs/planning/postgres-improvements.md b/docs/planning/postgres-improvements.md new file mode 100644 index 0000000..bda2dd6 --- /dev/null +++ b/docs/planning/postgres-improvements.md @@ -0,0 +1,1238 @@ +# PostgreSQL Driver Improvements — Feature Gap Audit & Implementation Plan + +**Ref:** [#16 — Better PostgreSQL Support](https://github.com/TabularisDB/tabularis/issues/16) +**Related:** [#15 — Schema handling fix (closed)](https://github.com/TabularisDB/tabularis/issues/15), +[PR #342 — Materialized views (merged)](https://github.com/TabularisDB/tabularis/pull/342), +[PR #402 — Multi-database connections (open, in progress)](https://github.com/TabularisDB/tabularis/pull/402) + +## Executive Summary + +A comprehensive audit of the PostgreSQL driver (`src-tauri/src/drivers/postgres/`) +reveals a **highly mature implementation** — the most complete of the three built-in +drivers. It implements 100% of the `DatabaseDriver` trait, supports 70+ data types +across 14 categories, and handles PostgreSQL-specific complexities (enum CASTs, +composite types, range/multi-range extraction, overloaded routine management, and +schema-qualified identifiers). + +However, several PostgreSQL capabilities that are standard in professional database +tools remain unimplemented. Issue 16 explicitly calls out **sequences**, **JSONB +editing**, and **schema handling** — the last of which is resolved. This document +identifies 3 active bugs, 6 feature gaps, and 5 polish items, organized into a +prioritized implementation plan. + +--- + +## Table of Contents + +1. [Dependency: PR 402 — Multi-Database Connections](#dependency-pr-402--multi-database-connections) +2. [Audit Methodology](#audit-methodology) +3. [Current State](#current-state) +4. [Findings: Active Bugs](#findings-active-bugs) +5. [Findings: Feature Gaps](#findings-feature-gaps) +6. [Findings: Polish & Enhancements](#findings-polish--enhancements) +7. [Findings: Out of Scope](#findings-out-of-scope) +8. [Feature Comparison Matrix](#feature-comparison-matrix) +9. [Implementation Plan](#implementation-plan) +10. [Testing Strategy](#testing-strategy) +11. [Open Questions](#open-questions) + +--- + +## Dependency: PR 402 — Multi-Database Connections + +[PR #402](https://github.com/TabularisDB/tabularis/pull/402) is an in-flight PR +by debba that adds multi-database browsing to PostgreSQL connections. It is the +foundational architecture change that all work in this plan should build on top of. + +### What PR 402 Delivers + +PR 402 allows a single PostgreSQL connection to browse multiple databases — each +with its own schemas — from the sidebar. Key architectural changes: + +- **Per-database connection pools** — Pool key becomes `driver:conn:{id}:{db}`, + with separate pools for each selected database +- **`database: Option` routing** — Every Tauri command now accepts an + optional `database` parameter; when set, the backend overrides `params.database` + to route to the correct pool +- **Editor tabs carry `database`** — Each tab stores its target database alongside + schema, so DML routes to the correct pool regardless of sidebar state +- **`buildTableRoutingParams` helper** — Frontend utility that builds the + `{ schema, database }` pair from a tab's context for any backend call +- **`isSchemaBasedMultiDb` helper** — Distinguishes hierarchical PG layout + (`database → schema → table`) from flat MySQL layout (`database → table`) +- **`SchemaData` nesting** — `databaseDataMap` entries now optionally contain + `schemas: string[]` and `schemaDataMap: Record` for + the hierarchical PG model + +### PR 402 Status + +| Aspect | State | +|--------|-------| +| Branch state | Open, has merge conflicts with `main` | +| Last activity | 2026-07-01 (14 commits, 60+ files changed) | +| Tests | 2730 frontend + 766 Rust passing at last push | +| Verification checklist | 4/69 items checked | +| Formal reviews | None submitted yet | + +### PR 402 Remaining Known Limitations + +These are gaps that PR 402 explicitly declares as out of scope. Some overlap +with our plan and some are purely routing issues that need follow-up work: + +#### Routing Gaps (Still Need Fixing After 402 Merges) + +| Gap | Description | Overlap with Our Plan | +|-----|-------------|----------------------| +| **Object-creation DDL not database-aware** | Create Table / View / Trigger / Index / FK from a nested schema node routes to the primary database, not the node's database | Affects our Enhancement 4 (schema/DB management) — any new DDL commands must be database-aware | +| **AI Query Generation not database-aware** | `AiQueryModal` schema context uses primary database only | Not in our scope | +| **Clipboard Import not database-aware** | Import creates table on primary database regardless of context | Not in our scope | +| **SQL autocomplete not database-aware** | `get_columns` for autocomplete runs against primary pool | Not in our scope but good to note | +| **No pool cap or idle eviction** | Each selected database keeps max 10 connections indefinitely | Operational concern for our work (large schemas with many DBs) | + +#### Features Explicitly Out of Scope in 402 + +PR 402's own checklist confirms these are NOT implemented and left for follow-up +(directly aligning with our plan): + +| Feature | Our Plan Item | +|---------|---------------| +| Sequences (first-class management) | **Gap 1** — our primary deliverable | +| Custom types / Enums / Domains | **Enhancement 3** | +| Extensions (PostGIS, hstore, …) | **Gap 5** | +| Check / Unique constraints (dedicated listing) | Not in our plan (low priority) | +| CREATE/DROP DATABASE, CREATE/DROP SCHEMA | **Enhancement 4** | +| TRUNCATE / RENAME table | Not in our plan | +| Query cancellation via `pg_cancel_backend` | Not in our plan | +| Materialized views | Already delivered in PR 342 | + +### Impact on Our Implementation + +#### What 402 Fixes That We Originally Identified + +**Bug 516 (Wrong Schema in DML)** — PR 402 directly addresses this class of bug. +The fix is that editor tabs now carry `database` alongside `schema`, and the +`buildTableRoutingParams` helper ensures DML operations route to the tab's stored +context rather than the sidebar's globally-selected schema. The specific commit +"fix: route results-grid operations to the tab's database pool" (86640146) fixed +the exact pattern: Ctrl+S commit was sending the schema name as a database name +on schema-based drivers, routing to the wrong pool. + +**Verdict:** Bug 516 should be **verified after PR 402 merges** rather than fixed +independently. If it persists, it would be a residual routing bug in 402's model +(unlikely given the thorough fix commits). + +#### What Our Plan Must Do Differently + +1. **All new Tauri commands must include `database: Option`** and apply + the standard routing pattern: + + ```rust + let mut params = resolve_connection_params_with_id(&expanded_params, &connection_id)?; + if let Some(db) = database.filter(|d| !d.is_empty()) { + params.database = crate::models::DatabaseSelection::Single(db); + } + ``` + +2. **All new frontend invocations must pass `database`** from the tab or sidebar + context using `buildTableRoutingParams` or equivalent. + +3. **New sidebar groups (Sequences, Extensions, Types) must propagate `database`** + down to their child items the same way `SidebarSchemaItem` propagates it to + tables, views, routines, and triggers. + +4. **Rebase on 402 before starting implementation** — our branch should be based + on the post-402 state of `main` to avoid conflict in `commands.rs` (182 + additions), `DatabaseContext.ts`, `DatabaseProvider.tsx`, and `Editor.tsx`. + +--- + +## Audit Methodology + +The audit compared the PostgreSQL driver against: + +- The full `DatabaseDriver` trait definition in `src-tauri/src/drivers/driver_trait.rs` +- The MySQL driver (`src-tauri/src/drivers/mysql/mod.rs`) for feature parity baseline +- PostgreSQL's own catalog (`pg_catalog`) and `information_schema` capabilities +- Open GitHub issues tagged with PostgreSQL-related keywords +- Professional database tool standards (pgAdmin, DBeaver, DataGrip) + +**Source files reviewed:** + +| File | Lines | Purpose | +|------|-------|---------| +| `src-tauri/src/drivers/postgres/mod.rs` | ~2500 | Main driver implementation | +| `src-tauri/src/drivers/postgres/binding.rs` | — | Value binding for parameterized queries | +| `src-tauri/src/drivers/postgres/client.rs` | — | Pool client wrappers | +| `src-tauri/src/drivers/postgres/explain.rs` | — | EXPLAIN plan parsing | +| `src-tauri/src/drivers/postgres/export.rs` | — | Streaming query export | +| `src-tauri/src/drivers/postgres/extract/` | 7 files | Value extraction (simple, array, composite, enum, range, multi_range, advanced) | +| `src-tauri/src/drivers/postgres/helpers.rs` | — | Identifier escaping, enum type handling | +| `src-tauri/src/drivers/postgres/routines.rs` | — | Stored routine SQL builders | +| `src-tauri/src/drivers/postgres/types.rs` | 838 | Data type catalog (70+ types) | +| `src-tauri/src/drivers/postgres/tests.rs` | — | Unit tests | +| `src-tauri/src/drivers/driver_trait.rs` | ~600 | Trait definition and capabilities | +| `src-tauri/src/commands.rs` | — | Tauri command layer | +| `src/types/plugins.ts` | — | Frontend capability types | +| `src/contexts/DatabaseContext.ts` | — | Schema data model | + +--- + +## Current State + +### What Works Correctly + +The PostgreSQL driver fully implements: + +| Category | Features | +|----------|----------| +| **Connection** | Pool-based (`deadpool-postgres`), SSL/TLS, SSH tunneling, connection string import, configurable search_path | +| **Schema Inspection** | Multi-schema browsing, tables, columns (with enum values, max length), FKs (with update/delete rules), indexes | +| **Views** | List, create (CREATE VIEW), alter (CREATE OR REPLACE VIEW), drop, column introspection | +| **Materialized Views** | List, columns, indexes, definition, refresh, read-only grid enforcement | +| **Routines** | List functions/procedures, parameters, full definition (`pg_get_functiondef`), call/create/edit/drop (overload-safe via identity arguments) | +| **Triggers** | List (with event aggregation), definition (`pg_get_triggerdef`), create, drop | +| **CRUD** | Type-aware insert/update/delete with enum CAST, JSON/JSONB, BLOB, DEFAULT VALUES, composite PK binding | +| **DDL** | CREATE TABLE, ADD COLUMN, ALTER COLUMN (with USING clause for incompatible casts), CREATE INDEX, CREATE FK, schema-qualified DROP | +| **Query Execution** | Paginated SELECT (LIMIT+1 pattern), batch on single client (session-safe), cancellation via CancellationToken | +| **EXPLAIN** | FORMAT JSON with ANALYZE and BUFFERS options, parsed plan tree | +| **BLOB** | Save BYTEA to file, preview as data URL | +| **Type Extraction** | Enums, JSON/JSONB, arrays (nested), composites, ranges, multi-ranges, HSTORE (read), network types, geometric types, FTS types, system types | +| **Compatibility** | PG 9.x/10 support (prokind fallback for pre-11 servers) | + +### Declared Capabilities + +```rust +DriverCapabilities { + schemas: true, // Multi-schema support + single_database: false, // Multiple databases + views: true, // Full view lifecycle + materialized_views: true, // PG-exclusive + routines: true, // Function/procedure listing + routine_management: true, // Full routine CRUD + file_based: false, // Network driver + folder_based: false, + connection_string: true, // postgres://user:pass@host:port/db + identifier_quote: "\"", // Double-quote identifiers + alter_primary_key: true, // ALTER TABLE PK modification + serial_type: "SERIAL", // Type-replacement auto-increment + auto_increment_keyword: "", // No keyword (uses SERIAL types) + inline_pk: false, + alter_column: true, // ALTER COLUMN support + create_foreign_keys: true, // FK creation + manage_tables: true, // Full table DDL + explain: true, // EXPLAIN plan visualization + readonly: false, + triggers: true, // Trigger management + supports_ssl: true, // SSL/TLS configuration + sql_dialect: Postgres, // PG-specific statement splitting +} +``` + +--- + +## Findings: Active Bugs + +### Bug 1: Wrong Schema Name in DML Submission + +**Issue:** [#516](https://github.com/TabularisDB/tabularis/issues/516) + +**Status:** ⚠️ **Likely resolved by PR 402** — verify after merge + +**The Problem:** + +When a user has tables with the same name in different schemas (e.g., +`schemaA.app_settings` and `schemaB.app_settings`), editing a row in one schema +may generate DML that targets the wrong schema. The UPDATE/DELETE statement +references `schemaB.app_settings` when the user was editing `schemaA.app_settings`. + +**Why PR 402 Likely Fixes This:** + +PR 402 introduces per-tab `database` + `schema` routing. The specific commit +(86640146) fixed a regression where `isMultiDatabaseCapable` now including Postgres +caused the flat-driver fallback to send the PostgreSQL schema name as the database +parameter — routing every update/insert/delete to a pool for a database literally +named after the schema. The fix gates this with `!isSchemaBasedConn` and routes +via `buildTableRoutingParams` which uses `activeTab.database` and `activeTab.schema`. + +**Action Required:** + +After PR 402 merges, reproduce the bug (same table name in two schemas, edit in +schema A, verify DML targets schema A). If it persists, the fix is to ensure the +editor tab captures its schema at open time and never resolves dynamically from +the sidebar. + +**Complexity:** None (verify only) or Low (residual fix if needed) + +--- + +### Bug 2: Visual Query Builder Fails on Reserved-Word Table Names + +**Issue:** [#335](https://github.com/TabularisDB/tabularis/issues/335) + +**The Problem:** + +When a table is named with a PostgreSQL reserved word (e.g., `user`, `order`, +`group`), the Visual Query Builder generates unquoted identifiers: + +```sql +-- Generated (broken): +SELECT user.name FROM user + +-- Correct: +SELECT "user"."name" FROM "user" +``` + +**Root Cause:** + +The Visual Query Builder's SQL generation does not apply identifier quoting. The +`identifier_quote` capability is declared (`"\""`), but the Visual Query Builder +bypasses it. + +**Impact:** Visual Query Builder is unusable for any table with a reserved-word name. + +**Severity:** MEDIUM + +**Fix Direction:** + +The Visual Query Builder's SQL generation must quote all identifiers using the +driver's `identifier_quote` value. This applies to table names, column names, +schema names, and aliases. The safest approach is to always quote — this is valid +SQL regardless of whether the name is reserved. + +**Complexity:** Low-Medium (Visual Query Builder SQL generation) + +--- + +### Bug 3: Foreign Keys Not Visible with Restricted Privileges + +**Issue:** [#96](https://github.com/TabularisDB/tabularis/issues/96) + +**The Problem:** + +Users with read-only grants (`GRANT SELECT ON ALL TABLES`) cannot see foreign +keys. The `get_foreign_keys` function queries `pg_constraint` which requires +additional privileges beyond SELECT on the user tables. + +**Root Cause:** + +The FK query uses: + +```sql +FROM pg_constraint con +JOIN pg_class cls ON cls.oid = con.conrelid +JOIN pg_namespace ns ON ns.oid = cls.relnamespace +... +WHERE con.contype = 'f' +``` + +Access to `pg_constraint` requires `USAGE` on the schema AND visibility into +the constraint's owning table in `pg_class`. A strictly read-only user with only +`SELECT` grants may not have the necessary catalog visibility. + +**Impact:** FKs appear to not exist for users with limited permissions. + +**Severity:** LOW-MEDIUM + +**Fix Direction:** + +Provide a fallback query using `information_schema.referential_constraints` + +`information_schema.key_column_usage`, which respects standard SQL privilege +rules. Try the `pg_constraint` query first (it returns richer data including +update/delete rules), and fall back to the information_schema approach if the +primary query returns zero results or errors. + +```sql +-- Fallback query: +SELECT + tc.constraint_name, + kcu.column_name, + ccu.table_schema AS referenced_schema, + ccu.table_name AS referenced_table, + ccu.column_name AS referenced_column, + rc.update_rule, + rc.delete_rule +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema +JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema +JOIN information_schema.referential_constraints rc + ON rc.constraint_name = tc.constraint_name + AND rc.constraint_schema = tc.table_schema +WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = $1 + AND tc.table_name = $2 +``` + +**Complexity:** Medium (fallback logic + testing with restricted users) + +--- + +## Findings: Feature Gaps + +### Gap 1: Sequences — Browse, Inspect, Manage + +**Priority:** HIGH — Explicitly named in issue 16 + +**What PostgreSQL Provides:** + +Sequences are first-class objects in PostgreSQL. They power `SERIAL`/`BIGSERIAL` +columns, `GENERATED AS IDENTITY`, and can be used standalone for custom ID +generation across tables. + +**Catalog Sources:** + +- `pg_sequences` view (PG 10+): name, schema, data_type, start, min, max, + increment, cycle, cache_size, last_value +- `information_schema.sequences` (less detailed) +- `pg_class WHERE relkind = 'S'` (pre-PG10 fallback) + +**Proposed Feature Set:** + +| Operation | SQL | UI Location | +|-----------|-----|-------------| +| List sequences | `SELECT * FROM pg_sequences WHERE schemaname = $1` | Sidebar → "Sequences" group per schema | +| View properties | `SELECT * FROM pg_sequences WHERE schemaname = $1 AND sequencename = $2` | Properties panel or context menu → "Show Details" | +| View current value | `SELECT last_value FROM schema.sequence_name` | Shown in properties | +| Alter (restart) | `ALTER SEQUENCE schema.seq RESTART WITH n` | Context menu → "Restart…" with value input | +| Alter (properties) | `ALTER SEQUENCE schema.seq INCREMENT BY n MINVALUE m MAXVALUE M CACHE c [NO] CYCLE` | Edit dialog | +| Create | `CREATE SEQUENCE schema.name [AS type] [START WITH n] [INCREMENT BY n] ...` | Context menu on "Sequences" group → "New Sequence" | +| Drop | `DROP SEQUENCE IF EXISTS schema.name [CASCADE]` | Context menu → "Drop Sequence" with confirmation | +| Show DDL | Reconstruct `CREATE SEQUENCE` from metadata | Context menu → "Show Definition" | + +**Implementation Requirements:** + +1. **Backend (Rust):** + - New model: `SequenceInfo { name, schema, data_type, start_value, min_value, max_value, increment_by, cycle, cache_size, last_value, owner_table, owner_column }` + - New functions in `postgres/mod.rs`: `get_sequences`, `get_sequence_details`, `create_sequence`, `alter_sequence`, `drop_sequence`, `restart_sequence`, `get_sequence_ddl` + - New trait methods with default impls (empty/error) to avoid breaking other drivers + - New capability flag: `sequences: bool` in `DriverCapabilities` + - New Tauri commands: `get_sequences`, `get_sequence_details`, `create_sequence`, `alter_sequence`, `drop_sequence`, `restart_sequence` + +2. **Frontend (TypeScript/React):** + - New type: `SequenceInfo` in `src/types/schema.ts` + - Extend `SchemaData` interface to include `sequences?: SequenceInfo[]` + - New sidebar group in `SidebarSchemaItem` (between "Tables" and "Views") + - New component: `SidebarSequenceItem` + - Context menu actions: Show Details, Restart, Drop + - Sequence creation dialog + - Gate on `capabilities.sequences` + +3. **Localization:** + - Add keys to all 8 locale files (en, de, es, fr, it, ja, ru, zh) + +**Owner relationship:** Also display which table/column owns a sequence (via +`pg_depend` joining `pg_class` to `pg_attrdef`). This helps users understand +the link between `users.id SERIAL` and `users_id_seq`. + +**Complexity:** High (new schema object type end-to-end) + +--- + +### Gap 2: HSTORE Write Support + +**Priority:** HIGH — Issue [#395](https://github.com/TabularisDB/tabularis/issues/395) is open + +**Current State:** + +- **Read:** Works correctly. `extract/simple.rs` line 71 deserializes HSTORE + to `HashMap>` → JSON object via serde. +- **Write:** Not implemented. `binding.rs` has no HSTORE path. Users cannot + insert or update HSTORE columns through the data grid. + +**What PostgreSQL Expects:** + +HSTORE values are written as text literals: `'"key1"=>"value1", "key2"=>"value2"'` + +Or via the `hstore()` function: `hstore(ARRAY['key1','key2'], ARRAY['val1','val2'])` + +**Implementation:** + +In `binding.rs`, add a match arm for HSTORE columns: + +```rust +// When the incoming value is a JSON object and the column type is hstore: +serde_json::Value::Object(map) => { + if is_hstore_column { + // Serialize to PostgreSQL hstore literal format + let hstore_literal = map.iter() + .map(|(k, v)| { + let val = match v { + serde_json::Value::Null => "NULL".to_string(), + serde_json::Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")), + other => format!("\"{}\"", other), + }; + format!("\"{}\"=>{}", k.replace('\\', "\\\\").replace('"', "\\\""), val) + }) + .collect::>() + .join(", "); + // Bind as TEXT, let PostgreSQL cast to hstore + separated.push_bind(hstore_literal); + } +} +``` + +**Alternatively:** Use a simple TEXT bind with explicit CAST: + +```sql +UPDATE table SET hstore_col = $1::hstore WHERE pk = $2 +``` + +This requires knowing the column is HSTORE at bind time — similar to the existing +enum CAST logic in `get_enum_column_types`. + +**Frontend Enhancement (optional but valuable):** + +A key/value editor UI for HSTORE cells (similar to the JSON tree editor but +simpler — flat key→value pairs only, both strings). Since HSTORE values are +already extracted as JSON objects, the existing `json-edit-react` component could +be reused with constraints (no nesting, values are always strings or null). + +**Complexity:** Medium (binding logic + type detection; optional UI enhancement) + +--- + +### Gap 3: Structured JSONB Editing + +**Priority:** MEDIUM — Explicitly named in issue 16 + +**Current State:** + +- **Read:** Perfect. JSON/JSONB values are extracted as structured `serde_json::Value` + and displayed in a tree editor (`json-edit-react` component in `JsonTreeView.tsx`). +- **Write (full replace):** Works. Users can edit the full JSON text and submit. +- **Write (path-based):** Not supported. Users cannot update a single key within + a JSONB document without replacing the entire value. + +**What PostgreSQL Provides:** + +```sql +-- Path-based update (PG 14+): +UPDATE t SET data = jsonb_set(data, '{address,city}', '"Berlin"') WHERE id = 1; + +-- Remove a key: +UPDATE t SET data = data - 'deprecated_key' WHERE id = 1; + +-- Deep merge (PG 16+): +UPDATE t SET data = data || '{"new_key": "value"}' WHERE id = 1; +``` + +**Proposed Enhancement:** + +This is primarily a **frontend** improvement. When a user edits a single key in +the JSON tree editor: + +1. Detect which path was modified (the tree editor already tracks this) +2. Instead of sending the entire document as a replacement, send a + path-based update command +3. Backend generates `jsonb_set()` for the specific path + +**Benefits:** + +- Avoids overwriting concurrent changes to other keys in the same document +- More efficient for large JSONB documents +- Matches what users expect from a professional database tool + +**Implementation:** + +1. **Frontend:** Extend the JSON tree editor to emit path-based change events + (e.g., `{ path: ["address", "city"], value: "Berlin", operation: "set" }`) +2. **Backend:** New helper that generates `jsonb_set` / `jsonb_delete_path` SQL + based on the operation type +3. **Fallback:** If the server is < PG 14 or the change is complex (multiple + paths, restructuring), fall back to full-document replacement + +**Complexity:** High (frontend tree editor changes + backend SQL generation + version detection) + +--- + +### Gap 4: Table/Database Size Information + +**Priority:** MEDIUM — Universally expected in database tools + +**What PostgreSQL Provides:** + +```sql +-- Database size: +SELECT pg_size_pretty(pg_database_size(current_database())); + +-- Table size (data only): +SELECT pg_size_pretty(pg_table_size('schema.table')); + +-- Table size (with indexes): +SELECT pg_size_pretty(pg_total_relation_size('schema.table')); + +-- Index size: +SELECT pg_size_pretty(pg_indexes_size('schema.table')); + +-- All tables in a schema with sizes: +SELECT + schemaname, + relname AS table_name, + pg_size_pretty(pg_total_relation_size(schemaname || '.' || relname)) AS total_size, + pg_size_pretty(pg_table_size(schemaname || '.' || relname)) AS data_size, + pg_size_pretty(pg_indexes_size(schemaname || '.' || relname)) AS index_size, + n_live_tup AS estimated_rows +FROM pg_stat_user_tables +WHERE schemaname = $1 +ORDER BY pg_total_relation_size(schemaname || '.' || relname) DESC; +``` + +**Proposed Feature Set:** + +| Location | Information Shown | +|----------|-------------------| +| Sidebar table item (tooltip or badge) | Total relation size (compact, e.g., "12 MB") | +| Table properties panel | Data size, index size, total size, estimated rows, toast size | +| Database item (tooltip) | Database total size | +| Status bar (when table is open) | Table size + row estimate | + +**Implementation:** + +1. **Backend:** New function `get_table_sizes(params, schema) -> Vec` + that batch-fetches sizes for all tables in a schema (single query). + Model: `TableSizeInfo { name, data_size, index_size, total_size, toast_size, estimated_rows }` +2. **Backend:** New function `get_database_size(params) -> DatabaseSizeInfo` +3. **Tauri commands:** `get_table_sizes`, `get_database_size` +4. **Frontend:** Display size info in sidebar tooltips and a new properties section +5. **Caching:** Size data should be fetched lazily and cached (refreshed on demand), + not on every schema load — `pg_total_relation_size` can be slow on schemas + with thousands of tables. + +**Complexity:** Medium (new queries + UI display, but no new object type management) + +--- + +### Gap 5: Extensions List + +**Priority:** MEDIUM — Helps users understand available types and features + +**What PostgreSQL Provides:** + +```sql +-- Installed extensions: +SELECT + e.extname AS name, + e.extversion AS version, + n.nspname AS schema, + c.description +FROM pg_extension e +JOIN pg_namespace n ON n.oid = e.extnamespace +LEFT JOIN pg_description c ON c.objoid = e.oid AND c.classoid = 'pg_extension'::regclass +ORDER BY e.extname; + +-- Available (not yet installed): +SELECT name, default_version, comment +FROM pg_available_extensions +WHERE installed_version IS NULL +ORDER BY name; +``` + +**Proposed Feature Set:** + +| Operation | SQL | UI Location | +|-----------|-----|-------------| +| List installed | `SELECT FROM pg_extension ...` | Sidebar → "Extensions" group (or separate section) | +| View details | Extension name, version, schema, description | Tooltip or details panel | +| Create (install) | `CREATE EXTENSION name [SCHEMA schema] [VERSION version]` | Context menu → "Install Extension" with picker | +| Drop | `DROP EXTENSION name [CASCADE]` | Context menu → "Drop Extension" with cascade warning | + +**Implementation:** + +1. **Backend:** `get_extensions(params) -> Vec`, + `create_extension(params, name, schema, version)`, + `drop_extension(params, name, cascade)` +2. **Model:** `ExtensionInfo { name, version, schema, description, is_relocatable }` +3. **New capability flag:** `extensions: bool` (only PG sets this to true) +4. **Frontend:** New sidebar section or group within the schema tree +5. **Localization:** Keys for all 8 locales + +**Note:** Extension management requires superuser privileges in most configurations. +The UI should gracefully handle permission errors and still allow listing +(which requires less privilege). + +**Complexity:** Medium (simpler than sequences — fewer operations, no complex state) + +--- + +### Gap 6: Table Partition Awareness + +**Priority:** MEDIUM — Issue [#338](https://github.com/TabularisDB/tabularis/issues/338) + +**Current State:** + +The `get_tables` query fetches from `information_schema.tables WHERE table_type = 'BASE TABLE'`. +This returns **all** tables including partitions, with no distinction between: + +- Regular tables (`relkind = 'r'`) +- Partitioned parent tables (`relkind = 'p'`) +- Child partition tables (`relispartition = true`) + +Large production schemas with many partitions show a flat list of hundreds of +tables, making navigation difficult. + +**What PostgreSQL Provides:** + +```sql +-- Identify partitioned tables and their children: +SELECT + c.relname AS table_name, + c.relkind, -- 'p' = partitioned, 'r' = regular + c.relispartition, -- true = is a child partition + pg_get_expr(c.relpartbound, c.oid) AS partition_bound, -- e.g., "FOR VALUES FROM (1) TO (100)" + parent.relname AS parent_table +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +LEFT JOIN pg_inherits i ON i.inhrelid = c.oid +LEFT JOIN pg_class parent ON parent.oid = i.inhparent +WHERE n.nspname = $1 + AND c.relkind IN ('r', 'p') + AND c.relpersistence != 't' -- exclude temp tables +ORDER BY c.relname; +``` + +**Proposed UX:** + +```text +📁 Tables +├── 📋 users (regular table) +├── 📋 orders (regular table) +├── 📋 events [Partitioned] (relkind = 'p') +│ ├── 📋 events_2024_q1 (partition, collapsed by default) +│ ├── 📋 events_2024_q2 +│ ├── 📋 events_2024_q3 +│ └── 📋 events_2024_q4 +└── 📋 logs [Partitioned] + ├── 📋 logs_archive + └── 📋 logs_current +``` + +**Implementation:** + +1. **Backend:** Extend `get_tables` to return partition metadata: + - New fields on `TableInfo`: `is_partitioned: bool`, `is_partition: bool`, + `parent_table: Option`, `partition_bound: Option` + - Query `pg_class` directly instead of `information_schema.tables` (needed + for `relkind`, `relispartition`) +2. **Frontend:** `SidebarTableItem` nests partition children under their parent + when `is_partitioned: true`. Partitions are collapsed by default. +3. **Context menu on parent:** "Show Partition Info" → displays partition strategy + (RANGE, LIST, HASH) and all partition bounds. +4. **Optional:** Filter to hide partitions from the flat list entirely (user preference). + +**Compatibility:** The `relispartition` column exists from PG 10+. For PG 9.x (which +supports only inheritance-based partitioning), fall back to showing all tables flat. + +**Complexity:** High (modifies the core table-listing model, frontend tree restructuring) + +--- + +## Findings: Polish & Enhancements + +These are lower-priority improvements that enhance the professional feel of the +PostgreSQL driver without addressing critical functional gaps. + +### Enhancement 1: Column & Table Comments + +**What PostgreSQL Provides:** + +```sql +-- Table comment: +SELECT obj_description('"schema"."table"'::regclass, 'pg_class'); + +-- Column comments (batch): +SELECT + a.attname AS column_name, + col_description(c.oid, a.attnum) AS comment +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute a ON a.attrelid = c.oid +WHERE n.nspname = $1 AND c.relname = $2 + AND a.attnum > 0 AND NOT a.attisdropped; +``` + +**Proposed:** Add `comment` field to `TableColumn` model. Display as tooltip in +the sidebar column list and in the column header of the data grid. + +**Complexity:** Low (add a field + join in existing get_columns query) + +--- + +### Enhancement 2: Table Statistics (pg_stat_user_tables) + +**What PostgreSQL Provides:** + +```sql +SELECT + n_live_tup AS estimated_rows, + n_dead_tup AS dead_rows, + last_vacuum, + last_autovacuum, + last_analyze, + last_autoanalyze, + seq_scan, + idx_scan +FROM pg_stat_user_tables +WHERE schemaname = $1 AND relname = $2; +``` + +**Proposed:** Display in a table properties panel (accessible via context menu). +Useful for identifying tables needing VACUUM or ANALYZE. + +**Complexity:** Low (read-only query + new UI panel) + +--- + +### Enhancement 3: Custom Type Browser (Enums, Domains, Composites) + +**What PostgreSQL Provides:** + +```sql +-- All user-defined types: +SELECT + t.typname AS name, + n.nspname AS schema, + CASE t.typtype + WHEN 'e' THEN 'enum' + WHEN 'd' THEN 'domain' + WHEN 'c' THEN 'composite' + WHEN 'r' THEN 'range' + END AS kind, + -- For enums: list values + -- For domains: base type + constraints + -- For composites: column definitions +FROM pg_type t +JOIN pg_namespace n ON n.oid = t.typnamespace +WHERE t.typtype IN ('e', 'd', 'c', 'r') + AND n.nspname = $1 +ORDER BY t.typname; +``` + +**Proposed:** A "Types" group in the sidebar (gated on a new `custom_types: bool` +capability). Allows browsing enum values, domain base types and constraints, +composite field definitions. + +**Future:** CREATE TYPE / ALTER TYPE / DROP TYPE operations. + +**Complexity:** Medium (new object type, multiple sub-kinds with different display needs) + +--- + +### Enhancement 4: Schema & Database Management + +**Current State:** Users can browse schemas and databases, but cannot create, rename, +or drop them from the UI. + +**Proposed Operations:** + +| Operation | SQL | +|-----------|-----| +| Create schema | `CREATE SCHEMA name [AUTHORIZATION role]` | +| Drop schema | `DROP SCHEMA name [CASCADE\|RESTRICT]` | +| Create database | `CREATE DATABASE name [OWNER role] [TEMPLATE tmpl] [ENCODING enc]` | +| Drop database | `DROP DATABASE name` (must not be connected to it) | + +**Note:** `CREATE DATABASE` cannot run inside a transaction and requires the +connection to target a different database (typically `postgres`). This is a +UX challenge — the user would need to be connected to `postgres` or another +database to create a new one. + +**Complexity:** Medium (backend is simple; UX for cross-database operations is tricky) + +--- + +### Enhancement 5: Row-Level Security (RLS) Policies + +**What PostgreSQL Provides:** + +```sql +SELECT + pol.polname AS policy_name, + CASE pol.polcmd + WHEN 'r' THEN 'SELECT' + WHEN 'a' THEN 'INSERT' + WHEN 'w' THEN 'UPDATE' + WHEN 'd' THEN 'DELETE' + WHEN '*' THEN 'ALL' + END AS command, + pg_get_expr(pol.polqual, pol.polrelid) AS using_expression, + pg_get_expr(pol.polwithcheck, pol.polrelid) AS with_check_expression, + ARRAY(SELECT rolname FROM pg_roles WHERE oid = ANY(pol.polroles)) AS roles +FROM pg_policy pol +JOIN pg_class cls ON cls.oid = pol.polrelid +JOIN pg_namespace ns ON ns.oid = cls.relnamespace +WHERE ns.nspname = $1 AND cls.relname = $2; +``` + +**Proposed:** Read-only display of RLS policies in table properties panel. +Increasingly important for modern architectures (Supabase, multi-tenant). + +**Complexity:** Low (read-only introspection, new panel section) + +--- + +## Findings: Out of Scope + +These PostgreSQL features are deliberately excluded from this plan as they serve +specialized admin/DBA workflows beyond what a database browser/editor targets: + +| Feature | Reason | +|---------|--------| +| **Active connections / `pg_stat_activity`** | Admin monitoring tool territory (pgAdmin, pg_top) | +| **VACUUM / ANALYZE / REINDEX** | Maintenance operations; could be added as simple actions later | +| **Publications / Subscriptions** | Logical replication admin — very specialized | +| **Foreign Data Wrappers** | Specialized federated query setup | +| **Event triggers** | Rare; standard triggers cover 99% of use cases | +| **Tablespaces** | Physical storage admin | +| **Roles / Grants management** | Full role admin is complex; read-only role display possible later | +| **pg_hba.conf / Server config** | Server-side config, not accessible via SQL connection | +| **Inheritance (non-partition)** | Legacy feature, rarely used in modern PG | + +--- + +## Feature Comparison Matrix + +| Feature | PostgreSQL (current) | PostgreSQL (proposed) | MySQL | Notes | +|---------|---------------------|----------------------|-------|-------| +| **Schema Objects** | | | | | +| Tables | ✅ | ✅ | ✅ | Parity | +| Views | ✅ | ✅ | ✅ | Parity | +| Materialized Views | ✅ | ✅ | N/A | PG-exclusive | +| Sequences | ❌ | ✅ | N/A | **Gap 1** | +| Routines | ✅ | ✅ | ✅ | Parity | +| Triggers | ✅ | ✅ | ✅ | Parity | +| Extensions | ❌ | ✅ | N/A | **Gap 5** | +| Custom Types | ❌ | ✅ | N/A | **Enhancement 3** | +| Partitions (nested display) | ❌ | ✅ | N/A | **Gap 6** | +| **Data Operations** | | | | | +| CRUD (basic types) | ✅ | ✅ | ✅ | Parity | +| HSTORE write | ❌ | ✅ | N/A | **Gap 2** | +| JSONB path-based edit | ❌ | ✅ | N/A | **Gap 3** | +| BLOB read/write | ✅ | ✅ | ✅ | Parity | +| **Metadata** | | | | | +| Table/DB sizes | ❌ | ✅ | ❌ | **Gap 4** | +| Column comments | ❌ | ✅ | ❌ | **Enhancement 1** | +| Table statistics | ❌ | ✅ | ❌ | **Enhancement 2** | +| RLS Policies | ❌ | ✅ | N/A | **Enhancement 5** | +| **Bug Fixes** | | | | | +| Schema in DML | ⚠️ Bug 516 | ✅ | N/A | **Bug 1** | +| Reserved-word quoting | ⚠️ Bug 335 | ✅ | N/A | **Bug 2** | +| FK with restricted user | ⚠️ Bug 96 | ✅ | N/A | **Bug 3** | + +--- + +## Implementation Plan + +### Tier 1: Bug Fixes (Critical Path) + +These should be addressed first as they affect basic usability. + +| Item | Severity | Complexity | Dependencies | +|------|----------|------------|--------------| +| Bug 516 — Schema context in DML | HIGH | Verify only | PR 402 must merge first | +| Bug 335 — Identifier quoting in VQB | MEDIUM | Low-Medium | None | +| Bug 96 — FK fallback for restricted users | LOW-MEDIUM | Medium | None | + +**Estimated effort:** 1-2 days (Bug 516 is verification; 335 and 96 are the real work) + +--- + +### Tier 2: Core Feature Gaps (Issue 16 Deliverables) + +These directly address the items called out in the issue. + +| Item | Priority | Complexity | Dependencies | +|------|----------|------------|--------------| +| Gap 1 — Sequences | HIGH | High | PR 402 merged (database routing pattern) | +| Gap 2 — HSTORE write | HIGH | Medium | Column type detection in binding | +| Gap 3 — JSONB path editing | MEDIUM | High | Frontend tree editor changes | +| Gap 4 — Table/DB sizes | MEDIUM | Medium | PR 402 merged (database routing pattern) | + +**Estimated effort:** 1-2 weeks + +**Implementation order:** HSTORE write (smaller, unblocks issue 395, no 402 +dependency) → Sequences (largest new feature, requires 402 routing pattern) → +Table sizes (independent) → JSONB path editing (highest complexity, can follow +later) + +**Critical constraint:** All new Tauri commands for Gaps 1 and 4 must follow the +`database: Option` routing pattern established by PR 402. See the +[Dependency section](#dependency-pr-402--multi-database-connections) for the +exact code pattern. + +--- + +### Tier 3: Schema Object Discovery + +New browsable object types in the sidebar. + +| Item | Priority | Complexity | Dependencies | +|------|----------|------------|--------------| +| Gap 5 — Extensions | MEDIUM | Medium | PR 402 merged (sidebar propagation) | +| Gap 6 — Partition awareness | MEDIUM | High | PR 402 merged (modifies core TableInfo) | +| Enhancement 3 — Custom Types | LOW-MEDIUM | Medium | PR 402 merged (sidebar propagation) | + +**Estimated effort:** 1-2 weeks + +**Implementation order:** Extensions (simpler, high visibility) → Partitions +(high value for production users but complex) → Custom Types + +**Critical constraint:** New sidebar groups must propagate `database` to their +child items following the same pattern as `SidebarSchemaItem` → `SidebarTableItem`. +PR 402 established this propagation chain; our new groups (Sequences, Extensions, +Types) must participate in it. + +--- + +### Tier 4: Metadata & Polish + +Read-only informational additions that enhance the professional feel. + +| Item | Priority | Complexity | Dependencies | +|------|----------|------------|--------------| +| Enhancement 1 — Column/table comments | LOW-MEDIUM | Low | None | +| Enhancement 2 — Table statistics | LOW | Low | None | +| Enhancement 4 — Schema/DB management | LOW-MEDIUM | Medium | Cross-DB UX design | +| Enhancement 5 — RLS Policies | LOW | Low | None | + +**Estimated effort:** 3-5 days + +--- + +## Testing Strategy + +### Unit Tests (Rust) + +```text +tests/drivers/postgres/ +├── sequences.test.rs +│ ├── get_sequences returns all sequences in schema +│ ├── get_sequence_details returns full metadata +│ ├── create_sequence generates valid DDL +│ ├── alter_sequence modifies properties correctly +│ ├── drop_sequence removes without error +│ ├── restart_sequence resets last_value +│ ├── owned-by relationship resolved correctly +│ └── schema-qualified names handled (non-public schema) +├── hstore_binding.test.rs +│ ├── insert_record with HSTORE JSON object succeeds +│ ├── update_record with HSTORE JSON object succeeds +│ ├── empty HSTORE (empty object) handled +│ ├── HSTORE with NULL values preserved +│ ├── HSTORE with special characters in keys/values +│ ├── HSTORE with unicode content +│ └── round-trip: insert HSTORE → select → compare +├── foreign_key_fallback.test.rs +│ ├── FK query succeeds for superuser (primary path) +│ ├── FK query fallback fires for restricted user +│ ├── Fallback returns same structure as primary +│ ├── FKs across schemas resolved correctly +│ └── Self-referencing FKs handled in both paths +├── extensions.test.rs +│ ├── get_extensions lists installed extensions +│ ├── Extension details include schema and version +│ └── Extensions from non-default schemas included +├── partitions.test.rs +│ ├── Partitioned table identified (relkind = 'p') +│ ├── Child partitions linked to parent +│ ├── Partition bound expression included +│ ├── Regular tables unaffected +│ └── Mixed schemas handled correctly +└── sizes.test.rs + ├── get_table_sizes returns data for all tables + ├── Sizes are human-readable + ├── Empty tables report 0 or minimal size + └── Schema-qualified tables handled +``` + +### Frontend Tests + +```text +tests/components/layout/sidebar/ +├── SidebarSequenceItem.test.tsx +│ ├── Renders sequence with correct icon +│ ├── Context menu shows Restart / Drop options +│ ├── Sequence group hidden when capability is false +│ └── Sequence group shows count badge +├── SidebarSchemaItem.test.tsx (extend) +│ ├── Partitioned tables render with nested partitions +│ ├── Partitions collapsed by default +│ ├── Extensions group rendered when capability is true +│ └── Sequences group rendered when capability is true +└── Editor.test.tsx (extend) + ├── Schema context retained per tab (not global) + └── DML uses tab's original schema, not sidebar selection +``` + +### Integration Tests + +- Connect with restricted-privilege user → verify FKs visible via fallback +- Create sequence → restart → verify new value → drop +- Insert HSTORE via grid → SELECT → verify round-trip +- Open table in schemaA → switch sidebar to schemaB → submit edit → verify targets schemaA +- Visual Query Builder with reserved-word table → verify quoted SQL generated +- Schema with 50+ partition tables → verify parent/child nesting in sidebar +- Install extension → verify it appears in list → drop + +--- + +## Open Questions + +1. **Sequence sidebar placement** — Should sequences be a peer group to "Tables" + and "Views" within each schema? Or a separate top-level section? Peer group + (inside the schema accordion) seems consistent with how other drivers organize + schema objects. + +2. **Partition nesting default** — Should partitions be hidden by default (with a + toggle to show), or shown nested under their parent (collapsed)? The latter + matches DBeaver's behavior and is less surprising. + +3. **JSONB path editing scope** — Should path-based editing be limited to + `jsonb_set` on leaf values, or also support structural operations (add key, + remove key, move key)? The tree editor already supports these operations + visually — the question is whether to wire them to path-based SQL or always + fall back to full-document replacement for structural changes. + +4. **Table sizes: eager vs. lazy** — Should table sizes be fetched alongside + `get_tables` (adds latency to schema load) or on-demand (e.g., when user + hovers or expands a table)? For schemas with thousands of tables, + `pg_total_relation_size` across all tables can be slow. Recommended: lazy + fetch with caching. + +5. **Extension management privileges** — `CREATE EXTENSION` typically requires + superuser. Should the UI hide the "Install Extension" action entirely for + non-superusers, or show it and let the error propagate? Recommended: always + show; handle the permission error with a clear message ("requires superuser + privileges"). + +6. **HSTORE column detection** — Should we detect HSTORE columns proactively + (like we do for enums in `get_enum_column_types`) to apply proper binding, + or use a reactive approach (detect from the error when a plain TEXT bind fails)? + Recommended: proactive detection via `pg_type` — consistent with the enum pattern. + +7. **Backward compatibility for `TableInfo`** — Adding `is_partitioned`, + `is_partition`, `parent_table` fields to `TableInfo` affects all drivers. + Should these be `Option` fields with `serde(default)` to avoid breaking + the MySQL/SQLite drivers, or should each driver explicitly return + `false`/`None`? + +8. **PR 402 merge timing** — Our Tier 2 and 3 work depends on PR 402's routing + pattern being in `main`. If 402 stalls (it has merge conflicts and no formal + reviews yet), should we proceed with HSTORE write support (which has no 402 + dependency) and defer sequence/extensions work? Or should we resolve 402's + conflicts and help get it merged first? + +9. **PR 402's DDL routing gap** — PR 402 explicitly leaves object-creation DDL + (Create Table/View/Trigger/Index/FK) as not database-aware on nested schema + nodes. Should we fix this as part of our Enhancement 4 (Schema/DB management) + work, or is it a separate follow-up PR that should go in between 402 and our + work? + +--- + +## Appendix: SQL Reference for Implementation + +### Sequence Introspection (PG 10+) + +```sql +SELECT + s.sequencename AS name, + s.schemaname AS schema, + s.data_type, + s.start_value, + s.min_value, + s.max_value, + s.increment_by, + s.cycle, + s.cache_size, + s.last_value, + -- Owner info (which table.column owns this sequence): + d.refobjid::regclass AS owner_table, + a.attname AS owner_column +FROM pg_sequences s +LEFT JOIN pg_depend d + ON d.objid = (s.schemaname || '.' || s.sequencename)::regclass + AND d.deptype = 'a' + AND d.classid = 'pg_class'::regclass +LEFT JOIN pg_attribute a + ON a.attrelid = d.refobjid + AND a.attnum = d.refobjsubid +WHERE s.schemaname = $1 +ORDER BY s.sequencename; +``` + +### Partition Hierarchy + +```sql +SELECT + c.relname AS table_name, + c.relkind, + c.relispartition, + CASE + WHEN pt.partstrat = 'r' THEN 'RANGE' + WHEN pt.partstrat = 'l' THEN 'LIST' + WHEN pt.partstrat = 'h' THEN 'HASH' + END AS partition_strategy, + pg_get_expr(c.relpartbound, c.oid) AS partition_bound, + parent.relname AS parent_table +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +LEFT JOIN pg_inherits i ON i.inhrelid = c.oid +LEFT JOIN pg_class parent ON parent.oid = i.inhparent +LEFT JOIN pg_partitioned_table pt ON pt.partrelid = c.oid +WHERE n.nspname = $1 + AND c.relkind IN ('r', 'p') + AND c.relpersistence != 't' +ORDER BY + COALESCE(parent.relname, c.relname), -- Group children with parent + c.relispartition, -- Parent first + c.relname; +``` + +### Extension Details + +```sql +SELECT + e.extname AS name, + e.extversion AS version, + n.nspname AS schema, + e.extrelocatable AS is_relocatable, + c.description +FROM pg_extension e +JOIN pg_namespace n ON n.oid = e.extnamespace +LEFT JOIN pg_description c + ON c.objoid = e.oid + AND c.classoid = 'pg_extension'::regclass +ORDER BY e.extname; +``` + +### HSTORE Binding Format + +```text +-- PostgreSQL HSTORE text representation: +'"key1"=>"value1", "key2"=>"value2", "null_key"=>NULL' + +-- Escaping rules: +-- - Keys and values are double-quoted +-- - Backslash and double-quote within values are backslash-escaped +-- - NULL (unquoted) represents a null value +-- - Empty HSTORE is an empty string: '' +``` + +### Column Comments (batch) + +```sql +SELECT + a.attname AS column_name, + col_description(c.oid, a.attnum) AS comment +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +JOIN pg_attribute a ON a.attrelid = c.oid +WHERE n.nspname = $1 + AND c.relname = $2 + AND a.attnum > 0 + AND NOT a.attisdropped +ORDER BY a.attnum; +``` diff --git a/docs/planning/postgres-plugin-migration-original.md b/docs/planning/postgres-plugin-migration-original.md new file mode 100644 index 0000000..76fb7ed --- /dev/null +++ b/docs/planning/postgres-plugin-migration-original.md @@ -0,0 +1,925 @@ +# PostgreSQL Plugin Migration — Phased Implementation Plan + +**Ref:** [#16 — Better PostgreSQL Support](https://github.com/TabularisDB/tabularis/issues/16) +**Related:** [PR #402 — Multi-database connections](https://github.com/TabularisDB/tabularis/pull/402) +**Direction:** Per debba — all drivers should eventually become plugins; built-in +drivers will be removed over time. + +## Executive Summary + +This plan migrates the built-in PostgreSQL driver to a standalone plugin driver, +achieving full feature parity before adding the multi-database capabilities from +PR #402 and the improvements from issue #16. The approach is incremental — each +phase delivers working software that can be tested and shipped independently. + +--- + +## Table of Contents + +1. [Architecture Context](#architecture-context) +2. [Critical Constraint: The BUILTIN_DRIVER_IDS Guard](#critical-constraint) +3. [Migration Strategy](#migration-strategy) +4. [RPC Adapter Blockers and Gotchas](#rpc-adapter-blockers-and-gotchas) +5. [Phase 0: Baseline Test Suite](#phase-0-baseline-test-suite-before-any-migration) +6. [Phase 1: Plugin Scaffold with Feature Parity](#phase-1-plugin-scaffold-with-feature-parity) +7. [Phase 2: Multi-Database Support (PR 402)](#phase-2-multi-database-support-pr-402) +8. [Phase 3: Issue 16 Improvements](#phase-3-issue-16-improvements) +9. [Phase 4: Deprecate Built-in Driver](#phase-4-deprecate-built-in-driver-deferred-decision) +10. [Plugin Architecture Reference](#plugin-architecture-reference) +11. [PR 402 Architecture Summary](#pr-402-architecture-summary) +12. [Dependency Sequencing](#dependency-sequencing) +13. [Developer Workflow](#developer-workflow) +14. [Risk Assessment](#risk-assessment) +15. [Open Questions](#open-questions) + +--- + +## Architecture Context + +### How Plugin Drivers Work + +Tabularis plugin drivers are **standalone executables** that communicate with the +host via **JSON-RPC 2.0 over stdin/stdout**. Each plugin: + +- Declares capabilities in a `.tabularium` manifest file +- Is spawned as a child process at startup (or on enable) +- Receives method calls as JSON-RPC requests on stdin +- Returns results as JSON-RPC responses on stdout +- Manages its own connection pooling internally +- Is killed on disable/uninstall (`kill_on_drop: true`) + +### Current Built-in PostgreSQL Driver + +- Location: `src-tauri/src/drivers/postgres/mod.rs` (2420 lines) +- Uses `sqlx` with `deadpool-postgres` for connection pooling +- 6 extraction submodules (simple, array, range, multi_range, composite, enum, advanced) +- Full typed binding system (473 lines in `binding.rs`) +- Routine management (overloaded function resolution) +- Schema-qualified identifier handling throughout +- 97+ declared data types across 14 categories + +--- + +## Critical Constraint + +### The `BUILTIN_DRIVER_IDS` Guard + +In `src-tauri/src/plugins/manager.rs` lines 164-169: + +```rust +const BUILTIN_DRIVER_IDS: [&str; 3] = ["mysql", "postgres", "sqlite"]; +if BUILTIN_DRIVER_IDS.contains(&&plugin_id.as_str()) { + return Err(format!( + "Plugin id '{}' collides with a built-in driver and was refused", + plugin_id + )); +} +``` + +**A plugin cannot use the id `"postgres"`.** This means: + +| Option | Approach | Impact | +| ------ | -------- | ------ | +| A | Use a different id (e.g., `"postgres-plugin"`) | Existing connections won't auto-migrate; users must reconnect or we need a migration script | +| B | Remove the guard before installing the plugin | Requires a Tabularis core change; allows seamless `driver: "postgres"` swap | +| C | Remove the built-in driver AND the guard simultaneously | Clean swap — plugin takes over the `"postgres"` id slot | + +**Recommended: Option C eventually, but deferred.** During development, the plugin +uses the id `"postgres-plugin"`. The question of whether/how to remove the guard +and take over the `"postgres"` id is a decision for later — once feature parity is +proven and the team agrees on a migration path for existing connections. + +--- + +## Migration Strategy + +```text +Phase 0: Build baseline test suite + CI infrastructure (PREREQUISITE) + ↓ +Phase 1: Build plugin "postgres-plugin" with full feature parity + ↓ +Phase 2: Integrate PR 402 multi-database support into plugin + ↓ +Phase 3: Add issue #16 improvements (sequences, JSONB editing, etc.) + ↓ +Phase 4: Deprecate built-in driver (decision deferred) +``` + +Each phase is independently shippable: + +- After Phase 0: Confidence in the built-in driver's behavior (test baseline) +- After Phase 1: Users can test the plugin alongside the built-in driver +- After Phase 2: Plugin surpasses built-in in functionality +- After Phase 3: Plugin is the definitive PostgreSQL experience +- After Phase 4: Clean architecture — one plugin, no built-in + +--- + +## RPC Adapter Blockers and Gotchas + +Before building the plugin, these limitations in the host's `RpcDriver` adapter +(`src-tauri/src/plugins/driver.rs`) must be understood and addressed. Some require +changes to the Tabularis core; others must be handled plugin-side. + +### P0 — Must Fix Before Feature Parity Is Possible + +| Issue | Detail | Resolution | +| ----- | ------ | ---------- | +| **BLOB methods not forwarded** | `save_blob_to_file` and `fetch_blob_as_data_url` inherit trait defaults that return "not supported". Built-in PG driver reads bytea data and exports to file or base64 wire format. | Extend the RpcDriver to forward these calls. Plugin returns base64 data over JSON; host writes to file. Requires Tabularis core PR. | +| **Materialized views not forwarded** | `get_materialized_views`, `get_materialized_view_columns`, `get_materialized_view_definition`, `refresh_materialized_view` all inherit empty defaults. | Extend the RpcDriver to forward these 4 methods. Straightforward — same pattern as triggers. Requires Tabularis core PR. | +| **`map_inferred_type` not forwarded** | Synchronous method — cannot issue RPC call. Built-in PG maps `DATETIME`→`TIMESTAMP`, `JSON`→`JSONB`. | Plugin declares mappings in manifest/settings at `initialize` time. Host stores them and applies locally. Requires core change to `RpcDriver`. | + +### P1 — Must Handle in Plugin Implementation + +| Issue | Detail | Resolution | +| ----- | ------ | ---------- | +| **Query cancellation** | Host aborts the Tokio task but plugin keeps executing. No signal reaches the DB server. | Plugin implements an internal `cancel_query` mechanism using `pg_cancel_backend()` or connection drop. Discuss with team whether a `cancel` RPC method should be added to the protocol. | +| **`execute_query_batch` session state** | If plugin doesn't implement this, fallback uses separate RPC calls (separate connections). Breaks `BEGIN`/`COMMIT`, temp tables, `SET` commands. | Plugin MUST implement `execute_query_batch` using a single connection for the entire batch. Non-negotiable for PG. | +| **Startup script execution** | Host passes `startup_script` in `ConnectionParams` but does NOT execute it. Plugin must detect and run it on every new pooled connection. | Plugin implements `after_connect` hook in its internal pool that executes `params.startup_script`. | +| **120-second hard timeout** | Long queries (VACUUM, migrations, large aggregations) will timeout. | For now: document the limitation. Later: propose configurable timeout per plugin setting. | + +### P2 — Acceptable for Initial Release, Fix Later + +| Issue | Detail | +| ----- | ------ | +| **No streaming for large results** | Full JSON response in one line. Memory spike for 10K+ row results. Acceptable with pagination (host passes `limit`/`page`). | +| **Batch progress fires post-completion** | UI doesn't show per-statement progress during native batch. Acceptable — same behavior as some existing drivers. | +| **Plaintext password over stdio** | Local pipes only, same user. Acceptable security posture for desktop app. | +| **SSH params still in serialized ConnParams** | Plugin should ignore them (host already tunneled). Document in plugin guide. | +| **Static data_types** | Extension types (PostGIS, pgvector) won't appear in picker. Solve later with dynamic type discovery. | +| **Plugin crash = 120s hang for in-flight calls** | Acceptable for now. Later: fast-fail detection + auto-restart. | + +--- + +## Phase 0: Baseline Test Suite (Before Any Migration) + +### Why Phase 0 Exists + +The current PostgreSQL driver test coverage has critical gaps: + +| Category | Status | +| -------- | ------ | +| Value extraction (wire format parsing) | ✅ 162 unit tests — excellent | +| Parameter binding (type coercion) | ✅ 96 unit tests — excellent | +| Public API functions (36 methods) | ❌ Zero dedicated tests | +| Trait-level interface tests | ❌ Zero tests | +| Integration tests | ⚠️ 4 tests, all `#[ignore]`, never run in CI | +| Cross-driver parity tests | ❌ None | +| EXPLAIN parsing | ❌ Zero tests | +| BLOB handling | ❌ Zero tests | +| DDL generation | ❌ Zero tests | + +**We cannot prove feature parity without a baseline.** Phase 0 creates the test +infrastructure that will be used to verify both the built-in driver AND the plugin +produce identical results. + +### Phase 0 Deliverables + +#### 0.1: CI PostgreSQL Service + +Add a PostgreSQL service container to the CI workflow so integration tests run +automatically on every PR: + +```yaml +# .github/workflows/ci.yml addition +services: + postgres: + image: postgres:16 + ports: + - 54320:5432 + env: + POSTGRES_PASSWORD: test + POSTGRES_DB: tabularis_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 +``` + +Remove `#[ignore]` from integration tests and gate on `services.postgres`. + +#### 0.2: Parity Test Harness + +A test framework that runs the same assertions against both the built-in driver +and (later) the plugin, ensuring identical behavior: + +```rust +// tests/parity/harness.rs +pub struct ParityTestHarness { + builtin: Box, + plugin: Option>, // Added in Phase 1 +} + +impl ParityTestHarness { + pub async fn assert_same( + &self, + method: &str, + builtin_result: Result, + plugin_result: Result, + ) { + assert_eq!(builtin_result, plugin_result, + "Parity failure in {}: built-in and plugin returned different results", method); + } +} +``` + +#### 0.3: Golden File Tests for API Surface + +Capture the exact output of every public method against a known test database +as golden/snapshot files: + +```text +tests/parity/golden/ +├── get_tables.json # Expected table list +├── get_columns_users.json # Expected columns for test table +├── get_indexes_users.json # Expected indexes +├── get_foreign_keys_orders.json # Expected FKs +├── get_views.json # Expected views +├── get_routines.json # Expected functions +├── get_triggers.json # Expected triggers +├── execute_query_types.json # Result of SELECT with every PG type +├── explain_simple.json # EXPLAIN output for simple query +├── explain_analyze.json # EXPLAIN ANALYZE output +├── get_materialized_views.json # MV listing +└── ddl/ + ├── create_table.sql # Generated CREATE TABLE + ├── add_column.sql # Generated ALTER TABLE ADD COLUMN + └── create_index.sql # Generated CREATE INDEX +``` + +These golden files become the parity contract. The plugin must produce output +that matches these files exactly (or with documented acceptable differences). + +#### 0.4: Integration Test Expansion + +Add dedicated integration tests for every public method that currently has zero +test coverage: + +```text +tests/integration/postgres/ +├── schema_discovery.rs +│ ├── test_get_schemas +│ ├── test_get_databases +│ ├── test_get_tables (with and without schema filter) +│ └── test_get_tables_system_tables_excluded +├── column_metadata.rs +│ ├── test_get_columns_all_types +│ ├── test_get_columns_nullable_detection +│ ├── test_get_columns_pk_detection +│ ├── test_get_columns_auto_increment_serial +│ ├── test_get_columns_default_values +│ └── test_get_columns_character_max_length +├── foreign_keys.rs +│ ├── test_get_foreign_keys_basic +│ ├── test_get_foreign_keys_composite +│ ├── test_get_foreign_keys_cross_schema +│ └── test_get_foreign_keys_on_delete_cascade +├── indexes.rs +│ ├── test_get_indexes_btree +│ ├── test_get_indexes_unique +│ ├── test_get_indexes_composite +│ └── test_get_indexes_partial +├── views.rs +│ ├── test_get_views +│ ├── test_get_view_definition +│ ├── test_get_view_columns +│ ├── test_create_view +│ ├── test_alter_view +│ └── test_drop_view +├── materialized_views.rs +│ ├── test_get_materialized_views +│ ├── test_get_mv_definition +│ ├── test_get_mv_columns +│ └── test_refresh_mv +├── routines.rs +│ ├── test_get_routines_functions +│ ├── test_get_routines_procedures +│ ├── test_get_routine_parameters +│ ├── test_get_routine_definition +│ ├── test_routine_create_template +│ └── test_drop_routine_overloaded +├── triggers.rs +│ ├── test_get_triggers +│ ├── test_get_trigger_definition +│ ├── test_create_trigger +│ └── test_drop_trigger +├── crud.rs +│ ├── test_insert_all_types +│ ├── test_insert_with_enum_cast +│ ├── test_insert_json_object +│ ├── test_insert_array_value +│ ├── test_update_with_pk +│ ├── test_update_composite_pk +│ ├── test_update_uuid_pk +│ ├── test_delete_single_pk +│ └── test_delete_composite_pk +├── ddl_generation.rs +│ ├── test_create_table_sql +│ ├── test_add_column_sql +│ ├── test_alter_column_rename +│ ├── test_alter_column_type +│ ├── test_create_index_sql +│ ├── test_create_foreign_key_sql +│ └── test_drop_index_sql +├── explain.rs +│ ├── test_explain_simple_select +│ ├── test_explain_analyze +│ └── test_explain_with_buffers +├── blob.rs +│ ├── test_save_blob_to_file +│ ├── test_fetch_blob_as_data_url +│ └── test_blob_round_trip +└── query_execution.rs + ├── test_execute_query_basic + ├── test_execute_query_with_pagination + ├── test_execute_query_all_types_roundtrip + ├── test_execute_batch_transaction + ├── test_execute_batch_temp_tables + └── test_execute_batch_set_commands +``` + +#### 0.5: Test Database Seed Script + +A repeatable seed script that creates the test schema used by all tests: + +```sql +-- tests/fixtures/postgres_seed.sql +CREATE SCHEMA IF NOT EXISTS test_schema; + +CREATE TABLE test_schema.all_types ( + id SERIAL PRIMARY KEY, + col_text TEXT, + col_varchar VARCHAR(255), + col_int INTEGER, + col_bigint BIGINT, + col_float REAL, + col_double DOUBLE PRECISION, + col_numeric NUMERIC(10,2), + col_bool BOOLEAN, + col_date DATE, + col_time TIME, + col_timestamp TIMESTAMP, + col_timestamptz TIMESTAMPTZ, + col_uuid UUID, + col_json JSON, + col_jsonb JSONB, + col_bytea BYTEA, + col_inet INET, + col_cidr CIDR, + col_macaddr MACADDR, + col_int_array INTEGER[], + col_text_array TEXT[], + col_int4range INT4RANGE, + col_tsrange TSRANGE +); + +CREATE TYPE test_schema.mood AS ENUM ('happy', 'sad', 'neutral'); +CREATE TABLE test_schema.with_enum ( + id SERIAL PRIMARY KEY, + current_mood test_schema.mood +); + +-- ... (tables with FKs, indexes, triggers, routines, views, MVs) +``` + +### Phase 0 Success Criteria + +- [ ] All 4 existing integration tests pass in CI (un-ignored, PG service running) +- [ ] 50+ new integration tests covering the full API surface +- [ ] Golden files captured for every public method +- [ ] Parity harness infrastructure ready (built-in driver fills it today) +- [ ] Seed script creates a comprehensive test schema +- [ ] CI runs in < 5 minutes with PG service + +--- + +## Phase 1: Plugin Scaffold with Feature Parity + +### Goal + +A standalone Rust plugin that implements every method the built-in PostgreSQL +driver currently supports, passing the same test suite. + +### Scaffold Structure + +```text +plugins/postgres-plugin/ +├── .tabularium # Plugin manifest +├── Cargo.toml # Rust project +├── src/ +│ ├── main.rs # Stdin/stdout JSON-RPC loop +│ ├── rpc.rs # Method dispatch router +│ ├── models.rs # ConnectionParams, shared types +│ ├── pool.rs # Connection pool management (tokio-postgres) +│ ├── handlers/ +│ │ ├── metadata.rs # get_tables, get_columns, get_views, etc. +│ │ ├── query.rs # execute_query, execute_query_batch +│ │ ├── crud.rs # insert_record, update_record, delete_record +│ │ ├── ddl.rs # get_create_table_sql, get_add_column_sql, etc. +│ │ ├── routines.rs # get_routines, build_routine_call_sql, etc. +│ │ ├── explain.rs # explain_query_plan +│ │ └── blob.rs # save_blob_to_file, fetch_blob_as_data_url +│ ├── binding.rs # Typed parameter binding (enum CAST, etc.) +│ ├── extract/ # Value extraction from PG rows +│ │ ├── mod.rs +│ │ ├── simple.rs # Basic types +│ │ ├── array.rs # PG arrays +│ │ ├── range.rs # Range types +│ │ ├── multi_range.rs # Multi-range types +│ │ ├── composite.rs # Composite/record types +│ │ ├── enum_type.rs # Enum extraction +│ │ └── advanced.rs # UUID, JSONB, geometric, etc. +│ └── types.rs # Data type declarations (97+ types) +└── tests/ + ├── metadata_test.rs + ├── query_test.rs + ├── crud_test.rs + └── ddl_test.rs +``` + +### Manifest (`.tabularium`) + +```json +{ + "id": "postgres-plugin", + "name": "PostgreSQL (Next)", + "version": "0.1.0", + "description": "Next-generation PostgreSQL driver plugin", + "executable": "postgres-plugin", + "default_port": 5432, + "default_username": "postgres", + "color": "#336791", + "icon": "postgres", + "engine": "PostgreSQL", + "paradigms": ["relational"], + "capabilities": { + "schemas": true, + "views": true, + "materialized_views": true, + "routines": true, + "routine_management": true, + "triggers": true, + "file_based": false, + "connection_string": true, + "connection_string_example": "postgresql://user:pass@host:5432/dbname", + "alter_primary_key": true, + "alter_column": true, + "create_foreign_keys": true, + "explain": true, + "supports_ssl": true, + "sql_dialect": "Postgres", + "identifier_quote": "\"", + "manage_tables": true, + "serial_type": "SERIAL", + "auto_increment_keyword": "" + }, + "settings": [ + { + "key": "sslMode", + "label": "SSL Mode", + "setting_type": "select", + "default": "prefer", + "options": ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"] + }, + { + "key": "statementTimeout", + "label": "Statement Timeout (ms)", + "setting_type": "number", + "default": 0, + "description": "0 = no timeout" + } + ], + "data_types": [] +} +``` + +### RPC Methods to Implement (Full List) + +| Category | Methods | +| -------- | ------- | +| Connection | `initialize`, `ping`, `test_connection`, `shutdown` | +| Databases | `get_databases`, `get_schemas` | +| Metadata | `get_tables`, `get_columns`, `get_views`, `get_view_definition`, `get_view_columns`, `get_indexes`, `get_foreign_keys`, `get_triggers`, `get_trigger_definition`, `get_routines`, `get_routine_parameters`, `get_routine_definition` | +| Query | `execute_query`, `execute_query_batch`, `count_query` | +| CRUD | `insert_record`, `update_record`, `delete_record` | +| BLOB | `save_blob_to_file`, `fetch_blob_as_data_url` | +| DDL | `get_create_table_sql`, `get_add_column_sql`, `get_alter_column_sql`, `get_create_index_sql`, `drop_index`, `get_create_foreign_key_sql`, `drop_foreign_key` | +| Views | `create_view`, `alter_view`, `drop_view` | +| Triggers | `create_trigger`, `drop_trigger`, `update_trigger` | +| Routines | `build_routine_call_sql`, `routine_create_template`, `get_routine_edit_script`, `drop_routine` | +| Explain | `explain_query_plan` | +| AI | `get_ai_schema_context` | + +### Key Technical Decisions + +| Decision | Choice | Rationale | +| -------- | ------ | --------- | +| PostgreSQL client library | `tokio-postgres` | Direct async access, full type system control. Matches what sqlx uses internally. | +| Connection pooling | `deadpool-postgres` | Production-grade pool with configurable size, timeouts, and recycling. Key pools by `host:port:database:user`. | +| Typed binding | Port existing `binding.rs` logic | Critical for enum CASTs, UUID handling, array bindings | +| Value extraction | Port existing `extract/` submodules | Needed for proper array, range, composite, enum rendering | +| SSL support | `tokio-postgres-rustls` | Matches existing SSL capability. `rustls` avoids OpenSSL dependency. | +| Binary format | Text protocol initially, binary later | Text is simpler to port; binary can be optimized later | + +### Phase 1 Success Criteria — Zero Wiggle Room + +Phase 1 is **not done** until: + +1. **All Phase 0 golden file tests pass with the plugin driver** — The parity + harness runs every test against both the built-in driver and the plugin, + asserting identical results. Zero tolerance for differences. + +2. **The full integration test suite passes with the plugin** — Same 50+ tests + that validate the built-in driver must pass when pointed at the plugin. + +3. **Manual smoke test checklist** (all pass): + - [ ] Connect to PG via host/port + - [ ] Connect via connection string + - [ ] Connect via SSL (all modes) + - [ ] Browse schemas in sidebar + - [ ] Browse tables, views, materialized views, routines, triggers + - [ ] Execute SELECT with all PG types (see seed table) + - [ ] Inline edit: update text, number, boolean, date, enum, json, array + - [ ] Insert new row with auto-generated serial PK + - [ ] Delete row by single PK and composite PK + - [ ] BLOB: save bytea column to file, preview as data URL + - [ ] EXPLAIN: view query plan, view ANALYZE output + - [ ] Batch: run multi-statement script with BEGIN/COMMIT + - [ ] Batch: temp table persists across statements + - [ ] Batch: SET command persists across statements + - [ ] Startup script: SET search_path executes on connect + - [ ] DDL: create table, add column, alter column, create index, create FK + - [ ] Views: create, alter, drop + - [ ] Materialized views: list, inspect, refresh + - [ ] Routines: list, inspect, call function, call procedure + - [ ] Triggers: list, inspect, create, drop + +4. **No regressions in existing frontend tests** — `pnpm test` passes unchanged. + +--- + +## Phase 2: Multi-Database Support (PR 402) + +### Phase 2 Goal + +Incorporate the multi-database browsing architecture from PR #402 into the plugin. + +### What PR 402 Requires from the Driver + +1. **Handle `database` parameter on every command** — The host sends `params.database` + set to the target database. The plugin must route to the correct pool. + +2. **Per-database connection pools** — When `params.database` changes between calls, + the plugin creates/reuses a pool for that specific database. + +3. **`get_schemas` per database** — Schema discovery is called separately for each + database the user expands in the sidebar. + +4. **`get_databases` returns all databases** — Used to populate the sidebar tree. + +5. **Fall back to `"postgres"` database** — When connecting without an explicit + database selection, use the maintenance database. + +6. **`ref_schema` in ForeignKey results** — Return the schema of the referenced + table for cross-schema FK navigation. + +### Implementation in the Plugin + +```rust +// In pool.rs — pool keyed by database +fn pool_key(params: &ConnectionParams) -> String { + format!("{}:{}:{}:{}", params.host, params.port, params.database, params.user) +} + +// In each handler — use params.database to select pool +async fn get_tables(params: &ConnectionParams, schema: Option<&str>) -> Result<...> { + let pool = get_or_create_pool(params).await?; + // Query using pool for params.database +} +``` + +The plugin naturally handles this because every RPC call receives the full +`ConnectionParams` with the correct `database` field already set by the host. + +--- + +## Phase 3: Issue 16 Improvements + +### Phase 3 Goal + +Add the feature gaps and bug fixes identified in the PostgreSQL audit (issue #16). + +### Items (from the audit) + +| Priority | Item | +| -------- | ---- | +| High | Sequence management (list, inspect, alter, reset) | +| High | JSONB inline editing (object/array manipulation) | +| High | Extension-aware type system (PostGIS, pgvector, ltree) | +| Medium | Partition table introspection | +| Medium | Row-level security policy display | +| Medium | Publication/subscription visibility | +| Medium | Advisory lock monitoring | +| Low | Query plan cost visualization improvements | +| Low | Table statistics (pg_stat_user_tables) display | + +### Advantage of Plugin Architecture + +These improvements are easier to ship as a plugin because: + +- No Tabularis core release needed — just update the plugin binary +- Can iterate faster (plugin version != app version) +- Users can opt-in to beta plugin versions +- Plugin-specific UI extensions can be bundled (`ui_extensions` in manifest) + +--- + +## Phase 4: Deprecate Built-in Driver (Deferred Decision) + +### Phase 4 Goal + +Remove the built-in PostgreSQL driver from the Tabularis core and let the plugin +become the sole PostgreSQL driver. **The specifics of this phase are deferred** +until Phases 1-3 are complete and the team can evaluate: + +- Whether the plugin id should become `"postgres"` (seamless migration) or remain + `"postgres-plugin"` (requires connection migration tooling) +- Whether to remove the `BUILTIN_DRIVER_IDS` guard entirely or modify it +- Whether to bundle the plugin with the app distribution or keep it installable + +### Possible Steps (to be finalized later) + +1. Remove `BUILTIN_DRIVER_IDS` guard (or remove `"postgres"` from the array) +2. Remove `src-tauri/src/drivers/postgres/` directory +3. Remove PostgreSQL pool logic from `pool_manager.rs` +4. Decide on plugin id (`"postgres"` vs keeping `"postgres-plugin"`) +5. If renaming to `"postgres"`: auto-migration for saved connections +6. If keeping `"postgres-plugin"`: connection migration UI or script +7. Update frontend: remove hardcoded PostgreSQL references in `useDrivers.ts` + +### Connection Migration (if plugin takes over `"postgres"` id) + +Existing saved connections use `driver: "postgres"`. If the plugin takes over +that exact id, connections work without modification: + +```text +Before: driver: "postgres" → built-in code path +After: driver: "postgres" → plugin registered with id "postgres" → same behavior +``` + +**No user action required** if the plugin uses the same id. + +--- + +## Plugin Architecture Reference + +### Communication Protocol + +```text +Host (Tauri) Plugin (standalone process) + | | + |-- JSON-RPC Request (stdin) ------->| + | {"jsonrpc":"2.0", | + | "method":"execute_query", | + | "params":{ | + | "params":{...ConnParams...}, | + | "query":"SELECT...", | + | "limit":500, | + | "page":1, | + | "schema":"public" | + | }, | + | "id":42} | + | | + |<-- JSON-RPC Response (stdout) -----| + | {"jsonrpc":"2.0", | + | "result":{ | + | "columns":["id","name"], | + | "rows":[[1,"Alice"],...], | + | "affected_rows":0, | + | "pagination":{...} | + | }, | + | "id":42} | +``` + +### Key Constraints + +| Constraint | Detail | +| ---------- | ------ | +| One process per plugin | All connections for that driver type go through one process | +| 120s call timeout | `PLUGIN_CALL_TIMEOUT` — if exceeded, returns error | +| No streaming | Full result returned in one JSON response | +| Newline-delimited | Each request/response is a single line of JSON | +| `ConnectionParams` on every call | Plugin must parse and route internally | +| `-32601` for unimplemented methods | Host falls back to defaults for optional methods | +| Plugin manages its own pools | Host does not pool connections for plugins | + +### ConnectionParams Structure (what the plugin receives) + +```json +{ + "host": "localhost", + "port": 5432, + "user": "postgres", + "password": "secret", + "database": "mydb", + "ssl": true, + "ssl_mode": "require", + "connection_string": null, + "startup_script": "SET search_path TO myschema", + "connection_id": "abc-123", + "driver": "postgres-plugin", + "settings": { + "sslMode": "prefer", + "statementTimeout": 30000 + } +} +``` + +--- + +## PR 402 Architecture Summary + +### Core Changes + +PR #402 adds per-database connection routing for PostgreSQL: + +- **Pool key includes database**: `postgres:conn:{id}:{host}:{port}:{dbname}` +- **Every command gains `database: Option`** parameter +- **Frontend tabs carry `tab.database`** alongside `tab.schema` +- **`buildTableRoutingParams()`** utility builds `{ schema, database }` for backend calls +- **`isSchemaBasedMultiDb()`** distinguishes PG hierarchy from MySQL flat layout +- **Lazy schema loading**: Sidebar loads schemas per-database on expand, not all at once +- **`ForeignKey.ref_schema`**: New field for cross-schema FK references + +### What This Means for the Plugin + +The plugin doesn't need to know about PR 402's frontend changes — the host handles +routing. The plugin just needs to: + +1. Use `params.database` to connect to the correct database +2. Pool connections per-database internally +3. Return schema-qualified FK references (`ref_schema`) +4. Support `get_schemas` called per-database + +--- + +## Dependency Sequencing + +```text +┌─────────────────────────────────────────────────────────────────────┐ +│ PREREQUISITE: 3 Tabularis Core PRs (can be one combined PR) │ +│ • RpcDriver: forward BLOB methods (base64 over JSON) │ +│ • RpcDriver: forward materialized view methods │ +│ • RpcDriver: resolve map_inferred_type from manifest/settings │ +└──────────────────────────────────┬──────────────────────────────────┘ + │ unblocks + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 0: Baseline Test Suite │ +│ • CI PG service + seed script │ +│ • 50+ integration tests against built-in driver │ +│ • Golden file captures │ +│ • Parity harness infrastructure │ +│ can overlap │ +└──────────────────────────────────┬───────────────────────────────────┘ + │ unblocks + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 1: Plugin with Feature Parity │ +│ • Build postgres-plugin (scaffold + all 30+ RPC methods) │ +│ • Run Phase 0 tests against plugin — must all pass │ +│ • Golden file comparison — must match built-in output │ +│ • Manual smoke test checklist — all items pass │ +└──────────────────────────────────┬───────────────────────────────────┘ + │ unblocks (+ PR 402 merges) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 2: Multi-Database (PR 402) │ +│ • Per-database pool routing in plugin │ +│ • get_schemas per database │ +│ • ref_schema in FK results │ +└──────────────────────────────────┬───────────────────────────────────┘ + │ unblocks + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 3: Issue #16 Improvements │ +│ • Sequences, JSONB editing, extensions, partitions, etc. │ +└──────────────────────────────────┬───────────────────────────────────┘ + │ team decision + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ PHASE 4: Deprecate Built-in (deferred) │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +**Parallelization opportunity:** Phase 0 test writing and the Core PRs can happen +simultaneously. Phase 1 plugin scaffold can begin once the Core PRs are merged +(the plugin needs BLOB/MV forwarding to pass parity tests). + +--- + +## Developer Workflow + +### Local Development Setup + +```bash +# 1. Clone and build the plugin +cd plugins/postgres-plugin +cargo build --release + +# 2. Install locally (symlink or copy to plugin directory) +# macOS: +cp target/release/postgres-plugin \ + ~/Library/Application\ Support/tabularis/plugins/postgres-plugin/ +cp .tabularium \ + ~/Library/Application\ Support/tabularis/plugins/postgres-plugin/ + +# 3. Restart Tabularis — plugin auto-loads on startup +# Or use Settings > Plugins > Enable to hot-reload +``` + +### Testing Locally + +```bash +# Run plugin unit tests +cargo test + +# Run integration tests against local PG (requires Docker) +docker run -d --name pg-test -p 54320:5432 \ + -e POSTGRES_PASSWORD=test -e POSTGRES_DB=tabularis_test postgres:16 +cargo test --features integration + +# Run parity tests (compares plugin output vs golden files) +cargo test --features parity + +# Interactive REPL for debugging RPC calls +cargo run --bin test_plugin +> {"jsonrpc":"2.0","method":"get_tables","params":{"params":{...},"schema":"public"},"id":1} +``` + +### Testing in Tabularis + +1. Build the plugin binary +2. Install to the plugins directory +3. Launch Tabularis +4. Create a new connection with driver "PostgreSQL (Next)" +5. Run the manual smoke test checklist (see Phase 1 Success Criteria) +6. Compare behavior with a parallel "PostgreSQL" (built-in) connection to the same database + +--- + +## Risk Assessment + +| Risk | Likelihood | Mitigation | +| ---- | ---------- | ---------- | +| **Performance regression** (JSON-RPC overhead) | Medium | Benchmark with large result sets. JSON serialization is fast for tabular data. Network latency to PG server dominates. Defer optimization unless measurably slow. | +| **Feature parity gaps missed** | Low (with Phase 0) | Phase 0's golden files and 50+ tests create a comprehensive contract. Gaps caught immediately via automated parity comparison. | +| **Plugin crash isolation** | Low | Plugin crash doesn't crash Tabularis. Host returns error. Can offer "Restart plugin" in UI. | +| **Typed binding fidelity** | High | Existing binding system handles 20+ PG types with CASTs. Port with per-type tests. This is the highest-risk area — must be methodical. | +| **PR 402 integration conflicts** | Medium | Build plugin against `main`. When 402 merges, adapt plugin (isolated codebase — no merge conflicts with Tabularis core). | +| **Core PR rejection** | Low | The 3 required RpcDriver changes are small, non-breaking additions. Same pattern as existing forwarded methods. | +| **Phase 0 scope creep** | Medium | Timebox Phase 0. The 50+ tests are the minimum viable baseline. Don't gold-plate — capture what's needed for parity proof, nothing more. | + +--- + +## Open Questions + +1. **Plugin id during development** — Use `"postgres-plugin"` during Phases 1-3. + Decision on whether to rename to `"postgres"` deferred to Phase 4. + +2. **Bundling strategy** — Should the PostgreSQL plugin be bundled with Tabularis + app distribution (always available) or installed on-demand from the registry? + Bundling ensures no regression for existing users on Phase 4 cutover. + +3. **RPC adapter core PRs** — Phase 1 requires 3 Tabularis core changes to the + `RpcDriver` (BLOB forwarding, materialized view forwarding, `map_inferred_type` + resolution). Should these be submitted as a prerequisite PR before plugin + development, or developed in parallel? + +4. **BLOB protocol extension** — The RPC protocol has no binary data support. + Proposed: base64-encode blob data in JSON responses. Is the size overhead + (33% increase) acceptable? Alternative: shared temp file path exchange. + +5. **Query cancellation protocol** — Should we propose a `cancel_query` RPC method + to the plugin protocol? Without it, long queries are unkillable from the user's + perspective (the task is aborted but the server query continues). + +6. **PR 402 timing** — Should we wait for PR 402 to merge into main before + starting Phase 2, or port its changes directly into the plugin from the PR + branch? The latter avoids waiting but means maintaining a fork of 402's logic. + +7. **Existing plugin ecosystem** — Are there any community PostgreSQL plugins + already? Could we conflict with or build on existing work? + +8. **Plugin versioning** — When the plugin ships updates independently of Tabularis, + how do we ensure compatibility? Should the manifest declare a minimum Tabularis + version? + +9. **Phase 0 scope negotiation** — The 50+ integration tests in Phase 0 represent + significant work. Can we parallelize Phase 0 and Phase 1 (build plugin scaffold + while writing tests), or must Phase 0 fully complete first? + +10. **Timeout configurability** — The 120s hard timeout will break long-running + queries. Should we propose a manifest field (`call_timeout_seconds`) or a + per-call timeout negotiation? diff --git a/docs/planning/postgres-plugin-migration.md b/docs/planning/postgres-plugin-migration.md new file mode 100644 index 0000000..4841f1c --- /dev/null +++ b/docs/planning/postgres-plugin-migration.md @@ -0,0 +1,533 @@ +# PostgreSQL Plugin Migration — Alternative: Multi-Database From Day One + +**Ref:** [#16 — Better PostgreSQL Support](https://github.com/TabularisDB/tabularis/issues/16) +**Related:** [PR #402 — Multi-database connections](https://github.com/TabularisDB/tabularis/pull/402) +**Context:** Feedback suggesting multi-database support should be built in from the +start rather than added as a later phase. + +## Executive Summary + +This document explores the alternative approach of building the PostgreSQL plugin +with multi-database support from day one. After analysis, the conclusion is that +**the two approaches are architecturally equivalent** — a correctly-built plugin +inherently supports multi-database because the RPC protocol routes `params.database` +on every call. The plugin cannot function without reading this field. + +However, the feedback raises a valid point about **test coverage and verification +confidence**. This alternative plan consolidates Phases 1 and 2 into a single +phase that tests multi-database from the beginning, eliminating any theoretical +risk of overlooking it. + +The Phase 0 baseline test suite and zero-regression guarantee remain unchanged. + +--- + +## Table of Contents + +1. [Why Multi-Database Is Not a Separate Concern](#why-multi-database-is-not-a-separate-concern) +2. [What Changes vs. the Phased Plan](#what-changes-vs-the-phased-plan) +3. [Revised Phase Structure](#revised-phase-structure) +4. [Phase 0: Baseline Test Suite](#phase-0-baseline-test-suite) +5. [Phase 1: Plugin with Full Parity + Multi-Database (TDD)](#phase-1-plugin-with-full-parity--multi-database-tdd) +6. [Phase 2: Issue 16 Improvements](#phase-2-issue-16-improvements) +7. [Phase 3: Deprecate Built-in Driver](#phase-3-deprecate-built-in-driver-deferred) +8. [Why This Is Safe — Zero Regression Guarantee](#why-this-is-safe--zero-regression-guarantee) +9. [RPC Adapter Blockers](#rpc-adapter-blockers) +10. [Open Questions](#open-questions) + +--- + +## Why Multi-Database Is Not a Separate Concern + +The RPC protocol makes multi-database support **emergent from correct implementation**: + +1. **Every RPC call includes `params.database`** — The host sets this to the target + database before calling the plugin. The plugin must read it to connect at all. + +2. **PostgreSQL requires per-database connections** — You cannot `USE other_db` + mid-session. Each database needs its own TCP connection. This means the pool + key MUST include the database name regardless of whether "multi-database" is a + stated goal. + +3. **The plugin is stateless between calls** — There is no "current database" + concept in the plugin. Each call receives full connection parameters including + the database to target. + +4. **The host does all routing** — The frontend (PR 402) handles sidebar tree + expansion, tab database tracking, and routing params construction. The plugin + just connects to whatever it's told. + +### What a Correctly-Built Plugin Pool Looks Like + +```rust +// This is the ONLY correct implementation — it naturally supports multi-database +fn pool_key(params: &ConnectionParams) -> String { + format!("{}:{}:{}:{}", params.host, params.port, params.database, params.user) +} + +async fn get_or_create_pool(params: &ConnectionParams) -> Result { + let key = pool_key(params); + // Return existing pool for this database, or create a new one + // ... +} +``` + +A developer building this plugin would write this code on day one because it's +the only way to connect to PostgreSQL. You cannot accidentally build a +single-database-only plugin — the protocol doesn't allow it. + +### The Only Multi-Database-Specific Items + +| Item | Effort | Why it's trivial | +| ---- | ------ | ---------------- | +| `get_databases` returns all databases | One SQL query | `SELECT datname FROM pg_database WHERE datallowconn` | +| Fall back to `"postgres"` maintenance DB | One-line default | `let db = params.database.or("postgres")` | +| `ref_schema` in ForeignKey results | One field in FK query | Add `nsp2.nspname AS ref_schema` to existing JOIN | + +These are not architectural decisions — they're checklist completeness items that +belong alongside all other method implementations. + +--- + +## What Changes vs. the Phased Plan + +| Aspect | Original (Phases 1+2 separate) | This Alternative (Combined) | +| ------ | ------------------------------ | --------------------------- | +| Plugin build phases | Phase 1 (parity) → Phase 2 (multi-db) | Single Phase 1 (parity + multi-db) | +| Testing approach | Phase 0 tests single-db, Phase 2 adds multi-db tests | Phase 0 tests BOTH from the start | +| Pool implementation | Same code either way | Same code either way | +| Phase 0 scope | 50+ tests, single database | 55+ tests, includes multi-database scenarios | +| Total phases | 5 (0-4) | 4 (0-3) | +| Risk | Theoretical: could build single-db pools accidentally | Eliminated: tests catch it immediately | +| Phase 0 seed script | Single database | Two databases (test primary + test secondary) | + +**The actual plugin code is identical.** The difference is purely in **test scope** +and **verification confidence** — which aligns exactly with the requirement for +zero-regression proof. + +--- + +## Revised Phase Structure + +```text +PREREQUISITE: 3 Tabularis Core PRs (RpcDriver fixes) + ↓ +Phase 0: Baseline test suite (includes multi-database scenarios) + ↓ +Phase 1: Build plugin "postgres-plugin" — full parity including multi-database + ↓ +Phase 2: Issue #16 improvements (sequences, JSONB editing, etc.) + ↓ +Phase 3: Deprecate built-in driver (deferred decision) +``` + +--- + +## Phase 0: Baseline Test Suite + +Phase 0 is identical to the original plan with one key addition: the test seed +creates **two databases** and the test suite includes multi-database scenarios. + +### Seed Script Addition + +```sql +-- tests/fixtures/postgres_seed.sql + +-- Primary test database (tabularis_test) — same as before +CREATE SCHEMA IF NOT EXISTS test_schema; +CREATE TABLE test_schema.all_types ( ... ); +-- ... all existing seed tables ... + +-- SECOND database for multi-database testing +-- (created via separate connection to maintenance DB) +CREATE DATABASE tabularis_test_secondary; + +-- In tabularis_test_secondary: +CREATE SCHEMA IF NOT EXISTS secondary_schema; +CREATE TABLE secondary_schema.remote_lookup ( + id SERIAL PRIMARY KEY, + code TEXT UNIQUE +); +``` + +### Additional Multi-Database Tests (Added to Phase 0) + +```text +tests/integration/postgres/ +└── multi_database.rs + ├── test_get_databases_lists_both + ├── test_get_schemas_on_secondary_database + ├── test_get_tables_on_secondary_database + ├── test_execute_query_on_secondary_database + ├── test_pool_reuse_same_database + ├── test_pool_isolation_different_databases + └── test_fallback_to_postgres_maintenance_db +``` + +### Phase 0 Success Criteria (Updated) + +- [ ] All existing integration tests pass in CI (un-ignored, PG service running) +- [ ] 55+ new integration tests covering full API surface + multi-database +- [ ] Golden files captured for every public method +- [ ] Multi-database golden files (schemas/tables from secondary database) +- [ ] Parity harness infrastructure ready +- [ ] Seed script creates TWO databases with comprehensive test schemas +- [ ] CI runs in < 5 minutes with PG service + +--- + +## Phase 1: Plugin with Full Parity + Multi-Database (TDD) + +### Phase 1 Goal + +A standalone Rust plugin that implements every method the built-in PostgreSQL +driver supports — including multi-database routing — passing the same test suite +that validates the built-in driver. Built iteratively using Test-Driven Development: +one method at a time, watching tests go from red to green. + +### TDD Workflow + +Phase 0 produces a test suite that passes against the built-in driver. At the +start of Phase 1, the same suite is pointed at the plugin. Every test is RED +because the plugin doesn't exist yet. Implementation proceeds method by method: + +```text +START: 0/55 tests GREEN (plugin binary doesn't exist) + +Sprint 1 — Foundation (scaffold + connection) +───────────────────────────────────────────── + cargo init → main.rs with JSON-RPC loop → rpc.rs router + Implement: initialize, ping, test_connection, shutdown + Run tests → 3/55 GREEN (connection tests pass) + +Sprint 2 — Schema Discovery +──────────────────────────── + Implement: get_databases, get_schemas, get_tables + Run tests → 8/55 GREEN + +Sprint 3 — Column & Key Metadata +────────────────────────────────── + Implement: get_columns, get_indexes, get_foreign_keys + Port: extract/ submodules (needed for type-aware column reading) + Run tests → 18/55 GREEN + +Sprint 4 — Query Execution +─────────────────────────── + Implement: execute_query, execute_query_batch, count_query + Port: extract/ for result value extraction (all PG types) + Run tests → 26/55 GREEN + +Sprint 5 — CRUD Operations +─────────────────────────── + Implement: insert_record, update_record, delete_record + Port: binding.rs (enum CASTs, UUID handling, array bindings) + Run tests → 35/55 GREEN + +Sprint 6 — Views & Materialized Views +─────────────────────────────────────── + Implement: get_views, get_view_definition, get_view_columns, + create_view, alter_view, drop_view, + get_materialized_views, get_mv_definition, + get_mv_columns, refresh_materialized_view + Run tests → 41/55 GREEN + +Sprint 7 — Routines & Triggers +─────────────────────────────── + Implement: get_routines, get_routine_parameters, + get_routine_definition, build_routine_call_sql, + routine_create_template, get_routine_edit_script, + drop_routine, get_triggers, get_trigger_definition, + create_trigger, drop_trigger, update_trigger + Run tests → 48/55 GREEN + +Sprint 8 — DDL, EXPLAIN, BLOB +────────────────────────────── + Implement: get_create_table_sql, get_add_column_sql, + get_alter_column_sql, get_create_index_sql, + drop_index, get_create_foreign_key_sql, drop_foreign_key, + explain_query_plan, save_blob_to_file, + fetch_blob_as_data_url, get_ai_schema_context + Run tests → 53/55 GREEN + +Sprint 9 — Multi-Database & Polish +──────────────────────────────────── + Verify: get_databases returns both test DBs + Verify: queries route to correct database + Verify: ref_schema populated in FK results + Fix: any remaining failures, edge cases + Run tests → 55/55 GREEN ✅ + +DONE: All tests green. Run golden file comparison. Run manual smoke test. +``` + +### The Red → Green Discipline + +At each sprint: + +1. **Run the full parity suite** — see exactly which tests are RED +2. **Pick the next batch of related methods** — implement them +3. **Run again** — confirm new tests are GREEN, nothing regressed +4. **Commit** — each commit message references which tests it turns green + +```bash +# Developer workflow at each sprint +cargo build --release +cp target/release/postgres-plugin ~/Library/Application\ Support/tabularis/plugins/postgres-plugin/ + +# Run parity suite against plugin +cargo test --features parity -- --nocapture +# Output: 26/55 passed, 29 failed (EXPECTED — haven't built those yet) + +# After implementing next batch: +cargo test --features parity -- --nocapture +# Output: 35/55 passed, 20 failed (PROGRESS — 9 new tests green) + +# Verify no regressions: +# Previously-green tests must stay green. If one goes RED, fix before moving on. +``` + +### What This Guarantees + +| Guarantee | Mechanism | +| --------- | --------- | +| No method is forgotten | Every method has a test from Phase 0. If the test is still RED, the method isn't done. | +| No silent regressions | The full suite runs at every sprint. A previously-GREEN test going RED is immediately visible. | +| Progress is measurable | "35/55 green" is an objective, unambiguous progress metric. | +| Parity is proven, not claimed | The same test produces the same assertion against both drivers. If it passes on both, they are equivalent by construction. | +| Implementation order is flexible | Sprints above are a suggested order. If a different order is easier, the tests don't care — they just need to all be GREEN eventually. | + +### What's Different From Original Phase 1 + +| Original Phase 1 | This Phase 1 | +| ----------------- | ------------ | +| Build plugin, then run tests | Tests exist first, guide implementation | +| `get_databases` not required | `get_databases` implemented and tested | +| No multi-db tests in parity suite | Multi-db tests included in parity suite | +| `ref_schema` not in FK results | `ref_schema` included from the start | +| Pool tested with one database | Pool tested with multiple databases | +| Progress measured by checklist | Progress measured by test count (objective) | + +### Plugin Structure + +```text +plugins/postgres-plugin/ +├── .tabularium +├── Cargo.toml +├── src/ +│ ├── main.rs # JSON-RPC stdin/stdout loop +│ ├── rpc.rs # Method dispatch router +│ ├── models.rs # ConnectionParams, shared types +│ ├── pool.rs # deadpool-postgres, keyed by host:port:db:user +│ ├── handlers/ +│ │ ├── metadata.rs # get_tables, get_columns, get_databases, etc. +│ │ ├── query.rs # execute_query, execute_query_batch +│ │ ├── crud.rs # insert_record, update_record, delete_record +│ │ ├── ddl.rs # get_create_table_sql, get_add_column_sql, etc. +│ │ ├── routines.rs # get_routines, build_routine_call_sql, etc. +│ │ ├── explain.rs # explain_query_plan +│ │ └── blob.rs # save_blob_to_file, fetch_blob_as_data_url +│ ├── binding.rs # Typed parameter binding (enum CAST, etc.) +│ ├── extract/ # Value extraction from PG rows +│ │ ├── mod.rs +│ │ ├── simple.rs +│ │ ├── array.rs +│ │ ├── range.rs +│ │ ├── multi_range.rs +│ │ ├── composite.rs +│ │ ├── enum_type.rs +│ │ └── advanced.rs +│ └── types.rs # 97+ data type declarations +└── tests/ + ├── metadata_test.rs + ├── query_test.rs + ├── crud_test.rs + ├── ddl_test.rs + └── multi_database_test.rs +``` + +### Phase 1 Success Criteria — Zero Wiggle Room + +Phase 1 is **not done** until: + +1. **55/55 parity tests GREEN** — Including multi-database tests. Zero RED. + This is binary: either all pass or it's not done. + +2. **Golden file comparison passes** — Plugin output matches built-in output + byte-for-byte for every captured method response. + +3. **Manual smoke test checklist** (all pass): + - [ ] Connect to PG via host/port + - [ ] Connect via connection string + - [ ] Connect via SSL (all modes) + - [ ] Browse schemas in sidebar + - [ ] Browse tables, views, materialized views, routines, triggers + - [ ] Execute SELECT with all PG types + - [ ] Inline edit: update text, number, boolean, date, enum, json, array + - [ ] Insert new row with auto-generated serial PK + - [ ] Delete row by single PK and composite PK + - [ ] BLOB: save bytea column to file, preview as data URL + - [ ] EXPLAIN: view query plan, view ANALYZE output + - [ ] Batch: run multi-statement script with BEGIN/COMMIT + - [ ] Batch: temp table persists across statements + - [ ] Batch: SET command persists across statements + - [ ] Startup script: SET search_path executes on connect + - [ ] DDL: create table, add column, alter column, create index, create FK + - [ ] Views: create, alter, drop + - [ ] Materialized views: list, inspect, refresh + - [ ] Routines: list, inspect, call function, call procedure + - [ ] Triggers: list, inspect, create, drop + - [ ] Multi-db: browse second database in sidebar + - [ ] Multi-db: execute query against second database + - [ ] Multi-db: get_schemas returns schemas from correct database + - [ ] Multi-db: FK with ref_schema navigates cross-schema + +4. **No regressions in existing frontend tests** — `pnpm test` passes unchanged. + +--- + +## Phase 2: Issue 16 Improvements + +Identical to original plan's Phase 3. Now Phase 2 since multi-db is absorbed +into Phase 1. + +**Important:** Before implementing any feature, check for existing open PRs that +already address it. Known in-flight: PR #427 (hstore editing), PR #222 (composite +PK). See `03-phase-2-issue-16.md` for the full coordination process. + +| Priority | Item | +| -------- | ---- | +| High | Sequence management (list, inspect, alter, reset) | +| High | JSONB inline editing (object/array manipulation) | +| High | Extension-aware type system (PostGIS, pgvector, ltree, hstore — **see PR #427**) | +| Medium | Partition table introspection | +| Medium | Row-level security policy display | +| Medium | Publication/subscription visibility | +| Medium | Advisory lock monitoring | +| Low | Query plan cost visualization improvements | +| Low | Table statistics (pg_stat_user_tables) display | + +--- + +## Phase 3: Deprecate Built-in Driver (Deferred) + +Identical to original plan's Phase 4. Decision deferred until Phase 1 parity is +proven. + +--- + +## Why This Is Safe — Zero Regression Guarantee + +The safety model has three layers: + +### Layer 1: Golden File Parity (Automated) + +Every public method's output is captured as a golden file against the built-in +driver. The plugin must produce byte-for-byte identical output. This runs in CI +on every commit. + +```text +Built-in: get_columns("all_types", "test_schema") → golden/get_columns_all_types.json +Plugin: get_columns("all_types", "test_schema") → must match exactly +``` + +### Layer 2: Integration Test Suite (Automated) + +55+ tests exercise every API method with real PostgreSQL. Parameterized to run +against both built-in and plugin. Any difference = test failure = CI red. + +```rust +#[test_case("postgres"; "built-in driver")] +#[test_case("postgres-plugin"; "plugin driver")] +async fn test_insert_with_enum_cast(driver: &str) { + // Same test, same assertions, both drivers must produce identical results +} +``` + +### Layer 3: Manual Smoke Test (Human Verification) + +24-item checklist performed manually before any release. Covers UX flows that +automated tests can't fully validate (sidebar navigation, inline editing feel, +error message quality). + +### What This Catches + +| Failure Mode | Caught By | +| ------------ | --------- | +| Missing method (returns -32601) | Golden file test fails (no output vs expected) | +| Wrong result shape | Golden file byte comparison fails | +| Type extraction bug (e.g., array renders differently) | Integration test + golden file | +| Pool keying error (wrong database) | Multi-database integration tests | +| Session state lost in batch | Batch integration tests (temp tables, SET) | +| Startup script not executed | Dedicated integration test | +| BLOB not working | BLOB round-trip integration test | +| Enum CAST missing (silent data corruption) | CRUD integration test with enum type | +| SSL connection failure | SSL integration test | +| Performance regression | Benchmark suite (separate, optional) | + +--- + +## RPC Adapter Blockers + +Identical to the original plan. These 3 Tabularis core PRs are prerequisites: + +| Issue | Resolution | +| ----- | ---------- | +| BLOB methods not forwarded | Extend RpcDriver to forward `save_blob_to_file` / `fetch_blob_as_data_url` (base64 over JSON) | +| Materialized views not forwarded | Extend RpcDriver to forward 4 MV methods | +| `map_inferred_type` not forwarded | Plugin declares mappings at `initialize`; host applies locally | + +Additionally, the plugin must handle these internally: + +| Issue | Plugin-Side Resolution | +| ----- | --------------------- | +| Query cancellation | Implement `pg_cancel_backend()` or connection drop internally | +| `execute_query_batch` session state | Use single connection for entire batch | +| Startup script execution | `after_connect` hook in internal pool | +| 120s hard timeout | Document limitation; propose configurable timeout later | + +--- + +## Open Questions + +1. **Core PRs timing** — Should the 3 RpcDriver fixes be submitted before or + during Phase 0 development? They can be parallelized. + +2. **PR 402 merge dependency** — The multi-database frontend routing lives in + PR 402. If it hasn't merged by the time Phase 1 is ready, multi-database + testing can only be done at the RPC level (calling the plugin directly), not + through the full Tabularis UI. Is RPC-level verification sufficient for the + multi-db smoke tests? + +3. **Bundling strategy** — Should the plugin be bundled with Tabularis distribution + or installed from registry? + +4. **BLOB protocol** — Base64 over JSON (33% overhead) vs shared temp files? + +5. **Query cancellation** — Add a `cancel_query` RPC method to the protocol? + +6. **Plugin versioning** — Manifest field for minimum compatible Tabularis version? + +7. **Phase 0 parallelization** — Can Phase 0 test writing and Core PRs happen + simultaneously? (Yes — they touch different code.) + +--- + +## Comparison: This Plan vs. Original Phased Plan + +| Dimension | Original (5 phases) | This Alternative (4 phases, TDD) | +| --------- | ------------------- | -------------------------------- | +| Methodology | Build first, test after | Tests first, build to pass them (TDD) | +| Plugin code | Identical | Identical | +| Pool architecture | Same | Same | +| Test coverage | Multi-db added in Phase 2 | Multi-db tested from Phase 0 | +| Confidence in multi-db | Proven in Phase 2 | Proven in Phase 1 | +| Progress tracking | Checklist-based (subjective) | Test count (0/55 → 55/55, objective) | +| Regression detection | End-of-phase verification | Every sprint (previously-green must stay green) | +| Implementation order | Implicit (build everything, then test) | Explicit sprints, flexible ordering | +| Total effort | Same | Same (7 extra tests in Phase 0) | +| Risk of parity gap | Detected at end of Phase 1 | Detected immediately at each sprint | +| Simpler to explain | 5 phases with small Phase 2 | 4 phases, TDD-driven, each substantive | + +**Bottom line:** This plan is better because it gives continuous, objective proof +of progress and catches regressions at every step — not just at the end. The test +suite IS the specification. Implementation is done when all tests are green. diff --git a/src/bin/test_plugin.rs b/src/bin/test_plugin.rs new file mode 100644 index 0000000..3e5157f --- /dev/null +++ b/src/bin/test_plugin.rs @@ -0,0 +1,56 @@ +//! Local REPL that drives the real JSON-RPC dispatch over stdio. +//! +//! Usage: `just repl` (or `cargo run --bin test_plugin`). +//! +//! Two input modes: +//! * A full JSON-RPC object (e.g. `{"method":"get_tables","params":{...}}`) +//! is forwarded verbatim to the dispatcher. +//! * A bare method name (e.g. `get_schemas`) is wrapped in a stub request, +//! handy for quickly hitting static methods. +//! +//! Type `exit` / `quit` or press Ctrl-D to leave. + +use std::io::{self, BufRead, Write}; + +use postgresql_plugin::rpc::handle_line; +use serde_json::json; + +#[tokio::main] +async fn main() { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut out = stdout.lock(); + + println!("test_plugin — enter a JSON-RPC request or a bare method name. `exit` to quit."); + + let mut next_id: u64 = 1; + for line in stdin.lock().lines() { + let Ok(line) = line else { break }; + let cmd = line.trim(); + if cmd.is_empty() { + continue; + } + if cmd == "exit" || cmd == "quit" { + break; + } + + let request_line = if cmd.starts_with('{') { + cmd.to_string() + } else { + let request = json!({ + "jsonrpc": "2.0", + "method": cmd, + "params": { "params": {}, "schema": null, "query": "" }, + "id": next_id, + }); + next_id += 1; + request.to_string() + }; + + let response = handle_line(&request_line).await; + let pretty = + serde_json::to_string_pretty(&response).unwrap_or_else(|_| response.to_string()); + writeln!(out, "{pretty}").ok(); + out.flush().ok(); + } +} diff --git a/src/binding.rs b/src/binding.rs new file mode 100644 index 0000000..ce482ea --- /dev/null +++ b/src/binding.rs @@ -0,0 +1,397 @@ +//! Value binding for INSERT/UPDATE — converts JSON values into SQL fragments +//! and typed bind parameters, matching the built-in driver's binding cascade +//! exactly (`src-tauri/src/drivers/postgres/binding.rs`). +//! +//! Why the explicit `Type` matters: `tokio-postgres`'s `prepare_typed` lets +//! the caller pin a placeholder's wire type instead of letting the server +//! infer it from query context. When a bound value's natural Rust type +//! (e.g. `String`) doesn't match what the surrounding SQL implies (e.g. +//! `CAST($N AS uuid)`), the client-side check rejects the bind before the +//! value reaches PostgreSQL's own parser. The fix: emit `CAST($N AS )` +//! in the SQL text and pin the placeholder's `Type` to `TEXT` so tokio-postgres +//! doesn't fight the CAST. + +use rust_decimal::Decimal; +use serde_json::Value; +use tokio_postgres::types::{ToSql, Type}; +use uuid::Uuid; + +pub type PgParam = Box; +pub type TypedPgParam = (PgParam, Type); + +pub struct BoundValue { + pub sql: String, + pub param: Option, +} + +impl std::fmt::Debug for BoundValue { + // `dyn ToSql + Sync` isn't Debug, so a derive won't work — show just the + // SQL fragment and whether a parameter is bound (sufficient for + // .unwrap_err() panic messages and test assertion failures). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BoundValue") + .field("sql", &self.sql) + .field("param", &self.param.as_ref().map(|(_, ty)| ty.clone())) + .finish() + } +} + +#[derive(Default)] +pub struct BindOptions<'a> { + pub column_type: Option<&'a str>, + /// Schema-qualified, already-quoted enum type name (e.g. `"public"."mood"`) + /// when the target column is a PostgreSQL enum; `None` otherwise. Drives + /// the `CAST($N AS )` coercion in [`bind_pg_enum_string`]. + pub enum_type: Option<&'a str>, + pub allow_default: bool, +} + +const USE_DEFAULT_SENTINEL: &str = "__USE_DEFAULT__"; + +/// Normalize a column type string: strip a trailing `(...)` and uppercase. +/// e.g. `"varchar(255)"` -> `"VARCHAR"`. +fn extract_base_type(column_type: &str) -> String { + let base = column_type.split('(').next().unwrap_or(column_type); + base.trim().to_uppercase() +} + +/// Bind a JSON value to a SQL fragment + optional typed parameter. +pub fn bind_pg_value( + value: Value, + placeholder_idx: usize, + options: &BindOptions, +) -> Result { + let base_type = options.column_type.map(extract_base_type); + + // JSON/JSONB columns receiving a native JSON value (object/array/number/bool) + // must bind the value's own ToSql JSON encoding — a text CAST trips an OID + // mismatch for json/jsonb columns. + if let Some(ref bt) = base_type { + if (bt == "JSON" || bt == "JSONB") && !matches!(value, Value::String(_) | Value::Null) { + let ty = if bt == "JSONB" { + Type::JSONB + } else { + Type::JSON + }; + return Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(value), ty)), + }); + } + } + + match value { + Value::Number(n) => bind_pg_number(n, placeholder_idx), + Value::String(s) => bind_pg_string(&s, placeholder_idx, options, base_type.as_deref()), + Value::Bool(b) => Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(b), Type::BOOL)), + }), + Value::Null => Ok(BoundValue { + sql: "NULL".to_string(), + param: None, + }), + Value::Array(arr) => { + let literal = json_array_to_pg_literal(&arr)?; + Ok(BoundValue { + sql: literal, + param: None, + }) + } + Value::Object(_) => Err("Cannot bind a JSON object to a non-JSON column".to_string()), + } +} + +fn bind_pg_number(n: serde_json::Number, placeholder_idx: usize) -> Result { + if let Some(i) = n.as_i64() { + Ok(BoundValue { + sql: format!("CAST(${} AS bigint)", placeholder_idx), + param: Some((Box::new(i), Type::INT8)), + }) + } else if let Some(f) = n.as_f64() { + Ok(BoundValue { + sql: format!("CAST(${} AS double precision)", placeholder_idx), + param: Some((Box::new(f), Type::FLOAT8)), + }) + } else { + Err("Unsupported numeric value".to_string()) + } +} + +fn bind_pg_string( + s: &str, + placeholder_idx: usize, + options: &BindOptions, + base_type: Option<&str>, +) -> Result { + // 1. DEFAULT sentinel (update only) + if options.allow_default && s == USE_DEFAULT_SENTINEL { + return Ok(BoundValue { + sql: "DEFAULT".to_string(), + param: None, + }); + } + + // 2. Blob wire format — must run before the boolean/numeric heuristics + // below, since a base64 blob string could otherwise look like a + // plausible (if garbage) numeric/boolean value for a mistyped column. + if let Some(bytes) = decode_blob_wire_format(s) { + return Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(bytes), Type::BYTEA)), + }); + } + + // 3. Enum column — always coerces through its own type. Any of the later + // shape-based heuristics (uuid-shaped, array-shaped strings) would + // otherwise misinterpret a label that merely looks like one of those. + if let Some(enum_type) = options.enum_type { + return Ok(bind_pg_enum_string(s, enum_type, placeholder_idx)); + } + + // 4. Boolean column + if matches!(base_type, Some("BOOLEAN") | Some("BOOL")) { + let lower = s.trim().to_lowercase(); + let b = match lower.as_str() { + "true" | "t" | "yes" | "y" | "on" | "1" => true, + "false" | "f" | "no" | "n" | "off" | "0" => false, + _ => { + return Err(format!( + "Cannot bind '{}' as boolean for target type BOOLEAN", + s + )) + } + }; + return Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(b), Type::BOOL)), + }); + } + + // 5. Numeric column + if let Some(bt) = base_type { + match bt { + "SMALLINT" | "INTEGER" | "BIGINT" | "INT2" | "INT4" | "INT8" | "SERIAL" + | "BIGSERIAL" => { + let i: i64 = s.parse().map_err(|_| { + format!("Cannot bind '{}' as integer for target type {}", s, bt) + })?; + return Ok(BoundValue { + sql: format!("CAST(${} AS bigint)", placeholder_idx), + param: Some((Box::new(i), Type::INT8)), + }); + } + "NUMERIC" | "DECIMAL" => { + let d: Decimal = s.parse().map_err(|_| { + format!("Cannot bind '{}' as numeric for target type {}", s, bt) + })?; + return Ok(BoundValue { + sql: format!("CAST(${} AS numeric)", placeholder_idx), + param: Some((Box::new(d), Type::NUMERIC)), + }); + } + "REAL" | "DOUBLE PRECISION" | "FLOAT4" | "FLOAT8" => { + let f: f64 = s + .parse() + .map_err(|_| format!("Cannot bind '{}' as float for target type {}", s, bt))?; + return Ok(BoundValue { + sql: format!("CAST(${} AS double precision)", placeholder_idx), + param: Some((Box::new(f), Type::FLOAT8)), + }); + } + _ => {} + } + } + + // 6. Temporal column + if let Some(bt) = base_type { + let cast_target = match bt { + "TIMESTAMP" | "TIMESTAMP WITHOUT TIME ZONE" => Some("timestamp"), + "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" => Some("timestamptz"), + "DATE" => Some("date"), + "TIME" | "TIME WITHOUT TIME ZONE" => Some("time"), + "TIMETZ" | "TIME WITH TIME ZONE" => Some("timetz"), + "INTERVAL" => Some("interval"), + _ => None, + }; + if let Some(target) = cast_target { + return Ok(BoundValue { + sql: format!("CAST(${} AS {})", placeholder_idx, target), + param: Some((Box::new(s.to_string()), Type::TEXT)), + }); + } + } + + // 7. UUID shape (value-based fallback, independent of column type) + if s.parse::().is_ok() { + return Ok(BoundValue { + sql: format!("CAST(${} AS uuid)", placeholder_idx), + param: Some((Box::new(s.to_string()), Type::TEXT)), + }); + } + + // 8. PG array literal (JSON array embedded in a string, e.g. "[1,2,3]") + let trimmed = s.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + if let Ok(Value::Array(arr)) = serde_json::from_str::(trimmed) { + let literal = json_array_to_pg_literal(&arr)?; + return Ok(BoundValue { + sql: literal, + param: None, + }); + } + } + + // 9. Final fallback: plain TEXT + Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(s.to_string()), Type::TEXT)), + }) +} + +/// Bind a value into an enum column via `CAST($N AS )`. +/// The placeholder is pinned to `TEXT` so tokio-postgres does not reject the +/// bound `String` client-side before the CAST resolves it server-side. +/// `qualified_enum` must already be quoted (see `quote_qualified_type` in +/// `client.rs`) so it cannot become a SQL-injection vector. +fn bind_pg_enum_string(s: &str, qualified_enum: &str, placeholder_idx: usize) -> BoundValue { + BoundValue { + sql: format!("CAST(${} AS {})", placeholder_idx, qualified_enum), + param: Some((Box::new(s.to_string()), Type::TEXT)), + } +} + +/// Decode the canonical BLOB wire format back to raw bytes. +/// +/// Expected format: `"BLOB:::"`. +/// Returns `None` if the string doesn't match, so it falls through to the +/// rest of the binding cascade as a plain string. Matches +/// `decode_blob_wire_format` in `src-tauri/src/drivers/common/blob.rs` +/// (this plugin doesn't yet support the `BLOB_FILE_REF:` variant since +/// that requires filesystem access outside the scope of value binding). +fn decode_blob_wire_format(value: &str) -> Option> { + let rest = value.strip_prefix("BLOB:")?; + // Skip the size field, then the mime field. + let (_, after_size) = rest.split_once(':')?; + let (_, base64_data) = after_size.split_once(':')?; + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data).ok() +} + +/// Convert a JSON array to a PostgreSQL `ARRAY[...]` literal string. +/// Recursively handles nested arrays (multi-dimensional PG arrays). +fn json_array_to_pg_literal(arr: &[Value]) -> Result { + let mut parts = Vec::with_capacity(arr.len()); + for elem in arr { + let part = match elem { + Value::String(s) => format!("'{}'", s.replace('\'', "''")), + Value::Number(n) => n.to_string(), + Value::Bool(b) => { + if *b { + "TRUE".to_string() + } else { + "FALSE".to_string() + } + } + Value::Null => "NULL".to_string(), + Value::Array(nested) => json_array_to_pg_literal(nested)?, + Value::Object(_) => return Err("Unsupported array element type".to_string()), + }; + parts.push(part); + } + Ok(format!("ARRAY[{}]", parts.join(", "))) +} + +/// Bind a WHERE-clause value from a PK map entry. Returns the SQL fragment +/// (may include a CAST) plus the typed parameter — stricter than +/// `bind_pg_value` for strings: UUID/integer string coercion is only applied +/// when the column's real type is confirmed (or unknown), matching +/// `build_pk_predicate` in the built-in driver. +pub fn bind_pk_value( + value: &Value, + placeholder_idx: usize, + column_type: Option<&str>, +) -> Result { + let base_type = column_type.map(extract_base_type); + + match value { + Value::Number(n) => { + if let Some(i) = n.as_i64() { + Ok(BoundValue { + sql: format!("CAST(${} AS bigint)", placeholder_idx), + param: Some((Box::new(i), Type::INT8)), + }) + } else if let Some(f) = n.as_f64() { + Ok(BoundValue { + sql: format!("CAST(${} AS double precision)", placeholder_idx), + param: Some((Box::new(f), Type::FLOAT8)), + }) + } else { + Err("Unsupported numeric PK value".to_string()) + } + } + Value::String(s) => { + let is_uuid_type = base_type.as_deref().is_none_or(|t| t == "UUID"); + if is_uuid_type { + if let Ok(uuid) = s.parse::() { + return Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(uuid), Type::UUID)), + }); + } + } + + let is_int_type = base_type.as_deref().is_none_or(|t| { + matches!( + t, + "SMALLINT" | "INTEGER" | "BIGINT" | "INT2" | "INT4" | "INT8" + ) + }); + if is_int_type { + if let Ok(i) = s.parse::() { + return Ok(BoundValue { + sql: format!("CAST(${} AS bigint)", placeholder_idx), + param: Some((Box::new(i), Type::INT8)), + }); + } + } + + Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some((Box::new(s.clone()), Type::TEXT)), + }) + } + _ => Err("Unsupported PK type".to_string()), + } +} + +/// Build a compound `WHERE` predicate from every entry of a pk_map, sorted +/// alphabetically by key for determinism (matches the builtin's composite-PK +/// ordering). Returns the predicate string (e.g. `"a" = $1 AND "b" = $2`) and +/// the typed parameters, starting at `placeholder_idx`. Shared by +/// update_record, delete_record, save_blob_to_file, and fetch_blob_as_data_url +/// — every method that identifies one row by primary key. +pub fn build_pk_map_predicate( + pk_map: &serde_json::Map, + column_types: &std::collections::HashMap, + placeholder_idx: usize, +) -> Result<(String, Vec), String> { + let mut keys: Vec<&String> = pk_map.keys().collect(); + keys.sort(); + + let mut predicates: Vec = Vec::with_capacity(keys.len()); + let mut owned_params: Vec = Vec::new(); + let mut idx = placeholder_idx; + + for key in keys { + let val = &pk_map[key]; + let pk_type = column_types.get(key).map(String::as_str); + let bound = bind_pk_value(val, idx, pk_type)?; + predicates.push(format!("\"{}\" = {}", key.replace('"', "\"\""), bound.sql)); + if let Some(param) = bound.param { + owned_params.push(param); + idx += 1; + } + } + + Ok((predicates.join(" AND "), owned_params)) +} diff --git a/src/binding_tests.rs b/src/binding_tests.rs new file mode 100644 index 0000000..6deca50 --- /dev/null +++ b/src/binding_tests.rs @@ -0,0 +1,309 @@ +//! Unit tests for `binding.rs`. Sibling test file per repo convention +//! (`.rules/rust.md` #4/#5) — loaded via `#[cfg(test)] mod binding_tests;`. + +use crate::binding::{bind_pg_value, bind_pk_value, BindOptions}; +use serde_json::json; + +mod bind_pg_value_tests { + use super::*; + + #[test] + fn number_binds_as_bigint_cast() { + let bound = bind_pg_value(json!(42), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + assert!(bound.param.is_some()); + } + + #[test] + fn float_number_binds_as_double_precision_cast() { + let bound = bind_pg_value(json!(1.5), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS double precision)"); + } + + #[test] + fn bool_binds_natively_without_cast() { + let bound = bind_pg_value(json!(true), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } + + #[test] + fn null_binds_as_inline_keyword_with_no_parameter() { + let bound = bind_pg_value(json!(null), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "NULL"); + assert!(bound.param.is_none()); + } + + #[test] + fn array_binds_as_inline_literal_with_no_parameter() { + let bound = bind_pg_value(json!([1, 2, 3]), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "ARRAY[1, 2, 3]"); + assert!(bound.param.is_none()); + } + + #[test] + fn nested_array_binds_recursively() { + let bound = bind_pg_value(json!([[1, 2], [3, 4]]), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "ARRAY[ARRAY[1, 2], ARRAY[3, 4]]"); + } + + #[test] + fn string_array_escapes_single_quotes() { + let bound = bind_pg_value(json!(["it's", "ok"]), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "ARRAY['it''s', 'ok']"); + } + + #[test] + fn object_without_json_column_type_is_rejected() { + let err = bind_pg_value(json!({"a": 1}), 1, &BindOptions::default()).unwrap_err(); + assert!(err.contains("Cannot bind a JSON object")); + } + + #[test] + fn object_with_jsonb_column_type_binds_natively() { + let options = BindOptions { + column_type: Some("jsonb"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!({"a": 1}), 1, &options).unwrap(); + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } + + #[test] + fn json_string_value_does_not_take_native_json_path() { + // A JSON *string* (not object/array) still goes through the generic + // string cascade even when the column is jsonb — matches the builtin's + // "value is neither String nor Null" gate. + let options = BindOptions { + column_type: Some("jsonb"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("{\"a\":1}"), 1, &options).unwrap(); + assert_eq!(bound.sql, "$1"); + } + + #[test] + fn default_sentinel_only_honored_when_allow_default_is_true() { + let options = BindOptions { + column_type: None, + enum_type: None, + allow_default: true, + }; + let bound = bind_pg_value(json!("__USE_DEFAULT__"), 1, &options).unwrap(); + assert_eq!(bound.sql, "DEFAULT"); + assert!(bound.param.is_none()); + } + + #[test] + fn default_sentinel_ignored_on_insert_allow_default_false() { + let options = BindOptions { + column_type: None, + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("__USE_DEFAULT__"), 1, &options).unwrap(); + // Falls through to the plain TEXT fallback, not treated as DEFAULT. + assert_eq!(bound.sql, "$1"); + } + + #[test] + fn blob_wire_format_decodes_to_bytea_before_other_heuristics() { + // "yv66vg==" is base64 for [0xCA, 0xFE, 0xBA, 0xBE]. + let bound = bind_pg_value( + json!("BLOB:4:application/octet-stream:yv66vg=="), + 1, + &BindOptions::default(), + ) + .unwrap(); + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } + + #[test] + fn enum_column_binds_with_qualified_cast() { + let options = BindOptions { + column_type: None, + enum_type: Some("\"test_schema\".\"mood\""), + allow_default: false, + }; + let bound = bind_pg_value(json!("sad"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS \"test_schema\".\"mood\")"); + assert!(bound.param.is_some()); + } + + #[test] + fn enum_column_takes_precedence_over_uuid_shape() { + // A value that happens to look like a UUID must still bind through + // the enum CAST if the column is an enum — the enum step runs before + // the UUID-shape heuristic in the cascade. + let options = BindOptions { + column_type: None, + enum_type: Some("\"public\".\"status\""), + allow_default: false, + }; + let bound = + bind_pg_value(json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS \"public\".\"status\")"); + } + + #[test] + fn boolean_column_accepts_common_truthy_strings() { + let options = BindOptions { + column_type: Some("boolean"), + enum_type: None, + allow_default: false, + }; + for truthy in ["true", "t", "yes", "y", "on", "1", "TRUE"] { + let bound = bind_pg_value(json!(truthy), 1, &options).unwrap(); + assert_eq!(bound.sql, "$1", "input: {truthy}"); + } + } + + #[test] + fn boolean_column_rejects_invalid_string() { + let options = BindOptions { + column_type: Some("boolean"), + enum_type: None, + allow_default: false, + }; + let err = bind_pg_value(json!("maybe"), 1, &options).unwrap_err(); + assert!(err.contains("boolean")); + } + + #[test] + fn integer_column_string_binds_as_bigint_cast() { + let options = BindOptions { + column_type: Some("integer"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("42"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + } + + #[test] + fn integer_column_rejects_non_numeric_string() { + let options = BindOptions { + column_type: Some("integer"), + enum_type: None, + allow_default: false, + }; + let err = bind_pg_value(json!("not-a-number"), 1, &options).unwrap_err(); + assert!(err.contains("integer")); + } + + #[test] + fn numeric_column_string_binds_as_numeric_cast() { + let options = BindOptions { + column_type: Some("numeric"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("12345.67"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS numeric)"); + } + + #[test] + fn timestamp_column_string_binds_with_timestamp_cast() { + let options = BindOptions { + column_type: Some("timestamp"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("2026-01-15 14:30:00"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS timestamp)"); + } + + #[test] + fn timestamptz_column_string_binds_with_timestamptz_cast() { + let options = BindOptions { + column_type: Some("timestamptz"), + enum_type: None, + allow_default: false, + }; + let bound = bind_pg_value(json!("2026-01-15 14:30:00+00"), 1, &options).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS timestamptz)"); + } + + #[test] + fn uuid_shaped_string_binds_with_uuid_cast_regardless_of_column_type() { + let bound = bind_pg_value( + json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), + 1, + &BindOptions::default(), + ) + .unwrap(); + assert_eq!(bound.sql, "CAST($1 AS uuid)"); + } + + #[test] + fn array_literal_embedded_in_string_is_parsed_as_pg_array() { + let bound = bind_pg_value(json!("[1,2,3]"), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "ARRAY[1, 2, 3]"); + assert!(bound.param.is_none()); + } + + #[test] + fn plain_string_falls_through_to_text_binding() { + let bound = bind_pg_value(json!("hello world"), 1, &BindOptions::default()).unwrap(); + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } +} + +mod bind_pk_value_tests { + use super::*; + + #[test] + fn integer_pk_binds_as_bigint_cast() { + let bound = bind_pk_value(&json!(42), 1, None).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + } + + #[test] + fn uuid_string_pk_binds_natively_when_column_type_confirmed_uuid() { + let bound = bind_pk_value( + &json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), + 1, + Some("uuid"), + ) + .unwrap(); + assert_eq!(bound.sql, "$1"); + } + + #[test] + fn uuid_shaped_string_pk_binds_as_text_when_column_type_is_not_uuid() { + // Stricter than the general bind_pg_value cascade: a uuid-*shaped* + // string targeting a confirmed non-uuid column must bind as TEXT. + let bound = bind_pk_value( + &json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), + 1, + Some("varchar"), + ) + .unwrap(); + assert_eq!(bound.sql, "$1"); + // (still bound as TEXT — no CAST — since the column type is known + // and confirmed not to be uuid) + } + + #[test] + fn integer_shaped_string_pk_binds_as_bigint_when_column_type_confirmed_integer() { + let bound = bind_pk_value(&json!("42"), 1, Some("integer")).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + } + + #[test] + fn plain_string_pk_falls_back_to_text() { + let bound = bind_pk_value(&json!("abc"), 1, None).unwrap(); + assert_eq!(bound.sql, "$1"); + } + + #[test] + fn object_pk_is_rejected() { + let err = bind_pk_value(&json!({"a": 1}), 1, None).unwrap_err(); + assert!(err.contains("Unsupported PK type")); + } +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..6db136f --- /dev/null +++ b/src/client.rs @@ -0,0 +1,472 @@ +//! PostgreSQL connection pool management via deadpool-postgres. +//! +//! Provides pool construction with optional TLS (via rustls), a process-wide +//! cache keyed by connection identity, and query helpers for common patterns +//! (single-column string queries, parameterized queries). +//! +//! # Pool caching +//! +//! Every RPC call originally built a brand-new `Pool` (connect, run one +//! query, discard) — noted as a Sprint 1 TODO ("Pool caching by connection +//! key will be added in Sprint 2") that was never followed up. Besides being +//! wasteful, a fresh TCP connect on every single call has no retry margin: a +//! transient connection hiccup on one call (e.g. a setup step in a test) is +//! silently swallowed by the caller and never retried, unlike a persistent +//! pool where a single connection failure doesn't affect already-established +//! connections. Caching by `host:port:database:user` (matches the builtin's +//! `build_connection_key` pattern in `src-tauri/src/pool_manager.rs`, minus +//! the TLS/connection_id refinements that plugin doesn't need yet) closes +//! that gap. + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::{LazyLock, Mutex}; + +use deadpool_postgres::{Config, ManagerConfig, Pool, RecyclingMethod, Runtime}; +use tokio_postgres::types::{ToSql, Type}; +use tokio_postgres::{NoTls, Row}; +use tokio_postgres_rustls::MakeRustlsConnect; + +use crate::models::ConnectionParams; + +static POOLS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Build a connection pool from the given params and verify connectivity +/// by acquiring one client and running `SELECT 1`. +pub async fn test_connection(params: &ConnectionParams) -> Result<(), String> { + let pool = get_or_create_pool(params).await?; + let client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + client + .query_one("SELECT 1", &[]) + .await + .map_err(|e| format!("Query failed: {e}"))?; + Ok(()) +} + +/// Run a query and extract a single text column from each row. +/// Used for schema discovery methods that return `Vec`. +pub async fn query_strings( + params: &ConnectionParams, + query: &str, + query_params: &[&(dyn ToSql + Sync)], + column: &str, +) -> Result, String> { + let pool = get_or_create_pool(params).await?; + let client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + let rows = client + .query(query, query_params) + .await + .map_err(|e| format!("Query failed: {e}"))?; + + let results = rows + .iter() + .map(|r| r.try_get::<_, String>(column).unwrap_or_default()) + .collect(); + Ok(results) +} + +/// Run a query and return the raw rows for caller-side mapping. +pub async fn query_rows( + params: &ConnectionParams, + query: &str, + query_params: &[&(dyn ToSql + Sync)], +) -> Result, String> { + let pool = get_or_create_pool(params).await?; + let client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + client + .query(query, query_params) + .await + .map_err(|e| format!("Query failed: {e}")) +} + +/// Execute a statement with explicit per-placeholder wire types, pinned via +/// `prepare_typed`. Required for `CAST($N AS X)`-style placeholders where +/// letting the server infer the type from query context would reject the +/// bind before PostgreSQL's own parser sees the value. Returns affected rows. +pub async fn execute_typed( + params: &ConnectionParams, + query: &str, + typed_params: &[(&(dyn ToSql + Sync), Type)], +) -> Result { + let pool = get_or_create_pool(params).await?; + let client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + let types: Vec = typed_params.iter().map(|(_, t)| t.clone()).collect(); + let stmt = client + .prepare_typed(query, &types) + .await + .map_err(|e| format!("Prepare failed: {e}"))?; + let values: Vec<&(dyn ToSql + Sync)> = typed_params.iter().map(|(v, _)| *v).collect(); + client + .execute(&stmt, &values) + .await + .map_err(|e| format!("Execute failed: {e}")) +} + +/// Run a SELECT with explicit per-placeholder wire types (same rationale as +/// `execute_typed`) and return the resulting rows. +pub async fn query_typed( + params: &ConnectionParams, + query: &str, + typed_params: &[(&(dyn ToSql + Sync), Type)], +) -> Result, String> { + let pool = get_or_create_pool(params).await?; + let client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + let types: Vec = typed_params.iter().map(|(_, t)| t.clone()).collect(); + let stmt = client + .prepare_typed(query, &types) + .await + .map_err(|e| format!("Prepare failed: {e}"))?; + let values: Vec<&(dyn ToSql + Sync)> = typed_params.iter().map(|(v, _)| *v).collect(); + client + .query(&stmt, &values) + .await + .map_err(|e| format!("Query failed: {e}")) +} + +/// Fetch data types for every column in a table as a name -> type map. +/// Used by insert to resolve type-aware binding for all columns in one query. +pub async fn get_column_types_map( + params: &ConnectionParams, + table: &str, + schema: &str, +) -> Result, String> { + let query = r#" + SELECT + column_name, + CASE + WHEN data_type = 'USER-DEFINED' THEN udt_name + ELSE data_type + END AS resolved_type + FROM information_schema.columns + WHERE table_schema = $1 AND table_name = $2 + "#; + let rows = query_rows(params, query, &[&schema, &table]).await?; + Ok(rows + .iter() + .filter_map(|r| { + let name: String = r.try_get("column_name").ok()?; + let ty: String = r.try_get("resolved_type").ok()?; + Some((name, ty)) + }) + .collect()) +} + +/// Fetch the schema-qualified, quoted enum type name for every enum column +/// in a table (e.g. `current_mood -> "test_schema"."mood"`). Columns not +/// backed by an enum type are absent from the map. +pub async fn get_enum_column_types( + params: &ConnectionParams, + schema: &str, + table: &str, +) -> Result, String> { + let query = "SELECT a.attname::text AS column_name, \ + tn.nspname::text AS type_schema, t.typname::text AS type_name \ + FROM pg_attribute a \ + JOIN pg_class c ON c.oid = a.attrelid \ + JOIN pg_namespace n ON n.oid = c.relnamespace \ + JOIN pg_type t ON t.oid = a.atttypid \ + JOIN pg_namespace tn ON tn.oid = t.typnamespace \ + WHERE n.nspname = $1 AND c.relname = $2 \ + AND a.attnum > 0 AND NOT a.attisdropped AND t.typtype = 'e'"; + + let rows = query_rows(params, query, &[&schema, &table]).await?; + Ok(rows + .iter() + .filter_map(|r| { + let col: String = r.try_get("column_name").ok()?; + let type_schema: String = r.try_get("type_schema").ok()?; + let type_name: String = r.try_get("type_name").ok()?; + Some((col, quote_qualified_type(&type_schema, &type_name))) + }) + .collect()) +} + +/// Quote a schema-qualified type name (e.g. `"public"."mood"`) so it can be +/// spliced into a `CAST($N AS ...)` without becoming an injection vector. +fn quote_qualified_type(type_schema: &str, type_name: &str) -> String { + format!( + "\"{}\".\"{}\"", + type_schema.replace('"', "\"\""), + type_name.replace('"', "\"\""), + ) +} + +/// Get the cached pool for these connection params, creating and caching one +/// on first use. Public for use by query handlers that need direct pool +/// access (e.g. to acquire one client for a multi-statement batch). +pub async fn build_pool_pub(params: &ConnectionParams) -> Result { + get_or_create_pool(params).await +} + +/// Identifies a connection target for pool-cache purposes. +/// Matches on host:port:database:user (plus the startup script, so editing +/// it forces a fresh pool) — sufficient for this plugin's scope (no +/// per-connection TLS-mode/connection_id refinement, unlike the builtin). +fn connection_key(params: &ConnectionParams) -> String { + format!( + "{}:{}:{}:{}:{}", + params.host.as_deref().unwrap_or(""), + params.port.unwrap_or(5432), + params.database.as_deref().unwrap_or(""), + params.username.as_deref().unwrap_or(""), + params.startup_script.as_deref().unwrap_or(""), + ) +} + +/// Return the cached pool for this connection's identity, or build and cache +/// a new one if this is the first request for that identity. +async fn get_or_create_pool(params: &ConnectionParams) -> Result { + let key = connection_key(params); + + { + let pools = POOLS + .lock() + .map_err(|_| "pool cache lock poisoned".to_string())?; + if let Some(pool) = pools.get(&key) { + return Ok(pool.clone()); + } + } + + let pool = build_pool(params).await?; + let mut pools = POOLS + .lock() + .map_err(|_| "pool cache lock poisoned".to_string())?; + // Another call may have raced us to create this pool between the read + // above and this write — keep whichever is already cached. + Ok(pools.entry(key).or_insert(pool).clone()) +} + +/// Drop pools that currently have no checked-out connections. Called +/// periodically so long-idle sessions don't linger for the plugin's +/// lifetime — matches the sqlserver/dynamodb sibling plugins' pattern. +pub fn cleanup_idle_pools() { + if let Ok(mut pools) = POOLS.lock() { + pools.retain(|_, pool| { + let status = pool.status(); + status.size > status.available + }); + } +} + +/// Build a deadpool-postgres pool for the given connection parameters. +/// +/// When `connection_string` is set, it takes precedence over the discrete +/// host/port/database/username/password fields — matching the README's +/// documented behavior ("as an alternative to the discrete fields above"). +async fn build_pool(params: &ConnectionParams) -> Result { + let mut cfg = Config::new(); + + match params + .connection_string + .as_deref() + .filter(|s| !s.trim().is_empty()) + { + Some(conn_str) => { + let parsed = tokio_postgres::Config::from_str(conn_str) + .map_err(|e| format!("Invalid connection string: {e}"))?; + let host = parsed.get_hosts().first().and_then(|h| match h { + tokio_postgres::config::Host::Tcp(host) => Some(host.clone()), + #[cfg(unix)] + tokio_postgres::config::Host::Unix(_) => None, + }); + cfg.host = host; + cfg.port = parsed.get_ports().first().copied(); + cfg.dbname = parsed.get_dbname().map(str::to_string); + cfg.user = parsed.get_user().map(str::to_string); + cfg.password = parsed + .get_password() + .map(|p| String::from_utf8_lossy(p).into_owned()); + } + None => { + cfg.host = params.host.clone(); + cfg.port = params.port; + cfg.dbname = params.database.clone(); + cfg.user = params.username.clone(); + cfg.password = params.password.clone(); + } + } + + cfg.manager = Some(ManagerConfig { + recycling_method: RecyclingMethod::Fast, + }); + + let script = params + .startup_script + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + + if needs_tls(params) { + let tls_config = build_tls_connector(params)?; + let tls = MakeRustlsConnect::new(tls_config); + if let Some(script) = script { + preflight_startup_script(&cfg, tls.clone(), script).await?; + } + let mut builder = cfg + .builder(tls) + .map_err(|e| format!("Pool creation failed (TLS): {e}"))? + .runtime(Runtime::Tokio1); + if let Some(script) = script { + builder = builder.post_create(startup_script_hook(script)); + } + builder + .build() + .map_err(|e| format!("Pool creation failed (TLS): {e}")) + } else { + if let Some(script) = script { + preflight_startup_script(&cfg, NoTls, script).await?; + } + let mut builder = cfg + .builder(NoTls) + .map_err(|e| format!("Pool creation failed: {e}"))? + .runtime(Runtime::Tokio1); + if let Some(script) = script { + builder = builder.post_create(startup_script_hook(script)); + } + builder + .build() + .map_err(|e| format!("Pool creation failed: {e}")) + } +} + +/// Format a startup-script execution failure so the surfaced error clearly +/// names the startup script as the cause, instead of reading like a bad host +/// or wrong credentials. +fn startup_script_error(err: impl std::fmt::Display) -> String { + format!("Startup script failed: {err}") +} + +/// Build the `post_create` hook that runs the startup script on every new +/// pooled connection (matches the builtin driver's `post_create` hook — see +/// `src-tauri/src/pool_manager.rs`). +fn startup_script_hook(script: &str) -> deadpool_postgres::Hook { + let script = script.to_string(); + deadpool_postgres::Hook::async_fn(move |client, _metrics| { + let script = script.clone(); + Box::pin(async move { + client + .batch_execute(&script) + .await + .map_err(|e| deadpool_postgres::HookError::message(startup_script_error(e)))?; + Ok(()) + }) + }) +} + +/// Validate the startup script on a throwaway connection so a broken script +/// fails fast with a clearly attributed error, **without** applying its side +/// effects (the script runs inside a transaction that is rolled back). This +/// preflight exists only for early, well-labelled failures — the per-pool +/// `post_create` hook is the single place the script actually takes effect. +/// Matches the builtin driver's `run_postgres_startup_script` preflight. +async fn preflight_startup_script(cfg: &Config, tls: T, script: &str) -> Result<(), String> +where + T: tokio_postgres::tls::MakeTlsConnect + Clone + Sync + Send + 'static, + T::Stream: Sync + Send, + T::TlsConnect: Sync + Send, + >::Future: Send, +{ + let pg_config = cfg + .get_pg_config() + .map_err(|e| format!("Pool creation failed: {e}"))?; + let (mut client, connection) = pg_config + .connect(tls) + .await + .map_err(|e| format!("Connection failed: {e}"))?; + let driver = tokio::spawn(async move { + let _ = connection.await; + }); + let outcome: Result<(), tokio_postgres::Error> = async { + let tx = client.transaction().await?; + tx.batch_execute(script).await?; + tx.rollback().await + } + .await; + drop(client); + driver.abort(); + outcome.map_err(startup_script_error) +} + +/// Determine whether TLS should be used based on ssl_mode. +fn needs_tls(params: &ConnectionParams) -> bool { + matches!( + params.ssl_mode.as_deref(), + Some("require" | "verify-ca" | "verify-full") + ) +} + +/// Build a rustls ClientConfig. `verify-ca`/`verify-full` validate the +/// server's certificate chain — against a caller-supplied CA bundle +/// (`ssl_ca`) when present, or the platform trust store otherwise. `require` +/// forces TLS without certificate validation (matches the builtin driver's +/// `require` behavior — see `src-tauri/src/pool_manager.rs`). +fn build_tls_connector(params: &ConnectionParams) -> Result { + use rustls_platform_verifier::BuilderVerifierExt; + + let user_ca = params.ssl_ca.as_deref().filter(|s| !s.trim().is_empty()); + + let needs_cert_validation = matches!( + params.ssl_mode.as_deref(), + Some("verify-ca" | "verify-full") + ); + + if needs_cert_validation { + if let Some(ca_path) = user_ca { + let roots = load_roots_from_pem(ca_path)?; + let verifier = + rustls::client::WebPkiServerVerifier::builder(std::sync::Arc::new(roots)) + .build() + .map_err(|e| format!("Failed to build certificate verifier: {e}"))?; + return Ok(rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth()); + } + } + + let config = rustls::ClientConfig::builder() + .with_platform_verifier() + .map_err(|e| format!("Failed to build platform TLS verifier: {e}"))? + .with_no_client_auth(); + Ok(config) +} + +/// Load root certificates from a PEM file (used for `ssl_ca`-pinned +/// `verify-ca`/`verify-full` connections). +fn load_roots_from_pem(path: &str) -> Result { + let pem = + std::fs::read(path).map_err(|e| format!("Failed to read ssl_ca file '{path}': {e}"))?; + let mut roots = rustls::RootCertStore::empty(); + let mut cursor = std::io::Cursor::new(&pem[..]); + for cert in rustls_pemfile::certs(&mut cursor) { + let cert = cert.map_err(|e| format!("Failed to parse ssl_ca '{path}': {e}"))?; + roots + .add(cert) + .map_err(|e| format!("Failed to add ssl_ca cert from '{path}': {e}"))?; + } + if roots.is_empty() { + return Err(format!( + "ssl_ca '{path}' contained no PEM CERTIFICATE blocks" + )); + } + Ok(roots) +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod client_tests; diff --git a/src/client_tests.rs b/src/client_tests.rs new file mode 100644 index 0000000..7f8bf26 --- /dev/null +++ b/src/client_tests.rs @@ -0,0 +1,110 @@ +//! Unit tests for `client.rs`. Sibling test file per repo convention +//! (`.rules/rust.md` #4/#5) — loaded via `#[cfg(test)] mod client_tests;`. + +use super::{cleanup_idle_pools, connection_key, get_or_create_pool, POOLS}; +use crate::models::ConnectionParams; + +fn params(host: &str, port: u16, db: &str, user: &str) -> ConnectionParams { + ConnectionParams { + driver: Some("postgres-plugin".to_string()), + host: Some(host.to_string()), + port: Some(port), + database: Some(db.to_string()), + username: Some(user.to_string()), + password: None, + ssl_mode: None, + ssl_ca: None, + ssl_cert: None, + ssl_key: None, + connection_string: None, + startup_script: None, + } +} + +#[test] +fn connection_key_differs_by_database() { + let a = connection_key(¶ms("localhost", 5432, "db1", "postgres")); + let b = connection_key(¶ms("localhost", 5432, "db2", "postgres")); + assert_ne!(a, b, "different databases must not share a cache key"); +} + +#[test] +fn connection_key_differs_by_host() { + let a = connection_key(¶ms("host1", 5432, "db", "postgres")); + let b = connection_key(¶ms("host2", 5432, "db", "postgres")); + assert_ne!(a, b); +} + +#[test] +fn connection_key_differs_by_port() { + let a = connection_key(¶ms("localhost", 5432, "db", "postgres")); + let b = connection_key(¶ms("localhost", 5433, "db", "postgres")); + assert_ne!(a, b); +} + +#[test] +fn connection_key_differs_by_user() { + let a = connection_key(¶ms("localhost", 5432, "db", "alice")); + let b = connection_key(¶ms("localhost", 5432, "db", "bob")); + assert_ne!(a, b); +} + +#[test] +fn connection_key_is_stable_for_identical_params() { + let a = connection_key(¶ms("localhost", 5432, "db", "postgres")); + let b = connection_key(¶ms("localhost", 5432, "db", "postgres")); + assert_eq!(a, b); +} + +#[tokio::test] +async fn get_or_create_pool_reuses_cached_entry_for_identical_params() { + // deadpool's Pool::new is lazy (no connection attempt at creation + // time) as long as no startup script is set — this test's `params()` + // helper leaves startup_script as None, so this exercises only the + // cache bookkeeping, not real connectivity. Use a key unlikely to + // collide with other tests running in the same process. + let p = params("cache-test-host-unique", 5432, "db", "user"); + let key = connection_key(&p); + + let before = POOLS.lock().unwrap().len(); + get_or_create_pool(&p) + .await + .expect("first call creates and caches a pool"); + let after_first = POOLS.lock().unwrap().len(); + assert_eq!( + after_first, + before + 1, + "first call should insert one entry" + ); + assert!(POOLS.lock().unwrap().contains_key(&key)); + + get_or_create_pool(&p) + .await + .expect("second call should hit the cache"); + let after_second = POOLS.lock().unwrap().len(); + assert_eq!( + after_second, after_first, + "second call with identical params must not create a new entry" + ); +} + +#[tokio::test] +async fn cleanup_idle_pools_evicts_pools_with_no_checked_out_connections() { + // A freshly-built, never-connected pool has status().size == + // status().available == 0 (deadpool's Pool::new is lazy) — no + // checked-out connections, so it must be evicted as idle/unused. + let p = params("cleanup-test-host-unique", 5432, "db", "user"); + let key = connection_key(&p); + + get_or_create_pool(&p) + .await + .expect("pool should be created and cached"); + assert!(POOLS.lock().unwrap().contains_key(&key)); + + cleanup_idle_pools(); + + assert!( + !POOLS.lock().unwrap().contains_key(&key), + "an idle pool with no checked-out connections must be evicted" + ); +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..8c375a1 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,22 @@ +//! Plugin error types. + +use std::fmt; + +#[derive(Debug)] +pub enum PluginError { + Connection(String), + Query(String), + Internal(String), +} + +impl fmt::Display for PluginError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Connection(msg) => write!(f, "connection error: {msg}"), + Self::Query(msg) => write!(f, "query error: {msg}"), + Self::Internal(msg) => write!(f, "internal error: {msg}"), + } + } +} + +impl std::error::Error for PluginError {} diff --git a/src/extract.rs b/src/extract.rs new file mode 100644 index 0000000..cf6279e --- /dev/null +++ b/src/extract.rs @@ -0,0 +1,571 @@ +//! Value extraction from tokio-postgres rows to serde_json::Value. +//! +//! Replicates the exact type mapping of the built-in driver's +//! `src-tauri/src/drivers/postgres/extract/` system. Every PG type must +//! produce byte-identical JSON to the builtin — the parity tests enforce this. + +use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; +use rust_decimal::Decimal; +use serde_json::Value as JsonValue; +use tokio_postgres::types::{FromSql, Kind, Type}; +use tokio_postgres::Row; +use uuid::Uuid; + +/// JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1). +const JS_MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + +/// Extract a single column value from a row as a JSON value. +/// Matches the builtin driver's extraction behavior exactly. +pub fn extract_value(row: &Row, index: usize) -> JsonValue { + let col_type = row.columns()[index].type_().clone(); + + // NULL check: try to get as Option first + match col_type { + ref t if *t == Type::BOOL => try_extract::(row, index, JsonValue::Bool), + ref t if *t == Type::INT2 => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::INT4 => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::INT8 => try_extract::(row, index, i64_to_json), + ref t if *t == Type::FLOAT4 => try_extract::(row, index, |v| { + serde_json::Number::from_f64(v as f64) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null) + }), + ref t if *t == Type::FLOAT8 => try_extract::(row, index, |v| { + serde_json::Number::from_f64(v) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null) + }), + ref t if *t == Type::NUMERIC => { + try_extract::(row, index, |v| JsonValue::String(v.to_string())) + } + ref t + if *t == Type::TEXT + || *t == Type::VARCHAR + || *t == Type::BPCHAR + || *t == Type::NAME => + { + try_extract::(row, index, JsonValue::String) + } + ref t if *t == Type::UUID => { + try_extract::(row, index, |v| JsonValue::String(v.to_string())) + } + ref t if *t == Type::DATE => try_extract::(row, index, |v| { + JsonValue::String(v.format("%Y-%m-%d").to_string()) + }), + ref t if *t == Type::TIME => try_extract::(row, index, |v| { + JsonValue::String(v.format("%H:%M:%S").to_string()) + }), + ref t if *t == Type::TIMETZ => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::INTERVAL => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::TIMESTAMP => try_extract::(row, index, |v| { + JsonValue::String(v.format("%Y-%m-%d %H:%M:%S").to_string()) + }), + ref t if *t == Type::TIMESTAMPTZ => { + try_extract::>(row, index, |v| { + JsonValue::String(v.format("%Y-%m-%d %H:%M:%S").to_string()) + }) + } + ref t if *t == Type::JSON || *t == Type::JSONB => { + try_extract::(row, index, |v| v) + } + ref t if *t == Type::BYTEA => try_extract::>(row, index, |v| { + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &v); + JsonValue::String(format!("BLOB:{}:application/octet-stream:{}", v.len(), b64)) + }), + ref t if *t == Type::INET || *t == Type::CIDR => { + try_extract::(row, index, JsonValue::from) + } + ref t if *t == Type::MACADDR => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::OID => try_extract::(row, index, JsonValue::from), + ref t + if *t == Type::INT4_RANGE + || *t == Type::INT8_RANGE + || *t == Type::NUM_RANGE + || *t == Type::TS_RANGE + || *t == Type::TSTZ_RANGE + || *t == Type::DATE_RANGE => + { + try_extract_range(row, index) + } + ref t if *t == Type::INT2_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array(v.into_iter().map(JsonValue::from).collect()) + }), + ref t if *t == Type::INT4_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array(v.into_iter().map(JsonValue::from).collect()) + }), + ref t if *t == Type::INT8_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array(v.into_iter().map(i64_to_json).collect()) + }), + ref t if *t == Type::TEXT_ARRAY || *t == Type::VARCHAR_ARRAY => { + try_extract::>(row, index, |v| { + JsonValue::Array(v.into_iter().map(JsonValue::String).collect()) + }) + } + ref t if *t == Type::FLOAT4_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array( + v.into_iter() + .map(|f| { + serde_json::Number::from_f64(f as f64) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null) + }) + .collect(), + ) + }), + ref t if *t == Type::FLOAT8_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array( + v.into_iter() + .map(|f| { + serde_json::Number::from_f64(f) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null) + }) + .collect(), + ) + }), + ref t if *t == Type::BOOL_ARRAY => try_extract::>(row, index, |v| { + JsonValue::Array(v.into_iter().map(JsonValue::Bool).collect()) + }), + // For types not explicitly handled (ranges, composites, geometric, etc.), + // fall back to text representation via the Display trait on the raw bytes. + _ => { + // Try as string — many types have text representations + match row.try_get::<_, String>(index) { + Ok(s) => JsonValue::String(s), + Err(_) => JsonValue::Null, + } + } + } +} + +/// Safely convert i64 to JSON: numbers within JS safe integer range are +/// JSON numbers; larger values become JSON strings to prevent precision loss. +fn i64_to_json(v: i64) -> JsonValue { + if v.abs() <= JS_MAX_SAFE_INTEGER { + JsonValue::from(v) + } else { + JsonValue::String(v.to_string()) + } +} + +/// Helper: try to extract a typed value from the row, returning JsonValue::Null +/// on any failure (NULL column, type mismatch, etc.). +fn try_extract<'a, T>(row: &'a Row, index: usize, map: impl FnOnce(T) -> JsonValue) -> JsonValue +where + T: tokio_postgres::types::FromSql<'a>, +{ + match row.try_get::<_, Option>(index) { + Ok(Some(v)) => map(v), + Ok(None) => JsonValue::Null, + Err(_) => { + // Type mismatch — try string fallback + match row.try_get::<_, Option>(index) { + Ok(Some(s)) => JsonValue::String(s), + _ => JsonValue::Null, + } + } + } +} + +/// Extract a range-typed column (INT4RANGE, TSRANGE, etc.) using the generic +/// `Type::kind()` dispatch (matches the builtin's `Kind::Range(subtype)` +/// handling) rather than per-range-type constants, since range subtypes are +/// resolved dynamically from the column's element type. +fn try_extract_range(row: &Row, index: usize) -> JsonValue { + match row.try_get::<_, Option>(index) { + Ok(Some(v)) => JsonValue::String(v.0), + Ok(None) => JsonValue::Null, + Err(_) => JsonValue::Null, + } +} + +/// Wraps the raw range wire format: 1 flag byte, then 0-2 length-prefixed +/// bound values (each a 4-byte big-endian length followed by that many +/// bytes), formatted as `"[lower, upper)"` (bracket/paren per bound +/// inclusivity) matching `src-tauri/src/drivers/postgres/extract/range.rs`. +struct RangeValue(String); + +impl<'a> FromSql<'a> for RangeValue { + fn from_sql( + ty: &Type, + raw: &'a [u8], + ) -> Result> { + let subtype = match ty.kind() { + Kind::Range(t) => t.clone(), + _ => return Err("expected a range type".into()), + }; + + if raw.is_empty() { + return Err("empty range buffer".into()); + } + let flag = raw[0]; + let mut buf = &raw[1..]; + + // RANGE_EMPTY flag bit 0 + if (flag & 1) == 1 { + return Ok(Self("empty".to_string())); + } + + let lower_char = if (flag & (1 << 1)) == 0 { '(' } else { '[' }; + let upper_char = if (flag & (1 << 2)) == 0 { ')' } else { ']' }; + + let mut out = String::new(); + out.push(lower_char); + + // RANGE_LB_INF flag bit 3 — lower bound is unbounded (nothing pushed). + if flag & (1 << 3) == 0 { + // A present-but-unextractable lower bound short-circuits the + // whole range to "null, null" and returns immediately — matches + // the builtin's early-return on lower-bound extraction failure. + match extract_range_bound(&subtype, &mut buf) { + Some(s) => out.push_str(&s), + None => { + out.push_str("null, null"); + out.push(upper_char); + return Ok(Self(out)); + } + } + } + out.push_str(", "); + + // RANGE_UB_INF flag bit 4 — upper bound is unbounded (nothing pushed). + if flag & (1 << 4) == 0 { + if let Some(s) = extract_range_bound(&subtype, &mut buf) { + out.push_str(&s); + } else { + out.push_str("null"); + } + } + out.push(upper_char); + + Ok(Self(out)) + } + + fn accepts(ty: &Type) -> bool { + matches!(ty.kind(), Kind::Range(_)) + } +} + +/// Read one length-prefixed bound value from a range buffer and format it +/// the same way `extract_value` would for a plain column of that subtype. +fn extract_range_bound(subtype: &Type, buf: &mut &[u8]) -> Option { + if buf.len() < 4 { + return None; + } + let len = i32::from_be_bytes(buf[..4].try_into().ok()?); + *buf = &buf[4..]; + if len < 0 { + return None; + } + let len = len as usize; + if buf.len() < len { + return None; + } + let (value_buf, rest) = buf.split_at(len); + *buf = rest; + + let json = extract_simple_from_bytes(subtype, value_buf); + match json { + JsonValue::Null => None, + // Matches the builtin's `range.push_str(&val.to_string())`: calling + // `.to_string()` on a serde_json::Value quotes strings (producing + // `"2026-01-01 00:00:00"` inside the range) but leaves numbers bare + // (producing `1` not `"1"`) — do not special-case String here. + other => Some(other.to_string()), + } +} + +/// Format a raw byte buffer as JSON for the subset of simple PG types that +/// can appear as range bounds in this plugin's test corpus (integers, +/// numeric, date/timestamp). Falls back to Null for anything else. +fn extract_simple_from_bytes(ty: &Type, buf: &[u8]) -> JsonValue { + match *ty { + Type::INT4 => i32::from_sql(ty, buf) + .map(JsonValue::from) + .unwrap_or(JsonValue::Null), + Type::INT8 => i64::from_sql(ty, buf) + .map(i64_to_json) + .unwrap_or(JsonValue::Null), + Type::NUMERIC => Decimal::from_sql(ty, buf) + .map(|v| JsonValue::String(v.to_string())) + .unwrap_or(JsonValue::Null), + Type::DATE => NaiveDate::from_sql(ty, buf) + .map(|v| JsonValue::String(v.format("%Y-%m-%d").to_string())) + .unwrap_or(JsonValue::Null), + Type::TIMESTAMP => NaiveDateTime::from_sql(ty, buf) + .map(|v| JsonValue::String(v.format("%Y-%m-%d %H:%M:%S").to_string())) + .unwrap_or(JsonValue::Null), + Type::TIMESTAMPTZ => chrono::DateTime::::from_sql(ty, buf) + .map(|v| JsonValue::String(v.format("%Y-%m-%d %H:%M:%S").to_string())) + .unwrap_or(JsonValue::Null), + _ => JsonValue::Null, + } +} + +/// TIMETZ: time-of-day + UTC offset. Wire format: 8-byte microseconds since +/// midnight (i64, always non-negative), then a 4-byte signed offset in +/// seconds (positive = west of UTC, hence the sign flip below). Matches +/// `src-tauri/src/drivers/postgres/extract/advanced_types.rs::TimeTz`. +struct TimeTz { + hrs: u8, + mins: u8, + secs: u8, + microseconds: u32, + offset_sign: char, + offset_hrs: u8, + offset_mins: u8, + offset_secs: u8, +} + +impl<'a> FromSql<'a> for TimeTz { + fn from_sql(_ty: &Type, raw: &[u8]) -> Result> { + if raw.len() < 12 { + return Err(format!("expected at least 12 bytes for TIMETZ, got {}", raw.len()).into()); + } + let mut microseconds = i64::from_be_bytes(raw[0..8].try_into().unwrap()); + if microseconds < 0 { + return Err("microseconds must not be negative for TIMETZ".into()); + } + let hrs = (microseconds / (1_000_000 * 60 * 60)) as u8; + microseconds %= 1_000_000 * 60 * 60; + let mins = (microseconds / (1_000_000 * 60)) as u8; + microseconds %= 1_000_000 * 60; + let secs = (microseconds / 1_000_000) as u8; + let microseconds = (microseconds % 1_000_000) as u32; + + let mut timezone_offset = i32::from_be_bytes(raw[8..12].try_into().unwrap()); + let offset_sign = if timezone_offset.is_positive() { + '-' + } else { + timezone_offset = -timezone_offset; + '+' + }; + let offset_hrs = (timezone_offset / 3600) as u8; + let remainder = timezone_offset % 3600; + let offset_mins = (remainder / 60) as u8; + let offset_secs = (remainder % 60) as u8; + + Ok(Self { + hrs, + mins, + secs, + microseconds, + offset_sign, + offset_hrs, + offset_mins, + offset_secs, + }) + } + + fn accepts(ty: &Type) -> bool { + *ty == Type::TIMETZ + } +} + +impl From for JsonValue { + fn from(v: TimeTz) -> Self { + let mut time = format!("{:02}:{:02}:{:02}", v.hrs, v.mins, v.secs); + if v.microseconds > 0 { + time.push('.'); + time.push_str(v.microseconds.to_string().trim_end_matches('0')); + } + time.push_str(&format!("{}{:02}", v.offset_sign, v.offset_hrs)); + if v.offset_mins > 0 { + time.push_str(&format!(":{:02}", v.offset_mins)); + } + if v.offset_secs > 0 { + time.push_str(&format!(":{:02}", v.offset_secs)); + } + JsonValue::String(time) + } +} + +/// INTERVAL: 8-byte microseconds, 4-byte days, 4-byte months (signed). +/// Matches `advanced_types.rs::Interval`. +struct Interval { + years: i32, + months: i8, + days: i32, + sign: char, + hours: u8, + minutes: u8, + seconds: u8, + microseconds: u32, +} + +impl<'a> FromSql<'a> for Interval { + fn from_sql(_ty: &Type, raw: &[u8]) -> Result> { + if raw.len() < 16 { + return Err(format!("expected 16 bytes for INTERVAL, got {}", raw.len()).into()); + } + let mut microseconds = i64::from_be_bytes(raw[0..8].try_into().unwrap()); + let mut days = i32::from_be_bytes(raw[8..12].try_into().unwrap()); + let mut months = i32::from_be_bytes(raw[12..16].try_into().unwrap()); + let mut years = 0; + + if !(-11..=11).contains(&months) { + years = months / 12; + months %= 12; + } + + let sign = if microseconds < 0 { + microseconds = -microseconds; + '-' + } else { + '+' + }; + + let mut hrs = microseconds / (1_000_000 * 60 * 60); + microseconds %= 1_000_000 * 60 * 60; + let mins = (microseconds / (1_000_000 * 60)) as u8; + microseconds %= 1_000_000 * 60; + let secs = (microseconds / 1_000_000) as u8; + let microseconds = (microseconds % 1_000_000) as u32; + + if !(-23..=23).contains(&hrs) { + days += (hrs / 24) as i32; + hrs %= 24; + } + + Ok(Self { + years, + months: months as i8, + days, + sign, + hours: hrs as u8, + minutes: mins, + seconds: secs, + microseconds, + }) + } + + fn accepts(ty: &Type) -> bool { + *ty == Type::INTERVAL + } +} + +impl From for JsonValue { + fn from(v: Interval) -> Self { + let mut s = String::new(); + + if v.years != 0 { + let unit = if v.years == 1 || v.years == -1 { + "year" + } else { + "years" + }; + s.push_str(&format!("{} {} ", v.years, unit)); + } + if v.months != 0 { + let unit = if v.months == 1 || v.months == -1 { + "month" + } else { + "months" + }; + s.push_str(&format!("{} {} ", v.months, unit)); + } + if v.days != 0 { + let unit = if v.days == 1 || v.days == -1 { + "day" + } else { + "days" + }; + s.push_str(&format!("{} {} ", v.days, unit)); + } + if v.hours != 0 || v.minutes != 0 || v.seconds != 0 || v.microseconds != 0 { + if v.sign != '+' { + s.push(v.sign); + } + s.push_str(&format!("{:02}:{:02}:{:02}", v.hours, v.minutes, v.seconds)); + if v.microseconds != 0 { + s.push('.'); + s.push_str(v.microseconds.to_string().trim_end_matches('0')); + } + } + + JsonValue::String(s) + } +} + +/// INET/CIDR wire format: 1 byte family (2=IPv4, 3=IPv6), 1 byte netmask, +/// 1 byte is_cidr flag (ignored — INET and CIDR share this layout), 1 byte +/// address length, then the address bytes. Matches +/// `advanced_types.rs::CidrOrInet`. +struct CidrOrInet { + addr: std::net::IpAddr, + netmask: u8, +} + +impl<'a> FromSql<'a> for CidrOrInet { + fn from_sql(_ty: &Type, raw: &[u8]) -> Result> { + if raw.len() < 8 { + return Err("invalid buffer size for INET/CIDR".into()); + } + let family = raw[0]; + let netmask = raw[1]; + let len = raw[3]; + + match family { + 2 => { + if netmask > 32 || len != 4 { + return Err("invalid IPv4 INET/CIDR buffer".into()); + } + let octets: [u8; 4] = raw[4..8].try_into().unwrap(); + Ok(Self { + addr: std::net::IpAddr::from(octets), + netmask, + }) + } + 3 => { + if netmask > 128 || len != 16 || raw.len() < 20 { + return Err("invalid IPv6 INET/CIDR buffer".into()); + } + let bytes: [u8; 16] = raw[4..20].try_into().unwrap(); + Ok(Self { + addr: std::net::IpAddr::from(bytes), + netmask, + }) + } + _ => Err(format!("unexpected INET/CIDR family byte: {family}").into()), + } + } + + fn accepts(ty: &Type) -> bool { + *ty == Type::INET || *ty == Type::CIDR + } +} + +impl From for JsonValue { + fn from(v: CidrOrInet) -> Self { + JsonValue::String(format!("{}/{}", v.addr, v.netmask)) + } +} + +/// MACADDR: exactly 6 raw bytes. Matches `advanced_types.rs::MacAddr`. +struct MacAddr { + bytes: [u8; 6], +} + +impl<'a> FromSql<'a> for MacAddr { + fn from_sql(_ty: &Type, raw: &[u8]) -> Result> { + if raw.len() != 6 { + return Err(format!("expected 6 bytes for MACADDR, got {}", raw.len()).into()); + } + let mut bytes = [0u8; 6]; + bytes.copy_from_slice(raw); + Ok(Self { bytes }) + } + + fn accepts(ty: &Type) -> bool { + *ty == Type::MACADDR + } +} + +impl From for JsonValue { + fn from(v: MacAddr) -> Self { + JsonValue::String(format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + v.bytes[0], v.bytes[1], v.bytes[2], v.bytes[3], v.bytes[4], v.bytes[5] + )) + } +} diff --git a/src/handlers/blob.rs b/src/handlers/blob.rs new file mode 100644 index 0000000..25d355a --- /dev/null +++ b/src/handlers/blob.rs @@ -0,0 +1,143 @@ +//! BLOB (bytea) helpers — save_blob_to_file, fetch_blob_as_data_url. +//! +//! Mirrors the built-in driver's exact query shape +//! (`src-tauri/src/drivers/postgres/mod.rs::save_blob_column_to_file` / +//! `fetch_blob_column_as_data_url`) — a single-column SELECT filtered by the +//! row's primary key, using the same `build_pk_map_predicate` helper as +//! update_record/delete_record. + +use serde_json::Value; +use tokio_postgres::types::{ToSql, Type}; + +use crate::binding::build_pk_map_predicate; +use crate::client; +use crate::models::{inner_params, ConnectionParams}; +use crate::rpc::{error_response, ok_response}; + +pub async fn save_blob_to_file(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let col_name = params.get("col_name").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let file_path = params + .get("file_path") + .and_then(Value::as_str) + .unwrap_or(""); + let pk_map = params + .get("pk_map") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + if let Err(e) = validate_writable_file_path(file_path) { + return error_response(id, -32602, &e); + } + + match fetch_blob_bytes(&conn_params, table, col_name, &pk_map, schema).await { + Ok(bytes) => match std::fs::write(file_path, bytes) { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to write '{file_path}': {e}")), + }, + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn fetch_blob_as_data_url(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let col_name = params.get("col_name").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let pk_map = params + .get("pk_map") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + match fetch_blob_bytes(&conn_params, table, col_name, &pk_map, schema).await { + Ok(bytes) => ok_response(id, Value::from(encode_blob_full(&bytes))), + Err(e) => error_response(id, -32603, &e), + } +} + +async fn fetch_blob_bytes( + conn_params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &serde_json::Map, + schema: &str, +) -> Result, String> { + let qualified = format!( + "\"{}\".\"{}\"", + schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); + let column_types = client::get_column_types_map(conn_params, table, schema) + .await + .unwrap_or_default(); + + let (predicate, owned_params) = build_pk_map_predicate(pk_map, &column_types, 1)?; + let query = format!( + "SELECT \"{}\" FROM {} WHERE {}", + col_name.replace('"', "\"\""), + qualified, + predicate + ); + + let typed_params: Vec<(&(dyn ToSql + Sync), Type)> = owned_params + .iter() + .map(|(p, t)| (p.as_ref() as &(dyn ToSql + Sync), t.clone())) + .collect(); + + let rows = client::query_typed(conn_params, &query, &typed_params).await?; + let row = rows.first().ok_or_else(|| "Row not found".to_string())?; + row.try_get::<_, Vec>(0).map_err(|e| e.to_string()) +} + +/// Sanity-check `file_path` before spending a DB round-trip on a write that's +/// going to fail anyway. Deliberately permissive — this is not a security +/// boundary (the path comes from the Tabularis frontend's native save-file +/// dialog, not directly from an untrusted network caller) but a fast-fail for +/// the common mistakes `std::fs::write`'s raw OS error doesn't clearly name: +/// an empty path, a parent directory that doesn't exist, or a path that's +/// itself an existing directory. +fn validate_writable_file_path(file_path: &str) -> Result<(), String> { + if file_path.trim().is_empty() { + return Err("file_path must not be empty".to_string()); + } + let path = std::path::Path::new(file_path); + if path.is_dir() { + return Err(format!( + "file_path '{file_path}' is a directory, not a file" + )); + } + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() && !parent.is_dir() => Err(format!( + "file_path '{file_path}': parent directory '{}' does not exist", + parent.display() + )), + _ => Ok(()), + } +} + +/// Encode raw bytes into the canonical BLOB wire format: +/// `"BLOB:::"`. MIME type is sniffed from the +/// content's magic bytes; unrecognized content falls back to +/// `application/octet-stream`. Matches `encode_blob_full` in +/// `src-tauri/src/drivers/common/blob.rs`. +fn encode_blob_full(data: &[u8]) -> String { + let mime_type = infer::get(data) + .map(|k| k.mime_type()) + .unwrap_or("application/octet-stream"); + let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, data); + format!("BLOB:{}:{}:{}", data.len(), mime_type, b64) +} + +#[cfg(test)] +#[path = "blob_tests.rs"] +mod blob_tests; diff --git a/src/handlers/blob_tests.rs b/src/handlers/blob_tests.rs new file mode 100644 index 0000000..8fde688 --- /dev/null +++ b/src/handlers/blob_tests.rs @@ -0,0 +1,65 @@ +//! Unit tests for `blob.rs`'s pure encoding helper. Sibling test file per +//! repo convention (`.rules/rust.md` #4/#5) — loaded via +//! `#[cfg(test)] #[path = "blob_tests.rs"] mod blob_tests;`. + +use super::{encode_blob_full, validate_writable_file_path}; + +#[test] +fn encodes_size_mime_and_base64() { + // 4 bytes (0xCA 0xFE 0xBA 0xBE) — not a recognized magic-byte format, so + // infer falls back to application/octet-stream. + let bytes = [0xCA, 0xFE, 0xBA, 0xBE]; + let wire = encode_blob_full(&bytes); + assert_eq!(wire, "BLOB:4:application/octet-stream:yv66vg=="); +} + +#[test] +fn empty_input_encodes_zero_size() { + let wire = encode_blob_full(&[]); + assert_eq!(wire, "BLOB:0:application/octet-stream:"); +} + +#[test] +fn sniffs_recognized_magic_bytes() { + // PNG signature. + let bytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + let wire = encode_blob_full(&bytes); + assert!(wire.starts_with("BLOB:8:image/png:")); +} + +mod validate_writable_file_path_tests { + use super::validate_writable_file_path; + + #[test] + fn rejects_empty_path() { + assert!(validate_writable_file_path("").is_err()); + assert!(validate_writable_file_path(" ").is_err()); + } + + #[test] + fn rejects_existing_directory() { + let err = validate_writable_file_path("/tmp") + .expect_err("an existing directory must be rejected"); + assert!(err.contains("directory")); + } + + #[test] + fn rejects_nonexistent_parent_directory() { + let err = validate_writable_file_path("/this-dir-should-not-exist-xyz/out.bin") + .expect_err("a missing parent directory must be rejected"); + assert!(err.contains("parent directory")); + } + + #[test] + fn accepts_writable_path_in_existing_directory() { + // /tmp always exists in the test environment; the target file itself + // need not exist yet (that's the whole point of a "save to" path). + assert!(validate_writable_file_path("/tmp/some-file-that-need-not-exist.bin").is_ok()); + } + + #[test] + fn accepts_relative_path_with_no_directory_component() { + // A bare filename (no parent) is valid — writes to the plugin's CWD. + assert!(validate_writable_file_path("output.bin").is_ok()); + } +} diff --git a/src/handlers/connection.rs b/src/handlers/connection.rs new file mode 100644 index 0000000..27d590d --- /dev/null +++ b/src/handlers/connection.rs @@ -0,0 +1,35 @@ +//! Connection lifecycle handlers: initialize, ping, test_connection, shutdown. + +use serde_json::Value; + +use crate::client; +use crate::models::{inner_params, ConnectionParams}; +use crate::rpc::{error_response, ok_response}; + +/// Receive plugin settings from the host. Currently a no-op. +pub async fn initialize(id: Value, _params: &Value) -> Value { + ok_response(id, Value::Null) +} + +/// Lightweight health check — verify we can reach the database. +pub async fn ping(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + match client::test_connection(&conn_params).await { + Ok(()) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &e), + } +} + +/// Full connection test with error reporting. +pub async fn test_connection(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + match client::test_connection(&conn_params).await { + Ok(()) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &e), + } +} + +/// Graceful shutdown — drain pools and exit. +pub async fn shutdown(id: Value, _params: &Value) -> Value { + ok_response(id, Value::Null) +} diff --git a/src/handlers/crud.rs b/src/handlers/crud.rs new file mode 100644 index 0000000..2449872 --- /dev/null +++ b/src/handlers/crud.rs @@ -0,0 +1,219 @@ +//! CRUD operation handlers — insert_record, update_record, delete_record. +//! +//! Mirrors the built-in driver's SQL generation and binding exactly +//! (`src-tauri/src/drivers/postgres/mod.rs` insert/update/delete_record + +//! `binding.rs`) so both drivers produce identical affected_rows and +//! identical persisted data for the same inputs. + +use serde_json::Value; +use tokio_postgres::types::{ToSql, Type}; + +use crate::binding::{bind_pg_value, build_pk_map_predicate, BindOptions}; +use crate::client; +use crate::models::{inner_params, ConnectionParams}; +use crate::rpc::{error_response, ok_response}; + +pub async fn insert_record(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let data = params + .get("data") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + match exec_insert(&conn_params, table, data, schema).await { + Ok(affected) => ok_response(id, Value::from(affected)), + Err(e) => error_response(id, -32603, &e), + } +} + +async fn exec_insert( + conn_params: &ConnectionParams, + table: &str, + data: serde_json::Map, + schema: &str, +) -> Result { + let qualified = format!( + "\"{}\".\"{}\"", + schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); + + // Stable column order: iterate the map once into a Vec (matches the + // builtin's "lock in an arbitrary-but-consistent order" behavior). + let entries: Vec<(String, Value)> = data.into_iter().collect(); + + if entries.is_empty() { + let query = format!("INSERT INTO {} DEFAULT VALUES", qualified); + return client::execute_typed(conn_params, &query, &[]).await; + } + + let column_types = client::get_column_types_map(conn_params, table, schema) + .await + .unwrap_or_default(); + let enum_types = client::get_enum_column_types(conn_params, schema, table) + .await + .unwrap_or_default(); + + let mut cols: Vec = Vec::with_capacity(entries.len()); + let mut sql_fragments: Vec = Vec::with_capacity(entries.len()); + let mut owned_params: Vec = Vec::new(); + let mut placeholder_idx = 1usize; + + for (col_name, val) in entries { + cols.push(format!("\"{}\"", col_name.replace('"', "\"\""))); + let column_type = column_types.get(&col_name).map(String::as_str); + let options = BindOptions { + column_type, + enum_type: enum_types.get(&col_name).map(String::as_str), + allow_default: false, + }; + let bound = bind_pg_value(val, placeholder_idx, &options)?; + sql_fragments.push(bound.sql); + if let Some(param) = bound.param { + owned_params.push(param); + placeholder_idx += 1; + } + } + + let query = format!( + "INSERT INTO {} ({}) VALUES ({})", + qualified, + cols.join(", "), + sql_fragments.join(", ") + ); + + let typed_params: Vec<(&(dyn ToSql + Sync), Type)> = owned_params + .iter() + .map(|(p, t)| (p.as_ref() as &(dyn ToSql + Sync), t.clone())) + .collect(); + + client::execute_typed(conn_params, &query, &typed_params).await +} + +pub async fn update_record(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let col_name = params.get("col_name").and_then(Value::as_str).unwrap_or(""); + let new_val = params.get("new_val").cloned().unwrap_or(Value::Null); + let pk_map = params + .get("pk_map") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + match exec_update(&conn_params, table, &pk_map, col_name, new_val, schema).await { + Ok(affected) => ok_response(id, Value::from(affected)), + Err(e) => error_response(id, -32603, &e), + } +} + +async fn exec_update( + conn_params: &ConnectionParams, + table: &str, + pk_map: &serde_json::Map, + col_name: &str, + new_val: Value, + schema: &str, +) -> Result { + let qualified = format!( + "\"{}\".\"{}\"", + schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); + + let column_types = client::get_column_types_map(conn_params, table, schema) + .await + .unwrap_or_default(); + let enum_types = client::get_enum_column_types(conn_params, schema, table) + .await + .unwrap_or_default(); + + let options = BindOptions { + column_type: column_types.get(col_name).map(String::as_str), + enum_type: enum_types.get(col_name).map(String::as_str), + allow_default: true, + }; + let bound = bind_pg_value(new_val, 1, &options)?; + + let mut owned_params: Vec = Vec::new(); + let mut placeholder_idx = 1usize; + if let Some(param) = bound.param { + owned_params.push(param); + placeholder_idx = 2; + } + + let (predicate, pk_params) = build_pk_map_predicate(pk_map, &column_types, placeholder_idx)?; + owned_params.extend(pk_params); + + let query = format!( + "UPDATE {} SET \"{}\" = {} WHERE {}", + qualified, + col_name.replace('"', "\"\""), + bound.sql, + predicate + ); + + let typed_params: Vec<(&(dyn ToSql + Sync), Type)> = owned_params + .iter() + .map(|(p, t)| (p.as_ref() as &(dyn ToSql + Sync), t.clone())) + .collect(); + + client::execute_typed(conn_params, &query, &typed_params).await +} + +pub async fn delete_record(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let pk_map = params + .get("pk_map") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + match exec_delete(&conn_params, table, &pk_map, schema).await { + Ok(affected) => ok_response(id, Value::from(affected)), + Err(e) => error_response(id, -32603, &e), + } +} + +async fn exec_delete( + conn_params: &ConnectionParams, + table: &str, + pk_map: &serde_json::Map, + schema: &str, +) -> Result { + let qualified = format!( + "\"{}\".\"{}\"", + schema.replace('"', "\"\""), + table.replace('"', "\"\"") + ); + + let column_types = client::get_column_types_map(conn_params, table, schema) + .await + .unwrap_or_default(); + + let (predicate, owned_params) = build_pk_map_predicate(pk_map, &column_types, 1)?; + + let query = format!("DELETE FROM {} WHERE {}", qualified, predicate); + + let typed_params: Vec<(&(dyn ToSql + Sync), Type)> = owned_params + .iter() + .map(|(p, t)| (p.as_ref() as &(dyn ToSql + Sync), t.clone())) + .collect(); + + client::execute_typed(conn_params, &query, &typed_params).await +} diff --git a/src/handlers/ddl.rs b/src/handlers/ddl.rs new file mode 100644 index 0000000..27d4a3d --- /dev/null +++ b/src/handlers/ddl.rs @@ -0,0 +1,409 @@ +//! DDL generation handlers — get_create_table_sql, get_add_column_sql, +//! get_alter_column_sql, get_create_index_sql, get_create_foreign_key_sql +//! (pure SQL string generation, no DB round-trip) plus drop_index and +//! drop_foreign_key (which execute against the database). +//! +//! Mirrors the built-in driver's DDL generation exactly +//! (`src-tauri/src/drivers/postgres/mod.rs` get_create_table_sql and +//! friends, `helpers.rs::is_implicit_cast_compatible`) so both drivers +//! produce byte-identical SQL for the same inputs. + +use serde_json::Value; + +use crate::client; +use crate::models::{inner_params, ColumnDefinition, ConnectionParams}; +use crate::rpc::{error_response, ok_response}; +use crate::utils::identifiers::qualified; + +pub async fn get_create_table_sql(id: Value, params: &Value) -> Value { + let table_name = params + .get("table_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let columns: Vec = params + .get("columns") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + ok_response( + id, + Value::from(vec![build_create_table_sql(table_name, &columns, schema)]), + ) +} + +pub async fn get_add_column_sql(id: Value, params: &Value) -> Value { + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let column: Option = params + .get("column") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + match column { + Some(column) => ok_response( + id, + Value::from(vec![build_add_column_sql(table, &column, schema)]), + ), + None => error_response(id, -32602, "Invalid params: missing or malformed 'column'"), + } +} + +pub async fn get_alter_column_sql(id: Value, params: &Value) -> Value { + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let old_column: Option = params + .get("old_column") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + let new_column: Option = params + .get("new_column") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + match (old_column, new_column) { + (Some(old_column), Some(new_column)) => { + match build_alter_column_sql(table, &old_column, &new_column, schema) { + Ok(stmts) => ok_response(id, Value::from(stmts)), + Err(e) => error_response(id, -32603, &e), + } + } + _ => error_response( + id, + -32602, + "Invalid params: missing or malformed old_column/new_column", + ), + } +} + +pub async fn get_create_index_sql(id: Value, params: &Value) -> Value { + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let index_name = params + .get("index_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + let is_unique = params + .get("is_unique") + .and_then(Value::as_bool) + .unwrap_or(false); + let columns: Vec = params + .get("columns") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + ok_response( + id, + Value::from(vec![build_create_index_sql( + table, index_name, &columns, is_unique, schema, + )]), + ) +} + +pub async fn get_create_foreign_key_sql(id: Value, params: &Value) -> Value { + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let fk_name = params.get("fk_name").and_then(Value::as_str).unwrap_or(""); + let column = params.get("column").and_then(Value::as_str).unwrap_or(""); + let ref_table = params + .get("ref_table") + .and_then(Value::as_str) + .unwrap_or(""); + let ref_column = params + .get("ref_column") + .and_then(Value::as_str) + .unwrap_or(""); + let on_delete = params.get("on_delete").and_then(Value::as_str); + let on_update = params.get("on_update").and_then(Value::as_str); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + ok_response( + id, + Value::from(vec![build_create_foreign_key_sql( + table, fk_name, column, ref_table, ref_column, on_delete, on_update, schema, + )]), + ) +} + +pub async fn drop_index(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let index_name = params + .get("index_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = format!("DROP INDEX {}", qualified(schema, index_name)); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn drop_foreign_key(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let fk_name = params.get("fk_name").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = format!( + "ALTER TABLE {} DROP CONSTRAINT \"{}\"", + qualified(schema, table), + fk_name.replace('"', "\"\""), + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &e), + } +} + +/// Render a column's declared type, substituting the appropriate serial +/// variant when the column is auto-increment (SERIAL/BIGSERIAL/SMALLSERIAL +/// cannot be combined with an explicit NOT NULL/DEFAULT clause the way a +/// plain integer type can). +fn resolve_column_type(column: &ColumnDefinition) -> String { + if !column.is_auto_increment { + return column.data_type.clone(); + } + let upper = column.data_type.to_uppercase(); + if upper.contains("BIGINT") || upper.contains("BIGSERIAL") { + "BIGSERIAL".to_string() + } else if upper.contains("SMALLINT") || upper.contains("SMALLSERIAL") { + "SMALLSERIAL".to_string() + } else { + "SERIAL".to_string() + } +} + +fn quote_ident(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +fn build_create_table_sql(table_name: &str, columns: &[ColumnDefinition], schema: &str) -> String { + let mut col_defs = Vec::with_capacity(columns.len()); + let mut pk_cols = Vec::new(); + + for col in columns { + let type_str = resolve_column_type(col); + let mut def = format!("{} {}", quote_ident(&col.name), type_str); + if !col.is_nullable && !col.is_auto_increment { + def.push_str(" NOT NULL"); + } + if let Some(default) = &col.default_value { + if !col.is_auto_increment { + def.push_str(&format!(" DEFAULT {}", default)); + } + } + col_defs.push(def); + if col.is_pk { + pk_cols.push(quote_ident(&col.name)); + } + } + + if !pk_cols.is_empty() { + col_defs.push(format!("PRIMARY KEY ({})", pk_cols.join(", "))); + } + + format!( + "CREATE TABLE {} (\n {}\n)", + qualified(schema, table_name), + col_defs.join(",\n ") + ) +} + +fn build_add_column_sql(table: &str, column: &ColumnDefinition, schema: &str) -> String { + let type_str = resolve_column_type(column); + let mut def = format!( + "ALTER TABLE {} ADD COLUMN {} {}", + qualified(schema, table), + quote_ident(&column.name), + type_str + ); + if !column.is_nullable && !column.is_auto_increment { + def.push_str(" NOT NULL"); + } + if let Some(default) = &column.default_value { + if !column.is_auto_increment { + def.push_str(&format!(" DEFAULT {}", default)); + } + } + def +} + +/// Normalize a data type string for cast-compatibility comparison: +/// strip a trailing `(...)` and uppercase. E.g. `"varchar(255)"` -> `"VARCHAR"`. +fn extract_base_type(data_type: &str) -> String { + data_type + .split('(') + .next() + .unwrap_or(data_type) + .trim() + .to_uppercase() +} + +/// Whether an ALTER COLUMN TYPE from `old_type` to `new_type` can rely on +/// PostgreSQL's implicit cast rather than needing an explicit `USING` clause. +fn is_implicit_cast_compatible(old_type: &str, new_type: &str) -> bool { + if old_type == new_type { + return true; + } + + const COMPATIBLE_GROUPS: &[&[&str]] = &[ + &[ + "SMALLINT", + "INTEGER", + "BIGINT", + "SERIAL", + "BIGSERIAL", + "SMALLSERIAL", + ], + &["REAL", "DOUBLE PRECISION", "NUMERIC", "DECIMAL", "MONEY"], + &["CHAR", "VARCHAR", "TEXT", "NAME", "CITEXT"], + &["TIMESTAMP", "TIMESTAMPTZ"], + &["TIME", "TIMETZ"], + &["JSON", "JSONB"], + &["BIT", "VARBIT"], + ]; + + COMPATIBLE_GROUPS + .iter() + .any(|group| group.contains(&old_type) && group.contains(&new_type)) +} + +fn build_alter_column_sql( + table: &str, + old_column: &ColumnDefinition, + new_column: &ColumnDefinition, + schema: &str, +) -> Result, String> { + let tbl = qualified(schema, table); + let old_name = quote_ident(&old_column.name); + let new_name = quote_ident(&new_column.name); + let mut stmts = Vec::new(); + + if old_column.name != new_column.name { + stmts.push(format!( + "ALTER TABLE {} RENAME COLUMN {} TO {}", + tbl, old_name, new_name + )); + } + + let col_ref = &new_name; + + if old_column.data_type != new_column.data_type { + let old_base = extract_base_type(&old_column.data_type); + let new_base = extract_base_type(&new_column.data_type); + + if is_implicit_cast_compatible(&old_base, &new_base) { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} TYPE {}", + tbl, col_ref, new_column.data_type + )); + } else { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} TYPE {} USING {}::{}", + tbl, col_ref, new_column.data_type, col_ref, new_column.data_type + )); + } + } + + if old_column.is_nullable != new_column.is_nullable { + if new_column.is_nullable { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL", + tbl, col_ref + )); + } else { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", + tbl, col_ref + )); + } + } + + if old_column.default_value != new_column.default_value { + if let Some(default) = &new_column.default_value { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}", + tbl, col_ref, default + )); + } else { + stmts.push(format!( + "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", + tbl, col_ref + )); + } + } + + if stmts.is_empty() { + return Err("No changes detected".to_string()); + } + Ok(stmts) +} + +fn build_create_index_sql( + table: &str, + index_name: &str, + columns: &[String], + is_unique: bool, + schema: &str, +) -> String { + let unique = if is_unique { "UNIQUE " } else { "" }; + let cols: Vec = columns.iter().map(|c| quote_ident(c)).collect(); + format!( + "CREATE {}INDEX {} ON {} ({})", + unique, + quote_ident(index_name), + qualified(schema, table), + cols.join(", ") + ) +} + +#[allow(clippy::too_many_arguments)] +fn build_create_foreign_key_sql( + table: &str, + fk_name: &str, + column: &str, + ref_table: &str, + ref_column: &str, + on_delete: Option<&str>, + on_update: Option<&str>, + schema: &str, +) -> String { + let mut query = format!( + "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", + qualified(schema, table), + quote_ident(fk_name), + quote_ident(column), + qualified(schema, ref_table), + quote_ident(ref_column), + ); + if let Some(action) = on_delete { + query.push_str(&format!(" ON DELETE {}", action)); + } + if let Some(action) = on_update { + query.push_str(&format!(" ON UPDATE {}", action)); + } + query +} + +#[cfg(test)] +#[path = "ddl_tests.rs"] +mod ddl_tests; diff --git a/src/handlers/ddl_tests.rs b/src/handlers/ddl_tests.rs new file mode 100644 index 0000000..5549b78 --- /dev/null +++ b/src/handlers/ddl_tests.rs @@ -0,0 +1,292 @@ +//! Unit tests for `ddl.rs`'s pure SQL-builder functions. Sibling test file +//! per repo convention (`.rules/rust.md` #4/#5) — loaded via +//! `#[cfg(test)] #[path = "ddl_tests.rs"] mod ddl_tests;`. + +use super::{ + build_add_column_sql, build_alter_column_sql, build_create_foreign_key_sql, + build_create_index_sql, build_create_table_sql, is_implicit_cast_compatible, +}; +use crate::models::ColumnDefinition; + +fn column(name: &str, data_type: &str) -> ColumnDefinition { + ColumnDefinition { + name: name.to_string(), + data_type: data_type.to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: None, + } +} + +mod create_table { + use super::*; + + #[test] + fn generates_quoted_qualified_table_with_columns() { + let columns = vec![ + ColumnDefinition { + name: "id".to_string(), + data_type: "SERIAL".to_string(), + is_nullable: false, + is_pk: true, + is_auto_increment: true, + default_value: None, + }, + column("name", "TEXT"), + ]; + let sql = build_create_table_sql("users", &columns, "public"); + assert!(sql.contains("CREATE TABLE \"public\".\"users\"")); + assert!(sql.contains("\"id\" SERIAL")); + assert!(sql.contains("PRIMARY KEY (\"id\")")); + } + + #[test] + fn auto_increment_column_skips_not_null_and_default() { + let columns = vec![ColumnDefinition { + name: "id".to_string(), + data_type: "INTEGER".to_string(), + is_nullable: false, + is_pk: true, + is_auto_increment: true, + default_value: Some("1".to_string()), + }]; + let sql = build_create_table_sql("t", &columns, "public"); + // is_auto_increment suppresses both NOT NULL and DEFAULT even though + // is_nullable is false and a default_value is set — matches builtin. + assert!(!sql.contains("NOT NULL")); + assert!(!sql.contains("DEFAULT")); + } + + #[test] + fn bigint_auto_increment_becomes_bigserial() { + let columns = vec![ColumnDefinition { + name: "id".to_string(), + data_type: "BIGINT".to_string(), + is_nullable: false, + is_pk: true, + is_auto_increment: true, + default_value: None, + }]; + let sql = build_create_table_sql("t", &columns, "public"); + assert!(sql.contains("\"id\" BIGSERIAL")); + } + + #[test] + fn non_nullable_non_auto_increment_column_gets_not_null() { + let columns = vec![ColumnDefinition { + name: "name".to_string(), + data_type: "TEXT".to_string(), + is_nullable: false, + is_pk: false, + is_auto_increment: false, + default_value: None, + }]; + let sql = build_create_table_sql("t", &columns, "public"); + assert!(sql.contains("\"name\" TEXT NOT NULL")); + } + + #[test] + fn default_value_is_spliced_in_verbatim() { + let columns = vec![ColumnDefinition { + name: "email".to_string(), + data_type: "VARCHAR(255)".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: Some("'unknown@example.com'".to_string()), + }]; + let sql = build_create_table_sql("t", &columns, "public"); + assert!(sql.contains("DEFAULT 'unknown@example.com'")); + } + + #[test] + fn no_primary_key_omits_pk_clause() { + let columns = vec![column("name", "TEXT")]; + let sql = build_create_table_sql("t", &columns, "public"); + assert!(!sql.contains("PRIMARY KEY")); + } +} + +mod add_column { + use super::*; + + #[test] + fn generates_alter_table_add_column() { + let col = ColumnDefinition { + name: "new_col".to_string(), + data_type: "INTEGER".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: Some("0".to_string()), + }; + let sql = build_add_column_sql("all_types", &col, "test_schema"); + assert!(sql + .contains("ALTER TABLE \"test_schema\".\"all_types\" ADD COLUMN \"new_col\" INTEGER")); + assert!(sql.contains("DEFAULT 0")); + } +} + +mod alter_column { + use super::*; + + #[test] + fn rename_only_when_names_differ() { + let old = column("old_name", "TEXT"); + let new = column("new_name", "TEXT"); + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert_eq!(stmts.len(), 1); + assert!(stmts[0].contains("RENAME COLUMN \"old_name\" TO \"new_name\"")); + } + + #[test] + fn compatible_type_change_omits_using_clause() { + let old = column("col_text", "TEXT"); + let new = column("col_text", "VARCHAR(500)"); + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts + .iter() + .any(|s| s.contains("TYPE VARCHAR(500)") && !s.contains("USING"))); + } + + #[test] + fn incompatible_type_change_adds_using_clause() { + let old = column("col", "TEXT"); + let new = column("col", "INTEGER"); + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("USING \"col\"::INTEGER"))); + } + + #[test] + fn nullable_to_not_nullable_sets_not_null() { + let mut old = column("col", "TEXT"); + old.is_nullable = true; + let mut new = column("col", "TEXT"); + new.is_nullable = false; + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("SET NOT NULL"))); + } + + #[test] + fn not_nullable_to_nullable_drops_not_null() { + let mut old = column("col", "TEXT"); + old.is_nullable = false; + let mut new = column("col", "TEXT"); + new.is_nullable = true; + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("DROP NOT NULL"))); + } + + #[test] + fn default_value_added_sets_default() { + let old = column("col", "TEXT"); + let mut new = column("col", "TEXT"); + new.default_value = Some("'x'".to_string()); + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("SET DEFAULT 'x'"))); + } + + #[test] + fn default_value_removed_drops_default() { + let mut old = column("col", "TEXT"); + old.default_value = Some("'x'".to_string()); + let new = column("col", "TEXT"); + let stmts = build_alter_column_sql("t", &old, &new, "public").unwrap(); + assert!(stmts.iter().any(|s| s.contains("DROP DEFAULT"))); + } + + #[test] + fn no_changes_is_an_error() { + let old = column("col", "TEXT"); + let new = column("col", "TEXT"); + let err = build_alter_column_sql("t", &old, &new, "public").unwrap_err(); + assert_eq!(err, "No changes detected"); + } +} + +mod cast_compatibility { + use super::*; + + #[test] + fn identical_types_are_compatible() { + assert!(is_implicit_cast_compatible("TEXT", "TEXT")); + } + + #[test] + fn integer_family_is_compatible() { + assert!(is_implicit_cast_compatible("INTEGER", "BIGINT")); + } + + #[test] + fn string_family_is_compatible() { + assert!(is_implicit_cast_compatible("VARCHAR", "TEXT")); + } + + #[test] + fn cross_family_is_incompatible() { + assert!(!is_implicit_cast_compatible("TEXT", "INTEGER")); + } +} + +mod create_index { + use super::*; + + #[test] + fn multi_column_index() { + let sql = build_create_index_sql( + "all_types", + "idx_test", + &["col_text".to_string(), "col_int".to_string()], + false, + "test_schema", + ); + assert_eq!( + sql, + "CREATE INDEX \"idx_test\" ON \"test_schema\".\"all_types\" (\"col_text\", \"col_int\")" + ); + } + + #[test] + fn unique_index_adds_unique_keyword() { + let sql = build_create_index_sql("t", "idx", &["c".to_string()], true, "public"); + assert!(sql.starts_with("CREATE UNIQUE INDEX")); + } +} + +mod create_foreign_key { + use super::*; + + #[test] + fn basic_foreign_key_without_actions() { + let sql = build_create_foreign_key_sql( + "crud_scratch", + "fk_test", + "value", + "all_types", + "id", + None, + None, + "test_schema", + ); + assert_eq!( + sql, + "ALTER TABLE \"test_schema\".\"crud_scratch\" ADD CONSTRAINT \"fk_test\" FOREIGN KEY (\"value\") REFERENCES \"test_schema\".\"all_types\" (\"id\")" + ); + } + + #[test] + fn on_delete_and_on_update_actions_are_appended() { + let sql = build_create_foreign_key_sql( + "t", + "fk", + "c", + "ref_t", + "ref_c", + Some("CASCADE"), + Some("RESTRICT"), + "public", + ); + assert!(sql.ends_with("ON DELETE CASCADE ON UPDATE RESTRICT")); + } +} diff --git a/src/handlers/metadata.rs b/src/handlers/metadata.rs new file mode 100644 index 0000000..bde49de --- /dev/null +++ b/src/handlers/metadata.rs @@ -0,0 +1,893 @@ +//! Schema discovery and metadata handlers. + +use serde_json::{json, Value}; + +use crate::client; +use crate::models::{inner_params, ConnectionParams}; +use crate::rpc::{error_response, not_implemented, ok_response}; + +pub async fn get_databases(id: Value, params: &Value) -> Value { + let mut conn_params = ConnectionParams::from_value(inner_params(params)); + // Must connect to 'postgres' maintenance DB to list all databases. + conn_params.database = Some("postgres".to_string()); + + match client::query_strings( + &conn_params, + "SELECT datname::text FROM pg_database WHERE datistemplate = false ORDER BY datname", + &[], + "datname", + ) + .await + { + Ok(databases) => ok_response(id, json!(databases)), + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_schemas(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + + match client::query_strings( + &conn_params, + "SELECT schema_name::text FROM information_schema.schemata \ + WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \ + AND schema_name NOT LIKE 'pg_temp_%' \ + AND schema_name NOT LIKE 'pg_toast_temp_%' \ + ORDER BY schema_name", + &[], + "schema_name", + ) + .await + { + Ok(schemas) => ok_response(id, json!(schemas)), + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_tables(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + match client::query_strings( + &conn_params, + "SELECT table_name::text as name FROM information_schema.tables \ + WHERE table_schema = $1 AND table_type = 'BASE TABLE' \ + ORDER BY table_name ASC", + &[&schema], + "name", + ) + .await + { + Ok(names) => { + let tables: Vec = names.into_iter().map(|n| json!({"name": n})).collect(); + ok_response(id, json!(tables)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_columns(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = r#" + SELECT + c.column_name::text, + CASE + WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name::text + ELSE c.data_type::text + END AS data_type, + c.is_nullable::text, + c.column_default::text, + c.is_identity::text, + c.character_maximum_length, + (SELECT string_agg('''' || replace(e.enumlabel, '''', '''''') || '''', ',' ORDER BY e.enumsortorder) + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + JOIN pg_namespace tn ON tn.oid = t.typnamespace + WHERE t.typname = c.udt_name AND tn.nspname = c.udt_schema) AS enum_values, + EXISTS ( + SELECT 1 + FROM pg_constraint pk_con + JOIN pg_class pk_table ON pk_table.oid = pk_con.conrelid + JOIN pg_namespace pk_schema ON pk_schema.oid = pk_table.relnamespace + JOIN unnest(pk_con.conkey) AS pk_col(attnum) ON true + JOIN pg_attribute pk_att + ON pk_att.attrelid = pk_table.oid + AND pk_att.attnum = pk_col.attnum + AND NOT pk_att.attisdropped + WHERE pk_con.contype = 'p' + AND pk_schema.nspname = c.table_schema + AND pk_table.relname = c.table_name + AND pk_att.attname = c.column_name + ) AS is_pk + FROM information_schema.columns c + WHERE c.table_schema = $1 AND c.table_name = $2 + ORDER BY c.ordinal_position + "#; + + match client::query_rows(&conn_params, query, &[&schema, &table]).await { + Ok(rows) => { + let columns: Vec = rows.iter().map(row_to_table_column).collect(); + ok_response(id, json!(columns)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +/// Map one `information_schema.columns`-shaped row (as queried by +/// `get_columns`/`get_view_columns`) to the host's `TableColumn` JSON shape. +fn row_to_table_column(r: &tokio_postgres::Row) -> Value { + let name: String = r.try_get("column_name").unwrap_or_default(); + let raw_data_type: String = r.try_get("data_type").unwrap_or_default(); + let enum_values: Option = r.try_get("enum_values").ok().flatten(); + let is_nullable_str: String = r.try_get("is_nullable").unwrap_or_default(); + let column_default: Option = r.try_get("column_default").ok().flatten(); + let is_identity: String = r.try_get("is_identity").unwrap_or_default(); + let char_max_len: Option = r + .try_get::<_, Option>("character_maximum_length") + .ok() + .flatten(); + let is_pk: bool = r.try_get("is_pk").unwrap_or(false); + + let data_type = match enum_values { + Some(ref vals) if !vals.is_empty() => format!("enum({})", vals), + _ => raw_data_type, + }; + + let is_auto_increment = is_identity == "YES" + || column_default + .as_deref() + .is_some_and(|d| d.contains("nextval")); + + let is_nullable = is_nullable_str == "YES"; + + let default_value = column_default.as_deref().and_then(|d| { + if is_auto_increment || d.is_empty() || d == "NULL" || d.starts_with("NULL::") { + None + } else { + Some(d.to_string()) + } + }); + + let mut col = json!({ + "name": name, + "data_type": data_type, + "is_pk": is_pk, + "is_nullable": is_nullable, + "is_auto_increment": is_auto_increment, + }); + + if let Some(dv) = default_value { + col.as_object_mut() + .unwrap() + .insert("default_value".to_string(), json!(dv)); + } + if let Some(len) = char_max_len.and_then(|v| u64::try_from(v).ok()) { + col.as_object_mut() + .unwrap() + .insert("character_maximum_length".to_string(), json!(len)); + } + + col +} + +pub async fn get_foreign_keys(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = r#" + SELECT + con.conname::text AS constraint_name, + src_att.attname::text AS column_name, + ref_nsp.nspname::text AS foreign_schema_name, + ref_cl.relname::text AS foreign_table_name, + ref_att.attname::text AS foreign_column_name, + CASE con.confupdtype + WHEN 'a' THEN 'NO ACTION' + WHEN 'r' THEN 'RESTRICT' + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + END::text AS update_rule, + CASE con.confdeltype + WHEN 'a' THEN 'NO ACTION' + WHEN 'r' THEN 'RESTRICT' + WHEN 'c' THEN 'CASCADE' + WHEN 'n' THEN 'SET NULL' + WHEN 'd' THEN 'SET DEFAULT' + END::text AS delete_rule + FROM pg_constraint con + JOIN pg_class src_cl ON src_cl.oid = con.conrelid + JOIN pg_namespace src_nsp ON src_nsp.oid = src_cl.relnamespace + JOIN pg_class ref_cl ON ref_cl.oid = con.confrelid + JOIN pg_namespace ref_nsp ON ref_nsp.oid = ref_cl.relnamespace + JOIN unnest(con.conkey, con.confkey) AS cols(src_attnum, ref_attnum) ON true + JOIN pg_attribute src_att + ON src_att.attrelid = src_cl.oid + AND src_att.attnum = cols.src_attnum + AND NOT src_att.attisdropped + JOIN pg_attribute ref_att + ON ref_att.attrelid = ref_cl.oid + AND ref_att.attnum = cols.ref_attnum + AND NOT ref_att.attisdropped + WHERE con.contype = 'f' + AND con.conparentid = 0 + AND src_nsp.nspname = $1 + AND src_cl.relname = $2 + ORDER BY con.conname, cols.src_attnum + "#; + + match client::query_rows(&conn_params, query, &[&schema, &table]).await { + Ok(rows) => { + let fks: Vec = rows + .iter() + .map(|r| { + let name: String = r.try_get("constraint_name").unwrap_or_default(); + let column_name: String = r.try_get("column_name").unwrap_or_default(); + let ref_table: String = r.try_get("foreign_table_name").unwrap_or_default(); + let ref_column: String = r.try_get("foreign_column_name").unwrap_or_default(); + let on_update: Option = r.try_get("update_rule").ok().flatten(); + let on_delete: Option = r.try_get("delete_rule").ok().flatten(); + + json!({ + "name": name, + "column_name": column_name, + "ref_table": ref_table, + "ref_column": ref_column, + "on_delete": on_delete, + "on_update": on_update, + }) + }) + .collect(); + ok_response(id, json!(fks)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_indexes(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let table = params.get("table").and_then(Value::as_str).unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = r#" + SELECT + i.relname AS index_name, + COALESCE( + a.attname::text, + pg_get_indexdef(ix.indexrelid, k.n::int, true) + ) AS column_name, + ix.indisunique AS is_unique, + ix.indisprimary AS is_primary, + k.n::int AS seq_in_index, + (k.attnum = 0) AS is_expression + FROM + pg_class t + JOIN pg_namespace n ON t.relnamespace = n.oid + JOIN pg_index ix ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + CROSS JOIN LATERAL unnest(string_to_array(ix.indkey::text, ' ')::int2[]) + WITH ORDINALITY AS k(attnum, n) + LEFT JOIN pg_attribute a + ON a.attrelid = t.oid + AND a.attnum = k.attnum + AND k.attnum <> 0 + WHERE + t.relkind IN ('r', 'm') + AND n.nspname = $1 + AND t.relname = $2 + ORDER BY + i.relname, + k.n + "#; + + match client::query_rows(&conn_params, query, &[&schema, &table]).await { + Ok(rows) => { + let indexes: Vec = rows + .iter() + .map(|r| { + let name: String = r.try_get("index_name").unwrap_or_default(); + let column_name: String = r.try_get("column_name").unwrap_or_default(); + let is_unique: bool = r.try_get("is_unique").unwrap_or(false); + let is_primary: bool = r.try_get("is_primary").unwrap_or(false); + let seq_in_index: i32 = r.try_get("seq_in_index").unwrap_or(1); + let is_expression: bool = r.try_get("is_expression").unwrap_or(false); + + json!({ + "name": name, + "column_name": column_name, + "is_unique": is_unique, + "is_primary": is_primary, + "seq_in_index": seq_in_index, + "is_expression": is_expression, + }) + }) + .collect(); + ok_response(id, json!(indexes)) + } + Err(e) => error_response(id, -32603, &e), + } +} +pub async fn get_views(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + match client::query_strings( + &conn_params, + "SELECT viewname as name FROM pg_views WHERE schemaname = $1 ORDER BY viewname ASC", + &[&schema], + "name", + ) + .await + { + Ok(names) => { + let views: Vec = names + .into_iter() + .map(|n| json!({"name": n, "definition": null})) + .collect(); + ok_response(id, json!(views)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_view_definition(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let qualified = crate::utils::identifiers::qualified(schema, view_name); + + match client::query_rows( + &conn_params, + "SELECT pg_get_viewdef(($1::text)::regclass, true) as definition", + &[&qualified], + ) + .await + { + Ok(rows) => { + if let Some(row) = rows.first() { + let definition: String = row.try_get("definition").unwrap_or_default(); + let full = format!("CREATE OR REPLACE VIEW {} AS\n{}", qualified, definition); + ok_response(id, json!(full)) + } else { + error_response(id, -32603, "View not found") + } + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_view_columns(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = r#" + SELECT + c.column_name::text, + CASE + WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name::text + ELSE c.data_type::text + END AS data_type, + c.is_nullable::text, + c.column_default::text, + c.is_identity::text, + c.character_maximum_length, + (SELECT string_agg('''' || replace(e.enumlabel, '''', '''''') || '''', ',' ORDER BY e.enumsortorder) + FROM pg_enum e + JOIN pg_type t ON t.oid = e.enumtypid + JOIN pg_namespace tn ON tn.oid = t.typnamespace + WHERE t.typname = c.udt_name AND tn.nspname = c.udt_schema) AS enum_values, + EXISTS ( + SELECT 1 + FROM pg_constraint pk_con + JOIN pg_class pk_table ON pk_table.oid = pk_con.conrelid + JOIN pg_namespace pk_schema ON pk_schema.oid = pk_table.relnamespace + JOIN unnest(pk_con.conkey) AS pk_col(attnum) ON true + JOIN pg_attribute pk_att + ON pk_att.attrelid = pk_table.oid + AND pk_att.attnum = pk_col.attnum + AND NOT pk_att.attisdropped + WHERE pk_con.contype = 'p' + AND pk_schema.nspname = c.table_schema + AND pk_table.relname = c.table_name + AND pk_att.attname = c.column_name + ) AS is_pk + FROM information_schema.columns c + WHERE c.table_schema = $1 AND c.table_name = $2 + ORDER BY c.ordinal_position + "#; + + match client::query_rows(&conn_params, query, &[&schema, &view_name]).await { + Ok(rows) => { + let columns: Vec = rows.iter().map(row_to_table_column).collect(); + ok_response(id, json!(columns)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn create_view(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let definition = params + .get("definition") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = format!( + "CREATE VIEW {} AS {}", + crate::utils::identifiers::qualified(schema, view_name), + definition + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to create view: {}", e)), + } +} + +pub async fn alter_view(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let definition = params + .get("definition") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = format!( + "CREATE OR REPLACE VIEW {} AS {}", + crate::utils::identifiers::qualified(schema, view_name), + definition + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to alter view: {}", e)), + } +} + +pub async fn drop_view(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = format!( + "DROP VIEW IF EXISTS {}", + crate::utils::identifiers::qualified(schema, view_name) + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to drop view: {}", e)), + } +} + +pub async fn get_materialized_views(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + match client::query_strings( + &conn_params, + "SELECT matviewname as name FROM pg_matviews WHERE schemaname = $1 ORDER BY matviewname ASC", + &[&schema], + "name", + ) + .await + { + Ok(names) => { + let views: Vec = names + .into_iter() + .map(|n| json!({"name": n, "definition": null})) + .collect(); + ok_response(id, json!(views)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_materialized_view_columns(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + // Materialized views are not exposed via information_schema.columns, so + // their columns must be read from the system catalog. + let query = r#" + SELECT + a.attname AS column_name, + format_type(a.atttypid, a.atttypmod) AS data_type, + a.attnotnull AS not_null + FROM pg_attribute a + JOIN pg_class c ON c.oid = a.attrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind = 'm' + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum + "#; + + match client::query_rows(&conn_params, query, &[&schema, &view_name]).await { + Ok(rows) => { + let columns: Vec = rows + .iter() + .map(|r| { + let name: String = r.try_get("column_name").unwrap_or_default(); + let data_type: String = r.try_get("data_type").unwrap_or_default(); + let not_null: bool = r.try_get("not_null").unwrap_or(false); + json!({ + "name": name, + "data_type": data_type, + "is_pk": false, + "is_nullable": !not_null, + "is_auto_increment": false, + }) + }) + .collect(); + ok_response(id, json!(columns)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_materialized_view_definition(id: Value, _params: &Value) -> Value { + not_implemented(id, "get_materialized_view_definition") +} + +pub async fn refresh_materialized_view(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let view_name = params + .get("view_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = format!( + "REFRESH MATERIALIZED VIEW {}", + crate::utils::identifiers::qualified(schema, view_name) + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response( + id, + -32603, + &format!("Failed to refresh materialized view: {}", e), + ), + } +} + +pub async fn get_routines(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + // PG 11+ uses prokind; older versions use proisagg/proiswindow flags. + // CI runs PG 16, so we use the modern query. + let query = r#" + SELECT proname, prokind + FROM pg_proc + WHERE pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1) + AND prokind IN ('f', 'p') + ORDER BY proname + "#; + + match client::query_rows(&conn_params, query, &[&schema]).await { + Ok(rows) => { + let routines: Vec = rows + .iter() + .map(|r| { + let name: String = r.try_get("proname").unwrap_or_default(); + let prokind: i8 = r.try_get("prokind").unwrap_or(b'f' as i8); + let routine_type = if prokind as u8 as char == 'p' { + "PROCEDURE" + } else { + "FUNCTION" + }; + json!({ + "name": name, + "routine_type": routine_type, + "definition": null, + }) + }) + .collect(); + ok_response(id, json!(routines)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_routine_parameters(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let routine_name = params + .get("routine_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let return_type_query = r#" + SELECT data_type, routine_type + FROM information_schema.routines + WHERE routine_schema = $1 AND routine_name = $2 + LIMIT 1 + "#; + let routine_info = match client::query_rows( + &conn_params, + return_type_query, + &[&schema, &routine_name], + ) + .await + { + Ok(rows) => rows, + Err(e) => return error_response(id, -32603, &e), + }; + + let mut parameters: Vec = Vec::new(); + + if let Some(info) = routine_info.first() { + let routine_type: String = info.try_get("routine_type").unwrap_or_default(); + if routine_type == "FUNCTION" { + let data_type: String = info.try_get("data_type").unwrap_or_default(); + if !data_type.eq_ignore_ascii_case("void") && !data_type.eq_ignore_ascii_case("trigger") + { + parameters.push(json!({ + "name": "", + "data_type": data_type, + "mode": "OUT", + "ordinal_position": 0, + })); + } + } + } + + let query = r#" + SELECT p.parameter_name, p.data_type, p.parameter_mode, p.ordinal_position + FROM information_schema.parameters p + JOIN information_schema.routines r ON p.specific_name = r.specific_name + WHERE r.routine_schema = $1 AND r.routine_name = $2 + ORDER BY p.ordinal_position + "#; + match client::query_rows(&conn_params, query, &[&schema, &routine_name]).await { + Ok(rows) => { + parameters.extend(rows.iter().map(|r| { + let name: Option = r.try_get("parameter_name").ok().flatten(); + let data_type: String = r.try_get("data_type").unwrap_or_default(); + let mode: String = r.try_get("parameter_mode").unwrap_or_default(); + let ordinal_position: i32 = r.try_get("ordinal_position").unwrap_or(0); + json!({ + "name": name.unwrap_or_default(), + "data_type": data_type, + "mode": mode, + "ordinal_position": ordinal_position, + }) + })); + ok_response(id, json!(parameters)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_routine_definition(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let routine_name = params + .get("routine_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = r#" + SELECT pg_get_functiondef(p.oid) as definition + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname = $1 AND p.proname = $2 + LIMIT 1 + "#; + + match client::query_rows(&conn_params, query, &[&schema, &routine_name]).await { + Ok(rows) => match rows.first() { + Some(row) => { + let definition: String = row.try_get("definition").unwrap_or_default(); + ok_response(id, json!(definition)) + } + None => error_response(id, -32603, &format!("Routine '{}' not found", routine_name)), + }, + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_triggers(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = r#" + SELECT + t.trigger_name AS name, + t.event_object_table AS table_name, + string_agg(t.event_manipulation, ' OR ' ORDER BY t.event_manipulation) AS event, + t.action_timing AS timing + FROM information_schema.triggers t + WHERE t.trigger_schema = $1 + GROUP BY t.trigger_name, t.event_object_table, t.action_timing + ORDER BY t.trigger_name + "#; + + match client::query_rows(&conn_params, query, &[&schema]).await { + Ok(rows) => { + let triggers: Vec = rows + .iter() + .map(|r| { + let name: String = r.try_get("name").unwrap_or_default(); + let table_name: String = r.try_get("table_name").unwrap_or_default(); + let event: String = r.try_get("event").unwrap_or_default(); + let timing: String = r.try_get("timing").unwrap_or_default(); + json!({ + "name": name, + "table_name": table_name, + "event": event, + "timing": timing, + "definition": null, + }) + }) + .collect(); + ok_response(id, json!(triggers)) + } + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn get_trigger_definition(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let trigger_name = params + .get("trigger_name") + .and_then(Value::as_str) + .unwrap_or(""); + let table_name = params + .get("table_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = r#" + SELECT pg_get_triggerdef(t.oid, true) AS definition + FROM pg_trigger t + JOIN pg_class c ON t.tgrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE t.tgname = $1 + AND c.relname = $2 + AND n.nspname = $3 + AND NOT t.tgisinternal + LIMIT 1 + "#; + + match client::query_rows(&conn_params, query, &[&trigger_name, &table_name, &schema]).await { + Ok(rows) => match rows.first() { + Some(row) => { + let definition: String = row.try_get("definition").unwrap_or_default(); + ok_response(id, json!(definition)) + } + None => error_response(id, -32603, &format!("Trigger '{}' not found", trigger_name)), + }, + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn create_trigger(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let trigger_sql = params + .get("trigger_sql") + .and_then(Value::as_str) + .unwrap_or(""); + + match client::execute_typed(&conn_params, trigger_sql, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to create trigger: {}", e)), + } +} + +pub async fn drop_trigger(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let trigger_name = params + .get("trigger_name") + .and_then(Value::as_str) + .unwrap_or(""); + let table_name = params + .get("table_name") + .and_then(Value::as_str) + .unwrap_or(""); + let schema = params + .get("schema") + .and_then(Value::as_str) + .unwrap_or("public"); + + let query = format!( + "DROP TRIGGER IF EXISTS {} ON {}", + crate::utils::identifiers::quote_identifier(trigger_name), + crate::utils::identifiers::qualified(schema, table_name), + ); + match client::execute_typed(&conn_params, &query, &[]).await { + Ok(_) => ok_response(id, Value::Null), + Err(e) => error_response(id, -32603, &format!("Failed to drop trigger: {}", e)), + } +} + +pub async fn get_schema_snapshot(id: Value, _params: &Value) -> Value { + not_implemented(id, "get_schema_snapshot") +} +pub async fn get_all_columns_batch(id: Value, _params: &Value) -> Value { + not_implemented(id, "get_all_columns_batch") +} +pub async fn get_all_foreign_keys_batch(id: Value, _params: &Value) -> Value { + not_implemented(id, "get_all_foreign_keys_batch") +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..74e7401 --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,8 @@ +//! Handler modules — each covers a logical domain of the RPC API. + +pub mod blob; +pub mod connection; +pub mod crud; +pub mod ddl; +pub mod metadata; +pub mod query; diff --git a/src/handlers/query.rs b/src/handlers/query.rs new file mode 100644 index 0000000..5eeacc9 --- /dev/null +++ b/src/handlers/query.rs @@ -0,0 +1,272 @@ +//! Query execution handlers. + +use deadpool_postgres::Object as PgClient; +use serde_json::{json, Value}; +use std::time::Instant; + +use crate::client; +use crate::extract::extract_value; +use crate::models::{inner_params, ConnectionParams}; +use crate::rpc::{error_response, ok_response}; + +pub async fn execute_query(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let query = params.get("query").and_then(Value::as_str).unwrap_or(""); + let limit = params + .get("limit") + .and_then(Value::as_u64) + .map(|v| v as u32); + let page = params.get("page").and_then(Value::as_u64).unwrap_or(1) as u32; + let schema = params.get("schema").and_then(Value::as_str); + + match exec_query(&conn_params, query, limit, page, schema).await { + Ok(result) => ok_response(id, result), + Err(e) => error_response(id, -32603, &e), + } +} + +pub async fn execute_query_batch(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let queries: Vec = params + .get("queries") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let limit = params + .get("limit") + .and_then(Value::as_u64) + .map(|v| v as u32); + let page = params.get("page").and_then(Value::as_u64).unwrap_or(1) as u32; + let schema = params.get("schema").and_then(Value::as_str); + + // Acquire ONE connection for the entire batch (session state must survive) + let pool = match client::build_pool_pub(&conn_params).await { + Ok(p) => p, + Err(e) => return error_response(id, -32603, &e), + }; + let pg_client = match pool.get().await { + Ok(c) => c, + Err(e) => return error_response(id, -32603, &format!("Connection failed: {e}")), + }; + + if let Some(s) = schema { + let set_path = format!("SET search_path TO \"{}\"", s.replace('"', "\"\"")); + if let Err(e) = pg_client.batch_execute(&set_path).await { + return error_response(id, -32603, &format!("Failed to set search_path: {e}")); + } + } + + let mut results: Vec = Vec::new(); + + for query in &queries { + let start = Instant::now(); + let outcome = exec_query_on_client(&pg_client, query, limit, page).await; + let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0; + + match outcome { + Ok(result) => results.push(json!({ + "result": result, + "error": null, + "execution_time_ms": elapsed_ms, + })), + Err(e) => results.push(json!({ + "result": null, + "error": e, + "execution_time_ms": elapsed_ms, + })), + } + } + + ok_response(id, json!(results)) +} + +pub async fn explain_query(id: Value, params: &Value) -> Value { + let conn_params = ConnectionParams::from_value(inner_params(params)); + let query = params.get("query").and_then(Value::as_str).unwrap_or(""); + let analyze = params + .get("analyze") + .and_then(Value::as_bool) + .unwrap_or(false); + let schema = params.get("schema").and_then(Value::as_str); + + let explain_sql = if analyze { + format!("EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) {}", query) + } else { + format!("EXPLAIN (FORMAT JSON) {}", query) + }; + + match exec_query(&conn_params, &explain_sql, None, 1, schema).await { + Ok(result) => { + // The host wraps this in ExplainQueryOutput::Plan { plan: res } + // We just return the raw explain JSON from the first row/col + if let Some(rows) = result.get("rows").and_then(Value::as_array) { + if let Some(first_row) = rows.first().and_then(Value::as_array) { + if let Some(plan_json) = first_row.first() { + return ok_response(id, plan_json.clone()); + } + } + } + ok_response(id, result) + } + Err(e) => error_response(id, -32603, &e), + } +} + +/// Execute a SQL query and return a QueryResult-shaped JSON value. +async fn exec_query( + conn_params: &ConnectionParams, + query: &str, + limit: Option, + page: u32, + schema: Option<&str>, +) -> Result { + let pool = client::build_pool_pub(conn_params).await?; + let pg_client = pool + .get() + .await + .map_err(|e| format!("Connection failed: {e}"))?; + + // Set search_path if schema is specified + if let Some(s) = schema { + let set_path = format!("SET search_path TO \"{}\"", s.replace('"', "\"\"")); + pg_client + .batch_execute(&set_path) + .await + .map_err(|e| format!("Failed to set search_path: {e}"))?; + } + + exec_query_on_client(&pg_client, query, limit, page).await +} + +/// Execute a query on an existing client (used by both single and batch execution). +async fn exec_query_on_client( + pg_client: &PgClient, + query: &str, + limit: Option, + page: u32, +) -> Result { + // Check if the statement returns a result set + if !returns_result_set(query) { + let affected = pg_client + .execute(query, &[]) + .await + .map_err(|e| format!("{e}"))?; + return Ok(json!({ + "columns": [], + "rows": [], + "affected_rows": affected, + "truncated": false, + "pagination": null, + })); + } + + // Build paginated query — strips any existing LIMIT/OFFSET first so we + // never emit a query with two LIMIT clauses (which is a syntax error). + let (final_query, page_size) = if let Some(lim) = limit { + let paginated = crate::utils::pagination::build_paginated_query(query, lim, page); + (paginated, lim) + } else { + (query.to_string(), 0u32) + }; + + // Execute query + let rows = pg_client + .query(&final_query, &[]) + .await + .map_err(|e| format!("{e}"))?; + + if rows.is_empty() { + // Get columns from the statement if possible + let columns: Vec = if let Ok(stmt) = pg_client.prepare(&final_query).await { + stmt.columns() + .iter() + .map(|c| c.name().to_string()) + .collect() + } else { + vec![] + }; + + let pagination = if limit.is_some() { + Some(json!({ + "page": page, + "page_size": page_size, + "total_rows": null, + "has_more": false, + })) + } else { + None + }; + + return Ok(json!({ + "columns": columns, + "rows": [], + "affected_rows": 0, + "truncated": false, + "pagination": pagination, + })); + } + + // Extract columns from first row + let columns: Vec = rows[0] + .columns() + .iter() + .map(|c| c.name().to_string()) + .collect(); + + // Determine has_more and truncate + let has_more = limit.is_some() && rows.len() > page_size as usize; + let result_rows = if has_more { + &rows[..page_size as usize] + } else { + &rows[..] + }; + + // Extract row values + let json_rows: Vec = result_rows + .iter() + .map(|row| { + let values: Vec = (0..row.columns().len()) + .map(|i| extract_value(row, i)) + .collect(); + Value::Array(values) + }) + .collect(); + + let pagination = if limit.is_some() { + Some(json!({ + "page": page, + "page_size": page_size, + "total_rows": null, + "has_more": has_more, + })) + } else { + None + }; + + Ok(json!({ + "columns": columns, + "rows": json_rows, + "affected_rows": 0, + "truncated": has_more, + "pagination": pagination, + })) +} + +/// Check if a SQL statement returns a result set (SELECT, WITH, SHOW, etc.) +fn returns_result_set(query: &str) -> bool { + let trimmed = query.trim_start(); + let upper = trimmed.to_uppercase(); + upper.starts_with("SELECT") + || upper.starts_with("WITH") + || upper.starts_with("SHOW") + || upper.starts_with("EXPLAIN") + || upper.starts_with("DESCRIBE") + || upper.starts_with("VALUES") + || upper.starts_with("TABLE") + || upper.starts_with("PRAGMA") + || upper.starts_with("CALL") +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ebfb562 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,19 @@ +//! PostgreSQL driver plugin for Tabularis. +//! +//! The crate is split into a thin library (this file plus the modules below) +//! and two binaries: `postgresql-plugin` (the real stdio JSON-RPC server) and +//! `test_plugin` (a local REPL that drives the same dispatch code). Keeping +//! the logic in a library is what lets the REPL exercise the exact same code +//! path. +#![allow(dead_code)] + +pub mod binding; +#[cfg(test)] +mod binding_tests; +pub mod client; +pub mod error; +pub mod extract; +pub mod handlers; +pub mod models; +pub mod rpc; +pub mod utils; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c9727c1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,130 @@ +//! PostgreSQL plugin for Tabularis — JSON-RPC driver over stdin/stdout. +//! +//! # Protocol +//! +//! Reads newline-delimited JSON-RPC 2.0 requests from stdin and writes +//! responses (one JSON object per line) to stdout. All handler logic is +//! async (tokio) since the database pool requires an async runtime. +//! +//! Requests are fanned out to a small worker pool so a slow query on one +//! connection does not block a `ping` or metadata call on another. Responses +//! are funneled through a single writer task so concurrent handlers never +//! interleave bytes on stdout. A dedicated background task periodically +//! evicts idle connection pools (see `client::cleanup_idle_pools`) so a +//! long-running session that has connected to many distinct targets doesn't +//! pin idle TCP connections and pool memory for the plugin's lifetime. +//! Matches the sqlserver/dynamodb sibling plugins' architecture. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{mpsc, watch, Mutex}; +use tokio::time::interval; + +const WORKER_POOL_SIZE: usize = 4; + +// Bounded so a burst of requests applies backpressure to the stdin reader +// instead of buffering unboundedly in memory. +const REQUEST_QUEUE_CAPACITY: usize = 64; + +const POOL_CLEANUP_INTERVAL: Duration = Duration::from_secs(600); // 10 minutes + +#[tokio::main] +async fn main() { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + + let cleanup_handle = tokio::spawn(run_pool_cleanup(shutdown_rx)); + + let (req_tx, req_rx) = mpsc::channel::(REQUEST_QUEUE_CAPACITY); + let req_rx = Arc::new(Mutex::new(req_rx)); + + let (resp_tx, resp_rx) = mpsc::unbounded_channel::(); + let writer_handle = tokio::spawn(run_writer(resp_rx)); + + let worker_handles: Vec<_> = (0..WORKER_POOL_SIZE) + .map(|_| tokio::spawn(run_worker(req_rx.clone(), resp_tx.clone()))) + .collect(); + drop(resp_tx); + + run_reader(req_tx).await; + + let _ = shutdown_tx.send(true); + + for handle in worker_handles { + let _ = handle.await; + } + let _ = writer_handle.await; + let _ = cleanup_handle.await; +} + +async fn run_pool_cleanup(mut shutdown_rx: watch::Receiver) { + let mut timer = interval(POOL_CLEANUP_INTERVAL); + loop { + tokio::select! { + _ = timer.tick() => postgresql_plugin::client::cleanup_idle_pools(), + _ = shutdown_rx.changed() => break, + } + } +} + +async fn run_reader(req_tx: mpsc::Sender) { + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + + loop { + let line = match lines.next_line().await { + Ok(Some(line)) => line, + Ok(None) => break, + Err(err) => { + eprintln!("stdin read error, exiting: {err}"); + break; + } + }; + + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // Blocks when the queue is full, applying backpressure to reading. + if req_tx.send(trimmed.to_string()).await.is_err() { + break; + } + } +} + +async fn run_worker( + req_rx: Arc>>, + resp_tx: mpsc::UnboundedSender, +) { + loop { + let line = { + let mut rx = req_rx.lock().await; + rx.recv().await + }; + let Some(line) = line else { break }; + + let response = postgresql_plugin::rpc::handle_line(&line).await; + let body = match serde_json::to_string(&response) { + Ok(s) => s, + Err(err) => format!( + "{{\"jsonrpc\":\"2.0\",\"error\":{{\"code\":-32603,\"message\":\"serialization failed: {err}\"}},\"id\":null}}" + ), + }; + + if resp_tx.send(body).is_err() { + break; + } + } +} + +async fn run_writer(mut resp_rx: mpsc::UnboundedReceiver) { + let mut stdout = tokio::io::stdout(); + while let Some(mut body) = resp_rx.recv().await { + body.push('\n'); + if stdout.write_all(body.as_bytes()).await.is_err() { + break; + } + let _ = stdout.flush().await; + } +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..0b7eedb --- /dev/null +++ b/src/models.rs @@ -0,0 +1,71 @@ +//! Shared request/response shapes. +//! +//! Mirrors the `ConnectionParams` struct the host sends. Fields are optional +//! since different database types leave different fields blank. + +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone)] +pub struct ConnectionParams { + pub driver: Option, + pub host: Option, + pub port: Option, + pub database: Option, + pub username: Option, + pub password: Option, + pub ssl_mode: Option, + pub ssl_ca: Option, + pub ssl_cert: Option, + pub ssl_key: Option, + pub connection_string: Option, + pub startup_script: Option, +} + +impl ConnectionParams { + pub fn from_value(value: &Value) -> Self { + let obj = value.as_object(); + let get_str = |k: &str| { + obj.and_then(|o| o.get(k)) + .and_then(Value::as_str) + .map(str::to_string) + }; + let port = obj + .and_then(|o| o.get("port")) + .and_then(Value::as_u64) + .and_then(|p| u16::try_from(p).ok()); + + Self { + driver: get_str("driver"), + host: get_str("host"), + port, + database: get_str("database"), + username: get_str("username"), + password: get_str("password"), + ssl_mode: get_str("ssl_mode"), + ssl_ca: get_str("ssl_ca"), + ssl_cert: get_str("ssl_cert"), + ssl_key: get_str("ssl_key"), + connection_string: get_str("connection_string"), + startup_script: get_str("startup_script"), + } + } +} + +/// Extract the nested `params` object every RPC method receives. +/// Tabularis wraps connection params in `params.params`. +pub fn inner_params(value: &Value) -> &Value { + value.get("params").unwrap_or(value) +} + +/// Mirrors `crate::models::ColumnDefinition` on the host — a single column's +/// shape for DDL generation (CREATE TABLE, ADD COLUMN, ALTER COLUMN). +#[derive(Debug, Clone, Deserialize)] +pub struct ColumnDefinition { + pub name: String, + pub data_type: String, + pub is_nullable: bool, + pub is_pk: bool, + pub is_auto_increment: bool, + pub default_value: Option, +} diff --git a/src/rpc.rs b/src/rpc.rs new file mode 100644 index 0000000..1b24690 --- /dev/null +++ b/src/rpc.rs @@ -0,0 +1,119 @@ +//! JSON-RPC dispatch and response helpers. + +use serde_json::{json, Value}; + +use crate::handlers; + +/// Parse one JSON-RPC line and return the response value. Never panics — +/// parse errors and method failures are surfaced as JSON-RPC error responses. +pub async fn handle_line(line: &str) -> Value { + let request: Value = match serde_json::from_str(line) { + Ok(v) => v, + Err(err) => return error_response(Value::Null, -32700, &format!("parse error: {err}")), + }; + + let id = request.get("id").cloned().unwrap_or(Value::Null); + let method = request + .get("method") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let params = request.get("params").cloned().unwrap_or(Value::Null); + + match method.as_str() { + // Connection lifecycle + "initialize" => handlers::connection::initialize(id, ¶ms).await, + "ping" => handlers::connection::ping(id, ¶ms).await, + "test_connection" => handlers::connection::test_connection(id, ¶ms).await, + "shutdown" => handlers::connection::shutdown(id, ¶ms).await, + + // Metadata — stubs for future sprints + "get_databases" => handlers::metadata::get_databases(id, ¶ms).await, + "get_schemas" => handlers::metadata::get_schemas(id, ¶ms).await, + "get_tables" => handlers::metadata::get_tables(id, ¶ms).await, + "get_columns" => handlers::metadata::get_columns(id, ¶ms).await, + "get_foreign_keys" => handlers::metadata::get_foreign_keys(id, ¶ms).await, + "get_indexes" => handlers::metadata::get_indexes(id, ¶ms).await, + "get_views" => handlers::metadata::get_views(id, ¶ms).await, + "get_view_definition" => handlers::metadata::get_view_definition(id, ¶ms).await, + "get_view_columns" => handlers::metadata::get_view_columns(id, ¶ms).await, + "get_materialized_views" => handlers::metadata::get_materialized_views(id, ¶ms).await, + "get_materialized_view_columns" => { + handlers::metadata::get_materialized_view_columns(id, ¶ms).await + } + "get_materialized_view_definition" => { + handlers::metadata::get_materialized_view_definition(id, ¶ms).await + } + "refresh_materialized_view" => { + handlers::metadata::refresh_materialized_view(id, ¶ms).await + } + "get_routines" => handlers::metadata::get_routines(id, ¶ms).await, + "get_routine_parameters" => handlers::metadata::get_routine_parameters(id, ¶ms).await, + "get_routine_definition" => handlers::metadata::get_routine_definition(id, ¶ms).await, + "get_triggers" => handlers::metadata::get_triggers(id, ¶ms).await, + "get_trigger_definition" => handlers::metadata::get_trigger_definition(id, ¶ms).await, + "get_schema_snapshot" => handlers::metadata::get_schema_snapshot(id, ¶ms).await, + "get_all_columns_batch" => handlers::metadata::get_all_columns_batch(id, ¶ms).await, + "get_all_foreign_keys_batch" => { + handlers::metadata::get_all_foreign_keys_batch(id, ¶ms).await + } + + // View mutation + "create_view" => handlers::metadata::create_view(id, ¶ms).await, + "alter_view" => handlers::metadata::alter_view(id, ¶ms).await, + "drop_view" => handlers::metadata::drop_view(id, ¶ms).await, + "create_trigger" => handlers::metadata::create_trigger(id, ¶ms).await, + "drop_trigger" => handlers::metadata::drop_trigger(id, ¶ms).await, + + // Query execution + "execute_query" => handlers::query::execute_query(id, ¶ms).await, + "execute_query_batch" => handlers::query::execute_query_batch(id, ¶ms).await, + "explain_query" => handlers::query::explain_query(id, ¶ms).await, + + // CRUD + "insert_record" => handlers::crud::insert_record(id, ¶ms).await, + "update_record" => handlers::crud::update_record(id, ¶ms).await, + "delete_record" => handlers::crud::delete_record(id, ¶ms).await, + + // DDL + "get_create_table_sql" => handlers::ddl::get_create_table_sql(id, ¶ms).await, + "get_add_column_sql" => handlers::ddl::get_add_column_sql(id, ¶ms).await, + "get_alter_column_sql" => handlers::ddl::get_alter_column_sql(id, ¶ms).await, + "get_create_index_sql" => handlers::ddl::get_create_index_sql(id, ¶ms).await, + "get_create_foreign_key_sql" => { + handlers::ddl::get_create_foreign_key_sql(id, ¶ms).await + } + "drop_index" => handlers::ddl::drop_index(id, ¶ms).await, + "drop_foreign_key" => handlers::ddl::drop_foreign_key(id, ¶ms).await, + + // BLOB + "save_blob_to_file" => handlers::blob::save_blob_to_file(id, ¶ms).await, + "fetch_blob_as_data_url" => handlers::blob::fetch_blob_as_data_url(id, ¶ms).await, + + other => not_implemented(id, other), + } +} + +pub fn ok_response(id: Value, result: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "result": result, + "id": id, + }) +} + +pub fn error_response(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "error": { "code": code, "message": message }, + "id": id, + }) +} + +pub fn not_implemented(id: Value, method: &str) -> Value { + error_response( + id, + -32601, + &format!("Method not found (-32601): '{method}' is not implemented"), + ) +} diff --git a/src/utils/identifiers.rs b/src/utils/identifiers.rs new file mode 100644 index 0000000..c9e6cc7 --- /dev/null +++ b/src/utils/identifiers.rs @@ -0,0 +1,11 @@ +//! SQL identifier quoting utilities. + +/// Quote a SQL identifier with double quotes, escaping any embedded quotes. +pub fn quote_identifier(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +/// Produce a schema-qualified identifier: "schema"."name". +pub fn qualified(schema: &str, name: &str) -> String { + format!("{}.{}", quote_identifier(schema), quote_identifier(name)) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..b7e27d7 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,6 @@ +//! Utility modules. + +pub mod identifiers; +pub mod pagination; +#[cfg(test)] +mod pagination_tests; diff --git a/src/utils/pagination.rs b/src/utils/pagination.rs new file mode 100644 index 0000000..145340c --- /dev/null +++ b/src/utils/pagination.rs @@ -0,0 +1,120 @@ +//! Pagination math for LIMIT/OFFSET queries. +//! +//! `build_paginated_query` mirrors the builtin driver's behavior in +//! `src-tauri/src/drivers/common/query.rs`: strip any trailing user-supplied +//! `LIMIT`/`OFFSET`, honor the user's LIMIT as a cap across pages, and append +//! the plugin's own pagination clause. ORDER BY is left in place (not wrapped +//! in a subquery) so table-qualified column references stay valid. +//! +//! This is a simpler whitespace/token scan than the builtin's full quote- and +//! comment-aware tokenizer — it correctly handles the common case (a plain +//! trailing `LIMIT n` / `LIMIT n OFFSET m`) but does not defend against SQL +//! comments after the clause or identifiers that literally are `LIMIT`/`OFFSET` +//! tokens inside quotes. Sufficient for the current parity test corpus; +//! revisit if a query pattern breaks this. + +/// Compute the SQL LIMIT and OFFSET for a given page and page size. +/// Pages are 1-indexed. +pub fn limit_offset(page: u32, page_size: u32) -> (u32, u32) { + let offset = (page.saturating_sub(1)) * page_size; + (page_size, offset) +} + +/// Split a query into whitespace-separated tokens, tracking each token's +/// starting byte offset in the original string. +fn tokenize_with_pos(sql: &str) -> Vec<(&str, usize)> { + let mut tokens = Vec::new(); + let mut idx = 0; + for part in sql.split_whitespace() { + // Find this token's actual position (split_whitespace doesn't give us + // offsets directly). + let start = sql[idx..].find(part).map(|p| idx + p).unwrap_or(idx); + idx = start + part.len(); + tokens.push((part, start)); + } + tokens +} + +/// Strip a trailing `LIMIT ` and/or `OFFSET ` clause from the query, +/// returning the query text with that clause removed. +fn strip_limit_offset(query: &str) -> String { + let trimmed = query.trim_end().trim_end_matches(';').trim_end(); + let tokens = tokenize_with_pos(trimmed); + let mut end = tokens.len(); + + if end >= 2 + && tokens[end - 2].0.to_uppercase() == "OFFSET" + && tokens[end - 1].0.parse::().is_ok() + { + end -= 2; + } + + if end >= 2 + && tokens[end - 2].0.to_uppercase() == "LIMIT" + && tokens[end - 1].0.parse::().is_ok() + { + end -= 2; + } + + if end == tokens.len() { + return trimmed.to_string(); + } + + trimmed[..tokens[end].1].trim_end().to_string() +} + +/// Extract the numeric value from a trailing `LIMIT` clause, if present. +fn extract_user_limit(query: &str) -> Option { + let trimmed = query.trim_end().trim_end_matches(';').trim_end(); + let tokens = tokenize_with_pos(trimmed); + let len = tokens.len(); + + let mut end = len; + if end >= 2 + && tokens[end - 2].0.to_uppercase() == "OFFSET" + && tokens[end - 1].0.parse::().is_ok() + { + end -= 2; + } + + if end >= 2 && tokens[end - 2].0.to_uppercase() == "LIMIT" { + return tokens[end - 1].0.parse().ok(); + } + + None +} + +/// Extract the numeric value from a trailing `OFFSET` clause, if present. +fn extract_user_offset(query: &str) -> Option { + let trimmed = query.trim_end().trim_end_matches(';').trim_end(); + let tokens = tokenize_with_pos(trimmed); + let end = tokens.len(); + + if end >= 2 && tokens[end - 2].0.to_uppercase() == "OFFSET" { + return tokens[end - 1].0.parse().ok(); + } + + None +} + +/// Build a paginated query: strip any user-supplied LIMIT/OFFSET and append +/// this page's clause. A user LIMIT caps the total rows returned across all +/// pages; a user OFFSET is added to the per-page offset. +pub fn build_paginated_query(query: &str, page_size: u32, page: u32) -> String { + let page_offset = limit_offset(page, page_size).1; + let user_limit = extract_user_limit(query); + let user_offset = extract_user_offset(query).unwrap_or(0); + let base = strip_limit_offset(query); + + let fetch_count = match user_limit { + Some(ul) => { + let remaining = ul.saturating_sub(page_offset); + remaining.min(page_size + 1) + } + None => page_size + 1, + }; + + let offset = user_offset.saturating_add(page_offset); + + format!("{} LIMIT {} OFFSET {}", base, fetch_count, offset) +} diff --git a/src/utils/pagination_tests.rs b/src/utils/pagination_tests.rs new file mode 100644 index 0000000..78498d3 --- /dev/null +++ b/src/utils/pagination_tests.rs @@ -0,0 +1,70 @@ +//! Unit tests for `pagination.rs`. Sibling test file per repo convention +//! (`.rules/rust.md` #4/#5) — loaded via `#[cfg(test)] mod pagination_tests;`. + +use crate::utils::pagination::{build_paginated_query, limit_offset}; + +#[test] +fn limit_offset_computes_zero_based_offset_from_one_indexed_page() { + assert_eq!(limit_offset(1, 10), (10, 0)); + assert_eq!(limit_offset(2, 10), (10, 10)); + assert_eq!(limit_offset(3, 5), (5, 10)); +} + +#[test] +fn build_paginated_query_appends_limit_offset_when_none_present() { + let sql = build_paginated_query("SELECT * FROM t ORDER BY id", 10, 1); + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 11 OFFSET 0"); +} + +#[test] +fn build_paginated_query_page_two_uses_correct_offset() { + let sql = build_paginated_query("SELECT * FROM t ORDER BY id", 10, 2); + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 11 OFFSET 10"); +} + +#[test] +fn build_paginated_query_strips_existing_trailing_limit() { + // Without stripping, this would produce two LIMIT clauses (a syntax + // error) — this is the regression this module exists to prevent. + let sql = build_paginated_query("SELECT * FROM t ORDER BY id LIMIT 5", 100, 1); + assert_eq!( + sql.matches("LIMIT").count(), + 1, + "must not contain two LIMIT clauses: {sql}" + ); +} + +#[test] +fn build_paginated_query_honors_user_limit_as_a_cap_across_pages() { + // User asked for at most 5 rows total. Page 1 with page_size=100 should + // fetch min(5, 101) = 5, not 101. + let sql = build_paginated_query("SELECT * FROM t ORDER BY id LIMIT 5", 100, 1); + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 5 OFFSET 0"); +} + +#[test] +fn build_paginated_query_user_limit_cap_shrinks_on_later_pages() { + // User LIMIT 5, page_size 2, page 3 -> offset 4, remaining = 5-4 = 1. + let sql = build_paginated_query("SELECT * FROM t ORDER BY id LIMIT 5", 2, 3); + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 1 OFFSET 4"); +} + +#[test] +fn build_paginated_query_strips_existing_limit_and_offset() { + let sql = build_paginated_query("SELECT * FROM t ORDER BY id LIMIT 5 OFFSET 3", 100, 1); + // User OFFSET 3 is preserved and added to the page offset (0 on page 1). + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 5 OFFSET 3"); +} + +#[test] +fn build_paginated_query_adds_user_offset_to_page_offset() { + let sql = build_paginated_query("SELECT * FROM t ORDER BY id OFFSET 3", 10, 2); + // page 2 offset = 10, plus user offset 3 = 13. + assert_eq!(sql, "SELECT * FROM t ORDER BY id LIMIT 11 OFFSET 13"); +} + +#[test] +fn build_paginated_query_ignores_trailing_semicolon() { + let sql = build_paginated_query("SELECT * FROM t;", 10, 1); + assert_eq!(sql, "SELECT * FROM t LIMIT 11 OFFSET 0"); +} diff --git a/tests/live_db.rs b/tests/live_db.rs new file mode 100644 index 0000000..ef48a54 --- /dev/null +++ b/tests/live_db.rs @@ -0,0 +1,253 @@ +//! Live-database integration test — a self-contained smoke test that +//! actually talks to a real PostgreSQL instance, unlike every other test in +//! this crate (all pure `#[cfg(test)]` unit tests, see `.rules/rust.md` +//! #4/#5). Closes the biggest gap in this repo's own CI: nothing here +//! previously verified the built binary against a live database +//! automatically — that only ever happened via a manual cross-repo parity +//! check against `tabularis`'s test suite. +//! +//! This is deliberately NOT the cross-repo 82-test parity suite (that stays +//! a manual/periodic check against `tabularis`, per the "Repo Extraction" +//! open question in `docs/planning/02-phase-1-plugin-build.md`). It's a +//! small self-check covering connect, a basic query, an insert, and the two +//! handlers found completely uncovered during the security-audit pass this +//! migration did (`startup_script`, `connection_string`). +//! +//! # Running locally +//! +//! Point `POSTGRES_PLUGIN_BIN` at a debug build and run against any +//! PostgreSQL 16 instance (defaults below match this session's local +//! Podman container: `postgres:16`, user `postgres`, password `password`, +//! db `testdb`, port `54320`): +//! +//! ```bash +//! cargo build +//! POSTGRES_PLUGIN_BIN=target/debug/postgresql-plugin cargo test --test live_db -- --test-threads=1 +//! ``` + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; + +use serde_json::{json, Value}; + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +fn conn_params() -> Value { + json!({ + "host": env_or("PGHOST", "127.0.0.1"), + "port": env_or("PGPORT", "54320").parse::().expect("PGPORT must be a valid port"), + "username": env_or("PGUSER", "postgres"), + "password": env_or("PGPASSWORD", "password"), + "database": env_or("PGDATABASE", "testdb"), + }) +} + +/// A running plugin process, driven over its stdin/stdout exactly like a +/// real host would — same shape as the manual JSON-RPC smoke tests run +/// throughout this migration. +struct Plugin { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, +} + +impl Plugin { + fn spawn() -> Self { + let bin = std::env::var("POSTGRES_PLUGIN_BIN").expect("POSTGRES_PLUGIN_BIN must be set"); + let mut child = Command::new(bin) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("failed to spawn plugin binary"); + let stdin = child.stdin.take().expect("no stdin"); + let stdout = BufReader::new(child.stdout.take().expect("no stdout")); + Self { + child, + stdin, + stdout, + next_id: 1, + } + } + + /// Send one JSON-RPC request and return its parsed response. + fn call(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let request = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": id, + }); + let mut line = serde_json::to_string(&request).expect("serialize request"); + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .expect("write to plugin stdin"); + self.stdin.flush().expect("flush plugin stdin"); + + let mut response_line = String::new(); + self.stdout + .read_line(&mut response_line) + .expect("read from plugin stdout"); + let response: Value = + serde_json::from_str(response_line.trim()).expect("parse JSON-RPC response"); + assert_eq!( + response.get("id").and_then(Value::as_u64), + Some(id), + "response id must match the request that produced it" + ); + response + } + + /// Call and assert the response carries a `result`, not an `error`. + fn call_ok(&mut self, method: &str, params: Value) -> Value { + let response = self.call(method, params); + assert!( + response.get("error").is_none(), + "{method} returned an error: {:?}", + response.get("error") + ); + response + .get("result") + .cloned() + .unwrap_or_else(|| panic!("{method} returned neither result nor error")) + } +} + +impl Drop for Plugin { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +fn test_connection_succeeds_against_live_database() { + let mut plugin = Plugin::spawn(); + plugin.call_ok("test_connection", json!({ "params": conn_params() })); +} + +#[test] +fn execute_query_returns_rows_from_live_database() { + let mut plugin = Plugin::spawn(); + let result = plugin.call_ok( + "execute_query", + json!({ "params": conn_params(), "query": "SELECT 1 AS one" }), + ); + let rows = result + .get("rows") + .and_then(Value::as_array) + .expect("rows array"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0][0], json!(1)); +} + +#[test] +fn insert_record_persists_a_row() { + let mut plugin = Plugin::spawn(); + let params = conn_params(); + + // Self-contained: create (and reset) our own scratch table rather than + // depending on tabularis's seed fixtures, since this test must not + // require anything outside this repo. + plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "CREATE TABLE IF NOT EXISTS live_db_test_scratch \ + (id SERIAL PRIMARY KEY, name TEXT, value INTEGER)", + }), + ); + plugin.call_ok( + "execute_query", + json!({ "params": params, "query": "TRUNCATE live_db_test_scratch RESTART IDENTITY" }), + ); + + let affected = plugin.call_ok( + "insert_record", + json!({ + "params": params, + "table": "live_db_test_scratch", + "schema": "public", + "data": { "name": "smoke-test", "value": 42 }, + }), + ); + assert_eq!(affected, json!(1), "insert should affect exactly one row"); + + let result = plugin.call_ok( + "execute_query", + json!({ + "params": params, + "query": "SELECT name, value FROM live_db_test_scratch", + }), + ); + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0], json!(["smoke-test", 42])); +} + +#[test] +fn connection_string_connects_with_no_discrete_fields() { + let mut plugin = Plugin::spawn(); + let p = conn_params(); + let conn_str = format!( + "postgres://{}:{}@{}:{}/{}", + p["username"].as_str().unwrap(), + p["password"].as_str().unwrap(), + p["host"].as_str().unwrap(), + p["port"].as_u64().unwrap(), + p["database"].as_str().unwrap(), + ); + + plugin.call_ok( + "test_connection", + json!({ "params": { "connection_string": conn_str } }), + ); +} + +#[test] +fn startup_script_runs_on_every_pooled_connection() { + let mut plugin = Plugin::spawn(); + let mut params = conn_params(); + params["startup_script"] = json!("SET search_path = public, pg_catalog"); + + plugin.call_ok("test_connection", json!({ "params": params })); + + let result = plugin.call_ok( + "execute_query", + json!({ "params": params, "query": "SHOW search_path" }), + ); + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + let search_path = rows[0][0].as_str().unwrap(); + assert!( + search_path.contains("public"), + "startup_script's SET search_path should have taken effect, got: {search_path}" + ); +} + +#[test] +fn broken_startup_script_fails_fast_with_clear_attribution() { + let mut plugin = Plugin::spawn(); + let mut params = conn_params(); + // Use a host/port/database unique to this test so it can't reuse a + // pool already cached (and validated) by another test in this file — + // the pool cache key folds in startup_script, but a fresh identity is + // the clearest way to guarantee a first-use preflight actually runs. + params["startup_script"] = json!("THIS IS NOT VALID SQL"); + + let response = plugin.call("test_connection", json!({ "params": params })); + let error = response + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .expect("a broken startup script must produce a JSON-RPC error"); + assert!( + error.starts_with("Startup script failed:"), + "error should be clearly attributed to the startup script, got: {error}" + ); +}