From 484d20922ab719b509640302f99ac04e23527ecb Mon Sep 17 00:00:00 2001 From: ptma Date: Thu, 25 Jun 2026 10:54:38 +0800 Subject: [PATCH] fix(postgres): handle PostgreSQL views --- apps/desktop/src/components/diff/SchemaDiffDialog.vue | 4 ++-- apps/desktop/src/lib/http.ts | 4 ++-- crates/dbx-core/src/database_export.rs | 4 ++-- crates/dbx-core/src/db/postgres.rs | 2 +- crates/dbx-core/src/object_source_sql.rs | 2 ++ crates/dbx-core/src/schema.rs | 11 ++++++----- crates/dbx-core/src/schema_diff.rs | 8 ++++---- 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/components/diff/SchemaDiffDialog.vue b/apps/desktop/src/components/diff/SchemaDiffDialog.vue index 61163c03c..6282f7d19 100644 --- a/apps/desktop/src/components/diff/SchemaDiffDialog.vue +++ b/apps/desktop/src/components/diff/SchemaDiffDialog.vue @@ -269,10 +269,10 @@ function handleOptionsUpdate(options: SchemaDiffCompareOptions) { * Views and materialized views need the object_type parameter so the * backend can call DBMS_METADATA.GET_DDL with the correct type. */ function isViewOrMaterializedView(tableType: string): ObjectSourceKind | undefined { - switch (tableType.toUpperCase()) { + switch (tableType.toUpperCase().replace(/\s+/g, "_")) { case "VIEW": return "VIEW"; - case "MATERIALIZED VIEW": + case "MATERIALIZED_VIEW": return "MATERIALIZED_VIEW"; default: return undefined; diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 2113259fa..fa49cc98c 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -163,7 +163,7 @@ async function del(url: string): Promise { return res.json(); } -function qs(params: Record): string { +function qs(params: Record): string { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null) sp.set(k, String(v)); @@ -564,7 +564,7 @@ export async function listFunctions(connectionId: string, database: string, sche } export async function listSequences(connectionId: string, database: string, schema: string, withLastValues: boolean): Promise { - return get(`/api/schema/sequences?${qs({ connection_id: connectionId, database, schema, with_last_values: withLastValues ? 1 : 0 })}`); + return get(`/api/schema/sequences?${qs({ connection_id: connectionId, database, schema, with_last_values: withLastValues })}`); } export async function listRules(connectionId: string, database: string, schema: string): Promise { diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index f1a495071..56c9be58c 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -578,8 +578,8 @@ pub async fn export_database_sql_core( } // 7. Separate tables and views - let mut tables: Vec<_> = all_tables.iter().filter(|t| t.table_type != "VIEW").collect(); - let views: Vec<_> = all_tables.iter().filter(|t| t.table_type == "VIEW").collect(); + let mut tables: Vec<_> = all_tables.iter().filter(|t| !t.table_type.contains("VIEW")).collect(); + let views: Vec<_> = all_tables.iter().filter(|t| t.table_type.contains("VIEW")).collect(); let postgres_sequences = if request.include_structure && matches!(db_type, DatabaseType::Postgres) { match list_postgres_export_sequences( state, diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 88a15348d..33e397bd7 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -1153,7 +1153,7 @@ pub async fn completion_assistant_search( let table_type: String = row.get(2); candidates.push(CompletionAssistantCandidate { name: row.get(0), - kind: if table_type == "VIEW" { + kind: if table_type.contains("VIEW") { CompletionAssistantCandidateKind::View } else { CompletionAssistantCandidateKind::Table diff --git a/crates/dbx-core/src/object_source_sql.rs b/crates/dbx-core/src/object_source_sql.rs index ac998dff5..e1c8550cf 100644 --- a/crates/dbx-core/src/object_source_sql.rs +++ b/crates/dbx-core/src/object_source_sql.rs @@ -432,6 +432,8 @@ fn build_sqlserver_alter_view_sql(schema: Option<&str>, name: &str, source: &str fn parse_object_source_kind(value: &str) -> Option { if value.eq_ignore_ascii_case("VIEW") { Some(ObjectSourceKind::View) + } else if value.eq_ignore_ascii_case("MATERIALIZED VIEW") || value.eq_ignore_ascii_case("MATERIALIZED_VIEW") { + Some(ObjectSourceKind::MaterializedView) } else if value.eq_ignore_ascii_case("PROCEDURE") { Some(ObjectSourceKind::Procedure) } else if value.eq_ignore_ascii_case("FUNCTION") { diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 99c30c9cb..ef01f0f29 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -1779,9 +1779,7 @@ async fn completion_assistant_fallback_core( ) .await?; for table in tables { - let kind = if table.table_type.eq_ignore_ascii_case("VIEW") - || table.table_type.eq_ignore_ascii_case("MATERIALIZED_VIEW") - { + let kind = if table.table_type.to_uppercase().contains("VIEW") { db::CompletionAssistantCandidateKind::View } else { db::CompletionAssistantCandidateKind::Table @@ -2819,7 +2817,9 @@ pub fn postgres_object_source_sql(schema: &str, name: &str, kind: &db::ObjectSou match kind { db::ObjectSourceKind::View | db::ObjectSourceKind::MaterializedView => { format!( - "SELECT pg_get_viewdef(c.oid, 0) \ + "SELECT CASE WHEN c.relkind = 'm' THEN format('CREATE MATERIALIZED VIEW %I.%I AS ', n.nspname, c.relname) || regexp_replace(pg_get_viewdef(c.oid, 0), ';[[:space:]]*$', '') || CASE WHEN c.relispopulated THEN ' WITH DATA' ELSE ' WITH NO DATA' END \ + ELSE format('CREATE OR REPLACE VIEW %I.%I AS ', n.nspname, c.relname) || pg_get_viewdef(c.oid, 0) \ + END \ FROM pg_catalog.pg_class c \ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \ WHERE n.nspname = {} AND c.relname = {} AND c.relkind IN ('v','m') \ @@ -3178,7 +3178,7 @@ mod object_source_tests { fn builds_postgres_object_source_sql_for_views_and_functions() { assert_eq!( postgres_object_source_sql("public", "active_users", &ObjectSourceKind::View), - "SELECT pg_get_viewdef(c.oid, 0) FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relname = 'active_users' AND c.relkind IN ('v','m') ORDER BY c.oid LIMIT 1" + "SELECT CASE WHEN c.relkind = 'm' THEN format('CREATE MATERIALIZED VIEW %I.%I AS ', n.nspname, c.relname) || regexp_replace(pg_get_viewdef(c.oid, 0), ';[[:space:]]*$', '') || CASE WHEN c.relispopulated THEN ' WITH DATA' ELSE ' WITH NO DATA' END ELSE format('CREATE OR REPLACE VIEW %I.%I AS ', n.nspname, c.relname) || pg_get_viewdef(c.oid, 0) END FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relname = 'active_users' AND c.relkind IN ('v','m') ORDER BY c.oid LIMIT 1" ); assert_eq!( postgres_object_source_sql("public", "recalc_score", &ObjectSourceKind::Function), @@ -3192,6 +3192,7 @@ mod object_source_tests { assert!(!sql.contains("::regclass")); assert!(sql.contains("pg_get_viewdef(c.oid, 0)")); + assert!(sql.contains("format('CREATE OR REPLACE VIEW %I.%I AS ', n.nspname, c.relname)")); assert!(sql.contains("n.nspname = 'tenant''s schema'")); assert!(sql.contains("c.relname = 'active users'")); assert!(sql.contains("c.relkind IN ('v','m')")); diff --git a/crates/dbx-core/src/schema_diff.rs b/crates/dbx-core/src/schema_diff.rs index 4e1a5c2bf..b6e0928db 100644 --- a/crates/dbx-core/src/schema_diff.rs +++ b/crates/dbx-core/src/schema_diff.rs @@ -263,25 +263,25 @@ fn diff_schema(options: &SchemaDiffPreparationOptions) -> Vec { let source_table_names: Vec = options .source_tables .iter() - .filter(|table| table.table_type != "VIEW") + .filter(|table| !table.table_type.contains("VIEW")) .map(|table| table.name.clone()) .collect(); let target_table_names: Vec = options .target_tables .iter() - .filter(|table| table.table_type != "VIEW") + .filter(|table| !table.table_type.contains("VIEW")) .map(|table| table.name.clone()) .collect(); let source_view_names: Vec = options .source_tables .iter() - .filter(|table| table.table_type == "VIEW") + .filter(|table| table.table_type.contains("VIEW")) .map(|table| table.name.clone()) .collect(); let target_view_names: Vec = options .target_tables .iter() - .filter(|table| table.table_type == "VIEW") + .filter(|table| table.table_type.contains("VIEW")) .map(|table| table.name.clone()) .collect();