mod common;
mod rule_evaluation_tests {
use crate::common::*;
use safe_migrate::_internal::analysis::state::{AnalysisState, Confidence};
use safe_migrate::_internal::ast::identifiers::ObjectId;
use safe_migrate::_internal::engine::config::{Config, RuleConfig};
use safe_migrate::_internal::engine::engine::SafeMigrateEngine;
use safe_migrate::_internal::model::column::Column;
use safe_migrate::_internal::model::function::{
FunctionOverlay, FunctionState, RoutineKind, SecurityMode, Volatility,
};
use safe_migrate::_internal::model::relation::{Persistence, RelationKind, RelationState};
use safe_migrate::_internal::report::violations::ViolationTier;
#[test]
fn test_rule_idempotency() {
let engine = setup_engine();
let mut state = setup_state();
let violations = engine
.analyze(
"
CREATE TABLE t(id int);
DROP TABLE t;
CREATE INDEX i ON t(id);
",
&mut state,
)
.unwrap();
assert!(violations.iter().any(|v| v.rule_id.contains("idempot")));
}
#[test]
fn test_rule_cascading_drop() {
let engine = setup_engine();
let mut state = setup_state();
assert!(
engine
.analyze(
"
CREATE TABLE data(id INT);
CREATE VIEW v AS SELECT * FROM data;
DROP TABLE data CASCADE;
",
&mut state,
)
.is_ok()
);
}
#[test]
fn test_rule_size_aware_toast_escalation() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
let tid = object_id("public", "t_toast");
let mut rel = RelationState::new(
tid.clone(),
ObjectId::new("public", "postgres"),
0,
Some(50_000),
RelationKind::Table,
Persistence::Permanent,
0,
);
rel.columns.push(Column {
name: "data".into(),
data_type: Some("text".into()),
type_id: None,
is_nullable: true,
default: None,
avg_width: Some(3000),
default_expr_text: None,
type_modifier: None,
});
cache.insert_baseline(tid, rel);
let mut state = AnalysisState::new(cache);
let violations = engine
.analyze(
"
ALTER TABLE t_toast
ADD COLUMN c INT DEFAULT random();
",
&mut state,
)
.unwrap();
assert!(violations.iter().any(|v| v.rule_id.contains("size")
|| v.rule_id.contains("rewrite")
|| v.rule_id.contains("toast")));
}
#[test]
fn test_rule_blocking_constraint_check_and_not_valid_fast_path() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v1 = engine
.analyze(
"
ALTER TABLE t
ADD CONSTRAINT c CHECK (id > 0);
",
&mut state,
)
.unwrap();
assert!(v1.iter().any(|v| v.rule_id.contains("constraint")));
let v2 = engine
.analyze(
"
ALTER TABLE t
ADD CONSTRAINT c2 CHECK (id > 0) NOT VALID;
",
&mut state,
)
.unwrap();
assert!(!v2.iter().any(|v| v.rule_id == "blocking-constraint"));
}
#[test]
fn test_rule_blocking_constraint_pk_and_unique() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v1 = engine
.analyze("ALTER TABLE t ADD PRIMARY KEY (id);", &mut state)
.unwrap();
assert!(
v1.iter()
.any(|v| v.rule_id.contains("constraint") || v.rule_id.contains("index"))
);
let v2 = engine
.analyze("ALTER TABLE t ADD UNIQUE (id);", &mut state)
.unwrap();
assert!(
v2.iter()
.any(|v| v.rule_id.contains("constraint") || v.rule_id.contains("index"))
);
}
#[test]
fn test_rule_temporary_table_bypass() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"
CREATE TEMP TABLE temp(id int);
CREATE INDEX i ON temp(id);
ALTER TABLE temp ADD UNIQUE(id);
",
&mut state,
)
.unwrap();
assert!(!v.is_empty());
}
#[test]
fn test_rule_mat_view_refresh() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "mv"),
RelationState::new(
object_id("public", "mv"),
ObjectId::new("public", "postgres"),
0,
Some(150_000),
RelationKind::MaterializedView,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v1 = engine
.analyze("REFRESH MATERIALIZED VIEW mv;", &mut state)
.unwrap();
assert!(
v1.iter()
.any(|v| v.rule_id.contains("mat") || v.rule_id.contains("refresh"))
);
let v2 = engine
.analyze("REFRESH MATERIALIZED VIEW CONCURRENTLY mv;", &mut state)
.unwrap();
assert!(v2.len() <= v1.len());
}
#[test]
fn test_rule_partition_attach_detach() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "p"),
RelationState::new(
object_id("public", "p"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
engine
.analyze("CREATE TABLE c(id int);", &mut state)
.unwrap();
let v1 = engine
.analyze(
"
ALTER TABLE p
ATTACH PARTITION c
FOR VALUES IN (1);
",
&mut state,
)
.unwrap();
assert!(v1.iter().any(|v| v.rule_id.contains("partition")));
let v2 = engine
.analyze(
"
ALTER TABLE p
DETACH PARTITION c;
",
&mut state,
)
.unwrap();
assert!(v2.iter().any(|v| v.rule_id.contains("partition")));
}
#[test]
fn test_rule_concurrent_inside_txn() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"
BEGIN;
CREATE INDEX CONCURRENTLY i ON t(id);
DROP INDEX CONCURRENTLY i;
COMMIT;
",
&mut state,
)
.unwrap();
assert!(
v.iter()
.any(|v| v.rule_id.contains("transaction") || v.rule_id.contains("concurrent"))
);
}
#[test]
fn test_rule_opaque_sql() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine.analyze("DO $$ BEGIN END $$;", &mut state).unwrap();
assert!(
v.iter()
.any(|v| v.rule_id.contains("opaque") || v.rule_id.contains("dynamic"))
);
assert_eq!(state.local.confidence, Confidence::Tainted);
}
#[test]
fn test_tainted_confidence_downgrades_tier1_to_tier2() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
let tid = object_id("public", "t");
cache.insert_baseline(
tid.clone(),
RelationState::new(
tid,
object_id("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze(
"DO $$ BEGIN EXECUTE 'DROP TABLE users'; END $$; DROP DATABASE mydb; DROP TABLE t CASCADE;",
&mut state,
)
.unwrap();
let db_violations: Vec<_> = v.iter().filter(|v| v.rule_id == "drop-database").collect();
let drop_table_violations: Vec<_> = v
.iter()
.filter(|v| v.rule_id == "destructive-cascade" || v.rule_id == "irreversible-migration")
.collect();
assert!(
db_violations.iter().all(|v| v.tier == ViolationTier::Tier2),
"DROP DATABASE after taint should be Tier2: {:?}",
db_violations
);
assert!(
drop_table_violations
.iter()
.all(|v| v.tier == ViolationTier::Tier2),
"DROP TABLE CASCADE after taint should be Tier2: {:?}",
drop_table_violations
);
}
#[test]
fn missing_relation_statistics_taint_only_statistics_based_findings() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
let table_id = object_id("public", "stats_unknown");
let mut relation = RelationState::new(
table_id.clone(),
object_id("public", "postgres"),
0,
None,
RelationKind::Table,
Persistence::Permanent,
0,
);
relation.columns.push(Column {
name: "id".into(),
data_type: Some("integer".into()),
type_id: None,
is_nullable: false,
default: None,
avg_width: Some(4),
default_expr_text: None,
type_modifier: None,
});
cache.insert_baseline(table_id, relation);
let mut state = AnalysisState::new(cache);
let findings = engine
.analyze(
"CREATE INDEX stats_unknown_idx ON stats_unknown(id);",
&mut state,
)
.unwrap();
assert!(
findings
.iter()
.any(|finding| { finding.rule_id == "require-concurrent-index" })
);
assert_eq!(state.local.confidence, Confidence::Tainted);
assert!(state.evidence().iter().any(|record| {
record.code == safe_migrate::_internal::analysis::evidence::EvidenceCode::CatalogCoverageIncomplete
}));
}
#[test]
fn test_confidence_taint_does_not_affect_prior_violations() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
let tid = object_id("public", "t");
cache.insert_baseline(
tid.clone(),
RelationState::new(
tid,
object_id("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze(
"DROP DATABASE mydb; DO $$ BEGIN END $$; DROP TABLE t CASCADE;",
&mut state,
)
.unwrap();
let db_violations: Vec<_> = v.iter().filter(|v| v.rule_id == "drop-database").collect();
let drop_table_violations: Vec<_> = v
.iter()
.filter(|v| v.rule_id == "destructive-cascade" || v.rule_id == "irreversible-migration")
.collect();
assert!(
db_violations.iter().any(|v| v.tier == ViolationTier::Tier1),
"DROP DATABASE should stay Tier1 (violation before taint): {:?}",
db_violations
);
assert!(
drop_table_violations
.iter()
.any(|v| v.tier == ViolationTier::Tier2),
"DROP TABLE CASCADE should be Tier2 (violation after taint): {:?}",
drop_table_violations
);
}
#[test]
fn test_exact_confidence_keeps_tier1() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
let tid = object_id("public", "t");
cache.insert_baseline(
tid.clone(),
RelationState::new(
tid,
object_id("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
let v = engine.analyze("DROP TABLE t CASCADE;", &mut state).unwrap();
assert!(
v.iter().any(|v| v.tier == ViolationTier::Tier1),
"expected Tier1 with exact confidence: {:?}",
v
);
assert!(
v.iter().all(|v| !v.reason.contains("DOWNGRADED")),
"expected no downgrade notices with exact confidence: {:?}",
v.iter().map(|v| &v.reason).collect::<Vec<_>>()
);
}
#[test]
fn test_rule_restrictive_policy() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"CREATE POLICY p ON t AS RESTRICTIVE FOR SELECT TO public USING (true);",
&mut state,
)
.unwrap();
assert!(v.iter().any(|v| v.rule_id == "restrictive-policy"));
}
#[test]
fn test_rule_disable_trigger() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze("ALTER TABLE t DISABLE TRIGGER tr;", &mut state)
.unwrap();
assert!(v.iter().any(|v| v.rule_id == "disable-trigger"));
}
#[test]
fn test_rule_overbroad_grant() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
let v = engine
.analyze("GRANT ALL ON t TO public;", &mut state)
.unwrap();
assert!(v.iter().any(|v| v.rule_id == "overbroad-grant"));
}
#[test]
fn grant_option_warns_even_when_state_matrix_skips_unmodeled_grant_chain() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE grant_option_target(id int);", &mut state)
.unwrap();
let findings = engine
.analyze(
"GRANT SELECT ON grant_option_target TO app_user WITH GRANT OPTION;",
&mut state,
)
.unwrap();
assert!(findings.iter().any(|finding| {
finding.rule_id == "overbroad-grant" && finding.reason.contains("WITH GRANT OPTION")
}));
}
#[test]
fn grant_all_owner_exemption_requires_every_grantee_to_own_every_table() {
let engine = setup_engine();
let table_id = object_id("public", "owned_table");
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
cache.insert_baseline(
table_id.clone(),
RelationState::new(
table_id,
object_id("", "table_owner"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut owner_only_state = AnalysisState::new(cache.clone());
let owner_only = engine
.analyze(
"GRANT ALL ON owned_table TO table_owner;",
&mut owner_only_state,
)
.unwrap();
assert!(
!owner_only
.iter()
.any(|violation| violation.rule_id == "overbroad-grant")
);
let mut mixed_state = AnalysisState::new(cache);
let mixed = engine
.analyze(
"GRANT ALL ON owned_table TO table_owner, outsider;",
&mut mixed_state,
)
.unwrap();
assert!(
mixed
.iter()
.any(|violation| violation.rule_id == "overbroad-grant")
);
}
#[test]
fn test_rule_volatile_default_create() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"
CREATE TABLE t(
id int DEFAULT random()
);
",
&mut state,
)
.unwrap();
assert!(
v.iter()
.any(|v| v.rule_id.contains("volatile") || v.rule_id.contains("default"))
);
}
#[test]
fn test_rule_volatile_default_alter() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
let tid = object_id("public", "t");
let rel = RelationState::new(
tid.clone(),
object_id("public", "postgres"),
0,
Some(10),
safe_migrate::_internal::model::relation::RelationKind::Table,
safe_migrate::_internal::model::relation::Persistence::Permanent,
0,
);
cache.insert_baseline(tid, rel);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze(
"ALTER TABLE t ADD COLUMN new_col int DEFAULT random();",
&mut state,
)
.unwrap();
assert!(v.iter().any(|v| v.rule_id == "volatile-default"));
let v2 = engine
.analyze(
"ALTER TABLE t ALTER COLUMN new_col SET DEFAULT random();",
&mut state,
)
.unwrap();
assert!(v2.iter().any(|v| v.rule_id == "volatile-default"));
let v3 = engine
.analyze(
"ALTER TABLE t ADD COLUMN nested_default double precision DEFAULT coalesce(random(), 0);",
&mut state,
)
.unwrap();
assert!(v3.iter().any(|v| v.rule_id == "volatile-default"));
}
#[test]
fn test_rule_function_volatility_change() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION add(a int, b int) RETURNS int LANGUAGE plpgsql IMMUTABLE AS 'BEGIN RETURN a + b; END';",
&mut state,
)
.unwrap();
let func_id = ObjectId::new("public", "add(integer,integer)");
assert!(state.local.functions.contains_key(&func_id));
let v = engine
.analyze("ALTER FUNCTION add(int, int) VOLATILE;", &mut state)
.unwrap();
assert!(
v.iter().any(|v| v.rule_id == "function-volatility-change"),
"Should flag volatility change from IMMUTABLE to VOLATILE"
);
}
#[test]
fn test_rule_function_volatility_unchanged() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION stable_func() RETURNS int LANGUAGE plpgsql STABLE AS 'BEGIN RETURN 1; END';",
&mut state,
)
.unwrap();
let v = engine
.analyze("ALTER FUNCTION stable_func() STABLE;", &mut state)
.unwrap();
assert!(
!v.iter().any(|v| v.rule_id == "function-volatility-change"),
"Should NOT flag when volatility is unchanged"
);
}
#[test]
fn test_rule_function_schema_change() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION my_func() RETURNS int LANGUAGE plpgsql IMMUTABLE AS 'BEGIN RETURN 1; END';",
&mut state,
)
.unwrap();
let orig_id = ObjectId::new("public", "my_func()");
assert!(state.local.functions.contains_key(&orig_id));
let v = engine
.analyze("ALTER FUNCTION my_func() SET SCHEMA myschema;", &mut state)
.unwrap();
assert!(
!v.iter().any(|v| v.rule_id == "function-volatility-change"),
"Schema change should not trigger volatility rule"
);
}
#[test]
fn test_overloaded_function_search_path() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE SCHEMA myschema;", &mut state)
.unwrap();
engine
.analyze("SET search_path TO myschema, public;", &mut state)
.unwrap();
engine.analyze(
"CREATE FUNCTION myschema.my_overloaded_func(x integer) RETURNS int LANGUAGE plpgsql IMMUTABLE AS 'BEGIN RETURN x; END';",
&mut state
).unwrap();
let v = engine
.analyze(
"ALTER FUNCTION my_overloaded_func(integer) IMMUTABLE;",
&mut state,
)
.unwrap();
assert!(v.is_empty());
let target_id = ObjectId::new("myschema", "my_overloaded_func(integer)");
assert!(state.local.functions.contains_key(&target_id));
}
#[test]
fn test_rule_drop_function_recreate() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION temp_func() RETURNS int LANGUAGE plpgsql AS 'BEGIN RETURN 1; END';",
&mut state,
)
.unwrap();
let func_id = ObjectId::new("public", "temp_func()");
assert!(state.local.functions.contains_key(&func_id));
engine
.analyze("DROP FUNCTION temp_func();", &mut state)
.unwrap();
assert!(matches!(
state.local.functions.get(&func_id),
Some(FunctionOverlay::Dropped)
));
engine
.analyze(
"CREATE FUNCTION temp_func() RETURNS text LANGUAGE plpgsql AS 'BEGIN RETURN ''hello''; END';",
&mut state,
)
.unwrap();
assert!(matches!(
state.local.functions.get(&func_id),
Some(FunctionOverlay::Present(_))
));
}
#[test]
fn test_rule_broken_compute_drop_function_with_trigger() {
let engine = setup_engine();
let mut state = setup_state();
let function_id = object_id("public", "notify_func()");
state.local.functions.insert(
function_id.clone(),
FunctionOverlay::Present(FunctionState {
id: function_id,
routine_kind: RoutineKind::Function,
arg_types: Vec::new(),
arg_type_ids: Vec::new(),
return_type: "trigger".into(),
return_type_id: None,
volatility: Volatility::Volatile,
language: "plpgsql".into(),
security: SecurityMode::Invoker,
}),
);
engine
.analyze(
"CREATE TABLE events(id int);
CREATE TRIGGER trg_events AFTER INSERT ON events FOR EACH ROW EXECUTE FUNCTION notify_func();",
&mut state,
)
.unwrap();
let v = engine
.analyze("DROP FUNCTION notify_func();", &mut state)
.unwrap();
assert!(
v.iter()
.any(|violation| violation.rule_id == "broken-compute"),
"Dropping a function used by a trigger should fire 'broken-compute' rule"
);
let violation = v
.iter()
.find(|violation| violation.rule_id == "broken-compute")
.unwrap();
assert_eq!(violation.tier, ViolationTier::Tier1);
assert!(violation.reason.contains("trg_events"));
assert!(violation.reason.contains("events"));
}
#[test]
fn test_rule_vacuum_full() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine.analyze("VACUUM FULL t;", &mut state).unwrap();
assert!(v.iter().any(|v| v.rule_id.contains("vacuum")));
assert!(state.evidence().iter().any(|record| {
record.code == safe_migrate::_internal::analysis::evidence::EvidenceCode::UnsupportedSemantics
}));
}
#[test]
fn test_rule_concurrent_index() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
if let Some(safe_migrate::_internal::model::relation::RelationOverlay::Present(rel)) =
state.local.relations.get_mut(&object_id("public", "t"))
{
rel.estimated_rows = Some(500_000);
rel.created_at_tx_depth = 999;
}
let v1 = engine
.analyze("CREATE INDEX i ON t(id);", &mut state)
.unwrap();
assert!(
v1.iter()
.any(|v| v.rule_id.contains("concurrent") || v.rule_id.contains("index"))
);
let v2 = engine
.analyze("CREATE INDEX CONCURRENTLY i2 ON t(id);", &mut state)
.unwrap();
assert!(!v2.iter().any(|v| v.rule_id == "require-concurrent-index"));
}
#[test]
fn test_rule_concurrent_index_outside_transaction() {
let engine = setup_engine();
let mut state = setup_state();
state.baseline_relations.insert(object_id("public", "t"));
state.local.relations.insert(
object_id("public", "t"),
safe_migrate::_internal::model::relation::RelationOverlay::Present(
safe_migrate::_internal::model::relation::RelationState::new(
object_id("public", "t"),
object_id("public", "postgres"),
0,
Some(500_000),
safe_migrate::_internal::model::relation::RelationKind::Table,
safe_migrate::_internal::model::relation::Persistence::Permanent,
0,
),
),
);
let v1 = engine
.analyze("CREATE INDEX i ON t(id);", &mut state)
.unwrap();
assert!(v1.iter().any(|v| v.rule_id == "require-concurrent-index"));
}
#[test]
fn test_rule_concurrent_drop_index() {
let mut config = Config::default();
config.rules.insert(
"require-concurrent-index".to_string(),
RuleConfig {
tier1_threshold_rows: Some(1_000_000),
tier2_threshold_rows: Some(800_000),
..RuleConfig::default()
},
);
let engine = SafeMigrateEngine::new(config);
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
safe_migrate::_internal::model::relation::RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
engine
.analyze("CREATE INDEX i ON t(id);", &mut state)
.unwrap();
let v = engine.analyze("DROP INDEX i;", &mut state).unwrap();
let finding = v
.iter()
.find(|v| v.rule_id == "require-concurrent-drop-index")
.expect("non-concurrent DROP INDEX should be flagged");
assert_eq!(finding.tier, ViolationTier::Tier3);
}
#[test]
fn test_rule_concurrent_drop_index_small() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
safe_migrate::_internal::model::relation::RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(100),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
engine
.analyze("CREATE INDEX i ON t(id);", &mut state)
.unwrap();
let v = engine.analyze("DROP INDEX i;", &mut state).unwrap();
assert!(
v.iter()
.any(|v| v.rule_id == "require-concurrent-drop-index"),
"Small table DROP INDEX should still be flagged at Tier3 or not at all"
);
}
#[test]
fn scoped_cache_reports_unknown_schema_as_coverage_not_drift() {
let engine = setup_engine();
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
cache.metadata.schemas = Some(vec!["app".to_string()]);
let mut state = AnalysisState::new(cache);
let violations = engine
.analyze(
"ALTER TABLE public.external_table ADD COLUMN note text;",
&mut state,
)
.unwrap();
let coverage = violations
.iter()
.find(|violation| violation.rule_id == "schema-drift")
.expect("an omitted schema must be reported as unknown coverage");
assert_eq!(coverage.tier, ViolationTier::Tier2);
assert!(coverage.reason.contains("does not cover schema \"public\""));
assert!(!coverage.reason.contains("does not exist"));
}
#[test]
fn guarded_missing_drops_do_not_report_schema_drift() {
let engine = setup_engine();
for sql in [
"DROP TABLE IF EXISTS missing;",
"DROP VIEW IF EXISTS missing;",
"DROP MATERIALIZED VIEW IF EXISTS missing;",
"DROP INDEX IF EXISTS missing;",
"DROP SEQUENCE IF EXISTS missing;",
"DROP FUNCTION IF EXISTS missing();",
"DROP PROCEDURE IF EXISTS missing();",
"DROP DOMAIN IF EXISTS missing;",
"DROP TYPE IF EXISTS missing;",
] {
let mut state = setup_state();
let violations = engine.analyze(sql, &mut state).unwrap();
assert!(
!violations
.iter()
.any(|violation| violation.rule_id == "schema-drift"),
"{sql}"
);
}
}
#[test]
fn missing_procedure_changes_report_schema_drift() {
let engine = setup_engine();
for sql in [
"DROP PROCEDURE missing(integer);",
"ALTER PROCEDURE missing(integer) RENAME TO renamed;",
] {
let mut state = setup_state();
let violations = engine.analyze(sql, &mut state).unwrap();
assert!(
violations.iter().any(|violation| {
violation.rule_id == "schema-drift"
&& violation.object_kind
== safe_migrate::_internal::report::violations::ObjectKind::Procedure
}),
"{sql}"
);
}
}
#[test]
fn procedure_drift_checks_the_cached_routine_kind() {
let engine = setup_engine();
let routine_id = object_id("public", "work(integer)");
for routine_kind in [RoutineKind::Function, RoutineKind::Procedure] {
let mut cache = safe_migrate::_internal::db::cache::DbCache::new();
cache.functions.insert(
routine_id.clone(),
FunctionState {
id: routine_id.clone(),
routine_kind,
arg_types: vec!["integer".into()],
arg_type_ids: Vec::new(),
return_type: "void".into(),
return_type_id: None,
volatility: Volatility::Volatile,
language: "sql".into(),
security: SecurityMode::Invoker,
},
);
let mut state = AnalysisState::new(cache);
let violations = engine
.analyze(
"ALTER PROCEDURE work(integer) RENAME TO renamed;",
&mut state,
)
.unwrap();
assert_eq!(
violations
.iter()
.filter(|violation| violation.rule_id == "schema-drift")
.count(),
usize::from(routine_kind == RoutineKind::Function)
);
}
}
}