#![allow(unused_imports)]
#[cfg(test)]
pub mod helpers {
use crate::ast::identifiers::ObjectId;
use crate::db::cache::DbCache;
use crate::engine::config::Config;
use crate::engine::engine::SafeMigrateEngine;
pub fn setup_engine() -> SafeMigrateEngine {
SafeMigrateEngine::new(Config::default())
}
pub fn setup_state() -> crate::analysis::state::AnalysisState {
crate::analysis::state::AnalysisState::new(DbCache::new())
}
pub fn object_id(schema: &str, name: &str) -> ObjectId {
ObjectId::new(schema, name)
}
}
#[cfg(test)]
mod state_machine_guards_tests {
use super::helpers::*;
use crate::analysis::state::AnalysisState;
use crate::model::relation::{Persistence, RelationKind, RelationOverlay, RelationState};
#[test]
fn test_skip_guard_create_table() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id INT);", &mut state)
.unwrap();
engine
.analyze("CREATE TABLE IF NOT EXISTS t(new_col INT);", &mut state)
.unwrap();
let rel = state.get_relation(&object_id("public", "t")).unwrap();
if let RelationOverlay::Present(r) = rel {
assert!(r.has_column("id"));
assert!(!r.has_column("new_col"));
} else {
panic!("relation should be present");
}
}
#[test]
fn test_reversibility_type_widen() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
let tid = object_id("public", "t");
let mut rel = RelationState::new(
tid.clone(),
object_id("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
);
rel.apply_column_action(&crate::model::relation::ColumnAction::Add {
name: "val".to_string(),
data_type: Some("int".to_string()),
not_null: false,
default: None,
});
cache.insert_baseline(tid, rel);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze("ALTER TABLE t ALTER COLUMN val TYPE bigint;", &mut state)
.unwrap();
assert!(
v.iter()
.all(|viol| viol.rule_id != "irreversible-migration"),
"ReversibilityRule flagged widening as irreversible: {:?}",
v.iter()
.filter(|viol| viol.rule_id == "irreversible-migration")
.collect::<Vec<_>>()
);
}
#[test]
fn test_skip_guard_drop_column() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id INT);", &mut state)
.unwrap();
assert!(
engine
.analyze("ALTER TABLE t DROP COLUMN IF EXISTS missing;", &mut state)
.is_ok()
);
}
#[test]
fn test_skip_guard_drop_missing_objects() {
let engine = setup_engine();
let mut state = setup_state();
assert!(
engine
.analyze("DROP TABLE IF EXISTS missing;", &mut state)
.is_ok()
);
assert!(
engine
.analyze("DROP VIEW IF EXISTS missing;", &mut state)
.is_ok()
);
assert!(
engine
.analyze("DROP MATERIALIZED VIEW IF EXISTS missing;", &mut state)
.is_ok()
);
assert!(
engine
.analyze("DROP INDEX IF EXISTS missing;", &mut state)
.is_ok()
);
assert!(
engine
.analyze("DROP SEQUENCE IF EXISTS missing;", &mut state)
.is_ok()
);
assert!(
engine
.analyze("DROP DOMAIN IF EXISTS missing;", &mut state)
.is_ok()
);
}
#[test]
fn test_skip_guard_create_index() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
engine
.analyze("CREATE INDEX idx ON t(id);", &mut state)
.unwrap();
let edge_count = state.local.graph.indexes.len();
engine
.analyze("CREATE INDEX IF NOT EXISTS idx ON t(id);", &mut state)
.unwrap();
assert_eq!(state.local.graph.indexes.len(), edge_count);
}
#[test]
fn test_skip_guard_create_sequence() {
let engine = setup_engine();
let mut state = setup_state();
engine.analyze("CREATE SEQUENCE s;", &mut state).unwrap();
let before = state.local.graph.sequences.len();
engine
.analyze(
"CREATE SEQUENCE IF NOT EXISTS s OWNED BY foo.bar;",
&mut state,
)
.unwrap();
assert_eq!(state.local.graph.sequences.len(), before);
}
}
#[cfg(test)]
mod rule_evaluation_tests {
use super::helpers::*;
use crate::analysis::facts::{
AlterFunctionAction, FuncOptionFact, ParamFact, ParamModeFact, RetTypeFact, VolatilityKind,
};
use crate::analysis::mutations::{AlterFunctionMutation, CreateFunctionMutation, Mutation};
use crate::analysis::state::{AnalysisState, Confidence, MutationResult};
use crate::ast::identifiers::ObjectId;
use crate::engine::config::Config;
use crate::model::column::Column;
use crate::model::function::{FunctionOverlay, FunctionState};
use crate::model::relation::{Persistence, RelationKind, RelationState};
use crate::report::violations::ViolationTier;
use crate::rules::Rule;
use crate::rules::functions::FunctionVolatilityRule;
#[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 = crate::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()),
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_fk() {
let engine = setup_engine();
let mut cache = crate::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.contains("constraint")));
}
#[test]
fn test_rule_blocking_constraint_pk_and_unique() {
let engine = setup_engine();
let mut cache = crate::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 = crate::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 = crate::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 state = setup_state();
engine
.analyze(
"DO $$ BEGIN EXECUTE 'DROP TABLE users'; END $$;",
&mut state,
)
.unwrap();
let v = engine.analyze("DROP TABLE t CASCADE;", &mut state).unwrap();
assert!(
v.iter().any(|v| v.tier == ViolationTier::Tier2),
"expected Tier2 after tainted confidence: {:?}",
v
);
assert!(
v.iter().all(|v| !v.reason.contains("DOWNGRADED")),
"expected no downgrade notice in reason: {:?}",
v.iter().map(|v| &v.reason).collect::<Vec<_>>()
);
}
#[test]
fn test_exact_confidence_keeps_tier1() {
let engine = setup_engine();
let mut cache = crate::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 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 = crate::db::cache::DbCache::new();
let tid = object_id("public", "t");
let rel = RelationState::new(
tid.clone(),
object_id("public", "postgres"),
0,
Some(10),
crate::model::relation::RelationKind::Table,
crate::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"));
}
#[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(int,int)");
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();
engine
.analyze(
"CREATE FUNCTION notify_func() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';",
&mut state,
)
.unwrap();
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")));
}
#[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(crate::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"),
crate::model::relation::RelationOverlay::Present(
crate::model::relation::RelationState::new(
object_id("public", "t"),
object_id("public", "postgres"),
0,
Some(500_000),
crate::model::relation::RelationKind::Table,
crate::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 engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
crate::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();
assert!(
v.iter()
.any(|v| v.rule_id == "require-concurrent-drop-index"),
"Non-concurrent DROP INDEX on large table should be flagged"
);
}
#[test]
fn test_rule_concurrent_drop_index_small() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
crate::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"
);
}
}
#[cfg(test)]
mod reversibility_tests {
use super::helpers::*;
use crate::analysis::state::AnalysisState;
use crate::ast::identifiers::ObjectId;
use crate::model::relation::{Persistence, RelationKind, RelationState};
use crate::report::violations::ViolationTier;
#[test]
fn test_reversibility_drop_column_empty_table() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id INT);", &mut state)
.unwrap();
let v = engine
.analyze("ALTER TABLE t DROP COLUMN id;", &mut state)
.unwrap();
assert!(
v.iter()
.any(|violation| violation.tier == ViolationTier::Tier3)
);
}
#[test]
fn test_reversibility_drop_column_nonempty_table() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
let tid = object_id("public", "t");
let mut rel = RelationState::new(
tid.clone(),
object_id("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
);
rel.apply_column_action(&crate::model::relation::ColumnAction::Add {
name: "id".to_string(),
data_type: Some("int".to_string()),
not_null: false,
default: None,
});
cache.insert_baseline(tid.clone(), rel);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze("ALTER TABLE t DROP COLUMN id;", &mut state)
.unwrap();
assert!(
v.iter()
.any(|violation| violation.tier == ViolationTier::Tier1)
);
}
#[test]
fn test_reversibility_drop_column_added_in_transaction() {
let engine = setup_engine();
let mut cache = crate::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(
"BEGIN; ALTER TABLE t ADD COLUMN val int; ALTER TABLE t DROP COLUMN val; COMMIT;",
&mut state,
)
.unwrap();
assert!(
v.iter()
.all(|violation| violation.rule_id != "irreversible-migration"
|| violation.tier != ViolationTier::Tier1)
);
}
#[test]
fn test_reversibility_drop_table() {
let engine = setup_engine();
let mut cache = crate::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;", &mut state).unwrap();
assert!(
v.iter()
.any(|violation| violation.tier == ViolationTier::Tier1)
);
}
#[test]
fn test_reversibility_rename_table() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id INT);", &mut state)
.unwrap();
let v = engine
.analyze("ALTER TABLE t RENAME TO t2;", &mut state)
.unwrap();
assert!(v.is_empty());
}
#[test]
fn test_reversibility_add_nullable_column_no_default() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id INT);", &mut state)
.unwrap();
let v = engine
.analyze("ALTER TABLE t ADD COLUMN name TEXT;", &mut state)
.unwrap();
assert!(
v.iter()
.all(|violation| violation.rule_id != "irreversible-migration")
);
}
#[test]
fn test_reversibility_type_widen() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
let tid = object_id("public", "t");
let mut rel = RelationState::new(
tid.clone(),
object_id("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
);
rel.apply_column_action(&crate::model::relation::ColumnAction::Add {
name: "val".to_string(),
data_type: Some("int".to_string()),
not_null: false,
default: None,
});
cache.insert_baseline(tid, rel);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze("ALTER TABLE t ALTER COLUMN val TYPE bigint;", &mut state)
.unwrap();
assert!(
v.iter()
.all(|viol| viol.rule_id != "irreversible-migration")
);
}
#[test]
fn test_reversibility_type_narrow() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
let tid = object_id("public", "t");
let mut rel = RelationState::new(
tid.clone(),
object_id("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
);
rel.apply_column_action(&crate::model::relation::ColumnAction::Add {
name: "val".to_string(),
data_type: Some("bigint".to_string()),
not_null: false,
default: None,
});
cache.insert_baseline(tid, rel);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze("ALTER TABLE t ALTER COLUMN val TYPE int;", &mut state)
.unwrap();
assert!(
v.iter()
.any(|violation| violation.rule_id == "irreversible-migration")
);
}
}
#[cfg(test)]
mod state_mutation_tests {
use super::helpers::*;
use crate::analysis::state::{AnalysisState, Confidence};
use crate::ast::identifiers::ObjectId;
use crate::model::relation::{Persistence, RelationKind, RelationOverlay};
use crate::model::sequence::SequenceOverlay;
use crate::model::types::{TypeKind, TypeOverlay};
#[test]
fn test_topology_table_basic() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); ALTER TABLE t ADD COLUMN name text; ALTER TABLE t RENAME COLUMN name TO full_name;",
&mut state,
)
.unwrap();
let rel = state.get_relation(&object_id("public", "t")).unwrap();
if let RelationOverlay::Present(r) = rel {
assert!(r.has_column("id"));
assert!(r.has_column("full_name"));
assert!(!r.has_column("name"));
} else {
panic!("relation should be present");
}
}
#[test]
fn test_topology_drop_table() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int); DROP TABLE t;", &mut state)
.unwrap();
assert!(!state.relation_is_present(&object_id("public", "t")));
}
#[test]
fn test_topology_rename_table() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE a(id int); ALTER TABLE a RENAME TO b;",
&mut state,
)
.unwrap();
assert!(!state.relation_is_present(&object_id("public", "a")));
assert!(state.relation_is_present(&object_id("public", "b")));
assert!(
state
.local
.graph
.renames
.iter()
.any(|e| e.from == object_id("public", "a") && e.to == object_id("public", "b"))
);
}
#[test]
fn test_topology_rename_index() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); CREATE INDEX i ON t(id); ALTER INDEX i RENAME TO i2;",
&mut state,
)
.unwrap();
assert!(
state
.local
.graph
.indexes
.iter()
.any(|i| i.index_id == object_id("public", "i2"))
);
assert!(
!state
.local
.graph
.indexes
.iter()
.any(|i| i.index_id == object_id("public", "i"))
);
}
#[test]
fn test_topology_foreign_key_graph() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE p(id int); CREATE TABLE c(p_id int); ALTER TABLE c ADD CONSTRAINT fk FOREIGN KEY (p_id) REFERENCES p(id);",
&mut state,
)
.unwrap();
assert!(
state
.local
.graph
.foreign_keys
.iter()
.any(|fk| fk.from_table == object_id("public", "c")
&& fk.to_table == object_id("public", "p"))
);
engine
.analyze("ALTER TABLE c DROP CONSTRAINT fk;", &mut state)
.unwrap();
assert!(state.local.graph.foreign_keys.is_empty());
}
#[test]
fn test_topology_view_graph() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); CREATE VIEW v AS SELECT * FROM t;",
&mut state,
)
.unwrap();
assert!(
state
.local
.graph
.views
.iter()
.any(|v| v.view_id == object_id("public", "v")
&& v.depends_on.contains(&object_id("public", "t")))
);
engine.analyze("DROP VIEW v;", &mut state).unwrap();
assert!(state.local.graph.views.is_empty());
}
#[test]
fn test_topology_materialized_view_graph() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); CREATE MATERIALIZED VIEW mv AS SELECT * FROM t;",
&mut state,
)
.unwrap();
assert!(
state
.local
.graph
.views
.iter()
.any(|v| v.view_id == object_id("public", "mv")
&& v.depends_on.contains(&object_id("public", "t")))
);
}
#[test]
fn test_topology_sequence_graph() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); CREATE SEQUENCE s OWNED BY t.id;",
&mut state,
)
.unwrap();
assert!(
state
.local
.graph
.sequences
.iter()
.any(|s| s.sequence_id == object_id("public", "s")
&& s.table_id == object_id("public", "t"))
);
engine.analyze("DROP SEQUENCE s;", &mut state).unwrap();
assert!(matches!(
state.local.sequences.get(&object_id("public", "s")),
Some(SequenceOverlay::Dropped)
));
assert!(state.local.graph.sequences.is_empty());
}
#[test]
fn test_topology_type_and_domain() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TYPE e AS ENUM('a'); ALTER TYPE e ADD VALUE 'b'; CREATE DOMAIN d AS INT; ALTER DOMAIN d SET DEFAULT 1;",
&mut state,
)
.unwrap();
if let Some(TypeOverlay::Present(t)) = state.local.types.get(&object_id("public", "e")) {
if let TypeKind::Enum { variants } = &t.kind {
assert!(variants.contains(&"b".to_string()));
} else {
panic!("type e should be enum");
}
} else {
panic!("type e missing");
}
engine.analyze("DROP DOMAIN d;", &mut state).unwrap();
assert!(matches!(
state.local.types.get(&object_id("public", "d")),
Some(TypeOverlay::Dropped)
));
}
#[test]
fn test_topology_replication_graph() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); CREATE PUBLICATION p FOR TABLE t; CREATE SUBSCRIPTION s CONNECTION '...' PUBLICATION p;",
&mut state,
)
.unwrap();
assert!(state.local.publications.contains_key("p"));
assert!(state.local.subscriptions.contains_key("s"));
engine
.analyze("DROP PUBLICATION p; DROP SUBSCRIPTION s;", &mut state)
.unwrap();
assert!(matches!(
state.local.publications.get("p"),
Some(crate::model::replication::PublicationOverlay::Dropped)
));
assert!(matches!(
state.local.subscriptions.get("s"),
Some(crate::model::replication::SubscriptionOverlay::Dropped)
));
}
#[test]
fn test_topology_trigger_and_policy() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); CREATE POLICY p ON t FOR SELECT USING(true); CREATE TRIGGER tr BEFORE INSERT ON t EXECUTE FUNCTION f();",
&mut state,
)
.unwrap();
if let Some(RelationOverlay::Present(r)) = state.get_relation(&object_id("public", "t")) {
assert!(r.policies.contains("p"));
assert!(r.triggers.contains("tr"));
}
engine
.analyze("DROP POLICY p ON t; DROP TRIGGER tr ON t;", &mut state)
.unwrap();
if let Some(RelationOverlay::Present(r)) = state.get_relation(&object_id("public", "t")) {
assert!(!r.policies.contains("p"));
assert!(!r.triggers.contains("tr"));
}
}
#[test]
fn test_topology_publication() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE PUBLICATION pub FOR TABLE t1, t2;", &mut state)
.unwrap();
assert!(state.local.publications.contains_key("pub"));
let deps = &state.local.graph.publication_dependencies;
assert_eq!(deps.len(), 2);
assert!(
deps.iter()
.any(|d| d.publication_name == "pub" && d.table_id == object_id("public", "t1"))
);
assert!(
deps.iter()
.any(|d| d.publication_name == "pub" && d.table_id == object_id("public", "t2"))
);
}
#[test]
fn test_topology_subscription() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE SUBSCRIPTION sub CONNECTION 'host=localhost' PUBLICATION pub;",
&mut state,
)
.unwrap();
assert!(state.local.subscriptions.contains_key("sub"));
}
#[test]
fn test_topology_role_lifecycle() {
let engine = setup_engine();
let mut state = setup_state();
let role_id = ObjectId::new("", "app_user");
engine.analyze("CREATE ROLE app_user;", &mut state).unwrap();
assert!(state.local.roles.contains_key(&role_id));
engine
.analyze("ALTER ROLE app_user WITH INHERIT;", &mut state)
.unwrap();
if let Some(crate::model::role::RoleOverlay::Present(role)) =
state.local.roles.get(&role_id)
{
assert!(role.can_login);
} else {
panic!("role app_user should be present");
}
engine.analyze("DROP ROLE app_user;", &mut state).unwrap();
assert!(matches!(
state.local.roles.get(&role_id),
Some(crate::model::role::RoleOverlay::Dropped)
));
}
#[test]
fn test_topology_function() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION f(int) RETURNS int AS '...' LANGUAGE plpgsql;",
&mut state,
)
.unwrap();
let id = object_id("public", "f(int)");
assert!(state.local.functions.contains_key(&id));
}
#[test]
fn test_topology_procedure() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE PROCEDURE p(int) AS '...' LANGUAGE plpgsql;",
&mut state,
)
.unwrap();
let id = object_id("public", "p(int)");
assert!(state.local.functions.contains_key(&id));
}
#[test]
fn test_topology_search_path() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"SET search_path TO myschema, public; CREATE TABLE t(id int);",
&mut state,
)
.unwrap();
assert!(state.relation_is_present(&object_id("myschema", "t")));
}
#[test]
fn test_state_alter_column_types() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id INT NOT NULL); ALTER TABLE t ALTER COLUMN id SET DATA TYPE text; ALTER TABLE t ALTER COLUMN id DROP NOT NULL; ALTER TABLE t ALTER COLUMN id SET DEFAULT 'x';",
&mut state,
)
.unwrap();
if let Some(RelationOverlay::Present(r)) = state.get_relation(&object_id("public", "t")) {
let col = r.get_column("id").unwrap();
assert_eq!(col.data_type.as_deref(), Some("text"));
assert!(col.is_nullable);
assert!(col.default.is_some());
} else {
panic!("relation should be present");
}
}
#[test]
fn test_state_storage_and_access_method() {
let engine = setup_engine();
let mut state = setup_state();
assert!(engine
.analyze(
"CREATE TABLE t(id int); ALTER TABLE t ALTER COLUMN id SET STORAGE MAIN; ALTER TABLE t SET ACCESS METHOD heap;",
&mut state,
)
.is_ok());
}
#[test]
fn test_bug011_set_storage_multiple_spaces() {
let engine = setup_engine();
let mut state = setup_state();
assert!(
engine
.analyze(
"CREATE TABLE t(id int); ALTER TABLE t ALTER COLUMN id SET STORAGE MAIN;",
&mut state,
)
.is_ok()
);
}
#[test]
fn test_state_confidence_is_accessible() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
let _ = &state.local.confidence;
}
#[test]
fn test_state_drop_view_cascade() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); CREATE VIEW v AS SELECT * FROM t;",
&mut state,
)
.unwrap();
assert!(state.relation_is_present(&object_id("public", "v")));
engine.analyze("DROP VIEW v;", &mut state).unwrap();
assert!(!state.relation_is_present(&object_id("public", "v")));
assert!(state.relation_is_present(&object_id("public", "t")));
}
#[test]
fn test_state_drop_materialized_view_cleanup() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); CREATE MATERIALIZED VIEW mv AS SELECT * FROM t; CREATE INDEX i ON mv(id);",
&mut state,
)
.unwrap();
assert!(state.relation_is_present(&object_id("public", "mv")));
assert!(
state
.local
.graph
.indexes
.iter()
.any(|i| i.relation_id == object_id("public", "mv"))
);
engine
.analyze("DROP MATERIALIZED VIEW mv;", &mut state)
.unwrap();
assert!(!state.relation_is_present(&object_id("public", "mv")));
assert!(state.relation_is_present(&object_id("public", "t")));
}
#[test]
fn test_state_drop_function_if_exists() {
let engine = setup_engine();
let mut state = setup_state();
assert_eq!(state.local.confidence, Confidence::Exact);
engine
.analyze("DROP FUNCTION IF EXISTS missing_func();", &mut state)
.unwrap();
assert_eq!(state.local.confidence, Confidence::Exact);
}
#[test]
fn test_state_drop_procedure_if_exists() {
let engine = setup_engine();
let mut state = setup_state();
assert_eq!(state.local.confidence, Confidence::Exact);
engine
.analyze("DROP PROCEDURE IF EXISTS missing_proc();", &mut state)
.unwrap();
assert_eq!(state.local.confidence, Confidence::Exact);
}
#[test]
fn test_state_alter_publication_non_existent() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE PUBLICATION existing_pub FOR ALL TABLES;",
&mut state,
)
.unwrap();
assert!(state.local.publications.contains_key("existing_pub"));
engine
.analyze("ALTER PUBLICATION missing_pub SET TABLE t;", &mut state)
.unwrap();
assert_eq!(state.local.confidence, Confidence::Tainted);
}
#[test]
fn test_state_grant_revoke_topology() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); GRANT SELECT ON t TO public;",
&mut state,
)
.unwrap();
assert!(state.relation_is_present(&object_id("public", "t")));
assert_eq!(state.local.confidence, Confidence::Exact);
engine
.analyze("REVOKE SELECT ON t FROM public;", &mut state)
.unwrap();
assert!(state.relation_is_present(&object_id("public", "t")));
}
}
#[cfg(test)]
mod transaction_lifecycle_tests {
use super::helpers::*;
use crate::analysis::state::AnalysisState;
#[test]
fn test_txn_commit() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("BEGIN; CREATE TABLE t(id int); COMMIT;", &mut state)
.unwrap();
assert!(state.local.transactions.is_empty());
assert!(state.relation_is_present(&object_id("public", "t")));
}
#[test]
fn test_txn_rollback() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("BEGIN; CREATE TABLE t(id int); ROLLBACK;", &mut state)
.unwrap();
assert!(state.local.transactions.is_empty());
assert!(!state.relation_is_present(&object_id("public", "t")));
}
#[test]
fn test_savepoint_flow() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"BEGIN; SAVEPOINT s1; CREATE TABLE t(id int); ROLLBACK TO s1; RELEASE SAVEPOINT s1; COMMIT;",
&mut state,
)
.unwrap();
assert!(state.local.transactions.is_empty());
}
#[test]
fn test_rollback_to_savepoint_keeps_outer_txn() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"BEGIN; SAVEPOINT s1; CREATE TABLE t(id int); ROLLBACK TO s1; COMMIT;",
&mut state,
)
.unwrap();
assert!(state.local.transactions.is_empty());
assert!(!state.relation_is_present(&object_id("public", "t")));
}
#[test]
fn test_rename_propagation_rollback() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
let t1_id = object_id("public", "t1");
let v1_id = object_id("public", "v1");
cache.insert_baseline(
t1_id.clone(),
crate::model::relation::RelationState::new(
t1_id.clone(),
object_id("public", "postgres"),
0,
Some(10),
crate::model::relation::RelationKind::Table,
crate::model::relation::Persistence::Permanent,
0,
),
);
cache.insert_baseline(
v1_id.clone(),
crate::model::relation::RelationState::new(
v1_id.clone(),
object_id("public", "postgres"),
0,
Some(1),
crate::model::relation::RelationKind::View,
crate::model::relation::Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
state
.local
.graph
.views
.push(crate::analysis::graph::ViewEdge {
view_id: v1_id.clone(),
depends_on: vec![t1_id.clone()],
view_generation: 0,
});
assert_eq!(state.local.graph.views[0].depends_on[0], t1_id);
engine
.analyze("BEGIN; ALTER TABLE t1 RENAME TO t2; ROLLBACK;", &mut state)
.unwrap();
assert!(state.relation_is_present(&t1_id));
assert!(!state.relation_is_present(&object_id("public", "t2")));
assert_eq!(state.local.graph.views[0].depends_on[0], t1_id);
}
}
#[cfg(test)]
mod expression_parsing_tests {
use super::helpers::*;
use crate::analysis::state::AnalysisState;
fn assert_expr(expr: &str) {
let engine = setup_engine();
let mut state = setup_state();
assert!(
engine
.analyze(
&format!("CREATE TABLE t(val INT DEFAULT {});", expr),
&mut state
)
.is_ok()
);
}
#[test]
fn test_expr_literal() {
assert_expr("42");
}
#[test]
fn test_expr_name_ref() {
assert_expr("some_col");
}
#[test]
fn test_expr_call() {
assert_expr("COALESCE(1, 2)");
}
#[test]
fn test_expr_bin_op() {
assert_expr("1 + 2 * 3 = 7");
}
#[test]
fn test_expr_cast() {
assert_expr("1::text");
}
#[test]
fn test_expr_prefix() {
assert_expr("-42");
}
#[test]
fn test_expr_paren() {
assert_expr("(1 + 2)");
}
#[test]
fn test_expr_case() {
assert_expr("CASE WHEN true THEN 1 ELSE 0 END");
}
#[test]
fn test_expr_array() {
assert_expr("ARRAY[1, 2, 3]");
}
#[test]
fn test_expr_between() {
assert_expr("5 BETWEEN 1 AND 10");
}
#[test]
fn test_expr_index() {
assert_expr("arr[1]");
}
#[test]
fn test_expr_slice() {
assert_expr("arr[1:3]");
}
#[test]
fn test_expr_slice_omitted() {
assert_expr("arr[2:]");
}
#[test]
fn test_expr_field() {
assert_expr("(my_record).my_field");
}
#[test]
fn test_parser_syntax_error_rejection() {
let engine = setup_engine();
let mut state = AnalysisState::new(crate::db::cache::DbCache::new());
assert!(engine.analyze("CREATE TABLE (;", &mut state).is_err());
}
}
#[cfg(test)]
mod identifier_casing_tests {
use super::helpers::*;
use crate::analysis::state::AnalysisState;
use crate::model::relation::RelationOverlay;
#[test]
fn test_ident_unquoted_lowercase() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE Users (Id int);", &mut state)
.unwrap();
assert!(state.relation_is_present(&object_id("public", "users")));
}
#[test]
fn test_ident_quoted_preserve() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE \"MyTable\" (\"MyCol\" int);", &mut state)
.unwrap();
let mixed_id = object_id("public", "MyTable");
assert!(state.relation_is_present(&mixed_id));
engine
.analyze(
"ALTER TABLE \"MyTable\" RENAME TO \"NewTable\";",
&mut state,
)
.unwrap();
assert!(!state.relation_is_present(&mixed_id));
assert!(state.relation_is_present(&object_id("public", "NewTable")));
engine
.analyze(
"ALTER TABLE \"NewTable\" RENAME COLUMN \"MyCol\" TO \"NewCol\";",
&mut state,
)
.unwrap();
let rel = state
.get_relation(&object_id("public", "NewTable"))
.unwrap();
if let RelationOverlay::Present(r) = rel {
assert!(r.has_column("NewCol"));
assert!(!r.has_column("MyCol"));
} else {
panic!("NewTable must be Present");
}
}
#[test]
fn test_ident_schema_resolution() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE MySchema.MyTable (id int);", &mut state)
.unwrap();
assert!(state.relation_is_present(&object_id("myschema", "mytable")));
}
}
#[cfg(test)]
mod destructive_rule_tests {
use super::helpers::*;
use crate::analysis::state::AnalysisState;
use crate::ast::identifiers::ObjectId;
use crate::model::column::Column;
use crate::model::relation::{Persistence, RelationKind, RelationState};
use crate::report::violations::ViolationTier;
#[test]
fn test_rule_drop_view_cascade() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); CREATE VIEW v AS SELECT * FROM t;",
&mut state,
)
.unwrap();
let v = engine.analyze("DROP VIEW v CASCADE;", &mut state).unwrap();
assert!(
v.iter().any(|v| v.rule_id == "destructive-general-cascade"),
"DROP VIEW CASCADE should trigger GeneralCascadeRule"
);
}
#[test]
fn test_rule_drop_database() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze("DROP DATABASE test_db;", &mut state)
.unwrap();
assert!(
v.iter().any(|v| v.rule_id == "drop-database"),
"DROP DATABASE should trigger DropDatabaseRule"
);
}
#[test]
fn test_rule_create_table_as_select() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"CREATE TABLE backup AS SELECT * FROM source_table;",
&mut state,
)
.unwrap();
assert!(
v.iter().any(|v| v.rule_id == "create-table-as-select"),
"CREATE TABLE AS SELECT should trigger CreateTableAsSelectRule"
);
}
#[test]
fn test_rule_type_change_rewrite_varchar_to_text() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
crate::model::relation::RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
engine
.analyze("CREATE TABLE t(data varchar(100) NOT NULL);", &mut state)
.unwrap();
let v = engine
.analyze("ALTER TABLE t ALTER COLUMN data TYPE text;", &mut state)
.unwrap();
if let Some(viol) = v.iter().find(|v| v.rule_id == "type-change-rewrite") {
assert!(
viol.tier != ViolationTier::Tier1,
"Safe varchar->text conversion should not be Tier1"
);
}
}
#[test]
fn test_rule_type_change_narrow_varchar_unbounded() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
let tid = object_id("public", "t");
let mut rel = RelationState::new(
tid.clone(),
object_id("public", "postgres"),
0,
Some(100),
RelationKind::Table,
Persistence::Permanent,
0,
);
rel.apply_column_action(&crate::model::relation::ColumnAction::Add {
name: "val".to_string(),
data_type: Some("varchar".to_string()),
not_null: false,
default: None,
});
cache.insert_baseline(tid, rel);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze(
"ALTER TABLE t ALTER COLUMN val TYPE varchar(10);",
&mut state,
)
.unwrap();
assert!(
v.iter().any(|viol| viol.rule_id == "type-change-rewrite"
&& viol.reason.contains("narrows VARCHAR precision (lossy)")),
"Unbounded to bounded varchar change should be flagged as lossy narrowing: {:?}",
v
);
}
#[test]
fn test_rule_varchar_narrowing_lossy() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
let mut rel = crate::model::relation::RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500),
RelationKind::Table,
Persistence::Permanent,
0,
);
rel.columns.push(Column {
name: "data".into(),
data_type: Some("character varying(255)".into()),
is_nullable: true,
default: None,
avg_width: None,
default_expr_text: None,
type_modifier: Some(259),
});
cache.insert_baseline(object_id("public", "t"), rel);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze(
"ALTER TABLE t ALTER COLUMN data TYPE varchar(50);",
&mut state,
)
.unwrap();
assert!(
v.iter()
.any(|v| v.rule_id == "type-change-rewrite" && v.reason.contains("narrows")),
"255→50 should be flagged as lossy VARCHAR narrowing"
);
let mut cache2 = crate::db::cache::DbCache::new();
let mut rel2 = crate::model::relation::RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500),
RelationKind::Table,
Persistence::Permanent,
0,
);
rel2.columns.push(Column {
name: "data".into(),
data_type: Some("character varying(50)".into()),
is_nullable: true,
default: None,
avg_width: None,
default_expr_text: None,
type_modifier: Some(54),
});
cache2.insert_baseline(object_id("public", "t"), rel2);
let mut state2 = AnalysisState::new(cache2);
let v2 = engine
.analyze(
"ALTER TABLE t ALTER COLUMN data TYPE varchar(255);",
&mut state2,
)
.unwrap();
assert!(
!v2.iter().any(|v| v.rule_id == "type-change-rewrite"),
"50→255 should be safe and NOT trigger type-change-rewrite: {:?}",
v2
);
}
#[test]
fn test_rule_drift_detection_drop_missing_table() {
let mut cache = crate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "existing_tbl"),
crate::model::relation::RelationState::new(
object_id("public", "existing_tbl"),
ObjectId::new("public", "postgres"),
0,
Some(100),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let engine = setup_engine();
let mut state = AnalysisState::new(cache);
let v = engine
.analyze("DROP TABLE nonexistent_tbl;", &mut state)
.unwrap();
assert!(
v.iter().any(|v| v.rule_id == "schema-drift"),
"DriftDetectionRule should flag DROP on table not in baseline"
);
assert_eq!(
v.iter().find(|v| v.rule_id == "schema-drift").unwrap().tier,
ViolationTier::Tier2,
"Drift becomes Tier2 after confidence is tainted by missing DROP"
);
}
#[test]
fn test_rule_drift_detection_alter_missing_table() {
let mut cache = crate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "existing_tbl"),
crate::model::relation::RelationState::new(
object_id("public", "existing_tbl"),
ObjectId::new("public", "postgres"),
0,
Some(100),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let engine = setup_engine();
let mut state = AnalysisState::new(cache);
let v = engine
.analyze("ALTER TABLE nonexistent_tbl ADD COLUMN x int;", &mut state)
.unwrap();
assert!(
v.iter().any(|v| v.rule_id == "schema-drift"),
"DriftDetectionRule should flag ALTER on table not in baseline"
);
assert_eq!(
v.iter().find(|v| v.rule_id == "schema-drift").unwrap().tier,
ViolationTier::Tier1,
"Drift should be Tier1"
);
}
#[test]
fn test_rule_drift_detection_drop_existing_table() {
let mut cache = crate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "existing_tbl"),
crate::model::relation::RelationState::new(
object_id("public", "existing_tbl"),
ObjectId::new("public", "postgres"),
0,
Some(100),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let engine = setup_engine();
let mut state = AnalysisState::new(cache);
let v = engine
.analyze("DROP TABLE existing_tbl;", &mut state)
.unwrap();
assert!(
!v.iter().any(|v| v.rule_id == "schema-drift"),
"DriftDetectionRule should NOT flag DROP on table present in baseline: {:?}",
v
);
}
#[test]
fn test_rule_drift_detection_non_table_objects() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze("DROP INDEX nonexistent_idx;", &mut state)
.unwrap();
assert!(
v.iter()
.any(|v| v.rule_id == "schema-drift" && v.reason.contains("index"))
);
let v = engine
.analyze("DROP SEQUENCE nonexistent_seq;", &mut state)
.unwrap();
assert!(
v.iter()
.any(|v| v.rule_id == "schema-drift" && v.reason.contains("sequence"))
);
let v = engine
.analyze("ALTER TYPE nonexistent_type ADD VALUE 'val';", &mut state)
.unwrap();
assert!(
v.iter()
.any(|v| v.rule_id == "schema-drift" && v.reason.contains("type"))
);
let v = engine
.analyze("ALTER FUNCTION nonexistent_func() IMMUTABLE;", &mut state)
.unwrap();
assert!(
v.iter()
.any(|v| v.rule_id == "schema-drift" && v.reason.contains("function"))
);
}
#[test]
fn test_rule_type_change_rewrite_unsafe_small() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
crate::model::relation::RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
engine
.analyze("CREATE TABLE t(data int);", &mut state)
.unwrap();
let v = engine
.analyze("ALTER TABLE t ALTER COLUMN data TYPE text;", &mut state)
.unwrap();
assert!(
v.iter().any(|v| v.rule_id == "type-change-rewrite"),
"int to text should trigger type-change-rewrite"
);
if let Some(viol) = v.iter().find(|v| v.rule_id == "type-change-rewrite") {
assert_eq!(
viol.tier,
ViolationTier::Tier2,
"Small table unsafe type change should be Tier2"
);
}
}
}
#[cfg(test)]
mod alter_schema_visitor_test {
#[test]
fn test_alter_schema_pipeline() {
use crate::analysis::facts::StatementFact;
use crate::ast::visitor::AstVisitor;
use squawk_syntax::ast::SourceFile;
let sql = "ALTER SCHEMA old_name RENAME TO new_name";
let parsed = SourceFile::parse(sql);
let stmt = parsed.tree().stmts().next().expect("Failed to parse SQL");
let fact = AstVisitor::extract(&stmt);
match &fact {
Some(StatementFact::AlterSchema { name, new_name }) => {
assert_eq!(name.name.text, "old_name");
assert!(new_name.is_some());
assert_eq!(new_name.as_ref().unwrap().text, "new_name");
}
other => panic!("Expected AlterSchema, got {:?}", other),
}
}
}
#[cfg(test)]
mod architectural_gap_tests {
use super::helpers::*;
use crate::analysis::state::Confidence;
use crate::ast::identifiers::ObjectId;
use crate::model::relation::{Persistence, RelationKind, RelationOverlay};
use crate::model::types::{TypeKind, TypeOverlay};
use crate::report::violations::ViolationTier;
#[test]
fn test_fk_parent_table_lock_escalation() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "parent_tbl"),
crate::model::relation::RelationState::new(
object_id("public", "parent_tbl"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
cache.insert_baseline(
object_id("public", "child_tbl"),
crate::model::relation::RelationState::new(
object_id("public", "child_tbl"),
ObjectId::new("public", "postgres"),
0,
Some(10),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = crate::analysis::state::AnalysisState::new(cache);
let violations = engine.analyze("ALTER TABLE child_tbl ADD CONSTRAINT fk FOREIGN KEY (p_id) REFERENCES parent_tbl(id);", &mut state).unwrap();
let is_tier_1 = violations
.iter()
.any(|v| v.tier == ViolationTier::Tier1 && v.rule_id.contains("blocking-constraint"));
assert!(
is_tier_1,
"Failed to escalate lock severity based on parent table size"
);
}
#[test]
fn test_nested_release_savepoint_chain() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"
BEGIN;
CREATE TABLE t1(id int);
SAVEPOINT s1;
CREATE TABLE t2(id int);
SAVEPOINT s2;
CREATE TABLE t3(id int);
RELEASE SAVEPOINT s2;
ROLLBACK TO s1;
COMMIT;
",
&mut state,
)
.unwrap();
assert!(state.relation_is_present(&object_id("public", "t1")));
assert!(!state.relation_is_present(&object_id("public", "t2")));
assert!(!state.relation_is_present(&object_id("public", "t3")));
}
#[test]
fn test_rollback_to_savepoint_partial() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"
BEGIN;
CREATE TABLE a(id int);
SAVEPOINT s;
CREATE TABLE b(id int);
ROLLBACK TO s;
CREATE TABLE c(id int);
COMMIT;
",
&mut state,
)
.unwrap();
assert!(state.relation_is_present(&object_id("public", "a")));
assert!(state.relation_is_present(&object_id("public", "c")));
assert!(!state.relation_is_present(&object_id("public", "b")));
}
#[test]
fn test_drop_schema_cascade_cleans_renames() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE SCHEMA s; CREATE TABLE s.t(id int); ALTER TABLE s.t RENAME TO t2;",
&mut state,
)
.unwrap();
assert!(!state.local.graph.renames.is_empty());
engine
.analyze("DROP SCHEMA s CASCADE;", &mut state)
.unwrap();
assert!(
state.local.graph.renames.is_empty(),
"Rename edges leaked after schema cascade"
);
}
#[test]
fn test_multi_schema_search_path_resolution() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE SCHEMA s1; CREATE SCHEMA s2; SET search_path TO s1, s2;",
&mut state,
)
.unwrap();
engine
.analyze("CREATE TABLE t1(id int);", &mut state)
.unwrap();
assert!(state.relation_is_present(&object_id("s1", "t1")));
}
#[test]
fn test_tombstone_shadowing_recreate() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
let gen1 = if let RelationOverlay::Present(r) =
state.get_relation(&object_id("public", "t")).unwrap()
{
r.generation
} else {
0
};
engine.analyze("DROP TABLE t;", &mut state).unwrap();
engine
.analyze("CREATE TABLE t(new_id text);", &mut state)
.unwrap();
if let RelationOverlay::Present(r) = state.get_relation(&object_id("public", "t")).unwrap()
{
assert!(
r.generation > gen1,
"Recreated table must have higher generation"
);
assert!(r.has_column("new_id"));
} else {
panic!("Table did not recreate over tombstone");
}
}
#[test]
fn test_drop_missing_object_halts_topology_mutation() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE exists_tbl(id int);", &mut state)
.unwrap();
let _ = engine.analyze("DROP TABLE missing_tbl;", &mut state);
assert!(state.relation_is_present(&object_id("public", "exists_tbl")));
assert_eq!(state.local.confidence, Confidence::Tainted);
}
#[test]
fn test_view_dependency_cte_isolation() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE base_table(id int);", &mut state)
.unwrap();
engine
.analyze(
"CREATE VIEW v AS WITH my_cte AS (SELECT * FROM base_table) SELECT * FROM my_cte;",
&mut state,
)
.unwrap();
let edge = state
.local
.graph
.views
.iter()
.find(|v| v.view_id == object_id("public", "v"))
.unwrap();
assert!(edge.depends_on.contains(&object_id("public", "base_table")));
assert!(!edge.depends_on.contains(&object_id("public", "my_cte")));
}
#[test]
fn test_partition_graph_cleanup_on_drop() {
let engine = setup_engine();
let mut state = setup_state();
engine.analyze("CREATE TABLE p(id int) PARTITION BY RANGE(id); CREATE TABLE c PARTITION OF p FOR VALUES FROM (1) TO (10);", &mut state).unwrap();
engine.analyze("DROP TABLE c;", &mut state).unwrap();
assert!(
state.local.graph.partitions.is_empty(),
"Partition edge leaked after child drop"
);
}
#[test]
fn test_concurrent_index_rollback() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
engine
.analyze(
"BEGIN; CREATE INDEX CONCURRENTLY idx ON t(id); ROLLBACK;",
&mut state,
)
.unwrap();
assert!(state.local.graph.indexes.is_empty());
}
#[test]
fn test_opaque_confidence_taint_persistence() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("DO $$ BEGIN EXECUTE 'DROP TABLE x;'; END $$;", &mut state)
.unwrap();
assert_eq!(state.local.confidence, Confidence::Tainted);
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
assert_eq!(state.local.confidence, Confidence::Tainted);
}
#[test]
fn test_quoted_ident_search_path() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE SCHEMA \"MySchema\"; SET search_path TO \"MySchema\";",
&mut state,
)
.unwrap();
engine
.analyze("CREATE TABLE \"MyTable\" (\"MyCol\" int);", &mut state)
.unwrap();
assert!(state.relation_is_present(&object_id("MySchema", "MyTable")));
}
#[test]
fn test_create_domain_recreation() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE DOMAIN my_type AS int;", &mut state)
.unwrap();
engine.analyze("DROP DOMAIN my_type;", &mut state).unwrap();
engine
.analyze("CREATE DOMAIN my_type AS text;", &mut state)
.unwrap();
assert!(matches!(
state.local.types.get(&object_id("public", "my_type")),
Some(TypeOverlay::Present(_))
));
}
#[test]
fn test_stale_view_edge_cleanup() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE t(id int); CREATE VIEW v AS SELECT * FROM t;",
&mut state,
)
.unwrap();
engine
.analyze("DROP VIEW v; CREATE VIEW v AS SELECT * FROM t;", &mut state)
.unwrap();
assert_eq!(
state.local.graph.views.len(),
1,
"Duplicate view edge created"
);
}
#[test]
fn test_if_not_exists_preserves_original_metadata() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id INT);", &mut state)
.unwrap();
let gen1 = if let RelationOverlay::Present(r) =
state.get_relation(&object_id("public", "t")).unwrap()
{
r.generation
} else {
0
};
engine
.analyze(
"CREATE TABLE IF NOT EXISTS t(id TEXT, diff_col INT);",
&mut state,
)
.unwrap();
let rel = state.get_relation(&object_id("public", "t")).unwrap();
if let RelationOverlay::Present(r) = rel {
assert_eq!(r.generation, gen1);
assert_eq!(
r.get_column("id").unwrap().data_type.as_deref(),
Some("INT")
);
assert!(!r.has_column("diff_col"));
}
}
#[test]
fn test_deep_rename_traversal_cascade() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"
CREATE TABLE a(id int);
CREATE VIEW v AS SELECT * FROM a;
ALTER TABLE a RENAME TO b;
DROP TABLE b CASCADE;
",
&mut state,
)
.unwrap();
assert!(
!state.relation_is_present(&object_id("public", "a")),
"Original table a should be gone"
);
assert!(
!state.relation_is_present(&object_id("public", "b")),
"Renamed table b should be gone"
);
assert!(
!state.relation_is_present(&object_id("public", "v")),
"Dependent view v should have been cascaded"
);
}
#[test]
fn test_partition_cycle_rejection() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"
CREATE TABLE a(id int) PARTITION BY RANGE(id);
CREATE TABLE b PARTITION OF a FOR VALUES FROM (1) TO (10) PARTITION BY RANGE(id);
ALTER TABLE b ATTACH PARTITION a FOR VALUES FROM (1) TO (10);
",
&mut state,
)
.unwrap();
assert_eq!(
state.local.confidence,
Confidence::Tainted,
"Partition cycle should taint the engine"
);
}
#[test]
fn test_tablespace_access_method_rewrite() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
cache.insert_baseline(
object_id("public", "massive_table"),
crate::model::relation::RelationState::new(
object_id("public", "massive_table"),
ObjectId::new("public", "postgres"),
0,
Some(150_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
let mut state = crate::analysis::state::AnalysisState::new(cache);
let v1 = engine
.analyze(
"ALTER TABLE massive_table SET ACCESS METHOD columnar;",
&mut state,
)
.unwrap();
assert!(
v1.iter()
.any(|v| v.rule_id == "table-rewrite-access-method"
&& v.tier == ViolationTier::Tier1)
);
let v2 = engine
.analyze(
"ALTER TABLE massive_table ALTER COLUMN id SET STORAGE MAIN;",
&mut state,
)
.unwrap();
assert!(
v2.iter()
.any(|v| v.rule_id == "table-rewrite-storage" && v.tier == ViolationTier::Tier1)
);
}
#[test]
fn test_generation_counter_rollback() {
let engine = setup_engine();
let mut state = setup_state();
let initial_gen = state.local.generation_counter;
engine
.analyze("BEGIN; CREATE TABLE t(id int);", &mut state)
.unwrap();
let mid_gen = state.local.generation_counter;
assert!(
mid_gen > initial_gen,
"Generation counter should increment on create"
);
engine.analyze("ROLLBACK;", &mut state).unwrap();
let post_gen = state.local.generation_counter;
assert_eq!(
post_gen, initial_gen,
"Generation counter should restore strictly to pre-txn state on rollback"
);
}
#[test]
fn test_partition_children_cascade_enumeration() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"
CREATE TABLE parent(id int) PARTITION BY RANGE(id);
CREATE TABLE child PARTITION OF parent FOR VALUES FROM (1) TO (10);
DROP TABLE parent CASCADE;
",
&mut state,
)
.unwrap();
assert!(
!state.relation_is_present(&object_id("public", "parent")),
"Parent should be dropped"
);
assert!(
!state.relation_is_present(&object_id("public", "child")),
"Child should be dropped via reverse-graph cascade"
);
}
#[test]
fn test_rename_updates_fk_graph_edges() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"
CREATE TABLE a(id int);
CREATE TABLE b(a_id int);
ALTER TABLE b ADD CONSTRAINT fk FOREIGN KEY (a_id) REFERENCES a(id);
ALTER TABLE a RENAME TO a2;
",
&mut state,
)
.unwrap();
let refs = state
.local
.graph
.is_referenced_by_fk(&object_id("public", "a2"));
assert!(
!refs.is_empty(),
"a2 should be recognized as referenced by b's FK dynamically"
);
assert_eq!(refs[0].0, &object_id("public", "b"));
}
#[test]
fn test_search_path_existence_check() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"
CREATE SCHEMA actual_schema;
CREATE TABLE actual_schema.my_table(id int);
SET search_path = nonexistent_schema, actual_schema;
ALTER TABLE my_table ADD COLUMN new_col int;
",
&mut state,
)
.unwrap();
let rel = state
.get_relation(&object_id("actual_schema", "my_table"))
.unwrap();
if let crate::model::relation::RelationOverlay::Present(r) = rel {
assert!(
r.has_column("new_col"),
"Should resolve to actual_schema bypassing nonexistent_schema"
);
} else {
panic!("Table not found; resolver hallucinated the schema");
}
}
#[test]
fn test_drop_without_cascade_validates_dependents() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"
CREATE TABLE a(id int);
CREATE TABLE b(a_id int);
ALTER TABLE b ADD CONSTRAINT fk FOREIGN KEY (a_id) REFERENCES a(id);
",
&mut state,
)
.unwrap();
let _ = engine.analyze("DROP TABLE a;", &mut state);
assert_eq!(
state.local.confidence,
crate::analysis::state::Confidence::Tainted,
"Engine should taint on unsafe drop"
);
assert!(
state.relation_is_present(&object_id("public", "a")),
"Table a should not be dropped if dependents exist without CASCADE"
);
}
}
#[cfg(test)]
mod chain_execution_tests {
use super::helpers::*;
use crate::analysis::state::AnalysisState;
use crate::model::relation::RelationOverlay;
use crate::report::violations::ViolationTier;
#[test]
fn test_chain_state_persists_across_files() {
let engine = setup_engine();
let mut state = setup_state();
let files = vec![
(
"V1__create.sql".to_string(),
"CREATE TABLE IF NOT EXISTS users (id INT);".to_string(),
),
(
"V2__alter.sql".to_string(),
"ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;".to_string(),
),
];
let violations = engine.analyze_chain(&files, &mut state).unwrap();
assert!(
violations.is_empty(),
"Expected no violations, got: {:?}",
violations
);
let rel = state.get_relation(&object_id("public", "users")).unwrap();
if let RelationOverlay::Present(r) = rel {
assert!(r.has_column("id"));
assert!(r.has_column("email"));
} else {
panic!("users table should be present after chain");
}
}
#[test]
fn test_chain_rename_visible_across_files() {
let engine = setup_engine();
let mut state = setup_state();
let files = vec![
(
"V1__base.sql".to_string(),
"CREATE TABLE IF NOT EXISTS orders (id INT);".to_string(),
),
(
"V2__rename.sql".to_string(),
"ALTER TABLE orders RENAME TO purchases;".to_string(),
),
(
"V3__post_rename.sql".to_string(),
"ALTER TABLE purchases ADD COLUMN IF NOT EXISTS total NUMERIC;".to_string(),
),
];
let violations = engine.analyze_chain(&files, &mut state).unwrap();
assert!(
violations.is_empty(),
"Expected no violations, got: {:?}",
violations
);
assert!(!state.relation_is_present(&object_id("public", "orders")));
assert!(state.relation_is_present(&object_id("public", "purchases")));
let rel = state
.get_relation(&object_id("public", "purchases"))
.unwrap();
if let RelationOverlay::Present(r) = rel {
assert!(r.has_column("total"));
} else {
panic!("purchases table should be present");
}
}
#[test]
fn test_chain_conflict_same_column_different_type() {
let engine = setup_engine();
let mut state = setup_state();
let files = vec![
(
"V1__create.sql".to_string(),
"CREATE TABLE products (id INT);".to_string(),
),
(
"V2__add_price.sql".to_string(),
"ALTER TABLE products ADD COLUMN price INT;".to_string(),
),
(
"V3__change_price.sql".to_string(),
"ALTER TABLE products ADD COLUMN price TEXT;".to_string(),
),
];
let violations = engine.analyze_chain(&files, &mut state).unwrap();
let conflict_violations: Vec<_> = violations
.iter()
.filter(|v| v.rule_id == "chain-conflict")
.collect();
assert!(
!conflict_violations.is_empty(),
"Expected chain-conflict violation, got: {:?}",
violations
);
let conflict = conflict_violations
.iter()
.find(|v| v.tier == ViolationTier::Tier1);
assert!(conflict.is_some(), "Conflict should be Tier1");
assert!(
conflict.unwrap().reason.contains("price"),
"Conflict message should mention column 'price'"
);
assert!(
conflict.unwrap().reason.contains("INT"),
"Conflict message should mention existing type INT"
);
assert!(
conflict.unwrap().reason.contains("TEXT"),
"Conflict message should mention conflicting type TEXT"
);
}
#[test]
fn test_chain_no_conflict_same_column_same_type() {
let engine = setup_engine();
let mut state = setup_state();
let files = vec![
(
"V1__create.sql".to_string(),
"CREATE TABLE IF NOT EXISTS items (id INT);".to_string(),
),
(
"V1__add.sql".to_string(),
"ALTER TABLE items ADD COLUMN IF NOT EXISTS code TEXT;".to_string(),
),
(
"V2__idempotent.sql".to_string(),
"ALTER TABLE items ADD COLUMN IF NOT EXISTS code TEXT;".to_string(),
),
];
let violations = engine.analyze_chain(&files, &mut state).unwrap();
let conflict_violations: Vec<_> = violations
.iter()
.filter(|v| v.rule_id == "chain-conflict")
.collect();
assert!(
conflict_violations.is_empty(),
"Expected no chain-conflict violation for same-type re-add, got: {:?}",
violations
);
let rel = state.get_relation(&object_id("public", "items")).unwrap();
if let RelationOverlay::Present(r) = rel {
assert!(r.has_column("code"));
let col = r.get_column("code").unwrap();
assert_eq!(col.data_type.as_deref(), Some("TEXT"));
} else {
panic!("items table should be present");
}
}
}
#[cfg(test)]
mod phase10_bug_fixes_and_sorting_tests {
use super::helpers::*;
use crate::analysis::state::AnalysisState;
use crate::ast::identifiers::ObjectId;
use crate::db::cache::DbCache;
use crate::model::relation::{Persistence, RelationKind, RelationOverlay};
use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier};
#[test]
fn test_bug008_index_not_in_baseline_relations() {
let mut cache = DbCache::new();
cache.indexes.push(crate::db::cache::IndexCache {
index_id: object_id("public", "idx_accounts_username"),
table_id: object_id("public", "accounts"),
});
let state = AnalysisState::new(cache);
assert!(
!state
.baseline_relations
.contains(&object_id("public", "idx_accounts_username"))
);
}
#[test]
fn test_bug008_index_in_baseline_indexes() {
let mut cache = DbCache::new();
cache.indexes.push(crate::db::cache::IndexCache {
index_id: object_id("public", "idx_accounts_username"),
table_id: object_id("public", "accounts"),
});
let state = AnalysisState::new(cache);
assert!(
state
.baseline_indexes
.contains(&object_id("public", "idx_accounts_username"))
);
}
#[test]
fn test_bug008_stale_stats_does_not_fire_on_index() {
let engine = setup_engine();
let mut cache = DbCache::new();
cache.insert_baseline(
object_id("public", "accounts"),
crate::model::relation::RelationState::new(
object_id("public", "accounts"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
cache.indexes.push(crate::db::cache::IndexCache {
index_id: object_id("public", "idx_accounts_username"),
table_id: object_id("public", "accounts"),
});
let mut state = AnalysisState::new(cache);
let violations = engine
.analyze("DROP INDEX idx_accounts_username;", &mut state)
.unwrap();
for v in &violations {
if v.reason.contains("statistics are stale") {
assert!(
!v.reason.contains("idx_accounts_username"),
"Should not fire stale stats warning on the index itself"
);
}
}
}
#[test]
fn test_add_column_now_default_not_flagged() {
let engine = setup_engine();
let mut cache = DbCache::new();
cache.pg_version_num = Some(110000);
cache.insert_baseline(
object_id("public", "t"),
crate::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);
let violations = engine
.analyze(
"ALTER TABLE t ADD COLUMN created_at TIMESTAMP DEFAULT NOW();",
&mut state,
)
.unwrap();
assert!(
!violations
.iter()
.any(|v| v.rule_id == "size-aware-add-column"),
"Expected no size-aware-add-column violation for stable DEFAULT NOW() on PG11+, got: {:?}",
violations
);
}
#[test]
fn test_drop_index_no_stale_stats_warning() {
let engine = setup_engine();
let mut cache = DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
crate::model::relation::RelationState::new(
object_id("public", "t"),
ObjectId::new("public", "postgres"),
0,
Some(500_000),
RelationKind::Table,
Persistence::Permanent,
0,
),
);
cache.indexes.push(crate::db::cache::IndexCache {
index_id: object_id("public", "idx_t_id"),
table_id: object_id("public", "t"),
});
let mut state = AnalysisState::new(cache);
let violations = engine.analyze("DROP INDEX idx_t_id;", &mut state).unwrap();
assert!(
violations
.iter()
.any(|v| v.rule_id == "require-concurrent-drop-index"),
"Expected require-concurrent-drop-index violation"
);
assert!(
!violations
.iter()
.any(|v| v.reason.contains("statistics are stale") || v.reason.contains("stale")),
"Should not emit stale statistics warning on DROP INDEX, got: {:?}",
violations
);
}
#[test]
fn test_deterministic_violation_sorting() {
let engine = setup_engine();
let mut cache = DbCache::new();
cache.insert_baseline(
object_id("public", "t"),
crate::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);
let sql = "CREATE INDEX idx2 ON t(a); CREATE INDEX idx1 ON t(b);";
let violations = engine.analyze(sql, &mut state).unwrap();
let idx_violations: Vec<&Violation> = violations
.iter()
.filter(|v| {
v.rule_id == "require-concurrent-index"
&& v.reason.contains("Synchronous index creation")
})
.collect();
assert_eq!(idx_violations.len(), 2);
assert_eq!(idx_violations[0].object_name, "public.idx2");
assert_eq!(idx_violations[1].object_name, "public.idx1");
}
#[test]
#[allow(clippy::useless_vec)]
fn test_manual_violation_sorting() {
let v_tier3 = Violation {
source_range: Some(rowan::TextRange::new(0.into(), 10.into())),
rule_id: "rule_a",
operation_kind: OperationKind::DropTable,
object_kind: ObjectKind::Table,
object_name: "a".to_string(),
tier: ViolationTier::Tier3,
reason: "tier 3".to_string(),
recipe: "recipe",
dedup_key: None,
sql: None,
};
let v_tier1 = Violation {
source_range: Some(rowan::TextRange::new(0.into(), 10.into())),
rule_id: "rule_b",
operation_kind: OperationKind::DropTable,
object_kind: ObjectKind::Table,
object_name: "b".to_string(),
tier: ViolationTier::Tier1,
reason: "tier 1".to_string(),
recipe: "recipe",
dedup_key: None,
sql: None,
};
let v_range_later = Violation {
source_range: Some(rowan::TextRange::new(20.into(), 30.into())),
rule_id: "rule_c",
operation_kind: OperationKind::DropTable,
object_kind: ObjectKind::Table,
object_name: "c".to_string(),
tier: ViolationTier::Tier1,
reason: "tier 1 later range".to_string(),
recipe: "recipe",
dedup_key: None,
sql: None,
};
let v_name_later = Violation {
source_range: Some(rowan::TextRange::new(0.into(), 10.into())),
rule_id: "rule_b",
operation_kind: OperationKind::DropTable,
object_kind: ObjectKind::Table,
object_name: "z".to_string(),
tier: ViolationTier::Tier1,
reason: "tier 1 name later".to_string(),
recipe: "recipe",
dedup_key: None,
sql: None,
};
let mut violations = vec![
v_tier3.clone(),
v_tier1.clone(),
v_range_later.clone(),
v_name_later.clone(),
];
violations.sort_by(|a, b| {
a.tier
.cmp(&b.tier)
.then_with(|| match (&a.source_range, &b.source_range) {
(Some(ar), Some(br)) => ar
.start()
.cmp(&br.start())
.then_with(|| ar.end().cmp(&br.end())),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
})
.then_with(|| a.object_name.cmp(&b.object_name))
.then_with(|| a.rule_id.cmp(b.rule_id))
});
assert_eq!(violations[0].reason, "tier 1");
assert_eq!(violations[1].reason, "tier 1 name later");
assert_eq!(violations[2].reason, "tier 1 later range");
assert_eq!(violations[3].reason, "tier 3");
}
#[test]
fn test_source_range_excludes_preceding_comments() {
let engine = setup_engine();
let mut state = setup_state();
let sql = "-- This is a preceding comment\n /* block comment */\n DROP DATABASE my_db;";
let violations = engine.analyze(sql, &mut state).unwrap();
assert!(
!violations.is_empty(),
"expected a violation for DROP DATABASE"
);
let v = &violations[0];
let range = v.source_range.expect("expected source_range to be set");
let expected_start = sql.find("DROP DATABASE").unwrap();
assert_eq!(usize::from(range.start()), expected_start);
assert_eq!(usize::from(range.end()), sql.len());
}
#[test]
fn test_bug003_broken_compute_fires_on_drop_function() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE FUNCTION notify_func(int, text) RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';",
&mut state,
)
.unwrap();
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(int, text);", &mut state)
.unwrap();
assert!(
v.iter()
.any(|violation| violation.rule_id == "broken-compute"),
"Expected broken-compute violation when dropping function used by trigger, even with parameter list"
);
}
#[test]
fn test_bug004_concurrent_in_txn_skips_on_skipped_mutation() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE TABLE tbl(id int);
CREATE INDEX idx_t ON tbl(id);",
&mut state,
)
.unwrap();
let v = engine
.analyze(
"BEGIN;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_t ON tbl(id);
COMMIT;",
&mut state,
)
.unwrap();
assert!(
!v.iter()
.any(|violation| violation.rule_id == "concurrent-in-transaction"),
"Expected no concurrent-in-transaction violation because the index already exists and statement was skipped"
);
}
#[test]
fn test_bug005_create_table_as_select_skips_on_skipped_mutation() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE tbl(id int);", &mut state)
.unwrap();
let v = engine
.analyze("CREATE TABLE IF NOT EXISTS tbl AS SELECT 1;", &mut state)
.unwrap();
assert!(
!v.iter()
.any(|violation| violation.rule_id == "create-table-as-select"),
"Expected no create-table-as-select violation because the table already exists and statement was skipped"
);
}
#[test]
fn test_bug007_broken_compute_no_fire_on_skipped_drop() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze("DROP FUNCTION IF EXISTS nonexistent_func();", &mut state)
.unwrap();
assert!(
!v.iter()
.any(|violation| violation.rule_id == "broken-compute"),
"Expected no broken-compute violation for nonexistent function"
);
}
#[test]
fn test_bug013_confidence_restored_on_rollback() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
let tid = object_id("public", "t");
cache.insert_baseline(
tid.clone(),
crate::model::relation::RelationState::new(
tid.clone(),
object_id("public", "postgres"),
0,
Some(10),
crate::model::relation::RelationKind::Table,
crate::model::relation::Persistence::Permanent,
0,
),
);
let mut state = AnalysisState::new(cache);
engine
.analyze(
"BEGIN;
DO $$ BEGIN END $$;
ROLLBACK;",
&mut state,
)
.unwrap();
let v = engine.analyze("DROP TABLE t CASCADE;", &mut state).unwrap();
assert!(
v.iter()
.any(|violation| violation.tier == crate::report::violations::ViolationTier::Tier1),
"Expected Tier1 because confidence should be restored to Exact after rollback"
);
}
#[test]
fn test_bug014_drop_schema_cascade_cleans_trigger_edges() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze(
"CREATE SCHEMA s;
CREATE FUNCTION s.notify_func() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';
CREATE TABLE s.t1(id int);
CREATE TRIGGER trg AFTER INSERT ON s.t1 FOR EACH ROW EXECUTE FUNCTION s.notify_func();
CREATE PUBLICATION pub FOR TABLE s.t1;",
&mut state,
)
.unwrap();
assert!(!state.local.graph.trigger_dependencies.is_empty());
assert!(!state.local.graph.publication_dependencies.is_empty());
engine
.analyze("DROP SCHEMA s CASCADE;", &mut state)
.unwrap();
assert!(state.local.graph.trigger_dependencies.is_empty());
assert!(state.local.graph.publication_dependencies.is_empty());
engine
.analyze(
"CREATE FUNCTION unrelated() RETURNS void LANGUAGE plpgsql AS 'BEGIN END';",
&mut state,
)
.unwrap();
let v = engine
.analyze("DROP FUNCTION unrelated();", &mut state)
.unwrap();
assert!(
!v.iter()
.any(|violation| violation.rule_id == "broken-compute"),
"Expected no broken-compute violation for unrelated function after schema drop cascade"
);
}
#[test]
fn test_bug001_vacuum_full_all_tables() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine.analyze("VACUUM FULL;", &mut state).unwrap();
let violation = v
.iter()
.find(|v| v.rule_id == "vacuum-full")
.expect("Expected vacuum-full violation");
assert_eq!(violation.object_name, "<all tables>");
}
#[test]
fn test_finding2_partition_strategy_mismatch_fires_on_none() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
let parent_id = object_id("public", "parent");
let mut parent = crate::model::relation::RelationState::new(
parent_id.clone(),
object_id("public", "postgres"),
0,
Some(10),
crate::model::relation::RelationKind::Table,
crate::model::relation::Persistence::Permanent,
0,
);
parent.partition_type = Some("RANGE".to_string());
cache.insert_baseline(parent_id, parent);
let child_id = object_id("public", "child");
let child = crate::model::relation::RelationState::new(
child_id,
object_id("public", "postgres"),
0,
Some(0),
crate::model::relation::RelationKind::Table,
crate::model::relation::Persistence::Permanent,
0,
);
cache.insert_baseline(object_id("public", "child"), child);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze(
"ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (1) TO (10);",
&mut state,
)
.unwrap();
let rule_violations: Vec<&str> = v.iter().map(|v| v.rule_id).collect();
assert!(
v.iter().any(|v| v.rule_id == "partition-strategy-mismatch"),
"Expected partition-strategy-mismatch violation when child has no partition strategy. Got: {:?}",
rule_violations
);
}
#[test]
fn test_finding2_partition_strategy_mismatch_fires_on_mismatch() {
let engine = setup_engine();
let mut cache = crate::db::cache::DbCache::new();
let parent_id = object_id("public", "parent");
let mut parent = crate::model::relation::RelationState::new(
parent_id.clone(),
object_id("public", "postgres"),
0,
Some(10),
crate::model::relation::RelationKind::Table,
crate::model::relation::Persistence::Permanent,
0,
);
parent.partition_type = Some("RANGE".to_string());
cache.insert_baseline(parent_id, parent);
let child_id = object_id("public", "child");
let mut child = crate::model::relation::RelationState::new(
child_id,
object_id("public", "postgres"),
0,
Some(0),
crate::model::relation::RelationKind::Table,
crate::model::relation::Persistence::Permanent,
0,
);
child.partition_type = Some("LIST".to_string());
cache.insert_baseline(object_id("public", "child"), child);
let mut state = AnalysisState::new(cache);
let v = engine
.analyze(
"ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (1) TO (10);",
&mut state,
)
.unwrap();
let rule_violations: Vec<&str> = v.iter().map(|v| v.rule_id).collect();
assert!(
v.iter().any(|v| v.rule_id == "partition-strategy-mismatch"),
"Expected partition-strategy-mismatch violation when strategies differ. Got: {:?}",
rule_violations
);
}
#[test]
fn test_finding2_partition_strategy_mismatch_silent_on_match() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"
CREATE TABLE parent(id int) PARTITION BY RANGE(id);
CREATE TABLE child(id int) PARTITION BY RANGE(id);
ALTER TABLE parent ATTACH PARTITION child FOR VALUES FROM (1) TO (10);
",
&mut state,
)
.unwrap();
assert!(
!v.iter().any(|v| v.rule_id == "partition-strategy-mismatch"),
"Expected no partition-strategy-mismatch violation when strategies match"
);
}
#[test]
fn test_finding2_alter_type_add_value_fires_inside_txn() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze(
"BEGIN; ALTER TYPE public.mood ADD VALUE 'ok'; COMMIT;",
&mut state,
)
.unwrap();
assert!(
v.iter().any(|v| v.rule_id == "alter-type-add-value-txn"),
"Expected alter-type-add-value-txn violation inside transaction"
);
}
#[test]
fn test_finding2_alter_type_add_value_silent_outside_txn() {
let engine = setup_engine();
let mut state = setup_state();
let v = engine
.analyze("ALTER TYPE public.mood ADD VALUE 'ok';", &mut state)
.unwrap();
assert!(
!v.iter().any(|v| v.rule_id == "alter-type-add-value-txn"),
"Expected no alter-type-add-value-txn violation outside transaction"
);
}
#[test]
fn test_finding8_drop_column_if_exists_noop() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
let v = engine
.analyze(
"ALTER TABLE t DROP COLUMN IF EXISTS nonexistent_col;",
&mut state,
)
.unwrap();
assert!(
!v.iter().any(|v| v.rule_id == "irreversible-migration"),
"ReversibilityRule should not fire on DROP COLUMN IF EXISTS of nonexistent column"
);
}
#[test]
fn test_finding8_drop_column_if_exists_with_existing_column() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int, name text);", &mut state)
.unwrap();
let v = engine
.analyze("ALTER TABLE t DROP COLUMN IF EXISTS name;", &mut state)
.unwrap();
assert!(
v.iter().any(|v| v.rule_id == "irreversible-migration"),
"ReversibilityRule should fire on DROP COLUMN IF EXISTS of an existing column"
);
}
#[test]
fn test_finding8_drop_column_no_if_exists_on_nonexistent() {
let engine = setup_engine();
let mut state = setup_state();
engine
.analyze("CREATE TABLE t(id int);", &mut state)
.unwrap();
let _v = engine
.analyze("ALTER TABLE t DROP COLUMN nonexistent_col;", &mut state)
.unwrap();
assert_eq!(
state.local.confidence,
crate::analysis::state::Confidence::Tainted,
"Confidence should be Tainted when dropping a nonexistent column without IF EXISTS"
);
}
}