use serde::Deserialize;
use sqlx::PgPool;
use sqlx::Row;
use std::path::Path;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ModelSchema {
models: Vec<Model>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Model {
name: String,
collection: String,
indexes: Vec<IndexDeclaration>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct IndexDeclaration {
#[serde(rename = "type")]
index_type: String, fields: Vec<String>,
#[serde(rename = "where")]
#[serde(default)]
where_clause: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
struct ExpectedIndex {
table: String,
index_name: String,
is_unique: bool,
columns: Vec<String>,
where_clause: Option<String>,
}
async fn pool() -> PgPool {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5433/backbone_inventory".to_string());
PgPool::connect(&url).await.unwrap()
}
fn load_schema_models() -> Vec<Model> {
let schema_dir = Path::new("schema/models");
let mut all_models = Vec::new();
for entry in std::fs::read_dir(schema_dir).expect("schema/models directory exists") {
let entry = entry.expect("readable entry");
let path = entry.path();
if path.file_name().and_then(|s| s.to_str()) == Some("index.model.yaml") {
continue;
}
if path.extension().and_then(|s| s.to_str()) == Some("yaml") {
let content = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read {:?}: {}", path, e));
let schema: ModelSchema = serde_yaml::from_str(&content)
.unwrap_or_else(|e| panic!("failed to parse {:?}: {}", path, e));
all_models.extend(schema.models);
}
}
all_models
}
fn declared_to_expected_indexes(models: &[Model]) -> Vec<ExpectedIndex> {
let mut expected = Vec::new();
for model in models {
for (_idx, decl) in model.indexes.iter().enumerate() {
let table = model.collection.clone();
let index_name = if let Some(name) = &decl.name {
name.clone()
} else {
let prefix = if decl.index_type == "unique" { "uniq" } else { "idx" };
let cols = decl.fields.join("_");
format!("{}_{}_{}", prefix, table, cols)
};
expected.push(ExpectedIndex {
table,
index_name,
is_unique: decl.index_type == "unique",
columns: decl.fields.clone(),
where_clause: decl.where_clause.clone(),
});
}
}
expected
}
async fn actual_indexes(pool: &PgPool) -> Vec<ExpectedIndex> {
let rows = sqlx::query(
r#"
SELECT
schemaname,
tablename,
indexname,
indexdef
FROM pg_indexes
WHERE schemaname = 'inventory'
ORDER BY tablename, indexname
"#
)
.fetch_all(pool)
.await
.expect("query pg_indexes");
let mut actual = Vec::new();
for row in rows {
let schemaname: String = row.get("schemaname");
let tablename: String = row.get("tablename");
let indexname: String = row.get("indexname");
let indexdef: String = row.get("indexdef");
let is_unique = indexdef.contains("CREATE UNIQUE INDEX");
let columns = if let Some(on_pos) = indexdef.find(" ON ") {
let after_on = &indexdef[on_pos + 4..];
if let Some(paren_start) = after_on.find('(') {
let mut depth = 0;
let mut paren_end = after_on.len();
for (i, ch) in after_on[paren_start..].char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
paren_end = paren_start + i;
break;
}
}
_ => {}
}
}
let col_list = &after_on[paren_start + 1..paren_end];
col_list
.split(',')
.map(|s| s.trim().to_string())
.collect()
} else {
continue; }
} else {
continue; };
let where_clause = if let Some(where_pos) = indexdef.find(" WHERE ") {
Some(indexdef[where_pos + 7..].trim().to_string())
} else {
None
};
actual.push(ExpectedIndex {
table: tablename,
index_name: indexname,
is_unique,
columns,
where_clause,
});
}
actual
}
fn normalize_where_clause(where_clause: &str) -> String {
let mut normalized = where_clause.replace(" ", "");
normalized = normalized.replace("::text", "");
normalized = normalized.replace("::move_state", ""); normalized = normalized.replace("->>", "→"); normalized = normalized.replace("(", "").replace(")", "").replace("'", "");
if normalized.contains("metadata→deleted_atISNULL") {
"deleted_atISNULL".to_string()
} else if normalized.contains("deleted_atISNULL") {
"deleted_atISNULL".to_string()
} else if normalized.contains("inventory_quantity_set=true") {
"inventory_quantity_set=true".to_string()
} else if normalized.contains("state<>done") {
"state<>done".to_string()
} else {
normalized
}
}
fn index_matches(expected: &ExpectedIndex, actual: &[ExpectedIndex]) -> bool {
actual.iter().any(|act| {
act.table == expected.table &&
act.columns == expected.columns &&
match (&expected.where_clause, &act.where_clause) {
(Some(exp), Some(act)) => {
let exp_norm = normalize_where_clause(exp);
let act_norm = normalize_where_clause(act);
exp_norm == act_norm
},
(None, None) => true,
(Some(_), None) | (None, Some(_)) => false,
} &&
act.is_unique == expected.is_unique
})
}
#[tokio::test]
async fn schema_declarations_have_corresponding_ddl() {
let pool = pool().await;
let models = load_schema_models();
let expected = declared_to_expected_indexes(&models);
let actual = actual_indexes(&pool).await;
let mut missing = Vec::new();
for exp in &expected {
if !index_matches(exp, &actual) {
missing.push(exp.clone());
}
}
if !missing.is_empty() {
let missing_fmt: Vec<String> = missing.iter().map(|m| {
format!(
" {}.{} (unique={}, columns={:?}, where={:?})",
m.table, m.index_name, m.is_unique, m.columns, m.where_clause
)
}).collect();
panic!(
"SCHEMA-TO-DDL DRIFT DETECTED: {} declared index(es) have no DDL counterpart:\n{}\n\n\
Each unique/index declaration in schema/models/*.model.yaml must have a \
corresponding CREATE [UNIQUE] INDEX in migrations/*.up.sql. The schema is the \
source of truth — if DDL is missing, add a migration.",
missing.len(),
missing_fmt.join("\n")
);
}
println!(
"✓ Schema-to-DDL drift check passed: {} declared indexes all have DDL counterparts",
expected.len()
);
}
#[tokio::test]
#[ignore] async fn drift_check_actually_catches_missing_indexes() {
let pool = pool().await;
let models = load_schema_models();
let expected = declared_to_expected_indexes(&models);
let actual = actual_indexes(&pool).await;
let missing: Vec<_> = expected.iter()
.filter(|exp| !index_matches(exp, &actual))
.collect();
if missing.is_empty() {
println!("No drift detected — test baseline verified (remove an index to see red run)");
} else {
println!("Drift detected — the check works! Missing indexes:");
for m in missing {
println!(" {}.{} (columns: {:?})", m.table, m.index_name, m.columns);
}
}
assert!(true, "Drift check validation test completed");
}