From 062ccf88f39e2ba88849984f193731574cfe785d Mon Sep 17 00:00:00 2001 From: nettrash Date: Mon, 3 Aug 2026 18:10:47 +0200 Subject: [PATCH 1/9] #238 --- app/src/comparer/{core_tests.rs => tests/core.rs} | 0 app/src/comparer/{production_tests.rs => tests/production.rs} | 0 app/src/comparer/{scanner_tests.rs => tests/scanner.rs} | 0 app/src/config/{core_tests.rs => tests/core.rs} | 0 app/src/config/{dump_config_tests.rs => tests/dump_config.rs} | 0 app/src/config/{grants_mode_tests.rs => tests/grants_mode.rs} | 0 app/src/dump/{acl_tests.rs => tests/acl.rs} | 0 app/src/dump/{cast_tests.rs => tests/cast.rs} | 0 app/src/dump/{collation_tests.rs => tests/collation.rs} | 0 .../dump/{column_dependent_tests.rs => tests/column_dependent.rs} | 0 app/src/dump/{core_tests.rs => tests/core.rs} | 0 .../{default_privilege_tests.rs => tests/default_privilege.rs} | 0 app/src/dump/{event_trigger_tests.rs => tests/event_trigger.rs} | 0 app/src/dump/{extension_tests.rs => tests/extension.rs} | 0 app/src/dump/{fdw_tests.rs => tests/fdw.rs} | 0 app/src/dump/{foreign_table_tests.rs => tests/foreign_table.rs} | 0 app/src/dump/{operator_tests.rs => tests/operator.rs} | 0 app/src/dump/{pg_enum_tests.rs => tests/pg_enum.rs} | 0 app/src/dump/{pg_type_tests.rs => tests/pg_type.rs} | 0 app/src/dump/{publication_tests.rs => tests/publication.rs} | 0 app/src/dump/{routine_tests.rs => tests/routine.rs} | 0 app/src/dump/{rule_tests.rs => tests/rule.rs} | 0 app/src/dump/{schema_tests.rs => tests/schema.rs} | 0 app/src/dump/{sequence_tests.rs => tests/sequence.rs} | 0 app/src/dump/{statistic_tests.rs => tests/statistic.rs} | 0 app/src/dump/{table_tests.rs => tests/table.rs} | 0 app/src/dump/{table_column_tests.rs => tests/table_column.rs} | 0 .../dump/{table_constraint_tests.rs => tests/table_constraint.rs} | 0 app/src/dump/{table_index_tests.rs => tests/table_index.rs} | 0 app/src/dump/{table_policy_tests.rs => tests/table_policy.rs} | 0 app/src/dump/{table_trigger_tests.rs => tests/table_trigger.rs} | 0 app/src/dump/{text_search_tests.rs => tests/text_search.rs} | 0 app/src/dump/{view_tests.rs => tests/view.rs} | 0 app/src/utils/{sql_normalize_tests.rs => tests/sql_normalize.rs} | 0 .../{string_extensions_tests.rs => tests/string_extensions.rs} | 0 35 files changed, 0 insertions(+), 0 deletions(-) rename app/src/comparer/{core_tests.rs => tests/core.rs} (100%) rename app/src/comparer/{production_tests.rs => tests/production.rs} (100%) rename app/src/comparer/{scanner_tests.rs => tests/scanner.rs} (100%) rename app/src/config/{core_tests.rs => tests/core.rs} (100%) rename app/src/config/{dump_config_tests.rs => tests/dump_config.rs} (100%) rename app/src/config/{grants_mode_tests.rs => tests/grants_mode.rs} (100%) rename app/src/dump/{acl_tests.rs => tests/acl.rs} (100%) rename app/src/dump/{cast_tests.rs => tests/cast.rs} (100%) rename app/src/dump/{collation_tests.rs => tests/collation.rs} (100%) rename app/src/dump/{column_dependent_tests.rs => tests/column_dependent.rs} (100%) rename app/src/dump/{core_tests.rs => tests/core.rs} (100%) rename app/src/dump/{default_privilege_tests.rs => tests/default_privilege.rs} (100%) rename app/src/dump/{event_trigger_tests.rs => tests/event_trigger.rs} (100%) rename app/src/dump/{extension_tests.rs => tests/extension.rs} (100%) rename app/src/dump/{fdw_tests.rs => tests/fdw.rs} (100%) rename app/src/dump/{foreign_table_tests.rs => tests/foreign_table.rs} (100%) rename app/src/dump/{operator_tests.rs => tests/operator.rs} (100%) rename app/src/dump/{pg_enum_tests.rs => tests/pg_enum.rs} (100%) rename app/src/dump/{pg_type_tests.rs => tests/pg_type.rs} (100%) rename app/src/dump/{publication_tests.rs => tests/publication.rs} (100%) rename app/src/dump/{routine_tests.rs => tests/routine.rs} (100%) rename app/src/dump/{rule_tests.rs => tests/rule.rs} (100%) rename app/src/dump/{schema_tests.rs => tests/schema.rs} (100%) rename app/src/dump/{sequence_tests.rs => tests/sequence.rs} (100%) rename app/src/dump/{statistic_tests.rs => tests/statistic.rs} (100%) rename app/src/dump/{table_tests.rs => tests/table.rs} (100%) rename app/src/dump/{table_column_tests.rs => tests/table_column.rs} (100%) rename app/src/dump/{table_constraint_tests.rs => tests/table_constraint.rs} (100%) rename app/src/dump/{table_index_tests.rs => tests/table_index.rs} (100%) rename app/src/dump/{table_policy_tests.rs => tests/table_policy.rs} (100%) rename app/src/dump/{table_trigger_tests.rs => tests/table_trigger.rs} (100%) rename app/src/dump/{text_search_tests.rs => tests/text_search.rs} (100%) rename app/src/dump/{view_tests.rs => tests/view.rs} (100%) rename app/src/utils/{sql_normalize_tests.rs => tests/sql_normalize.rs} (100%) rename app/src/utils/{string_extensions_tests.rs => tests/string_extensions.rs} (100%) diff --git a/app/src/comparer/core_tests.rs b/app/src/comparer/tests/core.rs similarity index 100% rename from app/src/comparer/core_tests.rs rename to app/src/comparer/tests/core.rs diff --git a/app/src/comparer/production_tests.rs b/app/src/comparer/tests/production.rs similarity index 100% rename from app/src/comparer/production_tests.rs rename to app/src/comparer/tests/production.rs diff --git a/app/src/comparer/scanner_tests.rs b/app/src/comparer/tests/scanner.rs similarity index 100% rename from app/src/comparer/scanner_tests.rs rename to app/src/comparer/tests/scanner.rs diff --git a/app/src/config/core_tests.rs b/app/src/config/tests/core.rs similarity index 100% rename from app/src/config/core_tests.rs rename to app/src/config/tests/core.rs diff --git a/app/src/config/dump_config_tests.rs b/app/src/config/tests/dump_config.rs similarity index 100% rename from app/src/config/dump_config_tests.rs rename to app/src/config/tests/dump_config.rs diff --git a/app/src/config/grants_mode_tests.rs b/app/src/config/tests/grants_mode.rs similarity index 100% rename from app/src/config/grants_mode_tests.rs rename to app/src/config/tests/grants_mode.rs diff --git a/app/src/dump/acl_tests.rs b/app/src/dump/tests/acl.rs similarity index 100% rename from app/src/dump/acl_tests.rs rename to app/src/dump/tests/acl.rs diff --git a/app/src/dump/cast_tests.rs b/app/src/dump/tests/cast.rs similarity index 100% rename from app/src/dump/cast_tests.rs rename to app/src/dump/tests/cast.rs diff --git a/app/src/dump/collation_tests.rs b/app/src/dump/tests/collation.rs similarity index 100% rename from app/src/dump/collation_tests.rs rename to app/src/dump/tests/collation.rs diff --git a/app/src/dump/column_dependent_tests.rs b/app/src/dump/tests/column_dependent.rs similarity index 100% rename from app/src/dump/column_dependent_tests.rs rename to app/src/dump/tests/column_dependent.rs diff --git a/app/src/dump/core_tests.rs b/app/src/dump/tests/core.rs similarity index 100% rename from app/src/dump/core_tests.rs rename to app/src/dump/tests/core.rs diff --git a/app/src/dump/default_privilege_tests.rs b/app/src/dump/tests/default_privilege.rs similarity index 100% rename from app/src/dump/default_privilege_tests.rs rename to app/src/dump/tests/default_privilege.rs diff --git a/app/src/dump/event_trigger_tests.rs b/app/src/dump/tests/event_trigger.rs similarity index 100% rename from app/src/dump/event_trigger_tests.rs rename to app/src/dump/tests/event_trigger.rs diff --git a/app/src/dump/extension_tests.rs b/app/src/dump/tests/extension.rs similarity index 100% rename from app/src/dump/extension_tests.rs rename to app/src/dump/tests/extension.rs diff --git a/app/src/dump/fdw_tests.rs b/app/src/dump/tests/fdw.rs similarity index 100% rename from app/src/dump/fdw_tests.rs rename to app/src/dump/tests/fdw.rs diff --git a/app/src/dump/foreign_table_tests.rs b/app/src/dump/tests/foreign_table.rs similarity index 100% rename from app/src/dump/foreign_table_tests.rs rename to app/src/dump/tests/foreign_table.rs diff --git a/app/src/dump/operator_tests.rs b/app/src/dump/tests/operator.rs similarity index 100% rename from app/src/dump/operator_tests.rs rename to app/src/dump/tests/operator.rs diff --git a/app/src/dump/pg_enum_tests.rs b/app/src/dump/tests/pg_enum.rs similarity index 100% rename from app/src/dump/pg_enum_tests.rs rename to app/src/dump/tests/pg_enum.rs diff --git a/app/src/dump/pg_type_tests.rs b/app/src/dump/tests/pg_type.rs similarity index 100% rename from app/src/dump/pg_type_tests.rs rename to app/src/dump/tests/pg_type.rs diff --git a/app/src/dump/publication_tests.rs b/app/src/dump/tests/publication.rs similarity index 100% rename from app/src/dump/publication_tests.rs rename to app/src/dump/tests/publication.rs diff --git a/app/src/dump/routine_tests.rs b/app/src/dump/tests/routine.rs similarity index 100% rename from app/src/dump/routine_tests.rs rename to app/src/dump/tests/routine.rs diff --git a/app/src/dump/rule_tests.rs b/app/src/dump/tests/rule.rs similarity index 100% rename from app/src/dump/rule_tests.rs rename to app/src/dump/tests/rule.rs diff --git a/app/src/dump/schema_tests.rs b/app/src/dump/tests/schema.rs similarity index 100% rename from app/src/dump/schema_tests.rs rename to app/src/dump/tests/schema.rs diff --git a/app/src/dump/sequence_tests.rs b/app/src/dump/tests/sequence.rs similarity index 100% rename from app/src/dump/sequence_tests.rs rename to app/src/dump/tests/sequence.rs diff --git a/app/src/dump/statistic_tests.rs b/app/src/dump/tests/statistic.rs similarity index 100% rename from app/src/dump/statistic_tests.rs rename to app/src/dump/tests/statistic.rs diff --git a/app/src/dump/table_tests.rs b/app/src/dump/tests/table.rs similarity index 100% rename from app/src/dump/table_tests.rs rename to app/src/dump/tests/table.rs diff --git a/app/src/dump/table_column_tests.rs b/app/src/dump/tests/table_column.rs similarity index 100% rename from app/src/dump/table_column_tests.rs rename to app/src/dump/tests/table_column.rs diff --git a/app/src/dump/table_constraint_tests.rs b/app/src/dump/tests/table_constraint.rs similarity index 100% rename from app/src/dump/table_constraint_tests.rs rename to app/src/dump/tests/table_constraint.rs diff --git a/app/src/dump/table_index_tests.rs b/app/src/dump/tests/table_index.rs similarity index 100% rename from app/src/dump/table_index_tests.rs rename to app/src/dump/tests/table_index.rs diff --git a/app/src/dump/table_policy_tests.rs b/app/src/dump/tests/table_policy.rs similarity index 100% rename from app/src/dump/table_policy_tests.rs rename to app/src/dump/tests/table_policy.rs diff --git a/app/src/dump/table_trigger_tests.rs b/app/src/dump/tests/table_trigger.rs similarity index 100% rename from app/src/dump/table_trigger_tests.rs rename to app/src/dump/tests/table_trigger.rs diff --git a/app/src/dump/text_search_tests.rs b/app/src/dump/tests/text_search.rs similarity index 100% rename from app/src/dump/text_search_tests.rs rename to app/src/dump/tests/text_search.rs diff --git a/app/src/dump/view_tests.rs b/app/src/dump/tests/view.rs similarity index 100% rename from app/src/dump/view_tests.rs rename to app/src/dump/tests/view.rs diff --git a/app/src/utils/sql_normalize_tests.rs b/app/src/utils/tests/sql_normalize.rs similarity index 100% rename from app/src/utils/sql_normalize_tests.rs rename to app/src/utils/tests/sql_normalize.rs diff --git a/app/src/utils/string_extensions_tests.rs b/app/src/utils/tests/string_extensions.rs similarity index 100% rename from app/src/utils/string_extensions_tests.rs rename to app/src/utils/tests/string_extensions.rs From b547792b3503dc63677e496b3e428ee61ad5fd94 Mon Sep 17 00:00:00 2001 From: nettrash Date: Mon, 3 Aug 2026 18:10:50 +0200 Subject: [PATCH 2/9] #238 --- app/Cargo.toml | 4 + app/src/comparer/core.rs | 2 +- app/src/comparer/production.rs | 2 +- app/src/comparer/scanner.rs | 2 +- app/src/comparer/tests/core.rs | 11793 +--------------- .../comparer/tests/core/buffer_ordering.rs | 263 + .../comparer/tests/core/cascade_dependents.rs | 1366 ++ .../comparer/tests/core/column_dependents.rs | 644 + app/src/comparer/tests/core/grants.rs | 2038 +++ app/src/comparer/tests/core/helpers.rs | 235 + .../comparer/tests/core/matview_indexes.rs | 311 + app/src/comparer/tests/core/persistence.rs | 1692 +++ app/src/comparer/tests/core/production.rs | 201 + app/src/comparer/tests/core/routines.rs | 1080 ++ .../comparer/tests/core/schemas_extensions.rs | 62 + app/src/comparer/tests/core/script_output.rs | 1035 ++ app/src/comparer/tests/core/sequences.rs | 754 + app/src/comparer/tests/core/tables.rs | 1196 ++ app/src/comparer/tests/core/types.rs | 267 + app/src/comparer/tests/core/views.rs | 749 + app/src/config/core.rs | 2 +- app/src/config/dump_config.rs | 2 +- app/src/config/grants_mode.rs | 2 +- app/src/dump/acl.rs | 2 +- app/src/dump/cast.rs | 2 +- app/src/dump/collation.rs | 2 +- app/src/dump/column_dependent.rs | 2 +- app/src/dump/core.rs | 2 +- app/src/dump/default_privilege.rs | 2 +- app/src/dump/event_trigger.rs | 2 +- app/src/dump/extension.rs | 2 +- app/src/dump/fdw.rs | 2 +- app/src/dump/foreign_table.rs | 2 +- app/src/dump/operator.rs | 2 +- app/src/dump/pg_enum.rs | 2 +- app/src/dump/pg_type.rs | 2 +- app/src/dump/publication.rs | 2 +- app/src/dump/routine.rs | 2 +- app/src/dump/rule.rs | 2 +- app/src/dump/schema.rs | 2 +- app/src/dump/sequence.rs | 2 +- app/src/dump/statistic.rs | 2 +- app/src/dump/table.rs | 2 +- app/src/dump/table_column.rs | 2 +- app/src/dump/table_constraint.rs | 2 +- app/src/dump/table_index.rs | 2 +- app/src/dump/table_policy.rs | 2 +- app/src/dump/table_trigger.rs | 2 +- app/src/dump/text_search.rs | 2 +- app/src/dump/view.rs | 2 +- app/src/lib.rs | 21 + app/src/main.rs | 11 +- app/src/utils/sql_normalize.rs | 2 +- app/src/utils/string_extensions.rs | 2 +- 54 files changed, 11977 insertions(+), 11815 deletions(-) create mode 100644 app/src/comparer/tests/core/buffer_ordering.rs create mode 100644 app/src/comparer/tests/core/cascade_dependents.rs create mode 100644 app/src/comparer/tests/core/column_dependents.rs create mode 100644 app/src/comparer/tests/core/grants.rs create mode 100644 app/src/comparer/tests/core/helpers.rs create mode 100644 app/src/comparer/tests/core/matview_indexes.rs create mode 100644 app/src/comparer/tests/core/persistence.rs create mode 100644 app/src/comparer/tests/core/production.rs create mode 100644 app/src/comparer/tests/core/routines.rs create mode 100644 app/src/comparer/tests/core/schemas_extensions.rs create mode 100644 app/src/comparer/tests/core/script_output.rs create mode 100644 app/src/comparer/tests/core/sequences.rs create mode 100644 app/src/comparer/tests/core/tables.rs create mode 100644 app/src/comparer/tests/core/types.rs create mode 100644 app/src/comparer/tests/core/views.rs create mode 100644 app/src/lib.rs diff --git a/app/Cargo.toml b/app/Cargo.toml index 07e9aaf..4d05e75 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -5,6 +5,10 @@ edition = "2024" license = "MIT" authors = ["nettrash "] +[lib] +name = "pgc" +path = "src/lib.rs" + [[bin]] name = "pgc" path = "src/main.rs" diff --git a/app/src/comparer/core.rs b/app/src/comparer/core.rs index aa408bb..2ddf0a4 100644 --- a/app/src/comparer/core.rs +++ b/app/src/comparer/core.rs @@ -5986,5 +5986,5 @@ fn policy_recreate_block(policy: &TablePolicy) -> String { } #[cfg(test)] -#[path = "core_tests.rs"] +#[path = "tests/core.rs"] mod tests; diff --git a/app/src/comparer/production.rs b/app/src/comparer/production.rs index 2b48073..104526f 100644 --- a/app/src/comparer/production.rs +++ b/app/src/comparer/production.rs @@ -615,5 +615,5 @@ pub fn make_idempotent(script: &str) -> String { } #[cfg(test)] -#[path = "production_tests.rs"] +#[path = "tests/production.rs"] mod tests; diff --git a/app/src/comparer/scanner.rs b/app/src/comparer/scanner.rs index c794b98..dfe19e7 100644 --- a/app/src/comparer/scanner.rs +++ b/app/src/comparer/scanner.rs @@ -185,5 +185,5 @@ pub(crate) fn dollar_tag_at(src: &[u8], pos: usize) -> Option { } #[cfg(test)] -#[path = "scanner_tests.rs"] +#[path = "tests/scanner.rs"] mod tests; diff --git a/app/src/comparer/tests/core.rs b/app/src/comparer/tests/core.rs index ae8a242..057a333 100644 --- a/app/src/comparer/tests/core.rs +++ b/app/src/comparer/tests/core.rs @@ -1,11772 +1,21 @@ -use super::*; -use crate::config::dump_config::DumpConfig; -use crate::config::grants_mode::GrantsMode; -use crate::dump::default_privilege::DefaultPrivilege; -use crate::dump::extension::Extension; -use crate::dump::foreign_table::ForeignTable; -use crate::dump::pg_type::{CompositeAttribute, PgType}; -use crate::dump::routine::Routine; -use crate::dump::schema::Schema; -use sqlx::postgres::types::Oid; - -fn make_domain_type(schema: &str, name: &str, oid: u32) -> PgType { - PgType::new( - Oid(oid), - schema.to_string(), - name.to_string(), - Oid(2200), - Oid(10), - "postgres".to_string(), - -1, - false, - 'd' as i8, - 'U' as i8, - false, - true, - ',' as i8, - None, - None, - None, - None, - "domain_in".to_string(), - "domain_out".to_string(), - None, - None, - None, - None, - None, - 'i' as i8, - 'x' as i8, - false, - Some(Oid(25)), - None, - 0, - None, - None, - Some("text".to_string()), - Vec::new(), - Vec::new(), - None, - ) -} - -fn make_enum_type(schema: &str, name: &str, oid: u32, labels: Vec<&str>) -> PgType { - let mut enum_type = make_domain_type(schema, name, oid); - enum_type.typtype = 'e' as i8; - enum_type.typcategory = 'E' as i8; - enum_type.typinput = "enum_in".to_string(); - enum_type.typoutput = "enum_out".to_string(); - enum_type.typbasetype = None; - enum_type.formatted_basetype = None; - enum_type.enum_labels = labels.into_iter().map(|label| label.to_string()).collect(); - enum_type.domain_constraints.clear(); - enum_type.hash(); - enum_type -} - -fn make_composite_type( - schema: &str, - name: &str, - oid: u32, - attributes: Vec<(&str, &str)>, -) -> PgType { - let mut composite_type = make_domain_type(schema, name, oid); - composite_type.typtype = 'c' as i8; - composite_type.typcategory = 'C' as i8; - composite_type.typinput = "record_in".to_string(); - composite_type.typoutput = "record_out".to_string(); - composite_type.typbasetype = None; - composite_type.formatted_basetype = None; - composite_type.domain_constraints.clear(); - composite_type.composite_attributes = attributes - .into_iter() - .map(|(attribute_name, data_type)| CompositeAttribute { - name: attribute_name.to_string(), - data_type: data_type.to_string(), - }) - .collect(); - composite_type.hash(); - composite_type -} - -#[tokio::test] -async fn compare_routines_drops_and_recreates_on_return_type_change() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let from_routine = Routine::new( - "public".to_string(), - Oid(1), - "test_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - - let to_routine = Routine::new( - "public".to_string(), - Oid(1), - "test_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "text".to_string(), - "".to_string(), - None, - None, - "BEGIN RETURN '1'; END".to_string(), - ); - - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains("drop function if exists public.test_func () cascade;")); - assert!(script.contains("create or replace function public.test_func() returns text")); -} - -#[tokio::test] -async fn compare_routines_drops_and_recreates_on_argument_change() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let from_routine = Routine::new( - "public".to_string(), - Oid(1), - "test_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "a integer".to_string(), - None, - None, - "BEGIN RETURN a; END".to_string(), - ); - - let to_routine = Routine::new( - "public".to_string(), - Oid(1), - "test_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "a text".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains("drop function if exists public.test_func (a integer) cascade;")); - assert!(script.contains("create or replace function public.test_func(a text) returns integer")); -} - -#[tokio::test] -async fn compare_routines_applies_sql_routines_last() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let plpgsql_routine = Routine::new( - "public".to_string(), - Oid(1), - "fn_plpgsql".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - - let sql_routine = Routine::new( - "public".to_string(), - Oid(2), - "fn_sql".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "SELECT 1;".to_string(), - ); - - // Intentionally add SQL first to ensure reordering happens. - to_dump.routines.push(sql_routine); - to_dump.routines.push(plpgsql_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - let pos_plpgsql = script - .find("create or replace function public.fn_plpgsql") - .expect("plpgsql routine script not found"); - let pos_sql = script - .find("create or replace function public.fn_sql") - .expect("sql routine script not found"); - - assert!(pos_plpgsql < pos_sql, "SQL routines should be applied last"); -} - -#[tokio::test] -async fn compare_drops_types_after_routines() { - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - let dropped_type = make_domain_type("test_schema", "status_type", 501); - from_dump.types.push(dropped_type); - - let dropped_routine = Routine::new( - "test_schema".to_string(), - Oid(1), - "get_users_by_status".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "status test_schema.status_type".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - from_dump.routines.push(dropped_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let routine_drop_pos = script - .find("drop function if exists test_schema.get_users_by_status") - .expect("routine drop script not found"); - let type_drop_pos = script - .find("drop type if exists test_schema.status_type cascade;") - .expect("type drop script not found"); - - assert!( - routine_drop_pos < type_drop_pos, - "Type drops must be emitted after routine drops" - ); -} - -#[tokio::test] -async fn compare_drops_enums_after_routines() { - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - let dropped_enum = make_enum_type( - "test_schema", - "status_enum", - 502, - vec!["active", "inactive"], - ); - from_dump.types.push(dropped_enum); - - let dropped_routine = Routine::new( - "test_schema".to_string(), - Oid(2), - "get_users_by_status_enum".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "status test_schema.status_enum".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - from_dump.routines.push(dropped_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let routine_drop_pos = script - .find("drop function if exists test_schema.get_users_by_status_enum") - .expect("routine drop script not found"); - let enum_drop_pos = script - .find("drop type if exists test_schema.status_enum cascade;") - .expect("enum drop script not found"); - - assert!( - routine_drop_pos < enum_drop_pos, - "Enum drops must be emitted after routine drops" - ); -} - -#[tokio::test] -async fn compare_composite_types_drops_removed_and_creates_new() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump.types.push(make_composite_type( - "test_schema", - "test_type_A", - 601, - vec![ - ("first_name_2", "varchar(50)"), - ("last_name_2", "varchar(50)"), - ], - )); - to_dump.types.push(make_composite_type( - "test_schema", - "test_type_B", - 602, - vec![("street", "varchar(255)"), ("city", "varchar(100)")], - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains("create type test_schema.test_type_B as (")); - assert!(script.contains("\"street\" varchar(255)")); - assert!(script.contains("\"city\" varchar(100)")); - assert!(script.contains("drop type if exists test_schema.test_type_A cascade;")); -} - -#[tokio::test] -async fn compare_schemas_emits_owner_change() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_schema = Schema::new("public".to_string(), "public".to_string(), None); - from_schema.owner = "old_owner".to_string(); - from_schema.hash(); - - let mut to_schema = Schema::new("public".to_string(), "public".to_string(), None); - to_schema.owner = "new_owner".to_string(); - to_schema.hash(); - - from_dump.schemas.push(from_schema); - to_dump.schemas.push(to_schema); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_schemas().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains("alter schema public owner to new_owner;")); -} - -#[tokio::test] -async fn compare_extensions_notes_owner_change_as_unsupported() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_ext = Extension::new( - "hstore".to_string(), - "1.0".to_string(), - "public".to_string(), - ); - from_ext.owner = "old_owner".to_string(); - - let mut to_ext = Extension::new( - "hstore".to_string(), - "1.0".to_string(), - "public".to_string(), - ); - to_ext.owner = "new_owner".to_string(); - - from_dump.extensions.push(from_ext); - to_dump.extensions.push(to_ext); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_extensions().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains( - "-- Extension owner change is not supported by PostgreSQL ALTER EXTENSION syntax (old_owner -> new_owner)." - )); -} - -#[tokio::test] -async fn compare_routines_emits_owner_change() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "public".to_string(), - Oid(1), - "test_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - from_routine.owner = "old_owner".to_string(); - from_routine.hash(); - - let mut to_routine = Routine::new( - "public".to_string(), - Oid(1), - "test_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - to_routine.owner = "new_owner".to_string(); - to_routine.hash(); - - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains("alter function public.test_func() owner to new_owner;")); -} - -#[tokio::test] -async fn compare_routines_orders_by_dependencies() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // r_base_value: no dependencies - let r_base = Routine::new( - "test_schema".to_string(), - Oid(1), - "r_base_value".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "\n SELECT 10;\n".to_string(), - ); - - // x_step_one: depends on r_base_value - let x_step = Routine::new( - "test_schema".to_string(), - Oid(2), - "x_step_one".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "\n SELECT test_schema.r_base_value() + 5;\n".to_string(), - ); - - // a_middle_layer: depends on x_step_one and r_base_value - let a_middle = Routine::new( - "test_schema".to_string(), - Oid(3), - "a_middle_layer".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "\n SELECT test_schema.x_step_one() * test_schema.r_base_value();\n".to_string(), - ); - - // z_final_report: depends on a_middle_layer - let z_final = Routine::new( - "test_schema".to_string(), - Oid(4), - "z_final_report".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "".to_string(), - None, - None, - "\nDECLARE\n result integer;\nBEGIN\n SELECT test_schema.a_middle_layer() INTO result;\n RAISE NOTICE 'Final result: %', result;\nEND;\n".to_string(), - ); - - // Push in deliberately wrong alphabetical / type order. - to_dump.routines.push(z_final); - to_dump.routines.push(x_step); - to_dump.routines.push(a_middle); - to_dump.routines.push(r_base); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - let pos_base = script - .find("create or replace function test_schema.r_base_value") - .expect("r_base_value not found"); - let pos_step = script - .find("create or replace function test_schema.x_step_one") - .expect("x_step_one not found"); - let pos_middle = script - .find("create or replace function test_schema.a_middle_layer") - .expect("a_middle_layer not found"); - let pos_final = script - .find("create or replace procedure test_schema.z_final_report") - .expect("z_final_report not found"); - - assert!( - pos_base < pos_step, - "r_base_value must come before x_step_one (depends on it)" - ); - assert!( - pos_base < pos_middle, - "r_base_value must come before a_middle_layer (depends on it)" - ); - assert!( - pos_step < pos_middle, - "x_step_one must come before a_middle_layer (depends on it)" - ); - assert!( - pos_middle < pos_final, - "a_middle_layer must come before z_final_report (depends on it)" - ); -} - -#[tokio::test] -async fn compare_routines_drops_in_reverse_dependency_order() { - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - // r_base_value: no dependencies - let r_base = Routine::new( - "test_schema".to_string(), - Oid(1), - "r_base_value".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "\n SELECT 10;\n".to_string(), - ); - - // x_step_one: depends on r_base_value - let x_step = Routine::new( - "test_schema".to_string(), - Oid(2), - "x_step_one".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "\n SELECT test_schema.r_base_value() + 5;\n".to_string(), - ); - - // a_middle_layer: depends on x_step_one and r_base_value - let a_middle = Routine::new( - "test_schema".to_string(), - Oid(3), - "a_middle_layer".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "\n SELECT test_schema.x_step_one() * test_schema.r_base_value();\n".to_string(), - ); - - from_dump.routines.push(r_base); - from_dump.routines.push(x_step); - from_dump.routines.push(a_middle); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - let pos_base = script - .find("drop function if exists test_schema.r_base_value") - .expect("r_base_value drop not found"); - let pos_step = script - .find("drop function if exists test_schema.x_step_one") - .expect("x_step_one drop not found"); - let pos_middle = script - .find("drop function if exists test_schema.a_middle_layer") - .expect("a_middle_layer drop not found"); - - // Drops should go in reverse dependency order: dependents first. - assert!( - pos_middle < pos_step, - "a_middle_layer must be dropped before x_step_one" - ); - assert!( - pos_step < pos_base, - "x_step_one must be dropped before r_base_value" - ); -} - -#[tokio::test] -async fn compare_routines_and_views_orders_by_dependencies() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let r_base = Routine::new( - "test_schema".to_string(), - Oid(1), - "r_base_value".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "\n SELECT 10;\n".to_string(), - ); - - let x_step = Routine::new( - "test_schema".to_string(), - Oid(2), - "x_step_one".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "\n SELECT test_schema.r_base_value() + 5;\n".to_string(), - ); - - let a_middle = Routine::new( - "test_schema".to_string(), - Oid(3), - "a_middle_layer".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "\n SELECT test_schema.x_step_one() * test_schema.r_base_value();\n".to_string(), - ); - - let z_final = Routine::new( - "test_schema".to_string(), - Oid(4), - "z_final_report".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "".to_string(), - None, - None, - "\nDECLARE\n result integer;\nBEGIN\n SELECT test_schema.a_middle_layer() INTO result;\n RAISE NOTICE 'Final result: %', result;\nEND;\n".to_string(), - ); - - // Push in deliberately wrong order. - to_dump.routines.push(z_final); - to_dump.routines.push(x_step); - to_dump.routines.push(a_middle); - to_dump.routines.push(r_base); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - let pos_base = script - .find("create or replace function test_schema.r_base_value") - .expect("r_base_value not found"); - let pos_step = script - .find("create or replace function test_schema.x_step_one") - .expect("x_step_one not found"); - let pos_middle = script - .find("create or replace function test_schema.a_middle_layer") - .expect("a_middle_layer not found"); - let pos_final = script - .find("create or replace procedure test_schema.z_final_report") - .expect("z_final_report not found"); - - assert!( - pos_base < pos_step, - "r_base_value must come before x_step_one" - ); - assert!( - pos_base < pos_middle, - "r_base_value must come before a_middle_layer" - ); - assert!( - pos_step < pos_middle, - "x_step_one must come before a_middle_layer" - ); - assert!( - pos_middle < pos_final, - "a_middle_layer must come before z_final_report" - ); -} - -use crate::dump::sequence::Sequence; -use crate::dump::table::Table; -use crate::dump::table_column::TableColumn; -use crate::dump::table_constraint::TableConstraint; -use crate::dump::table_trigger::TableTrigger; -use crate::dump::view::View; - -fn int_column(schema: &str, table: &str, name: &str, ordinal: i32) -> TableColumn { - TableColumn { - catalog: "postgres".to_string(), - schema: schema.to_string(), - table: table.to_string(), - name: name.to_string(), - ordinal_position: ordinal, - column_default: None, - is_nullable: true, - data_type: "integer".to_string(), - character_maximum_length: None, - character_octet_length: None, - numeric_precision: Some(32), - numeric_precision_radix: Some(2), - numeric_scale: Some(0), - datetime_precision: None, - interval_type: None, - interval_precision: None, - character_set_catalog: None, - character_set_schema: None, - character_set_name: None, - collation_catalog: None, - collation_schema: None, - collation_name: None, - domain_catalog: None, - domain_schema: None, - domain_name: None, - udt_catalog: None, - udt_schema: None, - udt_name: None, - scope_catalog: None, - scope_schema: None, - scope_name: None, - maximum_cardinality: None, - dtd_identifier: None, - is_self_referencing: false, - is_identity: false, - identity_generation: None, - identity_start: None, - identity_increment: None, - identity_maximum: None, - identity_minimum: None, - identity_cycle: false, - is_generated: "NEVER".to_string(), - generation_expression: None, - generation_type: None, - is_updatable: true, - related_views: None, - comment: None, - storage: None, - compression: None, - statistics_target: None, - acl: vec![], - serial_type: None, - } -} - -#[tokio::test] -async fn compare_sequences_skips_owned_by_serial_column() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Sequence owned by table column - let sequence = Sequence::new( - "public".to_string(), - "test_id_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(1000), - Some(1), - false, - Some(1), - Some(1), - Some("public".to_string()), - Some("test".to_string()), - Some("id".to_string()), - ); - to_dump.sequences.push(sequence); - - // Table with serial column - let column = TableColumn { - catalog: "postgres".to_string(), - schema: "public".to_string(), - table: "test".to_string(), - name: "id".to_string(), - ordinal_position: 1, - column_default: Some("nextval('test_id_seq'::regclass)".to_string()), - is_nullable: false, - data_type: "bigint".to_string(), // PostgreSQL reports bigserial as bigint with nextval default - character_maximum_length: None, - character_octet_length: None, - numeric_precision: Some(64), - numeric_precision_radix: Some(2), - numeric_scale: Some(0), - datetime_precision: None, - interval_type: None, - interval_precision: None, - character_set_catalog: None, - character_set_schema: None, - character_set_name: None, - collation_catalog: None, - collation_schema: None, - collation_name: None, - domain_catalog: None, - domain_schema: None, - domain_name: None, - udt_catalog: None, - udt_schema: None, - udt_name: None, - scope_catalog: None, - scope_schema: None, - scope_name: None, - maximum_cardinality: None, - dtd_identifier: None, - is_self_referencing: false, - is_identity: false, - identity_generation: None, - identity_start: None, - identity_increment: None, - identity_maximum: None, - identity_minimum: None, - identity_cycle: false, - is_generated: "NEVER".to_string(), - generation_expression: None, - generation_type: None, - is_updatable: true, - related_views: None, - comment: None, - storage: None, - compression: None, - statistics_target: None, - acl: vec![], - serial_type: None, - }; - - let table = Table::new( - "public".to_string(), - "test".to_string(), - "public".to_string(), - "test".to_string(), - "postgres".to_string(), - None, - vec![column], - vec![], - vec![], - vec![], - None, - ); - to_dump.tables.push(table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains( - "Skipping sequence public.test_id_seq as it will be created by column public.test.id" - )); - assert!(!script.contains("create sequence \"public\".\"test_id_seq\"")); -} - -#[tokio::test] -async fn compare_sequences_skips_owned_by_identity_column() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Sequence owned by table column - let sequence = Sequence::new( - "public".to_string(), - "test_id_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(1000), - Some(1), - false, - Some(1), - Some(1), - Some("public".to_string()), - Some("test".to_string()), - Some("id".to_string()), - ); - to_dump.sequences.push(sequence); - - // Table with identity column - let column = TableColumn { - catalog: "postgres".to_string(), - schema: "public".to_string(), - table: "test".to_string(), - name: "id".to_string(), - ordinal_position: 1, - column_default: None, - is_nullable: false, - data_type: "bigint".to_string(), - character_maximum_length: None, - character_octet_length: None, - numeric_precision: Some(64), - numeric_precision_radix: Some(2), - numeric_scale: Some(0), - datetime_precision: None, - interval_type: None, - interval_precision: None, - character_set_catalog: None, - character_set_schema: None, - character_set_name: None, - collation_catalog: None, - collation_schema: None, - collation_name: None, - domain_catalog: None, - domain_schema: None, - domain_name: None, - udt_catalog: None, - udt_schema: None, - udt_name: None, - scope_catalog: None, - scope_schema: None, - scope_name: None, - maximum_cardinality: None, - dtd_identifier: None, - is_self_referencing: false, - is_identity: true, // This triggers the skip - identity_generation: Some("ALWAYS".to_string()), - identity_start: Some("1".to_string()), - identity_increment: Some("1".to_string()), - identity_maximum: None, - identity_minimum: None, - identity_cycle: false, - is_generated: "NEVER".to_string(), - generation_expression: None, - generation_type: None, - is_updatable: true, - related_views: None, - comment: None, - storage: None, - compression: None, - statistics_target: None, - acl: vec![], - serial_type: None, - }; - - let table = Table::new( - "public".to_string(), - "test".to_string(), - "public".to_string(), - "test".to_string(), - "postgres".to_string(), - None, - vec![column], - vec![], - vec![], - vec![], - None, - ); - to_dump.tables.push(table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains( - "Skipping sequence public.test_id_seq as it will be created by column public.test.id" - )); - assert!(!script.contains("create sequence \"public\".\"test_id_seq\"")); -} - -#[tokio::test] -async fn compare_sequences_does_not_skip_normal_sequence() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Normal sequence not owned by any column - let sequence = Sequence::new( - "public".to_string(), - "test_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(1000), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - to_dump.sequences.push(sequence); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!(!script.contains("Skipping sequence")); - assert!(script.contains("create sequence public.test_seq")); -} - -#[tokio::test] -async fn compare_sequences_emits_owner_change() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let from_sequence = Sequence::new( - "public".to_string(), - "test_seq".to_string(), - "old_owner".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(1000), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - - let to_sequence = Sequence::new( - "public".to_string(), - "test_seq".to_string(), - "new_owner".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(1000), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - - from_dump.sequences.push(from_sequence); - to_dump.sequences.push(to_sequence); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains("alter sequence public.test_seq owner to new_owner;")); -} - -/// When MINVALUE is raised above the sequence's effective current position (last_value if -/// known, otherwise old start_value), the comparer must emit RESTART WITH so PostgreSQL does -/// not fall back to an old recorded start value that violates the new MINVALUE: -/// -/// ERROR: RESTART value (1) cannot be less than MINVALUE (10000000) -#[tokio::test] -async fn compare_sequences_emits_restart_when_effective_current_below_new_minvalue() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // last_value is None → effective_current falls back to start_value (1), which is < 10M. - let from_sequence = Sequence::new( - "my_schema".to_string(), - "my_sequence_id_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(999_999_999), - Some(1), - false, - Some(1), - None, - None, - None, - None, - ); - let to_sequence = Sequence::new( - "my_schema".to_string(), - "my_sequence_id_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(10_000_000), - Some(10_000_000), - Some(999_999_999), - Some(1), - true, - Some(1), - None, - None, - None, - None, - ); - - from_dump.sequences.push(from_sequence); - to_dump.sequences.push(to_sequence); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("start with 10000000"), - "script must contain START WITH: {script}" - ); - assert!( - script.contains("restart with 10000000"), - "script must contain RESTART WITH to prevent RESTART value < MINVALUE error: {script}" - ); -} - -/// When last_value is already above the new MINVALUE, RESTART WITH must NOT be emitted -/// even though start_value and MINVALUE are both raised. Emitting it would rewind the -/// live sequence and risk duplicate-key violations. -#[tokio::test] -async fn compare_sequences_no_restart_when_last_value_already_above_new_minvalue() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let from_sequence = Sequence::new( - "public".to_string(), - "busy_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(999_999_999), - Some(1), - false, - Some(1), - Some(15_000_000), // last_value is already well above the new MINVALUE (10M) - None, - None, - None, - ); - let to_sequence = Sequence::new( - "public".to_string(), - "busy_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(10_000_000), // start_value raised - Some(10_000_000), // MINVALUE raised — but last_value (15M) already satisfies it - Some(999_999_999), - Some(1), - false, - Some(1), - None, - None, - None, - None, - ); - - from_dump.sequences.push(from_sequence); - to_dump.sequences.push(to_sequence); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("restart with"), - "script must NOT contain RESTART WITH when last_value is already above new MINVALUE: {script}" - ); - assert!( - script.contains("alter sequence public.busy_seq"), - "script must still emit ALTER SEQUENCE to update start_value/minvalue: {script}" - ); -} - -/// When only non-start/minvalue parameters change (here: cycle) and the effective current -/// position is already within the new bounds, RESTART WITH must NOT be emitted. -#[tokio::test] -async fn compare_sequences_no_restart_when_only_other_params_change() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let from_sequence = Sequence::new( - "public".to_string(), - "live_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9999), - Some(1), - false, // cycle was false - Some(1), - Some(500_000), - None, - None, - None, - ); - let to_sequence = Sequence::new( - "public".to_string(), - "live_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), // start_value unchanged - Some(1), - Some(9999), - Some(1), - true, // only cycle changed - Some(1), - None, - None, - None, - None, - ); - - from_dump.sequences.push(from_sequence); - to_dump.sequences.push(to_sequence); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("restart with"), - "script must NOT contain RESTART WITH when start_value is unchanged: {script}" - ); - assert!( - script.contains("alter sequence public.live_seq"), - "script must still contain the ALTER SEQUENCE: {script}" - ); -} - -#[tokio::test] -async fn compare_sequences_skips_drop_if_owned_by_dropped_table() { - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - // Sequence owned by table column - let sequence = Sequence::new( - "public".to_string(), - "test_id_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(1000), - Some(1), - false, - Some(1), - Some(1), - Some("public".to_string()), - Some("test".to_string()), - Some("id".to_string()), - ); - from_dump.sequences.push(sequence); - - // Table that owns the sequence - let column = TableColumn { - catalog: "postgres".to_string(), - schema: "public".to_string(), - table: "test".to_string(), - name: "id".to_string(), - ordinal_position: 1, - column_default: None, - is_nullable: false, - data_type: "bigint".to_string(), - character_maximum_length: None, - character_octet_length: None, - numeric_precision: Some(64), - numeric_precision_radix: Some(2), - numeric_scale: Some(0), - datetime_precision: None, - interval_type: None, - interval_precision: None, - character_set_catalog: None, - character_set_schema: None, - character_set_name: None, - collation_catalog: None, - collation_schema: None, - collation_name: None, - domain_catalog: None, - domain_schema: None, - domain_name: None, - udt_catalog: None, - udt_schema: None, - udt_name: None, - scope_catalog: None, - scope_schema: None, - scope_name: None, - maximum_cardinality: None, - dtd_identifier: None, - is_self_referencing: false, - is_identity: true, - identity_generation: Some("ALWAYS".to_string()), - identity_start: Some("1".to_string()), - identity_increment: Some("1".to_string()), - identity_maximum: None, - identity_minimum: None, - identity_cycle: false, - is_generated: "NEVER".to_string(), - generation_expression: None, - generation_type: None, - is_updatable: true, - related_views: None, - comment: None, - storage: None, - compression: None, - statistics_target: None, - acl: vec![], - serial_type: None, - }; - - let table = Table::new( - "public".to_string(), - "test".to_string(), - "public".to_string(), - "test".to_string(), - "postgres".to_string(), - None, - vec![column], - vec![], - vec![], - vec![], - None, - ); - from_dump.tables.push(table); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains("Skipping drop of sequence public.test_id_seq as it is owned by table public.test which will be dropped.")); -} - -#[tokio::test] -async fn compare_sequences_skips_drop_if_owned_by_identity_column() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Sequence owned by table column - let sequence = Sequence::new( - "public".to_string(), - "test_id_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(1000), - Some(1), - false, - Some(1), - Some(1), - Some("public".to_string()), - Some("test".to_string()), - Some("id".to_string()), - ); - from_dump.sequences.push(sequence); - - // Table with identity column in FROM - let from_column = TableColumn { - catalog: "postgres".to_string(), - schema: "public".to_string(), - table: "test".to_string(), - name: "id".to_string(), - ordinal_position: 1, - column_default: None, - is_nullable: false, - data_type: "bigint".to_string(), - character_maximum_length: None, - character_octet_length: None, - numeric_precision: Some(64), - numeric_precision_radix: Some(2), - numeric_scale: Some(0), - datetime_precision: None, - interval_type: None, - interval_precision: None, - character_set_catalog: None, - character_set_schema: None, - character_set_name: None, - collation_catalog: None, - collation_schema: None, - collation_name: None, - domain_catalog: None, - domain_schema: None, - domain_name: None, - udt_catalog: None, - udt_schema: None, - udt_name: None, - scope_catalog: None, - scope_schema: None, - scope_name: None, - maximum_cardinality: None, - dtd_identifier: None, - is_self_referencing: false, - is_identity: true, - identity_generation: Some("ALWAYS".to_string()), - identity_start: Some("1".to_string()), - identity_increment: Some("1".to_string()), - identity_maximum: None, - identity_minimum: None, - identity_cycle: false, - is_generated: "NEVER".to_string(), - generation_expression: None, - generation_type: None, - is_updatable: true, - related_views: None, - comment: None, - storage: None, - compression: None, - statistics_target: None, - acl: vec![], - serial_type: None, - }; - - let from_table = Table::new( - "public".to_string(), - "test".to_string(), - "public".to_string(), - "test".to_string(), - "postgres".to_string(), - None, - vec![from_column], - vec![], - vec![], - vec![], - None, - ); - from_dump.tables.push(from_table); - - // Table in TO (exists, but maybe column changed or sequence changed) - // Even if column is same, if sequence is missing in TO (simulated here by not adding it to to_dump.sequences), - // we should skip drop if it's identity. - let to_column = TableColumn { - catalog: "postgres".to_string(), - schema: "public".to_string(), - table: "test".to_string(), - name: "id".to_string(), - ordinal_position: 1, - column_default: None, - is_nullable: false, - data_type: "bigint".to_string(), - character_maximum_length: None, - character_octet_length: None, - numeric_precision: Some(64), - numeric_precision_radix: Some(2), - numeric_scale: Some(0), - datetime_precision: None, - interval_type: None, - interval_precision: None, - character_set_catalog: None, - character_set_schema: None, - character_set_name: None, - collation_catalog: None, - collation_schema: None, - collation_name: None, - domain_catalog: None, - domain_schema: None, - domain_name: None, - udt_catalog: None, - udt_schema: None, - udt_name: None, - scope_catalog: None, - scope_schema: None, - scope_name: None, - maximum_cardinality: None, - dtd_identifier: None, - is_self_referencing: false, - is_identity: true, // Still identity - identity_generation: Some("ALWAYS".to_string()), - identity_start: Some("1".to_string()), - identity_increment: Some("1".to_string()), - identity_maximum: None, - identity_minimum: None, - identity_cycle: false, - is_generated: "NEVER".to_string(), - generation_expression: None, - generation_type: None, - is_updatable: true, - related_views: None, - comment: None, - storage: None, - compression: None, - statistics_target: None, - acl: vec![], - serial_type: None, - }; - - let to_table = Table::new( - "public".to_string(), - "test".to_string(), - "public".to_string(), - "test".to_string(), - "postgres".to_string(), - None, - vec![to_column], - vec![], - vec![], - vec![], - None, - ); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains("Skipping drop of sequence public.test_id_seq as it is owned by identity column public.test.id.")); -} - -#[tokio::test] -async fn tables_create_parent_before_partition_and_fk_after_tables() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Parent partitioned table - let mut parent = Table::new( - "public".to_string(), - "parent".to_string(), - "public".to_string(), - "parent".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "parent", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - parent.partition_key = Some("LIST (id)".to_string()); - parent.hash(); - - // Partition table - let mut part = Table::new( - "public".to_string(), - "child".to_string(), - "public".to_string(), - "child".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "child", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - part.partition_of = Some("public.parent".to_string()); - part.partition_bound = Some("FOR VALUES IN (1)".to_string()); - part.hash(); - - // Referencing table with FK to parent - let mut orders = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "postgres".to_string(), - None, - vec![ - int_column("public", "orders", "id", 1), - int_column("public", "orders", "parent_id", 2), - ], - vec![TableConstraint { - catalog: "postgres".to_string(), - schema: "public".to_string(), - name: "orders_parent_fk".to_string(), - table_name: "orders".to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("FOREIGN KEY (parent_id) REFERENCES public.parent(id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }], - vec![], - vec![], - None, - ); - orders.hash(); - - to_dump.tables.push(parent); - to_dump.tables.push(part); - to_dump.tables.push(orders); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let pos_parent = script - .find("create table public.parent") - .expect("parent table not created"); - let pos_child = script - .find("create table public.child partition of public.parent") - .expect("partition table not created"); - let pos_orders = script - .find("create table public.orders") - .expect("orders table not created"); - let pos_fk = script - .find("alter table public.orders add constraint orders_parent_fk") - .expect("fk not emitted"); - - assert!( - pos_parent < pos_child, - "parent should be created before partition" - ); - assert!( - pos_fk > pos_parent && pos_fk > pos_orders, - "foreign key should be created after tables" - ); -} - -#[tokio::test] -async fn compare_tables_emits_owner_change() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_table = Table::new( - "public".to_string(), - "users".to_string(), - "public".to_string(), - "users".to_string(), - "old_owner".to_string(), - None, - vec![int_column("public", "users", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - from_table.hash(); - - let mut to_table = Table::new( - "public".to_string(), - "users".to_string(), - "public".to_string(), - "users".to_string(), - "new_owner".to_string(), - None, - vec![int_column("public", "users", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - to_table.hash(); - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains("alter table public.users owner to new_owner;")); -} - -#[tokio::test] -async fn create_views_emits_owner_change_for_existing_view() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_view = View::new( - "active_users".to_string(), - "select id from public.users".to_string(), - "public".to_string(), - vec!["public.users".to_string()], - ); - from_view.owner = "old_owner".to_string(); - from_view.hash(); - - let mut to_view = View::new( - "active_users".to_string(), - "select id from public.users".to_string(), - "public".to_string(), - vec!["public.users".to_string()], - ); - to_view.owner = "new_owner".to_string(); - to_view.hash(); - - from_dump.views.push(from_view); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.create_views().await.unwrap(); - let script = comparer.get_script(); - - assert!(script.contains("alter view public.active_users owner to new_owner;")); -} - -#[tokio::test] -async fn compare_creates_routines_and_views_in_dependency_order() { - // Scenario from the user report: - // get_user_count() – function, no view dependency - // v_user_stats – view that calls get_user_count() - // report_user_stats – function that reads v_user_stats - // print_user_stats – procedure that reads v_user_stats - // - // Correct creation order: get_user_count → v_user_stats → report/print - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - to_dump.schemas.push(Schema::new( - "test_schema".to_string(), - "test_schema".to_string(), - None, - )); - - let get_user_count = Routine::new( - "test_schema".to_string(), - Oid(1), - "get_user_count".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - " SELECT count(*) FROM test_schema.users;\n".to_string(), - ); - - let report_user_stats = Routine::new( - "test_schema".to_string(), - Oid(2), - "report_user_stats".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "text".to_string(), - "".to_string(), - None, - None, - " SELECT 'Total users in view: ' || total_users\n FROM test_schema.v_user_stats\n LIMIT 1;\n".to_string(), - ); - - let print_user_stats = Routine::new( - "test_schema".to_string(), - Oid(3), - "print_user_stats".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "".to_string(), - None, - None, - "\nDECLARE\n cnt int;\nBEGIN\n SELECT total_users INTO cnt\n FROM test_schema.v_user_stats\n LIMIT 1;\n RAISE NOTICE 'Total users in view: %', cnt;\nEND;\n".to_string(), - ); - - let mut v_user_stats = View::new( - "v_user_stats".to_string(), - " SELECT test_schema.get_user_count() AS total_users,\n users.name\n FROM test_schema.users;\n".to_string(), - "test_schema".to_string(), - vec!["test_schema.users".to_string()], - ); - v_user_stats.owner = "postgres".to_string(); - v_user_stats.hash(); - - // Intentionally add in wrong order to test sorting - to_dump.routines.push(print_user_stats); - to_dump.routines.push(report_user_stats); - to_dump.routines.push(get_user_count); - to_dump.views.push(v_user_stats); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let pos_get_user_count = script - .find("create or replace function test_schema.get_user_count") - .expect("get_user_count not found in script"); - let pos_view = script - .find("test_schema.v_user_stats") - .expect("v_user_stats not found in script"); - let pos_report = script - .find("create or replace function test_schema.report_user_stats") - .expect("report_user_stats not found in script"); - let pos_print = script - .find("create or replace procedure test_schema.print_user_stats") - .expect("print_user_stats not found in script"); - - assert!( - pos_get_user_count < pos_view, - "get_user_count() must be created before v_user_stats (function is used by view)" - ); - assert!( - pos_view < pos_report, - "v_user_stats must be created before report_user_stats() (view is used by function)" - ); - assert!( - pos_view < pos_print, - "v_user_stats must be created before print_user_stats() (view is used by procedure)" - ); -} - -#[tokio::test] -async fn compare_creates_materialized_view_after_dependent_routine() { - // Materialized view that uses a function should be created after that function. - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let helper_fn = Routine::new( - "public".to_string(), - Oid(1), - "helper".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "SELECT 42;\n".to_string(), - ); - - let mut mat_view = View::new( - "mv_data".to_string(), - " SELECT public.helper() AS value;\n".to_string(), - "public".to_string(), - vec![], - ); - mat_view.is_materialized = true; - mat_view.hash(); - - to_dump.routines.push(helper_fn); - to_dump.views.push(mat_view); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let pos_fn = script - .find("create or replace function public.helper") - .expect("helper function not found"); - let pos_mv = script.find("public.mv_data").expect("mv_data not found"); - - assert!( - pos_fn < pos_mv, - "helper() must be created before mv_data (materialized view depends on function)" - ); -} - -#[tokio::test] -async fn compare_drops_routines_in_reverse_dependency_order() { - // Routine A calls Routine B; when both are dropped, A should be dropped first. - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - let routine_b = Routine::new( - "public".to_string(), - Oid(1), - "base_fn".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "SELECT 1;\n".to_string(), - ); - - let routine_a = Routine::new( - "public".to_string(), - Oid(2), - "caller_fn".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "SELECT public.base_fn();\n".to_string(), - ); - - // Add in wrong order - from_dump.routines.push(routine_b); - from_dump.routines.push(routine_a); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let pos_caller = script - .find("drop function if exists public.caller_fn") - .expect("caller_fn drop not found"); - let pos_base = script - .find("drop function if exists public.base_fn") - .expect("base_fn drop not found"); - - assert!( - pos_caller < pos_base, - "caller_fn (dependent) must be dropped before base_fn" - ); -} - -#[tokio::test] -async fn tables_multilevel_partitions_created_in_depth_order() { - // Hierarchy: grandparent (RANGE) -> parent_2023 (LIST, sub-partition) -> child_2023_a (leaf) - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Level 0: grandparent partitioned by RANGE - let mut grandparent = Table::new( - "public".to_string(), - "events".to_string(), - "public".to_string(), - "events".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "events", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - grandparent.partition_key = Some("RANGE (id)".to_string()); - grandparent.hash(); - - // Level 1: sub-partition parent (is both a partition child AND partitioned by LIST) - let mut sub_parent = Table::new( - "public".to_string(), - "events_2023".to_string(), - "public".to_string(), - "events_2023".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "events_2023", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - sub_parent.partition_of = Some("\"public\".\"events\"".to_string()); - sub_parent.partition_bound = Some("FOR VALUES FROM (2023) TO (2024)".to_string()); - sub_parent.partition_key = Some("LIST (id)".to_string()); - sub_parent.hash(); - - // Level 2: leaf partition - let mut leaf = Table::new( - "public".to_string(), - "events_2023_a".to_string(), - "public".to_string(), - "events_2023_a".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "events_2023_a", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - leaf.partition_of = Some("\"public\".\"events_2023\"".to_string()); - leaf.partition_bound = Some("FOR VALUES IN (1)".to_string()); - leaf.hash(); - - // Push in reverse order to stress the sorting - to_dump.tables.push(leaf); - to_dump.tables.push(grandparent); - to_dump.tables.push(sub_parent); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let pos_gp = script - .find("create table public.events") - .expect("grandparent not created"); - let pos_sp = script - .find("create table public.events_2023 partition of") - .expect("sub-partition parent not created"); - let pos_leaf = script - .find("create table public.events_2023_a partition of") - .expect("leaf partition not created"); - - assert!( - pos_gp < pos_sp, - "grandparent must be created before sub-partition parent" - ); - assert!( - pos_sp < pos_leaf, - "sub-partition parent must be created before leaf partition" - ); -} - -#[tokio::test] -async fn tables_multilevel_partitions_dropped_in_reverse_depth_order() { - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - let mut grandparent = Table::new( - "public".to_string(), - "events".to_string(), - "public".to_string(), - "events".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "events", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - grandparent.partition_key = Some("RANGE (id)".to_string()); - grandparent.hash(); - - let mut sub_parent = Table::new( - "public".to_string(), - "events_2023".to_string(), - "public".to_string(), - "events_2023".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "events_2023", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - sub_parent.partition_of = Some("public.events".to_string()); - sub_parent.partition_bound = Some("FOR VALUES FROM (2023) TO (2024)".to_string()); - sub_parent.partition_key = Some("LIST (id)".to_string()); - sub_parent.hash(); - - let mut leaf = Table::new( - "public".to_string(), - "events_2023_a".to_string(), - "public".to_string(), - "events_2023_a".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "events_2023_a", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - leaf.partition_of = Some("public.events_2023".to_string()); - leaf.partition_bound = Some("FOR VALUES IN (1)".to_string()); - leaf.hash(); - - from_dump.tables.push(grandparent); - from_dump.tables.push(sub_parent); - from_dump.tables.push(leaf); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let pos_gp = script - .find("drop table if exists public.events;") - .expect("grandparent drop not found"); - let pos_sp = script - .find("drop table if exists public.events_2023;") - .expect("sub-partition parent drop not found"); - let pos_leaf = script - .find("drop table if exists public.events_2023_a;") - .expect("leaf partition drop not found"); - - assert!( - pos_leaf < pos_sp, - "leaf must be dropped before sub-partition parent" - ); - assert!( - pos_sp < pos_gp, - "sub-partition parent must be dropped before grandparent" - ); -} - -#[tokio::test] -async fn serial_column_uses_serial_type_in_table_script() { - // When a serial/bigserial column's sequence is skipped, the table script - // should use serial/bigserial type instead of integer/bigint with nextval default. - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // serial column (integer + nextval) - let serial_seq = Sequence::new( - "test_schema".to_string(), - "test_serial_id_seq".to_string(), - "postgres".to_string(), - "integer".to_string(), - Some(1), - Some(1), - Some(2147483647), - Some(1), - false, - Some(1), - Some(1), - Some("test_schema".to_string()), - Some("test_serial".to_string()), - Some("id".to_string()), - ); - to_dump.sequences.push(serial_seq); - - let serial_col = TableColumn { - catalog: "postgres".to_string(), - schema: "test_schema".to_string(), - table: "test_serial".to_string(), - name: "id".to_string(), - ordinal_position: 1, - column_default: Some("nextval('test_schema.test_serial_id_seq'::regclass)".to_string()), - is_nullable: false, - data_type: "integer".to_string(), - character_maximum_length: None, - character_octet_length: None, - numeric_precision: Some(32), - numeric_precision_radix: Some(2), - numeric_scale: Some(0), - datetime_precision: None, - interval_type: None, - interval_precision: None, - character_set_catalog: None, - character_set_schema: None, - character_set_name: None, - collation_catalog: None, - collation_schema: None, - collation_name: None, - domain_catalog: None, - domain_schema: None, - domain_name: None, - udt_catalog: None, - udt_schema: None, - udt_name: None, - scope_catalog: None, - scope_schema: None, - scope_name: None, - maximum_cardinality: None, - dtd_identifier: None, - is_self_referencing: false, - is_identity: false, - identity_generation: None, - identity_start: None, - identity_increment: None, - identity_maximum: None, - identity_minimum: None, - identity_cycle: false, - is_generated: "NEVER".to_string(), - generation_expression: None, - generation_type: None, - is_updatable: true, - related_views: None, - comment: None, - storage: None, - compression: None, - statistics_target: None, - acl: vec![], - serial_type: None, - }; - let serial_table = Table::new( - "test_schema".to_string(), - "test_serial".to_string(), - "test_schema".to_string(), - "test_serial".to_string(), - "postgres".to_string(), - None, - vec![serial_col], - vec![], - vec![], - vec![], - None, - ); - to_dump.tables.push(serial_table); - - // bigserial column (bigint + nextval) - let bigserial_seq = Sequence::new( - "test_schema".to_string(), - "test_bigserial_id_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9223372036854775807), - Some(1), - false, - Some(1), - Some(1), - Some("test_schema".to_string()), - Some("test_bigserial".to_string()), - Some("id".to_string()), - ); - to_dump.sequences.push(bigserial_seq); - - let bigserial_col = TableColumn { - catalog: "postgres".to_string(), - schema: "test_schema".to_string(), - table: "test_bigserial".to_string(), - name: "id".to_string(), - ordinal_position: 1, - column_default: Some("nextval('test_schema.test_bigserial_id_seq'::regclass)".to_string()), - is_nullable: false, - data_type: "bigint".to_string(), - character_maximum_length: None, - character_octet_length: None, - numeric_precision: Some(64), - numeric_precision_radix: Some(2), - numeric_scale: Some(0), - datetime_precision: None, - interval_type: None, - interval_precision: None, - character_set_catalog: None, - character_set_schema: None, - character_set_name: None, - collation_catalog: None, - collation_schema: None, - collation_name: None, - domain_catalog: None, - domain_schema: None, - domain_name: None, - udt_catalog: None, - udt_schema: None, - udt_name: None, - scope_catalog: None, - scope_schema: None, - scope_name: None, - maximum_cardinality: None, - dtd_identifier: None, - is_self_referencing: false, - is_identity: false, - identity_generation: None, - identity_start: None, - identity_increment: None, - identity_maximum: None, - identity_minimum: None, - identity_cycle: false, - is_generated: "NEVER".to_string(), - generation_expression: None, - generation_type: None, - is_updatable: true, - related_views: None, - comment: None, - storage: None, - compression: None, - statistics_target: None, - acl: vec![], - serial_type: None, - }; - let bigserial_table = Table::new( - "test_schema".to_string(), - "test_bigserial".to_string(), - "test_schema".to_string(), - "test_bigserial".to_string(), - "postgres".to_string(), - None, - vec![bigserial_col], - vec![], - vec![], - vec![], - None, - ); - to_dump.tables.push(bigserial_table); - - to_dump.schemas.push(crate::dump::schema::Schema::new( - "test_schema".to_string(), - "test_schema".to_string(), - None, - )); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - // Sequences should be skipped - assert!( - script.contains("Skipping sequence test_schema.test_serial_id_seq"), - "serial sequence should be skipped" - ); - assert!( - script.contains("Skipping sequence test_schema.test_bigserial_id_seq"), - "bigserial sequence should be skipped" - ); - assert!( - !script.contains("create sequence test_schema.test_serial_id_seq"), - "serial sequence should not be created separately" - ); - assert!( - !script.contains("create sequence test_schema.test_bigserial_id_seq"), - "bigserial sequence should not be created separately" - ); - - // Table columns should use serial/bigserial types - assert!( - script.contains("id serial"), - "serial column should use 'serial' type, got:\n{script}" - ); - assert!( - script.contains("id bigserial"), - "bigserial column should use 'bigserial' type, got:\n{script}" - ); - - // Should NOT contain nextval defaults for these columns - assert!( - !script.contains("nextval('test_schema.test_serial_id_seq'"), - "serial column should not have explicit nextval default" - ); - assert!( - !script.contains("nextval('test_schema.test_bigserial_id_seq'"), - "bigserial column should not have explicit nextval default" - ); -} - -#[tokio::test] -async fn use_single_transaction_should_add_begin_commit() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut new_table = Table::new( - "public".to_string(), - "\"my-table\"".to_string(), - "public".to_string(), - "my-table".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "\"my-table\"", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - - new_table.hash(); - - to_dump.tables.push(new_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, true, true, GrantsMode::Ignore); - - comparer.compare().await.unwrap(); - - let script = comparer.get_script(); - - const SCRIPT_BODY_START_PATTERN: &str = "*/\n\n"; - - let script_body_start_index = script - .find(SCRIPT_BODY_START_PATTERN) - .map(|index| index + SCRIPT_BODY_START_PATTERN.len()) - .expect("Script header was not found"); - - let script_body = &script[script_body_start_index..]; - - assert!(script_body.starts_with("begin;\n\n")); - assert!(script_body.ends_with("\ncommit;")); -} - -#[tokio::test] -async fn use_comments_false_strips_block_and_line_comments() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - comparer.script = - "/* header comment */\nCREATE TABLE t1 (id int); -- inline comment\n/* trailing */\n" - .to_string(); - let result = comparer.get_script(); - assert_eq!(result, "CREATE TABLE t1 (id int);\n"); -} - -#[tokio::test] -async fn use_comments_false_strips_singly_nested_block_comment() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // /* outer /* inner */ still outer */ must all be stripped. - comparer.script = "SELECT /* outer /* inner */ still outer */ 1;\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT 1;\n"); -} - -#[tokio::test] -async fn use_comments_false_strips_deeply_nested_block_comment() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Three levels of nesting. - comparer.script = "SELECT /* a /* b /* c */ b */ a */ 1;\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT 1;\n"); -} - -#[tokio::test] -async fn use_comments_false_strips_adjacent_nested_block_comments() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Two independent outer comments each with their own inner comment. - // Any space left before SELECT after stripping the first comment is removed by get_script()'s trim(). - comparer.script = "/* a /* b */ a */ SELECT /* c /* d */ c */ 1;\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT 1;\n"); -} - -#[tokio::test] -async fn use_comments_false_nested_block_comment_before_statement() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Nested comment as a header; SQL that follows must be preserved intact. - comparer.script = "/* header /* nested */ end */\nCREATE TABLE t (id int);\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "CREATE TABLE t (id int);\n"); -} - -#[tokio::test] -async fn use_comments_false_nested_comment_only_script_returns_empty() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // A script consisting only of a nested block comment produces no output. - comparer.script = "/* outer /* inner */ outer */\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, ""); -} - -#[tokio::test] -async fn use_comments_false_nested_comment_sql_between_levels() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Text that sits between the outer /* and its matching */ must be stripped - // even when inner comment pairs appear in the middle. - comparer.script = "SELECT /* before /* mid */ after */ 42;\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT 42;\n"); -} - -#[tokio::test] -async fn use_comments_false_nested_comment_immediately_after_keyword() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // No space between the keyword and the nested comment; scanner must not - // be confused by the /* that immediately follows non-comment text. - comparer.script = "SELECT/* /* nested */ */1;\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT1;\n"); -} - -#[tokio::test] -async fn use_comments_false_nested_comment_followed_by_line_comment() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // After the nested block comment closes, a line comment on the same line - // must also be stripped. - comparer.script = "SELECT /* a /* b */ a */ 1; -- strip me\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT 1;\n"); -} - -#[tokio::test] -async fn use_comments_false_nested_comment_inside_single_quoted_string_not_stripped() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Nested-comment-like sequences inside a string literal must be preserved. - comparer.script = "SELECT '/* outer /* inner */ outer */' AS val;\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT '/* outer /* inner */ outer */' AS val;\n"); -} - -#[tokio::test] -async fn use_comments_false_nested_comment_inside_e_string_not_stripped() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Nested-comment-like sequences inside an E-string must also be preserved. - comparer.script = "SELECT E'/* outer /* inner */ outer */' AS val;\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT E'/* outer /* inner */ outer */' AS val;\n"); -} - -#[tokio::test] -async fn use_comments_false_nested_comment_inside_double_quoted_identifier_not_stripped() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Nested-comment-like sequences inside a double-quoted identifier must be preserved. - comparer.script = "SELECT 1 AS \"/* outer /* inner */ outer */\";\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT 1 AS \"/* outer /* inner */ outer */\";\n"); -} - -#[tokio::test] -async fn use_comments_false_preserves_dollar_quoted_comments() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - comparer.script = "CREATE FUNCTION f() RETURNS void AS $$\n-- inside body\n/* also inside */\n$$ LANGUAGE plpgsql;\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "CREATE FUNCTION f() RETURNS void AS $$\n-- inside body\n/* also inside */\n$$ LANGUAGE plpgsql;\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_named_dollar_tag_comments() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - comparer.script = - "CREATE FUNCTION g() RETURNS void AS $body$\n-- comment inside\n$body$ LANGUAGE plpgsql;\n" - .to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "CREATE FUNCTION g() RETURNS void AS $body$\n-- comment inside\n$body$ LANGUAGE plpgsql;\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_single_quoted_comments() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - comparer.script = "SELECT '-- not a comment' AS val, '/* also not */' AS val2;\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "SELECT '-- not a comment' AS val, '/* also not */' AS val2;\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_e_string_backslash_escaped_quote() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // \' inside E'...' is an escaped quote and must NOT terminate the string. - // The comment-like content after it must be preserved, not stripped. - comparer.script = "SELECT E'it\\'s fine -- not a comment' AS val; -- strip\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT E'it\\'s fine -- not a comment' AS val;\n"); -} - -#[tokio::test] -async fn use_comments_false_preserves_e_string_block_comment_lookalike() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // /* ... */ inside an E-string must not be treated as a block comment. - comparer.script = "SELECT E'/* not a comment */' AS val; /* strip */\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT E'/* not a comment */' AS val;\n"); -} - -#[tokio::test] -async fn use_comments_false_preserves_e_string_backslash_backslash_then_quote() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // \\\' is an escaped backslash (\\) followed by an escaped quote (\'). - // The string should continue after that sequence. - comparer.script = - "SELECT E'backslash\\\\\\'quote -- still inside' AS val; -- strip\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "SELECT E'backslash\\\\\\'quote -- still inside' AS val;\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_e_string_doubled_quote_escape() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // '' inside E'...' is also a valid quote escape; must not terminate string early. - comparer.script = "SELECT E'it''s fine -- not a comment' AS val; -- strip\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT E'it''s fine -- not a comment' AS val;\n"); -} - -#[tokio::test] -async fn use_comments_false_preserves_lowercase_e_string() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Lowercase e'...' prefix must be handled identically to E'...'. - comparer.script = "SELECT e'it\\'s fine -- not a comment' AS val; -- strip\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT e'it\\'s fine -- not a comment' AS val;\n"); -} - -#[tokio::test] -async fn use_comments_false_does_not_treat_standalone_e_as_e_string() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // A bare column/alias named "e" followed immediately by a plain string - // literal must not be misidentified as an E-string prefix. - // Here "e" is a table alias and 'text' is a separate literal. - comparer.script = "SELECT e, 'text -- not a comment' FROM t; -- strip\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT e, 'text -- not a comment' FROM t;\n"); -} - -#[tokio::test] -async fn use_comments_false_does_not_treat_uppercase_e_identifier_as_e_string() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Uppercase E as a standalone identifier (column name), not followed by - // a quote, must not be confused with an E-string prefix. - comparer.script = "SELECT E FROM t; -- strip\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT E FROM t;\n"); -} - -#[tokio::test] -async fn use_comments_false_preserves_empty_e_string() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // An empty E-string E'' must not confuse the state machine. - comparer.script = "SELECT E'' AS val; /* strip */\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT E'' AS val;\n"); -} - -#[tokio::test] -async fn use_comments_false_preserves_e_string_with_other_backslash_sequences() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // \n and \t are everyday escape sequences; the scanner must copy them - // verbatim and must not mistake the character after the backslash for - // anything other than the second byte of the pair. - comparer.script = - "SELECT E'line1\\nline2\\ttabbed -- not a comment' AS val; -- strip\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "SELECT E'line1\\nline2\\ttabbed -- not a comment' AS val;\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_multiple_e_strings_in_one_statement() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Multiple E-strings in one statement; real trailing comment stripped. - comparer.script = "INSERT INTO t VALUES (E'val\\'1 -- x', E'val/*2*/'); -- strip\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "INSERT INTO t VALUES (E'val\\'1 -- x', E'val/*2*/');\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_e_string_adjacent_to_double_quoted_identifier() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // E-string and double-quoted identifier in the same statement; both - // preserved, real trailing comment stripped. - comparer.script = - "INSERT INTO \"my--table\" (col) VALUES (E'it\\'s -- ok'); -- strip\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "INSERT INTO \"my--table\" (col) VALUES (E'it\\'s -- ok');\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_double_quoted_identifier_comments() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Double-quoted identifiers containing sequences that look like comment - // starters must be passed through verbatim and must NOT be stripped. - comparer.script = - "SELECT 1 AS \"col--name\", 2 AS \"/*not a comment*/\"; -- real comment\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "SELECT 1 AS \"col--name\", 2 AS \"/*not a comment*/\";\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_double_quoted_identifier_with_escaped_quote() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // A doubled double-quote inside a quoted identifier is an escape sequence - // and must survive comment stripping intact. - comparer.script = - "ALTER TABLE t RENAME COLUMN \"col\"\"--name\" TO new_name; /* drop this */\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "ALTER TABLE t RENAME COLUMN \"col\"\"--name\" TO new_name;\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_multiple_double_quoted_identifiers() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Several double-quoted identifiers in one statement, each containing - // comment-like sequences; only the trailing real comment should be stripped. - comparer.script = "SELECT \"a--b\", \"c/*d*/e\", \"f--g\" FROM t; -- strip me\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT \"a--b\", \"c/*d*/e\", \"f--g\" FROM t;\n"); -} - -#[tokio::test] -async fn use_comments_false_preserves_qualified_double_quoted_name() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Quoted schema + quoted table, both containing comment-like sequences. - comparer.script = - "CREATE TABLE \"my--schema\".\"my/*table*/\" (id int); /* strip */\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "CREATE TABLE \"my--schema\".\"my/*table*/\" (id int);\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_empty_double_quoted_identifier() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // "" is a valid (if unusual) quoted identifier; must not confuse the state machine. - comparer.script = "ALTER INDEX \"\" RENAME TO x; /* strip */\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "ALTER INDEX \"\" RENAME TO x;\n"); -} - -#[tokio::test] -async fn use_comments_false_strips_comment_after_double_quoted_identifier() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // The parser must exit the double-quote state correctly so the real block - // comment that follows is still stripped. - comparer.script = "SELECT \"col\" /* strip this */ FROM t;\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, "SELECT \"col\" FROM t;\n"); -} - -#[tokio::test] -async fn use_comments_false_mixed_double_and_single_quoted_with_comment() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - // Double-quoted identifier and single-quoted string both containing - // comment-like bytes; trailing real comment must still be stripped. - comparer.script = - "INSERT INTO \"my--table\" (col) VALUES ('/* not */ a -- val'); -- strip\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "INSERT INTO \"my--table\" (col) VALUES ('/* not */ a -- val');\n" - ); -} - -#[tokio::test] -async fn use_comments_false_returns_empty_for_comment_only_script() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - comparer.script = "/* only a comment */\n-- another comment\n".to_string(); - let result = comparer.get_script(); - assert_eq!(result, ""); -} - -#[tokio::test] -async fn use_comments_false_collapses_excess_newlines() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - comparer.script = - "CREATE TABLE t1 (id int);\n/* removed */\n\n\n\nCREATE TABLE t2 (id int);\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "CREATE TABLE t1 (id int);\n\nCREATE TABLE t2 (id int);\n" - ); -} - -#[tokio::test] -async fn use_comments_true_preserves_all_comments() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.script = "/* header */\nCREATE TABLE t1 (id int); -- inline\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "/* header */\nCREATE TABLE t1 (id int); -- inline\n" - ); -} - -#[tokio::test] -async fn use_comments_false_preserves_utf8() { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - comparer.script = - "/* comment */\nCOMMENT ON TABLE t IS '数据表 — 描述';\nSELECT $$函数体$$;\n".to_string(); - let result = comparer.get_script(); - assert_eq!( - result, - "COMMENT ON TABLE t IS '数据表 — 描述';\nSELECT $$函数体$$;\n" - ); -} - -/// Partition child must not be dropped+recreated when a non-partition-key -/// column changes type on the parent. -#[tokio::test] -async fn partition_child_non_pk_col_type_change_no_recreate() { - fn numeric_column( - schema: &str, - table: &str, - name: &str, - ordinal: i32, - precision: i32, - scale: i32, - ) -> TableColumn { - let mut col = int_column(schema, table, name, ordinal); - col.data_type = "numeric".to_string(); - col.numeric_precision = Some(precision); - col.numeric_scale = Some(scale); - col.numeric_precision_radix = Some(10); - col - } - fn date_column(schema: &str, table: &str, name: &str, ordinal: i32) -> TableColumn { - let mut col = int_column(schema, table, name, ordinal); - col.data_type = "date".to_string(); - col.numeric_precision = None; - col.numeric_precision_radix = None; - col.numeric_scale = None; - col - } - fn bigint_column(schema: &str, table: &str, name: &str, ordinal: i32) -> TableColumn { - let mut col = int_column(schema, table, name, ordinal); - col.data_type = "bigint".to_string(); - col.numeric_precision = Some(64); - col - } - - let tbl = "s6_issue2_expenses"; - let child_tbl = "s6_issue2_expenses_2024_01"; - let schema = "\"pt_test\""; - - // --- FROM dump --- - let mut from_parent = Table::new( - schema.to_string(), - tbl.to_string(), - "pt_test".to_string(), - tbl.to_string(), - "postgres".to_string(), - None, - vec![ - bigint_column(schema, tbl, "id", 1), - date_column(schema, tbl, "expense_date", 2), - numeric_column(schema, tbl, "amount", 3, 10, 2), - ], - vec![], - vec![], - vec![], - None, - ); - from_parent.partition_key = Some("RANGE (expense_date)".to_string()); - from_parent.hash(); - - let mut from_child = Table::new( - schema.to_string(), - child_tbl.to_string(), - "pt_test".to_string(), - child_tbl.to_string(), - "postgres".to_string(), - None, - vec![ - bigint_column(schema, child_tbl, "id", 1), - date_column(schema, child_tbl, "expense_date", 2), - numeric_column(schema, child_tbl, "amount", 3, 10, 2), - ], - vec![], - vec![], - vec![], - None, - ); - from_child.partition_of = Some(format!("{}.{}", schema, tbl)); - from_child.partition_bound = - Some("FOR VALUES FROM ('2024-01-01') TO ('2024-02-01')".to_string()); - from_child.hash(); - - // --- TO dump --- - let mut to_parent = Table::new( - schema.to_string(), - tbl.to_string(), - "pt_test".to_string(), - tbl.to_string(), - "postgres".to_string(), - None, - vec![ - bigint_column(schema, tbl, "id", 1), - date_column(schema, tbl, "expense_date", 2), - numeric_column(schema, tbl, "amount", 3, 15, 4), - ], - vec![], - vec![], - vec![], - None, - ); - to_parent.partition_key = Some("RANGE (expense_date)".to_string()); - to_parent.hash(); - - let mut to_child = Table::new( - schema.to_string(), - child_tbl.to_string(), - "pt_test".to_string(), - child_tbl.to_string(), - "postgres".to_string(), - None, - vec![ - bigint_column(schema, child_tbl, "id", 1), - date_column(schema, child_tbl, "expense_date", 2), - numeric_column(schema, child_tbl, "amount", 3, 15, 4), - ], - vec![], - vec![], - vec![], - None, - ); - to_child.partition_of = Some(format!("{}.{}", schema, tbl)); - to_child.partition_bound = Some("FOR VALUES FROM ('2024-01-01') TO ('2024-02-01')".to_string()); - to_child.hash(); - - let mut from_dump = Dump::new(DumpConfig::default()); - from_dump.tables.push(from_parent); - from_dump.tables.push(from_child); - - let mut to_dump = Dump::new(DumpConfig::default()); - to_dump.tables.push(to_parent); - to_dump.tables.push(to_child); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - // Parent should get ALTER COLUMN - assert!( - script.contains("alter column"), - "Parent must get ALTER COLUMN for amount, got: {script}" - ); - // Child should NOT be dropped - assert!( - !script.contains("drop table"), - "Partition child must not be dropped for non-partition-key column type change, got: {script}" - ); - // Child should NOT be recreated - assert!( - !script.to_lowercase().contains(&format!( - "create table {}.{} partition of", - schema, child_tbl - )), - "Partition child must not be recreated, got: {script}" - ); - assert!( - !script.contains("Data loss"), - "No data loss warning expected, got: {script}" - ); -} - -// ========================================================================= -// compare_grants tests -// ========================================================================= - -#[tokio::test] -async fn compare_grants_ignore_mode_produces_no_output() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_schema = Schema::new("public".to_string(), "public".to_string(), None); - from_schema.acl = vec!["reader=U/owner".to_string()]; - let mut to_schema = Schema::new("public".to_string(), "public".to_string(), None); - to_schema.acl = vec!["reader=U/owner".to_string(), "writer=UC/owner".to_string()]; - - from_dump.schemas.push(from_schema); - to_dump.schemas.push(to_schema); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("GRANT"), - "Ignore mode must not emit GRANT, got: {script}" - ); - assert!( - !script.contains("REVOKE"), - "Ignore mode must not emit REVOKE, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_addonly_adds_missing_schema_grant() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); - from_schema.acl = vec!["reader=U/owner".to_string()]; - let mut to_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); - to_schema.acl = vec!["reader=U/owner".to_string(), "writer=UC/owner".to_string()]; - - from_dump.schemas.push(from_schema); - to_dump.schemas.push(to_schema); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT CREATE, USAGE ON SCHEMA myschema TO writer;"), - "AddOnly must add missing grant, got: {script}" - ); - assert!( - !script.contains("REVOKE"), - "AddOnly must not emit REVOKE, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_addonly_does_not_revoke_removed_grant() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); - from_schema.acl = vec!["reader=U/owner".to_string(), "writer=UC/owner".to_string()]; - let mut to_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); - to_schema.acl = vec!["reader=U/owner".to_string()]; - - from_dump.schemas.push(from_schema); - to_dump.schemas.push(to_schema); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("REVOKE"), - "AddOnly must not revoke, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_full_revokes_removed_schema_grant() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); - from_schema.acl = vec!["reader=U/owner".to_string(), "writer=UC/owner".to_string()]; - let mut to_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); - to_schema.acl = vec!["reader=U/owner".to_string()]; - - from_dump.schemas.push(from_schema); - to_dump.schemas.push(to_schema); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("REVOKE CREATE, USAGE ON SCHEMA myschema FROM writer;"), - "Full mode must revoke removed grant, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_full_table_add_and_revoke() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_table = Table::new( - "public".to_string(), - "users".to_string(), - "public".to_string(), - "users".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec!["reader=r/owner".to_string()]; - - let mut to_table = Table::new( - "public".to_string(), - "users".to_string(), - "public".to_string(), - "users".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec!["writer=rw/owner".to_string()]; - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT SELECT, UPDATE ON TABLE public.users TO writer;"), - "Full must add new grant, got: {script}" - ); - assert!( - script.contains("REVOKE SELECT ON TABLE public.users FROM reader;"), - "Full must revoke removed grant, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_sequence() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_seq = Sequence::new( - "public".to_string(), - "my_seq".to_string(), - "owner".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9223372036854775807), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - from_seq.acl = vec![]; - - let mut to_seq = Sequence::new( - "public".to_string(), - "my_seq".to_string(), - "owner".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9223372036854775807), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - to_seq.acl = vec!["reader=U/owner".to_string()]; - - from_dump.sequences.push(from_seq); - to_dump.sequences.push(to_seq); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT USAGE ON SEQUENCE public.my_seq TO reader;"), - "Must add sequence grant, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_view() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - Vec::new(), - ); - from_view.acl = vec!["reader=r/owner".to_string()]; - - let mut to_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - Vec::new(), - ); - to_view.acl = vec!["reader=r/owner".to_string(), "writer=rw/owner".to_string()]; - - from_dump.views.push(from_view); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT SELECT, UPDATE ON TABLE public.my_view TO writer;"), - "Must add view grant, got: {script}" - ); - assert!( - !script.contains("REVOKE"), - "No revoke expected when unchanged grant remains, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_foreign_table_add_and_revoke() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_ft = ForeignTable::new( - "public".to_string(), - "ft_orders".to_string(), - "fdw_server".to_string(), - "owner".to_string(), - Vec::new(), - Vec::new(), - ); - from_ft.acl = vec!["reader=r/owner".to_string()]; - - let mut to_ft = ForeignTable::new( - "public".to_string(), - "ft_orders".to_string(), - "fdw_server".to_string(), - "owner".to_string(), - Vec::new(), - Vec::new(), - ); - to_ft.acl = vec!["writer=rw/owner".to_string()]; - - from_dump.foreign_tables.push(from_ft); - to_dump.foreign_tables.push(to_ft); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - // PostgreSQL's GRANT syntax has no `ON FOREIGN TABLE` form — foreign - // tables share the regular `ON TABLE` grant syntax. Pre-fix the - // comparer emitted `ON FOREIGN TABLE`, which produced invalid SQL - // (`syntax error at or near "TABLE"`) when the diff was applied. - assert!( - script.contains("GRANT SELECT, UPDATE ON TABLE public.ft_orders TO writer;"), - "Full must add foreign table grant via ON TABLE syntax, got: {script}" - ); - assert!( - script.contains("REVOKE SELECT ON TABLE public.ft_orders FROM reader;"), - "Full must revoke removed foreign table grant via ON TABLE syntax, got: {script}" - ); - assert!( - !script.contains("ON FOREIGN TABLE"), - "Foreign table grants must not use `ON FOREIGN TABLE` (invalid SQL), got: {script}" - ); -} - -/// User-reported regression: when ownership changes AND TO has an explicit -/// grant to the former owner, the migration must emit exactly one GRANT -/// (for the explicit privilege in TO) and zero REVOKEs (the implicit-owner -/// ACL row is stripped by ALTER OWNER alone). Replays the exact ACL shape -/// you'd see in the schema_a → schema_b owner-change scenario after both -/// FROM and TO have run their explicit GRANTs and PG has materialised the -/// implicit-owner row. -#[tokio::test] -async fn compare_grants_owner_change_with_explicit_grant_to_former_owner_is_idempotent() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // FROM table: owned by pgc_owner_from. relacl carries the implicit- - // owner row (pg materialises it once any GRANT exists) plus the two - // explicit grants to reader/writer. - let mut from_table = Table::new( - "test_schema".to_string(), - "users".to_string(), - "test_schema".to_string(), - "users".to_string(), - "pgc_owner_from".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec![ - "pgc_owner_from=arwdDxt/pgc_owner_from".to_string(), - "pgc_grant_reader=r/pgc_owner_from".to_string(), - "pgc_grant_writer=arw/pgc_owner_from".to_string(), - ]; - - // TO table: owned by pgc_owner_to. relacl has the new implicit-owner - // row, the same reader grant, the writer with UPDATE removed, and an - // explicit grant to the former owner pgc_owner_from. - let mut to_table = Table::new( - "test_schema".to_string(), - "users".to_string(), - "test_schema".to_string(), - "users".to_string(), - "pgc_owner_to".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec![ - "pgc_owner_to=arwdDxt/pgc_owner_to".to_string(), - "pgc_owner_from=r/pgc_owner_to".to_string(), - "pgc_grant_reader=r/pgc_owner_to".to_string(), - "pgc_grant_writer=ar/pgc_owner_to".to_string(), - ]; - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - // Exactly two statements expected: - // - GRANT SELECT TO pgc_owner_from (the new explicit grant in TO) - // - REVOKE UPDATE FROM pgc_grant_writer (UPDATE removed in TO) - assert!( - script.contains("GRANT SELECT ON TABLE test_schema.users TO pgc_owner_from;"), - "Must emit explicit grant to former owner, got: {script}" - ); - assert!( - script.contains("REVOKE UPDATE ON TABLE test_schema.users FROM pgc_grant_writer;"), - "Must revoke writer's UPDATE removed in TO, got: {script}" - ); - // No REVOKE/GRANT for pgc_owner_to (TO owner — implicit privileges). - // No REVOKE for pgc_owner_from's old implicit-owner row — ALTER OWNER - // strips it. Specifically NO REVOKE on pgc_owner_from for the 7 other - // privileges, which is the bug this regression test guards against. - assert!( - !script.contains("FROM pgc_owner_from"), - "Must not REVOKE anything from former owner — ALTER OWNER strips the implicit row, got: {script}" - ); - assert!( - !script.contains("pgc_owner_to"), - "Current owner must not appear in grants output, got: {script}" - ); -} - -/// Regression: a TO-only foreign table must inherit the FROM database's -/// default-table privileges as the effective `from_acl` under `full` mode, -/// because PostgreSQL auto-applies them on CREATE. Without this, the diff -/// is non-idempotent — re-running compare after applying it would emit -/// `REVOKE` statements for the auto-granted privileges that the migration -/// itself is responsible for cleaning up. -#[tokio::test] -async fn compare_grants_new_foreign_table_revokes_default_priv_grants() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // FROM has a default-privilege rule that grants SELECT to `reader` on - // any new table in `public`. No explicit grants in TO → after CREATE, - // the auto-applied SELECT must be revoked in this same diff. - let dp = DefaultPrivilege { - role_name: String::new(), - schema_name: "public".to_string(), - object_type: "r".to_string(), - acl: vec!["reader=r/owner".to_string()], - hash: Some("dp".to_string()), - }; - from_dump.default_privileges.push(dp); - - // TO-only foreign table (no FROM counterpart, no explicit ACL). - let to_ft = ForeignTable::new( - "public".to_string(), - "ft_new".to_string(), - "fdw_server".to_string(), - "owner".to_string(), - Vec::new(), - Vec::new(), - ); - to_dump.foreign_tables.push(to_ft); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("REVOKE SELECT ON TABLE public.ft_new FROM reader;"), - "New foreign table must revoke auto-applied default-privilege grants under full mode, got: {script}" - ); - assert!( - !script.contains("ON FOREIGN TABLE"), - "Foreign table grants must use `ON TABLE` syntax, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_dropped_view_restores_all_grants() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Both FROM and TO have the same grant on the view. - let mut from_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - Vec::new(), - ); - from_view.acl = vec!["reader=r/owner".to_string()]; - - let mut to_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - Vec::new(), - ); - to_view.acl = vec!["reader=r/owner".to_string()]; - - from_dump.views.push(from_view); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - // Simulate that the view was dropped earlier in the script - // (e.g. as a dependency of an altered table). - comparer - .dropped_views - .insert(Comparer::normalized_view_key("public", "my_view"), true); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT SELECT ON TABLE public.my_view TO reader;"), - "Dropped view must restore grants even when FROM has the same ACL, got: {script}" - ); -} - -/// When a view's DROP was only commented out (use_drop=false), the view still -/// exists in the database. compare_grants must keep the original from_acl so -/// that identical ACLs produce no diff (no redundant GRANTs/REVOKEs). -#[tokio::test] -async fn compare_grants_commented_drop_keeps_from_acl() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - Vec::new(), - ); - from_view.acl = vec!["reader=r/owner".to_string()]; - - let mut to_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - Vec::new(), - ); - to_view.acl = vec!["reader=r/owner".to_string()]; - - from_dump.views.push(from_view); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - // Simulate a commented-out drop (use_drop=false → stored as false). - comparer - .dropped_views - .insert(Comparer::normalized_view_key("public", "my_view"), false); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - // ACLs are identical and the view was NOT actually dropped, - // so no GRANT/REVOKE should appear. - let has_grant_stmt = script - .lines() - .any(|l| l.trim_start().to_lowercase().starts_with("grant ")); - assert!( - !has_grant_stmt, - "Commented-out drop must not cause redundant GRANTs, got: {script}" - ); - let has_revoke_stmt = script - .lines() - .any(|l| l.trim_start().to_lowercase().starts_with("revoke ")); - assert!( - !has_revoke_stmt, - "Commented-out drop must not cause redundant REVOKEs, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_routine_function() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "public".to_string(), - Oid(1), - "my_func".to_string(), - "plpgsql".to_string(), - "function".to_string(), - "void".to_string(), - "".to_string(), - None, - None, - "BEGIN END".to_string(), - ); - from_routine.acl = vec![]; - - let mut to_routine = Routine::new( - "public".to_string(), - Oid(1), - "my_func".to_string(), - "plpgsql".to_string(), - "function".to_string(), - "void".to_string(), - "".to_string(), - None, - None, - "BEGIN END".to_string(), - ); - to_routine.acl = vec!["app=X/owner".to_string()]; - - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT EXECUTE ON FUNCTION public.my_func() TO app;"), - "Must add function grant, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_routine_procedure() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "public".to_string(), - Oid(2), - "my_proc".to_string(), - "plpgsql".to_string(), - "procedure".to_string(), - "void".to_string(), - "days integer".to_string(), - None, - None, - "BEGIN END".to_string(), - ); - from_routine.acl = vec!["app=X/owner".to_string()]; - - let mut to_routine = Routine::new( - "public".to_string(), - Oid(2), - "my_proc".to_string(), - "plpgsql".to_string(), - "procedure".to_string(), - "void".to_string(), - "days integer".to_string(), - None, - None, - "BEGIN END".to_string(), - ); - to_routine.acl = vec![]; - - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("REVOKE EXECUTE ON PROCEDURE public.my_proc(days integer) FROM app;"), - "Full must revoke removed procedure grant, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_with_grant_option() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); - from_schema.acl = vec!["reader=U/owner".to_string()]; - let mut to_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); - to_schema.acl = vec!["reader=U*/owner".to_string()]; - - from_dump.schemas.push(from_schema); - to_dump.schemas.push(to_schema); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT USAGE ON SCHEMA myschema TO reader WITH GRANT OPTION;"), - "Must add grant with grant option, got: {script}" - ); - assert!( - !script.contains("REVOKE"), - "Upgrading to WITH GRANT OPTION must not REVOKE, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_no_comments_mode() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); - from_schema.acl = vec![]; - let mut to_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); - to_schema.acl = vec!["reader=U/owner".to_string()]; - - from_dump.schemas.push(from_schema); - to_dump.schemas.push(to_schema); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::AddOnly); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT USAGE ON SCHEMA myschema TO reader;"), - "Grant must still be emitted, got: {script}" - ); - assert!( - !script.contains("/* Grants for schema"), - "Comments must be suppressed, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_new_object_no_from_acl() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Table exists only in TO - let mut to_table = Table::new( - "public".to_string(), - "new_tbl".to_string(), - "public".to_string(), - "new_tbl".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec!["reader=r/owner".to_string()]; - - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT SELECT ON TABLE public.new_tbl TO reader;"), - "Must grant on new object with empty FROM acl, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_table_addonly_no_revoke() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_table = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec!["reader=r/owner".to_string(), "old_app=rw/owner".to_string()]; - - let mut to_table = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec![ - "reader=r/owner".to_string(), - "new_app=rwd/owner".to_string(), - ]; - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT DELETE, SELECT, UPDATE ON TABLE public.orders TO new_app;"), - "AddOnly must add new_app grant, got: {script}" - ); - assert!( - !script.contains("REVOKE"), - "AddOnly must not revoke old_app, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_sequence_full_revoke() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_seq = Sequence::new( - "public".to_string(), - "order_id_seq".to_string(), - "owner".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9223372036854775807), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - from_seq.acl = vec!["app=U/owner".to_string(), "old_svc=U/owner".to_string()]; - - let mut to_seq = Sequence::new( - "public".to_string(), - "order_id_seq".to_string(), - "owner".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9223372036854775807), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - to_seq.acl = vec!["app=U/owner".to_string()]; - - from_dump.sequences.push(from_seq); - to_dump.sequences.push(to_seq); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("REVOKE USAGE ON SEQUENCE public.order_id_seq FROM old_svc;"), - "Full must revoke removed sequence grant, got: {script}" - ); - assert!( - !script.contains("GRANT"), - "No new grants expected, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_sequence_addonly_no_revoke() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_seq = Sequence::new( - "public".to_string(), - "s1".to_string(), - "owner".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9223372036854775807), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - from_seq.acl = vec!["old_svc=U/owner".to_string()]; - - let mut to_seq = Sequence::new( - "public".to_string(), - "s1".to_string(), - "owner".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9223372036854775807), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - to_seq.acl = vec![]; - - from_dump.sequences.push(from_seq); - to_dump.sequences.push(to_seq); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("REVOKE"), - "AddOnly must not revoke sequence grants, got: {script}" - ); - assert!( - !script.contains("GRANT"), - "No grants expected, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_view_full_revoke() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_view = View::new( - "report_v".to_string(), - "SELECT 1".to_string(), - "reports".to_string(), - Vec::new(), - ); - from_view.acl = vec!["analyst=r/owner".to_string(), "intern=r/owner".to_string()]; - - let mut to_view = View::new( - "report_v".to_string(), - "SELECT 1".to_string(), - "reports".to_string(), - Vec::new(), - ); - to_view.acl = vec!["analyst=r/owner".to_string()]; - - from_dump.views.push(from_view); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("REVOKE SELECT ON TABLE reports.report_v FROM intern;"), - "Full must revoke removed view grant, got: {script}" - ); - assert!( - !script.contains("GRANT"), - "No new grants expected, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_routine_function_full_revoke() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "public".to_string(), - Oid(10), - "calc".to_string(), - "plpgsql".to_string(), - "function".to_string(), - "integer".to_string(), - "x integer".to_string(), - None, - None, - "BEGIN RETURN x; END".to_string(), - ); - from_routine.acl = vec!["app=X/owner".to_string(), "old_svc=X/owner".to_string()]; - - let mut to_routine = Routine::new( - "public".to_string(), - Oid(10), - "calc".to_string(), - "plpgsql".to_string(), - "function".to_string(), - "integer".to_string(), - "x integer".to_string(), - None, - None, - "BEGIN RETURN x; END".to_string(), - ); - to_routine.acl = vec!["app=X/owner".to_string()]; - - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("REVOKE EXECUTE ON FUNCTION public.calc(x integer) FROM old_svc;"), - "Full must revoke removed function grant, got: {script}" - ); - assert!( - !script.contains("GRANT"), - "No new grants expected, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_grantor_only_diff_produces_no_output() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Schema: same grantee + privileges, different grantor - let mut from_schema = Schema::new("app".to_string(), "app".to_string(), None); - from_schema.acl = vec!["reader=UC/old_owner".to_string()]; - let mut to_schema = Schema::new("app".to_string(), "app".to_string(), None); - to_schema.acl = vec!["reader=UC/new_owner".to_string()]; - from_dump.schemas.push(from_schema); - to_dump.schemas.push(to_schema); - - // Table: same grantee + privileges, different grantor - let mut from_table = Table::new( - "app".to_string(), - "t1".to_string(), - "app".to_string(), - "t1".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec!["reader=rw/old_owner".to_string()]; - let mut to_table = Table::new( - "app".to_string(), - "t1".to_string(), - "app".to_string(), - "t1".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec!["reader=rw/new_owner".to_string()]; - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - // Sequence: same grantee + privileges, different grantor - let mut from_seq = Sequence::new( - "app".to_string(), - "s1".to_string(), - "owner".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9223372036854775807), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - from_seq.acl = vec!["reader=U/old_owner".to_string()]; - let mut to_seq = Sequence::new( - "app".to_string(), - "s1".to_string(), - "owner".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9223372036854775807), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - to_seq.acl = vec!["reader=U/new_owner".to_string()]; - from_dump.sequences.push(from_seq); - to_dump.sequences.push(to_seq); - - // View: same grantee + privileges, different grantor - let mut from_view = View::new( - "v1".to_string(), - "SELECT 1".to_string(), - "app".to_string(), - Vec::new(), - ); - from_view.acl = vec!["reader=r/old_owner".to_string()]; - let mut to_view = View::new( - "v1".to_string(), - "SELECT 1".to_string(), - "app".to_string(), - Vec::new(), - ); - to_view.acl = vec!["reader=r/new_owner".to_string()]; - from_dump.views.push(from_view); - to_dump.views.push(to_view); - - // Routine: same grantee + privileges, different grantor - let mut from_routine = Routine::new( - "app".to_string(), - Oid(99), - "do_it".to_string(), - "plpgsql".to_string(), - "function".to_string(), - "void".to_string(), - "".to_string(), - None, - None, - "BEGIN END".to_string(), - ); - from_routine.acl = vec!["runner=X/old_owner".to_string()]; - let mut to_routine = Routine::new( - "app".to_string(), - Oid(99), - "do_it".to_string(), - "plpgsql".to_string(), - "function".to_string(), - "void".to_string(), - "".to_string(), - None, - None, - "BEGIN END".to_string(), - ); - to_routine.acl = vec!["runner=X/new_owner".to_string()]; - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("GRANT"), - "Grantor-only diff must not emit GRANT, got: {script}" - ); - assert!( - !script.contains("REVOKE"), - "Grantor-only diff must not emit REVOKE, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_full_grant_option_downgrade() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_table = Table::new( - "public".to_string(), - "items".to_string(), - "public".to_string(), - "items".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec!["admin=r*/owner".to_string()]; - - let mut to_table = Table::new( - "public".to_string(), - "items".to_string(), - "public".to_string(), - "items".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec!["admin=r/owner".to_string()]; - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("REVOKE GRANT OPTION FOR SELECT ON TABLE public.items FROM admin;"), - "Full must revoke grant option when downgrading, got: {script}" - ); - assert!( - !script.contains("GRANT SELECT"), - "No new grant expected for downgrade, got: {script}" - ); - // Should only contain REVOKE GRANT OPTION FOR, not a bare REVOKE SELECT - assert!( - !script.contains("REVOKE SELECT ON TABLE"), - "Must not fully revoke the privilege on downgrade, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_addonly_ignores_grant_option_downgrade() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_table = Table::new( - "public".to_string(), - "items".to_string(), - "public".to_string(), - "items".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec!["admin=r*/owner".to_string()]; - - let mut to_table = Table::new( - "public".to_string(), - "items".to_string(), - "public".to_string(), - "items".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec!["admin=r/owner".to_string()]; - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("REVOKE"), - "AddOnly must not revoke grant option, got: {script}" - ); - assert!( - !script.contains("GRANT"), - "No new grant expected, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_excludes_owner_acl_entries() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Table with ownership change: old_owner → new_owner - let mut from_table = Table::new( - "public".to_string(), - "data".to_string(), - "public".to_string(), - "data".to_string(), - "old_owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec![ - "old_owner=arwdDxt/old_owner".to_string(), - "reader=r/old_owner".to_string(), - ]; - - let mut to_table = Table::new( - "public".to_string(), - "data".to_string(), - "public".to_string(), - "data".to_string(), - "new_owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec![ - "new_owner=arwdDxt/new_owner".to_string(), - "reader=r/new_owner".to_string(), - ]; - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - // The `old_owner=arwdDxt/old_owner` and `new_owner=arwdDxt/new_owner` - // entries are PostgreSQL's implicit-owner ACL rows (materialised once - // any GRANT exists). `ALTER TABLE ... OWNER TO new_owner` removes - // old_owner's implicit row and adds new_owner's automatically — no - // REVOKE/GRANT is needed for those rows. Reader is unchanged on both - // sides. Net diff: empty. Pre-fix the comparer treated the implicit - // FROM-owner row as if it would persist post-migration and emitted a - // long REVOKE, then on the next compare run had nothing to compare - // against and emitted GRANTs — a non-idempotent oscillation. - assert!( - !script.contains("REVOKE"), - "ALTER OWNER alone strips the implicit-owner entry; no REVOKE should be emitted, got: {script}" - ); - assert!( - !script.contains("GRANT"), - "No grants expected — reader is unchanged and new_owner gets implicit privileges via ALTER OWNER, got: {script}" - ); - assert!( - !script.contains("new_owner"), - "Must not reference the new owner explicitly, got: {script}" - ); - assert!( - !script.contains("old_owner"), - "Must not reference the former owner explicitly when only the implicit-owner ACL row needs to migrate, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_full_revokes_explicit_grants_from_former_owner() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_schema = Schema::new("billing".to_string(), "billing".to_string(), None); - from_schema.owner = "old_owner".to_string(); - from_schema.acl = vec!["old_owner=UC/old_owner".to_string()]; - - let mut to_schema = Schema::new("billing".to_string(), "billing".to_string(), None); - to_schema.owner = "new_owner".to_string(); - - let mut from_table = Table::new( - "billing".to_string(), - "invoice".to_string(), - "billing".to_string(), - "invoice".to_string(), - "old_owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec!["old_owner=ar/old_owner".to_string()]; - - let to_table = Table::new( - "billing".to_string(), - "invoice".to_string(), - "billing".to_string(), - "invoice".to_string(), - "new_owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - - from_dump.schemas.push(from_schema); - from_dump.tables.push(from_table); - to_dump.schemas.push(to_schema); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - // Same reasoning as `compare_grants_excludes_owner_acl_entries`: - // `old_owner=UC/old_owner` and `old_owner=ar/old_owner` are - // implicit-owner ACL entries that `ALTER ... OWNER TO new_owner` - // strips automatically. Comparing against TO (which has no entries - // at all) should produce an empty diff, not REVOKE statements. - assert!( - !script.contains("REVOKE"), - "Implicit-owner ACL entries are removed by ALTER OWNER alone; no REVOKE should be emitted, got: {script}" - ); - assert!( - !script.contains("new_owner"), - "Current owner must not appear in grant/revoke output, got: {script}" - ); - assert!( - !script.contains("old_owner"), - "Former owner must not appear in grant/revoke output for the implicit-owner ACL row, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_emits_explicit_grants_to_former_owner() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_schema = Schema::new("billing".to_string(), "billing".to_string(), None); - from_schema.owner = "old_owner".to_string(); - - let mut to_schema = Schema::new("billing".to_string(), "billing".to_string(), None); - to_schema.owner = "new_owner".to_string(); - to_schema.acl = vec![ - "old_owner=UC/new_owner".to_string(), - "app_user=U/new_owner".to_string(), - ]; - - let from_table = Table::new( - "billing".to_string(), - "invoice".to_string(), - "billing".to_string(), - "invoice".to_string(), - "old_owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - - let mut to_table = Table::new( - "billing".to_string(), - "invoice".to_string(), - "billing".to_string(), - "invoice".to_string(), - "new_owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec![ - "old_owner=ar/new_owner".to_string(), - "app_user=r/new_owner".to_string(), - ]; - - from_dump.schemas.push(from_schema); - from_dump.tables.push(from_table); - to_dump.schemas.push(to_schema); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT CREATE, USAGE ON SCHEMA billing TO old_owner;"), - "Former schema owner must receive explicit TO grant, got: {script}" - ); - assert!( - script.contains("GRANT USAGE ON SCHEMA billing TO app_user;"), - "Non-owner schema grant must still be emitted, got: {script}" - ); - assert!( - script.contains("GRANT INSERT, SELECT ON TABLE billing.invoice TO old_owner;"), - "Former table owner must receive explicit TO grant, got: {script}" - ); - assert!( - script.contains("GRANT SELECT ON TABLE billing.invoice TO app_user;"), - "Non-owner table grant must still be emitted, got: {script}" - ); - assert!( - !script.contains("TO new_owner"), - "Current owner must not receive explicit grants, got: {script}" - ); -} - -#[tokio::test] -async fn compare_grants_owner_excluded_nonowner_still_diffed() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "public".to_string(), - Oid(50), - "process".to_string(), - "plpgsql".to_string(), - "function".to_string(), - "void".to_string(), - "".to_string(), - None, - None, - "BEGIN END".to_string(), - ); - from_routine.owner = "the_owner".to_string(); - from_routine.acl = vec![ - "the_owner=X/the_owner".to_string(), - "old_app=X/the_owner".to_string(), - ]; - - let mut to_routine = Routine::new( - "public".to_string(), - Oid(50), - "process".to_string(), - "plpgsql".to_string(), - "function".to_string(), - "void".to_string(), - "".to_string(), - None, - None, - "BEGIN END".to_string(), - ); - to_routine.owner = "the_owner".to_string(); - to_routine.acl = vec![ - "the_owner=X/the_owner".to_string(), - "new_app=X/the_owner".to_string(), - ]; - - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT EXECUTE ON FUNCTION public.process() TO new_app;"), - "Must grant to non-owner, got: {script}" - ); - assert!( - script.contains("REVOKE EXECUTE ON FUNCTION public.process() FROM old_app;"), - "Must revoke from non-owner, got: {script}" - ); - assert!( - !script.contains("the_owner"), - "Must not reference owner in grants/revokes, got: {script}" - ); -} - -#[tokio::test] -async fn compare_routines_overloaded_identical_no_diff() { - // Two routines with the same (schema, name) but different arguments. - // Both overloads are identical in FROM and TO → no output expected. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let overload_short_from = Routine::new( - "myschema".to_string(), - Oid(1), - "notify_event".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "pjobid uuid, peventtype character varying, pattributes jsonb".to_string(), - None, - None, - "BEGIN\n CALL myschema.notify_event(pjobid, peventtype, null, pattributes, null);\nEND;" - .to_string(), - ); - let overload_long_from = Routine::new( - "myschema".to_string(), - Oid(2), - "notify_event".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "pjobid uuid, peventtype character varying, puserid character varying, pattributes jsonb, psessionseed jsonb DEFAULT NULL::jsonb".to_string(), - None, - None, - "BEGIN\n RAISE NOTICE 'notify';\nEND;".to_string(), - ); - - let overload_short_to = Routine::new( - "myschema".to_string(), - Oid(1), - "notify_event".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "pjobid uuid, peventtype character varying, pattributes jsonb".to_string(), - None, - None, - "BEGIN\n CALL myschema.notify_event(pjobid, peventtype, null, pattributes, null);\nEND;" - .to_string(), - ); - let overload_long_to = Routine::new( - "myschema".to_string(), - Oid(2), - "notify_event".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "pjobid uuid, peventtype character varying, puserid character varying, pattributes jsonb, psessionseed jsonb DEFAULT NULL::jsonb".to_string(), - None, - None, - "BEGIN\n RAISE NOTICE 'notify';\nEND;".to_string(), - ); - - from_dump.routines.push(overload_short_from); - from_dump.routines.push(overload_long_from); - to_dump.routines.push(overload_short_to); - to_dump.routines.push(overload_long_to); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("create or replace"), - "Identical overloads must not produce CREATE, got: {script}" - ); - assert!( - !script.contains("drop procedure"), - "Identical overloads must not produce DROP, got: {script}" - ); -} - -#[tokio::test] -async fn compare_routines_overloaded_one_changed() { - // Two overloads with the same (schema, name). Only the long overload - // changes its body between FROM and TO. The short overload must remain - // untouched while the long one is recreated. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let overload_short_from = Routine::new( - "myschema".to_string(), - Oid(1), - "notify_event".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "pjobid uuid, peventtype character varying, pattributes jsonb".to_string(), - None, - None, - "BEGIN\n CALL myschema.notify_event(pjobid, peventtype, null, pattributes, null);\nEND;" - .to_string(), - ); - let overload_long_from = Routine::new( - "myschema".to_string(), - Oid(2), - "notify_event".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "pjobid uuid, peventtype character varying, puserid character varying, pattributes jsonb, psessionseed jsonb DEFAULT NULL::jsonb".to_string(), - None, - None, - "BEGIN\n RAISE NOTICE 'old body';\nEND;".to_string(), - ); - - // Short overload is identical to FROM - let overload_short_to = Routine::new( - "myschema".to_string(), - Oid(1), - "notify_event".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "pjobid uuid, peventtype character varying, pattributes jsonb".to_string(), - None, - None, - "BEGIN\n CALL myschema.notify_event(pjobid, peventtype, null, pattributes, null);\nEND;" - .to_string(), - ); - // Long overload has a different body → should be recreated - let overload_long_to = Routine::new( - "myschema".to_string(), - Oid(2), - "notify_event".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "pjobid uuid, peventtype character varying, puserid character varying, pattributes jsonb, psessionseed jsonb DEFAULT NULL::jsonb".to_string(), - None, - None, - "BEGIN\n RAISE NOTICE 'new body';\nEND;".to_string(), - ); - - from_dump.routines.push(overload_short_from); - from_dump.routines.push(overload_long_from); - to_dump.routines.push(overload_short_to); - to_dump.routines.push(overload_long_to); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - // The changed (long) overload must be recreated - assert!( - script.contains("create or replace procedure myschema.notify_event(pjobid uuid, peventtype character varying, puserid character varying, pattributes jsonb, psessionseed jsonb DEFAULT NULL::jsonb)"), - "Changed overload must be recreated, got: {script}" - ); - // The short overload's signature must NOT appear in any CREATE statement - let short_create = "create or replace procedure myschema.notify_event(pjobid uuid, peventtype character varying, pattributes jsonb)"; - assert!( - !script.contains(short_create), - "Unchanged overload must not be recreated, got: {script}" - ); -} - -#[tokio::test] -async fn compare_routines_overloaded_drop_only_removed_overload() { - // FROM has two overloads; TO has only the short one. - // Only the long overload must be dropped; the short one must stay. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let overload_short = Routine::new( - "myschema".to_string(), - Oid(1), - "notify_event".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "pjobid uuid, pattributes jsonb".to_string(), - None, - None, - "BEGIN\n RAISE NOTICE 'short';\nEND;".to_string(), - ); - let overload_long = Routine::new( - "myschema".to_string(), - Oid(2), - "notify_event".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "pjobid uuid, pattributes jsonb, pseed jsonb".to_string(), - None, - None, - "BEGIN\n RAISE NOTICE 'long';\nEND;".to_string(), - ); - - from_dump.routines.push(overload_short.clone()); - from_dump.routines.push(overload_long); - to_dump.routines.push(overload_short); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("drop procedure if exists myschema.notify_event (pjobid uuid, pattributes jsonb, pseed jsonb) cascade;"), - "Removed overload must be dropped, got: {script}" - ); - assert!( - !script.contains("create or replace"), - "Unchanged overload must not be recreated, got: {script}" - ); -} - -#[tokio::test] -async fn new_partition_children_deferred_until_parent_is_recreated() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Root partitioned table (unchanged in both dumps) - let mut root = Table::new( - "data".to_string(), - "events".to_string(), - "data".to_string(), - "events".to_string(), - "postgres".to_string(), - None, - vec![int_column("data", "events", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - root.partition_key = Some("RANGE (id)".to_string()); - root.hash(); - - // FROM: events_2023 exists but is NOT sub-partitioned (no partition_key) - let mut from_events_2023 = Table::new( - "data".to_string(), - "events_2023".to_string(), - "data".to_string(), - "events_2023".to_string(), - "postgres".to_string(), - None, - vec![int_column("data", "events_2023", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - from_events_2023.partition_of = Some("\"data\".\"events\"".to_string()); - from_events_2023.partition_bound = Some("FOR VALUES FROM (2023) TO (2024)".to_string()); - from_events_2023.hash(); - - // TO: events_2023 now gains a partition_key (LIST region) - let mut to_events_2023 = Table::new( - "data".to_string(), - "events_2023".to_string(), - "data".to_string(), - "events_2023".to_string(), - "postgres".to_string(), - None, - vec![int_column("data", "events_2023", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - to_events_2023.partition_of = Some("\"data\".\"events\"".to_string()); - to_events_2023.partition_bound = Some("FOR VALUES FROM (2023) TO (2024)".to_string()); - to_events_2023.partition_key = Some("LIST (region)".to_string()); - to_events_2023.hash(); - - let mut leaf_eu = Table::new( - "data".to_string(), - "events_2023_eu".to_string(), - "data".to_string(), - "events_2023_eu".to_string(), - "postgres".to_string(), - None, - vec![int_column("data", "events_2023_eu", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - leaf_eu.partition_of = Some("\"data\".\"events_2023\"".to_string()); - leaf_eu.partition_bound = Some("FOR VALUES IN ('eu')".to_string()); - leaf_eu.hash(); - - let mut leaf_us = Table::new( - "data".to_string(), - "events_2023_us".to_string(), - "data".to_string(), - "events_2023_us".to_string(), - "postgres".to_string(), - None, - vec![int_column("data", "events_2023_us", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - leaf_us.partition_of = Some("\"data\".\"events_2023\"".to_string()); - leaf_us.partition_bound = Some("FOR VALUES IN ('us')".to_string()); - leaf_us.hash(); - - // FROM dump: root + old events_2023 (no sub-partition key) - from_dump.tables.push(root.clone()); - from_dump.tables.push(from_events_2023); - - // TO dump: root + new events_2023 (with sub-partition key) + two leaves - // Push in reverse depth order to stress the sorting - to_dump.tables.push(leaf_us); - to_dump.tables.push(leaf_eu); - to_dump.tables.push(to_events_2023); - to_dump.tables.push(root); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let pos_recreate = script - .find("create table data.events_2023 partition of") - .expect("events_2023 recreate not found"); - let pos_eu = script - .find("create table data.events_2023_eu partition of") - .expect("events_2023_eu create not found"); - let pos_us = script - .find("create table data.events_2023_us partition of") - .expect("events_2023_us create not found"); - - assert!( - pos_recreate < pos_eu, - "events_2023 must be recreated before events_2023_eu is created (got recreate={pos_recreate}, eu={pos_eu})" - ); - assert!( - pos_recreate < pos_us, - "events_2023 must be recreated before events_2023_us is created (got recreate={pos_recreate}, us={pos_us})" - ); - - let pos_drop = script - .find("drop table if exists data.events_2023") - .expect("events_2023 drop not found"); - assert!( - pos_drop < pos_eu, - "events_2023 must be dropped before events_2023_eu is created" - ); - assert!( - pos_drop < pos_us, - "events_2023 must be dropped before events_2023_us is created" - ); -} - -#[tokio::test] -async fn fk_pre_drop_commented_when_use_drop_false() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // "from" has a table referenced by FK - let mut referenced = Table::new( - "public".to_string(), - "users".to_string(), - "public".to_string(), - "users".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "users", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - referenced.hash(); - - // "from" has a table with FK referencing "users" - let mut referencing = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "postgres".to_string(), - None, - vec![ - int_column("public", "orders", "id", 1), - int_column("public", "orders", "user_id", 2), - ], - vec![TableConstraint { - catalog: "postgres".to_string(), - schema: "public".to_string(), - name: "orders_user_fk".to_string(), - table_name: "orders".to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("FOREIGN KEY (user_id) REFERENCES public.users(id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }], - vec![], - vec![], - None, - ); - referencing.hash(); - - from_dump.tables.push(referenced.clone()); - from_dump.tables.push(referencing.clone()); - - // "to" has only "orders" — "users" is being dropped, so its FK must be pre-dropped - to_dump.tables.push(referencing); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - // FK drop should be commented out because use_drop=false - let has_commented_fk_drop = script.lines().any(|l| { - l.starts_with("--") && l.contains("drop constraint") && l.contains("orders_user_fk") - }); - assert!( - has_commented_fk_drop, - "FK pre-drop should be commented out when use_drop=false, script:\n{}", - script - ); - - // Should NOT have an active (uncommented) drop constraint for the FK - let has_active_fk_drop = script.lines().any(|l| { - !l.starts_with("--") && l.contains("drop constraint") && l.contains("orders_user_fk") - }); - assert!( - !has_active_fk_drop, - "FK pre-drop should NOT be active when use_drop=false" - ); -} - -#[tokio::test] -async fn fk_pre_drop_active_when_use_drop_true() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut referenced = Table::new( - "public".to_string(), - "users".to_string(), - "public".to_string(), - "users".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "users", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - referenced.hash(); - - let mut referencing = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "postgres".to_string(), - None, - vec![ - int_column("public", "orders", "id", 1), - int_column("public", "orders", "user_id", 2), - ], - vec![TableConstraint { - catalog: "postgres".to_string(), - schema: "public".to_string(), - name: "orders_user_fk".to_string(), - table_name: "orders".to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("FOREIGN KEY (user_id) REFERENCES public.users(id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }], - vec![], - vec![], - None, - ); - referencing.hash(); - - from_dump.tables.push(referenced.clone()); - from_dump.tables.push(referencing.clone()); - to_dump.tables.push(referencing); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let has_active_fk_drop = script.lines().any(|l| { - !l.starts_with("--") && l.contains("drop constraint") && l.contains("orders_user_fk") - }); - assert!( - has_active_fk_drop, - "FK pre-drop should be active when use_drop=true, script:\n{}", - script - ); -} - -#[tokio::test] -async fn trigger_pre_drop_commented_when_use_drop_false() { - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - // "from" has a table with a trigger; table is absent in "to" - let mut table_with_trigger = Table::new( - "public".to_string(), - "events".to_string(), - "public".to_string(), - "events".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "events", "id", 1)], - vec![], - vec![], - vec![TableTrigger { - oid: Oid(9999), - name: "trg_events_audit".to_string(), - definition: "before insert on events for each row execute function audit()".to_string(), - enabled: "O".to_string(), - comment: None, - }], - None, - ); - table_with_trigger.hash(); - - from_dump.tables.push(table_with_trigger); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - // Trigger drop should be commented out when use_drop=false - let has_commented_trigger_drop = script.lines().any(|l| { - l.starts_with("--") && l.contains("drop trigger") && l.contains("trg_events_audit") - }); - assert!( - has_commented_trigger_drop, - "Trigger pre-drop should be commented when use_drop=false, script:\n{}", - script - ); - - let has_active_trigger_drop = script.lines().any(|l| { - !l.starts_with("--") && l.contains("drop trigger") && l.contains("trg_events_audit") - }); - assert!( - !has_active_trigger_drop, - "Trigger pre-drop should NOT be active when use_drop=false" - ); -} - -#[tokio::test] -async fn trigger_pre_drop_active_when_use_drop_true() { - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - let mut table_with_trigger = Table::new( - "public".to_string(), - "events".to_string(), - "public".to_string(), - "events".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "events", "id", 1)], - vec![], - vec![], - vec![TableTrigger { - oid: Oid(9999), - name: "trg_events_audit".to_string(), - definition: "before insert on events for each row execute function audit()".to_string(), - enabled: "O".to_string(), - comment: None, - }], - None, - ); - table_with_trigger.hash(); - - from_dump.tables.push(table_with_trigger); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let has_active_trigger_drop = script.lines().any(|l| { - !l.starts_with("--") && l.contains("drop trigger") && l.contains("trg_events_audit") - }); - assert!( - has_active_trigger_drop, - "Trigger pre-drop should be active when use_drop=true, script:\n{}", - script - ); -} - -// ------ Kind-transition tests (regular <-> materialized) ------ - -#[tokio::test] -async fn kind_transition_regular_to_materialized_use_drop_true() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - vec![], - ); - from_view.is_materialized = false; - from_view.hash(); - from_dump.views.push(from_view); - - let mut to_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - vec![], - ); - to_view.is_materialized = true; - to_view.hash(); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - comparer.create_views().await.unwrap(); - let script = comparer.get_script(); - - // DROP VIEW (regular) should be active - let has_active_drop = script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")); - assert!( - has_active_drop, - "Regular→mat: DROP VIEW should be active when use_drop=true, script:\n{}", - script - ); - // CREATE MATERIALIZED VIEW should be active - let has_active_create = script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("create materialized view")); - assert!( - has_active_create, - "Regular→mat: CREATE MATERIALIZED VIEW should be active when use_drop=true, script:\n{}", - script - ); -} - -#[tokio::test] -async fn kind_transition_regular_to_materialized_use_drop_false() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - vec![], - ); - from_view.is_materialized = false; - from_view.hash(); - from_dump.views.push(from_view); - - let mut to_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - vec![], - ); - to_view.is_materialized = true; - to_view.hash(); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - comparer.create_views().await.unwrap(); - let script = comparer.get_script(); - - // DROP should be commented - let has_active_drop = script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")); - assert!( - !has_active_drop, - "Regular→mat: DROP VIEW should be commented when use_drop=false, script:\n{}", - script - ); - // CREATE should be commented (manual intervention needed) - let has_active_create = script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("create materialized view")); - assert!( - !has_active_create, - "Regular→mat: CREATE MATERIALIZED VIEW should be commented when use_drop=false, script:\n{}", - script - ); - assert!( - script.contains("manual intervention needed"), - "Should contain manual intervention warning, script:\n{}", - script - ); -} - -#[tokio::test] -async fn kind_transition_materialized_to_regular_use_drop_true() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - vec![], - ); - from_view.is_materialized = true; - from_view.hash(); - from_dump.views.push(from_view); - - let mut to_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - vec![], - ); - to_view.is_materialized = false; - to_view.hash(); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - comparer.create_views().await.unwrap(); - let script = comparer.get_script(); - - // DROP MATERIALIZED VIEW should be active - let has_active_drop = script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop materialized view")); - assert!( - has_active_drop, - "Mat→regular: DROP MATERIALIZED VIEW should be active when use_drop=true, script:\n{}", - script - ); - // CREATE OR REPLACE VIEW should be active - let has_active_create = script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("create or replace view")); - assert!( - has_active_create, - "Mat→regular: CREATE OR REPLACE VIEW should be active when use_drop=true, script:\n{}", - script - ); -} - -#[tokio::test] -async fn kind_transition_materialized_to_regular_use_drop_false() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - vec![], - ); - from_view.is_materialized = true; - from_view.hash(); - from_dump.views.push(from_view); - - let mut to_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - vec![], - ); - to_view.is_materialized = false; - to_view.hash(); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - comparer.create_views().await.unwrap(); - let script = comparer.get_script(); - - // DROP should be commented - let has_active_drop = script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop materialized view")); - assert!( - !has_active_drop, - "Mat→regular: DROP MATERIALIZED VIEW should be commented when use_drop=false, script:\n{}", - script - ); - // CREATE OR REPLACE VIEW should also be commented (kind transition) - let has_active_create = script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("create or replace view")); - assert!( - !has_active_create, - "Mat→regular: CREATE OR REPLACE VIEW should be commented when use_drop=false, script:\n{}", - script - ); - assert!( - script.contains("manual intervention needed"), - "Should contain manual intervention warning, script:\n{}", - script - ); -} - -/// FROM-only views (present in FROM, absent in TO) must still appear in the output -/// when use_drop=false — as a commented-out DROP statement so the user is aware the -/// view should be removed. Previously `should_drop` gated `is_from_only` behind -/// `self.use_drop`, which suppressed the DROP entirely. -#[tokio::test] -async fn from_only_view_commented_drop_when_use_drop_false() { - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - let mut view = View::new( - "obsolete_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - vec![], - ); - view.is_materialized = false; - view.hash(); - from_dump.views.push(view); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - let script = comparer.get_script(); - - // The DROP must appear in the output … - assert!( - script.to_lowercase().contains("drop view"), - "FROM-only view must produce a DROP statement even with use_drop=false, script:\n{}", - script - ); - // … but it must be commented out, not active SQL. - let has_active_drop = script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")); - assert!( - !has_active_drop, - "FROM-only view DROP should be commented when use_drop=false, script:\n{}", - script - ); -} - -/// Counterpart: with use_drop=true the DROP for a FROM-only view must be active SQL. -#[tokio::test] -async fn from_only_view_active_drop_when_use_drop_true() { - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - let mut view = View::new( - "obsolete_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - vec![], - ); - view.is_materialized = false; - view.hash(); - from_dump.views.push(view); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - let script = comparer.get_script(); - - let has_active_drop = script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")); - assert!( - has_active_drop, - "FROM-only view DROP should be active SQL when use_drop=true, script:\n{}", - script - ); -} - -// --- Tests for get_script() newline collapsing vs dollar-quoted bodies --- - -/// Helper: build a Comparer with use_comments=false and a given script body. -fn comparer_with_script(script: &str) -> Comparer { - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut c = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); - c.script = script.to_string(); - c -} - -#[test] -fn get_script_collapses_triple_newlines_outside_dollar_quotes() { - let input = "SELECT 1;\n\n\n\nSELECT 2;\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - // 4 newlines should be collapsed to 2 - assert_eq!(out, "SELECT 1;\n\nSELECT 2;\n"); -} - -#[test] -fn get_script_preserves_triple_newlines_inside_dollar_quotes() { - let input = concat!( - "CREATE OR REPLACE PROCEDURE public.test_proc() LANGUAGE plpgsql AS $$\n", - "BEGIN\n", - " RAISE NOTICE 'block 1';\n", - "\n", - "\n", - "\n", - " RAISE NOTICE 'block 2';\n", - "END;\n", - "$$;\n", - ); - let c = comparer_with_script(input); - let out = c.get_script(); - // The three consecutive newlines inside $$ must survive - assert!( - out.contains("'block 1';\n\n\n\n RAISE NOTICE 'block 2'"), - "blank lines inside $$ body must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_preserves_newlines_inside_tagged_dollar_quotes() { - let input = concat!( - "CREATE FUNCTION f() RETURNS void LANGUAGE plpgsql AS $body$\n", - "BEGIN\n", - "\n", - "\n", - "\n", - " NULL;\n", - "END;\n", - "$body$;\n", - ); - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("BEGIN\n\n\n\n NULL;"), - "blank lines inside $body$ must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_collapses_newlines_between_dollar_quoted_blocks() { - // Newlines *outside* dollar-quoted blocks should still be collapsed - let input = "$$body1$$;\n\n\n\n$$body2$$;\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert_eq!(out, "$$body1$$;\n\n$$body2$$;\n"); -} - -#[test] -fn get_script_mixed_dollar_quote_and_outside_newlines() { - let input = concat!( - "SELECT 1;\n\n\n\n", - "CREATE FUNCTION f() RETURNS void AS $$\n", - "BEGIN\n", - "\n\n\n", - " NULL;\n", - "END;\n", - "$$;\n", - "\n\n\n\n", - "SELECT 2;\n", - ); - let c = comparer_with_script(input); - let out = c.get_script(); - - // Outside: collapsed - assert!( - !out.contains("SELECT 1;\n\n\n"), - "newlines before $$ block should be collapsed" - ); - assert!( - !out.contains("$$;\n\n\n"), - "newlines after $$ block should be collapsed" - ); - // Inside: preserved - assert!( - out.contains("BEGIN\n\n\n\n NULL;"), - "blank lines inside $$ must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_with_use_comments_true_returns_verbatim() { - let input = "SELECT 1;\n\n\n\nSELECT 2;\n"; - let from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - let mut c = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - c.script = input.to_string(); - let out = c.get_script(); - // With use_comments=true, script is returned as-is - assert_eq!(out, input); -} - -#[test] -fn get_script_strips_comments_but_preserves_dollar_body_newlines() { - let input = concat!( - "-- a comment\n", - "CREATE FUNCTION f() RETURNS void AS $$\n", - "BEGIN\n", - "\n\n\n", - " NULL;\n", - "END;\n", - "$$;\n", - ); - let c = comparer_with_script(input); - let out = c.get_script(); - // Comment removed - assert!( - !out.contains("-- a comment"), - "line comment should be removed" - ); - // Dollar body preserved - assert!( - out.contains("BEGIN\n\n\n\n NULL;"), - "blank lines inside $$ body must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_empty_dollar_body_not_corrupted() { - let input = "CREATE FUNCTION f() AS $$$$;\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!(out.contains("$$$$"), "empty dollar body must be preserved"); -} - -#[test] -fn get_script_unterminated_dollar_quote_copies_to_end() { - // Unterminated dollar-quote: everything after opening tag should be - // copied verbatim (same as the comment-stripping pass behaviour). - let input = "CREATE FUNCTION f() AS $$\nBEGIN\n\n\n\n NULL;\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("\n\n\n\n"), - "unterminated $$ body newlines must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_preserves_newlines_inside_single_quoted_string() { - // Multi-line COMMENT body in a single-quoted literal must not be collapsed. - let input = "COMMENT ON TABLE t IS 'line1\n\n\n\nline5';\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("'line1\n\n\n\nline5'"), - "newlines inside single-quoted string must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_preserves_newlines_inside_e_string() { - // E-string literal with multi-line content. - let input = "SELECT E'first\n\n\n\nlast';\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("E'first\n\n\n\nlast'"), - "newlines inside E-string must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_preserves_newlines_inside_double_quoted_identifier() { - // Unusual but legal: double-quoted identifiers can contain newlines. - let input = "SELECT \"col\n\n\n\nname\";\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("\"col\n\n\n\nname\""), - "newlines inside double-quoted identifier must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_collapses_newlines_between_single_quoted_strings() { - // Newlines *outside* quoted strings should still be collapsed. - let input = "SELECT 'a';\n\n\n\nSELECT 'b';\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert_eq!(out, "SELECT 'a';\n\nSELECT 'b';\n"); -} - -#[test] -fn get_script_e_string_with_escaped_quote_and_newlines() { - // E-string with \' inside — must not terminate early. - let input = "SELECT E'it\\'s\n\n\n\nfine';\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("E'it\\'s\n\n\n\nfine'"), - "E-string with escaped quote and newlines must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_single_quoted_doubled_quote_and_newlines() { - // Standard single-quoted string with '' escape and embedded newlines. - let input = "SELECT 'it''s\n\n\n\nfine';\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("'it''s\n\n\n\nfine'"), - "single-quoted string with doubled quote and newlines must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_lowercase_e_string_preserves_newlines() { - // Lowercase e should be recognised as an E-string opener too. - let input = "SELECT e'first\n\n\n\nlast';\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("e'first\n\n\n\nlast'"), - "newlines inside lowercase e-string must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_unterminated_single_quoted_string_copies_to_end() { - let input = "SELECT 'unterminated\n\n\n\nstring\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("\n\n\n\n"), - "unterminated single-quoted string newlines must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_unterminated_e_string_copies_to_end() { - let input = "SELECT E'unterminated\n\n\n\nstring\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("\n\n\n\n"), - "unterminated E-string newlines must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_unterminated_double_quoted_identifier_copies_to_end() { - let input = "SELECT \"unterminated\n\n\n\nident\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("\n\n\n\n"), - "unterminated double-quoted identifier newlines must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_empty_single_quoted_string_no_corruption() { - // Empty string '' should not confuse the scanner. - let input = "SELECT '';\n\n\n\nSELECT 1;\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert_eq!(out, "SELECT '';\n\nSELECT 1;\n"); -} - -#[test] -fn get_script_newline_count_resets_after_single_quoted_literal() { - // Two newlines before a quoted literal, then two newlines after it — - // neither run alone exceeds 2 so nothing should be collapsed. - let input = "A;\n\n'inside\n\n\n\ntext';\n\nB;\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("'inside\n\n\n\ntext'"), - "newlines inside literal must be preserved, got:\n{}", - out - ); - // The two newlines before and after the literal should survive. - assert!( - out.contains("A;\n\n'inside"), - "two newlines before literal should survive, got:\n{}", - out - ); - assert!( - out.contains("';\n\nB;"), - "two newlines after literal should survive, got:\n{}", - out - ); -} - -#[test] -fn get_script_collapses_after_quoted_literal_with_excess_newlines() { - // Excess newlines *after* a quoted literal should still be collapsed. - let input = "SELECT 'hello';\n\n\n\nSELECT 'world';\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert_eq!(out, "SELECT 'hello';\n\nSELECT 'world';\n"); -} - -#[test] -fn get_script_mixed_literal_types_with_newlines() { - // Mix of dollar-quoted, single-quoted, E-string, and double-quoted - // literals each containing newlines that must be preserved, separated - // by excessive newlines that should be collapsed. - let input = concat!( - "COMMENT ON TABLE t IS 'line1\n\n\n\nline5';\n", - "\n\n\n\n", - "SELECT E'a\n\n\n\nb';\n", - "\n\n\n\n", - "SELECT \"id\n\n\n\ncol\";\n", - "\n\n\n\n", - "CREATE FUNCTION f() AS $$\nBEGIN\n\n\n\n NULL;\nEND;\n$$;\n", - ); - let c = comparer_with_script(input); - let out = c.get_script(); - // Inside literals: preserved - assert!( - out.contains("'line1\n\n\n\nline5'"), - "single-quoted newlines must be preserved" - ); - assert!( - out.contains("E'a\n\n\n\nb'"), - "E-string newlines must be preserved" - ); - assert!( - out.contains("\"id\n\n\n\ncol\""), - "double-quoted newlines must be preserved" - ); - assert!( - out.contains("BEGIN\n\n\n\n NULL;"), - "dollar-quoted newlines must be preserved" - ); - // Outside literals: collapsed (no run of 3+ newlines between statements) - let between_stmts = out - .split("'line1\n\n\n\nline5';") - .nth(1) - .unwrap() - .split("E'a\n\n\n\nb'") - .next() - .unwrap(); - assert!( - !between_stmts.contains("\n\n\n"), - "newlines between statements should be collapsed, got segment: {:?}", - between_stmts - ); -} - -#[test] -fn get_script_e_string_escaped_backslash_then_newlines() { - // E'foo\\' — the \\\\ is an escaped backslash, so the next ' closes - // the string. Newlines outside should be collapsed. - let input = "SELECT E'foo\\\\';\n\n\n\nSELECT 1;\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert_eq!(out, "SELECT E'foo\\\\';\n\nSELECT 1;\n"); -} - -#[test] -fn get_script_double_quoted_doubled_escape_and_newlines() { - // Double-quoted identifier with "" escape and embedded newlines. - let input = "SELECT \"col\"\"\n\n\n\nname\";\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("\"col\"\"\n\n\n\nname\""), - "double-quoted identifier with escaped quote and newlines must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_comment_stripped_but_single_quoted_newlines_preserved() { - // The comment-stripping pass runs first; the collapsing pass must - // still preserve newlines inside single-quoted strings. - let input = concat!( - "-- strip this\n", - "COMMENT ON TABLE t IS 'multi\n\n\n\nline';\n", - ); - let c = comparer_with_script(input); - let out = c.get_script(); - assert!(!out.contains("-- strip this"), "comment should be removed"); - assert!( - out.contains("'multi\n\n\n\nline'"), - "single-quoted newlines must survive comment stripping + collapsing, got:\n{}", - out - ); -} - -#[test] -fn get_script_block_comment_stripped_but_e_string_newlines_preserved() { - let input = concat!("/* block comment */\n", "SELECT E'keep\n\n\n\nme';\n",); - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - !out.contains("block comment"), - "block comment should be removed" - ); - assert!( - out.contains("E'keep\n\n\n\nme'"), - "E-string newlines must survive after block comment stripping, got:\n{}", - out - ); -} - -#[test] -fn get_script_adjacent_single_quoted_strings_both_preserved() { - // Two single-quoted strings back-to-back, each with internal newlines. - let input = "SELECT 'a\n\n\n\nb' || 'c\n\n\n\nd';\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("'a\n\n\n\nb'"), - "first literal newlines must be preserved, got:\n{}", - out - ); - assert!( - out.contains("'c\n\n\n\nd'"), - "second literal newlines must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_dollar_body_containing_single_quoted_newlines() { - // A dollar-quoted body that itself contains a single-quoted string - // with many newlines — everything inside $$ is already copied verbatim - // by the dollar-quote branch, so the inner literal is preserved too. - let input = concat!( - "CREATE FUNCTION f() AS $$\n", - "BEGIN\n", - " RAISE NOTICE 'msg\n\n\n\nend';\n", - "END;\n", - "$$;\n", - ); - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("'msg\n\n\n\nend'"), - "single-quoted literal inside dollar body must be preserved, got:\n{}", - out - ); -} - -#[test] -fn get_script_single_quoted_string_without_excess_newlines_unchanged() { - // A single-quoted string with exactly 2 newlines (not excess) — should - // pass through without any modification. - let input = "SELECT 'a\n\nb';\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert_eq!(out, "SELECT 'a\n\nb';\n"); -} - -#[test] -fn get_script_e_identifier_not_confused_with_e_string() { - // A column named "E" followed by a comparison — the E is followed by - // a space, not a quote, so it must not be mistaken for an E-string. - let input = "SELECT E = 1;\n\n\n\nSELECT 2;\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert_eq!(out, "SELECT E = 1;\n\nSELECT 2;\n"); -} - -#[test] -fn get_script_multiple_e_strings_on_same_line() { - let input = "SELECT E'x\n\n\n\ny', E'a\n\n\n\nb';\n"; - let c = comparer_with_script(input); - let out = c.get_script(); - assert!( - out.contains("E'x\n\n\n\ny'"), - "first E-string newlines must be preserved, got:\n{}", - out - ); - assert!( - out.contains("E'a\n\n\n\nb'"), - "second E-string newlines must be preserved, got:\n{}", - out - ); -} - -#[tokio::test] -async fn compare_routines_procedure_with_config_params() { - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut routine = Routine::new( - "public".to_string(), - Oid(1), - "test_proc".to_string(), - "plpgsql".to_string(), - "PROCEDURE".to_string(), - "void".to_string(), - "IN pvalue text".to_string(), - None, - None, - "\nBEGIN\n RAISE NOTICE 'value: %', pvalue;\nEND;\n".to_string(), - ); - routine.config = vec![ - "search_path=public, pg_temp".to_string(), - "lock_timeout=5s".to_string(), - ]; - routine.hash(); - to_dump.routines.push(routine); - - let from_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("SET search_path = public, pg_temp"), - "script must contain SET search_path, got:\n{}", - script - ); - assert!( - script.contains("SET lock_timeout = '5s'"), - "script must contain SET lock_timeout, got:\n{}", - script - ); -} - -#[tokio::test] -async fn compare_routines_function_with_config_params() { - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut routine = Routine::new( - "public".to_string(), - Oid(1), - "my_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "\nBEGIN\n RETURN 1;\nEND;\n".to_string(), - ); - routine.config = vec!["work_mem=256MB".to_string()]; - routine.hash(); - to_dump.routines.push(routine); - - let from_dump = Dump::new(DumpConfig::default()); - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("SET work_mem = '256MB'"), - "script must contain SET work_mem, got:\n{}", - script - ); - assert!( - script.contains("VOLATILE"), - "function flags must still be present, got:\n{}", - script - ); -} - -#[tokio::test] -async fn compare_routines_config_change_triggers_update() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "public".to_string(), - Oid(1), - "test_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - from_routine.config = vec!["search_path=public".to_string()]; - from_routine.hash(); - - let mut to_routine = Routine::new( - "public".to_string(), - Oid(1), - "test_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - to_routine.config = vec![ - "search_path=public".to_string(), - "lock_timeout=5s".to_string(), - ]; - to_routine.hash(); - - assert_ne!( - from_routine.hash, to_routine.hash, - "hashes must differ when config changes" - ); - - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("create or replace function"), - "config change must trigger CREATE OR REPLACE, got:\n{}", - script - ); - assert!( - script.contains("SET search_path = public"), - "script must contain SET search_path, got:\n{}", - script - ); - assert!( - script.contains("SET lock_timeout = '5s'"), - "script must contain SET lock_timeout, got:\n{}", - script - ); -} - -#[tokio::test] -async fn compare_routines_config_removal_triggers_update() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "public".to_string(), - Oid(1), - "test_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - from_routine.config = vec!["search_path=public".to_string()]; - from_routine.hash(); - - let to_routine = Routine::new( - "public".to_string(), - Oid(1), - "test_func".to_string(), - "plpgsql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "".to_string(), - None, - None, - "BEGIN RETURN 1; END".to_string(), - ); - - assert_ne!( - from_routine.hash, to_routine.hash, - "hashes must differ when config is removed" - ); - - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_routines().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("create or replace function"), - "config removal must trigger CREATE OR REPLACE, got:\n{}", - script - ); - assert!( - !script.contains("SET search_path"), - "removed config must not appear in script, got:\n{}", - script - ); -} - -/// A table tracked in `recreated_tables` (e.g. due to partition key change) -/// must use the FROM default privilege ACL as its effective from_acl in full -/// grants mode, so that no spurious REVOKEs appear on repeated runs. -#[tokio::test] -async fn compare_grants_recreated_table_uses_default_acl() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Both FROM and TO have the same table with the same ACL. - let mut from_table = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec!["reader=r/owner".to_string()]; - - let mut to_table = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec!["reader=r/owner".to_string()]; - - // Default privilege that auto-grants SELECT to reader on new tables. - from_dump.default_privileges.push(DefaultPrivilege { - role_name: "owner".to_string(), - schema_name: "public".to_string(), - object_type: "r".to_string(), - acl: vec!["reader=r/owner".to_string()], - hash: None, - }); - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - // Simulate that the table was recreated (e.g. partition key change). - comparer - .recreated_tables - .insert(Comparer::table_key("public", "orders")); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - // The default privilege matches the TO ACL, so no GRANT or REVOKE needed. - let has_grant = script - .lines() - .any(|l| l.trim_start().to_lowercase().starts_with("grant ")); - let has_revoke = script - .lines() - .any(|l| l.trim_start().to_lowercase().starts_with("revoke ")); - assert!( - !has_grant && !has_revoke, - "Recreated table with matching default ACL must produce no GRANT/REVOKE, got: {script}" - ); -} - -/// A recreated table whose TO ACL differs from the default privilege ACL -/// must produce the correct GRANT to bridge the gap. -#[tokio::test] -async fn compare_grants_recreated_table_grants_extra_over_default() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_table = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec!["reader=r/owner".to_string(), "writer=rw/owner".to_string()]; - - let mut to_table = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec!["reader=r/owner".to_string(), "writer=rw/owner".to_string()]; - - // Default privilege only grants SELECT to reader (no writer grant). - from_dump.default_privileges.push(DefaultPrivilege { - role_name: "owner".to_string(), - schema_name: "public".to_string(), - object_type: "r".to_string(), - acl: vec!["reader=r/owner".to_string()], - hash: None, - }); - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer - .recreated_tables - .insert(Comparer::table_key("public", "orders")); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT SELECT, UPDATE ON TABLE public.orders TO writer;"), - "Must grant writer privileges beyond default ACL, got: {script}" - ); - // reader already has SELECT via default, so no GRANT for reader. - let reader_grant = script.lines().any(|l| l.contains("TO reader")); - assert!( - !reader_grant, - "reader grant already covered by default ACL, got: {script}" - ); -} - -/// A non-recreated table that exists in both FROM and TO must use the -/// original FROM ACL, not the default privilege ACL. -#[tokio::test] -async fn compare_grants_non_recreated_table_uses_from_acl() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_table = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - from_table.acl = vec!["reader=r/owner".to_string()]; - - let mut to_table = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "owner".to_string(), - None, - Vec::new(), - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_table.acl = vec!["reader=r/owner".to_string()]; - - // Even though default privilege differs, we must use FROM ACL. - from_dump.default_privileges.push(DefaultPrivilege { - role_name: "owner".to_string(), - schema_name: "public".to_string(), - object_type: "r".to_string(), - acl: vec![], - hash: None, - }); - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - // Do NOT insert into recreated_tables — table is not recreated. - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - // FROM and TO ACLs match, so no diff should be produced. - let has_grant = script - .lines() - .any(|l| l.trim_start().to_lowercase().starts_with("grant ")); - let has_revoke = script - .lines() - .any(|l| l.trim_start().to_lowercase().starts_with("revoke ")); - assert!( - !has_grant && !has_revoke, - "Non-recreated table with identical ACLs must produce no GRANT/REVOKE, got: {script}" - ); -} - -/// A dropped+recreated view (use_drop=true) in full grants mode must use -/// the default privilege ACL as the effective from_acl, matching the table -/// recreated-object logic. -#[tokio::test] -async fn compare_grants_dropped_view_uses_default_acl_full_mode() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - Vec::new(), - ); - from_view.acl = vec!["reader=r/owner".to_string()]; - - let mut to_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - Vec::new(), - ); - to_view.acl = vec!["reader=r/owner".to_string()]; - - // Default privilege auto-grants SELECT to reader on new tables/views. - from_dump.default_privileges.push(DefaultPrivilege { - role_name: "owner".to_string(), - schema_name: "public".to_string(), - object_type: "r".to_string(), - acl: vec!["reader=r/owner".to_string()], - hash: None, - }); - - from_dump.views.push(from_view); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - // Simulate that the view was actually dropped (use_drop=true). - comparer - .dropped_views - .insert(Comparer::normalized_view_key("public", "my_view"), true); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - // Default ACL matches TO ACL, so no diff needed. - let has_grant = script - .lines() - .any(|l| l.trim_start().to_lowercase().starts_with("grant ")); - let has_revoke = script - .lines() - .any(|l| l.trim_start().to_lowercase().starts_with("revoke ")); - assert!( - !has_grant && !has_revoke, - "Dropped view with matching default ACL must produce no GRANT/REVOKE, got: {script}" - ); -} - -/// A dropped view (use_drop=true) in full mode whose TO ACL has more -/// privileges than the default must produce GRANTs to bridge the gap. -#[tokio::test] -async fn compare_grants_dropped_view_grants_extra_over_default() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - Vec::new(), - ); - from_view.acl = vec!["reader=r/owner".to_string(), "writer=rw/owner".to_string()]; - - let mut to_view = View::new( - "my_view".to_string(), - "SELECT 1".to_string(), - "public".to_string(), - Vec::new(), - ); - to_view.acl = vec!["reader=r/owner".to_string(), "writer=rw/owner".to_string()]; - - // Default only gives reader SELECT. - from_dump.default_privileges.push(DefaultPrivilege { - role_name: "owner".to_string(), - schema_name: "public".to_string(), - object_type: "r".to_string(), - acl: vec!["reader=r/owner".to_string()], - hash: None, - }); - - from_dump.views.push(from_view); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer - .dropped_views - .insert(Comparer::normalized_view_key("public", "my_view"), true); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("GRANT SELECT, UPDATE ON TABLE public.my_view TO writer;"), - "Must grant writer privileges beyond default ACL for dropped view, got: {script}" - ); -} - -/// When table ownership changes between FROM and TO, column-level ACL -/// diffing must keep former-owner entries diffable while suppressing -/// current-owner implicit privilege entries. -#[tokio::test] -async fn compare_column_grants_revokes_former_owner_and_excludes_current_owner() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // FROM table owned by old_owner with column ACL for old_owner - let mut from_col = int_column("public", "users", "secret", 1); - from_col.acl = vec!["old_owner=r/old_owner".to_string()]; - let from_table = Table::new( - "public".to_string(), - "users".to_string(), - "public".to_string(), - "users".to_string(), - "old_owner".to_string(), - None, - vec![from_col], - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - - // TO table owned by new_owner with column ACL for new_owner - let mut to_col = int_column("public", "users", "secret", 1); - to_col.acl = vec!["new_owner=r/new_owner".to_string()]; - let to_table = Table::new( - "public".to_string(), - "users".to_string(), - "public".to_string(), - "users".to_string(), - "new_owner".to_string(), - None, - vec![to_col], - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("REVOKE SELECT (secret) ON TABLE public.users FROM old_owner;"), - "Former owner column ACL must remain diffable in full mode, got: {script}" - ); - assert!( - !script.contains("new_owner"), - "Current owner column ACL entries must be suppressed, got: {script}" - ); - assert!( - !script - .lines() - .any(|l| l.contains("secret") && l.trim_start().to_lowercase().starts_with("grant ")), - "Unexpected column GRANT for owner ACL entries, got: {script}" - ); -} - -/// Multirange types are auto-dropped when their associated range type is -/// dropped. The comparer must NOT emit a separate DROP for the multirange, -/// otherwise PostgreSQL rejects it ("cannot drop type … because type … -/// requires it"). -#[tokio::test] -async fn compare_types_multirange_not_dropped_independently() { - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - // Range type present only in FROM → will be dropped. - let mut range_type = make_domain_type("test_schema", "old_range", 600); - range_type.typtype = 'r' as i8; - range_type.range_subtype = Some("integer".to_string()); - - // Associated multirange type present only in FROM. - let mut mr_type = make_domain_type("test_schema", "old_multirange", 601); - mr_type.typtype = 'm' as i8; - - from_dump.types.push(range_type); - from_dump.types.push(mr_type); - - let mut comparer = Comparer::new(from_dump, to_dump, false, true, true, GrantsMode::Full); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("drop type if exists test_schema.old_range cascade;"), - "Range type must be dropped, got: {script}" - ); - let has_mr_drop = script.contains("drop type if exists test_schema.old_multirange"); - assert!( - !has_mr_drop, - "Multirange type must NOT be dropped independently, got: {script}" - ); -} - -/// Symmetric to the drop-side test above: multirange types are auto-CREATED -/// by PostgreSQL when the range type is created. The comparer must NOT emit -/// any per-multirange output in the main script — the `CREATE TYPE … AS -/// RANGE` for the range is enough. Previously the CREATE loop skipped only -/// enums, so a new range also produced a stray `-- Multirange …` comment -/// that made new-range diffs look noisy and was the leading explanation for -/// "the diff looks empty" reports on fresh schema_b dumps. -#[tokio::test] -async fn compare_types_multirange_not_created_independently() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // New range type only in TO. - let mut range_type = make_domain_type("test_schema", "int_range", 800); - range_type.typtype = 'r' as i8; - range_type.range_subtype = Some("integer".to_string()); - range_type.hash(); - - // Its auto-generated multirange, also only in TO. - let mut mr_type = make_domain_type("test_schema", "int_range_multirange", 801); - mr_type.typtype = 'm' as i8; - mr_type.hash(); - - to_dump.types.push(range_type); - to_dump.types.push(mr_type); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("create type test_schema.int_range as range"), - "Range type must be created, got: {script}" - ); - let has_mr_comment = script - .contains("Multirange type test_schema.int_range_multirange is created automatically"); - assert!( - !has_mr_comment, - "Multirange must not emit a stand-alone comment block, got: {script}" - ); - let has_mr_create = script.contains("create type test_schema.int_range_multirange"); - assert!( - !has_mr_create, - "Multirange must not be CREATED independently, got: {script}" - ); -} - -/// A multirange that exists in BOTH dumps but whose owner or comment has -/// changed must still emit an ALTER (COMMENT ON TYPE / ALTER TYPE OWNER). -/// Regression guard against over-broad `'m'` skipping: the skip lives in the -/// new-in-`to` branch only, so metadata drift on existing multiranges still -/// propagates via `get_alter_script`'s comment/owner diff tail. -#[tokio::test] -async fn compare_types_multirange_comment_change_still_emits_alter() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_mr = make_domain_type("test_schema", "my_range_multirange", 900); - from_mr.typtype = 'm' as i8; - from_mr.comment = None; - from_mr.hash(); - - let mut to_mr = make_domain_type("test_schema", "my_range_multirange", 900); - to_mr.typtype = 'm' as i8; - to_mr.comment = Some("updated description".to_string()); - to_mr.hash(); - - from_dump.types.push(from_mr); - to_dump.types.push(to_mr); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script - .contains("comment on type test_schema.my_range_multirange is 'updated description';"), - "Metadata ALTER on existing multirange must still emit, got: {script}" - ); -} - -// --- Post-buffer ordering (Comparer::compare concatenates several ordered -// script buffers: main → sequence_post → type_post → enum_post → -// trigger_post. These tests pin the emission order so dependency-aware -// rearrangements don't regress silently). --- - -#[tokio::test] -async fn buffer_ordering_type_drop_before_enum_drop() { - // Both buffers are populated when the FROM dump carries a domain type - // AND an enum that are both absent in the TO dump. type_post must come - // before enum_post in the final script (so enums outlive types that may - // reference them). - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - from_dump - .types - .push(make_domain_type("test_schema", "dropped_domain", 701)); - from_dump.types.push(make_enum_type( - "test_schema", - "dropped_enum", - 702, - vec!["a", "b"], - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let type_drop_pos = script - .find("drop type if exists test_schema.dropped_domain cascade;") - .unwrap_or_else(|| panic!("domain drop missing in:\n{script}")); - let enum_drop_pos = script - .find("drop type if exists test_schema.dropped_enum cascade;") - .unwrap_or_else(|| panic!("enum drop missing in:\n{script}")); - assert!( - type_drop_pos < enum_drop_pos, - "type_post_script must precede enum_post_script, got:\n{script}" - ); -} - -#[tokio::test] -async fn buffer_ordering_enum_drop_before_trigger_create() { - // FROM has an enum to drop (populates enum_post_script). - // TO has a brand-new table with a trigger (populates trigger_post_script - // for the CREATE TRIGGER). enum_post must come before trigger_post so - // that triggers referencing newly-created routines/types run after all - // type-dependency cleanup. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump.types.push(make_enum_type( - "test_schema", - "legacy_status", - 703, - vec!["ok", "err"], - )); - - let mut new_table = Table::new( - "public".to_string(), - "events".to_string(), - "public".to_string(), - "events".to_string(), - "postgres".to_string(), - None, - vec![int_column("public", "events", "id", 1)], - vec![], - vec![], - vec![TableTrigger { - oid: Oid(9999), - name: "trg_events_audit".to_string(), - definition: - "create trigger trg_events_audit before insert on public.events for each row execute function audit()" - .to_string(), - enabled: "O".to_string(), - comment: None, - }], - None, - ); - new_table.hash(); - to_dump.tables.push(new_table); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let enum_drop_pos = script - .find("drop type if exists test_schema.legacy_status cascade;") - .unwrap_or_else(|| panic!("enum drop missing in:\n{script}")); - let trigger_create_pos = script - .find("create trigger trg_events_audit") - .unwrap_or_else(|| panic!("CREATE TRIGGER missing in:\n{script}")); - - assert!( - enum_drop_pos < trigger_create_pos, - "enum_post_script must precede trigger_post_script, got:\n{script}" - ); -} - -#[tokio::test] -async fn buffer_ordering_sequence_drop_before_type_drop() { - // FROM has an unowned sequence and a domain type, both absent in TO. - // sequence_post_script is emitted before type_post_script so that - // sequences with default-value dependencies on types are dropped first. - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - let seq = crate::dump::sequence::Sequence::new( - "test_schema".to_string(), - "dropped_seq".to_string(), - "postgres".to_string(), - "bigint".to_string(), - Some(1), - Some(1), - Some(9223372036854775807), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - from_dump.sequences.push(seq); - from_dump - .types - .push(make_domain_type("test_schema", "dropped_domain", 704)); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let seq_drop_pos = script - .find("drop sequence if exists \"test_schema\".\"dropped_seq\"") - .or_else(|| script.find("drop sequence if exists test_schema.dropped_seq")) - .unwrap_or_else(|| panic!("sequence drop missing in:\n{script}")); - let type_drop_pos = script - .find("drop type if exists test_schema.dropped_domain cascade;") - .unwrap_or_else(|| panic!("type drop missing in:\n{script}")); - - assert!( - seq_drop_pos < type_drop_pos, - "sequence_post_script must precede type_post_script, got:\n{script}" - ); -} - -/// Regression for the dependency-scan needle bug. Dump fields are populated -/// via `quote_ident`, so a mixed-case identifier comes back literally -/// quoted (`"MyView"`). Previously the needle kept the quotes and the -/// quote-stripped haystack flavour could never match an unquoted reference, -/// silently dropping a real dependency. -#[test] -fn text_references_qualified_name_pre_matches_unquoted_reference() { - let (lower, unquoted_lower) = Comparer::prelower_pair("SELECT * FROM public.regular_view;"); - // Needle as built from `quote_ident` for a mixed-case identifier. - assert!(Comparer::text_references_qualified_name_pre( - &lower, - &unquoted_lower, - "\"public\"", - "\"regular_view\"", - )); -} - -#[test] -fn text_references_qualified_name_pre_still_matches_quoted_reference() { - let (lower, unquoted_lower) = Comparer::prelower_pair("SELECT * FROM \"MySchema\".\"MyView\";"); - assert!(Comparer::text_references_qualified_name_pre( - &lower, - &unquoted_lower, - "\"myschema\"", - "\"myview\"", - )); -} - -/// Counterpart to `compare_column_grants_revokes_former_owner_and_excludes_current_owner`: -/// when ownership changes and the new TO has *no* explicit column ACL at all -/// (only the implicit owner privileges), a former owner's column grant in -/// FROM must still be revoked under `full` mode. Without this we would leak -/// the old owner's column-level access into the post-migration database. -#[tokio::test] -async fn compare_column_grants_revokes_former_owner_when_to_has_no_column_acl() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_col = int_column("public", "users", "secret", 1); - from_col.acl = vec!["old_owner=r/old_owner".to_string()]; - let from_table = Table::new( - "public".to_string(), - "users".to_string(), - "public".to_string(), - "users".to_string(), - "old_owner".to_string(), - None, - vec![from_col], - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - - // No column ACL in TO. - let to_col = int_column("public", "users", "secret", 1); - let to_table = Table::new( - "public".to_string(), - "users".to_string(), - "public".to_string(), - "users".to_string(), - "new_owner".to_string(), - None, - vec![to_col], - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("REVOKE SELECT (secret) ON TABLE public.users FROM old_owner;"), - "Former owner column ACL must be revoked even without ACL in TO, got: {script}" - ); - assert!( - !script.contains("new_owner"), - "Current owner must never appear in column grant output, got: {script}" - ); -} - -/// Regression test for the per-table column-ACL HashMap rewrite. Previously -/// each TO column did a linear scan over `from_cols`; the rewrite indexes -/// `from_cols` by name once per table. This test exercises a table with -/// multiple columns where each column's effective `from_acl` differs, to -/// catch off-by-one mistakes that a single-column test would miss. -#[tokio::test] -async fn compare_column_grants_dispatches_per_column_acl_correctly() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // FROM: three columns with distinct ACL states. - let mut from_a = int_column("public", "t", "a", 1); - from_a.acl = vec!["reader=r/owner".to_string()]; - let mut from_b = int_column("public", "t", "b", 2); - from_b.acl = vec!["reader=r/owner".to_string()]; - let from_c = int_column("public", "t", "c", 3); // no ACL in FROM - - let from_table = Table::new( - "public".to_string(), - "t".to_string(), - "public".to_string(), - "t".to_string(), - "owner".to_string(), - None, - vec![from_a, from_b, from_c], - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - - // TO: a kept, b loses its grant, c gains a grant. - let mut to_a = int_column("public", "t", "a", 1); - to_a.acl = vec!["reader=r/owner".to_string()]; - let to_b = int_column("public", "t", "b", 2); // grant should be revoked - let mut to_c = int_column("public", "t", "c", 3); - to_c.acl = vec!["writer=a/owner".to_string()]; // INSERT grant added - - let to_table = Table::new( - "public".to_string(), - "t".to_string(), - "public".to_string(), - "t".to_string(), - "owner".to_string(), - None, - vec![to_a, to_b, to_c], - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); - comparer.compare_grants().await.unwrap(); - let script = comparer.get_script(); - - // a: identical → nothing emitted for column a. - assert!( - !script.contains("(a)"), - "column a is unchanged and must not appear, got: {script}" - ); - // b: REVOKE for the dropped grant. - assert!( - script.contains("REVOKE SELECT (b) ON TABLE public.t FROM reader;"), - "expected REVOKE for column b, got: {script}" - ); - // c: GRANT for the added INSERT privilege. - assert!( - script.contains("GRANT INSERT (c) ON TABLE public.t TO writer;"), - "expected GRANT INSERT on column c, got: {script}" - ); - // Sanity: no cross-talk where column b's REVOKE refers to writer/c, etc. - assert!( - !script.contains("REVOKE SELECT (c)"), - "column c had no FROM grant and must not be revoked, got: {script}" - ); - assert!( - !script.contains("GRANT INSERT (a)") && !script.contains("GRANT INSERT (b)"), - "INSERT grant must be scoped to column c only, got: {script}" - ); -} - -/// Regression test for the `serial_columns` key change from a joined -/// `"schema.table.column"` `String` to a `(String, String, String)` tuple. -/// The old form was parsed back via `splitn(3, '.')`, which silently -/// misparsed any identifier containing a literal `.` (legal in PostgreSQL -/// when quoted). With the tuple key, dotted identifiers round-trip cleanly -/// and `mark_serial_columns` still finds the target column. -#[tokio::test] -async fn mark_serial_columns_handles_dotted_identifier_names() { - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // Schema, table, and column names all contain a literal dot — the - // pre-fix `splitn(3, '.')` would slice these in the wrong place and - // fail to locate the column. - let schema = "weird.schema"; - let table = "weird.table"; - let column = "weird.id"; - - let serial_seq = Sequence::new( - schema.to_string(), - format!("{table}_{column}_seq"), - "postgres".to_string(), - "integer".to_string(), - Some(1), - Some(1), - Some(2147483647), - Some(1), - false, - Some(1), - Some(1), - Some(schema.to_string()), - Some(table.to_string()), - Some(column.to_string()), - ); - to_dump.sequences.push(serial_seq); - - let mut col = int_column(schema, table, column, 1); - col.column_default = Some(format!( - "nextval('{schema}.{table}_{column}_seq'::regclass)" - )); - col.is_nullable = false; - - let table_obj = Table::new( - schema.to_string(), - table.to_string(), - schema.to_string(), - table.to_string(), - "postgres".to_string(), - None, - vec![col], - Vec::new(), - Vec::new(), - Vec::new(), - None, - ); - to_dump.tables.push(table_obj); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - comparer.mark_serial_columns(); - - let to_table = comparer - .to - .tables - .iter() - .find(|t| t.schema == schema && t.name == table) - .expect("table must round-trip"); - let to_column = to_table - .columns - .iter() - .find(|c| c.name == column) - .expect("column must round-trip"); - assert_eq!( - to_column.serial_type.as_deref(), - Some("serial"), - "dotted-name column must still be marked as serial" - ); -} - -// ───────────────────────────────────────────────────────────────────── -// Issue #179 — DROP FUNCTION ... CASCADE silently drops dependent -// objects (functional indexes, CHECK constraints, generated columns, -// column DEFAULT expressions, RLS policies). Phase 7 of -// `compare_routines_and_views` re-emits them. -// ───────────────────────────────────────────────────────────────────── - -use crate::dump::table_index::TableIndex; -use crate::dump::table_policy::TablePolicy; - -/// Build a `Routine` mirroring `test_deps.compute(x integer)` from the -/// issue report, parameterised by return type so a single helper covers -/// both the FROM (integer) and TO (bigint) sides. -fn issue179_compute_routine(return_type: &str, body: &str) -> Routine { - let mut routine = Routine::new( - "test_deps".to_string(), - Oid(900), - "compute".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - return_type.to_string(), - "x integer".to_string(), - None, - None, - body.to_string(), - ); - routine.hash(); - routine -} - -/// Construct an `items` table that mirrors the issue's example: each -/// dependent (functional index, CHECK constraint, generated column, -/// column DEFAULT, RLS policy) references `test_deps.compute`. -fn issue179_items_table(value_type: &str, def_default: &str, gen_type: &str) -> Table { - let mut def_col = int_column("test_deps", "items", "def_col", 2); - def_col.data_type = value_type.to_string(); - def_col.column_default = Some(def_default.to_string()); - - let mut gen_col = int_column("test_deps", "items", "gen_col", 3); - gen_col.data_type = gen_type.to_string(); - gen_col.is_generated = "ALWAYS".to_string(); - gen_col.generation_expression = Some("test_deps.compute(value)".to_string()); - gen_col.generation_type = Some("s".to_string()); - - let mut value_col = int_column("test_deps", "items", "value", 1); - value_col.is_nullable = false; - - let chk_constraint = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_deps".to_string(), - name: "chk_compute".to_string(), - table_name: "items".to_string(), - constraint_type: "CHECK".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("CHECK (test_deps.compute(value) > 0)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let idx = TableIndex { - schema: "test_deps".to_string(), - table: "items".to_string(), - name: "idx_compute".to_string(), - catalog: Some("postgres".to_string()), - indexdef: - "CREATE INDEX idx_compute ON test_deps.items USING btree (test_deps.compute(value))" - .to_string(), - is_partition_index: false, - comment: None, - }; - - let policy = TablePolicy { - schema: "test_deps".to_string(), - table: "items".to_string(), - name: "p_items".to_string(), - command: "all".to_string(), - permissive: true, - roles: vec![], - using_clause: Some("(test_deps.compute(value) > 0)".to_string()), - check_clause: None, - }; - - let mut table = Table::new( - "test_deps".to_string(), - "items".to_string(), - "test_deps".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![value_col, def_col, gen_col], - vec![chk_constraint], - vec![idx], - vec![], - None, - ); - table.policies = vec![policy]; - table.has_rowsecurity = true; - table.hash(); - table -} - -#[tokio::test] -async fn issue179_signature_change_recreates_all_cascade_dependents() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - from_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)::integer", - "integer", - )); - to_dump.tables.push(issue179_items_table( - "bigint", - "test_deps.compute(0)", - "bigint", - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - let drop_pos = script - .find("drop function if exists test_deps.compute (x integer) cascade;") - .expect("CASCADE drop must be emitted for the signature change"); - let create_pos = script - .find("create or replace function test_deps.compute(x integer) returns bigint") - .expect("function recreate must be emitted"); - assert!(drop_pos < create_pos); - - // The recreate phase must run AFTER the function is recreated so the - // dependent objects can be created against the new function. - let chk_pos = script - .find("alter table test_deps.items add constraint chk_compute") - .expect("CHECK constraint must be re-added after CASCADE"); - assert!( - create_pos < chk_pos, - "CHECK must be re-added after function recreate" - ); - assert!( - script.contains("alter table test_deps.items drop constraint if exists chk_compute;"), - "drop-if-exists guard for CHECK constraint missing: {}", - script - ); - - let idx_pos = script - .find("CREATE INDEX IF NOT EXISTS idx_compute ON test_deps.items") - .expect("functional index must be re-created (CREATE INDEX IF NOT EXISTS) after CASCADE"); - assert!(create_pos < idx_pos); - // Index recreate is now non-destructive: no separate DROP INDEX is - // emitted (a false-positive match must not silently invalidate a - // surviving index — see Phase 7 / issue #179 review thread). - assert!( - !script.contains("drop index if exists test_deps.idx_compute;"), - "DROP INDEX must not be emitted; recreate uses CREATE INDEX IF NOT EXISTS" - ); - - // Generated column recreate is non-destructive: ADD COLUMN IF NOT - // EXISTS, no DROP COLUMN. A drop here would cascade to attached - // indexes / FKs / constraints that Phase 7 cannot restore. - assert!( - !script.contains("alter table test_deps.items drop column if exists gen_col"), - "DROP COLUMN must not be emitted for generated column recreate" - ); - assert!( - script.contains("alter table test_deps.items add column if not exists gen_col bigint generated always as (test_deps.compute(value)) stored;"), - "generated column must be re-added (IF NOT EXISTS) with TO type/expression" - ); - - // Column DEFAULT: column survives, only the default is gone. - assert!( - script.contains( - "alter table test_deps.items alter column def_col set default test_deps.compute(0);" - ), - "column default must be restored from TO" - ); - // We must NOT drop+re-add a non-generated column whose default was cascaded: - assert!( - !script.contains("drop column if exists def_col"), - "non-generated column must survive — only its DEFAULT clause was cascaded" - ); - - let policy_pos = script - .find("create policy p_items on test_deps.items") - .expect("policy must be re-created after CASCADE"); - assert!(create_pos < policy_pos); - assert!( - script.contains("drop policy if exists p_items on test_deps.items;"), - "drop-if-exists guard for policy missing" - ); -} - -#[tokio::test] -async fn issue179_recreate_skipped_when_routine_unchanged() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let routine = issue179_compute_routine("integer", "SELECT x * 2;"); - from_dump.routines.push(routine.clone()); - to_dump.routines.push(routine); - - from_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)", - "integer", - )); - to_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)", - "integer", - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("Recreate dependents dropped by CASCADE"), - "no CASCADE drop happened — recreate phase must stay silent: {}", - script - ); - assert!(!script.contains("alter table test_deps.items add constraint chk_compute")); - assert!(!script.contains("CREATE INDEX idx_compute")); - assert!(!script.contains("create policy p_items")); -} - -#[tokio::test] -async fn issue179_recreate_skipped_when_to_dependent_missing() { - // Function is dropped entirely. The dependent objects are also gone - // in TO (user removed both function and dependents). We must NOT - // resurrect the dependents — they're intentionally absent. - let mut from_dump = Dump::new(DumpConfig::default()); - let to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - from_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)", - "integer", - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("alter table test_deps.items add constraint chk_compute"), - "CHECK must not be resurrected when neither it nor the function exist in TO" - ); - assert!( - !script.contains("CREATE INDEX idx_compute"), - "index must not be resurrected when neither it nor the function exist in TO" - ); - assert!( - !script.contains("create policy p_items"), - "policy must not be resurrected when neither it nor the function exist in TO" - ); -} - -/// Variant of [`issue179_items_table`] that mirrors PostgreSQL's -/// deparser output when the function is reachable via `search_path`: -/// the dependent texts use *unqualified* `compute(value)` instead of -/// `test_deps.compute(value)`. The `pg_get_*` family routinely drops -/// the schema qualifier in this case. -fn issue179_items_table_unqualified(value_type: &str, def_default: &str, gen_type: &str) -> Table { - let mut def_col = int_column("test_deps", "items", "def_col", 2); - def_col.data_type = value_type.to_string(); - def_col.column_default = Some(def_default.to_string()); - - let mut gen_col = int_column("test_deps", "items", "gen_col", 3); - gen_col.data_type = gen_type.to_string(); - gen_col.is_generated = "ALWAYS".to_string(); - // Unqualified function call. - gen_col.generation_expression = Some("compute(value)".to_string()); - gen_col.generation_type = Some("s".to_string()); - - let mut value_col = int_column("test_deps", "items", "value", 1); - value_col.is_nullable = false; - - let chk_constraint = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_deps".to_string(), - name: "chk_compute".to_string(), - table_name: "items".to_string(), - constraint_type: "CHECK".to_string(), - is_deferrable: false, - initially_deferred: false, - // Unqualified function call. - definition: Some("CHECK (compute(value) > 0)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let idx = TableIndex { - schema: "test_deps".to_string(), - table: "items".to_string(), - name: "idx_compute".to_string(), - catalog: Some("postgres".to_string()), - // Unqualified function call. - indexdef: "CREATE INDEX idx_compute ON test_deps.items USING btree (compute(value))" - .to_string(), - is_partition_index: false, - comment: None, - }; - - let policy = TablePolicy { - schema: "test_deps".to_string(), - table: "items".to_string(), - name: "p_items".to_string(), - command: "all".to_string(), - permissive: true, - roles: vec![], - // Unqualified function call. - using_clause: Some("(compute(value) > 0)".to_string()), - check_clause: None, - }; - - let mut table = Table::new( - "test_deps".to_string(), - "items".to_string(), - "test_deps".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![value_col, def_col, gen_col], - vec![chk_constraint], - vec![idx], - vec![], - None, - ); - table.policies = vec![policy]; - table.has_rowsecurity = true; - table.hash(); - table -} - -#[tokio::test] -async fn issue179_unqualified_function_calls_are_detected() { - // PostgreSQL's pg_get_constraintdef / pg_get_indexdef / pg_get_expr - // drop the schema qualifier when the function is in search_path - // (the typical `public` case). Phase 7 must still recognise these - // dependents — otherwise the CASCADE-drop drift goes unfixed for - // anything the deparser deemed "in scope". - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - from_dump.tables.push(issue179_items_table_unqualified( - "integer", - "compute(0)::integer", - "integer", - )); - to_dump.tables.push(issue179_items_table_unqualified( - "bigint", - "compute(0)", - "bigint", - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("alter table test_deps.items add constraint chk_compute"), - "unqualified CHECK reference must trigger recreate: {}", - script - ); - assert!( - script.contains("CREATE INDEX IF NOT EXISTS idx_compute ON test_deps.items"), - "unqualified index reference must trigger recreate: {}", - script - ); - assert!( - script.contains( - "alter table test_deps.items add column if not exists gen_col bigint generated always as (compute(value)) stored;" - ), - "unqualified generated-column reference must trigger recreate: {}", - script - ); - assert!( - script.contains("alter table test_deps.items alter column def_col set default compute(0);"), - "unqualified column DEFAULT reference must trigger recreate: {}", - script - ); - assert!( - script.contains("create policy p_items on test_deps.items"), - "unqualified policy reference must trigger recreate: {}", - script - ); -} - -#[test] -fn issue179_unqualified_match_rejects_substrings_and_non_calls() { - // Direct unit test for the boundary rules: the unqualified matcher - // must require `name(` at an identifier boundary, not match - // partial-name suffixes, qualified `schema.name`, or non-call uses. - let mut affected: HashSet<(String, String)> = HashSet::new(); - affected.insert(("ignored_schema".to_string(), "compute".to_string())); - - // Bare function call — should match. - assert!(Comparer::definition_references_any( - "check (compute(value) > 0)", - &affected - )); - // Whitespace between name and `(` — still a call. - assert!(Comparer::definition_references_any( - "check (compute (value) > 0)", - &affected - )); - // Qualified — qualified matcher handles it via the schema, but we - // also exercise that the unqualified matcher's left-dot exclusion - // does not double-match `other_schema.compute(`. - assert!(!Comparer::definition_references_any( - "check (other_schema.compute(value) > 0)", - &affected - )); - // Substring — must NOT match. - assert!(!Comparer::definition_references_any( - "check (compute_v2(value) > 0)", - &affected - )); - assert!(!Comparer::definition_references_any( - "check (precompute(value) > 0)", - &affected - )); - // Identifier without trailing `(` — must NOT match. - assert!(!Comparer::definition_references_any( - "check (compute > 0)", - &affected - )); -} - -#[test] -fn issue179_unqualified_match_handles_non_ascii_identifiers() { - // PostgreSQL allows Unicode identifiers (quoted), and the dump's - // `quote_ident` machinery preserves them. After `prelower_pair` - // strips the surrounding quotes the haystack and needle both - // contain raw multi-byte UTF-8 — the previous implementation did - // `text[start..]` with `start = i + 1` and panicked on the next - // iteration because byte index `i + 1` lands inside a codepoint. - // Drive the matcher with a Cyrillic name and several haystacks to - // ensure: (a) it returns true for a real call, (b) it returns - // false for a non-call use without panicking, and (c) it returns - // false for a substring without panicking. - let mut affected: HashSet<(String, String)> = HashSet::new(); - affected.insert(("test_schema".to_string(), "функция".to_string())); - - assert!(Comparer::definition_references_any( - "check (функция(value) > 0)", - &affected - )); - assert!(!Comparer::definition_references_any( - "check (функция > 0)", - &affected - )); - // Repeated occurrences without a `(` — would have triggered the - // panic on the post-match `start = i + 1` advance. - assert!(!Comparer::definition_references_any( - "check (функция функция функция > 0)", - &affected - )); - // Substring (Cyrillic suffix) must not falsely match. - assert!(!Comparer::definition_references_any( - "check (функция_v2(value) > 0)", - &affected - )); -} - -#[test] -fn issue179_qualified_match_requires_call_context() { - // `pg_get_indexdef` emits `CREATE INDEX … ON schema.table …`, - // `pg_get_expr` emits `nextval('schema.seq'::regclass)`, etc. - // Without a call gate the qualified matcher would pick up those - // `schema.name` references whenever a routine happens to share its - // name with a table / view / sequence in the same schema, and - // Phase 7 would emit spurious recreates for unrelated objects. - let mut affected: HashSet<(String, String)> = HashSet::new(); - affected.insert(("test_schema".to_string(), "users".to_string())); - - // Match: real qualified function call. - assert!(Comparer::definition_references_any( - "check (test_schema.users(value) > 0)", - &affected, - )); - // Match with whitespace before `(`. - assert!(Comparer::definition_references_any( - "check (test_schema.users (value) > 0)", - &affected, - )); - - // No match: qualified reference is the table in a CREATE INDEX - // ON clause — not a function call. - assert!(!Comparer::definition_references_any( - "create index idx ON test_schema.users using btree (value)", - &affected, - )); - // No match: qualified reference inside a `nextval` regclass cast. - assert!(!Comparer::definition_references_any( - "nextval('test_schema.users'::regclass)", - &affected, - )); - // No match: identifier without a `(` after. - assert!(!Comparer::definition_references_any( - "check (test_schema.users > 0)", - &affected, - )); - // No match: qualified suffix of a longer name (boundary check). - assert!(!Comparer::definition_references_any( - "check (test_schema.users_v2(value) > 0)", - &affected, - )); -} - -#[tokio::test] -async fn issue179_to_side_gate_skips_dependents_no_longer_referencing_routine() { - // `compare_tables()` runs before `compare_routines_and_views()`, so - // dependents that have been rewritten to no longer reference the - // affected routine reach the CASCADE-drop step with the dependency - // already broken — PostgreSQL leaves them alone. Phase 7 must NOT - // re-emit recreates for those: doing so is at best wasteful and at - // worst destructive (a `DROP COLUMN IF EXISTS` for a generated - // column also drops every index / FK / constraint attached to the - // column, none of which Phase 7 restores). - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - // FROM table: every dependent references `test_deps.compute`. - from_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)", - "integer", - )); - - // TO table: dependents have been rewritten to NOT reference - // `test_deps.compute` anymore. After `compare_tables` runs they - // exist in this rewritten form, so the `DROP FUNCTION ... CASCADE` - // does not touch them. - let mut value_col = int_column("test_deps", "items", "value", 1); - value_col.is_nullable = false; - - let mut def_col = int_column("test_deps", "items", "def_col", 2); - def_col.data_type = "integer".to_string(); - def_col.column_default = Some("0".to_string()); // no longer references compute - - let mut gen_col = int_column("test_deps", "items", "gen_col", 3); - gen_col.data_type = "integer".to_string(); - gen_col.is_generated = "ALWAYS".to_string(); - gen_col.generation_expression = Some("(value * 3)".to_string()); // no compute() - gen_col.generation_type = Some("s".to_string()); - - let chk = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_deps".to_string(), - name: "chk_compute".to_string(), - table_name: "items".to_string(), - constraint_type: "CHECK".to_string(), - is_deferrable: false, - initially_deferred: false, - // No compute() here either — TO swapped it out. - definition: Some("CHECK (value > 0)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let idx = TableIndex { - schema: "test_deps".to_string(), - table: "items".to_string(), - name: "idx_compute".to_string(), - catalog: Some("postgres".to_string()), - // No compute() in the index expression either. - indexdef: "CREATE INDEX idx_compute ON test_deps.items USING btree (value)".to_string(), - is_partition_index: false, - comment: None, - }; - - let policy = TablePolicy { - schema: "test_deps".to_string(), - table: "items".to_string(), - name: "p_items".to_string(), - command: "all".to_string(), - permissive: true, - roles: vec![], - // No compute() in the policy clause either. - using_clause: Some("(value > 0)".to_string()), - check_clause: None, - }; - - let mut to_table = Table::new( - "test_deps".to_string(), - "items".to_string(), - "test_deps".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![value_col, def_col, gen_col], - vec![chk], - vec![idx], - vec![], - None, - ); - to_table.policies = vec![policy]; - to_table.has_rowsecurity = true; - to_table.hash(); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - // Function still gets the CASCADE drop (signature change still - // requires DROP+CREATE). - assert!( - script.contains("drop function if exists test_deps.compute (x integer) cascade;"), - "function drop must still be emitted: {}", - script - ); - - // CRITICAL: the destructive generated-column path must stay silent. - assert!( - !script.contains("alter table test_deps.items drop column if exists gen_col"), - "generated column must NOT be dropped+re-added when TO no longer references the routine — that would cascade-destroy attached indexes/FKs without restoring them: {}", - script - ); - assert!( - !script.contains("alter table test_deps.items add column gen_col"), - "generated column add must not be emitted when TO does not reference the routine: {}", - script - ); - - // Constraint, index, and policy recreates must also be skipped to - // avoid redundant work that would conflict with `compare_tables`. - assert!( - !script.contains("alter table test_deps.items add constraint chk_compute"), - "CHECK recreate must not fire when TO definition no longer references the routine: {}", - script - ); - assert!( - !script.contains("CREATE INDEX idx_compute ON test_deps.items"), - "index recreate must not fire when TO indexdef no longer references the routine: {}", - script - ); - assert!( - !script.contains("create policy p_items"), - "policy recreate must not fire when TO clauses no longer reference the routine: {}", - script - ); - - // Column DEFAULT must also be skipped (TO default is `0`, no - // function reference). - assert!( - !script.contains("alter table test_deps.items alter column def_col set default 0;"), - "column DEFAULT recreate must not fire when TO default no longer references the routine: {}", - script - ); -} - -#[tokio::test] -async fn issue179_to_side_gate_still_recreates_when_to_keeps_reference() { - // Sanity check on the gate: when TO *does* still reference the - // affected routine (e.g. the dependent definition is unchanged), - // Phase 7 must continue to emit recreates exactly as before. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - from_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)::integer", - "integer", - )); - to_dump.tables.push(issue179_items_table( - "bigint", - "test_deps.compute(0)", - "bigint", - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - // Each kind of dependent must be re-emitted because TO still - // references the routine. - assert!( - script.contains("alter table test_deps.items add constraint chk_compute"), - "CHECK constraint recreate expected when TO still references the routine: {}", - script - ); - assert!( - script.contains("CREATE INDEX IF NOT EXISTS idx_compute ON test_deps.items"), - "index recreate expected when TO still references the routine: {}", - script - ); - assert!( - script.contains("alter table test_deps.items add column if not exists gen_col"), - "generated column recreate expected when TO still references the routine: {}", - script - ); - assert!( - script.contains( - "alter table test_deps.items alter column def_col set default test_deps.compute(0);" - ), - "column DEFAULT recreate expected when TO still references the routine: {}", - script - ); - assert!( - script.contains("create policy p_items on test_deps.items"), - "policy recreate expected when TO still references the routine: {}", - script - ); -} - -#[tokio::test] -async fn issue179_overload_collision_does_not_destroy_unrelated_column() { - // Phase 7's `affected` set keys on `(schema, name)` and ignores the - // argument signature, because text-based reference matching cannot - // distinguish overloads (`compute(value)` in a CHECK or generation - // expression carries no type info). When `compute(integer)` is - // dropped+recreated and `compute(text)` is unchanged, a dependent - // referencing `compute(text_value)` will text-match the affected - // set even though CASCADE never touched it. The recreate paths - // must therefore be non-destructive — a `DROP COLUMN IF EXISTS` - // for a generated column would cascade through every index / FK / - // constraint attached to the column, none of which Phase 7 knows - // how to restore. This test pins the non-destructive contract. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // FROM: two overloads. `compute(integer)` will change return type - // (forces DROP+CREATE); `compute(text)` is unchanged. - let mut from_int = Routine::new( - "test_deps".to_string(), - Oid(900), - "compute".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "x integer".to_string(), - None, - None, - "SELECT x * 2;".to_string(), - ); - from_int.hash(); - let mut from_text = Routine::new( - "test_deps".to_string(), - Oid(901), - "compute".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "x text".to_string(), - None, - None, - "SELECT length(x);".to_string(), - ); - from_text.hash(); - from_dump.routines.push(from_int); - from_dump.routines.push(from_text.clone()); - - // TO: same overloads, but `compute(integer)` now returns BIGINT. - let mut to_int = Routine::new( - "test_deps".to_string(), - Oid(900), - "compute".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "bigint".to_string(), - "x integer".to_string(), - None, - None, - "SELECT (x * 2)::bigint;".to_string(), - ); - to_int.hash(); - to_dump.routines.push(to_int); - to_dump.routines.push(from_text); // text overload unchanged - - // Build a table whose generated column references `compute(text_value)` - // — i.e. the unchanged `compute(text)` overload. CASCADE never - // drops this column (the dropped function is `compute(integer)`), - // so Phase 7 must NOT emit a destructive recreate. - let make_table = || { - let mut text_col = int_column("test_deps", "items", "text_value", 1); - text_col.data_type = "text".to_string(); - text_col.is_nullable = false; - let mut gen_col = int_column("test_deps", "items", "gen_col", 2); - gen_col.is_generated = "ALWAYS".to_string(); - gen_col.generation_expression = Some("test_deps.compute(text_value)".to_string()); - gen_col.generation_type = Some("s".to_string()); - let mut table = Table::new( - "test_deps".to_string(), - "items".to_string(), - "test_deps".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![text_col, gen_col], - vec![], - vec![], - vec![], - None, - ); - table.hash(); - table - }; - from_dump.tables.push(make_table()); - to_dump.tables.push(make_table()); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - // The integer overload is still cascaded. - assert!( - script.contains("drop function if exists test_deps.compute (x integer) cascade;"), - "integer overload must still be DROP+CREATEd: {}", - script - ); - - // CRITICAL: Phase 7 must NOT emit a DROP COLUMN. The text matcher - // false-positives on the affected set (which collapses overloads - // by name), so without IF NOT EXISTS the unconditional drop would - // destroy the unrelated column. Pinning this prevents regression. - assert!( - !script.contains("drop column if exists gen_col"), - "overload collision must not emit DROP COLUMN — would cascade-destroy attached indexes/FKs: {}", - script - ); - // The recreate that *is* emitted must use IF NOT EXISTS so the - // surviving column is left intact when the script runs. - if let Some(add_idx) = script.find("alter table test_deps.items add column") { - let snippet = &script[add_idx..(add_idx + 80).min(script.len())]; - assert!( - snippet.contains("if not exists"), - "ADD COLUMN must use IF NOT EXISTS to be non-destructive on overload false-positives: {}", - snippet - ); - } -} - -#[tokio::test] -async fn issue179_quoted_routine_name_recreates_dependents() { - // The dump query wraps `proname` with `quote_ident`, so a - // mixed-case routine like `MyFunc` arrives as `"MyFunc"`. The - // unqualified-call matcher operates on the quote-stripped haystack, - // so the affected-routine name must also be quote-stripped or - // dependents like `CHECK ("MyFunc"(value) > 0)` are missed. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "\"MySchema\"".to_string(), - Oid(950), - "\"MyFunc\"".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "x integer".to_string(), - None, - None, - "SELECT x * 2;".to_string(), - ); - from_routine.hash(); - from_dump.routines.push(from_routine); - - let mut to_routine = Routine::new( - "\"MySchema\"".to_string(), - Oid(950), - "\"MyFunc\"".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "bigint".to_string(), - "x integer".to_string(), - None, - None, - "SELECT (x * 2)::bigint;".to_string(), - ); - to_routine.hash(); - to_dump.routines.push(to_routine); - - let mut value_col = int_column("public", "items", "value", 1); - value_col.is_nullable = false; - - let chk = TableConstraint { - catalog: "postgres".to_string(), - schema: "public".to_string(), - name: "chk_my".to_string(), - table_name: "items".to_string(), - constraint_type: "CHECK".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("CHECK (\"MyFunc\"(value) > 0)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let mut from_table = Table::new( - "public".to_string(), - "items".to_string(), - "public".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![value_col.clone()], - vec![chk.clone()], - vec![], - vec![], - None, - ); - from_table.hash(); - let mut to_table = Table::new( - "public".to_string(), - "items".to_string(), - "public".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![value_col], - vec![chk], - vec![], - vec![], - None, - ); - to_table.hash(); - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("alter table public.items add constraint chk_my"), - "quoted-name function reference must trigger CHECK recreate: {}", - script - ); -} - -#[tokio::test] -async fn issue179_defaults_only_change_uses_create_or_replace_no_cascade() { - // PR #187 review (C10/C15): defaults-only changes must NOT - // trigger `DROP FUNCTION ... CASCADE`. PostgreSQL accepts - // default-argument changes via `CREATE OR REPLACE FUNCTION` - // when the identity argument types and return type are - // unchanged. The earlier version of this test pinned the - // destructive behaviour (DROP CASCADE + Phase 7 dependent - // recreates); the correct expectation is the non-destructive - // OR REPLACE form, with no CASCADE drop and no dependent - // recreates (since the function was never actually dropped). - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "test_deps".to_string(), - Oid(951), - "compute".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "x integer".to_string(), - Some("DEFAULT 0".to_string()), - None, - "SELECT x * 2;".to_string(), - ); - from_routine.hash(); - from_dump.routines.push(from_routine); - - let mut to_routine = Routine::new( - "test_deps".to_string(), - Oid(951), - "compute".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "x integer".to_string(), - Some("DEFAULT 1".to_string()), - None, - "SELECT x * 2;".to_string(), - ); - to_routine.hash(); - to_dump.routines.push(to_routine); - - from_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)", - "integer", - )); - to_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)", - "integer", - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("create or replace function test_deps.compute(x integer DEFAULT 1)"), - "defaults-only change must be re-emitted via CREATE OR REPLACE: {}", - script - ); - assert!( - !script.contains("drop function if exists test_deps.compute"), - "defaults-only change must NOT emit DROP FUNCTION CASCADE: {}", - script - ); - assert!( - !script.contains("alter table test_deps.items add constraint chk_compute"), - "dependents must NOT be recreated when the function was not actually dropped: {}", - script - ); -} - -/// Build a partition child of `parent_table` whose dependents (CHECK -/// constraint with `coninhcount=1`, `is_partition_index=true` index, a -/// generated column, and a CHECK with `coninhcount=0` so we can prove -/// the truly-local case is still emitted) all reference -/// `test_deps.compute`. Used by the partition-child guard tests. -fn issue179_items_partition_child(parent_qualified: &str) -> Table { - let mut value_col = int_column("test_deps", "items_2026", "value", 1); - value_col.is_nullable = false; - - let mut gen_col = int_column("test_deps", "items_2026", "gen_col", 2); - gen_col.is_generated = "ALWAYS".to_string(); - gen_col.generation_expression = Some("test_deps.compute(value)".to_string()); - gen_col.generation_type = Some("s".to_string()); - - let inherited_check = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_deps".to_string(), - name: "chk_compute".to_string(), - table_name: "items_2026".to_string(), - constraint_type: "CHECK".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("CHECK (test_deps.compute(value) > 0)".to_string()), - coninhcount: 1, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - let mut local_check = inherited_check.clone(); - local_check.name = "chk_compute_local".to_string(); - local_check.coninhcount = 0; - - let inherited_idx = TableIndex { - schema: "test_deps".to_string(), - table: "items_2026".to_string(), - name: "idx_compute".to_string(), - catalog: Some("postgres".to_string()), - indexdef: "CREATE INDEX idx_compute ON test_deps.items_2026 USING btree (test_deps.compute(value))".to_string(), - is_partition_index: true, - comment: None, - }; - - let mut table = Table::new( - "test_deps".to_string(), - "items_2026".to_string(), - "test_deps".to_string(), - "items_2026".to_string(), - "postgres".to_string(), - None, - vec![value_col, gen_col], - vec![inherited_check, local_check], - vec![inherited_idx], - vec![], - None, - ); - table.partition_of = Some(parent_qualified.to_string()); - table.partition_bound = Some("FOR VALUES IN (1)".to_string()); - table.hash(); - table -} - -#[tokio::test] -async fn issue179_partition_child_skips_inherited_dependents() { - // FROM and TO each contain a partitioned parent + one partition - // child. The function signature changes, so CASCADE drops the - // parent-side dependents. Phase 7 must NOT emit recreates for the - // child's inherited objects (PostgreSQL forbids `ALTER TABLE child` - // on inherited columns/constraints/indexes), but it MUST still - // emit the truly-local CHECK constraint (`coninhcount = 0`). - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - // Parent (partitioned) carries the dependent definitions on its own - // row — its recreate handles the propagation to children. - let mut parent_from = - issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); - parent_from.name = "items".to_string(); - parent_from.raw_name = "items".to_string(); - parent_from.partition_key = Some("LIST (value)".to_string()); - parent_from.hash(); - - let mut parent_to = issue179_items_table("bigint", "test_deps.compute(0)", "bigint"); - parent_to.name = "items".to_string(); - parent_to.raw_name = "items".to_string(); - parent_to.partition_key = Some("LIST (value)".to_string()); - parent_to.hash(); - - from_dump.tables.push(parent_from); - to_dump.tables.push(parent_to); - - from_dump - .tables - .push(issue179_items_partition_child("test_deps.items")); - to_dump - .tables - .push(issue179_items_partition_child("test_deps.items")); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - // Parent dependents (which Table::diff would diff normally) MUST - // be recreated against the parent. - assert!( - script.contains("alter table test_deps.items add constraint chk_compute"), - "parent CHECK must be re-added: {}", - script - ); - assert!( - script.contains("CREATE INDEX IF NOT EXISTS idx_compute ON test_deps.items "), - "parent index must be re-created: {}", - script - ); - - // Inherited child constraint (coninhcount > 0) must NOT be re-added - // on the child — PostgreSQL would reject `ALTER TABLE child ADD - // CONSTRAINT` for an inherited constraint, and the parent's recreate - // already propagates. (Use exact-suffix match so we don't accidentally - // match `chk_compute_local` below.) - assert!( - !script.contains("alter table test_deps.items_2026 drop constraint if exists chk_compute;"), - "inherited child CHECK must not be re-emitted: {}", - script - ); - assert!( - !script.contains("alter table test_deps.items_2026 add constraint chk_compute check"), - "inherited child CHECK must not be re-added: {}", - script - ); - - // Truly-local child constraint (coninhcount == 0) IS re-emitted. - assert!( - script.contains( - "alter table test_deps.items_2026 drop constraint if exists chk_compute_local;" - ), - "local child CHECK must be re-emitted: {}", - script - ); - assert!( - script.contains("alter table test_deps.items_2026 add constraint chk_compute_local"), - "local child CHECK must be re-added: {}", - script - ); - - // Partition-inherited index: must not be re-emitted on the child. - // Match by the load-bearing fragment so the assertion holds whether - // the recreate uses `CREATE INDEX` or `CREATE INDEX IF NOT EXISTS`. - assert!( - !script.contains("idx_compute ON test_deps.items_2026"), - "partition-inherited index must not be re-emitted on child: {}", - script - ); - - // Partition child's generated column must NOT be added — PostgreSQL - // forbids modifying inherited columns directly on a partition. - // (Recreate paths never emit DROP COLUMN now; the add assertion - // below is the load-bearing one for partition-child safety.) - assert!( - !script.contains("alter table test_deps.items_2026 add column if not exists gen_col"), - "partition child column must not be re-added: {}", - script - ); -} - -#[tokio::test] -async fn issue179_full_drop_recreates_dependents_when_overload_survives() { - // PR #187 review (C16): the previous version of this test built - // an invalid PostgreSQL state — TO had dependents referencing - // `test_deps.compute` but no function with that name at all, so - // the recreate SQL would fail to apply. The valid scenario where - // "function fully dropped, dependents kept" is meaningful is when - // a *different overload* of the same name survives in TO and the - // dependents resolve to it via PostgreSQL's name-based function - // binding. Set that up explicitly here. FROM has both - // `compute(integer)` (which gets dropped) and `compute(text)` - // (the surviving overload). TO has only `compute(text)`. - // Dependents in both sides reference `test_deps.compute` and - // resolve via overload resolution. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - let mut compute_text_from = Routine::new( - "test_deps".to_string(), - Oid(961), - "compute".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "x text".to_string(), - None, - None, - "SELECT length(x);".to_string(), - ); - compute_text_from.hash(); - from_dump.routines.push(compute_text_from.clone()); - // TO keeps only the text overload — `compute(integer)` is gone. - to_dump.routines.push(compute_text_from); - - from_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)", - "integer", - )); - to_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)", - "integer", - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("drop function if exists test_deps.compute (x integer) cascade;"), - "the integer overload must be dropped" - ); - assert!( - script.contains("alter table test_deps.items add constraint chk_compute"), - "CHECK present in TO must be re-added after CASCADE (overload-resolves to surviving compute)" - ); - assert!( - script.contains("CREATE INDEX IF NOT EXISTS idx_compute"), - "functional index present in TO must be re-created (IF NOT EXISTS) after CASCADE" - ); - assert!( - script.contains("create policy p_items on test_deps.items"), - "policy present in TO must be re-created after CASCADE" - ); -} - -// ───────────────────────────────────────────────────────────────────── -// Issue #180 — SET UNLOGGED / SET LOGGED statements must respect FK -// dependencies (PostgreSQL rejects an out-of-order conversion), and -// owned sequences should not redundantly re-emit the persistence flip -// the table cascade already propagates. -// ───────────────────────────────────────────────────────────────────── - -/// Build a logged/unlogged-controlled table with a single FK to another -/// table in the same schema, named with a numeric `id` PK column. Used -/// by the issue-#180 ordering tests. -fn issue180_logged_table( - schema: &str, - name: &str, - is_unlogged: bool, - fk_target: Option<(&str, &str, &str)>, -) -> Table { - let mut id_col = int_column(schema, name, "id", 1); - id_col.is_nullable = false; - - let mut constraints: Vec = vec![TableConstraint { - catalog: "postgres".to_string(), - schema: schema.to_string(), - name: format!("{name}_pkey"), - table_name: name.to_string(), - constraint_type: "PRIMARY KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("PRIMARY KEY (id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }]; - - let mut columns = vec![id_col]; - if let Some((fk_col, fk_schema, fk_table)) = fk_target { - let mut ref_col = int_column(schema, name, fk_col, 2); - ref_col.is_nullable = true; - columns.push(ref_col); - constraints.push(TableConstraint { - catalog: "postgres".to_string(), - schema: schema.to_string(), - name: format!("{name}_{fk_col}_fkey"), - table_name: name.to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some(format!( - "FOREIGN KEY ({fk_col}) REFERENCES {fk_schema}.{fk_table}(id)" - )), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }); - } - - let mut table = Table::new( - schema.to_string(), - name.to_string(), - schema.to_string(), - name.to_string(), - "postgres".to_string(), - None, - columns, - constraints, - vec![], - vec![], - None, - ); - table.is_unlogged = is_unlogged; - table.hash(); - table -} - -#[tokio::test] -async fn issue180_set_unlogged_orders_dependents_before_referenced() { - // FROM: three logged tables with FK chain - // child -> parent -> grandparent. - // TO: the same three tables, all UNLOGGED. - // PostgreSQL refuses `SET UNLOGGED` on a table while a LOGGED table - // still references it, so the conversion order must be leaves - // first: child, then parent, then grandparent. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump.tables.push(issue180_logged_table( - "test_order", - "grandparent", - false, - None, - )); - from_dump.tables.push(issue180_logged_table( - "test_order", - "parent", - false, - Some(("grandparent_id", "test_order", "grandparent")), - )); - from_dump.tables.push(issue180_logged_table( - "test_order", - "child", - false, - Some(("parent_id", "test_order", "parent")), - )); - to_dump.tables.push(issue180_logged_table( - "test_order", - "grandparent", - true, - None, - )); - to_dump.tables.push(issue180_logged_table( - "test_order", - "parent", - true, - Some(("grandparent_id", "test_order", "grandparent")), - )); - to_dump.tables.push(issue180_logged_table( - "test_order", - "child", - true, - Some(("parent_id", "test_order", "parent")), - )); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - let pos_child = script - .find("alter table test_order.child set unlogged;") - .expect("child SET UNLOGGED must be emitted"); - let pos_parent = script - .find("alter table test_order.parent set unlogged;") - .expect("parent SET UNLOGGED must be emitted"); - let pos_grand = script - .find("alter table test_order.grandparent set unlogged;") - .expect("grandparent SET UNLOGGED must be emitted"); - - assert!( - pos_child < pos_parent && pos_parent < pos_grand, - "SET UNLOGGED must be ordered child -> parent -> grandparent (FK leaves first); got\n{}", - script - ); -} - -#[tokio::test] -async fn issue180_set_logged_orders_referenced_before_dependents() { - // Reverse direction: all UNLOGGED -> all LOGGED. - // PostgreSQL refuses `SET LOGGED` while the table still references - // an UNLOGGED one, so order must be roots first: grandparent, then - // parent, then child. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump.tables.push(issue180_logged_table( - "test_order", - "grandparent", - true, - None, - )); - from_dump.tables.push(issue180_logged_table( - "test_order", - "parent", - true, - Some(("grandparent_id", "test_order", "grandparent")), - )); - from_dump.tables.push(issue180_logged_table( - "test_order", - "child", - true, - Some(("parent_id", "test_order", "parent")), - )); - to_dump.tables.push(issue180_logged_table( - "test_order", - "grandparent", - false, - None, - )); - to_dump.tables.push(issue180_logged_table( - "test_order", - "parent", - false, - Some(("grandparent_id", "test_order", "grandparent")), - )); - to_dump.tables.push(issue180_logged_table( - "test_order", - "child", - false, - Some(("parent_id", "test_order", "parent")), - )); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - let pos_grand = script - .find("alter table test_order.grandparent set logged;") - .expect("grandparent SET LOGGED must be emitted"); - let pos_parent = script - .find("alter table test_order.parent set logged;") - .expect("parent SET LOGGED must be emitted"); - let pos_child = script - .find("alter table test_order.child set logged;") - .expect("child SET LOGGED must be emitted"); - - assert!( - pos_grand < pos_parent && pos_parent < pos_child, - "SET LOGGED must be ordered grandparent -> parent -> child (FK roots first); got\n{}", - script - ); -} - -#[tokio::test] -async fn issue180_persistence_change_does_not_emit_inline_inside_alter_table() { - // A table-level ALTER (e.g. add column) MUST NOT carry a SET - // UNLOGGED line — that would re-introduce the alphabetical ordering - // bug. The persistence flip is owned by the dedicated phase. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_table = Table::new( - "test_order".to_string(), - "items".to_string(), - "test_order".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![int_column("test_order", "items", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - from_table.is_unlogged = false; - from_table.hash(); - - let mut to_table = Table::new( - "test_order".to_string(), - "items".to_string(), - "test_order".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![ - int_column("test_order", "items", "id", 1), - int_column("test_order", "items", "name", 2), - ], - vec![], - vec![], - vec![], - None, - ); - to_table.is_unlogged = true; - to_table.hash(); - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - // SET UNLOGGED is still emitted, but only once and after the ADD - // COLUMN — not interleaved inside the per-table ALTER block. - let add_pos = script - .find("alter table test_order.items add column name") - .expect("add column must be emitted"); - let set_pos = script - .find("alter table test_order.items set unlogged;") - .expect("set unlogged must be emitted by the dedicated phase"); - assert!( - add_pos < set_pos, - "SET UNLOGGED must come from the dedicated phase, after the per-table ALTER: {}", - script - ); - assert_eq!( - script.matches("set unlogged").count(), - 1, - "SET UNLOGGED must be emitted exactly once (no inline + dedicated double-up): {}", - script - ); -} - -#[tokio::test] -async fn issue180_owned_sequence_persistence_only_diff_is_skipped() { - // A sequence whose owning table flips persistence — and which has - // no other diff — produces a redundant `ALTER SEQUENCE ... SET - // UNLOGGED` followed by the full clause list. Both are noise: the - // table's `ALTER TABLE ... SET UNLOGGED` already cascades to all - // owned sequences. Suppress the entire ALTER SEQUENCE. - use crate::dump::sequence::Sequence; - - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_table = Table::new( - "test_order".to_string(), - "items".to_string(), - "test_order".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![int_column("test_order", "items", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - from_table.is_unlogged = false; - from_table.hash(); - - let mut to_table = from_table.clone(); - to_table.is_unlogged = true; - to_table.hash(); - - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let make_seq = |is_unlogged: bool| { - let mut s = Sequence::new( - "test_order".to_string(), - "items_id_seq".to_string(), - "postgres".to_string(), - "integer".to_string(), - Some(1), - Some(1), - Some(2147483647), - Some(1), - false, - Some(1), - Some(1), - Some("test_order".to_string()), - Some("items".to_string()), - Some("id".to_string()), - ); - s.is_unlogged = is_unlogged; - s.hash(); - s - }; - from_dump.sequences.push(make_seq(false)); - to_dump.sequences.push(make_seq(true)); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("alter sequence test_order.items_id_seq"), - "owned-sequence persistence-only flip must be suppressed (table cascade handles it); got:\n{}", - script - ); -} - -#[tokio::test] -async fn issue180_owned_sequence_other_diff_skips_only_persistence_line() { - // When the sequence has a real change (e.g. cache_size) AND the - // owning table is also flipping persistence, we still need the - // ALTER SEQUENCE — but not the `SET UNLOGGED|LOGGED` line, because - // the table cascade handles that. - use crate::dump::sequence::Sequence; - - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_table = Table::new( - "test_order".to_string(), - "items".to_string(), - "test_order".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![int_column("test_order", "items", "id", 1)], - vec![], - vec![], - vec![], - None, - ); - from_table.is_unlogged = false; - from_table.hash(); - let mut to_table = from_table.clone(); - to_table.is_unlogged = true; - to_table.hash(); - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let make_seq = |is_unlogged: bool, cache: i64| { - let mut s = Sequence::new( - "test_order".to_string(), - "items_id_seq".to_string(), - "postgres".to_string(), - "integer".to_string(), - Some(1), - Some(1), - Some(2147483647), - Some(1), - false, - Some(cache), - Some(1), - Some("test_order".to_string()), - Some("items".to_string()), - Some("id".to_string()), - ); - s.is_unlogged = is_unlogged; - s.hash(); - s - }; - from_dump.sequences.push(make_seq(false, 1)); - to_dump.sequences.push(make_seq(true, 5)); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("alter sequence test_order.items_id_seq"), - "ALTER SEQUENCE must be emitted when non-persistence params changed: {}", - script - ); - assert!( - script.contains("cache 5"), - "the changed cache value must be in the script: {}", - script - ); - assert!( - !script.contains("alter sequence test_order.items_id_seq set unlogged"), - "SET UNLOGGED on owned sequence is redundant when the owning table is flipping persistence: {}", - script - ); -} - -#[tokio::test] -async fn issue180_standalone_sequence_persistence_change_still_emits_set() { - // A sequence not owned by any table (or owned by a table whose - // persistence is unchanged) must still get its own SET because no - // table cascade applies. - use crate::dump::sequence::Sequence; - - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let make_seq = |is_unlogged: bool| { - let mut s = Sequence::new( - "test_order".to_string(), - "global_seq".to_string(), - "postgres".to_string(), - "integer".to_string(), - Some(1), - Some(1), - Some(2147483647), - Some(1), - false, - Some(1), - Some(1), - None, - None, - None, - ); - s.is_unlogged = is_unlogged; - s.hash(); - s - }; - from_dump.sequences.push(make_seq(false)); - to_dump.sequences.push(make_seq(true)); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_sequences().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("alter sequence test_order.global_seq set unlogged"), - "standalone sequence must still emit SET UNLOGGED: {}", - script - ); -} - -#[tokio::test] -async fn issue179_quoted_routine_names_match_unqualified_calls() { - // PGC's dump query wraps `nspname` / `proname` with `quote_ident`, - // so a function named `MyFunc` lands here as `Routine.name = "MyFunc"`. - // Phase 7 must strip those quotes when building its affected set — - // otherwise the unqualified matcher (which scans quote-stripped - // text) never lines up with the deparsed `myfunc(` in dependents. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "\"TestDeps\"".to_string(), - Oid(900), - "\"MyFunc\"".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "x integer".to_string(), - None, - None, - "SELECT x * 2;".to_string(), - ); - from_routine.hash(); - let mut to_routine = from_routine.clone(); - to_routine.return_type = "bigint".to_string(); - to_routine.source_code = "SELECT (x * 2)::bigint;".to_string(); - to_routine.hash(); - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - // Dependent uses the deparsed unqualified form `"MyFunc"(value)` - // (PostgreSQL preserves the case-sensitive identifier with quotes - // but drops the schema qualifier when the function is in - // `search_path`). - let chk = TableConstraint { - catalog: "postgres".to_string(), - schema: "public".to_string(), - name: "chk_myfunc".to_string(), - table_name: "items".to_string(), - constraint_type: "CHECK".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("CHECK (\"MyFunc\"(value) > 0)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let mut value_col = int_column("public", "items", "value", 1); - value_col.is_nullable = false; - - let mut from_table = Table::new( - "public".to_string(), - "items".to_string(), - "public".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![value_col.clone()], - vec![chk.clone()], - vec![], - vec![], - None, - ); - from_table.hash(); - let mut to_table = Table::new( - "public".to_string(), - "items".to_string(), - "public".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![value_col], - vec![chk], - vec![], - vec![], - None, - ); - to_table.hash(); - from_dump.tables.push(from_table); - to_dump.tables.push(to_table); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("alter table public.items add constraint chk_myfunc"), - "quoted routine name must still match its unqualified deparsed dependent: {}", - script - ); -} - -#[tokio::test] -async fn issue179_defaults_only_change_emits_create_or_replace_no_cascade() { - // PR #187 review (C10/C15): PostgreSQL accepts default-argument - // changes via `CREATE OR REPLACE FUNCTION` for the same identity - // signature/return type — there is no DROP+CREATE requirement. - // The `arguments_defaults` field is included in `Routine::hash()` - // so the diff is *detected*; the migration is then emitted as a - // plain `CREATE OR REPLACE` form (no CASCADE drop, no Phase 7 - // dependent recreates). - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let mut from_routine = Routine::new( - "test_deps".to_string(), - Oid(900), - "compute".to_string(), - "sql".to_string(), - "FUNCTION".to_string(), - "integer".to_string(), - "x integer".to_string(), - Some("0".to_string()), - None, - "SELECT x * 2;".to_string(), - ); - from_routine.hash(); - let mut to_routine = from_routine.clone(); - // ONLY the default value changes — every other field is identical. - to_routine.arguments_defaults = Some("1".to_string()); - to_routine.hash(); - from_dump.routines.push(from_routine); - to_dump.routines.push(to_routine); - - let table = issue179_items_table("integer", "test_deps.compute(0)", "integer"); - from_dump.tables.push(table.clone()); - to_dump.tables.push(table); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("create or replace function test_deps.compute(x integer DEFAULT 1)"), - "defaults-only change must use CREATE OR REPLACE FUNCTION: {}", - script - ); - assert!( - !script.contains("drop function if exists test_deps.compute"), - "defaults-only change must NOT emit DROP FUNCTION CASCADE: {}", - script - ); - assert!( - !script.contains("alter table test_deps.items add constraint chk_compute"), - "Phase 7 must NOT fire on defaults-only change (no CASCADE happened): {}", - script - ); - assert!( - !script.contains("CREATE INDEX IF NOT EXISTS idx_compute ON test_deps.items"), - "no index recreate on defaults-only change: {}", - script - ); -} - -#[test] -fn issue180_parse_fk_referenced_table_word_boundary() { - // PR #184 review: a naive `find("references ")` substring match - // can pick up the literal text inside a quoted column name in the - // FK column list. The matcher must be anchored to a word boundary - // and the keyword must be followed by whitespace. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (col_a) REFERENCES public.target(id)", - "public", - ), - Some(("public".to_string(), "target".to_string())), - "happy-path FK definition must parse" - ); - // Column literally named `"references "` (with trailing space) in - // the FK column list. Naive substring search would lock onto it - // before the real keyword and parse garbage. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (\"references \", col_b) REFERENCES public.target(id)", - "public", - ), - Some(("public".to_string(), "target".to_string())) - ); - // Column named `references_count` — substring match on - // "references" without the right-side word-boundary check would - // see this column first and try to parse what follows. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (references_count) REFERENCES public.target(id)", - "public", - ), - Some(("public".to_string(), "target".to_string())) - ); -} - -#[test] -fn issue180_parse_fk_referenced_table_quoted_identifier_with_dot() { - // PR #184 review: a quoted identifier may contain a literal `.`, - // and the schema/name split must respect quotes — otherwise the - // first dot inside the quoted segment is taken as the boundary - // and the parsed pair is nonsensical. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (col) REFERENCES \"weird.schema\".\"t\"(id)", - "public", - ), - Some(("weird.schema".to_string(), "t".to_string())) - ); - // Both halves quoted with embedded dots — the split must still - // land on the dot OUTSIDE every quoted segment. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (col) REFERENCES \"a.b\".\"c.d\"(id)", - "public", - ), - Some(("a.b".to_string(), "c.d".to_string())) - ); -} - -#[test] -fn pr187_parse_fk_skips_keyword_inside_quoted_column_name() { - // PR #187 review (C7): a column literally named - // `"my references col"` puts the bytes `references` between two - // spaces, passing the naive boundary check, then returns `None` - // from the false match without ever reaching the real keyword. - // The scanner must skip matches that fall inside a double-quoted - // identifier. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (\"my references col\") REFERENCES public.target(id)", - "public", - ), - Some(("public".to_string(), "target".to_string())), - "FK keyword must still be located even with `references` inside a quoted column name" - ); -} - -#[test] -fn pr187_parse_fk_handles_dollar_in_identifier() { - // PR #187 review (C8): PostgreSQL identifiers may contain `$`, - // so the unquoted-identifier scan must include it. Otherwise a - // target like `public.parent$table` is truncated to - // `public.parent`. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (col) REFERENCES public.parent$table(id)", - "public", - ), - Some(("public".to_string(), "parent$table".to_string())) - ); -} - -#[test] -fn pr187_definition_references_any_skips_string_literals() { - // PR #187 review (C4): a SQL string literal containing routine - // text — `CHECK (msg <> 'compute(value)')` — must not trigger - // the unqualified-call matcher. The `definition_references_any` - // pre-pass must blank out single-quoted literals before scanning. - let mut affected: HashSet<(String, String)> = HashSet::new(); - affected.insert(("public".to_string(), "compute".to_string())); - assert!( - !Comparer::definition_references_any("CHECK (msg <> 'compute(value)')", &affected), - "literal text must not be treated as a function call" - ); - // Sanity check: a real call outside a literal still matches. - assert!( - Comparer::definition_references_any( - "CHECK (compute(value) > 0 AND msg <> 'compute(value)')", - &affected - ), - "real call outside the literal must still match" - ); -} - -#[tokio::test] -async fn pr187_persistence_ordering_works_with_quoted_identifiers() { - // PR #187 review (C2): mixed-case table names round-trip into - // `Table.schema` / `Table.name` with surrounding quotes - // (`quote_ident` in the dump query). The FK parser strips quotes - // from its returned `(schema, name)`. Without normalising the - // lookup map to the same quote-stripped form, FK edges between - // quoted-identifier tables go missing and persistence flips fall - // back to alphabetical order, which PostgreSQL rejects for FK - // chains. Build a parent→child chain whose names are quoted and - // assert the SET UNLOGGED order is leaves-first. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - let mk = |name: &str, is_unlogged: bool, fk: Option<(&str, &str, &str)>| { - // Wrap the schema/name in quotes the way `quote_ident` would. - let mut t = issue180_logged_table("\"TestOrder\"", name, is_unlogged, fk); - t.schema = "\"TestOrder\"".to_string(); - t.raw_schema = "\"TestOrder\"".to_string(); - t - }; - from_dump.tables.push(mk("\"Grand\"", false, None)); - from_dump.tables.push(mk( - "\"Parent\"", - false, - Some(("grand_id", "\"TestOrder\"", "\"Grand\"")), - )); - from_dump.tables.push(mk( - "\"Child\"", - false, - Some(("parent_id", "\"TestOrder\"", "\"Parent\"")), - )); - to_dump.tables.push(mk("\"Grand\"", true, None)); - to_dump.tables.push(mk( - "\"Parent\"", - true, - Some(("grand_id", "\"TestOrder\"", "\"Grand\"")), - )); - to_dump.tables.push(mk( - "\"Child\"", - true, - Some(("parent_id", "\"TestOrder\"", "\"Parent\"")), - )); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - let pos_child = script - .find("alter table \"TestOrder\".\"Child\" set unlogged;") - .expect("child SET UNLOGGED missing"); - let pos_parent = script - .find("alter table \"TestOrder\".\"Parent\" set unlogged;") - .expect("parent SET UNLOGGED missing"); - let pos_grand = script - .find("alter table \"TestOrder\".\"Grand\" set unlogged;") - .expect("grand SET UNLOGGED missing"); - assert!( - pos_child < pos_parent && pos_parent < pos_grand, - "FK-leaf-first order must hold for quoted identifiers too: {script}" - ); -} - -#[tokio::test] -async fn pr187_persistence_ordering_includes_in_place_alterable_fks() { - // PR #187 review (C13): an FK whose definition differs only in - // an in-place-alterable property (deferrability, enforced, - // no_inherit, comment) is NOT dropped by `compare_tables` — it - // stays live until `compare_foreign_keys` ALTERs it. The live - // FK adjacency for the SET phase must include it, otherwise - // chains where one FK is being toggled deferrable/enforced fall - // back to alphabetical SET order. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump - .tables - .push(issue180_logged_table("test_order", "parent", false, None)); - from_dump.tables.push(issue180_logged_table( - "test_order", - "child", - false, - Some(("parent_id", "test_order", "parent")), - )); - to_dump - .tables - .push(issue180_logged_table("test_order", "parent", true, None)); - let mut to_child = issue180_logged_table( - "test_order", - "child", - true, - Some(("parent_id", "test_order", "parent")), - ); - // Toggle the FK's deferrability — `can_be_altered_to` accepts - // this, so the FK survives `compare_tables` and is still live at - // the SET point. - if let Some(fk) = to_child - .constraints - .iter_mut() - .find(|c| c.constraint_type.eq_ignore_ascii_case("foreign key")) - { - fk.is_deferrable = true; - fk.initially_deferred = true; - } - to_child.hash(); - to_dump.tables.push(to_child); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - let pos_child = script - .find("alter table test_order.child set unlogged;") - .expect("child SET UNLOGGED missing"); - let pos_parent = script - .find("alter table test_order.parent set unlogged;") - .expect("parent SET UNLOGGED missing"); - assert!( - pos_child < pos_parent, - "child must come before parent even when the FK between them is being in-place ALTERed: {script}" - ); -} - -#[test] -fn pr187_unqualified_matcher_unicode_boundary_rejects_longer_identifier() { - // PR #187 review (C17): the boundary check used raw ASCII byte - // tests, which treated Cyrillic neighbours as non-identifier and - // let `функция` match inside `мояфункция(`. The check now uses - // character-class identifier rules, so Cyrillic-letter neighbours - // correctly extend the identifier and reject the match. - let mut affected: HashSet<(String, String)> = HashSet::new(); - affected.insert(("public".to_string(), "функция".to_string())); - assert!( - !Comparer::definition_references_any("CHECK (мояфункция(x) > 0)", &affected), - "unicode letter to the left must extend the identifier" - ); - assert!( - !Comparer::definition_references_any("CHECK (функцияд(x) > 0)", &affected), - "unicode letter to the right must extend the identifier" - ); - // Sanity: a clean Cyrillic call still matches. - assert!( - Comparer::definition_references_any("CHECK (функция(x) > 0)", &affected), - "standalone unicode call must still match" - ); -} - -#[test] -fn issue180_parse_fk_referenced_table_handles_non_ascii_column_names() { - // PR #184 follow-up review: `parse_fk_referenced_table` previously - // built the case-insensitive haystack via `to_lowercase()`, which - // can change byte length for some non-ASCII characters - // (e.g. capital Turkish dotted I, `İ`, lowercases to a multi-char - // sequence with a different UTF-8 length). The keyword position - // came from the lowercased haystack but the slice that produces - // the parsed identifier reaches back into `def`, so a - // length-changing lowercasing would land mid-codepoint and panic. - // `to_ascii_lowercase()` is byte-length-preserving — pin that - // contract by parsing FK definitions whose column list contains - // identifiers that trip every byte-length-changing lowercase - // conversion in common locales. - // - // Quoted column with capital `İ` (U+0130). With `to_lowercase()` - // this produces `i\u{0307}` (3 bytes total); `to_ascii_lowercase` - // leaves the 2-byte `İ` alone, so byte offsets line up. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (\"\u{0130}d\") REFERENCES public.target(id)", - "public", - ), - Some(("public".to_string(), "target".to_string())) - ); - // German sharp S (`ß`, U+00DF). `to_lowercase()` keeps it as `ß`, - // but the inverse — uppercase `ẞ` (U+1E9E) lowercasing to `ß` — - // is length-preserving in UTF-8 too. Use a Cyrillic lowercase - // identifier here just to round out coverage of identifiers whose - // bytes lie outside the ASCII range. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (\"русское_имя\") REFERENCES public.target(id)", - "public", - ), - Some(("public".to_string(), "target".to_string())) - ); - // Same case in the qualified target identifier. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (col) REFERENCES \"тест\".\"target\"(id)", - "public", - ), - Some(("тест".to_string(), "target".to_string())) - ); -} - -#[test] -fn issue190_parse_fk_unqualified_target_falls_back_to_owner_schema() { - // Issue #190: `pg_get_constraintdef` omits the schema qualifier - // when the target is reachable via `search_path` — typical for - // tables in `public`. Pre-fix the parser returned `None` for these - // and the FK edge was silently dropped from the persistence-flip - // adjacency, leaving FK chains in `public` ordered alphabetically - // (the order PostgreSQL rejects). - // - // Plain unqualified target — same schema as the FK owner. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (parent_id) REFERENCES parent(id)", - "public", - ), - Some(("public".to_string(), "parent".to_string())), - "unqualified target must resolve to (owner_schema, target)" - ); - // Quoted unqualified target — the quotes must be stripped to - // match the comparer's normalised `to_index_by_key` keys (which - // strip quotes on the index side too). - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (col) REFERENCES \"MixedCase\"(id)", - "public", - ), - Some(("public".to_string(), "MixedCase".to_string())) - ); - // Quoted owner schema (e.g. mixed-case schema names land here as - // `"MySchema"` via `quote_ident`) — the fallback must strip the - // surrounding quotes from the owner schema too, otherwise the - // produced pair misses the index-side lookup keys. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (col) REFERENCES parent(id)", - "\"MySchema\"", - ), - Some(("MySchema".to_string(), "parent".to_string())) - ); - // ON UPDATE / ON DELETE clauses follow the target — make sure - // they don't confuse the boundary scan. - assert_eq!( - Comparer::parse_fk_referenced_table( - "FOREIGN KEY (col) REFERENCES parent(id) ON DELETE CASCADE", - "public", - ), - Some(("public".to_string(), "parent".to_string())) - ); -} - -/// Issue #190 end-to-end: a FK chain in `public` whose deparsed -/// definition uses unqualified target names must still be ordered -/// leaves-first by `emit_persistence_changes`. Pre-fix the unqualified -/// targets returned `None` from the parser, the adjacency went empty, -/// and the SET UNLOGGED order fell back to alphabetical (`child` -/// emitted *after* `parent` — exactly the order PostgreSQL rejects). -#[tokio::test] -async fn issue190_set_unlogged_orders_unqualified_public_fk_chain() { - // Builder that matches `issue180_logged_table` but emits FK - // definitions WITHOUT the schema qualifier — the - // `pg_get_constraintdef` output shape that exposes the issue. - fn make_table(name: &str, is_unlogged: bool, fk_target: Option<(&str, &str)>) -> Table { - let mut id_col = int_column("public", name, "id", 1); - id_col.is_nullable = false; - let mut constraints: Vec = vec![TableConstraint { - catalog: "postgres".to_string(), - schema: "public".to_string(), - name: format!("{name}_pkey"), - table_name: name.to_string(), - constraint_type: "PRIMARY KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("PRIMARY KEY (id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }]; - - let mut columns = vec![id_col]; - if let Some((fk_col, fk_table)) = fk_target { - let mut ref_col = int_column("public", name, fk_col, 2); - ref_col.is_nullable = true; - columns.push(ref_col); - // Unqualified `REFERENCES parent(id)` — no `public.` - // qualifier. This is what `pg_get_constraintdef` returns - // when the target is reachable via `search_path`. - constraints.push(TableConstraint { - catalog: "postgres".to_string(), - schema: "public".to_string(), - name: format!("{name}_{fk_col}_fkey"), - table_name: name.to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some(format!("FOREIGN KEY ({fk_col}) REFERENCES {fk_table}(id)")), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }); - } - - let mut table = Table::new( - "public".to_string(), - name.to_string(), - "public".to_string(), - name.to_string(), - "postgres".to_string(), - None, - columns, - constraints, - vec![], - vec![], - None, - ); - table.is_unlogged = is_unlogged; - table.hash(); - table - } - - // FROM: child → parent in public, both LOGGED. - // TO: same chain, both UNLOGGED. The FK must survive in TO - // unchanged (live edge) so the adjacency considers it. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump.tables.push(make_table("parent", false, None)); - from_dump - .tables - .push(make_table("child", false, Some(("parent_id", "parent")))); - to_dump.tables.push(make_table("parent", true, None)); - to_dump - .tables - .push(make_table("child", true, Some(("parent_id", "parent")))); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - let child_pos = script - .find("alter table public.child set unlogged;") - .expect("child SET UNLOGGED must be emitted"); - let parent_pos = script - .find("alter table public.parent set unlogged;") - .expect("parent SET UNLOGGED must be emitted"); - assert!( - child_pos < parent_pos, - "child (referrer) must SET UNLOGGED BEFORE parent (referenced); \ - got child@{} parent@{} — alphabetical order would put `child` \ - after `parent` and PostgreSQL would reject the SET on `parent` \ - while `child` is still LOGGED:\n{}", - child_pos, - parent_pos, - script - ); -} - -#[tokio::test] -async fn issue180_set_unlogged_skips_ordering_for_new_fks_added_later() { - // PR #184 review (FK-timing): when an FK is brand-new in TO it is - // not yet active at the moment `emit_persistence_changes` runs — - // `compare_foreign_keys` adds it strictly after. The adjacency - // must therefore filter to FKs that exist UNCHANGED in both - // FROM and TO. Without that filter, an alphabetical pair would - // be over-ordered as if the new FK were already live. - // - // Setup: child references parent in TO (new FK). FROM has no FK. - // Both flip from LOGGED to UNLOGGED. - // - // With the live-FK-set tightening, the adjacency is empty, so - // ordering falls back to alphabetical (deterministic via the - // sort_key in the topo sort). This is safe because PG won't see - // the FK link until after the SET phase. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump - .tables - .push(issue180_logged_table("test_order", "parent", false, None)); - // FROM child has no FK to parent — the FK is new in TO. - from_dump - .tables - .push(issue180_logged_table("test_order", "child", false, None)); - to_dump - .tables - .push(issue180_logged_table("test_order", "parent", true, None)); - to_dump.tables.push(issue180_logged_table( - "test_order", - "child", - true, - Some(("parent_id", "test_order", "parent")), - )); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - // Both SET UNLOGGED statements must be present; ordering between - // them is not constrained by the (not-yet-live) new FK. - assert!(script.contains("alter table test_order.child set unlogged;")); - assert!(script.contains("alter table test_order.parent set unlogged;")); -} - -#[test] -fn issue180_sequence_only_persistence_change_uses_hash_diff() { - // PR #184 review: `is_only_persistence_change` clones, equalises - // `is_unlogged` to FROM, recomputes the hash, and compares. - // That keeps the check honest if `Sequence::hash` later starts - // covering a new field. This test pins the contract by exercising - // both directions: identical-except-persistence returns true; a - // hashed field different (here `cache_size`) returns false. - use crate::dump::sequence::Sequence; - - let make = |is_unlogged: bool, cache: i64| { - let mut s = Sequence::new( - "public".to_string(), - "s".to_string(), - "postgres".to_string(), - "integer".to_string(), - Some(1), - Some(1), - Some(2147483647), - Some(1), - false, - Some(cache), - Some(1), - None, - None, - None, - ); - s.is_unlogged = is_unlogged; - s.hash(); - s - }; - let from = make(false, 1); - let to_only_persistence = make(true, 1); - let to_persistence_and_cache = make(true, 5); - assert!( - to_only_persistence.is_only_persistence_change(&from), - "identical except is_unlogged must be detected as persistence-only" - ); - assert!( - !to_persistence_and_cache.is_only_persistence_change(&from), - "a hashed field difference must block the persistence-only suppression" - ); -} - -/// Build a view whose definition textually references `test_deps.compute`. -/// Returns a regular or materialized view depending on `is_materialized`. -fn issue189_view(name: &str, is_materialized: bool) -> View { - let mut view = View::new( - name.to_string(), - " SELECT test_deps.compute(value) AS c\n FROM test_deps.items;".to_string(), - "test_deps".to_string(), - vec!["test_deps.items".to_string()], - ); - view.is_materialized = is_materialized; - view.hash(); - view -} - -#[tokio::test] -async fn issue189_signature_change_recreates_byte_identical_view() { - // The view's hash is unchanged between FROM and TO, but the function - // it references undergoes a signature change (integer → bigint), - // forcing DROP FUNCTION ... CASCADE. PostgreSQL silently drops the - // view as part of the cascade. Phase 7 must re-emit the view so the - // migration leaves the database in a consistent state. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - from_dump.views.push(issue189_view("v_things", false)); - to_dump.views.push(issue189_view("v_things", false)); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - let drop_pos = script - .find("drop function if exists test_deps.compute (x integer) cascade;") - .expect("CASCADE drop must be emitted for the signature change"); - let create_fn_pos = script - .find("create or replace function test_deps.compute(x integer) returns bigint") - .expect("function recreate must be emitted"); - assert!(drop_pos < create_fn_pos); - - let view_pos = script - .find("CREATE OR REPLACE VIEW test_deps.v_things") - .expect("byte-identical view must be re-emitted as CREATE OR REPLACE VIEW after CASCADE"); - assert!( - create_fn_pos < view_pos, - "view recreate must run after the function recreate so the new signature is in place" - ); -} - -#[tokio::test] -async fn issue189_signature_change_recreates_byte_identical_materialized_view() { - // Same scenario as the regular-view case but with a materialized - // view. PostgreSQL CASCADE drops these via `pg_depend` the same way, - // so Phase 7 must emit a `create materialized view if not exists`. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - from_dump.views.push(issue189_view("mv_things", true)); - to_dump.views.push(issue189_view("mv_things", true)); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("create materialized view if not exists test_deps.mv_things"), - "materialized view must be re-emitted with IF NOT EXISTS: {}", - script - ); -} - -#[tokio::test] -async fn issue189_view_not_referencing_routine_is_not_recreated() { - // A view that doesn't textually reference the CASCADE-affected - // routine must be left alone — re-emitting it would clutter the - // migration and could re-introduce a stale definition if the user - // has the same view in both dumps for unrelated reasons. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - let mut unrelated_from = View::new( - "v_other".to_string(), - " SELECT value FROM test_deps.items;".to_string(), - "test_deps".to_string(), - vec!["test_deps.items".to_string()], - ); - unrelated_from.hash(); - let mut unrelated_to = View::new( - "v_other".to_string(), - " SELECT value FROM test_deps.items;".to_string(), - "test_deps".to_string(), - vec!["test_deps.items".to_string()], - ); - unrelated_to.hash(); - from_dump.views.push(unrelated_from); - to_dump.views.push(unrelated_to); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("v_other"), - "view that doesn't reference the cascaded routine must not be re-emitted: {}", - script - ); -} - -#[tokio::test] -async fn issue189_view_recreate_skipped_when_to_definition_no_longer_references_routine() { - // TO-side gate (PR #186): if the TO view's definition was rewritten - // to no longer call the affected routine, the CASCADE drop never - // touches it (no pg_depend link). `compare_routines_and_views` - // already emits the rewrite via the normal hash-diff path; Phase 7 - // must stay silent so we don't re-emit the view twice. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - from_dump.views.push(issue189_view("v_things", false)); - // TO view: same name, but the definition no longer references the - // function — different hash, so Phase 5 handles it. - let mut to_view = View::new( - "v_things".to_string(), - " SELECT value AS c FROM test_deps.items;".to_string(), - "test_deps".to_string(), - vec!["test_deps.items".to_string()], - ); - to_view.hash(); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - // The view must appear exactly once (the normal hash-diff path), not - // a second time from Phase 7's recreate block. - let recreate_section_start = script.find("Recreate dependents dropped by CASCADE: Start"); - if let Some(start) = recreate_section_start { - let recreate_end = script[start..] - .find("Recreate dependents dropped by CASCADE: End") - .map(|e| start + e) - .unwrap_or(script.len()); - let recreate_section = &script[start..recreate_end]; - assert!( - !recreate_section.contains("v_things"), - "Phase 7 must not re-emit a view whose TO definition no longer references the routine: {}", - recreate_section - ); - } -} - -#[test] -fn issue189_rewrite_create_view_anchored_to_prefix() { - // PR #195 review (Copilot): the helper must not be fooled by the - // literal text `CREATE OR REPLACE VIEW` appearing inside the view - // definition body that `View::get_script` appends after the - // `create view` prefix. A whole-script `contains` early-return - // would skip the rewrite and leave the leading `create view` - // unchanged, which is not idempotent against a surviving view. - let script = "create view public.v_with_literal as\n\ - SELECT 'CREATE OR REPLACE VIEW pretend.v AS SELECT 1' AS payload;\n\n"; - let rewritten = rewrite_create_view_to_create_or_replace(script); - assert!( - rewritten.starts_with("CREATE OR REPLACE VIEW public.v_with_literal as\n"), - "leading `create view` must be rewritten even when the body \ - contains the same phrase as a string literal: {}", - rewritten - ); - // Already in the desired form — return unchanged (no double rewrite). - let already = "CREATE OR REPLACE VIEW public.v as\nSELECT 1;\n"; - assert_eq!(rewrite_create_view_to_create_or_replace(already), already); -} - -#[tokio::test] -async fn issue189_view_definition_with_create_or_replace_literal_is_recreated() { - // End-to-end pin for the PR #195 reviewer concern: a view whose - // definition embeds the literal text `CREATE OR REPLACE VIEW` must - // still emit a properly idempotent `CREATE OR REPLACE VIEW` prefix - // when Phase 7 re-emits it after a CASCADE drop. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - let definition = " SELECT test_deps.compute(value) AS c,\n \ - 'CREATE OR REPLACE VIEW evil.v AS SELECT 1' AS payload\n \ - FROM test_deps.items;"; - let mut from_view = View::new( - "v_things".to_string(), - definition.to_string(), - "test_deps".to_string(), - vec!["test_deps.items".to_string()], - ); - from_view.hash(); - let mut to_view = View::new( - "v_things".to_string(), - definition.to_string(), - "test_deps".to_string(), - vec!["test_deps.items".to_string()], - ); - to_view.hash(); - from_dump.views.push(from_view); - to_dump.views.push(to_view); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("CREATE OR REPLACE VIEW test_deps.v_things"), - "view recreate must emit `CREATE OR REPLACE VIEW` at the leading \ - statement even when the body contains the same phrase: {}", - script - ); - // The body's literal must survive unchanged — we do NOT want a - // rewrite that mangles a non-prefix occurrence. - assert!( - script.contains("'CREATE OR REPLACE VIEW evil.v AS SELECT 1'"), - "literal inside the view body must be left intact: {}", - script - ); -} - -#[tokio::test] -async fn issue189_view_recreate_skipped_when_routine_unchanged() { - // No CASCADE — no recreate. Mirrors - // `issue179_recreate_skipped_when_routine_unchanged` for views. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let routine = issue179_compute_routine("integer", "SELECT x * 2;"); - from_dump.routines.push(routine.clone()); - to_dump.routines.push(routine); - - from_dump.views.push(issue189_view("v_things", false)); - to_dump.views.push(issue189_view("v_things", false)); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("Recreate dependents dropped by CASCADE"), - "no CASCADE drop happened — recreate phase must stay silent: {}", - script - ); - assert!( - !script.contains("CREATE OR REPLACE VIEW test_deps.v_things"), - "view must not be re-emitted when the function is unchanged: {}", - script - ); -} - -// ============================================================ -// Issue #188 — pg_depend-driven secondary dependent restoration -// ============================================================ - -use crate::dump::column_dependent::{ColumnDependent, ColumnDependentKind}; - -/// Phase 7 / Path A: A routine signature change CASCADE-drops a -/// generated column. PostgreSQL also drops a plain index on that -/// column *because the index depends on the column, not the routine*. -/// The text-based scanner cannot see the dependency. The -/// `column_dependents` graph must drive a recreate of the index. -#[tokio::test] -async fn issue188_phase7_restores_plain_index_on_generated_column() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - // Add a plain index ON the generated column (no function reference) - // to both sides. PostgreSQL would CASCADE-drop it along with the - // column; Phase 7's text scan does not detect this case. - let plain_idx = TableIndex { - schema: "test_deps".to_string(), - table: "items".to_string(), - name: "idx_gen_col".to_string(), - catalog: Some("postgres".to_string()), - indexdef: "CREATE INDEX idx_gen_col ON test_deps.items USING btree (gen_col)".to_string(), - is_partition_index: false, - comment: None, - }; - - let mut from_table = - issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); - from_table.indexes.push(plain_idx.clone()); - from_table.hash(); - from_dump.tables.push(from_table); - - let mut to_table = issue179_items_table("bigint", "test_deps.compute(0)", "bigint"); - to_table.indexes.push(plain_idx); - to_table.hash(); - to_dump.tables.push(to_table); - - // pg_depend at dump time recorded that `idx_gen_col` depends on - // `gen_col`. Without this, the text scanner has no way to discover - // the secondary dependency. - from_dump.column_dependents.push(ColumnDependent { - schema: "test_deps".to_string(), - table: "items".to_string(), - column: "gen_col".to_string(), - kind: ColumnDependentKind::Index, - dep_schema: "test_deps".to_string(), - dep_table: "items".to_string(), - dep_name: "idx_gen_col".to_string(), - }); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("CREATE INDEX IF NOT EXISTS idx_gen_col ON test_deps.items"), - "plain index on generated column must be re-emitted from pg_depend graph: {}", - script - ); -} - -/// Phase 7 / Path A: same idea for a CHECK constraint that references -/// the generated column but does not name the routine. -#[tokio::test] -async fn issue188_phase7_restores_check_constraint_on_generated_column() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - let chk_on_col = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_deps".to_string(), - name: "chk_gen_positive".to_string(), - table_name: "items".to_string(), - constraint_type: "CHECK".to_string(), - is_deferrable: false, - initially_deferred: false, - // References only the generated column — no function name. - definition: Some("CHECK (gen_col > 0)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let mut from_table = - issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); - from_table.constraints.push(chk_on_col.clone()); - from_table.hash(); - from_dump.tables.push(from_table); - - let mut to_table = issue179_items_table("bigint", "test_deps.compute(0)", "bigint"); - to_table.constraints.push(chk_on_col); - to_table.hash(); - to_dump.tables.push(to_table); - - from_dump.column_dependents.push(ColumnDependent { - schema: "test_deps".to_string(), - table: "items".to_string(), - column: "gen_col".to_string(), - kind: ColumnDependentKind::Constraint, - dep_schema: "test_deps".to_string(), - dep_table: "items".to_string(), - dep_name: "chk_gen_positive".to_string(), - }); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("alter table test_deps.items add constraint chk_gen_positive"), - "CHECK constraint anchored on generated column must be re-emitted: {}", - script - ); - assert!( - script.contains("alter table test_deps.items drop constraint if exists chk_gen_positive;"), - "drop-if-exists guard for column-anchored CHECK constraint missing: {}", - script - ); -} - -/// Phase 7 / Path A: TO-side gate. When the dependent is intentionally -/// removed in TO, we must NOT resurrect it via the pg_depend graph. -#[tokio::test] -async fn issue188_phase7_skips_dependent_absent_from_to() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - // FROM has the plain index; TO deliberately omits it. - let plain_idx = TableIndex { - schema: "test_deps".to_string(), - table: "items".to_string(), - name: "idx_gen_col".to_string(), - catalog: Some("postgres".to_string()), - indexdef: "CREATE INDEX idx_gen_col ON test_deps.items USING btree (gen_col)".to_string(), - is_partition_index: false, - comment: None, - }; - - let mut from_table = - issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); - from_table.indexes.push(plain_idx); - from_table.hash(); - from_dump.tables.push(from_table); - - // TO-side table does NOT include `idx_gen_col`. - to_dump.tables.push(issue179_items_table( - "bigint", - "test_deps.compute(0)", - "bigint", - )); - - from_dump.column_dependents.push(ColumnDependent { - schema: "test_deps".to_string(), - table: "items".to_string(), - column: "gen_col".to_string(), - kind: ColumnDependentKind::Index, - dep_schema: "test_deps".to_string(), - dep_table: "items".to_string(), - dep_name: "idx_gen_col".to_string(), - }); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("idx_gen_col"), - "index absent from TO must not be resurrected from pg_depend: {}", - script - ); -} - -/// Phase 7 / Path A: a UNIQUE/PK constraint and its backing index both -/// appear in `pg_depend`. The constraint emission already recreates the -/// index, so the dedup logic must skip the index branch when the same -/// name exists as a constraint on the TO-side table. -#[tokio::test] -async fn issue188_phase7_skips_index_backing_constraint() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - let uniq_constraint = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_deps".to_string(), - name: "items_gen_col_key".to_string(), - table_name: "items".to_string(), - constraint_type: "UNIQUE".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("UNIQUE (gen_col)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - // Backing index has the same name as the constraint. - let uniq_idx = TableIndex { - schema: "test_deps".to_string(), - table: "items".to_string(), - name: "items_gen_col_key".to_string(), - catalog: Some("postgres".to_string()), - indexdef: "CREATE UNIQUE INDEX items_gen_col_key ON test_deps.items USING btree (gen_col)" - .to_string(), - is_partition_index: false, - comment: None, - }; - - let mut from_table = - issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); - from_table.constraints.push(uniq_constraint.clone()); - from_table.indexes.push(uniq_idx.clone()); - from_table.hash(); - from_dump.tables.push(from_table); - - let mut to_table = issue179_items_table("bigint", "test_deps.compute(0)", "bigint"); - to_table.constraints.push(uniq_constraint); - to_table.indexes.push(uniq_idx); - to_table.hash(); - to_dump.tables.push(to_table); - - // pg_depend records BOTH edges. - from_dump.column_dependents.push(ColumnDependent { - schema: "test_deps".to_string(), - table: "items".to_string(), - column: "gen_col".to_string(), - kind: ColumnDependentKind::Index, - dep_schema: "test_deps".to_string(), - dep_table: "items".to_string(), - dep_name: "items_gen_col_key".to_string(), - }); - from_dump.column_dependents.push(ColumnDependent { - schema: "test_deps".to_string(), - table: "items".to_string(), - column: "gen_col".to_string(), - kind: ColumnDependentKind::Constraint, - dep_schema: "test_deps".to_string(), - dep_table: "items".to_string(), - dep_name: "items_gen_col_key".to_string(), - }); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - // The constraint emission must fire … - assert!( - script.contains("alter table test_deps.items add constraint items_gen_col_key"), - "UNIQUE constraint must be re-emitted: {}", - script - ); - // … and the backing-index CREATE must NOT also be emitted (the - // constraint creates the index implicitly). - assert!( - !script.contains("CREATE UNIQUE INDEX IF NOT EXISTS items_gen_col_key"), - "backing index must be skipped when a same-named constraint emission already recreates it: {}", - script - ); -} - -/// Path B: a STORED → VIRTUAL flip routes the column through the -/// `DROP COLUMN` + `ADD COLUMN` branch in `TableColumn::get_alter_script` -/// (issue #181). PostgreSQL CASCADE-drops a plain index attached to the -/// column. `compare_tables` must walk the column-dependent graph and -/// emit the recreate. -#[tokio::test] -async fn issue188_path_b_virtual_flip_restores_dependent_index() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let value_col = { - let mut c = int_column("test_deps", "items", "value", 1); - c.is_nullable = false; - c - }; - - let mut from_gen_col = int_column("test_deps", "items", "gen_col", 2); - from_gen_col.data_type = "integer".to_string(); - from_gen_col.is_generated = "ALWAYS".to_string(); - from_gen_col.generation_expression = Some("(value * 2)".to_string()); - from_gen_col.generation_type = Some("s".to_string()); // STORED in FROM - - let mut to_gen_col = from_gen_col.clone(); - to_gen_col.generation_type = Some("v".to_string()); // VIRTUAL in TO - - let plain_idx = TableIndex { - schema: "test_deps".to_string(), - table: "items".to_string(), - name: "idx_gen_col".to_string(), - catalog: Some("postgres".to_string()), - indexdef: "CREATE INDEX idx_gen_col ON test_deps.items USING btree (gen_col)".to_string(), - is_partition_index: false, - comment: None, - }; - - let mut from_table = Table::new( - "test_deps".to_string(), - "items".to_string(), - "test_deps".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![value_col.clone(), from_gen_col], - vec![], - vec![plain_idx.clone()], - vec![], - None, - ); - from_table.hash(); - from_dump.tables.push(from_table); - - let mut to_table = Table::new( - "test_deps".to_string(), - "items".to_string(), - "test_deps".to_string(), - "items".to_string(), - "postgres".to_string(), - None, - vec![value_col, to_gen_col], - vec![], - vec![plain_idx], - vec![], - None, - ); - to_table.hash(); - to_dump.tables.push(to_table); - - from_dump.column_dependents.push(ColumnDependent { - schema: "test_deps".to_string(), - table: "items".to_string(), - column: "gen_col".to_string(), - kind: ColumnDependentKind::Index, - dep_schema: "test_deps".to_string(), - dep_table: "items".to_string(), - dep_name: "idx_gen_col".to_string(), - }); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - // The drop+add for the column must fire (Path B trigger). - assert!( - script.contains("drop column"), - "STORED→VIRTUAL flip should DROP COLUMN: {}", - script - ); - // The recreate block must include the dependent index. - assert!( - script.contains("Recreate dependents dropped by virtual-column rewrite"), - "labeled recreate block must wrap Path B dependents: {}", - script - ); - assert!( - script.contains("CREATE INDEX IF NOT EXISTS idx_gen_col ON test_deps.items"), - "plain index on virtually-recreated column must be re-emitted: {}", - script - ); -} - -/// Phase 7 / Path A: an FK on a *different* table referencing the -/// generated column on the anchor table. The pg_depend row's -/// `refobjid` points at the parent table (where the column lives) but -/// `con.conrelid` points at the child (where the FK lives) — the -/// `dep_schema`/`dep_table` in `ColumnDependent` must be the child's, -/// not the anchor's. Locks in correct behaviour for the asymmetric -/// `conrelid` vs `refobjid` case (PR #196 review). -#[tokio::test] -async fn issue188_phase7_restores_fk_on_different_table() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - // Parent table: the standard issue179 items table — gen_col is - // the anchor whose CASCADE drop the test exercises. - from_dump.tables.push(issue179_items_table( - "integer", - "test_deps.compute(0)::integer", - "integer", - )); - to_dump.tables.push(issue179_items_table( - "bigint", - "test_deps.compute(0)", - "bigint", - )); - - // Child table: separate table whose FK references gen_col on the - // parent. The FK's own definition contains no function name; the - // text scanner cannot see this dependency. The pg_depend graph - // must drive the re-emission. - let make_child = |ref_type: &str| { - let mut id_col = int_column("test_deps", "items_child", "id", 1); - id_col.is_nullable = false; - - let mut ref_col = int_column("test_deps", "items_child", "ref_gen", 2); - ref_col.data_type = ref_type.to_string(); - - let fk = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_deps".to_string(), - name: "fk_items_child_ref_gen".to_string(), - table_name: "items_child".to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some( - "FOREIGN KEY (ref_gen) REFERENCES test_deps.items (gen_col)".to_string(), - ), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let mut t = Table::new( - "test_deps".to_string(), - "items_child".to_string(), - "test_deps".to_string(), - "items_child".to_string(), - "postgres".to_string(), - None, - vec![id_col, ref_col], - vec![fk], - vec![], - vec![], - None, - ); - t.hash(); - t - }; - from_dump.tables.push(make_child("integer")); - to_dump.tables.push(make_child("bigint")); - - // Anchor is the parent column (gen_col on items). dep_table is - // the *child* (items_child) because the FK constraint's - // `conrelid` points at the child, not the parent where the - // depended-on column lives. - from_dump.column_dependents.push(ColumnDependent { - schema: "test_deps".to_string(), - table: "items".to_string(), - column: "gen_col".to_string(), - kind: ColumnDependentKind::Constraint, - dep_schema: "test_deps".to_string(), - dep_table: "items_child".to_string(), - dep_name: "fk_items_child_ref_gen".to_string(), - }); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("alter table test_deps.items_child add constraint fk_items_child_ref_gen"), - "FK on a different table must be re-emitted via pg_depend graph: {}", - script - ); - assert!( - script.contains( - "alter table test_deps.items_child drop constraint if exists fk_items_child_ref_gen;" - ), - "drop-if-exists guard for cross-table FK missing: {}", - script - ); -} - -/// Phase 7 / Path A: when the anchor column has both a UNIQUE -/// constraint and an FK on another table referencing it, the FK must -/// be emitted *after* the UNIQUE constraint — PostgreSQL rejects -/// `ADD CONSTRAINT … FOREIGN KEY` when the referenced columns lack a -/// unique constraint. Two-pass ordering in `recreate_column_dependents`. -#[tokio::test] -async fn issue188_phase7_emits_fk_after_unique_target() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - let uniq_constraint = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_deps".to_string(), - name: "items_gen_col_uniq".to_string(), - table_name: "items".to_string(), - constraint_type: "UNIQUE".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("UNIQUE (gen_col)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let mut from_items = - issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); - from_items.constraints.push(uniq_constraint.clone()); - from_items.hash(); - from_dump.tables.push(from_items); - - let mut to_items = issue179_items_table("bigint", "test_deps.compute(0)", "bigint"); - to_items.constraints.push(uniq_constraint); - to_items.hash(); - to_dump.tables.push(to_items); - - let make_child = |ref_type: &str| { - let mut id_col = int_column("test_deps", "items_child", "id", 1); - id_col.is_nullable = false; - let mut ref_col = int_column("test_deps", "items_child", "ref_gen", 2); - ref_col.data_type = ref_type.to_string(); - - let fk = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_deps".to_string(), - name: "fk_items_child_ref_gen".to_string(), - table_name: "items_child".to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some( - "FOREIGN KEY (ref_gen) REFERENCES test_deps.items (gen_col)".to_string(), - ), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let mut t = Table::new( - "test_deps".to_string(), - "items_child".to_string(), - "test_deps".to_string(), - "items_child".to_string(), - "postgres".to_string(), - None, - vec![id_col, ref_col], - vec![fk], - vec![], - vec![], - None, - ); - t.hash(); - t - }; - from_dump.tables.push(make_child("integer")); - to_dump.tables.push(make_child("bigint")); - - // FK first in the column_dependents vec — the helper must defer - // it regardless of input order so it lands after the UNIQUE. - from_dump.column_dependents.push(ColumnDependent { - schema: "test_deps".to_string(), - table: "items".to_string(), - column: "gen_col".to_string(), - kind: ColumnDependentKind::Constraint, - dep_schema: "test_deps".to_string(), - dep_table: "items_child".to_string(), - dep_name: "fk_items_child_ref_gen".to_string(), - }); - from_dump.column_dependents.push(ColumnDependent { - schema: "test_deps".to_string(), - table: "items".to_string(), - column: "gen_col".to_string(), - kind: ColumnDependentKind::Constraint, - dep_schema: "test_deps".to_string(), - dep_table: "items".to_string(), - dep_name: "items_gen_col_uniq".to_string(), - }); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - let uniq_pos = script - .find("add constraint items_gen_col_uniq") - .expect("UNIQUE constraint must be re-emitted"); - let fk_pos = script - .find("add constraint fk_items_child_ref_gen") - .expect("FK constraint must be re-emitted"); - assert!( - uniq_pos < fk_pos, - "FK must be emitted AFTER its UNIQUE target; got uniq@{} fk@{}: {}", - uniq_pos, - fk_pos, - script - ); -} - -/// Regression for the inheritance_child idempotency bug — pgc was -/// dumping classical-inheritance children with `partition_of` wrongly -/// set (because `pg_inherits` records both partition and classical -/// inheritance), which made `column_type_change_forces_recreate` fire -/// and trigger a wholesale drop+recreate. After migration the -/// recreated child silently picked up the *current* default -/// privileges, leaving stray REVOKE statements in the next -/// `pgc compare` pass. -/// -/// This test pins down the post-fix invariant: a classical-inheritance -/// child (`partition_of = None`, `inherits_from = [parent]`) with a -/// column type change must NOT be flagged for wholesale recreate. -/// The dump-side fix that produces this shape (filtering -/// `pg_inherits` joins by `parent.relkind = 'p'`) lives in -/// `fetch_partition_info_bulk` and cannot be unit-tested without a -/// live PostgreSQL connection, but the comparer-side gate has its own -/// expectations and those are what this test enforces. -#[tokio::test] -async fn inheritance_child_classical_inheritance_does_not_force_recreate() { - let make_inheritance_child = |child_data_type: &str, max_len: Option| { - let mut child_col = int_column("test_deps", "inheritance_child", "child_data", 1); - child_col.data_type = child_data_type.to_string(); - child_col.character_maximum_length = max_len; - - let mut t = Table::new( - "test_deps".to_string(), - "inheritance_child".to_string(), - "test_deps".to_string(), - "inheritance_child".to_string(), - "postgres".to_string(), - None, - vec![child_col], - vec![], - vec![], - vec![], - None, - ); - // Classical inheritance: parent is a regular table; partition_of - // stays None, inherits_from carries the parent reference. With - // the pre-fix dump query, partition_of would have been - // erroneously set here too — that mis-shape is exactly what - // this test forbids. - t.inherits_from = vec!["test_deps.inheritance_parent".to_string()]; - t.hash(); - t - }; - - let from_table = make_inheritance_child("text", None); - let to_table = make_inheritance_child("character varying", Some(255)); - - // The comparer-side predicate must NOT classify this column change - // as a wholesale recreate. PostgreSQL accepts in-place - // `ALTER TABLE … ALTER COLUMN child_data TYPE varchar(255)` on a - // classical-inheritance child, and dropping the child wholesale - // would leak default-privilege grants onto the recreated table. - assert!( - !from_table.will_be_dropped_and_recreated(&to_table), - "classical-inheritance child with column type change must NOT \ - be flagged for wholesale recreate (partition_of: {:?}, \ - inherits_from: {:?})", - from_table.partition_of, - from_table.inherits_from, - ); - - // Sanity counter-test: same column change on a real partition - // child (partition_of = Some, inherits_from = []) SHOULD force - // wholesale recreate — PG forbids in-place type changes on - // partition-key columns and partition-inherited columns. - let mut from_partition_child = make_inheritance_child("text", None); - from_partition_child.inherits_from = Vec::new(); - from_partition_child.partition_of = Some("test_deps.parent_partitioned".to_string()); - let mut to_partition_child = make_inheritance_child("character varying", Some(255)); - to_partition_child.inherits_from = Vec::new(); - to_partition_child.partition_of = Some("test_deps.parent_partitioned".to_string()); - assert!( - from_partition_child.will_be_dropped_and_recreated(&to_partition_child), - "real partition child with column type change MUST be flagged \ - for wholesale recreate" - ); -} - -/// Issue #191: a mutual FK cycle (`A → B` and `B → A`) flipping -/// persistence in the same direction has NO valid SET LOGGED|UNLOGGED -/// order — `SET UNLOGGED A` requires B to already be UNLOGGED and -/// vice versa. Pre-fix `kahn_toposort`'s fallback appended cyclic -/// nodes alphabetically, the migration emitted the SETs in that -/// order, and PostgreSQL rejected the second SET at apply time with -/// the same `could not change table … to logged/unlogged` error -/// issue #180 was meant to eliminate. Fix: detect the cycle, drop -/// every FK whose endpoints both sit in the cyclic set BEFORE the -/// SETs, then re-add them from their TO definitions AFTER. The -/// post-fix migration is therefore: DROP cycle FKs → SET both -/// (any order) → ADD cycle FKs. -#[tokio::test] -async fn issue191_persistence_flip_breaks_mutual_fk_cycle() { - fn make_cycle_table(name: &str, is_unlogged: bool, fk: (&str, &str, &str)) -> Table { - let (fk_col, target_schema, target_table) = fk; - - let mut id_col = int_column("test_cycle", name, "id", 1); - id_col.is_nullable = false; - - let mut ref_col = int_column("test_cycle", name, fk_col, 2); - ref_col.is_nullable = true; - - let pk = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_cycle".to_string(), - name: format!("{name}_pkey"), - table_name: name.to_string(), - constraint_type: "PRIMARY KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("PRIMARY KEY (id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - // Deferrable FK — PostgreSQL only permits mutual FK cycles - // when both FKs are deferrable; without DEFERRABLE the cycle - // can't be inserted/seeded in the first place. - let fk_constraint = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_cycle".to_string(), - name: format!("{name}_{fk_col}_fkey"), - table_name: name.to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: true, - initially_deferred: true, - definition: Some(format!( - "FOREIGN KEY ({fk_col}) REFERENCES {target_schema}.{target_table}(id) DEFERRABLE INITIALLY DEFERRED" - )), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let mut table = Table::new( - "test_cycle".to_string(), - name.to_string(), - "test_cycle".to_string(), - name.to_string(), - "postgres".to_string(), - None, - vec![id_col, ref_col], - vec![pk, fk_constraint], - vec![], - vec![], - None, - ); - table.is_unlogged = is_unlogged; - table.hash(); - table - } - - // FROM: both LOGGED, mutual deferrable FKs. - // TO: both UNLOGGED, same FKs (live edges — unchanged). - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump - .tables - .push(make_cycle_table("a", false, ("b_id", "test_cycle", "b"))); - from_dump - .tables - .push(make_cycle_table("b", false, ("a_id", "test_cycle", "a"))); - to_dump - .tables - .push(make_cycle_table("a", true, ("b_id", "test_cycle", "b"))); - to_dump - .tables - .push(make_cycle_table("b", true, ("a_id", "test_cycle", "a"))); - - // `use_drop=true` — this test validates the *active* cycle-break - // path (drops and re-adds emitted live). The `use_drop=false` - // semantics are covered separately by - // `issue191_pr198_use_drop_false_comments_out_cycle_break`. - // PR #198 review: an earlier revision used `use_drop=false` here - // and the substring-based `script.find` assertions were - // false-positives — they matched the commented-out `-- alter - // table … drop constraint …` lines and never actually verified - // the live path. - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - // Cycle-break banner must appear so the choice is loud. - assert!( - script.contains("Persistence-flip FK cycle (issue #191)"), - "cycle banner must mark the drop+SET+add block: {}", - script - ); - - // Both cycle FKs must be dropped BEFORE either SET UNLOGGED. - // Anchor each `find` to a leading newline so the assertion - // distinguishes the live statement from a commented `-- alter - // table …` prefix — this is the PR #198 review fix. - let drop_a_pos = script - .find("\nalter table test_cycle.a drop constraint a_b_id_fkey;") - .expect("FK a_b_id_fkey must be dropped before SET (live, not commented)"); - let drop_b_pos = script - .find("\nalter table test_cycle.b drop constraint b_a_id_fkey;") - .expect("FK b_a_id_fkey must be dropped before SET (live, not commented)"); - assert!( - !script.contains("-- alter table test_cycle.a drop constraint a_b_id_fkey;"), - "DROP CONSTRAINT must be emitted LIVE under use_drop=true, not commented: {}", - script - ); - assert!( - !script.contains("-- alter table test_cycle.b drop constraint b_a_id_fkey;"), - "DROP CONSTRAINT must be emitted LIVE under use_drop=true, not commented: {}", - script - ); - let set_a_pos = script - .find("\nalter table test_cycle.a set unlogged;") - .expect("a SET UNLOGGED must be emitted"); - let set_b_pos = script - .find("\nalter table test_cycle.b set unlogged;") - .expect("b SET UNLOGGED must be emitted"); - assert!( - drop_a_pos < set_a_pos && drop_a_pos < set_b_pos, - "FK a_b_id_fkey drop must precede every SET: drop@{} a@{} b@{}\n{}", - drop_a_pos, - set_a_pos, - set_b_pos, - script - ); - assert!( - drop_b_pos < set_a_pos && drop_b_pos < set_b_pos, - "FK b_a_id_fkey drop must precede every SET: drop@{} a@{} b@{}\n{}", - drop_b_pos, - set_a_pos, - set_b_pos, - script - ); - - // Both cycle FKs must be re-added AFTER every SET so the post- - // migration state matches TO. PR #198 review: the re-emit path - // now goes through `TableConstraint::get_script()`, which - // lowercases SQL keywords outside literals — match on the - // lowercase form. The newline anchor again separates live ADDs - // from any `-- alter table … add constraint …` form. - let add_a_pos = script - .find("\nalter table test_cycle.a add constraint a_b_id_fkey foreign key") - .expect("FK a_b_id_fkey must be re-added after SET (live, not commented)"); - let add_b_pos = script - .find("\nalter table test_cycle.b add constraint b_a_id_fkey foreign key") - .expect("FK b_a_id_fkey must be re-added after SET (live, not commented)"); - assert!( - !script.contains("-- alter table test_cycle.a add constraint a_b_id_fkey"), - "ADD CONSTRAINT must be emitted LIVE under use_drop=true, not commented: {}", - script - ); - assert!( - !script.contains("-- alter table test_cycle.b add constraint b_a_id_fkey"), - "ADD CONSTRAINT must be emitted LIVE under use_drop=true, not commented: {}", - script - ); - assert!( - add_a_pos > set_a_pos && add_a_pos > set_b_pos, - "FK a_b_id_fkey re-add must follow every SET: add@{} a@{} b@{}\n{}", - add_a_pos, - set_a_pos, - set_b_pos, - script - ); - assert!( - add_b_pos > set_a_pos && add_b_pos > set_b_pos, - "FK b_a_id_fkey re-add must follow every SET: add@{} a@{} b@{}\n{}", - add_b_pos, - set_a_pos, - set_b_pos, - script - ); -} - -/// Issue #191 counter-test: an acyclic FK chain (no cycle) must NOT -/// emit cycle-break drops/re-adds. Locks the cycle path to only the -/// cycle case so we don't regress and start dropping FKs on every -/// persistence flip. -#[tokio::test] -async fn issue191_persistence_flip_acyclic_chain_does_not_drop_fks() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump - .tables - .push(issue180_logged_table("test_order", "parent", false, None)); - from_dump.tables.push(issue180_logged_table( - "test_order", - "child", - false, - Some(("parent_id", "test_order", "parent")), - )); - to_dump - .tables - .push(issue180_logged_table("test_order", "parent", true, None)); - to_dump.tables.push(issue180_logged_table( - "test_order", - "child", - true, - Some(("parent_id", "test_order", "parent")), - )); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.contains("Persistence-flip FK cycle"), - "acyclic chain must not trip the cycle-break path: {}", - script - ); - assert!( - !script.contains("drop constraint child_parent_id_fkey"), - "live FK on an acyclic chain must not be dropped: {}", - script - ); -} - -/// Issue #191 / PR #198 review: when a cycle exists alongside an -/// edge that's *blocked by* the cycle but not in it (e.g. `A ↔ B` -/// plus `A → C` from outside the cycle, with all three flipping -/// persistence in the same direction), Kahn's "couldn't-be-ordered" -/// remainder includes C — even though C is not part of any directed -/// cycle. Pre-refinement the comparer treated the entire remainder -/// as cycle participants and dropped the `A → C` FK alongside the -/// true cycle edges, making the migration more destructive than -/// needed. Tarjan's SCC narrows the cycle set to nodes in -/// strongly-connected components of size >= 2, so only the true -/// cycle edges get dropped. -#[tokio::test] -async fn issue191_pr198_cycle_detection_excludes_blocked_non_cycle_nodes() { - fn build(name: &str, is_unlogged: bool, fk: Option<(&str, &str, &str)>) -> Table { - let mut id_col = int_column("test_cycle", name, "id", 1); - id_col.is_nullable = false; - - let pk = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_cycle".to_string(), - name: format!("{name}_pkey"), - table_name: name.to_string(), - constraint_type: "PRIMARY KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("PRIMARY KEY (id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let mut constraints = vec![pk]; - let mut columns = vec![id_col]; - - if let Some((fk_col, target_schema, target_table)) = fk { - let mut ref_col = int_column("test_cycle", name, fk_col, 2); - ref_col.is_nullable = true; - columns.push(ref_col); - constraints.push(TableConstraint { - catalog: "postgres".to_string(), - schema: "test_cycle".to_string(), - name: format!("{name}_{fk_col}_fkey"), - table_name: name.to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: true, - initially_deferred: true, - definition: Some(format!( - "FOREIGN KEY ({fk_col}) REFERENCES {target_schema}.{target_table}(id) DEFERRABLE INITIALLY DEFERRED" - )), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }); - } - - let mut t = Table::new( - "test_cycle".to_string(), - name.to_string(), - "test_cycle".to_string(), - name.to_string(), - "postgres".to_string(), - None, - columns, - constraints, - vec![], - vec![], - None, - ); - t.is_unlogged = is_unlogged; - t.hash(); - t - } - - // Build three tables: - // a ↔ b (cycle: a → b and b → a) - // a → c (non-cycle: a depends on c, but c does not depend on a) - // All three flip LOGGED → UNLOGGED. The cycle set is {a, b}; the - // FK `a_c_id_fkey` is NOT in any cycle and must survive the - // cycle break. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - // FROM: all LOGGED. `a` has TWO FKs: a → b (cycle), a → c (not). - let mut a_from = build("a", false, Some(("b_id", "test_cycle", "b"))); - a_from.columns.push({ - let mut c_id = int_column("test_cycle", "a", "c_id", 3); - c_id.is_nullable = true; - c_id - }); - a_from.constraints.push(TableConstraint { - catalog: "postgres".to_string(), - schema: "test_cycle".to_string(), - name: "a_c_id_fkey".to_string(), - table_name: "a".to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("FOREIGN KEY (c_id) REFERENCES test_cycle.c(id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }); - a_from.hash(); - from_dump.tables.push(a_from); - from_dump - .tables - .push(build("b", false, Some(("a_id", "test_cycle", "a")))); - from_dump.tables.push(build("c", false, None)); - - // TO: all UNLOGGED. Same constraint shapes. - let mut a_to = build("a", true, Some(("b_id", "test_cycle", "b"))); - a_to.columns.push({ - let mut c_id = int_column("test_cycle", "a", "c_id", 3); - c_id.is_nullable = true; - c_id - }); - a_to.constraints.push(TableConstraint { - catalog: "postgres".to_string(), - schema: "test_cycle".to_string(), - name: "a_c_id_fkey".to_string(), - table_name: "a".to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("FOREIGN KEY (c_id) REFERENCES test_cycle.c(id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }); - a_to.hash(); - to_dump.tables.push(a_to); - to_dump - .tables - .push(build("b", true, Some(("a_id", "test_cycle", "a")))); - to_dump.tables.push(build("c", true, None)); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - // Cycle banner must appear (a↔b cycle is present). - assert!( - script.contains("Persistence-flip FK cycle (issue #191)"), - "cycle banner must appear for the a↔b cycle: {}", - script - ); - // True cycle FKs must be dropped. - assert!( - script.contains("alter table test_cycle.a drop constraint a_b_id_fkey;"), - "true cycle FK a_b_id_fkey must be dropped: {}", - script - ); - assert!( - script.contains("alter table test_cycle.b drop constraint b_a_id_fkey;"), - "true cycle FK b_a_id_fkey must be dropped: {}", - script - ); - // The non-cycle FK (a → c) is merely *blocked by* the cycle in - // Kahn's remainder but is not part of any directed cycle. With - // SCC-based detection it must NOT be dropped. - assert!( - !script.contains("drop constraint a_c_id_fkey"), - "non-cycle FK a_c_id_fkey (a → c) must NOT be dropped: {}", - script - ); -} - -/// Issue #191 / PR #198 review: when `use_drop=false`, the cycle- -/// break drops and re-adds must be commented out, with a loud banner -/// explaining that the SETs will fail without manual intervention. -/// The user has explicitly asked the comparer to surface destructive -/// statements for review rather than emit them live. -#[tokio::test] -async fn issue191_pr198_use_drop_false_comments_out_cycle_break() { - fn make_cycle_table(name: &str, is_unlogged: bool, fk: (&str, &str, &str)) -> Table { - let (fk_col, target_schema, target_table) = fk; - let mut id_col = int_column("test_cycle", name, "id", 1); - id_col.is_nullable = false; - let mut ref_col = int_column("test_cycle", name, fk_col, 2); - ref_col.is_nullable = true; - - let pk = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_cycle".to_string(), - name: format!("{name}_pkey"), - table_name: name.to_string(), - constraint_type: "PRIMARY KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("PRIMARY KEY (id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - let fk_constraint = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_cycle".to_string(), - name: format!("{name}_{fk_col}_fkey"), - table_name: name.to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: true, - initially_deferred: true, - definition: Some(format!( - "FOREIGN KEY ({fk_col}) REFERENCES {target_schema}.{target_table}(id) DEFERRABLE INITIALLY DEFERRED" - )), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - - let mut table = Table::new( - "test_cycle".to_string(), - name.to_string(), - "test_cycle".to_string(), - name.to_string(), - "postgres".to_string(), - None, - vec![id_col, ref_col], - vec![pk, fk_constraint], - vec![], - vec![], - None, - ); - table.is_unlogged = is_unlogged; - table.hash(); - table - } - - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump - .tables - .push(make_cycle_table("a", false, ("b_id", "test_cycle", "b"))); - from_dump - .tables - .push(make_cycle_table("b", false, ("a_id", "test_cycle", "a"))); - to_dump - .tables - .push(make_cycle_table("a", true, ("b_id", "test_cycle", "b"))); - to_dump - .tables - .push(make_cycle_table("b", true, ("a_id", "test_cycle", "a"))); - - // use_drop=false — drops and re-adds must be commented out. - // `use_comments=true` so the banner and commented-out lines - // survive `get_script`'s output (which strips comments under - // `use_comments=false`). - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - // Loud banner specifically calls out use_drop=false semantics. - assert!( - script.contains("use_drop=false"), - "banner must mention use_drop=false: {}", - script - ); - // DROP CONSTRAINT lines must be commented out (i.e. they appear - // only as `-- alter table ... drop constraint ...;`). - assert!( - !script.contains("\nalter table test_cycle.a drop constraint a_b_id_fkey;"), - "live DROP CONSTRAINT must NOT be emitted under use_drop=false: {}", - script - ); - assert!( - script.contains("-- alter table test_cycle.a drop constraint a_b_id_fkey;"), - "commented DROP CONSTRAINT must be emitted under use_drop=false: {}", - script - ); - // ADD CONSTRAINT lines must also be commented out so re-running - // with use_drop=true after manual review produces a clean diff. - assert!( - !script.contains("\nalter table test_cycle.a add constraint a_b_id_fkey foreign key"), - "live ADD CONSTRAINT must NOT be emitted under use_drop=false: {}", - script - ); - assert!( - script.contains("-- alter table test_cycle.a add constraint a_b_id_fkey foreign key"), - "commented ADD CONSTRAINT must be emitted under use_drop=false: {}", - script - ); - // SET statements are NOT destructive and stay live, matching how - // SETs are handled elsewhere when use_drop=false (the cycle case - // is highlighted by the banner above). - assert!( - script.contains("alter table test_cycle.a set unlogged;"), - "SET UNLOGGED must remain live under use_drop=false: {}", - script - ); -} - -/// Issue #191 / PR #198 review: the cycle-break re-emit path goes -/// through `TableConstraint::get_script()`, which appends -/// `COMMENT ON CONSTRAINT ...` when the FK has a comment. Verify -/// the comment survives the drop+SET+re-add round-trip — i.e. the -/// emitted re-add carries the `comment on constraint` clause from -/// the TO-side metadata so the post-migration schema matches TO. -#[tokio::test] -async fn issue191_pr198_cycle_fk_comment_survives_round_trip() { - fn cycle_table_with_comment(name: &str, is_unlogged: bool, fk: (&str, &str, &str)) -> Table { - let (fk_col, target_schema, target_table) = fk; - let mut id_col = int_column("test_cycle", name, "id", 1); - id_col.is_nullable = false; - let mut ref_col = int_column("test_cycle", name, fk_col, 2); - ref_col.is_nullable = true; - - let pk = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_cycle".to_string(), - name: format!("{name}_pkey"), - table_name: name.to_string(), - constraint_type: "PRIMARY KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("PRIMARY KEY (id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - let fk_constraint = TableConstraint { - catalog: "postgres".to_string(), - schema: "test_cycle".to_string(), - name: format!("{name}_{fk_col}_fkey"), - table_name: name.to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: true, - initially_deferred: true, - definition: Some(format!( - "FOREIGN KEY ({fk_col}) REFERENCES {target_schema}.{target_table}(id) DEFERRABLE INITIALLY DEFERRED" - )), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: Some(format!("FK {name} → {target_table} (cycle annotated)")), - }; - - let mut table = Table::new( - "test_cycle".to_string(), - name.to_string(), - "test_cycle".to_string(), - name.to_string(), - "postgres".to_string(), - None, - vec![id_col, ref_col], - vec![pk, fk_constraint], - vec![], - vec![], - None, - ); - table.is_unlogged = is_unlogged; - table.hash(); - table - } - - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump.tables.push(cycle_table_with_comment( - "a", - false, - ("b_id", "test_cycle", "b"), - )); - from_dump.tables.push(cycle_table_with_comment( - "b", - false, - ("a_id", "test_cycle", "a"), - )); - to_dump.tables.push(cycle_table_with_comment( - "a", - true, - ("b_id", "test_cycle", "b"), - )); - to_dump.tables.push(cycle_table_with_comment( - "b", - true, - ("a_id", "test_cycle", "a"), - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_tables().await.unwrap(); - let script = comparer.get_script(); - - // The re-add must include the `comment on constraint` clause — - // proof that the cycle-break path round-trips full - // `TableConstraint` metadata via `get_script()`, not just the - // raw `(schema, table, name, definition)` tuple. - assert!( - script.contains( - "comment on constraint a_b_id_fkey on test_cycle.a is 'FK a → b (cycle annotated)';" - ), - "FK comment on a_b_id_fkey must be re-emitted after the SET: {}", - script - ); - assert!( - script.contains( - "comment on constraint b_a_id_fkey on test_cycle.b is 'FK b → a (cycle annotated)';" - ), - "FK comment on b_a_id_fkey must be re-emitted after the SET: {}", - script - ); -} - -#[tokio::test] -async fn output_for_production_defers_concurrent_index_and_validates_fk() { - // New table with one index and one foreign key. Production mode must build - // the index CONCURRENTLY after COMMIT, add the FK NOT VALID inside the - // transaction, and VALIDATE it after COMMIT. - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let index = TableIndex { - schema: "public".to_string(), - table: "orders".to_string(), - name: "idx_orders_total".to_string(), - catalog: None, - indexdef: "CREATE INDEX idx_orders_total ON public.orders USING btree (total)".to_string(), - is_partition_index: false, - comment: None, - }; - let fk = TableConstraint { - catalog: "postgres".to_string(), - schema: "public".to_string(), - name: "fk_orders_customer".to_string(), - table_name: "orders".to_string(), - constraint_type: "FOREIGN KEY".to_string(), - is_deferrable: false, - initially_deferred: false, - definition: Some("FOREIGN KEY (customer_id) REFERENCES public.customers (id)".to_string()), - coninhcount: 0, - is_enforced: true, - no_inherit: false, - nulls_not_distinct: false, - comment: None, - }; - let table = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "postgres".to_string(), - None, - vec![], - vec![fk], - vec![index], - vec![], - None, - ); - to_dump.tables.push(table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, true, true, GrantsMode::Ignore); - comparer.set_output_for_production(true); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let commit_pos = script.find("commit;").expect("script must contain commit;"); - let concurrent_pos = script - .find( - "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_total ON public.orders USING btree (total);", - ) - .expect("concurrent index build must be present"); - let validate_pos = script - .find("validate constraint fk_orders_customer;") - .expect("FK validation must be present"); - let not_valid_pos = script - .find("not valid;") - .expect("FK must be added NOT VALID"); - - // Concurrent build and FK validation run after COMMIT. - assert!( - concurrent_pos > commit_pos, - "concurrent index must come after commit;\n{script}" - ); - assert!( - validate_pos > commit_pos, - "validate constraint must come after commit;\n{script}" - ); - // The FK is added NOT VALID before COMMIT. - assert!( - not_valid_pos < commit_pos, - "FK NOT VALID must be added before commit;\n{script}" - ); - // The in-transaction CREATE TABLE must not carry an inline (non-concurrent) - // index build. - let in_txn = &script[..commit_pos]; - assert!( - !in_txn.contains("CREATE INDEX idx_orders_total ON public.orders"), - "index must be deferred out of the in-transaction create:\n{in_txn}" - ); -} - -#[tokio::test] -async fn output_for_production_disabled_keeps_inline_index() { - // Same shape as above but with the flag off: the index is inline in the - // CREATE TABLE and there is no post-commit / CONCURRENTLY output. - let from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let index = TableIndex { - schema: "public".to_string(), - table: "orders".to_string(), - name: "idx_orders_total".to_string(), - catalog: None, - indexdef: "CREATE INDEX idx_orders_total ON public.orders USING btree (total)".to_string(), - is_partition_index: false, - comment: None, - }; - let table = Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "postgres".to_string(), - None, - vec![], - vec![], - vec![index], - vec![], - None, - ); - to_dump.tables.push(table); - - let mut comparer = Comparer::new(from_dump, to_dump, false, true, true, GrantsMode::Ignore); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("CREATE INDEX idx_orders_total ON public.orders USING btree (total);"), - "default mode keeps the inline index:\n{script}" - ); - assert!( - !script.contains("CONCURRENTLY"), - "default mode must not emit CONCURRENTLY:\n{script}" - ); - assert!( - !script.contains("Production post-commit"), - "default mode must not emit a post-commit section:\n{script}" - ); -} - -#[tokio::test] -async fn production_header_mentions_commit_only_with_single_transaction() { - // The production header comment must not claim post-transaction statements - // run "after COMMIT" when --use-single-transaction is off (no COMMIT is - // emitted in that valid configuration). - let build = || { - let mut to_dump = Dump::new(DumpConfig::default()); - to_dump.tables.push(Table::new( - "public".to_string(), - "orders".to_string(), - "public".to_string(), - "orders".to_string(), - "postgres".to_string(), - None, - vec![], - vec![], - vec![], - vec![], - None, - )); - (Dump::new(DumpConfig::default()), to_dump) - }; - - // With single transaction: header says "after COMMIT" and a commit; exists. - let (from_txn, to_txn) = build(); - let mut with_txn = Comparer::new(from_txn, to_txn, false, true, true, GrantsMode::Ignore); - with_txn.set_output_for_production(true); - with_txn.compare().await.unwrap(); - let txn_script = with_txn.get_script(); - assert!( - txn_script.contains("are emitted after COMMIT"), - "single-transaction header must mention COMMIT:\n{txn_script}" - ); - assert!(txn_script.contains("commit;"), "{txn_script}"); - - // Without single transaction: no COMMIT, so the header must not claim one. - let (from_no, to_no) = build(); - let mut no_txn = Comparer::new(from_no, to_no, false, false, true, GrantsMode::Ignore); - no_txn.set_output_for_production(true); - no_txn.compare().await.unwrap(); - let no_txn_script = no_txn.get_script(); - assert!( - !no_txn_script.contains("commit;"), - "no transaction must be opened without --use-single-transaction:\n{no_txn_script}" - ); - assert!( - !no_txn_script.contains("after COMMIT"), - "header must not claim 'after COMMIT' without a transaction:\n{no_txn_script}" - ); - assert!( - no_txn_script.contains("are emitted in a separate section at the end"), - "header must describe the trailing section accurately:\n{no_txn_script}" - ); -} - -// ------ OR-REPLACE incompatible column changes (issue #227) ------ - -fn view_227(name: &str, definition: &str, cols: &[(&str, &str)]) -> View { - let mut v = View::new( - name.to_string(), - definition.to_string(), - "public".to_string(), - vec![], - ); - v.columns = cols - .iter() - .map(|(n, t)| crate::dump::view::ViewColumn { - name: n.to_string(), - data_type: t.to_string(), - collation: None, - }) - .collect(); - v.hash(); - v -} - -#[tokio::test] -async fn incompatible_view_column_change_drops_and_recreates() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump.views.push(view_227( - "item_v", - "select id, profile_id from public.item", - &[("id", "integer"), ("profile_id", "integer")], - )); - to_dump.views.push(view_227( - "item_v", - "select id, kind, profile_id from public.item", - &[ - ("id", "integer"), - ("kind", "text"), - ("profile_id", "integer"), - ], - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - let script = comparer.get_script(); - let has_active_drop = script - .lines() - .any(|l| !l.starts_with("--") && l.contains("drop view if exists public.item_v")); - assert!( - has_active_drop, - "incompatible column change must emit an active DROP VIEW:\n{script}" - ); -} - -#[tokio::test] -async fn compatible_view_column_append_keeps_or_replace_without_drop() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump.views.push(view_227( - "item_v", - "select id from public.item", - &[("id", "integer")], - )); - to_dump.views.push(view_227( - "item_v", - "select id, kind from public.item", - &[("id", "integer"), ("kind", "text")], - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - let script = comparer.get_script(); - assert!( - !script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")), - "appending a column at the end must not drop the view:\n{script}" - ); -} - -#[tokio::test] -async fn changed_views_without_column_data_keep_or_replace() { - // Dumps written by an older pgc carry no column data; the historical - // CREATE OR REPLACE behavior must be preserved for them. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - from_dump.views.push(view_227( - "item_v", - "select id, profile_id from public.item", - &[], - )); - to_dump.views.push(view_227( - "item_v", - "select id, kind, profile_id from public.item", - &[], - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - let script = comparer.get_script(); - assert!( - !script - .lines() - .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")), - "without column data the view must not be dropped:\n{script}" - ); -} - -#[tokio::test] -async fn dependent_view_is_dropped_before_incompatible_base_view() { - // v2 reads item_v and is unchanged; item_v changes incompatibly. DROP VIEW runs - // without CASCADE, so v2 must be pulled into the drop set and dropped first. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump.views.push(view_227( - "item_v", - "select id, profile_id from public.item", - &[("id", "integer"), ("profile_id", "integer")], - )); - let mut from_dep = view_227("v2", "select id from public.item_v", &[("id", "integer")]); - from_dep.table_relation = vec!["public.item_v".to_string()]; - from_dep.hash(); - from_dump.views.push(from_dep); - - to_dump.views.push(view_227( - "item_v", - "select id, kind, profile_id from public.item", - &[ - ("id", "integer"), - ("kind", "text"), - ("profile_id", "integer"), - ], - )); - let mut to_dep = view_227("v2", "select id from public.item_v", &[("id", "integer")]); - to_dep.table_relation = vec!["public.item_v".to_string()]; - to_dep.hash(); - to_dump.views.push(to_dep); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - let script = comparer.get_script(); - - let drop_dep = script - .find("drop view if exists public.v2;") - .expect("dependent view must be dropped too"); - let drop_base = script - .find("drop view if exists public.item_v;") - .expect("base view must be dropped"); - assert!( - drop_dep < drop_base, - "dependent must drop before the view it reads:\n{script}" - ); -} - -// ── Issue #235: indexes on a materialized view ────────────────────────────── - -fn mv235_index(name: &str, indexdef: &str) -> TableIndex { - TableIndex { - schema: "test_schema".to_string(), - table: "mv".to_string(), - name: name.to_string(), - catalog: None, - indexdef: indexdef.to_string(), - is_partition_index: false, - comment: None, - } -} - -fn mv235_view(definition: &str, indexes: Vec) -> View { - let mut view = View::new( - "mv".to_string(), - definition.to_string(), - "test_schema".to_string(), - vec!["test_schema.base".to_string()], - ); - view.is_materialized = true; - view.indexes = indexes; - view.hash(); - view -} - -const MV235_IX_VAL: &str = "CREATE INDEX ix_val ON test_schema.mv USING btree (val)"; -const MV235_IX_ID: &str = "CREATE UNIQUE INDEX ix_id ON test_schema.mv USING btree (id)"; - -#[tokio::test] -async fn issue235_recreated_matview_restores_its_indexes() { - // The definition changes, so the view is dropped and rebuilt. DROP - // MATERIALIZED VIEW takes the indexes with it and nothing put them back: - // the migration silently left the view unindexed, and because neither dump - // carried the indexes the round-2 diff was empty and never reported it. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump.views.push(mv235_view( - "SELECT id, val FROM test_schema.base;", - vec![ - mv235_index("ix_id", MV235_IX_ID), - mv235_index("ix_val", MV235_IX_VAL), - ], - )); - to_dump.views.push(mv235_view( - "SELECT id, val, num FROM test_schema.base;", - vec![ - mv235_index("ix_id", MV235_IX_ID), - mv235_index("ix_val", MV235_IX_VAL), - ], - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - let drop_pos = script - .find("drop materialized view if exists test_schema.mv;") - .expect("changed materialized view must be dropped"); - let create_pos = script - .find("create materialized view test_schema.mv as") - .expect("changed materialized view must be recreated"); - let id_pos = script - .find("CREATE UNIQUE INDEX ix_id ON test_schema.mv USING btree (id);") - .expect("the unique index must be recreated with the view"); - let val_pos = script - .find("CREATE INDEX ix_val ON test_schema.mv USING btree (val);") - .expect("the plain index must be recreated with the view"); - - assert!(drop_pos < create_pos, "drop must precede create:\n{script}"); - assert!( - create_pos < id_pos && create_pos < val_pos, - "indexes must be built after the view exists:\n{script}" - ); -} - -#[tokio::test] -async fn issue235_unchanged_matview_reconciles_indexes_in_place() { - // Same definition on both sides, so the view survives; only the index set - // moves. It must not be dropped and rebuilt just to change an index. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let definition = "SELECT id, val FROM test_schema.base;"; - let mut recommented = mv235_index("ix_cmt", "CREATE INDEX ix_cmt ON test_schema.mv (num)"); - recommented.comment = Some("after".to_string()); - - from_dump.views.push(mv235_view( - definition, - vec![ - mv235_index("ix_cmt", "CREATE INDEX ix_cmt ON test_schema.mv (num)"), - mv235_index("ix_val", MV235_IX_VAL), - ], - )); - to_dump.views.push(mv235_view( - definition, - vec![ - mv235_index("ix_cmt", "CREATE INDEX ix_cmt ON test_schema.mv (num)"), - mv235_index("ix_id", MV235_IX_ID), - recommented, - ], - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - !script.to_lowercase().contains("drop materialized view"), - "an index change must not rebuild the view's contents:\n{script}" - ); - assert!( - !script.to_lowercase().contains("create materialized view"), - "an index change must not rebuild the view's contents:\n{script}" - ); - assert!( - script.contains("drop index if exists test_schema.ix_val;"), - "the removed index must be dropped:\n{script}" - ); - assert!( - script.contains("CREATE UNIQUE INDEX ix_id ON test_schema.mv USING btree (id);"), - "the added index must be created:\n{script}" - ); - assert!( - script.contains("comment on index test_schema.ix_cmt is 'after';"), - "a comment-only change must not touch the index itself:\n{script}" - ); - assert!( - !script.contains("drop index if exists test_schema.ix_cmt;"), - "a comment-only change must not drop the index:\n{script}" - ); -} - -#[tokio::test] -async fn issue235_matview_with_identical_indexes_emits_nothing() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let definition = "SELECT id, val FROM test_schema.base;"; - from_dump.views.push(mv235_view( - definition, - vec![mv235_index("ix_val", MV235_IX_VAL)], - )); - to_dump.views.push(mv235_view( - definition, - vec![mv235_index("ix_val", MV235_IX_VAL)], - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, false, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.trim().is_empty(), - "an unchanged view with unchanged indexes must produce no SQL:\n{script}" - ); -} - -#[tokio::test] -async fn issue235_matview_index_drop_is_commented_out_when_use_drop_is_false() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let definition = "SELECT id, val FROM test_schema.base;"; - from_dump.views.push(mv235_view( - definition, - vec![mv235_index("ix_val", MV235_IX_VAL)], - )); - to_dump.views.push(mv235_view(definition, Vec::new())); - - let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); - comparer.drop_views().await.unwrap(); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("-- drop index if exists test_schema.ix_val;"), - "use_drop=false must comment the index drop out, as it does for a table:\n{script}" - ); - assert!( - !script - .lines() - .any(|l| !l.trim_start().starts_with("--") && l.contains("drop index")), - "no active drop may survive use_drop=false:\n{script}" - ); -} - -#[tokio::test] -async fn issue235_production_mode_builds_matview_indexes_concurrently_after_commit() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump.views.push(mv235_view( - "SELECT id, val FROM test_schema.base;", - vec![mv235_index( - "ix_stale", - "CREATE INDEX ix_stale ON test_schema.mv (num)", - )], - )); - to_dump.views.push(mv235_view( - "SELECT id, val, num FROM test_schema.base;", - vec![mv235_index("ix_id", MV235_IX_ID)], - )); - - let mut comparer = Comparer::new(from_dump, to_dump, true, true, true, GrantsMode::Ignore); - comparer.set_output_for_production(true); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let commit_pos = script.find("commit;").expect("script must contain commit;"); - let create_pos = script - .find("create materialized view if not exists test_schema.mv as") - .expect("the view itself is still built inside the transaction"); - let concurrent_pos = script - .find("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ix_id ON test_schema.mv USING btree (id);") - .expect("a materialized view's index must be built concurrently in production mode"); - - assert!(create_pos < commit_pos, "view create is in-txn:\n{script}"); - assert!( - concurrent_pos > commit_pos, - "CREATE INDEX CONCURRENTLY cannot run inside a transaction block:\n{script}" - ); -} - -#[tokio::test] -async fn issue235_production_mode_drops_matview_indexes_concurrently() { - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - let definition = "SELECT id, val FROM test_schema.base;"; - from_dump.views.push(mv235_view( - definition, - vec![mv235_index("ix_val", MV235_IX_VAL)], - )); - to_dump.views.push(mv235_view(definition, Vec::new())); - - let mut comparer = Comparer::new(from_dump, to_dump, true, true, true, GrantsMode::Ignore); - comparer.set_output_for_production(true); - comparer.compare().await.unwrap(); - let script = comparer.get_script(); - - let commit_pos = script.find("commit;").expect("script must contain commit;"); - let drop_pos = script - .find("drop index concurrently if exists test_schema.ix_val;") - .expect("an in-place index drop must be concurrent in production mode"); - assert!( - drop_pos > commit_pos, - "DROP INDEX CONCURRENTLY cannot run inside a transaction block:\n{script}" - ); -} - -#[tokio::test] -async fn issue235_cascade_recreated_matview_guards_its_indexes() { - // Phase 7 restores a materialized view that DROP FUNCTION ... CASCADE may - // have taken out. The match is textual and can false-positive on a view - // PostgreSQL never dropped, so the recreate is guarded — and the indexes - // that would have gone with it need the same guard, or an unconditional - // CREATE INDEX fails against the surviving one. - let mut from_dump = Dump::new(DumpConfig::default()); - let mut to_dump = Dump::new(DumpConfig::default()); - - from_dump - .routines - .push(issue179_compute_routine("integer", "SELECT x * 2;")); - to_dump.routines.push(issue179_compute_routine( - "bigint", - "SELECT (x * 2)::bigint;", - )); - - let mut view = issue189_view("mv_things", true); - let index = TableIndex { - schema: "test_deps".to_string(), - table: "mv_things".to_string(), - name: "ix_mv_things".to_string(), - catalog: None, - indexdef: "CREATE INDEX ix_mv_things ON test_deps.mv_things USING btree (c)".to_string(), - is_partition_index: false, - comment: None, - }; - view.indexes = vec![index]; - from_dump.views.push(view.clone()); - to_dump.views.push(view); - - let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); - comparer.compare_routines_and_views().await.unwrap(); - let script = comparer.get_script(); - - assert!( - script.contains("create materialized view if not exists test_deps.mv_things as"), - "the cascade recreate must stay guarded:\n{script}" - ); - assert!( - script.contains( - "CREATE INDEX IF NOT EXISTS ix_mv_things ON test_deps.mv_things USING btree (c);" - ), - "the recreated view's indexes must be guarded the same way:\n{script}" - ); -} +//! Unit tests for [`Comparer`](super::Comparer). +//! +//! Split by concern; every submodule reaches the private internals of +//! `comparer::core` through `use crate::comparer::core::*;`, and shared +//! fixture builders live in [`helpers`]. + +mod buffer_ordering; +mod cascade_dependents; +mod column_dependents; +mod grants; +mod helpers; +mod matview_indexes; +mod persistence; +mod production; +mod routines; +mod schemas_extensions; +mod script_output; +mod sequences; +mod tables; +mod types; +mod views; diff --git a/app/src/comparer/tests/core/buffer_ordering.rs b/app/src/comparer/tests/core/buffer_ordering.rs new file mode 100644 index 0000000..8631fc9 --- /dev/null +++ b/app/src/comparer/tests/core/buffer_ordering.rs @@ -0,0 +1,263 @@ +//! Emission-order tests for the post-script buffers. +//! +//! [`Comparer::compare`] concatenates several ordered script buffers — +//! main → `sequence_post` → `type_post` → `enum_post` → `trigger_post`. +//! These tests pin that order so dependency-aware rearrangements cannot +//! regress silently. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::schema::Schema; +use crate::dump::sequence::Sequence; +use crate::dump::table::Table; +use crate::dump::table_trigger::TableTrigger; +use sqlx::postgres::types::Oid; + +#[tokio::test] +async fn buffer_ordering_type_drop_before_enum_drop() { + // Both buffers are populated when the FROM dump carries a domain type + // AND an enum that are both absent in the TO dump. type_post must come + // before enum_post in the final script (so enums outlive types that may + // reference them). + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + from_dump + .types + .push(make_domain_type("test_schema", "dropped_domain", 701)); + from_dump.types.push(make_enum_type( + "test_schema", + "dropped_enum", + 702, + vec!["a", "b"], + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let type_drop_pos = script + .find("drop type if exists test_schema.dropped_domain cascade;") + .unwrap_or_else(|| panic!("domain drop missing in:\n{script}")); + let enum_drop_pos = script + .find("drop type if exists test_schema.dropped_enum cascade;") + .unwrap_or_else(|| panic!("enum drop missing in:\n{script}")); + assert!( + type_drop_pos < enum_drop_pos, + "type_post_script must precede enum_post_script, got:\n{script}" + ); +} + +#[tokio::test] +async fn buffer_ordering_enum_drop_before_trigger_create() { + // FROM has an enum to drop (populates enum_post_script). + // TO has a brand-new table with a trigger (populates trigger_post_script + // for the CREATE TRIGGER). enum_post must come before trigger_post so + // that triggers referencing newly-created routines/types run after all + // type-dependency cleanup. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump.types.push(make_enum_type( + "test_schema", + "legacy_status", + 703, + vec!["ok", "err"], + )); + + let mut new_table = Table::new( + "public".to_string(), + "events".to_string(), + "public".to_string(), + "events".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "events", "id", 1)], + vec![], + vec![], + vec![TableTrigger { + oid: Oid(9999), + name: "trg_events_audit".to_string(), + definition: + "create trigger trg_events_audit before insert on public.events for each row execute function audit()" + .to_string(), + enabled: "O".to_string(), + comment: None, + }], + None, + ); + new_table.hash(); + to_dump.tables.push(new_table); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let enum_drop_pos = script + .find("drop type if exists test_schema.legacy_status cascade;") + .unwrap_or_else(|| panic!("enum drop missing in:\n{script}")); + let trigger_create_pos = script + .find("create trigger trg_events_audit") + .unwrap_or_else(|| panic!("CREATE TRIGGER missing in:\n{script}")); + + assert!( + enum_drop_pos < trigger_create_pos, + "enum_post_script must precede trigger_post_script, got:\n{script}" + ); +} + +#[tokio::test] +async fn buffer_ordering_sequence_drop_before_type_drop() { + // FROM has an unowned sequence and a domain type, both absent in TO. + // sequence_post_script is emitted before type_post_script so that + // sequences with default-value dependencies on types are dropped first. + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + let seq = crate::dump::sequence::Sequence::new( + "test_schema".to_string(), + "dropped_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9223372036854775807), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + from_dump.sequences.push(seq); + from_dump + .types + .push(make_domain_type("test_schema", "dropped_domain", 704)); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let seq_drop_pos = script + .find("drop sequence if exists \"test_schema\".\"dropped_seq\"") + .or_else(|| script.find("drop sequence if exists test_schema.dropped_seq")) + .unwrap_or_else(|| panic!("sequence drop missing in:\n{script}")); + let type_drop_pos = script + .find("drop type if exists test_schema.dropped_domain cascade;") + .unwrap_or_else(|| panic!("type drop missing in:\n{script}")); + + assert!( + seq_drop_pos < type_drop_pos, + "sequence_post_script must precede type_post_script, got:\n{script}" + ); +} + +/// Regression for the dependency-scan needle bug. Dump fields are populated +/// via `quote_ident`, so a mixed-case identifier comes back literally +/// quoted (`"MyView"`). Previously the needle kept the quotes and the +/// quote-stripped haystack flavour could never match an unquoted reference, +/// silently dropping a real dependency. +#[test] +fn text_references_qualified_name_pre_matches_unquoted_reference() { + let (lower, unquoted_lower) = Comparer::prelower_pair("SELECT * FROM public.regular_view;"); + // Needle as built from `quote_ident` for a mixed-case identifier. + assert!(Comparer::text_references_qualified_name_pre( + &lower, + &unquoted_lower, + "\"public\"", + "\"regular_view\"", + )); +} + +#[test] +fn text_references_qualified_name_pre_still_matches_quoted_reference() { + let (lower, unquoted_lower) = Comparer::prelower_pair("SELECT * FROM \"MySchema\".\"MyView\";"); + assert!(Comparer::text_references_qualified_name_pre( + &lower, + &unquoted_lower, + "\"myschema\"", + "\"myview\"", + )); +} + +/// Regression test for the `serial_columns` key change from a joined +/// `"schema.table.column"` `String` to a `(String, String, String)` tuple. +/// The old form was parsed back via `splitn(3, '.')`, which silently +/// misparsed any identifier containing a literal `.` (legal in PostgreSQL +/// when quoted). With the tuple key, dotted identifiers round-trip cleanly +/// and `mark_serial_columns` still finds the target column. +#[tokio::test] +async fn mark_serial_columns_handles_dotted_identifier_names() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Schema, table, and column names all contain a literal dot — the + // pre-fix `splitn(3, '.')` would slice these in the wrong place and + // fail to locate the column. + let schema = "weird.schema"; + let table = "weird.table"; + let column = "weird.id"; + + let serial_seq = Sequence::new( + schema.to_string(), + format!("{table}_{column}_seq"), + "postgres".to_string(), + "integer".to_string(), + Some(1), + Some(1), + Some(2147483647), + Some(1), + false, + Some(1), + Some(1), + Some(schema.to_string()), + Some(table.to_string()), + Some(column.to_string()), + ); + to_dump.sequences.push(serial_seq); + + let mut col = int_column(schema, table, column, 1); + col.column_default = Some(format!( + "nextval('{schema}.{table}_{column}_seq'::regclass)" + )); + col.is_nullable = false; + + let table_obj = Table::new( + schema.to_string(), + table.to_string(), + schema.to_string(), + table.to_string(), + "postgres".to_string(), + None, + vec![col], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_dump.tables.push(table_obj); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + comparer.mark_serial_columns(); + + let to_table = comparer + .to + .tables + .iter() + .find(|t| t.schema == schema && t.name == table) + .expect("table must round-trip"); + let to_column = to_table + .columns + .iter() + .find(|c| c.name == column) + .expect("column must round-trip"); + assert_eq!( + to_column.serial_type.as_deref(), + Some("serial"), + "dotted-name column must still be marked as serial" + ); +} diff --git a/app/src/comparer/tests/core/cascade_dependents.rs b/app/src/comparer/tests/core/cascade_dependents.rs new file mode 100644 index 0000000..fd4dd49 --- /dev/null +++ b/app/src/comparer/tests/core/cascade_dependents.rs @@ -0,0 +1,1366 @@ +//! Issue #179 — `DROP FUNCTION ... CASCADE` silently drops dependent +//! objects (functional indexes, CHECK constraints, generated columns, +//! column DEFAULT expressions, RLS policies). Phase 7 of +//! `compare_routines_and_views` re-emits them; these tests cover it. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::routine::Routine; +use crate::dump::table::Table; +use crate::dump::table_constraint::TableConstraint; +use crate::dump::table_index::TableIndex; +use crate::dump::table_policy::TablePolicy; +use sqlx::postgres::types::Oid; + +#[tokio::test] +async fn issue179_signature_change_recreates_all_cascade_dependents() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + from_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)::integer", + "integer", + )); + to_dump.tables.push(issue179_items_table( + "bigint", + "test_deps.compute(0)", + "bigint", + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + let drop_pos = script + .find("drop function if exists test_deps.compute (x integer) cascade;") + .expect("CASCADE drop must be emitted for the signature change"); + let create_pos = script + .find("create or replace function test_deps.compute(x integer) returns bigint") + .expect("function recreate must be emitted"); + assert!(drop_pos < create_pos); + + // The recreate phase must run AFTER the function is recreated so the + // dependent objects can be created against the new function. + let chk_pos = script + .find("alter table test_deps.items add constraint chk_compute") + .expect("CHECK constraint must be re-added after CASCADE"); + assert!( + create_pos < chk_pos, + "CHECK must be re-added after function recreate" + ); + assert!( + script.contains("alter table test_deps.items drop constraint if exists chk_compute;"), + "drop-if-exists guard for CHECK constraint missing: {}", + script + ); + + let idx_pos = script + .find("CREATE INDEX IF NOT EXISTS idx_compute ON test_deps.items") + .expect("functional index must be re-created (CREATE INDEX IF NOT EXISTS) after CASCADE"); + assert!(create_pos < idx_pos); + // Index recreate is now non-destructive: no separate DROP INDEX is + // emitted (a false-positive match must not silently invalidate a + // surviving index — see Phase 7 / issue #179 review thread). + assert!( + !script.contains("drop index if exists test_deps.idx_compute;"), + "DROP INDEX must not be emitted; recreate uses CREATE INDEX IF NOT EXISTS" + ); + + // Generated column recreate is non-destructive: ADD COLUMN IF NOT + // EXISTS, no DROP COLUMN. A drop here would cascade to attached + // indexes / FKs / constraints that Phase 7 cannot restore. + assert!( + !script.contains("alter table test_deps.items drop column if exists gen_col"), + "DROP COLUMN must not be emitted for generated column recreate" + ); + assert!( + script.contains("alter table test_deps.items add column if not exists gen_col bigint generated always as (test_deps.compute(value)) stored;"), + "generated column must be re-added (IF NOT EXISTS) with TO type/expression" + ); + + // Column DEFAULT: column survives, only the default is gone. + assert!( + script.contains( + "alter table test_deps.items alter column def_col set default test_deps.compute(0);" + ), + "column default must be restored from TO" + ); + // We must NOT drop+re-add a non-generated column whose default was cascaded: + assert!( + !script.contains("drop column if exists def_col"), + "non-generated column must survive — only its DEFAULT clause was cascaded" + ); + + let policy_pos = script + .find("create policy p_items on test_deps.items") + .expect("policy must be re-created after CASCADE"); + assert!(create_pos < policy_pos); + assert!( + script.contains("drop policy if exists p_items on test_deps.items;"), + "drop-if-exists guard for policy missing" + ); +} + +#[tokio::test] +async fn issue179_recreate_skipped_when_routine_unchanged() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let routine = issue179_compute_routine("integer", "SELECT x * 2;"); + from_dump.routines.push(routine.clone()); + to_dump.routines.push(routine); + + from_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)", + "integer", + )); + to_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)", + "integer", + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("Recreate dependents dropped by CASCADE"), + "no CASCADE drop happened — recreate phase must stay silent: {}", + script + ); + assert!(!script.contains("alter table test_deps.items add constraint chk_compute")); + assert!(!script.contains("CREATE INDEX idx_compute")); + assert!(!script.contains("create policy p_items")); +} + +#[tokio::test] +async fn issue179_recreate_skipped_when_to_dependent_missing() { + // Function is dropped entirely. The dependent objects are also gone + // in TO (user removed both function and dependents). We must NOT + // resurrect the dependents — they're intentionally absent. + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + from_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)", + "integer", + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("alter table test_deps.items add constraint chk_compute"), + "CHECK must not be resurrected when neither it nor the function exist in TO" + ); + assert!( + !script.contains("CREATE INDEX idx_compute"), + "index must not be resurrected when neither it nor the function exist in TO" + ); + assert!( + !script.contains("create policy p_items"), + "policy must not be resurrected when neither it nor the function exist in TO" + ); +} + +/// Variant of [`issue179_items_table`] that mirrors PostgreSQL's +/// deparser output when the function is reachable via `search_path`: +/// the dependent texts use *unqualified* `compute(value)` instead of +/// `test_deps.compute(value)`. The `pg_get_*` family routinely drops +/// the schema qualifier in this case. +fn issue179_items_table_unqualified(value_type: &str, def_default: &str, gen_type: &str) -> Table { + let mut def_col = int_column("test_deps", "items", "def_col", 2); + def_col.data_type = value_type.to_string(); + def_col.column_default = Some(def_default.to_string()); + + let mut gen_col = int_column("test_deps", "items", "gen_col", 3); + gen_col.data_type = gen_type.to_string(); + gen_col.is_generated = "ALWAYS".to_string(); + // Unqualified function call. + gen_col.generation_expression = Some("compute(value)".to_string()); + gen_col.generation_type = Some("s".to_string()); + + let mut value_col = int_column("test_deps", "items", "value", 1); + value_col.is_nullable = false; + + let chk_constraint = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_deps".to_string(), + name: "chk_compute".to_string(), + table_name: "items".to_string(), + constraint_type: "CHECK".to_string(), + is_deferrable: false, + initially_deferred: false, + // Unqualified function call. + definition: Some("CHECK (compute(value) > 0)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let idx = TableIndex { + schema: "test_deps".to_string(), + table: "items".to_string(), + name: "idx_compute".to_string(), + catalog: Some("postgres".to_string()), + // Unqualified function call. + indexdef: "CREATE INDEX idx_compute ON test_deps.items USING btree (compute(value))" + .to_string(), + is_partition_index: false, + comment: None, + }; + + let policy = TablePolicy { + schema: "test_deps".to_string(), + table: "items".to_string(), + name: "p_items".to_string(), + command: "all".to_string(), + permissive: true, + roles: vec![], + // Unqualified function call. + using_clause: Some("(compute(value) > 0)".to_string()), + check_clause: None, + }; + + let mut table = Table::new( + "test_deps".to_string(), + "items".to_string(), + "test_deps".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![value_col, def_col, gen_col], + vec![chk_constraint], + vec![idx], + vec![], + None, + ); + table.policies = vec![policy]; + table.has_rowsecurity = true; + table.hash(); + table +} + +#[tokio::test] +async fn issue179_unqualified_function_calls_are_detected() { + // PostgreSQL's pg_get_constraintdef / pg_get_indexdef / pg_get_expr + // drop the schema qualifier when the function is in search_path + // (the typical `public` case). Phase 7 must still recognise these + // dependents — otherwise the CASCADE-drop drift goes unfixed for + // anything the deparser deemed "in scope". + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + from_dump.tables.push(issue179_items_table_unqualified( + "integer", + "compute(0)::integer", + "integer", + )); + to_dump.tables.push(issue179_items_table_unqualified( + "bigint", + "compute(0)", + "bigint", + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("alter table test_deps.items add constraint chk_compute"), + "unqualified CHECK reference must trigger recreate: {}", + script + ); + assert!( + script.contains("CREATE INDEX IF NOT EXISTS idx_compute ON test_deps.items"), + "unqualified index reference must trigger recreate: {}", + script + ); + assert!( + script.contains( + "alter table test_deps.items add column if not exists gen_col bigint generated always as (compute(value)) stored;" + ), + "unqualified generated-column reference must trigger recreate: {}", + script + ); + assert!( + script.contains("alter table test_deps.items alter column def_col set default compute(0);"), + "unqualified column DEFAULT reference must trigger recreate: {}", + script + ); + assert!( + script.contains("create policy p_items on test_deps.items"), + "unqualified policy reference must trigger recreate: {}", + script + ); +} + +#[test] +fn issue179_unqualified_match_rejects_substrings_and_non_calls() { + // Direct unit test for the boundary rules: the unqualified matcher + // must require `name(` at an identifier boundary, not match + // partial-name suffixes, qualified `schema.name`, or non-call uses. + let mut affected: HashSet<(String, String)> = HashSet::new(); + affected.insert(("ignored_schema".to_string(), "compute".to_string())); + + // Bare function call — should match. + assert!(Comparer::definition_references_any( + "check (compute(value) > 0)", + &affected + )); + // Whitespace between name and `(` — still a call. + assert!(Comparer::definition_references_any( + "check (compute (value) > 0)", + &affected + )); + // Qualified — qualified matcher handles it via the schema, but we + // also exercise that the unqualified matcher's left-dot exclusion + // does not double-match `other_schema.compute(`. + assert!(!Comparer::definition_references_any( + "check (other_schema.compute(value) > 0)", + &affected + )); + // Substring — must NOT match. + assert!(!Comparer::definition_references_any( + "check (compute_v2(value) > 0)", + &affected + )); + assert!(!Comparer::definition_references_any( + "check (precompute(value) > 0)", + &affected + )); + // Identifier without trailing `(` — must NOT match. + assert!(!Comparer::definition_references_any( + "check (compute > 0)", + &affected + )); +} + +#[test] +fn issue179_unqualified_match_handles_non_ascii_identifiers() { + // PostgreSQL allows Unicode identifiers (quoted), and the dump's + // `quote_ident` machinery preserves them. After `prelower_pair` + // strips the surrounding quotes the haystack and needle both + // contain raw multi-byte UTF-8 — the previous implementation did + // `text[start..]` with `start = i + 1` and panicked on the next + // iteration because byte index `i + 1` lands inside a codepoint. + // Drive the matcher with a Cyrillic name and several haystacks to + // ensure: (a) it returns true for a real call, (b) it returns + // false for a non-call use without panicking, and (c) it returns + // false for a substring without panicking. + let mut affected: HashSet<(String, String)> = HashSet::new(); + affected.insert(("test_schema".to_string(), "функция".to_string())); + + assert!(Comparer::definition_references_any( + "check (функция(value) > 0)", + &affected + )); + assert!(!Comparer::definition_references_any( + "check (функция > 0)", + &affected + )); + // Repeated occurrences without a `(` — would have triggered the + // panic on the post-match `start = i + 1` advance. + assert!(!Comparer::definition_references_any( + "check (функция функция функция > 0)", + &affected + )); + // Substring (Cyrillic suffix) must not falsely match. + assert!(!Comparer::definition_references_any( + "check (функция_v2(value) > 0)", + &affected + )); +} + +#[test] +fn issue179_qualified_match_requires_call_context() { + // `pg_get_indexdef` emits `CREATE INDEX … ON schema.table …`, + // `pg_get_expr` emits `nextval('schema.seq'::regclass)`, etc. + // Without a call gate the qualified matcher would pick up those + // `schema.name` references whenever a routine happens to share its + // name with a table / view / sequence in the same schema, and + // Phase 7 would emit spurious recreates for unrelated objects. + let mut affected: HashSet<(String, String)> = HashSet::new(); + affected.insert(("test_schema".to_string(), "users".to_string())); + + // Match: real qualified function call. + assert!(Comparer::definition_references_any( + "check (test_schema.users(value) > 0)", + &affected, + )); + // Match with whitespace before `(`. + assert!(Comparer::definition_references_any( + "check (test_schema.users (value) > 0)", + &affected, + )); + + // No match: qualified reference is the table in a CREATE INDEX + // ON clause — not a function call. + assert!(!Comparer::definition_references_any( + "create index idx ON test_schema.users using btree (value)", + &affected, + )); + // No match: qualified reference inside a `nextval` regclass cast. + assert!(!Comparer::definition_references_any( + "nextval('test_schema.users'::regclass)", + &affected, + )); + // No match: identifier without a `(` after. + assert!(!Comparer::definition_references_any( + "check (test_schema.users > 0)", + &affected, + )); + // No match: qualified suffix of a longer name (boundary check). + assert!(!Comparer::definition_references_any( + "check (test_schema.users_v2(value) > 0)", + &affected, + )); +} + +#[tokio::test] +async fn issue179_to_side_gate_skips_dependents_no_longer_referencing_routine() { + // `compare_tables()` runs before `compare_routines_and_views()`, so + // dependents that have been rewritten to no longer reference the + // affected routine reach the CASCADE-drop step with the dependency + // already broken — PostgreSQL leaves them alone. Phase 7 must NOT + // re-emit recreates for those: doing so is at best wasteful and at + // worst destructive (a `DROP COLUMN IF EXISTS` for a generated + // column also drops every index / FK / constraint attached to the + // column, none of which Phase 7 restores). + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + // FROM table: every dependent references `test_deps.compute`. + from_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)", + "integer", + )); + + // TO table: dependents have been rewritten to NOT reference + // `test_deps.compute` anymore. After `compare_tables` runs they + // exist in this rewritten form, so the `DROP FUNCTION ... CASCADE` + // does not touch them. + let mut value_col = int_column("test_deps", "items", "value", 1); + value_col.is_nullable = false; + + let mut def_col = int_column("test_deps", "items", "def_col", 2); + def_col.data_type = "integer".to_string(); + def_col.column_default = Some("0".to_string()); // no longer references compute + + let mut gen_col = int_column("test_deps", "items", "gen_col", 3); + gen_col.data_type = "integer".to_string(); + gen_col.is_generated = "ALWAYS".to_string(); + gen_col.generation_expression = Some("(value * 3)".to_string()); // no compute() + gen_col.generation_type = Some("s".to_string()); + + let chk = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_deps".to_string(), + name: "chk_compute".to_string(), + table_name: "items".to_string(), + constraint_type: "CHECK".to_string(), + is_deferrable: false, + initially_deferred: false, + // No compute() here either — TO swapped it out. + definition: Some("CHECK (value > 0)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let idx = TableIndex { + schema: "test_deps".to_string(), + table: "items".to_string(), + name: "idx_compute".to_string(), + catalog: Some("postgres".to_string()), + // No compute() in the index expression either. + indexdef: "CREATE INDEX idx_compute ON test_deps.items USING btree (value)".to_string(), + is_partition_index: false, + comment: None, + }; + + let policy = TablePolicy { + schema: "test_deps".to_string(), + table: "items".to_string(), + name: "p_items".to_string(), + command: "all".to_string(), + permissive: true, + roles: vec![], + // No compute() in the policy clause either. + using_clause: Some("(value > 0)".to_string()), + check_clause: None, + }; + + let mut to_table = Table::new( + "test_deps".to_string(), + "items".to_string(), + "test_deps".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![value_col, def_col, gen_col], + vec![chk], + vec![idx], + vec![], + None, + ); + to_table.policies = vec![policy]; + to_table.has_rowsecurity = true; + to_table.hash(); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + // Function still gets the CASCADE drop (signature change still + // requires DROP+CREATE). + assert!( + script.contains("drop function if exists test_deps.compute (x integer) cascade;"), + "function drop must still be emitted: {}", + script + ); + + // CRITICAL: the destructive generated-column path must stay silent. + assert!( + !script.contains("alter table test_deps.items drop column if exists gen_col"), + "generated column must NOT be dropped+re-added when TO no longer references the routine — that would cascade-destroy attached indexes/FKs without restoring them: {}", + script + ); + assert!( + !script.contains("alter table test_deps.items add column gen_col"), + "generated column add must not be emitted when TO does not reference the routine: {}", + script + ); + + // Constraint, index, and policy recreates must also be skipped to + // avoid redundant work that would conflict with `compare_tables`. + assert!( + !script.contains("alter table test_deps.items add constraint chk_compute"), + "CHECK recreate must not fire when TO definition no longer references the routine: {}", + script + ); + assert!( + !script.contains("CREATE INDEX idx_compute ON test_deps.items"), + "index recreate must not fire when TO indexdef no longer references the routine: {}", + script + ); + assert!( + !script.contains("create policy p_items"), + "policy recreate must not fire when TO clauses no longer reference the routine: {}", + script + ); + + // Column DEFAULT must also be skipped (TO default is `0`, no + // function reference). + assert!( + !script.contains("alter table test_deps.items alter column def_col set default 0;"), + "column DEFAULT recreate must not fire when TO default no longer references the routine: {}", + script + ); +} + +#[tokio::test] +async fn issue179_to_side_gate_still_recreates_when_to_keeps_reference() { + // Sanity check on the gate: when TO *does* still reference the + // affected routine (e.g. the dependent definition is unchanged), + // Phase 7 must continue to emit recreates exactly as before. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + from_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)::integer", + "integer", + )); + to_dump.tables.push(issue179_items_table( + "bigint", + "test_deps.compute(0)", + "bigint", + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + // Each kind of dependent must be re-emitted because TO still + // references the routine. + assert!( + script.contains("alter table test_deps.items add constraint chk_compute"), + "CHECK constraint recreate expected when TO still references the routine: {}", + script + ); + assert!( + script.contains("CREATE INDEX IF NOT EXISTS idx_compute ON test_deps.items"), + "index recreate expected when TO still references the routine: {}", + script + ); + assert!( + script.contains("alter table test_deps.items add column if not exists gen_col"), + "generated column recreate expected when TO still references the routine: {}", + script + ); + assert!( + script.contains( + "alter table test_deps.items alter column def_col set default test_deps.compute(0);" + ), + "column DEFAULT recreate expected when TO still references the routine: {}", + script + ); + assert!( + script.contains("create policy p_items on test_deps.items"), + "policy recreate expected when TO still references the routine: {}", + script + ); +} + +#[tokio::test] +async fn issue179_overload_collision_does_not_destroy_unrelated_column() { + // Phase 7's `affected` set keys on `(schema, name)` and ignores the + // argument signature, because text-based reference matching cannot + // distinguish overloads (`compute(value)` in a CHECK or generation + // expression carries no type info). When `compute(integer)` is + // dropped+recreated and `compute(text)` is unchanged, a dependent + // referencing `compute(text_value)` will text-match the affected + // set even though CASCADE never touched it. The recreate paths + // must therefore be non-destructive — a `DROP COLUMN IF EXISTS` + // for a generated column would cascade through every index / FK / + // constraint attached to the column, none of which Phase 7 knows + // how to restore. This test pins the non-destructive contract. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // FROM: two overloads. `compute(integer)` will change return type + // (forces DROP+CREATE); `compute(text)` is unchanged. + let mut from_int = Routine::new( + "test_deps".to_string(), + Oid(900), + "compute".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "x integer".to_string(), + None, + None, + "SELECT x * 2;".to_string(), + ); + from_int.hash(); + let mut from_text = Routine::new( + "test_deps".to_string(), + Oid(901), + "compute".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "x text".to_string(), + None, + None, + "SELECT length(x);".to_string(), + ); + from_text.hash(); + from_dump.routines.push(from_int); + from_dump.routines.push(from_text.clone()); + + // TO: same overloads, but `compute(integer)` now returns BIGINT. + let mut to_int = Routine::new( + "test_deps".to_string(), + Oid(900), + "compute".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "bigint".to_string(), + "x integer".to_string(), + None, + None, + "SELECT (x * 2)::bigint;".to_string(), + ); + to_int.hash(); + to_dump.routines.push(to_int); + to_dump.routines.push(from_text); // text overload unchanged + + // Build a table whose generated column references `compute(text_value)` + // — i.e. the unchanged `compute(text)` overload. CASCADE never + // drops this column (the dropped function is `compute(integer)`), + // so Phase 7 must NOT emit a destructive recreate. + let make_table = || { + let mut text_col = int_column("test_deps", "items", "text_value", 1); + text_col.data_type = "text".to_string(); + text_col.is_nullable = false; + let mut gen_col = int_column("test_deps", "items", "gen_col", 2); + gen_col.is_generated = "ALWAYS".to_string(); + gen_col.generation_expression = Some("test_deps.compute(text_value)".to_string()); + gen_col.generation_type = Some("s".to_string()); + let mut table = Table::new( + "test_deps".to_string(), + "items".to_string(), + "test_deps".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![text_col, gen_col], + vec![], + vec![], + vec![], + None, + ); + table.hash(); + table + }; + from_dump.tables.push(make_table()); + to_dump.tables.push(make_table()); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + // The integer overload is still cascaded. + assert!( + script.contains("drop function if exists test_deps.compute (x integer) cascade;"), + "integer overload must still be DROP+CREATEd: {}", + script + ); + + // CRITICAL: Phase 7 must NOT emit a DROP COLUMN. The text matcher + // false-positives on the affected set (which collapses overloads + // by name), so without IF NOT EXISTS the unconditional drop would + // destroy the unrelated column. Pinning this prevents regression. + assert!( + !script.contains("drop column if exists gen_col"), + "overload collision must not emit DROP COLUMN — would cascade-destroy attached indexes/FKs: {}", + script + ); + // The recreate that *is* emitted must use IF NOT EXISTS so the + // surviving column is left intact when the script runs. + if let Some(add_idx) = script.find("alter table test_deps.items add column") { + let snippet = &script[add_idx..(add_idx + 80).min(script.len())]; + assert!( + snippet.contains("if not exists"), + "ADD COLUMN must use IF NOT EXISTS to be non-destructive on overload false-positives: {}", + snippet + ); + } +} + +#[tokio::test] +async fn issue179_quoted_routine_name_recreates_dependents() { + // The dump query wraps `proname` with `quote_ident`, so a + // mixed-case routine like `MyFunc` arrives as `"MyFunc"`. The + // unqualified-call matcher operates on the quote-stripped haystack, + // so the affected-routine name must also be quote-stripped or + // dependents like `CHECK ("MyFunc"(value) > 0)` are missed. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "\"MySchema\"".to_string(), + Oid(950), + "\"MyFunc\"".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "x integer".to_string(), + None, + None, + "SELECT x * 2;".to_string(), + ); + from_routine.hash(); + from_dump.routines.push(from_routine); + + let mut to_routine = Routine::new( + "\"MySchema\"".to_string(), + Oid(950), + "\"MyFunc\"".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "bigint".to_string(), + "x integer".to_string(), + None, + None, + "SELECT (x * 2)::bigint;".to_string(), + ); + to_routine.hash(); + to_dump.routines.push(to_routine); + + let mut value_col = int_column("public", "items", "value", 1); + value_col.is_nullable = false; + + let chk = TableConstraint { + catalog: "postgres".to_string(), + schema: "public".to_string(), + name: "chk_my".to_string(), + table_name: "items".to_string(), + constraint_type: "CHECK".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("CHECK (\"MyFunc\"(value) > 0)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let mut from_table = Table::new( + "public".to_string(), + "items".to_string(), + "public".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![value_col.clone()], + vec![chk.clone()], + vec![], + vec![], + None, + ); + from_table.hash(); + let mut to_table = Table::new( + "public".to_string(), + "items".to_string(), + "public".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![value_col], + vec![chk], + vec![], + vec![], + None, + ); + to_table.hash(); + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("alter table public.items add constraint chk_my"), + "quoted-name function reference must trigger CHECK recreate: {}", + script + ); +} + +#[tokio::test] +async fn issue179_defaults_only_change_uses_create_or_replace_no_cascade() { + // PR #187 review (C10/C15): defaults-only changes must NOT + // trigger `DROP FUNCTION ... CASCADE`. PostgreSQL accepts + // default-argument changes via `CREATE OR REPLACE FUNCTION` + // when the identity argument types and return type are + // unchanged. The earlier version of this test pinned the + // destructive behaviour (DROP CASCADE + Phase 7 dependent + // recreates); the correct expectation is the non-destructive + // OR REPLACE form, with no CASCADE drop and no dependent + // recreates (since the function was never actually dropped). + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "test_deps".to_string(), + Oid(951), + "compute".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "x integer".to_string(), + Some("DEFAULT 0".to_string()), + None, + "SELECT x * 2;".to_string(), + ); + from_routine.hash(); + from_dump.routines.push(from_routine); + + let mut to_routine = Routine::new( + "test_deps".to_string(), + Oid(951), + "compute".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "x integer".to_string(), + Some("DEFAULT 1".to_string()), + None, + "SELECT x * 2;".to_string(), + ); + to_routine.hash(); + to_dump.routines.push(to_routine); + + from_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)", + "integer", + )); + to_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)", + "integer", + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("create or replace function test_deps.compute(x integer DEFAULT 1)"), + "defaults-only change must be re-emitted via CREATE OR REPLACE: {}", + script + ); + assert!( + !script.contains("drop function if exists test_deps.compute"), + "defaults-only change must NOT emit DROP FUNCTION CASCADE: {}", + script + ); + assert!( + !script.contains("alter table test_deps.items add constraint chk_compute"), + "dependents must NOT be recreated when the function was not actually dropped: {}", + script + ); +} + +/// Build a partition child of `parent_table` whose dependents (CHECK +/// constraint with `coninhcount=1`, `is_partition_index=true` index, a +/// generated column, and a CHECK with `coninhcount=0` so we can prove +/// the truly-local case is still emitted) all reference +/// `test_deps.compute`. Used by the partition-child guard tests. +fn issue179_items_partition_child(parent_qualified: &str) -> Table { + let mut value_col = int_column("test_deps", "items_2026", "value", 1); + value_col.is_nullable = false; + + let mut gen_col = int_column("test_deps", "items_2026", "gen_col", 2); + gen_col.is_generated = "ALWAYS".to_string(); + gen_col.generation_expression = Some("test_deps.compute(value)".to_string()); + gen_col.generation_type = Some("s".to_string()); + + let inherited_check = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_deps".to_string(), + name: "chk_compute".to_string(), + table_name: "items_2026".to_string(), + constraint_type: "CHECK".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("CHECK (test_deps.compute(value) > 0)".to_string()), + coninhcount: 1, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + let mut local_check = inherited_check.clone(); + local_check.name = "chk_compute_local".to_string(); + local_check.coninhcount = 0; + + let inherited_idx = TableIndex { + schema: "test_deps".to_string(), + table: "items_2026".to_string(), + name: "idx_compute".to_string(), + catalog: Some("postgres".to_string()), + indexdef: "CREATE INDEX idx_compute ON test_deps.items_2026 USING btree (test_deps.compute(value))".to_string(), + is_partition_index: true, + comment: None, + }; + + let mut table = Table::new( + "test_deps".to_string(), + "items_2026".to_string(), + "test_deps".to_string(), + "items_2026".to_string(), + "postgres".to_string(), + None, + vec![value_col, gen_col], + vec![inherited_check, local_check], + vec![inherited_idx], + vec![], + None, + ); + table.partition_of = Some(parent_qualified.to_string()); + table.partition_bound = Some("FOR VALUES IN (1)".to_string()); + table.hash(); + table +} + +#[tokio::test] +async fn issue179_partition_child_skips_inherited_dependents() { + // FROM and TO each contain a partitioned parent + one partition + // child. The function signature changes, so CASCADE drops the + // parent-side dependents. Phase 7 must NOT emit recreates for the + // child's inherited objects (PostgreSQL forbids `ALTER TABLE child` + // on inherited columns/constraints/indexes), but it MUST still + // emit the truly-local CHECK constraint (`coninhcount = 0`). + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + // Parent (partitioned) carries the dependent definitions on its own + // row — its recreate handles the propagation to children. + let mut parent_from = + issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); + parent_from.name = "items".to_string(); + parent_from.raw_name = "items".to_string(); + parent_from.partition_key = Some("LIST (value)".to_string()); + parent_from.hash(); + + let mut parent_to = issue179_items_table("bigint", "test_deps.compute(0)", "bigint"); + parent_to.name = "items".to_string(); + parent_to.raw_name = "items".to_string(); + parent_to.partition_key = Some("LIST (value)".to_string()); + parent_to.hash(); + + from_dump.tables.push(parent_from); + to_dump.tables.push(parent_to); + + from_dump + .tables + .push(issue179_items_partition_child("test_deps.items")); + to_dump + .tables + .push(issue179_items_partition_child("test_deps.items")); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + // Parent dependents (which Table::diff would diff normally) MUST + // be recreated against the parent. + assert!( + script.contains("alter table test_deps.items add constraint chk_compute"), + "parent CHECK must be re-added: {}", + script + ); + assert!( + script.contains("CREATE INDEX IF NOT EXISTS idx_compute ON test_deps.items "), + "parent index must be re-created: {}", + script + ); + + // Inherited child constraint (coninhcount > 0) must NOT be re-added + // on the child — PostgreSQL would reject `ALTER TABLE child ADD + // CONSTRAINT` for an inherited constraint, and the parent's recreate + // already propagates. (Use exact-suffix match so we don't accidentally + // match `chk_compute_local` below.) + assert!( + !script.contains("alter table test_deps.items_2026 drop constraint if exists chk_compute;"), + "inherited child CHECK must not be re-emitted: {}", + script + ); + assert!( + !script.contains("alter table test_deps.items_2026 add constraint chk_compute check"), + "inherited child CHECK must not be re-added: {}", + script + ); + + // Truly-local child constraint (coninhcount == 0) IS re-emitted. + assert!( + script.contains( + "alter table test_deps.items_2026 drop constraint if exists chk_compute_local;" + ), + "local child CHECK must be re-emitted: {}", + script + ); + assert!( + script.contains("alter table test_deps.items_2026 add constraint chk_compute_local"), + "local child CHECK must be re-added: {}", + script + ); + + // Partition-inherited index: must not be re-emitted on the child. + // Match by the load-bearing fragment so the assertion holds whether + // the recreate uses `CREATE INDEX` or `CREATE INDEX IF NOT EXISTS`. + assert!( + !script.contains("idx_compute ON test_deps.items_2026"), + "partition-inherited index must not be re-emitted on child: {}", + script + ); + + // Partition child's generated column must NOT be added — PostgreSQL + // forbids modifying inherited columns directly on a partition. + // (Recreate paths never emit DROP COLUMN now; the add assertion + // below is the load-bearing one for partition-child safety.) + assert!( + !script.contains("alter table test_deps.items_2026 add column if not exists gen_col"), + "partition child column must not be re-added: {}", + script + ); +} + +#[tokio::test] +async fn issue179_full_drop_recreates_dependents_when_overload_survives() { + // PR #187 review (C16): the previous version of this test built + // an invalid PostgreSQL state — TO had dependents referencing + // `test_deps.compute` but no function with that name at all, so + // the recreate SQL would fail to apply. The valid scenario where + // "function fully dropped, dependents kept" is meaningful is when + // a *different overload* of the same name survives in TO and the + // dependents resolve to it via PostgreSQL's name-based function + // binding. Set that up explicitly here. FROM has both + // `compute(integer)` (which gets dropped) and `compute(text)` + // (the surviving overload). TO has only `compute(text)`. + // Dependents in both sides reference `test_deps.compute` and + // resolve via overload resolution. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + let mut compute_text_from = Routine::new( + "test_deps".to_string(), + Oid(961), + "compute".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "x text".to_string(), + None, + None, + "SELECT length(x);".to_string(), + ); + compute_text_from.hash(); + from_dump.routines.push(compute_text_from.clone()); + // TO keeps only the text overload — `compute(integer)` is gone. + to_dump.routines.push(compute_text_from); + + from_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)", + "integer", + )); + to_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)", + "integer", + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("drop function if exists test_deps.compute (x integer) cascade;"), + "the integer overload must be dropped" + ); + assert!( + script.contains("alter table test_deps.items add constraint chk_compute"), + "CHECK present in TO must be re-added after CASCADE (overload-resolves to surviving compute)" + ); + assert!( + script.contains("CREATE INDEX IF NOT EXISTS idx_compute"), + "functional index present in TO must be re-created (IF NOT EXISTS) after CASCADE" + ); + assert!( + script.contains("create policy p_items on test_deps.items"), + "policy present in TO must be re-created after CASCADE" + ); +} + +#[tokio::test] +async fn issue179_quoted_routine_names_match_unqualified_calls() { + // PGC's dump query wraps `nspname` / `proname` with `quote_ident`, + // so a function named `MyFunc` lands here as `Routine.name = "MyFunc"`. + // Phase 7 must strip those quotes when building its affected set — + // otherwise the unqualified matcher (which scans quote-stripped + // text) never lines up with the deparsed `myfunc(` in dependents. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "\"TestDeps\"".to_string(), + Oid(900), + "\"MyFunc\"".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "x integer".to_string(), + None, + None, + "SELECT x * 2;".to_string(), + ); + from_routine.hash(); + let mut to_routine = from_routine.clone(); + to_routine.return_type = "bigint".to_string(); + to_routine.source_code = "SELECT (x * 2)::bigint;".to_string(); + to_routine.hash(); + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + // Dependent uses the deparsed unqualified form `"MyFunc"(value)` + // (PostgreSQL preserves the case-sensitive identifier with quotes + // but drops the schema qualifier when the function is in + // `search_path`). + let chk = TableConstraint { + catalog: "postgres".to_string(), + schema: "public".to_string(), + name: "chk_myfunc".to_string(), + table_name: "items".to_string(), + constraint_type: "CHECK".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("CHECK (\"MyFunc\"(value) > 0)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let mut value_col = int_column("public", "items", "value", 1); + value_col.is_nullable = false; + + let mut from_table = Table::new( + "public".to_string(), + "items".to_string(), + "public".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![value_col.clone()], + vec![chk.clone()], + vec![], + vec![], + None, + ); + from_table.hash(); + let mut to_table = Table::new( + "public".to_string(), + "items".to_string(), + "public".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![value_col], + vec![chk], + vec![], + vec![], + None, + ); + to_table.hash(); + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("alter table public.items add constraint chk_myfunc"), + "quoted routine name must still match its unqualified deparsed dependent: {}", + script + ); +} + +#[tokio::test] +async fn issue179_defaults_only_change_emits_create_or_replace_no_cascade() { + // PR #187 review (C10/C15): PostgreSQL accepts default-argument + // changes via `CREATE OR REPLACE FUNCTION` for the same identity + // signature/return type — there is no DROP+CREATE requirement. + // The `arguments_defaults` field is included in `Routine::hash()` + // so the diff is *detected*; the migration is then emitted as a + // plain `CREATE OR REPLACE` form (no CASCADE drop, no Phase 7 + // dependent recreates). + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "test_deps".to_string(), + Oid(900), + "compute".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "x integer".to_string(), + Some("0".to_string()), + None, + "SELECT x * 2;".to_string(), + ); + from_routine.hash(); + let mut to_routine = from_routine.clone(); + // ONLY the default value changes — every other field is identical. + to_routine.arguments_defaults = Some("1".to_string()); + to_routine.hash(); + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let table = issue179_items_table("integer", "test_deps.compute(0)", "integer"); + from_dump.tables.push(table.clone()); + to_dump.tables.push(table); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("create or replace function test_deps.compute(x integer DEFAULT 1)"), + "defaults-only change must use CREATE OR REPLACE FUNCTION: {}", + script + ); + assert!( + !script.contains("drop function if exists test_deps.compute"), + "defaults-only change must NOT emit DROP FUNCTION CASCADE: {}", + script + ); + assert!( + !script.contains("alter table test_deps.items add constraint chk_compute"), + "Phase 7 must NOT fire on defaults-only change (no CASCADE happened): {}", + script + ); + assert!( + !script.contains("CREATE INDEX IF NOT EXISTS idx_compute ON test_deps.items"), + "no index recreate on defaults-only change: {}", + script + ); +} diff --git a/app/src/comparer/tests/core/column_dependents.rs b/app/src/comparer/tests/core/column_dependents.rs new file mode 100644 index 0000000..f9ee383 --- /dev/null +++ b/app/src/comparer/tests/core/column_dependents.rs @@ -0,0 +1,644 @@ +//! Issue #188 — pg_depend-driven secondary dependent restoration. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::column_dependent::ColumnDependent; +use crate::dump::column_dependent::ColumnDependentKind; +use crate::dump::table::Table; +use crate::dump::table_column::TableColumn; +use crate::dump::table_constraint::TableConstraint; +use crate::dump::table_index::TableIndex; + +/// Phase 7 / Path A: A routine signature change CASCADE-drops a +/// generated column. PostgreSQL also drops a plain index on that +/// column *because the index depends on the column, not the routine*. +/// The text-based scanner cannot see the dependency. The +/// `column_dependents` graph must drive a recreate of the index. +#[tokio::test] +async fn issue188_phase7_restores_plain_index_on_generated_column() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + // Add a plain index ON the generated column (no function reference) + // to both sides. PostgreSQL would CASCADE-drop it along with the + // column; Phase 7's text scan does not detect this case. + let plain_idx = TableIndex { + schema: "test_deps".to_string(), + table: "items".to_string(), + name: "idx_gen_col".to_string(), + catalog: Some("postgres".to_string()), + indexdef: "CREATE INDEX idx_gen_col ON test_deps.items USING btree (gen_col)".to_string(), + is_partition_index: false, + comment: None, + }; + + let mut from_table = + issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); + from_table.indexes.push(plain_idx.clone()); + from_table.hash(); + from_dump.tables.push(from_table); + + let mut to_table = issue179_items_table("bigint", "test_deps.compute(0)", "bigint"); + to_table.indexes.push(plain_idx); + to_table.hash(); + to_dump.tables.push(to_table); + + // pg_depend at dump time recorded that `idx_gen_col` depends on + // `gen_col`. Without this, the text scanner has no way to discover + // the secondary dependency. + from_dump.column_dependents.push(ColumnDependent { + schema: "test_deps".to_string(), + table: "items".to_string(), + column: "gen_col".to_string(), + kind: ColumnDependentKind::Index, + dep_schema: "test_deps".to_string(), + dep_table: "items".to_string(), + dep_name: "idx_gen_col".to_string(), + }); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("CREATE INDEX IF NOT EXISTS idx_gen_col ON test_deps.items"), + "plain index on generated column must be re-emitted from pg_depend graph: {}", + script + ); +} + +/// Phase 7 / Path A: same idea for a CHECK constraint that references +/// the generated column but does not name the routine. +#[tokio::test] +async fn issue188_phase7_restores_check_constraint_on_generated_column() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + let chk_on_col = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_deps".to_string(), + name: "chk_gen_positive".to_string(), + table_name: "items".to_string(), + constraint_type: "CHECK".to_string(), + is_deferrable: false, + initially_deferred: false, + // References only the generated column — no function name. + definition: Some("CHECK (gen_col > 0)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let mut from_table = + issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); + from_table.constraints.push(chk_on_col.clone()); + from_table.hash(); + from_dump.tables.push(from_table); + + let mut to_table = issue179_items_table("bigint", "test_deps.compute(0)", "bigint"); + to_table.constraints.push(chk_on_col); + to_table.hash(); + to_dump.tables.push(to_table); + + from_dump.column_dependents.push(ColumnDependent { + schema: "test_deps".to_string(), + table: "items".to_string(), + column: "gen_col".to_string(), + kind: ColumnDependentKind::Constraint, + dep_schema: "test_deps".to_string(), + dep_table: "items".to_string(), + dep_name: "chk_gen_positive".to_string(), + }); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("alter table test_deps.items add constraint chk_gen_positive"), + "CHECK constraint anchored on generated column must be re-emitted: {}", + script + ); + assert!( + script.contains("alter table test_deps.items drop constraint if exists chk_gen_positive;"), + "drop-if-exists guard for column-anchored CHECK constraint missing: {}", + script + ); +} + +/// Phase 7 / Path A: TO-side gate. When the dependent is intentionally +/// removed in TO, we must NOT resurrect it via the pg_depend graph. +#[tokio::test] +async fn issue188_phase7_skips_dependent_absent_from_to() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + // FROM has the plain index; TO deliberately omits it. + let plain_idx = TableIndex { + schema: "test_deps".to_string(), + table: "items".to_string(), + name: "idx_gen_col".to_string(), + catalog: Some("postgres".to_string()), + indexdef: "CREATE INDEX idx_gen_col ON test_deps.items USING btree (gen_col)".to_string(), + is_partition_index: false, + comment: None, + }; + + let mut from_table = + issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); + from_table.indexes.push(plain_idx); + from_table.hash(); + from_dump.tables.push(from_table); + + // TO-side table does NOT include `idx_gen_col`. + to_dump.tables.push(issue179_items_table( + "bigint", + "test_deps.compute(0)", + "bigint", + )); + + from_dump.column_dependents.push(ColumnDependent { + schema: "test_deps".to_string(), + table: "items".to_string(), + column: "gen_col".to_string(), + kind: ColumnDependentKind::Index, + dep_schema: "test_deps".to_string(), + dep_table: "items".to_string(), + dep_name: "idx_gen_col".to_string(), + }); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("idx_gen_col"), + "index absent from TO must not be resurrected from pg_depend: {}", + script + ); +} + +/// Phase 7 / Path A: a UNIQUE/PK constraint and its backing index both +/// appear in `pg_depend`. The constraint emission already recreates the +/// index, so the dedup logic must skip the index branch when the same +/// name exists as a constraint on the TO-side table. +#[tokio::test] +async fn issue188_phase7_skips_index_backing_constraint() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + let uniq_constraint = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_deps".to_string(), + name: "items_gen_col_key".to_string(), + table_name: "items".to_string(), + constraint_type: "UNIQUE".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("UNIQUE (gen_col)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + // Backing index has the same name as the constraint. + let uniq_idx = TableIndex { + schema: "test_deps".to_string(), + table: "items".to_string(), + name: "items_gen_col_key".to_string(), + catalog: Some("postgres".to_string()), + indexdef: "CREATE UNIQUE INDEX items_gen_col_key ON test_deps.items USING btree (gen_col)" + .to_string(), + is_partition_index: false, + comment: None, + }; + + let mut from_table = + issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); + from_table.constraints.push(uniq_constraint.clone()); + from_table.indexes.push(uniq_idx.clone()); + from_table.hash(); + from_dump.tables.push(from_table); + + let mut to_table = issue179_items_table("bigint", "test_deps.compute(0)", "bigint"); + to_table.constraints.push(uniq_constraint); + to_table.indexes.push(uniq_idx); + to_table.hash(); + to_dump.tables.push(to_table); + + // pg_depend records BOTH edges. + from_dump.column_dependents.push(ColumnDependent { + schema: "test_deps".to_string(), + table: "items".to_string(), + column: "gen_col".to_string(), + kind: ColumnDependentKind::Index, + dep_schema: "test_deps".to_string(), + dep_table: "items".to_string(), + dep_name: "items_gen_col_key".to_string(), + }); + from_dump.column_dependents.push(ColumnDependent { + schema: "test_deps".to_string(), + table: "items".to_string(), + column: "gen_col".to_string(), + kind: ColumnDependentKind::Constraint, + dep_schema: "test_deps".to_string(), + dep_table: "items".to_string(), + dep_name: "items_gen_col_key".to_string(), + }); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + // The constraint emission must fire … + assert!( + script.contains("alter table test_deps.items add constraint items_gen_col_key"), + "UNIQUE constraint must be re-emitted: {}", + script + ); + // … and the backing-index CREATE must NOT also be emitted (the + // constraint creates the index implicitly). + assert!( + !script.contains("CREATE UNIQUE INDEX IF NOT EXISTS items_gen_col_key"), + "backing index must be skipped when a same-named constraint emission already recreates it: {}", + script + ); +} + +/// Path B: a STORED → VIRTUAL flip routes the column through the +/// `DROP COLUMN` + `ADD COLUMN` branch in `TableColumn::get_alter_script` +/// (issue #181). PostgreSQL CASCADE-drops a plain index attached to the +/// column. `compare_tables` must walk the column-dependent graph and +/// emit the recreate. +#[tokio::test] +async fn issue188_path_b_virtual_flip_restores_dependent_index() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let value_col = { + let mut c = int_column("test_deps", "items", "value", 1); + c.is_nullable = false; + c + }; + + let mut from_gen_col = int_column("test_deps", "items", "gen_col", 2); + from_gen_col.data_type = "integer".to_string(); + from_gen_col.is_generated = "ALWAYS".to_string(); + from_gen_col.generation_expression = Some("(value * 2)".to_string()); + from_gen_col.generation_type = Some("s".to_string()); // STORED in FROM + + let mut to_gen_col = from_gen_col.clone(); + to_gen_col.generation_type = Some("v".to_string()); // VIRTUAL in TO + + let plain_idx = TableIndex { + schema: "test_deps".to_string(), + table: "items".to_string(), + name: "idx_gen_col".to_string(), + catalog: Some("postgres".to_string()), + indexdef: "CREATE INDEX idx_gen_col ON test_deps.items USING btree (gen_col)".to_string(), + is_partition_index: false, + comment: None, + }; + + let mut from_table = Table::new( + "test_deps".to_string(), + "items".to_string(), + "test_deps".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![value_col.clone(), from_gen_col], + vec![], + vec![plain_idx.clone()], + vec![], + None, + ); + from_table.hash(); + from_dump.tables.push(from_table); + + let mut to_table = Table::new( + "test_deps".to_string(), + "items".to_string(), + "test_deps".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![value_col, to_gen_col], + vec![], + vec![plain_idx], + vec![], + None, + ); + to_table.hash(); + to_dump.tables.push(to_table); + + from_dump.column_dependents.push(ColumnDependent { + schema: "test_deps".to_string(), + table: "items".to_string(), + column: "gen_col".to_string(), + kind: ColumnDependentKind::Index, + dep_schema: "test_deps".to_string(), + dep_table: "items".to_string(), + dep_name: "idx_gen_col".to_string(), + }); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + // The drop+add for the column must fire (Path B trigger). + assert!( + script.contains("drop column"), + "STORED→VIRTUAL flip should DROP COLUMN: {}", + script + ); + // The recreate block must include the dependent index. + assert!( + script.contains("Recreate dependents dropped by virtual-column rewrite"), + "labeled recreate block must wrap Path B dependents: {}", + script + ); + assert!( + script.contains("CREATE INDEX IF NOT EXISTS idx_gen_col ON test_deps.items"), + "plain index on virtually-recreated column must be re-emitted: {}", + script + ); +} + +/// Phase 7 / Path A: an FK on a *different* table referencing the +/// generated column on the anchor table. The pg_depend row's +/// `refobjid` points at the parent table (where the column lives) but +/// `con.conrelid` points at the child (where the FK lives) — the +/// `dep_schema`/`dep_table` in `ColumnDependent` must be the child's, +/// not the anchor's. Locks in correct behaviour for the asymmetric +/// `conrelid` vs `refobjid` case (PR #196 review). +#[tokio::test] +async fn issue188_phase7_restores_fk_on_different_table() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + // Parent table: the standard issue179 items table — gen_col is + // the anchor whose CASCADE drop the test exercises. + from_dump.tables.push(issue179_items_table( + "integer", + "test_deps.compute(0)::integer", + "integer", + )); + to_dump.tables.push(issue179_items_table( + "bigint", + "test_deps.compute(0)", + "bigint", + )); + + // Child table: separate table whose FK references gen_col on the + // parent. The FK's own definition contains no function name; the + // text scanner cannot see this dependency. The pg_depend graph + // must drive the re-emission. + let make_child = |ref_type: &str| { + let mut id_col = int_column("test_deps", "items_child", "id", 1); + id_col.is_nullable = false; + + let mut ref_col = int_column("test_deps", "items_child", "ref_gen", 2); + ref_col.data_type = ref_type.to_string(); + + let fk = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_deps".to_string(), + name: "fk_items_child_ref_gen".to_string(), + table_name: "items_child".to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some( + "FOREIGN KEY (ref_gen) REFERENCES test_deps.items (gen_col)".to_string(), + ), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let mut t = Table::new( + "test_deps".to_string(), + "items_child".to_string(), + "test_deps".to_string(), + "items_child".to_string(), + "postgres".to_string(), + None, + vec![id_col, ref_col], + vec![fk], + vec![], + vec![], + None, + ); + t.hash(); + t + }; + from_dump.tables.push(make_child("integer")); + to_dump.tables.push(make_child("bigint")); + + // Anchor is the parent column (gen_col on items). dep_table is + // the *child* (items_child) because the FK constraint's + // `conrelid` points at the child, not the parent where the + // depended-on column lives. + from_dump.column_dependents.push(ColumnDependent { + schema: "test_deps".to_string(), + table: "items".to_string(), + column: "gen_col".to_string(), + kind: ColumnDependentKind::Constraint, + dep_schema: "test_deps".to_string(), + dep_table: "items_child".to_string(), + dep_name: "fk_items_child_ref_gen".to_string(), + }); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("alter table test_deps.items_child add constraint fk_items_child_ref_gen"), + "FK on a different table must be re-emitted via pg_depend graph: {}", + script + ); + assert!( + script.contains( + "alter table test_deps.items_child drop constraint if exists fk_items_child_ref_gen;" + ), + "drop-if-exists guard for cross-table FK missing: {}", + script + ); +} + +/// Phase 7 / Path A: when the anchor column has both a UNIQUE +/// constraint and an FK on another table referencing it, the FK must +/// be emitted *after* the UNIQUE constraint — PostgreSQL rejects +/// `ADD CONSTRAINT … FOREIGN KEY` when the referenced columns lack a +/// unique constraint. Two-pass ordering in `recreate_column_dependents`. +#[tokio::test] +async fn issue188_phase7_emits_fk_after_unique_target() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + let uniq_constraint = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_deps".to_string(), + name: "items_gen_col_uniq".to_string(), + table_name: "items".to_string(), + constraint_type: "UNIQUE".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("UNIQUE (gen_col)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let mut from_items = + issue179_items_table("integer", "test_deps.compute(0)::integer", "integer"); + from_items.constraints.push(uniq_constraint.clone()); + from_items.hash(); + from_dump.tables.push(from_items); + + let mut to_items = issue179_items_table("bigint", "test_deps.compute(0)", "bigint"); + to_items.constraints.push(uniq_constraint); + to_items.hash(); + to_dump.tables.push(to_items); + + let make_child = |ref_type: &str| { + let mut id_col = int_column("test_deps", "items_child", "id", 1); + id_col.is_nullable = false; + let mut ref_col = int_column("test_deps", "items_child", "ref_gen", 2); + ref_col.data_type = ref_type.to_string(); + + let fk = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_deps".to_string(), + name: "fk_items_child_ref_gen".to_string(), + table_name: "items_child".to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some( + "FOREIGN KEY (ref_gen) REFERENCES test_deps.items (gen_col)".to_string(), + ), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let mut t = Table::new( + "test_deps".to_string(), + "items_child".to_string(), + "test_deps".to_string(), + "items_child".to_string(), + "postgres".to_string(), + None, + vec![id_col, ref_col], + vec![fk], + vec![], + vec![], + None, + ); + t.hash(); + t + }; + from_dump.tables.push(make_child("integer")); + to_dump.tables.push(make_child("bigint")); + + // FK first in the column_dependents vec — the helper must defer + // it regardless of input order so it lands after the UNIQUE. + from_dump.column_dependents.push(ColumnDependent { + schema: "test_deps".to_string(), + table: "items".to_string(), + column: "gen_col".to_string(), + kind: ColumnDependentKind::Constraint, + dep_schema: "test_deps".to_string(), + dep_table: "items_child".to_string(), + dep_name: "fk_items_child_ref_gen".to_string(), + }); + from_dump.column_dependents.push(ColumnDependent { + schema: "test_deps".to_string(), + table: "items".to_string(), + column: "gen_col".to_string(), + kind: ColumnDependentKind::Constraint, + dep_schema: "test_deps".to_string(), + dep_table: "items".to_string(), + dep_name: "items_gen_col_uniq".to_string(), + }); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + let uniq_pos = script + .find("add constraint items_gen_col_uniq") + .expect("UNIQUE constraint must be re-emitted"); + let fk_pos = script + .find("add constraint fk_items_child_ref_gen") + .expect("FK constraint must be re-emitted"); + assert!( + uniq_pos < fk_pos, + "FK must be emitted AFTER its UNIQUE target; got uniq@{} fk@{}: {}", + uniq_pos, + fk_pos, + script + ); +} diff --git a/app/src/comparer/tests/core/grants.rs b/app/src/comparer/tests/core/grants.rs new file mode 100644 index 0000000..04a283d --- /dev/null +++ b/app/src/comparer/tests/core/grants.rs @@ -0,0 +1,2038 @@ +//! Tests for `compare_grants` and `compare_column_grants` across all three +//! [`GrantsMode`] settings (`ignore`, `addonly`, `full`), including owner +//! changes and the default-ACL handling for recreated or dropped objects. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::default_privilege::DefaultPrivilege; +use crate::dump::foreign_table::ForeignTable; +use crate::dump::routine::Routine; +use crate::dump::schema::Schema; +use crate::dump::sequence::Sequence; +use crate::dump::table::Table; +use crate::dump::view::View; +use sqlx::postgres::types::Oid; + +#[tokio::test] +async fn compare_grants_ignore_mode_produces_no_output() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_schema = Schema::new("public".to_string(), "public".to_string(), None); + from_schema.acl = vec!["reader=U/owner".to_string()]; + let mut to_schema = Schema::new("public".to_string(), "public".to_string(), None); + to_schema.acl = vec!["reader=U/owner".to_string(), "writer=UC/owner".to_string()]; + + from_dump.schemas.push(from_schema); + to_dump.schemas.push(to_schema); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("GRANT"), + "Ignore mode must not emit GRANT, got: {script}" + ); + assert!( + !script.contains("REVOKE"), + "Ignore mode must not emit REVOKE, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_addonly_adds_missing_schema_grant() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); + from_schema.acl = vec!["reader=U/owner".to_string()]; + let mut to_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); + to_schema.acl = vec!["reader=U/owner".to_string(), "writer=UC/owner".to_string()]; + + from_dump.schemas.push(from_schema); + to_dump.schemas.push(to_schema); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT CREATE, USAGE ON SCHEMA myschema TO writer;"), + "AddOnly must add missing grant, got: {script}" + ); + assert!( + !script.contains("REVOKE"), + "AddOnly must not emit REVOKE, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_addonly_does_not_revoke_removed_grant() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); + from_schema.acl = vec!["reader=U/owner".to_string(), "writer=UC/owner".to_string()]; + let mut to_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); + to_schema.acl = vec!["reader=U/owner".to_string()]; + + from_dump.schemas.push(from_schema); + to_dump.schemas.push(to_schema); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("REVOKE"), + "AddOnly must not revoke, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_full_revokes_removed_schema_grant() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); + from_schema.acl = vec!["reader=U/owner".to_string(), "writer=UC/owner".to_string()]; + let mut to_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); + to_schema.acl = vec!["reader=U/owner".to_string()]; + + from_dump.schemas.push(from_schema); + to_dump.schemas.push(to_schema); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("REVOKE CREATE, USAGE ON SCHEMA myschema FROM writer;"), + "Full mode must revoke removed grant, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_full_table_add_and_revoke() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_table = Table::new( + "public".to_string(), + "users".to_string(), + "public".to_string(), + "users".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec!["reader=r/owner".to_string()]; + + let mut to_table = Table::new( + "public".to_string(), + "users".to_string(), + "public".to_string(), + "users".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec!["writer=rw/owner".to_string()]; + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT SELECT, UPDATE ON TABLE public.users TO writer;"), + "Full must add new grant, got: {script}" + ); + assert!( + script.contains("REVOKE SELECT ON TABLE public.users FROM reader;"), + "Full must revoke removed grant, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_sequence() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_seq = Sequence::new( + "public".to_string(), + "my_seq".to_string(), + "owner".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9223372036854775807), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + from_seq.acl = vec![]; + + let mut to_seq = Sequence::new( + "public".to_string(), + "my_seq".to_string(), + "owner".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9223372036854775807), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + to_seq.acl = vec!["reader=U/owner".to_string()]; + + from_dump.sequences.push(from_seq); + to_dump.sequences.push(to_seq); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT USAGE ON SEQUENCE public.my_seq TO reader;"), + "Must add sequence grant, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_view() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + Vec::new(), + ); + from_view.acl = vec!["reader=r/owner".to_string()]; + + let mut to_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + Vec::new(), + ); + to_view.acl = vec!["reader=r/owner".to_string(), "writer=rw/owner".to_string()]; + + from_dump.views.push(from_view); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT SELECT, UPDATE ON TABLE public.my_view TO writer;"), + "Must add view grant, got: {script}" + ); + assert!( + !script.contains("REVOKE"), + "No revoke expected when unchanged grant remains, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_foreign_table_add_and_revoke() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_ft = ForeignTable::new( + "public".to_string(), + "ft_orders".to_string(), + "fdw_server".to_string(), + "owner".to_string(), + Vec::new(), + Vec::new(), + ); + from_ft.acl = vec!["reader=r/owner".to_string()]; + + let mut to_ft = ForeignTable::new( + "public".to_string(), + "ft_orders".to_string(), + "fdw_server".to_string(), + "owner".to_string(), + Vec::new(), + Vec::new(), + ); + to_ft.acl = vec!["writer=rw/owner".to_string()]; + + from_dump.foreign_tables.push(from_ft); + to_dump.foreign_tables.push(to_ft); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + // PostgreSQL's GRANT syntax has no `ON FOREIGN TABLE` form — foreign + // tables share the regular `ON TABLE` grant syntax. Pre-fix the + // comparer emitted `ON FOREIGN TABLE`, which produced invalid SQL + // (`syntax error at or near "TABLE"`) when the diff was applied. + assert!( + script.contains("GRANT SELECT, UPDATE ON TABLE public.ft_orders TO writer;"), + "Full must add foreign table grant via ON TABLE syntax, got: {script}" + ); + assert!( + script.contains("REVOKE SELECT ON TABLE public.ft_orders FROM reader;"), + "Full must revoke removed foreign table grant via ON TABLE syntax, got: {script}" + ); + assert!( + !script.contains("ON FOREIGN TABLE"), + "Foreign table grants must not use `ON FOREIGN TABLE` (invalid SQL), got: {script}" + ); +} + +/// User-reported regression: when ownership changes AND TO has an explicit +/// grant to the former owner, the migration must emit exactly one GRANT +/// (for the explicit privilege in TO) and zero REVOKEs (the implicit-owner +/// ACL row is stripped by ALTER OWNER alone). Replays the exact ACL shape +/// you'd see in the schema_a → schema_b owner-change scenario after both +/// FROM and TO have run their explicit GRANTs and PG has materialised the +/// implicit-owner row. +#[tokio::test] +async fn compare_grants_owner_change_with_explicit_grant_to_former_owner_is_idempotent() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // FROM table: owned by pgc_owner_from. relacl carries the implicit- + // owner row (pg materialises it once any GRANT exists) plus the two + // explicit grants to reader/writer. + let mut from_table = Table::new( + "test_schema".to_string(), + "users".to_string(), + "test_schema".to_string(), + "users".to_string(), + "pgc_owner_from".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec![ + "pgc_owner_from=arwdDxt/pgc_owner_from".to_string(), + "pgc_grant_reader=r/pgc_owner_from".to_string(), + "pgc_grant_writer=arw/pgc_owner_from".to_string(), + ]; + + // TO table: owned by pgc_owner_to. relacl has the new implicit-owner + // row, the same reader grant, the writer with UPDATE removed, and an + // explicit grant to the former owner pgc_owner_from. + let mut to_table = Table::new( + "test_schema".to_string(), + "users".to_string(), + "test_schema".to_string(), + "users".to_string(), + "pgc_owner_to".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec![ + "pgc_owner_to=arwdDxt/pgc_owner_to".to_string(), + "pgc_owner_from=r/pgc_owner_to".to_string(), + "pgc_grant_reader=r/pgc_owner_to".to_string(), + "pgc_grant_writer=ar/pgc_owner_to".to_string(), + ]; + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + // Exactly two statements expected: + // - GRANT SELECT TO pgc_owner_from (the new explicit grant in TO) + // - REVOKE UPDATE FROM pgc_grant_writer (UPDATE removed in TO) + assert!( + script.contains("GRANT SELECT ON TABLE test_schema.users TO pgc_owner_from;"), + "Must emit explicit grant to former owner, got: {script}" + ); + assert!( + script.contains("REVOKE UPDATE ON TABLE test_schema.users FROM pgc_grant_writer;"), + "Must revoke writer's UPDATE removed in TO, got: {script}" + ); + // No REVOKE/GRANT for pgc_owner_to (TO owner — implicit privileges). + // No REVOKE for pgc_owner_from's old implicit-owner row — ALTER OWNER + // strips it. Specifically NO REVOKE on pgc_owner_from for the 7 other + // privileges, which is the bug this regression test guards against. + assert!( + !script.contains("FROM pgc_owner_from"), + "Must not REVOKE anything from former owner — ALTER OWNER strips the implicit row, got: {script}" + ); + assert!( + !script.contains("pgc_owner_to"), + "Current owner must not appear in grants output, got: {script}" + ); +} + +/// Regression: a TO-only foreign table must inherit the FROM database's +/// default-table privileges as the effective `from_acl` under `full` mode, +/// because PostgreSQL auto-applies them on CREATE. Without this, the diff +/// is non-idempotent — re-running compare after applying it would emit +/// `REVOKE` statements for the auto-granted privileges that the migration +/// itself is responsible for cleaning up. +#[tokio::test] +async fn compare_grants_new_foreign_table_revokes_default_priv_grants() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // FROM has a default-privilege rule that grants SELECT to `reader` on + // any new table in `public`. No explicit grants in TO → after CREATE, + // the auto-applied SELECT must be revoked in this same diff. + let dp = DefaultPrivilege { + role_name: String::new(), + schema_name: "public".to_string(), + object_type: "r".to_string(), + acl: vec!["reader=r/owner".to_string()], + hash: Some("dp".to_string()), + }; + from_dump.default_privileges.push(dp); + + // TO-only foreign table (no FROM counterpart, no explicit ACL). + let to_ft = ForeignTable::new( + "public".to_string(), + "ft_new".to_string(), + "fdw_server".to_string(), + "owner".to_string(), + Vec::new(), + Vec::new(), + ); + to_dump.foreign_tables.push(to_ft); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("REVOKE SELECT ON TABLE public.ft_new FROM reader;"), + "New foreign table must revoke auto-applied default-privilege grants under full mode, got: {script}" + ); + assert!( + !script.contains("ON FOREIGN TABLE"), + "Foreign table grants must use `ON TABLE` syntax, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_dropped_view_restores_all_grants() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Both FROM and TO have the same grant on the view. + let mut from_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + Vec::new(), + ); + from_view.acl = vec!["reader=r/owner".to_string()]; + + let mut to_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + Vec::new(), + ); + to_view.acl = vec!["reader=r/owner".to_string()]; + + from_dump.views.push(from_view); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + // Simulate that the view was dropped earlier in the script + // (e.g. as a dependency of an altered table). + comparer + .dropped_views + .insert(Comparer::normalized_view_key("public", "my_view"), true); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT SELECT ON TABLE public.my_view TO reader;"), + "Dropped view must restore grants even when FROM has the same ACL, got: {script}" + ); +} + +/// When a view's DROP was only commented out (use_drop=false), the view still +/// exists in the database. compare_grants must keep the original from_acl so +/// that identical ACLs produce no diff (no redundant GRANTs/REVOKEs). +#[tokio::test] +async fn compare_grants_commented_drop_keeps_from_acl() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + Vec::new(), + ); + from_view.acl = vec!["reader=r/owner".to_string()]; + + let mut to_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + Vec::new(), + ); + to_view.acl = vec!["reader=r/owner".to_string()]; + + from_dump.views.push(from_view); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + // Simulate a commented-out drop (use_drop=false → stored as false). + comparer + .dropped_views + .insert(Comparer::normalized_view_key("public", "my_view"), false); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + // ACLs are identical and the view was NOT actually dropped, + // so no GRANT/REVOKE should appear. + let has_grant_stmt = script + .lines() + .any(|l| l.trim_start().to_lowercase().starts_with("grant ")); + assert!( + !has_grant_stmt, + "Commented-out drop must not cause redundant GRANTs, got: {script}" + ); + let has_revoke_stmt = script + .lines() + .any(|l| l.trim_start().to_lowercase().starts_with("revoke ")); + assert!( + !has_revoke_stmt, + "Commented-out drop must not cause redundant REVOKEs, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_routine_function() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "public".to_string(), + Oid(1), + "my_func".to_string(), + "plpgsql".to_string(), + "function".to_string(), + "void".to_string(), + "".to_string(), + None, + None, + "BEGIN END".to_string(), + ); + from_routine.acl = vec![]; + + let mut to_routine = Routine::new( + "public".to_string(), + Oid(1), + "my_func".to_string(), + "plpgsql".to_string(), + "function".to_string(), + "void".to_string(), + "".to_string(), + None, + None, + "BEGIN END".to_string(), + ); + to_routine.acl = vec!["app=X/owner".to_string()]; + + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT EXECUTE ON FUNCTION public.my_func() TO app;"), + "Must add function grant, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_routine_procedure() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "public".to_string(), + Oid(2), + "my_proc".to_string(), + "plpgsql".to_string(), + "procedure".to_string(), + "void".to_string(), + "days integer".to_string(), + None, + None, + "BEGIN END".to_string(), + ); + from_routine.acl = vec!["app=X/owner".to_string()]; + + let mut to_routine = Routine::new( + "public".to_string(), + Oid(2), + "my_proc".to_string(), + "plpgsql".to_string(), + "procedure".to_string(), + "void".to_string(), + "days integer".to_string(), + None, + None, + "BEGIN END".to_string(), + ); + to_routine.acl = vec![]; + + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("REVOKE EXECUTE ON PROCEDURE public.my_proc(days integer) FROM app;"), + "Full must revoke removed procedure grant, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_with_grant_option() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); + from_schema.acl = vec!["reader=U/owner".to_string()]; + let mut to_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); + to_schema.acl = vec!["reader=U*/owner".to_string()]; + + from_dump.schemas.push(from_schema); + to_dump.schemas.push(to_schema); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT USAGE ON SCHEMA myschema TO reader WITH GRANT OPTION;"), + "Must add grant with grant option, got: {script}" + ); + assert!( + !script.contains("REVOKE"), + "Upgrading to WITH GRANT OPTION must not REVOKE, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_no_comments_mode() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); + from_schema.acl = vec![]; + let mut to_schema = Schema::new("myschema".to_string(), "myschema".to_string(), None); + to_schema.acl = vec!["reader=U/owner".to_string()]; + + from_dump.schemas.push(from_schema); + to_dump.schemas.push(to_schema); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::AddOnly); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT USAGE ON SCHEMA myschema TO reader;"), + "Grant must still be emitted, got: {script}" + ); + assert!( + !script.contains("/* Grants for schema"), + "Comments must be suppressed, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_new_object_no_from_acl() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Table exists only in TO + let mut to_table = Table::new( + "public".to_string(), + "new_tbl".to_string(), + "public".to_string(), + "new_tbl".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec!["reader=r/owner".to_string()]; + + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT SELECT ON TABLE public.new_tbl TO reader;"), + "Must grant on new object with empty FROM acl, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_table_addonly_no_revoke() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_table = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec!["reader=r/owner".to_string(), "old_app=rw/owner".to_string()]; + + let mut to_table = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec![ + "reader=r/owner".to_string(), + "new_app=rwd/owner".to_string(), + ]; + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT DELETE, SELECT, UPDATE ON TABLE public.orders TO new_app;"), + "AddOnly must add new_app grant, got: {script}" + ); + assert!( + !script.contains("REVOKE"), + "AddOnly must not revoke old_app, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_sequence_full_revoke() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_seq = Sequence::new( + "public".to_string(), + "order_id_seq".to_string(), + "owner".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9223372036854775807), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + from_seq.acl = vec!["app=U/owner".to_string(), "old_svc=U/owner".to_string()]; + + let mut to_seq = Sequence::new( + "public".to_string(), + "order_id_seq".to_string(), + "owner".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9223372036854775807), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + to_seq.acl = vec!["app=U/owner".to_string()]; + + from_dump.sequences.push(from_seq); + to_dump.sequences.push(to_seq); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("REVOKE USAGE ON SEQUENCE public.order_id_seq FROM old_svc;"), + "Full must revoke removed sequence grant, got: {script}" + ); + assert!( + !script.contains("GRANT"), + "No new grants expected, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_sequence_addonly_no_revoke() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_seq = Sequence::new( + "public".to_string(), + "s1".to_string(), + "owner".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9223372036854775807), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + from_seq.acl = vec!["old_svc=U/owner".to_string()]; + + let mut to_seq = Sequence::new( + "public".to_string(), + "s1".to_string(), + "owner".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9223372036854775807), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + to_seq.acl = vec![]; + + from_dump.sequences.push(from_seq); + to_dump.sequences.push(to_seq); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("REVOKE"), + "AddOnly must not revoke sequence grants, got: {script}" + ); + assert!( + !script.contains("GRANT"), + "No grants expected, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_view_full_revoke() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_view = View::new( + "report_v".to_string(), + "SELECT 1".to_string(), + "reports".to_string(), + Vec::new(), + ); + from_view.acl = vec!["analyst=r/owner".to_string(), "intern=r/owner".to_string()]; + + let mut to_view = View::new( + "report_v".to_string(), + "SELECT 1".to_string(), + "reports".to_string(), + Vec::new(), + ); + to_view.acl = vec!["analyst=r/owner".to_string()]; + + from_dump.views.push(from_view); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("REVOKE SELECT ON TABLE reports.report_v FROM intern;"), + "Full must revoke removed view grant, got: {script}" + ); + assert!( + !script.contains("GRANT"), + "No new grants expected, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_routine_function_full_revoke() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "public".to_string(), + Oid(10), + "calc".to_string(), + "plpgsql".to_string(), + "function".to_string(), + "integer".to_string(), + "x integer".to_string(), + None, + None, + "BEGIN RETURN x; END".to_string(), + ); + from_routine.acl = vec!["app=X/owner".to_string(), "old_svc=X/owner".to_string()]; + + let mut to_routine = Routine::new( + "public".to_string(), + Oid(10), + "calc".to_string(), + "plpgsql".to_string(), + "function".to_string(), + "integer".to_string(), + "x integer".to_string(), + None, + None, + "BEGIN RETURN x; END".to_string(), + ); + to_routine.acl = vec!["app=X/owner".to_string()]; + + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("REVOKE EXECUTE ON FUNCTION public.calc(x integer) FROM old_svc;"), + "Full must revoke removed function grant, got: {script}" + ); + assert!( + !script.contains("GRANT"), + "No new grants expected, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_grantor_only_diff_produces_no_output() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Schema: same grantee + privileges, different grantor + let mut from_schema = Schema::new("app".to_string(), "app".to_string(), None); + from_schema.acl = vec!["reader=UC/old_owner".to_string()]; + let mut to_schema = Schema::new("app".to_string(), "app".to_string(), None); + to_schema.acl = vec!["reader=UC/new_owner".to_string()]; + from_dump.schemas.push(from_schema); + to_dump.schemas.push(to_schema); + + // Table: same grantee + privileges, different grantor + let mut from_table = Table::new( + "app".to_string(), + "t1".to_string(), + "app".to_string(), + "t1".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec!["reader=rw/old_owner".to_string()]; + let mut to_table = Table::new( + "app".to_string(), + "t1".to_string(), + "app".to_string(), + "t1".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec!["reader=rw/new_owner".to_string()]; + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + // Sequence: same grantee + privileges, different grantor + let mut from_seq = Sequence::new( + "app".to_string(), + "s1".to_string(), + "owner".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9223372036854775807), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + from_seq.acl = vec!["reader=U/old_owner".to_string()]; + let mut to_seq = Sequence::new( + "app".to_string(), + "s1".to_string(), + "owner".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9223372036854775807), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + to_seq.acl = vec!["reader=U/new_owner".to_string()]; + from_dump.sequences.push(from_seq); + to_dump.sequences.push(to_seq); + + // View: same grantee + privileges, different grantor + let mut from_view = View::new( + "v1".to_string(), + "SELECT 1".to_string(), + "app".to_string(), + Vec::new(), + ); + from_view.acl = vec!["reader=r/old_owner".to_string()]; + let mut to_view = View::new( + "v1".to_string(), + "SELECT 1".to_string(), + "app".to_string(), + Vec::new(), + ); + to_view.acl = vec!["reader=r/new_owner".to_string()]; + from_dump.views.push(from_view); + to_dump.views.push(to_view); + + // Routine: same grantee + privileges, different grantor + let mut from_routine = Routine::new( + "app".to_string(), + Oid(99), + "do_it".to_string(), + "plpgsql".to_string(), + "function".to_string(), + "void".to_string(), + "".to_string(), + None, + None, + "BEGIN END".to_string(), + ); + from_routine.acl = vec!["runner=X/old_owner".to_string()]; + let mut to_routine = Routine::new( + "app".to_string(), + Oid(99), + "do_it".to_string(), + "plpgsql".to_string(), + "function".to_string(), + "void".to_string(), + "".to_string(), + None, + None, + "BEGIN END".to_string(), + ); + to_routine.acl = vec!["runner=X/new_owner".to_string()]; + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("GRANT"), + "Grantor-only diff must not emit GRANT, got: {script}" + ); + assert!( + !script.contains("REVOKE"), + "Grantor-only diff must not emit REVOKE, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_full_grant_option_downgrade() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_table = Table::new( + "public".to_string(), + "items".to_string(), + "public".to_string(), + "items".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec!["admin=r*/owner".to_string()]; + + let mut to_table = Table::new( + "public".to_string(), + "items".to_string(), + "public".to_string(), + "items".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec!["admin=r/owner".to_string()]; + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("REVOKE GRANT OPTION FOR SELECT ON TABLE public.items FROM admin;"), + "Full must revoke grant option when downgrading, got: {script}" + ); + assert!( + !script.contains("GRANT SELECT"), + "No new grant expected for downgrade, got: {script}" + ); + // Should only contain REVOKE GRANT OPTION FOR, not a bare REVOKE SELECT + assert!( + !script.contains("REVOKE SELECT ON TABLE"), + "Must not fully revoke the privilege on downgrade, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_addonly_ignores_grant_option_downgrade() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_table = Table::new( + "public".to_string(), + "items".to_string(), + "public".to_string(), + "items".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec!["admin=r*/owner".to_string()]; + + let mut to_table = Table::new( + "public".to_string(), + "items".to_string(), + "public".to_string(), + "items".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec!["admin=r/owner".to_string()]; + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("REVOKE"), + "AddOnly must not revoke grant option, got: {script}" + ); + assert!( + !script.contains("GRANT"), + "No new grant expected, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_excludes_owner_acl_entries() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Table with ownership change: old_owner → new_owner + let mut from_table = Table::new( + "public".to_string(), + "data".to_string(), + "public".to_string(), + "data".to_string(), + "old_owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec![ + "old_owner=arwdDxt/old_owner".to_string(), + "reader=r/old_owner".to_string(), + ]; + + let mut to_table = Table::new( + "public".to_string(), + "data".to_string(), + "public".to_string(), + "data".to_string(), + "new_owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec![ + "new_owner=arwdDxt/new_owner".to_string(), + "reader=r/new_owner".to_string(), + ]; + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + // The `old_owner=arwdDxt/old_owner` and `new_owner=arwdDxt/new_owner` + // entries are PostgreSQL's implicit-owner ACL rows (materialised once + // any GRANT exists). `ALTER TABLE ... OWNER TO new_owner` removes + // old_owner's implicit row and adds new_owner's automatically — no + // REVOKE/GRANT is needed for those rows. Reader is unchanged on both + // sides. Net diff: empty. Pre-fix the comparer treated the implicit + // FROM-owner row as if it would persist post-migration and emitted a + // long REVOKE, then on the next compare run had nothing to compare + // against and emitted GRANTs — a non-idempotent oscillation. + assert!( + !script.contains("REVOKE"), + "ALTER OWNER alone strips the implicit-owner entry; no REVOKE should be emitted, got: {script}" + ); + assert!( + !script.contains("GRANT"), + "No grants expected — reader is unchanged and new_owner gets implicit privileges via ALTER OWNER, got: {script}" + ); + assert!( + !script.contains("new_owner"), + "Must not reference the new owner explicitly, got: {script}" + ); + assert!( + !script.contains("old_owner"), + "Must not reference the former owner explicitly when only the implicit-owner ACL row needs to migrate, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_full_revokes_explicit_grants_from_former_owner() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_schema = Schema::new("billing".to_string(), "billing".to_string(), None); + from_schema.owner = "old_owner".to_string(); + from_schema.acl = vec!["old_owner=UC/old_owner".to_string()]; + + let mut to_schema = Schema::new("billing".to_string(), "billing".to_string(), None); + to_schema.owner = "new_owner".to_string(); + + let mut from_table = Table::new( + "billing".to_string(), + "invoice".to_string(), + "billing".to_string(), + "invoice".to_string(), + "old_owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec!["old_owner=ar/old_owner".to_string()]; + + let to_table = Table::new( + "billing".to_string(), + "invoice".to_string(), + "billing".to_string(), + "invoice".to_string(), + "new_owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + + from_dump.schemas.push(from_schema); + from_dump.tables.push(from_table); + to_dump.schemas.push(to_schema); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + // Same reasoning as `compare_grants_excludes_owner_acl_entries`: + // `old_owner=UC/old_owner` and `old_owner=ar/old_owner` are + // implicit-owner ACL entries that `ALTER ... OWNER TO new_owner` + // strips automatically. Comparing against TO (which has no entries + // at all) should produce an empty diff, not REVOKE statements. + assert!( + !script.contains("REVOKE"), + "Implicit-owner ACL entries are removed by ALTER OWNER alone; no REVOKE should be emitted, got: {script}" + ); + assert!( + !script.contains("new_owner"), + "Current owner must not appear in grant/revoke output, got: {script}" + ); + assert!( + !script.contains("old_owner"), + "Former owner must not appear in grant/revoke output for the implicit-owner ACL row, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_emits_explicit_grants_to_former_owner() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_schema = Schema::new("billing".to_string(), "billing".to_string(), None); + from_schema.owner = "old_owner".to_string(); + + let mut to_schema = Schema::new("billing".to_string(), "billing".to_string(), None); + to_schema.owner = "new_owner".to_string(); + to_schema.acl = vec![ + "old_owner=UC/new_owner".to_string(), + "app_user=U/new_owner".to_string(), + ]; + + let from_table = Table::new( + "billing".to_string(), + "invoice".to_string(), + "billing".to_string(), + "invoice".to_string(), + "old_owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + + let mut to_table = Table::new( + "billing".to_string(), + "invoice".to_string(), + "billing".to_string(), + "invoice".to_string(), + "new_owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec![ + "old_owner=ar/new_owner".to_string(), + "app_user=r/new_owner".to_string(), + ]; + + from_dump.schemas.push(from_schema); + from_dump.tables.push(from_table); + to_dump.schemas.push(to_schema); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::AddOnly); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT CREATE, USAGE ON SCHEMA billing TO old_owner;"), + "Former schema owner must receive explicit TO grant, got: {script}" + ); + assert!( + script.contains("GRANT USAGE ON SCHEMA billing TO app_user;"), + "Non-owner schema grant must still be emitted, got: {script}" + ); + assert!( + script.contains("GRANT INSERT, SELECT ON TABLE billing.invoice TO old_owner;"), + "Former table owner must receive explicit TO grant, got: {script}" + ); + assert!( + script.contains("GRANT SELECT ON TABLE billing.invoice TO app_user;"), + "Non-owner table grant must still be emitted, got: {script}" + ); + assert!( + !script.contains("TO new_owner"), + "Current owner must not receive explicit grants, got: {script}" + ); +} + +#[tokio::test] +async fn compare_grants_owner_excluded_nonowner_still_diffed() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "public".to_string(), + Oid(50), + "process".to_string(), + "plpgsql".to_string(), + "function".to_string(), + "void".to_string(), + "".to_string(), + None, + None, + "BEGIN END".to_string(), + ); + from_routine.owner = "the_owner".to_string(); + from_routine.acl = vec![ + "the_owner=X/the_owner".to_string(), + "old_app=X/the_owner".to_string(), + ]; + + let mut to_routine = Routine::new( + "public".to_string(), + Oid(50), + "process".to_string(), + "plpgsql".to_string(), + "function".to_string(), + "void".to_string(), + "".to_string(), + None, + None, + "BEGIN END".to_string(), + ); + to_routine.owner = "the_owner".to_string(); + to_routine.acl = vec![ + "the_owner=X/the_owner".to_string(), + "new_app=X/the_owner".to_string(), + ]; + + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT EXECUTE ON FUNCTION public.process() TO new_app;"), + "Must grant to non-owner, got: {script}" + ); + assert!( + script.contains("REVOKE EXECUTE ON FUNCTION public.process() FROM old_app;"), + "Must revoke from non-owner, got: {script}" + ); + assert!( + !script.contains("the_owner"), + "Must not reference owner in grants/revokes, got: {script}" + ); +} + +/// A table tracked in `recreated_tables` (e.g. due to partition key change) +/// must use the FROM default privilege ACL as its effective from_acl in full +/// grants mode, so that no spurious REVOKEs appear on repeated runs. +#[tokio::test] +async fn compare_grants_recreated_table_uses_default_acl() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Both FROM and TO have the same table with the same ACL. + let mut from_table = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec!["reader=r/owner".to_string()]; + + let mut to_table = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec!["reader=r/owner".to_string()]; + + // Default privilege that auto-grants SELECT to reader on new tables. + from_dump.default_privileges.push(DefaultPrivilege { + role_name: "owner".to_string(), + schema_name: "public".to_string(), + object_type: "r".to_string(), + acl: vec!["reader=r/owner".to_string()], + hash: None, + }); + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + // Simulate that the table was recreated (e.g. partition key change). + comparer + .recreated_tables + .insert(Comparer::table_key("public", "orders")); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + // The default privilege matches the TO ACL, so no GRANT or REVOKE needed. + let has_grant = script + .lines() + .any(|l| l.trim_start().to_lowercase().starts_with("grant ")); + let has_revoke = script + .lines() + .any(|l| l.trim_start().to_lowercase().starts_with("revoke ")); + assert!( + !has_grant && !has_revoke, + "Recreated table with matching default ACL must produce no GRANT/REVOKE, got: {script}" + ); +} + +/// A recreated table whose TO ACL differs from the default privilege ACL +/// must produce the correct GRANT to bridge the gap. +#[tokio::test] +async fn compare_grants_recreated_table_grants_extra_over_default() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_table = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec!["reader=r/owner".to_string(), "writer=rw/owner".to_string()]; + + let mut to_table = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec!["reader=r/owner".to_string(), "writer=rw/owner".to_string()]; + + // Default privilege only grants SELECT to reader (no writer grant). + from_dump.default_privileges.push(DefaultPrivilege { + role_name: "owner".to_string(), + schema_name: "public".to_string(), + object_type: "r".to_string(), + acl: vec!["reader=r/owner".to_string()], + hash: None, + }); + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer + .recreated_tables + .insert(Comparer::table_key("public", "orders")); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT SELECT, UPDATE ON TABLE public.orders TO writer;"), + "Must grant writer privileges beyond default ACL, got: {script}" + ); + // reader already has SELECT via default, so no GRANT for reader. + let reader_grant = script.lines().any(|l| l.contains("TO reader")); + assert!( + !reader_grant, + "reader grant already covered by default ACL, got: {script}" + ); +} + +/// A non-recreated table that exists in both FROM and TO must use the +/// original FROM ACL, not the default privilege ACL. +#[tokio::test] +async fn compare_grants_non_recreated_table_uses_from_acl() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_table = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + from_table.acl = vec!["reader=r/owner".to_string()]; + + let mut to_table = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "owner".to_string(), + None, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + to_table.acl = vec!["reader=r/owner".to_string()]; + + // Even though default privilege differs, we must use FROM ACL. + from_dump.default_privileges.push(DefaultPrivilege { + role_name: "owner".to_string(), + schema_name: "public".to_string(), + object_type: "r".to_string(), + acl: vec![], + hash: None, + }); + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + // Do NOT insert into recreated_tables — table is not recreated. + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + // FROM and TO ACLs match, so no diff should be produced. + let has_grant = script + .lines() + .any(|l| l.trim_start().to_lowercase().starts_with("grant ")); + let has_revoke = script + .lines() + .any(|l| l.trim_start().to_lowercase().starts_with("revoke ")); + assert!( + !has_grant && !has_revoke, + "Non-recreated table with identical ACLs must produce no GRANT/REVOKE, got: {script}" + ); +} + +/// A dropped+recreated view (use_drop=true) in full grants mode must use +/// the default privilege ACL as the effective from_acl, matching the table +/// recreated-object logic. +#[tokio::test] +async fn compare_grants_dropped_view_uses_default_acl_full_mode() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + Vec::new(), + ); + from_view.acl = vec!["reader=r/owner".to_string()]; + + let mut to_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + Vec::new(), + ); + to_view.acl = vec!["reader=r/owner".to_string()]; + + // Default privilege auto-grants SELECT to reader on new tables/views. + from_dump.default_privileges.push(DefaultPrivilege { + role_name: "owner".to_string(), + schema_name: "public".to_string(), + object_type: "r".to_string(), + acl: vec!["reader=r/owner".to_string()], + hash: None, + }); + + from_dump.views.push(from_view); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + // Simulate that the view was actually dropped (use_drop=true). + comparer + .dropped_views + .insert(Comparer::normalized_view_key("public", "my_view"), true); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + // Default ACL matches TO ACL, so no diff needed. + let has_grant = script + .lines() + .any(|l| l.trim_start().to_lowercase().starts_with("grant ")); + let has_revoke = script + .lines() + .any(|l| l.trim_start().to_lowercase().starts_with("revoke ")); + assert!( + !has_grant && !has_revoke, + "Dropped view with matching default ACL must produce no GRANT/REVOKE, got: {script}" + ); +} + +/// A dropped view (use_drop=true) in full mode whose TO ACL has more +/// privileges than the default must produce GRANTs to bridge the gap. +#[tokio::test] +async fn compare_grants_dropped_view_grants_extra_over_default() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + Vec::new(), + ); + from_view.acl = vec!["reader=r/owner".to_string(), "writer=rw/owner".to_string()]; + + let mut to_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + Vec::new(), + ); + to_view.acl = vec!["reader=r/owner".to_string(), "writer=rw/owner".to_string()]; + + // Default only gives reader SELECT. + from_dump.default_privileges.push(DefaultPrivilege { + role_name: "owner".to_string(), + schema_name: "public".to_string(), + object_type: "r".to_string(), + acl: vec!["reader=r/owner".to_string()], + hash: None, + }); + + from_dump.views.push(from_view); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer + .dropped_views + .insert(Comparer::normalized_view_key("public", "my_view"), true); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("GRANT SELECT, UPDATE ON TABLE public.my_view TO writer;"), + "Must grant writer privileges beyond default ACL for dropped view, got: {script}" + ); +} + +/// When table ownership changes between FROM and TO, column-level ACL +/// diffing must keep former-owner entries diffable while suppressing +/// current-owner implicit privilege entries. +#[tokio::test] +async fn compare_column_grants_revokes_former_owner_and_excludes_current_owner() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // FROM table owned by old_owner with column ACL for old_owner + let mut from_col = int_column("public", "users", "secret", 1); + from_col.acl = vec!["old_owner=r/old_owner".to_string()]; + let from_table = Table::new( + "public".to_string(), + "users".to_string(), + "public".to_string(), + "users".to_string(), + "old_owner".to_string(), + None, + vec![from_col], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + + // TO table owned by new_owner with column ACL for new_owner + let mut to_col = int_column("public", "users", "secret", 1); + to_col.acl = vec!["new_owner=r/new_owner".to_string()]; + let to_table = Table::new( + "public".to_string(), + "users".to_string(), + "public".to_string(), + "users".to_string(), + "new_owner".to_string(), + None, + vec![to_col], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("REVOKE SELECT (secret) ON TABLE public.users FROM old_owner;"), + "Former owner column ACL must remain diffable in full mode, got: {script}" + ); + assert!( + !script.contains("new_owner"), + "Current owner column ACL entries must be suppressed, got: {script}" + ); + assert!( + !script + .lines() + .any(|l| l.contains("secret") && l.trim_start().to_lowercase().starts_with("grant ")), + "Unexpected column GRANT for owner ACL entries, got: {script}" + ); +} + +/// Counterpart to `compare_column_grants_revokes_former_owner_and_excludes_current_owner`: +/// when ownership changes and the new TO has *no* explicit column ACL at all +/// (only the implicit owner privileges), a former owner's column grant in +/// FROM must still be revoked under `full` mode. Without this we would leak +/// the old owner's column-level access into the post-migration database. +#[tokio::test] +async fn compare_column_grants_revokes_former_owner_when_to_has_no_column_acl() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_col = int_column("public", "users", "secret", 1); + from_col.acl = vec!["old_owner=r/old_owner".to_string()]; + let from_table = Table::new( + "public".to_string(), + "users".to_string(), + "public".to_string(), + "users".to_string(), + "old_owner".to_string(), + None, + vec![from_col], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + + // No column ACL in TO. + let to_col = int_column("public", "users", "secret", 1); + let to_table = Table::new( + "public".to_string(), + "users".to_string(), + "public".to_string(), + "users".to_string(), + "new_owner".to_string(), + None, + vec![to_col], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("REVOKE SELECT (secret) ON TABLE public.users FROM old_owner;"), + "Former owner column ACL must be revoked even without ACL in TO, got: {script}" + ); + assert!( + !script.contains("new_owner"), + "Current owner must never appear in column grant output, got: {script}" + ); +} + +/// Regression test for the per-table column-ACL HashMap rewrite. Previously +/// each TO column did a linear scan over `from_cols`; the rewrite indexes +/// `from_cols` by name once per table. This test exercises a table with +/// multiple columns where each column's effective `from_acl` differs, to +/// catch off-by-one mistakes that a single-column test would miss. +#[tokio::test] +async fn compare_column_grants_dispatches_per_column_acl_correctly() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // FROM: three columns with distinct ACL states. + let mut from_a = int_column("public", "t", "a", 1); + from_a.acl = vec!["reader=r/owner".to_string()]; + let mut from_b = int_column("public", "t", "b", 2); + from_b.acl = vec!["reader=r/owner".to_string()]; + let from_c = int_column("public", "t", "c", 3); // no ACL in FROM + + let from_table = Table::new( + "public".to_string(), + "t".to_string(), + "public".to_string(), + "t".to_string(), + "owner".to_string(), + None, + vec![from_a, from_b, from_c], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + + // TO: a kept, b loses its grant, c gains a grant. + let mut to_a = int_column("public", "t", "a", 1); + to_a.acl = vec!["reader=r/owner".to_string()]; + let to_b = int_column("public", "t", "b", 2); // grant should be revoked + let mut to_c = int_column("public", "t", "c", 3); + to_c.acl = vec!["writer=a/owner".to_string()]; // INSERT grant added + + let to_table = Table::new( + "public".to_string(), + "t".to_string(), + "public".to_string(), + "t".to_string(), + "owner".to_string(), + None, + vec![to_a, to_b, to_c], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Full); + comparer.compare_grants().await.unwrap(); + let script = comparer.get_script(); + + // a: identical → nothing emitted for column a. + assert!( + !script.contains("(a)"), + "column a is unchanged and must not appear, got: {script}" + ); + // b: REVOKE for the dropped grant. + assert!( + script.contains("REVOKE SELECT (b) ON TABLE public.t FROM reader;"), + "expected REVOKE for column b, got: {script}" + ); + // c: GRANT for the added INSERT privilege. + assert!( + script.contains("GRANT INSERT (c) ON TABLE public.t TO writer;"), + "expected GRANT INSERT on column c, got: {script}" + ); + // Sanity: no cross-talk where column b's REVOKE refers to writer/c, etc. + assert!( + !script.contains("REVOKE SELECT (c)"), + "column c had no FROM grant and must not be revoked, got: {script}" + ); + assert!( + !script.contains("GRANT INSERT (a)") && !script.contains("GRANT INSERT (b)"), + "INSERT grant must be scoped to column c only, got: {script}" + ); +} diff --git a/app/src/comparer/tests/core/helpers.rs b/app/src/comparer/tests/core/helpers.rs new file mode 100644 index 0000000..4c41827 --- /dev/null +++ b/app/src/comparer/tests/core/helpers.rs @@ -0,0 +1,235 @@ +//! Fixture builders shared by more than one `comparer::core` test module. +//! +//! Helpers used by a single module live in that module instead. + +use crate::comparer::core::*; +use crate::dump::pg_type::PgType; +use crate::dump::routine::Routine; +use crate::dump::table::Table; +use crate::dump::table_column::TableColumn; +use crate::dump::table_constraint::TableConstraint; +use crate::dump::table_index::TableIndex; +use crate::dump::table_policy::TablePolicy; +use crate::dump::view::View; +use sqlx::postgres::types::Oid; + +pub(super) fn make_domain_type(schema: &str, name: &str, oid: u32) -> PgType { + PgType::new( + Oid(oid), + schema.to_string(), + name.to_string(), + Oid(2200), + Oid(10), + "postgres".to_string(), + -1, + false, + 'd' as i8, + 'U' as i8, + false, + true, + ',' as i8, + None, + None, + None, + None, + "domain_in".to_string(), + "domain_out".to_string(), + None, + None, + None, + None, + None, + 'i' as i8, + 'x' as i8, + false, + Some(Oid(25)), + None, + 0, + None, + None, + Some("text".to_string()), + Vec::new(), + Vec::new(), + None, + ) +} + +pub(super) fn make_enum_type(schema: &str, name: &str, oid: u32, labels: Vec<&str>) -> PgType { + let mut enum_type = make_domain_type(schema, name, oid); + enum_type.typtype = 'e' as i8; + enum_type.typcategory = 'E' as i8; + enum_type.typinput = "enum_in".to_string(); + enum_type.typoutput = "enum_out".to_string(); + enum_type.typbasetype = None; + enum_type.formatted_basetype = None; + enum_type.enum_labels = labels.into_iter().map(|label| label.to_string()).collect(); + enum_type.domain_constraints.clear(); + enum_type.hash(); + enum_type +} + +pub(super) fn int_column(schema: &str, table: &str, name: &str, ordinal: i32) -> TableColumn { + TableColumn { + catalog: "postgres".to_string(), + schema: schema.to_string(), + table: table.to_string(), + name: name.to_string(), + ordinal_position: ordinal, + column_default: None, + is_nullable: true, + data_type: "integer".to_string(), + character_maximum_length: None, + character_octet_length: None, + numeric_precision: Some(32), + numeric_precision_radix: Some(2), + numeric_scale: Some(0), + datetime_precision: None, + interval_type: None, + interval_precision: None, + character_set_catalog: None, + character_set_schema: None, + character_set_name: None, + collation_catalog: None, + collation_schema: None, + collation_name: None, + domain_catalog: None, + domain_schema: None, + domain_name: None, + udt_catalog: None, + udt_schema: None, + udt_name: None, + scope_catalog: None, + scope_schema: None, + scope_name: None, + maximum_cardinality: None, + dtd_identifier: None, + is_self_referencing: false, + is_identity: false, + identity_generation: None, + identity_start: None, + identity_increment: None, + identity_maximum: None, + identity_minimum: None, + identity_cycle: false, + is_generated: "NEVER".to_string(), + generation_expression: None, + generation_type: None, + is_updatable: true, + related_views: None, + comment: None, + storage: None, + compression: None, + statistics_target: None, + acl: vec![], + serial_type: None, + } +} + +/// Build a `Routine` mirroring `test_deps.compute(x integer)` from the +/// issue report, parameterised by return type so a single helper covers +/// both the FROM (integer) and TO (bigint) sides. +pub(super) fn issue179_compute_routine(return_type: &str, body: &str) -> Routine { + let mut routine = Routine::new( + "test_deps".to_string(), + Oid(900), + "compute".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + return_type.to_string(), + "x integer".to_string(), + None, + None, + body.to_string(), + ); + routine.hash(); + routine +} + +/// Construct an `items` table that mirrors the issue's example: each +/// dependent (functional index, CHECK constraint, generated column, +/// column DEFAULT, RLS policy) references `test_deps.compute`. +pub(super) fn issue179_items_table(value_type: &str, def_default: &str, gen_type: &str) -> Table { + let mut def_col = int_column("test_deps", "items", "def_col", 2); + def_col.data_type = value_type.to_string(); + def_col.column_default = Some(def_default.to_string()); + + let mut gen_col = int_column("test_deps", "items", "gen_col", 3); + gen_col.data_type = gen_type.to_string(); + gen_col.is_generated = "ALWAYS".to_string(); + gen_col.generation_expression = Some("test_deps.compute(value)".to_string()); + gen_col.generation_type = Some("s".to_string()); + + let mut value_col = int_column("test_deps", "items", "value", 1); + value_col.is_nullable = false; + + let chk_constraint = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_deps".to_string(), + name: "chk_compute".to_string(), + table_name: "items".to_string(), + constraint_type: "CHECK".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("CHECK (test_deps.compute(value) > 0)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let idx = TableIndex { + schema: "test_deps".to_string(), + table: "items".to_string(), + name: "idx_compute".to_string(), + catalog: Some("postgres".to_string()), + indexdef: + "CREATE INDEX idx_compute ON test_deps.items USING btree (test_deps.compute(value))" + .to_string(), + is_partition_index: false, + comment: None, + }; + + let policy = TablePolicy { + schema: "test_deps".to_string(), + table: "items".to_string(), + name: "p_items".to_string(), + command: "all".to_string(), + permissive: true, + roles: vec![], + using_clause: Some("(test_deps.compute(value) > 0)".to_string()), + check_clause: None, + }; + + let mut table = Table::new( + "test_deps".to_string(), + "items".to_string(), + "test_deps".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![value_col, def_col, gen_col], + vec![chk_constraint], + vec![idx], + vec![], + None, + ); + table.policies = vec![policy]; + table.has_rowsecurity = true; + table.hash(); + table +} + +/// Build a view whose definition textually references `test_deps.compute`. +/// Returns a regular or materialized view depending on `is_materialized`. +pub(super) fn issue189_view(name: &str, is_materialized: bool) -> View { + let mut view = View::new( + name.to_string(), + " SELECT test_deps.compute(value) AS c\n FROM test_deps.items;".to_string(), + "test_deps".to_string(), + vec!["test_deps.items".to_string()], + ); + view.is_materialized = is_materialized; + view.hash(); + view +} diff --git a/app/src/comparer/tests/core/matview_indexes.rs b/app/src/comparer/tests/core/matview_indexes.rs new file mode 100644 index 0000000..dd44ab7 --- /dev/null +++ b/app/src/comparer/tests/core/matview_indexes.rs @@ -0,0 +1,311 @@ +//! Issue #235 — indexes on a materialized view. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::table_index::TableIndex; +use crate::dump::view::View; + +fn mv235_index(name: &str, indexdef: &str) -> TableIndex { + TableIndex { + schema: "test_schema".to_string(), + table: "mv".to_string(), + name: name.to_string(), + catalog: None, + indexdef: indexdef.to_string(), + is_partition_index: false, + comment: None, + } +} + +fn mv235_view(definition: &str, indexes: Vec) -> View { + let mut view = View::new( + "mv".to_string(), + definition.to_string(), + "test_schema".to_string(), + vec!["test_schema.base".to_string()], + ); + view.is_materialized = true; + view.indexes = indexes; + view.hash(); + view +} + +const MV235_IX_VAL: &str = "CREATE INDEX ix_val ON test_schema.mv USING btree (val)"; + +const MV235_IX_ID: &str = "CREATE UNIQUE INDEX ix_id ON test_schema.mv USING btree (id)"; + +#[tokio::test] +async fn issue235_recreated_matview_restores_its_indexes() { + // The definition changes, so the view is dropped and rebuilt. DROP + // MATERIALIZED VIEW takes the indexes with it and nothing put them back: + // the migration silently left the view unindexed, and because neither dump + // carried the indexes the round-2 diff was empty and never reported it. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump.views.push(mv235_view( + "SELECT id, val FROM test_schema.base;", + vec![ + mv235_index("ix_id", MV235_IX_ID), + mv235_index("ix_val", MV235_IX_VAL), + ], + )); + to_dump.views.push(mv235_view( + "SELECT id, val, num FROM test_schema.base;", + vec![ + mv235_index("ix_id", MV235_IX_ID), + mv235_index("ix_val", MV235_IX_VAL), + ], + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + let drop_pos = script + .find("drop materialized view if exists test_schema.mv;") + .expect("changed materialized view must be dropped"); + let create_pos = script + .find("create materialized view test_schema.mv as") + .expect("changed materialized view must be recreated"); + let id_pos = script + .find("CREATE UNIQUE INDEX ix_id ON test_schema.mv USING btree (id);") + .expect("the unique index must be recreated with the view"); + let val_pos = script + .find("CREATE INDEX ix_val ON test_schema.mv USING btree (val);") + .expect("the plain index must be recreated with the view"); + + assert!(drop_pos < create_pos, "drop must precede create:\n{script}"); + assert!( + create_pos < id_pos && create_pos < val_pos, + "indexes must be built after the view exists:\n{script}" + ); +} + +#[tokio::test] +async fn issue235_unchanged_matview_reconciles_indexes_in_place() { + // Same definition on both sides, so the view survives; only the index set + // moves. It must not be dropped and rebuilt just to change an index. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let definition = "SELECT id, val FROM test_schema.base;"; + let mut recommented = mv235_index("ix_cmt", "CREATE INDEX ix_cmt ON test_schema.mv (num)"); + recommented.comment = Some("after".to_string()); + + from_dump.views.push(mv235_view( + definition, + vec![ + mv235_index("ix_cmt", "CREATE INDEX ix_cmt ON test_schema.mv (num)"), + mv235_index("ix_val", MV235_IX_VAL), + ], + )); + to_dump.views.push(mv235_view( + definition, + vec![ + mv235_index("ix_cmt", "CREATE INDEX ix_cmt ON test_schema.mv (num)"), + mv235_index("ix_id", MV235_IX_ID), + recommented, + ], + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.to_lowercase().contains("drop materialized view"), + "an index change must not rebuild the view's contents:\n{script}" + ); + assert!( + !script.to_lowercase().contains("create materialized view"), + "an index change must not rebuild the view's contents:\n{script}" + ); + assert!( + script.contains("drop index if exists test_schema.ix_val;"), + "the removed index must be dropped:\n{script}" + ); + assert!( + script.contains("CREATE UNIQUE INDEX ix_id ON test_schema.mv USING btree (id);"), + "the added index must be created:\n{script}" + ); + assert!( + script.contains("comment on index test_schema.ix_cmt is 'after';"), + "a comment-only change must not touch the index itself:\n{script}" + ); + assert!( + !script.contains("drop index if exists test_schema.ix_cmt;"), + "a comment-only change must not drop the index:\n{script}" + ); +} + +#[tokio::test] +async fn issue235_matview_with_identical_indexes_emits_nothing() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let definition = "SELECT id, val FROM test_schema.base;"; + from_dump.views.push(mv235_view( + definition, + vec![mv235_index("ix_val", MV235_IX_VAL)], + )); + to_dump.views.push(mv235_view( + definition, + vec![mv235_index("ix_val", MV235_IX_VAL)], + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, false, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.trim().is_empty(), + "an unchanged view with unchanged indexes must produce no SQL:\n{script}" + ); +} + +#[tokio::test] +async fn issue235_matview_index_drop_is_commented_out_when_use_drop_is_false() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let definition = "SELECT id, val FROM test_schema.base;"; + from_dump.views.push(mv235_view( + definition, + vec![mv235_index("ix_val", MV235_IX_VAL)], + )); + to_dump.views.push(mv235_view(definition, Vec::new())); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("-- drop index if exists test_schema.ix_val;"), + "use_drop=false must comment the index drop out, as it does for a table:\n{script}" + ); + assert!( + !script + .lines() + .any(|l| !l.trim_start().starts_with("--") && l.contains("drop index")), + "no active drop may survive use_drop=false:\n{script}" + ); +} + +#[tokio::test] +async fn issue235_production_mode_builds_matview_indexes_concurrently_after_commit() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump.views.push(mv235_view( + "SELECT id, val FROM test_schema.base;", + vec![mv235_index( + "ix_stale", + "CREATE INDEX ix_stale ON test_schema.mv (num)", + )], + )); + to_dump.views.push(mv235_view( + "SELECT id, val, num FROM test_schema.base;", + vec![mv235_index("ix_id", MV235_IX_ID)], + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, true, true, GrantsMode::Ignore); + comparer.set_output_for_production(true); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let commit_pos = script.find("commit;").expect("script must contain commit;"); + let create_pos = script + .find("create materialized view if not exists test_schema.mv as") + .expect("the view itself is still built inside the transaction"); + let concurrent_pos = script + .find("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ix_id ON test_schema.mv USING btree (id);") + .expect("a materialized view's index must be built concurrently in production mode"); + + assert!(create_pos < commit_pos, "view create is in-txn:\n{script}"); + assert!( + concurrent_pos > commit_pos, + "CREATE INDEX CONCURRENTLY cannot run inside a transaction block:\n{script}" + ); +} + +#[tokio::test] +async fn issue235_production_mode_drops_matview_indexes_concurrently() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let definition = "SELECT id, val FROM test_schema.base;"; + from_dump.views.push(mv235_view( + definition, + vec![mv235_index("ix_val", MV235_IX_VAL)], + )); + to_dump.views.push(mv235_view(definition, Vec::new())); + + let mut comparer = Comparer::new(from_dump, to_dump, true, true, true, GrantsMode::Ignore); + comparer.set_output_for_production(true); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let commit_pos = script.find("commit;").expect("script must contain commit;"); + let drop_pos = script + .find("drop index concurrently if exists test_schema.ix_val;") + .expect("an in-place index drop must be concurrent in production mode"); + assert!( + drop_pos > commit_pos, + "DROP INDEX CONCURRENTLY cannot run inside a transaction block:\n{script}" + ); +} + +#[tokio::test] +async fn issue235_cascade_recreated_matview_guards_its_indexes() { + // Phase 7 restores a materialized view that DROP FUNCTION ... CASCADE may + // have taken out. The match is textual and can false-positive on a view + // PostgreSQL never dropped, so the recreate is guarded — and the indexes + // that would have gone with it need the same guard, or an unconditional + // CREATE INDEX fails against the surviving one. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + let mut view = issue189_view("mv_things", true); + let index = TableIndex { + schema: "test_deps".to_string(), + table: "mv_things".to_string(), + name: "ix_mv_things".to_string(), + catalog: None, + indexdef: "CREATE INDEX ix_mv_things ON test_deps.mv_things USING btree (c)".to_string(), + is_partition_index: false, + comment: None, + }; + view.indexes = vec![index]; + from_dump.views.push(view.clone()); + to_dump.views.push(view); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("create materialized view if not exists test_deps.mv_things as"), + "the cascade recreate must stay guarded:\n{script}" + ); + assert!( + script.contains( + "CREATE INDEX IF NOT EXISTS ix_mv_things ON test_deps.mv_things USING btree (c);" + ), + "the recreated view's indexes must be guarded the same way:\n{script}" + ); +} diff --git a/app/src/comparer/tests/core/persistence.rs b/app/src/comparer/tests/core/persistence.rs new file mode 100644 index 0000000..93599f9 --- /dev/null +++ b/app/src/comparer/tests/core/persistence.rs @@ -0,0 +1,1692 @@ +//! Issue #180 — `SET UNLOGGED` / `SET LOGGED` statements must respect FK +//! dependencies (PostgreSQL rejects an out-of-order conversion), and owned +//! sequences should not redundantly re-emit the persistence flip the table +//! cascade already propagates. Also covers the FK-parsing fixes from +//! PR #187 and the cycle-breaking work in issues #190 and #191. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::sequence::Sequence; +use crate::dump::table::Table; +use crate::dump::table_constraint::TableConstraint; + +/// Build a logged/unlogged-controlled table with a single FK to another +/// table in the same schema, named with a numeric `id` PK column. Used +/// by the issue-#180 ordering tests. +fn issue180_logged_table( + schema: &str, + name: &str, + is_unlogged: bool, + fk_target: Option<(&str, &str, &str)>, +) -> Table { + let mut id_col = int_column(schema, name, "id", 1); + id_col.is_nullable = false; + + let mut constraints: Vec = vec![TableConstraint { + catalog: "postgres".to_string(), + schema: schema.to_string(), + name: format!("{name}_pkey"), + table_name: name.to_string(), + constraint_type: "PRIMARY KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("PRIMARY KEY (id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }]; + + let mut columns = vec![id_col]; + if let Some((fk_col, fk_schema, fk_table)) = fk_target { + let mut ref_col = int_column(schema, name, fk_col, 2); + ref_col.is_nullable = true; + columns.push(ref_col); + constraints.push(TableConstraint { + catalog: "postgres".to_string(), + schema: schema.to_string(), + name: format!("{name}_{fk_col}_fkey"), + table_name: name.to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some(format!( + "FOREIGN KEY ({fk_col}) REFERENCES {fk_schema}.{fk_table}(id)" + )), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }); + } + + let mut table = Table::new( + schema.to_string(), + name.to_string(), + schema.to_string(), + name.to_string(), + "postgres".to_string(), + None, + columns, + constraints, + vec![], + vec![], + None, + ); + table.is_unlogged = is_unlogged; + table.hash(); + table +} + +#[tokio::test] +async fn issue180_set_unlogged_orders_dependents_before_referenced() { + // FROM: three logged tables with FK chain + // child -> parent -> grandparent. + // TO: the same three tables, all UNLOGGED. + // PostgreSQL refuses `SET UNLOGGED` on a table while a LOGGED table + // still references it, so the conversion order must be leaves + // first: child, then parent, then grandparent. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump.tables.push(issue180_logged_table( + "test_order", + "grandparent", + false, + None, + )); + from_dump.tables.push(issue180_logged_table( + "test_order", + "parent", + false, + Some(("grandparent_id", "test_order", "grandparent")), + )); + from_dump.tables.push(issue180_logged_table( + "test_order", + "child", + false, + Some(("parent_id", "test_order", "parent")), + )); + to_dump.tables.push(issue180_logged_table( + "test_order", + "grandparent", + true, + None, + )); + to_dump.tables.push(issue180_logged_table( + "test_order", + "parent", + true, + Some(("grandparent_id", "test_order", "grandparent")), + )); + to_dump.tables.push(issue180_logged_table( + "test_order", + "child", + true, + Some(("parent_id", "test_order", "parent")), + )); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + let pos_child = script + .find("alter table test_order.child set unlogged;") + .expect("child SET UNLOGGED must be emitted"); + let pos_parent = script + .find("alter table test_order.parent set unlogged;") + .expect("parent SET UNLOGGED must be emitted"); + let pos_grand = script + .find("alter table test_order.grandparent set unlogged;") + .expect("grandparent SET UNLOGGED must be emitted"); + + assert!( + pos_child < pos_parent && pos_parent < pos_grand, + "SET UNLOGGED must be ordered child -> parent -> grandparent (FK leaves first); got\n{}", + script + ); +} + +#[tokio::test] +async fn issue180_set_logged_orders_referenced_before_dependents() { + // Reverse direction: all UNLOGGED -> all LOGGED. + // PostgreSQL refuses `SET LOGGED` while the table still references + // an UNLOGGED one, so order must be roots first: grandparent, then + // parent, then child. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump.tables.push(issue180_logged_table( + "test_order", + "grandparent", + true, + None, + )); + from_dump.tables.push(issue180_logged_table( + "test_order", + "parent", + true, + Some(("grandparent_id", "test_order", "grandparent")), + )); + from_dump.tables.push(issue180_logged_table( + "test_order", + "child", + true, + Some(("parent_id", "test_order", "parent")), + )); + to_dump.tables.push(issue180_logged_table( + "test_order", + "grandparent", + false, + None, + )); + to_dump.tables.push(issue180_logged_table( + "test_order", + "parent", + false, + Some(("grandparent_id", "test_order", "grandparent")), + )); + to_dump.tables.push(issue180_logged_table( + "test_order", + "child", + false, + Some(("parent_id", "test_order", "parent")), + )); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + let pos_grand = script + .find("alter table test_order.grandparent set logged;") + .expect("grandparent SET LOGGED must be emitted"); + let pos_parent = script + .find("alter table test_order.parent set logged;") + .expect("parent SET LOGGED must be emitted"); + let pos_child = script + .find("alter table test_order.child set logged;") + .expect("child SET LOGGED must be emitted"); + + assert!( + pos_grand < pos_parent && pos_parent < pos_child, + "SET LOGGED must be ordered grandparent -> parent -> child (FK roots first); got\n{}", + script + ); +} + +#[tokio::test] +async fn issue180_persistence_change_does_not_emit_inline_inside_alter_table() { + // A table-level ALTER (e.g. add column) MUST NOT carry a SET + // UNLOGGED line — that would re-introduce the alphabetical ordering + // bug. The persistence flip is owned by the dedicated phase. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_table = Table::new( + "test_order".to_string(), + "items".to_string(), + "test_order".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![int_column("test_order", "items", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + from_table.is_unlogged = false; + from_table.hash(); + + let mut to_table = Table::new( + "test_order".to_string(), + "items".to_string(), + "test_order".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![ + int_column("test_order", "items", "id", 1), + int_column("test_order", "items", "name", 2), + ], + vec![], + vec![], + vec![], + None, + ); + to_table.is_unlogged = true; + to_table.hash(); + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + // SET UNLOGGED is still emitted, but only once and after the ADD + // COLUMN — not interleaved inside the per-table ALTER block. + let add_pos = script + .find("alter table test_order.items add column name") + .expect("add column must be emitted"); + let set_pos = script + .find("alter table test_order.items set unlogged;") + .expect("set unlogged must be emitted by the dedicated phase"); + assert!( + add_pos < set_pos, + "SET UNLOGGED must come from the dedicated phase, after the per-table ALTER: {}", + script + ); + assert_eq!( + script.matches("set unlogged").count(), + 1, + "SET UNLOGGED must be emitted exactly once (no inline + dedicated double-up): {}", + script + ); +} + +#[tokio::test] +async fn issue180_owned_sequence_persistence_only_diff_is_skipped() { + // A sequence whose owning table flips persistence — and which has + // no other diff — produces a redundant `ALTER SEQUENCE ... SET + // UNLOGGED` followed by the full clause list. Both are noise: the + // table's `ALTER TABLE ... SET UNLOGGED` already cascades to all + // owned sequences. Suppress the entire ALTER SEQUENCE. + use crate::dump::sequence::Sequence; + + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_table = Table::new( + "test_order".to_string(), + "items".to_string(), + "test_order".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![int_column("test_order", "items", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + from_table.is_unlogged = false; + from_table.hash(); + + let mut to_table = from_table.clone(); + to_table.is_unlogged = true; + to_table.hash(); + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let make_seq = |is_unlogged: bool| { + let mut s = Sequence::new( + "test_order".to_string(), + "items_id_seq".to_string(), + "postgres".to_string(), + "integer".to_string(), + Some(1), + Some(1), + Some(2147483647), + Some(1), + false, + Some(1), + Some(1), + Some("test_order".to_string()), + Some("items".to_string()), + Some("id".to_string()), + ); + s.is_unlogged = is_unlogged; + s.hash(); + s + }; + from_dump.sequences.push(make_seq(false)); + to_dump.sequences.push(make_seq(true)); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("alter sequence test_order.items_id_seq"), + "owned-sequence persistence-only flip must be suppressed (table cascade handles it); got:\n{}", + script + ); +} + +#[tokio::test] +async fn issue180_owned_sequence_other_diff_skips_only_persistence_line() { + // When the sequence has a real change (e.g. cache_size) AND the + // owning table is also flipping persistence, we still need the + // ALTER SEQUENCE — but not the `SET UNLOGGED|LOGGED` line, because + // the table cascade handles that. + use crate::dump::sequence::Sequence; + + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_table = Table::new( + "test_order".to_string(), + "items".to_string(), + "test_order".to_string(), + "items".to_string(), + "postgres".to_string(), + None, + vec![int_column("test_order", "items", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + from_table.is_unlogged = false; + from_table.hash(); + let mut to_table = from_table.clone(); + to_table.is_unlogged = true; + to_table.hash(); + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let make_seq = |is_unlogged: bool, cache: i64| { + let mut s = Sequence::new( + "test_order".to_string(), + "items_id_seq".to_string(), + "postgres".to_string(), + "integer".to_string(), + Some(1), + Some(1), + Some(2147483647), + Some(1), + false, + Some(cache), + Some(1), + Some("test_order".to_string()), + Some("items".to_string()), + Some("id".to_string()), + ); + s.is_unlogged = is_unlogged; + s.hash(); + s + }; + from_dump.sequences.push(make_seq(false, 1)); + to_dump.sequences.push(make_seq(true, 5)); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("alter sequence test_order.items_id_seq"), + "ALTER SEQUENCE must be emitted when non-persistence params changed: {}", + script + ); + assert!( + script.contains("cache 5"), + "the changed cache value must be in the script: {}", + script + ); + assert!( + !script.contains("alter sequence test_order.items_id_seq set unlogged"), + "SET UNLOGGED on owned sequence is redundant when the owning table is flipping persistence: {}", + script + ); +} + +#[tokio::test] +async fn issue180_standalone_sequence_persistence_change_still_emits_set() { + // A sequence not owned by any table (or owned by a table whose + // persistence is unchanged) must still get its own SET because no + // table cascade applies. + use crate::dump::sequence::Sequence; + + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let make_seq = |is_unlogged: bool| { + let mut s = Sequence::new( + "test_order".to_string(), + "global_seq".to_string(), + "postgres".to_string(), + "integer".to_string(), + Some(1), + Some(1), + Some(2147483647), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + s.is_unlogged = is_unlogged; + s.hash(); + s + }; + from_dump.sequences.push(make_seq(false)); + to_dump.sequences.push(make_seq(true)); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("alter sequence test_order.global_seq set unlogged"), + "standalone sequence must still emit SET UNLOGGED: {}", + script + ); +} + +#[test] +fn issue180_parse_fk_referenced_table_word_boundary() { + // PR #184 review: a naive `find("references ")` substring match + // can pick up the literal text inside a quoted column name in the + // FK column list. The matcher must be anchored to a word boundary + // and the keyword must be followed by whitespace. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (col_a) REFERENCES public.target(id)", + "public", + ), + Some(("public".to_string(), "target".to_string())), + "happy-path FK definition must parse" + ); + // Column literally named `"references "` (with trailing space) in + // the FK column list. Naive substring search would lock onto it + // before the real keyword and parse garbage. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (\"references \", col_b) REFERENCES public.target(id)", + "public", + ), + Some(("public".to_string(), "target".to_string())) + ); + // Column named `references_count` — substring match on + // "references" without the right-side word-boundary check would + // see this column first and try to parse what follows. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (references_count) REFERENCES public.target(id)", + "public", + ), + Some(("public".to_string(), "target".to_string())) + ); +} + +#[test] +fn issue180_parse_fk_referenced_table_quoted_identifier_with_dot() { + // PR #184 review: a quoted identifier may contain a literal `.`, + // and the schema/name split must respect quotes — otherwise the + // first dot inside the quoted segment is taken as the boundary + // and the parsed pair is nonsensical. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (col) REFERENCES \"weird.schema\".\"t\"(id)", + "public", + ), + Some(("weird.schema".to_string(), "t".to_string())) + ); + // Both halves quoted with embedded dots — the split must still + // land on the dot OUTSIDE every quoted segment. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (col) REFERENCES \"a.b\".\"c.d\"(id)", + "public", + ), + Some(("a.b".to_string(), "c.d".to_string())) + ); +} + +#[test] +fn pr187_parse_fk_skips_keyword_inside_quoted_column_name() { + // PR #187 review (C7): a column literally named + // `"my references col"` puts the bytes `references` between two + // spaces, passing the naive boundary check, then returns `None` + // from the false match without ever reaching the real keyword. + // The scanner must skip matches that fall inside a double-quoted + // identifier. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (\"my references col\") REFERENCES public.target(id)", + "public", + ), + Some(("public".to_string(), "target".to_string())), + "FK keyword must still be located even with `references` inside a quoted column name" + ); +} + +#[test] +fn pr187_parse_fk_handles_dollar_in_identifier() { + // PR #187 review (C8): PostgreSQL identifiers may contain `$`, + // so the unquoted-identifier scan must include it. Otherwise a + // target like `public.parent$table` is truncated to + // `public.parent`. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (col) REFERENCES public.parent$table(id)", + "public", + ), + Some(("public".to_string(), "parent$table".to_string())) + ); +} + +#[test] +fn pr187_definition_references_any_skips_string_literals() { + // PR #187 review (C4): a SQL string literal containing routine + // text — `CHECK (msg <> 'compute(value)')` — must not trigger + // the unqualified-call matcher. The `definition_references_any` + // pre-pass must blank out single-quoted literals before scanning. + let mut affected: HashSet<(String, String)> = HashSet::new(); + affected.insert(("public".to_string(), "compute".to_string())); + assert!( + !Comparer::definition_references_any("CHECK (msg <> 'compute(value)')", &affected), + "literal text must not be treated as a function call" + ); + // Sanity check: a real call outside a literal still matches. + assert!( + Comparer::definition_references_any( + "CHECK (compute(value) > 0 AND msg <> 'compute(value)')", + &affected + ), + "real call outside the literal must still match" + ); +} + +#[tokio::test] +async fn pr187_persistence_ordering_works_with_quoted_identifiers() { + // PR #187 review (C2): mixed-case table names round-trip into + // `Table.schema` / `Table.name` with surrounding quotes + // (`quote_ident` in the dump query). The FK parser strips quotes + // from its returned `(schema, name)`. Without normalising the + // lookup map to the same quote-stripped form, FK edges between + // quoted-identifier tables go missing and persistence flips fall + // back to alphabetical order, which PostgreSQL rejects for FK + // chains. Build a parent→child chain whose names are quoted and + // assert the SET UNLOGGED order is leaves-first. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + let mk = |name: &str, is_unlogged: bool, fk: Option<(&str, &str, &str)>| { + // Wrap the schema/name in quotes the way `quote_ident` would. + let mut t = issue180_logged_table("\"TestOrder\"", name, is_unlogged, fk); + t.schema = "\"TestOrder\"".to_string(); + t.raw_schema = "\"TestOrder\"".to_string(); + t + }; + from_dump.tables.push(mk("\"Grand\"", false, None)); + from_dump.tables.push(mk( + "\"Parent\"", + false, + Some(("grand_id", "\"TestOrder\"", "\"Grand\"")), + )); + from_dump.tables.push(mk( + "\"Child\"", + false, + Some(("parent_id", "\"TestOrder\"", "\"Parent\"")), + )); + to_dump.tables.push(mk("\"Grand\"", true, None)); + to_dump.tables.push(mk( + "\"Parent\"", + true, + Some(("grand_id", "\"TestOrder\"", "\"Grand\"")), + )); + to_dump.tables.push(mk( + "\"Child\"", + true, + Some(("parent_id", "\"TestOrder\"", "\"Parent\"")), + )); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + let pos_child = script + .find("alter table \"TestOrder\".\"Child\" set unlogged;") + .expect("child SET UNLOGGED missing"); + let pos_parent = script + .find("alter table \"TestOrder\".\"Parent\" set unlogged;") + .expect("parent SET UNLOGGED missing"); + let pos_grand = script + .find("alter table \"TestOrder\".\"Grand\" set unlogged;") + .expect("grand SET UNLOGGED missing"); + assert!( + pos_child < pos_parent && pos_parent < pos_grand, + "FK-leaf-first order must hold for quoted identifiers too: {script}" + ); +} + +#[tokio::test] +async fn pr187_persistence_ordering_includes_in_place_alterable_fks() { + // PR #187 review (C13): an FK whose definition differs only in + // an in-place-alterable property (deferrability, enforced, + // no_inherit, comment) is NOT dropped by `compare_tables` — it + // stays live until `compare_foreign_keys` ALTERs it. The live + // FK adjacency for the SET phase must include it, otherwise + // chains where one FK is being toggled deferrable/enforced fall + // back to alphabetical SET order. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump + .tables + .push(issue180_logged_table("test_order", "parent", false, None)); + from_dump.tables.push(issue180_logged_table( + "test_order", + "child", + false, + Some(("parent_id", "test_order", "parent")), + )); + to_dump + .tables + .push(issue180_logged_table("test_order", "parent", true, None)); + let mut to_child = issue180_logged_table( + "test_order", + "child", + true, + Some(("parent_id", "test_order", "parent")), + ); + // Toggle the FK's deferrability — `can_be_altered_to` accepts + // this, so the FK survives `compare_tables` and is still live at + // the SET point. + if let Some(fk) = to_child + .constraints + .iter_mut() + .find(|c| c.constraint_type.eq_ignore_ascii_case("foreign key")) + { + fk.is_deferrable = true; + fk.initially_deferred = true; + } + to_child.hash(); + to_dump.tables.push(to_child); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + let pos_child = script + .find("alter table test_order.child set unlogged;") + .expect("child SET UNLOGGED missing"); + let pos_parent = script + .find("alter table test_order.parent set unlogged;") + .expect("parent SET UNLOGGED missing"); + assert!( + pos_child < pos_parent, + "child must come before parent even when the FK between them is being in-place ALTERed: {script}" + ); +} + +#[test] +fn pr187_unqualified_matcher_unicode_boundary_rejects_longer_identifier() { + // PR #187 review (C17): the boundary check used raw ASCII byte + // tests, which treated Cyrillic neighbours as non-identifier and + // let `функция` match inside `мояфункция(`. The check now uses + // character-class identifier rules, so Cyrillic-letter neighbours + // correctly extend the identifier and reject the match. + let mut affected: HashSet<(String, String)> = HashSet::new(); + affected.insert(("public".to_string(), "функция".to_string())); + assert!( + !Comparer::definition_references_any("CHECK (мояфункция(x) > 0)", &affected), + "unicode letter to the left must extend the identifier" + ); + assert!( + !Comparer::definition_references_any("CHECK (функцияд(x) > 0)", &affected), + "unicode letter to the right must extend the identifier" + ); + // Sanity: a clean Cyrillic call still matches. + assert!( + Comparer::definition_references_any("CHECK (функция(x) > 0)", &affected), + "standalone unicode call must still match" + ); +} + +#[test] +fn issue180_parse_fk_referenced_table_handles_non_ascii_column_names() { + // PR #184 follow-up review: `parse_fk_referenced_table` previously + // built the case-insensitive haystack via `to_lowercase()`, which + // can change byte length for some non-ASCII characters + // (e.g. capital Turkish dotted I, `İ`, lowercases to a multi-char + // sequence with a different UTF-8 length). The keyword position + // came from the lowercased haystack but the slice that produces + // the parsed identifier reaches back into `def`, so a + // length-changing lowercasing would land mid-codepoint and panic. + // `to_ascii_lowercase()` is byte-length-preserving — pin that + // contract by parsing FK definitions whose column list contains + // identifiers that trip every byte-length-changing lowercase + // conversion in common locales. + // + // Quoted column with capital `İ` (U+0130). With `to_lowercase()` + // this produces `i\u{0307}` (3 bytes total); `to_ascii_lowercase` + // leaves the 2-byte `İ` alone, so byte offsets line up. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (\"\u{0130}d\") REFERENCES public.target(id)", + "public", + ), + Some(("public".to_string(), "target".to_string())) + ); + // German sharp S (`ß`, U+00DF). `to_lowercase()` keeps it as `ß`, + // but the inverse — uppercase `ẞ` (U+1E9E) lowercasing to `ß` — + // is length-preserving in UTF-8 too. Use a Cyrillic lowercase + // identifier here just to round out coverage of identifiers whose + // bytes lie outside the ASCII range. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (\"русское_имя\") REFERENCES public.target(id)", + "public", + ), + Some(("public".to_string(), "target".to_string())) + ); + // Same case in the qualified target identifier. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (col) REFERENCES \"тест\".\"target\"(id)", + "public", + ), + Some(("тест".to_string(), "target".to_string())) + ); +} + +#[test] +fn issue190_parse_fk_unqualified_target_falls_back_to_owner_schema() { + // Issue #190: `pg_get_constraintdef` omits the schema qualifier + // when the target is reachable via `search_path` — typical for + // tables in `public`. Pre-fix the parser returned `None` for these + // and the FK edge was silently dropped from the persistence-flip + // adjacency, leaving FK chains in `public` ordered alphabetically + // (the order PostgreSQL rejects). + // + // Plain unqualified target — same schema as the FK owner. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (parent_id) REFERENCES parent(id)", + "public", + ), + Some(("public".to_string(), "parent".to_string())), + "unqualified target must resolve to (owner_schema, target)" + ); + // Quoted unqualified target — the quotes must be stripped to + // match the comparer's normalised `to_index_by_key` keys (which + // strip quotes on the index side too). + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (col) REFERENCES \"MixedCase\"(id)", + "public", + ), + Some(("public".to_string(), "MixedCase".to_string())) + ); + // Quoted owner schema (e.g. mixed-case schema names land here as + // `"MySchema"` via `quote_ident`) — the fallback must strip the + // surrounding quotes from the owner schema too, otherwise the + // produced pair misses the index-side lookup keys. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (col) REFERENCES parent(id)", + "\"MySchema\"", + ), + Some(("MySchema".to_string(), "parent".to_string())) + ); + // ON UPDATE / ON DELETE clauses follow the target — make sure + // they don't confuse the boundary scan. + assert_eq!( + Comparer::parse_fk_referenced_table( + "FOREIGN KEY (col) REFERENCES parent(id) ON DELETE CASCADE", + "public", + ), + Some(("public".to_string(), "parent".to_string())) + ); +} + +/// Issue #190 end-to-end: a FK chain in `public` whose deparsed +/// definition uses unqualified target names must still be ordered +/// leaves-first by `emit_persistence_changes`. Pre-fix the unqualified +/// targets returned `None` from the parser, the adjacency went empty, +/// and the SET UNLOGGED order fell back to alphabetical (`child` +/// emitted *after* `parent` — exactly the order PostgreSQL rejects). +#[tokio::test] +async fn issue190_set_unlogged_orders_unqualified_public_fk_chain() { + // Builder that matches `issue180_logged_table` but emits FK + // definitions WITHOUT the schema qualifier — the + // `pg_get_constraintdef` output shape that exposes the issue. + fn make_table(name: &str, is_unlogged: bool, fk_target: Option<(&str, &str)>) -> Table { + let mut id_col = int_column("public", name, "id", 1); + id_col.is_nullable = false; + let mut constraints: Vec = vec![TableConstraint { + catalog: "postgres".to_string(), + schema: "public".to_string(), + name: format!("{name}_pkey"), + table_name: name.to_string(), + constraint_type: "PRIMARY KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("PRIMARY KEY (id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }]; + + let mut columns = vec![id_col]; + if let Some((fk_col, fk_table)) = fk_target { + let mut ref_col = int_column("public", name, fk_col, 2); + ref_col.is_nullable = true; + columns.push(ref_col); + // Unqualified `REFERENCES parent(id)` — no `public.` + // qualifier. This is what `pg_get_constraintdef` returns + // when the target is reachable via `search_path`. + constraints.push(TableConstraint { + catalog: "postgres".to_string(), + schema: "public".to_string(), + name: format!("{name}_{fk_col}_fkey"), + table_name: name.to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some(format!("FOREIGN KEY ({fk_col}) REFERENCES {fk_table}(id)")), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }); + } + + let mut table = Table::new( + "public".to_string(), + name.to_string(), + "public".to_string(), + name.to_string(), + "postgres".to_string(), + None, + columns, + constraints, + vec![], + vec![], + None, + ); + table.is_unlogged = is_unlogged; + table.hash(); + table + } + + // FROM: child → parent in public, both LOGGED. + // TO: same chain, both UNLOGGED. The FK must survive in TO + // unchanged (live edge) so the adjacency considers it. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump.tables.push(make_table("parent", false, None)); + from_dump + .tables + .push(make_table("child", false, Some(("parent_id", "parent")))); + to_dump.tables.push(make_table("parent", true, None)); + to_dump + .tables + .push(make_table("child", true, Some(("parent_id", "parent")))); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + let child_pos = script + .find("alter table public.child set unlogged;") + .expect("child SET UNLOGGED must be emitted"); + let parent_pos = script + .find("alter table public.parent set unlogged;") + .expect("parent SET UNLOGGED must be emitted"); + assert!( + child_pos < parent_pos, + "child (referrer) must SET UNLOGGED BEFORE parent (referenced); \ + got child@{} parent@{} — alphabetical order would put `child` \ + after `parent` and PostgreSQL would reject the SET on `parent` \ + while `child` is still LOGGED:\n{}", + child_pos, + parent_pos, + script + ); +} + +#[tokio::test] +async fn issue180_set_unlogged_skips_ordering_for_new_fks_added_later() { + // PR #184 review (FK-timing): when an FK is brand-new in TO it is + // not yet active at the moment `emit_persistence_changes` runs — + // `compare_foreign_keys` adds it strictly after. The adjacency + // must therefore filter to FKs that exist UNCHANGED in both + // FROM and TO. Without that filter, an alphabetical pair would + // be over-ordered as if the new FK were already live. + // + // Setup: child references parent in TO (new FK). FROM has no FK. + // Both flip from LOGGED to UNLOGGED. + // + // With the live-FK-set tightening, the adjacency is empty, so + // ordering falls back to alphabetical (deterministic via the + // sort_key in the topo sort). This is safe because PG won't see + // the FK link until after the SET phase. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump + .tables + .push(issue180_logged_table("test_order", "parent", false, None)); + // FROM child has no FK to parent — the FK is new in TO. + from_dump + .tables + .push(issue180_logged_table("test_order", "child", false, None)); + to_dump + .tables + .push(issue180_logged_table("test_order", "parent", true, None)); + to_dump.tables.push(issue180_logged_table( + "test_order", + "child", + true, + Some(("parent_id", "test_order", "parent")), + )); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + // Both SET UNLOGGED statements must be present; ordering between + // them is not constrained by the (not-yet-live) new FK. + assert!(script.contains("alter table test_order.child set unlogged;")); + assert!(script.contains("alter table test_order.parent set unlogged;")); +} + +#[test] +fn issue180_sequence_only_persistence_change_uses_hash_diff() { + // PR #184 review: `is_only_persistence_change` clones, equalises + // `is_unlogged` to FROM, recomputes the hash, and compares. + // That keeps the check honest if `Sequence::hash` later starts + // covering a new field. This test pins the contract by exercising + // both directions: identical-except-persistence returns true; a + // hashed field different (here `cache_size`) returns false. + use crate::dump::sequence::Sequence; + + let make = |is_unlogged: bool, cache: i64| { + let mut s = Sequence::new( + "public".to_string(), + "s".to_string(), + "postgres".to_string(), + "integer".to_string(), + Some(1), + Some(1), + Some(2147483647), + Some(1), + false, + Some(cache), + Some(1), + None, + None, + None, + ); + s.is_unlogged = is_unlogged; + s.hash(); + s + }; + let from = make(false, 1); + let to_only_persistence = make(true, 1); + let to_persistence_and_cache = make(true, 5); + assert!( + to_only_persistence.is_only_persistence_change(&from), + "identical except is_unlogged must be detected as persistence-only" + ); + assert!( + !to_persistence_and_cache.is_only_persistence_change(&from), + "a hashed field difference must block the persistence-only suppression" + ); +} + +/// Issue #191: a mutual FK cycle (`A → B` and `B → A`) flipping +/// persistence in the same direction has NO valid SET LOGGED|UNLOGGED +/// order — `SET UNLOGGED A` requires B to already be UNLOGGED and +/// vice versa. Pre-fix `kahn_toposort`'s fallback appended cyclic +/// nodes alphabetically, the migration emitted the SETs in that +/// order, and PostgreSQL rejected the second SET at apply time with +/// the same `could not change table … to logged/unlogged` error +/// issue #180 was meant to eliminate. Fix: detect the cycle, drop +/// every FK whose endpoints both sit in the cyclic set BEFORE the +/// SETs, then re-add them from their TO definitions AFTER. The +/// post-fix migration is therefore: DROP cycle FKs → SET both +/// (any order) → ADD cycle FKs. +#[tokio::test] +async fn issue191_persistence_flip_breaks_mutual_fk_cycle() { + fn make_cycle_table(name: &str, is_unlogged: bool, fk: (&str, &str, &str)) -> Table { + let (fk_col, target_schema, target_table) = fk; + + let mut id_col = int_column("test_cycle", name, "id", 1); + id_col.is_nullable = false; + + let mut ref_col = int_column("test_cycle", name, fk_col, 2); + ref_col.is_nullable = true; + + let pk = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_cycle".to_string(), + name: format!("{name}_pkey"), + table_name: name.to_string(), + constraint_type: "PRIMARY KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("PRIMARY KEY (id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + // Deferrable FK — PostgreSQL only permits mutual FK cycles + // when both FKs are deferrable; without DEFERRABLE the cycle + // can't be inserted/seeded in the first place. + let fk_constraint = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_cycle".to_string(), + name: format!("{name}_{fk_col}_fkey"), + table_name: name.to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: true, + initially_deferred: true, + definition: Some(format!( + "FOREIGN KEY ({fk_col}) REFERENCES {target_schema}.{target_table}(id) DEFERRABLE INITIALLY DEFERRED" + )), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let mut table = Table::new( + "test_cycle".to_string(), + name.to_string(), + "test_cycle".to_string(), + name.to_string(), + "postgres".to_string(), + None, + vec![id_col, ref_col], + vec![pk, fk_constraint], + vec![], + vec![], + None, + ); + table.is_unlogged = is_unlogged; + table.hash(); + table + } + + // FROM: both LOGGED, mutual deferrable FKs. + // TO: both UNLOGGED, same FKs (live edges — unchanged). + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump + .tables + .push(make_cycle_table("a", false, ("b_id", "test_cycle", "b"))); + from_dump + .tables + .push(make_cycle_table("b", false, ("a_id", "test_cycle", "a"))); + to_dump + .tables + .push(make_cycle_table("a", true, ("b_id", "test_cycle", "b"))); + to_dump + .tables + .push(make_cycle_table("b", true, ("a_id", "test_cycle", "a"))); + + // `use_drop=true` — this test validates the *active* cycle-break + // path (drops and re-adds emitted live). The `use_drop=false` + // semantics are covered separately by + // `issue191_pr198_use_drop_false_comments_out_cycle_break`. + // PR #198 review: an earlier revision used `use_drop=false` here + // and the substring-based `script.find` assertions were + // false-positives — they matched the commented-out `-- alter + // table … drop constraint …` lines and never actually verified + // the live path. + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + // Cycle-break banner must appear so the choice is loud. + assert!( + script.contains("Persistence-flip FK cycle (issue #191)"), + "cycle banner must mark the drop+SET+add block: {}", + script + ); + + // Both cycle FKs must be dropped BEFORE either SET UNLOGGED. + // Anchor each `find` to a leading newline so the assertion + // distinguishes the live statement from a commented `-- alter + // table …` prefix — this is the PR #198 review fix. + let drop_a_pos = script + .find("\nalter table test_cycle.a drop constraint a_b_id_fkey;") + .expect("FK a_b_id_fkey must be dropped before SET (live, not commented)"); + let drop_b_pos = script + .find("\nalter table test_cycle.b drop constraint b_a_id_fkey;") + .expect("FK b_a_id_fkey must be dropped before SET (live, not commented)"); + assert!( + !script.contains("-- alter table test_cycle.a drop constraint a_b_id_fkey;"), + "DROP CONSTRAINT must be emitted LIVE under use_drop=true, not commented: {}", + script + ); + assert!( + !script.contains("-- alter table test_cycle.b drop constraint b_a_id_fkey;"), + "DROP CONSTRAINT must be emitted LIVE under use_drop=true, not commented: {}", + script + ); + let set_a_pos = script + .find("\nalter table test_cycle.a set unlogged;") + .expect("a SET UNLOGGED must be emitted"); + let set_b_pos = script + .find("\nalter table test_cycle.b set unlogged;") + .expect("b SET UNLOGGED must be emitted"); + assert!( + drop_a_pos < set_a_pos && drop_a_pos < set_b_pos, + "FK a_b_id_fkey drop must precede every SET: drop@{} a@{} b@{}\n{}", + drop_a_pos, + set_a_pos, + set_b_pos, + script + ); + assert!( + drop_b_pos < set_a_pos && drop_b_pos < set_b_pos, + "FK b_a_id_fkey drop must precede every SET: drop@{} a@{} b@{}\n{}", + drop_b_pos, + set_a_pos, + set_b_pos, + script + ); + + // Both cycle FKs must be re-added AFTER every SET so the post- + // migration state matches TO. PR #198 review: the re-emit path + // now goes through `TableConstraint::get_script()`, which + // lowercases SQL keywords outside literals — match on the + // lowercase form. The newline anchor again separates live ADDs + // from any `-- alter table … add constraint …` form. + let add_a_pos = script + .find("\nalter table test_cycle.a add constraint a_b_id_fkey foreign key") + .expect("FK a_b_id_fkey must be re-added after SET (live, not commented)"); + let add_b_pos = script + .find("\nalter table test_cycle.b add constraint b_a_id_fkey foreign key") + .expect("FK b_a_id_fkey must be re-added after SET (live, not commented)"); + assert!( + !script.contains("-- alter table test_cycle.a add constraint a_b_id_fkey"), + "ADD CONSTRAINT must be emitted LIVE under use_drop=true, not commented: {}", + script + ); + assert!( + !script.contains("-- alter table test_cycle.b add constraint b_a_id_fkey"), + "ADD CONSTRAINT must be emitted LIVE under use_drop=true, not commented: {}", + script + ); + assert!( + add_a_pos > set_a_pos && add_a_pos > set_b_pos, + "FK a_b_id_fkey re-add must follow every SET: add@{} a@{} b@{}\n{}", + add_a_pos, + set_a_pos, + set_b_pos, + script + ); + assert!( + add_b_pos > set_a_pos && add_b_pos > set_b_pos, + "FK b_a_id_fkey re-add must follow every SET: add@{} a@{} b@{}\n{}", + add_b_pos, + set_a_pos, + set_b_pos, + script + ); +} + +/// Issue #191 counter-test: an acyclic FK chain (no cycle) must NOT +/// emit cycle-break drops/re-adds. Locks the cycle path to only the +/// cycle case so we don't regress and start dropping FKs on every +/// persistence flip. +#[tokio::test] +async fn issue191_persistence_flip_acyclic_chain_does_not_drop_fks() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump + .tables + .push(issue180_logged_table("test_order", "parent", false, None)); + from_dump.tables.push(issue180_logged_table( + "test_order", + "child", + false, + Some(("parent_id", "test_order", "parent")), + )); + to_dump + .tables + .push(issue180_logged_table("test_order", "parent", true, None)); + to_dump.tables.push(issue180_logged_table( + "test_order", + "child", + true, + Some(("parent_id", "test_order", "parent")), + )); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("Persistence-flip FK cycle"), + "acyclic chain must not trip the cycle-break path: {}", + script + ); + assert!( + !script.contains("drop constraint child_parent_id_fkey"), + "live FK on an acyclic chain must not be dropped: {}", + script + ); +} + +/// Issue #191 / PR #198 review: when a cycle exists alongside an +/// edge that's *blocked by* the cycle but not in it (e.g. `A ↔ B` +/// plus `A → C` from outside the cycle, with all three flipping +/// persistence in the same direction), Kahn's "couldn't-be-ordered" +/// remainder includes C — even though C is not part of any directed +/// cycle. Pre-refinement the comparer treated the entire remainder +/// as cycle participants and dropped the `A → C` FK alongside the +/// true cycle edges, making the migration more destructive than +/// needed. Tarjan's SCC narrows the cycle set to nodes in +/// strongly-connected components of size >= 2, so only the true +/// cycle edges get dropped. +#[tokio::test] +async fn issue191_pr198_cycle_detection_excludes_blocked_non_cycle_nodes() { + fn build(name: &str, is_unlogged: bool, fk: Option<(&str, &str, &str)>) -> Table { + let mut id_col = int_column("test_cycle", name, "id", 1); + id_col.is_nullable = false; + + let pk = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_cycle".to_string(), + name: format!("{name}_pkey"), + table_name: name.to_string(), + constraint_type: "PRIMARY KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("PRIMARY KEY (id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let mut constraints = vec![pk]; + let mut columns = vec![id_col]; + + if let Some((fk_col, target_schema, target_table)) = fk { + let mut ref_col = int_column("test_cycle", name, fk_col, 2); + ref_col.is_nullable = true; + columns.push(ref_col); + constraints.push(TableConstraint { + catalog: "postgres".to_string(), + schema: "test_cycle".to_string(), + name: format!("{name}_{fk_col}_fkey"), + table_name: name.to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: true, + initially_deferred: true, + definition: Some(format!( + "FOREIGN KEY ({fk_col}) REFERENCES {target_schema}.{target_table}(id) DEFERRABLE INITIALLY DEFERRED" + )), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }); + } + + let mut t = Table::new( + "test_cycle".to_string(), + name.to_string(), + "test_cycle".to_string(), + name.to_string(), + "postgres".to_string(), + None, + columns, + constraints, + vec![], + vec![], + None, + ); + t.is_unlogged = is_unlogged; + t.hash(); + t + } + + // Build three tables: + // a ↔ b (cycle: a → b and b → a) + // a → c (non-cycle: a depends on c, but c does not depend on a) + // All three flip LOGGED → UNLOGGED. The cycle set is {a, b}; the + // FK `a_c_id_fkey` is NOT in any cycle and must survive the + // cycle break. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // FROM: all LOGGED. `a` has TWO FKs: a → b (cycle), a → c (not). + let mut a_from = build("a", false, Some(("b_id", "test_cycle", "b"))); + a_from.columns.push({ + let mut c_id = int_column("test_cycle", "a", "c_id", 3); + c_id.is_nullable = true; + c_id + }); + a_from.constraints.push(TableConstraint { + catalog: "postgres".to_string(), + schema: "test_cycle".to_string(), + name: "a_c_id_fkey".to_string(), + table_name: "a".to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("FOREIGN KEY (c_id) REFERENCES test_cycle.c(id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }); + a_from.hash(); + from_dump.tables.push(a_from); + from_dump + .tables + .push(build("b", false, Some(("a_id", "test_cycle", "a")))); + from_dump.tables.push(build("c", false, None)); + + // TO: all UNLOGGED. Same constraint shapes. + let mut a_to = build("a", true, Some(("b_id", "test_cycle", "b"))); + a_to.columns.push({ + let mut c_id = int_column("test_cycle", "a", "c_id", 3); + c_id.is_nullable = true; + c_id + }); + a_to.constraints.push(TableConstraint { + catalog: "postgres".to_string(), + schema: "test_cycle".to_string(), + name: "a_c_id_fkey".to_string(), + table_name: "a".to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("FOREIGN KEY (c_id) REFERENCES test_cycle.c(id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }); + a_to.hash(); + to_dump.tables.push(a_to); + to_dump + .tables + .push(build("b", true, Some(("a_id", "test_cycle", "a")))); + to_dump.tables.push(build("c", true, None)); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + // Cycle banner must appear (a↔b cycle is present). + assert!( + script.contains("Persistence-flip FK cycle (issue #191)"), + "cycle banner must appear for the a↔b cycle: {}", + script + ); + // True cycle FKs must be dropped. + assert!( + script.contains("alter table test_cycle.a drop constraint a_b_id_fkey;"), + "true cycle FK a_b_id_fkey must be dropped: {}", + script + ); + assert!( + script.contains("alter table test_cycle.b drop constraint b_a_id_fkey;"), + "true cycle FK b_a_id_fkey must be dropped: {}", + script + ); + // The non-cycle FK (a → c) is merely *blocked by* the cycle in + // Kahn's remainder but is not part of any directed cycle. With + // SCC-based detection it must NOT be dropped. + assert!( + !script.contains("drop constraint a_c_id_fkey"), + "non-cycle FK a_c_id_fkey (a → c) must NOT be dropped: {}", + script + ); +} + +/// Issue #191 / PR #198 review: when `use_drop=false`, the cycle- +/// break drops and re-adds must be commented out, with a loud banner +/// explaining that the SETs will fail without manual intervention. +/// The user has explicitly asked the comparer to surface destructive +/// statements for review rather than emit them live. +#[tokio::test] +async fn issue191_pr198_use_drop_false_comments_out_cycle_break() { + fn make_cycle_table(name: &str, is_unlogged: bool, fk: (&str, &str, &str)) -> Table { + let (fk_col, target_schema, target_table) = fk; + let mut id_col = int_column("test_cycle", name, "id", 1); + id_col.is_nullable = false; + let mut ref_col = int_column("test_cycle", name, fk_col, 2); + ref_col.is_nullable = true; + + let pk = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_cycle".to_string(), + name: format!("{name}_pkey"), + table_name: name.to_string(), + constraint_type: "PRIMARY KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("PRIMARY KEY (id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + let fk_constraint = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_cycle".to_string(), + name: format!("{name}_{fk_col}_fkey"), + table_name: name.to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: true, + initially_deferred: true, + definition: Some(format!( + "FOREIGN KEY ({fk_col}) REFERENCES {target_schema}.{target_table}(id) DEFERRABLE INITIALLY DEFERRED" + )), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + + let mut table = Table::new( + "test_cycle".to_string(), + name.to_string(), + "test_cycle".to_string(), + name.to_string(), + "postgres".to_string(), + None, + vec![id_col, ref_col], + vec![pk, fk_constraint], + vec![], + vec![], + None, + ); + table.is_unlogged = is_unlogged; + table.hash(); + table + } + + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump + .tables + .push(make_cycle_table("a", false, ("b_id", "test_cycle", "b"))); + from_dump + .tables + .push(make_cycle_table("b", false, ("a_id", "test_cycle", "a"))); + to_dump + .tables + .push(make_cycle_table("a", true, ("b_id", "test_cycle", "b"))); + to_dump + .tables + .push(make_cycle_table("b", true, ("a_id", "test_cycle", "a"))); + + // use_drop=false — drops and re-adds must be commented out. + // `use_comments=true` so the banner and commented-out lines + // survive `get_script`'s output (which strips comments under + // `use_comments=false`). + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + // Loud banner specifically calls out use_drop=false semantics. + assert!( + script.contains("use_drop=false"), + "banner must mention use_drop=false: {}", + script + ); + // DROP CONSTRAINT lines must be commented out (i.e. they appear + // only as `-- alter table ... drop constraint ...;`). + assert!( + !script.contains("\nalter table test_cycle.a drop constraint a_b_id_fkey;"), + "live DROP CONSTRAINT must NOT be emitted under use_drop=false: {}", + script + ); + assert!( + script.contains("-- alter table test_cycle.a drop constraint a_b_id_fkey;"), + "commented DROP CONSTRAINT must be emitted under use_drop=false: {}", + script + ); + // ADD CONSTRAINT lines must also be commented out so re-running + // with use_drop=true after manual review produces a clean diff. + assert!( + !script.contains("\nalter table test_cycle.a add constraint a_b_id_fkey foreign key"), + "live ADD CONSTRAINT must NOT be emitted under use_drop=false: {}", + script + ); + assert!( + script.contains("-- alter table test_cycle.a add constraint a_b_id_fkey foreign key"), + "commented ADD CONSTRAINT must be emitted under use_drop=false: {}", + script + ); + // SET statements are NOT destructive and stay live, matching how + // SETs are handled elsewhere when use_drop=false (the cycle case + // is highlighted by the banner above). + assert!( + script.contains("alter table test_cycle.a set unlogged;"), + "SET UNLOGGED must remain live under use_drop=false: {}", + script + ); +} + +/// Issue #191 / PR #198 review: the cycle-break re-emit path goes +/// through `TableConstraint::get_script()`, which appends +/// `COMMENT ON CONSTRAINT ...` when the FK has a comment. Verify +/// the comment survives the drop+SET+re-add round-trip — i.e. the +/// emitted re-add carries the `comment on constraint` clause from +/// the TO-side metadata so the post-migration schema matches TO. +#[tokio::test] +async fn issue191_pr198_cycle_fk_comment_survives_round_trip() { + fn cycle_table_with_comment(name: &str, is_unlogged: bool, fk: (&str, &str, &str)) -> Table { + let (fk_col, target_schema, target_table) = fk; + let mut id_col = int_column("test_cycle", name, "id", 1); + id_col.is_nullable = false; + let mut ref_col = int_column("test_cycle", name, fk_col, 2); + ref_col.is_nullable = true; + + let pk = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_cycle".to_string(), + name: format!("{name}_pkey"), + table_name: name.to_string(), + constraint_type: "PRIMARY KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("PRIMARY KEY (id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + let fk_constraint = TableConstraint { + catalog: "postgres".to_string(), + schema: "test_cycle".to_string(), + name: format!("{name}_{fk_col}_fkey"), + table_name: name.to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: true, + initially_deferred: true, + definition: Some(format!( + "FOREIGN KEY ({fk_col}) REFERENCES {target_schema}.{target_table}(id) DEFERRABLE INITIALLY DEFERRED" + )), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: Some(format!("FK {name} → {target_table} (cycle annotated)")), + }; + + let mut table = Table::new( + "test_cycle".to_string(), + name.to_string(), + "test_cycle".to_string(), + name.to_string(), + "postgres".to_string(), + None, + vec![id_col, ref_col], + vec![pk, fk_constraint], + vec![], + vec![], + None, + ); + table.is_unlogged = is_unlogged; + table.hash(); + table + } + + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump.tables.push(cycle_table_with_comment( + "a", + false, + ("b_id", "test_cycle", "b"), + )); + from_dump.tables.push(cycle_table_with_comment( + "b", + false, + ("a_id", "test_cycle", "a"), + )); + to_dump.tables.push(cycle_table_with_comment( + "a", + true, + ("b_id", "test_cycle", "b"), + )); + to_dump.tables.push(cycle_table_with_comment( + "b", + true, + ("a_id", "test_cycle", "a"), + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + // The re-add must include the `comment on constraint` clause — + // proof that the cycle-break path round-trips full + // `TableConstraint` metadata via `get_script()`, not just the + // raw `(schema, table, name, definition)` tuple. + assert!( + script.contains( + "comment on constraint a_b_id_fkey on test_cycle.a is 'FK a → b (cycle annotated)';" + ), + "FK comment on a_b_id_fkey must be re-emitted after the SET: {}", + script + ); + assert!( + script.contains( + "comment on constraint b_a_id_fkey on test_cycle.b is 'FK b → a (cycle annotated)';" + ), + "FK comment on b_a_id_fkey must be re-emitted after the SET: {}", + script + ); +} diff --git a/app/src/comparer/tests/core/production.rs b/app/src/comparer/tests/core/production.rs new file mode 100644 index 0000000..7f0b069 --- /dev/null +++ b/app/src/comparer/tests/core/production.rs @@ -0,0 +1,201 @@ +//! Tests for `--output-for-production` script rewriting. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::table::Table; +use crate::dump::table_constraint::TableConstraint; +use crate::dump::table_index::TableIndex; + +#[tokio::test] +async fn output_for_production_defers_concurrent_index_and_validates_fk() { + // New table with one index and one foreign key. Production mode must build + // the index CONCURRENTLY after COMMIT, add the FK NOT VALID inside the + // transaction, and VALIDATE it after COMMIT. + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let index = TableIndex { + schema: "public".to_string(), + table: "orders".to_string(), + name: "idx_orders_total".to_string(), + catalog: None, + indexdef: "CREATE INDEX idx_orders_total ON public.orders USING btree (total)".to_string(), + is_partition_index: false, + comment: None, + }; + let fk = TableConstraint { + catalog: "postgres".to_string(), + schema: "public".to_string(), + name: "fk_orders_customer".to_string(), + table_name: "orders".to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("FOREIGN KEY (customer_id) REFERENCES public.customers (id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }; + let table = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "postgres".to_string(), + None, + vec![], + vec![fk], + vec![index], + vec![], + None, + ); + to_dump.tables.push(table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, true, true, GrantsMode::Ignore); + comparer.set_output_for_production(true); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let commit_pos = script.find("commit;").expect("script must contain commit;"); + let concurrent_pos = script + .find( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_total ON public.orders USING btree (total);", + ) + .expect("concurrent index build must be present"); + let validate_pos = script + .find("validate constraint fk_orders_customer;") + .expect("FK validation must be present"); + let not_valid_pos = script + .find("not valid;") + .expect("FK must be added NOT VALID"); + + // Concurrent build and FK validation run after COMMIT. + assert!( + concurrent_pos > commit_pos, + "concurrent index must come after commit;\n{script}" + ); + assert!( + validate_pos > commit_pos, + "validate constraint must come after commit;\n{script}" + ); + // The FK is added NOT VALID before COMMIT. + assert!( + not_valid_pos < commit_pos, + "FK NOT VALID must be added before commit;\n{script}" + ); + // The in-transaction CREATE TABLE must not carry an inline (non-concurrent) + // index build. + let in_txn = &script[..commit_pos]; + assert!( + !in_txn.contains("CREATE INDEX idx_orders_total ON public.orders"), + "index must be deferred out of the in-transaction create:\n{in_txn}" + ); +} + +#[tokio::test] +async fn output_for_production_disabled_keeps_inline_index() { + // Same shape as above but with the flag off: the index is inline in the + // CREATE TABLE and there is no post-commit / CONCURRENTLY output. + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let index = TableIndex { + schema: "public".to_string(), + table: "orders".to_string(), + name: "idx_orders_total".to_string(), + catalog: None, + indexdef: "CREATE INDEX idx_orders_total ON public.orders USING btree (total)".to_string(), + is_partition_index: false, + comment: None, + }; + let table = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "postgres".to_string(), + None, + vec![], + vec![], + vec![index], + vec![], + None, + ); + to_dump.tables.push(table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, true, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("CREATE INDEX idx_orders_total ON public.orders USING btree (total);"), + "default mode keeps the inline index:\n{script}" + ); + assert!( + !script.contains("CONCURRENTLY"), + "default mode must not emit CONCURRENTLY:\n{script}" + ); + assert!( + !script.contains("Production post-commit"), + "default mode must not emit a post-commit section:\n{script}" + ); +} + +#[tokio::test] +async fn production_header_mentions_commit_only_with_single_transaction() { + // The production header comment must not claim post-transaction statements + // run "after COMMIT" when --use-single-transaction is off (no COMMIT is + // emitted in that valid configuration). + let build = || { + let mut to_dump = Dump::new(DumpConfig::default()); + to_dump.tables.push(Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "postgres".to_string(), + None, + vec![], + vec![], + vec![], + vec![], + None, + )); + (Dump::new(DumpConfig::default()), to_dump) + }; + + // With single transaction: header says "after COMMIT" and a commit; exists. + let (from_txn, to_txn) = build(); + let mut with_txn = Comparer::new(from_txn, to_txn, false, true, true, GrantsMode::Ignore); + with_txn.set_output_for_production(true); + with_txn.compare().await.unwrap(); + let txn_script = with_txn.get_script(); + assert!( + txn_script.contains("are emitted after COMMIT"), + "single-transaction header must mention COMMIT:\n{txn_script}" + ); + assert!(txn_script.contains("commit;"), "{txn_script}"); + + // Without single transaction: no COMMIT, so the header must not claim one. + let (from_no, to_no) = build(); + let mut no_txn = Comparer::new(from_no, to_no, false, false, true, GrantsMode::Ignore); + no_txn.set_output_for_production(true); + no_txn.compare().await.unwrap(); + let no_txn_script = no_txn.get_script(); + assert!( + !no_txn_script.contains("commit;"), + "no transaction must be opened without --use-single-transaction:\n{no_txn_script}" + ); + assert!( + !no_txn_script.contains("after COMMIT"), + "header must not claim 'after COMMIT' without a transaction:\n{no_txn_script}" + ); + assert!( + no_txn_script.contains("are emitted in a separate section at the end"), + "header must describe the trailing section accurately:\n{no_txn_script}" + ); +} diff --git a/app/src/comparer/tests/core/routines.rs b/app/src/comparer/tests/core/routines.rs new file mode 100644 index 0000000..a5b9613 --- /dev/null +++ b/app/src/comparer/tests/core/routines.rs @@ -0,0 +1,1080 @@ +//! Tests for `compare_routines` and `compare_routines_and_views`: drop and +//! recreate rules, dependency ordering, overloads, owner and config changes. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::routine::Routine; +use crate::dump::schema::Schema; +use crate::dump::view::View; +use sqlx::postgres::types::Oid; + +#[tokio::test] +async fn compare_routines_drops_and_recreates_on_return_type_change() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let from_routine = Routine::new( + "public".to_string(), + Oid(1), + "test_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + + let to_routine = Routine::new( + "public".to_string(), + Oid(1), + "test_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "text".to_string(), + "".to_string(), + None, + None, + "BEGIN RETURN '1'; END".to_string(), + ); + + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains("drop function if exists public.test_func () cascade;")); + assert!(script.contains("create or replace function public.test_func() returns text")); +} + +#[tokio::test] +async fn compare_routines_drops_and_recreates_on_argument_change() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let from_routine = Routine::new( + "public".to_string(), + Oid(1), + "test_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "a integer".to_string(), + None, + None, + "BEGIN RETURN a; END".to_string(), + ); + + let to_routine = Routine::new( + "public".to_string(), + Oid(1), + "test_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "a text".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains("drop function if exists public.test_func (a integer) cascade;")); + assert!(script.contains("create or replace function public.test_func(a text) returns integer")); +} + +#[tokio::test] +async fn compare_routines_applies_sql_routines_last() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let plpgsql_routine = Routine::new( + "public".to_string(), + Oid(1), + "fn_plpgsql".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + + let sql_routine = Routine::new( + "public".to_string(), + Oid(2), + "fn_sql".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "SELECT 1;".to_string(), + ); + + // Intentionally add SQL first to ensure reordering happens. + to_dump.routines.push(sql_routine); + to_dump.routines.push(plpgsql_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + let pos_plpgsql = script + .find("create or replace function public.fn_plpgsql") + .expect("plpgsql routine script not found"); + let pos_sql = script + .find("create or replace function public.fn_sql") + .expect("sql routine script not found"); + + assert!(pos_plpgsql < pos_sql, "SQL routines should be applied last"); +} + +#[tokio::test] +async fn compare_routines_emits_owner_change() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "public".to_string(), + Oid(1), + "test_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + from_routine.owner = "old_owner".to_string(); + from_routine.hash(); + + let mut to_routine = Routine::new( + "public".to_string(), + Oid(1), + "test_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + to_routine.owner = "new_owner".to_string(); + to_routine.hash(); + + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains("alter function public.test_func() owner to new_owner;")); +} + +#[tokio::test] +async fn compare_routines_orders_by_dependencies() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // r_base_value: no dependencies + let r_base = Routine::new( + "test_schema".to_string(), + Oid(1), + "r_base_value".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "\n SELECT 10;\n".to_string(), + ); + + // x_step_one: depends on r_base_value + let x_step = Routine::new( + "test_schema".to_string(), + Oid(2), + "x_step_one".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "\n SELECT test_schema.r_base_value() + 5;\n".to_string(), + ); + + // a_middle_layer: depends on x_step_one and r_base_value + let a_middle = Routine::new( + "test_schema".to_string(), + Oid(3), + "a_middle_layer".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "\n SELECT test_schema.x_step_one() * test_schema.r_base_value();\n".to_string(), + ); + + // z_final_report: depends on a_middle_layer + let z_final = Routine::new( + "test_schema".to_string(), + Oid(4), + "z_final_report".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "".to_string(), + None, + None, + "\nDECLARE\n result integer;\nBEGIN\n SELECT test_schema.a_middle_layer() INTO result;\n RAISE NOTICE 'Final result: %', result;\nEND;\n".to_string(), + ); + + // Push in deliberately wrong alphabetical / type order. + to_dump.routines.push(z_final); + to_dump.routines.push(x_step); + to_dump.routines.push(a_middle); + to_dump.routines.push(r_base); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + let pos_base = script + .find("create or replace function test_schema.r_base_value") + .expect("r_base_value not found"); + let pos_step = script + .find("create or replace function test_schema.x_step_one") + .expect("x_step_one not found"); + let pos_middle = script + .find("create or replace function test_schema.a_middle_layer") + .expect("a_middle_layer not found"); + let pos_final = script + .find("create or replace procedure test_schema.z_final_report") + .expect("z_final_report not found"); + + assert!( + pos_base < pos_step, + "r_base_value must come before x_step_one (depends on it)" + ); + assert!( + pos_base < pos_middle, + "r_base_value must come before a_middle_layer (depends on it)" + ); + assert!( + pos_step < pos_middle, + "x_step_one must come before a_middle_layer (depends on it)" + ); + assert!( + pos_middle < pos_final, + "a_middle_layer must come before z_final_report (depends on it)" + ); +} + +#[tokio::test] +async fn compare_routines_drops_in_reverse_dependency_order() { + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + // r_base_value: no dependencies + let r_base = Routine::new( + "test_schema".to_string(), + Oid(1), + "r_base_value".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "\n SELECT 10;\n".to_string(), + ); + + // x_step_one: depends on r_base_value + let x_step = Routine::new( + "test_schema".to_string(), + Oid(2), + "x_step_one".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "\n SELECT test_schema.r_base_value() + 5;\n".to_string(), + ); + + // a_middle_layer: depends on x_step_one and r_base_value + let a_middle = Routine::new( + "test_schema".to_string(), + Oid(3), + "a_middle_layer".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "\n SELECT test_schema.x_step_one() * test_schema.r_base_value();\n".to_string(), + ); + + from_dump.routines.push(r_base); + from_dump.routines.push(x_step); + from_dump.routines.push(a_middle); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + let pos_base = script + .find("drop function if exists test_schema.r_base_value") + .expect("r_base_value drop not found"); + let pos_step = script + .find("drop function if exists test_schema.x_step_one") + .expect("x_step_one drop not found"); + let pos_middle = script + .find("drop function if exists test_schema.a_middle_layer") + .expect("a_middle_layer drop not found"); + + // Drops should go in reverse dependency order: dependents first. + assert!( + pos_middle < pos_step, + "a_middle_layer must be dropped before x_step_one" + ); + assert!( + pos_step < pos_base, + "x_step_one must be dropped before r_base_value" + ); +} + +#[tokio::test] +async fn compare_routines_and_views_orders_by_dependencies() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let r_base = Routine::new( + "test_schema".to_string(), + Oid(1), + "r_base_value".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "\n SELECT 10;\n".to_string(), + ); + + let x_step = Routine::new( + "test_schema".to_string(), + Oid(2), + "x_step_one".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "\n SELECT test_schema.r_base_value() + 5;\n".to_string(), + ); + + let a_middle = Routine::new( + "test_schema".to_string(), + Oid(3), + "a_middle_layer".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "\n SELECT test_schema.x_step_one() * test_schema.r_base_value();\n".to_string(), + ); + + let z_final = Routine::new( + "test_schema".to_string(), + Oid(4), + "z_final_report".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "".to_string(), + None, + None, + "\nDECLARE\n result integer;\nBEGIN\n SELECT test_schema.a_middle_layer() INTO result;\n RAISE NOTICE 'Final result: %', result;\nEND;\n".to_string(), + ); + + // Push in deliberately wrong order. + to_dump.routines.push(z_final); + to_dump.routines.push(x_step); + to_dump.routines.push(a_middle); + to_dump.routines.push(r_base); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + let pos_base = script + .find("create or replace function test_schema.r_base_value") + .expect("r_base_value not found"); + let pos_step = script + .find("create or replace function test_schema.x_step_one") + .expect("x_step_one not found"); + let pos_middle = script + .find("create or replace function test_schema.a_middle_layer") + .expect("a_middle_layer not found"); + let pos_final = script + .find("create or replace procedure test_schema.z_final_report") + .expect("z_final_report not found"); + + assert!( + pos_base < pos_step, + "r_base_value must come before x_step_one" + ); + assert!( + pos_base < pos_middle, + "r_base_value must come before a_middle_layer" + ); + assert!( + pos_step < pos_middle, + "x_step_one must come before a_middle_layer" + ); + assert!( + pos_middle < pos_final, + "a_middle_layer must come before z_final_report" + ); +} + +#[tokio::test] +async fn compare_creates_routines_and_views_in_dependency_order() { + // Scenario from the user report: + // get_user_count() – function, no view dependency + // v_user_stats – view that calls get_user_count() + // report_user_stats – function that reads v_user_stats + // print_user_stats – procedure that reads v_user_stats + // + // Correct creation order: get_user_count → v_user_stats → report/print + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + to_dump.schemas.push(Schema::new( + "test_schema".to_string(), + "test_schema".to_string(), + None, + )); + + let get_user_count = Routine::new( + "test_schema".to_string(), + Oid(1), + "get_user_count".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + " SELECT count(*) FROM test_schema.users;\n".to_string(), + ); + + let report_user_stats = Routine::new( + "test_schema".to_string(), + Oid(2), + "report_user_stats".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "text".to_string(), + "".to_string(), + None, + None, + " SELECT 'Total users in view: ' || total_users\n FROM test_schema.v_user_stats\n LIMIT 1;\n".to_string(), + ); + + let print_user_stats = Routine::new( + "test_schema".to_string(), + Oid(3), + "print_user_stats".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "".to_string(), + None, + None, + "\nDECLARE\n cnt int;\nBEGIN\n SELECT total_users INTO cnt\n FROM test_schema.v_user_stats\n LIMIT 1;\n RAISE NOTICE 'Total users in view: %', cnt;\nEND;\n".to_string(), + ); + + let mut v_user_stats = View::new( + "v_user_stats".to_string(), + " SELECT test_schema.get_user_count() AS total_users,\n users.name\n FROM test_schema.users;\n".to_string(), + "test_schema".to_string(), + vec!["test_schema.users".to_string()], + ); + v_user_stats.owner = "postgres".to_string(); + v_user_stats.hash(); + + // Intentionally add in wrong order to test sorting + to_dump.routines.push(print_user_stats); + to_dump.routines.push(report_user_stats); + to_dump.routines.push(get_user_count); + to_dump.views.push(v_user_stats); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let pos_get_user_count = script + .find("create or replace function test_schema.get_user_count") + .expect("get_user_count not found in script"); + let pos_view = script + .find("test_schema.v_user_stats") + .expect("v_user_stats not found in script"); + let pos_report = script + .find("create or replace function test_schema.report_user_stats") + .expect("report_user_stats not found in script"); + let pos_print = script + .find("create or replace procedure test_schema.print_user_stats") + .expect("print_user_stats not found in script"); + + assert!( + pos_get_user_count < pos_view, + "get_user_count() must be created before v_user_stats (function is used by view)" + ); + assert!( + pos_view < pos_report, + "v_user_stats must be created before report_user_stats() (view is used by function)" + ); + assert!( + pos_view < pos_print, + "v_user_stats must be created before print_user_stats() (view is used by procedure)" + ); +} + +#[tokio::test] +async fn compare_creates_materialized_view_after_dependent_routine() { + // Materialized view that uses a function should be created after that function. + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let helper_fn = Routine::new( + "public".to_string(), + Oid(1), + "helper".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "SELECT 42;\n".to_string(), + ); + + let mut mat_view = View::new( + "mv_data".to_string(), + " SELECT public.helper() AS value;\n".to_string(), + "public".to_string(), + vec![], + ); + mat_view.is_materialized = true; + mat_view.hash(); + + to_dump.routines.push(helper_fn); + to_dump.views.push(mat_view); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let pos_fn = script + .find("create or replace function public.helper") + .expect("helper function not found"); + let pos_mv = script.find("public.mv_data").expect("mv_data not found"); + + assert!( + pos_fn < pos_mv, + "helper() must be created before mv_data (materialized view depends on function)" + ); +} + +#[tokio::test] +async fn compare_drops_routines_in_reverse_dependency_order() { + // Routine A calls Routine B; when both are dropped, A should be dropped first. + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + let routine_b = Routine::new( + "public".to_string(), + Oid(1), + "base_fn".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "SELECT 1;\n".to_string(), + ); + + let routine_a = Routine::new( + "public".to_string(), + Oid(2), + "caller_fn".to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "SELECT public.base_fn();\n".to_string(), + ); + + // Add in wrong order + from_dump.routines.push(routine_b); + from_dump.routines.push(routine_a); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let pos_caller = script + .find("drop function if exists public.caller_fn") + .expect("caller_fn drop not found"); + let pos_base = script + .find("drop function if exists public.base_fn") + .expect("base_fn drop not found"); + + assert!( + pos_caller < pos_base, + "caller_fn (dependent) must be dropped before base_fn" + ); +} + +#[tokio::test] +async fn compare_routines_overloaded_identical_no_diff() { + // Two routines with the same (schema, name) but different arguments. + // Both overloads are identical in FROM and TO → no output expected. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let overload_short_from = Routine::new( + "myschema".to_string(), + Oid(1), + "notify_event".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "pjobid uuid, peventtype character varying, pattributes jsonb".to_string(), + None, + None, + "BEGIN\n CALL myschema.notify_event(pjobid, peventtype, null, pattributes, null);\nEND;" + .to_string(), + ); + let overload_long_from = Routine::new( + "myschema".to_string(), + Oid(2), + "notify_event".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "pjobid uuid, peventtype character varying, puserid character varying, pattributes jsonb, psessionseed jsonb DEFAULT NULL::jsonb".to_string(), + None, + None, + "BEGIN\n RAISE NOTICE 'notify';\nEND;".to_string(), + ); + + let overload_short_to = Routine::new( + "myschema".to_string(), + Oid(1), + "notify_event".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "pjobid uuid, peventtype character varying, pattributes jsonb".to_string(), + None, + None, + "BEGIN\n CALL myschema.notify_event(pjobid, peventtype, null, pattributes, null);\nEND;" + .to_string(), + ); + let overload_long_to = Routine::new( + "myschema".to_string(), + Oid(2), + "notify_event".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "pjobid uuid, peventtype character varying, puserid character varying, pattributes jsonb, psessionseed jsonb DEFAULT NULL::jsonb".to_string(), + None, + None, + "BEGIN\n RAISE NOTICE 'notify';\nEND;".to_string(), + ); + + from_dump.routines.push(overload_short_from); + from_dump.routines.push(overload_long_from); + to_dump.routines.push(overload_short_to); + to_dump.routines.push(overload_long_to); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("create or replace"), + "Identical overloads must not produce CREATE, got: {script}" + ); + assert!( + !script.contains("drop procedure"), + "Identical overloads must not produce DROP, got: {script}" + ); +} + +#[tokio::test] +async fn compare_routines_overloaded_one_changed() { + // Two overloads with the same (schema, name). Only the long overload + // changes its body between FROM and TO. The short overload must remain + // untouched while the long one is recreated. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let overload_short_from = Routine::new( + "myschema".to_string(), + Oid(1), + "notify_event".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "pjobid uuid, peventtype character varying, pattributes jsonb".to_string(), + None, + None, + "BEGIN\n CALL myschema.notify_event(pjobid, peventtype, null, pattributes, null);\nEND;" + .to_string(), + ); + let overload_long_from = Routine::new( + "myschema".to_string(), + Oid(2), + "notify_event".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "pjobid uuid, peventtype character varying, puserid character varying, pattributes jsonb, psessionseed jsonb DEFAULT NULL::jsonb".to_string(), + None, + None, + "BEGIN\n RAISE NOTICE 'old body';\nEND;".to_string(), + ); + + // Short overload is identical to FROM + let overload_short_to = Routine::new( + "myschema".to_string(), + Oid(1), + "notify_event".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "pjobid uuid, peventtype character varying, pattributes jsonb".to_string(), + None, + None, + "BEGIN\n CALL myschema.notify_event(pjobid, peventtype, null, pattributes, null);\nEND;" + .to_string(), + ); + // Long overload has a different body → should be recreated + let overload_long_to = Routine::new( + "myschema".to_string(), + Oid(2), + "notify_event".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "pjobid uuid, peventtype character varying, puserid character varying, pattributes jsonb, psessionseed jsonb DEFAULT NULL::jsonb".to_string(), + None, + None, + "BEGIN\n RAISE NOTICE 'new body';\nEND;".to_string(), + ); + + from_dump.routines.push(overload_short_from); + from_dump.routines.push(overload_long_from); + to_dump.routines.push(overload_short_to); + to_dump.routines.push(overload_long_to); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + // The changed (long) overload must be recreated + assert!( + script.contains("create or replace procedure myschema.notify_event(pjobid uuid, peventtype character varying, puserid character varying, pattributes jsonb, psessionseed jsonb DEFAULT NULL::jsonb)"), + "Changed overload must be recreated, got: {script}" + ); + // The short overload's signature must NOT appear in any CREATE statement + let short_create = "create or replace procedure myschema.notify_event(pjobid uuid, peventtype character varying, pattributes jsonb)"; + assert!( + !script.contains(short_create), + "Unchanged overload must not be recreated, got: {script}" + ); +} + +#[tokio::test] +async fn compare_routines_overloaded_drop_only_removed_overload() { + // FROM has two overloads; TO has only the short one. + // Only the long overload must be dropped; the short one must stay. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let overload_short = Routine::new( + "myschema".to_string(), + Oid(1), + "notify_event".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "pjobid uuid, pattributes jsonb".to_string(), + None, + None, + "BEGIN\n RAISE NOTICE 'short';\nEND;".to_string(), + ); + let overload_long = Routine::new( + "myschema".to_string(), + Oid(2), + "notify_event".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "pjobid uuid, pattributes jsonb, pseed jsonb".to_string(), + None, + None, + "BEGIN\n RAISE NOTICE 'long';\nEND;".to_string(), + ); + + from_dump.routines.push(overload_short.clone()); + from_dump.routines.push(overload_long); + to_dump.routines.push(overload_short); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("drop procedure if exists myschema.notify_event (pjobid uuid, pattributes jsonb, pseed jsonb) cascade;"), + "Removed overload must be dropped, got: {script}" + ); + assert!( + !script.contains("create or replace"), + "Unchanged overload must not be recreated, got: {script}" + ); +} + +#[tokio::test] +async fn compare_routines_procedure_with_config_params() { + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut routine = Routine::new( + "public".to_string(), + Oid(1), + "test_proc".to_string(), + "plpgsql".to_string(), + "PROCEDURE".to_string(), + "void".to_string(), + "IN pvalue text".to_string(), + None, + None, + "\nBEGIN\n RAISE NOTICE 'value: %', pvalue;\nEND;\n".to_string(), + ); + routine.config = vec![ + "search_path=public, pg_temp".to_string(), + "lock_timeout=5s".to_string(), + ]; + routine.hash(); + to_dump.routines.push(routine); + + let from_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("SET search_path = public, pg_temp"), + "script must contain SET search_path, got:\n{}", + script + ); + assert!( + script.contains("SET lock_timeout = '5s'"), + "script must contain SET lock_timeout, got:\n{}", + script + ); +} + +#[tokio::test] +async fn compare_routines_function_with_config_params() { + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut routine = Routine::new( + "public".to_string(), + Oid(1), + "my_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "\nBEGIN\n RETURN 1;\nEND;\n".to_string(), + ); + routine.config = vec!["work_mem=256MB".to_string()]; + routine.hash(); + to_dump.routines.push(routine); + + let from_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("SET work_mem = '256MB'"), + "script must contain SET work_mem, got:\n{}", + script + ); + assert!( + script.contains("VOLATILE"), + "function flags must still be present, got:\n{}", + script + ); +} + +#[tokio::test] +async fn compare_routines_config_change_triggers_update() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "public".to_string(), + Oid(1), + "test_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + from_routine.config = vec!["search_path=public".to_string()]; + from_routine.hash(); + + let mut to_routine = Routine::new( + "public".to_string(), + Oid(1), + "test_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + to_routine.config = vec![ + "search_path=public".to_string(), + "lock_timeout=5s".to_string(), + ]; + to_routine.hash(); + + assert_ne!( + from_routine.hash, to_routine.hash, + "hashes must differ when config changes" + ); + + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("create or replace function"), + "config change must trigger CREATE OR REPLACE, got:\n{}", + script + ); + assert!( + script.contains("SET search_path = public"), + "script must contain SET search_path, got:\n{}", + script + ); + assert!( + script.contains("SET lock_timeout = '5s'"), + "script must contain SET lock_timeout, got:\n{}", + script + ); +} + +#[tokio::test] +async fn compare_routines_config_removal_triggers_update() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_routine = Routine::new( + "public".to_string(), + Oid(1), + "test_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + from_routine.config = vec!["search_path=public".to_string()]; + from_routine.hash(); + + let to_routine = Routine::new( + "public".to_string(), + Oid(1), + "test_func".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + + assert_ne!( + from_routine.hash, to_routine.hash, + "hashes must differ when config is removed" + ); + + from_dump.routines.push(from_routine); + to_dump.routines.push(to_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_routines().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("create or replace function"), + "config removal must trigger CREATE OR REPLACE, got:\n{}", + script + ); + assert!( + !script.contains("SET search_path"), + "removed config must not appear in script, got:\n{}", + script + ); +} diff --git a/app/src/comparer/tests/core/schemas_extensions.rs b/app/src/comparer/tests/core/schemas_extensions.rs new file mode 100644 index 0000000..216ecf5 --- /dev/null +++ b/app/src/comparer/tests/core/schemas_extensions.rs @@ -0,0 +1,62 @@ +//! Tests for `compare_schemas` and `compare_extensions`. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::extension::Extension; +use crate::dump::schema::Schema; + +#[tokio::test] +async fn compare_schemas_emits_owner_change() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_schema = Schema::new("public".to_string(), "public".to_string(), None); + from_schema.owner = "old_owner".to_string(); + from_schema.hash(); + + let mut to_schema = Schema::new("public".to_string(), "public".to_string(), None); + to_schema.owner = "new_owner".to_string(); + to_schema.hash(); + + from_dump.schemas.push(from_schema); + to_dump.schemas.push(to_schema); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_schemas().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains("alter schema public owner to new_owner;")); +} + +#[tokio::test] +async fn compare_extensions_notes_owner_change_as_unsupported() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_ext = Extension::new( + "hstore".to_string(), + "1.0".to_string(), + "public".to_string(), + ); + from_ext.owner = "old_owner".to_string(); + + let mut to_ext = Extension::new( + "hstore".to_string(), + "1.0".to_string(), + "public".to_string(), + ); + to_ext.owner = "new_owner".to_string(); + + from_dump.extensions.push(from_ext); + to_dump.extensions.push(to_ext); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_extensions().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains( + "-- Extension owner change is not supported by PostgreSQL ALTER EXTENSION syntax (old_owner -> new_owner)." + )); +} diff --git a/app/src/comparer/tests/core/script_output.rs b/app/src/comparer/tests/core/script_output.rs new file mode 100644 index 0000000..1f47008 --- /dev/null +++ b/app/src/comparer/tests/core/script_output.rs @@ -0,0 +1,1035 @@ +//! Tests for the final script rendering performed by `get_script`: +//! `use_single_transaction` framing, comment stripping under +//! `use_comments = false`, and newline collapsing — all of which must leave +//! string literals, E-strings, quoted identifiers and dollar-quoted bodies +//! byte-for-byte intact. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::table::Table; + +#[tokio::test] +async fn use_single_transaction_should_add_begin_commit() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut new_table = Table::new( + "public".to_string(), + "\"my-table\"".to_string(), + "public".to_string(), + "my-table".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "\"my-table\"", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + + new_table.hash(); + + to_dump.tables.push(new_table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, true, true, GrantsMode::Ignore); + + comparer.compare().await.unwrap(); + + let script = comparer.get_script(); + + const SCRIPT_BODY_START_PATTERN: &str = "*/\n\n"; + + let script_body_start_index = script + .find(SCRIPT_BODY_START_PATTERN) + .map(|index| index + SCRIPT_BODY_START_PATTERN.len()) + .expect("Script header was not found"); + + let script_body = &script[script_body_start_index..]; + + assert!(script_body.starts_with("begin;\n\n")); + assert!(script_body.ends_with("\ncommit;")); +} + +#[tokio::test] +async fn use_comments_false_strips_block_and_line_comments() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + comparer.script = + "/* header comment */\nCREATE TABLE t1 (id int); -- inline comment\n/* trailing */\n" + .to_string(); + let result = comparer.get_script(); + assert_eq!(result, "CREATE TABLE t1 (id int);\n"); +} + +#[tokio::test] +async fn use_comments_false_strips_singly_nested_block_comment() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // /* outer /* inner */ still outer */ must all be stripped. + comparer.script = "SELECT /* outer /* inner */ still outer */ 1;\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT 1;\n"); +} + +#[tokio::test] +async fn use_comments_false_strips_deeply_nested_block_comment() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Three levels of nesting. + comparer.script = "SELECT /* a /* b /* c */ b */ a */ 1;\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT 1;\n"); +} + +#[tokio::test] +async fn use_comments_false_strips_adjacent_nested_block_comments() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Two independent outer comments each with their own inner comment. + // Any space left before SELECT after stripping the first comment is removed by get_script()'s trim(). + comparer.script = "/* a /* b */ a */ SELECT /* c /* d */ c */ 1;\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT 1;\n"); +} + +#[tokio::test] +async fn use_comments_false_nested_block_comment_before_statement() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Nested comment as a header; SQL that follows must be preserved intact. + comparer.script = "/* header /* nested */ end */\nCREATE TABLE t (id int);\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "CREATE TABLE t (id int);\n"); +} + +#[tokio::test] +async fn use_comments_false_nested_comment_only_script_returns_empty() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // A script consisting only of a nested block comment produces no output. + comparer.script = "/* outer /* inner */ outer */\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, ""); +} + +#[tokio::test] +async fn use_comments_false_nested_comment_sql_between_levels() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Text that sits between the outer /* and its matching */ must be stripped + // even when inner comment pairs appear in the middle. + comparer.script = "SELECT /* before /* mid */ after */ 42;\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT 42;\n"); +} + +#[tokio::test] +async fn use_comments_false_nested_comment_immediately_after_keyword() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // No space between the keyword and the nested comment; scanner must not + // be confused by the /* that immediately follows non-comment text. + comparer.script = "SELECT/* /* nested */ */1;\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT1;\n"); +} + +#[tokio::test] +async fn use_comments_false_nested_comment_followed_by_line_comment() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // After the nested block comment closes, a line comment on the same line + // must also be stripped. + comparer.script = "SELECT /* a /* b */ a */ 1; -- strip me\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT 1;\n"); +} + +#[tokio::test] +async fn use_comments_false_nested_comment_inside_single_quoted_string_not_stripped() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Nested-comment-like sequences inside a string literal must be preserved. + comparer.script = "SELECT '/* outer /* inner */ outer */' AS val;\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT '/* outer /* inner */ outer */' AS val;\n"); +} + +#[tokio::test] +async fn use_comments_false_nested_comment_inside_e_string_not_stripped() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Nested-comment-like sequences inside an E-string must also be preserved. + comparer.script = "SELECT E'/* outer /* inner */ outer */' AS val;\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT E'/* outer /* inner */ outer */' AS val;\n"); +} + +#[tokio::test] +async fn use_comments_false_nested_comment_inside_double_quoted_identifier_not_stripped() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Nested-comment-like sequences inside a double-quoted identifier must be preserved. + comparer.script = "SELECT 1 AS \"/* outer /* inner */ outer */\";\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT 1 AS \"/* outer /* inner */ outer */\";\n"); +} + +#[tokio::test] +async fn use_comments_false_preserves_dollar_quoted_comments() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + comparer.script = "CREATE FUNCTION f() RETURNS void AS $$\n-- inside body\n/* also inside */\n$$ LANGUAGE plpgsql;\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "CREATE FUNCTION f() RETURNS void AS $$\n-- inside body\n/* also inside */\n$$ LANGUAGE plpgsql;\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_named_dollar_tag_comments() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + comparer.script = + "CREATE FUNCTION g() RETURNS void AS $body$\n-- comment inside\n$body$ LANGUAGE plpgsql;\n" + .to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "CREATE FUNCTION g() RETURNS void AS $body$\n-- comment inside\n$body$ LANGUAGE plpgsql;\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_single_quoted_comments() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + comparer.script = "SELECT '-- not a comment' AS val, '/* also not */' AS val2;\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "SELECT '-- not a comment' AS val, '/* also not */' AS val2;\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_e_string_backslash_escaped_quote() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // \' inside E'...' is an escaped quote and must NOT terminate the string. + // The comment-like content after it must be preserved, not stripped. + comparer.script = "SELECT E'it\\'s fine -- not a comment' AS val; -- strip\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT E'it\\'s fine -- not a comment' AS val;\n"); +} + +#[tokio::test] +async fn use_comments_false_preserves_e_string_block_comment_lookalike() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // /* ... */ inside an E-string must not be treated as a block comment. + comparer.script = "SELECT E'/* not a comment */' AS val; /* strip */\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT E'/* not a comment */' AS val;\n"); +} + +#[tokio::test] +async fn use_comments_false_preserves_e_string_backslash_backslash_then_quote() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // \\\' is an escaped backslash (\\) followed by an escaped quote (\'). + // The string should continue after that sequence. + comparer.script = + "SELECT E'backslash\\\\\\'quote -- still inside' AS val; -- strip\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "SELECT E'backslash\\\\\\'quote -- still inside' AS val;\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_e_string_doubled_quote_escape() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // '' inside E'...' is also a valid quote escape; must not terminate string early. + comparer.script = "SELECT E'it''s fine -- not a comment' AS val; -- strip\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT E'it''s fine -- not a comment' AS val;\n"); +} + +#[tokio::test] +async fn use_comments_false_preserves_lowercase_e_string() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Lowercase e'...' prefix must be handled identically to E'...'. + comparer.script = "SELECT e'it\\'s fine -- not a comment' AS val; -- strip\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT e'it\\'s fine -- not a comment' AS val;\n"); +} + +#[tokio::test] +async fn use_comments_false_does_not_treat_standalone_e_as_e_string() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // A bare column/alias named "e" followed immediately by a plain string + // literal must not be misidentified as an E-string prefix. + // Here "e" is a table alias and 'text' is a separate literal. + comparer.script = "SELECT e, 'text -- not a comment' FROM t; -- strip\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT e, 'text -- not a comment' FROM t;\n"); +} + +#[tokio::test] +async fn use_comments_false_does_not_treat_uppercase_e_identifier_as_e_string() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Uppercase E as a standalone identifier (column name), not followed by + // a quote, must not be confused with an E-string prefix. + comparer.script = "SELECT E FROM t; -- strip\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT E FROM t;\n"); +} + +#[tokio::test] +async fn use_comments_false_preserves_empty_e_string() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // An empty E-string E'' must not confuse the state machine. + comparer.script = "SELECT E'' AS val; /* strip */\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT E'' AS val;\n"); +} + +#[tokio::test] +async fn use_comments_false_preserves_e_string_with_other_backslash_sequences() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // \n and \t are everyday escape sequences; the scanner must copy them + // verbatim and must not mistake the character after the backslash for + // anything other than the second byte of the pair. + comparer.script = + "SELECT E'line1\\nline2\\ttabbed -- not a comment' AS val; -- strip\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "SELECT E'line1\\nline2\\ttabbed -- not a comment' AS val;\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_multiple_e_strings_in_one_statement() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Multiple E-strings in one statement; real trailing comment stripped. + comparer.script = "INSERT INTO t VALUES (E'val\\'1 -- x', E'val/*2*/'); -- strip\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "INSERT INTO t VALUES (E'val\\'1 -- x', E'val/*2*/');\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_e_string_adjacent_to_double_quoted_identifier() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // E-string and double-quoted identifier in the same statement; both + // preserved, real trailing comment stripped. + comparer.script = + "INSERT INTO \"my--table\" (col) VALUES (E'it\\'s -- ok'); -- strip\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "INSERT INTO \"my--table\" (col) VALUES (E'it\\'s -- ok');\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_double_quoted_identifier_comments() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Double-quoted identifiers containing sequences that look like comment + // starters must be passed through verbatim and must NOT be stripped. + comparer.script = + "SELECT 1 AS \"col--name\", 2 AS \"/*not a comment*/\"; -- real comment\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "SELECT 1 AS \"col--name\", 2 AS \"/*not a comment*/\";\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_double_quoted_identifier_with_escaped_quote() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // A doubled double-quote inside a quoted identifier is an escape sequence + // and must survive comment stripping intact. + comparer.script = + "ALTER TABLE t RENAME COLUMN \"col\"\"--name\" TO new_name; /* drop this */\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "ALTER TABLE t RENAME COLUMN \"col\"\"--name\" TO new_name;\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_multiple_double_quoted_identifiers() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Several double-quoted identifiers in one statement, each containing + // comment-like sequences; only the trailing real comment should be stripped. + comparer.script = "SELECT \"a--b\", \"c/*d*/e\", \"f--g\" FROM t; -- strip me\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT \"a--b\", \"c/*d*/e\", \"f--g\" FROM t;\n"); +} + +#[tokio::test] +async fn use_comments_false_preserves_qualified_double_quoted_name() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Quoted schema + quoted table, both containing comment-like sequences. + comparer.script = + "CREATE TABLE \"my--schema\".\"my/*table*/\" (id int); /* strip */\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "CREATE TABLE \"my--schema\".\"my/*table*/\" (id int);\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_empty_double_quoted_identifier() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // "" is a valid (if unusual) quoted identifier; must not confuse the state machine. + comparer.script = "ALTER INDEX \"\" RENAME TO x; /* strip */\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "ALTER INDEX \"\" RENAME TO x;\n"); +} + +#[tokio::test] +async fn use_comments_false_strips_comment_after_double_quoted_identifier() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // The parser must exit the double-quote state correctly so the real block + // comment that follows is still stripped. + comparer.script = "SELECT \"col\" /* strip this */ FROM t;\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, "SELECT \"col\" FROM t;\n"); +} + +#[tokio::test] +async fn use_comments_false_mixed_double_and_single_quoted_with_comment() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + // Double-quoted identifier and single-quoted string both containing + // comment-like bytes; trailing real comment must still be stripped. + comparer.script = + "INSERT INTO \"my--table\" (col) VALUES ('/* not */ a -- val'); -- strip\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "INSERT INTO \"my--table\" (col) VALUES ('/* not */ a -- val');\n" + ); +} + +#[tokio::test] +async fn use_comments_false_returns_empty_for_comment_only_script() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + comparer.script = "/* only a comment */\n-- another comment\n".to_string(); + let result = comparer.get_script(); + assert_eq!(result, ""); +} + +#[tokio::test] +async fn use_comments_false_collapses_excess_newlines() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + comparer.script = + "CREATE TABLE t1 (id int);\n/* removed */\n\n\n\nCREATE TABLE t2 (id int);\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "CREATE TABLE t1 (id int);\n\nCREATE TABLE t2 (id int);\n" + ); +} + +#[tokio::test] +async fn use_comments_true_preserves_all_comments() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.script = "/* header */\nCREATE TABLE t1 (id int); -- inline\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "/* header */\nCREATE TABLE t1 (id int); -- inline\n" + ); +} + +#[tokio::test] +async fn use_comments_false_preserves_utf8() { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut comparer = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + comparer.script = + "/* comment */\nCOMMENT ON TABLE t IS '数据表 — 描述';\nSELECT $$函数体$$;\n".to_string(); + let result = comparer.get_script(); + assert_eq!( + result, + "COMMENT ON TABLE t IS '数据表 — 描述';\nSELECT $$函数体$$;\n" + ); +} + +/// Helper: build a Comparer with use_comments=false and a given script body. +fn comparer_with_script(script: &str) -> Comparer { + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut c = Comparer::new(from_dump, to_dump, false, false, false, GrantsMode::Ignore); + c.script = script.to_string(); + c +} + +#[test] +fn get_script_collapses_triple_newlines_outside_dollar_quotes() { + let input = "SELECT 1;\n\n\n\nSELECT 2;\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + // 4 newlines should be collapsed to 2 + assert_eq!(out, "SELECT 1;\n\nSELECT 2;\n"); +} + +#[test] +fn get_script_preserves_triple_newlines_inside_dollar_quotes() { + let input = concat!( + "CREATE OR REPLACE PROCEDURE public.test_proc() LANGUAGE plpgsql AS $$\n", + "BEGIN\n", + " RAISE NOTICE 'block 1';\n", + "\n", + "\n", + "\n", + " RAISE NOTICE 'block 2';\n", + "END;\n", + "$$;\n", + ); + let c = comparer_with_script(input); + let out = c.get_script(); + // The three consecutive newlines inside $$ must survive + assert!( + out.contains("'block 1';\n\n\n\n RAISE NOTICE 'block 2'"), + "blank lines inside $$ body must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_preserves_newlines_inside_tagged_dollar_quotes() { + let input = concat!( + "CREATE FUNCTION f() RETURNS void LANGUAGE plpgsql AS $body$\n", + "BEGIN\n", + "\n", + "\n", + "\n", + " NULL;\n", + "END;\n", + "$body$;\n", + ); + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("BEGIN\n\n\n\n NULL;"), + "blank lines inside $body$ must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_collapses_newlines_between_dollar_quoted_blocks() { + // Newlines *outside* dollar-quoted blocks should still be collapsed + let input = "$$body1$$;\n\n\n\n$$body2$$;\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert_eq!(out, "$$body1$$;\n\n$$body2$$;\n"); +} + +#[test] +fn get_script_mixed_dollar_quote_and_outside_newlines() { + let input = concat!( + "SELECT 1;\n\n\n\n", + "CREATE FUNCTION f() RETURNS void AS $$\n", + "BEGIN\n", + "\n\n\n", + " NULL;\n", + "END;\n", + "$$;\n", + "\n\n\n\n", + "SELECT 2;\n", + ); + let c = comparer_with_script(input); + let out = c.get_script(); + + // Outside: collapsed + assert!( + !out.contains("SELECT 1;\n\n\n"), + "newlines before $$ block should be collapsed" + ); + assert!( + !out.contains("$$;\n\n\n"), + "newlines after $$ block should be collapsed" + ); + // Inside: preserved + assert!( + out.contains("BEGIN\n\n\n\n NULL;"), + "blank lines inside $$ must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_with_use_comments_true_returns_verbatim() { + let input = "SELECT 1;\n\n\n\nSELECT 2;\n"; + let from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + let mut c = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + c.script = input.to_string(); + let out = c.get_script(); + // With use_comments=true, script is returned as-is + assert_eq!(out, input); +} + +#[test] +fn get_script_strips_comments_but_preserves_dollar_body_newlines() { + let input = concat!( + "-- a comment\n", + "CREATE FUNCTION f() RETURNS void AS $$\n", + "BEGIN\n", + "\n\n\n", + " NULL;\n", + "END;\n", + "$$;\n", + ); + let c = comparer_with_script(input); + let out = c.get_script(); + // Comment removed + assert!( + !out.contains("-- a comment"), + "line comment should be removed" + ); + // Dollar body preserved + assert!( + out.contains("BEGIN\n\n\n\n NULL;"), + "blank lines inside $$ body must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_empty_dollar_body_not_corrupted() { + let input = "CREATE FUNCTION f() AS $$$$;\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!(out.contains("$$$$"), "empty dollar body must be preserved"); +} + +#[test] +fn get_script_unterminated_dollar_quote_copies_to_end() { + // Unterminated dollar-quote: everything after opening tag should be + // copied verbatim (same as the comment-stripping pass behaviour). + let input = "CREATE FUNCTION f() AS $$\nBEGIN\n\n\n\n NULL;\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("\n\n\n\n"), + "unterminated $$ body newlines must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_preserves_newlines_inside_single_quoted_string() { + // Multi-line COMMENT body in a single-quoted literal must not be collapsed. + let input = "COMMENT ON TABLE t IS 'line1\n\n\n\nline5';\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("'line1\n\n\n\nline5'"), + "newlines inside single-quoted string must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_preserves_newlines_inside_e_string() { + // E-string literal with multi-line content. + let input = "SELECT E'first\n\n\n\nlast';\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("E'first\n\n\n\nlast'"), + "newlines inside E-string must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_preserves_newlines_inside_double_quoted_identifier() { + // Unusual but legal: double-quoted identifiers can contain newlines. + let input = "SELECT \"col\n\n\n\nname\";\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("\"col\n\n\n\nname\""), + "newlines inside double-quoted identifier must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_collapses_newlines_between_single_quoted_strings() { + // Newlines *outside* quoted strings should still be collapsed. + let input = "SELECT 'a';\n\n\n\nSELECT 'b';\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert_eq!(out, "SELECT 'a';\n\nSELECT 'b';\n"); +} + +#[test] +fn get_script_e_string_with_escaped_quote_and_newlines() { + // E-string with \' inside — must not terminate early. + let input = "SELECT E'it\\'s\n\n\n\nfine';\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("E'it\\'s\n\n\n\nfine'"), + "E-string with escaped quote and newlines must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_single_quoted_doubled_quote_and_newlines() { + // Standard single-quoted string with '' escape and embedded newlines. + let input = "SELECT 'it''s\n\n\n\nfine';\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("'it''s\n\n\n\nfine'"), + "single-quoted string with doubled quote and newlines must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_lowercase_e_string_preserves_newlines() { + // Lowercase e should be recognised as an E-string opener too. + let input = "SELECT e'first\n\n\n\nlast';\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("e'first\n\n\n\nlast'"), + "newlines inside lowercase e-string must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_unterminated_single_quoted_string_copies_to_end() { + let input = "SELECT 'unterminated\n\n\n\nstring\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("\n\n\n\n"), + "unterminated single-quoted string newlines must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_unterminated_e_string_copies_to_end() { + let input = "SELECT E'unterminated\n\n\n\nstring\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("\n\n\n\n"), + "unterminated E-string newlines must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_unterminated_double_quoted_identifier_copies_to_end() { + let input = "SELECT \"unterminated\n\n\n\nident\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("\n\n\n\n"), + "unterminated double-quoted identifier newlines must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_empty_single_quoted_string_no_corruption() { + // Empty string '' should not confuse the scanner. + let input = "SELECT '';\n\n\n\nSELECT 1;\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert_eq!(out, "SELECT '';\n\nSELECT 1;\n"); +} + +#[test] +fn get_script_newline_count_resets_after_single_quoted_literal() { + // Two newlines before a quoted literal, then two newlines after it — + // neither run alone exceeds 2 so nothing should be collapsed. + let input = "A;\n\n'inside\n\n\n\ntext';\n\nB;\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("'inside\n\n\n\ntext'"), + "newlines inside literal must be preserved, got:\n{}", + out + ); + // The two newlines before and after the literal should survive. + assert!( + out.contains("A;\n\n'inside"), + "two newlines before literal should survive, got:\n{}", + out + ); + assert!( + out.contains("';\n\nB;"), + "two newlines after literal should survive, got:\n{}", + out + ); +} + +#[test] +fn get_script_collapses_after_quoted_literal_with_excess_newlines() { + // Excess newlines *after* a quoted literal should still be collapsed. + let input = "SELECT 'hello';\n\n\n\nSELECT 'world';\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert_eq!(out, "SELECT 'hello';\n\nSELECT 'world';\n"); +} + +#[test] +fn get_script_mixed_literal_types_with_newlines() { + // Mix of dollar-quoted, single-quoted, E-string, and double-quoted + // literals each containing newlines that must be preserved, separated + // by excessive newlines that should be collapsed. + let input = concat!( + "COMMENT ON TABLE t IS 'line1\n\n\n\nline5';\n", + "\n\n\n\n", + "SELECT E'a\n\n\n\nb';\n", + "\n\n\n\n", + "SELECT \"id\n\n\n\ncol\";\n", + "\n\n\n\n", + "CREATE FUNCTION f() AS $$\nBEGIN\n\n\n\n NULL;\nEND;\n$$;\n", + ); + let c = comparer_with_script(input); + let out = c.get_script(); + // Inside literals: preserved + assert!( + out.contains("'line1\n\n\n\nline5'"), + "single-quoted newlines must be preserved" + ); + assert!( + out.contains("E'a\n\n\n\nb'"), + "E-string newlines must be preserved" + ); + assert!( + out.contains("\"id\n\n\n\ncol\""), + "double-quoted newlines must be preserved" + ); + assert!( + out.contains("BEGIN\n\n\n\n NULL;"), + "dollar-quoted newlines must be preserved" + ); + // Outside literals: collapsed (no run of 3+ newlines between statements) + let between_stmts = out + .split("'line1\n\n\n\nline5';") + .nth(1) + .unwrap() + .split("E'a\n\n\n\nb'") + .next() + .unwrap(); + assert!( + !between_stmts.contains("\n\n\n"), + "newlines between statements should be collapsed, got segment: {:?}", + between_stmts + ); +} + +#[test] +fn get_script_e_string_escaped_backslash_then_newlines() { + // E'foo\\' — the \\\\ is an escaped backslash, so the next ' closes + // the string. Newlines outside should be collapsed. + let input = "SELECT E'foo\\\\';\n\n\n\nSELECT 1;\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert_eq!(out, "SELECT E'foo\\\\';\n\nSELECT 1;\n"); +} + +#[test] +fn get_script_double_quoted_doubled_escape_and_newlines() { + // Double-quoted identifier with "" escape and embedded newlines. + let input = "SELECT \"col\"\"\n\n\n\nname\";\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("\"col\"\"\n\n\n\nname\""), + "double-quoted identifier with escaped quote and newlines must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_comment_stripped_but_single_quoted_newlines_preserved() { + // The comment-stripping pass runs first; the collapsing pass must + // still preserve newlines inside single-quoted strings. + let input = concat!( + "-- strip this\n", + "COMMENT ON TABLE t IS 'multi\n\n\n\nline';\n", + ); + let c = comparer_with_script(input); + let out = c.get_script(); + assert!(!out.contains("-- strip this"), "comment should be removed"); + assert!( + out.contains("'multi\n\n\n\nline'"), + "single-quoted newlines must survive comment stripping + collapsing, got:\n{}", + out + ); +} + +#[test] +fn get_script_block_comment_stripped_but_e_string_newlines_preserved() { + let input = concat!("/* block comment */\n", "SELECT E'keep\n\n\n\nme';\n",); + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + !out.contains("block comment"), + "block comment should be removed" + ); + assert!( + out.contains("E'keep\n\n\n\nme'"), + "E-string newlines must survive after block comment stripping, got:\n{}", + out + ); +} + +#[test] +fn get_script_adjacent_single_quoted_strings_both_preserved() { + // Two single-quoted strings back-to-back, each with internal newlines. + let input = "SELECT 'a\n\n\n\nb' || 'c\n\n\n\nd';\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("'a\n\n\n\nb'"), + "first literal newlines must be preserved, got:\n{}", + out + ); + assert!( + out.contains("'c\n\n\n\nd'"), + "second literal newlines must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_dollar_body_containing_single_quoted_newlines() { + // A dollar-quoted body that itself contains a single-quoted string + // with many newlines — everything inside $$ is already copied verbatim + // by the dollar-quote branch, so the inner literal is preserved too. + let input = concat!( + "CREATE FUNCTION f() AS $$\n", + "BEGIN\n", + " RAISE NOTICE 'msg\n\n\n\nend';\n", + "END;\n", + "$$;\n", + ); + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("'msg\n\n\n\nend'"), + "single-quoted literal inside dollar body must be preserved, got:\n{}", + out + ); +} + +#[test] +fn get_script_single_quoted_string_without_excess_newlines_unchanged() { + // A single-quoted string with exactly 2 newlines (not excess) — should + // pass through without any modification. + let input = "SELECT 'a\n\nb';\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert_eq!(out, "SELECT 'a\n\nb';\n"); +} + +#[test] +fn get_script_e_identifier_not_confused_with_e_string() { + // A column named "E" followed by a comparison — the E is followed by + // a space, not a quote, so it must not be mistaken for an E-string. + let input = "SELECT E = 1;\n\n\n\nSELECT 2;\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert_eq!(out, "SELECT E = 1;\n\nSELECT 2;\n"); +} + +#[test] +fn get_script_multiple_e_strings_on_same_line() { + let input = "SELECT E'x\n\n\n\ny', E'a\n\n\n\nb';\n"; + let c = comparer_with_script(input); + let out = c.get_script(); + assert!( + out.contains("E'x\n\n\n\ny'"), + "first E-string newlines must be preserved, got:\n{}", + out + ); + assert!( + out.contains("E'a\n\n\n\nb'"), + "second E-string newlines must be preserved, got:\n{}", + out + ); +} diff --git a/app/src/comparer/tests/core/sequences.rs b/app/src/comparer/tests/core/sequences.rs new file mode 100644 index 0000000..763b1a1 --- /dev/null +++ b/app/src/comparer/tests/core/sequences.rs @@ -0,0 +1,754 @@ +//! Tests for `compare_sequences`, including the serial- and identity-owned +//! sequences that must not be emitted independently of their table. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::sequence::Sequence; +use crate::dump::table::Table; +use crate::dump::table_column::TableColumn; + +#[tokio::test] +async fn compare_sequences_skips_owned_by_serial_column() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Sequence owned by table column + let sequence = Sequence::new( + "public".to_string(), + "test_id_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(1000), + Some(1), + false, + Some(1), + Some(1), + Some("public".to_string()), + Some("test".to_string()), + Some("id".to_string()), + ); + to_dump.sequences.push(sequence); + + // Table with serial column + let column = TableColumn { + catalog: "postgres".to_string(), + schema: "public".to_string(), + table: "test".to_string(), + name: "id".to_string(), + ordinal_position: 1, + column_default: Some("nextval('test_id_seq'::regclass)".to_string()), + is_nullable: false, + data_type: "bigint".to_string(), // PostgreSQL reports bigserial as bigint with nextval default + character_maximum_length: None, + character_octet_length: None, + numeric_precision: Some(64), + numeric_precision_radix: Some(2), + numeric_scale: Some(0), + datetime_precision: None, + interval_type: None, + interval_precision: None, + character_set_catalog: None, + character_set_schema: None, + character_set_name: None, + collation_catalog: None, + collation_schema: None, + collation_name: None, + domain_catalog: None, + domain_schema: None, + domain_name: None, + udt_catalog: None, + udt_schema: None, + udt_name: None, + scope_catalog: None, + scope_schema: None, + scope_name: None, + maximum_cardinality: None, + dtd_identifier: None, + is_self_referencing: false, + is_identity: false, + identity_generation: None, + identity_start: None, + identity_increment: None, + identity_maximum: None, + identity_minimum: None, + identity_cycle: false, + is_generated: "NEVER".to_string(), + generation_expression: None, + generation_type: None, + is_updatable: true, + related_views: None, + comment: None, + storage: None, + compression: None, + statistics_target: None, + acl: vec![], + serial_type: None, + }; + + let table = Table::new( + "public".to_string(), + "test".to_string(), + "public".to_string(), + "test".to_string(), + "postgres".to_string(), + None, + vec![column], + vec![], + vec![], + vec![], + None, + ); + to_dump.tables.push(table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains( + "Skipping sequence public.test_id_seq as it will be created by column public.test.id" + )); + assert!(!script.contains("create sequence \"public\".\"test_id_seq\"")); +} + +#[tokio::test] +async fn compare_sequences_skips_owned_by_identity_column() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Sequence owned by table column + let sequence = Sequence::new( + "public".to_string(), + "test_id_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(1000), + Some(1), + false, + Some(1), + Some(1), + Some("public".to_string()), + Some("test".to_string()), + Some("id".to_string()), + ); + to_dump.sequences.push(sequence); + + // Table with identity column + let column = TableColumn { + catalog: "postgres".to_string(), + schema: "public".to_string(), + table: "test".to_string(), + name: "id".to_string(), + ordinal_position: 1, + column_default: None, + is_nullable: false, + data_type: "bigint".to_string(), + character_maximum_length: None, + character_octet_length: None, + numeric_precision: Some(64), + numeric_precision_radix: Some(2), + numeric_scale: Some(0), + datetime_precision: None, + interval_type: None, + interval_precision: None, + character_set_catalog: None, + character_set_schema: None, + character_set_name: None, + collation_catalog: None, + collation_schema: None, + collation_name: None, + domain_catalog: None, + domain_schema: None, + domain_name: None, + udt_catalog: None, + udt_schema: None, + udt_name: None, + scope_catalog: None, + scope_schema: None, + scope_name: None, + maximum_cardinality: None, + dtd_identifier: None, + is_self_referencing: false, + is_identity: true, // This triggers the skip + identity_generation: Some("ALWAYS".to_string()), + identity_start: Some("1".to_string()), + identity_increment: Some("1".to_string()), + identity_maximum: None, + identity_minimum: None, + identity_cycle: false, + is_generated: "NEVER".to_string(), + generation_expression: None, + generation_type: None, + is_updatable: true, + related_views: None, + comment: None, + storage: None, + compression: None, + statistics_target: None, + acl: vec![], + serial_type: None, + }; + + let table = Table::new( + "public".to_string(), + "test".to_string(), + "public".to_string(), + "test".to_string(), + "postgres".to_string(), + None, + vec![column], + vec![], + vec![], + vec![], + None, + ); + to_dump.tables.push(table); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains( + "Skipping sequence public.test_id_seq as it will be created by column public.test.id" + )); + assert!(!script.contains("create sequence \"public\".\"test_id_seq\"")); +} + +#[tokio::test] +async fn compare_sequences_does_not_skip_normal_sequence() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Normal sequence not owned by any column + let sequence = Sequence::new( + "public".to_string(), + "test_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(1000), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + to_dump.sequences.push(sequence); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!(!script.contains("Skipping sequence")); + assert!(script.contains("create sequence public.test_seq")); +} + +#[tokio::test] +async fn compare_sequences_emits_owner_change() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let from_sequence = Sequence::new( + "public".to_string(), + "test_seq".to_string(), + "old_owner".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(1000), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + + let to_sequence = Sequence::new( + "public".to_string(), + "test_seq".to_string(), + "new_owner".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(1000), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + + from_dump.sequences.push(from_sequence); + to_dump.sequences.push(to_sequence); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains("alter sequence public.test_seq owner to new_owner;")); +} + +/// When MINVALUE is raised above the sequence's effective current position (last_value if +/// known, otherwise old start_value), the comparer must emit RESTART WITH so PostgreSQL does +/// not fall back to an old recorded start value that violates the new MINVALUE: +/// +/// ERROR: RESTART value (1) cannot be less than MINVALUE (10000000) +#[tokio::test] +async fn compare_sequences_emits_restart_when_effective_current_below_new_minvalue() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // last_value is None → effective_current falls back to start_value (1), which is < 10M. + let from_sequence = Sequence::new( + "my_schema".to_string(), + "my_sequence_id_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(999_999_999), + Some(1), + false, + Some(1), + None, + None, + None, + None, + ); + let to_sequence = Sequence::new( + "my_schema".to_string(), + "my_sequence_id_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(10_000_000), + Some(10_000_000), + Some(999_999_999), + Some(1), + true, + Some(1), + None, + None, + None, + None, + ); + + from_dump.sequences.push(from_sequence); + to_dump.sequences.push(to_sequence); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("start with 10000000"), + "script must contain START WITH: {script}" + ); + assert!( + script.contains("restart with 10000000"), + "script must contain RESTART WITH to prevent RESTART value < MINVALUE error: {script}" + ); +} + +/// When last_value is already above the new MINVALUE, RESTART WITH must NOT be emitted +/// even though start_value and MINVALUE are both raised. Emitting it would rewind the +/// live sequence and risk duplicate-key violations. +#[tokio::test] +async fn compare_sequences_no_restart_when_last_value_already_above_new_minvalue() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let from_sequence = Sequence::new( + "public".to_string(), + "busy_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(999_999_999), + Some(1), + false, + Some(1), + Some(15_000_000), // last_value is already well above the new MINVALUE (10M) + None, + None, + None, + ); + let to_sequence = Sequence::new( + "public".to_string(), + "busy_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(10_000_000), // start_value raised + Some(10_000_000), // MINVALUE raised — but last_value (15M) already satisfies it + Some(999_999_999), + Some(1), + false, + Some(1), + None, + None, + None, + None, + ); + + from_dump.sequences.push(from_sequence); + to_dump.sequences.push(to_sequence); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("restart with"), + "script must NOT contain RESTART WITH when last_value is already above new MINVALUE: {script}" + ); + assert!( + script.contains("alter sequence public.busy_seq"), + "script must still emit ALTER SEQUENCE to update start_value/minvalue: {script}" + ); +} + +/// When only non-start/minvalue parameters change (here: cycle) and the effective current +/// position is already within the new bounds, RESTART WITH must NOT be emitted. +#[tokio::test] +async fn compare_sequences_no_restart_when_only_other_params_change() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let from_sequence = Sequence::new( + "public".to_string(), + "live_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9999), + Some(1), + false, // cycle was false + Some(1), + Some(500_000), + None, + None, + None, + ); + let to_sequence = Sequence::new( + "public".to_string(), + "live_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), // start_value unchanged + Some(1), + Some(9999), + Some(1), + true, // only cycle changed + Some(1), + None, + None, + None, + None, + ); + + from_dump.sequences.push(from_sequence); + to_dump.sequences.push(to_sequence); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("restart with"), + "script must NOT contain RESTART WITH when start_value is unchanged: {script}" + ); + assert!( + script.contains("alter sequence public.live_seq"), + "script must still contain the ALTER SEQUENCE: {script}" + ); +} + +#[tokio::test] +async fn compare_sequences_skips_drop_if_owned_by_dropped_table() { + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + // Sequence owned by table column + let sequence = Sequence::new( + "public".to_string(), + "test_id_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(1000), + Some(1), + false, + Some(1), + Some(1), + Some("public".to_string()), + Some("test".to_string()), + Some("id".to_string()), + ); + from_dump.sequences.push(sequence); + + // Table that owns the sequence + let column = TableColumn { + catalog: "postgres".to_string(), + schema: "public".to_string(), + table: "test".to_string(), + name: "id".to_string(), + ordinal_position: 1, + column_default: None, + is_nullable: false, + data_type: "bigint".to_string(), + character_maximum_length: None, + character_octet_length: None, + numeric_precision: Some(64), + numeric_precision_radix: Some(2), + numeric_scale: Some(0), + datetime_precision: None, + interval_type: None, + interval_precision: None, + character_set_catalog: None, + character_set_schema: None, + character_set_name: None, + collation_catalog: None, + collation_schema: None, + collation_name: None, + domain_catalog: None, + domain_schema: None, + domain_name: None, + udt_catalog: None, + udt_schema: None, + udt_name: None, + scope_catalog: None, + scope_schema: None, + scope_name: None, + maximum_cardinality: None, + dtd_identifier: None, + is_self_referencing: false, + is_identity: true, + identity_generation: Some("ALWAYS".to_string()), + identity_start: Some("1".to_string()), + identity_increment: Some("1".to_string()), + identity_maximum: None, + identity_minimum: None, + identity_cycle: false, + is_generated: "NEVER".to_string(), + generation_expression: None, + generation_type: None, + is_updatable: true, + related_views: None, + comment: None, + storage: None, + compression: None, + statistics_target: None, + acl: vec![], + serial_type: None, + }; + + let table = Table::new( + "public".to_string(), + "test".to_string(), + "public".to_string(), + "test".to_string(), + "postgres".to_string(), + None, + vec![column], + vec![], + vec![], + vec![], + None, + ); + from_dump.tables.push(table); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains("Skipping drop of sequence public.test_id_seq as it is owned by table public.test which will be dropped.")); +} + +#[tokio::test] +async fn compare_sequences_skips_drop_if_owned_by_identity_column() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Sequence owned by table column + let sequence = Sequence::new( + "public".to_string(), + "test_id_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(1000), + Some(1), + false, + Some(1), + Some(1), + Some("public".to_string()), + Some("test".to_string()), + Some("id".to_string()), + ); + from_dump.sequences.push(sequence); + + // Table with identity column in FROM + let from_column = TableColumn { + catalog: "postgres".to_string(), + schema: "public".to_string(), + table: "test".to_string(), + name: "id".to_string(), + ordinal_position: 1, + column_default: None, + is_nullable: false, + data_type: "bigint".to_string(), + character_maximum_length: None, + character_octet_length: None, + numeric_precision: Some(64), + numeric_precision_radix: Some(2), + numeric_scale: Some(0), + datetime_precision: None, + interval_type: None, + interval_precision: None, + character_set_catalog: None, + character_set_schema: None, + character_set_name: None, + collation_catalog: None, + collation_schema: None, + collation_name: None, + domain_catalog: None, + domain_schema: None, + domain_name: None, + udt_catalog: None, + udt_schema: None, + udt_name: None, + scope_catalog: None, + scope_schema: None, + scope_name: None, + maximum_cardinality: None, + dtd_identifier: None, + is_self_referencing: false, + is_identity: true, + identity_generation: Some("ALWAYS".to_string()), + identity_start: Some("1".to_string()), + identity_increment: Some("1".to_string()), + identity_maximum: None, + identity_minimum: None, + identity_cycle: false, + is_generated: "NEVER".to_string(), + generation_expression: None, + generation_type: None, + is_updatable: true, + related_views: None, + comment: None, + storage: None, + compression: None, + statistics_target: None, + acl: vec![], + serial_type: None, + }; + + let from_table = Table::new( + "public".to_string(), + "test".to_string(), + "public".to_string(), + "test".to_string(), + "postgres".to_string(), + None, + vec![from_column], + vec![], + vec![], + vec![], + None, + ); + from_dump.tables.push(from_table); + + // Table in TO (exists, but maybe column changed or sequence changed) + // Even if column is same, if sequence is missing in TO (simulated here by not adding it to to_dump.sequences), + // we should skip drop if it's identity. + let to_column = TableColumn { + catalog: "postgres".to_string(), + schema: "public".to_string(), + table: "test".to_string(), + name: "id".to_string(), + ordinal_position: 1, + column_default: None, + is_nullable: false, + data_type: "bigint".to_string(), + character_maximum_length: None, + character_octet_length: None, + numeric_precision: Some(64), + numeric_precision_radix: Some(2), + numeric_scale: Some(0), + datetime_precision: None, + interval_type: None, + interval_precision: None, + character_set_catalog: None, + character_set_schema: None, + character_set_name: None, + collation_catalog: None, + collation_schema: None, + collation_name: None, + domain_catalog: None, + domain_schema: None, + domain_name: None, + udt_catalog: None, + udt_schema: None, + udt_name: None, + scope_catalog: None, + scope_schema: None, + scope_name: None, + maximum_cardinality: None, + dtd_identifier: None, + is_self_referencing: false, + is_identity: true, // Still identity + identity_generation: Some("ALWAYS".to_string()), + identity_start: Some("1".to_string()), + identity_increment: Some("1".to_string()), + identity_maximum: None, + identity_minimum: None, + identity_cycle: false, + is_generated: "NEVER".to_string(), + generation_expression: None, + generation_type: None, + is_updatable: true, + related_views: None, + comment: None, + storage: None, + compression: None, + statistics_target: None, + acl: vec![], + serial_type: None, + }; + + let to_table = Table::new( + "public".to_string(), + "test".to_string(), + "public".to_string(), + "test".to_string(), + "postgres".to_string(), + None, + vec![to_column], + vec![], + vec![], + vec![], + None, + ); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_sequences().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains("Skipping drop of sequence public.test_id_seq as it is owned by identity column public.test.id.")); +} diff --git a/app/src/comparer/tests/core/tables.rs b/app/src/comparer/tests/core/tables.rs new file mode 100644 index 0000000..a8f1e7b --- /dev/null +++ b/app/src/comparer/tests/core/tables.rs @@ -0,0 +1,1196 @@ +//! Tests for `compare_tables`: partition and inheritance ordering, serial +//! columns, and the pre-drop of foreign keys and triggers. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::schema::Schema; +use crate::dump::sequence::Sequence; +use crate::dump::table::Table; +use crate::dump::table_column::TableColumn; +use crate::dump::table_constraint::TableConstraint; +use crate::dump::table_trigger::TableTrigger; +use sqlx::postgres::types::Oid; + +#[tokio::test] +async fn tables_create_parent_before_partition_and_fk_after_tables() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Parent partitioned table + let mut parent = Table::new( + "public".to_string(), + "parent".to_string(), + "public".to_string(), + "parent".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "parent", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + parent.partition_key = Some("LIST (id)".to_string()); + parent.hash(); + + // Partition table + let mut part = Table::new( + "public".to_string(), + "child".to_string(), + "public".to_string(), + "child".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "child", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + part.partition_of = Some("public.parent".to_string()); + part.partition_bound = Some("FOR VALUES IN (1)".to_string()); + part.hash(); + + // Referencing table with FK to parent + let mut orders = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "postgres".to_string(), + None, + vec![ + int_column("public", "orders", "id", 1), + int_column("public", "orders", "parent_id", 2), + ], + vec![TableConstraint { + catalog: "postgres".to_string(), + schema: "public".to_string(), + name: "orders_parent_fk".to_string(), + table_name: "orders".to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("FOREIGN KEY (parent_id) REFERENCES public.parent(id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }], + vec![], + vec![], + None, + ); + orders.hash(); + + to_dump.tables.push(parent); + to_dump.tables.push(part); + to_dump.tables.push(orders); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let pos_parent = script + .find("create table public.parent") + .expect("parent table not created"); + let pos_child = script + .find("create table public.child partition of public.parent") + .expect("partition table not created"); + let pos_orders = script + .find("create table public.orders") + .expect("orders table not created"); + let pos_fk = script + .find("alter table public.orders add constraint orders_parent_fk") + .expect("fk not emitted"); + + assert!( + pos_parent < pos_child, + "parent should be created before partition" + ); + assert!( + pos_fk > pos_parent && pos_fk > pos_orders, + "foreign key should be created after tables" + ); +} + +#[tokio::test] +async fn compare_tables_emits_owner_change() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_table = Table::new( + "public".to_string(), + "users".to_string(), + "public".to_string(), + "users".to_string(), + "old_owner".to_string(), + None, + vec![int_column("public", "users", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + from_table.hash(); + + let mut to_table = Table::new( + "public".to_string(), + "users".to_string(), + "public".to_string(), + "users".to_string(), + "new_owner".to_string(), + None, + vec![int_column("public", "users", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + to_table.hash(); + + from_dump.tables.push(from_table); + to_dump.tables.push(to_table); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains("alter table public.users owner to new_owner;")); +} + +#[tokio::test] +async fn tables_multilevel_partitions_created_in_depth_order() { + // Hierarchy: grandparent (RANGE) -> parent_2023 (LIST, sub-partition) -> child_2023_a (leaf) + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Level 0: grandparent partitioned by RANGE + let mut grandparent = Table::new( + "public".to_string(), + "events".to_string(), + "public".to_string(), + "events".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "events", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + grandparent.partition_key = Some("RANGE (id)".to_string()); + grandparent.hash(); + + // Level 1: sub-partition parent (is both a partition child AND partitioned by LIST) + let mut sub_parent = Table::new( + "public".to_string(), + "events_2023".to_string(), + "public".to_string(), + "events_2023".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "events_2023", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + sub_parent.partition_of = Some("\"public\".\"events\"".to_string()); + sub_parent.partition_bound = Some("FOR VALUES FROM (2023) TO (2024)".to_string()); + sub_parent.partition_key = Some("LIST (id)".to_string()); + sub_parent.hash(); + + // Level 2: leaf partition + let mut leaf = Table::new( + "public".to_string(), + "events_2023_a".to_string(), + "public".to_string(), + "events_2023_a".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "events_2023_a", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + leaf.partition_of = Some("\"public\".\"events_2023\"".to_string()); + leaf.partition_bound = Some("FOR VALUES IN (1)".to_string()); + leaf.hash(); + + // Push in reverse order to stress the sorting + to_dump.tables.push(leaf); + to_dump.tables.push(grandparent); + to_dump.tables.push(sub_parent); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let pos_gp = script + .find("create table public.events") + .expect("grandparent not created"); + let pos_sp = script + .find("create table public.events_2023 partition of") + .expect("sub-partition parent not created"); + let pos_leaf = script + .find("create table public.events_2023_a partition of") + .expect("leaf partition not created"); + + assert!( + pos_gp < pos_sp, + "grandparent must be created before sub-partition parent" + ); + assert!( + pos_sp < pos_leaf, + "sub-partition parent must be created before leaf partition" + ); +} + +#[tokio::test] +async fn tables_multilevel_partitions_dropped_in_reverse_depth_order() { + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + let mut grandparent = Table::new( + "public".to_string(), + "events".to_string(), + "public".to_string(), + "events".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "events", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + grandparent.partition_key = Some("RANGE (id)".to_string()); + grandparent.hash(); + + let mut sub_parent = Table::new( + "public".to_string(), + "events_2023".to_string(), + "public".to_string(), + "events_2023".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "events_2023", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + sub_parent.partition_of = Some("public.events".to_string()); + sub_parent.partition_bound = Some("FOR VALUES FROM (2023) TO (2024)".to_string()); + sub_parent.partition_key = Some("LIST (id)".to_string()); + sub_parent.hash(); + + let mut leaf = Table::new( + "public".to_string(), + "events_2023_a".to_string(), + "public".to_string(), + "events_2023_a".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "events_2023_a", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + leaf.partition_of = Some("public.events_2023".to_string()); + leaf.partition_bound = Some("FOR VALUES IN (1)".to_string()); + leaf.hash(); + + from_dump.tables.push(grandparent); + from_dump.tables.push(sub_parent); + from_dump.tables.push(leaf); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let pos_gp = script + .find("drop table if exists public.events;") + .expect("grandparent drop not found"); + let pos_sp = script + .find("drop table if exists public.events_2023;") + .expect("sub-partition parent drop not found"); + let pos_leaf = script + .find("drop table if exists public.events_2023_a;") + .expect("leaf partition drop not found"); + + assert!( + pos_leaf < pos_sp, + "leaf must be dropped before sub-partition parent" + ); + assert!( + pos_sp < pos_gp, + "sub-partition parent must be dropped before grandparent" + ); +} + +#[tokio::test] +async fn serial_column_uses_serial_type_in_table_script() { + // When a serial/bigserial column's sequence is skipped, the table script + // should use serial/bigserial type instead of integer/bigint with nextval default. + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // serial column (integer + nextval) + let serial_seq = Sequence::new( + "test_schema".to_string(), + "test_serial_id_seq".to_string(), + "postgres".to_string(), + "integer".to_string(), + Some(1), + Some(1), + Some(2147483647), + Some(1), + false, + Some(1), + Some(1), + Some("test_schema".to_string()), + Some("test_serial".to_string()), + Some("id".to_string()), + ); + to_dump.sequences.push(serial_seq); + + let serial_col = TableColumn { + catalog: "postgres".to_string(), + schema: "test_schema".to_string(), + table: "test_serial".to_string(), + name: "id".to_string(), + ordinal_position: 1, + column_default: Some("nextval('test_schema.test_serial_id_seq'::regclass)".to_string()), + is_nullable: false, + data_type: "integer".to_string(), + character_maximum_length: None, + character_octet_length: None, + numeric_precision: Some(32), + numeric_precision_radix: Some(2), + numeric_scale: Some(0), + datetime_precision: None, + interval_type: None, + interval_precision: None, + character_set_catalog: None, + character_set_schema: None, + character_set_name: None, + collation_catalog: None, + collation_schema: None, + collation_name: None, + domain_catalog: None, + domain_schema: None, + domain_name: None, + udt_catalog: None, + udt_schema: None, + udt_name: None, + scope_catalog: None, + scope_schema: None, + scope_name: None, + maximum_cardinality: None, + dtd_identifier: None, + is_self_referencing: false, + is_identity: false, + identity_generation: None, + identity_start: None, + identity_increment: None, + identity_maximum: None, + identity_minimum: None, + identity_cycle: false, + is_generated: "NEVER".to_string(), + generation_expression: None, + generation_type: None, + is_updatable: true, + related_views: None, + comment: None, + storage: None, + compression: None, + statistics_target: None, + acl: vec![], + serial_type: None, + }; + let serial_table = Table::new( + "test_schema".to_string(), + "test_serial".to_string(), + "test_schema".to_string(), + "test_serial".to_string(), + "postgres".to_string(), + None, + vec![serial_col], + vec![], + vec![], + vec![], + None, + ); + to_dump.tables.push(serial_table); + + // bigserial column (bigint + nextval) + let bigserial_seq = Sequence::new( + "test_schema".to_string(), + "test_bigserial_id_seq".to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(9223372036854775807), + Some(1), + false, + Some(1), + Some(1), + Some("test_schema".to_string()), + Some("test_bigserial".to_string()), + Some("id".to_string()), + ); + to_dump.sequences.push(bigserial_seq); + + let bigserial_col = TableColumn { + catalog: "postgres".to_string(), + schema: "test_schema".to_string(), + table: "test_bigserial".to_string(), + name: "id".to_string(), + ordinal_position: 1, + column_default: Some("nextval('test_schema.test_bigserial_id_seq'::regclass)".to_string()), + is_nullable: false, + data_type: "bigint".to_string(), + character_maximum_length: None, + character_octet_length: None, + numeric_precision: Some(64), + numeric_precision_radix: Some(2), + numeric_scale: Some(0), + datetime_precision: None, + interval_type: None, + interval_precision: None, + character_set_catalog: None, + character_set_schema: None, + character_set_name: None, + collation_catalog: None, + collation_schema: None, + collation_name: None, + domain_catalog: None, + domain_schema: None, + domain_name: None, + udt_catalog: None, + udt_schema: None, + udt_name: None, + scope_catalog: None, + scope_schema: None, + scope_name: None, + maximum_cardinality: None, + dtd_identifier: None, + is_self_referencing: false, + is_identity: false, + identity_generation: None, + identity_start: None, + identity_increment: None, + identity_maximum: None, + identity_minimum: None, + identity_cycle: false, + is_generated: "NEVER".to_string(), + generation_expression: None, + generation_type: None, + is_updatable: true, + related_views: None, + comment: None, + storage: None, + compression: None, + statistics_target: None, + acl: vec![], + serial_type: None, + }; + let bigserial_table = Table::new( + "test_schema".to_string(), + "test_bigserial".to_string(), + "test_schema".to_string(), + "test_bigserial".to_string(), + "postgres".to_string(), + None, + vec![bigserial_col], + vec![], + vec![], + vec![], + None, + ); + to_dump.tables.push(bigserial_table); + + to_dump.schemas.push(crate::dump::schema::Schema::new( + "test_schema".to_string(), + "test_schema".to_string(), + None, + )); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + // Sequences should be skipped + assert!( + script.contains("Skipping sequence test_schema.test_serial_id_seq"), + "serial sequence should be skipped" + ); + assert!( + script.contains("Skipping sequence test_schema.test_bigserial_id_seq"), + "bigserial sequence should be skipped" + ); + assert!( + !script.contains("create sequence test_schema.test_serial_id_seq"), + "serial sequence should not be created separately" + ); + assert!( + !script.contains("create sequence test_schema.test_bigserial_id_seq"), + "bigserial sequence should not be created separately" + ); + + // Table columns should use serial/bigserial types + assert!( + script.contains("id serial"), + "serial column should use 'serial' type, got:\n{script}" + ); + assert!( + script.contains("id bigserial"), + "bigserial column should use 'bigserial' type, got:\n{script}" + ); + + // Should NOT contain nextval defaults for these columns + assert!( + !script.contains("nextval('test_schema.test_serial_id_seq'"), + "serial column should not have explicit nextval default" + ); + assert!( + !script.contains("nextval('test_schema.test_bigserial_id_seq'"), + "bigserial column should not have explicit nextval default" + ); +} + +/// Partition child must not be dropped+recreated when a non-partition-key +/// column changes type on the parent. +#[tokio::test] +async fn partition_child_non_pk_col_type_change_no_recreate() { + fn numeric_column( + schema: &str, + table: &str, + name: &str, + ordinal: i32, + precision: i32, + scale: i32, + ) -> TableColumn { + let mut col = int_column(schema, table, name, ordinal); + col.data_type = "numeric".to_string(); + col.numeric_precision = Some(precision); + col.numeric_scale = Some(scale); + col.numeric_precision_radix = Some(10); + col + } + fn date_column(schema: &str, table: &str, name: &str, ordinal: i32) -> TableColumn { + let mut col = int_column(schema, table, name, ordinal); + col.data_type = "date".to_string(); + col.numeric_precision = None; + col.numeric_precision_radix = None; + col.numeric_scale = None; + col + } + fn bigint_column(schema: &str, table: &str, name: &str, ordinal: i32) -> TableColumn { + let mut col = int_column(schema, table, name, ordinal); + col.data_type = "bigint".to_string(); + col.numeric_precision = Some(64); + col + } + + let tbl = "s6_issue2_expenses"; + let child_tbl = "s6_issue2_expenses_2024_01"; + let schema = "\"pt_test\""; + + // --- FROM dump --- + let mut from_parent = Table::new( + schema.to_string(), + tbl.to_string(), + "pt_test".to_string(), + tbl.to_string(), + "postgres".to_string(), + None, + vec![ + bigint_column(schema, tbl, "id", 1), + date_column(schema, tbl, "expense_date", 2), + numeric_column(schema, tbl, "amount", 3, 10, 2), + ], + vec![], + vec![], + vec![], + None, + ); + from_parent.partition_key = Some("RANGE (expense_date)".to_string()); + from_parent.hash(); + + let mut from_child = Table::new( + schema.to_string(), + child_tbl.to_string(), + "pt_test".to_string(), + child_tbl.to_string(), + "postgres".to_string(), + None, + vec![ + bigint_column(schema, child_tbl, "id", 1), + date_column(schema, child_tbl, "expense_date", 2), + numeric_column(schema, child_tbl, "amount", 3, 10, 2), + ], + vec![], + vec![], + vec![], + None, + ); + from_child.partition_of = Some(format!("{}.{}", schema, tbl)); + from_child.partition_bound = + Some("FOR VALUES FROM ('2024-01-01') TO ('2024-02-01')".to_string()); + from_child.hash(); + + // --- TO dump --- + let mut to_parent = Table::new( + schema.to_string(), + tbl.to_string(), + "pt_test".to_string(), + tbl.to_string(), + "postgres".to_string(), + None, + vec![ + bigint_column(schema, tbl, "id", 1), + date_column(schema, tbl, "expense_date", 2), + numeric_column(schema, tbl, "amount", 3, 15, 4), + ], + vec![], + vec![], + vec![], + None, + ); + to_parent.partition_key = Some("RANGE (expense_date)".to_string()); + to_parent.hash(); + + let mut to_child = Table::new( + schema.to_string(), + child_tbl.to_string(), + "pt_test".to_string(), + child_tbl.to_string(), + "postgres".to_string(), + None, + vec![ + bigint_column(schema, child_tbl, "id", 1), + date_column(schema, child_tbl, "expense_date", 2), + numeric_column(schema, child_tbl, "amount", 3, 15, 4), + ], + vec![], + vec![], + vec![], + None, + ); + to_child.partition_of = Some(format!("{}.{}", schema, tbl)); + to_child.partition_bound = Some("FOR VALUES FROM ('2024-01-01') TO ('2024-02-01')".to_string()); + to_child.hash(); + + let mut from_dump = Dump::new(DumpConfig::default()); + from_dump.tables.push(from_parent); + from_dump.tables.push(from_child); + + let mut to_dump = Dump::new(DumpConfig::default()); + to_dump.tables.push(to_parent); + to_dump.tables.push(to_child); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_tables().await.unwrap(); + let script = comparer.get_script(); + + // Parent should get ALTER COLUMN + assert!( + script.contains("alter column"), + "Parent must get ALTER COLUMN for amount, got: {script}" + ); + // Child should NOT be dropped + assert!( + !script.contains("drop table"), + "Partition child must not be dropped for non-partition-key column type change, got: {script}" + ); + // Child should NOT be recreated + assert!( + !script.to_lowercase().contains(&format!( + "create table {}.{} partition of", + schema, child_tbl + )), + "Partition child must not be recreated, got: {script}" + ); + assert!( + !script.contains("Data loss"), + "No data loss warning expected, got: {script}" + ); +} + +#[tokio::test] +async fn new_partition_children_deferred_until_parent_is_recreated() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // Root partitioned table (unchanged in both dumps) + let mut root = Table::new( + "data".to_string(), + "events".to_string(), + "data".to_string(), + "events".to_string(), + "postgres".to_string(), + None, + vec![int_column("data", "events", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + root.partition_key = Some("RANGE (id)".to_string()); + root.hash(); + + // FROM: events_2023 exists but is NOT sub-partitioned (no partition_key) + let mut from_events_2023 = Table::new( + "data".to_string(), + "events_2023".to_string(), + "data".to_string(), + "events_2023".to_string(), + "postgres".to_string(), + None, + vec![int_column("data", "events_2023", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + from_events_2023.partition_of = Some("\"data\".\"events\"".to_string()); + from_events_2023.partition_bound = Some("FOR VALUES FROM (2023) TO (2024)".to_string()); + from_events_2023.hash(); + + // TO: events_2023 now gains a partition_key (LIST region) + let mut to_events_2023 = Table::new( + "data".to_string(), + "events_2023".to_string(), + "data".to_string(), + "events_2023".to_string(), + "postgres".to_string(), + None, + vec![int_column("data", "events_2023", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + to_events_2023.partition_of = Some("\"data\".\"events\"".to_string()); + to_events_2023.partition_bound = Some("FOR VALUES FROM (2023) TO (2024)".to_string()); + to_events_2023.partition_key = Some("LIST (region)".to_string()); + to_events_2023.hash(); + + let mut leaf_eu = Table::new( + "data".to_string(), + "events_2023_eu".to_string(), + "data".to_string(), + "events_2023_eu".to_string(), + "postgres".to_string(), + None, + vec![int_column("data", "events_2023_eu", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + leaf_eu.partition_of = Some("\"data\".\"events_2023\"".to_string()); + leaf_eu.partition_bound = Some("FOR VALUES IN ('eu')".to_string()); + leaf_eu.hash(); + + let mut leaf_us = Table::new( + "data".to_string(), + "events_2023_us".to_string(), + "data".to_string(), + "events_2023_us".to_string(), + "postgres".to_string(), + None, + vec![int_column("data", "events_2023_us", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + leaf_us.partition_of = Some("\"data\".\"events_2023\"".to_string()); + leaf_us.partition_bound = Some("FOR VALUES IN ('us')".to_string()); + leaf_us.hash(); + + // FROM dump: root + old events_2023 (no sub-partition key) + from_dump.tables.push(root.clone()); + from_dump.tables.push(from_events_2023); + + // TO dump: root + new events_2023 (with sub-partition key) + two leaves + // Push in reverse depth order to stress the sorting + to_dump.tables.push(leaf_us); + to_dump.tables.push(leaf_eu); + to_dump.tables.push(to_events_2023); + to_dump.tables.push(root); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let pos_recreate = script + .find("create table data.events_2023 partition of") + .expect("events_2023 recreate not found"); + let pos_eu = script + .find("create table data.events_2023_eu partition of") + .expect("events_2023_eu create not found"); + let pos_us = script + .find("create table data.events_2023_us partition of") + .expect("events_2023_us create not found"); + + assert!( + pos_recreate < pos_eu, + "events_2023 must be recreated before events_2023_eu is created (got recreate={pos_recreate}, eu={pos_eu})" + ); + assert!( + pos_recreate < pos_us, + "events_2023 must be recreated before events_2023_us is created (got recreate={pos_recreate}, us={pos_us})" + ); + + let pos_drop = script + .find("drop table if exists data.events_2023") + .expect("events_2023 drop not found"); + assert!( + pos_drop < pos_eu, + "events_2023 must be dropped before events_2023_eu is created" + ); + assert!( + pos_drop < pos_us, + "events_2023 must be dropped before events_2023_us is created" + ); +} + +#[tokio::test] +async fn fk_pre_drop_commented_when_use_drop_false() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // "from" has a table referenced by FK + let mut referenced = Table::new( + "public".to_string(), + "users".to_string(), + "public".to_string(), + "users".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "users", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + referenced.hash(); + + // "from" has a table with FK referencing "users" + let mut referencing = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "postgres".to_string(), + None, + vec![ + int_column("public", "orders", "id", 1), + int_column("public", "orders", "user_id", 2), + ], + vec![TableConstraint { + catalog: "postgres".to_string(), + schema: "public".to_string(), + name: "orders_user_fk".to_string(), + table_name: "orders".to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("FOREIGN KEY (user_id) REFERENCES public.users(id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }], + vec![], + vec![], + None, + ); + referencing.hash(); + + from_dump.tables.push(referenced.clone()); + from_dump.tables.push(referencing.clone()); + + // "to" has only "orders" — "users" is being dropped, so its FK must be pre-dropped + to_dump.tables.push(referencing); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + // FK drop should be commented out because use_drop=false + let has_commented_fk_drop = script.lines().any(|l| { + l.starts_with("--") && l.contains("drop constraint") && l.contains("orders_user_fk") + }); + assert!( + has_commented_fk_drop, + "FK pre-drop should be commented out when use_drop=false, script:\n{}", + script + ); + + // Should NOT have an active (uncommented) drop constraint for the FK + let has_active_fk_drop = script.lines().any(|l| { + !l.starts_with("--") && l.contains("drop constraint") && l.contains("orders_user_fk") + }); + assert!( + !has_active_fk_drop, + "FK pre-drop should NOT be active when use_drop=false" + ); +} + +#[tokio::test] +async fn fk_pre_drop_active_when_use_drop_true() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut referenced = Table::new( + "public".to_string(), + "users".to_string(), + "public".to_string(), + "users".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "users", "id", 1)], + vec![], + vec![], + vec![], + None, + ); + referenced.hash(); + + let mut referencing = Table::new( + "public".to_string(), + "orders".to_string(), + "public".to_string(), + "orders".to_string(), + "postgres".to_string(), + None, + vec![ + int_column("public", "orders", "id", 1), + int_column("public", "orders", "user_id", 2), + ], + vec![TableConstraint { + catalog: "postgres".to_string(), + schema: "public".to_string(), + name: "orders_user_fk".to_string(), + table_name: "orders".to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some("FOREIGN KEY (user_id) REFERENCES public.users(id)".to_string()), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + }], + vec![], + vec![], + None, + ); + referencing.hash(); + + from_dump.tables.push(referenced.clone()); + from_dump.tables.push(referencing.clone()); + to_dump.tables.push(referencing); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let has_active_fk_drop = script.lines().any(|l| { + !l.starts_with("--") && l.contains("drop constraint") && l.contains("orders_user_fk") + }); + assert!( + has_active_fk_drop, + "FK pre-drop should be active when use_drop=true, script:\n{}", + script + ); +} + +#[tokio::test] +async fn trigger_pre_drop_commented_when_use_drop_false() { + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + // "from" has a table with a trigger; table is absent in "to" + let mut table_with_trigger = Table::new( + "public".to_string(), + "events".to_string(), + "public".to_string(), + "events".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "events", "id", 1)], + vec![], + vec![], + vec![TableTrigger { + oid: Oid(9999), + name: "trg_events_audit".to_string(), + definition: "before insert on events for each row execute function audit()".to_string(), + enabled: "O".to_string(), + comment: None, + }], + None, + ); + table_with_trigger.hash(); + + from_dump.tables.push(table_with_trigger); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + // Trigger drop should be commented out when use_drop=false + let has_commented_trigger_drop = script.lines().any(|l| { + l.starts_with("--") && l.contains("drop trigger") && l.contains("trg_events_audit") + }); + assert!( + has_commented_trigger_drop, + "Trigger pre-drop should be commented when use_drop=false, script:\n{}", + script + ); + + let has_active_trigger_drop = script.lines().any(|l| { + !l.starts_with("--") && l.contains("drop trigger") && l.contains("trg_events_audit") + }); + assert!( + !has_active_trigger_drop, + "Trigger pre-drop should NOT be active when use_drop=false" + ); +} + +#[tokio::test] +async fn trigger_pre_drop_active_when_use_drop_true() { + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + let mut table_with_trigger = Table::new( + "public".to_string(), + "events".to_string(), + "public".to_string(), + "events".to_string(), + "postgres".to_string(), + None, + vec![int_column("public", "events", "id", 1)], + vec![], + vec![], + vec![TableTrigger { + oid: Oid(9999), + name: "trg_events_audit".to_string(), + definition: "before insert on events for each row execute function audit()".to_string(), + enabled: "O".to_string(), + comment: None, + }], + None, + ); + table_with_trigger.hash(); + + from_dump.tables.push(table_with_trigger); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let has_active_trigger_drop = script.lines().any(|l| { + !l.starts_with("--") && l.contains("drop trigger") && l.contains("trg_events_audit") + }); + assert!( + has_active_trigger_drop, + "Trigger pre-drop should be active when use_drop=true, script:\n{}", + script + ); +} + +/// Regression for the inheritance_child idempotency bug — pgc was +/// dumping classical-inheritance children with `partition_of` wrongly +/// set (because `pg_inherits` records both partition and classical +/// inheritance), which made `column_type_change_forces_recreate` fire +/// and trigger a wholesale drop+recreate. After migration the +/// recreated child silently picked up the *current* default +/// privileges, leaving stray REVOKE statements in the next +/// `pgc compare` pass. +/// +/// This test pins down the post-fix invariant: a classical-inheritance +/// child (`partition_of = None`, `inherits_from = [parent]`) with a +/// column type change must NOT be flagged for wholesale recreate. +/// The dump-side fix that produces this shape (filtering +/// `pg_inherits` joins by `parent.relkind = 'p'`) lives in +/// `fetch_partition_info_bulk` and cannot be unit-tested without a +/// live PostgreSQL connection, but the comparer-side gate has its own +/// expectations and those are what this test enforces. +#[tokio::test] +async fn inheritance_child_classical_inheritance_does_not_force_recreate() { + let make_inheritance_child = |child_data_type: &str, max_len: Option| { + let mut child_col = int_column("test_deps", "inheritance_child", "child_data", 1); + child_col.data_type = child_data_type.to_string(); + child_col.character_maximum_length = max_len; + + let mut t = Table::new( + "test_deps".to_string(), + "inheritance_child".to_string(), + "test_deps".to_string(), + "inheritance_child".to_string(), + "postgres".to_string(), + None, + vec![child_col], + vec![], + vec![], + vec![], + None, + ); + // Classical inheritance: parent is a regular table; partition_of + // stays None, inherits_from carries the parent reference. With + // the pre-fix dump query, partition_of would have been + // erroneously set here too — that mis-shape is exactly what + // this test forbids. + t.inherits_from = vec!["test_deps.inheritance_parent".to_string()]; + t.hash(); + t + }; + + let from_table = make_inheritance_child("text", None); + let to_table = make_inheritance_child("character varying", Some(255)); + + // The comparer-side predicate must NOT classify this column change + // as a wholesale recreate. PostgreSQL accepts in-place + // `ALTER TABLE … ALTER COLUMN child_data TYPE varchar(255)` on a + // classical-inheritance child, and dropping the child wholesale + // would leak default-privilege grants onto the recreated table. + assert!( + !from_table.will_be_dropped_and_recreated(&to_table), + "classical-inheritance child with column type change must NOT \ + be flagged for wholesale recreate (partition_of: {:?}, \ + inherits_from: {:?})", + from_table.partition_of, + from_table.inherits_from, + ); + + // Sanity counter-test: same column change on a real partition + // child (partition_of = Some, inherits_from = []) SHOULD force + // wholesale recreate — PG forbids in-place type changes on + // partition-key columns and partition-inherited columns. + let mut from_partition_child = make_inheritance_child("text", None); + from_partition_child.inherits_from = Vec::new(); + from_partition_child.partition_of = Some("test_deps.parent_partitioned".to_string()); + let mut to_partition_child = make_inheritance_child("character varying", Some(255)); + to_partition_child.inherits_from = Vec::new(); + to_partition_child.partition_of = Some("test_deps.parent_partitioned".to_string()); + assert!( + from_partition_child.will_be_dropped_and_recreated(&to_partition_child), + "real partition child with column type change MUST be flagged \ + for wholesale recreate" + ); +} diff --git a/app/src/comparer/tests/core/types.rs b/app/src/comparer/tests/core/types.rs new file mode 100644 index 0000000..aaf0191 --- /dev/null +++ b/app/src/comparer/tests/core/types.rs @@ -0,0 +1,267 @@ +//! Tests for `compare_types` and `compare_enums`, including composite and +//! multirange types. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::pg_type::CompositeAttribute; +use crate::dump::pg_type::PgType; +use crate::dump::routine::Routine; +use sqlx::postgres::types::Oid; + +fn make_composite_type( + schema: &str, + name: &str, + oid: u32, + attributes: Vec<(&str, &str)>, +) -> PgType { + let mut composite_type = make_domain_type(schema, name, oid); + composite_type.typtype = 'c' as i8; + composite_type.typcategory = 'C' as i8; + composite_type.typinput = "record_in".to_string(); + composite_type.typoutput = "record_out".to_string(); + composite_type.typbasetype = None; + composite_type.formatted_basetype = None; + composite_type.domain_constraints.clear(); + composite_type.composite_attributes = attributes + .into_iter() + .map(|(attribute_name, data_type)| CompositeAttribute { + name: attribute_name.to_string(), + data_type: data_type.to_string(), + }) + .collect(); + composite_type.hash(); + composite_type +} + +#[tokio::test] +async fn compare_drops_types_after_routines() { + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + let dropped_type = make_domain_type("test_schema", "status_type", 501); + from_dump.types.push(dropped_type); + + let dropped_routine = Routine::new( + "test_schema".to_string(), + Oid(1), + "get_users_by_status".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "status test_schema.status_type".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + from_dump.routines.push(dropped_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let routine_drop_pos = script + .find("drop function if exists test_schema.get_users_by_status") + .expect("routine drop script not found"); + let type_drop_pos = script + .find("drop type if exists test_schema.status_type cascade;") + .expect("type drop script not found"); + + assert!( + routine_drop_pos < type_drop_pos, + "Type drops must be emitted after routine drops" + ); +} + +#[tokio::test] +async fn compare_drops_enums_after_routines() { + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + let dropped_enum = make_enum_type( + "test_schema", + "status_enum", + 502, + vec!["active", "inactive"], + ); + from_dump.types.push(dropped_enum); + + let dropped_routine = Routine::new( + "test_schema".to_string(), + Oid(2), + "get_users_by_status_enum".to_string(), + "plpgsql".to_string(), + "FUNCTION".to_string(), + "integer".to_string(), + "status test_schema.status_enum".to_string(), + None, + None, + "BEGIN RETURN 1; END".to_string(), + ); + from_dump.routines.push(dropped_routine); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + let routine_drop_pos = script + .find("drop function if exists test_schema.get_users_by_status_enum") + .expect("routine drop script not found"); + let enum_drop_pos = script + .find("drop type if exists test_schema.status_enum cascade;") + .expect("enum drop script not found"); + + assert!( + routine_drop_pos < enum_drop_pos, + "Enum drops must be emitted after routine drops" + ); +} + +#[tokio::test] +async fn compare_composite_types_drops_removed_and_creates_new() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump.types.push(make_composite_type( + "test_schema", + "test_type_A", + 601, + vec![ + ("first_name_2", "varchar(50)"), + ("last_name_2", "varchar(50)"), + ], + )); + to_dump.types.push(make_composite_type( + "test_schema", + "test_type_B", + 602, + vec![("street", "varchar(255)"), ("city", "varchar(100)")], + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains("create type test_schema.test_type_B as (")); + assert!(script.contains("\"street\" varchar(255)")); + assert!(script.contains("\"city\" varchar(100)")); + assert!(script.contains("drop type if exists test_schema.test_type_A cascade;")); +} + +/// Multirange types are auto-dropped when their associated range type is +/// dropped. The comparer must NOT emit a separate DROP for the multirange, +/// otherwise PostgreSQL rejects it ("cannot drop type … because type … +/// requires it"). +#[tokio::test] +async fn compare_types_multirange_not_dropped_independently() { + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + // Range type present only in FROM → will be dropped. + let mut range_type = make_domain_type("test_schema", "old_range", 600); + range_type.typtype = 'r' as i8; + range_type.range_subtype = Some("integer".to_string()); + + // Associated multirange type present only in FROM. + let mut mr_type = make_domain_type("test_schema", "old_multirange", 601); + mr_type.typtype = 'm' as i8; + + from_dump.types.push(range_type); + from_dump.types.push(mr_type); + + let mut comparer = Comparer::new(from_dump, to_dump, false, true, true, GrantsMode::Full); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("drop type if exists test_schema.old_range cascade;"), + "Range type must be dropped, got: {script}" + ); + let has_mr_drop = script.contains("drop type if exists test_schema.old_multirange"); + assert!( + !has_mr_drop, + "Multirange type must NOT be dropped independently, got: {script}" + ); +} + +/// Symmetric to the drop-side test above: multirange types are auto-CREATED +/// by PostgreSQL when the range type is created. The comparer must NOT emit +/// any per-multirange output in the main script — the `CREATE TYPE … AS +/// RANGE` for the range is enough. Previously the CREATE loop skipped only +/// enums, so a new range also produced a stray `-- Multirange …` comment +/// that made new-range diffs look noisy and was the leading explanation for +/// "the diff looks empty" reports on fresh schema_b dumps. +#[tokio::test] +async fn compare_types_multirange_not_created_independently() { + let from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + // New range type only in TO. + let mut range_type = make_domain_type("test_schema", "int_range", 800); + range_type.typtype = 'r' as i8; + range_type.range_subtype = Some("integer".to_string()); + range_type.hash(); + + // Its auto-generated multirange, also only in TO. + let mut mr_type = make_domain_type("test_schema", "int_range_multirange", 801); + mr_type.typtype = 'm' as i8; + mr_type.hash(); + + to_dump.types.push(range_type); + to_dump.types.push(mr_type); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("create type test_schema.int_range as range"), + "Range type must be created, got: {script}" + ); + let has_mr_comment = script + .contains("Multirange type test_schema.int_range_multirange is created automatically"); + assert!( + !has_mr_comment, + "Multirange must not emit a stand-alone comment block, got: {script}" + ); + let has_mr_create = script.contains("create type test_schema.int_range_multirange"); + assert!( + !has_mr_create, + "Multirange must not be CREATED independently, got: {script}" + ); +} + +/// A multirange that exists in BOTH dumps but whose owner or comment has +/// changed must still emit an ALTER (COMMENT ON TYPE / ALTER TYPE OWNER). +/// Regression guard against over-broad `'m'` skipping: the skip lives in the +/// new-in-`to` branch only, so metadata drift on existing multiranges still +/// propagates via `get_alter_script`'s comment/owner diff tail. +#[tokio::test] +async fn compare_types_multirange_comment_change_still_emits_alter() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_mr = make_domain_type("test_schema", "my_range_multirange", 900); + from_mr.typtype = 'm' as i8; + from_mr.comment = None; + from_mr.hash(); + + let mut to_mr = make_domain_type("test_schema", "my_range_multirange", 900); + to_mr.typtype = 'm' as i8; + to_mr.comment = Some("updated description".to_string()); + to_mr.hash(); + + from_dump.types.push(from_mr); + to_dump.types.push(to_mr); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script + .contains("comment on type test_schema.my_range_multirange is 'updated description';"), + "Metadata ALTER on existing multirange must still emit, got: {script}" + ); +} diff --git a/app/src/comparer/tests/core/views.rs b/app/src/comparer/tests/core/views.rs new file mode 100644 index 0000000..fc33dce --- /dev/null +++ b/app/src/comparer/tests/core/views.rs @@ -0,0 +1,749 @@ +//! Tests for view handling: drop/create ordering, regular ↔ materialized +//! kind transitions, the `CREATE OR REPLACE` compatibility rules from +//! issue #227, and the byte-identical recreation of issue #189. + +use crate::comparer::core::*; +use super::helpers::*; +use crate::config::dump_config::DumpConfig; +use crate::config::grants_mode::GrantsMode; +use crate::dump::view::View; + +#[tokio::test] +async fn create_views_emits_owner_change_for_existing_view() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_view = View::new( + "active_users".to_string(), + "select id from public.users".to_string(), + "public".to_string(), + vec!["public.users".to_string()], + ); + from_view.owner = "old_owner".to_string(); + from_view.hash(); + + let mut to_view = View::new( + "active_users".to_string(), + "select id from public.users".to_string(), + "public".to_string(), + vec!["public.users".to_string()], + ); + to_view.owner = "new_owner".to_string(); + to_view.hash(); + + from_dump.views.push(from_view); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.create_views().await.unwrap(); + let script = comparer.get_script(); + + assert!(script.contains("alter view public.active_users owner to new_owner;")); +} + +#[tokio::test] +async fn kind_transition_regular_to_materialized_use_drop_true() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + vec![], + ); + from_view.is_materialized = false; + from_view.hash(); + from_dump.views.push(from_view); + + let mut to_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + vec![], + ); + to_view.is_materialized = true; + to_view.hash(); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + comparer.create_views().await.unwrap(); + let script = comparer.get_script(); + + // DROP VIEW (regular) should be active + let has_active_drop = script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")); + assert!( + has_active_drop, + "Regular→mat: DROP VIEW should be active when use_drop=true, script:\n{}", + script + ); + // CREATE MATERIALIZED VIEW should be active + let has_active_create = script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("create materialized view")); + assert!( + has_active_create, + "Regular→mat: CREATE MATERIALIZED VIEW should be active when use_drop=true, script:\n{}", + script + ); +} + +#[tokio::test] +async fn kind_transition_regular_to_materialized_use_drop_false() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + vec![], + ); + from_view.is_materialized = false; + from_view.hash(); + from_dump.views.push(from_view); + + let mut to_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + vec![], + ); + to_view.is_materialized = true; + to_view.hash(); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + comparer.create_views().await.unwrap(); + let script = comparer.get_script(); + + // DROP should be commented + let has_active_drop = script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")); + assert!( + !has_active_drop, + "Regular→mat: DROP VIEW should be commented when use_drop=false, script:\n{}", + script + ); + // CREATE should be commented (manual intervention needed) + let has_active_create = script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("create materialized view")); + assert!( + !has_active_create, + "Regular→mat: CREATE MATERIALIZED VIEW should be commented when use_drop=false, script:\n{}", + script + ); + assert!( + script.contains("manual intervention needed"), + "Should contain manual intervention warning, script:\n{}", + script + ); +} + +#[tokio::test] +async fn kind_transition_materialized_to_regular_use_drop_true() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + vec![], + ); + from_view.is_materialized = true; + from_view.hash(); + from_dump.views.push(from_view); + + let mut to_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + vec![], + ); + to_view.is_materialized = false; + to_view.hash(); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + comparer.create_views().await.unwrap(); + let script = comparer.get_script(); + + // DROP MATERIALIZED VIEW should be active + let has_active_drop = script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop materialized view")); + assert!( + has_active_drop, + "Mat→regular: DROP MATERIALIZED VIEW should be active when use_drop=true, script:\n{}", + script + ); + // CREATE OR REPLACE VIEW should be active + let has_active_create = script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("create or replace view")); + assert!( + has_active_create, + "Mat→regular: CREATE OR REPLACE VIEW should be active when use_drop=true, script:\n{}", + script + ); +} + +#[tokio::test] +async fn kind_transition_materialized_to_regular_use_drop_false() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let mut from_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + vec![], + ); + from_view.is_materialized = true; + from_view.hash(); + from_dump.views.push(from_view); + + let mut to_view = View::new( + "my_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + vec![], + ); + to_view.is_materialized = false; + to_view.hash(); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + comparer.create_views().await.unwrap(); + let script = comparer.get_script(); + + // DROP should be commented + let has_active_drop = script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop materialized view")); + assert!( + !has_active_drop, + "Mat→regular: DROP MATERIALIZED VIEW should be commented when use_drop=false, script:\n{}", + script + ); + // CREATE OR REPLACE VIEW should also be commented (kind transition) + let has_active_create = script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("create or replace view")); + assert!( + !has_active_create, + "Mat→regular: CREATE OR REPLACE VIEW should be commented when use_drop=false, script:\n{}", + script + ); + assert!( + script.contains("manual intervention needed"), + "Should contain manual intervention warning, script:\n{}", + script + ); +} + +/// FROM-only views (present in FROM, absent in TO) must still appear in the output +/// when use_drop=false — as a commented-out DROP statement so the user is aware the +/// view should be removed. Previously `should_drop` gated `is_from_only` behind +/// `self.use_drop`, which suppressed the DROP entirely. +#[tokio::test] +async fn from_only_view_commented_drop_when_use_drop_false() { + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + let mut view = View::new( + "obsolete_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + vec![], + ); + view.is_materialized = false; + view.hash(); + from_dump.views.push(view); + + let mut comparer = Comparer::new(from_dump, to_dump, false, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + let script = comparer.get_script(); + + // The DROP must appear in the output … + assert!( + script.to_lowercase().contains("drop view"), + "FROM-only view must produce a DROP statement even with use_drop=false, script:\n{}", + script + ); + // … but it must be commented out, not active SQL. + let has_active_drop = script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")); + assert!( + !has_active_drop, + "FROM-only view DROP should be commented when use_drop=false, script:\n{}", + script + ); +} + +/// Counterpart: with use_drop=true the DROP for a FROM-only view must be active SQL. +#[tokio::test] +async fn from_only_view_active_drop_when_use_drop_true() { + let mut from_dump = Dump::new(DumpConfig::default()); + let to_dump = Dump::new(DumpConfig::default()); + + let mut view = View::new( + "obsolete_view".to_string(), + "SELECT 1".to_string(), + "public".to_string(), + vec![], + ); + view.is_materialized = false; + view.hash(); + from_dump.views.push(view); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + let script = comparer.get_script(); + + let has_active_drop = script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")); + assert!( + has_active_drop, + "FROM-only view DROP should be active SQL when use_drop=true, script:\n{}", + script + ); +} + +#[tokio::test] +async fn issue189_signature_change_recreates_byte_identical_view() { + // The view's hash is unchanged between FROM and TO, but the function + // it references undergoes a signature change (integer → bigint), + // forcing DROP FUNCTION ... CASCADE. PostgreSQL silently drops the + // view as part of the cascade. Phase 7 must re-emit the view so the + // migration leaves the database in a consistent state. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + from_dump.views.push(issue189_view("v_things", false)); + to_dump.views.push(issue189_view("v_things", false)); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + let drop_pos = script + .find("drop function if exists test_deps.compute (x integer) cascade;") + .expect("CASCADE drop must be emitted for the signature change"); + let create_fn_pos = script + .find("create or replace function test_deps.compute(x integer) returns bigint") + .expect("function recreate must be emitted"); + assert!(drop_pos < create_fn_pos); + + let view_pos = script + .find("CREATE OR REPLACE VIEW test_deps.v_things") + .expect("byte-identical view must be re-emitted as CREATE OR REPLACE VIEW after CASCADE"); + assert!( + create_fn_pos < view_pos, + "view recreate must run after the function recreate so the new signature is in place" + ); +} + +#[tokio::test] +async fn issue189_signature_change_recreates_byte_identical_materialized_view() { + // Same scenario as the regular-view case but with a materialized + // view. PostgreSQL CASCADE drops these via `pg_depend` the same way, + // so Phase 7 must emit a `create materialized view if not exists`. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + from_dump.views.push(issue189_view("mv_things", true)); + to_dump.views.push(issue189_view("mv_things", true)); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("create materialized view if not exists test_deps.mv_things"), + "materialized view must be re-emitted with IF NOT EXISTS: {}", + script + ); +} + +#[tokio::test] +async fn issue189_view_not_referencing_routine_is_not_recreated() { + // A view that doesn't textually reference the CASCADE-affected + // routine must be left alone — re-emitting it would clutter the + // migration and could re-introduce a stale definition if the user + // has the same view in both dumps for unrelated reasons. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + let mut unrelated_from = View::new( + "v_other".to_string(), + " SELECT value FROM test_deps.items;".to_string(), + "test_deps".to_string(), + vec!["test_deps.items".to_string()], + ); + unrelated_from.hash(); + let mut unrelated_to = View::new( + "v_other".to_string(), + " SELECT value FROM test_deps.items;".to_string(), + "test_deps".to_string(), + vec!["test_deps.items".to_string()], + ); + unrelated_to.hash(); + from_dump.views.push(unrelated_from); + to_dump.views.push(unrelated_to); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("v_other"), + "view that doesn't reference the cascaded routine must not be re-emitted: {}", + script + ); +} + +#[tokio::test] +async fn issue189_view_recreate_skipped_when_to_definition_no_longer_references_routine() { + // TO-side gate (PR #186): if the TO view's definition was rewritten + // to no longer call the affected routine, the CASCADE drop never + // touches it (no pg_depend link). `compare_routines_and_views` + // already emits the rewrite via the normal hash-diff path; Phase 7 + // must stay silent so we don't re-emit the view twice. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + from_dump.views.push(issue189_view("v_things", false)); + // TO view: same name, but the definition no longer references the + // function — different hash, so Phase 5 handles it. + let mut to_view = View::new( + "v_things".to_string(), + " SELECT value AS c FROM test_deps.items;".to_string(), + "test_deps".to_string(), + vec!["test_deps.items".to_string()], + ); + to_view.hash(); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + // The view must appear exactly once (the normal hash-diff path), not + // a second time from Phase 7's recreate block. + let recreate_section_start = script.find("Recreate dependents dropped by CASCADE: Start"); + if let Some(start) = recreate_section_start { + let recreate_end = script[start..] + .find("Recreate dependents dropped by CASCADE: End") + .map(|e| start + e) + .unwrap_or(script.len()); + let recreate_section = &script[start..recreate_end]; + assert!( + !recreate_section.contains("v_things"), + "Phase 7 must not re-emit a view whose TO definition no longer references the routine: {}", + recreate_section + ); + } +} + +#[test] +fn issue189_rewrite_create_view_anchored_to_prefix() { + // PR #195 review (Copilot): the helper must not be fooled by the + // literal text `CREATE OR REPLACE VIEW` appearing inside the view + // definition body that `View::get_script` appends after the + // `create view` prefix. A whole-script `contains` early-return + // would skip the rewrite and leave the leading `create view` + // unchanged, which is not idempotent against a surviving view. + let script = "create view public.v_with_literal as\n\ + SELECT 'CREATE OR REPLACE VIEW pretend.v AS SELECT 1' AS payload;\n\n"; + let rewritten = rewrite_create_view_to_create_or_replace(script); + assert!( + rewritten.starts_with("CREATE OR REPLACE VIEW public.v_with_literal as\n"), + "leading `create view` must be rewritten even when the body \ + contains the same phrase as a string literal: {}", + rewritten + ); + // Already in the desired form — return unchanged (no double rewrite). + let already = "CREATE OR REPLACE VIEW public.v as\nSELECT 1;\n"; + assert_eq!(rewrite_create_view_to_create_or_replace(already), already); +} + +#[tokio::test] +async fn issue189_view_definition_with_create_or_replace_literal_is_recreated() { + // End-to-end pin for the PR #195 reviewer concern: a view whose + // definition embeds the literal text `CREATE OR REPLACE VIEW` must + // still emit a properly idempotent `CREATE OR REPLACE VIEW` prefix + // when Phase 7 re-emits it after a CASCADE drop. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump + .routines + .push(issue179_compute_routine("integer", "SELECT x * 2;")); + to_dump.routines.push(issue179_compute_routine( + "bigint", + "SELECT (x * 2)::bigint;", + )); + + let definition = " SELECT test_deps.compute(value) AS c,\n \ + 'CREATE OR REPLACE VIEW evil.v AS SELECT 1' AS payload\n \ + FROM test_deps.items;"; + let mut from_view = View::new( + "v_things".to_string(), + definition.to_string(), + "test_deps".to_string(), + vec!["test_deps.items".to_string()], + ); + from_view.hash(); + let mut to_view = View::new( + "v_things".to_string(), + definition.to_string(), + "test_deps".to_string(), + vec!["test_deps.items".to_string()], + ); + to_view.hash(); + from_dump.views.push(from_view); + to_dump.views.push(to_view); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + script.contains("CREATE OR REPLACE VIEW test_deps.v_things"), + "view recreate must emit `CREATE OR REPLACE VIEW` at the leading \ + statement even when the body contains the same phrase: {}", + script + ); + // The body's literal must survive unchanged — we do NOT want a + // rewrite that mangles a non-prefix occurrence. + assert!( + script.contains("'CREATE OR REPLACE VIEW evil.v AS SELECT 1'"), + "literal inside the view body must be left intact: {}", + script + ); +} + +#[tokio::test] +async fn issue189_view_recreate_skipped_when_routine_unchanged() { + // No CASCADE — no recreate. Mirrors + // `issue179_recreate_skipped_when_routine_unchanged` for views. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + let routine = issue179_compute_routine("integer", "SELECT x * 2;"); + from_dump.routines.push(routine.clone()); + to_dump.routines.push(routine); + + from_dump.views.push(issue189_view("v_things", false)); + to_dump.views.push(issue189_view("v_things", false)); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.compare_routines_and_views().await.unwrap(); + let script = comparer.get_script(); + + assert!( + !script.contains("Recreate dependents dropped by CASCADE"), + "no CASCADE drop happened — recreate phase must stay silent: {}", + script + ); + assert!( + !script.contains("CREATE OR REPLACE VIEW test_deps.v_things"), + "view must not be re-emitted when the function is unchanged: {}", + script + ); +} + +fn view_227(name: &str, definition: &str, cols: &[(&str, &str)]) -> View { + let mut v = View::new( + name.to_string(), + definition.to_string(), + "public".to_string(), + vec![], + ); + v.columns = cols + .iter() + .map(|(n, t)| crate::dump::view::ViewColumn { + name: n.to_string(), + data_type: t.to_string(), + collation: None, + }) + .collect(); + v.hash(); + v +} + +#[tokio::test] +async fn incompatible_view_column_change_drops_and_recreates() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump.views.push(view_227( + "item_v", + "select id, profile_id from public.item", + &[("id", "integer"), ("profile_id", "integer")], + )); + to_dump.views.push(view_227( + "item_v", + "select id, kind, profile_id from public.item", + &[ + ("id", "integer"), + ("kind", "text"), + ("profile_id", "integer"), + ], + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + let script = comparer.get_script(); + let has_active_drop = script + .lines() + .any(|l| !l.starts_with("--") && l.contains("drop view if exists public.item_v")); + assert!( + has_active_drop, + "incompatible column change must emit an active DROP VIEW:\n{script}" + ); +} + +#[tokio::test] +async fn compatible_view_column_append_keeps_or_replace_without_drop() { + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump.views.push(view_227( + "item_v", + "select id from public.item", + &[("id", "integer")], + )); + to_dump.views.push(view_227( + "item_v", + "select id, kind from public.item", + &[("id", "integer"), ("kind", "text")], + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + let script = comparer.get_script(); + assert!( + !script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")), + "appending a column at the end must not drop the view:\n{script}" + ); +} + +#[tokio::test] +async fn changed_views_without_column_data_keep_or_replace() { + // Dumps written by an older pgc carry no column data; the historical + // CREATE OR REPLACE behavior must be preserved for them. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + from_dump.views.push(view_227( + "item_v", + "select id, profile_id from public.item", + &[], + )); + to_dump.views.push(view_227( + "item_v", + "select id, kind, profile_id from public.item", + &[], + )); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + let script = comparer.get_script(); + assert!( + !script + .lines() + .any(|l| !l.starts_with("--") && l.to_lowercase().contains("drop view")), + "without column data the view must not be dropped:\n{script}" + ); +} + +#[tokio::test] +async fn dependent_view_is_dropped_before_incompatible_base_view() { + // v2 reads item_v and is unchanged; item_v changes incompatibly. DROP VIEW runs + // without CASCADE, so v2 must be pulled into the drop set and dropped first. + let mut from_dump = Dump::new(DumpConfig::default()); + let mut to_dump = Dump::new(DumpConfig::default()); + + from_dump.views.push(view_227( + "item_v", + "select id, profile_id from public.item", + &[("id", "integer"), ("profile_id", "integer")], + )); + let mut from_dep = view_227("v2", "select id from public.item_v", &[("id", "integer")]); + from_dep.table_relation = vec!["public.item_v".to_string()]; + from_dep.hash(); + from_dump.views.push(from_dep); + + to_dump.views.push(view_227( + "item_v", + "select id, kind, profile_id from public.item", + &[ + ("id", "integer"), + ("kind", "text"), + ("profile_id", "integer"), + ], + )); + let mut to_dep = view_227("v2", "select id from public.item_v", &[("id", "integer")]); + to_dep.table_relation = vec!["public.item_v".to_string()]; + to_dep.hash(); + to_dump.views.push(to_dep); + + let mut comparer = Comparer::new(from_dump, to_dump, true, false, true, GrantsMode::Ignore); + comparer.drop_views().await.unwrap(); + let script = comparer.get_script(); + + let drop_dep = script + .find("drop view if exists public.v2;") + .expect("dependent view must be dropped too"); + let drop_base = script + .find("drop view if exists public.item_v;") + .expect("base view must be dropped"); + assert!( + drop_dep < drop_base, + "dependent must drop before the view it reads:\n{script}" + ); +} diff --git a/app/src/config/core.rs b/app/src/config/core.rs index c58f86d..00fe398 100644 --- a/app/src/config/core.rs +++ b/app/src/config/core.rs @@ -241,5 +241,5 @@ impl Config { } #[cfg(test)] -#[path = "core_tests.rs"] +#[path = "tests/core.rs"] mod tests; diff --git a/app/src/config/dump_config.rs b/app/src/config/dump_config.rs index 92e9bfc..033ac34 100644 --- a/app/src/config/dump_config.rs +++ b/app/src/config/dump_config.rs @@ -62,5 +62,5 @@ impl Default for DumpConfig { } #[cfg(test)] -#[path = "dump_config_tests.rs"] +#[path = "tests/dump_config.rs"] mod tests; diff --git a/app/src/config/grants_mode.rs b/app/src/config/grants_mode.rs index 043dbd5..67c6293 100644 --- a/app/src/config/grants_mode.rs +++ b/app/src/config/grants_mode.rs @@ -41,5 +41,5 @@ impl FromStr for GrantsMode { } #[cfg(test)] -#[path = "grants_mode_tests.rs"] +#[path = "tests/grants_mode.rs"] mod tests; diff --git a/app/src/dump/acl.rs b/app/src/dump/acl.rs index 216725f..6277cf6 100644 --- a/app/src/dump/acl.rs +++ b/app/src/dump/acl.rs @@ -544,5 +544,5 @@ pub fn generate_column_grants_script( } #[cfg(test)] -#[path = "acl_tests.rs"] +#[path = "tests/acl.rs"] mod tests; diff --git a/app/src/dump/cast.rs b/app/src/dump/cast.rs index aa21ee8..3c54bba 100644 --- a/app/src/dump/cast.rs +++ b/app/src/dump/cast.rs @@ -137,5 +137,5 @@ impl Cast { } #[cfg(test)] -#[path = "cast_tests.rs"] +#[path = "tests/cast.rs"] mod tests; diff --git a/app/src/dump/collation.rs b/app/src/dump/collation.rs index 8670ce9..7e53664 100644 --- a/app/src/dump/collation.rs +++ b/app/src/dump/collation.rs @@ -188,5 +188,5 @@ impl Collation { } #[cfg(test)] -#[path = "collation_tests.rs"] +#[path = "tests/collation.rs"] mod tests; diff --git a/app/src/dump/column_dependent.rs b/app/src/dump/column_dependent.rs index ab6b70b..8e7cf92 100644 --- a/app/src/dump/column_dependent.rs +++ b/app/src/dump/column_dependent.rs @@ -39,5 +39,5 @@ pub struct ColumnDependent { } #[cfg(test)] -#[path = "column_dependent_tests.rs"] +#[path = "tests/column_dependent.rs"] mod tests; diff --git a/app/src/dump/core.rs b/app/src/dump/core.rs index b78476e..d44ad76 100644 --- a/app/src/dump/core.rs +++ b/app/src/dump/core.rs @@ -3791,5 +3791,5 @@ impl Dump { } #[cfg(test)] -#[path = "core_tests.rs"] +#[path = "tests/core.rs"] mod tests; diff --git a/app/src/dump/default_privilege.rs b/app/src/dump/default_privilege.rs index 50efece..8904570 100644 --- a/app/src/dump/default_privilege.rs +++ b/app/src/dump/default_privilege.rs @@ -166,5 +166,5 @@ fn expand_privilege_chars(chars: &str, object_type: &str) -> Vec { } #[cfg(test)] -#[path = "default_privilege_tests.rs"] +#[path = "tests/default_privilege.rs"] mod tests; diff --git a/app/src/dump/event_trigger.rs b/app/src/dump/event_trigger.rs index 088b175..8de4201 100644 --- a/app/src/dump/event_trigger.rs +++ b/app/src/dump/event_trigger.rs @@ -188,5 +188,5 @@ impl EventTrigger { } #[cfg(test)] -#[path = "event_trigger_tests.rs"] +#[path = "tests/event_trigger.rs"] mod tests; diff --git a/app/src/dump/extension.rs b/app/src/dump/extension.rs index 26134f5..f690f00 100644 --- a/app/src/dump/extension.rs +++ b/app/src/dump/extension.rs @@ -72,5 +72,5 @@ impl Extension { } #[cfg(test)] -#[path = "extension_tests.rs"] +#[path = "tests/extension.rs"] mod tests; diff --git a/app/src/dump/fdw.rs b/app/src/dump/fdw.rs index 0bda788..e15a287 100644 --- a/app/src/dump/fdw.rs +++ b/app/src/dump/fdw.rs @@ -438,5 +438,5 @@ impl UserMapping { } #[cfg(test)] -#[path = "fdw_tests.rs"] +#[path = "tests/fdw.rs"] mod tests; diff --git a/app/src/dump/foreign_table.rs b/app/src/dump/foreign_table.rs index 20f7af2..0f4928d 100644 --- a/app/src/dump/foreign_table.rs +++ b/app/src/dump/foreign_table.rs @@ -355,5 +355,5 @@ impl ForeignTable { } #[cfg(test)] -#[path = "foreign_table_tests.rs"] +#[path = "tests/foreign_table.rs"] mod tests; diff --git a/app/src/dump/operator.rs b/app/src/dump/operator.rs index b9145bb..43ad3be 100644 --- a/app/src/dump/operator.rs +++ b/app/src/dump/operator.rs @@ -218,5 +218,5 @@ impl Operator { } #[cfg(test)] -#[path = "operator_tests.rs"] +#[path = "tests/operator.rs"] mod tests; diff --git a/app/src/dump/pg_enum.rs b/app/src/dump/pg_enum.rs index 127c5a4..9cba23a 100644 --- a/app/src/dump/pg_enum.rs +++ b/app/src/dump/pg_enum.rs @@ -11,5 +11,5 @@ pub struct PgEnum { } #[cfg(test)] -#[path = "pg_enum_tests.rs"] +#[path = "tests/pg_enum.rs"] mod tests; diff --git a/app/src/dump/pg_type.rs b/app/src/dump/pg_type.rs index 8d16c9b..f42aadb 100644 --- a/app/src/dump/pg_type.rs +++ b/app/src/dump/pg_type.rs @@ -809,5 +809,5 @@ where } #[cfg(test)] -#[path = "pg_type_tests.rs"] +#[path = "tests/pg_type.rs"] mod tests; diff --git a/app/src/dump/publication.rs b/app/src/dump/publication.rs index 11300e5..4b1a226 100644 --- a/app/src/dump/publication.rs +++ b/app/src/dump/publication.rs @@ -270,5 +270,5 @@ impl Subscription { } #[cfg(test)] -#[path = "publication_tests.rs"] +#[path = "tests/publication.rs"] mod tests; diff --git a/app/src/dump/routine.rs b/app/src/dump/routine.rs index 916f897..df0f1ec 100644 --- a/app/src/dump/routine.rs +++ b/app/src/dump/routine.rs @@ -819,5 +819,5 @@ impl Routine { } #[cfg(test)] -#[path = "routine_tests.rs"] +#[path = "tests/routine.rs"] mod tests; diff --git a/app/src/dump/rule.rs b/app/src/dump/rule.rs index 65784d1..6e94653 100644 --- a/app/src/dump/rule.rs +++ b/app/src/dump/rule.rs @@ -104,5 +104,5 @@ impl Rule { } #[cfg(test)] -#[path = "rule_tests.rs"] +#[path = "tests/rule.rs"] mod tests; diff --git a/app/src/dump/schema.rs b/app/src/dump/schema.rs index a469033..ece16e8 100644 --- a/app/src/dump/schema.rs +++ b/app/src/dump/schema.rs @@ -110,5 +110,5 @@ impl Schema { } #[cfg(test)] -#[path = "schema_tests.rs"] +#[path = "tests/schema.rs"] mod tests; diff --git a/app/src/dump/sequence.rs b/app/src/dump/sequence.rs index 1ba3c66..181b412 100644 --- a/app/src/dump/sequence.rs +++ b/app/src/dump/sequence.rs @@ -367,5 +367,5 @@ impl Sequence { } #[cfg(test)] -#[path = "sequence_tests.rs"] +#[path = "tests/sequence.rs"] mod tests; diff --git a/app/src/dump/statistic.rs b/app/src/dump/statistic.rs index 530dc35..38e5dd5 100644 --- a/app/src/dump/statistic.rs +++ b/app/src/dump/statistic.rs @@ -238,5 +238,5 @@ impl Statistic { } #[cfg(test)] -#[path = "statistic_tests.rs"] +#[path = "tests/statistic.rs"] mod tests; diff --git a/app/src/dump/table.rs b/app/src/dump/table.rs index 646d63e..8e1ce57 100644 --- a/app/src/dump/table.rs +++ b/app/src/dump/table.rs @@ -2412,5 +2412,5 @@ impl Table { } #[cfg(test)] -#[path = "table_tests.rs"] +#[path = "tests/table.rs"] mod tests; diff --git a/app/src/dump/table_column.rs b/app/src/dump/table_column.rs index febb833..7c09879 100644 --- a/app/src/dump/table_column.rs +++ b/app/src/dump/table_column.rs @@ -926,5 +926,5 @@ impl PartialEq for TableColumn { } #[cfg(test)] -#[path = "table_column_tests.rs"] +#[path = "tests/table_column.rs"] mod tests; diff --git a/app/src/dump/table_constraint.rs b/app/src/dump/table_constraint.rs index 78a67b9..0e575e7 100644 --- a/app/src/dump/table_constraint.rs +++ b/app/src/dump/table_constraint.rs @@ -602,5 +602,5 @@ impl PartialEq for TableConstraint { } #[cfg(test)] -#[path = "table_constraint_tests.rs"] +#[path = "tests/table_constraint.rs"] mod tests; diff --git a/app/src/dump/table_index.rs b/app/src/dump/table_index.rs index 008c0b6..6d8d663 100644 --- a/app/src/dump/table_index.rs +++ b/app/src/dump/table_index.rs @@ -99,5 +99,5 @@ impl PartialEq for TableIndex { } #[cfg(test)] -#[path = "table_index_tests.rs"] +#[path = "tests/table_index.rs"] mod tests; diff --git a/app/src/dump/table_policy.rs b/app/src/dump/table_policy.rs index a93160b..eb6b6cd 100644 --- a/app/src/dump/table_policy.rs +++ b/app/src/dump/table_policy.rs @@ -144,5 +144,5 @@ impl PartialEq for TablePolicy { } #[cfg(test)] -#[path = "table_policy_tests.rs"] +#[path = "tests/table_policy.rs"] mod tests; diff --git a/app/src/dump/table_trigger.rs b/app/src/dump/table_trigger.rs index afe5204..3a128b4 100644 --- a/app/src/dump/table_trigger.rs +++ b/app/src/dump/table_trigger.rs @@ -152,5 +152,5 @@ impl PartialEq for TableTrigger { } #[cfg(test)] -#[path = "table_trigger_tests.rs"] +#[path = "tests/table_trigger.rs"] mod tests; diff --git a/app/src/dump/text_search.rs b/app/src/dump/text_search.rs index e8cccc5..ba3df5c 100644 --- a/app/src/dump/text_search.rs +++ b/app/src/dump/text_search.rs @@ -283,5 +283,5 @@ impl TextSearchDict { } #[cfg(test)] -#[path = "text_search_tests.rs"] +#[path = "tests/text_search.rs"] mod tests; diff --git a/app/src/dump/view.rs b/app/src/dump/view.rs index 275a0a3..0529f69 100644 --- a/app/src/dump/view.rs +++ b/app/src/dump/view.rs @@ -544,5 +544,5 @@ impl View { } #[cfg(test)] -#[path = "view_tests.rs"] +#[path = "tests/view.rs"] mod tests; diff --git a/app/src/lib.rs b/app/src/lib.rs new file mode 100644 index 0000000..c7a1e7d --- /dev/null +++ b/app/src/lib.rs @@ -0,0 +1,21 @@ +//! `pgc` — PostgreSQL database schema comparer. +//! +//! The crate is split into four modules, mirroring the three user-facing +//! commands of the `pgc` binary: +//! +//! - [`dump`] — per-object PostgreSQL introspection; [`dump::core::Dump`] owns +//! the schema snapshot and can serialize it to a zip-compressed JSON file. +//! - [`comparer`] — [`comparer::core::Comparer`] reads two [`dump::core::Dump`]s +//! and emits the migration SQL that turns `FROM` into `TO`. +//! - [`config`] — the `pgc.conf` key-value parser and its value types. +//! - [`utils`] — small shared helpers (SQL normalisation, string extensions). +//! +//! The library target exists so that the binary in `src/main.rs` and the +//! integration tests in `app/tests/` can both build on the same public API. +//! Unit tests that need access to private internals live beside their module, +//! under each module's `tests/` directory. + +pub mod comparer; +pub mod config; +pub mod dump; +pub mod utils; diff --git a/app/src/main.rs b/app/src/main.rs index 06d2089..ad2a0ae 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -1,17 +1,12 @@ -use crate::{ +use chrono::Datelike; +use clap::{CommandFactory, Parser}; +use pgc::{ comparer::core::Comparer, config::{core::Config, dump_config::DumpConfig, grants_mode::GrantsMode}, dump::core::Dump, }; -use chrono::Datelike; -use clap::{CommandFactory, Parser}; use std::{io::Error, path::Path, time::Instant}; -pub mod comparer; -pub mod config; -pub mod dump; -pub mod utils; - // Command line arguments. #[derive(Parser, Debug)] #[command( diff --git a/app/src/utils/sql_normalize.rs b/app/src/utils/sql_normalize.rs index 5117000..765eb22 100644 --- a/app/src/utils/sql_normalize.rs +++ b/app/src/utils/sql_normalize.rs @@ -514,5 +514,5 @@ fn trailing_array_cast(chars: &[char], pos: usize) -> Option<(String, usize)> { } #[cfg(test)] -#[path = "sql_normalize_tests.rs"] +#[path = "tests/sql_normalize.rs"] mod tests; diff --git a/app/src/utils/string_extensions.rs b/app/src/utils/string_extensions.rs index 6f257b4..2ec03ae 100644 --- a/app/src/utils/string_extensions.rs +++ b/app/src/utils/string_extensions.rs @@ -58,5 +58,5 @@ impl StringExt for String { } #[cfg(test)] -#[path = "string_extensions_tests.rs"] +#[path = "tests/string_extensions.rs"] mod tests; From fa93d64958da3993c30323ef2775459a41e62550 Mon Sep 17 00:00:00 2001 From: nettrash Date: Mon, 3 Aug 2026 18:11:47 +0200 Subject: [PATCH 3/9] #238 --- app/src/comparer/tests/core.rs | 25 ++++++++++++++++--- .../comparer/tests/core/buffer_ordering.rs | 3 +-- .../comparer/tests/core/cascade_dependents.rs | 2 +- .../comparer/tests/core/column_dependents.rs | 3 +-- app/src/comparer/tests/core/grants.rs | 2 +- app/src/comparer/tests/core/helpers.rs | 1 - .../comparer/tests/core/matview_indexes.rs | 2 +- app/src/comparer/tests/core/persistence.rs | 3 +-- app/src/comparer/tests/core/production.rs | 1 - app/src/comparer/tests/core/routines.rs | 1 - .../comparer/tests/core/schemas_extensions.rs | 1 - app/src/comparer/tests/core/script_output.rs | 2 +- app/src/comparer/tests/core/sequences.rs | 1 - app/src/comparer/tests/core/tables.rs | 3 +-- app/src/comparer/tests/core/types.rs | 2 +- app/src/comparer/tests/core/views.rs | 2 +- 16 files changed, 32 insertions(+), 22 deletions(-) diff --git a/app/src/comparer/tests/core.rs b/app/src/comparer/tests/core.rs index 057a333..4bc3b7a 100644 --- a/app/src/comparer/tests/core.rs +++ b/app/src/comparer/tests/core.rs @@ -1,21 +1,40 @@ //! Unit tests for [`Comparer`](super::Comparer). //! -//! Split by concern; every submodule reaches the private internals of -//! `comparer::core` through `use crate::comparer::core::*;`, and shared -//! fixture builders live in [`helpers`]. +//! Split by concern. Every submodule reaches the private internals of +//! `comparer::core` through `use crate::comparer::core::*;`, and fixture +//! builders shared by more than one submodule live in [`helpers`]. +//! +//! The `#[path]` attributes are required: this module is itself loaded via +//! `#[path = "tests/core.rs"]`, so rustc resolves child modules relative to +//! `src/comparer/tests/` rather than to `src/comparer/tests/core/`. +#[path = "core/buffer_ordering.rs"] mod buffer_ordering; +#[path = "core/cascade_dependents.rs"] mod cascade_dependents; +#[path = "core/column_dependents.rs"] mod column_dependents; +#[path = "core/grants.rs"] mod grants; +#[path = "core/helpers.rs"] mod helpers; +#[path = "core/matview_indexes.rs"] mod matview_indexes; +#[path = "core/persistence.rs"] mod persistence; +#[path = "core/production.rs"] mod production; +#[path = "core/routines.rs"] mod routines; +#[path = "core/schemas_extensions.rs"] mod schemas_extensions; +#[path = "core/script_output.rs"] mod script_output; +#[path = "core/sequences.rs"] mod sequences; +#[path = "core/tables.rs"] mod tables; +#[path = "core/types.rs"] mod types; +#[path = "core/views.rs"] mod views; diff --git a/app/src/comparer/tests/core/buffer_ordering.rs b/app/src/comparer/tests/core/buffer_ordering.rs index 8631fc9..316ac76 100644 --- a/app/src/comparer/tests/core/buffer_ordering.rs +++ b/app/src/comparer/tests/core/buffer_ordering.rs @@ -5,11 +5,10 @@ //! These tests pin that order so dependency-aware rearrangements cannot //! regress silently. -use crate::comparer::core::*; use super::helpers::*; +use crate::comparer::core::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; -use crate::dump::schema::Schema; use crate::dump::sequence::Sequence; use crate::dump::table::Table; use crate::dump::table_trigger::TableTrigger; diff --git a/app/src/comparer/tests/core/cascade_dependents.rs b/app/src/comparer/tests/core/cascade_dependents.rs index fd4dd49..81bd82e 100644 --- a/app/src/comparer/tests/core/cascade_dependents.rs +++ b/app/src/comparer/tests/core/cascade_dependents.rs @@ -3,8 +3,8 @@ //! column DEFAULT expressions, RLS policies). Phase 7 of //! `compare_routines_and_views` re-emits them; these tests cover it. -use crate::comparer::core::*; use super::helpers::*; +use crate::comparer::core::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::routine::Routine; diff --git a/app/src/comparer/tests/core/column_dependents.rs b/app/src/comparer/tests/core/column_dependents.rs index f9ee383..f725df1 100644 --- a/app/src/comparer/tests/core/column_dependents.rs +++ b/app/src/comparer/tests/core/column_dependents.rs @@ -1,13 +1,12 @@ //! Issue #188 — pg_depend-driven secondary dependent restoration. -use crate::comparer::core::*; use super::helpers::*; +use crate::comparer::core::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::column_dependent::ColumnDependent; use crate::dump::column_dependent::ColumnDependentKind; use crate::dump::table::Table; -use crate::dump::table_column::TableColumn; use crate::dump::table_constraint::TableConstraint; use crate::dump::table_index::TableIndex; diff --git a/app/src/comparer/tests/core/grants.rs b/app/src/comparer/tests/core/grants.rs index 04a283d..b216a8f 100644 --- a/app/src/comparer/tests/core/grants.rs +++ b/app/src/comparer/tests/core/grants.rs @@ -2,8 +2,8 @@ //! [`GrantsMode`] settings (`ignore`, `addonly`, `full`), including owner //! changes and the default-ACL handling for recreated or dropped objects. -use crate::comparer::core::*; use super::helpers::*; +use crate::comparer::core::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::default_privilege::DefaultPrivilege; diff --git a/app/src/comparer/tests/core/helpers.rs b/app/src/comparer/tests/core/helpers.rs index 4c41827..7c13dd1 100644 --- a/app/src/comparer/tests/core/helpers.rs +++ b/app/src/comparer/tests/core/helpers.rs @@ -2,7 +2,6 @@ //! //! Helpers used by a single module live in that module instead. -use crate::comparer::core::*; use crate::dump::pg_type::PgType; use crate::dump::routine::Routine; use crate::dump::table::Table; diff --git a/app/src/comparer/tests/core/matview_indexes.rs b/app/src/comparer/tests/core/matview_indexes.rs index dd44ab7..a416dda 100644 --- a/app/src/comparer/tests/core/matview_indexes.rs +++ b/app/src/comparer/tests/core/matview_indexes.rs @@ -1,7 +1,7 @@ //! Issue #235 — indexes on a materialized view. -use crate::comparer::core::*; use super::helpers::*; +use crate::comparer::core::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::table_index::TableIndex; diff --git a/app/src/comparer/tests/core/persistence.rs b/app/src/comparer/tests/core/persistence.rs index 93599f9..7e626ee 100644 --- a/app/src/comparer/tests/core/persistence.rs +++ b/app/src/comparer/tests/core/persistence.rs @@ -4,11 +4,10 @@ //! cascade already propagates. Also covers the FK-parsing fixes from //! PR #187 and the cycle-breaking work in issues #190 and #191. -use crate::comparer::core::*; use super::helpers::*; +use crate::comparer::core::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; -use crate::dump::sequence::Sequence; use crate::dump::table::Table; use crate::dump::table_constraint::TableConstraint; diff --git a/app/src/comparer/tests/core/production.rs b/app/src/comparer/tests/core/production.rs index 7f0b069..f1cce1d 100644 --- a/app/src/comparer/tests/core/production.rs +++ b/app/src/comparer/tests/core/production.rs @@ -1,7 +1,6 @@ //! Tests for `--output-for-production` script rewriting. use crate::comparer::core::*; -use super::helpers::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::table::Table; diff --git a/app/src/comparer/tests/core/routines.rs b/app/src/comparer/tests/core/routines.rs index a5b9613..4c0185f 100644 --- a/app/src/comparer/tests/core/routines.rs +++ b/app/src/comparer/tests/core/routines.rs @@ -2,7 +2,6 @@ //! recreate rules, dependency ordering, overloads, owner and config changes. use crate::comparer::core::*; -use super::helpers::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::routine::Routine; diff --git a/app/src/comparer/tests/core/schemas_extensions.rs b/app/src/comparer/tests/core/schemas_extensions.rs index 216ecf5..b707be0 100644 --- a/app/src/comparer/tests/core/schemas_extensions.rs +++ b/app/src/comparer/tests/core/schemas_extensions.rs @@ -1,7 +1,6 @@ //! Tests for `compare_schemas` and `compare_extensions`. use crate::comparer::core::*; -use super::helpers::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::extension::Extension; diff --git a/app/src/comparer/tests/core/script_output.rs b/app/src/comparer/tests/core/script_output.rs index 1f47008..461c419 100644 --- a/app/src/comparer/tests/core/script_output.rs +++ b/app/src/comparer/tests/core/script_output.rs @@ -4,8 +4,8 @@ //! string literals, E-strings, quoted identifiers and dollar-quoted bodies //! byte-for-byte intact. -use crate::comparer::core::*; use super::helpers::*; +use crate::comparer::core::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::table::Table; diff --git a/app/src/comparer/tests/core/sequences.rs b/app/src/comparer/tests/core/sequences.rs index 763b1a1..2de12cb 100644 --- a/app/src/comparer/tests/core/sequences.rs +++ b/app/src/comparer/tests/core/sequences.rs @@ -2,7 +2,6 @@ //! sequences that must not be emitted independently of their table. use crate::comparer::core::*; -use super::helpers::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::sequence::Sequence; diff --git a/app/src/comparer/tests/core/tables.rs b/app/src/comparer/tests/core/tables.rs index a8f1e7b..e88b4e5 100644 --- a/app/src/comparer/tests/core/tables.rs +++ b/app/src/comparer/tests/core/tables.rs @@ -1,11 +1,10 @@ //! Tests for `compare_tables`: partition and inheritance ordering, serial //! columns, and the pre-drop of foreign keys and triggers. -use crate::comparer::core::*; use super::helpers::*; +use crate::comparer::core::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; -use crate::dump::schema::Schema; use crate::dump::sequence::Sequence; use crate::dump::table::Table; use crate::dump::table_column::TableColumn; diff --git a/app/src/comparer/tests/core/types.rs b/app/src/comparer/tests/core/types.rs index aaf0191..d523c19 100644 --- a/app/src/comparer/tests/core/types.rs +++ b/app/src/comparer/tests/core/types.rs @@ -1,8 +1,8 @@ //! Tests for `compare_types` and `compare_enums`, including composite and //! multirange types. -use crate::comparer::core::*; use super::helpers::*; +use crate::comparer::core::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::pg_type::CompositeAttribute; diff --git a/app/src/comparer/tests/core/views.rs b/app/src/comparer/tests/core/views.rs index fc33dce..0c4b4d3 100644 --- a/app/src/comparer/tests/core/views.rs +++ b/app/src/comparer/tests/core/views.rs @@ -2,8 +2,8 @@ //! kind transitions, the `CREATE OR REPLACE` compatibility rules from //! issue #227, and the byte-identical recreation of issue #189. -use crate::comparer::core::*; use super::helpers::*; +use crate::comparer::core::*; use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; use crate::dump::view::View; From 385cc5c67697da4a816c4754c7072417f9f7bf1a Mon Sep 17 00:00:00 2001 From: nettrash Date: Mon, 3 Aug 2026 18:20:08 +0200 Subject: [PATCH 4/9] #238 --- app/tests/clear_script.rs | 217 ++++++++++++++++++++++++ app/tests/common/mod.rs | 309 ++++++++++++++++++++++++++++++++++ app/tests/compare_pipeline.rs | 176 +++++++++++++++++++ app/tests/config_file.rs | 195 +++++++++++++++++++++ app/tests/dump_file_format.rs | 154 +++++++++++++++++ app/tests/live_database.rs | 103 ++++++++++++ 6 files changed, 1154 insertions(+) create mode 100644 app/tests/clear_script.rs create mode 100644 app/tests/common/mod.rs create mode 100644 app/tests/compare_pipeline.rs create mode 100644 app/tests/config_file.rs create mode 100644 app/tests/dump_file_format.rs create mode 100644 app/tests/live_database.rs diff --git a/app/tests/clear_script.rs b/app/tests/clear_script.rs new file mode 100644 index 0000000..4fa8c5e --- /dev/null +++ b/app/tests/clear_script.rs @@ -0,0 +1,217 @@ +//! Integration tests for the `clear` command's script generation. +//! +//! `Dump::generate_clear_script` is the only part of `clear` that does not need +//! a live server: the drop order it produces is the whole correctness argument, +//! since PostgreSQL rejects drops that run before their dependents. The shape +//! asserted here matches `data/test/clear_expected.sql`. + +mod common; + +use common::{ + empty_dump, extension, foreign_key, materialized_view, populated_dump, routine, schema, + sequence, table, view, +}; + +/// Offset of the first occurrence of `needle`, or a panic naming the script — +/// every ordering assertion below reads better as a byte offset. +/// +/// Needles must be specific enough to skip the section banners: the script +/// contains `/* ---> Drop Views --- */` and `/* ---> Drop Tables --- */`, whose +/// plural forms would otherwise match `drop view` and `drop table` ahead of the +/// statements they introduce. Matching on the `if exists` suffix pins each +/// needle to a real statement. +fn position_of(script: &str, needle: &str) -> usize { + script + .find(needle) + .unwrap_or_else(|| panic!("expected {needle:?} in clear script:\n{script}")) +} + +fn clear_script_of_populated_dump() -> String { + let mut dump = populated_dump("shop"); + dump.types.clear(); // building a PgType needs 30+ catalog columns; not what this file covers + dump.generate_clear_script(true, true, false) +} + +#[test] +fn objects_are_dropped_in_dependency_safe_order() { + let script = clear_script_of_populated_dump().to_lowercase(); + + let matview = position_of(&script, "drop materialized view if exists"); + let regular_view = position_of(&script, "drop view if exists"); + let fk = position_of(&script, "drop constraint if exists"); + let table = position_of(&script, "drop table if exists"); + let routine = position_of(&script, "drop function if exists"); + let sequence = position_of(&script, "drop sequence if exists"); + let extension = position_of(&script, "drop extension if exists"); + let schema = position_of(&script, "drop schema if exists"); + + assert!(matview < regular_view, "materialized views drop first"); + assert!(regular_view < fk, "views drop before foreign keys"); + assert!(fk < table, "foreign keys drop before their tables"); + assert!(table < routine, "tables drop before routines"); + assert!(routine < sequence, "routines drop before sequences"); + assert!(sequence < extension, "sequences drop before extensions"); + assert!(extension < schema, "extensions drop last before schemas"); +} + +#[test] +fn a_view_is_dropped_before_the_view_it_depends_on() { + let mut dump = empty_dump("shop"); + dump.schemas.push(schema("app")); + dump.tables.push(table("app", "customers", &["id"])); + dump.views.push(view( + "app", + "base", + " SELECT id FROM app.customers;", + &["app.customers"], + )); + dump.views.push(view( + "app", + "derived", + " SELECT id FROM app.base;", + &["app.base"], + )); + + let script = dump.generate_clear_script(true, true, false).to_lowercase(); + assert!( + position_of(&script, "drop view if exists app.derived") + < position_of(&script, "drop view if exists app.base"), + "a dependent view must be dropped before its source:\n{script}" + ); +} + +#[test] +fn foreign_keys_are_dropped_before_the_tables_they_constrain() { + let mut dump = empty_dump("shop"); + dump.schemas.push(schema("app")); + dump.tables.push(table("app", "customers", &["id"])); + let mut orders = table("app", "orders", &["id", "customer_id"]); + orders.constraints.push(foreign_key( + "app", + "orders", + "fk_orders_customer", + "customer_id", + "app.customers", + "id", + )); + dump.tables.push(orders); + + let script = dump.generate_clear_script(true, true, false).to_lowercase(); + assert!( + position_of(&script, "drop constraint if exists fk_orders_customer") + < position_of(&script, "drop table if exists"), + "every FK must be dropped before any table:\n{script}" + ); +} + +#[test] +fn single_transaction_wraps_the_clear_script() { + let with_tx = clear_script_of_populated_dump().to_lowercase(); + assert!(with_tx.contains("begin;"), "expected begin:\n{with_tx}"); + assert!(with_tx.contains("commit;"), "expected commit:\n{with_tx}"); + + let mut dump = populated_dump("shop"); + dump.types.clear(); + let without_tx = dump.generate_clear_script(false, true, false).to_lowercase(); + assert!( + !without_tx.contains("begin;"), + "use_single_transaction=false must not open a transaction:\n{without_tx}" + ); +} + +/// `use_comments = false` drops the per-statement annotations and the section +/// banners, but deliberately keeps the generated-by header: it records which +/// database and schemas the script came from, which is provenance rather than +/// commentary. +#[test] +fn use_comments_false_strips_the_per_statement_commentary() { + let mut dump = populated_dump("shop"); + dump.types.clear(); + let script = dump.generate_clear_script(true, false, false); + + assert!( + script.starts_with("/*") && script.contains("Script generated by"), + "the generated-by header stays:\n{script}" + ); + let after_header = &script[script.find("*/").expect("header is closed") + 2..]; + for line in after_header.lines() { + let line = line.trim(); + assert!( + !line.starts_with("/*") && !line.starts_with("--"), + "use_comments=false left a comment behind: {line}" + ); + } + assert!(!after_header.contains("---> Drop"), "section banners are dropped"); + // Stripping comments must not strip the statements. + assert!(after_header.to_lowercase().contains("drop table if exists")); +} + +#[test] +fn cascade_is_opt_in() { + let mut dump = populated_dump("shop"); + dump.types.clear(); + + let plain = dump.generate_clear_script(true, true, false).to_lowercase(); + assert!( + !plain.contains("cascade"), + "cascade must not appear unless requested:\n{plain}" + ); + + let cascading = dump.generate_clear_script(true, true, true).to_lowercase(); + assert!( + cascading.contains("cascade"), + "use_cascade=true must emit CASCADE:\n{cascading}" + ); +} + +#[test] +fn an_empty_dump_produces_a_script_with_no_drops() { + let script = empty_dump("blank") + .generate_clear_script(true, true, false) + .to_lowercase(); + + assert!( + !script.contains("drop "), + "nothing to drop, so nothing should be dropped:\n{script}" + ); +} + +#[test] +fn every_object_kind_present_in_the_dump_is_dropped() { + let mut dump = empty_dump("shop"); + dump.schemas.push(schema("app")); + dump.extensions.push(extension("pgcrypto", "1.3", "public")); + dump.tables.push(table("app", "customers", &["id"])); + dump.views.push(view( + "app", + "v", + " SELECT id FROM app.customers;", + &["app.customers"], + )); + dump.views.push(materialized_view( + "app", + "mv", + " SELECT id FROM app.customers;", + &["app.customers"], + )); + dump.routines + .push(routine("app", 900, "compute", "integer", "SELECT $1")); + dump.sequences.push(sequence("app", "s")); + + let script = dump.generate_clear_script(true, true, false).to_lowercase(); + + for object in [ + "app.customers", + "app.v", + "app.mv", + "app.compute", + "app.s", + "pgcrypto", + "app", + ] { + assert!( + script.contains(object), + "clear script never mentions {object}:\n{script}" + ); + } +} diff --git a/app/tests/common/mod.rs b/app/tests/common/mod.rs new file mode 100644 index 0000000..ffdeb5b --- /dev/null +++ b/app/tests/common/mod.rs @@ -0,0 +1,309 @@ +//! Fixture builders and scratch-directory helpers shared by the integration +//! tests. +//! +//! This is a `common/` subdirectory rather than a top-level `tests/*.rs` file +//! on purpose: Cargo compiles every top-level file in `tests/` as its own test +//! binary, but leaves subdirectories alone, so this module is included by the +//! test binaries that declare `mod common;` instead of becoming one itself. +//! +//! Everything here goes through `pgc`'s **public** API only. Tests that need +//! access to private internals belong with their module, under +//! `src//tests/`. + +#![allow(dead_code)] // each test binary uses only part of this module + +use pgc::config::dump_config::DumpConfig; +use pgc::dump::core::Dump; +use pgc::dump::extension::Extension; +use pgc::dump::routine::Routine; +use pgc::dump::schema::Schema; +use pgc::dump::sequence::Sequence; +use pgc::dump::table::Table; +use pgc::dump::table_column::TableColumn; +use pgc::dump::table_constraint::TableConstraint; +use pgc::dump::view::View; +use sqlx::postgres::types::Oid; +use std::path::{Path, PathBuf}; + +/// A uniquely-named directory under the system temp dir, removed on drop. +/// +/// Avoids a `tempfile` dev-dependency — the crate keeps a deliberately lean +/// dependency list, and the tests only need a scratch path. +pub struct ScratchDir { + path: PathBuf, +} + +impl ScratchDir { + /// `label` only has to be unique per test binary; the pid and a counter + /// disambiguate concurrent runs. + pub fn new(label: &str) -> Self { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "pgc-it-{label}-{}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("create scratch dir"); + Self { path } + } + + pub fn join(&self, name: &str) -> PathBuf { + self.path.join(name) + } + + /// Same as [`Self::join`], as the `&str` the `pgc` API takes. + pub fn path_str(&self, name: &str) -> String { + self.join(name).to_str().expect("utf-8 path").to_string() + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// Absolute path to a file under the repository's `data/` directory. +pub fn data_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("app/ has a parent") + .join("data") + .join(relative) +} + +/// A `DumpConfig` that is never connected to — dumps built in-process still +/// carry one, and `Dump::new` requires it. +pub fn offline_config(database: &str, scheme: &str) -> DumpConfig { + DumpConfig { + host: "localhost".to_string(), + port: "5432".to_string(), + user: "postgres".to_string(), + password: String::new(), + database: database.to_string(), + scheme: scheme.to_string(), + ssl: false, + file: String::new(), + } +} + +pub fn empty_dump(database: &str) -> Dump { + Dump::new(offline_config(database, "public")) +} + +pub fn schema(name: &str) -> Schema { + Schema::new(name.to_string(), name.to_string(), None) +} + +pub fn extension(name: &str, version: &str, schema: &str) -> Extension { + Extension::new(name.to_string(), version.to_string(), schema.to_string()) +} + +/// An `integer` column with everything else left at its catalog default. +pub fn int_column(schema: &str, table: &str, name: &str, ordinal: i32) -> TableColumn { + TableColumn { + catalog: "postgres".to_string(), + schema: schema.to_string(), + table: table.to_string(), + name: name.to_string(), + ordinal_position: ordinal, + column_default: None, + is_nullable: true, + data_type: "integer".to_string(), + character_maximum_length: None, + character_octet_length: None, + numeric_precision: Some(32), + numeric_precision_radix: Some(2), + numeric_scale: Some(0), + datetime_precision: None, + interval_type: None, + interval_precision: None, + character_set_catalog: None, + character_set_schema: None, + character_set_name: None, + collation_catalog: None, + collation_schema: None, + collation_name: None, + domain_catalog: None, + domain_schema: None, + domain_name: None, + udt_catalog: None, + udt_schema: None, + udt_name: None, + scope_catalog: None, + scope_schema: None, + scope_name: None, + maximum_cardinality: None, + dtd_identifier: None, + is_self_referencing: false, + is_identity: false, + identity_generation: None, + identity_start: None, + identity_increment: None, + identity_maximum: None, + identity_minimum: None, + identity_cycle: false, + is_generated: "NEVER".to_string(), + generation_expression: None, + generation_type: None, + is_updatable: true, + related_views: None, + comment: None, + storage: None, + compression: None, + statistics_target: None, + acl: vec![], + serial_type: None, + } +} + +/// A table with the given integer columns, hashed and ready to compare. +pub fn table(schema_name: &str, name: &str, columns: &[&str]) -> Table { + let cols = columns + .iter() + .enumerate() + .map(|(i, c)| int_column(schema_name, name, c, i as i32 + 1)) + .collect(); + let mut t = Table::new( + schema_name.to_string(), + name.to_string(), + schema_name.to_string(), + name.to_string(), + "postgres".to_string(), + None, + cols, + vec![], + vec![], + vec![], + None, + ); + t.hash(); + t +} + +/// A `FOREIGN KEY` constraint on `table` referencing `references`. +pub fn foreign_key( + schema: &str, + table: &str, + name: &str, + column: &str, + references: &str, + referenced_column: &str, +) -> TableConstraint { + TableConstraint { + catalog: "postgres".to_string(), + schema: schema.to_string(), + name: name.to_string(), + table_name: table.to_string(), + constraint_type: "FOREIGN KEY".to_string(), + is_deferrable: false, + initially_deferred: false, + definition: Some(format!( + "FOREIGN KEY ({column}) REFERENCES {references}({referenced_column})" + )), + coninhcount: 0, + is_enforced: true, + no_inherit: false, + nulls_not_distinct: false, + comment: None, + } +} + +pub fn view(schema: &str, name: &str, definition: &str, relations: &[&str]) -> View { + let mut v = View::new( + name.to_string(), + definition.to_string(), + schema.to_string(), + relations.iter().map(|r| r.to_string()).collect(), + ); + v.hash(); + v +} + +pub fn materialized_view(schema: &str, name: &str, definition: &str, relations: &[&str]) -> View { + let mut v = view(schema, name, definition, relations); + v.is_materialized = true; + v.hash(); + v +} + +pub fn routine(schema: &str, oid: u32, name: &str, return_type: &str, body: &str) -> Routine { + let mut r = Routine::new( + schema.to_string(), + Oid(oid), + name.to_string(), + "sql".to_string(), + "FUNCTION".to_string(), + return_type.to_string(), + "x integer".to_string(), + None, + None, + body.to_string(), + ); + r.hash(); + r +} + +#[allow(clippy::too_many_arguments)] +pub fn sequence(schema: &str, name: &str) -> Sequence { + let mut s = Sequence::new( + schema.to_string(), + name.to_string(), + "postgres".to_string(), + "bigint".to_string(), + Some(1), + Some(1), + Some(i64::MAX), + Some(1), + false, + Some(1), + Some(1), + None, + None, + None, + ); + s.hash(); + s +} + +/// A dump with one schema, two related tables, a view over them, a routine and +/// a sequence — enough object kinds that a self-comparison exercises most of +/// `Comparer`'s branches. +pub fn populated_dump(database: &str) -> Dump { + let mut dump = empty_dump(database); + dump.schemas.push(schema("app")); + dump.extensions.push(extension("pgcrypto", "1.3", "public")); + + let parent = table("app", "customers", &["id", "region"]); + let mut child = table("app", "orders", &["id", "customer_id"]); + child.constraints.push(foreign_key( + "app", + "orders", + "fk_orders_customer", + "customer_id", + "app.customers", + "id", + )); + child.hash(); + dump.tables.push(parent); + dump.tables.push(child); + + dump.views.push(view( + "app", + "order_summary", + " SELECT o.id, c.region\n FROM app.orders o JOIN app.customers c ON c.id = o.customer_id;", + &["app.orders", "app.customers"], + )); + dump.views.push(materialized_view( + "app", + "region_totals", + " SELECT region, count(*) AS n\n FROM app.customers GROUP BY region;", + &["app.customers"], + )); + dump.routines + .push(routine("app", 900, "compute", "integer", "SELECT $1 * 2")); + dump.sequences.push(sequence("app", "order_seq")); + dump +} diff --git a/app/tests/compare_pipeline.rs b/app/tests/compare_pipeline.rs new file mode 100644 index 0000000..859b110 --- /dev/null +++ b/app/tests/compare_pipeline.rs @@ -0,0 +1,176 @@ +//! End-to-end tests for the `compare` command's public path. +//! +//! These drive the same sequence `main.rs` does — build two [`Dump`]s, write +//! them out, read them back, hand them to [`Comparer`], and save the script — +//! so they cover the dump→file→dump hop that the in-memory unit tests skip. + +mod common; + +use common::{ScratchDir, populated_dump, table, view}; +use pgc::comparer::core::Comparer; +use pgc::config::grants_mode::GrantsMode; +use pgc::dump::core::Dump; + +/// Round-trip both dumps through the filesystem, then compare them, exactly as +/// `pgc --command compare --from … --to …` does. +async fn compare_via_files(from: Dump, to: Dump, use_drop: bool, label: &str) -> String { + let dir = ScratchDir::new(label); + let from_path = dir.path_str("dump.from"); + let to_path = dir.path_str("dump.to"); + let out_path = dir.path_str("output.sql"); + + from.write_to_file(&from_path).expect("write FROM dump"); + to.write_to_file(&to_path).expect("write TO dump"); + + let from = Dump::read_from_file(&from_path).await.expect("read FROM dump"); + let to = Dump::read_from_file(&to_path).await.expect("read TO dump"); + + let mut comparer = Comparer::new(from, to, use_drop, false, true, GrantsMode::Ignore); + comparer.compare().await.expect("compare"); + comparer.save_script(&out_path).await.expect("save script"); + + std::fs::read_to_string(&out_path).expect("read generated script") +} + +/// The core correctness property, at the level this test can reach without a +/// live server: a schema compared against itself needs no migration. +#[tokio::test] +async fn comparing_a_dump_against_itself_emits_no_ddl() { + let script = compare_via_files( + populated_dump("shop"), + populated_dump("shop"), + true, + "self-compare", + ) + .await; + + for line in script.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with("--") || line.starts_with("/*") { + continue; + } + panic!("self-comparison emitted DDL:\n{script}"); + } +} + +#[tokio::test] +async fn a_new_table_is_created_and_a_removed_one_is_dropped() { + let from = populated_dump("shop"); + let mut to = populated_dump("shop"); + to.tables.push(table("app", "invoices", &["id", "total"])); + to.tables.retain(|t| t.name != "orders"); + // `order_summary` reads `app.orders`; drop it too so the TO side is + // self-consistent. + to.views.retain(|v| v.name != "order_summary"); + + let script = compare_via_files(from, to, true, "add-drop-table").await; + + assert!( + script.contains("create table") || script.contains("CREATE TABLE"), + "expected a CREATE TABLE for app.invoices:\n{script}" + ); + assert!(script.contains("invoices"), "new table missing:\n{script}"); + assert!( + script.to_lowercase().contains("drop table"), + "expected a DROP TABLE for app.orders:\n{script}" + ); +} + +/// With `use_drop = false` the destructive statements must still be *present*, +/// but commented out, so a reviewer can see and opt into them. +#[tokio::test] +async fn destructive_statements_are_commented_out_when_use_drop_is_false() { + let from = populated_dump("shop"); + let mut to = populated_dump("shop"); + to.tables.retain(|t| t.name != "orders"); + to.views.retain(|v| v.name != "order_summary"); + + let script = compare_via_files(from, to, false, "no-drop").await; + + let uncommented_drop = script.lines().any(|l| { + let l = l.trim().to_lowercase(); + l.starts_with("drop table") + }); + assert!( + !uncommented_drop, + "use_drop=false must not emit an active DROP TABLE:\n{script}" + ); +} + +#[tokio::test] +async fn a_changed_view_definition_is_re_emitted() { + let from = populated_dump("shop"); + let mut to = populated_dump("shop"); + to.views.retain(|v| v.name != "order_summary"); + to.views.push(view( + "app", + "order_summary", + " SELECT o.id\n FROM app.orders o;", + &["app.orders"], + )); + + let script = compare_via_files(from, to, true, "view-change").await; + let lower = script.to_lowercase(); + assert!( + lower.contains("view app.order_summary") || lower.contains("view order_summary"), + "expected app.order_summary to be re-emitted:\n{script}" + ); +} + +#[tokio::test] +async fn single_transaction_wraps_the_script_in_begin_and_commit() { + let dir = ScratchDir::new("single-tx"); + let out_path = dir.path_str("output.sql"); + + let mut to = populated_dump("shop"); + to.tables.push(table("app", "invoices", &["id"])); + + let mut comparer = Comparer::new( + populated_dump("shop"), + to, + true, + true, // use_single_transaction + true, + GrantsMode::Ignore, + ); + comparer.compare().await.expect("compare"); + comparer.save_script(&out_path).await.expect("save script"); + + let script = std::fs::read_to_string(&out_path).expect("read script"); + let lower = script.to_lowercase(); + let begin = lower.find("begin;").expect("script must open a transaction"); + let commit = lower.rfind("commit;").expect("script must commit"); + assert!(begin < commit, "begin must precede commit:\n{script}"); +} + +/// `--output-for-production` moves statements that cannot run inside a +/// transaction into a post-commit section. Off by default, and toggling it must +/// not change anything else about how the comparison is driven. +#[tokio::test] +async fn production_mode_is_opt_in() { + let build = |production: bool| async move { + let mut to = populated_dump("shop"); + to.tables.push(table("app", "invoices", &["id"])); + let mut comparer = + Comparer::new(populated_dump("shop"), to, true, true, true, GrantsMode::Ignore); + comparer.set_output_for_production(production); + comparer.compare().await.expect("compare"); + let dir = ScratchDir::new(if production { "prod-on" } else { "prod-off" }); + let out = dir.path_str("output.sql"); + comparer.save_script(&out).await.expect("save script"); + std::fs::read_to_string(&out).expect("read script") + }; + + let default_script = build(false).await; + let production_script = build(true).await; + + assert!( + !default_script.to_lowercase().contains("post-commit"), + "default output must not carry a post-commit section:\n{default_script}" + ); + assert!( + production_script.to_lowercase().contains("if not exists") + || production_script.to_lowercase().contains("post-commit"), + "production output must add idempotency guards:\n{production_script}" + ); +} diff --git a/app/tests/config_file.rs b/app/tests/config_file.rs new file mode 100644 index 0000000..3f482a9 --- /dev/null +++ b/app/tests/config_file.rs @@ -0,0 +1,195 @@ +//! Integration tests for `pgc.conf` loading. +//! +//! [`Config::load`] takes a path, so unlike the unit tests these read real +//! files — including the configuration samples shipped under `data/`, which +//! must keep parsing as keys are added. + +mod common; + +use common::{ScratchDir, data_path}; +use pgc::config::core::Config; +use pgc::config::grants_mode::GrantsMode; + +fn write_config(dir: &ScratchDir, body: &str) -> String { + let path = dir.path_str("pgc.conf"); + std::fs::write(&path, body).expect("write config"); + path +} + +#[test] +fn shipped_sample_config_parses() { + let path = data_path("pgc.conf"); + let config = Config::load(path.to_str().expect("utf-8 path")) + .unwrap_or_else(|e| panic!("data/pgc.conf must stay loadable: {e}")); + + assert_eq!(config.from.host, "localhost"); + assert_eq!(config.from.database, "service"); + assert!(config.from.ssl, "FROM_SSL=true"); + assert!(!config.to.ssl, "TO_SSL=false"); + assert_eq!(config.output, "delta.sql"); + assert!(!config.use_drop); + assert!(!config.output_for_production); + // Not set in the sample — must fall back to the documented defaults. + assert_eq!(config.grants_mode, GrantsMode::Ignore); + assert_eq!(config.max_connections, 16); + assert!(config.use_comments); +} + +#[test] +fn shipped_test_config_parses() { + let path = data_path("test.conf"); + let config = Config::load(path.to_str().expect("utf-8 path")) + .unwrap_or_else(|e| panic!("data/test.conf must stay loadable: {e}")); + + // The multi-schema pattern is passed through verbatim: `--scheme` is + // matched with SQL `SIMILAR TO`, so the alternation must survive parsing. + assert!(config.from.scheme.contains('|')); + assert_eq!(config.from.scheme, config.to.scheme); + assert!(config.use_drop); + assert!(!config.use_comments); + assert_eq!(config.grants_mode, GrantsMode::Full); + assert_eq!(config.max_connections, 10); + assert!(config.output_for_production); +} + +#[test] +fn every_key_round_trips_from_a_file() { + let dir = ScratchDir::new("full"); + let path = write_config( + &dir, + "\ +# a comment, and the blank line below, are both ignored + +FROM_HOST=from.example +FROM_PORT=5433 +FROM_USER=alice +FROM_PASSWORD=secret +FROM_DATABASE=old +FROM_SCHEME=app_.* +FROM_SSL=true +FROM_DUMP=a.dump +TO_HOST=to.example +TO_PORT=5434 +TO_USER=bob +TO_DATABASE=new +TO_SCHEME=public|app +TO_SSL=false +TO_DUMP=b.dump +OUTPUT=migration.sql +USE_DROP=true +USE_SINGLE_TRANSACTION=true +USE_COMMENTS=false +GRANTS_MODE=addonly +MAX_CONNECTIONS=4 +OUTPUT_FOR_PRODUCTION=true +", + ); + + let config = Config::load(&path).expect("load config"); + + assert_eq!(config.from.host, "from.example"); + assert_eq!(config.from.port, "5433"); + assert_eq!(config.from.user, "alice"); + assert_eq!(config.from.database, "old"); + assert_eq!(config.from.scheme, "app_.*"); + assert!(config.from.ssl); + assert_eq!(config.from.file, "a.dump"); + + assert_eq!(config.to.host, "to.example"); + assert_eq!(config.to.port, "5434"); + assert_eq!(config.to.user, "bob"); + assert_eq!(config.to.database, "new"); + assert_eq!(config.to.scheme, "public|app"); + assert!(!config.to.ssl); + assert_eq!(config.to.file, "b.dump"); + + assert_eq!(config.output, "migration.sql"); + assert!(config.use_drop); + assert!(config.use_single_transaction); + assert!(!config.use_comments); + assert_eq!(config.grants_mode, GrantsMode::AddOnly); + assert_eq!(config.max_connections, 4); + assert!(config.output_for_production); +} + +#[test] +fn omitted_keys_fall_back_to_defaults() { + let dir = ScratchDir::new("defaults"); + let path = write_config(&dir, "FROM_HOST=a.example\nTO_HOST=b.example\n"); + + let config = Config::load(&path).expect("load config"); + + assert_eq!(config.from.port, "5432"); + assert_eq!(config.to.port, "5432"); + assert_eq!(config.from.file, "dump.from"); + assert_eq!(config.to.file, "dump.to"); + assert_eq!(config.output, "data.out"); + assert!(!config.use_drop); + assert!(!config.use_single_transaction); + assert!(config.use_comments); + assert_eq!(config.grants_mode, GrantsMode::Ignore); + assert_eq!(config.max_connections, 16); + assert!(!config.output_for_production); +} + +#[test] +fn a_missing_file_is_an_error_naming_the_path() { + let dir = ScratchDir::new("missing"); + let path = dir.path_str("nope.conf"); + + let err = Config::load(&path).expect_err("a missing config must not load"); + assert!(err.contains(&path), "error should name the file: {err}"); +} + +#[test] +fn an_unknown_key_is_rejected() { + let dir = ScratchDir::new("unknown-key"); + let path = write_config(&dir, "FROM_HOST=a.example\nFROM_FLAVOUR=vanilla\n"); + + let err = Config::load(&path).expect_err("unknown keys must not be ignored"); + assert!(err.contains("FROM_FLAVOUR"), "unexpected error: {err}"); +} + +#[test] +fn a_line_without_a_value_is_rejected() { + let dir = ScratchDir::new("no-value"); + let path = write_config(&dir, "FROM_HOST=a.example\nTO_HOST=\n"); + + assert!( + Config::load(&path).is_err(), + "a key with an empty value must not load" + ); +} + +#[test] +fn a_non_boolean_ssl_value_is_rejected() { + let dir = ScratchDir::new("bad-ssl"); + let path = write_config(&dir, "FROM_HOST=a.example\nFROM_SSL=yes\n"); + + let err = Config::load(&path).expect_err("FROM_SSL must be true or false"); + assert!(err.contains("FROM_SSL"), "unexpected error: {err}"); +} + +#[test] +fn a_non_numeric_max_connections_is_rejected() { + let dir = ScratchDir::new("bad-max-conn"); + let path = write_config(&dir, "FROM_HOST=a.example\nMAX_CONNECTIONS=many\n"); + + let err = Config::load(&path).expect_err("MAX_CONNECTIONS must be a number"); + assert!(err.contains("MAX_CONNECTIONS"), "unexpected error: {err}"); +} + +/// Values keep their original case even though keys are matched +/// case-insensitively — schema patterns and passwords are case-sensitive. +#[test] +fn keys_are_case_insensitive_but_values_are_not() { + let dir = ScratchDir::new("case"); + let path = write_config( + &dir, + "from_host=MixedCase.Example\nFrOm_ScHeMe=App_Schema\n", + ); + + let config = Config::load(&path).expect("load config"); + assert_eq!(config.from.host, "MixedCase.Example"); + assert_eq!(config.from.scheme, "App_Schema"); +} diff --git a/app/tests/dump_file_format.rs b/app/tests/dump_file_format.rs new file mode 100644 index 0000000..a65ac0b --- /dev/null +++ b/app/tests/dump_file_format.rs @@ -0,0 +1,154 @@ +//! Integration tests for the on-disk dump format. +//! +//! A dump file is a zip archive holding a single `dump.io` entry with the +//! JSON-serialized [`Dump`]. Two contracts matter here and neither is covered +//! by the unit tests, which build `Dump` values in memory and never touch the +//! filesystem: +//! +//! 1. `write_to_file` → `read_from_file` must round-trip losslessly. +//! 2. Dumps written by *older* `pgc` versions must stay readable — every field +//! added after the initial release carries `#[serde(default)]`, so a JSON +//! document missing those keys has to deserialize rather than error. + +mod common; + +use common::{ScratchDir, empty_dump, populated_dump}; +use pgc::dump::core::Dump; +use std::io::{Read, Write}; + +#[tokio::test] +async fn write_then_read_round_trips_every_object_kind() { + let dir = ScratchDir::new("roundtrip"); + let path = dir.path_str("schema.dump"); + let original = populated_dump("shop"); + + original.write_to_file(&path).expect("write dump"); + let reloaded = Dump::read_from_file(&path).await.expect("read dump"); + + assert_eq!(original.get_info(), reloaded.get_info()); + assert_eq!(original.schemas.len(), reloaded.schemas.len()); + assert_eq!(original.tables.len(), reloaded.tables.len()); + assert_eq!(original.views.len(), reloaded.views.len()); + assert_eq!(original.routines.len(), reloaded.routines.len()); + assert_eq!(original.sequences.len(), reloaded.sequences.len()); + assert_eq!(original.extensions.len(), reloaded.extensions.len()); + + // Hashes drive every comparison, so they must survive serialization — + // a dropped hash would silently turn "unchanged" into "recreate". + for (before, after) in original.tables.iter().zip(&reloaded.tables) { + assert_eq!(before.name, after.name); + assert_eq!(before.hash, after.hash, "table {} hash changed", before.name); + } + for (before, after) in original.views.iter().zip(&reloaded.views) { + assert_eq!(before.hash, after.hash, "view {} hash changed", before.name); + } +} + +#[tokio::test] +async fn round_trip_of_an_empty_dump_produces_an_empty_dump() { + let dir = ScratchDir::new("empty"); + let path = dir.path_str("empty.dump"); + + empty_dump("blank").write_to_file(&path).expect("write dump"); + let reloaded = Dump::read_from_file(&path).await.expect("read dump"); + + assert!(reloaded.schemas.is_empty()); + assert!(reloaded.tables.is_empty()); + assert!(reloaded.column_dependents.is_empty()); +} + +#[tokio::test] +async fn dump_file_is_a_zip_holding_a_single_dump_io_entry() { + let dir = ScratchDir::new("layout"); + let path = dir.path_str("layout.dump"); + populated_dump("shop").write_to_file(&path).expect("write dump"); + + let mut archive = + zip::ZipArchive::new(std::fs::File::open(&path).expect("open dump")).expect("read zip"); + assert_eq!(archive.len(), 1, "dump archive holds exactly one entry"); + + let mut entry = archive.by_index(0).expect("first entry"); + assert_eq!(entry.name(), "dump.io"); + + let mut json = String::new(); + entry.read_to_string(&mut json).expect("entry is utf-8 json"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("entry is valid json"); + assert!(parsed.get("tables").is_some(), "tables key is always present"); + // `configuration` is `skip_serializing`: connection details, including the + // password, must never reach the dump file. + assert!( + parsed.get("configuration").is_none(), + "connection configuration must not be serialized into the dump" + ); +} + +#[tokio::test] +async fn dump_written_before_the_optional_fields_existed_still_loads() { + let dir = ScratchDir::new("legacy"); + let path = dir.path_str("legacy.dump"); + + // Exactly the keys a pre-`foreign_tables` pgc would have written: the + // eight non-defaulted vectors and nothing else. + let legacy = r#"{ + "schemas": [], + "extensions": [], + "types": [], + "enums": [], + "sequences": [], + "routines": [], + "tables": [], + "views": [] + }"#; + write_dump_io(&path, legacy); + + let dump = Dump::read_from_file(&path) + .await + .expect("a dump missing every optional field must still deserialize"); + + assert!(dump.foreign_tables.is_empty()); + assert!(dump.statistics.is_empty()); + assert!(dump.publications.is_empty()); + assert!(dump.column_dependents.is_empty()); + assert!(dump.user_mappings.is_empty()); +} + +#[tokio::test] +async fn dump_missing_a_required_field_reports_an_error() { + let dir = ScratchDir::new("corrupt"); + let path = dir.path_str("corrupt.dump"); + write_dump_io(&path, r#"{"schemas": []}"#); + + let err = Dump::read_from_file(&path) + .await + .expect_err("a truncated dump must not silently succeed"); + assert!( + err.to_string().contains("Failed to deserialize dump"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn reading_a_file_that_is_not_a_zip_reports_an_error() { + let dir = ScratchDir::new("notzip"); + let path = dir.path_str("notzip.dump"); + std::fs::write(&path, b"this is not a zip archive").expect("write file"); + + assert!( + Dump::read_from_file(&path).await.is_err(), + "a non-zip file must not parse as a dump" + ); +} + +/// Write `json` into a zip at `path` under the `dump.io` entry name that +/// `Dump::read_from_file` looks for. +fn write_dump_io(path: &str, json: &str) { + let mut zip = zip::ZipWriter::new(std::fs::File::create(path).expect("create dump")); + zip.start_file( + "dump.io", + zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated), + ) + .expect("start entry"); + zip.write_all(json.as_bytes()).expect("write entry"); + zip.finish().expect("finish zip"); +} diff --git a/app/tests/live_database.rs b/app/tests/live_database.rs new file mode 100644 index 0000000..ad65223 --- /dev/null +++ b/app/tests/live_database.rs @@ -0,0 +1,103 @@ +//! Round-trip tests against a real PostgreSQL server. +//! +//! Ignored by default — they need a reachable database, so `cargo test` on a +//! bare checkout must not try them. Run explicitly: +//! +//! ```text +//! PGHOST=localhost PGDATABASE=postgres PGUSER=postgres PGPASSWORD=secret \ +//! cargo test --test live_database -- --ignored +//! ``` +//! +//! CI's `integration` job covers the full contract (apply both fixture schemas, +//! compare, apply the diff, compare again → empty) across PostgreSQL 14–18 in +//! `.github/workflows/rust.yml`. These are the equivalent local smoke tests. + +mod common; + +use common::ScratchDir; +use pgc::comparer::core::Comparer; +use pgc::config::dump_config::DumpConfig; +use pgc::config::grants_mode::GrantsMode; +use pgc::dump::core::Dump; + +/// Connection details from the standard `PG*` environment variables, with the +/// same defaults as the `pgc` CLI. +fn env_config(file: &str) -> DumpConfig { + let var = |name: &str, fallback: &str| { + std::env::var(name).unwrap_or_else(|_| fallback.to_string()) + }; + DumpConfig { + host: var("PGHOST", "localhost"), + port: var("PGPORT", "5432"), + user: var("PGUSER", "postgres"), + password: var("PGPASSWORD", ""), + database: var("PGDATABASE", "postgres"), + scheme: var("PGC_TEST_SCHEME", "public"), + ssl: false, + file: file.to_string(), + } +} + +#[tokio::test] +#[ignore = "needs a reachable PostgreSQL server; run with --ignored"] +async fn dump_of_a_live_database_round_trips_through_a_file() { + let dir = ScratchDir::new("live-roundtrip"); + let path = dir.path_str("live.dump"); + + let mut dump = Dump::new(env_config(&path)); + dump.process(8).await.expect("dump the live database"); + + let reloaded = Dump::read_from_file(&path) + .await + .expect("read back the dump pgc just wrote"); + assert_eq!(dump.get_info(), reloaded.get_info()); +} + +/// The idempotency contract at the database level: a schema compared against +/// itself needs no migration. +#[tokio::test] +#[ignore = "needs a reachable PostgreSQL server; run with --ignored"] +async fn live_database_compared_against_itself_emits_no_ddl() { + let dir = ScratchDir::new("live-self"); + let path = dir.path_str("live.dump"); + + let mut dump = Dump::new(env_config(&path)); + dump.process(8).await.expect("dump the live database"); + + let from = Dump::read_from_file(&path).await.expect("read FROM"); + let to = Dump::read_from_file(&path).await.expect("read TO"); + + let mut comparer = Comparer::new(from, to, true, false, true, GrantsMode::Full); + comparer.compare().await.expect("compare"); + + let out = dir.path_str("output.sql"); + comparer.save_script(&out).await.expect("save script"); + let script = std::fs::read_to_string(&out).expect("read script"); + + for line in script.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with("--") || line.starts_with("/*") { + continue; + } + panic!("self-comparison of a live database emitted DDL:\n{script}"); + } +} + +#[tokio::test] +#[ignore = "needs a reachable PostgreSQL server; run with --ignored"] +async fn inspect_populates_a_dump_without_writing_a_file() { + let dir = ScratchDir::new("live-inspect"); + let path = dir.path_str("never-written.dump"); + + let mut dump = Dump::new(env_config(&path)); + dump.inspect(8).await.expect("inspect the live database"); + + assert!(!dump.schemas.is_empty(), "inspect must find at least one schema"); + assert!( + !std::path::Path::new(&path).exists(), + "inspect must not write the dump file" + ); + // The clear script is generated from an inspected dump, so it must be + // buildable straight after. + assert!(!dump.generate_clear_script(true, true, false).is_empty()); +} From a8a9ac42a93321628868fdeee3df38dcf4252756 Mon Sep 17 00:00:00 2001 From: nettrash Date: Mon, 3 Aug 2026 18:22:55 +0200 Subject: [PATCH 5/9] #238 --- app/tests/clear_script.rs | 9 +++++-- app/tests/common/mod.rs | 49 ++++++++++++++++++++++++++++++++--- app/tests/compare_pipeline.rs | 28 +++++++++++--------- app/tests/dump_file_format.rs | 23 ++++++++++++---- app/tests/live_database.rs | 20 ++++++-------- 5 files changed, 94 insertions(+), 35 deletions(-) diff --git a/app/tests/clear_script.rs b/app/tests/clear_script.rs index 4fa8c5e..858753c 100644 --- a/app/tests/clear_script.rs +++ b/app/tests/clear_script.rs @@ -112,7 +112,9 @@ fn single_transaction_wraps_the_clear_script() { let mut dump = populated_dump("shop"); dump.types.clear(); - let without_tx = dump.generate_clear_script(false, true, false).to_lowercase(); + let without_tx = dump + .generate_clear_script(false, true, false) + .to_lowercase(); assert!( !without_tx.contains("begin;"), "use_single_transaction=false must not open a transaction:\n{without_tx}" @@ -141,7 +143,10 @@ fn use_comments_false_strips_the_per_statement_commentary() { "use_comments=false left a comment behind: {line}" ); } - assert!(!after_header.contains("---> Drop"), "section banners are dropped"); + assert!( + !after_header.contains("---> Drop"), + "section banners are dropped" + ); // Stripping comments must not strip the statements. assert!(after_header.to_lowercase().contains("drop table if exists")); } diff --git a/app/tests/common/mod.rs b/app/tests/common/mod.rs index ffdeb5b..2b16ac2 100644 --- a/app/tests/common/mod.rs +++ b/app/tests/common/mod.rs @@ -40,10 +40,7 @@ impl ScratchDir { use std::sync::atomic::{AtomicU32, Ordering}; static COUNTER: AtomicU32 = AtomicU32::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( - "pgc-it-{label}-{}-{n}", - std::process::id() - )); + let path = std::env::temp_dir().join(format!("pgc-it-{label}-{}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&path); std::fs::create_dir_all(&path).expect("create scratch dir"); Self { path } @@ -65,6 +62,50 @@ impl Drop for ScratchDir { } } +/// Strip SQL comments so a generated script can be checked for "contains no +/// statements at all". +/// +/// Handles `--` to end-of-line and nested `/* … */` blocks, which the headers +/// and section banners `pgc` emits are built from. It does *not* skip string +/// literals — it does not need to: a script containing a literal necessarily +/// contains a statement, so the caller's emptiness assertion fails either way. +pub fn strip_sql_comments(script: &str) -> String { + let bytes: Vec = script.chars().collect(); + let mut out = String::with_capacity(script.len()); + let mut depth = 0usize; + let mut i = 0; + while i < bytes.len() { + if bytes[i] == '/' && bytes.get(i + 1) == Some(&'*') { + depth += 1; + i += 2; + } else if depth > 0 && bytes[i] == '*' && bytes.get(i + 1) == Some(&'/') { + depth -= 1; + i += 2; + } else if depth > 0 { + i += 1; + } else if bytes[i] == '-' && bytes.get(i + 1) == Some(&'-') { + while i < bytes.len() && bytes[i] != '\n' { + i += 1; + } + } else { + out.push(bytes[i]); + i += 1; + } + } + out +} + +/// Panic with the full script unless it consists purely of comments and +/// whitespace — the shape a no-op migration must have. +pub fn assert_no_ddl(script: &str, context: &str) { + let stripped = strip_sql_comments(script); + let remaining = stripped.trim(); + assert!( + remaining.is_empty(), + "{context} emitted DDL:\n--- statements ---\n{remaining}\n--- full script ---\n{script}" + ); +} + /// Absolute path to a file under the repository's `data/` directory. pub fn data_path(relative: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/app/tests/compare_pipeline.rs b/app/tests/compare_pipeline.rs index 859b110..808a30a 100644 --- a/app/tests/compare_pipeline.rs +++ b/app/tests/compare_pipeline.rs @@ -6,7 +6,7 @@ mod common; -use common::{ScratchDir, populated_dump, table, view}; +use common::{ScratchDir, assert_no_ddl, populated_dump, table, view}; use pgc::comparer::core::Comparer; use pgc::config::grants_mode::GrantsMode; use pgc::dump::core::Dump; @@ -22,7 +22,9 @@ async fn compare_via_files(from: Dump, to: Dump, use_drop: bool, label: &str) -> from.write_to_file(&from_path).expect("write FROM dump"); to.write_to_file(&to_path).expect("write TO dump"); - let from = Dump::read_from_file(&from_path).await.expect("read FROM dump"); + let from = Dump::read_from_file(&from_path) + .await + .expect("read FROM dump"); let to = Dump::read_from_file(&to_path).await.expect("read TO dump"); let mut comparer = Comparer::new(from, to, use_drop, false, true, GrantsMode::Ignore); @@ -44,13 +46,7 @@ async fn comparing_a_dump_against_itself_emits_no_ddl() { ) .await; - for line in script.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with("--") || line.starts_with("/*") { - continue; - } - panic!("self-comparison emitted DDL:\n{script}"); - } + assert_no_ddl(&script, "self-comparison"); } #[tokio::test] @@ -138,7 +134,9 @@ async fn single_transaction_wraps_the_script_in_begin_and_commit() { let script = std::fs::read_to_string(&out_path).expect("read script"); let lower = script.to_lowercase(); - let begin = lower.find("begin;").expect("script must open a transaction"); + let begin = lower + .find("begin;") + .expect("script must open a transaction"); let commit = lower.rfind("commit;").expect("script must commit"); assert!(begin < commit, "begin must precede commit:\n{script}"); } @@ -151,8 +149,14 @@ async fn production_mode_is_opt_in() { let build = |production: bool| async move { let mut to = populated_dump("shop"); to.tables.push(table("app", "invoices", &["id"])); - let mut comparer = - Comparer::new(populated_dump("shop"), to, true, true, true, GrantsMode::Ignore); + let mut comparer = Comparer::new( + populated_dump("shop"), + to, + true, + true, + true, + GrantsMode::Ignore, + ); comparer.set_output_for_production(production); comparer.compare().await.expect("compare"); let dir = ScratchDir::new(if production { "prod-on" } else { "prod-off" }); diff --git a/app/tests/dump_file_format.rs b/app/tests/dump_file_format.rs index a65ac0b..2c46a73 100644 --- a/app/tests/dump_file_format.rs +++ b/app/tests/dump_file_format.rs @@ -37,7 +37,11 @@ async fn write_then_read_round_trips_every_object_kind() { // a dropped hash would silently turn "unchanged" into "recreate". for (before, after) in original.tables.iter().zip(&reloaded.tables) { assert_eq!(before.name, after.name); - assert_eq!(before.hash, after.hash, "table {} hash changed", before.name); + assert_eq!( + before.hash, after.hash, + "table {} hash changed", + before.name + ); } for (before, after) in original.views.iter().zip(&reloaded.views) { assert_eq!(before.hash, after.hash, "view {} hash changed", before.name); @@ -49,7 +53,9 @@ async fn round_trip_of_an_empty_dump_produces_an_empty_dump() { let dir = ScratchDir::new("empty"); let path = dir.path_str("empty.dump"); - empty_dump("blank").write_to_file(&path).expect("write dump"); + empty_dump("blank") + .write_to_file(&path) + .expect("write dump"); let reloaded = Dump::read_from_file(&path).await.expect("read dump"); assert!(reloaded.schemas.is_empty()); @@ -61,7 +67,9 @@ async fn round_trip_of_an_empty_dump_produces_an_empty_dump() { async fn dump_file_is_a_zip_holding_a_single_dump_io_entry() { let dir = ScratchDir::new("layout"); let path = dir.path_str("layout.dump"); - populated_dump("shop").write_to_file(&path).expect("write dump"); + populated_dump("shop") + .write_to_file(&path) + .expect("write dump"); let mut archive = zip::ZipArchive::new(std::fs::File::open(&path).expect("open dump")).expect("read zip"); @@ -71,9 +79,14 @@ async fn dump_file_is_a_zip_holding_a_single_dump_io_entry() { assert_eq!(entry.name(), "dump.io"); let mut json = String::new(); - entry.read_to_string(&mut json).expect("entry is utf-8 json"); + entry + .read_to_string(&mut json) + .expect("entry is utf-8 json"); let parsed: serde_json::Value = serde_json::from_str(&json).expect("entry is valid json"); - assert!(parsed.get("tables").is_some(), "tables key is always present"); + assert!( + parsed.get("tables").is_some(), + "tables key is always present" + ); // `configuration` is `skip_serializing`: connection details, including the // password, must never reach the dump file. assert!( diff --git a/app/tests/live_database.rs b/app/tests/live_database.rs index ad65223..14a693f 100644 --- a/app/tests/live_database.rs +++ b/app/tests/live_database.rs @@ -14,7 +14,7 @@ mod common; -use common::ScratchDir; +use common::{ScratchDir, assert_no_ddl}; use pgc::comparer::core::Comparer; use pgc::config::dump_config::DumpConfig; use pgc::config::grants_mode::GrantsMode; @@ -23,9 +23,8 @@ use pgc::dump::core::Dump; /// Connection details from the standard `PG*` environment variables, with the /// same defaults as the `pgc` CLI. fn env_config(file: &str) -> DumpConfig { - let var = |name: &str, fallback: &str| { - std::env::var(name).unwrap_or_else(|_| fallback.to_string()) - }; + let var = + |name: &str, fallback: &str| std::env::var(name).unwrap_or_else(|_| fallback.to_string()); DumpConfig { host: var("PGHOST", "localhost"), port: var("PGPORT", "5432"), @@ -74,13 +73,7 @@ async fn live_database_compared_against_itself_emits_no_ddl() { comparer.save_script(&out).await.expect("save script"); let script = std::fs::read_to_string(&out).expect("read script"); - for line in script.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with("--") || line.starts_with("/*") { - continue; - } - panic!("self-comparison of a live database emitted DDL:\n{script}"); - } + assert_no_ddl(&script, "self-comparison of a live database"); } #[tokio::test] @@ -92,7 +85,10 @@ async fn inspect_populates_a_dump_without_writing_a_file() { let mut dump = Dump::new(env_config(&path)); dump.inspect(8).await.expect("inspect the live database"); - assert!(!dump.schemas.is_empty(), "inspect must find at least one schema"); + assert!( + !dump.schemas.is_empty(), + "inspect must find at least one schema" + ); assert!( !std::path::Path::new(&path).exists(), "inspect must not write the dump file" From f6615298234d8e2ac66f2d154bcf837468b62dc2 Mon Sep 17 00:00:00 2001 From: nettrash Date: Mon, 3 Aug 2026 19:01:58 +0200 Subject: [PATCH 6/9] #238 --- .github/workflows/rust.yml | 10 ++ app/src/comparer/core.rs | 55 ++++++-- app/src/comparer/mod.rs | 10 ++ app/src/comparer/production.rs | 7 +- app/src/config/core.rs | 70 ++++++++-- app/src/config/dump_config.rs | 72 +++++++++-- app/src/config/grants_mode.rs | 30 +++++ app/src/config/mod.rs | 11 ++ app/src/dump/acl.rs | 49 ++++++- app/src/dump/cast.rs | 2 + app/src/dump/collation.rs | 2 + app/src/dump/column_dependent.rs | 11 ++ app/src/dump/core.rs | 201 ++++++++++++++++++++++++----- app/src/dump/default_privilege.rs | 8 ++ app/src/dump/event_trigger.rs | 14 +- app/src/dump/extension.rs | 7 +- app/src/dump/fdw.rs | 7 + app/src/dump/foreign_table.rs | 3 + app/src/dump/mod.rs | 22 ++++ app/src/dump/operator.rs | 2 + app/src/dump/pg_enum.rs | 22 +++- app/src/dump/pg_type.rs | 147 ++++++++++++++------- app/src/dump/publication.rs | 6 + app/src/dump/routine.rs | 11 +- app/src/dump/rule.rs | 5 + app/src/dump/schema.rs | 8 +- app/src/dump/sequence.rs | 66 +++++++--- app/src/dump/statistic.rs | 2 + app/src/dump/table.rs | 91 +++++++++---- app/src/dump/table_column.rs | 169 ++++++++++++++++-------- app/src/dump/table_constraint.rs | 45 +++++-- app/src/dump/table_index.rs | 27 +++- app/src/dump/table_policy.rs | 26 ++-- app/src/dump/table_trigger.rs | 17 ++- app/src/dump/text_search.rs | 3 + app/src/dump/view.rs | 14 +- app/src/lib.rs | 38 ++++++ app/src/utils/mod.rs | 6 + app/src/utils/string_extensions.rs | 24 ++++ 39 files changed, 1057 insertions(+), 263 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 09cb435..9fadc32 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -42,6 +42,16 @@ jobs: run: cd ${{ github.workspace }}/app ; cargo build --verbose + # Documents private items too: pgc ships as a binary, so most of the code + # a contributor reads (comparer::core is 4 pub fns out of 47) is private, + # and a broken intra-doc link there is just as wrong. -D warnings turns + # unresolved links into a build failure instead of silent plain text. + - name: Check docs + env: + RUSTDOCFLAGS: "-D warnings" + run: + cd ${{ github.workspace }}/app ; + cargo doc --no-deps --lib --document-private-items - name: Run tests run: cd ${{ github.workspace }}/app ; diff --git a/app/src/comparer/core.rs b/app/src/comparer/core.rs index 2ddf0a4..0685e85 100644 --- a/app/src/comparer/core.rs +++ b/app/src/comparer/core.rs @@ -1,3 +1,19 @@ +//! The [`Comparer`] — reads two [`Dump`]s and emits the +//! migration SQL that makes `FROM` equal to `TO`. +//! +//! Output is assembled from several ordered buffers rather than one string, +//! because PostgreSQL dependency rules do not match the order objects are +//! compared in. They are concatenated as: +//! +//! ```text +//! script → sequence_post → type_post → enum_post → trigger_post +//! ``` +//! +//! The comparer also tracks cross-cutting state that individual passes need: +//! `dropped_views` and `recreated_tables` coordinate drop/recreate sequencing, +//! and `serial_columns` keeps owned sequences from being emitted independently +//! of their table. + use crate::comparer::production::{self, ChildRef, PartitionContext}; use crate::config::grants_mode::GrantsMode; use crate::dump::acl; @@ -15,8 +31,8 @@ use std::{ io::{Error, Write}, }; -// This is a Dump comparer that generates a script comparing two PostgreSQL dumps. -// The result script, if it will be applied on "from" dump database, will make it equal to "to" dump database. +/// This is a Dump comparer that generates a script comparing two PostgreSQL dumps. +/// The result script, if it will be applied on "from" dump database, will make it equal to "to" dump database. pub struct Comparer { // The dump to compare from from: Dump, @@ -58,7 +74,7 @@ pub struct Comparer { } impl Comparer { - // Creates a new Comparer with the given dumps + /// Creates a new Comparer with the given dumps pub fn new( from: Dump, to: Dump, @@ -108,12 +124,35 @@ impl Comparer { /// built concurrently (partition-aware), foreign keys are added `NOT VALID` /// then validated after commit, and indexes are dropped concurrently — all /// post-commit statements are emitted after the main transaction. + /// Returns `&mut Self` so it can be chained after construction. + /// + /// ``` + /// # use pgc::comparer::core::Comparer; + /// # use pgc::config::dump_config::DumpConfig; + /// # use pgc::config::grants_mode::GrantsMode; + /// # use pgc::dump::core::Dump; + /// # let config = || DumpConfig { + /// # host: "localhost".to_string(), port: "5432".to_string(), + /// # user: "postgres".to_string(), password: String::new(), + /// # database: "shop".to_string(), scheme: "public".to_string(), + /// # ssl: false, file: String::new(), + /// # }; + /// let mut comparer = Comparer::new( + /// Dump::new(config()), + /// Dump::new(config()), + /// true, + /// true, + /// true, + /// GrantsMode::Ignore, + /// ); + /// comparer.set_output_for_production(true); + /// ``` pub fn set_output_for_production(&mut self, value: bool) -> &mut Self { self.output_for_production = value; self } - // Compare dumps and generate the script + /// Compare dumps and generate the script pub async fn compare(&mut self) -> Result<(), Error> { if self.output_for_production { // The statements that cannot run inside a transaction block are @@ -657,7 +696,7 @@ impl Comparer { Self::kahn_toposort_detect_cycle(n, depends_on, sort_key).0 } - /// Like [`kahn_toposort`], but also returns the set of nodes that + /// Like [`Comparer::kahn_toposort`], but also returns the set of nodes that /// could not be ordered acyclically — i.e. nodes whose in-degree /// never reached zero during the BFS. Those nodes still appear in /// the returned `Vec` (appended in `sort_key` order so the result @@ -671,7 +710,7 @@ impl Comparer { /// Kahn was unable to remove — that includes nodes *blocked by* /// a cycle (e.g. `C` in `A↔B + A→C`), not only nodes *in* a /// cycle. Callers that need to act on *true* cycle members - /// should pair this with [`strongly_connected_components`] — + /// should pair this with [`Comparer::strongly_connected_components`] — /// `topo_order_within_subset_detect_cycle` does exactly that /// (PR #198 review). fn kahn_toposort_detect_cycle( @@ -980,7 +1019,7 @@ impl Comparer { dependent_views } - // Saves the generated script to a file + /// Saves the generated script to a file pub async fn save_script(&self, output: &str) -> Result<(), Error> { let mut file = File::create(output)?; file.write_all(self.get_script().as_bytes())?; @@ -2636,7 +2675,7 @@ impl Comparer { /// restricted to `subset` is acyclic the cyclic set is empty. /// /// PR #198 review: the cyclic set is computed via - /// [`strongly_connected_components`] (Tarjan), not the raw Kahn + /// [`Comparer::strongly_connected_components`] (Tarjan), not the raw Kahn /// remainder. The remainder would include nodes merely *blocked /// by* a cycle (e.g. `C` in `A↔B + A→C`), and treating them as /// cycle participants would drop FKs that are not actually in diff --git a/app/src/comparer/mod.rs b/app/src/comparer/mod.rs index 3487ef1..754e20c 100644 --- a/app/src/comparer/mod.rs +++ b/app/src/comparer/mod.rs @@ -1,3 +1,13 @@ +//! Diff generation: turns two [`Dump`](crate::dump::core::Dump)s into the +//! migration SQL that makes the `FROM` schema equal to the `TO` schema. +//! +//! - [`core`] — the [`Comparer`](core::Comparer) itself, which walks every object +//! kind in dependency order and accumulates the ordered script buffers. +//! - [`production`] — rewrites a finished script so it can be applied to a live +//! database with minimal locking (`--output-for-production`). +//! - `scanner` (private) — the shared SQL-aware scanner both use to skip string literals, +//! quoted identifiers, comments and dollar-quoted bodies. + pub mod core; pub mod production; mod scanner; diff --git a/app/src/comparer/production.rs b/app/src/comparer/production.rs index 104526f..16bf451 100644 --- a/app/src/comparer/production.rs +++ b/app/src/comparer/production.rs @@ -418,10 +418,11 @@ const ALTER_GUARDS: &[AlterGuard] = &[ /// Make a production migration script re-runnable by injecting idempotency /// guards into the DDL forms PostgreSQL supports them for. Applied once, at the -/// end of [`Comparer::compare`], only when `output_for_production` is set. +/// end of [`crate::comparer::core::Comparer::compare`], only when +/// `output_for_production` is set. /// -/// The scan is literal-, comment- and dollar-quote-aware (mirroring -/// [`crate::comparer::scanner::strip_comments_and_collapse`]) so a keyword that +/// The scan is literal-, comment- and dollar-quote-aware (mirroring the private +/// `comparer::scanner::strip_comments_and_collapse`) so a keyword that /// appears inside a string literal, quoted identifier, or comment is never /// mistaken for a statement to rewrite — including the `-- ` line-commented /// drops emitted when `use_drop` is off, which must stay untouched. diff --git a/app/src/config/core.rs b/app/src/config/core.rs index 00fe398..0dfcc26 100644 --- a/app/src/config/core.rs +++ b/app/src/config/core.rs @@ -1,35 +1,79 @@ +//! The `pgc.conf` parser. +//! +//! The format is one `KEY=VALUE` per line; blank lines and `#` comments are +//! skipped, keys are matched case-insensitively, and values keep their original +//! case. Unknown keys are an error rather than a silent no-op, so a typo surfaces +//! at load time instead of as a baffling connection failure later. + use crate::config::dump_config::DumpConfig; use crate::config::grants_mode::GrantsMode; -// Configuration file representation. +/// Configuration file representation. #[derive(Debug, Clone)] pub struct Config { - // From Dump Configuration + /// From Dump Configuration pub from: DumpConfig, - // To Dump Configuration + /// To Dump Configuration pub to: DumpConfig, - // Output file name for the comparison result + /// Output file name for the comparison result pub output: String, - // Whether to use DROP statements in the output + /// Whether to use DROP statements in the output pub use_drop: bool, - // True - if explicit begin...commit statement has to be added into resulting diff file; False - otherwise + /// True - if explicit begin...commit statement has to be added into resulting diff file; False - otherwise pub use_single_transaction: bool, - // Whether to include comments in the output script + /// Whether to include comments in the output script pub use_comments: bool, - // How to handle grants (privileges) during comparison + /// How to handle grants (privileges) during comparison pub grants_mode: GrantsMode, - // Maximum number of connections in the PostgreSQL connection pool + /// Maximum number of connections in the PostgreSQL connection pool pub max_connections: u32, - // Whether to emit a migration script that is safe/convenient to run on a - // live production database (concurrent index builds, partition-aware index - // creation, NOT VALID + VALIDATE for foreign keys, concurrent index drops, - // and a split transaction so the concurrent statements run outside it). + /// Whether to emit a migration script that is safe/convenient to run on a + /// live production database (concurrent index builds, partition-aware index + /// creation, NOT VALID + VALIDATE for foreign keys, concurrent index drops, + /// and a split transaction so the concurrent statements run outside it). pub output_for_production: bool, } impl Config { /// Load configuration from `file`. Returns a descriptive error instead of /// panicking on malformed input so callers can format it however they want. + /// # Examples + /// + /// ``` + /// use pgc::config::core::Config; + /// use pgc::config::grants_mode::GrantsMode; + /// + /// let path = std::env::temp_dir().join("pgc-doctest-load.conf"); + /// // A leading `#` is a comment. (Written with `\n` rather than a + /// // multi-line literal because rustdoc treats a `#` at the start of a + /// // doctest line as a hidden-line marker.) + /// std::fs::write( + /// &path, + /// "# comments and blank lines are ignored\n\ + /// \n\ + /// FROM_HOST=old.example\n\ + /// TO_HOST=new.example\n\ + /// FROM_SCHEME=public|app\n\ + /// GRANTS_MODE=full\n", + /// )?; + /// + /// let config = Config::load(path.to_str().unwrap())?; + /// assert_eq!(config.from.host, "old.example"); + /// assert_eq!(config.from.scheme, "public|app"); + /// assert_eq!(config.grants_mode, GrantsMode::Full); + /// + /// // Keys that were not set fall back to their documented defaults. + /// assert_eq!(config.from.port, "5432"); + /// assert_eq!(config.output, "data.out"); + /// assert_eq!(config.max_connections, 16); + /// + /// // A typo is an error rather than a silently ignored line. + /// std::fs::write(&path, "FROM_HOST=a\nFROM_FLAVOUR=vanilla\n")?; + /// assert!(Config::load(path.to_str().unwrap()).is_err()); + /// + /// std::fs::remove_file(&path)?; + /// # Ok::<(), Box>(()) + /// ``` pub fn load(file: &str) -> Result { let binding = std::fs::read_to_string(file) .map_err(|e| format!("Error reading configuration file {file}: {e}"))?; diff --git a/app/src/config/dump_config.rs b/app/src/config/dump_config.rs index 033ac34..11f495e 100644 --- a/app/src/config/dump_config.rs +++ b/app/src/config/dump_config.rs @@ -1,28 +1,58 @@ +//! Connection details and output path for one side of a comparison. +//! +//! Note that [`DumpConfig`] is `skip_serializing` on +//! [`Dump`](crate::dump::core::Dump): the password must never reach a dump file. + use serde::{Deserialize, Serialize}; -// This is a database dump configuration structure. +/// This is a database dump configuration structure. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DumpConfig { - // Database host + /// Database host pub host: String, - // Database port + /// Database port pub port: String, - // Database user name + /// Database user name pub user: String, - // Database user password + /// Database user password pub password: String, - // Database name + /// Database name pub database: String, - // Schema name. Mask allowed. For example: sche* + /// Schema name. Mask allowed. For example: sche* pub scheme: String, - // Flag of SSL usage + /// Flag of SSL usage pub ssl: bool, - // Dump file name + /// Dump file name pub file: String, } impl DumpConfig { - // Returns the connection string for the database. + /// Returns the connection string for the database. + /// # Examples + /// + /// ``` + /// use pgc::config::dump_config::DumpConfig; + /// + /// let config = DumpConfig { + /// host: "db.example".to_string(), + /// port: "5432".to_string(), + /// user: "alice".to_string(), + /// password: "s3cret".to_string(), + /// database: "shop".to_string(), + /// scheme: "public".to_string(), + /// ssl: true, + /// file: "dump.from".to_string(), + /// }; + /// + /// assert_eq!( + /// config.get_connection_string(), + /// "postgres://alice:s3cret@db.example:5432/shop?sslmode=require" + /// ); + /// ``` + /// + /// This string contains the password — use + /// [`get_masked_connection_string`](Self::get_masked_connection_string) + /// for anything that gets printed or logged. pub fn get_connection_string(&self) -> String { format!( "postgres://{}:{}@{}:{}/{}?sslmode={}", @@ -35,7 +65,27 @@ impl DumpConfig { ) } - // Returns a masked connection string for the database. + /// Returns a masked connection string for the database. + /// # Examples + /// + /// ``` + /// use pgc::config::dump_config::DumpConfig; + /// + /// let config = DumpConfig { + /// host: "db.example".to_string(), + /// port: "5432".to_string(), + /// user: "alice".to_string(), + /// password: "s3cret".to_string(), + /// database: "shop".to_string(), + /// scheme: "public".to_string(), + /// ssl: false, + /// file: "dump.from".to_string(), + /// }; + /// + /// let masked = config.get_masked_connection_string(); + /// assert_eq!(masked, "postgres://*:*@db.example:5432/shop?sslmode=disable"); + /// assert!(!masked.contains("s3cret")); + /// ``` pub fn get_masked_connection_string(&self) -> String { format!( "postgres://*:*@{}:{}/{}?sslmode={}", diff --git a/app/src/config/grants_mode.rs b/app/src/config/grants_mode.rs index 67c6293..92baf5c 100644 --- a/app/src/config/grants_mode.rs +++ b/app/src/config/grants_mode.rs @@ -1,9 +1,39 @@ +//! How privilege differences are handled — the `--grants-mode` flag. + use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; /// Controls how grants (privileges) are handled during comparison. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +/// +/// Parsed from the `--grants-mode` flag or the `GRANTS_MODE` config key, and +/// rendered back with [`Display`](std::fmt::Display). +/// +/// ``` +/// use pgc::config::grants_mode::GrantsMode; +/// +/// assert_eq!("ignore".parse(), Ok(GrantsMode::Ignore)); +/// assert_eq!("full".parse(), Ok(GrantsMode::Full)); +/// +/// // The additive mode accepts three spellings. +/// for spelling in ["addonly", "add_only", "add-only"] { +/// assert_eq!(spelling.parse(), Ok(GrantsMode::AddOnly)); +/// } +/// +/// // Matching is case-insensitive, and Display round-trips. +/// assert_eq!("FULL".parse::().unwrap().to_string(), "full"); +/// +/// assert!("sometimes".parse::().is_err()); +/// ``` +/// +/// The default is [`GrantsMode::Ignore`] — privilege diffs are not emitted +/// unless asked for. +/// +/// ``` +/// # use pgc::config::grants_mode::GrantsMode; +/// assert_eq!(GrantsMode::default(), GrantsMode::Ignore); +/// ``` pub enum GrantsMode { /// Ignore grants entirely (default, current behaviour). #[default] diff --git a/app/src/config/mod.rs b/app/src/config/mod.rs index 2bad3f7..9556762 100644 --- a/app/src/config/mod.rs +++ b/app/src/config/mod.rs @@ -1,3 +1,14 @@ +//! Configuration types shared by the CLI and the `pgc.conf` file. +//! +//! - [`core`] — [`Config`](core::Config), the `KEY=VALUE` parser for `pgc.conf`. +//! - [`dump_config`] — [`DumpConfig`](dump_config::DumpConfig), the connection +//! details for one side of a comparison. +//! - [`grants_mode`] — [`GrantsMode`](grants_mode::GrantsMode), the +//! `ignore` / `addonly` / `full` privilege-handling selector. +//! +//! `--config ` takes precedence over every other flag and runs the full +//! chain: dump `FROM`, dump `TO`, then compare. + pub mod core; pub mod dump_config; pub mod grants_mode; diff --git a/app/src/dump/acl.rs b/app/src/dump/acl.rs index 6277cf6..907c7c8 100644 --- a/app/src/dump/acl.rs +++ b/app/src/dump/acl.rs @@ -1,3 +1,15 @@ +//! Access control lists — parsing PostgreSQL `aclitem` strings and turning the +//! difference between two of them into `GRANT` / `REVOKE` statements. +//! +//! An `aclitem` renders as `grantee=privileges/grantor`, where each privilege is +//! a single character (`r` SELECT, `a` INSERT, `w` UPDATE, `d` DELETE, +//! `D` TRUNCATE, `x` REFERENCES, `t` TRIGGER, `X` EXECUTE, `U` USAGE, `C` CREATE, +//! `c` CONNECT, `T` TEMPORARY) and a trailing `*` means WITH GRANT OPTION. An +//! empty grantee means `PUBLIC`. +//! +//! What actually gets emitted depends on +//! [`GrantsMode`](crate::config::grants_mode::GrantsMode). + use crate::utils::string_extensions::StringExt; /// Represents a single parsed PostgreSQL ACL entry. @@ -54,7 +66,28 @@ fn find_unquoted(s: &str, target: u8) -> Option { impl AclEntry { /// Parse a single ACL item string like `"user=arwdDxt/owner"`. Quoted /// role names with embedded `=` or `/` (e.g. `"weird=name"=r/owner`) are - /// handled correctly via [`find_unquoted`]. + /// handled correctly via the private `find_unquoted` scanner. + /// # Examples + /// + /// ``` + /// use pgc::dump::acl::AclEntry; + /// + /// let entry = AclEntry::parse("alice=arwd/postgres").unwrap(); + /// assert_eq!(entry.grantee, "alice"); + /// assert_eq!(entry.privileges, "arwd"); + /// assert_eq!(entry.grantor, "postgres"); + /// + /// // An empty grantee is PUBLIC. + /// assert_eq!(AclEntry::parse("=r/postgres").unwrap().grantee, ""); + /// + /// // Separators inside a quoted role name do not confuse the parser. + /// let quoted = AclEntry::parse("\"weird=name\"=r/postgres").unwrap(); + /// assert_eq!(quoted.grantee, "\"weird=name\""); + /// assert_eq!(quoted.privileges, "r"); + /// + /// // An item granting nothing is not an entry. + /// assert!(AclEntry::parse("alice=/postgres").is_none()); + /// ``` pub fn parse(acl_item: &str) -> Option { let eq_pos = find_unquoted(acl_item, b'=')?; // Search for `/` only after the `=` so a slash inside the grantee @@ -156,6 +189,20 @@ impl AclEntry { /// Generate GRANT statement(s) for this ACL entry on the given object. /// Privileges with and without GRANT OPTION are emitted as separate statements. + /// # Examples + /// + /// ``` + /// use pgc::dump::acl::AclEntry; + /// + /// let script = AclEntry::get_grant_script("alice=rw/postgres", "TABLE", "app.orders"); + /// assert!(script.contains("GRANT SELECT, UPDATE ON TABLE app.orders TO alice;")); + /// + /// // Privileges valid for one object kind are dropped for another: a + /// // sequence has no TRIGGER privilege, so `t` is ignored. + /// let seq = AclEntry::get_grant_script("alice=rt/postgres", "SEQUENCE", "app.s"); + /// assert!(seq.contains("SELECT")); + /// assert!(!seq.contains("TRIGGER")); + /// ``` pub fn get_grant_script(acl_item: &str, object_kind: &str, object_name: &str) -> String { let entry = match AclEntry::parse(acl_item) { Some(e) => e, diff --git a/app/src/dump/cast.rs b/app/src/dump/cast.rs index 3c54bba..d5c5226 100644 --- a/app/src/dump/cast.rs +++ b/app/src/dump/cast.rs @@ -1,3 +1,5 @@ +//! User-defined casts (`pg_cast`) — `CREATE CAST`. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/app/src/dump/collation.rs b/app/src/dump/collation.rs index 7e53664..0bcb246 100644 --- a/app/src/dump/collation.rs +++ b/app/src/dump/collation.rs @@ -1,3 +1,5 @@ +//! Collations (`pg_collation`) — `CREATE COLLATION`. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/app/src/dump/column_dependent.rs b/app/src/dump/column_dependent.rs index 8e7cf92..e135cff 100644 --- a/app/src/dump/column_dependent.rs +++ b/app/src/dump/column_dependent.rs @@ -1,3 +1,14 @@ +//! Column → dependent-object edges read from `pg_depend`. +//! +//! When a column is dropped, PostgreSQL silently CASCADEs the drop to the indexes, +//! constraints and policies attached to it. The text-based scanner cannot see +//! these: the dependent's definition references the *column*, not the routine +//! whose change triggered the CASCADE chain. Recording the edges at dump time lets +//! the comparer re-emit them afterwards (issue #188). +//! +//! Empty in dumps written before that issue; the comparer then degrades to the +//! previously documented "run `pgc compare` twice" workaround. + use serde::{Deserialize, Serialize}; /// Kind of database object that depends on a column. When the column is diff --git a/app/src/dump/core.rs b/app/src/dump/core.rs index d44ad76..ddba458 100644 --- a/app/src/dump/core.rs +++ b/app/src/dump/core.rs @@ -1,3 +1,16 @@ +//! The [`Dump`] snapshot: every object `pgc` knows how to compare, for one +//! database, plus the code that fills it and moves it to and from disk. +//! +//! The private `Dump::fill` runs the per-object-kind queries in parallel over a single +//! [`PgPool`]. Connection budgeting there is a hard correctness concern — see +//! `FILL_SIBLING_BRANCH_COUNT` and the `fill_try_join!` macro at the top of this +//! file. +//! +//! On disk a dump is a zip archive holding one `dump.io` entry with the +//! JSON-serialized `Dump`. Fields added after the initial release carry +//! `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so dumps written by +//! older `pgc` versions stay readable; preserve that when adding object kinds. + use crate::dump::cast::Cast; use crate::dump::collation::Collation; use crate::dump::column_dependent::{ColumnDependent, ColumnDependentKind}; @@ -39,7 +52,7 @@ use zip::write::SimpleFileOptions; /// possible. /// /// This constant is statically asserted to equal the actual arity of the -/// [`fill_try_join!`] invocation in [`Dump::fill`]; adding or removing a +/// `fill_try_join!` invocation in [`Dump::fill`]; adding or removing a /// branch without updating this value is a compile error. pub(crate) const FILL_SIBLING_BRANCH_COUNT: u32 = 13; @@ -70,109 +83,132 @@ macro_rules! fill_try_join { }}; } -// This file defines the Dump struct and its serialization/deserialization logic. +/// This file defines the Dump struct and its serialization/deserialization logic. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Dump { - // Configuration of the dump. + /// Configuration of the dump. #[serde(skip_serializing, skip_deserializing)] pub configuration: DumpConfig, - // List of schemas in the dump. + /// List of schemas in the dump. pub schemas: Vec, - // List of extensions in the dump. + /// List of extensions in the dump. pub extensions: Vec, - // List of PostgreSQL types in the dump. + /// List of PostgreSQL types in the dump. pub types: Vec, - // List of PostgreSQL enums in the dump. + /// List of PostgreSQL enums in the dump. pub enums: Vec, - // List of sequences in the dump. + /// List of sequences in the dump. pub sequences: Vec, - // List of routines in the dump. + /// List of routines in the dump. pub routines: Vec, - // List of tables in the dump. + /// List of tables in the dump. pub tables: Vec, - // List of views in the dump. + /// List of views in the dump. pub views: Vec, - // List of foreign tables in the dump. + /// List of foreign tables in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub foreign_tables: Vec, - // List of extended statistics in the dump. + /// List of extended statistics in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub statistics: Vec, - // List of rules in the dump. + /// List of rules in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rules: Vec, - // List of event triggers in the dump. + /// List of event triggers in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub event_triggers: Vec, - // List of collations in the dump. + /// List of collations in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub collations: Vec, - // List of text search configurations in the dump. + /// List of text search configurations in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ts_configs: Vec, - // List of text search dictionaries in the dump. + /// List of text search dictionaries in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ts_dicts: Vec, - // List of user-defined casts in the dump. + /// List of user-defined casts in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub casts: Vec, - // List of user-defined operators in the dump. + /// List of user-defined operators in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub operators: Vec, - // List of default ACL entries in the dump. + /// List of default ACL entries in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub default_privileges: Vec, - // List of publications in the dump. + /// List of publications in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub publications: Vec, - // List of subscriptions in the dump. + /// List of subscriptions in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub subscriptions: Vec, - // List of foreign-data wrappers in the dump. + /// List of foreign-data wrappers in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub foreign_data_wrappers: Vec, - // List of foreign servers in the dump. + /// List of foreign servers in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub foreign_servers: Vec, - // List of user mappings in the dump. + /// List of user mappings in the dump. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub user_mappings: Vec, - // Column → dependent-object edges from pg_depend. Powers Phase 7's - // restoration of secondary dependents (indexes/constraints/policies) - // that PostgreSQL silently CASCADE-drops along with a generated - // column. Empty in pre-issue-#188 dumps; the comparer degrades to the - // previous behaviour (the documented "run pgc compare twice" - // workaround) when the field is absent. + /// Column → dependent-object edges from pg_depend. Powers Phase 7's + /// restoration of secondary dependents (indexes/constraints/policies) + /// that PostgreSQL silently CASCADE-drops along with a generated + /// column. Empty in pre-issue-#188 dumps; the comparer degrades to the + /// previous behaviour (the documented "run pgc compare twice" + /// workaround) when the field is absent. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub column_dependents: Vec, } impl Dump { - // Create a new Dump instance. + /// Create a new Dump instance. + /// An empty dump bound to `config`. Nothing is read from the database until + /// [`process`](Self::process) or [`inspect`](Self::inspect) is called. + /// + /// # Examples + /// + /// ``` + /// use pgc::config::dump_config::DumpConfig; + /// use pgc::dump::core::Dump; + /// + /// let dump = Dump::new(DumpConfig { + /// host: "localhost".to_string(), + /// port: "5432".to_string(), + /// user: "postgres".to_string(), + /// password: String::new(), + /// database: "shop".to_string(), + /// scheme: "public".to_string(), + /// ssl: false, + /// file: "shop.dump".to_string(), + /// }); + /// + /// assert!(dump.schemas.is_empty()); + /// assert!(dump.tables.is_empty()); + /// ``` pub fn new(config: DumpConfig) -> Self { Dump { configuration: config, @@ -203,7 +239,33 @@ impl Dump { } } - // Retrieve the dump from the configuration. + /// Retrieve the dump from the configuration. + /// Connect, introspect every object kind, and write the dump to + /// `config.file`. This is the `pgc --command dump` path. + /// + /// `max_connections` sizes the pool the parallel fill runs over. + /// + /// # Examples + /// + /// ```no_run + /// # use pgc::config::dump_config::DumpConfig; + /// # use pgc::dump::core::Dump; + /// # fn main() -> Result<(), std::io::Error> { + /// # tokio::runtime::Runtime::new()?.block_on(async { + /// # let config = DumpConfig { + /// # host: "localhost".to_string(), port: "5432".to_string(), + /// # user: "postgres".to_string(), password: String::new(), + /// # database: "shop".to_string(), scheme: "public".to_string(), + /// # ssl: false, file: "shop.dump".to_string(), + /// # }; + /// let mut dump = Dump::new(config); + /// dump.process(16).await?; + /// println!("{}", dump.get_info()); + /// # Ok::<(), std::io::Error>(()) + /// # })?; + /// # Ok(()) + /// # } + /// ``` pub async fn process(&mut self, max_connections: u32) -> Result<(), Error> { if max_connections < FILL_SIBLING_BRANCH_COUNT { eprintln!( @@ -244,6 +306,45 @@ impl Dump { /// Buffering keeps peak memory bounded (no full intermediate `String` /// copy of the payload) while still amortising the compressor overhead. /// Pairs with [`Dump::read_from_file`]. + /// The connection configuration is deliberately *not* written, so a dump + /// file never carries a password. + /// + /// # Examples + /// + /// ``` + /// use pgc::config::dump_config::DumpConfig; + /// use pgc::dump::core::Dump; + /// use pgc::dump::schema::Schema; + /// + /// # fn main() -> Result<(), std::io::Error> { + /// # tokio::runtime::Runtime::new()?.block_on(async { + /// # let config = DumpConfig { + /// # host: "localhost".to_string(), port: "5432".to_string(), + /// # user: "postgres".to_string(), password: "hunter2".to_string(), + /// # database: "shop".to_string(), scheme: "public".to_string(), + /// # ssl: false, file: String::new(), + /// # }; + /// let path = std::env::temp_dir().join("pgc-doctest-write.dump"); + /// let path = path.to_str().unwrap(); + /// + /// let mut dump = Dump::new(config); + /// dump.schemas.push(Schema::new("app".to_string(), "app".to_string(), None)); + /// dump.write_to_file(path)?; + /// + /// let reloaded = Dump::read_from_file(path).await?; + /// assert_eq!(reloaded.schemas.len(), 1); + /// + /// // The credentials did not survive the round-trip, because they were + /// // never written: the reloaded dump carries `DumpConfig::default()`. + /// assert_ne!(reloaded.configuration.password, "hunter2"); + /// assert_eq!(reloaded.configuration.password, DumpConfig::default().password); + /// + /// std::fs::remove_file(path)?; + /// # Ok::<(), std::io::Error>(()) + /// # })?; + /// # Ok(()) + /// # } + /// ``` pub fn write_to_file(&self, path: &str) -> Result<(), Error> { const WRITE_BUF_BYTES: usize = 256 * 1024; let file = File::create(path)?; @@ -3379,7 +3480,7 @@ impl Dump { } } - // Read a dump from a file and deserialize it. + /// Read a dump from a file and deserialize it. pub async fn read_from_file(file: &str) -> Result { let file = File::open(file)?; let mut zip = zip::ZipArchive::new(file)?; @@ -3441,6 +3542,36 @@ impl Dump { /// The drop order respects dependencies: views (topologically sorted by /// table_relation), tables (with foreign keys dropped first), routines, /// sequences, types/enums, extensions, schemas. + /// `use_cascade` is destructive across schema boundaries: `CASCADE` can drop + /// dependents outside the selected schemas. Without it, the ordering above + /// is the only guarantee, and unresolved cross-schema dependencies make the + /// script fail rather than silently over-drop. + /// + /// ``` + /// # use pgc::config::dump_config::DumpConfig; + /// use pgc::dump::core::Dump; + /// use pgc::dump::schema::Schema; + /// + /// # let config = DumpConfig { + /// # host: "localhost".to_string(), port: "5432".to_string(), + /// # user: "postgres".to_string(), password: String::new(), + /// # database: "shop".to_string(), scheme: "public".to_string(), + /// # ssl: false, file: String::new(), + /// # }; + /// let mut dump = Dump::new(config); + /// dump.schemas.push(Schema::new("app".to_string(), "app".to_string(), None)); + /// + /// let script = dump.generate_clear_script( + /// true, // use_single_transaction + /// true, // use_comments + /// false, // use_cascade + /// ); + /// + /// assert!(script.contains("begin;")); + /// assert!(script.contains("drop schema if exists app;")); + /// assert!(script.contains("commit;")); + /// assert!(!script.to_lowercase().contains("cascade")); + /// ``` pub fn generate_clear_script( &self, use_single_transaction: bool, diff --git a/app/src/dump/default_privilege.rs b/app/src/dump/default_privilege.rs index 8904570..b06f5fb 100644 --- a/app/src/dump/default_privilege.rs +++ b/app/src/dump/default_privilege.rs @@ -1,3 +1,11 @@ +//! Default privileges (`pg_default_acl`) — the +//! `ALTER DEFAULT PRIVILEGES … GRANT … TO …` statements that decide what ACL a +//! newly created object starts with. +//! +//! This is why [`Comparer`](crate::comparer::core::Comparer) tracks +//! `recreated_tables`: a table dropped and recreated during a migration inherits +//! these defaults, so re-emitting the same grants would be redundant. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/app/src/dump/event_trigger.rs b/app/src/dump/event_trigger.rs index 8de4201..a4ede07 100644 --- a/app/src/dump/event_trigger.rs +++ b/app/src/dump/event_trigger.rs @@ -1,3 +1,5 @@ +//! Event triggers (`pg_event_trigger`) — database-wide `CREATE EVENT TRIGGER`. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -11,11 +13,15 @@ fn escape_single_quotes(value: &str) -> String { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EventTrigger { pub name: String, - pub event: String, // e.g. "ddl_command_start", "ddl_command_end", "sql_drop", "table_rewrite" - pub function_name: String, // Fully-qualified function name + /// e.g. "ddl_command_start", "ddl_command_end", "sql_drop", "table_rewrite" + pub event: String, + /// Fully-qualified function name + pub function_name: String, + /// Filter tags (e.g. "CREATE TABLE", "DROP TABLE") #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tags: Vec, // Filter tags (e.g. "CREATE TABLE", "DROP TABLE") - pub enabled: String, // O=origin/local, D=disabled, R=replica, A=always + pub tags: Vec, + /// O=origin/local, D=disabled, R=replica, A=always + pub enabled: String, pub owner: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub comment: Option, diff --git a/app/src/dump/extension.rs b/app/src/dump/extension.rs index f690f00..c82b26e 100644 --- a/app/src/dump/extension.rs +++ b/app/src/dump/extension.rs @@ -1,8 +1,13 @@ +//! Installed extensions (`pg_extension`) — `CREATE EXTENSION`. +//! +//! Only the name, version and schema are compared; an extension's own objects are +//! owned by the extension and are never emitted individually. + use serde::{Deserialize, Serialize}; use crate::utils::string_extensions::StringExt; -// This is an information about a PostgreSQL extension. +/// This is an information about a PostgreSQL extension. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Extension { /// Name of the extension diff --git a/app/src/dump/fdw.rs b/app/src/dump/fdw.rs index e15a287..ab71aec 100644 --- a/app/src/dump/fdw.rs +++ b/app/src/dump/fdw.rs @@ -1,3 +1,10 @@ +//! Foreign-data infrastructure: [`ForeignDataWrapper`] (`pg_foreign_data_wrapper`), +//! [`ForeignServer`] (`pg_foreign_server`) and [`UserMapping`] (`pg_user_mapping`). +//! +//! They are ordered by dependency — a server needs its wrapper, a user mapping +//! needs its server — and [`ForeignTable`](super::foreign_table::ForeignTable) +//! needs all three. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/app/src/dump/foreign_table.rs b/app/src/dump/foreign_table.rs index 0f4928d..5d33d1b 100644 --- a/app/src/dump/foreign_table.rs +++ b/app/src/dump/foreign_table.rs @@ -1,3 +1,6 @@ +//! Foreign tables (`pg_foreign_table`) — `CREATE FOREIGN TABLE`, including the +//! per-column definitions and the `OPTIONS (…)` attached to each. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/app/src/dump/mod.rs b/app/src/dump/mod.rs index d095f9c..4d59b66 100644 --- a/app/src/dump/mod.rs +++ b/app/src/dump/mod.rs @@ -1,3 +1,25 @@ +//! PostgreSQL introspection: one module per catalog object kind, plus +//! [`core`] which owns the [`Dump`](core::Dump) snapshot and orchestrates the +//! parallel fill. +//! +//! Nearly every object module follows the same shape, so learning one teaches +//! the rest: +//! +//! | Item | Role | +//! | --- | --- | +//! | `struct ` | Mirrors the catalog row; fields are named after the catalog columns | +//! | `hash(&mut self)` | Folds the identity-bearing fields into a digest — change detection compares hashes, never field-by-field | +//! | `get_script(&self)` | `CREATE …` for this object | +//! | `get_drop_script(&self)` | `DROP …` for this object | +//! | `get_alter_script(&self, target)` | The in-place migration to `target`, or a drop/recreate pair when PostgreSQL has no in-place form | +//! +//! The comparer never builds SQL itself for these kinds; it decides *which* of +//! the four to call and in *what order*. Adding an object kind therefore means +//! adding a module here with those methods, a field on [`Dump`](core::Dump) +//! carrying `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so older +//! dump files stay readable, and a comparison pass in +//! [`Comparer`](crate::comparer::core::Comparer). + pub mod acl; pub mod cast; pub mod collation; diff --git a/app/src/dump/operator.rs b/app/src/dump/operator.rs index 43ad3be..5a93de3 100644 --- a/app/src/dump/operator.rs +++ b/app/src/dump/operator.rs @@ -1,3 +1,5 @@ +//! User-defined operators (`pg_operator`) — `CREATE OPERATOR`. Schema-scoped. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/app/src/dump/pg_enum.rs b/app/src/dump/pg_enum.rs index 9cba23a..40f37c5 100644 --- a/app/src/dump/pg_enum.rs +++ b/app/src/dump/pg_enum.rs @@ -1,13 +1,25 @@ +//! Enum labels (`pg_enum`). +//! +//! Enums are ordered, and PostgreSQL can only *add* labels +//! (`ALTER TYPE … ADD VALUE`), never remove or reorder them. That asymmetry is why +//! [`Comparer`](crate::comparer::core::Comparer) keeps separate `enum_pre_script` +//! and `enum_post_script` buffers: additions can run early, while anything +//! requiring a type recreation has to wait until its dependents are gone. + use serde::{Deserialize, Serialize}; use sqlx::postgres::types::Oid; -// This is an information about a PostgreSQL type. +/// This is an information about a PostgreSQL type. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PgEnum { - pub oid: Oid, // Oid of Enum type - pub enumtypid: Oid, // Oid of the Enum type - pub enumsortorder: f32, // Sort order of the enum value - pub enumlabel: String, // Label of the enum value + /// Oid of Enum type + pub oid: Oid, + /// Oid of the Enum type + pub enumtypid: Oid, + /// Sort order of the enum value + pub enumsortorder: f32, + /// Label of the enum value + pub enumlabel: String, } #[cfg(test)] diff --git a/app/src/dump/pg_type.rs b/app/src/dump/pg_type.rs index f42aadb..2a1a9b8 100644 --- a/app/src/dump/pg_type.rs +++ b/app/src/dump/pg_type.rs @@ -1,3 +1,10 @@ +//! User-defined types (`pg_type`) — domains, composites, ranges and multiranges, +//! along with their domain constraints and composite attributes. +//! +//! Types are dropped *after* routines and recreated *before* them, since a routine +//! signature can name a type. Multirange types are created and dropped implicitly +//! with their range type and must never be emitted on their own. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::postgres::types::Oid; @@ -25,69 +32,115 @@ fn escape_single_quotes(value: &str) -> String { value.replace('\'', "''") } -// This is an information about a PostgreSQL type. +/// This is an information about a PostgreSQL type. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PgType { - pub oid: Oid, // Unique identifier of the type - pub schema: String, // Schema where the type is defined - pub typname: String, // Name of the type - pub typnamespace: Oid, // Schema where the type is defined - pub typowner: Oid, // Owner of the type + /// Unique identifier of the type + pub oid: Oid, + /// Schema where the type is defined + pub schema: String, + /// Name of the type + pub typname: String, + /// Schema where the type is defined + pub typnamespace: Oid, + /// Owner of the type + pub typowner: Oid, + /// Owner role name of the type #[serde(default, skip_serializing_if = "String::is_empty")] - pub owner: String, // Owner role name of the type - pub typlen: i16, // Length of the type in bytes - pub typbyval: bool, // Whether the type is passed by value - pub typtype: i8, // Type of the type (e.g., base, composite, domain) - pub typcategory: i8, // Category of the type (e.g., numeric, string) - pub typispreferred: bool, // Whether the type is preferred for implicit casts - pub typisdefined: bool, // Whether the type is defined - pub typdelim: i8, // Delimiter for array types - pub typrelid: Option, // Type of the type if it is a domain - pub typsubscript: Option, // Subscript type if it is an array - pub typelem: Option, // Element type if it is an array - pub typarray: Option, // Array type if it is an array - pub typinput: String, // Input function for the type - pub typoutput: String, // Output function for the type - pub typreceive: Option, // Receive function for the type - pub typsend: Option, // Send function for the type - pub typmodin: Option, // Type modifier input function - pub typmodout: Option, // Type modifier output function - pub typanalyze: Option, // Analyze function for the type - pub typalign: i8, // Alignment of the type (e.g., char, int, double) - pub typstorage: i8, // Storage type of the type (e.g., plain, extended) - pub typnotnull: bool, // Whether the type is not null - pub typbasetype: Option, // Base type if it is a domain - pub typtypmod: Option, // Type modifier for the type - pub typndims: i32, // Number of dimensions if it is an array - pub typcollation: Option, // Collation for the type - pub typdefault: Option, // Default value for the type + pub owner: String, + /// Length of the type in bytes + pub typlen: i16, + /// Whether the type is passed by value + pub typbyval: bool, + /// Type of the type (e.g., base, composite, domain) + pub typtype: i8, + /// Category of the type (e.g., numeric, string) + pub typcategory: i8, + /// Whether the type is preferred for implicit casts + pub typispreferred: bool, + /// Whether the type is defined + pub typisdefined: bool, + /// Delimiter for array types + pub typdelim: i8, + /// Type of the type if it is a domain + pub typrelid: Option, + /// Subscript type if it is an array + pub typsubscript: Option, + /// Element type if it is an array + pub typelem: Option, + /// Array type if it is an array + pub typarray: Option, + /// Input function for the type + pub typinput: String, + /// Output function for the type + pub typoutput: String, + /// Receive function for the type + pub typreceive: Option, + /// Send function for the type + pub typsend: Option, + /// Type modifier input function + pub typmodin: Option, + /// Type modifier output function + pub typmodout: Option, + /// Analyze function for the type + pub typanalyze: Option, + /// Alignment of the type (e.g., char, int, double) + pub typalign: i8, + /// Storage type of the type (e.g., plain, extended) + pub typstorage: i8, + /// Whether the type is not null + pub typnotnull: bool, + /// Base type if it is a domain + pub typbasetype: Option, + /// Type modifier for the type + pub typtypmod: Option, + /// Number of dimensions if it is an array + pub typndims: i32, + /// Collation for the type + pub typcollation: Option, + /// Default value for the type + pub typdefault: Option, + /// Human-readable base type (for domains) #[serde(default, skip_serializing_if = "Option::is_none")] - pub formatted_basetype: Option, // Human-readable base type (for domains) + pub formatted_basetype: Option, + /// Enum labels ordered by sort order #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub enum_labels: Vec, // Enum labels ordered by sort order + pub enum_labels: Vec, + /// Domain constraints (check, etc.) #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub domain_constraints: Vec, // Domain constraints (check, etc.) + pub domain_constraints: Vec, + /// Composite type attributes ordered by attnum #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub composite_attributes: Vec, // Composite type attributes ordered by attnum + pub composite_attributes: Vec, + /// Subtype for range types #[serde(default, skip_serializing_if = "Option::is_none")] - pub range_subtype: Option, // Subtype for range types + pub range_subtype: Option, + /// Collation for range types #[serde(default, skip_serializing_if = "Option::is_none")] - pub range_collation: Option, // Collation for range types + pub range_collation: Option, + /// Operator class for range types #[serde(default, skip_serializing_if = "Option::is_none")] - pub range_opclass: Option, // Operator class for range types + pub range_opclass: Option, + /// Canonical function for range types #[serde(default, skip_serializing_if = "Option::is_none")] - pub range_canonical: Option, // Canonical function for range types + pub range_canonical: Option, + /// Subtype diff function for range types #[serde(default, skip_serializing_if = "Option::is_none")] - pub range_subdiff: Option, // Subtype diff function for range types + pub range_subdiff: Option, + /// Multirange type name (for range types) #[serde(default, skip_serializing_if = "Option::is_none")] - pub multirange_name: Option, // Multirange type name (for range types) + pub multirange_name: Option, + /// Resolved collation name for domain types #[serde(default, skip_serializing_if = "Option::is_none")] - pub domain_collation_name: Option, // Resolved collation name for domain types + pub domain_collation_name: Option, + /// Optional comment on the type #[serde(default, skip_serializing_if = "Option::is_none")] - pub comment: Option, // Optional comment on the type + pub comment: Option, + /// ACL entries for GRANT/REVOKE #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub acl: Vec, // ACL entries for GRANT/REVOKE - pub hash: Option, // SHA256 hash of the type definition + pub acl: Vec, + /// SHA256 hash of the type definition + pub hash: Option, } impl PgType { diff --git a/app/src/dump/publication.rs b/app/src/dump/publication.rs index 4b1a226..cae6965 100644 --- a/app/src/dump/publication.rs +++ b/app/src/dump/publication.rs @@ -1,3 +1,9 @@ +//! Logical replication: [`Publication`] (`pg_publication`) and [`Subscription`] +//! (`pg_subscription`). +//! +//! Creating a publication needs the server running with `wal_level = logical`, +//! which is why CI starts PostgreSQL that way for the fixture schemas. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/app/src/dump/routine.rs b/app/src/dump/routine.rs index df0f1ec..df4bb9d 100644 --- a/app/src/dump/routine.rs +++ b/app/src/dump/routine.rs @@ -1,3 +1,12 @@ +//! Functions, procedures and aggregates (`pg_proc`). +//! +//! Routines are compared by their full signature, not just their name: overloads +//! are distinct objects, and an argument- or return-type change forces a drop and +//! recreate rather than `CREATE OR REPLACE`. Because `DROP FUNCTION … CASCADE` +//! silently takes dependent indexes, constraints, generated columns, defaults and +//! policies with it, the comparer re-emits those afterwards — see +//! [`column_dependent`](super::column_dependent). + use serde::{Deserialize, Serialize}; use sqlx::postgres::types::Oid; @@ -172,7 +181,7 @@ impl AggregateInfo { } } -// This is an information about a PostgreSQL routine. +/// This is an information about a PostgreSQL routine. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Routine { /// The schema name of the routine. diff --git a/app/src/dump/rule.rs b/app/src/dump/rule.rs index 6e94653..8b07e62 100644 --- a/app/src/dump/rule.rs +++ b/app/src/dump/rule.rs @@ -1,3 +1,8 @@ +//! Rewrite rules (`pg_rewrite`) — `CREATE RULE`. +//! +//! The rule that backs a view (`_RETURN`) is excluded; views are handled by +//! [`view`](super::view). + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/app/src/dump/schema.rs b/app/src/dump/schema.rs index ece16e8..b4af79d 100644 --- a/app/src/dump/schema.rs +++ b/app/src/dump/schema.rs @@ -1,9 +1,15 @@ +//! Schemas (`pg_namespace`) — `CREATE SCHEMA`. +//! +//! Which schemas are dumped is decided by `--scheme`, matched against +//! `nspname` with SQL `SIMILAR TO` rather than equality, so one run can target +//! several schemas via a pattern such as `public|app` or `app_.*`. + use md5; use serde::{Deserialize, Serialize}; use crate::utils::string_extensions::StringExt; -// This is an information about a PostgreSQL schema. +/// This is an information about a PostgreSQL schema. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Schema { /// Schema name as returned by PostgreSQL's quote_ident() (optionally quoted) diff --git a/app/src/dump/sequence.rs b/app/src/dump/sequence.rs index 181b412..c7efb12 100644 --- a/app/src/dump/sequence.rs +++ b/app/src/dump/sequence.rs @@ -1,33 +1,59 @@ +//! Sequences (`pg_sequence`) — `CREATE SEQUENCE`. +//! +//! Sequences owned by a `serial` or identity column are *not* independent objects: +//! they are created and dropped with their column, and emitting them separately +//! would produce a migration that fails to apply. The comparer detects them and +//! routes the rest through its `sequence_post_script` buffer. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::utils::string_extensions::StringExt; -// This is an information about a PostgreSQL sequence. +/// This is an information about a PostgreSQL sequence. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Sequence { - pub schema: String, // Schema where the sequence is defined - pub name: String, // Name of the sequence - pub owner: String, // Owner of the sequence - pub data_type: String, // Data type of the sequence - pub start_value: Option, // Start value of the sequence - pub min_value: Option, // Minimum value of the sequence - pub max_value: Option, // Maximum value of the sequence - pub increment_by: Option, // Increment value of the sequence - pub cycle: bool, // Whether the sequence cycles - pub cache_size: Option, // Cache size of the sequence - pub last_value: Option, // Last value generated by the sequence - pub owned_by_schema: Option, // Schema of the owning table/column - pub owned_by_table: Option, // Owning table name - pub owned_by_column: Option, // Owning column name - pub is_identity: bool, // Whether the sequence is an identity sequence + /// Schema where the sequence is defined + pub schema: String, + /// Name of the sequence + pub name: String, + /// Owner of the sequence + pub owner: String, + /// Data type of the sequence + pub data_type: String, + /// Start value of the sequence + pub start_value: Option, + /// Minimum value of the sequence + pub min_value: Option, + /// Maximum value of the sequence + pub max_value: Option, + /// Increment value of the sequence + pub increment_by: Option, + /// Whether the sequence cycles + pub cycle: bool, + /// Cache size of the sequence + pub cache_size: Option, + /// Last value generated by the sequence + pub last_value: Option, + /// Schema of the owning table/column + pub owned_by_schema: Option, + /// Owning table name + pub owned_by_table: Option, + /// Owning column name + pub owned_by_column: Option, + /// Whether the sequence is an identity sequence + pub is_identity: bool, + /// Whether the sequence is unlogged (relpersistence = 'u') #[serde(default)] - pub is_unlogged: bool, // Whether the sequence is unlogged (relpersistence = 'u') + pub is_unlogged: bool, + /// Optional sequence comment #[serde(default)] - pub comment: Option, // Optional sequence comment - pub hash: Option, // Hash of the sequence + pub comment: Option, + /// Hash of the sequence + pub hash: Option, + /// ACL (grant) entries for this sequence #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub acl: Vec, // ACL (grant) entries for this sequence + pub acl: Vec, } impl Sequence { diff --git a/app/src/dump/statistic.rs b/app/src/dump/statistic.rs index 38e5dd5..d95cf95 100644 --- a/app/src/dump/statistic.rs +++ b/app/src/dump/statistic.rs @@ -1,3 +1,5 @@ +//! Extended statistics (`pg_statistic_ext`) — `CREATE STATISTICS`. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/app/src/dump/table.rs b/app/src/dump/table.rs index 8e1ce57..684d150 100644 --- a/app/src/dump/table.rs +++ b/app/src/dump/table.rs @@ -1,3 +1,17 @@ +//! Tables (`pg_class`) and everything attached to them. +//! +//! A table aggregates the pieces held by its sibling modules — columns, +//! constraints, indexes, triggers and policies — and hashes them together, so any +//! change below the table surfaces as a change to the table. +//! +//! Partitioning drives most of the ordering rules here: a parent must be created +//! before its partitions and dropped after them, multi-level hierarchies are +//! ordered by depth, and an index on a partitioned parent expands to `ON ONLY` +//! plus a per-partition `ATTACH PARTITION` under `--output-for-production`. +//! +//! [`PgCatalogCaps`] records which catalog columns the connected server actually +//! has, so one binary can introspect PostgreSQL 14 through 18. + use crate::{ dump::{ table_column::TableColumn, table_constraint::TableConstraint, table_index::TableIndex, @@ -131,48 +145,73 @@ impl PgCatalogCaps { } } -// This is an information about a PostgreSQL table. +/// This is an information about a PostgreSQL table. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Table { pub schema: String, pub name: String, pub raw_schema: String, pub raw_name: String, - pub owner: String, // Owner of the table - pub space: Option, // Tablespace of the table - pub has_indexes: bool, // Whether the table has indexes - pub has_triggers: bool, // Whether the table has triggers - pub has_rules: bool, // Whether the table has rules - pub has_rowsecurity: bool, // Whether the table has row security - pub columns: Vec, // Column names - pub constraints: Vec, // Constraint names - pub indexes: Vec, // Index names - pub triggers: Vec, // Trigger names + /// Owner of the table + pub owner: String, + /// Tablespace of the table + pub space: Option, + /// Whether the table has indexes + pub has_indexes: bool, + /// Whether the table has triggers + pub has_triggers: bool, + /// Whether the table has rules + pub has_rules: bool, + /// Whether the table has row security + pub has_rowsecurity: bool, + /// Column names + pub columns: Vec, + /// Constraint names + pub constraints: Vec, + /// Index names + pub indexes: Vec, + /// Trigger names + pub triggers: Vec, + /// Row-level security policies #[serde(default)] - pub policies: Vec, // Row-level security policies - pub definition: Option, // Table definition (optional) - pub partition_key: Option, // Partition key (PARTITION BY ...) - pub partition_of: Option, // Parent table (PARTITION OF ...) - pub partition_bound: Option, // Partition bound (FOR VALUES ... or DEFAULT) + pub policies: Vec, + /// Table definition (optional) + pub definition: Option, + /// Partition key (PARTITION BY ...) + pub partition_key: Option, + /// Parent table (PARTITION OF ...) + pub partition_of: Option, + /// Partition bound (FOR VALUES ... or DEFAULT) + pub partition_bound: Option, + /// Table comment #[serde(default)] - pub comment: Option, // Table comment - pub hash: Option, // Hash of the table + pub comment: Option, + /// Hash of the table + pub hash: Option, + /// ACL (grant) entries for this table #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub acl: Vec, // ACL (grant) entries for this table + pub acl: Vec, + /// Table access method (e.g., "heap", custom AM) #[serde(default, skip_serializing_if = "Option::is_none")] - pub access_method: Option, // Table access method (e.g., "heap", custom AM) + pub access_method: Option, + /// Whether the table is UNLOGGED (relpersistence = 'u') #[serde(default)] - pub is_unlogged: bool, // Whether the table is UNLOGGED (relpersistence = 'u') + pub is_unlogged: bool, + /// Table-level storage parameters (reloptions) #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage_parameters: Option>, // Table-level storage parameters (reloptions) + pub storage_parameters: Option>, + /// REPLICA IDENTITY setting (d=default, n=nothing, f=full, i=index) #[serde(default, skip_serializing_if = "Option::is_none")] - pub replica_identity: Option, // REPLICA IDENTITY setting (d=default, n=nothing, f=full, i=index) + pub replica_identity: Option, + /// Whether FORCE ROW LEVEL SECURITY is enabled #[serde(default)] - pub force_rowsecurity: bool, // Whether FORCE ROW LEVEL SECURITY is enabled + pub force_rowsecurity: bool, + /// Classical inheritance parents (non-partition) #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub inherits_from: Vec, // Classical inheritance parents (non-partition) + pub inherits_from: Vec, + /// OF type name for typed tables #[serde(default, skip_serializing_if = "Option::is_none")] - pub typed_table_type: Option, // OF type name for typed tables + pub typed_table_type: Option, } /// Structured result of [`Table::index_alter_plan`]: which indexes to create, diff --git a/app/src/dump/table_column.rs b/app/src/dump/table_column.rs index 7c09879..6771e54 100644 --- a/app/src/dump/table_column.rs +++ b/app/src/dump/table_column.rs @@ -1,3 +1,12 @@ +//! Table columns (`pg_attribute` / `information_schema.columns`). +//! +//! Most changes are an in-place `ALTER COLUMN`, but some — a type change with no +//! valid cast, a stored ↔ virtual generated-column flip — require `DROP COLUMN` +//! plus `ADD COLUMN`. That distinction matters beyond this module: +//! [`TableColumn::would_drop_and_re_add`] is what tells the comparer that +//! PostgreSQL will CASCADE away the column's indexes, constraints and policies, +//! which then have to be re-emitted. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -17,68 +26,120 @@ fn generation_expressions_equivalent(a: &Option, b: &Option) -> } } -// This is an information about a PostgreSQL table. +/// This is an information about a PostgreSQL table. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TableColumn { - pub catalog: String, // Catalog name - pub schema: String, // Schema name - pub table: String, // Table name - pub name: String, // Column name - pub ordinal_position: i32, // Ordinal position of the column - pub column_default: Option, // Default value of the column - pub is_nullable: bool, // Whether the column is nullable - pub data_type: String, // Data type of the column - pub character_maximum_length: Option, // Maximum length for character types - pub character_octet_length: Option, // Octet length for character types - pub numeric_precision: Option, // Numeric precision - pub numeric_precision_radix: Option, // Numeric precision radix - pub numeric_scale: Option, // Numeric scale - pub datetime_precision: Option, // Datetime precision - pub interval_type: Option, // Interval type - pub interval_precision: Option, // Interval precision - pub character_set_catalog: Option, // Character set catalog - pub character_set_schema: Option, // Character set schema - pub character_set_name: Option, // Character set name - pub collation_catalog: Option, // Collation catalog - pub collation_schema: Option, // Collation schema - pub collation_name: Option, // Collation name - pub domain_catalog: Option, // Domain catalog - pub domain_schema: Option, // Domain schema - pub domain_name: Option, // Domain name - pub udt_catalog: Option, // UDT catalog - pub udt_schema: Option, // UDT schema - pub udt_name: Option, // UDT name - pub scope_catalog: Option, // Scope catalog - pub scope_schema: Option, // Scope schema - pub scope_name: Option, // Scope name - pub maximum_cardinality: Option, // Maximum cardinality - pub dtd_identifier: Option, // DTD identifier - pub is_self_referencing: bool, // Whether the column is self-referencing - pub is_identity: bool, // Whether the column is an identity column - pub identity_generation: Option, // Identity generation method - pub identity_start: Option, // Identity start value - pub identity_increment: Option, // Identity increment value - pub identity_maximum: Option, // Identity maximum value - pub identity_minimum: Option, // Identity minimum value - pub identity_cycle: bool, // Whether the identity column cycles - pub is_generated: String, // Whether the column is generated - pub generation_expression: Option, // Generation expression for the column + /// Catalog name + pub catalog: String, + /// Schema name + pub schema: String, + /// Table name + pub table: String, + /// Column name + pub name: String, + /// Ordinal position of the column + pub ordinal_position: i32, + /// Default value of the column + pub column_default: Option, + /// Whether the column is nullable + pub is_nullable: bool, + /// Data type of the column + pub data_type: String, + /// Maximum length for character types + pub character_maximum_length: Option, + /// Octet length for character types + pub character_octet_length: Option, + /// Numeric precision + pub numeric_precision: Option, + /// Numeric precision radix + pub numeric_precision_radix: Option, + /// Numeric scale + pub numeric_scale: Option, + /// Datetime precision + pub datetime_precision: Option, + /// Interval type + pub interval_type: Option, + /// Interval precision + pub interval_precision: Option, + /// Character set catalog + pub character_set_catalog: Option, + /// Character set schema + pub character_set_schema: Option, + /// Character set name + pub character_set_name: Option, + /// Collation catalog + pub collation_catalog: Option, + /// Collation schema + pub collation_schema: Option, + /// Collation name + pub collation_name: Option, + /// Domain catalog + pub domain_catalog: Option, + /// Domain schema + pub domain_schema: Option, + /// Domain name + pub domain_name: Option, + /// UDT catalog + pub udt_catalog: Option, + /// UDT schema + pub udt_schema: Option, + /// UDT name + pub udt_name: Option, + /// Scope catalog + pub scope_catalog: Option, + /// Scope schema + pub scope_schema: Option, + /// Scope name + pub scope_name: Option, + /// Maximum cardinality + pub maximum_cardinality: Option, + /// DTD identifier + pub dtd_identifier: Option, + /// Whether the column is self-referencing + pub is_self_referencing: bool, + /// Whether the column is an identity column + pub is_identity: bool, + /// Identity generation method + pub identity_generation: Option, + /// Identity start value + pub identity_start: Option, + /// Identity increment value + pub identity_increment: Option, + /// Identity maximum value + pub identity_maximum: Option, + /// Identity minimum value + pub identity_minimum: Option, + /// Whether the identity column cycles + pub identity_cycle: bool, + /// Whether the column is generated + pub is_generated: String, + /// Generation expression for the column + pub generation_expression: Option, + /// 's' for stored, 'v' for virtual (PG18+); None treated as stored #[serde(default)] - pub generation_type: Option, // 's' for stored, 'v' for virtual (PG18+); None treated as stored - pub is_updatable: bool, // Whether the column is updatable - pub related_views: Option>, // Related views (optional) + pub generation_type: Option, + /// Whether the column is updatable + pub is_updatable: bool, + /// Related views (optional) + pub related_views: Option>, + /// Column comment #[serde(default)] - pub comment: Option, // Column comment + pub comment: Option, + /// TOAST storage strategy (PLAIN, EXTERNAL, MAIN, EXTENDED) #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage: Option, // TOAST storage strategy (PLAIN, EXTERNAL, MAIN, EXTENDED) + pub storage: Option, + /// Column compression method (pglz, lz4; PG14+) #[serde(default, skip_serializing_if = "Option::is_none")] - pub compression: Option, // Column compression method (pglz, lz4; PG14+) + pub compression: Option, + /// Per-column statistics target (attstattarget; -1 = use default) #[serde(default, skip_serializing_if = "Option::is_none")] - pub statistics_target: Option, // Per-column statistics target (attstattarget; -1 = use default) + pub statistics_target: Option, + /// Column-level ACL entries (attacl) #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub acl: Vec, // Column-level ACL entries (attacl) + pub acl: Vec, + /// Transient: set at comparison time to "serial", "bigserial", or "smallserial" #[serde(skip)] - pub serial_type: Option, // Transient: set at comparison time to "serial", "bigserial", or "smallserial" + pub serial_type: Option, } impl TableColumn { @@ -161,7 +222,7 @@ impl TableColumn { /// True when comparing `self` (the new TO-side column) against /// `existing` (the FROM-side column) would route through the - /// `needs_full_recreate` branch in [`get_alter_script`] — i.e., the + /// `needs_full_recreate` branch in [`TableColumn::get_alter_script`] — i.e., the /// migration is `DROP COLUMN` + `ADD COLUMN` rather than an /// in-place ALTER. This is the Path B trigger from issue #188: /// PostgreSQL CASCADE-drops every index / FK / CHECK / EXCLUDE diff --git a/app/src/dump/table_constraint.rs b/app/src/dump/table_constraint.rs index 0e575e7..7f9659a 100644 --- a/app/src/dump/table_constraint.rs +++ b/app/src/dump/table_constraint.rs @@ -1,27 +1,46 @@ +//! Table constraints (`pg_constraint`) — PRIMARY KEY, UNIQUE, CHECK, EXCLUDE and +//! FOREIGN KEY. +//! +//! Foreign keys are emitted after every table exists, and under +//! `--output-for-production` they are added `NOT VALID` and validated in a +//! separate statement so the migration does not hold a lock while scanning. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::utils::string_extensions::StringExt; -// This is an information about a PostgreSQL table. +/// This is an information about a PostgreSQL table. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TableConstraint { - pub catalog: String, // Catalog name - pub schema: String, // Schema name - pub name: String, // Constraint name - pub table_name: String, // Table name - pub constraint_type: String, // Type of the constraint (e.g., PRIMARY KEY, FOREIGN KEY, UNIQUE) - pub is_deferrable: bool, // Whether the constraint is deferrable - pub initially_deferred: bool, // Whether the constraint is initially deferred - pub definition: Option, // Definition of the constraint (e.g., check expression) + /// Catalog name + pub catalog: String, + /// Schema name + pub schema: String, + /// Constraint name + pub name: String, + /// Table name + pub table_name: String, + /// Type of the constraint (e.g., PRIMARY KEY, FOREIGN KEY, UNIQUE) + pub constraint_type: String, + /// Whether the constraint is deferrable + pub is_deferrable: bool, + /// Whether the constraint is initially deferred + pub initially_deferred: bool, + /// Definition of the constraint (e.g., check expression) + pub definition: Option, + /// Number of direct inheritance ancestors (0 = local, >0 = inherited) #[serde(default)] - pub coninhcount: i32, // Number of direct inheritance ancestors (0 = local, >0 = inherited) + pub coninhcount: i32, + /// Whether the constraint is enforced (PG18+ supports NOT ENFORCED) #[serde(default = "TableConstraint::default_enforced")] - pub is_enforced: bool, // Whether the constraint is enforced (PG18+ supports NOT ENFORCED) + pub is_enforced: bool, + /// Whether the constraint is marked NO INHERIT #[serde(default)] - pub no_inherit: bool, // Whether the constraint is marked NO INHERIT + pub no_inherit: bool, + /// PG15+: UNIQUE constraint treats NULLs as not distinct #[serde(default)] - pub nulls_not_distinct: bool, // PG15+: UNIQUE constraint treats NULLs as not distinct + pub nulls_not_distinct: bool, /// Optional comment on the constraint #[serde(default, skip_serializing_if = "Option::is_none")] pub comment: Option, diff --git a/app/src/dump/table_index.rs b/app/src/dump/table_index.rs index 6d8d663..c7e2378 100644 --- a/app/src/dump/table_index.rs +++ b/app/src/dump/table_index.rs @@ -1,3 +1,10 @@ +//! Indexes (`pg_index`), carried as their `pg_get_indexdef` text. +//! +//! Indexes backing a constraint are owned by that constraint and must not be +//! emitted independently. Under `--output-for-production` index builds and drops +//! become `CONCURRENTLY`, which cannot run inside a transaction and so moves to +//! the post-commit section. + use std::borrow::Cow; use serde::{Deserialize, Serialize}; @@ -5,16 +12,22 @@ use sha2::{Digest, Sha256}; use crate::utils::string_extensions::StringExt; -// This is an information about a PostgreSQL table. +/// This is an information about a PostgreSQL table. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TableIndex { - pub schema: String, // Schema name - pub table: String, // Table name - pub name: String, // Index name - pub catalog: Option, // Catalog name - pub indexdef: String, // Index definition + /// Schema name + pub schema: String, + /// Table name + pub table: String, + /// Index name + pub name: String, + /// Catalog name + pub catalog: Option, + /// Index definition + pub indexdef: String, + /// Whether this index is inherited from a partitioned parent #[serde(default)] - pub is_partition_index: bool, // Whether this index is inherited from a partitioned parent + pub is_partition_index: bool, /// Optional comment on the index #[serde(default, skip_serializing_if = "Option::is_none")] pub comment: Option, diff --git a/app/src/dump/table_policy.rs b/app/src/dump/table_policy.rs index eb6b6cd..8c96d00 100644 --- a/app/src/dump/table_policy.rs +++ b/app/src/dump/table_policy.rs @@ -1,3 +1,5 @@ +//! Row-level security policies (`pg_policy`) — `CREATE POLICY`. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::{Error, Row, postgres::PgRow}; @@ -6,15 +8,23 @@ use crate::utils::string_extensions::StringExt; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TablePolicy { - pub schema: String, // Schema name - pub table: String, // Table name - pub name: String, // Policy name - pub command: String, // ALL, SELECT, INSERT, UPDATE, DELETE - pub permissive: bool, // true = PERMISSIVE, false = RESTRICTIVE + /// Schema name + pub schema: String, + /// Table name + pub table: String, + /// Policy name + pub name: String, + /// ALL, SELECT, INSERT, UPDATE, DELETE + pub command: String, + /// true = PERMISSIVE, false = RESTRICTIVE + pub permissive: bool, + /// Roles the policy applies to; empty means PUBLIC #[serde(default)] - pub roles: Vec, // Roles the policy applies to; empty means PUBLIC - pub using_clause: Option, // USING (predicate) - pub check_clause: Option, // WITH CHECK (predicate) + pub roles: Vec, + /// USING (predicate) + pub using_clause: Option, + /// WITH CHECK (predicate) + pub check_clause: Option, } impl TablePolicy { diff --git a/app/src/dump/table_trigger.rs b/app/src/dump/table_trigger.rs index 3a128b4..7f0dbcc 100644 --- a/app/src/dump/table_trigger.rs +++ b/app/src/dump/table_trigger.rs @@ -1,15 +1,24 @@ +//! Triggers (`pg_trigger`) — `CREATE TRIGGER`. +//! +//! A trigger needs both its table and its trigger function, so creation is +//! deferred to the comparer's `trigger_post_script` buffer, which is emitted last. +//! Constraint triggers backing a foreign key are excluded. + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::postgres::types::Oid; use crate::utils::string_extensions::StringExt; -// This is an information about a PostgreSQL table trigger. +/// This is an information about a PostgreSQL table trigger. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TableTrigger { - pub oid: Oid, // Object identifier of the trigger - pub name: String, // Name of the trigger - pub definition: String, // Definition of the trigger + /// Object identifier of the trigger + pub oid: Oid, + /// Name of the trigger + pub name: String, + /// Definition of the trigger + pub definition: String, /// Trigger enabled state from pg_trigger.tgenabled: /// 'O' = fires in "origin" and "local" modes (the default), /// 'D' = disabled, diff --git a/app/src/dump/text_search.rs b/app/src/dump/text_search.rs index ba3df5c..cc49163 100644 --- a/app/src/dump/text_search.rs +++ b/app/src/dump/text_search.rs @@ -1,3 +1,6 @@ +//! Full-text search objects: [`TextSearchConfig`] (`pg_ts_config`) and +//! [`TextSearchDict`] (`pg_ts_dict`). + use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/app/src/dump/view.rs b/app/src/dump/view.rs index 0529f69..63c2b96 100644 --- a/app/src/dump/view.rs +++ b/app/src/dump/view.rs @@ -1,3 +1,15 @@ +//! Views and materialized views (`pg_class`), carried as their deparsed +//! definition. +//! +//! Ordering is the whole problem: a view may read other views, so creation is +//! topologically sorted and dropping runs in reverse. A change is applied with +//! `CREATE OR REPLACE` where PostgreSQL allows it — which requires the existing +//! column list to be a prefix of the new one — and otherwise degrades to a drop +//! and recreate that also takes every dependent view with it. +//! +//! Materialized views are separate objects with their own indexes, and a regular ↔ +//! materialized transition is always a drop and recreate. + use serde::{Deserialize, Serialize}; use crate::dump::table::IndexAlterPlan; @@ -36,7 +48,7 @@ pub struct ViewColumn { pub collation: Option, } -// This is an information about a PostgreSQL view. +/// This is an information about a PostgreSQL view. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct View { /// Schema where the view is defined diff --git a/app/src/lib.rs b/app/src/lib.rs index c7a1e7d..1052555 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -14,6 +14,44 @@ //! integration tests in `app/tests/` can both build on the same public API. //! Unit tests that need access to private internals live beside their module, //! under each module's `tests/` directory. +//! +//! # Example +//! +//! The `compare` command, end to end: read two dumps from disk and write the +//! migration that turns the first into the second. +//! +//! ```no_run +//! use pgc::comparer::core::Comparer; +//! use pgc::config::grants_mode::GrantsMode; +//! use pgc::dump::core::Dump; +//! +//! # fn main() -> Result<(), std::io::Error> { +//! # tokio::runtime::Runtime::new()?.block_on(async { +//! let from = Dump::read_from_file("dump.from").await?; +//! let to = Dump::read_from_file("dump.to").await?; +//! +//! let mut comparer = Comparer::new( +//! from, +//! to, +//! true, // use_drop: emit DROP statements +//! true, // use_single_transaction: wrap in begin/commit +//! true, // use_comments +//! GrantsMode::Ignore, +//! ); +//! comparer.compare().await?; +//! comparer.save_script("migration.sql").await?; +//! # Ok::<(), std::io::Error>(()) +//! # })?; +//! # Ok(()) +//! # } +//! ``` +//! +//! Note the direction: applying the generated script to `FROM` makes its schema +//! equal to `TO`. `TO` is the reference schema and is never modified. +//! +//! Because the crate is documented for contributors rather than API consumers, +//! and most of the interesting code is private, build the docs with +//! `cargo doc --no-deps --lib --document-private-items`. pub mod comparer; pub mod config; diff --git a/app/src/utils/mod.rs b/app/src/utils/mod.rs index c6955cb..a4669a8 100644 --- a/app/src/utils/mod.rs +++ b/app/src/utils/mod.rs @@ -1,2 +1,8 @@ +//! Small helpers shared across the dump and comparer modules. +//! +//! - [`sql_normalize`] — canonicalises a deparsed SQL definition so that two +//! renderings of the same expression compare equal. +//! - [`string_extensions`] — quoting, identifier and script-assembly helpers. + pub mod sql_normalize; pub mod string_extensions; diff --git a/app/src/utils/string_extensions.rs b/app/src/utils/string_extensions.rs index 2ec03ae..6d775bb 100644 --- a/app/src/utils/string_extensions.rs +++ b/app/src/utils/string_extensions.rs @@ -1,3 +1,11 @@ +//! Identifier quoting and script-assembly helpers. +//! +//! [`unquote_ident`] reverses PostgreSQL's `quote_ident()` rendering, recovering +//! the raw catalog name. Case-sensitivity is the reason this matters: `"Foo"` and +//! `foo` are different relations, and a dump stores both spellings. +//! +//! The [`StringExt`] trait adds the small append helpers the script builders use. + const EMPTY_LINES: &str = "\n\n"; pub trait StringExt { @@ -24,6 +32,22 @@ pub trait StringExt { /// - The one input it cannot round-trip is a raw, never-quoted name that itself /// begins and ends with a double quote; such a name is indistinguishable from a /// quoted rendering of its own interior. +/// # Examples +/// +/// ``` +/// use pgc::utils::string_extensions::unquote_ident; +/// +/// // A quoted identifier gives back its raw catalog name… +/// assert_eq!(unquote_ident("\"MyView\""), "MyView"); +/// // …and an unquoted one is already raw. +/// assert_eq!(unquote_ident("myview"), "myview"); +/// +/// // Doubled quotes inside a quoted identifier collapse to one. +/// assert_eq!(unquote_ident("\"say \"\"hi\"\"\""), "say \"hi\""); +/// +/// // Case matters: these are two different relations. +/// assert_ne!(unquote_ident("\"Foo\""), unquote_ident("foo")); +/// ``` pub fn unquote_ident(part: &str) -> String { let trimmed = part.trim(); match trimmed From 55f26131b46a9ffffe16adb26fac7bd873fdee5a Mon Sep 17 00:00:00 2001 From: nettrash Date: Mon, 3 Aug 2026 20:49:09 +0200 Subject: [PATCH 7/9] #238 --- app/tests/common/mod.rs | 16 ++++++++++++++-- app/tests/config_file.rs | 29 +++++++++++++++++------------ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/tests/common/mod.rs b/app/tests/common/mod.rs index 2b16ac2..7580abe 100644 --- a/app/tests/common/mod.rs +++ b/app/tests/common/mod.rs @@ -107,12 +107,24 @@ pub fn assert_no_ddl(script: &str, context: &str) { } /// Absolute path to a file under the repository's `data/` directory. +/// +/// **Only pass files that are committed to git.** `data/` also holds +/// developer-local files that `.gitignore` excludes — `test.conf` among them — +/// and a test reading one of those passes on the machine that has it and fails +/// everywhere else, CI included. Panics with that reminder rather than letting +/// the caller hit a bare "No such file or directory". pub fn data_path(relative: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) + let path = Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .expect("app/ has a parent") .join("data") - .join(relative) + .join(relative); + assert!( + path.exists(), + "data/{relative} is missing. Tests may only depend on git-tracked files \ + under data/; check whether this one is excluded by .gitignore." + ); + path } /// A `DumpConfig` that is never connected to — dumps built in-process still diff --git a/app/tests/config_file.rs b/app/tests/config_file.rs index 3f482a9..2feb2ae 100644 --- a/app/tests/config_file.rs +++ b/app/tests/config_file.rs @@ -35,21 +35,26 @@ fn shipped_sample_config_parses() { assert!(config.use_comments); } +// Note: `data/test.conf` is deliberately *not* covered here. It is listed in +// `.gitignore` — a developer-local file holding real credentials and absolute +// paths — so a test reading it passes only on the machine that has it. The +// keys it exercises are covered by `every_key_round_trips_from_a_file` below, +// which builds its own fixture. + +/// The `|` alternation in a scheme pattern has to survive parsing verbatim: +/// `--scheme` is matched against `nspname` with SQL `SIMILAR TO`, not equality, +/// so mangling it would silently change which schemas are dumped. #[test] -fn shipped_test_config_parses() { - let path = data_path("test.conf"); - let config = Config::load(path.to_str().expect("utf-8 path")) - .unwrap_or_else(|e| panic!("data/test.conf must stay loadable: {e}")); +fn multi_schema_patterns_survive_parsing() { + let dir = ScratchDir::new("scheme-pattern"); + let path = write_config( + &dir, + "FROM_HOST=a.example\nFROM_SCHEME=public|app\nTO_HOST=b.example\nTO_SCHEME=public|app\n", + ); - // The multi-schema pattern is passed through verbatim: `--scheme` is - // matched with SQL `SIMILAR TO`, so the alternation must survive parsing. - assert!(config.from.scheme.contains('|')); + let config = Config::load(&path).expect("load config"); + assert_eq!(config.from.scheme, "public|app"); assert_eq!(config.from.scheme, config.to.scheme); - assert!(config.use_drop); - assert!(!config.use_comments); - assert_eq!(config.grants_mode, GrantsMode::Full); - assert_eq!(config.max_connections, 10); - assert!(config.output_for_production); } #[test] From 1561deac77aaba0ae8f935f72fc5c1277d7652d5 Mon Sep 17 00:00:00 2001 From: nettrash Date: Mon, 3 Aug 2026 21:02:09 +0200 Subject: [PATCH 8/9] #238 --- CHANGELOG | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++ app/Cargo.lock | 2 +- app/Cargo.toml | 2 +- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 124e35e..b0d0a71 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,91 @@ +2026-08-03 v1.0.27 + + No change to what pgc emits. This release reorganises + the test suite, adds a library target and integration + tests, and documents the crate. The dump file format, + the CLI and the generated SQL are all unchanged. + + Internals: + - Added a library target (`app/src/lib.rs`) + exporting the four existing modules. `main.rs` + is now a thin binary over it and holds only CLI + parsing and command dispatch. A binary-only + crate cannot have integration tests or run + doctests, so this is what the two sections below + are built on. The binary's behaviour, its flags + and its output are unaffected. + + Tests: + - Unit tests moved out of the source directories: + `src//_tests.rs` is now + `src//tests/.rs`. They remain + `#[cfg(test)] #[path = ...] mod tests;` children + of the module they cover, so they still reach + its private items; only the file layout changed. + `src/dump/` no longer interleaves 27 test files + with the 28 sources beside them. + - `comparer/core_tests.rs`, at 11,772 lines the + largest file in the project, was split by + concern into fifteen files under + `src/comparer/tests/core/` — grants, views, + persistence, routines, sequences, tables and so + on — with the fixtures used by more than one of + them in `helpers.rs`. The before and after test + name sets are identical. Note that these + submodules need explicit `#[path]` attributes: + their parent is itself loaded through `#[path]`, + so rustc resolves children against + `src/comparer/tests/` rather than the `core/` + subdirectory, and a bare `mod production;` + silently binds to the unrelated + `tests/production.rs`. + - New integration suite in `app/tests/`, thirty + tests over the public API only, covering ground + the in-memory unit tests cannot reach: the zip + dump file round-trip and the `#[serde(default)]` + contract that keeps dumps from older versions + readable; the full dump → file → dump → compare + → script path the `compare` command takes, + including that a schema compared against itself + emits no DDL; `Config::load` against real files, + among them the shipped `data/pgc.conf`; and the + drop ordering of the `clear` command. Three + further tests exercise a live server and are + `#[ignore]`d by default — run them with + `cargo test -- --ignored` and the standard `PG*` + environment variables. + - `cargo test` now runs 1106 tests, up from 1062. + + Documentation: + - Module-level documentation on all 36 modules. + `dump/mod.rs` describes the + hash / get_script / get_drop_script / + get_alter_script shape that nearly every object + module repeats, and what adding a new object + kind requires, so the individual module headers + only carry what is specific to that kind. + - 245 comments on public items that were written + with `//`, and therefore invisible to rustdoc, + promoted to `///`. Trailing comments moved above + the field they describe. + - Fourteen doctests on the primary public API. + They compile and run under `cargo test`, so the + examples cannot drift from the code. + - Fixed eight broken intra-doc links that had been + rendering as plain text. Four were only visible + with `--document-private-items`. + + Tooling: + - CI gained a docs step running + `cargo doc --no-deps --lib + --document-private-items` under + `RUSTDOCFLAGS="-D warnings"`, so an unresolved + link fails the build instead of degrading + silently. Private items are included because pgc + ships as a binary: most of what a contributor + reads is private, and a broken link there is + just as wrong. + 2026-07-28 v1.0.26 Bug fixes: diff --git a/app/Cargo.lock b/app/Cargo.lock index d86b432..c0f951d 100644 --- a/app/Cargo.lock +++ b/app/Cargo.lock @@ -1146,7 +1146,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pgc" -version = "1.0.26" +version = "1.0.27" dependencies = [ "chrono", "clap", diff --git a/app/Cargo.toml b/app/Cargo.toml index 4d05e75..287ae1e 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pgc" -version = "1.0.26" +version = "1.0.27" edition = "2024" license = "MIT" authors = ["nettrash "] From e8c1140a1c12f53ad044bf91ee71913213b6a87a Mon Sep 17 00:00:00 2001 From: nettrash Date: Mon, 3 Aug 2026 21:22:55 +0200 Subject: [PATCH 9/9] #238 --- app/tests/common/mod.rs | 14 ++++++ app/tests/compare_pipeline.rs | 83 ++++++++++++++++++++++++++++++----- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/app/tests/common/mod.rs b/app/tests/common/mod.rs index 7580abe..4d80e08 100644 --- a/app/tests/common/mod.rs +++ b/app/tests/common/mod.rs @@ -21,6 +21,7 @@ use pgc::dump::sequence::Sequence; use pgc::dump::table::Table; use pgc::dump::table_column::TableColumn; use pgc::dump::table_constraint::TableConstraint; +use pgc::dump::table_index::TableIndex; use pgc::dump::view::View; use sqlx::postgres::types::Oid; use std::path::{Path, PathBuf}; @@ -264,6 +265,19 @@ pub fn foreign_key( } } +/// A plain btree index on `column`. +pub fn index(schema: &str, table: &str, name: &str, column: &str) -> TableIndex { + TableIndex { + schema: schema.to_string(), + table: table.to_string(), + name: name.to_string(), + catalog: Some("postgres".to_string()), + indexdef: format!("CREATE INDEX {name} ON {schema}.{table} USING btree ({column})"), + is_partition_index: false, + comment: None, + } +} + pub fn view(schema: &str, name: &str, definition: &str, relations: &[&str]) -> View { let mut v = View::new( name.to_string(), diff --git a/app/tests/compare_pipeline.rs b/app/tests/compare_pipeline.rs index 808a30a..4d5a570 100644 --- a/app/tests/compare_pipeline.rs +++ b/app/tests/compare_pipeline.rs @@ -6,7 +6,7 @@ mod common; -use common::{ScratchDir, assert_no_ddl, populated_dump, table, view}; +use common::{ScratchDir, assert_no_ddl, index, populated_dump, schema, table, view}; use pgc::comparer::core::Comparer; use pgc::config::grants_mode::GrantsMode; use pgc::dump::core::Dump; @@ -141,14 +141,32 @@ async fn single_transaction_wraps_the_script_in_begin_and_commit() { assert!(begin < commit, "begin must precede commit:\n{script}"); } -/// `--output-for-production` moves statements that cannot run inside a -/// transaction into a post-commit section. Off by default, and toggling it must -/// not change anything else about how the comparison is driven. +/// `--output-for-production` adds idempotency guards and moves statements that +/// cannot run inside a transaction into a post-commit section. Off by default. +/// +/// The assertions below deliberately name whole statements rather than looking +/// for a bare `if not exists`. That substring is **not** production-specific: +/// `Schema::get_script` and `Extension::get_script` emit `create schema if not +/// exists` / `create extension if not exists` on the default path too, so the +/// fixture below — which adds a schema — contains it either way. A test +/// matching only the substring would keep passing if the rewrite stopped +/// working entirely. #[tokio::test] async fn production_mode_is_opt_in() { let build = |production: bool| async move { let mut to = populated_dump("shop"); - to.tables.push(table("app", "invoices", &["id"])); + // A new schema: emits `create schema if not exists` on BOTH paths, and + // is what makes the naive substring check useless. + to.schemas.push(schema("reporting")); + // A new table with an index: `create table` gains a guard and the index + // build becomes CONCURRENTLY, only under production mode. + let mut invoices = table("app", "invoices", &["id"]); + invoices + .indexes + .push(index("app", "invoices", "ix_invoices_id", "id")); + invoices.hash(); + to.tables.push(invoices); + let mut comparer = Comparer::new( populated_dump("shop"), to, @@ -162,19 +180,64 @@ async fn production_mode_is_opt_in() { let dir = ScratchDir::new(if production { "prod-on" } else { "prod-off" }); let out = dir.path_str("output.sql"); comparer.save_script(&out).await.expect("save script"); - std::fs::read_to_string(&out).expect("read script") + std::fs::read_to_string(&out) + .expect("read script") + .to_lowercase() }; let default_script = build(false).await; let production_script = build(true).await; + // The confound, pinned: present on both paths, so it discriminates nothing. + for (label, script) in [ + ("default", &default_script), + ("production", &production_script), + ] { + assert!( + script.contains("create schema if not exists reporting;"), + "{label} output should carry the unguarded-by-production schema create:\n{script}" + ); + } + + // Default path: no guard on CREATE TABLE, index built inline, no + // post-commit section. + assert!( + default_script.contains("create table app.invoices"), + "default output must create the table without a guard:\n{default_script}" + ); + assert!( + !default_script.contains("create table if not exists app.invoices"), + "the CREATE TABLE guard is production-only:\n{default_script}" + ); assert!( - !default_script.to_lowercase().contains("post-commit"), + !default_script.contains("concurrently"), + "default output must build indexes inline:\n{default_script}" + ); + assert!( + !default_script.contains("post-commit"), "default output must not carry a post-commit section:\n{default_script}" ); + + // Production path: guarded CREATE TABLE, CONCURRENTLY index, and the + // statement that cannot run in a transaction emitted after the commit. + assert!( + production_script.contains("create table if not exists app.invoices"), + "production output must guard CREATE TABLE:\n{production_script}" + ); + assert!( + production_script.contains("create index concurrently if not exists ix_invoices_id"), + "production output must build the index concurrently:\n{production_script}" + ); + + let commit = production_script + .rfind("commit;") + .expect("production script commits"); + let concurrent_index = production_script + .find("create index concurrently") + .expect("production script builds the index concurrently"); assert!( - production_script.to_lowercase().contains("if not exists") - || production_script.to_lowercase().contains("post-commit"), - "production output must add idempotency guards:\n{production_script}" + concurrent_index > commit, + "CREATE INDEX CONCURRENTLY cannot run inside a transaction, so it must \ + be emitted after commit:\n{production_script}" ); }