diff --git a/.github/workflows/pg-integration.yml b/.github/workflows/pg-integration.yml new file mode 100644 index 000000000..e86bb0c90 --- /dev/null +++ b/.github/workflows/pg-integration.yml @@ -0,0 +1,87 @@ +name: Integration Tests (PostgreSQL) + +concurrency: + group: pg-integration-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'src-tauri/**' + - '.github/workflows/pg-integration.yml' + - 'tests/fixtures/**' + pull_request: + branches: [main] + paths: + - 'src-tauri/**' + - '.github/workflows/pg-integration.yml' + - 'tests/fixtures/**' + workflow_dispatch: + inputs: + regenerate_golden: + description: 'Regenerate golden files and upload as an artifact for review (does not commit)' + type: boolean + default: false + +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 + key: ${{ runner.os }}-cargo-pg-${{ hashFiles('src-tauri/Cargo.lock') }} + 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: Run PostgreSQL integration tests + working-directory: src-tauri + env: + RUST_TEST_THREADS: "1" + REGENERATE_GOLDEN: ${{ (github.event_name == 'workflow_dispatch' && inputs.regenerate_golden) && '1' || '' }} + run: cargo test --test postgres_integration -- --include-ignored + + - name: Upload golden files + if: github.event_name == 'workflow_dispatch' && inputs.regenerate_golden + uses: actions/upload-artifact@v4 + with: + name: golden-files + path: src-tauri/tests/postgres_integration/golden/ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4fe7f19ce..eb7602fba 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6459,7 +6459,7 @@ dependencies = [ [[package]] name = "tabularis" -version = "0.18.0" +version = "0.19.0" dependencies = [ "aes-gcm", "argon2", diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 45cb1eb52..3730e322a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1424,8 +1424,9 @@ pub async fn duplicate_connection( pub async fn get_connections( app: AppHandle, ) -> Result, String> { - // Run migration if needed + // Run migrations if needed migrate_ssh_connections(&app).await.ok(); + migrate_postgres_ssl_mode_spelling(&app).await.ok(); let path = get_config_path(&app)?; // Use persistence function that handles both old and new formats @@ -1559,6 +1560,120 @@ async fn migrate_ssh_connections(app: &AppHandle) -> Result<(), S Ok(()) } +// ==================== PostgreSQL Plugin SSL Mode Migration ==================== + +/// The SSL mode dropdown used to branch on `driver === "postgres"` literally +/// (issue #614), so a postgres-dialect driver with a different id (e.g. the +/// standalone PostgreSQL plugin, id `"postgresql"`) offered MySQL-style +/// underscored `ssl_mode` values instead of Postgres-style hyphenated ones. +/// The plugin's own TLS check only recognizes the hyphenated spelling, so a +/// connection saved with the wrong-family value connects in cleartext with no +/// error. Fixing the dropdown only stops *new* saves from getting the wrong +/// value — this rewrites values already persisted before the fix shipped. +/// +/// Maps a stale MySQL-style `ssl_mode` value to its Postgres-style +/// equivalent. Returns `None` for a value that isn't one of the stale +/// MySQL-style spellings (including values already correct, or driver- +/// specific values like ClickHouse's `"disable"`/`"require"`, which happen to +/// already be spelled correctly in both families and need no rewrite). +fn stale_postgres_ssl_mode_replacement(value: &str) -> Option<&'static str> { + match value { + "disabled" => Some("disable"), + "preferred" => Some("prefer"), + "required" => Some("require"), + "verify_ca" => Some("verify-ca"), + "verify_identity" => Some("verify-full"), + _ => None, + } +} + +/// Rewrites `conn.params.ssl_mode` in place if `conn`'s driver resolves (via +/// `dialects`) to the postgres SQL dialect and its stored value is a stale +/// MySQL-style spelling. Builtin `"postgres"` connections are excluded — +/// their own dropdown was always correct, so nothing there needs migrating. +/// A driver whose manifest doesn't declare `sql_dialect` (`None`, or absent +/// from the map because it never resolved) is treated as NOT postgres — +/// deliberately not defaulted, unlike the splitter's own historical +/// postgres-default, because this migration must distinguish "explicitly +/// postgres" from "unspecified" to avoid rewriting a driver's SSL value +/// based on a guess. +/// Pure and synchronous so it can be exercised directly in tests without a +/// live driver registry. Returns whether a rewrite happened. +fn migrate_connection_ssl_mode_in_place( + conn: &mut SavedConnection, + dialects: &HashMap>, +) -> bool { + if conn.params.driver == "postgres" { + return false; + } + let is_postgres_dialect = dialects + .get(&conn.params.driver) + .copied() + .flatten() + .is_some_and(|d| d == crate::drivers::driver_trait::SqlDialect::Postgres); + if !is_postgres_dialect { + return false; + } + let Some(stale) = conn.params.ssl_mode.as_deref() else { + return false; + }; + let Some(replacement) = stale_postgres_ssl_mode_replacement(stale) else { + return false; + }; + conn.params.ssl_mode = Some(replacement.to_string()); + true +} + +/// Migrates already-persisted `ssl_mode` values on postgres-dialect +/// connections (driver id other than the builtin `"postgres"`) from the +/// stale MySQL-style spelling to the Postgres-style spelling the plugin +/// actually understands. Idempotent — a no-op once every affected +/// connection has been rewritten. +async fn migrate_postgres_ssl_mode_spelling(app: &AppHandle) -> Result<(), String> { + let conn_path = get_config_path(app)?; + if !conn_path.exists() { + return Ok(()); // Nothing to migrate + } + + let mut conn_file = persistence::load_connections_file(&conn_path)?; + + // Resolve each distinct non-builtin driver id's dialect once, not once + // per connection — the registry lookup is async and connections commonly + // share a driver. + let mut dialects: HashMap> = + HashMap::new(); + for conn in &conn_file.connections { + let driver_id = &conn.params.driver; + if driver_id == "postgres" || dialects.contains_key(driver_id) { + continue; // builtin driver's own dropdown was always correct + } + if let Some(driver) = crate::drivers::registry::get_driver(driver_id).await { + dialects.insert( + driver_id.clone(), + driver.manifest().capabilities.sql_dialect, + ); + } + } + + let mut migrated_count = 0usize; + for conn in conn_file.connections.iter_mut() { + if migrate_connection_ssl_mode_in_place(conn, &dialects) { + migrated_count += 1; + } + } + + if migrated_count == 0 { + return Ok(()); // No migration needed + } + + eprintln!( + "[Migration] Rewriting stale ssl_mode spelling on {} postgres-dialect connection(s)", + migrated_count + ); + save_connections_and_invalidate(app, &conn_path, &conn_file)?; + Ok(()) +} + #[tauri::command] pub async fn get_ssh_connections( app: AppHandle, @@ -2458,6 +2573,156 @@ mod tests { } } + #[test] + fn stale_postgres_ssl_mode_replacement_maps_every_mysql_style_value() { + assert_eq!(stale_postgres_ssl_mode_replacement("disabled"), Some("disable")); + assert_eq!(stale_postgres_ssl_mode_replacement("preferred"), Some("prefer")); + assert_eq!(stale_postgres_ssl_mode_replacement("required"), Some("require")); + assert_eq!(stale_postgres_ssl_mode_replacement("verify_ca"), Some("verify-ca")); + assert_eq!( + stale_postgres_ssl_mode_replacement("verify_identity"), + Some("verify-full"), + ); + } + + #[test] + fn stale_postgres_ssl_mode_replacement_leaves_already_correct_values_alone() { + for already_correct in + ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"] + { + assert_eq!( + stale_postgres_ssl_mode_replacement(already_correct), + None, + "{already_correct} should not be rewritten", + ); + } + } + + #[test] + fn stale_postgres_ssl_mode_replacement_ignores_unrecognized_values() { + assert_eq!(stale_postgres_ssl_mode_replacement(""), None); + assert_eq!(stale_postgres_ssl_mode_replacement("not-a-real-mode"), None); + } + + fn saved_connection(driver: &str, ssl_mode: Option<&str>) -> SavedConnection { + SavedConnection { + id: "conn-1".to_string(), + name: "test".to_string(), + params: ConnectionParams { + driver: driver.to_string(), + ssl_mode: ssl_mode.map(str::to_string), + ..base_params() + }, + group_id: None, + sort_order: None, + detect_json_in_text_columns: None, + appearance: None, + tag_ids: None, + environment: None, + } + } + + #[test] + fn migrate_connection_ssl_mode_rewrites_a_plugin_postgres_connection() { + let mut dialects = HashMap::new(); + dialects.insert( + "postgresql".to_string(), + Some(crate::drivers::driver_trait::SqlDialect::Postgres), + ); + let mut conn = saved_connection("postgresql", Some("required")); + + let rewrote = migrate_connection_ssl_mode_in_place(&mut conn, &dialects); + + assert!(rewrote); + assert_eq!(conn.params.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn migrate_connection_ssl_mode_leaves_a_mysql_connection_alone() { + // "required" is the CORRECT spelling for mysql — this is the case + // that makes the migration driver-aware rather than a blanket + // string-remap. A dialect map that (incorrectly) resolved mysql to + // Postgres would also demonstrate the bug this guards against, so + // this test exercises the real decision, not just the dialect map. + let mut dialects = HashMap::new(); + dialects.insert( + "mysql".to_string(), + Some(crate::drivers::driver_trait::SqlDialect::Mysql), + ); + let mut conn = saved_connection("mysql", Some("required")); + + let rewrote = migrate_connection_ssl_mode_in_place(&mut conn, &dialects); + + assert!(!rewrote); + assert_eq!(conn.params.ssl_mode.as_deref(), Some("required")); + } + + #[test] + fn migrate_connection_ssl_mode_leaves_the_builtin_postgres_driver_alone() { + // The builtin driver's own dropdown was always correct — even if it + // somehow ended up with a stale value, this migration is scoped to + // plugin-driven connections only (driver id != "postgres"). + let mut dialects = HashMap::new(); + dialects.insert( + "postgres".to_string(), + Some(crate::drivers::driver_trait::SqlDialect::Postgres), + ); + let mut conn = saved_connection("postgres", Some("required")); + + let rewrote = migrate_connection_ssl_mode_in_place(&mut conn, &dialects); + + assert!(!rewrote); + assert_eq!(conn.params.ssl_mode.as_deref(), Some("required")); + } + + #[test] + fn migrate_connection_ssl_mode_leaves_an_unresolved_driver_alone() { + // No entry in `dialects` (e.g. the driver failed to resolve from the + // registry) must not be treated as postgres-dialect by default. + let dialects = HashMap::new(); + let mut conn = saved_connection("postgresql", Some("required")); + + let rewrote = migrate_connection_ssl_mode_in_place(&mut conn, &dialects); + + assert!(!rewrote); + assert_eq!(conn.params.ssl_mode.as_deref(), Some("required")); + } + + #[test] + fn migrate_connection_ssl_mode_leaves_a_resolved_driver_with_no_declared_dialect_alone() { + // A driver that resolves from the registry but whose manifest omits + // `sql_dialect` entirely (e.g. the Oracle plugin, which sets + // supports_ssl but declares no dialect) must be treated as NOT + // postgres-dialect — `None`, not defaulted to `Some(Postgres)`. + // Getting this wrong would rewrite that driver's legitimately-spelled + // SSL value based on a guess, exactly the bug this test guards + // against. + let mut dialects = HashMap::new(); + dialects.insert("oracle".to_string(), None); + let mut conn = saved_connection("oracle", Some("required")); + + let rewrote = migrate_connection_ssl_mode_in_place(&mut conn, &dialects); + + assert!(!rewrote); + assert_eq!(conn.params.ssl_mode.as_deref(), Some("required")); + } + + #[test] + fn migrate_connection_ssl_mode_is_idempotent() { + let mut dialects = HashMap::new(); + dialects.insert( + "postgresql".to_string(), + Some(crate::drivers::driver_trait::SqlDialect::Postgres), + ); + let mut conn = saved_connection("postgresql", Some("required")); + + assert!(migrate_connection_ssl_mode_in_place(&mut conn, &dialects)); + assert_eq!(conn.params.ssl_mode.as_deref(), Some("require")); + // Second pass: the value is already correct, nothing to rewrite. + assert!(!migrate_connection_ssl_mode_in_place(&mut conn, &dialects)); + assert_eq!(conn.params.ssl_mode.as_deref(), Some("require")); + } + #[test] fn persisted_params_never_contain_the_connection_uri() { let sentinel = "mongodb+srv://fixture-user:fixture-password@cluster.example.invalid/app"; @@ -5466,8 +5731,9 @@ pub async fn get_connection_groups( pub async fn get_connections_with_groups( app: AppHandle, ) -> Result { - // Run migration if needed + // Run migrations if needed migrate_ssh_connections(&app).await.ok(); + migrate_postgres_ssl_mode_spelling(&app).await.ok(); let path = get_config_path(&app)?; persistence::load_connections_file(&path) diff --git a/src-tauri/src/drivers/driver_trait.rs b/src-tauri/src/drivers/driver_trait.rs index eb84af486..a32689471 100644 --- a/src-tauri/src/drivers/driver_trait.rs +++ b/src-tauri/src/drivers/driver_trait.rs @@ -39,15 +39,6 @@ pub enum SqlDialect { Generic, } -impl Default for SqlDialect { - /// Preserves the behavior shipped before `sql_dialect` was introduced: - /// every driver — including PG-compat plugins already in the wild — - /// went through postgres-flavored splitting via `postgreSplitterOptions`. - fn default() -> Self { - Self::Postgres - } -} - /// Capabilities advertised by a driver. /// The frontend uses these flags to decide which UI sections to show. #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -155,11 +146,22 @@ pub struct DriverCapabilities { /// Defaults to `false`. #[serde(default)] pub readonly: bool, - /// SQL dialect for the statement splitter / classifier. Plugins that - /// omit the field fall back to `postgres` (matches pre-existing - /// behavior shipped via the previous `postgreSplitterOptions`). - #[serde(default)] - pub sql_dialect: SqlDialect, + /// SQL dialect for the statement splitter / classifier, and for any + /// other check that needs to distinguish "postgres-compatible" from + /// "not." `None` when the manifest omits the field — deliberately NOT + /// defaulted to `Some(Postgres)` at this layer (issue #614): a type-level + /// default here would be indistinguishable, once serialized to the + /// frontend, from a manifest that explicitly declared `"postgres"` — + /// which broke a security-relevant check (SSL mode dropdown/migration) + /// that needs to tell "explicitly postgres" apart from "unspecified." + /// The frontend's own splitter still applies the historical + /// postgres-default at its point of use (`?? "postgres"` in + /// `src/utils/identifiers.ts`/`sqlSplitter`); this Rust-side field + /// stays a strict `Option` so security-relevant Rust consumers (the SSL + /// mode migration, the MCP schema default) can't inherit that default + /// by accident. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sql_dialect: Option, } fn default_double_quote() -> String { diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 86961a2bc..9b8d2b8a5 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -1798,7 +1798,7 @@ impl MysqlDriver { triggers: true, user_management: true, supports_ssl: true, - sql_dialect: SqlDialect::Mysql, + sql_dialect: Some(SqlDialect::Mysql), }, is_builtin: true, engine: Some("mysql".to_string()), diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index ca5a04bff..292c9511d 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -1783,7 +1783,7 @@ impl PostgresDriver { triggers: true, supports_ssl: true, user_management: false, - sql_dialect: SqlDialect::Postgres, + sql_dialect: Some(SqlDialect::Postgres), }, is_builtin: true, engine: Some("postgres".to_string()), diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index ce30aa355..790292755 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -1003,7 +1003,7 @@ impl SqliteDriver { triggers: true, supports_ssl: false, user_management: false, - sql_dialect: SqlDialect::Sqlite, + sql_dialect: Some(SqlDialect::Sqlite), }, is_builtin: true, engine: Some("sqlite".to_string()), diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index ac02edb43..b6dc87145 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -7,7 +7,7 @@ use crate::config::{ DEFAULT_MCP_APPROVAL_TIMEOUT_SECONDS, DEFAULT_MCP_PREFLIGHT_EXPLAIN, }; use crate::credential_cache; -use crate::drivers::driver_trait::DatabaseDriver; +use crate::drivers::driver_trait::{DatabaseDriver, SqlDialect}; use crate::drivers::registry as driver_registry; use crate::drivers::{mysql, postgres, sqlite}; use crate::heartbeat; @@ -348,6 +348,27 @@ async fn resolve_db_driver( Ok((conn, db_params, driver)) } +/// Resolves the schema to pass to a metadata fetch, defaulting to `"public"` +/// on postgres-dialect drivers when the caller didn't supply one. +/// +/// Capability-driven, not driver-id-driven (issue #614): checks the +/// resolved driver's declared `sql_dialect` rather than comparing +/// `driver == "postgres"` literally, so a postgres-compatible driver +/// registered under a different id (e.g. the standalone PostgreSQL plugin, +/// id `"postgresql"`) gets the same `"public"` default the builtin driver +/// always did. Any other dialect passes `requested_schema` through +/// unchanged. +fn resolve_default_schema<'a>( + driver: &Arc, + requested_schema: Option<&'a str>, +) -> Option<&'a str> { + if driver.manifest().capabilities.sql_dialect == Some(SqlDialect::Postgres) { + Some(requested_schema.unwrap_or("public")) + } else { + requested_schema + } +} + pub async fn run_mcp_server() { eprintln!("[MCP] Starting Tabularis MCP Server..."); @@ -555,12 +576,8 @@ async fn handle_read_resource(params: Option) -> Result = Arc::new(postgres::PostgresDriver::new()); + assert_eq!(resolve_default_schema(&driver, None), Some("public")); +} + +#[test] +fn resolve_default_schema_prefers_a_caller_supplied_schema_on_postgres() { + let driver: Arc = Arc::new(postgres::PostgresDriver::new()); + assert_eq!( + resolve_default_schema(&driver, Some("analytics")), + Some("analytics"), + ); +} + +#[test] +fn resolve_default_schema_passes_through_unchanged_on_non_postgres_drivers() { + let mysql: Arc = Arc::new(mysql::MysqlDriver::new()); + assert_eq!(resolve_default_schema(&mysql, None), None); + assert_eq!( + resolve_default_schema(&mysql, Some("whatever")), + Some("whatever"), + ); + + let sqlite: Arc = Arc::new(sqlite::SqliteDriver::new()); + assert_eq!(resolve_default_schema(&sqlite, None), None); +} 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..3bf864c7a --- /dev/null +++ b/src-tauri/tests/postgres_integration/blob.rs @@ -0,0 +1,123 @@ +//! BLOB (bytea) handling tests. + +use crate::helpers::pg_params; +use serde_json::json; +use std::collections::HashMap; +use tabularis_lib::drivers::postgres; + +#[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..f7b449ab6 --- /dev/null +++ b/src-tauri/tests/postgres_integration/column_metadata.rs @@ -0,0 +1,138 @@ +//! Column metadata tests: get_columns for various table types. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::postgres; + +#[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..2ce305cb3 --- /dev/null +++ b/src-tauri/tests/postgres_integration/crud.rs @@ -0,0 +1,319 @@ +//! CRUD operation tests (insert, update, delete). + +use crate::helpers::pg_params; +use serde_json::json; +use std::collections::HashMap; +use tabularis_lib::drivers::postgres; + +#[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..bbe17d501 --- /dev/null +++ b/src-tauri/tests/postgres_integration/ddl_generation.rs @@ -0,0 +1,269 @@ +//! DDL generation tests. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::drivers::postgres::PostgresDriver; +use tabularis_lib::models::ColumnDefinition; + +#[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 params = pg_params(); + + let sql_statements = PostgresDriver::new() + .get_create_foreign_key_sql( + ¶ms, + "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..337a108c2 --- /dev/null +++ b/src-tauri/tests/postgres_integration/explain.rs @@ -0,0 +1,88 @@ +//! EXPLAIN query plan tests. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::drivers::postgres::PostgresDriver; +use tabularis_lib::models::ExplainQueryOutput; + +#[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..584e57413 --- /dev/null +++ b/src-tauri/tests/postgres_integration/foreign_keys.rs @@ -0,0 +1,78 @@ +//! Foreign key introspection tests. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::postgres; + +#[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..a8c80037d --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden.rs @@ -0,0 +1,372 @@ +//! 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 crate::golden_utils::{assert_golden, write_golden}; +use crate::helpers::{pg_params, pg_params_secondary}; +use tabularis_lib::drivers::driver_trait::DatabaseDriver; +use tabularis_lib::drivers::postgres; +use tabularis_lib::drivers::postgres::PostgresDriver; + +#[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..3c5ed298a --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_columns_all_types.json @@ -0,0 +1,218 @@ +[ + { + "name": "id", + "data_type": "integer", + "is_pk": true, + "is_nullable": false, + "is_auto_increment": true, + "is_generated": false + }, + { + "name": "col_text", + "data_type": "text", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_varchar", + "data_type": "character varying", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_int", + "data_type": "integer", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_bigint", + "data_type": "bigint", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_smallint", + "data_type": "smallint", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_float", + "data_type": "real", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_double", + "data_type": "double precision", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_numeric", + "data_type": "numeric", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_bool", + "data_type": "boolean", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_date", + "data_type": "date", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_time", + "data_type": "time without time zone", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_timetz", + "data_type": "time with time zone", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_timestamp", + "data_type": "timestamp without time zone", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_timestamptz", + "data_type": "timestamp with time zone", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_uuid", + "data_type": "uuid", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_json", + "data_type": "json", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_jsonb", + "data_type": "jsonb", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_bytea", + "data_type": "bytea", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_inet", + "data_type": "inet", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_cidr", + "data_type": "cidr", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_macaddr", + "data_type": "macaddr", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_int_array", + "data_type": "ARRAY", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_text_array", + "data_type": "ARRAY", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_int4range", + "data_type": "int4range", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_tsrange", + "data_type": "tsrange", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "col_interval", + "data_type": "interval", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": 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..fb2845702 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_columns_with_enum.json @@ -0,0 +1,19 @@ +[ + { + "name": "id", + "data_type": "integer", + "is_pk": true, + "is_nullable": false, + "is_auto_increment": true, + "is_generated": false + }, + { + "name": "current_mood", + "data_type": "enum('happy','sad','neutral')", + "is_pk": false, + "is_nullable": false, + "is_auto_increment": false, + "is_generated": 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..faee7b2e2 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_mv_columns.json @@ -0,0 +1,18 @@ +[ + { + "name": "total", + "data_type": "bigint", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "max_id", + "data_type": "integer", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": 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..7fb448158 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden/get_view_columns_active_users.json @@ -0,0 +1,26 @@ +[ + { + "name": "id", + "data_type": "integer", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "name", + "data_type": "text", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": false + }, + { + "name": "is_active", + "data_type": "boolean", + "is_pk": false, + "is_nullable": true, + "is_auto_increment": false, + "is_generated": 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..1a16a0809 --- /dev/null +++ b/src-tauri/tests/postgres_integration/golden_utils.rs @@ -0,0 +1,69 @@ +//! 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. +/// Panics if the golden file doesn't exist — a missing fixture in a normal +/// (non-regeneration) run means a golden file was deleted, renamed, or never +/// committed, and must fail loud rather than being silently skipped. +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() { + panic!( + "Golden file missing: {}\n\ + If this is a new capture, run with REGENERATE_GOLDEN=1 to create it; \ + if it was unexpectedly deleted or renamed, restore it.\n\ + Actual output from this run:\n{}", + path.display(), + actual + ); + } + + 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..11464c886 --- /dev/null +++ b/src-tauri/tests/postgres_integration/indexes.rs @@ -0,0 +1,84 @@ +//! Index introspection tests. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::postgres; + +#[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..0f13f4c5f --- /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 blob; +mod column_metadata; +mod crud; +mod ddl_generation; +mod explain; +mod foreign_keys; +mod golden; +mod golden_utils; +mod helpers; +mod indexes; +mod materialized_views; +mod multi_database; +mod parity; +mod parity_batch; +mod parity_blob; +mod parity_column_metadata; +mod parity_crud; +mod parity_crud_extra; +mod parity_ddl; +mod parity_explain; +mod parity_foreign_keys; +mod parity_indexes; +mod parity_multi_db; +mod parity_multi_db_extra; +mod parity_mv_extra; +mod parity_query; +mod parity_query_extra; +mod parity_routines_extra; +mod parity_routines_full; +mod parity_schema_discovery; +mod parity_tests; +mod parity_triggers_extra; +mod parity_views_extra; +mod parity_views_full; +mod query_execution; +mod routines; +mod schema_discovery; +mod triggers; +mod views; 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..9fbc2786b --- /dev/null +++ b/src-tauri/tests/postgres_integration/materialized_views.rs @@ -0,0 +1,91 @@ +//! Materialized view tests. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::postgres; + +#[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..e68b7f0a2 --- /dev/null +++ b/src-tauri/tests/postgres_integration/multi_database.rs @@ -0,0 +1,135 @@ +//! Multi-database tests (exercises per-database pool routing). + +use crate::helpers::{pg_params, pg_params_secondary}; +use tabularis_lib::drivers::postgres; + +#[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..718d2ccad --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity.rs @@ -0,0 +1,320 @@ +//! 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 path doesn't exist or the plugin +/// fails to start — an explicit `POSTGRES_PLUGIN_BIN` is a request to run +/// against a real plugin, so a bad path must fail loud rather than silently +/// falling back to builtin-only parity (which would trivially "pass"). +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() { + panic!( + "POSTGRES_PLUGIN_BIN is set to '{}' but the file does not exist — \ + fix the path or unset the variable to run builtin-only parity", + bin_path + ); + } + + 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: Some(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..6fc7e92f9 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_batch.rs @@ -0,0 +1,187 @@ +//! 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 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..55380aa9c --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_blob.rs @@ -0,0 +1,171 @@ +//! 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 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..6cbced1b6 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_column_metadata.rs @@ -0,0 +1,279 @@ +//! 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 serde_json::Value; + +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..895ee0b7b --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_crud.rs @@ -0,0 +1,166 @@ +//! 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 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..8a22988d6 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_crud_extra.rs @@ -0,0 +1,286 @@ +//! Extra parity tests for CRUD — composite PK, NULL update, insert_with_default, +//! enum column binding. + +use std::collections::HashMap; + +use serde_json::{json, Value}; + +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..abaebf3c7 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_ddl.rs @@ -0,0 +1,373 @@ +//! 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 tabularis_lib::models::ColumnDefinition; + +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( + ¶ms, + "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..8354e6804 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_explain.rs @@ -0,0 +1,61 @@ +//! 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 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..6d4d8df3b --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_foreign_keys.rs @@ -0,0 +1,112 @@ +//! 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 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..66188cf1e --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_indexes.rs @@ -0,0 +1,178 @@ +//! 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 serde_json::Value; + +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..8e80fa5ff --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_multi_db.rs @@ -0,0 +1,83 @@ +//! Parity tests for multi-database operations — using the secondary database +//! (tabularis_test_secondary) with its `secondary_schema.remote_data` table. + +use serde_json::Value; + +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..ad9d5647d --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_multi_db_extra.rs @@ -0,0 +1,118 @@ +//! Extra parity tests for multi-database operations — get_databases_lists_both, +//! get_tables_secondary, pool_isolation, fallback_to_maintenance_db. + +use serde_json::Value; +use tabularis_lib::models::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..158e4b703 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_mv_extra.rs @@ -0,0 +1,28 @@ +//! 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 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..159897ce1 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_query.rs @@ -0,0 +1,261 @@ +//! Parity tests for `execute_query` — ensures plugin produces identical query +//! results to the built-in driver. + +use serde_json::Value; + +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"); +} + +/// Regression test for a real bug caught by manual smoke testing (issue #614 +/// follow-up, filed as tabularis-postgresql-plugin#7): the plugin's +/// `extract_value` had no explicit case for PostgreSQL enum columns, so +/// SELECTing an enum value returned `null` instead of the real label — while +/// the builtin driver (confirmed here, and independently via `psql`) returns +/// the correct string. `assert_parity` alone would have failed loudly on this +/// exact mismatch had it existed; it didn't, because no parity test read an +/// enum value back through `execute_query` — only `get_columns` (metadata) +/// and `insert_record`/`update_record` (binding) exercised enums before this. +#[tokio::test] +#[ignore] +async fn parity_execute_query_enum_value() { + require_pg!(); + let harness = ParityHarness::new().await; + + let result = harness + .assert_parity("execute_query:enum_value", |driver, params| async move { + driver + .execute_query( + ¶ms, + "SELECT id, current_mood FROM test_schema.with_enum ORDER BY id LIMIT 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"); + let row = &rows[0]; + + // Seed data (tests/fixtures/postgres_seed.sql) inserts current_mood = + // 'happy' for the one seeded row and never updates it in any other + // integration test, so this value is stable — a real, non-null enum + // label, not a placeholder. Handles both row shapes (object keyed by + // column name, or positional array) the same way parity_execute_query_ + // null_handling above does. + let mood_val = row + .get("current_mood") + .or_else(|| row.as_array().and_then(|arr| arr.get(1))) + .expect("current_mood should be present in the result row"); + + assert_eq!( + mood_val.as_str(), + Some("happy"), + "current_mood should be the real enum label 'happy', not null — \ + got: {mood_val:?}" + ); +} 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..9b92cf4e3 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_query_extra.rs @@ -0,0 +1,125 @@ +//! Extra parity tests for query execution — affected_rows_for_dml, batch_session_state. + +use serde_json::Value; + +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..476d097bc --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_routines_extra.rs @@ -0,0 +1,110 @@ +//! Extra parity tests for routines — overloaded functions, procedures, drop_routine. + +use serde_json::Value; + +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..19a77a748 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_routines_full.rs @@ -0,0 +1,101 @@ +//! Parity tests for routine (function/procedure) and trigger introspection. + +use serde_json::Value; + +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..722e5985d --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_schema_discovery.rs @@ -0,0 +1,33 @@ +//! 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 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..6c4565b18 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_tests.rs @@ -0,0 +1,265 @@ +//! 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 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..2bbb0e3bc --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_triggers_extra.rs @@ -0,0 +1,96 @@ +//! Extra parity tests for triggers — create/drop trigger and empty schema. + +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..09ff612fb --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_views_extra.rs @@ -0,0 +1,76 @@ +//! Extra parity tests for views — alter_view and empty schema scenarios. + +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..6cbe1a922 --- /dev/null +++ b/src-tauri/tests/postgres_integration/parity_views_full.rs @@ -0,0 +1,131 @@ +//! Parity tests for view and materialized view lifecycle operations. + +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..9f3a3d87d --- /dev/null +++ b/src-tauri/tests/postgres_integration/query_execution.rs @@ -0,0 +1,182 @@ +//! Query execution tests. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::postgres; + +#[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..794d6bd99 --- /dev/null +++ b/src-tauri/tests/postgres_integration/routines.rs @@ -0,0 +1,130 @@ +//! Routine (function/procedure) management tests. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::postgres; + +#[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..018c9e30d --- /dev/null +++ b/src-tauri/tests/postgres_integration/schema_discovery.rs @@ -0,0 +1,98 @@ +//! Schema discovery tests: get_schemas, get_databases, get_tables. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::postgres; + +#[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..a3f4a5134 --- /dev/null +++ b/src-tauri/tests/postgres_integration/triggers.rs @@ -0,0 +1,98 @@ +//! Trigger management tests. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::postgres; + +#[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..862f811f3 --- /dev/null +++ b/src-tauri/tests/postgres_integration/views.rs @@ -0,0 +1,156 @@ +//! View management tests. + +use crate::helpers::pg_params; +use tabularis_lib::drivers::postgres; + +#[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/src/components/layout/ExplorerSidebar.tsx b/src/components/layout/ExplorerSidebar.tsx index 37afb4a73..08dc10eef 100644 --- a/src/components/layout/ExplorerSidebar.tsx +++ b/src/components/layout/ExplorerSidebar.tsx @@ -193,7 +193,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar const navigate = useNavigate(); const objectNavigation = useDatabaseObjectNavigation( activeConnectionId, - activeDriver, + activeCapabilities ?? activeDriver, ); const [schemaVersion, setSchemaVersion] = useState(0); const sidebarBodyRef = useRef(null); @@ -1578,6 +1578,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar onContextMenu={handleContextMenu} connectionId={activeConnectionId!} driver={activeDriver!} + capabilities={activeCapabilities} canManage={supportsManageTables(activeCapabilities)} onAddColumn={(t_name) => setModifyColumnModal({ isOpen: true, tableName: t_name, column: null }) @@ -1684,6 +1685,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar onContextMenu={handleContextMenu} connectionId={activeConnectionId!} driver={activeDriver!} + capabilities={activeCapabilities} /> ))} @@ -1957,7 +1959,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar icon: Trash2, danger: true, action: async () => { - const quotedTable = quoteTableRef(contextMenu.id, activeDriver, ctxSchema); + const quotedTable = quoteTableRef(contextMenu.id, activeCapabilities ?? activeDriver, ctxSchema); if ( await ask( t("sidebar.deleteTableConfirm", { table: contextMenu.id }), @@ -2648,6 +2650,7 @@ export const ExplorerSidebar = ({ sidebarWidth, startResize, onCollapse, sidebar tableName={triggerEditorModal.tableName} schema={triggerEditorModal.schema} driver={activeDriver ?? undefined} + capabilities={activeCapabilities} isNewTrigger={triggerEditorModal.isNewTrigger} onSuccess={() => { if (refreshTriggers) refreshTriggers(); diff --git a/src/components/layout/sidebar/SidebarColumnItem.tsx b/src/components/layout/sidebar/SidebarColumnItem.tsx index 4cba26b81..3e9aa8bc3 100644 --- a/src/components/layout/sidebar/SidebarColumnItem.tsx +++ b/src/components/layout/sidebar/SidebarColumnItem.tsx @@ -7,6 +7,7 @@ import { Key, Columns, Edit, Copy, Trash2 } from "lucide-react"; import clsx from "clsx"; import { ContextMenu } from "../../ui/ContextMenu"; import type { TableColumn } from "../../../types/schema"; +import type { DriverCapabilities } from "../../../types/plugins"; import { quoteIdentifier, quoteTableRef } from "../../../utils/identifiers"; interface SidebarColumnItemProps { @@ -14,6 +15,11 @@ interface SidebarColumnItemProps { tableName: string; connectionId: string; driver: string; + /** Capability-driven identifier quoting (issue #614): when available, + * takes precedence over the bare `driver` id so a postgres-compatible + * driver registered under a different id (e.g. a standalone PostgreSQL + * plugin) is quoted the same as the builtin "postgres" driver. */ + capabilities?: DriverCapabilities | null; onRefresh: () => void; onEdit: (column: TableColumn) => void; isView?: boolean; @@ -26,6 +32,7 @@ export const SidebarColumnItem = ({ tableName, connectionId, driver, + capabilities, onRefresh, onEdit, isView = false, @@ -56,8 +63,9 @@ export const SidebarColumnItem = ({ if (confirmed) { try { - const quotedTable = quoteTableRef(tableName, driver, schema); - const quotedColumn = quoteIdentifier(column.name, driver); + const quotingDriver = capabilities ?? driver; + const quotedTable = quoteTableRef(tableName, quotingDriver, schema); + const quotedColumn = quoteIdentifier(column.name, quotingDriver); const query = `ALTER TABLE ${quotedTable} DROP COLUMN ${quotedColumn}`; await invoke("execute_query", { diff --git a/src/components/layout/sidebar/SidebarTableItem.tsx b/src/components/layout/sidebar/SidebarTableItem.tsx index 0e9534759..08656d19a 100644 --- a/src/components/layout/sidebar/SidebarTableItem.tsx +++ b/src/components/layout/sidebar/SidebarTableItem.tsx @@ -18,6 +18,7 @@ import { areTableItemPropsEqual } from "../../../utils/sidebarTableItem"; import { groupIndexes } from "../../../utils/indexes"; import type { TableColumn, ForeignKey, Index } from "../../../types/schema"; import type { ContextMenuData } from "../../../types/sidebar"; +import type { DriverCapabilities } from "../../../types/plugins"; interface SidebarTableItemProps { table: { name: string }; @@ -33,6 +34,9 @@ interface SidebarTableItemProps { ) => void; connectionId: string; driver: string; + /** Capability-driven identifier quoting (issue #614): forwarded to + * `SidebarColumnItem`, which prefers it over the bare `driver` id. */ + capabilities?: DriverCapabilities | null; onAddColumn: (tableName: string) => void; onEditColumn: (tableName: string, col: TableColumn) => void; onAddIndex: (tableName: string) => void; @@ -52,6 +56,7 @@ const SidebarTableItemImpl = ({ onContextMenu, connectionId, driver, + capabilities, canManage, onAddColumn, onEditColumn, @@ -229,6 +234,7 @@ const SidebarTableItemImpl = ({ tableName={table.name} connectionId={connectionId} driver={driver} + capabilities={capabilities} canManage={canManage} onRefresh={refreshMetadata} onEdit={(c) => onEditColumn(table.name, c)} diff --git a/src/components/layout/sidebar/SidebarViewItem.tsx b/src/components/layout/sidebar/SidebarViewItem.tsx index 2e9fd45b3..f12ecfa2f 100644 --- a/src/components/layout/sidebar/SidebarViewItem.tsx +++ b/src/components/layout/sidebar/SidebarViewItem.tsx @@ -15,6 +15,7 @@ import { SidebarIndexList } from "./SidebarIndexList"; import { groupIndexes } from "../../../utils/indexes"; import type { TableColumn, Index } from "../../../types/schema"; import type { ContextMenuData } from "../../../types/sidebar"; +import type { DriverCapabilities } from "../../../types/plugins"; interface SidebarViewItemProps { view: { name: string }; @@ -30,6 +31,9 @@ interface SidebarViewItemProps { ) => void; connectionId: string; driver: string; + /** Capability-driven identifier quoting (issue #614): forwarded to + * `SidebarColumnItem`, which prefers it over the bare `driver` id. */ + capabilities?: DriverCapabilities | null; schema?: string; materialized?: boolean; isRefreshing?: boolean; @@ -43,6 +47,7 @@ export const SidebarViewItem = ({ onContextMenu, connectionId, driver, + capabilities, schema, materialized = false, isRefreshing = false, @@ -168,6 +173,7 @@ export const SidebarViewItem = ({ tableName={view.name} connectionId={connectionId} driver={driver} + capabilities={capabilities} onRefresh={refreshColumns} onEdit={() => {}} isView={true} diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 076f54330..bee181e91 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -264,6 +264,16 @@ export const NewConnectionModal = ({ // ── form state ── const [driver, setDriver] = useState("mysql"); const activeDriver = drivers.find((d) => d.id === driver) ?? drivers[0]; + // Capability-driven, not driver-id-driven: a driver whose manifest EXPLICITLY + // declares the postgres SQL dialect (builtin "postgres" or a plugin like + // "postgresql") gets Postgres-style SSL mode options. Deliberately requires + // an explicit declaration rather than falling back to the schema's default + // (unlike identifier quoting / statement splitting elsewhere) — most shipped + // plugins omit `sql_dialect` entirely, and defaulting them into the + // Postgres SSL branch would silently change behavior for drivers unrelated + // to this fix (e.g. the Oracle plugin, which sets supports_ssl but declares + // no dialect). + const isPostgresDialect = activeDriver?.capabilities?.sql_dialect === "postgres"; // ── driver install state ── const [installStatus, setInstallStatus] = useState< @@ -2533,7 +2543,7 @@ export const NewConnectionModal = ({
void; } @@ -50,6 +57,7 @@ export const TriggerEditorModal = ({ tableName: initialTableName, schema: schemaProp, driver, + capabilities, isNewTrigger = false, onSuccess, }: TriggerEditorModalProps) => { @@ -120,7 +128,7 @@ export const TriggerEditorModal = ({ const buildTriggerSql = (): string => { if (useRawSql) return rawSql; - const q = (id: string) => quoteIdentifier(id, driver ?? "postgres"); + const q = (id: string) => quoteIdentifier(id, capabilities ?? driver ?? "postgres"); // MySQL handles schema via the connection — including it in the ON clause causes error 1435 const isMysql = driver === "mysql"; const schemaPrefix = (!isMysql && resolvedSchema) ? `${q(resolvedSchema)}.` : ""; diff --git a/src/components/ui/RelatedRecordsPanel.tsx b/src/components/ui/RelatedRecordsPanel.tsx index fffabef7d..761bd2c0e 100644 --- a/src/components/ui/RelatedRecordsPanel.tsx +++ b/src/components/ui/RelatedRecordsPanel.tsx @@ -9,6 +9,7 @@ import { import { MiniResultGrid } from './MiniResultGrid'; import { useReferencedRecord } from '../../hooks/useReferencedRecord'; import type { ForeignKey } from '../../types/editor'; +import type { DriverCapabilities } from '../../types/plugins'; interface RelatedRecordsPanelProps { activeFkQuery: { @@ -18,6 +19,11 @@ interface RelatedRecordsPanelProps { }; connectionId: string; driver?: string | null; + /** Capability-driven identifier quoting (issue #614): when available, + * takes precedence over the bare `driver` id so a postgres-compatible + * driver registered under a different id (e.g. a standalone PostgreSQL + * plugin) is quoted the same as the builtin "postgres" driver. */ + capabilities?: DriverCapabilities | null; schema?: string | null; onClose: () => void; onNavigateToTab: (fk: ForeignKey, value: unknown) => void; @@ -27,6 +33,7 @@ export function RelatedRecordsPanel({ activeFkQuery, connectionId, driver, + capabilities, schema, onClose, onNavigateToTab, @@ -37,7 +44,7 @@ export function RelatedRecordsPanel({ connectionId, fk, value, - driver, + driver: capabilities ?? driver, schema, sourceColumnType, }); diff --git a/src/components/ui/TableToolbar.tsx b/src/components/ui/TableToolbar.tsx index c9bb74ead..57d9dd38f 100644 --- a/src/components/ui/TableToolbar.tsx +++ b/src/components/ui/TableToolbar.tsx @@ -67,7 +67,11 @@ const TableToolbarInternal = ({ onUpdate, }: TableToolbarInternalProps) => { const { t } = useTranslation(); - const { activeDriver } = useDatabase(); + const { activeDriver, activeCapabilities } = useDatabase(); + // Capability-driven when available (issue #614): a postgres-compatible + // driver registered under a different id (e.g. a standalone PostgreSQL + // plugin) is quoted the same as the builtin "postgres" driver. + const quotingDriver = activeCapabilities ?? activeDriver; const [filterInput, setFilterInput] = useState(initialFilter || ""); const [sortInput, setSortInput] = useState(initialSort || ""); const [limitInput, setLimitInput] = useState( @@ -109,10 +113,10 @@ const TableToolbarInternal = ({ const sortChanged = (sort || "") !== (initialSort || ""); const limitChanged = limitVal !== initialLimit; if (filterChanged || sortChanged || limitChanged) { - onUpdate(filter, formatSortClause(sort, activeDriver), limitVal); + onUpdate(filter, formatSortClause(sort, quotingDriver), limitVal); } }, - [getLimitVal, initialFilter, initialSort, initialLimit, onUpdate, activeDriver] + [getLimitVal, initialFilter, initialSort, initialLimit, onUpdate, quotingDriver] ); // ── click outside to close panel ───────────────────────────────────────────── @@ -148,11 +152,11 @@ const TableToolbarInternal = ({ }; const closePanel = useCallback(() => { - const clause = buildStructuredFilterClause(structuredFilters, activeDriver); + const clause = buildStructuredFilterClause(structuredFilters, quotingDriver); setFilterInput(clause); onPanelOpenChange(false); - onUpdate(clause, formatSortClause(sortInput, activeDriver), getLimitVal(limitInput)); - }, [structuredFilters, sortInput, limitInput, getLimitVal, onUpdate, onPanelOpenChange, activeDriver]); + onUpdate(clause, formatSortClause(sortInput, quotingDriver), getLimitVal(limitInput)); + }, [structuredFilters, sortInput, limitInput, getLimitVal, onUpdate, onPanelOpenChange, quotingDriver]); const togglePanel = () => { if (panelOpen) { @@ -166,8 +170,8 @@ const TableToolbarInternal = ({ // Applies all enabled filters — does NOT close panel const handleApplyAll = useCallback(() => { - const clause = buildStructuredFilterClause(structuredFilters, activeDriver); - onUpdate(clause, formatSortClause(sortInput, activeDriver), getLimitVal(limitInput)); + const clause = buildStructuredFilterClause(structuredFilters, quotingDriver); + onUpdate(clause, formatSortClause(sortInput, quotingDriver), getLimitVal(limitInput)); structuredFilters.forEach((f) => { if (f.enabled !== false) { onTriggerApplied(f.id); @@ -175,25 +179,25 @@ const TableToolbarInternal = ({ onResetApplied(f.id); } }); - }, [structuredFilters, sortInput, limitInput, getLimitVal, onUpdate, onTriggerApplied, onResetApplied, activeDriver]); + }, [structuredFilters, sortInput, limitInput, getLimitVal, onUpdate, onTriggerApplied, onResetApplied, quotingDriver]); // Applies only that single row's filter — resets Applied on all others const handleApplySingle = useCallback( (filter: StructuredFilter) => { onUpdate( - buildSingleFilterClause(filter, activeDriver), - formatSortClause(sortInput, activeDriver), + buildSingleFilterClause(filter, quotingDriver), + formatSortClause(sortInput, quotingDriver), getLimitVal(limitInput), ); onResetAllApplied(); onTriggerApplied(filter.id); }, - [sortInput, limitInput, getLimitVal, onUpdate, onResetAllApplied, onTriggerApplied, activeDriver] + [sortInput, limitInput, getLimitVal, onUpdate, onResetAllApplied, onTriggerApplied, quotingDriver] ); const handleUnset = () => { onStructuredFiltersChange([]); - onUpdate("", formatSortClause(sortInput, activeDriver), getLimitVal(limitInput)); + onUpdate("", formatSortClause(sortInput, quotingDriver), getLimitVal(limitInput)); }; const handleAddFilter = () => { @@ -264,7 +268,7 @@ const TableToolbarInternal = ({ const acceptSuggestion = (col: TableColumn) => { const input = filterInputRef.current; const cursorPos = input?.selectionStart ?? filterInput.length; - const replacement = formatSqlIdentifier(col.name, activeDriver); + const replacement = formatSqlIdentifier(col.name, quotingDriver); const newValue = replaceCurrentWord(filterInput, cursorPos, replacement); setFilterInput(newValue); setAutocompleteOpen(false); @@ -334,7 +338,7 @@ const TableToolbarInternal = ({ const acceptSortSuggestion = (col: TableColumn) => { const input = sortInputRef.current; const cursorPos = input?.selectionStart ?? sortInput.length; - const replacement = formatSqlIdentifier(col.name, activeDriver); + const replacement = formatSqlIdentifier(col.name, quotingDriver); const newValue = replaceCurrentWord(sortInput, cursorPos, replacement); setSortInput(newValue); setSortAcOpen(false); @@ -370,8 +374,8 @@ const TableToolbarInternal = ({ commitSql(filterInput, sortInput, limitInput); } else { onUpdate( - buildStructuredFilterClause(structuredFilters, activeDriver), - formatSortClause(sortInput, activeDriver), + buildStructuredFilterClause(structuredFilters, quotingDriver), + formatSortClause(sortInput, quotingDriver), getLimitVal(limitInput), ); } @@ -382,8 +386,8 @@ const TableToolbarInternal = ({ commitSql(filterInput, sortInput, limitInput); } else { onUpdate( - buildStructuredFilterClause(structuredFilters, activeDriver), - formatSortClause(sortInput, activeDriver), + buildStructuredFilterClause(structuredFilters, quotingDriver), + formatSortClause(sortInput, quotingDriver), getLimitVal(limitInput), ); } @@ -480,7 +484,7 @@ const TableToolbarInternal = ({
- {buildStructuredFilterClause(structuredFilters, activeDriver) || ( + {buildStructuredFilterClause(structuredFilters, quotingDriver) || ( {t("toolbar.noActiveFilters")} )} diff --git a/src/components/ui/VisualQueryBuilder.tsx b/src/components/ui/VisualQueryBuilder.tsx index 206cabff3..c8fa2b989 100644 --- a/src/components/ui/VisualQueryBuilder.tsx +++ b/src/components/ui/VisualQueryBuilder.tsx @@ -39,7 +39,7 @@ interface TableColumn { } const VisualQueryBuilderContent = () => { - const { activeConnectionId, activeDriver, activeSchema } = useDatabase(); + const { activeConnectionId, activeDriver, activeCapabilities, activeSchema } = useDatabase(); const { activeTab, activeTabId, updateTab } = useEditor(); const { screenToFlowPosition } = useReactFlow(); @@ -166,13 +166,13 @@ const VisualQueryBuilderContent = () => { orderBy, groupBy, limit, - activeDriver, + activeCapabilities ?? activeDriver, ); if (sql) { updateTab(activeTabId, { query: sql }); } - }, [nodes, edges, activeTabId, updateTab, whereConditions, orderBy, groupBy, limit, activeDriver]); + }, [nodes, edges, activeTabId, updateTab, whereConditions, orderBy, groupBy, limit, activeDriver, activeCapabilities]); const onConnect = useCallback( (params: Connection) => { diff --git a/src/hooks/useCommandPaletteObjectItems.ts b/src/hooks/useCommandPaletteObjectItems.ts index d2d8de3a9..75ef07b12 100644 --- a/src/hooks/useCommandPaletteObjectItems.ts +++ b/src/hooks/useCommandPaletteObjectItems.ts @@ -194,7 +194,7 @@ export function useCommandPaletteObjectItems( return createObjectPaletteItems({ navigatorItems, connectionId, - driver: connectionData?.driver ?? null, + driver: connectionData?.capabilities ?? connectionData?.driver ?? null, hasGroups: hasSchemas || isMultiDatabase, isMultiDatabase, runtime, @@ -217,6 +217,7 @@ export function useCommandPaletteObjectItems( }); }, [ connectionData?.driver, + connectionData?.capabilities, connectionId, hasSchemas, isMultiDatabase, diff --git a/src/hooks/useDatabaseObjectNavigation.ts b/src/hooks/useDatabaseObjectNavigation.ts index 8f7ad548e..7c1c5dbf6 100644 --- a/src/hooks/useDatabaseObjectNavigation.ts +++ b/src/hooks/useDatabaseObjectNavigation.ts @@ -4,6 +4,7 @@ import type { RoutineInfo, TriggerInfo, } from "../contexts/DatabaseContext"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; import { createDefinitionRequest, createQueryableObjectRequests, @@ -27,7 +28,7 @@ interface QueryableObjectNavigationOptions { */ export function useDatabaseObjectNavigation( connectionId: string | null, - driver: string | null, + driver: string | PluginManifest | DriverCapabilities | null, ) { const runtime = useDatabaseObjectActionRuntime(); diff --git a/src/hooks/useReferencedRecord.ts b/src/hooks/useReferencedRecord.ts index 4a98a151a..c2a198c16 100644 --- a/src/hooks/useReferencedRecord.ts +++ b/src/hooks/useReferencedRecord.ts @@ -1,17 +1,20 @@ import { useState, useEffect, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; import type { ForeignKey, QueryResult } from '../types/editor'; +import type { DriverCapabilities, PluginManifest } from '../types/plugins'; import { quoteTableRef } from '../utils/identifiers'; import { isForeignKeyValueNavigable, buildForeignKeyFilterClause, } from '../utils/foreignKeys'; +type DriverArg = string | PluginManifest | DriverCapabilities | null | undefined; + export interface FetchReferencedRecordParams { connectionId: string; fk: ForeignKey; value: unknown; - driver?: string | null; + driver?: DriverArg; schema?: string | null; sourceColumnType?: string; } @@ -53,7 +56,7 @@ export interface UseReferencedRecordParams { connectionId: string; fk: ForeignKey | null | undefined; value: unknown; - driver?: string | null; + driver?: DriverArg; schema?: string | null; sourceColumnType?: string; } diff --git a/src/hooks/useSqlAutocompleteRegistration.ts b/src/hooks/useSqlAutocompleteRegistration.ts index 20c89bb39..7ce483158 100644 --- a/src/hooks/useSqlAutocompleteRegistration.ts +++ b/src/hooks/useSqlAutocompleteRegistration.ts @@ -57,7 +57,7 @@ export function useSqlAutocompleteRegistration( connectionId, effectiveTables, schema, - activeDriver ?? null, + activeCapabilities ?? activeDriver ?? null, ); }; diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index e448f0197..4228e388b 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -332,7 +332,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { const tabForQuery = { ...tab, schema: effectiveSchema }; const query = tab.type === "table" && tab.activeTable - ? reconstructTableQuery(tabForQuery, activeDriver ?? undefined) + ? reconstructTableQuery(tabForQuery, activeCapabilities ?? activeDriver ?? undefined) : tab.query; addTab({ @@ -342,7 +342,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { connectionId: tab.connectionId, }); }, - [addTab, activeDriver, activeCapabilities?.schemas], + [addTab, activeDriver, activeCapabilities], ); const [saveQueryModal, setSaveQueryModal] = useState<{ @@ -914,7 +914,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { const tabForQuery = { ...targetTab, schema: effectiveSchema }; textToRun = reconstructTableQuery( tabForQuery, - activeDriver ?? undefined, + activeCapabilities ?? activeDriver ?? undefined, { filterOverride: filterOverride !== undefined ? filterOverride : undefined, @@ -1150,7 +1150,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { t, activeDriver, activeSchema, - activeCapabilities?.schemas, + activeCapabilities, views, materializedViews, isMultiDb, @@ -1511,7 +1511,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { schema: activeCapabilities?.schemas === true ? tab.schema : undefined, }, - activeDriver ?? undefined, + activeCapabilities ?? activeDriver ?? undefined, { sortOverride: null, limitOverride: null }, ) : tab.query; @@ -1542,7 +1542,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { activeConnectionId, activeSchema, activeDriver, - activeCapabilities?.schemas, + activeCapabilities, updateTab, ], ); @@ -2137,7 +2137,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { const filterClause = buildForeignKeyFilterClause( fk, value, - activeDriver ?? null, + activeCapabilities ?? activeDriver ?? null, sourceType, ); @@ -2175,7 +2175,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { [ activeConnectionId, activeDriver, - activeCapabilities?.schemas, + activeCapabilities, addTab, updateTab, runQuery, @@ -2199,14 +2199,14 @@ export const Editor = ({ commandScopeId }: EditorProps) => { if (!currentDir || currentDir === "ASC") { // ASC -> DESC - newSort = `${formatSqlIdentifier(colName, activeDriver)} DESC`; + newSort = `${formatSqlIdentifier(colName, activeCapabilities ?? activeDriver)} DESC`; } else { // DESC -> None (Clear) newSort = ""; } } else { // New column -> ASC - newSort = `${formatSqlIdentifier(colName, activeDriver)} ASC`; + newSort = `${formatSqlIdentifier(colName, activeCapabilities ?? activeDriver)} ASC`; } handleToolbarUpdate( @@ -2215,7 +2215,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { activeTab.limitClause, ); }, - [activeTab, activeDriver, handleToolbarUpdate], + [activeTab, activeDriver, activeCapabilities, handleToolbarUpdate], ); const handlePendingChange = useCallback( @@ -3277,7 +3277,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { const tabForQuery = { ...activeTab, schema: effectiveSchema }; const query = activeTab.type === "table" && activeTab.activeTable - ? reconstructTableQuery(tabForQuery, activeDriver ?? undefined) + ? reconstructTableQuery(tabForQuery, activeCapabilities ?? activeDriver ?? undefined) : activeTab.query; if (!query || !query.trim()) return; @@ -3360,7 +3360,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { ? // limitOverride: copy-all goes beyond the tab's "Total Limit" — the // user explicitly asked for every row. Sort is kept so the copy // matches the on-screen order. - reconstructTableQuery(tabForQuery, activeDriver ?? undefined, { + reconstructTableQuery(tabForQuery, activeCapabilities ?? activeDriver ?? undefined, { limitOverride: null, }) : activeTab.query; @@ -4610,6 +4610,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => { activeFkQuery={activeFkQuery} connectionId={activeConnectionId} driver={activeDriver} + capabilities={activeCapabilities} schema={activeSchema} onClose={() => setActiveFkQuery(null)} onNavigateToTab={handleForeignKeyNavigate} diff --git a/src/utils/autocomplete.ts b/src/utils/autocomplete.ts index ec95718fe..223f442e2 100644 --- a/src/utils/autocomplete.ts +++ b/src/utils/autocomplete.ts @@ -1,6 +1,7 @@ import type { Monaco } from "@monaco-editor/react"; import { invoke } from "@tauri-apps/api/core"; import type { TableInfo } from "../contexts/DatabaseContext"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; import { formatSqlIdentifier, getQuoteChar, quoteIdentifier } from "./identifiers"; import { getCurrentStatement, parseTablesFromQuery, type ParsedTableRef } from "./sqlAnalysis"; import { analyzeSqlContext, findStatementScopeEnd, getKeywordRelevance, getSuggestionKinds } from "./sqlContext"; @@ -117,7 +118,7 @@ export const registerSqlAutocomplete = ( connectionId: string | null, tables: TableInfo[], schema?: string | null, - driver?: string | null, + driver?: string | PluginManifest | DriverCapabilities | null, ) => { const provider = monaco.languages.registerCompletionItemProvider("sql", { triggerCharacters: [".", " "], diff --git a/src/utils/connections.ts b/src/utils/connections.ts index 053e48699..efb9a8f23 100644 --- a/src/utils/connections.ts +++ b/src/utils/connections.ts @@ -3,7 +3,7 @@ * Extracted from Connections.tsx for testability */ -import type { DriverCapabilities } from "../types/plugins"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; import type { SavedConnection } from "../contexts/DatabaseContext"; import { isLocalDriver } from "./driverCapabilities"; import { isMultiDatabaseCapable } from "./database"; @@ -105,11 +105,19 @@ export function formatConnectionString( } /** - * Get the default port for a database driver - * @param driver - Database driver type + * Get the default port for a database driver. + * Accepts a driver id string, or a PluginManifest — when a manifest is + * given, its own declared `default_port` is used verbatim (issue #614: + * this lets a plugin driver like the standalone PostgreSQL plugin, id + * "postgresql", report the correct port instead of falling through to the + * literal-id switch below, which only recognizes the builtin ids). + * @param driver - Database driver type, or a resolved PluginManifest * @returns Default port number */ -export function getDefaultPort(driver: DatabaseDriver): number { +export function getDefaultPort(driver: DatabaseDriver | PluginManifest): number { + if (typeof driver === "object") { + return driver.default_port ?? 0; + } switch (driver) { case "postgres": return 5432; @@ -208,11 +216,19 @@ export function validateConnectionParams( } /** - * Get a human-readable label for a database driver - * @param driver - Database driver type + * Get a human-readable label for a database driver. + * Accepts a driver id string, or a PluginManifest — when a manifest is + * given, its own declared `name` is used verbatim (issue #614: a plugin + * driver like the standalone PostgreSQL plugin, id "postgresql", gets its + * real display name instead of falling through to an all-caps rendering + * of its id). + * @param driver - Database driver type, or a resolved PluginManifest * @returns Display label for the driver */ -export function getDriverLabel(driver: DatabaseDriver): string { +export function getDriverLabel(driver: DatabaseDriver | PluginManifest): string { + if (typeof driver === "object") { + return driver.name; + } switch (driver) { case "postgres": return "PostgreSQL"; diff --git a/src/utils/databaseObjectActions.ts b/src/utils/databaseObjectActions.ts index 5c899c1b7..22ffb65e4 100644 --- a/src/utils/databaseObjectActions.ts +++ b/src/utils/databaseObjectActions.ts @@ -6,9 +6,17 @@ import type { EditorNavigationRequest, TableEditorNavigationRequest, } from "../types/editor"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; import { quoteTableRef } from "./identifiers"; import { newConsoleForTable } from "./newConsole"; +/** Driver argument accepted throughout this module: a bare driver id + * string, a resolved manifest, or a bare capabilities object. Capability- + * driven when available (issue #614): a postgres-compatible driver + * registered under a different id (e.g. a standalone PostgreSQL plugin) is + * quoted the same as the builtin "postgres" driver. */ +type DriverArg = string | PluginManifest | DriverCapabilities | null; + export interface DatabaseObjectTarget { connectionId: string; objectName: string; @@ -16,7 +24,7 @@ export interface DatabaseObjectTarget { } interface QueryableObjectOptions extends DatabaseObjectTarget { - driver: string | null; + driver: DriverArg; materialized?: boolean; qualifySchema?: boolean; title?: string; @@ -28,7 +36,7 @@ interface QueryableObjectRequests { } interface CountRequestOptions extends DatabaseObjectTarget { - driver: string | null; + driver: DriverArg; qualifySchema?: boolean; } @@ -53,7 +61,7 @@ interface DatabaseObjectBase { } interface QueryableDatabaseObjectBase extends DatabaseObjectBase { - driver: string | null; + driver: DriverArg; materialized?: boolean; qualifySchema?: boolean; title?: string; @@ -209,7 +217,7 @@ export function createQueryableObjectRequests({ export function createTableConsoleRequest( target: DatabaseObjectTarget, - driver: string | null, + driver: DriverArg, ): ConsoleEditorNavigationRequest { const spec = newConsoleForTable( target.objectName, @@ -229,7 +237,7 @@ export function createTableConsoleRequest( export function createTableCountRequest( target: DatabaseObjectTarget, - driver: string | null, + driver: DriverArg, ): ConsoleEditorNavigationRequest { return createCountRequest({ ...target, diff --git a/src/utils/editor.ts b/src/utils/editor.ts index 25e5d70e6..cc860e700 100644 --- a/src/utils/editor.ts +++ b/src/utils/editor.ts @@ -4,6 +4,7 @@ import type { TableSchema, EditorPreferences, } from "../types/editor"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; import { quoteTableRef } from "./identifiers"; import { invoke } from "@tauri-apps/api/core"; import { cleanTabForStorage, restoreTabFromStorage } from "./tabCleaner"; @@ -335,7 +336,7 @@ function normalizeSmartQuotes(s: string): string { */ export function reconstructTableQuery( tab: Tab, - driver?: string, + driver?: string | PluginManifest | DriverCapabilities, options?: ReconstructQueryOptions, ): string { if (!tab.activeTable) { diff --git a/src/utils/filterBar.ts b/src/utils/filterBar.ts index 6627068eb..2edb898a7 100644 --- a/src/utils/filterBar.ts +++ b/src/utils/filterBar.ts @@ -1,4 +1,5 @@ import type { TableColumn } from "../types/editor"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; import { formatSqlIdentifier } from "./identifiers"; @@ -151,7 +152,7 @@ export function getOperatorsForType(dataType: string): FilterOperator[] { */ export function buildSingleFilterClause( filter: StructuredFilter, - driver?: string | null + driver?: string | PluginManifest | DriverCapabilities | null ): string { const col = formatSqlIdentifier(filter.column, driver); const op = filter.operator; @@ -204,7 +205,7 @@ function quoteIfNeeded(value: string): string { */ export function buildStructuredFilterClause( filters: StructuredFilter[], - driver?: string | null + driver?: string | PluginManifest | DriverCapabilities | null ): string { const clauses = filters .filter((f) => f.column && f.enabled !== false) diff --git a/src/utils/foreignKeys.ts b/src/utils/foreignKeys.ts index eae06863c..24fff151d 100644 --- a/src/utils/foreignKeys.ts +++ b/src/utils/foreignKeys.ts @@ -1,4 +1,5 @@ import type { ForeignKey } from "../types/schema"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; import { quoteIdentifier } from "./identifiers"; const NUMERIC_TYPE_KEYWORDS = [ @@ -90,7 +91,7 @@ export function getForeignKeyForPreview( export function buildForeignKeyFilterClause( fk: ForeignKey, value: unknown, - driver: string | null | undefined, + driver: string | PluginManifest | DriverCapabilities | null | undefined, sourceColumnType?: string, ): string { const col = quoteIdentifier(fk.ref_column, driver); diff --git a/src/utils/identifiers.ts b/src/utils/identifiers.ts index f96edab70..68c3ae928 100644 --- a/src/utils/identifiers.ts +++ b/src/utils/identifiers.ts @@ -1,19 +1,35 @@ -import type { PluginManifest } from "../types/plugins"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; + +/** Narrows a `driver` argument down to its `DriverCapabilities`, whether it + * arrived as a full `PluginManifest`, a bare `DriverCapabilities`, or neither. */ +function capabilitiesOf( + driver: string | PluginManifest | DriverCapabilities | null | undefined, +): DriverCapabilities | null { + if (typeof driver !== "object" || driver === null) return null; + return "capabilities" in driver ? driver.capabilities : driver; +} /** * Returns the appropriate quote character for SQL identifiers based on the database driver. - * Accepts a driver string or a PluginManifest object. - * When a manifest is provided, the identifier_quote from capabilities is used. + * Accepts a driver string, a PluginManifest, or a bare DriverCapabilities object. + * When an object is provided, the identifier_quote from capabilities is used. * MySQL/MariaDB use backticks (`), while PostgreSQL and SQLite use double quotes ("). */ export function getQuoteChar( - driver: string | PluginManifest | null | undefined, + driver: string | PluginManifest | DriverCapabilities | null | undefined, ): string { - if (typeof driver === "object" && driver?.capabilities?.identifier_quote) { - return driver.capabilities.identifier_quote; + const caps = capabilitiesOf(driver); + if (caps?.identifier_quote) { + return caps.identifier_quote; } - // legacy fallback for string driver names - const driverStr = typeof driver === "object" ? driver?.id : driver; + // legacy fallback for string driver names (or an object with no id, e.g. a + // bare DriverCapabilities that omitted identifier_quote) + const driverStr = + typeof driver === "object" && driver !== null && "id" in driver + ? driver.id + : typeof driver === "string" + ? driver + : undefined; return driverStr === "mysql" || driverStr === "mariadb" ? "`" : '"'; } @@ -29,12 +45,27 @@ export function getQuoteChar( * quoteIdentifier("my table", "mysql") // returns: `my table` * quoteIdentifier("my_table", "postgres") // returns: "my_table" */ -/** True when identifiers in generated SQL fragments should be double-quoted (PostgreSQL). */ +/** + * True when identifiers in generated SQL fragments should be double-quoted. + * Capability-driven when a manifest/capabilities object is available (issue + * #614): checks `sql_dialect`, not the driver id string, so a postgres- + * compatible driver registered under a different id (e.g. a standalone + * PostgreSQL plugin) is quoted identically to the builtin "postgres" driver. + * An omitted `sql_dialect` defaults to "postgres" per the manifest schema — + * matching the same fallback `src/utils/sqlSplitter/index.ts` already uses. + * Falls back to a literal string check when only a bare driver id is + * available (no capabilities object in scope) — covers both "postgres" + * (builtin) and "postgresql" (the shipped plugin's id, per PR #588) so + * bare-string callers keep working without a manifest in scope. + */ export function shouldQuoteIdentifiers( - driver: string | PluginManifest | null | undefined, + driver: string | PluginManifest | DriverCapabilities | null | undefined, ): boolean { - const driverStr = typeof driver === "object" ? driver?.id : driver; - return driverStr === "postgres" || driverStr === "postgresql"; + const caps = capabilitiesOf(driver); + if (caps) { + return (caps.sql_dialect ?? "postgres") === "postgres"; + } + return driver === "postgres" || driver === "postgresql"; } // PostgreSQL folds unquoted identifiers to lowercase and only needs quotes for @@ -51,7 +82,7 @@ const PG_RESERVED = new Set([ */ export function formatSqlIdentifier( identifier: string, - driver: string | PluginManifest | null | undefined, + driver: string | PluginManifest | DriverCapabilities | null | undefined, ): string { if (!shouldQuoteIdentifiers(driver)) return identifier; if (PG_SAFE_IDENTIFIER.test(identifier) && !PG_RESERVED.has(identifier)) { @@ -62,7 +93,7 @@ export function formatSqlIdentifier( export function quoteIdentifier( identifier: string, - driver: string | PluginManifest | null | undefined, + driver: string | PluginManifest | DriverCapabilities | null | undefined, ): string { const quote = getQuoteChar(driver); const escaped = @@ -79,7 +110,7 @@ export function quoteIdentifier( */ export function quoteTableRef( table: string, - driver: string | PluginManifest | null | undefined, + driver: string | PluginManifest | DriverCapabilities | null | undefined, schema?: string | null, ): string { if (schema) { diff --git a/src/utils/newConsole.ts b/src/utils/newConsole.ts index 7ccb2e924..871fc4e04 100644 --- a/src/utils/newConsole.ts +++ b/src/utils/newConsole.ts @@ -1,3 +1,4 @@ +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; import { quoteTableRef } from "./identifiers"; export interface NewConsoleSpec { @@ -12,7 +13,7 @@ export function newConsoleForDatabase(databaseName: string): NewConsoleSpec { export function newConsoleForTable( tableName: string, - driver: string | null | undefined, + driver: string | PluginManifest | DriverCapabilities | null | undefined, schema?: string, ): NewConsoleSpec { return { diff --git a/src/utils/objectPaletteItems.ts b/src/utils/objectPaletteItems.ts index ede1945a3..601f63c28 100644 --- a/src/utils/objectPaletteItems.ts +++ b/src/utils/objectPaletteItems.ts @@ -1,5 +1,6 @@ import type { TableTarget } from "../types/databaseObjects"; import type { PaletteAction, PaletteItem } from "../types/palette"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; import { createQueryableObjectRequests, createTableConsoleRequest, @@ -33,7 +34,7 @@ interface ObjectPaletteLabels { interface CreateObjectPaletteItemsOptions { navigatorItems: NavigatorItem[]; connectionId: string; - driver: string | null; + driver: string | PluginManifest | DriverCapabilities | null; hasGroups: boolean; isMultiDatabase: boolean; labels: ObjectPaletteLabels; diff --git a/src/utils/quickNavigator.ts b/src/utils/quickNavigator.ts index 357f5d79a..cd7947bdc 100644 --- a/src/utils/quickNavigator.ts +++ b/src/utils/quickNavigator.ts @@ -6,6 +6,7 @@ import type { ViewInfo, } from "../contexts/DatabaseContext"; import type { DatabaseObject } from "./databaseObjectActions"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; interface NavigatorItemBase { name: string; @@ -139,7 +140,7 @@ export function getNavigatorItems(params: NavigatorItemParams): NavigatorItem[] interface DatabaseObjectContext { connectionId: string; - driver: string | null; + driver: string | PluginManifest | DriverCapabilities | null; isMultiDatabase: boolean; } diff --git a/src/utils/sidebarTableItem.ts b/src/utils/sidebarTableItem.ts index 6727a7fb5..3aae60db6 100644 --- a/src/utils/sidebarTableItem.ts +++ b/src/utils/sidebarTableItem.ts @@ -9,12 +9,20 @@ * table only re-renders the two affected items instead of all of them. */ +import type { DriverCapabilities } from "../types/plugins"; + /** Subset of the table item props that actually affect its rendered output. */ export interface TableItemComparableProps { table: { name: string }; activeTable: string | null; connectionId: string; driver: string; + /** Drives identifier quoting in the nested `SidebarColumnItem` (issue + * #614) — must be compared, or a capabilities-only update (e.g. the + * driver's manifest finishes resolving after mount) would be silently + * skipped by memo and the item would keep quoting as if no capabilities + * were available. */ + capabilities?: DriverCapabilities | null; canManage?: boolean; schemaVersion: number; schema?: string; @@ -41,6 +49,7 @@ export function areTableItemPropsEqual( wasActive === isActive && prev.connectionId === next.connectionId && prev.driver === next.driver && + prev.capabilities === next.capabilities && prev.canManage === next.canManage && prev.schemaVersion === next.schemaVersion && prev.schema === next.schema diff --git a/src/utils/tableToolbar.ts b/src/utils/tableToolbar.ts index ce48ddaf4..eaf336bf8 100644 --- a/src/utils/tableToolbar.ts +++ b/src/utils/tableToolbar.ts @@ -3,7 +3,8 @@ * Pure functions for managing toolbar state and changes */ -import { formatSqlIdentifier } from "./identifiers"; +import { formatSqlIdentifier, shouldQuoteIdentifiers } from "./identifiers"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; export interface TableToolbarState { filterInput: string; @@ -97,12 +98,16 @@ export function generateOrderByPlaceholder(column: string): string { /** * Quotes column names in ORDER BY input for PostgreSQL (e.g. `Status DESC` → `"Status" DESC`). + * Capability-driven (issue #614): routes through `shouldQuoteIdentifiers` + * instead of its own literal `driver === "postgres"` check, so a + * postgres-compatible driver registered under a different id (e.g. a + * standalone PostgreSQL plugin) is quoted the same as the builtin driver. */ export function formatSortClause( clause: string, - driver?: string | null, + driver?: string | PluginManifest | DriverCapabilities | null, ): string { - if (!clause.trim() || driver !== "postgres") { + if (!clause.trim() || !shouldQuoteIdentifiers(driver)) { return clause; } diff --git a/src/utils/visualQuery.ts b/src/utils/visualQuery.ts index 2881785ff..6469b72e4 100644 --- a/src/utils/visualQuery.ts +++ b/src/utils/visualQuery.ts @@ -4,6 +4,14 @@ */ import { formatSqlIdentifier, quoteTableRef } from "./identifiers"; +import type { DriverCapabilities, PluginManifest } from "../types/plugins"; + +/** Driver argument accepted throughout this module: a bare driver id + * string, a resolved manifest, or a bare capabilities object. Capability- + * driven when available (issue #614): a postgres-compatible driver + * registered under a different id (e.g. a standalone PostgreSQL plugin) is + * quoted the same as the builtin "postgres" driver. */ +type DriverArg = string | PluginManifest | DriverCapabilities | null | undefined; export interface TableNodeData { label: string; @@ -66,7 +74,7 @@ export interface SelectedColumn { function formatTableRef( tableName: string, - driver: string | null | undefined, + driver: DriverArg, ): string { return tableName .split('.') @@ -77,14 +85,14 @@ function formatTableRef( function formatColumnRef( alias: string, column: string, - driver: string | null | undefined, + driver: DriverArg, ): string { return `${alias}.${formatSqlIdentifier(column, driver)}`; } function formatGeneratedColumnRef( column: string, - driver: string | null | undefined, + driver: DriverArg, ): string { const [alias, ...nameParts] = column.split('.'); if (nameParts.length === 0) return formatSqlIdentifier(column, driver); @@ -93,7 +101,7 @@ function formatGeneratedColumnRef( function formatAggregateArgument( argument: string, - driver: string | null | undefined, + driver: DriverArg, ): string { const trimmed = argument.trim(); if ( @@ -111,7 +119,7 @@ function formatAggregateArgument( function formatHavingColumnRef( column: string, - driver: string | null | undefined, + driver: DriverArg, ): string { const aggregateMatch = column.match(/^([A-Z_][A-Z0-9_]*)\((.*)\)$/i); if (!aggregateMatch) return formatGeneratedColumnRef(column, driver); @@ -128,7 +136,7 @@ function formatHavingColumnRef( function formatAlias( alias: string, - driver: string | null | undefined, + driver: DriverArg, ): string { return formatSqlIdentifier(alias, driver); } @@ -147,7 +155,7 @@ export function collectTableAliases(nodes: QueryNode[]): Record /** * Generates table list with aliases */ -function formatNodeTableRef(data: TableNodeData, driver?: string | null): string { +function formatNodeTableRef(data: TableNodeData, driver?: DriverArg): string { if (!data.schema) return formatTableRef(data.label, driver); return quoteTableRef(data.label, driver, data.schema); } @@ -155,7 +163,7 @@ function formatNodeTableRef(data: TableNodeData, driver?: string | null): string export function generateTableList( nodes: QueryNode[], aliases: Record, - driver?: string | null, + driver?: DriverArg, ): string[] { return nodes.map((node) => { const tableName = formatNodeTableRef(node.data, driver); @@ -170,7 +178,7 @@ export function generateTableList( export function collectSelectedColumns( nodes: QueryNode[], aliases: Record, - driver?: string | null, + driver?: DriverArg, ): { columns: SelectedColumn[]; hasAggregation: boolean; nonAggregatedCols: string[] } { const selectedColsWithOrder: SelectedColumn[] = []; const nonAggregatedCols: string[] = []; @@ -248,7 +256,7 @@ export function generateFromClause( nodes: QueryNode[], edges: QueryEdge[], aliases: Record, - driver?: string | null, + driver?: DriverArg, ): string { if (nodes.length === 0) return ''; @@ -326,7 +334,7 @@ export function generateFromClause( */ export function generateWhereClause( conditions: WhereCondition[], - driver?: string | null, + driver?: DriverArg, ): string { const normalConditions = conditions.filter((c) => !c.isAggregate && c.column && c.value); @@ -347,7 +355,7 @@ export function generateGroupByClause( hasAggregation: boolean, nonAggregatedCols: string[], manualGroupBy: string[], - driver?: string | null, + driver?: DriverArg, ): string { const formattedManualGroupBy = manualGroupBy.map((column) => formatGeneratedColumnRef(column, driver), @@ -369,7 +377,7 @@ export function generateGroupByClause( */ export function generateHavingClause( conditions: WhereCondition[], - driver?: string | null, + driver?: DriverArg, ): string { const aggregateConditions = conditions.filter((c) => c.isAggregate && c.column && c.value); @@ -388,7 +396,7 @@ export function generateHavingClause( */ export function generateOrderByClause( orderBy: OrderByClause[], - driver?: string | null, + driver?: DriverArg, ): string { if (orderBy.length === 0) return ''; @@ -416,7 +424,7 @@ export function generateVisualQuerySQL( orderBy: OrderByClause[], groupBy: string[], limit: string, - driver?: string | null, + driver?: DriverArg, ): string { if (nodes.length === 0) return ''; diff --git a/tests/components/layout/ExplorerSidebar.test.tsx b/tests/components/layout/ExplorerSidebar.test.tsx index a066429ef..adcb9f6c3 100644 --- a/tests/components/layout/ExplorerSidebar.test.tsx +++ b/tests/components/layout/ExplorerSidebar.test.tsx @@ -46,6 +46,37 @@ vi.mock("../../../src/utils/notebookStore", async (importOriginal) => { // (the global setup mock only stubs a fixed subset). vi.mock("lucide-react", async (importOriginal) => await importOriginal()); +const sidebarItemMocks = vi.hoisted(() => ({ + sidebarTableItemProps: [] as Array>, + sidebarViewItemProps: [] as Array>, +})); + +vi.mock("../../../src/components/layout/sidebar/SidebarTableItem", async (importOriginal) => { + const actual = await importOriginal< + typeof import("../../../src/components/layout/sidebar/SidebarTableItem") + >(); + return { + ...actual, + SidebarTableItem: (props: Record) => { + sidebarItemMocks.sidebarTableItemProps.push(props); + return [0])} />; + }, + }; +}); + +vi.mock("../../../src/components/layout/sidebar/SidebarViewItem", async (importOriginal) => { + const actual = await importOriginal< + typeof import("../../../src/components/layout/sidebar/SidebarViewItem") + >(); + return { + ...actual, + SidebarViewItem: (props: Record) => { + sidebarItemMocks.sidebarViewItemProps.push(props); + return [0])} />; + }, + }; +}); + const DISPLAY = "could not connect: TLS handshake failed"; const DEBUG = 'InvalidCertificate(UnknownIssuer)\n caused by: certificate verify failed'; const RAW_ERROR = `${DISPLAY}\n\n${DEBUG}`; @@ -352,3 +383,104 @@ describe("ExplorerSidebar — database object navigation", () => { ).toBeDisabled(); }); }); + +describe("ExplorerSidebar — capabilities threading (issue #614)", () => { + const databaseState = { + activeConnectionId: "c1" as string | null, + activeDriver: "postgresql", + activeCapabilities: { sql_dialect: "postgres", identifier_quote: '"' }, + activeTable: null, + setActiveTable: vi.fn(), + tables: [{ name: "orders" }], + views: [{ name: "active_orders" }], + routines: [], + triggers: [], + schemas: [] as string[], + connectionDataMap: { c1: {} }, + schemaDataMap: {}, + databaseDataMap: {}, + selectedSchemas: [] as string[], + selectedDatabases: [] as string[], + connections: [], + isLoadingTables: false, + isLoadingSchemas: false, + connect: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + sidebarItemMocks.sidebarTableItemProps.length = 0; + sidebarItemMocks.sidebarViewItemProps.length = 0; + + vi.mocked(useDatabaseObjectNavigation).mockReturnValue({ + open: vi.fn(), + count: vi.fn(), + newConsole: vi.fn(), + openRoutineDefinition: vi.fn(), + openTriggerDefinition: vi.fn(), + openDefinition: vi.fn(), + } as unknown as ReturnType); + + vi.mocked(useDatabase).mockReturnValue( + databaseState as unknown as ReturnType, + ); + + vi.mocked(useSavedQueries).mockReturnValue({ + queries: [], + deleteQuery: vi.fn(), + updateQuery: vi.fn(), + saveQuery: vi.fn(), + } as unknown as ReturnType); + + vi.mocked(useQueryHistory).mockReturnValue({ + entries: [], + isLoading: false, + deleteEntry: vi.fn(), + clearHistory: vi.fn(), + recoveryNotice: null, + dismissRecoveryNotice: vi.fn(), + } as unknown as ReturnType); + + vi.mocked(useAlert).mockReturnValue({ + showAlert: vi.fn(), + } as unknown as ReturnType); + + vi.mocked(useDrivers).mockReturnValue({ + allDrivers: [], + } as unknown as ReturnType); + + vi.mocked(useEditor).mockReturnValue({ + tabs: [], + openNotebook: vi.fn(), + updateTab: vi.fn(), + closeTab: vi.fn(), + } as unknown as ReturnType); + + vi.mocked(useSettings).mockReturnValue({ + settings: { displayTimezone: "auto" }, + } as unknown as ReturnType); + + vi.mocked(useConnectionLayoutContext).mockReturnValue({ + splitView: { connectionIds: [] }, + isSplitVisible: false, + explorerConnectionId: null, + setExplorerConnectionId: vi.fn(), + } as unknown as ReturnType); + }); + + it("passes activeCapabilities through to SidebarTableItem, not just the driver id", () => { + renderSidebar(); + expect(sidebarItemMocks.sidebarTableItemProps).toHaveLength(1); + expect(sidebarItemMocks.sidebarTableItemProps[0].capabilities).toBe( + databaseState.activeCapabilities, + ); + }); + + it("passes activeCapabilities through to SidebarViewItem, not just the driver id", () => { + renderSidebar(); + expect(sidebarItemMocks.sidebarViewItemProps).toHaveLength(1); + expect(sidebarItemMocks.sidebarViewItemProps[0].capabilities).toBe( + databaseState.activeCapabilities, + ); + }); +}); diff --git a/tests/components/modals/NewConnectionModal.test.tsx b/tests/components/modals/NewConnectionModal.test.tsx index 1408e909f..aa2c6183e 100644 --- a/tests/components/modals/NewConnectionModal.test.tsx +++ b/tests/components/modals/NewConnectionModal.test.tsx @@ -89,7 +89,7 @@ vi.mock("../../../src/hooks/useDrivers", () => ({ file_based: false, folder_based: false, connection_string: true, - supports_ssl: false, + supports_ssl: true, }, }, { @@ -105,6 +105,23 @@ vi.mock("../../../src/hooks/useDrivers", () => ({ supports_ssl: false, }, }, + { + // Simulates the standalone PostgreSQL plugin (issue #614): a + // non-"postgres" driver id whose manifest explicitly declares the + // postgres SQL dialect. + id: "postgresql", + name: "PostgreSQL", + version: "1.0.0-beta.2", + default_port: 5432, + is_builtin: false, + capabilities: { + file_based: false, + folder_based: false, + connection_string: true, + supports_ssl: true, + sql_dialect: "postgres", + }, + }, ], allDrivers: [], installedPlugins: [], @@ -1278,3 +1295,80 @@ describe("NewConnectionModal SQLite file creation", () => { ).toHaveValue(""); }); }); + +describe("NewConnectionModal SSL mode options (issue #614)", () => { + beforeEach(() => { + vi.clearAllMocks(); + sshMocks.loadSshConnections.mockResolvedValue([]); + k8sMocks.loadK8sConnections.mockResolvedValue([]); + }); + + // Regression test for issue #614: the SSL mode dropdown used to branch on + // `driver === "postgres"` literally, so a non-builtin driver whose manifest + // declares the postgres SQL dialect (e.g. the standalone PostgreSQL plugin, + // id "postgresql") fell into the MySQL-style branch instead. The plugin's + // own `needs_tls()` only recognizes the Postgres-style hyphenated values + // ("require"/"verify-ca"/"verify-full"), so a connection saved with the + // MySQL-style "required"/"verify_ca" would silently connect in cleartext. + it("shows Postgres-style SSL mode options for a non-'postgres' driver with sql_dialect: postgres", async () => { + renderModal(createInitialConnection({ driver: "postgresql" })); + + fireEvent.click(screen.getByText("SSL")); + + const select = screen.getByLabelText("select"); + const optionValues = Array.from(select.querySelectorAll("option")) + .map((o) => o.getAttribute("value")) + .filter((v) => v !== ""); + expect(optionValues).toEqual([ + "disable", + "allow", + "prefer", + "require", + "verify-ca", + "verify-full", + ]); + expect(select).toHaveValue("prefer"); + }); + + it("still shows MySQL-style SSL mode options for the mysql driver", async () => { + renderModal(createInitialConnection({ driver: "mysql" })); + + fireEvent.click(screen.getByText("SSL")); + + const select = screen.getByLabelText("select"); + const optionValues = Array.from(select.querySelectorAll("option")) + .map((o) => o.getAttribute("value")) + .filter((v) => v !== ""); + expect(optionValues).toEqual([ + "disabled", + "preferred", + "required", + "verify_ca", + "verify_identity", + ]); + }); + + // Regression test for issue #614: the host/port grid also branched on + // `driver === "postgres"` literally, so a non-builtin driver whose + // manifest declares the postgres SQL dialect got a 3-column grid instead + // of the 4-column grid the builtin driver got for the same two fields — + // a real (if purely visual) layout inconsistency between the builtin and + // the plugin, not just a cosmetic no-op. + it("uses a 4-column host/port grid for a non-'postgres' driver with sql_dialect: postgres, same as the builtin", () => { + renderModal(createInitialConnection({ driver: "postgresql" })); + + const hostInput = screen.getByPlaceholderText("localhost"); + const grid = hostInput.closest(".grid"); + expect(grid).not.toBeNull(); + expect(grid).toHaveClass("grid-cols-4"); + }); + + it("uses a 3-column host/port grid for the mysql driver", () => { + renderModal(createInitialConnection({ driver: "mysql" })); + + const hostInput = screen.getByPlaceholderText("localhost"); + const grid = hostInput.closest(".grid"); + expect(grid).not.toBeNull(); + expect(grid).toHaveClass("grid-cols-3"); + }); +}); 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." diff --git a/tests/utils/autocomplete.test.ts b/tests/utils/autocomplete.test.ts index 6a555a040..76c7141e4 100644 --- a/tests/utils/autocomplete.test.ts +++ b/tests/utils/autocomplete.test.ts @@ -4,6 +4,7 @@ import { registerSqlAutocomplete, } from '../../src/utils/autocomplete'; import type { TableInfo } from '../../src/contexts/DatabaseContext'; +import type { PluginManifest } from '../../src/types/plugins'; // Mock @tauri-apps/api/core vi.mock('@tauri-apps/api/core', () => ({ @@ -173,6 +174,41 @@ describe('autocomplete', () => { expect(tableSuggestions[0]?.insertText).toBe('"AccountEventLog"'); }); + it('inserts double-quoted table names for a postgres-dialect plugin manifest, same as the bare "postgres" string (issue #614)', async () => { + const pluginManifest: PluginManifest = { + id: 'postgresql', + name: 'PostgreSQL', + version: '1.0.0', + description: '', + default_port: 5432, + capabilities: { + schemas: true, views: true, routines: true, + file_based: false, folder_based: false, + identifier_quote: '"', alter_primary_key: true, + sql_dialect: 'postgres', + }, + }; + const monaco = createMockMonaco(); + registerSqlAutocomplete( + monaco as unknown as Parameters[0], + 'conn1', + [{ name: 'AccountEventLog' }], + null, + pluginManifest, + ); + + const provider = monaco.languages.registerCompletionItemProvider.mock.calls[0][1]; + const result = await provider.provideCompletionItems( + createMockModel('SELECT * FROM '), + { lineNumber: 1, column: 15 }, + ); + + const tableSuggestions = result.suggestions.filter((s: { sortText?: string }) => + s.sortText?.startsWith('1_'), + ); + expect(tableSuggestions[0]?.insertText).toBe('"AccountEventLog"'); + }); + it('does not prefix schema and quotes table name only if needed for postgres', async () => { const monaco = createMockMonaco(); registerSqlAutocomplete( diff --git a/tests/utils/connections.test.ts b/tests/utils/connections.test.ts index d40d44061..323dbeb1f 100644 --- a/tests/utils/connections.test.ts +++ b/tests/utils/connections.test.ts @@ -11,7 +11,7 @@ import { type ConnectionParams, type DatabaseDriver, } from '../../src/utils/connections'; -import type { DriverCapabilities } from '../../src/types/plugins'; +import type { DriverCapabilities, PluginManifest } from '../../src/types/plugins'; import type { SavedConnection } from '../../src/contexts/DatabaseContext'; const makeFileCaps = (): DriverCapabilities => ({ @@ -97,6 +97,42 @@ describe('connections', () => { it('should return 0 for SQLite', () => { expect(getDefaultPort('sqlite')).toBe(0); }); + + it('should return 0 for an unrecognized bare driver id (e.g. a plugin id with no manifest resolved)', () => { + expect(getDefaultPort('postgresql')).toBe(0); + }); + + it('should read default_port off a PluginManifest for a non-"postgres" plugin id (issue #614)', () => { + const manifest: PluginManifest = { + id: 'postgresql', + name: 'PostgreSQL', + version: '1.0.0', + description: '', + default_port: 5432, + capabilities: { + schemas: true, views: true, routines: true, + file_based: false, folder_based: false, + identifier_quote: '"', alter_primary_key: true, + }, + }; + expect(getDefaultPort(manifest)).toBe(5432); + }); + + it('should fall back to 0 when a PluginManifest declares no default_port', () => { + const manifest: PluginManifest = { + id: 'some-plugin', + name: 'Some Plugin', + version: '1.0.0', + description: '', + default_port: null, + capabilities: { + schemas: false, views: false, routines: false, + file_based: false, folder_based: false, + identifier_quote: '"', alter_primary_key: false, + }, + }; + expect(getDefaultPort(manifest)).toBe(0); + }); }); describe('validateConnectionParams', () => { @@ -289,6 +325,26 @@ describe('connections', () => { it('should return human-readable label for SQLite', () => { expect(getDriverLabel('sqlite')).toBe('SQLite'); }); + + it('should return an all-caps fallback for an unrecognized bare driver id', () => { + expect(getDriverLabel('postgresql')).toBe('POSTGRESQL'); + }); + + it('should read name off a PluginManifest for a non-"postgres" plugin id (issue #614), not the all-caps fallback', () => { + const manifest: PluginManifest = { + id: 'postgresql', + name: 'PostgreSQL', + version: '1.0.0', + description: '', + default_port: 5432, + capabilities: { + schemas: true, views: true, routines: true, + file_based: false, folder_based: false, + identifier_quote: '"', alter_primary_key: true, + }, + }; + expect(getDriverLabel(manifest)).toBe('PostgreSQL'); + }); }); describe('generateConnectionName', () => { diff --git a/tests/utils/foreignKeys.test.ts b/tests/utils/foreignKeys.test.ts index 27c5b3817..f05d42f58 100644 --- a/tests/utils/foreignKeys.test.ts +++ b/tests/utils/foreignKeys.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import type { ForeignKey } from "../../src/types/schema"; +import type { PluginManifest } from "../../src/types/plugins"; import { pickPrimaryForeignKeyByColumn, isForeignKeyValueNavigable, @@ -123,6 +124,25 @@ describe("foreignKeys", () => { ).toBe('"id" = 9007199254740993'); }); + it("quotes identically for a postgres-dialect plugin manifest as for the bare \"postgres\" string (issue #614)", () => { + const pluginManifest: PluginManifest = { + id: "postgresql", + name: "PostgreSQL", + version: "1.0.0", + description: "", + default_port: 5432, + capabilities: { + schemas: true, views: true, routines: true, + file_based: false, folder_based: false, + identifier_quote: '"', alter_primary_key: true, + sql_dialect: "postgres", + }, + }; + expect(buildForeignKeyFilterClause(orgFk, 42, pluginManifest)).toBe( + buildForeignKeyFilterClause(orgFk, 42, "postgres"), + ); + }); + it("quotes string values and escapes embedded single quotes", () => { const slugFk = fk("fk_slug", "slug", "pages", "slug"); expect( diff --git a/tests/utils/identifiers.test.ts b/tests/utils/identifiers.test.ts index d744f1905..90bc7d718 100644 --- a/tests/utils/identifiers.test.ts +++ b/tests/utils/identifiers.test.ts @@ -4,9 +4,67 @@ import { quoteIdentifier, quoteTableRef, formatSqlIdentifier, + shouldQuoteIdentifiers, } from '../../src/utils/identifiers'; import type { PluginManifest } from '../../src/types/plugins'; +/** Minimal PluginManifest fixture for a postgres-dialect plugin registered + * under a non-"postgres" id (issue #614's exact scenario). */ +function pluginManifest( + overrides: Partial = {}, +): PluginManifest { + return { + id: 'postgresql', + name: 'PostgreSQL', + version: '1.0.0', + description: '', + default_port: 5432, + capabilities: { + schemas: true, + views: true, + routines: true, + file_based: false, + folder_based: false, + identifier_quote: '"', + alter_primary_key: true, + sql_dialect: 'postgres', + ...overrides, + }, + }; +} + +describe('shouldQuoteIdentifiers', () => { + it('returns true for the bare "postgres" string (legacy path)', () => { + expect(shouldQuoteIdentifiers('postgres')).toBe(true); + }); + + it('returns true for the bare "postgresql" string — no capabilities object, so the literal fallback covers the shipped plugin id too (PR #588)', () => { + expect(shouldQuoteIdentifiers('postgresql')).toBe(true); + }); + + it('returns true for a manifest/capabilities object declaring sql_dialect: "postgres"', () => { + expect(shouldQuoteIdentifiers(pluginManifest())).toBe(true); + expect(shouldQuoteIdentifiers(pluginManifest().capabilities)).toBe(true); + }); + + it('returns true when sql_dialect is omitted entirely (defaults to postgres per the manifest schema)', () => { + const { sql_dialect, ...withoutDialect } = pluginManifest().capabilities; + expect(shouldQuoteIdentifiers(withoutDialect)).toBe(true); + }); + + it('returns false for sql_dialect: "sqlite" — must not flip on identifier_quote alone, which sqlite shares with postgres', () => { + expect( + shouldQuoteIdentifiers(pluginManifest({ sql_dialect: 'sqlite' })), + ).toBe(false); + }); + + it('returns false for sql_dialect: "mysql"', () => { + expect( + shouldQuoteIdentifiers(pluginManifest({ sql_dialect: 'mysql' })), + ).toBe(false); + }); +}); + describe('getQuoteChar', () => { it('should return backtick for mysql', () => { expect(getQuoteChar('mysql')).toBe('`'); @@ -35,6 +93,10 @@ describe('getQuoteChar', () => { it('should return double quote for unknown driver', () => { expect(getQuoteChar('oracle')).toBe('"'); }); + + it('should read identifier_quote off a PluginManifest for a non-"postgres" plugin id', () => { + expect(getQuoteChar(pluginManifest())).toBe('"'); + }); }); describe('quoteIdentifier', () => { @@ -71,6 +133,12 @@ describe('quoteIdentifier', () => { it('should handle identifiers with special characters', () => { expect(quoteIdentifier('table-name.v2', 'postgres')).toBe('"table-name.v2"'); }); + + it('quotes identically for a "postgresql" plugin manifest and the bare "postgres" string', () => { + expect(quoteIdentifier('my_table', pluginManifest())).toBe( + quoteIdentifier('my_table', 'postgres'), + ); + }); }); describe('quoteTableRef', () => { @@ -101,6 +169,12 @@ describe('quoteTableRef', () => { it('should escape special chars in both schema and table', () => { expect(quoteTableRef('my"table', 'postgres', 'my"schema')).toBe('"my""schema"."my""table"'); }); + + it('produces the same schema-qualified reference for a "postgresql" plugin manifest as for the bare "postgres" string', () => { + expect(quoteTableRef('users', pluginManifest(), 'public')).toBe( + quoteTableRef('users', 'postgres', 'public'), + ); + }); }); describe('formatSqlIdentifier', () => { @@ -150,4 +224,15 @@ describe('formatSqlIdentifier', () => { expect(formatSqlIdentifier('users', 'sqlite')).toBe('users'); expect(formatSqlIdentifier('AccountEventLog', 'sqlite')).toBe('AccountEventLog'); }); + it('quotes mixed-case identifiers identically for a "postgresql" plugin manifest and the bare "postgres" string', () => { + expect(formatSqlIdentifier('AccountEventLog', pluginManifest())).toBe( + formatSqlIdentifier('AccountEventLog', 'postgres'), + ); + }); + + it('leaves identifiers unchanged for a plugin manifest declaring a non-postgres dialect', () => { + expect( + formatSqlIdentifier('Status', pluginManifest({ sql_dialect: 'mysql' })), + ).toBe('Status'); + }); }); diff --git a/tests/utils/sidebarTableItem.test.ts b/tests/utils/sidebarTableItem.test.ts index 433e9fca3..454c6b418 100644 --- a/tests/utils/sidebarTableItem.test.ts +++ b/tests/utils/sidebarTableItem.test.ts @@ -4,12 +4,14 @@ import { buildTableItemSelector, type TableItemComparableProps, } from '@/utils/sidebarTableItem'; +import type { DriverCapabilities } from '@/types/plugins'; const base: TableItemComparableProps = { table: { name: 'users' }, activeTable: null, connectionId: 'conn-1', driver: 'postgres', + capabilities: null, canManage: true, schemaVersion: 0, schema: 'public', @@ -52,6 +54,17 @@ describe('areTableItemPropsEqual', () => { ['canManage', { canManage: false }], ['schemaVersion', { schemaVersion: 1 }], ['schema', { schema: 'analytics' }], + [ + 'capabilities', + { + capabilities: { + schemas: true, views: true, routines: true, + file_based: false, folder_based: false, + identifier_quote: '`', alter_primary_key: true, + sql_dialect: 'mysql', + } satisfies DriverCapabilities, + }, + ], ])('re-renders when %s changes', (_label, change) => { expect(areTableItemPropsEqual(base, { ...base, ...change })).toBe(false); }); diff --git a/tests/utils/tableToolbar.test.ts b/tests/utils/tableToolbar.test.ts index 330bbddb1..6dfc623d2 100644 --- a/tests/utils/tableToolbar.test.ts +++ b/tests/utils/tableToolbar.test.ts @@ -9,6 +9,7 @@ import { generateOrderByPlaceholder, formatSortClause, } from '../../src/utils/tableToolbar'; +import type { DriverCapabilities } from '../../src/types/plugins'; describe('tableToolbar utils', () => { describe('haveToolbarValuesChanged', () => { @@ -228,6 +229,16 @@ describe('tableToolbar utils', () => { expect(formatSortClause('Status DESC', null)).toBe('Status DESC'); }); + it('should quote column names for a postgres-dialect plugin driver (issue #614) — previously had its own separate driver !== "postgres" check, bypassing shouldQuoteIdentifiers entirely', () => { + const pluginCapabilities: DriverCapabilities = { + schemas: true, views: true, routines: true, + file_based: false, folder_based: false, + identifier_quote: '"', alter_primary_key: true, + sql_dialect: 'postgres', + }; + expect(formatSortClause('Status DESC', pluginCapabilities)).toBe('"Status" DESC'); + }); + it('should return empty clause unchanged', () => { expect(formatSortClause('', 'postgres')).toBe(''); expect(formatSortClause(' ', 'postgres')).toBe(' '); diff --git a/tests/utils/visualQuery.test.ts b/tests/utils/visualQuery.test.ts index f7d78dbf3..ff3a9d4f2 100644 --- a/tests/utils/visualQuery.test.ts +++ b/tests/utils/visualQuery.test.ts @@ -17,6 +17,7 @@ import { type WhereCondition, type OrderByClause, } from '../../src/utils/visualQuery'; +import type { PluginManifest } from '../../src/types/plugins'; describe('visualQuery utils', () => { describe('collectTableAliases', () => { @@ -888,6 +889,36 @@ describe('visualQuery utils', () => { expect(result).toContain('FROM\n "user" t1'); }); + it('should generate the same postgres SQL for a postgres-dialect plugin manifest as for the bare "postgres" string (issue #614)', () => { + const nodes: QueryNode[] = [ + { + id: 'n1', + data: { + label: 'user', + columns: [{ name: 'id', type: 'INT' }, { name: 'order', type: 'INT' }], + selectedColumns: { id: true, order: true }, + }, + }, + ]; + const pluginManifest: PluginManifest = { + id: 'postgresql', + name: 'PostgreSQL', + version: '1.0.0', + description: '', + default_port: 5432, + capabilities: { + schemas: true, views: true, routines: true, + file_based: false, folder_based: false, + identifier_quote: '"', alter_primary_key: true, + sql_dialect: 'postgres', + }, + }; + + const result = generateVisualQuerySQL(nodes, [], [], [], [], '', pluginManifest); + + expect(result).toBe(generateVisualQuerySQL(nodes, [], [], [], [], '', 'postgres')); + }); + it('should quote postgres SQL when the driver id is postgresql', () => { const nodes: QueryNode[] = [ {