use std::collections::BTreeSet;
use proptest::prelude::*;
use pgevolve_core::catalog::DriftReport;
use pgevolve_core::diff::ViewChange;
use pgevolve_core::identifier::QualifiedName;
use pgevolve_core::ir::catalog::Catalog;
use pgevolve_core::parse::normalize_body::NormalizedBody;
use pgevolve_core::plan::edges::NodeId;
use pgevolve_core::plan::{PlanId, build_create_graph};
use pgevolve_testkit::{
IRGeneratorConfig, arbitrary_catalog, arbitrary_view_catalog, docker_available,
};
const VIEW_BODIES: &[&str] = &[
"SELECT id FROM app.users",
"SELECT id, email FROM app.users",
"SELECT id FROM app.users WHERE id > 0",
"SELECT count(*) AS c FROM app.users",
"SELECT id, email FROM app.users WHERE id > 10 ORDER BY email",
"SELECT 1 AS one",
"SELECT id AS user_id, email AS user_email FROM app.users",
];
const PLPGSQL_BODIES: &[&str] = &[
"BEGIN NULL; END",
"BEGIN PERFORM 1; END",
"BEGIN\n PERFORM 1;\nEND",
"DECLARE\n v integer := 0;\nBEGIN\n v := 1;\nEND",
"BEGIN\n IF true THEN\n PERFORM 1;\n END IF;\nEND",
"BEGIN\n IF true THEN\n PERFORM 1;\n ELSE\n PERFORM 2;\n END IF;\nEND",
"BEGIN\n FOR i IN 1..10 LOOP\n PERFORM i;\n END LOOP;\nEND",
"BEGIN\n INSERT INTO app.log(msg) VALUES ('x');\nEND",
"BEGIN\n INSERT INTO app.log(msg) VALUES ('x');\n COMMIT;\nEND",
"-- @pgevolve dep: app.summary\nBEGIN EXECUTE 'REFRESH MATERIALIZED VIEW app.summary'; END",
"BEGIN\n RAISE NOTICE 'hello';\nEND",
"DECLARE\n r record;\nBEGIN\n FOR r IN SELECT id FROM app.users LOOP\n PERFORM r.id;\n END LOOP;\nEND",
];
fn pick_referenced_table(catalog: &Catalog) -> Option<QualifiedName> {
for v in &catalog.views {
for dep in &v.body_dependencies {
if let NodeId::Table(q) = &dep.to {
return Some(q.clone());
}
}
}
None
}
fn transitively_dependent_views(
catalog: &Catalog,
leaf_table: &QualifiedName,
) -> BTreeSet<QualifiedName> {
let mut reverse: std::collections::BTreeMap<QualifiedName, BTreeSet<QualifiedName>> =
std::collections::BTreeMap::new();
for v in &catalog.views {
for dep in &v.body_dependencies {
let dep_qname = match &dep.to {
NodeId::Table(q) | NodeId::View(q) | NodeId::Mv(q) => q.clone(),
_ => continue,
};
reverse
.entry(dep_qname)
.or_default()
.insert(v.qname.clone());
}
}
let mut affected: BTreeSet<QualifiedName> = BTreeSet::new();
let mut queue: Vec<QualifiedName> = Vec::new();
if let Some(dependents) = reverse.get(leaf_table) {
for q in dependents {
if affected.insert(q.clone()) {
queue.push(q.clone());
}
}
}
while let Some(trigger) = queue.pop() {
if let Some(dependents) = reverse.get(&trigger) {
for q in dependents {
if affected.insert(q.clone()) {
queue.push(q.clone());
}
}
}
}
affected
}
fn mutate_leaf_column(mut catalog: Catalog, leaf_qname: &QualifiedName) -> Option<Catalog> {
use pgevolve_core::ir::column_type::ColumnType;
let table = catalog.tables.iter_mut().find(|t| &t.qname == leaf_qname)?;
let pk_cols: BTreeSet<String> = table
.constraints
.iter()
.filter_map(|c| {
if let pgevolve_core::ir::constraint::ConstraintKind::PrimaryKey { columns, .. } =
&c.kind
{
Some(
columns
.iter()
.map(|id| id.as_str().to_string())
.collect::<Vec<_>>(),
)
} else {
None
}
})
.flatten()
.collect();
let target_col = table
.columns
.iter_mut()
.find(|c| !pk_cols.contains(c.name.as_str()))?;
target_col.ty = if matches!(target_col.ty, ColumnType::Text) {
ColumnType::BigInt
} else {
ColumnType::Text
};
target_col.nullable = true;
Some(catalog)
}
fn view_recreations_in_changes(
changes: &pgevolve_core::diff::ChangeSet,
) -> BTreeSet<QualifiedName> {
use pgevolve_core::diff::Change;
changes
.entries
.iter()
.filter_map(|e| match &e.change {
Change::View(ViewChange::ReplaceBody { source, .. }) => Some(source.qname.clone()),
_ => None,
})
.collect()
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[ignore = "property test — Docker-gated; run with `cargo test -- --ignored view_canonicalization_closed_under_pg_rewrite`"]
#[test]
fn view_canonicalization_closed_under_pg_rewrite(
body_idx in 0usize..VIEW_BODIES.len(),
) {
if !docker_available() {
return Ok(());
}
let body_text = VIEW_BODIES[body_idx];
let source_canon = NormalizedBody::from_sql(body_text)
.map_err(|e| TestCaseError::fail(format!("source canonicalize failed: {e}")))?;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("tokio runtime");
let catalog_body: Option<String> = rt.block_on(async {
use pgevolve_testkit::EphemeralPostgres;
let pg = match EphemeralPostgres::start(pgevolve_testkit::default_pg_version()).await {
Ok(pg) => pg,
Err(e) => {
eprintln!("skipping case: ephemeral PG failed to start: {e}");
return None;
}
};
let client = pg.connect().await.expect("connect");
client
.batch_execute(
"CREATE SCHEMA app; \
CREATE TABLE app.users (id bigint, email text);",
)
.await
.expect("create schema + table");
let create_sql = format!("CREATE VIEW app.v AS {body_text}");
client
.execute(&create_sql, &[])
.await
.expect("create view");
let row = client
.query_one(
"SELECT pg_get_viewdef('app.v'::regclass, true)",
&[],
)
.await
.expect("pg_get_viewdef");
let raw: String = row.get(0);
Some(raw)
});
let Some(catalog_body) = catalog_body else {
return Ok(());
};
let catalog_canon = NormalizedBody::from_sql(&catalog_body)
.map_err(|e| TestCaseError::fail(format!(
"catalog canonicalize failed for PG-rewritten body {catalog_body:?}: {e}"
)))?;
prop_assert_eq!(
source_canon.canonical_text(),
catalog_canon.canonical_text(),
"canonicalization diverged for body {:?}\n source => {:?}\n catalog => {:?}\n pg_get_viewdef raw => {:?}",
body_text,
source_canon.canonical_text(),
catalog_canon.canonical_text(),
catalog_body,
);
}
#[ignore = "property test — run via property-tests workflow or `cargo test -- --ignored`"]
#[test]
fn plan_minimality_under_no_op_mutations(
catalog in arbitrary_catalog(IRGeneratorConfig::default()),
) {
let drift = pgevolve_core::catalog::DriftReport::default();
let changes = pgevolve_core::diff::diff(&catalog, &catalog, &drift);
prop_assert!(changes.is_empty(), "C → C produced {:?}", changes);
}
#[ignore = "property test — run via property-tests workflow or `cargo test -- --ignored`"]
#[test]
fn plan_id_is_deterministic(
source in arbitrary_catalog(IRGeneratorConfig::default()),
target in arbitrary_catalog(IRGeneratorConfig::default()),
) {
let a = PlanId::compute(&source, &target, "0.1.0", 1).unwrap();
let b = PlanId::compute(&source, &target, "0.1.0", 1).unwrap();
prop_assert_eq!(a, b);
let c = PlanId::compute(&source, &target, "0.1.0", 2).unwrap();
prop_assert_ne!(a, c);
}
#[ignore = "property test — run via property-tests workflow or `cargo test -- --ignored`"]
#[test]
fn create_graph_topo_sorts_or_only_fk_cycles(
catalog in arbitrary_catalog(IRGeneratorConfig::default()),
) {
let g = build_create_graph(&catalog);
match g.topological_sort() {
Ok(_) => {}
Err(cycle) => {
for node in &cycle.nodes {
prop_assert!(
matches!(node, NodeId::Table(_) | NodeId::Constraint { .. }),
"cycle contains non-FK node: {node:?}",
);
}
}
}
}
#[ignore = "property test — run via property-tests workflow or `cargo test -- --ignored`"]
#[test]
fn enum_add_value_preserves_existing_values(
existing in proptest::collection::vec("[a-z]{1,5}", 1..5usize),
new_value in "[a-z]{1,5}",
) {
use pgevolve_core::diff::change::{Change, UserTypeChange};
use pgevolve_core::identifier::{Identifier, QualifiedName};
use pgevolve_core::ir::user_type::{EnumValue, UserType, UserTypeKind};
prop_assume!(!existing.contains(&new_value));
let unique_existing: Vec<String> = {
let mut seen = std::collections::BTreeSet::new();
existing.into_iter().filter(|v| seen.insert(v.clone())).collect()
};
prop_assume!(!unique_existing.is_empty());
#[allow(clippy::cast_precision_loss)]
let before: Vec<EnumValue> = unique_existing
.iter()
.enumerate()
.map(|(i, n)| EnumValue { name: n.clone(), sort_order: i as f32 + 1.0 })
.collect();
#[allow(clippy::cast_precision_loss)]
let new_sort_order = before.len() as f32 + 1.0;
let mut after = before.clone();
after.push(EnumValue {
name: new_value.clone(),
sort_order: new_sort_order,
});
let qname = QualifiedName::new(
Identifier::from_unquoted("app").unwrap(),
Identifier::from_unquoted("status").unwrap(),
);
let cat = vec![UserType {
qname: qname.clone(),
kind: UserTypeKind::Enum { values: before },
comment: None,
owner: None,
grants: vec![],
}];
let src = vec![UserType {
qname,
kind: UserTypeKind::Enum { values: after },
comment: None,
owner: None,
grants: vec![],
}];
let mut out = pgevolve_core::diff::ChangeSet::new();
pgevolve_core::diff::types::diff_user_types(&cat, &src, &mut out, &std::collections::BTreeSet::new());
prop_assert_eq!(out.len(), 1, "expected exactly one change, got: {:?}", out);
let entry = &out.entries[0];
prop_assert!(
matches!(&entry.change, Change::UserType(UserTypeChange::EnumAddValue { value, .. }) if value == &new_value),
"expected EnumAddValue for {:?}, got: {:?}", new_value, entry.change,
);
}
#[test]
#[ignore = "property test — run via property-tests workflow or `cargo test -- --ignored`"]
fn plpgsql_canonicalization_is_idempotent(
body_idx in 0usize..PLPGSQL_BODIES.len(),
) {
use pgevolve_core::identifier::{Identifier, QualifiedName};
use pgevolve_core::ir::function::FunctionLanguage;
use pgevolve_core::parse::builder::plpgsql::parse_routine_body;
use pgevolve_core::parse::error::SourceLocation;
let qname = QualifiedName::new(
Identifier::from_unquoted("app").unwrap(),
Identifier::from_unquoted("f").unwrap(),
);
let loc = SourceLocation::new(std::path::PathBuf::from("test.sql"), 1, 1);
let body = PLPGSQL_BODIES[body_idx];
let r1 = parse_routine_body(body, FunctionLanguage::PlPgSql, &qname, &loc);
prop_assume!(r1.is_ok());
let (body1, _deps1, _commits1) = r1.unwrap();
let r2 = parse_routine_body(body1.canonical_text(), FunctionLanguage::PlPgSql, &qname, &loc);
prop_assume!(r2.is_ok());
let (body2, _, _) = r2.unwrap();
prop_assert_eq!(
body1.canonical_text(),
body2.canonical_text(),
"canonical_text diverged on re-parse for body {:?}",
body,
);
prop_assert_eq!(
body1.canonical_hash(),
body2.canonical_hash(),
"canonical_hash diverged on re-parse for body {:?}",
body,
);
}
#[ignore = "property test — run via property-tests workflow or `cargo test -- --ignored`"]
#[test]
fn arb_view_dependency_graph(
catalog in arbitrary_view_catalog(),
) {
let leaf_qname = pick_referenced_table(&catalog);
prop_assume!(leaf_qname.is_some());
let leaf_qname = leaf_qname.unwrap();
let expected = transitively_dependent_views(&catalog, &leaf_qname);
prop_assume!(!expected.is_empty());
let mutated = mutate_leaf_column(catalog.clone(), &leaf_qname);
prop_assume!(mutated.is_some());
let mutated = mutated.unwrap();
let drift = DriftReport::default();
let changes = pgevolve_core::diff::diff(&catalog, &mutated, &drift);
let policy = pgevolve_core::plan::PlannerPolicy::default();
let mut raw_changes: Vec<pgevolve_core::diff::Change> =
changes.entries.iter().map(|e| e.change.clone()).collect();
pgevolve_core::plan::recreate_views::extend_with_dependent_recreations(
&mut raw_changes,
&catalog,
&policy,
)
.map_err(|views| {
TestCaseError::fail(format!(
"dep-recreation walker returned policy error for: {views:?}"
))
})?;
let extended = pgevolve_core::diff::ChangeSet {
entries: raw_changes
.into_iter()
.map(|c| pgevolve_core::diff::change::ChangeEntry {
change: c,
destructiveness: pgevolve_core::diff::Destructiveness::Safe,
})
.collect(),
..pgevolve_core::diff::ChangeSet::new()
};
let actual = view_recreations_in_changes(&extended);
for q in &expected {
prop_assert!(
actual.contains(q),
"expected view {:?} to be recreated but it was not; actual: {:?}",
q,
actual,
);
}
for q in &actual {
prop_assert!(
expected.contains(q),
"spurious recreation of view {:?}; expected set: {:?}",
q,
expected,
);
}
let g = build_create_graph(&catalog);
match g.topological_sort() {
Ok(order) => {
let positions: std::collections::BTreeMap<QualifiedName, usize> = order
.iter()
.enumerate()
.filter_map(|(i, node)| {
if let NodeId::View(q) = node {
if actual.contains(q) {
Some((q.clone(), i))
} else {
None
}
} else {
None
}
})
.collect();
for v in &catalog.views {
if !actual.contains(&v.qname) {
continue;
}
for dep in &v.body_dependencies {
let NodeId::View(dep_q) = &dep.to else { continue };
if !actual.contains(dep_q) {
continue;
}
let Some(&vp) = positions.get(&v.qname) else { continue };
let Some(&dp) = positions.get(dep_q) else { continue };
prop_assert!(
dp < vp,
"topo violation: dep {:?} (pos {}) must precede {:?} (pos {})",
dep_q,
dp,
v.qname,
vp,
);
}
}
}
Err(cycle) => {
prop_assert!(
false,
"create graph has a cycle (generator invariant violated): {:?}",
cycle.nodes,
);
}
}
}
#[ignore = "property test — run via property-tests workflow or `cargo test -- --ignored`"]
#[test]
fn cluster_diff_then_apply_in_memory_yields_target(
a in pgevolve_testkit::arbitrary_cluster_catalog(),
b in pgevolve_testkit::arbitrary_cluster_catalog(),
) {
use pgevolve_core::diff::diff_cluster;
let changes = diff_cluster(&a, &b);
let mut applied = a.clone();
apply_cluster_changes_in_memory(&mut applied, &changes);
applied.canonicalize().unwrap();
let mut expected = b.clone();
expected.canonicalize().unwrap();
prop_assert_eq!(
applied,
expected,
"diff_cluster(A, B) applied to A did not yield B — A: {:?}, B: {:?}, changes: {:?}",
a,
b,
changes,
);
}
}
fn apply_cluster_changes_in_memory(
cat: &mut pgevolve_core::ir::cluster::catalog::ClusterCatalog,
cs: &pgevolve_core::diff::ClusterChangeSet,
) {
use pgevolve_core::diff::ClusterChange;
for entry in &cs.entries {
match &entry.change {
ClusterChange::CreateRole(r) => cat.roles.push(r.clone()),
ClusterChange::DropRole { name } => cat.roles.retain(|r| &r.name != name),
ClusterChange::AlterRoleAttributes { name, to, .. } => {
if let Some(r) = cat.roles.iter_mut().find(|r| &r.name == name) {
r.attributes = to.clone();
}
}
ClusterChange::GrantRoleMembership { member, role } => {
if let Some(r) = cat.roles.iter_mut().find(|r| &r.name == member)
&& !r.member_of.contains(role)
{
r.member_of.push(role.clone());
}
}
ClusterChange::RevokeRoleMembership { member, role } => {
if let Some(r) = cat.roles.iter_mut().find(|r| &r.name == member) {
r.member_of.retain(|m| m != role);
}
}
ClusterChange::CommentOnRole { name, comment } => {
if let Some(r) = cat.roles.iter_mut().find(|r| &r.name == name) {
r.comment.clone_from(comment);
}
}
ClusterChange::CreateTablespace(ts) => cat.tablespaces.push(ts.clone()),
ClusterChange::DropTablespace { name } => {
cat.tablespaces.retain(|t| &t.name != name);
}
ClusterChange::AlterTablespaceOwner { name, owner } => {
if let Some(t) = cat.tablespaces.iter_mut().find(|t| &t.name == name) {
t.owner = Some(owner.clone());
}
}
ClusterChange::SetTablespaceOptions { name, options } => {
if let Some(t) = cat.tablespaces.iter_mut().find(|t| &t.name == name) {
for (k, v) in options {
t.options.insert(k.clone(), v.clone());
}
}
}
ClusterChange::CommentOnTablespace { name, comment } => {
if let Some(t) = cat.tablespaces.iter_mut().find(|t| &t.name == name) {
t.comment.clone_from(comment);
}
}
}
}
}