diff --git a/.github/planning/.markdownlint.json b/.github/planning/.markdownlint.json new file mode 100644 index 000000000..7bf87b1a8 --- /dev/null +++ b/.github/planning/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "MD013": false, + "MD024": { "siblings_only": true }, + "MD060": false +} diff --git a/.github/planning/postgres-improvements.md b/.github/planning/postgres-improvements.md new file mode 100644 index 000000000..bda2dd6bb --- /dev/null +++ b/.github/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/.github/planning/postgres-plugin-migration-original.md b/.github/planning/postgres-plugin-migration-original.md new file mode 100644 index 000000000..76fb7ed6c --- /dev/null +++ b/.github/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/.github/planning/postgres-plugin-migration.md b/.github/planning/postgres-plugin-migration.md new file mode 100644 index 000000000..4841f1c46 --- /dev/null +++ b/.github/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/.github/planning/postgres-plugin/00-prerequisites.md b/.github/planning/postgres-plugin/00-prerequisites.md new file mode 100644 index 000000000..511e9d305 --- /dev/null +++ b/.github/planning/postgres-plugin/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/.github/planning/postgres-plugin/01-phase-0-baseline-tests.md b/.github/planning/postgres-plugin/01-phase-0-baseline-tests.md new file mode 100644 index 000000000..fd42086b8 --- /dev/null +++ b/.github/planning/postgres-plugin/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/.github/planning/postgres-plugin/02-phase-1-plugin-build.md b/.github/planning/postgres-plugin/02-phase-1-plugin-build.md new file mode 100644 index 000000000..d56259194 --- /dev/null +++ b/.github/planning/postgres-plugin/02-phase-1-plugin-build.md @@ -0,0 +1,514 @@ +# 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/.github/planning/postgres-plugin/03-phase-2-issue-16.md b/.github/planning/postgres-plugin/03-phase-2-issue-16.md new file mode 100644 index 000000000..aa39d1600 --- /dev/null +++ b/.github/planning/postgres-plugin/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/.github/planning/postgres-plugin/04-phase-3-deprecate-builtin.md b/.github/planning/postgres-plugin/04-phase-3-deprecate-builtin.md new file mode 100644 index 000000000..bc2b7458a --- /dev/null +++ b/.github/planning/postgres-plugin/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/.github/planning/postgres-plugin/README.md b/.github/planning/postgres-plugin/README.md new file mode 100644 index 000000000..017630495 --- /dev/null +++ b/.github/planning/postgres-plugin/README.md @@ -0,0 +1,35 @@ +# PostgreSQL Plugin Migration — Phase Docs Index + +**Master Plan:** [postgres-plugin-migration-alt.md](../postgres-plugin-migration-alt.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/.github/planning/sqlite-improvements.md b/.github/planning/sqlite-improvements.md new file mode 100644 index 000000000..b00a906d3 --- /dev/null +++ b/.github/planning/sqlite-improvements.md @@ -0,0 +1,652 @@ +# SQLite Driver Improvements — Feature Parity Audit & Plan + +**Ref:** [#17 — Better SQLite Support](https://github.com/TabularisDB/tabularis/issues/17) + +## Executive Summary + +A comprehensive audit of the SQLite driver (`src-tauri/src/drivers/sqlite/`) reveals +that the driver is **structurally sound** and correctly handles most SQLite-specific +behaviors. However, it has 2 bugs, 1 missing CRUD capability, and several polish +items that impact the user experience. Most "gaps" identified in a raw feature +matrix comparison against PostgreSQL/MySQL are actually **SQLite limitations** rather +than driver deficiencies. + +This document separates genuine issues from inherent SQLite constraints, proposes +fixes prioritized by impact, and provides implementation guidance. + +--- + +## Table of Contents + +1. [Audit Methodology](#audit-methodology) +2. [Current State](#current-state) +3. [Findings: Bugs](#findings-bugs) +4. [Findings: Genuine Improvements](#findings-genuine-improvements) +5. [Findings: Not Applicable](#findings-not-applicable-sqlite-limitations) +6. [Feature Comparison Matrix](#feature-comparison-matrix) +7. [Implementation Plan](#implementation-plan) +8. [Testing Strategy](#testing-strategy) +9. [Open Questions](#open-questions) + +--- + +## Audit Methodology + +The audit compared three drivers across all methods of the `DatabaseDriver` trait +(50+ methods): + +- **PostgreSQL** — `src-tauri/src/drivers/postgres/mod.rs` (2420 lines, 6 extraction submodules) +- **MySQL** — `src-tauri/src/drivers/mysql/mod.rs` (2279 lines, 5 extraction submodules) +- **SQLite** — `src-tauri/src/drivers/sqlite/mod.rs` (1405 lines, 2 extraction submodules) + +Additionally reviewed: + +- `src-tauri/src/drivers/driver_trait.rs` — trait definition and `DriverCapabilities` +- `src-tauri/src/pool_manager.rs` — connection pool creation +- `src-tauri/src/models.rs` — shared data structures (`TableColumn`, `ForeignKey`, `Index`) +- Frontend code — SQLite-specific conditionals and workarounds + +--- + +## Current State + +### What Works Correctly + +The SQLite driver correctly implements: + +- Connection management (pool-based with configurable startup scripts) +- Query execution with pagination +- Schema introspection (`get_tables`, `get_columns`, `get_views`, `get_indexes`, `get_foreign_keys`) +- Trigger management (create, list, get definition, drop) +- View management (create, drop, get definition — correctly uses DROP+CREATE since SQLite lacks ALTER VIEW) +- BLOB read/write with hex wire format +- EXPLAIN QUERY PLAN output +- DDL generation (CREATE TABLE, ADD COLUMN, CREATE INDEX) +- Record deletion with PK binding +- Batch execution with sequential statement processing +- `ALTER COLUMN` correctly limited to rename-only (returns error for type/null changes) +- `create_foreign_keys: false` correctly declared (SQLite only supports FKs at CREATE TABLE time) + +### Declared Capabilities + +```rust +DriverCapabilities { + schemas: false, // Correct — SQLite has no schema namespacing + views: true, // Correct + materialized_views: false, // Correct — not supported + routines: false, // Correct — no stored procedures + routine_management: false, // Correct + file_based: true, // Correct + connection_string: false, // Correct — uses file path + alter_primary_key: true, // ⚠ BUG — should be false + alter_column: false, // Correct — only rename supported + create_foreign_keys: false, // Correct — only at CREATE TABLE time + triggers: true, // Correct + explain: true, // Correct + supports_ssl: false, // Correct — local file DB + sql_dialect: "Sqlite", // Correct + manage_tables: true, // Correct + settings: vec![], // ⚠ Missing PRAGMA settings +} +``` + +--- + +## Findings: Bugs + +### Bug 1: AUTOINCREMENT Detection Always Returns False + +**Location:** `src-tauri/src/drivers/sqlite/mod.rs` lines 82-89 + +**The Problem:** + +```rust +let _is_auto = pk > 0 && dtype.to_uppercase().contains("INT"); + +TableColumn { + // ... + is_auto_increment: false, // Always false — _is_auto is unused + // ... +} +``` + +The detection logic is computed but the result is assigned to an unused variable +(prefixed with `_`). + +**Impact:** + +- The UI never shows "Auto" placeholder for auto-increment columns +- Users may be forced to manually enter values for ROWID/INTEGER PRIMARY KEY columns +- The "Set Generated" quick-action button doesn't appear in the row editor + +**Correct Behavior:** + +In SQLite, any `INTEGER PRIMARY KEY` column is automatically an alias for ROWID +and auto-increments. The `AUTOINCREMENT` keyword only adds a stricter guarantee +that values are never reused. Both should report `is_auto_increment: true`. + +**Fix:** + +```rust +let is_auto = pk > 0 && dtype.to_uppercase().contains("INT"); + +TableColumn { + // ... + is_auto_increment: is_auto, + // ... +} +``` + +**Complexity:** Trivial (one-line change) + +--- + +### Bug 2: Primary Key Alteration — Table Recreation with Safety Dialog + +**Location:** `src-tauri/src/drivers/sqlite/mod.rs` line 893 + +**The Problem:** + +SQLite cannot ALTER a primary key on an existing table via `ALTER TABLE`. The +capability `alter_primary_key` is declared `true`, which allows the UI to present +PK modification options — but executing the generated SQL fails silently or with +a confusing error. + +**Impact:** + +- Users see an enabled PK checkbox, make changes, and get unexpected errors +- No path to actually modify PKs on existing SQLite tables + +#### Solution: Table Recreation with Explicit User Consent + +Keep `alter_primary_key: true` — the capability genuinely exists, it just requires +a multi-step approach. When the user attempts to modify a PK on a SQLite table: + +1. Show a confirmation dialog explaining the operation +2. If user consents, execute the table-recreation algorithm +3. If user cancels, revert the UI change + +**Confirmation Dialog Content:** + +> **Recreate Table Required** +> +> SQLite cannot modify primary keys directly. This operation will: +> +> 1. Create a new table with the updated primary key +> 2. Copy all existing data to the new table +> 3. Verify the data was copied completely +> 4. Replace the original table with the new one +> 5. Rebuild all indexes, triggers, and constraints +> +> This runs inside a single transaction — if any step fails, all changes +> are rolled back and your original table remains untouched. +> +> **[Cancel]** **[Proceed]** + +**The Algorithm:** + +```sql +BEGIN IMMEDIATE; + +-- Step 1: Create new table with modified schema +CREATE TABLE "_tabularis_tmp_users" ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT +); + +-- Step 2: Copy all data (column-mapped) +INSERT INTO "_tabularis_tmp_users" (id, name, email) +SELECT id, name, email FROM "users"; + +-- Step 3: Verify row count +-- (programmatic check: COUNT(*) of both must match) + +-- Step 4: Drop the original +DROP TABLE "users"; + +-- Step 5: Rename to original name +ALTER TABLE "_tabularis_tmp_users" RENAME TO "users"; + +-- Step 6: Recreate dependent objects +CREATE INDEX idx_users_email ON "users" (email); +CREATE TRIGGER trg_users_audit AFTER UPDATE ON "users" ...; + +COMMIT; +``` + +**Why This Is Safe — Zero Risk of Data Loss:** + +| Guarantee | Mechanism | +| --------- | --------- | +| **Atomicity** | All steps run inside `BEGIN IMMEDIATE ... COMMIT`. If ANY step fails, `ROLLBACK` undoes everything — including the DDL. SQLite uniquely supports transactional DDL (CREATE, DROP, ALTER all roll back). | +| **Write lock** | `BEGIN IMMEDIATE` takes an exclusive write lock at the start. No other writer can interfere or see intermediate state. | +| **Original untouched until step 4** | The original table exists with all data intact through steps 1-3. If the INSERT or verification fails, ROLLBACK restores the database to its pre-transaction state. | +| **Row count verification** | Before dropping the original, programmatically verify `COUNT(*)` matches between old and new tables. Mismatch → ROLLBACK. | +| **Crash safety** | If the application crashes mid-transaction, SQLite's journal/WAL automatically rolls back the uncommitted transaction on next database open. The original table survives intact. | +| **Temp table naming** | Uses `_tabularis_tmp_` prefix — won't collide with user tables. | +| **Disk-full handling** | If disk runs out during COMMIT, the transaction fails and rolls back. The only true unrecoverable case is disk-full combined with journal corruption, which is a system-level failure beyond any application's control. | + +**Implementation Requirements:** + +1. Detect when a PK change is requested on a SQLite connection +2. Gather all dependent objects (indexes, triggers, views referencing the table) +3. Generate the full recreation script +4. Show the confirmation dialog with the script preview +5. Execute within a single transaction on an acquired connection +6. Verify row count before DROP +7. Report success/failure clearly to the user + +**What Gets Rebuilt:** + +| Object Type | Detection | Rebuild Method | +| ----------- | --------- | -------------- | +| Indexes | `PRAGMA index_list` | `CREATE INDEX` from stored metadata | +| Triggers | `SELECT * FROM sqlite_master WHERE type='trigger' AND tbl_name=?` | Re-execute the original `CREATE TRIGGER` SQL | +| Views | `SELECT * FROM sqlite_master WHERE type='view' AND sql LIKE '%tablename%'` | Views reference by name — they automatically resolve after rename | +| Foreign keys from other tables | `PRAGMA foreign_key_list` on all tables | Cannot be rebuilt (SQLite limitation) — warn user if detected | + +**Complexity:** High (new Tauri command + dialog + dependent object detection + testing) + +**Note:** This same table-recreation infrastructure can later be reused for other +SQLite operations that require table rebuilds: changing column types, reordering +columns, changing nullability, and removing columns on older SQLite versions. + +--- + +## Findings: Genuine Improvements + +### Improvement 1: JSON Object/Array Support in CRUD + +**Location:** `insert_record` and `update_record` functions + +**Current Behavior:** + +```rust +_ => return Err("Unsupported value type".into()), +``` + +When a user edits a cell containing a JSON object or array, the sidebar/inline +editor sends a `serde_json::Value::Object` or `Value::Array`. The SQLite driver +rejects these with a generic error. + +**How MySQL Handles It:** Wraps with `CAST(... AS JSON)` + +**Correct SQLite Approach:** + +SQLite stores JSON as plain TEXT. The fix is to serialize the value to a JSON +string: + +```rust +serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + let json_text = serde_json::to_string(&val) + .map_err(|e| format!("Failed to serialize JSON: {}", e))?; + separated.push_bind(json_text); +} +``` + +No special function or cast is needed — SQLite's JSON functions (`json()`, +`json_extract()`, etc.) operate on TEXT values containing valid JSON. + +**Complexity:** Low (add a match arm in two functions) + +--- + +### Improvement 2: PRAGMA Settings as Connection Configuration + +**Current State:** + +MySQL exposes 4 configurable settings via the driver manifest: + +```rust +settings: vec![ + DriverSetting { key: "maxAllowedPacket", ... }, + DriverSetting { key: "socketTimeout", ... }, + DriverSetting { key: "connectTimeout", ... }, + DriverSetting { key: "timezone", ... }, +] +``` + +SQLite exposes zero settings. Users must know to use the `startup_script` field +to configure PRAGMAs. + +**Proposed Settings:** + +| Setting | PRAGMA | Default | Description | +| ------- | ------ | ------- | ----------- | +| `journalMode` | `journal_mode` | `delete` | WAL mode enables concurrent readers with one writer. Options: delete, truncate, persist, memory, wal, off | +| `foreignKeys` | `foreign_keys` | `OFF` → `ON` | Whether FK constraints are enforced. SQLite defaults to OFF for backwards compatibility. | +| `synchronous` | `synchronous` | `FULL` | Durability vs performance trade-off. Options: OFF, NORMAL, FULL, EXTRA | +| `cacheSize` | `cache_size` | `-2000` | Page cache size. Negative = KiB, positive = pages | +| `busyTimeout` | `busy_timeout` | `5000` | Milliseconds to wait for a locked database before returning BUSY | + +**Implementation:** + +1. Add `DriverSetting` entries to the SQLite manifest +2. In `build_sqlite_connectoptions`, apply settings via `.pragma()` calls: + +```rust +fn build_sqlite_connectoptions(params: &ConnectionParams) -> SqliteConnectOptions { + let mut opts = SqliteConnectOptions::new() + .filename(params.database.to_string()) + .journal_mode(SqliteJournalMode::Wal) // from settings + .foreign_keys(true) // from settings + .busy_timeout(Duration::from_millis(5000)); // from settings + opts +} +``` + +**Note on `foreign_keys=ON` default:** This is a behavior change. Existing users +may have data violating FK constraints. The setting should default to ON for **new** +connections but respect existing saved configurations. A migration path is needed. + +**Complexity:** Medium (settings infrastructure + pool creation changes) + +--- + +### Improvement 3: Expanded Data Type List + +**Current State:** 8 types declared (INTEGER, REAL, TEXT, BLOB, VARCHAR, BOOLEAN, DATE, DATETIME) + +**Impact:** The UI type picker when creating/altering tables shows only 8 options. +Users familiar with SQL may expect to see common type names that SQLite accepts +via its type affinity system. + +**Proposed Additions:** + +```rust +// Numeric affinity +DataTypeInfo { name: "INT", ... }, +DataTypeInfo { name: "BIGINT", ... }, +DataTypeInfo { name: "SMALLINT", ... }, +DataTypeInfo { name: "TINYINT", ... }, +DataTypeInfo { name: "NUMERIC", ... }, +DataTypeInfo { name: "DECIMAL", ... }, +DataTypeInfo { name: "FLOAT", ... }, +DataTypeInfo { name: "DOUBLE", ... }, + +// Text affinity +DataTypeInfo { name: "CHAR", has_length: true, ... }, +DataTypeInfo { name: "CLOB", ... }, +DataTypeInfo { name: "NVARCHAR", has_length: true, ... }, +DataTypeInfo { name: "JSON", ... }, + +// Date/time (stored as TEXT/REAL/INTEGER but semantically distinct) +DataTypeInfo { name: "TIMESTAMP", ... }, +DataTypeInfo { name: "TIME", ... }, +``` + +**Important context:** In SQLite, type names are advisory — the engine uses +[type affinity rules](https://www.sqlite.org/datatype3.html) to determine storage +class. All of these types "work" regardless of whether the driver lists them. This +improvement is purely for UI discoverability. + +**Complexity:** Low (add entries to the types array) + +--- + +### Improvement 4: Parse `character_maximum_length` from Type Strings + +**Current State:** Always returns `None`, even for `VARCHAR(255)`. + +**Why it matters:** The frontend uses `character_maximum_length` to show a character +counter in the row editor and to validate input length. + +**Fix:** + +```rust +fn parse_max_length(data_type: &str) -> Option { + // Match patterns like VARCHAR(255), CHAR(10), NVARCHAR(100) + let re = regex::Regex::new(r"\((\d+)\)").ok()?; + re.captures(data_type)? + .get(1)? + .as_str() + .parse::() + .ok() +} +``` + +**Complexity:** Low (add a helper function, call it in `get_columns`) + +--- + +### Improvement 5: Full ALTER COLUMN Support via Table Recreation + +**Depends on:** Tier 3 (Table Recreation Engine from Bug 2) + +**Current State:** + +The SQLite driver correctly returns errors for ALTER COLUMN operations beyond +rename. The `alter_column` capability is declared `false`. However, once the +table-recreation engine is built for PK changes, the same infrastructure enables: + +| Operation | Current Behavior | With Recreation Engine | +| --------- | ---------------- | ---------------------- | +| Change column type | Error: "not supported" | ✅ Recreate table with new type | +| Change nullability | Error: "not supported" | ✅ Recreate table with NOT NULL / NULL | +| Change default value | Error: "not supported" | ✅ Recreate table with new DEFAULT | +| Reorder columns | Not possible | ✅ Recreate table with new column order | +| Drop column (SQLite < 3.35.0) | Error on old versions | ✅ Recreate table without the column | + +**Implementation:** + +Once the recreation engine exists, the `get_alter_column_sql` method would: + +1. Detect that the requested change requires recreation (type, nullability, or default change) +2. Return a special marker or invoke the recreation flow with the modified column definition +3. Show the same safety confirmation dialog as PK changes +4. Execute the recreation within a single atomic transaction + +**Impact:** This changes `alter_column` capability from `false` to `true` — +enabling the full Modify Column modal for SQLite users, matching the PostgreSQL +and MySQL experience. + +**Complexity:** Medium (reuses the Tier 3 recreation engine; primarily wiring + UI integration) + +--- + +## Findings: Not Applicable (SQLite Limitations) + +These items appeared as "gaps" in a raw comparison but are **inherent SQLite +limitations** that cannot be fixed at the driver level: + +| Item | Why It's Not Fixable | +| ---- | -------------------- | +| **No EXPLAIN ANALYZE** | SQLite does not support runtime execution statistics. `EXPLAIN QUERY PLAN` is the only available introspection. The plain `EXPLAIN` shows internal bytecode opcodes that are meaningless to end users. | +| **No cost/timing data in query plans** | SQLite's query planner does not expose cost estimates or actual execution times. This is a fundamental architectural difference from PG/MySQL. | +| **No schemas** | SQLite is a file-based embedded database with a single namespace. `ATTACH DATABASE` provides a workaround but it's a different concept. | +| **No stored procedures/routines** | SQLite has no procedural language. This is by design — it's an embedded engine. | +| **No enum types** | SQLite uses CHECK constraints instead (`CHECK(status IN ('active','inactive'))`). | +| **No array types** | SQLite has no composite types. JSON arrays stored as TEXT are the workaround. | +| **No geometry types** | No spatial extension in vanilla SQLite (SpatiaLite exists but is a separate extension). | +| **Synthetic FK names** | `PRAGMA foreign_key_list` does not return constraint names — only an integer `id`. The driver correctly generates synthetic names `fk_{id}_{ref_table}`. No alternative exists. | +| **No multi-result sets** | SQLite does not support returning multiple result sets from a single execution. | + +--- + +## Feature Comparison Matrix + +| Feature | PostgreSQL | MySQL | SQLite | Status | +| ------- | ---------- | ----- | ------ | ------ | +| **Connection** | | | | | +| Pool-based connections | ✅ | ✅ | ✅ | Parity | +| Startup script support | ✅ | ✅ | ✅ | Parity | +| SSL/TLS | ✅ | ✅ | N/A | By design | +| SSH tunneling | ✅ | ✅ | N/A | By design | +| Configurable settings | ✅ (0) | ✅ (4) | ❌ (0) | **Gap — Improvement 2** | +| **Schema Inspection** | | | | | +| List tables | ✅ | ✅ | ✅ | Parity | +| List columns | ✅ | ✅ | ✅ | Parity | +| Auto-increment detection | ✅ | ✅ | ❌ | **Bug 1** | +| character_maximum_length | ✅ | ✅ | ❌ | **Gap — Improvement 4** | +| Foreign keys | ✅ | ✅ | ✅ (synthetic names) | Acceptable | +| Indexes | ✅ | ✅ | ✅ | Parity | +| Views | ✅ | ✅ | ✅ | Parity | +| Triggers | ✅ | ✅ | ✅ | Parity | +| **CRUD** | | | | | +| Insert (basic types) | ✅ | ✅ | ✅ | Parity | +| Insert (JSON objects) | ✅ | ✅ | ❌ | **Gap — Improvement 1** | +| Update (basic types) | ✅ | ✅ | ✅ | Parity | +| Update (JSON objects) | ✅ | ✅ | ❌ | **Gap — Improvement 1** | +| Delete | ✅ | ✅ | ✅ | Parity | +| BLOB read/write | ✅ | ✅ | ✅ | Parity | +| **DDL** | | | | | +| CREATE TABLE | ✅ | ✅ | ✅ | Parity | +| ADD COLUMN | ✅ | ✅ | ✅ | Parity | +| ALTER COLUMN (type) | ✅ | ✅ | ⚠️ (via table recreation) | **Improvement 5 — unlocked by Tier 3** | +| ALTER PRIMARY KEY | ✅ | ✅ | ⚠️ (via table recreation) | **Bug 2 — needs recreation engine** | +| CREATE INDEX | ✅ | ✅ | ✅ | Parity | +| DROP INDEX | ✅ | ✅ | ✅ | Parity | +| CREATE FOREIGN KEY | ✅ | ✅ | N/A | SQLite limitation | +| **Query Plans** | | | | | +| EXPLAIN | ✅ (JSON tree) | ✅ (JSON/tabular) | ✅ (flat plan) | Parity | +| EXPLAIN ANALYZE | ✅ | ✅ | N/A | SQLite limitation | +| Cost estimates | ✅ | ✅ | N/A | SQLite limitation | +| **Type System** | | | | | +| Declared types count | 97+ | 30+ | 8 | **Gap — Improvement 3** | +| Type picker completeness | ✅ | ✅ | ❌ | **Gap — Improvement 3** | + +--- + +## Implementation Plan + +### Tier 1: Quick Fixes + +| Item | Risk | +| ---- | ---- | +| Fix AUTOINCREMENT detection | None | +| Fix JSON in CRUD (serialize to TEXT) | Low — test with JSON functions | +| Expand data type list | None | +| Parse `character_maximum_length` | None | + +### Tier 2: PRAGMA Settings + +| Item | Risk | +| ---- | ---- | +| PRAGMA settings infrastructure (journal_mode, foreign_keys, synchronous, cache_size, busy_timeout) | Medium — behavior change for `foreign_keys` | +| Default `foreign_keys=ON` for new connections | Medium — migration path needed | +| Default `busy_timeout=5000` | None | + +### Tier 3: Table Recreation Engine + +This is the most significant piece of work — implementing the safe table-recreation +approach for PK alterations (and eventually other unsupported ALTER operations). + +| Item | Risk | +| ---- | ---- | +| New Tauri command: `recreate_sqlite_table` | Medium — must handle all edge cases | +| Dependent object detection (indexes, triggers, FK references) | Low | +| Confirmation dialog (frontend) | None | +| Row count verification step | None | +| Integration with existing ModifyColumnModal flow | Low | +| Comprehensive tests (see Testing Strategy) | None | + +**Future reuse:** Once the table-recreation engine exists, it unlocks other +operations that SQLite can't do via ALTER TABLE: + +- Change column types +- Change column nullability +- Change column defaults +- Reorder columns +- Remove columns (on SQLite < 3.35.0) + +--- + +## Testing Strategy + +### Unit Tests + +```text +tests/drivers/sqlite/ +├── autoincrement_detection.test.rs +│ ├── INTEGER PRIMARY KEY reports is_auto_increment: true +│ ├── INTEGER PRIMARY KEY AUTOINCREMENT reports is_auto_increment: true +│ ├── TEXT PRIMARY KEY reports is_auto_increment: false +│ ├── Non-PK INTEGER column reports is_auto_increment: false +│ └── Composite PK does not report auto-increment +├── json_crud.test.rs +│ ├── Insert JSON object stores as TEXT +│ ├── Insert JSON array stores as TEXT +│ ├── Insert nested JSON preserves structure +│ ├── Update cell with JSON object succeeds +│ ├── Round-trip: insert JSON → select → compare +│ └── json_extract() works on inserted values +├── character_max_length.test.rs +│ ├── VARCHAR(255) → 255 +│ ├── CHAR(10) → 10 +│ ├── NVARCHAR(100) → 100 +│ ├── TEXT → None +│ ├── INTEGER → None +│ └── VARCHAR (no parens) → None +├── pragma_settings.test.rs +│ ├── journal_mode=WAL applied on connect +│ ├── foreign_keys=ON applied on connect +│ ├── Settings from saved connection respected +│ └── Invalid PRAGMA value handled gracefully +└── table_recreation.test.rs + ├── Basic recreation (change PK column) + │ ├── Data fully preserved after recreation + │ ├── Row count matches before and after + │ └── New PK constraint is enforced + ├── Dependent object rebuild + │ ├── Indexes recreated correctly + │ ├── Triggers recreated and functional + │ └── Views still resolve after rename + ├── Rollback safety + │ ├── Invalid new schema → rolls back, original intact + │ ├── Data copy failure → rolls back, original intact + │ ├── Row count mismatch → rolls back, original intact + │ └── Simulated disk error → original survives + ├── Edge cases + │ ├── Table with no indexes or triggers + │ ├── Table with composite PK + │ ├── Table with self-referencing FK + │ ├── Table with columns containing special characters + │ ├── Empty table (zero rows) + │ ├── Large table (10K+ rows) — verify performance + │ └── Table with BLOB data preserved + └── Concurrent access + ├── Read during recreation blocked by IMMEDIATE lock + └── Write during recreation blocked by IMMEDIATE lock +``` + +### Integration Tests + +- Create table with INTEGER PRIMARY KEY → insert row without specifying PK → verify auto-generated +- Create table with FK → insert violating row with `foreign_keys=ON` → verify error +- Create table with FK → insert violating row with `foreign_keys=OFF` → verify success +- Insert JSON object → SELECT → verify round-trip fidelity +- Table recreation: change PK → verify all data, indexes, triggers preserved +- Table recreation: cancel dialog → verify nothing changed + +--- + +## Open Questions + +1. **`foreign_keys=ON` default** — Should this be ON by default for all SQLite + connections, or only new ones? Existing users may have data that violates + constraints. Proposed: ON for new connections, preserve existing config for + saved connections. + +2. **WAL mode default** — Should new SQLite connections default to WAL mode? + WAL provides better concurrency but creates additional files (`-wal`, `-shm`) + alongside the database. Desktop app context may favor WAL; CLI/embedded may not. + +3. **Type list scope** — How exhaustive should the type picker be? SQLite accepts + literally any string as a type name. Should we list only common types, or + include obscure but valid ones? + +4. **AUTOINCREMENT vs ROWID semantics** — Should the UI distinguish between + `INTEGER PRIMARY KEY` (auto-assigns but can reuse deleted IDs) and + `INTEGER PRIMARY KEY AUTOINCREMENT` (strictly monotonic, never reuses)? + Both are "auto increment" but with different guarantees. + +5. **Recreation engine scope** — Should the table-recreation engine be + implemented as a generic utility (reusable for PK changes, column type + changes, nullability, reorder, etc.) from the start? Or build it narrowly + for PK changes first and generalize later? A generic approach is more + effort upfront but avoids rework. + +6. **Recreation for column drops** — SQLite 3.35.0+ supports `ALTER TABLE + DROP COLUMN` natively. Should the recreation engine only be used for + older SQLite versions, or always (for consistency)? diff --git a/.github/workflows/pg-integration.yml b/.github/workflows/pg-integration.yml new file mode 100644 index 000000000..309edefea --- /dev/null +++ b/.github/workflows/pg-integration.yml @@ -0,0 +1,98 @@ +name: Integration Tests (PostgreSQL) + +concurrency: + group: pg-integration-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'src-tauri/**' + - 'plugins/postgres-plugin/**' + - '.github/workflows/pg-integration.yml' + - 'tests/fixtures/**' + pull_request: + branches: [main] + paths: + - 'src-tauri/**' + - 'plugins/postgres-plugin/**' + - '.github/workflows/pg-integration.yml' + - 'tests/fixtures/**' + +jobs: + test-postgres: + runs-on: ubuntu-24.04 + + 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: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Seed PostgreSQL databases + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends postgresql-client + bash tests/fixtures/seed_postgres.sh + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-tauri/target + plugins/postgres-plugin/target + key: ${{ runner.os }}-cargo-pg-${{ hashFiles('src-tauri/Cargo.lock', 'plugins/postgres-plugin/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo-pg- + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev build-essential libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev + + - name: Build PostgreSQL plugin + run: cargo build --release --manifest-path plugins/postgres-plugin/Cargo.toml + + - name: Run PostgreSQL plugin unit tests + run: cargo test --manifest-path plugins/postgres-plugin/Cargo.toml + + - name: Run PostgreSQL integration tests + working-directory: src-tauri + env: + RUST_TEST_THREADS: "1" + REGENERATE_GOLDEN: "1" + run: cargo test --test postgres_integration -- --include-ignored + + - name: Run parity tests (plugin vs builtin) + working-directory: src-tauri + continue-on-error: true + env: + RUST_TEST_THREADS: "1" + POSTGRES_PLUGIN_BIN: ${{ github.workspace }}/plugins/postgres-plugin/target/release/postgresql-plugin + run: cargo test --test postgres_integration parity -- --include-ignored + + - name: Upload golden files + if: always() + uses: actions/upload-artifact@v4 + with: + name: golden-files + path: src-tauri/tests/postgres_integration/golden/ diff --git a/plugins/postgres-plugin/.gitignore b/plugins/postgres-plugin/.gitignore new file mode 100644 index 000000000..b83d22266 --- /dev/null +++ b/plugins/postgres-plugin/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/plugins/postgres-plugin/.tabularium b/plugins/postgres-plugin/.tabularium new file mode 100644 index 000000000..17e1bee41 --- /dev/null +++ b/plugins/postgres-plugin/.tabularium @@ -0,0 +1,70 @@ +{ + "$schema": "https://registry.tabularis.dev/manifest.schema.json?kind=driver", + "id": "postgres-plugin", + "name": "postgres-plugin", + "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/plugins/postgres-plugin/Cargo.lock b/plugins/postgres-plugin/Cargo.lock new file mode 100644 index 000000000..c3d9cc140 --- /dev/null +++ b/plugins/postgres-plugin/Cargo.lock @@ -0,0 +1,1997 @@ +# 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-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-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/plugins/postgres-plugin/Cargo.toml b/plugins/postgres-plugin/Cargo.toml new file mode 100644 index 000000000..3b7da3506 --- /dev/null +++ b/plugins/postgres-plugin/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "postgresql-plugin" +version = "0.1.0" +edition = "2021" +description = "PostgreSQL plugin driver for Tabularis" +publish = false + +[[bin]] +name = "postgresql-plugin" +path = "src/main.rs" + +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std"] } +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" +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/plugins/postgres-plugin/README.md b/plugins/postgres-plugin/README.md new file mode 100644 index 000000000..719c685c9 --- /dev/null +++ b/plugins/postgres-plugin/README.md @@ -0,0 +1,25 @@ +# PostgreSQL Plugin for Tabularis + +Standalone PostgreSQL driver implemented as a JSON-RPC plugin for Tabularis. + +This is an in-tree development crate for the PostgreSQL plugin migration +(Issue #16). It communicates with the Tabularis host over stdin/stdout using +the JSON-RPC 2.0 protocol. + +## Building + +```bash +cargo build --manifest-path plugins/postgres-plugin/Cargo.toml +``` + +## Testing + +The plugin is tested via the parity harness in +`src-tauri/tests/postgres_integration/`. Set `POSTGRES_PLUGIN_BIN` to point +at the compiled binary to activate dual-driver parity testing: + +```bash +cargo build --release --manifest-path plugins/postgres-plugin/Cargo.toml +POSTGRES_PLUGIN_BIN=plugins/postgres-plugin/target/release/postgresql-plugin \ + cargo test --test postgres_integration -- --include-ignored --test-threads=1 +``` diff --git a/plugins/postgres-plugin/src/binding.rs b/plugins/postgres-plugin/src/binding.rs new file mode 100644 index 000000000..96be3530a --- /dev/null +++ b/plugins/postgres-plugin/src/binding.rs @@ -0,0 +1,387 @@ +//! 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.splitn(2, ':').nth(1)?; + let base64_data = after_size.splitn(2, ':').nth(1)?; + 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().map_or(true, |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().map_or(true, |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/plugins/postgres-plugin/src/binding_tests.rs b/plugins/postgres-plugin/src/binding_tests.rs new file mode 100644 index 000000000..278467de4 --- /dev/null +++ b/plugins/postgres-plugin/src/binding_tests.rs @@ -0,0 +1,313 @@ +//! 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/plugins/postgres-plugin/src/client.rs b/plugins/postgres-plugin/src/client.rs new file mode 100644 index 000000000..58d7e08d6 --- /dev/null +++ b/plugins/postgres-plugin/src/client.rs @@ -0,0 +1,291 @@ +//! 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::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)?; + 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)?; + 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)?; + 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)?; + 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)?; + 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 fn build_pool_pub(params: &ConnectionParams) -> Result { + get_or_create_pool(params) +} + +/// Identifies a connection target for pool-cache purposes. +/// Matches on host:port:database:user — 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(""), + ) +} + +/// 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. +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)?; + 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()) +} + +/// Build a deadpool-postgres pool for the given connection parameters. +fn build_pool(params: &ConnectionParams) -> Result { + let mut cfg = Config::new(); + 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, + }); + + if needs_tls(params) { + let tls_config = build_tls_connector()?; + cfg.create_pool(Some(Runtime::Tokio1), MakeRustlsConnect::new(tls_config)) + .map_err(|e| format!("Pool creation failed (TLS): {e}")) + } else { + cfg.create_pool(Some(Runtime::Tokio1), NoTls) + .map_err(|e| format!("Pool creation failed: {e}")) + } +} + +/// 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 using the platform certificate verifier. +fn build_tls_connector() -> Result { + use rustls_platform_verifier::BuilderVerifierExt; + + 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) +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod client_tests; + diff --git a/plugins/postgres-plugin/src/client_tests.rs b/plugins/postgres-plugin/src/client_tests.rs new file mode 100644 index 000000000..1dbccf6b0 --- /dev/null +++ b/plugins/postgres-plugin/src/client_tests.rs @@ -0,0 +1,79 @@ +//! 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::{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, + } +} + +#[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); +} + +#[test] +fn get_or_create_pool_reuses_cached_entry_for_identical_params() { + // deadpool's Pool::new is lazy (no connection attempt at creation + // time), 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).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).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" + ); +} diff --git a/plugins/postgres-plugin/src/error.rs b/plugins/postgres-plugin/src/error.rs new file mode 100644 index 000000000..8c375a1f9 --- /dev/null +++ b/plugins/postgres-plugin/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/plugins/postgres-plugin/src/extract.rs b/plugins/postgres-plugin/src/extract.rs new file mode 100644 index 000000000..96ea28cb9 --- /dev/null +++ b/plugins/postgres-plugin/src/extract.rs @@ -0,0 +1,550 @@ +//! 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, |v| JsonValue::Bool(v)), + ref t if *t == Type::INT2 => try_extract::(row, index, |v| JsonValue::from(v)), + ref t if *t == Type::INT4 => try_extract::(row, index, |v| JsonValue::from(v)), + ref t if *t == Type::INT8 => try_extract::(row, index, |v| i64_to_json(v)), + 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, |v| JsonValue::from(v)), + 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/plugins/postgres-plugin/src/handlers/blob.rs b/plugins/postgres-plugin/src/handlers/blob.rs new file mode 100644 index 000000000..96e614f76 --- /dev/null +++ b/plugins/postgres-plugin/src/handlers/blob.rs @@ -0,0 +1,96 @@ +//! 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(); + + 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, &e.to_string()), + }, + 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()) +} + +/// 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/plugins/postgres-plugin/src/handlers/blob_tests.rs b/plugins/postgres-plugin/src/handlers/blob_tests.rs new file mode 100644 index 000000000..29bde7662 --- /dev/null +++ b/plugins/postgres-plugin/src/handlers/blob_tests.rs @@ -0,0 +1,28 @@ +//! 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; + +#[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:")); +} diff --git a/plugins/postgres-plugin/src/handlers/connection.rs b/plugins/postgres-plugin/src/handlers/connection.rs new file mode 100644 index 000000000..b5d23854f --- /dev/null +++ b/plugins/postgres-plugin/src/handlers/connection.rs @@ -0,0 +1,35 @@ +//! Connection lifecycle handlers: initialize, ping, test_connection, shutdown. + +use serde_json::Value; + +use crate::rpc::{ok_response, error_response}; +use crate::models::{ConnectionParams, inner_params}; +use crate::client; + +/// 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/plugins/postgres-plugin/src/handlers/crud.rs b/plugins/postgres-plugin/src/handlers/crud.rs new file mode 100644 index 000000000..b8d4c4576 --- /dev/null +++ b/plugins/postgres-plugin/src/handlers/crud.rs @@ -0,0 +1,188 @@ +//! 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/plugins/postgres-plugin/src/handlers/ddl.rs b/plugins/postgres-plugin/src/handlers/ddl.rs new file mode 100644 index 000000000..928c3b362 --- /dev/null +++ b/plugins/postgres-plugin/src/handlers/ddl.rs @@ -0,0 +1,327 @@ +//! 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(", ") + ) +} + +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/plugins/postgres-plugin/src/handlers/ddl_tests.rs b/plugins/postgres-plugin/src/handlers/ddl_tests.rs new file mode 100644 index 000000000..3d26c8d2e --- /dev/null +++ b/plugins/postgres-plugin/src/handlers/ddl_tests.rs @@ -0,0 +1,282 @@ +//! 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/plugins/postgres-plugin/src/handlers/metadata.rs b/plugins/postgres-plugin/src/handlers/metadata.rs new file mode 100644 index 000000000..38aae9044 --- /dev/null +++ b/plugins/postgres-plugin/src/handlers/metadata.rs @@ -0,0 +1,768 @@ +//! Schema discovery and metadata handlers. + +use serde_json::{json, Value}; + +use crate::client; +use crate::models::{ConnectionParams, inner_params}; +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().map_or(false, |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/plugins/postgres-plugin/src/handlers/mod.rs b/plugins/postgres-plugin/src/handlers/mod.rs new file mode 100644 index 000000000..74e7401c1 --- /dev/null +++ b/plugins/postgres-plugin/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/plugins/postgres-plugin/src/handlers/query.rs b/plugins/postgres-plugin/src/handlers/query.rs new file mode 100644 index 000000000..030ff4dc8 --- /dev/null +++ b/plugins/postgres-plugin/src/handlers/query.rs @@ -0,0 +1,259 @@ +//! 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::{ConnectionParams, inner_params}; +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) { + 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)?; + 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/plugins/postgres-plugin/src/main.rs b/plugins/postgres-plugin/src/main.rs new file mode 100644 index 000000000..c77e61db0 --- /dev/null +++ b/plugins/postgres-plugin/src/main.rs @@ -0,0 +1,55 @@ +//! 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. +#![allow(dead_code)] + +use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader}; + +mod binding; +#[cfg(test)] +mod binding_tests; +mod client; +mod error; +mod extract; +mod handlers; +mod models; +mod rpc; +mod utils; + +#[tokio::main] +async fn main() { + let stdin = io::stdin(); + let stdout = io::stdout(); + let mut reader = BufReader::new(stdin); + let mut out = stdout; + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let response = rpc::handle_line(trimmed).await; + let mut 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}}" + ), + }; + body.push('\n'); + if out.write_all(body.as_bytes()).await.is_err() { + break; + } + let _ = out.flush().await; + } +} diff --git a/plugins/postgres-plugin/src/models.rs b/plugins/postgres-plugin/src/models.rs new file mode 100644 index 000000000..95cf4972c --- /dev/null +++ b/plugins/postgres-plugin/src/models.rs @@ -0,0 +1,70 @@ +//! 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, +} + +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"), + } + } +} + +/// 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/plugins/postgres-plugin/src/rpc.rs b/plugins/postgres-plugin/src/rpc.rs new file mode 100644 index 000000000..e30ae0bd9 --- /dev/null +++ b/plugins/postgres-plugin/src/rpc.rs @@ -0,0 +1,109 @@ +//! 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/plugins/postgres-plugin/src/utils/identifiers.rs b/plugins/postgres-plugin/src/utils/identifiers.rs new file mode 100644 index 000000000..c9e6cc7ae --- /dev/null +++ b/plugins/postgres-plugin/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/plugins/postgres-plugin/src/utils/mod.rs b/plugins/postgres-plugin/src/utils/mod.rs new file mode 100644 index 000000000..b7e27d737 --- /dev/null +++ b/plugins/postgres-plugin/src/utils/mod.rs @@ -0,0 +1,6 @@ +//! Utility modules. + +pub mod identifiers; +pub mod pagination; +#[cfg(test)] +mod pagination_tests; diff --git a/plugins/postgres-plugin/src/utils/pagination.rs b/plugins/postgres-plugin/src/utils/pagination.rs new file mode 100644 index 000000000..145340c74 --- /dev/null +++ b/plugins/postgres-plugin/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/plugins/postgres-plugin/src/utils/pagination_tests.rs b/plugins/postgres-plugin/src/utils/pagination_tests.rs new file mode 100644 index 000000000..78498d319 --- /dev/null +++ b/plugins/postgres-plugin/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/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4d45f490d..4fe7f19ce 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6459,7 +6459,7 @@ dependencies = [ [[package]] name = "tabularis" -version = "0.17.0" +version = "0.18.0" dependencies = [ "aes-gcm", "argon2", diff --git a/src-tauri/tests/integration_tests.rs b/src-tauri/tests/integration_tests.rs index 29396d25c..42fd5cf42 100644 --- a/src-tauri/tests/integration_tests.rs +++ b/src-tauri/tests/integration_tests.rs @@ -96,7 +96,7 @@ async fn test_mysql_integration_flow() { } #[tokio::test] -#[ignore] // Ignored by default +#[ignore] // Run via pg-integration.yml CI or --include-ignored async fn test_postgres_integration_flow() { let params = get_postgres_params(); @@ -338,7 +338,7 @@ async fn test_mysql_batch_preserves_transaction_atomicity() { /// subsequent `SELECT` in the same batch — i.e. all statements observe /// the same session. #[tokio::test] -#[ignore] +#[ignore] // Run via pg-integration.yml CI or --include-ignored async fn test_postgres_batch_preserves_temp_table_and_transaction() { let params = get_postgres_params(); if !wait_for_postgres(¶ms).await { @@ -468,7 +468,7 @@ async fn test_mysql_affected_rows_reported_correctly() { } #[tokio::test] -#[ignore] +#[ignore] // Run via pg-integration.yml CI or --include-ignored async fn test_postgres_affected_rows_reported_correctly() { let params = get_postgres_params(); if !wait_for_postgres(¶ms).await { @@ -602,7 +602,7 @@ async fn test_concurrent_cancel_aborts_all_in_flight_queries() { // --------------------------------------------------------------------------- #[tokio::test] -#[ignore] +#[ignore] // Run via pg-integration.yml CI or --include-ignored async fn test_postgres_foreign_keys_via_pg_catalog() { let params = get_postgres_params(); if !wait_for_postgres(¶ms).await { diff --git a/src-tauri/tests/postgres_integration/blob.rs b/src-tauri/tests/postgres_integration/blob.rs new file mode 100644 index 000000000..15ee03bc1 --- /dev/null +++ b/src-tauri/tests/postgres_integration/blob.rs @@ -0,0 +1,115 @@ +//! BLOB (bytea) handling tests. + +use std::collections::HashMap; +use serde_json::json; +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_insert_and_query_bytea() { + require_pg!(); + let params = pg_params(); + + // The driver expects blob data in the wire format: "BLOB:::" + // 4 bytes (0xCA 0xFE 0xBA 0xBE) encoded as base64 = "yv66vg==" + let blob_wire = "BLOB:4:application/octet-stream:yv66vg=="; + + let mut data = HashMap::new(); + data.insert("col_bytea".to_string(), json!(blob_wire)); + data.insert("col_text".to_string(), json!("blob_test")); + + let affected = postgres::insert_record(¶ms, "all_types", data, "test_schema", 10_000_000) + .await + .expect("insert bytea should succeed"); + + assert_eq!(affected, 1); + + // Verify the data comes back + let result = postgres::execute_query( + ¶ms, + "SELECT col_bytea FROM test_schema.all_types WHERE col_text = 'blob_test'", + None, + 1, + None, + ) + .await + .expect("query bytea should succeed"); + + assert_eq!(result.rows.len(), 1); + assert!(!result.rows[0][0].is_null(), "bytea should not be null"); + + // Clean up + let _ = postgres::execute_query( + ¶ms, + "DELETE FROM test_schema.all_types WHERE col_text = 'blob_test'", + None, + 1, + None, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn test_save_blob_to_file() { + require_pg!(); + let params = pg_params(); + + // Use the seeded row (id=1) which has col_bytea = '\xDEADBEEF' + let mut pk_map = HashMap::new(); + pk_map.insert("id".to_string(), json!(1)); + + let tmp_path = std::env::temp_dir().join("tabularis_blob_test.bin"); + let path_str = tmp_path.to_str().unwrap(); + + let result = postgres::save_blob_column_to_file( + ¶ms, + "all_types", + "col_bytea", + &pk_map, + "test_schema", + path_str, + ) + .await; + + assert!(result.is_ok(), "save_blob_to_file should succeed: {:?}", result.err()); + + // Verify file was written and has content + let metadata = std::fs::metadata(&tmp_path); + assert!(metadata.is_ok(), "File should exist"); + assert!(metadata.unwrap().len() > 0, "File should have content"); + + // Clean up + let _ = std::fs::remove_file(&tmp_path); +} + +#[tokio::test] +#[ignore] +async fn test_fetch_blob_as_data_url() { + require_pg!(); + let params = pg_params(); + + // Use the seeded row (id=1) which has col_bytea = '\xDEADBEEF' + let mut pk_map = HashMap::new(); + pk_map.insert("id".to_string(), json!(1)); + + let result = postgres::fetch_blob_column_as_data_url( + ¶ms, + "all_types", + "col_bytea", + &pk_map, + "test_schema", + ) + .await; + + assert!(result.is_ok(), "fetch_blob_as_data_url should succeed: {:?}", result.err()); + + let data_url = result.unwrap(); + // Should be in BLOB wire format: "BLOB:::" + assert!( + data_url.starts_with("BLOB:") || data_url.starts_with("data:"), + "Should return BLOB wire format or data URL, got: {}", + &data_url[..data_url.len().min(50)] + ); +} diff --git a/src-tauri/tests/postgres_integration/column_metadata.rs b/src-tauri/tests/postgres_integration/column_metadata.rs new file mode 100644 index 000000000..5be00f2d3 --- /dev/null +++ b/src-tauri/tests/postgres_integration/column_metadata.rs @@ -0,0 +1,126 @@ +//! Column metadata tests: get_columns for various table types. + +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_get_columns_all_types_count() { + require_pg!(); + let params = pg_params(); + + let columns = postgres::get_columns(¶ms, "all_types", "test_schema") + .await + .expect("get_columns should succeed"); + + // all_types has 27 columns (id + 26 typed columns) + assert_eq!(columns.len(), 27, "Expected 27 columns in all_types"); +} + +#[tokio::test] +#[ignore] +async fn test_get_columns_pk_detection() { + require_pg!(); + let params = pg_params(); + + let columns = postgres::get_columns(¶ms, "all_types", "test_schema") + .await + .expect("get_columns should succeed"); + + let id_col = columns.iter().find(|c| c.name == "id").expect("id column should exist"); + assert!(id_col.is_pk, "id should be primary key"); + assert!(id_col.is_auto_increment, "SERIAL id should be auto_increment"); + assert_eq!(id_col.data_type, "integer", "SERIAL resolves to integer"); + + // Non-PK columns should not be marked as PK + let text_col = columns.iter().find(|c| c.name == "col_text").unwrap(); + assert!(!text_col.is_pk); + assert!(!text_col.is_auto_increment); +} + +#[tokio::test] +#[ignore] +async fn test_get_columns_nullable_detection() { + require_pg!(); + let params = pg_params(); + + let columns = postgres::get_columns(¶ms, "all_types", "test_schema") + .await + .expect("get_columns should succeed"); + + // id (SERIAL PRIMARY KEY) is NOT NULL + let id_col = columns.iter().find(|c| c.name == "id").unwrap(); + assert!(!id_col.is_nullable, "PK should not be nullable"); + + // col_text has no NOT NULL constraint + let text_col = columns.iter().find(|c| c.name == "col_text").unwrap(); + assert!(text_col.is_nullable, "col_text should be nullable"); +} + +#[tokio::test] +#[ignore] +async fn test_get_columns_type_detection() { + require_pg!(); + let params = pg_params(); + + let columns = postgres::get_columns(¶ms, "all_types", "test_schema") + .await + .expect("get_columns should succeed"); + + let find = |name: &str| columns.iter().find(|c| c.name == name).unwrap(); + + assert_eq!(find("col_text").data_type, "text"); + assert_eq!(find("col_int").data_type, "integer"); + assert_eq!(find("col_bigint").data_type, "bigint"); + assert_eq!(find("col_bool").data_type, "boolean"); + assert_eq!(find("col_uuid").data_type, "uuid"); + assert_eq!(find("col_jsonb").data_type, "jsonb"); + assert_eq!(find("col_bytea").data_type, "bytea"); + assert_eq!(find("col_timestamptz").data_type, "timestamp with time zone"); +} + +#[tokio::test] +#[ignore] +async fn test_get_columns_character_max_length() { + require_pg!(); + let params = pg_params(); + + let columns = postgres::get_columns(¶ms, "all_types", "test_schema") + .await + .expect("get_columns should succeed"); + + let varchar_col = columns.iter().find(|c| c.name == "col_varchar").unwrap(); + // KNOWN BEHAVIOR: The PG driver does NOT populate character_maximum_length. + // This is a driver limitation, not a PostgreSQL limitation (PG does expose this + // in information_schema). The plugin MUST match this exact behavior (return None). + // If the built-in driver is fixed later, this test will correctly fail — prompting + // an update to both the test and the plugin. + assert_eq!( + varchar_col.character_maximum_length, None, + "Built-in PG driver returns None for character_maximum_length (known limitation)" + ); + + let text_col = columns.iter().find(|c| c.name == "col_text").unwrap(); + assert_eq!(text_col.character_maximum_length, None, "TEXT has no max length"); +} + +#[tokio::test] +#[ignore] +async fn test_get_columns_enum_type() { + require_pg!(); + let params = pg_params(); + + let columns = postgres::get_columns(¶ms, "with_enum", "test_schema") + .await + .expect("get_columns should succeed"); + + let mood_col = columns.iter().find(|c| c.name == "current_mood").unwrap(); + // The PG driver resolves enum types to "enum('val1','val2',...)" format + assert!( + mood_col.data_type.contains("mood") + || mood_col.data_type.starts_with("enum(") + || mood_col.data_type == "USER-DEFINED", + "Enum column should have type containing 'mood', start with 'enum(', or be 'USER-DEFINED', got: {}", + mood_col.data_type + ); +} diff --git a/src-tauri/tests/postgres_integration/crud.rs b/src-tauri/tests/postgres_integration/crud.rs new file mode 100644 index 000000000..c07f07d1f --- /dev/null +++ b/src-tauri/tests/postgres_integration/crud.rs @@ -0,0 +1,298 @@ +//! CRUD operation tests (insert, update, delete). + +use std::collections::HashMap; +use serde_json::json; +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_insert_basic_types() { + require_pg!(); + let params = pg_params(); + + let mut data = HashMap::new(); + data.insert("name".to_string(), json!("insert_test")); + data.insert("value".to_string(), json!(42)); + + let affected = postgres::insert_record(¶ms, "crud_scratch", data, "test_schema", 10_000_000) + .await + .expect("insert_record should succeed"); + + assert_eq!(affected, 1); + + // Cleanup + let _ = postgres::execute_query( + ¶ms, + "DELETE FROM test_schema.crud_scratch WHERE name = 'insert_test'", + None, 1, None, + ).await; +} + +#[tokio::test] +#[ignore] +async fn test_insert_null_values() { + require_pg!(); + let params = pg_params(); + + let mut data = HashMap::new(); + data.insert("name".to_string(), json!(null)); + data.insert("value".to_string(), json!(null)); + + let affected = postgres::insert_record(¶ms, "crud_scratch", data, "test_schema", 10_000_000) + .await + .expect("insert_record with nulls should succeed"); + + assert_eq!(affected, 1); + + // Cleanup — delete rows with null name (our insert) + let _ = postgres::execute_query( + ¶ms, + "DELETE FROM test_schema.crud_scratch WHERE name IS NULL", + None, 1, None, + ).await; +} + +#[tokio::test] +#[ignore] +async fn test_update_with_single_pk() { + require_pg!(); + let params = pg_params(); + + // Insert a row to update + let mut insert_data = HashMap::new(); + insert_data.insert("name".to_string(), json!("to_update")); + insert_data.insert("value".to_string(), json!(1)); + postgres::insert_record(¶ms, "crud_scratch", insert_data, "test_schema", 10_000_000) + .await + .expect("insert for update test"); + + // Find the row's ID + let result = postgres::execute_query( + ¶ms, + "SELECT id FROM test_schema.crud_scratch WHERE name = 'to_update' ORDER BY id DESC LIMIT 1", + None, + 1, + None, + ) + .await + .expect("find row"); + let row_id = result.rows[0][0].as_i64().unwrap(); + + // Update it (update_record updates one column at a time) + let mut pk_map = HashMap::new(); + pk_map.insert("id".to_string(), json!(row_id)); + + let affected = postgres::update_record( + ¶ms, + "crud_scratch", + &pk_map, + "value", + json!(999), + "test_schema", + 10_000_000, + ) + .await + .expect("update_record should succeed"); + + assert_eq!(affected, 1); + + // Verify the update took effect + let verify = postgres::execute_query( + ¶ms, + &format!( + "SELECT value FROM test_schema.crud_scratch WHERE id = {}", + row_id + ), + None, + 1, + None, + ) + .await + .unwrap(); + assert_eq!(verify.rows[0][0], json!(999)); +} + +#[tokio::test] +#[ignore] +async fn test_update_composite_pk() { + require_pg!(); + let params = pg_params(); + + // order_items has composite PK (order_id, item_no) + let mut pk_map = HashMap::new(); + pk_map.insert("order_id".to_string(), json!(1)); + pk_map.insert("item_no".to_string(), json!(1)); + + let affected = postgres::update_record( + ¶ms, + "order_items", + &pk_map, + "product", + json!("Updated Widget"), + "test_schema", + 10_000_000, + ) + .await + .expect("update_record with composite PK should succeed"); + + assert_eq!(affected, 1); + + // Restore original value + let _ = postgres::update_record( + ¶ms, + "order_items", + &pk_map, + "product", + json!("Widget"), + "test_schema", + 10_000_000, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn test_delete_single_pk() { + require_pg!(); + let params = pg_params(); + + // Insert a row to delete + let mut data = HashMap::new(); + data.insert("name".to_string(), json!("to_delete")); + data.insert("value".to_string(), json!(0)); + postgres::insert_record(¶ms, "crud_scratch", data, "test_schema", 10_000_000) + .await + .expect("insert for delete test"); + + // Find the row + let result = postgres::execute_query( + ¶ms, + "SELECT id FROM test_schema.crud_scratch WHERE name = 'to_delete' ORDER BY id DESC LIMIT 1", + None, + 1, + None, + ) + .await + .unwrap(); + let row_id = result.rows[0][0].as_i64().unwrap(); + + // Delete it + let mut pk_map = HashMap::new(); + pk_map.insert("id".to_string(), json!(row_id)); + + let affected = postgres::delete_record(¶ms, "crud_scratch", &pk_map, "test_schema") + .await + .expect("delete_record should succeed"); + + assert_eq!(affected, 1); + + // Verify gone + let verify = postgres::execute_query( + ¶ms, + &format!( + "SELECT COUNT(*) FROM test_schema.crud_scratch WHERE id = {}", + row_id + ), + None, + 1, + None, + ) + .await + .unwrap(); + assert_eq!(verify.rows[0][0], json!(0_i64)); +} + +#[tokio::test] +#[ignore] +async fn test_insert_json_object() { + require_pg!(); + let params = pg_params(); + + let mut data = HashMap::new(); + data.insert("col_jsonb".to_string(), json!({"nested": {"key": "value"}, "arr": [1, 2, 3]})); + data.insert("col_text".to_string(), json!("json_test")); + + let affected = postgres::insert_record(¶ms, "all_types", data, "test_schema", 10_000_000) + .await + .expect("insert JSON object should succeed"); + + assert_eq!(affected, 1); + + // Clean up + let _ = postgres::execute_query( + ¶ms, + "DELETE FROM test_schema.all_types WHERE col_text = 'json_test'", + None, + 1, + None, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn test_insert_array_value() { + require_pg!(); + let params = pg_params(); + + let mut data = HashMap::new(); + data.insert("col_int_array".to_string(), json!([10, 20, 30])); + data.insert("col_text".to_string(), json!("array_test")); + + let affected = postgres::insert_record(¶ms, "all_types", data, "test_schema", 10_000_000) + .await + .expect("insert array value should succeed"); + + assert_eq!(affected, 1); + + // Verify round-trip + let result = postgres::execute_query( + ¶ms, + "SELECT col_int_array FROM test_schema.all_types WHERE col_text = 'array_test'", + None, + 1, + None, + ) + .await + .unwrap(); + + assert_eq!(result.rows.len(), 1); + assert!(result.rows[0][0].is_array(), "Expected array, got: {:?}", result.rows[0][0]); + + // Clean up + let _ = postgres::execute_query( + ¶ms, + "DELETE FROM test_schema.all_types WHERE col_text = 'array_test'", + None, + 1, + None, + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn test_insert_enum_value() { + require_pg!(); + let params = pg_params(); + + let mut data = HashMap::new(); + data.insert("current_mood".to_string(), json!("sad")); + + let affected = postgres::insert_record(¶ms, "with_enum", data, "test_schema", 10_000_000) + .await + .expect("insert enum value should succeed"); + + assert_eq!(affected, 1); + + // Clean up the extra row + let _ = postgres::execute_query( + ¶ms, + "DELETE FROM test_schema.with_enum WHERE id > 1", + None, + 1, + None, + ) + .await; +} diff --git a/src-tauri/tests/postgres_integration/ddl_generation.rs b/src-tauri/tests/postgres_integration/ddl_generation.rs new file mode 100644 index 000000000..580b1820f --- /dev/null +++ b/src-tauri/tests/postgres_integration/ddl_generation.rs @@ -0,0 +1,237 @@ +//! DDL generation tests. + +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::drivers::postgres::PostgresDriver; +use tabularis_lib::models::ColumnDefinition; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_get_create_table_sql() { + require_pg!(); + let params = pg_params(); + + 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, + }, + ColumnDefinition { + name: "name".to_string(), + data_type: "TEXT".to_string(), + is_nullable: false, + is_pk: false, + is_auto_increment: false, + default_value: None, + }, + 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_statements = PostgresDriver::new() + .get_create_table_sql("ddl_test_table", columns, Some("test_schema")) + .await + .expect("get_create_table_sql should succeed"); + + assert!(!sql_statements.is_empty(), "Should return at least one SQL statement"); + let sql = sql_statements.join("; "); + let lower = sql.to_lowercase(); + + assert!(lower.contains("create table"), "Should contain CREATE TABLE"); + assert!(lower.contains("ddl_test_table"), "Should contain table name"); + assert!(lower.contains("serial") || lower.contains("generated"), "Should handle auto-increment"); + assert!(lower.contains("not null"), "Should contain NOT NULL for non-nullable columns"); + assert!(lower.contains("varchar(255)") || lower.contains("character varying(255)"), "Should preserve varchar type"); +} + +#[tokio::test] +#[ignore] +async fn test_get_add_column_sql() { + require_pg!(); + + let column = 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_statements = PostgresDriver::new() + .get_add_column_sql("all_types", column, Some("test_schema")) + .await + .expect("get_add_column_sql should succeed"); + + assert!(!sql_statements.is_empty()); + let sql = sql_statements.join("; "); + let lower = sql.to_lowercase(); + + assert!(lower.contains("alter table"), "Should contain ALTER TABLE"); + assert!(lower.contains("add column"), "Should contain ADD COLUMN"); + assert!(lower.contains("new_col"), "Should contain column name"); + assert!(lower.contains("integer"), "Should contain type"); +} + +#[tokio::test] +#[ignore] +async fn test_get_alter_column_rename() { + require_pg!(); + + let old_column = ColumnDefinition { + name: "old_name".to_string(), + data_type: "TEXT".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: None, + }; + + let new_column = ColumnDefinition { + name: "new_name".to_string(), + data_type: "TEXT".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: None, + }; + + let sql_statements = PostgresDriver::new() + .get_alter_column_sql("all_types", old_column, new_column, Some("test_schema")) + .await + .expect("get_alter_column_sql for rename should succeed"); + + assert!(!sql_statements.is_empty()); + let sql = sql_statements.join("; "); + let lower = sql.to_lowercase(); + + assert!(lower.contains("rename column") || lower.contains("alter column"), "Should rename"); + assert!(lower.contains("old_name"), "Should reference old name"); + assert!(lower.contains("new_name"), "Should reference new name"); +} + +#[tokio::test] +#[ignore] +async fn test_get_alter_column_type_change() { + require_pg!(); + + let old_column = ColumnDefinition { + name: "col_text".to_string(), + data_type: "TEXT".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: None, + }; + + let new_column = ColumnDefinition { + name: "col_text".to_string(), + data_type: "VARCHAR(500)".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: None, + }; + + let sql_statements = PostgresDriver::new() + .get_alter_column_sql("all_types", old_column, new_column, Some("test_schema")) + .await + .expect("get_alter_column_sql for type change should succeed"); + + assert!(!sql_statements.is_empty()); + let sql = sql_statements.join("; "); + let lower = sql.to_lowercase(); + + assert!( + lower.contains("type") || lower.contains("alter column"), + "Should change type, got: {}", + sql + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_create_index_sql() { + require_pg!(); + + let sql_statements = PostgresDriver::new() + .get_create_index_sql( + "all_types", + "idx_ddl_test", + vec!["col_text".to_string(), "col_int".to_string()], + false, // not unique + Some("test_schema"), + ) + .await + .expect("get_create_index_sql should succeed"); + + assert!(!sql_statements.is_empty()); + let sql = sql_statements.join("; "); + let lower = sql.to_lowercase(); + + assert!(lower.contains("create index"), "Should contain CREATE INDEX"); + assert!(lower.contains("idx_ddl_test"), "Should contain index name"); + assert!(lower.contains("col_text"), "Should contain first column"); + assert!(lower.contains("col_int"), "Should contain second column"); +} + +#[tokio::test] +#[ignore] +async fn test_get_create_index_sql_unique() { + require_pg!(); + + let sql_statements = PostgresDriver::new() + .get_create_index_sql( + "all_types", + "idx_ddl_unique_test", + vec!["col_varchar".to_string()], + true, // unique + Some("test_schema"), + ) + .await + .expect("get_create_index_sql unique should succeed"); + + let sql = sql_statements.join("; "); + let lower = sql.to_lowercase(); + + assert!(lower.contains("create unique index"), "Should contain CREATE UNIQUE INDEX"); +} + +#[tokio::test] +#[ignore] +async fn test_get_create_foreign_key_sql() { + require_pg!(); + + let sql_statements = PostgresDriver::new() + .get_create_foreign_key_sql( + "crud_scratch", + "fk_ddl_test", + "value", + "all_types", + "id", + None, + None, + Some("test_schema"), + ) + .await + .expect("get_create_foreign_key_sql should succeed"); + + assert!(!sql_statements.is_empty()); + let sql = sql_statements.join("; "); + let lower = sql.to_lowercase(); + + assert!(lower.contains("alter table"), "Should contain ALTER TABLE"); + assert!(lower.contains("add constraint"), "Should contain ADD CONSTRAINT"); + assert!(lower.contains("foreign key"), "Should contain FOREIGN KEY"); + assert!(lower.contains("references"), "Should contain REFERENCES"); +} diff --git a/src-tauri/tests/postgres_integration/explain.rs b/src-tauri/tests/postgres_integration/explain.rs new file mode 100644 index 000000000..2f210d960 --- /dev/null +++ b/src-tauri/tests/postgres_integration/explain.rs @@ -0,0 +1,85 @@ +//! EXPLAIN query plan tests. + +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::drivers::postgres::PostgresDriver; +use tabularis_lib::models::ExplainQueryOutput; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_explain_simple_select() { + require_pg!(); + let params = pg_params(); + + let output = PostgresDriver::new() + .explain_query( + ¶ms, + "SELECT * FROM test_schema.all_types WHERE id = 1", + false, + Some("test_schema"), + ) + .await + .expect("explain_query should succeed"); + + match &output { + ExplainQueryOutput::Plan { plan } => { + assert!(!plan.is_null(), "Plan should not be null"); + } + ExplainQueryOutput::Raw { raw } => { + assert!(!raw.payload.is_empty(), "Raw output should have lines"); + } + } +} + +#[tokio::test] +#[ignore] +async fn test_explain_analyze() { + require_pg!(); + let params = pg_params(); + + let output = PostgresDriver::new() + .explain_query( + ¶ms, + "SELECT * FROM test_schema.all_types WHERE id = 1", + true, + Some("test_schema"), + ) + .await + .expect("explain_query with analyze should succeed"); + + match &output { + ExplainQueryOutput::Plan { plan } => { + assert!(!plan.is_null(), "ANALYZE plan should not be null"); + } + ExplainQueryOutput::Raw { raw } => { + assert!(!raw.payload.is_empty(), "ANALYZE raw output should have lines"); + } + } +} + +#[tokio::test] +#[ignore] +async fn test_explain_join_query() { + require_pg!(); + let params = pg_params(); + + let output = PostgresDriver::new() + .explain_query( + ¶ms, + "SELECT o.id, oi.product FROM test_schema.orders o \ + JOIN test_schema.order_items oi ON o.id = oi.order_id", + false, + Some("test_schema"), + ) + .await + .expect("explain JOIN should succeed"); + + match &output { + ExplainQueryOutput::Plan { plan } => { + assert!(!plan.is_null(), "JOIN plan should not be null"); + } + ExplainQueryOutput::Raw { raw } => { + assert!(!raw.payload.is_empty(), "JOIN raw output should have lines"); + } + } +} diff --git a/src-tauri/tests/postgres_integration/foreign_keys.rs b/src-tauri/tests/postgres_integration/foreign_keys.rs new file mode 100644 index 000000000..c3348c11c --- /dev/null +++ b/src-tauri/tests/postgres_integration/foreign_keys.rs @@ -0,0 +1,78 @@ +//! Foreign key introspection tests. + +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_get_foreign_keys_basic() { + require_pg!(); + let params = pg_params(); + + let fks = postgres::get_foreign_keys(¶ms, "orders", "test_schema") + .await + .expect("get_foreign_keys should succeed"); + + assert!(!fks.is_empty(), "orders table should have foreign keys"); + + let user_fk = fks.iter().find(|f| f.column_name == "user_id"); + assert!(user_fk.is_some(), "Expected FK on user_id column"); + let user_fk = user_fk.unwrap(); + assert_eq!(user_fk.ref_table, "all_types"); + assert_eq!(user_fk.ref_column, "id"); + assert_eq!( + user_fk.on_delete.as_deref(), + Some("CASCADE"), + "Expected ON DELETE CASCADE" + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_foreign_keys_composite_table() { + require_pg!(); + let params = pg_params(); + + let fks = postgres::get_foreign_keys(¶ms, "order_items", "test_schema") + .await + .expect("get_foreign_keys should succeed"); + + let order_fk = fks.iter().find(|f| f.column_name == "order_id"); + assert!(order_fk.is_some(), "Expected FK on order_id"); + let order_fk = order_fk.unwrap(); + assert_eq!(order_fk.ref_table, "orders"); + assert_eq!(order_fk.ref_column, "id"); + assert_eq!(order_fk.on_delete.as_deref(), Some("CASCADE")); +} + +#[tokio::test] +#[ignore] +async fn test_get_foreign_keys_cross_schema() { + require_pg!(); + let params = pg_params(); + + let fks = postgres::get_foreign_keys(¶ms, "with_cross_schema_fk", "test_schema") + .await + .expect("get_foreign_keys should succeed"); + + let lookup_fk = fks.iter().find(|f| f.column_name == "lookup_code"); + assert!(lookup_fk.is_some(), "Expected FK on lookup_code"); + let lookup_fk = lookup_fk.unwrap(); + assert_eq!(lookup_fk.ref_table, "lookup"); + assert_eq!(lookup_fk.ref_column, "code"); + // TODO: Once PR #402 merges and ForeignKey gains `ref_schema`, assert: + // assert_eq!(lookup_fk.ref_schema.as_deref(), Some("other_schema")); +} + +#[tokio::test] +#[ignore] +async fn test_get_foreign_keys_table_without_fks() { + require_pg!(); + let params = pg_params(); + + let fks = postgres::get_foreign_keys(¶ms, "crud_scratch", "test_schema") + .await + .expect("get_foreign_keys should succeed"); + + assert!(fks.is_empty(), "crud_scratch has no foreign keys"); +} diff --git a/src-tauri/tests/postgres_integration/golden.rs b/src-tauri/tests/postgres_integration/golden.rs new file mode 100644 index 000000000..20271a211 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden.rs @@ -0,0 +1,359 @@ +//! Golden file capture tests. +//! +//! These tests capture the exact output of every driver method and compare against +//! committed golden files. To regenerate: +//! +//! ```bash +//! REGENERATE_GOLDEN=1 cargo test --test postgres_integration golden -- --include-ignored --test-threads=1 +//! ``` + +use tabularis_lib::drivers::postgres; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::drivers::postgres::PostgresDriver; +use crate::helpers::{pg_params, pg_params_secondary}; +use crate::golden_utils::{write_golden, assert_golden}; + +#[tokio::test] +#[ignore] +async fn golden_get_schemas() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_schemas(¶ms).await.expect("get_schemas"); + write_golden("get_schemas.json", &result); + assert_golden("get_schemas.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_databases() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_databases(¶ms).await.expect("get_databases"); + write_golden("get_databases.json", &result); + assert_golden("get_databases.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_tables() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_tables(¶ms, "test_schema").await.expect("get_tables"); + write_golden("get_tables.json", &result); + assert_golden("get_tables.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_columns_all_types() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_columns(¶ms, "all_types", "test_schema") + .await + .expect("get_columns"); + write_golden("get_columns_all_types.json", &result); + assert_golden("get_columns_all_types.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_columns_with_enum() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_columns(¶ms, "with_enum", "test_schema") + .await + .expect("get_columns"); + write_golden("get_columns_with_enum.json", &result); + assert_golden("get_columns_with_enum.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_indexes() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_indexes(¶ms, "all_types", "test_schema") + .await + .expect("get_indexes"); + write_golden("get_indexes_all_types.json", &result); + assert_golden("get_indexes_all_types.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_foreign_keys_orders() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_foreign_keys(¶ms, "orders", "test_schema") + .await + .expect("get_foreign_keys"); + write_golden("get_foreign_keys_orders.json", &result); + assert_golden("get_foreign_keys_orders.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_foreign_keys_cross_schema() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_foreign_keys(¶ms, "with_cross_schema_fk", "test_schema") + .await + .expect("get_foreign_keys"); + write_golden("get_foreign_keys_cross_schema.json", &result); + assert_golden("get_foreign_keys_cross_schema.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_views() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_views(¶ms, "test_schema").await.expect("get_views"); + write_golden("get_views.json", &result); + assert_golden("get_views.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_view_definition() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_view_definition(¶ms, "active_users", "test_schema") + .await + .expect("get_view_definition"); + write_golden("get_view_definition_active_users.json", &result); + assert_golden("get_view_definition_active_users.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_materialized_views() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_materialized_views(¶ms, "test_schema") + .await + .expect("get_materialized_views"); + write_golden("get_materialized_views.json", &result); + assert_golden("get_materialized_views.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_routines() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_routines(¶ms, "test_schema").await.expect("get_routines"); + write_golden("get_routines.json", &result); + assert_golden("get_routines.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_triggers() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_triggers(¶ms, "test_schema").await.expect("get_triggers"); + write_golden("get_triggers.json", &result); + assert_golden("get_triggers.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_execute_query_all_types() { + require_pg!(); + let params = pg_params(); + let result = postgres::execute_query( + ¶ms, + "SELECT * FROM test_schema.all_types WHERE id = 1", + None, + 1, + None, + ) + .await + .expect("execute_query"); + write_golden("execute_query_all_types.json", &result); + assert_golden("execute_query_all_types.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_explain_simple() { + require_pg!(); + let params = pg_params(); + let result = PostgresDriver::new() + .explain_query( + ¶ms, + "SELECT * FROM test_schema.all_types WHERE id = 1", + false, + Some("test_schema"), + ) + .await + .expect("explain_query"); + // EXPLAIN output contains volatile cost/width values that change with table + // statistics, PG version, and row count. Write golden for documentation only; + // do NOT assert exact match. The structural assertions in explain.rs cover + // correctness. The plugin parity test should verify the output SHAPE matches + // (Plan vs Raw variant, key presence) rather than exact numeric values. + write_golden("explain_simple.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_multi_db_get_tables_secondary() { + require_pg!(); + let params = pg_params_secondary(); + let result = postgres::get_tables(¶ms, "secondary_schema") + .await + .expect("get_tables secondary"); + write_golden("multi_db/get_tables_secondary.json", &result); + assert_golden("multi_db/get_tables_secondary.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_multi_db_get_schemas_secondary() { + require_pg!(); + let params = pg_params_secondary(); + let result = postgres::get_schemas(¶ms).await.expect("get_schemas secondary"); + write_golden("multi_db/get_schemas_secondary.json", &result); + assert_golden("multi_db/get_schemas_secondary.json", &result); +} + +// --- Missing golden captures below --- + +#[tokio::test] +#[ignore] +async fn golden_get_view_columns_active_users() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_view_columns(¶ms, "active_users", "test_schema") + .await + .expect("get_view_columns"); + write_golden("get_view_columns_active_users.json", &result); + assert_golden("get_view_columns_active_users.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_materialized_view_definition() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_materialized_view_definition(¶ms, "user_stats", "test_schema") + .await; + // KNOWN BUG: Built-in driver errors with "error serializing parameter 0" on PG 16 + // due to regclass cast issue. Capture the error as the golden expectation — the + // plugin must replicate this behavior until the driver is fixed. + match result { + Ok(def) => { + write_golden("get_mv_definition.json", &def); + assert_golden("get_mv_definition.json", &def); + } + Err(ref e) => { + // Expected failure — record it as the golden expectation + write_golden("get_mv_definition.json", e); + assert_golden("get_mv_definition.json", e); + } + } +} + +#[tokio::test] +#[ignore] +async fn golden_get_materialized_view_columns() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_materialized_view_columns(¶ms, "user_stats", "test_schema") + .await + .expect("get_materialized_view_columns"); + write_golden("get_mv_columns.json", &result); + assert_golden("get_mv_columns.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_routine_parameters() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_routine_parameters(¶ms, "add_numbers", "test_schema") + .await + .expect("get_routine_parameters"); + write_golden("get_routine_parameters_add_numbers.json", &result); + assert_golden("get_routine_parameters_add_numbers.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_routine_definition() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_routine_definition(¶ms, "add_numbers", "FUNCTION", "test_schema") + .await + .expect("get_routine_definition"); + write_golden("get_routine_definition_add_numbers.json", &result); + assert_golden("get_routine_definition_add_numbers.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_get_trigger_definition() { + require_pg!(); + let params = pg_params(); + let result = postgres::get_trigger_definition(¶ms, "trg_audit", "all_types", "test_schema") + .await + .expect("get_trigger_definition"); + write_golden("get_trigger_definition_audit.json", &result); + assert_golden("get_trigger_definition_audit.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_execute_query_with_pagination() { + require_pg!(); + let params = pg_params(); + let result = postgres::execute_query( + ¶ms, + "SELECT id, col_text FROM test_schema.all_types ORDER BY id", + Some(2), + 1, + Some("test_schema"), + ) + .await + .expect("execute_query with pagination"); + write_golden("execute_query_with_pagination.json", &result); + assert_golden("execute_query_with_pagination.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_explain_analyze() { + require_pg!(); + let params = pg_params(); + let result = postgres::explain_query( + ¶ms, + "SELECT * FROM test_schema.all_types WHERE id = 1", + true, + Some("test_schema"), + ) + .await + .expect("explain_query with analyze"); + // EXPLAIN ANALYZE output contains volatile timing and buffer values. + // Write for documentation; do NOT assert exact match. + write_golden("explain_analyze.json", &result); +} + +#[tokio::test] +#[ignore] +async fn golden_count_query() { + require_pg!(); + let params = pg_params(); + let result = postgres::execute_query( + ¶ms, + "SELECT COUNT(*) AS cnt FROM test_schema.all_types", + None, + 1, + Some("test_schema"), + ) + .await + .expect("count query"); + write_golden("count_query.json", &result); + assert_golden("count_query.json", &result); +} diff --git a/src-tauri/tests/postgres_integration/golden/count_query.json b/src-tauri/tests/postgres_integration/golden/count_query.json new file mode 100644 index 000000000..edd054a64 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/count_query.json @@ -0,0 +1,13 @@ +{ + "columns": [ + "cnt" + ], + "rows": [ + [ + 2 + ] + ], + "affected_rows": 0, + "truncated": false, + "pagination": null +} diff --git a/src-tauri/tests/postgres_integration/golden/execute_query_all_types.json b/src-tauri/tests/postgres_integration/golden/execute_query_all_types.json new file mode 100644 index 000000000..0e91f530e --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/execute_query_all_types.json @@ -0,0 +1,83 @@ +{ + "columns": [ + "id", + "col_text", + "col_varchar", + "col_int", + "col_bigint", + "col_smallint", + "col_float", + "col_double", + "col_numeric", + "col_bool", + "col_date", + "col_time", + "col_timetz", + "col_timestamp", + "col_timestamptz", + "col_uuid", + "col_json", + "col_jsonb", + "col_bytea", + "col_inet", + "col_cidr", + "col_macaddr", + "col_int_array", + "col_text_array", + "col_int4range", + "col_tsrange", + "col_interval" + ], + "rows": [ + [ + 1, + "hello", + "world", + 42, + "9223372036854775807", + 32767, + 3.140000104904175, + 2.718281828459045, + "12345.67", + true, + "2026-01-15", + "14:30:00", + "14:30:00+02", + "2026-01-15 14:30:00", + "2026-01-15 14:30:00", + "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", + { + "key": "value" + }, + { + "nested": { + "arr": [ + 1, + 2, + 3 + ] + } + }, + "BLOB:4:application/octet-stream:3q2+7w==", + "192.168.1.1/32", + "10.0.0.0/8", + "08:00:2b:01:02:03", + [ + 1, + 2, + 3 + ], + [ + "a", + "b", + "c" + ], + "[1, 10)", + "[\"2026-01-01 00:00:00\", \"2026-12-31 00:00:00\")", + "1 year 2 months 3 days " + ] + ], + "affected_rows": 0, + "truncated": false, + "pagination": null +} diff --git a/src-tauri/tests/postgres_integration/golden/execute_query_with_pagination.json b/src-tauri/tests/postgres_integration/golden/execute_query_with_pagination.json new file mode 100644 index 000000000..4753b8b19 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/execute_query_with_pagination.json @@ -0,0 +1,24 @@ +{ + "columns": [ + "id", + "col_text" + ], + "rows": [ + [ + 1, + "hello" + ], + [ + 2, + null + ] + ], + "affected_rows": 0, + "truncated": false, + "pagination": { + "page": 1, + "page_size": 2, + "total_rows": null, + "has_more": false + } +} diff --git a/src-tauri/tests/postgres_integration/golden/explain_analyze.json b/src-tauri/tests/postgres_integration/golden/explain_analyze.json new file mode 100644 index 000000000..455860907 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/explain_analyze.json @@ -0,0 +1,9 @@ +{ + "kind": "raw", + "raw": { + "engine": "postgres", + "format": "postgres-json", + "payload": "[{\"Execution Time\":0.012,\"Plan\":{\"Actual Loops\":1,\"Actual Rows\":1,\"Actual Startup Time\":0.004,\"Actual Total Time\":0.005,\"Alias\":\"all_types\",\"Async Capable\":false,\"Filter\":\"(id = 1)\",\"Local Dirtied Blocks\":0,\"Local Hit Blocks\":0,\"Local Read Blocks\":0,\"Local Written Blocks\":0,\"Node Type\":\"Seq Scan\",\"Parallel Aware\":false,\"Plan Rows\":1,\"Plan Width\":961,\"Relation Name\":\"all_types\",\"Rows Removed by Filter\":1,\"Shared Dirtied Blocks\":0,\"Shared Hit Blocks\":1,\"Shared Read Blocks\":0,\"Shared Written Blocks\":0,\"Startup Cost\":0.0,\"Temp Read Blocks\":0,\"Temp Written Blocks\":0,\"Total Cost\":1.02},\"Planning\":{\"Local Dirtied Blocks\":0,\"Local Hit Blocks\":0,\"Local Read Blocks\":0,\"Local Written Blocks\":0,\"Shared Dirtied Blocks\":0,\"Shared Hit Blocks\":122,\"Shared Read Blocks\":0,\"Shared Written Blocks\":0,\"Temp Read Blocks\":0,\"Temp Written Blocks\":0},\"Planning Time\":0.125,\"Triggers\":[]}]", + "original_query": "SELECT * FROM test_schema.all_types WHERE id = 1" + } +} diff --git a/src-tauri/tests/postgres_integration/golden/explain_simple.json b/src-tauri/tests/postgres_integration/golden/explain_simple.json new file mode 100644 index 000000000..4fde85508 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/explain_simple.json @@ -0,0 +1,9 @@ +{ + "kind": "raw", + "raw": { + "engine": "postgres", + "format": "postgres-json", + "payload": "[{\"Plan\":{\"Alias\":\"all_types\",\"Async Capable\":false,\"Filter\":\"(id = 1)\",\"Node Type\":\"Seq Scan\",\"Parallel Aware\":false,\"Plan Rows\":1,\"Plan Width\":961,\"Relation Name\":\"all_types\",\"Startup Cost\":0.0,\"Total Cost\":1.02}}]", + "original_query": "SELECT * FROM test_schema.all_types WHERE id = 1" + } +} diff --git a/src-tauri/tests/postgres_integration/golden/get_columns_all_types.json b/src-tauri/tests/postgres_integration/golden/get_columns_all_types.json new file mode 100644 index 000000000..702446747 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_columns_all_types.json @@ -0,0 +1,191 @@ +[ + { + "name": "id", + "data_type": "integer", + "is_pk": true, + "is_nullable": false, + "is_auto_increment": true + }, + { + "name": "col_text", + "data_type": "text", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_varchar", + "data_type": "character varying", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_int", + "data_type": "integer", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_bigint", + "data_type": "bigint", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_smallint", + "data_type": "smallint", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_float", + "data_type": "real", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_double", + "data_type": "double precision", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_numeric", + "data_type": "numeric", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_bool", + "data_type": "boolean", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_date", + "data_type": "date", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_time", + "data_type": "time without time zone", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_timetz", + "data_type": "time with time zone", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_timestamp", + "data_type": "timestamp without time zone", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_timestamptz", + "data_type": "timestamp with time zone", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_uuid", + "data_type": "uuid", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_json", + "data_type": "json", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_jsonb", + "data_type": "jsonb", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_bytea", + "data_type": "bytea", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_inet", + "data_type": "inet", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_cidr", + "data_type": "cidr", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_macaddr", + "data_type": "macaddr", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_int_array", + "data_type": "ARRAY", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_text_array", + "data_type": "ARRAY", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_int4range", + "data_type": "int4range", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_tsrange", + "data_type": "tsrange", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "col_interval", + "data_type": "interval", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_columns_with_enum.json b/src-tauri/tests/postgres_integration/golden/get_columns_with_enum.json new file mode 100644 index 000000000..feae29806 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_columns_with_enum.json @@ -0,0 +1,17 @@ +[ + { + "name": "id", + "data_type": "integer", + "is_pk": true, + "is_nullable": false, + "is_auto_increment": true + }, + { + "name": "current_mood", + "data_type": "enum('happy','sad','neutral')", + "is_pk": false, + "is_nullable": false, + "is_auto_increment": false, + "default_value": "'neutral'::test_schema.mood" + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_databases.json b/src-tauri/tests/postgres_integration/golden/get_databases.json new file mode 100644 index 000000000..cb51bb6d2 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_databases.json @@ -0,0 +1,5 @@ +[ + "postgres", + "tabularis_test_secondary", + "testdb" +] diff --git a/src-tauri/tests/postgres_integration/golden/get_foreign_keys_cross_schema.json b/src-tauri/tests/postgres_integration/golden/get_foreign_keys_cross_schema.json new file mode 100644 index 000000000..bf75ee532 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_foreign_keys_cross_schema.json @@ -0,0 +1,10 @@ +[ + { + "name": "with_cross_schema_fk_lookup_code_fkey", + "column_name": "lookup_code", + "ref_table": "lookup", + "ref_column": "code", + "on_delete": "NO ACTION", + "on_update": "NO ACTION" + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_foreign_keys_orders.json b/src-tauri/tests/postgres_integration/golden/get_foreign_keys_orders.json new file mode 100644 index 000000000..584215165 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_foreign_keys_orders.json @@ -0,0 +1,10 @@ +[ + { + "name": "orders_user_id_fkey", + "column_name": "user_id", + "ref_table": "all_types", + "ref_column": "id", + "on_delete": "CASCADE", + "on_update": "NO ACTION" + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_indexes_all_types.json b/src-tauri/tests/postgres_integration/golden/get_indexes_all_types.json new file mode 100644 index 000000000..463eddae9 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_indexes_all_types.json @@ -0,0 +1,26 @@ +[ + { + "name": "all_types_pkey", + "column_name": "id", + "is_unique": true, + "is_primary": true, + "seq_in_index": 1, + "is_expression": false + }, + { + "name": "idx_all_types_text", + "column_name": "col_text", + "is_unique": false, + "is_primary": false, + "seq_in_index": 1, + "is_expression": false + }, + { + "name": "idx_all_types_uuid", + "column_name": "col_uuid", + "is_unique": true, + "is_primary": false, + "seq_in_index": 1, + "is_expression": false + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_materialized_views.json b/src-tauri/tests/postgres_integration/golden/get_materialized_views.json new file mode 100644 index 000000000..526648d2d --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_materialized_views.json @@ -0,0 +1,6 @@ +[ + { + "name": "user_stats", + "definition": null + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_mv_columns.json b/src-tauri/tests/postgres_integration/golden/get_mv_columns.json new file mode 100644 index 000000000..4e7a91fc1 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_mv_columns.json @@ -0,0 +1,16 @@ +[ + { + "name": "total", + "data_type": "bigint", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "max_id", + "data_type": "integer", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_mv_definition.json b/src-tauri/tests/postgres_integration/golden/get_mv_definition.json new file mode 100644 index 000000000..d8fb70cd5 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_mv_definition.json @@ -0,0 +1 @@ +"Failed to get materialized view definition: error serializing parameter 0" diff --git a/src-tauri/tests/postgres_integration/golden/get_routine_definition_add_numbers.json b/src-tauri/tests/postgres_integration/golden/get_routine_definition_add_numbers.json new file mode 100644 index 000000000..3d703d524 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_routine_definition_add_numbers.json @@ -0,0 +1 @@ +"CREATE OR REPLACE FUNCTION test_schema.add_numbers(a integer, b integer)\n RETURNS integer\n LANGUAGE sql\n IMMUTABLE\nAS $function$ SELECT a + b $function$\n" diff --git a/src-tauri/tests/postgres_integration/golden/get_routine_parameters_add_numbers.json b/src-tauri/tests/postgres_integration/golden/get_routine_parameters_add_numbers.json new file mode 100644 index 000000000..186d83203 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_routine_parameters_add_numbers.json @@ -0,0 +1,38 @@ +[ + { + "name": "", + "data_type": "integer", + "mode": "OUT", + "ordinal_position": 0 + }, + { + "name": "a", + "data_type": "integer", + "mode": "IN", + "ordinal_position": 1 + }, + { + "name": "a", + "data_type": "integer", + "mode": "IN", + "ordinal_position": 1 + }, + { + "name": "b", + "data_type": "integer", + "mode": "IN", + "ordinal_position": 2 + }, + { + "name": "b", + "data_type": "integer", + "mode": "IN", + "ordinal_position": 2 + }, + { + "name": "c", + "data_type": "integer", + "mode": "IN", + "ordinal_position": 3 + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_routines.json b/src-tauri/tests/postgres_integration/golden/get_routines.json new file mode 100644 index 000000000..bfd544421 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_routines.json @@ -0,0 +1,27 @@ +[ + { + "name": "add_numbers", + "routine_type": "FUNCTION", + "definition": null + }, + { + "name": "add_numbers", + "routine_type": "FUNCTION", + "definition": null + }, + { + "name": "audit_trigger_fn", + "routine_type": "FUNCTION", + "definition": null + }, + { + "name": "get_user", + "routine_type": "FUNCTION", + "definition": null + }, + { + "name": "reset_orders", + "routine_type": "PROCEDURE", + "definition": null + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_schemas.json b/src-tauri/tests/postgres_integration/golden/get_schemas.json new file mode 100644 index 000000000..b3b631e57 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_schemas.json @@ -0,0 +1,5 @@ +[ + "other_schema", + "public", + "test_schema" +] diff --git a/src-tauri/tests/postgres_integration/golden/get_tables.json b/src-tauri/tests/postgres_integration/golden/get_tables.json new file mode 100644 index 000000000..50588ae1a --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_tables.json @@ -0,0 +1,20 @@ +[ + { + "name": "all_types" + }, + { + "name": "crud_scratch" + }, + { + "name": "order_items" + }, + { + "name": "orders" + }, + { + "name": "with_cross_schema_fk" + }, + { + "name": "with_enum" + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_trigger_definition_audit.json b/src-tauri/tests/postgres_integration/golden/get_trigger_definition_audit.json new file mode 100644 index 000000000..5d5e01d73 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_trigger_definition_audit.json @@ -0,0 +1 @@ +"CREATE TRIGGER trg_audit AFTER UPDATE ON test_schema.all_types FOR EACH ROW EXECUTE FUNCTION test_schema.audit_trigger_fn()" diff --git a/src-tauri/tests/postgres_integration/golden/get_triggers.json b/src-tauri/tests/postgres_integration/golden/get_triggers.json new file mode 100644 index 000000000..8fe5b24db --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_triggers.json @@ -0,0 +1,9 @@ +[ + { + "name": "trg_audit", + "table_name": "all_types", + "event": "UPDATE", + "timing": "AFTER", + "definition": null + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_view_columns_active_users.json b/src-tauri/tests/postgres_integration/golden/get_view_columns_active_users.json new file mode 100644 index 000000000..eaee949ec --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_view_columns_active_users.json @@ -0,0 +1,23 @@ +[ + { + "name": "id", + "data_type": "integer", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "name", + "data_type": "text", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + }, + { + "name": "is_active", + "data_type": "boolean", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false + } +] diff --git a/src-tauri/tests/postgres_integration/golden/get_view_definition_active_users.json b/src-tauri/tests/postgres_integration/golden/get_view_definition_active_users.json new file mode 100644 index 000000000..1b88fed65 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_view_definition_active_users.json @@ -0,0 +1 @@ +"CREATE OR REPLACE VIEW \"test_schema\".\"active_users\" AS\n SELECT id,\n col_text AS name,\n col_bool AS is_active\n FROM test_schema.all_types\n WHERE col_bool = true;" diff --git a/src-tauri/tests/postgres_integration/golden/get_views.json b/src-tauri/tests/postgres_integration/golden/get_views.json new file mode 100644 index 000000000..d1d0744bf --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_views.json @@ -0,0 +1,6 @@ +[ + { + "name": "active_users", + "definition": null + } +] diff --git a/src-tauri/tests/postgres_integration/golden/multi_db/get_schemas_secondary.json b/src-tauri/tests/postgres_integration/golden/multi_db/get_schemas_secondary.json new file mode 100644 index 000000000..fcc9c2a05 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/multi_db/get_schemas_secondary.json @@ -0,0 +1,4 @@ +[ + "public", + "secondary_schema" +] diff --git a/src-tauri/tests/postgres_integration/golden/multi_db/get_tables_secondary.json b/src-tauri/tests/postgres_integration/golden/multi_db/get_tables_secondary.json new file mode 100644 index 000000000..c5fafb578 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/multi_db/get_tables_secondary.json @@ -0,0 +1,5 @@ +[ + { + "name": "remote_data" + } +] diff --git a/src-tauri/tests/postgres_integration/golden_utils.rs b/src-tauri/tests/postgres_integration/golden_utils.rs new file mode 100644 index 000000000..e01f7eea5 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden_utils.rs @@ -0,0 +1,64 @@ +//! Golden file capture and comparison utilities. +//! +//! Golden files record the exact output of driver methods against the seeded test +//! database. They serve as the parity contract: the plugin must produce output that +//! matches these files. +//! +//! # Regenerating golden files +//! +//! ```bash +//! cd src-tauri +//! REGENERATE_GOLDEN=1 cargo test --test postgres_integration golden -- --include-ignored --test-threads=1 +//! ``` + +use serde::Serialize; +use std::path::{Path, PathBuf}; + +/// Directory where golden files are stored (relative to the test binary's CWD). +fn golden_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("postgres_integration") + .join("golden") +} + +/// Write a golden file (only when REGENERATE_GOLDEN=1 is set). +pub fn write_golden(filename: &str, data: &T) { + if std::env::var("REGENERATE_GOLDEN").unwrap_or_default() != "1" { + return; + } + let path = golden_dir().join(filename); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create golden dir"); + } + let json = serde_json::to_string_pretty(data).expect("serialize golden data"); + std::fs::write(&path, format!("{}\n", json)) + .unwrap_or_else(|e| panic!("write golden file {:?}: {}", path, e)); + eprintln!(" [golden] wrote {}", path.display()); +} + +/// Assert that the given data matches the golden file exactly. +/// If the golden file doesn't exist yet, the assertion is skipped with a warning. +pub fn assert_golden(filename: &str, data: &T) { + let path = golden_dir().join(filename); + let actual = serde_json::to_string_pretty(data).expect("serialize for comparison"); + + if !path.exists() { + eprintln!( + " [golden] SKIP: {} does not exist. Run with REGENERATE_GOLDEN=1 to create it.", + path.display() + ); + return; + } + + let expected = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read golden file {:?}: {}", path, e)); + + assert_eq!( + actual.trim(), + expected.trim(), + "Golden file mismatch: {}\n\ + To update, run: REGENERATE_GOLDEN=1 cargo test --test postgres_integration golden -- --include-ignored --test-threads=1", + filename + ); +} diff --git a/src-tauri/tests/postgres_integration/helpers.rs b/src-tauri/tests/postgres_integration/helpers.rs new file mode 100644 index 000000000..14b30ae45 --- /dev/null +++ b/src-tauri/tests/postgres_integration/helpers.rs @@ -0,0 +1,80 @@ +//! Shared helpers for PostgreSQL parity tests. + +use std::future::Future; +use std::time::Duration; +use tabularis_lib::drivers::postgres; +use tabularis_lib::models::{ConnectionParams, DatabaseSelection}; +use tokio::time::sleep; + +/// Standard connection parameters matching the CI service and local Docker setup. +pub fn pg_params() -> ConnectionParams { + ConnectionParams { + driver: "postgres".to_string(), + host: Some("127.0.0.1".to_string()), + port: Some(54320), + username: Some("postgres".to_string()), + password: Some("password".to_string()), + database: DatabaseSelection::Single("testdb".to_string()), + ..Default::default() + } +} + +/// Connection params targeting the secondary database (multi-database tests). +pub fn pg_params_secondary() -> ConnectionParams { + ConnectionParams { + database: DatabaseSelection::Single("tabularis_test_secondary".to_string()), + ..pg_params() + } +} + +/// Wait for PostgreSQL to be ready, retrying up to 10 times. +/// Returns `true` if connected, `false` if all retries failed. +pub async fn wait_for_pg() -> bool { + let params = pg_params(); + for _ in 0..10 { + if postgres::get_tables(¶ms, "public").await.is_ok() { + return true; + } + sleep(Duration::from_millis(500)).await; + } + false +} + +/// Retry a fallible async operation up to `attempts` times when the error looks +/// like a transient pool/connection issue ("connection closed", "pool timed out", +/// "broken pipe"). Non-transient errors are returned immediately. +pub async fn retry_transient(attempts: u32, mut f: F) -> Result +where + E: AsRef, + F: FnMut() -> Fut, + Fut: Future>, +{ + let mut last_err = None; + for attempt in 0..attempts { + match f().await { + Ok(val) => return Ok(val), + Err(e) => { + let msg = e.as_ref(); + let transient = msg.contains("connection closed") + || msg.contains("pool timed out") + || msg.contains("broken pipe") + || msg.contains("Connection reset"); + if !transient || attempt + 1 == attempts { + return Err(e); + } + last_err = Some(e); + sleep(Duration::from_millis(100 * (attempt as u64 + 1))).await; + } + } + } + Err(last_err.unwrap()) +} + +/// Convenience wrapper: retry up to 3 times on transient pool errors. +pub async fn retry(f: F) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + retry_transient(3, f).await +} diff --git a/src-tauri/tests/postgres_integration/indexes.rs b/src-tauri/tests/postgres_integration/indexes.rs new file mode 100644 index 000000000..688bd0044 --- /dev/null +++ b/src-tauri/tests/postgres_integration/indexes.rs @@ -0,0 +1,84 @@ +//! Index introspection tests. + +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_get_indexes_btree() { + require_pg!(); + let params = pg_params(); + + let indexes = postgres::get_indexes(¶ms, "all_types", "test_schema") + .await + .expect("get_indexes should succeed"); + + let idx = indexes.iter().find(|i| i.name == "idx_all_types_text"); + assert!(idx.is_some(), "Expected idx_all_types_text index"); + let idx = idx.unwrap(); + assert_eq!(idx.column_name, "col_text"); + assert!(!idx.is_unique); + assert!(!idx.is_primary); +} + +#[tokio::test] +#[ignore] +async fn test_get_indexes_unique() { + require_pg!(); + let params = pg_params(); + + let indexes = postgres::get_indexes(¶ms, "all_types", "test_schema") + .await + .expect("get_indexes should succeed"); + + let idx = indexes.iter().find(|i| i.name == "idx_all_types_uuid"); + assert!(idx.is_some(), "Expected idx_all_types_uuid unique index"); + let idx = idx.unwrap(); + assert_eq!(idx.column_name, "col_uuid"); + assert!(idx.is_unique); +} + +#[tokio::test] +#[ignore] +async fn test_get_indexes_composite() { + require_pg!(); + let params = pg_params(); + + let indexes = postgres::get_indexes(¶ms, "order_items", "test_schema") + .await + .expect("get_indexes should succeed"); + + // The composite index idx_order_items_composite covers (order_id, product) + let idx_entries: Vec<_> = indexes + .iter() + .filter(|i| i.name == "idx_order_items_composite") + .collect(); + + assert_eq!( + idx_entries.len(), + 2, + "Composite index should have 2 entries (one per column)" + ); + // Verify seq_in_index ordering + let first = idx_entries.iter().find(|i| i.seq_in_index == 1).unwrap(); + assert_eq!(first.column_name, "order_id"); + let second = idx_entries.iter().find(|i| i.seq_in_index == 2).unwrap(); + assert_eq!(second.column_name, "product"); +} + +#[tokio::test] +#[ignore] +async fn test_get_indexes_primary_key() { + require_pg!(); + let params = pg_params(); + + let indexes = postgres::get_indexes(¶ms, "all_types", "test_schema") + .await + .expect("get_indexes should succeed"); + + let pk = indexes.iter().find(|i| i.is_primary); + assert!(pk.is_some(), "Expected primary key index"); + let pk = pk.unwrap(); + assert_eq!(pk.column_name, "id"); + assert!(pk.is_unique, "PK index should also be unique"); +} diff --git a/src-tauri/tests/postgres_integration/main.rs b/src-tauri/tests/postgres_integration/main.rs new file mode 100644 index 000000000..86469a4af --- /dev/null +++ b/src-tauri/tests/postgres_integration/main.rs @@ -0,0 +1,74 @@ +//! PostgreSQL parity integration tests. +//! +//! These tests exercise every public method of the PostgreSQL driver against a +//! real PostgreSQL instance. They serve as the baseline specification that the +//! plugin driver must also pass (Phase 1 TDD). +//! +//! # Running locally +//! +//! Start a PostgreSQL 16 container: +//! ```bash +//! docker run -d --name pg-parity -p 54320:5432 \ +//! -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=password -e POSTGRES_DB=testdb \ +//! postgres:16 +//! ``` +//! +//! Seed the database: +//! ```bash +//! bash tests/fixtures/seed_postgres.sh +//! ``` +//! +//! Run the tests (sequential to avoid pool contention): +//! ```bash +//! cd src-tauri && cargo test --test postgres_integration -- --include-ignored --test-threads=1 +//! ``` + +/// Skip the test gracefully if PostgreSQL is unavailable. +macro_rules! require_pg { + () => { + if !crate::helpers::wait_for_pg().await { + eprintln!("SKIPPING: PostgreSQL not available on port 54320"); + return; + } + }; +} + +mod helpers; +mod golden_utils; +mod schema_discovery; +mod column_metadata; +mod indexes; +mod foreign_keys; +mod views; +mod materialized_views; +mod routines; +mod triggers; +mod crud; +mod query_execution; +mod multi_database; +mod ddl_generation; +mod explain; +mod blob; +mod golden; +mod parity; +mod parity_tests; +mod parity_query; +mod parity_batch; +mod parity_explain; +mod parity_crud; +mod parity_views_full; +mod parity_routines_full; +mod parity_multi_db; +mod parity_schema_discovery; +mod parity_column_metadata; +mod parity_indexes; +mod parity_foreign_keys; +mod parity_ddl; +mod parity_blob; +mod parity_views_extra; +mod parity_mv_extra; +mod parity_routines_extra; +mod parity_triggers_extra; +mod parity_multi_db_extra; +mod parity_query_extra; +mod parity_crud_extra; diff --git a/src-tauri/tests/postgres_integration/materialized_views.rs b/src-tauri/tests/postgres_integration/materialized_views.rs new file mode 100644 index 000000000..5bda24d53 --- /dev/null +++ b/src-tauri/tests/postgres_integration/materialized_views.rs @@ -0,0 +1,91 @@ +//! Materialized view tests. + +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_get_materialized_views() { + require_pg!(); + let params = pg_params(); + + let mvs = postgres::get_materialized_views(¶ms, "test_schema") + .await + .expect("get_materialized_views should succeed"); + + let mv_names: Vec<&str> = mvs.iter().map(|v| v.name.as_str()).collect(); + assert!( + mv_names.contains(&"user_stats"), + "Expected user_stats MV, got: {:?}", + mv_names + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_materialized_view_columns() { + require_pg!(); + let params = pg_params(); + + let columns = postgres::get_materialized_view_columns(¶ms, "user_stats", "test_schema") + .await + .expect("get_materialized_view_columns should succeed"); + + let col_names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect(); + assert!(col_names.contains(&"total"), "Expected total column"); + assert!(col_names.contains(&"max_id"), "Expected max_id column"); +} + +#[tokio::test] +#[ignore] +async fn test_get_materialized_view_definition() { + require_pg!(); + let params = pg_params(); + + let result = postgres::get_materialized_view_definition(¶ms, "user_stats", "test_schema") + .await; + + // KNOWN BEHAVIOR: The built-in driver errors with "error serializing parameter 0" + // on PG 16 for this call. This is a pre-existing driver bug. + // The plugin MUST replicate this exact behavior — either succeed with the definition + // (if the bug is fixed upstream) or fail with the same error. + assert!( + result.is_err(), + "Built-in driver should error on MV definition (known bug). \ + If this passes, the driver was fixed — update this test and the plugin spec." + ); + let err = result.unwrap_err(); + assert!( + err.contains("serializing parameter"), + "Expected serialization error, got: {}", + err + ); +} + +#[tokio::test] +#[ignore] +async fn test_refresh_materialized_view() { + require_pg!(); + let params = pg_params(); + + // Refresh should succeed without error + postgres::refresh_materialized_view(¶ms, "user_stats", "test_schema") + .await + .expect("refresh_materialized_view should succeed"); + + // Verify the MV still has data after refresh + let result = postgres::execute_query( + ¶ms, + "SELECT total FROM test_schema.user_stats", + None, + 1, + None, + ) + .await + .expect("SELECT from MV should work after refresh"); + + assert_eq!(result.rows.len(), 1); + // total should be >= 2 (we seeded 2 rows in all_types) + let total = result.rows[0][0].as_i64().unwrap_or(0); + assert!(total >= 2, "Expected total >= 2, got: {}", total); +} diff --git a/src-tauri/tests/postgres_integration/multi_database.rs b/src-tauri/tests/postgres_integration/multi_database.rs new file mode 100644 index 000000000..3c5436fec --- /dev/null +++ b/src-tauri/tests/postgres_integration/multi_database.rs @@ -0,0 +1,135 @@ +//! Multi-database tests (exercises per-database pool routing). + +use tabularis_lib::drivers::postgres; +use crate::helpers::{pg_params, pg_params_secondary}; + +#[tokio::test] +#[ignore] +async fn test_get_databases_lists_both() { + require_pg!(); + let params = pg_params(); + + let databases = postgres::get_databases(¶ms) + .await + .expect("get_databases should succeed"); + + assert!(databases.contains(&"testdb".to_string())); + assert!(databases.contains(&"tabularis_test_secondary".to_string())); +} + +#[tokio::test] +#[ignore] +async fn test_get_schemas_on_secondary_database() { + require_pg!(); + let params = pg_params_secondary(); + + let schemas = postgres::get_schemas(¶ms) + .await + .expect("get_schemas on secondary should succeed"); + + assert!( + schemas.contains(&"secondary_schema".to_string()), + "Expected secondary_schema, got: {:?}", + schemas + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_tables_on_secondary_database() { + require_pg!(); + let params = pg_params_secondary(); + + let tables = postgres::get_tables(¶ms, "secondary_schema") + .await + .expect("get_tables on secondary should succeed"); + + let table_names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect(); + assert!( + table_names.contains(&"remote_data"), + "Expected remote_data table in secondary, got: {:?}", + table_names + ); +} + +#[tokio::test] +#[ignore] +async fn test_execute_query_on_secondary_database() { + require_pg!(); + let params = pg_params_secondary(); + + let result = postgres::execute_query( + ¶ms, + "SELECT COUNT(*) AS cnt FROM secondary_schema.remote_data", + None, + 1, + None, + ) + .await + .expect("query on secondary should succeed"); + + let count = result.rows[0][0].as_i64().unwrap_or(0); + assert_eq!(count, 5, "Expected 5 seeded rows in secondary"); +} + +#[tokio::test] +#[ignore] +async fn test_pool_isolation_between_databases() { + require_pg!(); + let primary = pg_params(); + let secondary = pg_params_secondary(); + + // Query primary — should see test_schema tables + let primary_tables = crate::helpers::retry(|| { + let p = primary.clone(); + async move { postgres::get_tables(&p, "test_schema").await } + }) + .await + .expect("primary tables"); + assert!(!primary_tables.is_empty()); + + // Query secondary — should NOT see test_schema (it doesn't exist there) + let secondary_schemas = crate::helpers::retry(|| { + let s = secondary.clone(); + async move { postgres::get_schemas(&s).await } + }) + .await + .expect("secondary schemas"); + assert!( + !secondary_schemas.contains(&"test_schema".to_string()), + "test_schema should not exist in secondary database" + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_columns_on_secondary_database() { + require_pg!(); + let params = pg_params_secondary(); + + let columns = postgres::get_columns(¶ms, "remote_data", "secondary_schema") + .await + .expect("get_columns on secondary"); + + let col_names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect(); + assert!(col_names.contains(&"id")); + assert!(col_names.contains(&"value")); + assert_eq!(columns.len(), 2); +} + +#[tokio::test] +#[ignore] +async fn test_fallback_to_postgres_maintenance_db() { + require_pg!(); + + // Connect with empty database — should fall back to "postgres" maintenance DB + let mut params = pg_params(); + params.database = tabularis_lib::models::DatabaseSelection::Single("postgres".to_string()); + + let databases = postgres::get_databases(¶ms) + .await + .expect("should connect to maintenance DB"); + + // The maintenance DB can list all databases + assert!(databases.contains(&"testdb".to_string())); +} diff --git a/src-tauri/tests/postgres_integration/parity.rs b/src-tauri/tests/postgres_integration/parity.rs new file mode 100644 index 000000000..1b679ec17 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity.rs @@ -0,0 +1,314 @@ +//! Parity test harness — runs identical assertions against multiple driver +//! implementations to prove behavioral equivalence. +//! +//! # Phase 0 +//! +//! Only `DriverTarget::Builtin` is registered. Tests pass trivially (single +//! result, nothing to compare), but the harness infrastructure is ready for +//! Phase 1 to add `DriverTarget::Plugin`. +//! +//! # Phase 1 +//! +//! Both targets are registered. Tests now run against both drivers and assert +//! that their outputs are identical — proving parity by construction. +//! +//! # Comparison Strategy +//! +//! Since model structs don't derive `PartialEq`, the harness serializes results +//! to `serde_json::Value` and compares those. This also catches subtle +//! differences in field ordering or null handling that direct struct comparison +//! might miss. + +use std::collections::HashMap; +use std::fmt::Debug; +use std::path::PathBuf; +use std::sync::Arc; + +use serde::Serialize; +use serde_json::Value as JsonValue; + +use tabularis_lib::drivers::driver_trait::{DatabaseDriver, DriverCapabilities, PluginManifest, SqlDialect}; +use tabularis_lib::drivers::postgres::PostgresDriver; +use tabularis_lib::models::{ConnectionParams, DataTypeInfo}; +use tabularis_lib::plugins::driver::RpcDriver; + +use crate::helpers::{pg_params, pg_params_secondary, retry_transient}; + +/// Identifies which driver implementation to test. +#[derive(Debug, Clone)] +pub enum DriverTarget { + /// The built-in PostgreSQL driver (direct sqlx implementation). + Builtin, + /// A plugin driver communicating over JSON-RPC stdio. + /// The string is the plugin id (e.g. "postgres-plugin"). + Plugin(String), +} + +impl std::fmt::Display for DriverTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Builtin => write!(f, "builtin"), + Self::Plugin(id) => write!(f, "plugin:{}", id), + } + } +} + +/// The parity harness. Holds configured driver targets and connection params. +pub struct ParityHarness { + targets: Vec<(DriverTarget, Arc)>, + pub params: ConnectionParams, + pub params_secondary: ConnectionParams, +} + +impl ParityHarness { + /// Create a harness with only the built-in driver (Phase 0 fallback). + pub fn builtin_only() -> Self { + let driver = Arc::new(PostgresDriver::new()) as Arc; + Self { + targets: vec![(DriverTarget::Builtin, driver)], + params: pg_params(), + params_secondary: pg_params_secondary(), + } + } + + /// Create a harness, optionally including the plugin driver if + /// `POSTGRES_PLUGIN_BIN` is set. This is the primary constructor for + /// Phase 1+ parity tests. + pub async fn new() -> Self { + let mut harness = Self::builtin_only(); + if let Some(plugin_driver) = try_plugin_driver().await { + harness = harness.with_plugin("postgres-plugin", plugin_driver); + } + harness + } + + /// Add a plugin driver target. + pub fn with_plugin(mut self, id: &str, driver: Arc) -> Self { + self.targets.push((DriverTarget::Plugin(id.to_string()), driver)); + self + } + + /// Returns a reference to the list of configured targets. + pub fn targets(&self) -> &[(DriverTarget, Arc)] { + &self.targets + } + + /// Run a test function against all configured targets and assert identical + /// results (compared via JSON serialization). The `method_name` is used in + /// assertion messages for diagnostics. + /// + /// With a single target (Phase 0), this simply runs the function once and + /// returns the JSON value. With multiple targets (Phase 1+), it compares all + /// serialized results pairwise. + pub async fn assert_parity(&self, method_name: &str, test_fn: F) -> JsonValue + where + T: Debug + Serialize, + F: Fn(Arc, ConnectionParams) -> Fut, + Fut: std::future::Future>, + { + self.run_parity_inner(method_name, &self.params, test_fn).await + } + + /// Same as `assert_parity` but uses `params_secondary` for multi-database tests. + pub async fn assert_parity_secondary( + &self, + method_name: &str, + test_fn: F, + ) -> JsonValue + where + T: Debug + Serialize, + F: Fn(Arc, ConnectionParams) -> Fut, + Fut: std::future::Future>, + { + self.run_parity_inner(method_name, &self.params_secondary, test_fn).await + } + + async fn run_parity_inner( + &self, + method_name: &str, + params: &ConnectionParams, + test_fn: F, + ) -> JsonValue + where + T: Debug + Serialize, + F: Fn(Arc, ConnectionParams) -> Fut, + Fut: std::future::Future>, + { + let mut results: Vec<(String, JsonValue)> = Vec::new(); + + for (target, driver) in &self.targets { + let result = retry_transient(3, || { + test_fn(Arc::clone(driver), params.clone()) + }) + .await + .unwrap_or_else(|e| { + panic!( + "Parity test '{}' failed on target {}: {}", + method_name, target, e + ) + }); + let json = serde_json::to_value(&result).unwrap_or_else(|e| { + panic!( + "Parity test '{}': failed to serialize result from {}: {}", + method_name, target, e + ) + }); + results.push((target.to_string(), json)); + } + + // Compare all results pairwise + for window in results.windows(2) { + let (ref name_a, ref val_a) = window[0]; + let (ref name_b, ref val_b) = window[1]; + assert_eq!( + val_a, val_b, + "Parity failure in '{}': {} and {} returned different results.\n\ + Left: {}\n\ + Right: {}", + method_name, + name_a, + name_b, + serde_json::to_string_pretty(val_a).unwrap(), + serde_json::to_string_pretty(val_b).unwrap() + ); + } + + // Return the first result (all are equal) + results.into_iter().next().unwrap().1 + } + + /// Assert that a method produces the same error semantics across targets. + /// For methods expected to fail, this checks that all targets either succeed + /// with equal results or fail (error messages may differ between drivers, + /// so only the success/failure outcome is compared). + #[allow(dead_code)] + pub async fn assert_error_parity(&self, method_name: &str, test_fn: F) + where + T: Debug + Serialize, + F: Fn(Arc, ConnectionParams) -> Fut, + Fut: std::future::Future>, + { + let mut results: Vec<(String, Result)> = Vec::new(); + + for (target, driver) in &self.targets { + let result = test_fn(Arc::clone(driver), self.params.clone()).await; + let mapped = result.map(|v| { + serde_json::to_value(&v).unwrap_or_else(|e| { + panic!("Failed to serialize result from {}: {}", target, e) + }) + }); + results.push((target.to_string(), mapped)); + } + + for window in results.windows(2) { + let (ref name_a, ref res_a) = window[0]; + let (ref name_b, ref res_b) = window[1]; + match (res_a, res_b) { + (Ok(a), Ok(b)) => assert_eq!( + a, b, + "Parity failure in '{}': {} and {} returned different success values", + method_name, name_a, name_b + ), + (Err(_), Err(_)) => { + // Both failed — parity holds (error messages may differ between drivers) + } + _ => panic!( + "Parity failure in '{}': {} {} but {} {}", + method_name, + name_a, + if res_a.is_ok() { "succeeded" } else { "failed" }, + name_b, + if res_b.is_ok() { "succeeded" } else { "failed" } + ), + } + } + } +} + +/// Attempt to construct a plugin driver from the `POSTGRES_PLUGIN_BIN` env var. +/// Returns `None` if the env var is unset (Phase 0 / no plugin available). +/// Panics if the env var is set but the plugin fails to start (broken binary). +async fn try_plugin_driver() -> Option> { + let bin_path = std::env::var("POSTGRES_PLUGIN_BIN").ok()?; + let path = PathBuf::from(&bin_path); + + if !path.exists() { + eprintln!( + " [parity] POSTGRES_PLUGIN_BIN set to '{}' but file does not exist — skipping plugin", + bin_path + ); + return None; + } + + eprintln!(" [parity] Spawning plugin driver from: {}", bin_path); + + let manifest = plugin_manifest(); + let data_types = plugin_data_types(); + + let driver = RpcDriver::new(manifest, path, None, data_types, HashMap::new()) + .await + .unwrap_or_else(|e| panic!("Failed to start plugin driver: {}", e)); + + Some(Arc::new(driver) as Arc) +} + +/// Build the PluginManifest matching the plugin's .tabularium file. +fn plugin_manifest() -> PluginManifest { + PluginManifest { + id: "postgres-plugin".to_string(), + name: "PostgreSQL Plugin".to_string(), + version: "0.1.0".to_string(), + description: "PostgreSQL plugin driver for Tabularis".to_string(), + default_port: Some(5432), + capabilities: DriverCapabilities { + schemas: true, + single_database: false, + views: true, + materialized_views: true, + routines: true, + routine_management: true, + file_based: false, + folder_based: false, + connection_string: true, + connection_string_example: "postgres://user:pass@localhost:5432/db".into(), + connection_uri: false, + connection_uri_schemes: Vec::new(), + identifier_quote: "\"".into(), + alter_primary_key: true, + auto_increment_keyword: String::new(), + serial_type: "SERIAL".into(), + inline_pk: false, + alter_column: true, + create_foreign_keys: true, + no_connection_required: false, + manage_tables: true, + explain: true, + readonly: false, + triggers: true, + supports_ssl: true, + user_management: false, + sql_dialect: SqlDialect::Postgres, + }, + is_builtin: false, + engine: Some("postgresql".to_string()), + paradigms: vec!["relational".to_string()], + default_username: "postgres".to_string(), + color: "#3b82f6".to_string(), + icon: "postgres".to_string(), + settings: vec![], + ui_extensions: None, + type_mappings: { + let mut m = HashMap::new(); + m.insert("DATETIME".to_string(), "TIMESTAMP".to_string()); + m.insert("JSON".to_string(), "JSONB".to_string()); + m + }, + } +} + +/// Data types the plugin supports (matches .tabularium data_types array). +fn plugin_data_types() -> Vec { + // For the parity harness, data types are used for display only. + // Return an empty vec — the RpcDriver doesn't use these for query execution. + Vec::new() +} diff --git a/src-tauri/tests/postgres_integration/parity_batch.rs b/src-tauri/tests/postgres_integration/parity_batch.rs new file mode 100644 index 000000000..b1d1a558a --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_batch.rs @@ -0,0 +1,156 @@ +//! Parity tests for `execute_batch` — ensures plugin handles multi-statement +//! batch execution identically to the built-in driver. +//! +//! `execute_batch` returns `Vec`, and each entry +//! carries `execution_time_ms: Option` — a genuinely non-deterministic +//! wall-clock value that can never byte-match between two separate driver +//! processes. `assert_parity`'s exact JSON comparison is the wrong tool here +//! (same class of issue as `explain_query`'s volatile cost/timing output in +//! `parity_explain.rs`). Instead, call each target directly and compare only +//! the deterministic fields (`error`, `result`), ignoring `execution_time_ms`. + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; + +use crate::parity::ParityHarness; + +/// Strip the non-deterministic `execution_time_ms` field from each batch +/// entry so the remaining structure (`error`, `result`) can be compared +/// exactly across targets. +fn normalize_batch_result(v: &Value) -> Value { + let arr = v.as_array().expect("batch result should be an array"); + Value::Array( + arr.iter() + .map(|entry| { + json_without_key(entry, "execution_time_ms") + }) + .collect(), + ) +} + +fn json_without_key(v: &Value, key: &str) -> Value { + match v.as_object() { + Some(obj) => { + let mut filtered = serde_json::Map::new(); + for (k, val) in obj { + if k != key { + filtered.insert(k.clone(), val.clone()); + } + } + Value::Object(filtered) + } + None => v.clone(), + } +} + +#[tokio::test] +#[ignore] +async fn parity_batch_session_state() { + require_pg!(); + let harness = ParityHarness::new().await; + + let queries = vec![ + "SET search_path TO test_schema".to_string(), + "SELECT current_schema() AS current_schema".to_string(), + ]; + + let mut normalized_results = Vec::new(); + for (target, driver) in harness.targets() { + let result = driver + .execute_batch(&harness.params, &queries, Some(100), 1, Some("test_schema"), None) + .await + .unwrap_or_else(|e| panic!("execute_batch failed on {}: {}", target, e)); + let json = serde_json::to_value(&result).expect("serialize batch result"); + normalized_results.push((target.to_string(), normalize_batch_result(&json))); + } + + for window in normalized_results.windows(2) { + assert_eq!( + window[0].1, window[1].1, + "execute_batch:session_state parity failure between {} and {}", + window[0].0, window[1].0 + ); + } + + let arr = normalized_results[0].1.as_array().unwrap(); + assert_eq!(arr.len(), 2, "should have results for both statements"); + let second = &arr[1]; + let succeeded = second.get("error").map(Value::is_null).unwrap_or(false); + assert!(succeeded, "SELECT current_schema() should succeed, got: {:?}", second); +} + +#[tokio::test] +#[ignore] +async fn parity_batch_mixed_statements() { + require_pg!(); + let harness = ParityHarness::new().await; + + let queries = vec![ + "SELECT id FROM test_schema.all_types ORDER BY id LIMIT 2".to_string(), + "INSERT INTO test_schema.crud_scratch(name, value) VALUES ('batch_parity', 42)".to_string(), + ]; + + let mut normalized_results = Vec::new(); + for (target, driver) in harness.targets() { + let result = driver + .execute_batch(&harness.params, &queries, Some(100), 1, Some("test_schema"), None) + .await + .unwrap_or_else(|e| panic!("execute_batch failed on {}: {}", target, e)); + let json = serde_json::to_value(&result).expect("serialize batch result"); + normalized_results.push((target.to_string(), normalize_batch_result(&json))); + } + + for window in normalized_results.windows(2) { + assert_eq!( + window[0].1, window[1].1, + "execute_batch:mixed_statements parity failure between {} and {}", + window[0].0, window[1].0 + ); + } + + let arr = normalized_results[0].1.as_array().unwrap(); + assert_eq!(arr.len(), 2, "should have results for both statements"); + let first_ok = arr[0].get("error").map(Value::is_null).unwrap_or(false); + assert!(first_ok, "SELECT should succeed, got: {:?}", arr[0]); + let second_ok = arr[1].get("error").map(Value::is_null).unwrap_or(false); + assert!(second_ok, "INSERT should succeed, got: {:?}", arr[1]); +} + +#[tokio::test] +#[ignore] +async fn parity_batch_error_handling() { + require_pg!(); + let harness = ParityHarness::new().await; + + let queries = vec![ + "SELECT 1 AS ok".to_string(), + "SELECT * FROM test_schema.this_table_does_not_exist".to_string(), + ]; + + let mut normalized_results = Vec::new(); + for (target, driver) in harness.targets() { + let result = driver + .execute_batch(&harness.params, &queries, Some(100), 1, Some("test_schema"), None) + .await + .unwrap_or_else(|e| panic!("execute_batch failed on {}: {}", target, e)); + let json = serde_json::to_value(&result).expect("serialize batch result"); + normalized_results.push((target.to_string(), normalize_batch_result(&json))); + } + + // Do not assert_eq the error message text across targets — the builtin + // and plugin surface different underlying driver error strings for the + // same failure (e.g. differing wording from sqlx vs tokio-postgres). + // Compare only the success/failure shape. + for (target, json) in &normalized_results { + let arr = json.as_array().unwrap(); + assert_eq!(arr.len(), 2, "{}: should have results for both statements", target); + let first_ok = arr[0].get("error").map(Value::is_null).unwrap_or(false); + assert!(first_ok, "{}: valid SELECT should succeed, got: {:?}", target, arr[0]); + let second_failed = arr[1].get("error").map(|e| !e.is_null()).unwrap_or(false); + assert!( + second_failed, + "{}: query on non-existent table should fail, got: {:?}", + target, arr[1] + ); + } +} diff --git a/src-tauri/tests/postgres_integration/parity_blob.rs b/src-tauri/tests/postgres_integration/parity_blob.rs new file mode 100644 index 000000000..fd05e5479 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_blob.rs @@ -0,0 +1,162 @@ +//! Parity tests for BLOB (bytea) handling — covers ALL 3 baseline tests from +//! `blob.rs`. None of these were previously covered by parity tests. +//! +//! The `save_blob_to_file` test verifies both drivers can write to a file without +//! error. The `fetch_blob_as_data_url` test verifies both drivers return +//! identical wire-format strings for the same row. + +use std::collections::HashMap; + +use serde_json::json; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; + +use crate::parity::ParityHarness; + +/// Parity equivalent of `test_insert_and_query_bytea`. +/// Verifies inserting a BLOB-wire-encoded bytea value and querying it back. +/// Both drivers must handle the "BLOB:::" wire format +/// identically on insert and produce identical query results. +#[tokio::test] +#[ignore] +async fn parity_blob_insert_and_query() { + require_pg!(); + let harness = ParityHarness::new().await; + + // insert_record is destructive against the one shared physical database + // both targets point at — assert_parity calls each target in sequence, + // and col_text has no unique constraint, so inserting the same marker + // value from both targets produces TWO rows in the shared table (not + // one row inserted "the same way twice"). Run insert+query+cleanup + // directly per target so each target's row is isolated and cleaned up + // before the next target runs. + for (target, driver) in harness.targets() { + // 4 bytes (0xCA 0xFE 0xBA 0xBE) encoded as base64 = "yv66vg==" + let blob_wire = "BLOB:4:application/octet-stream:yv66vg=="; + let mut data = HashMap::new(); + data.insert("col_bytea".to_string(), json!(blob_wire)); + data.insert("col_text".to_string(), json!("parity_blob_test")); + driver + .insert_record(&harness.params, "all_types", data, Some("test_schema"), 10_000_000) + .await + .unwrap_or_else(|e| panic!("insert_record failed on {}: {}", target, e)); + + let query_result = driver + .execute_query( + &harness.params, + "SELECT col_bytea FROM test_schema.all_types WHERE col_text = 'parity_blob_test'", + None, + 1, + Some("test_schema"), + ) + .await + .unwrap_or_else(|e| panic!("execute_query failed on {}: {}", target, e)); + + assert_eq!(query_result.rows.len(), 1, "{}: should find exactly the inserted blob row", target); + assert!( + !query_result.rows[0][0].is_null(), + "{}: bytea column should not be null", + target + ); + + driver + .execute_query( + &harness.params, + "DELETE FROM test_schema.all_types WHERE col_text = 'parity_blob_test'", + None, + 1, + Some("test_schema"), + ) + .await + .unwrap_or_else(|e| panic!("cleanup delete failed on {}: {}", target, e)); + } +} + +/// Parity equivalent of `test_save_blob_to_file`. +/// Verifies both drivers can export a blob column to a file without error. +/// The seeded row (id=1) has col_bytea = '\xDEADBEEF'. +#[tokio::test] +#[ignore] +async fn parity_blob_save_to_file() { + require_pg!(); + let harness = ParityHarness::new().await; + + let tmp_path = std::env::temp_dir().join("tabularis_parity_blob_test.bin"); + let path_str = tmp_path.to_str().unwrap().to_string(); + + let result = harness + .assert_parity( + "save_blob_to_file:basic", + |driver, params| { + let path = path_str.clone(); + async move { + let mut pk_map = HashMap::new(); + pk_map.insert("id".to_string(), json!(1)); + driver + .save_blob_to_file( + ¶ms, + "all_types", + "col_bytea", + &pk_map, + Some("test_schema"), + &path, + ) + .await + } + }, + ) + .await; + + // Both drivers should succeed (result is null/() serialized) + assert!( + result.is_null(), + "save_blob_to_file returns () which serializes to null" + ); + + // Verify file was written and has content + let metadata = std::fs::metadata(&tmp_path); + assert!(metadata.is_ok(), "File should exist after save_blob_to_file"); + assert!( + metadata.unwrap().len() > 0, + "File should have content" + ); + + // Clean up + let _ = std::fs::remove_file(&tmp_path); +} + +/// Parity equivalent of `test_fetch_blob_as_data_url`. +/// Verifies both drivers return identical BLOB wire format strings for the +/// same row. The seeded row (id=1) has col_bytea = '\xDEADBEEF'. +#[tokio::test] +#[ignore] +async fn parity_blob_fetch_as_data_url() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "fetch_blob_as_data_url:basic", + |driver, params| async move { + let mut pk_map = HashMap::new(); + pk_map.insert("id".to_string(), json!(1)); + driver + .fetch_blob_as_data_url( + ¶ms, + "all_types", + "col_bytea", + &pk_map, + Some("test_schema"), + ) + .await + }, + ) + .await; + + let data_url = result.as_str().expect("fetch_blob_as_data_url should return a string"); + // Should be in BLOB wire format: "BLOB:::" or data URL + assert!( + data_url.starts_with("BLOB:") || data_url.starts_with("data:"), + "Should return BLOB wire format or data URL, got: {}", + &data_url[..data_url.len().min(50)] + ); +} diff --git a/src-tauri/tests/postgres_integration/parity_column_metadata.rs b/src-tauri/tests/postgres_integration/parity_column_metadata.rs new file mode 100644 index 000000000..85912410c --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_column_metadata.rs @@ -0,0 +1,254 @@ +//! Parity tests for column metadata — covers baseline tests from +//! `column_metadata.rs` that are NOT already covered in `parity_tests.rs`. +//! +//! Already covered by `parity_tests.rs`: +//! - parity_get_columns (basic: all_types table, checks id is PK) +//! +//! New in this file — each test calls `get_columns` through the trait via the +//! harness and uses `assert_parity()` for byte-perfect JSON comparison, then +//! adds structural assertions on the shared result. + +use std::sync::Arc; + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::ConnectionParams; + +use crate::parity::ParityHarness; + +/// Parity equivalent of `test_get_columns_all_types_count`. +/// Verifies the all_types table returns exactly 27 columns from both drivers. +#[tokio::test] +#[ignore] +async fn parity_get_columns_all_types_count() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_columns:all_types:count", + |driver, params| async move { + driver.get_columns(¶ms, "all_types", Some("test_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("columns should be an array"); + assert_eq!(arr.len(), 27, "Expected 27 columns in all_types"); +} + +/// Parity equivalent of `test_get_columns_pk_detection`. +/// Verifies primary key detection and auto-increment flag match between drivers. +#[tokio::test] +#[ignore] +async fn parity_get_columns_pk_detection() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_columns:all_types:pk_detection", + |driver, params| async move { + driver.get_columns(¶ms, "all_types", Some("test_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("columns should be an array"); + + let id_col = arr + .iter() + .find(|c| c.get("name").and_then(|n| n.as_str()) == Some("id")) + .expect("id column should exist"); + assert_eq!( + id_col.get("is_pk").and_then(|v| v.as_bool()), + Some(true), + "id should be primary key" + ); + assert_eq!( + id_col.get("is_auto_increment").and_then(|v| v.as_bool()), + Some(true), + "SERIAL id should be auto_increment" + ); + assert_eq!( + id_col.get("data_type").and_then(|v| v.as_str()), + Some("integer"), + "SERIAL resolves to integer" + ); + + // Non-PK columns should not be marked as PK + let text_col = arr + .iter() + .find(|c| c.get("name").and_then(|n| n.as_str()) == Some("col_text")) + .expect("col_text should exist"); + assert_eq!( + text_col.get("is_pk").and_then(|v| v.as_bool()), + Some(false), + "col_text should not be PK" + ); + assert_eq!( + text_col.get("is_auto_increment").and_then(|v| v.as_bool()), + Some(false), + "col_text should not be auto_increment" + ); +} + +/// Parity equivalent of `test_get_columns_nullable_detection`. +/// Verifies nullable flag matches between drivers. +#[tokio::test] +#[ignore] +async fn parity_get_columns_nullable_detection() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_columns:all_types:nullable", + |driver, params| async move { + driver.get_columns(¶ms, "all_types", Some("test_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("columns should be an array"); + + // id (SERIAL PRIMARY KEY) is NOT NULL + let id_col = arr + .iter() + .find(|c| c.get("name").and_then(|n| n.as_str()) == Some("id")) + .unwrap(); + assert_eq!( + id_col.get("is_nullable").and_then(|v| v.as_bool()), + Some(false), + "PK should not be nullable" + ); + + // col_text has no NOT NULL constraint + let text_col = arr + .iter() + .find(|c| c.get("name").and_then(|n| n.as_str()) == Some("col_text")) + .unwrap(); + assert_eq!( + text_col.get("is_nullable").and_then(|v| v.as_bool()), + Some(true), + "col_text should be nullable" + ); +} + +/// Parity equivalent of `test_get_columns_type_detection`. +/// Verifies data type strings match between drivers for multiple column types. +#[tokio::test] +#[ignore] +async fn parity_get_columns_type_detection() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_columns:all_types:type_detection", + |driver, params| async move { + driver.get_columns(¶ms, "all_types", Some("test_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("columns should be an array"); + let find = |name: &str| -> &Value { + arr.iter() + .find(|c| c.get("name").and_then(|n| n.as_str()) == Some(name)) + .unwrap_or_else(|| panic!("column '{}' should exist", name)) + }; + + assert_eq!(find("col_text").get("data_type").and_then(|v| v.as_str()), Some("text")); + assert_eq!(find("col_int").get("data_type").and_then(|v| v.as_str()), Some("integer")); + assert_eq!(find("col_bigint").get("data_type").and_then(|v| v.as_str()), Some("bigint")); + assert_eq!(find("col_bool").get("data_type").and_then(|v| v.as_str()), Some("boolean")); + assert_eq!(find("col_uuid").get("data_type").and_then(|v| v.as_str()), Some("uuid")); + assert_eq!(find("col_jsonb").get("data_type").and_then(|v| v.as_str()), Some("jsonb")); + assert_eq!(find("col_bytea").get("data_type").and_then(|v| v.as_str()), Some("bytea")); + assert_eq!( + find("col_timestamptz").get("data_type").and_then(|v| v.as_str()), + Some("timestamp with time zone") + ); +} + +/// Parity equivalent of `test_get_columns_character_max_length`. +/// Verifies that character_maximum_length is reported identically. +#[tokio::test] +#[ignore] +async fn parity_get_columns_character_max_length() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_columns:all_types:char_max_length", + |driver, params| async move { + driver.get_columns(¶ms, "all_types", Some("test_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("columns should be an array"); + + let varchar_col = arr + .iter() + .find(|c| c.get("name").and_then(|n| n.as_str()) == Some("col_varchar")) + .expect("col_varchar should exist"); + // KNOWN BEHAVIOR: The PG driver does NOT populate character_maximum_length. + // The plugin MUST match this exact behavior (return None/null). + assert!( + varchar_col.get("character_maximum_length").is_none() + || varchar_col.get("character_maximum_length") == Some(&Value::Null), + "Built-in PG driver returns None for character_maximum_length (known limitation)" + ); + + let text_col = arr + .iter() + .find(|c| c.get("name").and_then(|n| n.as_str()) == Some("col_text")) + .expect("col_text should exist"); + assert!( + text_col.get("character_maximum_length").is_none() + || text_col.get("character_maximum_length") == Some(&Value::Null), + "TEXT has no max length" + ); +} + +/// Parity equivalent of `test_get_columns_enum_type`. +/// Verifies enum type representation matches between drivers. +#[tokio::test] +#[ignore] +async fn parity_get_columns_enum_type() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_columns:with_enum", + |driver, params| async move { + driver.get_columns(¶ms, "with_enum", Some("test_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("columns should be an array"); + let mood_col = arr + .iter() + .find(|c| c.get("name").and_then(|n| n.as_str()) == Some("current_mood")) + .expect("current_mood column should exist"); + + let data_type = mood_col + .get("data_type") + .and_then(|v| v.as_str()) + .expect("data_type should be a string"); + // The PG driver resolves enum types — the plugin must match exactly. + // The assert_parity() already guarantees the strings are equal; + // this structural check just documents the expected format. + assert!( + data_type.contains("mood") + || data_type.starts_with("enum(") + || data_type == "USER-DEFINED", + "Enum column data_type should contain 'mood', start with 'enum(', or be 'USER-DEFINED', got: {}", + data_type + ); +} diff --git a/src-tauri/tests/postgres_integration/parity_crud.rs b/src-tauri/tests/postgres_integration/parity_crud.rs new file mode 100644 index 000000000..d9f6094f1 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_crud.rs @@ -0,0 +1,165 @@ +//! Parity tests for CRUD operations — insert_record, update_record, delete_record. +//! +//! All tests use the `crud_scratch` table which is truncated by the seed script. + +use std::collections::HashMap; + +use serde_json::json; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_insert_record() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("insert_record:basic", |driver, params| async move { + let mut data = HashMap::new(); + data.insert("name".to_string(), json!("parity_insert")); + data.insert("value".to_string(), json!(100)); + driver + .insert_record(¶ms, "crud_scratch", data, Some("test_schema"), 0) + .await + }) + .await; + + let affected = result.as_u64().expect("insert should return affected rows"); + assert_eq!(affected, 1, "inserting one row should affect 1 row"); +} + +#[tokio::test] +#[ignore] +async fn parity_update_record() { + require_pg!(); + let harness = ParityHarness::new().await; + + // Setup: insert a row to update (use execute_query for deterministic PK) + for (_target, driver) in harness.targets() { + let _ = driver + .execute_query( + &harness.params, + "INSERT INTO test_schema.crud_scratch(id, name, value) VALUES (9000, 'update_target', 1) ON CONFLICT (id) DO NOTHING", + None, + 1, + Some("test_schema"), + ) + .await; + } + + let result = harness + .assert_parity("update_record:basic", |driver, params| async move { + let mut pk_map = HashMap::new(); + pk_map.insert("id".to_string(), json!(9000)); + driver + .update_record( + ¶ms, + "crud_scratch", + &pk_map, + "value", + json!(999), + Some("test_schema"), + 0, + ) + .await + }) + .await; + + let affected = result.as_u64().expect("update should return affected rows"); + assert_eq!(affected, 1, "updating one matching row should affect 1 row"); +} + +#[tokio::test] +#[ignore] +async fn parity_delete_record() { + require_pg!(); + let harness = ParityHarness::new().await; + + // DELETE is destructive against the one shared physical database both + // targets point at — assert_parity calls each target in sequence, so a + // row inserted once and deleted by the first target would legitimately + // report 0 rows affected for the second target (it's already gone). + // Re-insert the row before each target's delete attempt instead of + // sharing a single setup pass, and compare only the deterministic + // affected_rows count directly (matches the direct-per-target pattern + // used in parity_batch.rs for the same class of issue). + let mut affected_by_target = Vec::new(); + for (target, driver) in harness.targets() { + driver + .execute_query( + &harness.params, + "INSERT INTO test_schema.crud_scratch(id, name, value) VALUES (9001, 'delete_target', 1) ON CONFLICT (id) DO UPDATE SET name = 'delete_target', value = 1", + None, + 1, + Some("test_schema"), + ) + .await + .unwrap_or_else(|e| panic!("setup insert failed on {}: {}", target, e)); + + let mut pk_map = HashMap::new(); + pk_map.insert("id".to_string(), json!(9001)); + let affected = driver + .delete_record(&harness.params, "crud_scratch", &pk_map, Some("test_schema")) + .await + .unwrap_or_else(|e| panic!("delete_record failed on {}: {}", target, e)); + affected_by_target.push((target.to_string(), affected)); + } + + for (target, affected) in &affected_by_target { + assert_eq!( + *affected, 1, + "{}: deleting one matching row should affect 1 row", + target + ); + } +} + +#[tokio::test] +#[ignore] +async fn parity_insert_types() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("insert_record:types", |driver, params| async move { + let mut data = HashMap::new(); + data.insert("name".to_string(), json!("typed_insert")); + data.insert("value".to_string(), json!(42)); + driver + .insert_record(¶ms, "crud_scratch", data, Some("test_schema"), 0) + .await + }) + .await; + + let affected = result.as_u64().expect("insert should return affected rows"); + assert_eq!(affected, 1); +} + +#[tokio::test] +#[ignore] +async fn parity_delete_nonexistent() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "delete_record:nonexistent", + |driver, params| async move { + let mut pk_map = HashMap::new(); + // Use an ID that definitely doesn't exist + pk_map.insert("id".to_string(), json!(999999)); + driver + .delete_record(¶ms, "crud_scratch", &pk_map, Some("test_schema")) + .await + }, + ) + .await; + + let affected = result.as_u64().expect("delete should return affected rows"); + assert_eq!( + affected, 0, + "deleting a non-existent row should affect 0 rows" + ); +} diff --git a/src-tauri/tests/postgres_integration/parity_crud_extra.rs b/src-tauri/tests/postgres_integration/parity_crud_extra.rs new file mode 100644 index 000000000..0f5edef09 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_crud_extra.rs @@ -0,0 +1,292 @@ +//! Extra parity tests for CRUD — composite PK, NULL update, insert_with_default, +//! enum column binding. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::{json, Value}; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::ConnectionParams; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_update_composite_pk() { + require_pg!(); + let harness = ParityHarness::new().await; + + // order_items has composite PK (order_id, item_no) and order_id has an FK + // to orders(id). The seed only creates order id=1, so reuse it here with + // a distinct item_no to avoid colliding with the seeded (1, 1) row. + for (_target, driver) in harness.targets() { + let _ = driver + .execute_query( + &harness.params, + "INSERT INTO test_schema.order_items(order_id, item_no, product) \ + VALUES (1, 99, 'Parity Widget') ON CONFLICT (order_id, item_no) DO NOTHING", + None, + 1, + Some("test_schema"), + ) + .await; + } + + // Update using composite PK + let result = harness + .assert_parity( + "update_record:composite_pk", + |driver, params| async move { + let mut pk_map = HashMap::new(); + pk_map.insert("order_id".to_string(), json!(1)); + pk_map.insert("item_no".to_string(), json!(99)); + driver + .update_record( + ¶ms, + "order_items", + &pk_map, + "product", + json!("Parity Updated Widget"), + Some("test_schema"), + 0, + ) + .await + }, + ) + .await; + + let affected = result.as_u64().expect("update should return affected rows"); + assert_eq!( + affected, 1, + "Composite PK update should affect exactly 1 row" + ); + + // Restore original value + for (_target, driver) in harness.targets() { + let mut pk_map = HashMap::new(); + pk_map.insert("order_id".to_string(), json!(1)); + pk_map.insert("item_no".to_string(), json!(99)); + let _ = driver + .update_record( + &harness.params, + "order_items", + &pk_map, + "product", + json!("Parity Widget"), + Some("test_schema"), + 0, + ) + .await; + } +} + +#[tokio::test] +#[ignore] +async fn parity_update_to_null() { + require_pg!(); + let harness = ParityHarness::new().await; + + // Setup: insert a row with a non-null value + for (_target, driver) in harness.targets() { + let _ = driver + .execute_query( + &harness.params, + "INSERT INTO test_schema.crud_scratch(id, name, value) \ + VALUES (9010, 'parity_null_update', 42) \ + ON CONFLICT (id) DO UPDATE SET value = 42", + None, + 1, + Some("test_schema"), + ) + .await; + } + + // Update the value column to NULL + let result = harness + .assert_parity( + "update_record:set_null", + |driver, params| async move { + let mut pk_map = HashMap::new(); + pk_map.insert("id".to_string(), json!(9010)); + driver + .update_record( + ¶ms, + "crud_scratch", + &pk_map, + "value", + json!(null), + Some("test_schema"), + 0, + ) + .await + }, + ) + .await; + + let affected = result.as_u64().expect("update should return affected rows"); + assert_eq!(affected, 1, "NULL update should affect 1 row"); + + // Verify the value is now NULL + let verify = harness + .assert_parity( + "execute_query:verify_null_update", + |driver, params| async move { + driver + .execute_query( + ¶ms, + "SELECT value FROM test_schema.crud_scratch WHERE id = 9010", + Some(100), + 1, + Some("test_schema"), + ) + .await + }, + ) + .await; + + let rows = verify.get("rows").and_then(Value::as_array).unwrap(); + assert_eq!(rows.len(), 1); + let value = rows[0] + .as_array() + .and_then(|arr| arr.first()) + .unwrap_or(&Value::Null); + assert!( + value.is_null(), + "Value should be NULL after update, got: {:?}", + value + ); +} + +#[tokio::test] +#[ignore] +async fn parity_insert_with_default() { + require_pg!(); + let harness = ParityHarness::new().await; + + // Insert only the name column — let `id` use its DEFAULT (serial/auto-increment) + // and `value` default to NULL. + let result = harness + .assert_parity( + "insert_record:with_default", + |driver, params| async move { + let mut data = HashMap::new(); + data.insert("name".to_string(), json!("parity_default_test")); + driver + .insert_record( + ¶ms, + "crud_scratch", + data, + Some("test_schema"), + 0, + ) + .await + }, + ) + .await; + + let affected = result.as_u64().expect("insert should return affected rows"); + assert_eq!( + affected, 1, + "Insert with defaults should affect exactly 1 row" + ); + + // Verify the row was inserted (value should be NULL since we didn't set it) + let verify = harness + .assert_parity( + "execute_query:verify_default_insert", + |driver, params| async move { + driver + .execute_query( + ¶ms, + "SELECT name, value FROM test_schema.crud_scratch \ + WHERE name = 'parity_default_test' LIMIT 1", + Some(100), + 1, + Some("test_schema"), + ) + .await + }, + ) + .await; + + let rows = verify.get("rows").and_then(Value::as_array).unwrap(); + assert!(!rows.is_empty(), "Inserted row should be queryable"); +} + +#[tokio::test] +#[ignore] +async fn parity_insert_enum_value() { + require_pg!(); + let harness = ParityHarness::new().await; + + // with_enum.current_mood is a PostgreSQL enum (test_schema.mood). Binding + // an enum column requires a CAST($N AS ) — without it, the + // driver sends a plain TEXT parameter and PostgreSQL rejects it with + // "column current_mood is of type mood but expression is of type text". + // insert_record is destructive (adds a row neither target can attribute + // to a stable id), so clean up per target inside the loop. + for (target, driver) in harness.targets() { + let mut data = HashMap::new(); + data.insert("current_mood".to_string(), json!("sad")); + + let affected = driver + .insert_record(&harness.params, "with_enum", data, Some("test_schema"), 0) + .await + .unwrap_or_else(|e| panic!("insert_record with enum value failed on {}: {}", target, e)); + assert_eq!(affected, 1, "{}: inserting one enum row should affect 1 row", target); + + driver + .execute_query( + &harness.params, + "DELETE FROM test_schema.with_enum WHERE current_mood = 'sad'", + None, + 1, + Some("test_schema"), + ) + .await + .unwrap_or_else(|e| panic!("cleanup delete failed on {}: {}", target, e)); + } +} + +#[tokio::test] +#[ignore] +async fn parity_update_enum_value() { + require_pg!(); + let harness = ParityHarness::new().await; + + // Same enum-CAST requirement as parity_insert_enum_value, exercised via + // update_record instead. Seed row id=1 always exists (see + // tests/fixtures/postgres_seed.sql) with current_mood = 'happy'. + for (target, driver) in harness.targets() { + let mut pk_map = HashMap::new(); + pk_map.insert("id".to_string(), json!(1)); + + let affected = driver + .update_record( + &harness.params, + "with_enum", + &pk_map, + "current_mood", + json!("neutral"), + Some("test_schema"), + 0, + ) + .await + .unwrap_or_else(|e| panic!("update_record with enum value failed on {}: {}", target, e)); + assert_eq!(affected, 1, "{}: updating the enum column should affect 1 row", target); + + // Restore the seeded value so later tests see the original state. + driver + .update_record( + &harness.params, + "with_enum", + &pk_map, + "current_mood", + json!("happy"), + Some("test_schema"), + 0, + ) + .await + .unwrap_or_else(|e| panic!("restore failed on {}: {}", target, e)); + } +} diff --git a/src-tauri/tests/postgres_integration/parity_ddl.rs b/src-tauri/tests/postgres_integration/parity_ddl.rs new file mode 100644 index 000000000..c412e1b7f --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_ddl.rs @@ -0,0 +1,356 @@ +//! Parity tests for DDL generation — covers ALL 7 baseline tests from +//! `ddl_generation.rs`. None of these were previously covered by parity tests. +//! +//! DDL methods generate SQL without connecting to the database. The parity +//! comparison ensures the plugin generates IDENTICAL DDL strings to the builtin. + +use std::sync::Arc; + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::{ColumnDefinition, ConnectionParams}; + +use crate::parity::ParityHarness; + +/// Parity equivalent of `test_get_create_table_sql`. +/// Verifies CREATE TABLE DDL generation matches between drivers. +#[tokio::test] +#[ignore] +async fn parity_ddl_create_table() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_create_table_sql:basic", |driver, _params| async move { + 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, + }, + ColumnDefinition { + name: "name".to_string(), + data_type: "TEXT".to_string(), + is_nullable: false, + is_pk: false, + is_auto_increment: false, + default_value: None, + }, + 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()), + }, + ]; + driver + .get_create_table_sql("parity_ddl_scratch_table", columns, Some("test_schema")) + .await + }) + .await; + + let arr = result.as_array().expect("DDL should return array of statements"); + assert!(!arr.is_empty(), "Should return at least one SQL statement"); + + let sql: String = arr + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("; "); + let lower = sql.to_lowercase(); + + assert!(lower.contains("create table"), "Should contain CREATE TABLE"); + assert!( + lower.contains("parity_ddl_scratch_table"), + "Should contain table name" + ); + assert!( + lower.contains("serial") || lower.contains("generated"), + "Should handle auto-increment" + ); + assert!(lower.contains("not null"), "Should contain NOT NULL"); + assert!( + lower.contains("varchar(255)") || lower.contains("character varying(255)"), + "Should preserve varchar type" + ); +} + +/// Parity equivalent of `test_get_add_column_sql`. +/// Verifies ADD COLUMN DDL generation matches between drivers. +#[tokio::test] +#[ignore] +async fn parity_ddl_add_column() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_add_column_sql:basic", |driver, _params| async move { + let column = 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()), + }; + driver + .get_add_column_sql("all_types", column, Some("test_schema")) + .await + }) + .await; + + let arr = result.as_array().expect("DDL should return array of statements"); + assert!(!arr.is_empty()); + + let sql: String = arr + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("; "); + let lower = sql.to_lowercase(); + + assert!(lower.contains("alter table"), "Should contain ALTER TABLE"); + assert!(lower.contains("add column"), "Should contain ADD COLUMN"); + assert!(lower.contains("new_col"), "Should contain column name"); + assert!(lower.contains("integer"), "Should contain type"); +} + +/// Parity equivalent of `test_get_alter_column_rename`. +/// Verifies RENAME COLUMN DDL generation matches between drivers. +#[tokio::test] +#[ignore] +async fn parity_ddl_alter_column_rename() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_alter_column_sql:rename", + |driver, _params| async move { + let old_column = ColumnDefinition { + name: "old_name".to_string(), + data_type: "TEXT".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: None, + }; + let new_column = ColumnDefinition { + name: "new_name".to_string(), + data_type: "TEXT".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: None, + }; + driver + .get_alter_column_sql("all_types", old_column, new_column, Some("test_schema")) + .await + }, + ) + .await; + + let arr = result.as_array().expect("DDL should return array of statements"); + assert!(!arr.is_empty()); + + let sql: String = arr + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("; "); + let lower = sql.to_lowercase(); + + assert!( + lower.contains("rename column") || lower.contains("alter column"), + "Should rename" + ); + assert!(lower.contains("old_name"), "Should reference old name"); + assert!(lower.contains("new_name"), "Should reference new name"); +} + +/// Parity equivalent of `test_get_alter_column_type_change`. +/// Verifies ALTER COLUMN TYPE DDL generation matches between drivers. +#[tokio::test] +#[ignore] +async fn parity_ddl_alter_column_type_change() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_alter_column_sql:type_change", + |driver, _params| async move { + let old_column = ColumnDefinition { + name: "col_text".to_string(), + data_type: "TEXT".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: None, + }; + let new_column = ColumnDefinition { + name: "col_text".to_string(), + data_type: "VARCHAR(500)".to_string(), + is_nullable: true, + is_pk: false, + is_auto_increment: false, + default_value: None, + }; + driver + .get_alter_column_sql("all_types", old_column, new_column, Some("test_schema")) + .await + }, + ) + .await; + + let arr = result.as_array().expect("DDL should return array of statements"); + assert!(!arr.is_empty()); + + let sql: String = arr + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("; "); + let lower = sql.to_lowercase(); + + assert!( + lower.contains("type") || lower.contains("alter column"), + "Should change type, got: {}", + sql + ); +} + +/// Parity equivalent of `test_get_create_index_sql`. +/// Verifies CREATE INDEX DDL generation matches between drivers. +#[tokio::test] +#[ignore] +async fn parity_ddl_create_index() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_create_index_sql:multi_column", + |driver, _params| async move { + driver + .get_create_index_sql( + "all_types", + "idx_parity_ddl_test", + vec!["col_text".to_string(), "col_int".to_string()], + false, // not unique + Some("test_schema"), + ) + .await + }, + ) + .await; + + let arr = result.as_array().expect("DDL should return array of statements"); + assert!(!arr.is_empty()); + + let sql: String = arr + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("; "); + let lower = sql.to_lowercase(); + + assert!(lower.contains("create index"), "Should contain CREATE INDEX"); + assert!( + lower.contains("idx_parity_ddl_test"), + "Should contain index name" + ); + assert!(lower.contains("col_text"), "Should contain first column"); + assert!(lower.contains("col_int"), "Should contain second column"); +} + +/// Parity equivalent of `test_get_create_index_sql_unique`. +/// Verifies CREATE UNIQUE INDEX DDL generation matches between drivers. +#[tokio::test] +#[ignore] +async fn parity_ddl_create_index_unique() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_create_index_sql:unique", + |driver, _params| async move { + driver + .get_create_index_sql( + "all_types", + "idx_parity_ddl_unique_test", + vec!["col_varchar".to_string()], + true, // unique + Some("test_schema"), + ) + .await + }, + ) + .await; + + let arr = result.as_array().expect("DDL should return array of statements"); + let sql: String = arr + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("; "); + let lower = sql.to_lowercase(); + + assert!( + lower.contains("create unique index"), + "Should contain CREATE UNIQUE INDEX" + ); +} + +/// Parity equivalent of `test_get_create_foreign_key_sql`. +/// Verifies ADD CONSTRAINT FOREIGN KEY DDL generation matches between drivers. +#[tokio::test] +#[ignore] +async fn parity_ddl_create_foreign_key() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_create_foreign_key_sql:basic", + |driver, _params| async move { + driver + .get_create_foreign_key_sql( + "crud_scratch", + "fk_parity_ddl_test", + "value", + "all_types", + "id", + None, + None, + Some("test_schema"), + ) + .await + }, + ) + .await; + + let arr = result.as_array().expect("DDL should return array of statements"); + assert!(!arr.is_empty()); + + let sql: String = arr + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("; "); + let lower = sql.to_lowercase(); + + assert!(lower.contains("alter table"), "Should contain ALTER TABLE"); + assert!( + lower.contains("add constraint"), + "Should contain ADD CONSTRAINT" + ); + assert!(lower.contains("foreign key"), "Should contain FOREIGN KEY"); + assert!(lower.contains("references"), "Should contain REFERENCES"); +} diff --git a/src-tauri/tests/postgres_integration/parity_explain.rs b/src-tauri/tests/postgres_integration/parity_explain.rs new file mode 100644 index 000000000..6bdb66653 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_explain.rs @@ -0,0 +1,63 @@ +//! Parity tests for `explain_query` — verifies both drivers succeed on EXPLAIN. +//! +//! Note: ExplainQueryOutput differs structurally between built-in (Raw variant) +//! and plugin (Plan variant). These tests verify that both drivers return Ok +//! (no error) rather than comparing exact output, since EXPLAIN output contains +//! volatile runtime values (cost estimates, actual times, buffers). + +use tabularis_lib::drivers::driver_trait::DatabaseDriver; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_explain_simple() { + require_pg!(); + let harness = ParityHarness::new().await; + + // We cannot use assert_parity here because the output format differs. + // Instead, verify that each target returns Ok (non-error) for EXPLAIN. + for (target, driver) in harness.targets() { + let result = driver + .explain_query( + &harness.params, + "SELECT id, col_text FROM test_schema.all_types WHERE id < 5", + false, + Some("test_schema"), + ) + .await; + + assert!( + result.is_ok(), + "EXPLAIN (no analyze) failed on target {}: {:?}", + target, + result.err() + ); + } +} + +#[tokio::test] +#[ignore] +async fn parity_explain_analyze() { + require_pg!(); + let harness = ParityHarness::new().await; + + // EXPLAIN ANALYZE actually executes the query and reports timing. + for (target, driver) in harness.targets() { + let result = driver + .explain_query( + &harness.params, + "SELECT id FROM test_schema.all_types ORDER BY id LIMIT 3", + true, + Some("test_schema"), + ) + .await; + + assert!( + result.is_ok(), + "EXPLAIN ANALYZE failed on target {}: {:?}", + target, + result.err() + ); + } +} diff --git a/src-tauri/tests/postgres_integration/parity_foreign_keys.rs b/src-tauri/tests/postgres_integration/parity_foreign_keys.rs new file mode 100644 index 000000000..aa3b76767 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_foreign_keys.rs @@ -0,0 +1,118 @@ +//! Parity tests for foreign key introspection — covers baseline tests from +//! `foreign_keys.rs` that are NOT already covered in `parity_tests.rs`. +//! +//! Already covered by `parity_tests.rs`: +//! - parity_get_foreign_keys (basic: orders table, checks user_id FK) +//! +//! New in this file — each test calls `get_foreign_keys` through the trait via +//! the harness and uses `assert_parity()` for byte-perfect JSON comparison. + +use std::sync::Arc; + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::ConnectionParams; + +use crate::parity::ParityHarness; + +/// Parity equivalent of `test_get_foreign_keys_composite_table`. +/// Verifies FK introspection on order_items (FK to orders). +#[tokio::test] +#[ignore] +async fn parity_get_foreign_keys_composite_table() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_foreign_keys:order_items", + |driver, params| async move { + driver + .get_foreign_keys(¶ms, "order_items", Some("test_schema")) + .await + }, + ) + .await; + + let arr = result.as_array().expect("foreign keys should be an array"); + let order_fk = arr + .iter() + .find(|f| f.get("column_name").and_then(|v| v.as_str()) == Some("order_id")) + .expect("Expected FK on order_id"); + + assert_eq!( + order_fk.get("ref_table").and_then(|v| v.as_str()), + Some("orders"), + "order_id FK should reference orders" + ); + assert_eq!( + order_fk.get("ref_column").and_then(|v| v.as_str()), + Some("id"), + "order_id FK should reference id column" + ); + assert_eq!( + order_fk.get("on_delete").and_then(|v| v.as_str()), + Some("CASCADE"), + "Expected ON DELETE CASCADE" + ); +} + +/// Parity equivalent of `test_get_foreign_keys_cross_schema`. +/// Verifies FK introspection on a table referencing another schema. +#[tokio::test] +#[ignore] +async fn parity_get_foreign_keys_cross_schema() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_foreign_keys:with_cross_schema_fk", + |driver, params| async move { + driver + .get_foreign_keys(¶ms, "with_cross_schema_fk", Some("test_schema")) + .await + }, + ) + .await; + + let arr = result.as_array().expect("foreign keys should be an array"); + let lookup_fk = arr + .iter() + .find(|f| f.get("column_name").and_then(|v| v.as_str()) == Some("lookup_code")) + .expect("Expected FK on lookup_code"); + + assert_eq!( + lookup_fk.get("ref_table").and_then(|v| v.as_str()), + Some("lookup"), + "lookup_code FK should reference lookup table" + ); + assert_eq!( + lookup_fk.get("ref_column").and_then(|v| v.as_str()), + Some("code"), + "lookup_code FK should reference code column" + ); +} + +/// Parity equivalent of `test_get_foreign_keys_table_without_fks`. +/// Verifies a table with no foreign keys returns an empty array from both drivers. +#[tokio::test] +#[ignore] +async fn parity_get_foreign_keys_table_without_fks() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_foreign_keys:crud_scratch:empty", + |driver, params| async move { + driver + .get_foreign_keys(¶ms, "crud_scratch", Some("test_schema")) + .await + }, + ) + .await; + + let arr = result.as_array().expect("foreign keys should be an array"); + assert!(arr.is_empty(), "crud_scratch has no foreign keys"); +} diff --git a/src-tauri/tests/postgres_integration/parity_indexes.rs b/src-tauri/tests/postgres_integration/parity_indexes.rs new file mode 100644 index 000000000..73d0c8aaa --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_indexes.rs @@ -0,0 +1,177 @@ +//! Parity tests for index introspection — covers baseline tests from +//! `indexes.rs` that are NOT already covered in `parity_tests.rs`. +//! +//! Already covered by `parity_tests.rs`: +//! - parity_get_indexes (basic: all_types table, checks non-empty) +//! +//! New in this file — each test calls `get_indexes` through the trait via the +//! harness and uses `assert_parity()` for byte-perfect JSON comparison. + +use std::sync::Arc; + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::ConnectionParams; + +use crate::parity::ParityHarness; + +/// Parity equivalent of `test_get_indexes_btree`. +/// Verifies a specific btree index is present with correct attributes. +#[tokio::test] +#[ignore] +async fn parity_get_indexes_btree() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_indexes:all_types:btree", + |driver, params| async move { + driver.get_indexes(¶ms, "all_types", Some("test_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("indexes should be an array"); + let idx = arr + .iter() + .find(|i| i.get("name").and_then(|n| n.as_str()) == Some("idx_all_types_text")) + .expect("Expected idx_all_types_text index"); + + assert_eq!( + idx.get("column_name").and_then(|v| v.as_str()), + Some("col_text"), + "idx_all_types_text should be on col_text" + ); + assert_eq!( + idx.get("is_unique").and_then(|v| v.as_bool()), + Some(false), + "idx_all_types_text should not be unique" + ); + assert_eq!( + idx.get("is_primary").and_then(|v| v.as_bool()), + Some(false), + "idx_all_types_text should not be primary" + ); +} + +/// Parity equivalent of `test_get_indexes_unique`. +/// Verifies a unique index is reported with correct flags. +#[tokio::test] +#[ignore] +async fn parity_get_indexes_unique() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_indexes:all_types:unique", + |driver, params| async move { + driver.get_indexes(¶ms, "all_types", Some("test_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("indexes should be an array"); + let idx = arr + .iter() + .find(|i| i.get("name").and_then(|n| n.as_str()) == Some("idx_all_types_uuid")) + .expect("Expected idx_all_types_uuid unique index"); + + assert_eq!( + idx.get("column_name").and_then(|v| v.as_str()), + Some("col_uuid"), + "idx_all_types_uuid should be on col_uuid" + ); + assert_eq!( + idx.get("is_unique").and_then(|v| v.as_bool()), + Some(true), + "idx_all_types_uuid should be unique" + ); +} + +/// Parity equivalent of `test_get_indexes_composite`. +/// Verifies a composite (multi-column) index is reported with correct seq_in_index. +#[tokio::test] +#[ignore] +async fn parity_get_indexes_composite() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_indexes:order_items:composite", + |driver, params| async move { + driver.get_indexes(¶ms, "order_items", Some("test_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("indexes should be an array"); + + // The composite index idx_order_items_composite covers (order_id, product) + let idx_entries: Vec<&Value> = arr + .iter() + .filter(|i| i.get("name").and_then(|n| n.as_str()) == Some("idx_order_items_composite")) + .collect(); + + assert_eq!( + idx_entries.len(), + 2, + "Composite index should have 2 entries (one per column)" + ); + + // Verify seq_in_index ordering + let first = idx_entries + .iter() + .find(|i| i.get("seq_in_index").and_then(|v| v.as_u64()) == Some(1)) + .expect("should have entry with seq_in_index=1"); + assert_eq!( + first.get("column_name").and_then(|v| v.as_str()), + Some("order_id") + ); + + let second = idx_entries + .iter() + .find(|i| i.get("seq_in_index").and_then(|v| v.as_u64()) == Some(2)) + .expect("should have entry with seq_in_index=2"); + assert_eq!( + second.get("column_name").and_then(|v| v.as_str()), + Some("product") + ); +} + +/// Parity equivalent of `test_get_indexes_primary_key`. +/// Verifies the primary key index is correctly reported. +#[tokio::test] +#[ignore] +async fn parity_get_indexes_primary_key() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_indexes:all_types:primary_key", + |driver, params| async move { + driver.get_indexes(¶ms, "all_types", Some("test_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("indexes should be an array"); + let pk = arr + .iter() + .find(|i| i.get("is_primary").and_then(|v| v.as_bool()) == Some(true)) + .expect("Expected primary key index"); + + assert_eq!( + pk.get("column_name").and_then(|v| v.as_str()), + Some("id"), + "PK should be on id column" + ); + assert_eq!( + pk.get("is_unique").and_then(|v| v.as_bool()), + Some(true), + "PK index should also be unique" + ); +} diff --git a/src-tauri/tests/postgres_integration/parity_multi_db.rs b/src-tauri/tests/postgres_integration/parity_multi_db.rs new file mode 100644 index 000000000..aaf444625 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_multi_db.rs @@ -0,0 +1,97 @@ +//! Parity tests for multi-database operations — using the secondary database +//! (tabularis_test_secondary) with its `secondary_schema.remote_data` table. + +use std::sync::Arc; + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::ConnectionParams; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_get_schemas_secondary() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity_secondary( + "get_schemas:secondary", + |driver, params| async move { driver.get_schemas(¶ms).await }, + ) + .await; + + let schemas: Vec = serde_json::from_value(result).unwrap(); + assert!( + schemas.contains(&"secondary_schema".to_string()), + "secondary database should contain secondary_schema, got: {:?}", + schemas + ); +} + +#[tokio::test] +#[ignore] +async fn parity_get_columns_secondary() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity_secondary( + "get_columns:remote_data", + |driver, params| async move { + driver + .get_columns(¶ms, "remote_data", Some("secondary_schema")) + .await + }, + ) + .await; + + let columns = result.as_array().expect("columns should be an array"); + assert!( + !columns.is_empty(), + "remote_data table should have columns" + ); + + let col_names: Vec<&str> = columns + .iter() + .filter_map(|c| c.get("name").and_then(Value::as_str)) + .collect(); + assert!( + col_names.contains(&"id"), + "remote_data should have an id column, got: {:?}", + col_names + ); + assert!( + col_names.contains(&"value"), + "remote_data should have a value column, got: {:?}", + col_names + ); +} + +#[tokio::test] +#[ignore] +async fn parity_execute_query_secondary() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity_secondary( + "execute_query:secondary", + |driver, params| async move { + driver + .execute_query( + ¶ms, + "SELECT id, value FROM secondary_schema.remote_data ORDER BY id LIMIT 5", + Some(100), + 1, + Some("secondary_schema"), + ) + .await + }, + ) + .await; + + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + assert!(!rows.is_empty(), "secondary query should return rows"); +} diff --git a/src-tauri/tests/postgres_integration/parity_multi_db_extra.rs b/src-tauri/tests/postgres_integration/parity_multi_db_extra.rs new file mode 100644 index 000000000..4c9d93aa4 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_multi_db_extra.rs @@ -0,0 +1,128 @@ +//! Extra parity tests for multi-database operations — get_databases_lists_both, +//! get_tables_secondary, pool_isolation, fallback_to_maintenance_db. + +use std::sync::Arc; + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::{ConnectionParams, DatabaseSelection}; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_get_databases_lists_both() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_databases:lists_both", + |driver, params| async move { driver.get_databases(¶ms).await }, + ) + .await; + + let databases: Vec = serde_json::from_value(result).unwrap(); + assert!( + databases.contains(&"testdb".to_string()), + "Should list testdb, got: {:?}", + databases + ); + assert!( + databases.contains(&"tabularis_test_secondary".to_string()), + "Should list tabularis_test_secondary, got: {:?}", + databases + ); +} + +#[tokio::test] +#[ignore] +async fn parity_get_tables_secondary_schema() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity_secondary( + "get_tables:secondary_extra", + |driver, params| async move { + driver.get_tables(¶ms, Some("secondary_schema")).await + }, + ) + .await; + + let tables = result.as_array().expect("tables should be an array"); + let table_names: Vec<&str> = tables + .iter() + .filter_map(|t| t.get("name").and_then(Value::as_str)) + .collect(); + assert!( + table_names.contains(&"remote_data"), + "Expected remote_data table in secondary, got: {:?}", + table_names + ); +} + +#[tokio::test] +#[ignore] +async fn parity_pool_isolation_between_databases() { + require_pg!(); + let harness = ParityHarness::new().await; + + // Primary database should see test_schema tables + let primary_result = harness + .assert_parity( + "get_tables:primary_pool_isolation", + |driver, params| async move { + driver.get_tables(¶ms, Some("test_schema")).await + }, + ) + .await; + + let primary_tables = primary_result.as_array().expect("tables should be array"); + assert!( + !primary_tables.is_empty(), + "Primary db should have test_schema tables" + ); + + // Secondary database should NOT have test_schema + let secondary_result = harness + .assert_parity_secondary( + "get_schemas:pool_isolation_secondary", + |driver, params| async move { driver.get_schemas(¶ms).await }, + ) + .await; + + let secondary_schemas: Vec = serde_json::from_value(secondary_result).unwrap(); + assert!( + !secondary_schemas.contains(&"test_schema".to_string()), + "test_schema should not exist in secondary database, got: {:?}", + secondary_schemas + ); +} + +#[tokio::test] +#[ignore] +async fn parity_fallback_to_maintenance_db() { + require_pg!(); + let harness = ParityHarness::new().await; + + // Connect with "postgres" maintenance database — should be able to list databases + let result = harness + .assert_parity( + "get_databases:maintenance_db", + |driver, params| async move { + let mut maint_params = params.clone(); + maint_params.database = + DatabaseSelection::Single("postgres".to_string()); + driver.get_databases(&maint_params).await + }, + ) + .await; + + let databases: Vec = serde_json::from_value(result).unwrap(); + assert!( + databases.contains(&"testdb".to_string()), + "Maintenance db should list testdb, got: {:?}", + databases + ); +} diff --git a/src-tauri/tests/postgres_integration/parity_mv_extra.rs b/src-tauri/tests/postgres_integration/parity_mv_extra.rs new file mode 100644 index 000000000..c58839514 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_mv_extra.rs @@ -0,0 +1,38 @@ +//! Extra parity tests for materialized views — MV definition error behavior. +//! +//! The built-in PostgreSQL driver has a known bug where +//! `get_materialized_view_definition` fails with "error serializing parameter 0" +//! on PG 16. The plugin MUST replicate this exact failure semantics (both must +//! either succeed identically or both fail). + +use std::sync::Arc; + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::ConnectionParams; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_get_materialized_view_definition_error() { + require_pg!(); + let harness = ParityHarness::new().await; + + // This exercises the known bug: both drivers must produce the same error + // semantics (both fail or both succeed with the same result). + harness + .assert_error_parity( + "get_materialized_view_definition:user_stats", + |driver, params| async move { + driver + .get_materialized_view_definition( + ¶ms, + "user_stats", + Some("test_schema"), + ) + .await + }, + ) + .await; +} diff --git a/src-tauri/tests/postgres_integration/parity_query.rs b/src-tauri/tests/postgres_integration/parity_query.rs new file mode 100644 index 000000000..13bb581e6 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_query.rs @@ -0,0 +1,216 @@ +//! Parity tests for `execute_query` — ensures plugin produces identical query +//! results to the built-in driver. + +use std::sync::Arc; + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::ConnectionParams; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_execute_query_basic_select() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("execute_query:basic_select", |driver, params| async move { + driver + .execute_query( + ¶ms, + "SELECT id, col_text FROM test_schema.all_types ORDER BY id LIMIT 5", + Some(100), + 1, + Some("test_schema"), + ) + .await + }) + .await; + + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + assert!(!rows.is_empty()); + assert!(rows.len() <= 5); +} + +#[tokio::test] +#[ignore] +async fn parity_execute_query_all_types() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("execute_query:all_types", |driver, params| async move { + driver + .execute_query( + ¶ms, + "SELECT * FROM test_schema.all_types WHERE id = 1", + Some(100), + 1, + Some("test_schema"), + ) + .await + }) + .await; + + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + assert_eq!(rows.len(), 1, "expected exactly one row for id = 1"); +} + +#[tokio::test] +#[ignore] +async fn parity_execute_query_with_pagination() { + require_pg!(); + let harness = ParityHarness::new().await; + + // The seed only guarantees 2 rows in all_types, so paginate with limit=1 + // across 2 pages rather than limit=2 (which would leave page 2 empty). + let page1 = harness + .assert_parity( + "execute_query:pagination_page1", + |driver, params| async move { + driver + .execute_query( + ¶ms, + "SELECT id, col_text FROM test_schema.all_types ORDER BY id", + Some(1), + 1, + Some("test_schema"), + ) + .await + }, + ) + .await; + + let rows_p1 = page1.get("rows").and_then(Value::as_array).unwrap(); + assert_eq!(rows_p1.len(), 1, "page 1 should have exactly 1 row"); + + // Page 2 with limit 1 — should return a different row + let page2 = harness + .assert_parity( + "execute_query:pagination_page2", + |driver, params| async move { + driver + .execute_query( + ¶ms, + "SELECT id, col_text FROM test_schema.all_types ORDER BY id", + Some(1), + 2, + Some("test_schema"), + ) + .await + }, + ) + .await; + + let rows_p2 = page2.get("rows").and_then(Value::as_array).unwrap(); + assert_eq!(rows_p2.len(), 1, "page 2 should have exactly 1 row"); + assert_ne!(rows_p1, rows_p2, "pages should return different rows"); +} + +#[tokio::test] +#[ignore] +async fn parity_execute_query_dml() { + require_pg!(); + let harness = ParityHarness::new().await; + + // Use UPDATE on a known scratch row to verify affected_rows handling. + // First insert a row to update. + let _ = harness + .assert_parity("execute_query:dml_setup", |driver, params| async move { + driver + .execute_query( + ¶ms, + "INSERT INTO test_schema.crud_scratch(name, value) VALUES ('dml_parity', 0) ON CONFLICT DO NOTHING", + Some(100), + 1, + Some("test_schema"), + ) + .await + }) + .await; + + let result = harness + .assert_parity("execute_query:dml_update", |driver, params| async move { + driver + .execute_query( + ¶ms, + "UPDATE test_schema.crud_scratch SET value = value + 1 WHERE name = 'dml_parity'", + Some(100), + 1, + Some("test_schema"), + ) + .await + }) + .await; + + // DML queries should report affected_rows + let affected = result.get("affected_rows").and_then(Value::as_u64); + assert!( + affected.is_some(), + "DML result should include affected_rows field" + ); + assert!(affected.unwrap() >= 1); +} + +#[tokio::test] +#[ignore] +async fn parity_execute_query_null_handling() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "execute_query:null_handling", + |driver, params| async move { + driver + .execute_query( + ¶ms, + "SELECT NULL AS null_col, id FROM test_schema.all_types WHERE id = 1", + Some(100), + 1, + Some("test_schema"), + ) + .await + }, + ) + .await; + + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + assert_eq!(rows.len(), 1); + let row = &rows[0]; + // The null column should be present and null + let null_val = row.get("null_col").or_else(|| { + // Some drivers return rows as arrays + row.as_array().and_then(|arr| arr.first()) + }); + assert!( + null_val.is_some(), + "null column should be present in result" + ); +} + +#[tokio::test] +#[ignore] +async fn parity_execute_query_count() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("execute_query:count", |driver, params| async move { + driver + .execute_query( + ¶ms, + "SELECT COUNT(*) AS cnt FROM test_schema.all_types", + Some(100), + 1, + Some("test_schema"), + ) + .await + }) + .await; + + let rows = result.get("rows").and_then(Value::as_array).unwrap(); + assert_eq!(rows.len(), 1, "COUNT query should return exactly one row"); +} diff --git a/src-tauri/tests/postgres_integration/parity_query_extra.rs b/src-tauri/tests/postgres_integration/parity_query_extra.rs new file mode 100644 index 000000000..d2a933934 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_query_extra.rs @@ -0,0 +1,116 @@ +//! Extra parity tests for query execution — affected_rows_for_dml, batch_session_state. + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_execute_query_affected_rows_for_dml() { + require_pg!(); + let harness = ParityHarness::new().await; + + // Insert into scratch table — DML should report affected_rows = 1 + // and return no columns/rows. + let result = harness + .assert_parity( + "execute_query:affected_rows_dml", + |driver, params| async move { + driver + .execute_query( + ¶ms, + "INSERT INTO test_schema.crud_scratch (name, value) \ + VALUES ('parity_affected_rows', 1)", + None, + 1, + Some("test_schema"), + ) + .await + }, + ) + .await; + + // DML returns affected_rows = 1 + let affected = result + .get("affected_rows") + .and_then(Value::as_u64) + .unwrap_or(0); + assert_eq!(affected, 1, "INSERT should affect exactly 1 row"); + + // DML returns no columns + let columns = result + .get("columns") + .and_then(Value::as_array) + .map(|a| a.len()) + .unwrap_or(0); + assert_eq!(columns, 0, "DML should return no columns"); + + // DML returns no rows + let rows = result + .get("rows") + .and_then(Value::as_array) + .map(|a| a.len()) + .unwrap_or(0); + assert_eq!(rows, 0, "DML should return no rows"); +} + +#[tokio::test] +#[ignore] +async fn parity_execute_batch_session_state() { + require_pg!(); + let harness = ParityHarness::new().await; + + // execute_batch returns Vec, and each entry + // carries execution_time_ms: Option — a genuine wall-clock value + // that can never byte-match between two separate driver processes. + // assert_parity's exact comparison is the wrong tool here (same class + // of issue fixed in parity_batch.rs) — call each target directly and + // check only the deterministic fields. + let statements = vec![ + "BEGIN".to_string(), + "CREATE TEMP TABLE _parity_batch_test (x INT)".to_string(), + "INSERT INTO _parity_batch_test VALUES (42)".to_string(), + "SELECT x FROM _parity_batch_test".to_string(), + "COMMIT".to_string(), + ]; + + for (target, driver) in harness.targets() { + let result = driver + .execute_batch(&harness.params, &statements, Some(100), 1, Some("test_schema"), None) + .await + .unwrap_or_else(|e| panic!("execute_batch failed on {}: {}", target, e)); + let arr = serde_json::to_value(&result).expect("serialize batch result"); + let arr = arr.as_array().expect("batch result should be an array"); + + assert!( + arr.len() >= 4, + "{}: expected at least 4 results, got: {}", + target, + arr.len() + ); + + // The SELECT result (4th statement, index 3) should return the inserted value + let select_result = &arr[3]; + let succeeded = select_result.get("error").map(Value::is_null).unwrap_or(false); + assert!( + succeeded, + "{}: SELECT from temp table should succeed, got: {:?}", + target, select_result + ); + + if let Some(result_obj) = select_result.get("result") { + if let Some(rows) = result_obj.get("rows").and_then(Value::as_array) { + assert_eq!(rows.len(), 1, "{}: SELECT should return 1 row", target); + if let Some(row) = rows.first().and_then(Value::as_array) { + assert_eq!( + row.first().and_then(Value::as_i64), + Some(42), + "{}: temp table should contain value 42", + target + ); + } + } + } + } +} diff --git a/src-tauri/tests/postgres_integration/parity_routines_extra.rs b/src-tauri/tests/postgres_integration/parity_routines_extra.rs new file mode 100644 index 000000000..eedf7a565 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_routines_extra.rs @@ -0,0 +1,115 @@ +//! Extra parity tests for routines — overloaded functions, procedures, drop_routine. + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_get_routines_overloaded_functions() { + require_pg!(); + let harness = ParityHarness::new().await; + + // add_numbers is overloaded: (int, int) and (int, int, int). + // Both drivers must return the same number of overloaded entries. + let result = harness + .assert_parity( + "get_routines:overloaded", + |driver, params| async move { + driver.get_routines(¶ms, Some("test_schema")).await + }, + ) + .await; + + let routines = result.as_array().expect("routines should be an array"); + let add_numbers_count = routines + .iter() + .filter(|r| r.get("name").and_then(Value::as_str) == Some("add_numbers")) + .count(); + assert_eq!( + add_numbers_count, 2, + "Expected 2 overloaded add_numbers functions, got: {}", + add_numbers_count + ); +} + +#[tokio::test] +#[ignore] +async fn parity_get_routines_lists_procedures() { + require_pg!(); + let harness = ParityHarness::new().await; + + // Verify that procedures (not just functions) appear in get_routines. + let result = harness + .assert_parity( + "get_routines:procedures", + |driver, params| async move { + driver.get_routines(¶ms, Some("test_schema")).await + }, + ) + .await; + + let routines = result.as_array().expect("routines should be an array"); + let proc_names: Vec<&str> = routines + .iter() + .filter(|r| r.get("routine_type").and_then(Value::as_str) == Some("PROCEDURE")) + .filter_map(|r| r.get("name").and_then(Value::as_str)) + .collect(); + + assert!( + proc_names.contains(&"reset_orders"), + "Expected reset_orders procedure in routine list, got: {:?}", + proc_names + ); +} + +#[tokio::test] +#[ignore] +async fn parity_drop_routine() { + require_pg!(); + let harness = ParityHarness::new().await; + + // drop_routine is destructive against the one shared physical database + // both targets point at — assert_parity calls each target in sequence, + // so the second target's drop would legitimately fail with "function + // does not exist" once the first target already dropped it. Re-create + // (idempotent via CREATE OR REPLACE) before each target's drop attempt. + for (target, driver) in harness.targets() { + driver + .execute_query( + &harness.params, + "CREATE OR REPLACE FUNCTION test_schema.parity_drop_fn(a INT) \ + RETURNS INT LANGUAGE SQL AS $$ SELECT a $$", + None, + 1, + Some("test_schema"), + ) + .await + .unwrap_or_else(|e| panic!("setup create function failed on {}: {}", target, e)); + + driver + .drop_routine(&harness.params, "parity_drop_fn", "FUNCTION", Some("test_schema")) + .await + .unwrap_or_else(|e| panic!("drop_routine failed on {}: {}", target, e)); + } + + // Verify it's gone by checking that the routine no longer appears + let result = harness + .assert_parity( + "get_routines:after_drop", + |driver, params| async move { + driver.get_routines(¶ms, Some("test_schema")).await + }, + ) + .await; + + let routines = result.as_array().expect("routines should be an array"); + let found = routines + .iter() + .any(|r| r.get("name").and_then(Value::as_str) == Some("parity_drop_fn")); + assert!( + !found, + "Dropped function parity_drop_fn should not appear in routine list" + ); +} diff --git a/src-tauri/tests/postgres_integration/parity_routines_full.rs b/src-tauri/tests/postgres_integration/parity_routines_full.rs new file mode 100644 index 000000000..745a99cdb --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_routines_full.rs @@ -0,0 +1,109 @@ +//! Parity tests for routine (function/procedure) and trigger introspection. + +use std::sync::Arc; + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::ConnectionParams; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_get_routine_parameters() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_routine_parameters:add_numbers", + |driver, params| async move { + driver + .get_routine_parameters(¶ms, "add_numbers", Some("test_schema")) + .await + }, + ) + .await; + + let params_arr = result + .as_array() + .expect("routine parameters should be an array"); + assert!( + !params_arr.is_empty(), + "add_numbers should have parameters" + ); + + // Verify parameter names are present + let names: Vec<&str> = params_arr + .iter() + .filter_map(|p| p.get("name").and_then(Value::as_str)) + .collect(); + assert!( + names.contains(&"a") || names.contains(&"b"), + "add_numbers parameters should include a and/or b, got: {:?}", + names + ); +} + +#[tokio::test] +#[ignore] +async fn parity_get_routine_definition() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_routine_definition:add_numbers", + |driver, params| async move { + driver + .get_routine_definition( + ¶ms, + "add_numbers", + "function", + Some("test_schema"), + ) + .await + }, + ) + .await; + + let definition = result.as_str().expect("routine definition should be a string"); + assert!( + !definition.is_empty(), + "add_numbers definition should not be empty" + ); + // The function body should reference addition + assert!( + definition.contains('+') || definition.to_lowercase().contains("return"), + "add_numbers definition should contain arithmetic or RETURN" + ); +} + +#[tokio::test] +#[ignore] +async fn parity_get_trigger_definition() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_trigger_definition:trg_audit", + |driver, params| async move { + driver + .get_trigger_definition(¶ms, "trg_audit", "all_types", Some("test_schema")) + .await + }, + ) + .await; + + let definition = result.as_str().expect("trigger definition should be a string"); + assert!( + !definition.is_empty(), + "trg_audit definition should not be empty" + ); + assert!( + definition.to_lowercase().contains("trigger") + || definition.to_lowercase().contains("execute"), + "trigger definition should reference TRIGGER or EXECUTE" + ); +} diff --git a/src-tauri/tests/postgres_integration/parity_schema_discovery.rs b/src-tauri/tests/postgres_integration/parity_schema_discovery.rs new file mode 100644 index 000000000..4bb6ed08b --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_schema_discovery.rs @@ -0,0 +1,45 @@ +//! Parity tests for schema discovery — covers baseline tests from +//! `schema_discovery.rs` that are NOT already covered in `parity_tests.rs`. +//! +//! Already covered by `parity_tests.rs`: +//! - parity_get_schemas (covers test_get_schemas_returns_test_schema) +//! - parity_get_databases (covers test_get_databases_returns_testdb) +//! - parity_get_tables (covers test_get_tables_returns_seeded_tables) +//! +//! New in this file: +//! - parity_get_tables_other_schema (covers test_get_tables_other_schema) + +use std::sync::Arc; + +use serde_json::Value; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::ConnectionParams; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_get_tables_other_schema() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_tables:other_schema", + |driver, params| async move { + driver.get_tables(¶ms, Some("other_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("tables should be an array"); + let names: Vec<&str> = arr + .iter() + .filter_map(|t| t.get("name")?.as_str()) + .collect(); + assert!( + names.contains(&"lookup"), + "Expected lookup table in other_schema, got: {:?}", + names + ); +} diff --git a/src-tauri/tests/postgres_integration/parity_tests.rs b/src-tauri/tests/postgres_integration/parity_tests.rs new file mode 100644 index 000000000..7ddaea39c --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_tests.rs @@ -0,0 +1,252 @@ +//! Parity integration tests — run driver methods through the harness to prove +//! equivalence across driver implementations. +//! +//! In Phase 0 these serve as a structural validation that the harness works +//! correctly with the built-in driver. In Phase 1 they become the gate: the +//! plugin must produce identical outputs. + +use std::sync::Arc; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::models::ConnectionParams; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_get_schemas() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_schemas", |driver, params| async move { + driver.get_schemas(¶ms).await + }) + .await; + + let schemas: Vec = serde_json::from_value(result).unwrap(); + assert!(schemas.contains(&"test_schema".to_string())); + assert!(schemas.contains(&"public".to_string())); +} + +#[tokio::test] +#[ignore] +async fn parity_get_databases() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_databases", |driver, params| async move { + driver.get_databases(¶ms).await + }) + .await; + + let databases: Vec = serde_json::from_value(result).unwrap(); + assert!(databases.contains(&"testdb".to_string())); +} + +#[tokio::test] +#[ignore] +async fn parity_get_tables() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_tables", |driver, params| async move { + driver.get_tables(¶ms, Some("test_schema")).await + }) + .await; + + let arr = result.as_array().expect("tables should be an array"); + let names: Vec<&str> = arr + .iter() + .filter_map(|t| t.get("name")?.as_str()) + .collect(); + assert!(names.contains(&"all_types")); + assert!(names.contains(&"orders")); +} + +#[tokio::test] +#[ignore] +async fn parity_get_columns() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_columns:all_types", |driver, params| async move { + driver.get_columns(¶ms, "all_types", Some("test_schema")).await + }) + .await; + + let arr = result.as_array().expect("columns should be an array"); + assert!(!arr.is_empty()); + let id_col = arr.iter().find(|c| c.get("name").and_then(|n| n.as_str()) == Some("id")); + assert!(id_col.is_some(), "should have an 'id' column"); + assert_eq!(id_col.unwrap().get("is_pk").and_then(|v| v.as_bool()), Some(true)); +} + +#[tokio::test] +#[ignore] +async fn parity_get_foreign_keys() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_foreign_keys:orders", |driver, params| async move { + driver.get_foreign_keys(¶ms, "orders", Some("test_schema")).await + }) + .await; + + let arr = result.as_array().expect("foreign keys should be an array"); + assert!(!arr.is_empty()); + let fk = &arr[0]; + assert_eq!(fk.get("column_name").and_then(|v| v.as_str()), Some("user_id")); +} + +#[tokio::test] +#[ignore] +async fn parity_get_indexes() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_indexes:all_types", |driver, params| async move { + driver.get_indexes(¶ms, "all_types", Some("test_schema")).await + }) + .await; + + let arr = result.as_array().expect("indexes should be an array"); + assert!(!arr.is_empty()); +} + +#[tokio::test] +#[ignore] +async fn parity_get_views() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_views", |driver, params| async move { + driver.get_views(¶ms, Some("test_schema")).await + }) + .await; + + let arr = result.as_array().expect("views should be an array"); + let names: Vec<&str> = arr.iter().filter_map(|v| v.get("name")?.as_str()).collect(); + assert!(names.contains(&"active_users")); +} + +#[tokio::test] +#[ignore] +async fn parity_get_view_definition() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_view_definition:active_users", |driver, params| async move { + driver.get_view_definition(¶ms, "active_users", Some("test_schema")).await + }) + .await; + + let def = result.as_str().expect("view definition should be a string"); + assert!(def.to_lowercase().contains("select")); +} + +#[tokio::test] +#[ignore] +async fn parity_get_materialized_views() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_materialized_views", |driver, params| async move { + driver.get_materialized_views(¶ms, Some("test_schema")).await + }) + .await; + + let arr = result.as_array().expect("materialized views should be an array"); + let names: Vec<&str> = arr.iter().filter_map(|v| v.get("name")?.as_str()).collect(); + assert!(names.contains(&"user_stats")); +} + +#[tokio::test] +#[ignore] +async fn parity_get_routines() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_routines", |driver, params| async move { + driver.get_routines(¶ms, Some("test_schema")).await + }) + .await; + + let arr = result.as_array().expect("routines should be an array"); + let names: Vec<&str> = arr.iter().filter_map(|r| r.get("name")?.as_str()).collect(); + assert!(names.contains(&"add_numbers")); +} + +#[tokio::test] +#[ignore] +async fn parity_get_triggers() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("get_triggers", |driver, params| async move { + driver.get_triggers(¶ms, Some("test_schema")).await + }) + .await; + + let arr = result.as_array().expect("triggers should be an array"); + let names: Vec<&str> = arr.iter().filter_map(|t| t.get("name")?.as_str()).collect(); + assert!(names.contains(&"trg_audit")); +} + +#[tokio::test] +#[ignore] +async fn parity_get_tables_secondary() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity_secondary( + "get_tables:secondary", + |driver, params| async move { + driver.get_tables(¶ms, Some("secondary_schema")).await + }, + ) + .await; + + let arr = result.as_array().expect("tables should be an array"); + let names: Vec<&str> = arr.iter().filter_map(|t| t.get("name")?.as_str()).collect(); + assert!(names.contains(&"remote_data")); +} + +#[tokio::test] +#[ignore] +async fn parity_map_inferred_type() { + require_pg!(); + let harness = ParityHarness::new().await; + + // map_inferred_type is synchronous — test it directly on each target + for (target, driver) in harness.targets() { + assert_eq!( + driver.map_inferred_type("DATETIME"), + "TIMESTAMP", + "map_inferred_type(DATETIME) failed on {}", + target + ); + assert_eq!( + driver.map_inferred_type("JSON"), + "JSONB", + "map_inferred_type(JSON) failed on {}", + target + ); + assert_eq!( + driver.map_inferred_type("TEXT"), + "TEXT", + "map_inferred_type(TEXT) passthrough failed on {}", + target + ); + } +} diff --git a/src-tauri/tests/postgres_integration/parity_triggers_extra.rs b/src-tauri/tests/postgres_integration/parity_triggers_extra.rs new file mode 100644 index 000000000..63277762f --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_triggers_extra.rs @@ -0,0 +1,88 @@ +//! Extra parity tests for triggers — create/drop trigger and empty schema. + +use tabularis_lib::drivers::driver_trait::DatabaseDriver; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_create_and_drop_trigger() { + require_pg!(); + let harness = ParityHarness::new().await; + + let trigger_name = "trg_parity_temp"; + let table_name = "crud_scratch"; + let schema = Some("test_schema"); + + // Cleanup from any prior failed run + for (_target, driver) in harness.targets() { + let _ = driver + .drop_trigger(&harness.params, trigger_name, table_name, schema) + .await; + } + + // create_trigger/drop_trigger are destructive against the one shared + // physical database both targets point at — assert_parity calls each + // target in sequence, so the second target's CREATE would legitimately + // fail with "trigger already exists" (created by the first target) and + // its DROP would legitimately fail with "trigger does not exist" (already + // dropped by the first target). Run create+drop directly per target + // instead, so each target creates its own copy and drops its own copy. + let create_sql = format!( + "CREATE TRIGGER {} BEFORE INSERT ON test_schema.{} \ + FOR EACH ROW EXECUTE FUNCTION test_schema.audit_trigger_fn()", + trigger_name, table_name + ); + + for (target, driver) in harness.targets() { + driver + .create_trigger(&harness.params, &create_sql, Some("test_schema")) + .await + .unwrap_or_else(|e| panic!("create_trigger failed on {}: {}", target, e)); + + // Verify the trigger exists by listing triggers (read-only, safe to + // check per-target since both point at the same live state). + let triggers = driver + .get_triggers(&harness.params, Some("test_schema")) + .await + .unwrap_or_else(|e| panic!("get_triggers failed on {}: {}", target, e)); + let found = triggers.iter().any(|t| t.name == trigger_name); + assert!(found, "{}: created trigger {} should appear in list", target, trigger_name); + + driver + .drop_trigger(&harness.params, trigger_name, table_name, Some("test_schema")) + .await + .unwrap_or_else(|e| panic!("drop_trigger failed on {}: {}", target, e)); + + let triggers = driver + .get_triggers(&harness.params, Some("test_schema")) + .await + .unwrap_or_else(|e| panic!("get_triggers (after drop) failed on {}: {}", target, e)); + let still_found = triggers.iter().any(|t| t.name == trigger_name); + assert!(!still_found, "{}: dropped trigger {} should not appear in list", target, trigger_name); + } +} + +#[tokio::test] +#[ignore] +async fn parity_get_triggers_empty_schema() { + require_pg!(); + let harness = ParityHarness::new().await; + + // other_schema has no triggers — both drivers should return an empty list + let result = harness + .assert_parity( + "get_triggers:empty_schema", + |driver, params| async move { + driver.get_triggers(¶ms, Some("other_schema")).await + }, + ) + .await; + + let triggers = result.as_array().expect("triggers should be an array"); + assert!( + triggers.is_empty(), + "other_schema should have no triggers, got: {:?}", + triggers + ); +} diff --git a/src-tauri/tests/postgres_integration/parity_views_extra.rs b/src-tauri/tests/postgres_integration/parity_views_extra.rs new file mode 100644 index 000000000..8ba1baf35 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_views_extra.rs @@ -0,0 +1,78 @@ +//! Extra parity tests for views — alter_view and empty schema scenarios. + +use tabularis_lib::drivers::driver_trait::DatabaseDriver; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_alter_view() { + require_pg!(); + let harness = ParityHarness::new().await; + + let view_name = "parity_alter_view"; + let schema = Some("test_schema"); + let def1 = "SELECT id FROM test_schema.all_types"; + let def2 = "SELECT id, col_text FROM test_schema.all_types"; + + // create_view/drop_view are destructive against the one shared physical + // database both targets point at — assert_parity calls each target in + // sequence, so the second target's plain CREATE VIEW would legitimately + // fail with "view already exists" and its DROP would legitimately fail + // with "view does not exist" once the first target already did so. + // alter_view itself uses CREATE OR REPLACE (idempotent), so it's safe + // under assert_parity — but the surrounding create/drop are not. Run the + // whole create->alter->verify->drop sequence directly per target. + for (target, driver) in harness.targets() { + // Cleanup from any prior failed run. + let _ = driver.drop_view(&harness.params, view_name, schema).await; + + driver + .create_view(&harness.params, view_name, def1, schema) + .await + .unwrap_or_else(|e| panic!("create_view failed on {}: {}", target, e)); + + driver + .alter_view(&harness.params, view_name, def2, schema) + .await + .unwrap_or_else(|e| panic!("alter_view failed on {}: {}", target, e)); + + let columns = driver + .get_view_columns(&harness.params, view_name, schema) + .await + .unwrap_or_else(|e| panic!("get_view_columns failed on {}: {}", target, e)); + assert_eq!( + columns.len(), + 2, + "{}: altered view should have 2 columns, got: {}", + target, + columns.len() + ); + + driver + .drop_view(&harness.params, view_name, schema) + .await + .unwrap_or_else(|e| panic!("drop_view (cleanup) failed on {}: {}", target, e)); + } +} + +#[tokio::test] +#[ignore] +async fn parity_get_views_empty_schema() { + require_pg!(); + let harness = ParityHarness::new().await; + + // other_schema has no views — both drivers should return an empty list + let result = harness + .assert_parity("get_views:empty_schema", |driver, params| async move { + driver.get_views(¶ms, Some("other_schema")).await + }) + .await; + + let views = result.as_array().expect("views should be an array"); + assert!( + views.is_empty(), + "other_schema should have no views, got: {:?}", + views + ); +} diff --git a/src-tauri/tests/postgres_integration/parity_views_full.rs b/src-tauri/tests/postgres_integration/parity_views_full.rs new file mode 100644 index 000000000..39c7f8e97 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_views_full.rs @@ -0,0 +1,130 @@ +//! Parity tests for view and materialized view lifecycle operations. + +use tabularis_lib::drivers::driver_trait::DatabaseDriver; + +use crate::parity::ParityHarness; + +#[tokio::test] +#[ignore] +async fn parity_get_view_columns() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_view_columns:active_users", + |driver, params| async move { + driver + .get_view_columns(¶ms, "active_users", Some("test_schema")) + .await + }, + ) + .await; + + let columns = result.as_array().expect("view columns should be an array"); + assert!( + !columns.is_empty(), + "active_users view should have columns" + ); +} + +#[tokio::test] +#[ignore] +async fn parity_create_drop_view() { + require_pg!(); + let harness = ParityHarness::new().await; + + let view_name = "parity_temp_view"; + let definition = "SELECT id, col_text FROM test_schema.all_types WHERE id < 10"; + + // create_view/drop_view are destructive against the one shared physical + // database both targets point at — assert_parity calls each target in + // sequence, so the second target's plain CREATE VIEW would legitimately + // fail with "view already exists" (created by the first target) and its + // DROP would legitimately fail with "view does not exist" (already + // dropped by the first target). Run create+verify+drop directly per + // target instead, so each target creates and drops its own view. + for (target, driver) in harness.targets() { + // Cleanup from any prior failed run. + let _ = driver.drop_view(&harness.params, view_name, Some("test_schema")).await; + + driver + .create_view(&harness.params, view_name, definition, Some("test_schema")) + .await + .unwrap_or_else(|e| panic!("create_view failed on {}: {}", target, e)); + + let columns = driver + .get_view_columns(&harness.params, view_name, Some("test_schema")) + .await + .unwrap_or_else(|e| panic!("get_view_columns failed on {}: {}", target, e)); + assert!(!columns.is_empty(), "{}: temp view should have columns", target); + + driver + .drop_view(&harness.params, view_name, Some("test_schema")) + .await + .unwrap_or_else(|e| panic!("drop_view failed on {}: {}", target, e)); + + // get_view_columns queries information_schema.columns filtered by + // table name, so a dropped view returns Ok(empty), not Err. + let columns_after_drop = driver + .get_view_columns(&harness.params, view_name, Some("test_schema")) + .await + .unwrap_or_else(|e| { + panic!( + "get_view_columns on dropped view should return Ok(empty), not Err, on {}: {}", + target, e + ) + }); + assert!( + columns_after_drop.is_empty(), + "{}: view should have no columns after drop, got: {:?}", + target, + columns_after_drop + ); + } +} + +#[tokio::test] +#[ignore] +async fn parity_get_materialized_view_columns() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity( + "get_materialized_view_columns:user_stats", + |driver, params| async move { + driver + .get_materialized_view_columns(¶ms, "user_stats", Some("test_schema")) + .await + }, + ) + .await; + + let columns = result + .as_array() + .expect("materialized view columns should be an array"); + assert!( + !columns.is_empty(), + "user_stats materialized view should have columns" + ); +} + +#[tokio::test] +#[ignore] +async fn parity_refresh_materialized_view() { + require_pg!(); + let harness = ParityHarness::new().await; + + // refresh_materialized_view returns () on success — verify no error + harness + .assert_parity( + "refresh_materialized_view:user_stats", + |driver, params| async move { + driver + .refresh_materialized_view(¶ms, "user_stats", Some("test_schema")) + .await + }, + ) + .await; +} diff --git a/src-tauri/tests/postgres_integration/query_execution.rs b/src-tauri/tests/postgres_integration/query_execution.rs new file mode 100644 index 000000000..c7d6b6f1b --- /dev/null +++ b/src-tauri/tests/postgres_integration/query_execution.rs @@ -0,0 +1,174 @@ +//! Query execution tests. + +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_execute_query_basic_select() { + require_pg!(); + let params = pg_params(); + + let result = postgres::execute_query( + ¶ms, + "SELECT id, col_text, col_int FROM test_schema.all_types ORDER BY id LIMIT 1", + Some(100), + 1, + None, + ) + .await + .expect("execute_query should succeed"); + + assert_eq!(result.columns, vec!["id", "col_text", "col_int"]); + assert!(!result.rows.is_empty(), "Should have at least one row"); + + // First row should have id=1 from seed + let first_row = &result.rows[0]; + assert_eq!(first_row[0], serde_json::json!(1)); // id + assert_eq!(first_row[1], serde_json::json!("hello")); // col_text + assert_eq!(first_row[2], serde_json::json!(42)); // col_int +} + +#[tokio::test] +#[ignore] +async fn test_execute_query_with_pagination() { + require_pg!(); + let params = pg_params(); + + // Page 1 with limit 1 + let page1 = postgres::execute_query( + ¶ms, + "SELECT id FROM test_schema.all_types ORDER BY id", + Some(1), + 1, + None, + ) + .await + .expect("page 1"); + + assert_eq!(page1.rows.len(), 1); + assert_eq!(page1.rows[0][0], serde_json::json!(1)); + assert!( + page1.pagination.as_ref().map_or(false, |p| p.has_more), + "Should have more pages" + ); + + // Page 2 + let page2 = postgres::execute_query( + ¶ms, + "SELECT id FROM test_schema.all_types ORDER BY id", + Some(1), + 2, + None, + ) + .await + .expect("page 2"); + + assert_eq!(page2.rows.len(), 1); + assert_eq!(page2.rows[0][0], serde_json::json!(2)); +} + +#[tokio::test] +#[ignore] +async fn test_execute_query_all_types_roundtrip() { + require_pg!(); + let params = pg_params(); + + let result = postgres::execute_query( + ¶ms, + "SELECT * FROM test_schema.all_types WHERE id = 1", + None, + 1, + None, + ) + .await + .expect("execute_query should succeed"); + + assert_eq!(result.rows.len(), 1, "Expected exactly 1 row"); + let row = &result.rows[0]; + + // Verify key type extractions produce valid JSON values (not null for seeded data) + let col_idx = |name: &str| result.columns.iter().position(|c| c == name).unwrap(); + + assert!(!row[col_idx("col_text")].is_null()); + assert!(!row[col_idx("col_int")].is_null()); + assert!(!row[col_idx("col_bool")].is_null()); + assert!(!row[col_idx("col_uuid")].is_null()); + assert!(!row[col_idx("col_jsonb")].is_null()); + assert!(!row[col_idx("col_int_array")].is_null()); + assert!(!row[col_idx("col_timestamptz")].is_null()); +} + +#[tokio::test] +#[ignore] +async fn test_execute_query_null_handling() { + require_pg!(); + let params = pg_params(); + + // Row 2 was seeded with only col_text (NULL) — most columns are null + // except col_uuid which has DEFAULT gen_random_uuid() + let result = postgres::execute_query( + ¶ms, + "SELECT col_text, col_int, col_bool, col_bytea FROM test_schema.all_types WHERE id = 2", + None, + 1, + None, + ) + .await + .expect("execute_query should succeed"); + + assert_eq!(result.rows.len(), 1); + let row = &result.rows[0]; + // These columns have no default and weren't set — should be null + for (i, val) in row.iter().enumerate() { + assert!(val.is_null(), "Column {} expected null, got: {:?}", result.columns[i], val); + } +} + +#[tokio::test] +#[ignore] +async fn test_execute_query_affected_rows_for_dml() { + require_pg!(); + let params = pg_params(); + + // Insert into scratch table + let result = postgres::execute_query( + ¶ms, + "INSERT INTO test_schema.crud_scratch (name, value) VALUES ('test', 1)", + None, + 1, + None, + ) + .await + .expect("INSERT should succeed"); + + assert_eq!(result.affected_rows, 1); + assert!(result.columns.is_empty(), "DML returns no columns"); + assert!(result.rows.is_empty(), "DML returns no rows"); +} + +#[tokio::test] +#[ignore] +async fn test_execute_batch_session_state() { + require_pg!(); + let params = pg_params(); + + // Batch with transaction + temp table — session state must persist + let statements: Vec = vec![ + "BEGIN".into(), + "CREATE TEMP TABLE _batch_test (x INT)".into(), + "INSERT INTO _batch_test VALUES (42)".into(), + "SELECT x FROM _batch_test".into(), + "COMMIT".into(), + ]; + + let results = postgres::execute_batch(¶ms, &statements, Some(100), 1, None, None) + .await + .expect("execute_batch should succeed"); + + // The SELECT result (4th statement, index 3) should return the inserted value + assert!(results.len() >= 4, "Expected at least 4 results"); + let select_result = results[3].result.as_ref().expect("SELECT should produce a result"); + assert_eq!(select_result.rows.len(), 1); + assert_eq!(select_result.rows[0][0], serde_json::json!(42)); +} diff --git a/src-tauri/tests/postgres_integration/routines.rs b/src-tauri/tests/postgres_integration/routines.rs new file mode 100644 index 000000000..08042366c --- /dev/null +++ b/src-tauri/tests/postgres_integration/routines.rs @@ -0,0 +1,126 @@ +//! Routine (function/procedure) management tests. + +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_get_routines_lists_functions() { + require_pg!(); + let params = pg_params(); + + let routines = postgres::get_routines(¶ms, "test_schema") + .await + .expect("get_routines should succeed"); + + let routine_names: Vec<&str> = routines.iter().map(|r| r.name.as_str()).collect(); + assert!( + routine_names.contains(&"add_numbers"), + "Expected add_numbers function, got: {:?}", + routine_names + ); + assert!( + routine_names.contains(&"get_user"), + "Expected get_user function" + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_routines_lists_procedures() { + require_pg!(); + let params = pg_params(); + + let routines = postgres::get_routines(¶ms, "test_schema") + .await + .expect("get_routines should succeed"); + + let proc_names: Vec<&str> = routines + .iter() + .filter(|r| r.routine_type == "PROCEDURE") + .map(|r| r.name.as_str()) + .collect(); + + assert!( + proc_names.contains(&"reset_orders"), + "Expected reset_orders procedure, got: {:?}", + proc_names + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_routines_overloaded_functions() { + require_pg!(); + let params = pg_params(); + + let routines = postgres::get_routines(¶ms, "test_schema") + .await + .expect("get_routines should succeed"); + + // add_numbers is overloaded: (int, int) and (int, int, int) + let add_numbers_count = routines.iter().filter(|r| r.name == "add_numbers").count(); + assert_eq!( + add_numbers_count, 2, + "Expected 2 overloaded add_numbers functions" + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_routine_parameters() { + require_pg!(); + let params = pg_params(); + + let routine_params = postgres::get_routine_parameters(¶ms, "add_numbers", "test_schema") + .await + .expect("get_routine_parameters should succeed"); + + // At least 2 params (from the 2-arg version); may include params from both overloads + assert!( + routine_params.len() >= 2, + "add_numbers should have at least 2 parameters, got: {}", + routine_params.len() + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_routine_definition() { + require_pg!(); + let params = pg_params(); + + let def = postgres::get_routine_definition(¶ms, "get_user", "FUNCTION", "test_schema") + .await + .expect("get_routine_definition should succeed"); + + assert!( + def.to_lowercase().contains("select") || def.to_lowercase().contains("function"), + "Function definition should contain SQL, got: {}", + def + ); +} + +#[tokio::test] +#[ignore] +async fn test_drop_routine() { + require_pg!(); + let params = pg_params(); + + // Create a temporary function to test drop + postgres::execute_query( + ¶ms, + "CREATE OR REPLACE FUNCTION test_schema.temp_drop_test(a INT) RETURNS INT LANGUAGE SQL AS $$ SELECT a $$", + None, + 1, + None, + ) + .await + .expect("create function"); + + // Drop it + let drop_result = postgres::drop_routine(¶ms, "temp_drop_test", "FUNCTION", "test_schema") + .await; + + assert!(drop_result.is_ok(), "drop_routine should succeed: {:?}", drop_result.err()); +} diff --git a/src-tauri/tests/postgres_integration/schema_discovery.rs b/src-tauri/tests/postgres_integration/schema_discovery.rs new file mode 100644 index 000000000..4e8fa82cc --- /dev/null +++ b/src-tauri/tests/postgres_integration/schema_discovery.rs @@ -0,0 +1,86 @@ +//! Schema discovery tests: get_schemas, get_databases, get_tables. + +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_get_schemas_returns_test_schema() { + require_pg!(); + let params = pg_params(); + + let schemas = postgres::get_schemas(¶ms) + .await + .expect("get_schemas should succeed"); + + assert!( + schemas.contains(&"test_schema".to_string()), + "Expected test_schema in schemas list, got: {:?}", + schemas + ); + assert!( + schemas.contains(&"other_schema".to_string()), + "Expected other_schema in schemas list" + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_databases_returns_testdb() { + require_pg!(); + let params = pg_params(); + + let databases = postgres::get_databases(¶ms) + .await + .expect("get_databases should succeed"); + + assert!( + databases.contains(&"testdb".to_string()), + "Expected testdb in databases list, got: {:?}", + databases + ); + assert!( + databases.contains(&"tabularis_test_secondary".to_string()), + "Expected tabularis_test_secondary in databases list" + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_tables_returns_seeded_tables() { + require_pg!(); + let params = pg_params(); + + let tables = postgres::get_tables(¶ms, "test_schema") + .await + .expect("get_tables should succeed"); + + let table_names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect(); + + assert!(table_names.contains(&"all_types"), "Expected all_types table"); + assert!(table_names.contains(&"with_enum"), "Expected with_enum table"); + assert!(table_names.contains(&"orders"), "Expected orders table"); + assert!(table_names.contains(&"order_items"), "Expected order_items table"); + assert!( + table_names.contains(&"with_cross_schema_fk"), + "Expected with_cross_schema_fk table" + ); + assert!( + table_names.contains(&"crud_scratch"), + "Expected crud_scratch table" + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_tables_other_schema() { + require_pg!(); + let params = pg_params(); + + let tables = postgres::get_tables(¶ms, "other_schema") + .await + .expect("get_tables for other_schema should succeed"); + + let table_names: Vec<&str> = tables.iter().map(|t| t.name.as_str()).collect(); + assert!(table_names.contains(&"lookup"), "Expected lookup table in other_schema"); +} diff --git a/src-tauri/tests/postgres_integration/triggers.rs b/src-tauri/tests/postgres_integration/triggers.rs new file mode 100644 index 000000000..fafe1fd07 --- /dev/null +++ b/src-tauri/tests/postgres_integration/triggers.rs @@ -0,0 +1,98 @@ +//! Trigger management tests. + +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_get_triggers() { + require_pg!(); + let params = pg_params(); + + let triggers = postgres::get_triggers(¶ms, "test_schema") + .await + .expect("get_triggers should succeed"); + + let trigger_names: Vec<&str> = triggers.iter().map(|t| t.name.as_str()).collect(); + assert!( + trigger_names.contains(&"trg_audit"), + "Expected trg_audit trigger, got: {:?}", + trigger_names + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_trigger_definition() { + require_pg!(); + let params = pg_params(); + + let def = postgres::get_trigger_definition(¶ms, "trg_audit", "all_types", "test_schema") + .await + .expect("get_trigger_definition should succeed"); + + assert!( + def.to_lowercase().contains("after update"), + "Trigger definition should indicate AFTER UPDATE, got: {}", + def + ); + assert!( + def.to_lowercase().contains("all_types"), + "Trigger definition should reference all_types table" + ); +} + +#[tokio::test] +#[ignore] +async fn test_create_and_drop_trigger() { + require_pg!(); + let params = pg_params(); + + let trigger_name = "trg_test_temp"; + let schema = "test_schema"; + + // Cleanup from any prior failed run + let _ = postgres::drop_trigger(¶ms, trigger_name, "crud_scratch", schema).await; + + // Create trigger (reuse existing trigger function) + let create_sql = format!( + "CREATE TRIGGER {} BEFORE INSERT ON {}.crud_scratch \ + FOR EACH ROW EXECUTE FUNCTION {}.audit_trigger_fn()", + trigger_name, schema, schema + ); + postgres::create_trigger(¶ms, &create_sql, schema) + .await + .expect("create_trigger should succeed"); + + // Verify exists + let triggers = postgres::get_triggers(¶ms, schema).await.unwrap(); + assert!( + triggers.iter().any(|t| t.name == trigger_name), + "Created trigger should appear in list" + ); + + // Drop + postgres::drop_trigger(¶ms, trigger_name, "crud_scratch", schema) + .await + .expect("drop_trigger should succeed"); + + // Verify gone + let triggers = postgres::get_triggers(¶ms, schema).await.unwrap(); + assert!( + !triggers.iter().any(|t| t.name == trigger_name), + "Dropped trigger should not appear in list" + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_triggers_empty_schema() { + require_pg!(); + let params = pg_params(); + + let triggers = postgres::get_triggers(¶ms, "other_schema") + .await + .expect("get_triggers should succeed for schema with no triggers"); + + assert!(triggers.is_empty(), "other_schema should have no triggers"); +} diff --git a/src-tauri/tests/postgres_integration/views.rs b/src-tauri/tests/postgres_integration/views.rs new file mode 100644 index 000000000..e25739245 --- /dev/null +++ b/src-tauri/tests/postgres_integration/views.rs @@ -0,0 +1,151 @@ +//! View management tests. + +use tabularis_lib::drivers::postgres; +use crate::helpers::pg_params; + +#[tokio::test] +#[ignore] +async fn test_get_views() { + require_pg!(); + let params = pg_params(); + + let views = postgres::get_views(¶ms, "test_schema") + .await + .expect("get_views should succeed"); + + let view_names: Vec<&str> = views.iter().map(|v| v.name.as_str()).collect(); + assert!( + view_names.contains(&"active_users"), + "Expected active_users view, got: {:?}", + view_names + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_view_definition() { + require_pg!(); + let params = pg_params(); + + let def = postgres::get_view_definition(¶ms, "active_users", "test_schema") + .await + .expect("get_view_definition should succeed"); + + assert!( + def.to_lowercase().contains("select"), + "View definition should contain SELECT, got: {}", + def + ); + assert!( + def.to_lowercase().contains("all_types"), + "View definition should reference all_types table" + ); +} + +#[tokio::test] +#[ignore] +async fn test_get_view_columns() { + require_pg!(); + let params = pg_params(); + + let columns = postgres::get_view_columns(¶ms, "active_users", "test_schema") + .await + .expect("get_view_columns should succeed"); + + let col_names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect(); + assert!(col_names.contains(&"id"), "Expected id column in view"); + assert!(col_names.contains(&"name"), "Expected name column in view"); + assert!(col_names.contains(&"is_active"), "Expected is_active column in view"); +} + +#[tokio::test] +#[ignore] +async fn test_create_and_drop_view() { + require_pg!(); + let params = pg_params(); + + let view_name = "test_temp_view"; + let schema = "test_schema"; + let definition = "SELECT id, col_text FROM test_schema.all_types WHERE id < 10"; + + // Create + postgres::create_view(¶ms, view_name, definition, schema) + .await + .expect("create_view should succeed"); + + // Verify exists + let views = postgres::get_views(¶ms, schema).await.unwrap(); + assert!( + views.iter().any(|v| v.name == view_name), + "Created view should appear in list" + ); + + // Drop + postgres::drop_view(¶ms, view_name, schema) + .await + .expect("drop_view should succeed"); + + // Verify gone + let views = postgres::get_views(¶ms, schema).await.unwrap(); + assert!( + !views.iter().any(|v| v.name == view_name), + "Dropped view should not appear in list" + ); +} + +#[tokio::test] +#[ignore] +async fn test_alter_view() { + require_pg!(); + let params = pg_params(); + + let view_name = "test_alter_view"; + let schema = "test_schema"; + + // Cleanup from any prior failed run + let _ = postgres::drop_view(¶ms, view_name, schema).await; + + // Create initial view + let def1 = "SELECT id FROM test_schema.all_types"; + crate::helpers::retry(|| { + let p = params.clone(); + async move { postgres::create_view(&p, view_name, def1, schema).await } + }) + .await + .expect("create_view should succeed"); + + // Alter (replace) with new definition + let def2 = "SELECT id, col_text FROM test_schema.all_types"; + crate::helpers::retry(|| { + let p = params.clone(); + async move { postgres::alter_view(&p, view_name, def2, schema).await } + }) + .await + .expect("alter_view should succeed"); + + // Verify new definition has both columns + let columns = crate::helpers::retry(|| { + let p = params.clone(); + async move { postgres::get_view_columns(&p, view_name, schema).await } + }) + .await + .unwrap(); + assert_eq!(columns.len(), 2, "Altered view should have 2 columns"); + + // Cleanup + postgres::drop_view(¶ms, view_name, schema).await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn test_get_views_empty_schema() { + require_pg!(); + let params = pg_params(); + + // other_schema has no views + let views = postgres::get_views(¶ms, "other_schema") + .await + .expect("get_views should succeed for schema with no views"); + + assert!(views.is_empty(), "other_schema should have no views"); +} diff --git a/tests/fixtures/postgres_seed.sql b/tests/fixtures/postgres_seed.sql new file mode 100644 index 000000000..232bae730 --- /dev/null +++ b/tests/fixtures/postgres_seed.sql @@ -0,0 +1,226 @@ +-- PostgreSQL integration test seed. +-- Idempotent: uses IF NOT EXISTS / OR REPLACE throughout. +-- Creates objects in test_schema (not public) to avoid conflicts with +-- existing integration tests that use the public schema. + +CREATE SCHEMA IF NOT EXISTS test_schema; + +-- ============================================================================= +-- Core type-coverage table (exercises every common PG type) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS test_schema.all_types ( + id SERIAL PRIMARY KEY, + col_text TEXT, + col_varchar VARCHAR(255), + col_int INTEGER, + col_bigint BIGINT, + col_smallint SMALLINT, + col_float REAL, + col_double DOUBLE PRECISION, + col_numeric NUMERIC(10,2), + col_bool BOOLEAN, + col_date DATE, + col_time TIME, + col_timetz TIME WITH TIME ZONE, + 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, + col_interval INTERVAL +); + +-- Seed rows for query/extraction tests +INSERT INTO test_schema.all_types ( + col_text, col_varchar, col_int, col_bigint, col_smallint, + col_float, col_double, col_numeric, col_bool, + col_date, col_time, col_timetz, col_timestamp, col_timestamptz, + col_uuid, + col_json, col_jsonb, col_bytea, col_inet, col_cidr, col_macaddr, + col_int_array, col_text_array, col_int4range, col_tsrange, col_interval +) SELECT + 'hello', 'world', 42, 9223372036854775807, 32767, + 3.14, 2.718281828459045, 12345.67, TRUE, + '2026-01-15', '14:30:00', '14:30:00+02', '2026-01-15 14:30:00', '2026-01-15 14:30:00+00', + 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::uuid, + '{"key": "value"}', '{"nested": {"arr": [1,2,3]}}', + '\xDEADBEEF', '192.168.1.1', '10.0.0.0/8', '08:00:2b:01:02:03', + ARRAY[1,2,3], ARRAY['a','b','c'], '[1,10)', '[2026-01-01, 2026-12-31)', + '1 year 2 months 3 days' +WHERE NOT EXISTS (SELECT 1 FROM test_schema.all_types LIMIT 1); + +-- NULL row for null-handling tests +INSERT INTO test_schema.all_types (col_text) +SELECT NULL +WHERE (SELECT COUNT(*) FROM test_schema.all_types) < 2; + +-- ============================================================================= +-- Enum type +-- ============================================================================= +DO $$ BEGIN + CREATE TYPE test_schema.mood AS ENUM ('happy', 'sad', 'neutral'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS test_schema.with_enum ( + id SERIAL PRIMARY KEY, + current_mood test_schema.mood NOT NULL DEFAULT 'neutral' +); + +INSERT INTO test_schema.with_enum (current_mood) +SELECT 'happy' +WHERE NOT EXISTS (SELECT 1 FROM test_schema.with_enum LIMIT 1); + +-- ============================================================================= +-- Foreign key relationships (single PK and composite PK) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS test_schema.orders ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES test_schema.all_types(id) ON DELETE CASCADE, + amount NUMERIC(10,2) NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS test_schema.order_items ( + order_id INTEGER NOT NULL, + item_no INTEGER NOT NULL, + product TEXT NOT NULL, + PRIMARY KEY (order_id, item_no), + FOREIGN KEY (order_id) REFERENCES test_schema.orders(id) ON DELETE CASCADE +); + +-- Seed FK data +INSERT INTO test_schema.orders (user_id, amount) +SELECT 1, 99.99 +WHERE NOT EXISTS (SELECT 1 FROM test_schema.orders LIMIT 1); + +INSERT INTO test_schema.order_items (order_id, item_no, product) +SELECT 1, 1, 'Widget' +WHERE NOT EXISTS (SELECT 1 FROM test_schema.order_items LIMIT 1); + +-- ============================================================================= +-- Indexes (btree, unique, partial, composite) +-- ============================================================================= +CREATE INDEX IF NOT EXISTS idx_all_types_text + ON test_schema.all_types (col_text); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_all_types_uuid + ON test_schema.all_types (col_uuid); + +CREATE INDEX IF NOT EXISTS idx_orders_amount_positive + ON test_schema.orders (amount) + WHERE amount > 0; + +CREATE INDEX IF NOT EXISTS idx_order_items_composite + ON test_schema.order_items (order_id, product); + +-- ============================================================================= +-- Views +-- ============================================================================= +CREATE OR REPLACE VIEW test_schema.active_users AS + SELECT id, col_text AS name, col_bool AS is_active + FROM test_schema.all_types + WHERE col_bool = TRUE; + +-- ============================================================================= +-- Materialized views +-- ============================================================================= +-- DROP + CREATE because CREATE ... IF NOT EXISTS doesn't exist for MVs +DO $$ BEGIN + PERFORM 1 FROM pg_matviews + WHERE schemaname = 'test_schema' AND matviewname = 'user_stats'; + IF NOT FOUND THEN + EXECUTE 'CREATE MATERIALIZED VIEW test_schema.user_stats AS + SELECT COUNT(*) AS total, MAX(id) AS max_id + FROM test_schema.all_types'; + END IF; +END $$; + +-- ============================================================================= +-- Functions (including overloaded) +-- ============================================================================= +CREATE OR REPLACE FUNCTION test_schema.add_numbers(a INTEGER, b INTEGER) + RETURNS INTEGER + LANGUAGE SQL + IMMUTABLE +AS $$ SELECT a + b $$; + +CREATE OR REPLACE FUNCTION test_schema.add_numbers(a INTEGER, b INTEGER, c INTEGER) + RETURNS INTEGER + LANGUAGE SQL + IMMUTABLE +AS $$ SELECT a + b + c $$; + +CREATE OR REPLACE FUNCTION test_schema.get_user(p_id INTEGER) + RETURNS TABLE(id INTEGER, name TEXT) + LANGUAGE SQL + STABLE +AS $$ + SELECT id, col_text FROM test_schema.all_types WHERE id = p_id +$$; + +-- ============================================================================= +-- Procedures +-- ============================================================================= +CREATE OR REPLACE PROCEDURE test_schema.reset_orders() + LANGUAGE SQL +AS $$ + DELETE FROM test_schema.order_items; + DELETE FROM test_schema.orders; +$$; + +-- ============================================================================= +-- Triggers +-- ============================================================================= +CREATE OR REPLACE FUNCTION test_schema.audit_trigger_fn() + RETURNS TRIGGER + LANGUAGE plpgsql +AS $$ +BEGIN + -- In a real app this would log to an audit table + RAISE NOTICE 'Row modified in %', TG_TABLE_NAME; + RETURN NEW; +END $$; + +-- Drop and recreate trigger (no IF NOT EXISTS for triggers) +DROP TRIGGER IF EXISTS trg_audit ON test_schema.all_types; +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 IF NOT EXISTS other_schema.lookup ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL +); + +INSERT INTO other_schema.lookup (code, label) +SELECT 'A', 'Alpha' +WHERE NOT EXISTS (SELECT 1 FROM other_schema.lookup WHERE code = 'A'); + +CREATE TABLE IF NOT EXISTS test_schema.with_cross_schema_fk ( + id SERIAL PRIMARY KEY, + lookup_code TEXT REFERENCES other_schema.lookup(code) +); + +-- ============================================================================= +-- CRUD scratch table (tests can freely mutate this; truncated between test runs) +-- ============================================================================= +CREATE TABLE IF NOT EXISTS test_schema.crud_scratch ( + id SERIAL PRIMARY KEY, + name TEXT, + value INTEGER +); +TRUNCATE test_schema.crud_scratch RESTART IDENTITY; diff --git a/tests/fixtures/seed_postgres.sh b/tests/fixtures/seed_postgres.sh new file mode 100755 index 000000000..e431cb561 --- /dev/null +++ b/tests/fixtures/seed_postgres.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Seed script for PostgreSQL integration tests. +# Idempotent — safe to run multiple times. +# +# Expected environment: +# PG on localhost:54320, user=postgres, password=password, db=testdb +# (matches the GitHub Actions service and existing integration tests) +set -euo pipefail + +PGHOST="${PGHOST:-127.0.0.1}" +PGPORT="${PGPORT:-54320}" +PGUSER="${PGUSER:-postgres}" +PGPASSWORD="${PGPASSWORD:-password}" +export PGHOST PGPORT PGUSER PGPASSWORD + +# The existing integration tests expect a database called "testdb" with their +# own tables in the public schema. We don't touch those — our parity tests use +# a dedicated "test_schema" within the same database. + +echo "==> Seeding primary database (testdb)..." +psql -d testdb -f "$(dirname "$0")/postgres_seed.sql" + +# Secondary database for multi-database testing +echo "==> Creating secondary database (tabularis_test_secondary)..." +psql -d postgres -c " + SELECT 'exists' FROM pg_database WHERE datname = 'tabularis_test_secondary' +" | grep -q exists || createdb tabularis_test_secondary + +echo "==> Seeding secondary database..." +psql -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 NOT NULL + ); + + INSERT INTO secondary_schema.remote_data (value) + SELECT 'row_' || g + FROM generate_series(1, 5) g + WHERE NOT EXISTS (SELECT 1 FROM secondary_schema.remote_data LIMIT 1); +" + +echo "==> PostgreSQL seed complete."