use super::schema::{
Generated, MigrationHint, Schema, check_expr_to_sql, foreign_key_to_sql, index_method_str,
multi_column_fk_to_alter_command,
};
use super::types::ColumnType;
use crate::ast::{Action, ColumnGeneration, Constraint, Expr, IndexDef, Qail};
use std::collections::BTreeSet;
fn unsupported_state_diff_features(schema: &Schema) -> BTreeSet<&'static str> {
let mut out = BTreeSet::new();
if !schema.extensions.is_empty() {
out.insert("extensions");
}
if !schema.comments.is_empty() {
out.insert("comments");
}
if !schema.sequences.is_empty() {
out.insert("sequences");
}
if !schema.enums.is_empty() {
out.insert("enums");
}
if !schema.views.is_empty() {
out.insert("views");
}
if !schema.functions.is_empty() {
out.insert("functions");
}
if !schema.triggers.is_empty() {
out.insert("triggers");
}
if !schema.grants.is_empty() {
out.insert("grants");
}
if !schema.policies.is_empty() {
out.insert("policies");
}
if !schema.resources.is_empty() {
out.insert("resources");
}
out
}
fn unconfirmed_drop_hints(schema: &Schema) -> Vec<String> {
let mut hints = schema
.migrations
.iter()
.filter_map(|hint| match hint {
MigrationHint::Drop {
target,
confirmed: false,
} => Some(target.clone()),
_ => None,
})
.collect::<Vec<_>>();
hints.sort();
hints
}
fn validate_schema_for_state_diff(label: &str, schema: &Schema) -> Result<(), String> {
schema.validate().map_err(|errors| {
format!(
"State-based diff cannot use invalid {label} schema:\n{}",
errors.join("\n")
)
})
}
fn existing_column_check_diffs(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
let old_column_names = old_table
.columns
.iter()
.map(|column| column.name.as_str())
.collect::<std::collections::BTreeSet<_>>();
let new_column_names = new_table
.columns
.iter()
.map(|column| column.name.as_str())
.collect::<std::collections::BTreeSet<_>>();
let existing_column_names = old_column_names
.intersection(&new_column_names)
.copied()
.collect::<std::collections::BTreeSet<_>>();
let old_checks = table_check_signatures(old_table, &existing_column_names);
let new_checks = table_check_signatures(new_table, &existing_column_names);
for (signature, columns) in &new_checks {
if !old_checks.contains_key(signature) {
changes.push(format!(
"{}.{} (new CHECK not present in old schema: {})",
table_name,
columns.join("|"),
signature
));
}
}
for (signature, columns) in &old_checks {
if !new_checks.contains_key(signature) {
changes.push(format!(
"{}.{} (old CHECK not present in new schema: {})",
table_name,
columns.join("|"),
signature
));
}
}
}
changes.sort();
changes.dedup();
changes
}
fn table_check_signatures(
table: &super::schema::Table,
existing_column_names: &std::collections::BTreeSet<&str>,
) -> std::collections::BTreeMap<String, Vec<String>> {
let mut signatures = std::collections::BTreeMap::<String, Vec<String>>::new();
for column in table
.columns
.iter()
.filter(|column| existing_column_names.contains(column.name.as_str()))
{
if let Some(signature) = check_signature(&column.check) {
signatures
.entry(signature)
.or_default()
.push(column.name.clone());
}
}
signatures
}
fn existing_column_foreign_key_diffs(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in &new_table.columns {
let Some(old_col) = old_table
.columns
.iter()
.find(|old_col| old_col.name == new_col.name)
else {
continue;
};
if foreign_key_signature(&old_col.foreign_key)
!= foreign_key_signature(&new_col.foreign_key)
{
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn removed_or_changed_multi_column_foreign_keys(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, old_table) in &old.tables {
let Some(new_table) = new.tables.get(table_name) else {
continue;
};
for old_fk in &old_table.multi_column_fks {
if !new_table.multi_column_fks.contains(old_fk) {
changes.push(format!(
"{}.{}",
table_name,
multi_column_fk_signature(old_fk)
));
}
}
}
changes.sort();
changes
}
fn added_multi_column_foreign_keys_on_existing_tables(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_fk in &new_table.multi_column_fks {
if !old_table.multi_column_fks.contains(new_fk) {
changes.push(format!(
"{}.{}",
table_name,
multi_column_fk_signature(new_fk)
));
}
}
}
changes.sort();
changes.dedup();
changes
}
fn existing_column_unique_diffs(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in &new_table.columns {
let Some(old_col) = old_table
.columns
.iter()
.find(|old_col| old_col.name == new_col.name)
else {
continue;
};
if old_col.unique != new_col.unique {
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn existing_column_primary_key_diffs(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in &new_table.columns {
let Some(old_col) = old_table
.columns
.iter()
.find(|old_col| old_col.name == new_col.name)
else {
continue;
};
if old_col.primary_key != new_col.primary_key {
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn existing_column_set_not_null_diffs(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in &new_table.columns {
let Some(old_col) = old_table
.columns
.iter()
.find(|old_col| old_col.name == new_col.name)
else {
continue;
};
if old_col.nullable && !new_col.nullable && !new_col.primary_key {
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn existing_column_generated_diffs(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in &new_table.columns {
let Some(old_col) = old_table
.columns
.iter()
.find(|old_col| old_col.name == new_col.name)
else {
continue;
};
if generated_signature(&old_col.generated) != generated_signature(&new_col.generated) {
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn unsupported_existing_column_type_diffs(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in &new_table.columns {
let Some(old_col) = old_table
.columns
.iter()
.find(|old_col| old_col.name == new_col.name)
else {
continue;
};
if !column_types_equivalent_for_diff(&old_col.data_type, &new_col.data_type)
&& !is_safe_existing_column_type_change(&old_col.data_type, &new_col.data_type)
{
changes.push(format!(
"{}.{} ({} -> {})",
table_name,
new_col.name,
old_col.data_type.to_pg_type(),
new_col.data_type.to_pg_type()
));
}
}
}
changes.sort();
changes
}
fn is_safe_existing_column_type_change(old: &ColumnType, new: &ColumnType) -> bool {
if column_types_equivalent_for_diff(old, new) {
return true;
}
if is_serial_pseudo_type(old) || is_serial_pseudo_type(new) {
return false;
}
match (old, new) {
(ColumnType::Int, ColumnType::BigInt) => true,
(old, ColumnType::Text) if is_unbounded_character_type(old) => true,
(ColumnType::Text, ColumnType::Varchar(None)) => true,
(ColumnType::Varchar(None), ColumnType::Text) => true,
(ColumnType::Varchar(Some(old_len)), ColumnType::Varchar(Some(new_len))) => {
new_len >= old_len
}
(ColumnType::Varchar(Some(_)), ColumnType::Varchar(None)) => true,
(old, ColumnType::Int | ColumnType::BigInt) if is_smallint_type(old) => true,
_ => false,
}
}
fn column_types_equivalent_for_diff(old: &ColumnType, new: &ColumnType) -> bool {
if old == new {
return true;
}
match (old, new) {
(ColumnType::Array(old_inner), ColumnType::Array(new_inner)) => {
column_types_equivalent_for_diff(old_inner, new_inner)
}
(ColumnType::Enum { name: old_name, .. }, ColumnType::Enum { name: new_name, .. })
| (ColumnType::Enum { name: old_name, .. }, ColumnType::Range(new_name))
| (ColumnType::Range(old_name), ColumnType::Enum { name: new_name, .. })
| (ColumnType::Range(old_name), ColumnType::Range(new_name)) => {
old_name.eq_ignore_ascii_case(new_name)
}
_ => false,
}
}
fn is_serial_pseudo_type(ty: &ColumnType) -> bool {
matches!(ty, ColumnType::Serial | ColumnType::BigSerial)
}
fn is_unbounded_character_type(ty: &ColumnType) -> bool {
matches!(ty, ColumnType::Varchar(_) | ColumnType::Text)
}
fn is_smallint_type(ty: &ColumnType) -> bool {
matches!(ty, ColumnType::Range(name) if name.eq_ignore_ascii_case("SMALLINT"))
}
fn new_column_primary_key_additions(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in &new_table.columns {
if new_col.primary_key
&& !old_table
.columns
.iter()
.any(|old_col| old_col.name == new_col.name)
{
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn new_serial_pseudo_type_column_additions(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in &new_table.columns {
if is_serial_pseudo_type(&new_col.data_type)
&& !old_table
.columns
.iter()
.any(|old_col| old_col.name == new_col.name)
{
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn new_required_column_additions_without_value(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in &new_table.columns {
if !new_col.nullable
&& !new_col.primary_key
&& !column_has_value_source(new_col)
&& !old_table
.columns
.iter()
.any(|old_col| old_col.name == new_col.name)
{
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn new_unique_column_additions_with_value(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in new_columns(old_table, new_table) {
if new_col.unique && column_has_value_source(new_col) {
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn new_foreign_key_column_additions_with_value(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in new_columns(old_table, new_table) {
if new_col.foreign_key.is_some() && column_has_value_source(new_col) {
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn new_check_column_additions_requiring_validation(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, new_table) in &new.tables {
let Some(old_table) = old.tables.get(table_name) else {
continue;
};
for new_col in new_columns(old_table, new_table) {
let Some(check) = &new_col.check else {
continue;
};
if column_has_value_source(new_col)
|| check_expr_requires_existing_row_validation(&check.expr)
{
changes.push(format!("{}.{}", table_name, new_col.name));
}
}
}
changes.sort();
changes
}
fn new_columns<'a>(
old_table: &'a super::schema::Table,
new_table: &'a super::schema::Table,
) -> impl Iterator<Item = &'a super::schema::Column> {
new_table.columns.iter().filter(|new_col| {
!old_table
.columns
.iter()
.any(|old_col| old_col.name == new_col.name)
})
}
fn column_has_value_source(column: &super::schema::Column) -> bool {
column.default.is_some() || column.generated.is_some()
}
fn check_expr_requires_existing_row_validation(expr: &super::schema::CheckExpr) -> bool {
match expr {
super::schema::CheckExpr::NotNull { .. } | super::schema::CheckExpr::Sql(_) => true,
super::schema::CheckExpr::And(left, right) | super::schema::CheckExpr::Or(left, right) => {
check_expr_requires_existing_row_validation(left)
|| check_expr_requires_existing_row_validation(right)
}
super::schema::CheckExpr::Not(inner) => check_expr_requires_existing_row_validation(inner),
_ => false,
}
}
fn same_name_index_definition_diffs(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for new_idx in &new.indexes {
let Some(old_idx) = old
.indexes
.iter()
.find(|old_idx| old_idx.name == new_idx.name)
else {
continue;
};
let reasons = index_difference_reasons(old_idx, new_idx);
if !reasons.is_empty() {
changes.push(format!("{} ({})", new_idx.name, reasons.join("; ")));
}
}
changes.sort();
changes.dedup();
changes
}
fn existing_table_rls_downgrades(old: &Schema, new: &Schema) -> Vec<String> {
let mut changes = Vec::new();
for (table_name, old_table) in &old.tables {
let Some(new_table) = new.tables.get(table_name) else {
continue;
};
if old_table.enable_rls && !new_table.enable_rls {
changes.push(format!("{table_name} (disable RLS)"));
}
if old_table.force_rls && !new_table.force_rls {
changes.push(format!("{table_name} (drop FORCE RLS)"));
}
}
changes.sort();
changes
}
fn check_signature(check: &Option<super::schema::CheckConstraint>) -> Option<String> {
check
.as_ref()
.map(|check| normalize_index_sql_fragment(&check_expr_to_sql(&check.expr)))
}
fn foreign_key_signature(fk: &Option<super::schema::ForeignKey>) -> Option<String> {
fk.as_ref().map(|fk| format!("{:?}", fk))
}
fn multi_column_fk_signature(fk: &super::schema::MultiColumnForeignKey) -> String {
match &fk.name {
Some(name) => format!("constraint:{name}"),
None => format!("{:?}->{:?}.{:?}", fk.columns, fk.ref_table, fk.ref_columns),
}
}
fn generated_signature(generated: &Option<Generated>) -> Option<String> {
match generated {
Some(Generated::AlwaysStored(expr)) => Some(format!("stored:{expr}")),
Some(Generated::AlwaysIdentity) => Some("identity:always".to_string()),
Some(Generated::ByDefaultIdentity) => Some("identity:by_default".to_string()),
None => None,
}
}
fn generated_to_constraint(generated: &Generated) -> Constraint {
match generated {
Generated::AlwaysStored(expr) => {
Constraint::Generated(ColumnGeneration::Stored(expr.clone()))
}
Generated::AlwaysIdentity => {
Constraint::Generated(ColumnGeneration::Stored("identity".to_string()))
}
Generated::ByDefaultIdentity => {
Constraint::Generated(ColumnGeneration::Stored("identity_by_default".to_string()))
}
}
}
#[derive(Debug, PartialEq, Eq)]
struct ComparableIndex {
table: String,
columns: Vec<String>,
expressions: Vec<String>,
unique: bool,
method: &'static str,
where_clause: Option<String>,
include: Vec<String>,
}
fn comparable_index(idx: &super::schema::Index) -> ComparableIndex {
ComparableIndex {
table: idx.table.clone(),
columns: normalized_index_fragments(&idx.columns),
expressions: normalized_index_fragments(&idx.expressions),
unique: idx.unique,
method: index_method_str(&idx.method),
where_clause: idx
.where_clause
.as_ref()
.map(check_expr_to_sql)
.map(|fragment| normalize_index_sql_fragment(&fragment)),
include: normalized_index_fragments(&idx.include),
}
}
fn index_difference_reasons(
old_idx: &super::schema::Index,
new_idx: &super::schema::Index,
) -> Vec<String> {
let old = comparable_index(old_idx);
let new = comparable_index(new_idx);
let mut reasons = Vec::new();
push_index_diff(&mut reasons, "table", &old.table, &new.table);
push_index_diff(&mut reasons, "columns", &old.columns, &new.columns);
push_index_diff(
&mut reasons,
"expressions",
&old.expressions,
&new.expressions,
);
push_index_diff(&mut reasons, "unique", &old.unique, &new.unique);
push_index_diff(&mut reasons, "method", &old.method, &new.method);
push_index_diff(&mut reasons, "where", &old.where_clause, &new.where_clause);
push_index_diff(&mut reasons, "include", &old.include, &new.include);
reasons
}
fn push_index_diff<T>(reasons: &mut Vec<String>, label: &str, old: &T, new: &T)
where
T: std::fmt::Debug + PartialEq,
{
if old != new {
reasons.push(format!("{label}: {old:?} -> {new:?}"));
}
}
fn normalized_index_fragments(values: &[String]) -> Vec<String> {
values
.iter()
.map(|value| normalize_index_sql_fragment(value))
.collect()
}
fn normalize_index_sql_fragment(input: &str) -> String {
let mut normalized = compact_sql_for_index_compare(input);
normalized = normalized.replace("!=", "<>");
normalized = normalized.replace("::charactervarying", "");
normalized = normalized.replace("::varchar", "");
normalized = normalized.replace("::text", "");
normalized = unquote_simple_lowercase_identifiers(&normalized);
loop {
let stripped = strip_redundant_outer_parens(&normalized);
let simplified = simplify_parenthesized_identifiers(&stripped);
if simplified == normalized {
normalized = simplified;
break;
}
normalized = simplified;
}
normalize_any_array_predicate(&normalized)
}
fn unquote_simple_lowercase_identifiers(input: &str) -> String {
let mut out = String::new();
let mut chars = input.char_indices().peekable();
let mut last = 0usize;
let mut in_single = false;
while let Some((idx, ch)) = chars.next() {
if ch == '\'' {
if in_single && chars.peek().is_some_and(|(_, next)| *next == '\'') {
chars.next();
continue;
}
in_single = !in_single;
continue;
}
if in_single {
continue;
}
if ch != '"' {
continue;
}
out.push_str(&input[last..idx]);
let content_start = idx + ch.len_utf8();
let mut content = String::new();
let mut end = None;
while let Some((inner_idx, inner_ch)) = chars.next() {
if inner_ch == '"' {
if chars.peek().is_some_and(|(_, next)| *next == '"') {
chars.next();
content.push('"');
continue;
}
end = Some(inner_idx);
break;
}
content.push(inner_ch);
}
let Some(end_idx) = end else {
out.push('"');
out.push_str(&input[content_start..]);
return out;
};
if is_simple_lowercase_identifier(&content) {
out.push_str(&content);
} else {
out.push('"');
out.push_str(&input[content_start..end_idx]);
out.push('"');
}
last = end_idx + 1;
}
out.push_str(&input[last..]);
out
}
fn is_simple_lowercase_identifier(value: &str) -> bool {
let mut chars = value.chars();
matches!(chars.next(), Some(ch) if ch.is_ascii_lowercase() || ch == '_')
&& chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
}
fn compact_sql_for_index_compare(input: &str) -> String {
let mut out = String::new();
let mut in_single = false;
let mut in_double = false;
let mut chars = input.trim().chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\'' if !in_double => {
out.push(ch);
if in_single && chars.peek().is_some_and(|next| *next == '\'') {
out.push('\'');
chars.next();
} else {
in_single = !in_single;
}
}
'"' if !in_single => {
out.push(ch);
if in_double && chars.peek().is_some_and(|next| *next == '"') {
out.push('"');
chars.next();
} else {
in_double = !in_double;
}
}
_ if !in_single && !in_double && ch.is_whitespace() => {}
_ if !in_single && !in_double => out.extend(ch.to_lowercase()),
_ => out.push(ch),
}
}
out
}
fn strip_redundant_outer_parens(input: &str) -> String {
let mut s = input;
while s.starts_with('(') && s.ends_with(')') && outer_parens_wrap_entire_fragment(s) {
s = &s[1..s.len() - 1];
}
s.to_string()
}
fn outer_parens_wrap_entire_fragment(input: &str) -> bool {
let mut depth = 0_i32;
let mut in_single = false;
let mut in_double = false;
let mut chars = input.char_indices().peekable();
while let Some((idx, ch)) = chars.next() {
match ch {
'\'' if !in_double => {
if in_single && chars.peek().is_some_and(|(_, next)| *next == '\'') {
chars.next();
} else {
in_single = !in_single;
}
}
'"' if !in_single => {
if in_double && chars.peek().is_some_and(|(_, next)| *next == '"') {
chars.next();
} else {
in_double = !in_double;
}
}
'(' if !in_single && !in_double => depth += 1,
')' if !in_single && !in_double => {
depth -= 1;
if depth == 0 && idx != input.len() - 1 {
return false;
}
}
_ => {}
}
}
depth == 0 && !in_single && !in_double
}
fn simplify_parenthesized_identifiers(input: &str) -> String {
let mut out = String::new();
let chars: Vec<char> = input.chars().collect();
let mut idx = 0;
while idx < chars.len() {
if chars[idx] != '(' {
out.push(chars[idx]);
idx += 1;
continue;
}
let Some(end) = matching_paren_chars(&chars, idx) else {
out.push(chars[idx]);
idx += 1;
continue;
};
let inner: String = chars[idx + 1..end].iter().collect();
let preceded_by_identifier = idx > 0 && is_compact_identifier_char(chars[idx - 1]);
if !preceded_by_identifier && is_compact_identifier_path(&inner) {
out.push_str(&inner);
idx = end + 1;
} else {
out.push(chars[idx]);
idx += 1;
}
}
out
}
fn matching_paren_chars(chars: &[char], start: usize) -> Option<usize> {
let mut depth = 0_i32;
let mut in_single = false;
let mut in_double = false;
let mut idx = start;
while idx < chars.len() {
match chars[idx] {
'\'' if !in_double => {
if in_single && chars.get(idx + 1).is_some_and(|next| *next == '\'') {
idx += 1;
} else {
in_single = !in_single;
}
}
'"' if !in_single => {
if in_double && chars.get(idx + 1).is_some_and(|next| *next == '"') {
idx += 1;
} else {
in_double = !in_double;
}
}
'(' if !in_single && !in_double => depth += 1,
')' if !in_single && !in_double => {
depth -= 1;
if depth == 0 {
return Some(idx);
}
}
_ => {}
}
idx += 1;
}
None
}
fn is_compact_identifier_path(input: &str) -> bool {
!input.is_empty() && input.chars().all(is_compact_identifier_char)
}
fn is_compact_identifier_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'
}
fn normalize_any_array_predicate(input: &str) -> String {
const ANY_ARRAY: &str = "=any(array[";
let Some(pos) = input.find(ANY_ARRAY) else {
return input.to_string();
};
if !input.ends_with("])") {
return input.to_string();
}
let left = &input[..pos];
let values = &input[pos + ANY_ARRAY.len()..input.len() - 2];
format!("{left}in({values})")
}
fn table_references_table(table: &super::schema::Table, target: &str) -> bool {
table.columns.iter().any(|col| {
col.foreign_key
.as_ref()
.is_some_and(|fk| fk.table == target)
}) || table
.multi_column_fks
.iter()
.any(|fk| fk.ref_table == target)
}
pub fn validate_state_diff_support(old: &Schema, new: &Schema) -> Result<(), String> {
validate_schema_for_state_diff("source", old)?;
validate_schema_for_state_diff("target", new)?;
let mut unsupported = unsupported_state_diff_features(old);
unsupported.extend(unsupported_state_diff_features(new));
if !unsupported.is_empty() {
let detail = unsupported.into_iter().collect::<Vec<_>>().join(", ");
return Err(format!(
"State-based diff currently supports tables, columns, indexes, and migration hints only. \
Unsupported schema object families present: {}. \
Use folder-based strict migrations for these objects.",
detail
));
}
let unconfirmed_drops = unconfirmed_drop_hints(new);
if !unconfirmed_drops.is_empty() {
return Err(format!(
"State-based diff refuses unconfirmed destructive drop hints: {}. \
Add `confirm` to the drop hint or restore the object.",
unconfirmed_drops.join(", ")
));
}
let index_diffs = same_name_index_definition_diffs(old, new);
if !index_diffs.is_empty() {
return Err(format!(
"State-based diff cannot safely replace existing indexes with changed definitions: {}. \
Use an explicit migration for DROP INDEX/CREATE INDEX replacement.",
index_diffs.join(", ")
));
}
let check_diffs = existing_column_check_diffs(old, new);
if !check_diffs.is_empty() {
return Err(format!(
"State-based diff cannot safely alter CHECK constraints on existing columns: {}. \
Use an explicit migration for ADD/DROP/replace CHECK constraints.",
check_diffs.join(", ")
));
}
let unique_diffs = existing_column_unique_diffs(old, new);
if !unique_diffs.is_empty() {
return Err(format!(
"State-based diff cannot safely alter UNIQUE constraints on existing columns: {}. \
Use an explicit migration for ADD/DROP/replace UNIQUE constraints.",
unique_diffs.join(", ")
));
}
let pk_diffs = existing_column_primary_key_diffs(old, new);
if !pk_diffs.is_empty() {
return Err(format!(
"State-based diff cannot safely alter PRIMARY KEY constraints on existing columns: {}. \
Use an explicit migration for ADD/DROP/replace PRIMARY KEY constraints.",
pk_diffs.join(", ")
));
}
let set_not_null_diffs = existing_column_set_not_null_diffs(old, new);
if !set_not_null_diffs.is_empty() {
return Err(format!(
"State-based diff cannot safely set NOT NULL on existing columns: {}. \
Use an explicit migration to backfill/validate data before SET NOT NULL.",
set_not_null_diffs.join(", ")
));
}
let type_diffs = unsupported_existing_column_type_diffs(old, new);
if !type_diffs.is_empty() {
return Err(format!(
"State-based diff cannot safely alter existing column types without an explicit cast plan: {}. \
Use an explicit migration with USING/backfill steps for narrowing casts, pseudo-type changes, or data-validating conversions.",
type_diffs.join(", ")
));
}
let new_pk_columns = new_column_primary_key_additions(old, new);
if !new_pk_columns.is_empty() {
return Err(format!(
"State-based diff cannot safely add PRIMARY KEY columns to existing tables: {}. \
Use an explicit migration to backfill data and add the PRIMARY KEY constraint.",
new_pk_columns.join(", ")
));
}
let new_serial_columns = new_serial_pseudo_type_column_additions(old, new);
if !new_serial_columns.is_empty() {
return Err(format!(
"State-based diff cannot safely add SERIAL/BIGSERIAL columns to existing tables: {}. \
Use an explicit migration to create the sequence/default or use an identity column plan.",
new_serial_columns.join(", ")
));
}
let new_required_columns = new_required_column_additions_without_value(old, new);
if !new_required_columns.is_empty() {
return Err(format!(
"State-based diff cannot safely add required columns without a default/generated value to existing tables: {}. \
Use an explicit migration to add the column nullable, backfill, then set NOT NULL.",
new_required_columns.join(", ")
));
}
let new_unique_value_columns = new_unique_column_additions_with_value(old, new);
if !new_unique_value_columns.is_empty() {
return Err(format!(
"State-based diff cannot safely add UNIQUE columns with default/generated values to existing tables: {}. \
Use an explicit migration to backfill distinct values before adding the UNIQUE constraint.",
new_unique_value_columns.join(", ")
));
}
let new_fk_value_columns = new_foreign_key_column_additions_with_value(old, new);
if !new_fk_value_columns.is_empty() {
return Err(format!(
"State-based diff cannot safely add FOREIGN KEY columns with default/generated values to existing tables: {}. \
Use an explicit migration to backfill valid references before adding the FOREIGN KEY constraint.",
new_fk_value_columns.join(", ")
));
}
let new_check_validation_columns = new_check_column_additions_requiring_validation(old, new);
if !new_check_validation_columns.is_empty() {
return Err(format!(
"State-based diff cannot safely add CHECK constraints that may validate existing rows on new columns: {}. \
Use an explicit migration to add/backfill/validate the CHECK constraint.",
new_check_validation_columns.join(", ")
));
}
let fk_diffs = existing_column_foreign_key_diffs(old, new);
if !fk_diffs.is_empty() {
return Err(format!(
"State-based diff cannot safely alter single-column foreign keys on existing columns: {}. \
Use an explicit migration for ADD/DROP/replace FOREIGN KEY constraints.",
fk_diffs.join(", ")
));
}
let multi_fk_diffs = removed_or_changed_multi_column_foreign_keys(old, new);
if !multi_fk_diffs.is_empty() {
return Err(format!(
"State-based diff cannot safely drop or replace multi-column foreign keys on existing tables: {}. \
Use an explicit migration for DROP CONSTRAINT/ADD CONSTRAINT replacement.",
multi_fk_diffs.join(", ")
));
}
let added_multi_fks = added_multi_column_foreign_keys_on_existing_tables(old, new);
if !added_multi_fks.is_empty() {
return Err(format!(
"State-based diff cannot safely add multi-column foreign keys to existing tables: {}. \
Use an explicit migration to validate/backfill references before ADD CONSTRAINT.",
added_multi_fks.join(", ")
));
}
let generated_diffs = existing_column_generated_diffs(old, new);
if !generated_diffs.is_empty() {
return Err(format!(
"State-based diff cannot safely alter GENERATED/IDENTITY clauses on existing columns: {}. \
Use an explicit migration for GENERATED/IDENTITY changes.",
generated_diffs.join(", ")
));
}
let rls_downgrades = existing_table_rls_downgrades(old, new);
if !rls_downgrades.is_empty() {
return Err(format!(
"State-based diff cannot safely downgrade RLS on existing tables: {}. \
Use an explicit migration for DISABLE ROW LEVEL SECURITY or NO FORCE ROW LEVEL SECURITY.",
rls_downgrades.join(", ")
));
}
Ok(())
}
pub fn diff_schemas_checked(old: &Schema, new: &Schema) -> Result<Vec<Qail>, String> {
validate_state_diff_support(old, new)?;
Ok(diff_schemas(old, new))
}
pub fn diff_schemas(old: &Schema, new: &Schema) -> Vec<Qail> {
let mut cmds = Vec::new();
for hint in &new.migrations {
match hint {
MigrationHint::Rename { from, to } => {
if let (Some((from_table, from_col)), Some((to_table, to_col))) =
(parse_table_col(from), parse_table_col(to))
&& from_table == to_table
{
cmds.push(Qail {
action: Action::Mod,
table: from_table.to_string(),
columns: vec![Expr::Named(format!("{} -> {}", from_col, to_col))],
..Default::default()
});
}
}
MigrationHint::Transform { expression, target } => {
if let Some((table, _col)) = parse_table_col(target) {
cmds.push(Qail {
action: Action::Set,
table: table.to_string(),
columns: vec![Expr::Named(format!("/* TRANSFORM: {} */", expression))],
..Default::default()
});
}
}
MigrationHint::Drop {
target,
confirmed: true,
} => {
if target.contains('.') {
if let Some((table, col)) = parse_table_col(target) {
cmds.push(Qail {
action: Action::AlterDrop,
table: table.to_string(),
columns: vec![Expr::Named(col.to_string())],
..Default::default()
});
}
} else {
cmds.push(Qail {
action: Action::Drop,
table: target.clone(),
..Default::default()
});
}
}
_ => {}
}
}
let new_table_names: Vec<&String> = new
.tables
.keys()
.filter(|name| !old.tables.contains_key(*name))
.collect();
let new_set: std::collections::HashSet<&str> =
new_table_names.iter().map(|n| n.as_str()).collect();
let mut emitted: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut sorted: Vec<&String> = Vec::with_capacity(new_table_names.len());
let mut remaining = new_table_names;
loop {
let before = sorted.len();
remaining.retain(|name| {
let deps_satisfied = new.tables.get(*name).is_none_or(|t| {
t.columns.iter().all(|c| {
c.foreign_key.as_ref().is_none_or(|fk| {
!new_set.contains(fk.table.as_str()) || emitted.contains(fk.table.as_str())
})
}) && t.multi_column_fks.iter().all(|fk| {
!new_set.contains(fk.ref_table.as_str())
|| emitted.contains(fk.ref_table.as_str())
})
});
if deps_satisfied {
emitted.insert(name.as_str());
sorted.push(name);
false } else {
true }
});
if remaining.is_empty() || sorted.len() == before {
sorted.extend(remaining);
break;
}
}
let new_table_names = sorted;
for name in new_table_names {
let table = &new.tables[name];
let columns: Vec<Expr> = table
.columns
.iter()
.map(|col| {
let mut constraints = Vec::new();
if col.primary_key {
constraints.push(Constraint::PrimaryKey);
}
if col.nullable {
constraints.push(Constraint::Nullable);
}
if col.unique {
constraints.push(Constraint::Unique);
}
if let Some(def) = &col.default {
constraints.push(Constraint::Default(def.clone()));
}
if let Some(ref fk) = col.foreign_key {
constraints.push(Constraint::References(foreign_key_to_sql(fk)));
}
if let Some(check) = &col.check {
let check_sql = check_expr_to_sql(&check.expr);
if let Some(name) = &check.name {
constraints.push(Constraint::Check(vec![format!(
"CONSTRAINT {} CHECK ({})",
name, check_sql
)]));
} else {
constraints.push(Constraint::Check(vec![check_sql]));
}
}
if let Some(generated) = &col.generated {
constraints.push(generated_to_constraint(generated));
}
Expr::Def {
name: col.name.clone(),
data_type: col.data_type.to_pg_type(),
constraints,
}
})
.collect();
cmds.push(Qail {
action: Action::Make,
table: name.clone(),
columns,
..Default::default()
});
if table.enable_rls {
cmds.push(Qail {
action: Action::AlterEnableRls,
table: name.clone(),
..Default::default()
});
}
if table.force_rls {
cmds.push(Qail {
action: Action::AlterForceRls,
table: name.clone(),
..Default::default()
});
}
}
let mut dropped_tables: Vec<&String> = old
.tables
.keys()
.filter(|name| {
!new.tables.contains_key(*name) && !new.migrations.iter().any(
|h| matches!(h, MigrationHint::Drop { target, confirmed: true } if target == *name),
)
})
.collect();
dropped_tables.sort();
let mut remaining = dropped_tables;
let mut dropped_tables = Vec::with_capacity(remaining.len());
while !remaining.is_empty() {
let before = dropped_tables.len();
let remaining_names: Vec<String> = remaining.iter().map(|name| (*name).clone()).collect();
let mut next_remaining = Vec::new();
for name in remaining {
let has_dropped_dependent = remaining_names.iter().any(|other| {
other.as_str() != name.as_str()
&& old
.tables
.get(other)
.is_some_and(|table| table_references_table(table, name))
});
if has_dropped_dependent {
next_remaining.push(name);
} else {
dropped_tables.push(name);
}
}
if dropped_tables.len() == before {
next_remaining.sort();
dropped_tables.extend(next_remaining);
break;
}
remaining = next_remaining;
}
for name in dropped_tables {
cmds.push(Qail {
action: Action::Drop,
table: name.clone(),
..Default::default()
});
}
for (name, new_table) in &new.tables {
if let Some(old_table) = old.tables.get(name) {
let old_cols: std::collections::HashSet<_> =
old_table.columns.iter().map(|c| &c.name).collect();
let new_cols: std::collections::HashSet<_> =
new_table.columns.iter().map(|c| &c.name).collect();
for col in &new_table.columns {
if !old_cols.contains(&col.name) {
let col_path = format!("{}.{}", name, col.name);
let is_rename_target = new
.migrations
.iter()
.any(|h| matches!(h, MigrationHint::Rename { to, .. } if to == &col_path));
if !is_rename_target {
let mut constraints = Vec::new();
if col.nullable {
constraints.push(Constraint::Nullable);
}
if col.unique {
constraints.push(Constraint::Unique);
}
if let Some(def) = &col.default {
constraints.push(Constraint::Default(def.clone()));
}
if let Some(fk) = &col.foreign_key {
constraints.push(Constraint::References(foreign_key_to_sql(fk)));
}
if let Some(check) = &col.check {
let check_sql = check_expr_to_sql(&check.expr);
if let Some(name) = &check.name {
constraints.push(Constraint::Check(vec![format!(
"CONSTRAINT {} CHECK ({})",
name, check_sql
)]));
} else {
constraints.push(Constraint::Check(vec![check_sql]));
}
}
if let Some(generated) = &col.generated {
constraints.push(generated_to_constraint(generated));
}
let data_type = match &col.data_type {
super::types::ColumnType::Serial => "INTEGER".to_string(),
super::types::ColumnType::BigSerial => "BIGINT".to_string(),
other => other.to_pg_type(),
};
cmds.push(Qail {
action: Action::Alter,
table: name.clone(),
columns: vec![Expr::Def {
name: col.name.clone(),
data_type,
constraints,
}],
..Default::default()
});
}
}
}
for col in &old_table.columns {
if !new_cols.contains(&col.name) {
let col_path = format!("{}.{}", name, col.name);
let is_rename_source = new.migrations.iter().any(
|h| matches!(h, MigrationHint::Rename { from, .. } if from == &col_path),
);
let is_drop_hinted = new.migrations.iter().any(|h| {
matches!(h, MigrationHint::Drop { target, confirmed: true } if target == &col_path)
});
if !is_rename_source && !is_drop_hinted {
cmds.push(Qail {
action: Action::AlterDrop,
table: name.clone(),
columns: vec![Expr::Named(col.name.clone())],
..Default::default()
});
}
}
}
for new_col in &new_table.columns {
if let Some(old_col) = old_table.columns.iter().find(|c| c.name == new_col.name) {
let new_type = new_col.data_type.to_pg_type();
if !column_types_equivalent_for_diff(&old_col.data_type, &new_col.data_type) {
let safe_new_type = match &new_col.data_type {
super::types::ColumnType::Serial => "INTEGER".to_string(),
super::types::ColumnType::BigSerial => "BIGINT".to_string(),
_ => new_type,
};
cmds.push(Qail {
action: Action::AlterType,
table: name.clone(),
columns: vec![Expr::Def {
name: new_col.name.clone(),
data_type: safe_new_type,
constraints: vec![],
}],
..Default::default()
});
}
if old_col.nullable && !new_col.nullable && !new_col.primary_key {
cmds.push(Qail {
action: Action::AlterSetNotNull,
table: name.clone(),
columns: vec![Expr::Named(new_col.name.clone())],
..Default::default()
});
} else if !old_col.nullable && new_col.nullable && !old_col.primary_key {
cmds.push(Qail {
action: Action::AlterDropNotNull,
table: name.clone(),
columns: vec![Expr::Named(new_col.name.clone())],
..Default::default()
});
}
match (&old_col.default, &new_col.default) {
(None, Some(new_default)) => {
cmds.push(Qail {
action: Action::AlterSetDefault,
table: name.clone(),
columns: vec![Expr::Named(new_col.name.clone())],
payload: Some(new_default.clone()),
..Default::default()
});
}
(Some(_), None) => {
cmds.push(Qail {
action: Action::AlterDropDefault,
table: name.clone(),
columns: vec![Expr::Named(new_col.name.clone())],
..Default::default()
});
}
(Some(old_default), Some(new_default)) if old_default != new_default => {
cmds.push(Qail {
action: Action::AlterSetDefault,
table: name.clone(),
columns: vec![Expr::Named(new_col.name.clone())],
payload: Some(new_default.clone()),
..Default::default()
});
}
_ => {} }
}
}
if !old_table.enable_rls && new_table.enable_rls {
cmds.push(Qail {
action: Action::AlterEnableRls,
table: name.clone(),
..Default::default()
});
} else if old_table.enable_rls && !new_table.enable_rls {
cmds.push(Qail {
action: Action::AlterDisableRls,
table: name.clone(),
..Default::default()
});
}
if !old_table.force_rls && new_table.force_rls {
cmds.push(Qail {
action: Action::AlterForceRls,
table: name.clone(),
..Default::default()
});
} else if old_table.force_rls && !new_table.force_rls {
cmds.push(Qail {
action: Action::AlterNoForceRls,
table: name.clone(),
..Default::default()
});
}
}
}
for new_idx in &new.indexes {
let exists = old.indexes.iter().any(|i| i.name == new_idx.name);
if !exists {
cmds.push(Qail {
action: Action::Index,
table: String::new(),
index_def: Some(IndexDef {
name: new_idx.name.clone(),
table: new_idx.table.clone(),
columns: if !new_idx.expressions.is_empty() {
new_idx.expressions.clone()
} else {
new_idx.columns.clone()
},
unique: new_idx.unique,
index_type: Some(index_method_str(&new_idx.method).to_string()),
include: new_idx.include.clone(),
concurrently: new_idx.concurrently,
where_clause: new_idx.where_clause.as_ref().map(check_expr_to_sql),
}),
..Default::default()
});
}
}
let mut fk_table_names: Vec<&String> = new
.tables
.iter()
.filter(|(_, table)| !table.multi_column_fks.is_empty())
.map(|(name, _)| name)
.collect();
fk_table_names.sort();
for name in fk_table_names {
let new_table = &new.tables[name];
if let Some(old_table) = old.tables.get(name) {
for fk in &new_table.multi_column_fks {
if !old_table.multi_column_fks.contains(fk) {
cmds.push(multi_column_fk_to_alter_command(name, fk));
}
}
} else {
for fk in &new_table.multi_column_fks {
cmds.push(multi_column_fk_to_alter_command(name, fk));
}
}
}
for old_idx in &old.indexes {
let exists = new.indexes.iter().any(|i| i.name == old_idx.name);
if !exists {
cmds.push(Qail {
action: Action::DropIndex,
table: old_idx.name.clone(),
..Default::default()
});
}
}
cmds
}
fn parse_table_col(s: &str) -> Option<(&str, &str)> {
let parts: Vec<&str> = s.splitn(2, '.').collect();
if parts.len() == 2 {
Some((parts[0], parts[1]))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::super::schema::{
CheckExpr, Column, FkAction, Index, IndexMethod, MultiColumnForeignKey, Table, ViewDef,
};
use super::super::types::ColumnType;
use super::*;
#[test]
fn test_diff_new_table() {
use super::super::types::ColumnType;
let old = Schema::default();
let mut new = Schema::default();
new.add_table(
Table::new("users")
.column(Column::new("id", ColumnType::Serial).primary_key())
.column(Column::new("name", ColumnType::Text).not_null()),
);
let cmds = diff_schemas(&old, &new);
assert_eq!(cmds.len(), 1);
assert!(matches!(cmds[0].action, Action::Make));
}
#[test]
fn state_diff_support_rejects_non_table_object_families() {
let old = Schema::default();
let mut new = Schema::default();
new.add_view(ViewDef::new("active_users", "SELECT 1"));
let err = validate_state_diff_support(&old, &new)
.expect_err("state-based diff should reject unsupported view objects");
assert!(
err.contains("views"),
"error should include unsupported family name"
);
}
#[test]
fn state_diff_checked_passes_for_table_index_only_schema() {
use super::super::types::ColumnType;
let old = Schema::default();
let mut new = Schema::default();
new.add_table(Table::new("users").column(Column::new("id", ColumnType::Serial)));
let cmds = diff_schemas_checked(&old, &new).expect("table/index-only schema should pass");
assert!(
cmds.iter().any(|c| matches!(c.action, Action::Make)),
"checked diff should still produce normal table commands"
);
}
#[test]
fn state_diff_checked_rejects_invalid_target_schema() {
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
let mut new = old.clone();
new.add_index(Index::new(
"idx_users_missing",
"users",
vec!["missing_col".to_string()],
));
let err = diff_schemas_checked(&old, &new)
.expect_err("checked diff must reject invalid target schema");
assert!(err.contains("invalid target schema"));
assert!(err.contains("non-existent column 'users.missing_col'"));
}
#[test]
fn state_diff_checked_rejects_unconfirmed_drop_hint() {
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
let mut new = Schema::default();
new.add_hint(MigrationHint::Drop {
target: "users".to_string(),
confirmed: false,
});
let err =
diff_schemas_checked(&old, &new).expect_err("unconfirmed drop hint should fail closed");
assert!(err.contains("unconfirmed destructive drop hints"));
assert!(err.contains("users"));
}
fn schema_with_users_index(index: Index) -> Schema {
use super::super::types::ColumnType;
let mut schema = Schema::default();
schema.add_table(
Table::new("users")
.column(Column::new("email", ColumnType::Text))
.column(Column::new("username", ColumnType::Text))
.column(Column::new("deleted_at", ColumnType::Text)),
);
schema.add_table(
Table::new("audit_log")
.column(Column::new("impersonation_session_id", ColumnType::Text)),
);
schema.add_table(
Table::new("whatsapp_outbox")
.column(Column::new("next_attempt_at", ColumnType::Timestamp))
.column(Column::new("status", ColumnType::Text)),
);
schema.add_table(
Table::new("car_availability")
.column(Column::new("vehicle_id", ColumnType::Text))
.column(Column::new("service_date", ColumnType::Date))
.column(Column::new("start_time", ColumnType::Time))
.column(Column::new("end_time", ColumnType::Time))
.column(Column::new("status", ColumnType::Text)),
);
schema.add_index(index);
schema
}
#[test]
fn state_diff_checked_rejects_same_name_index_unique_change() {
let old = schema_with_users_index(Index::new(
"idx_users_email",
"users",
vec!["email".to_string()],
));
let new = schema_with_users_index(
Index::new("idx_users_email", "users", vec!["email".to_string()]).unique(),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("same-name index unique change should fail closed");
assert!(err.contains("replace existing indexes"));
assert!(err.contains("idx_users_email"));
}
#[test]
fn state_diff_checked_rejects_same_name_index_predicate_change() {
let old = schema_with_users_index(
Index::new("idx_users_email", "users", vec!["email".to_string()])
.partial(CheckExpr::Sql("deleted_at IS NULL".to_string())),
);
let new = schema_with_users_index(
Index::new("idx_users_email", "users", vec!["email".to_string()])
.partial(CheckExpr::Sql("deleted_at IS NOT NULL".to_string())),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("same-name index predicate change should fail closed");
assert!(err.contains("replace existing indexes"));
assert!(err.contains("idx_users_email"));
assert!(err.contains("where:"));
}
#[test]
fn state_diff_checked_rejects_same_name_index_method_change() {
let old = schema_with_users_index(Index::new(
"idx_users_email",
"users",
vec!["email".to_string()],
));
let new = schema_with_users_index(
Index::new("idx_users_email", "users", vec!["email".to_string()])
.using(IndexMethod::Hash),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("same-name index method change should fail closed");
assert!(err.contains("replace existing indexes"));
assert!(err.contains("idx_users_email"));
}
#[test]
fn state_diff_checked_rejects_same_name_index_column_change() {
let old = schema_with_users_index(Index::new(
"idx_users_email",
"users",
vec!["email".to_string()],
));
let new = schema_with_users_index(Index::new(
"idx_users_email",
"users",
vec!["username".to_string()],
));
let err = diff_schemas_checked(&old, &new)
.expect_err("same-name index column change should fail closed");
assert!(err.contains("replace existing indexes"));
assert!(err.contains("idx_users_email"));
}
#[test]
fn state_diff_index_compare_ignores_concurrently_execution_option() {
let old = schema_with_users_index(Index::new(
"idx_users_email",
"users",
vec!["email".to_string()],
));
let new = schema_with_users_index(
Index::new("idx_users_email", "users", vec!["email".to_string()]).concurrently(),
);
let cmds = diff_schemas_checked(&old, &new)
.expect("CONCURRENTLY is an execution option, not index definition drift");
assert!(cmds.is_empty());
}
#[test]
fn state_diff_index_compare_ignores_postgres_predicate_parentheses() {
let old = schema_with_users_index(
Index::new(
"audit_log_session",
"audit_log",
vec!["impersonation_session_id".to_string()],
)
.partial(CheckExpr::Sql(
"(impersonation_session_id IS NOT NULL)".to_string(),
)),
);
let new = schema_with_users_index(
Index::new(
"audit_log_session",
"audit_log",
vec!["impersonation_session_id".to_string()],
)
.partial(CheckExpr::Sql(
"impersonation_session_id IS NOT NULL".to_string(),
)),
);
let cmds = diff_schemas_checked(&old, &new)
.expect("equivalent partial index predicates should not fail closed");
assert!(cmds.is_empty());
}
#[test]
fn state_diff_index_compare_ignores_postgres_text_casts() {
let old = schema_with_users_index(
Index::expression(
"users_email_unique_ci",
"users",
vec!["lower((email)::text)".to_string()],
)
.unique(),
);
let new = schema_with_users_index(
Index::expression(
"users_email_unique_ci",
"users",
vec!["lower(email)".to_string()],
)
.unique(),
);
let cmds = diff_schemas_checked(&old, &new)
.expect("equivalent expression index casts should not fail closed");
assert!(cmds.is_empty());
}
#[test]
fn state_diff_index_compare_ignores_in_any_array_canonicalization() {
let old = schema_with_users_index(
Index::new(
"idx_outbox_due",
"whatsapp_outbox",
vec!["next_attempt_at".to_string()],
)
.partial(CheckExpr::Sql(
"status = ANY (ARRAY['pending'::text, 'failed'::text])".to_string(),
)),
);
let new = schema_with_users_index(
Index::new(
"idx_outbox_due",
"whatsapp_outbox",
vec!["next_attempt_at".to_string()],
)
.partial(CheckExpr::Sql(
"status IN ('pending', 'failed')".to_string(),
)),
);
let cmds = diff_schemas_checked(&old, &new)
.expect("equivalent IN predicate forms should not fail closed");
assert!(cmds.is_empty());
}
#[test]
fn state_diff_index_compare_ignores_not_equal_canonicalization() {
let old = schema_with_users_index(
Index::new(
"idx_car_availability_overlap",
"car_availability",
vec![
"vehicle_id".to_string(),
"service_date".to_string(),
"start_time".to_string(),
"end_time".to_string(),
],
)
.partial(CheckExpr::Sql(
"((status)::text <> 'completed'::text)".to_string(),
)),
);
let new = schema_with_users_index(
Index::new(
"idx_car_availability_overlap",
"car_availability",
vec![
"vehicle_id".to_string(),
"service_date".to_string(),
"start_time".to_string(),
"end_time".to_string(),
],
)
.partial(CheckExpr::Sql("status != 'completed'".to_string())),
);
let cmds = diff_schemas_checked(&old, &new)
.expect("equivalent not-equal predicates should not fail closed");
assert!(cmds.is_empty());
}
#[test]
fn state_diff_checked_rejects_existing_column_check_addition() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(
Table::new("inventory").column(Column::new("quantity", ColumnType::Int).not_null()),
);
let mut new = Schema::default();
new.add_table(
Table::new("inventory").column(
Column::new("quantity", ColumnType::Int).not_null().check(
CheckExpr::GreaterOrEqual {
column: "quantity".to_string(),
value: 0,
},
),
),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("existing-column CHECK change should fail closed");
assert!(err.contains("CHECK constraints"));
assert!(err.contains("inventory.quantity"));
}
#[test]
fn state_diff_check_compare_is_table_scoped_not_column_anchor_scoped() {
use super::super::types::ColumnType;
let check = CheckExpr::Sql(
"((segment_id IS NOT NULL) AND (virtual_segment_id IS NULL)) OR ((segment_id IS NULL) AND (virtual_segment_id IS NOT NULL))"
.to_string(),
);
let mut old = Schema::default();
old.add_table(
Table::new("pricing_plans")
.column(
Column::new("segment_id", ColumnType::Uuid)
.check_named("pricing_plans_single_source_of_truth", check.clone()),
)
.column(Column::new("virtual_segment_id", ColumnType::Uuid)),
);
let mut new = Schema::default();
new.add_table(
Table::new("pricing_plans")
.column(Column::new("segment_id", ColumnType::Uuid))
.column(
Column::new("virtual_segment_id", ColumnType::Uuid)
.check_named("pricing_plans_single_source_of_truth", check),
),
);
let cmds = diff_schemas_checked(&old, &new)
.expect("same table-level CHECK should not depend on inline column anchor");
assert!(cmds.is_empty());
}
#[test]
fn state_diff_check_compare_normalizes_sql_and_ast_equivalent_checks() {
let mut old = Schema::default();
old.add_table(Table::new("inventory").column(
Column::new("quantity", ColumnType::Int).check_named(
"inventory_quantity_check",
CheckExpr::Sql("((quantity >= 0))".to_string()),
),
));
let mut new = Schema::default();
new.add_table(Table::new("inventory").column(
Column::new("quantity", ColumnType::Int).check(CheckExpr::GreaterOrEqual {
column: "quantity".to_string(),
value: 0,
}),
));
let cmds = diff_schemas_checked(&old, &new)
.expect("equivalent SQL and AST-native CHECK predicates should not fail closed");
assert!(cmds.is_empty());
}
#[test]
fn state_diff_check_compare_ignores_lowercase_identifier_quotes() {
let mut old = Schema::default();
old.add_table(
Table::new("schedule_patterns").column(
Column::new("interval", ColumnType::Int)
.check(CheckExpr::Sql("\"interval\" > 0".to_string())),
),
);
let mut new = Schema::default();
new.add_table(Table::new("schedule_patterns").column(
Column::new("interval", ColumnType::Int).check(CheckExpr::GreaterThan {
column: "interval".to_string(),
value: 0,
}),
));
let cmds = diff_schemas_checked(&old, &new)
.expect("quoted lowercase identifier should match unquoted column reference");
assert!(cmds.is_empty());
}
#[test]
fn state_diff_check_error_reports_directional_signatures() {
let mut old = Schema::default();
old.add_table(
Table::new("charters_diathesi")
.column(
Column::new("current_bookings", ColumnType::Int).check(CheckExpr::Sql(
"current_bookings <= max_bookings".to_string(),
)),
)
.column(Column::new("max_bookings", ColumnType::Int)),
);
let mut new = Schema::default();
new.add_table(
Table::new("charters_diathesi")
.column(Column::new("current_bookings", ColumnType::Int))
.column(Column::new("max_bookings", ColumnType::Int)),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("missing target CHECK should fail closed with details");
assert!(err.contains("charters_diathesi.current_bookings"), "{err}");
assert!(err.contains("old CHECK not present in new schema"), "{err}");
assert!(err.contains("current_bookings<=max_bookings"), "{err}");
}
#[test]
fn state_diff_checked_rejects_existing_column_unique_addition() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(
Table::new("users").column(Column::new("email", ColumnType::Text).not_null()),
);
let mut new = Schema::default();
new.add_table(
Table::new("users").column(Column::new("email", ColumnType::Text).not_null().unique()),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("existing-column UNIQUE change should fail closed");
assert!(err.contains("UNIQUE constraints"));
assert!(err.contains("users.email"));
}
#[test]
fn state_diff_checked_rejects_existing_column_primary_key_addition() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(Table::new("api_keys").column(Column::new("key", ColumnType::Text)));
let mut new = Schema::default();
new.add_table(
Table::new("api_keys").column(Column::new("key", ColumnType::Text).primary_key()),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("existing-column PRIMARY KEY addition should fail closed");
assert!(err.contains("PRIMARY KEY constraints"));
assert!(err.contains("api_keys.key"));
}
#[test]
fn state_diff_checked_rejects_existing_column_primary_key_removal() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(
Table::new("api_keys").column(Column::new("key", ColumnType::Text).primary_key()),
);
let mut new = Schema::default();
new.add_table(Table::new("api_keys").column(Column::new("key", ColumnType::Text)));
let err = diff_schemas_checked(&old, &new)
.expect_err("existing-column PRIMARY KEY removal should fail closed");
assert!(err.contains("PRIMARY KEY constraints"));
assert!(err.contains("api_keys.key"));
}
#[test]
fn state_diff_checked_rejects_existing_column_set_not_null() {
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("email", ColumnType::Text)));
let mut new = Schema::default();
new.add_table(
Table::new("users").column(Column::new("email", ColumnType::Text).not_null()),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("SET NOT NULL should require explicit backfill/validation");
assert!(err.contains("set NOT NULL"));
assert!(err.contains("users.email"));
}
#[test]
fn state_diff_checked_rejects_new_primary_key_column_on_existing_table() {
let mut old = Schema::default();
old.add_table(Table::new("api_keys").column(Column::new("label", ColumnType::Text)));
let mut new = old.clone();
new.tables
.get_mut("api_keys")
.expect("api_keys table should exist")
.columns
.push(Column::new("key", ColumnType::Text).primary_key());
let err = diff_schemas_checked(&old, &new)
.expect_err("new PRIMARY KEY column on existing table should fail closed");
assert!(err.contains("add PRIMARY KEY columns"));
assert!(err.contains("api_keys.key"));
}
#[test]
fn state_diff_checked_rejects_new_required_column_without_value_source() {
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
let mut new = old.clone();
new.tables
.get_mut("users")
.expect("users table should exist")
.columns
.push(Column::new("email", ColumnType::Text).not_null());
let err = diff_schemas_checked(&old, &new)
.expect_err("required column without default should require explicit migration");
assert!(err.contains("required columns"));
assert!(err.contains("users.email"));
}
#[test]
fn state_diff_checked_allows_new_required_column_with_default() {
use crate::transpiler::ToSql;
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
let mut new = old.clone();
new.tables
.get_mut("users")
.expect("users table should exist")
.columns
.push(
Column::new("status", ColumnType::Text)
.not_null()
.default("'active'"),
);
let cmds = diff_schemas_checked(&old, &new)
.expect("required column with default should be auto-planned");
let add_col = cmds
.iter()
.find(|cmd| cmd.action == Action::Alter && cmd.table == "users")
.expect("add-column command should be present");
let sql = add_col.to_sql();
assert!(
sql.contains("ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"),
"add-column SQL should preserve default-backed NOT NULL, got: {sql}"
);
}
#[test]
fn state_diff_checked_rejects_new_unique_column_with_default() {
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
let mut new = old.clone();
new.tables
.get_mut("users")
.expect("users table should exist")
.columns
.push(
Column::new("external_id", ColumnType::Text)
.unique()
.default("'same-for-existing-rows'"),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("UNIQUE column with default can duplicate existing rows");
assert!(err.contains("UNIQUE columns"));
assert!(err.contains("users.external_id"));
}
#[test]
fn state_diff_checked_rejects_new_foreign_key_column_with_default() {
let mut old = Schema::default();
old.add_table(
Table::new("tenants").column(Column::new("id", ColumnType::Uuid).primary_key()),
);
old.add_table(Table::new("orders").column(Column::new("id", ColumnType::Uuid)));
let mut new = old.clone();
new.tables
.get_mut("orders")
.expect("orders table should exist")
.columns
.push(
Column::new("tenant_id", ColumnType::Uuid)
.references("tenants", "id")
.default("'00000000-0000-0000-0000-000000000000'::uuid"),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("FK column with default can violate existing references");
assert!(err.contains("FOREIGN KEY columns"));
assert!(err.contains("orders.tenant_id"));
}
#[test]
fn state_diff_checked_rejects_new_column_with_raw_sql_check() {
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
let mut new = old.clone();
new.tables
.get_mut("users")
.expect("users table should exist")
.columns
.push(
Column::new("email", ColumnType::Text)
.check(CheckExpr::Sql("email IS NOT NULL".to_string())),
);
let err =
diff_schemas_checked(&old, &new).expect_err("raw SQL CHECK may validate existing rows");
assert!(err.contains("CHECK constraints"));
assert!(err.contains("users.email"));
}
#[test]
fn state_diff_checked_rejects_new_column_with_check_and_default() {
let mut old = Schema::default();
old.add_table(Table::new("inventory").column(Column::new("id", ColumnType::Int)));
let mut new = old.clone();
new.tables
.get_mut("inventory")
.expect("inventory table should exist")
.columns
.push(
Column::new("quantity", ColumnType::Int)
.default("-1")
.check(CheckExpr::GreaterOrEqual {
column: "quantity".to_string(),
value: 0,
}),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("CHECK column with default can validate existing rows");
assert!(err.contains("CHECK constraints"));
assert!(err.contains("inventory.quantity"));
}
#[test]
fn state_diff_checked_rejects_new_serial_pseudo_type_column_on_existing_table() {
let mut old = Schema::default();
old.add_table(Table::new("events").column(Column::new("name", ColumnType::Text)));
let mut new = old.clone();
new.tables
.get_mut("events")
.expect("events table should exist")
.columns
.push(Column::new("id", ColumnType::Serial));
let err = diff_schemas_checked(&old, &new)
.expect_err("SERIAL add-column cannot be represented by ALTER ADD COLUMN INTEGER");
assert!(err.contains("SERIAL/BIGSERIAL"));
assert!(err.contains("events.id"));
}
#[test]
fn state_diff_checked_rejects_unsafe_existing_column_type_change() {
let mut old = Schema::default();
old.add_table(Table::new("events").column(Column::new("external_id", ColumnType::Text)));
let mut new = Schema::default();
new.add_table(Table::new("events").column(Column::new("external_id", ColumnType::Uuid)));
let err = diff_schemas_checked(&old, &new)
.expect_err("TEXT -> UUID should require an explicit cast plan");
assert!(err.contains("existing column types"));
assert!(err.contains("events.external_id"));
assert!(err.contains("TEXT -> UUID"));
}
#[test]
fn state_diff_checked_does_not_treat_array_default_as_type_suffix() {
let old = super::super::parser::parse_qail(
r#"
table agents {
id uuid primary_key
verticals TEXT[]
}
"#,
)
.expect("old schema should parse");
let new = super::super::parser::parse_qail(
r#"
table agents {
id uuid primary_key
verticals TEXT[] not_null default '{}'::text[]
}
"#,
)
.expect("new schema should parse");
let err = diff_schemas_checked(&old, &new)
.expect_err("setting NOT NULL on an existing array column needs explicit migration");
assert!(err.contains("set NOT NULL"));
assert!(err.contains("agents.verticals"));
assert!(!err.contains("existing column types"));
assert!(!err.contains("TEXT[] NOT_NULL DEFAULT"));
}
#[test]
fn state_diff_checked_ignores_unquoted_enum_identifier_case_drift() {
let mut old = Schema::default();
old.add_table(Table::new("articles").column(Column::new(
"status",
ColumnType::Range("ARTICLE_STATUS".to_string()),
)));
let mut new = Schema::default();
new.add_table(Table::new("articles").column(Column::new(
"status",
ColumnType::Enum {
name: "article_status".to_string(),
values: vec![
"draft".to_string(),
"published".to_string(),
"archived".to_string(),
],
},
)));
let cmds = diff_schemas_checked(&old, &new)
.expect("case-only enum placeholder drift should be treated as same type");
assert!(
cmds.iter()
.all(|cmd| cmd.action != Action::AlterType || cmd.table != "articles"),
"case-only enum type drift must not emit ALTER TYPE: {cmds:?}"
);
}
#[test]
fn state_diff_checked_ignores_array_enum_identifier_case_drift() {
let mut old = Schema::default();
old.add_table(Table::new("operators").column(Column::new(
"roles",
ColumnType::Array(Box::new(ColumnType::Range("USER_ROLE".to_string()))),
)));
let mut new = Schema::default();
new.add_table(Table::new("operators").column(Column::new(
"roles",
ColumnType::Array(Box::new(ColumnType::Enum {
name: "user_role".to_string(),
values: vec!["admin".to_string(), "operator".to_string()],
})),
)));
let cmds = diff_schemas_checked(&old, &new)
.expect("case-only array enum placeholder drift should be treated as same type");
assert!(
cmds.iter()
.all(|cmd| cmd.action != Action::AlterType || cmd.table != "operators"),
"case-only array enum type drift must not emit ALTER TYPE: {cmds:?}"
);
}
#[test]
fn state_diff_checked_rejects_distinct_custom_type_names() {
let mut old = Schema::default();
old.add_table(Table::new("articles").column(Column::new(
"status",
ColumnType::Range("article_status".to_string()),
)));
let mut new = Schema::default();
new.add_table(Table::new("articles").column(Column::new(
"status",
ColumnType::Enum {
name: "user_role".to_string(),
values: vec!["admin".to_string(), "operator".to_string()],
},
)));
let err = diff_schemas_checked(&old, &new)
.expect_err("distinct custom type names should require explicit migration");
assert!(err.contains("existing column types"));
assert!(err.contains("articles.status"));
}
#[test]
fn state_diff_checked_rejects_existing_column_serial_pseudo_type_change() {
let mut old = Schema::default();
old.add_table(Table::new("events").column(Column::new("id", ColumnType::Int)));
let mut new = Schema::default();
new.add_table(Table::new("events").column(Column::new("id", ColumnType::Serial)));
let err = diff_schemas_checked(&old, &new)
.expect_err("INT -> SERIAL cannot be represented by ALTER COLUMN TYPE");
assert!(err.contains("existing column types"));
assert!(err.contains("events.id"));
assert!(err.contains("INT -> SERIAL"));
}
#[test]
fn state_diff_checked_allows_safe_existing_column_type_widening() {
use crate::transpiler::ToSql;
let mut old = Schema::default();
old.add_table(Table::new("events").column(Column::new("counter", ColumnType::Int)));
let mut new = Schema::default();
new.add_table(Table::new("events").column(Column::new("counter", ColumnType::BigInt)));
let cmds = diff_schemas_checked(&old, &new).expect("INT -> BIGINT should be auto-planned");
let type_cmd = cmds
.iter()
.find(|cmd| cmd.action == Action::AlterType && cmd.table == "events")
.expect("ALTER TYPE command should be present");
assert_eq!(
type_cmd.to_sql(),
"ALTER TABLE events ALTER COLUMN counter TYPE BIGINT"
);
}
#[test]
fn state_diff_checked_rejects_varchar_length_narrowing() {
let mut old = Schema::default();
old.add_table(
Table::new("users").column(Column::new("display_name", ColumnType::Varchar(Some(255)))),
);
let mut new = Schema::default();
new.add_table(
Table::new("users").column(Column::new("display_name", ColumnType::Varchar(Some(64)))),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("VARCHAR length shrink should require explicit validation");
assert!(err.contains("existing column types"));
assert!(err.contains("users.display_name"));
}
#[test]
fn state_diff_checked_rejects_existing_column_foreign_key_addition() {
let mut old = Schema::default();
old.add_table(
Table::new("tenants").column(Column::new("id", ColumnType::Int).primary_key()),
);
old.add_table(Table::new("orders").column(Column::new("tenant_id", ColumnType::Int)));
let mut new = Schema::default();
new.add_table(
Table::new("tenants").column(Column::new("id", ColumnType::Int).primary_key()),
);
new.add_table(
Table::new("orders")
.column(Column::new("tenant_id", ColumnType::Int).references("tenants", "id")),
);
let err = diff_schemas_checked(&old, &new)
.expect_err("existing-column single-column FK change should fail closed");
assert!(err.contains("single-column foreign keys"));
assert!(err.contains("orders.tenant_id"));
}
#[test]
fn diff_new_column_preserves_foreign_key_reference() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let mut old = Schema::default();
old.add_table(
Table::new("tenants").column(Column::new("id", ColumnType::Int).primary_key()),
);
old.add_table(Table::new("orders").column(Column::new("id", ColumnType::Int)));
let mut new = old.clone();
new.tables
.get_mut("orders")
.expect("orders table should exist")
.columns
.push(
Column::new("tenant_id", ColumnType::Int)
.references("tenants", "id")
.on_delete(FkAction::Cascade)
.on_update(FkAction::Restrict)
.initially_deferred(),
);
let cmds = diff_schemas_checked(&old, &new).expect("new referenced column should diff");
let add_col = cmds
.iter()
.find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "orders")
.expect("add-column command should be present");
let Expr::Def { constraints, .. } = &add_col.columns[0] else {
panic!("expected added column def");
};
assert!(constraints.iter().any(|constraint| {
matches!(
constraint,
Constraint::References(target)
if target == "tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED"
)
}));
let sql = add_col.to_sql();
assert!(
sql.contains(
"REFERENCES tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED"
),
"add-column SQL should preserve FK reference, got: {sql}"
);
}
#[test]
fn diff_new_column_preserves_check_constraint() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let mut old = Schema::default();
old.add_table(Table::new("players").column(Column::new("id", ColumnType::Int)));
let mut new = old.clone();
new.tables
.get_mut("players")
.expect("players table should exist")
.columns
.push(
Column::new("score", ColumnType::Int).check(CheckExpr::GreaterOrEqual {
column: "score".to_string(),
value: 0,
}),
);
let cmds = diff_schemas_checked(&old, &new).expect("new checked column should diff");
let add_col = cmds
.iter()
.find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "players")
.expect("add-column command should be present");
let Expr::Def { constraints, .. } = &add_col.columns[0] else {
panic!("expected score column definition");
};
assert!(constraints.iter().any(|constraint| {
matches!(
constraint,
Constraint::Check(vals) if vals.len() == 1 && vals[0] == "score >= 0"
)
}));
let sql = add_col.to_sql();
assert!(
sql.contains("CHECK (score >= 0)"),
"add-column SQL should preserve CHECK constraint, got: {sql}"
);
}
#[test]
fn diff_new_column_preserves_unique_constraint() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("id", ColumnType::Int)));
let mut new = old.clone();
new.tables
.get_mut("users")
.expect("users table should exist")
.columns
.push(Column::new("email", ColumnType::Text).unique());
let cmds = diff_schemas_checked(&old, &new).expect("new unique column should diff");
let add_col = cmds
.iter()
.find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "users")
.expect("add-column command should be present");
let Expr::Def { constraints, .. } = &add_col.columns[0] else {
panic!("expected email column definition");
};
assert!(constraints.contains(&Constraint::Unique));
let sql = add_col.to_sql();
assert!(
sql.contains("UNIQUE"),
"add-column SQL should preserve UNIQUE constraint, got: {sql}"
);
}
#[test]
fn diff_new_column_preserves_generated_constraint() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let mut old = Schema::default();
old.add_table(
Table::new("people")
.column(Column::new("first_name", ColumnType::Text))
.column(Column::new("last_name", ColumnType::Text)),
);
let mut new = old.clone();
new.tables
.get_mut("people")
.expect("people table should exist")
.columns
.push(
Column::new("full_name", ColumnType::Text)
.generated_stored("first_name || ' ' || last_name"),
);
let cmds = diff_schemas_checked(&old, &new).expect("new generated column should diff");
let add_col = cmds
.iter()
.find(|cmd| matches!(cmd.action, Action::Alter) && cmd.table == "people")
.expect("add-column command should be present");
let Expr::Def { constraints, .. } = &add_col.columns[0] else {
panic!("expected generated column definition");
};
assert!(constraints.iter().any(|constraint| {
matches!(
constraint,
Constraint::Generated(ColumnGeneration::Stored(expr))
if expr == "first_name || ' ' || last_name"
)
}));
let sql = add_col.to_sql();
assert!(
sql.contains("GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED"),
"add-column SQL should preserve GENERATED clause, got: {sql}"
);
}
#[test]
fn diff_new_table_preserves_foreign_key_actions() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let old = Schema::default();
let mut new = Schema::default();
new.add_table(
Table::new("tenants").column(Column::new("id", ColumnType::Int).primary_key()),
);
new.add_table(
Table::new("orders").column(
Column::new("tenant_id", ColumnType::Int)
.references("tenants", "id")
.on_delete(FkAction::Cascade)
.on_update(FkAction::Restrict),
),
);
let cmds = diff_schemas_checked(&old, &new).expect("new table with FK should diff");
let make_cmd = cmds
.iter()
.find(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "orders")
.expect("orders create-table command should be present");
let Expr::Def { constraints, .. } = &make_cmd.columns[0] else {
panic!("expected tenant_id column definition");
};
assert!(constraints.iter().any(|constraint| {
matches!(
constraint,
Constraint::References(target)
if target == "tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT"
)
}));
let sql = make_cmd.to_sql();
assert!(
sql.contains("REFERENCES tenants(id) ON DELETE CASCADE ON UPDATE RESTRICT"),
"create-table SQL should preserve FK action clauses, got: {sql}"
);
}
#[test]
fn diff_new_table_preserves_generated_and_identity_columns() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let old = Schema::default();
let mut new = Schema::default();
new.add_table(
Table::new("people")
.column(Column::new("first_name", ColumnType::Text))
.column(Column::new("last_name", ColumnType::Text))
.column(
Column::new("full_name", ColumnType::Text)
.generated_stored("first_name || ' ' || last_name"),
)
.column(Column::new("row_seq", ColumnType::BigInt).generated_by_default()),
);
let cmds = diff_schemas_checked(&old, &new).expect("new table should diff");
let make_cmd = cmds
.iter()
.find(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "people")
.expect("create-table command should be present");
let sql = make_cmd.to_sql();
assert!(
sql.contains("GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED"),
"create-table SQL should preserve GENERATED clause, got: {sql}"
);
assert!(
sql.contains("GENERATED BY DEFAULT AS IDENTITY"),
"create-table SQL should preserve IDENTITY clause, got: {sql}"
);
}
#[test]
fn state_diff_rejects_generated_changes_on_existing_columns() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(Table::new("people").column(Column::new("full_name", ColumnType::Text)));
let mut new = Schema::default();
new.add_table(
Table::new("people").column(
Column::new("full_name", ColumnType::Text)
.generated_stored("first_name || ' ' || last_name"),
),
);
let err = validate_state_diff_support(&old, &new)
.expect_err("generated changes on existing columns should fail closed");
assert!(err.contains("GENERATED/IDENTITY"), "{err}");
assert!(err.contains("people.full_name"), "{err}");
}
#[test]
fn diff_new_table_emits_rls_commands_after_create() {
use super::super::types::ColumnType;
let old = Schema::default();
let mut new = Schema::default();
let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Int));
docs.enable_rls = true;
docs.force_rls = true;
new.add_table(docs);
let cmds = diff_schemas_checked(&old, &new).expect("new RLS table should diff");
let make_idx = cmds
.iter()
.position(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "docs")
.expect("create-table command should be present");
let enable_idx = cmds
.iter()
.position(|cmd| matches!(cmd.action, Action::AlterEnableRls) && cmd.table == "docs")
.expect("enable RLS command should be present");
let force_idx = cmds
.iter()
.position(|cmd| matches!(cmd.action, Action::AlterForceRls) && cmd.table == "docs")
.expect("force RLS command should be present");
assert!(make_idx < enable_idx);
assert!(enable_idx < force_idx);
}
#[test]
fn state_diff_checked_rejects_existing_table_rls_disable() {
let mut old = Schema::default();
let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Int));
docs.enable_rls = true;
old.add_table(docs);
let mut new = Schema::default();
new.add_table(Table::new("docs").column(Column::new("id", ColumnType::Int)));
let err = diff_schemas_checked(&old, &new)
.expect_err("RLS disable should require an explicit migration");
assert!(err.contains("downgrade RLS"));
assert!(err.contains("docs (disable RLS)"));
}
#[test]
fn state_diff_checked_rejects_existing_table_force_rls_drop() {
let mut old = Schema::default();
let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Int));
docs.enable_rls = true;
docs.force_rls = true;
old.add_table(docs);
let mut new = Schema::default();
let mut docs = Table::new("docs").column(Column::new("id", ColumnType::Int));
docs.enable_rls = true;
new.add_table(docs);
let err = diff_schemas_checked(&old, &new)
.expect_err("FORCE RLS removal should require an explicit migration");
assert!(err.contains("downgrade RLS"));
assert!(err.contains("docs (drop FORCE RLS)"));
}
#[test]
fn diff_dropped_tables_orders_child_before_parent_by_incoming_fk_topology() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(
Table::new("root_a").column(Column::new("id", ColumnType::Int).primary_key()),
);
old.add_table(
Table::new("root_b").column(Column::new("id", ColumnType::Int).primary_key()),
);
old.add_table(
Table::new("parent")
.column(Column::new("id", ColumnType::Int).primary_key())
.column(Column::new("root_a_id", ColumnType::Int).references("root_a", "id"))
.column(Column::new("root_b_id", ColumnType::Int).references("root_b", "id")),
);
old.add_table(
Table::new("child")
.column(Column::new("id", ColumnType::Int))
.column(Column::new("parent_id", ColumnType::Int).references("parent", "id")),
);
let mut new = Schema::default();
new.add_table(
Table::new("root_a").column(Column::new("id", ColumnType::Int).primary_key()),
);
new.add_table(
Table::new("root_b").column(Column::new("id", ColumnType::Int).primary_key()),
);
let cmds = diff_schemas_checked(&old, &new).expect("dropped tables should diff");
let child_drop_idx = cmds
.iter()
.position(|cmd| matches!(cmd.action, Action::Drop) && cmd.table == "child")
.expect("child drop should be present");
let parent_drop_idx = cmds
.iter()
.position(|cmd| matches!(cmd.action, Action::Drop) && cmd.table == "parent")
.expect("parent drop should be present");
assert!(
child_drop_idx < parent_drop_idx,
"child table must be dropped before referenced parent table"
);
}
#[test]
fn diff_new_table_preserves_column_check_constraint() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let old = Schema::default();
let mut new = Schema::default();
new.add_table(
Table::new("inventory").column(
Column::new("quantity", ColumnType::Int).not_null().check(
CheckExpr::GreaterOrEqual {
column: "quantity".to_string(),
value: 0,
},
),
),
);
let cmds =
diff_schemas_checked(&old, &new).expect("new table with checked column should diff");
let make_cmd = cmds
.iter()
.find(|cmd| matches!(cmd.action, Action::Make) && cmd.table == "inventory")
.expect("create-table command should be present");
let Expr::Def { constraints, .. } = &make_cmd.columns[0] else {
panic!("expected quantity column definition");
};
assert!(constraints.iter().any(|constraint| {
matches!(
constraint,
Constraint::Check(vals) if vals.len() == 1 && vals[0] == "quantity >= 0"
)
}));
let sql = make_cmd.to_sql();
assert!(
sql.contains("CHECK (quantity >= 0)"),
"create-table SQL should preserve CHECK constraint, got: {sql}"
);
}
#[test]
fn diff_new_partial_unique_index_preserves_predicate() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let mut old = Schema::default();
old.add_table(
Table::new("users")
.column(Column::new("email", ColumnType::Text))
.column(Column::new("deleted_at", ColumnType::Text)),
);
let mut new = old.clone();
new.add_index(
Index::new("idx_users_email_active", "users", vec!["email".to_string()])
.unique()
.partial(CheckExpr::Sql("deleted_at IS NULL".to_string())),
);
let cmds = diff_schemas_checked(&old, &new).expect("new partial index should diff");
let index_cmd = cmds
.iter()
.find(|cmd| matches!(cmd.action, Action::Index))
.expect("index command should be present");
let index_def = index_cmd
.index_def
.as_ref()
.expect("index command should carry index definition");
assert!(index_def.unique);
assert_eq!(index_def.index_type.as_deref(), Some("btree"));
assert_eq!(
index_def.where_clause.as_deref(),
Some("deleted_at IS NULL")
);
let sql = index_cmd.to_sql();
assert!(
sql.contains("WHERE deleted_at IS NULL"),
"index SQL should preserve partial predicate, got: {sql}"
);
}
#[test]
fn diff_new_covering_concurrent_index_preserves_options() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let mut old = Schema::default();
old.add_table(
Table::new("users")
.column(Column::new("email", ColumnType::Text))
.column(Column::new("name", ColumnType::Text))
.column(Column::new("created_at", ColumnType::Timestamp)),
);
let mut new = old.clone();
new.add_index(
Index::new("idx_users_email_cover", "users", vec!["email".to_string()])
.include(vec!["name".to_string(), "created_at".to_string()])
.concurrently(),
);
let cmds =
diff_schemas_checked(&old, &new).expect("new covering concurrent index should diff");
let index_cmd = cmds
.iter()
.find(|cmd| matches!(cmd.action, Action::Index))
.expect("index command should be present");
let index_def = index_cmd
.index_def
.as_ref()
.expect("index command should carry index definition");
assert!(index_def.concurrently);
assert_eq!(
index_def.include,
vec!["name".to_string(), "created_at".to_string()]
);
let sql = index_cmd.to_sql();
assert!(
sql.contains("CREATE INDEX CONCURRENTLY idx_users_email_cover"),
"index SQL should preserve CONCURRENTLY, got: {sql}"
);
assert!(
sql.contains("INCLUDE (name, created_at)"),
"index SQL should preserve INCLUDE columns, got: {sql}"
);
}
#[test]
fn test_diff_rename_with_hint() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("username", ColumnType::Text)));
let mut new = Schema::default();
new.add_table(Table::new("users").column(Column::new("name", ColumnType::Text)));
new.add_hint(MigrationHint::Rename {
from: "users.username".into(),
to: "users.name".into(),
});
let cmds = diff_schemas(&old, &new);
assert!(cmds.iter().any(|c| matches!(c.action, Action::Mod)));
assert!(!cmds.iter().any(|c| matches!(c.action, Action::AlterDrop)));
}
#[test]
fn rename_hint_does_not_suppress_same_named_add_column_in_other_table() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("username", ColumnType::Text)));
old.add_table(Table::new("profiles").column(Column::new("id", ColumnType::Int)));
let mut new = Schema::default();
new.add_table(Table::new("users").column(Column::new("name", ColumnType::Text)));
new.add_table(
Table::new("profiles")
.column(Column::new("id", ColumnType::Int))
.column(Column::new("name", ColumnType::Text)),
);
new.add_hint(MigrationHint::Rename {
from: "users.username".into(),
to: "users.name".into(),
});
let cmds = diff_schemas_checked(&old, &new).expect("schema should diff");
assert!(cmds.iter().any(|cmd| {
matches!(cmd.action, Action::Alter)
&& cmd.table == "profiles"
&& matches!(
cmd.columns.first(),
Some(Expr::Def { name, .. }) if name == "name"
)
}));
}
#[test]
fn rename_hint_does_not_suppress_same_named_drop_column_in_other_table() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(Table::new("users").column(Column::new("username", ColumnType::Text)));
old.add_table(
Table::new("profiles")
.column(Column::new("id", ColumnType::Int))
.column(Column::new("username", ColumnType::Text)),
);
let mut new = Schema::default();
new.add_table(Table::new("users").column(Column::new("name", ColumnType::Text)));
new.add_table(Table::new("profiles").column(Column::new("id", ColumnType::Int)));
new.add_hint(MigrationHint::Rename {
from: "users.username".into(),
to: "users.name".into(),
});
let cmds = diff_schemas_checked(&old, &new).expect("schema should diff");
assert!(cmds.iter().any(|cmd| {
matches!(cmd.action, Action::AlterDrop)
&& cmd.table == "profiles"
&& matches!(
cmd.columns.first(),
Some(Expr::Named(name)) if name == "username"
)
}));
}
#[test]
fn test_fk_ordering_parent_before_child() {
use super::super::types::ColumnType;
let old = Schema::default();
let mut new = Schema::default();
new.add_table(
Table::new("child")
.column(Column::new("id", ColumnType::Serial).primary_key())
.column(Column::new("parent_id", ColumnType::Int).references("parent", "id")),
);
new.add_table(
Table::new("parent")
.column(Column::new("id", ColumnType::Serial).primary_key())
.column(Column::new("name", ColumnType::Text)),
);
let cmds = diff_schemas(&old, &new);
let make_cmds: Vec<_> = cmds
.iter()
.filter(|c| matches!(c.action, Action::Make))
.collect();
assert_eq!(make_cmds.len(), 2);
let parent_idx = make_cmds.iter().position(|c| c.table == "parent").unwrap();
let child_idx = make_cmds.iter().position(|c| c.table == "child").unwrap();
assert!(
parent_idx < child_idx,
"parent table should be created before child with FK"
);
}
#[test]
fn test_fk_ordering_multiple_dependencies() {
use super::super::types::ColumnType;
let old = Schema::default();
let mut new = Schema::default();
new.add_table(
Table::new("order_items")
.column(Column::new("id", ColumnType::Serial).primary_key())
.column(Column::new("order_id", ColumnType::Int).references("orders", "id"))
.column(Column::new("product_id", ColumnType::Int).references("products", "id")),
);
new.add_table(
Table::new("orders")
.column(Column::new("id", ColumnType::Serial).primary_key())
.column(Column::new("user_id", ColumnType::Int).references("users", "id")),
);
new.add_table(
Table::new("users").column(Column::new("id", ColumnType::Serial).primary_key()),
);
new.add_table(
Table::new("products").column(Column::new("id", ColumnType::Serial).primary_key()),
);
let cmds = diff_schemas(&old, &new);
let make_cmds: Vec<_> = cmds
.iter()
.filter(|c| matches!(c.action, Action::Make))
.collect();
assert_eq!(make_cmds.len(), 4);
let users_idx = make_cmds.iter().position(|c| c.table == "users").unwrap();
let products_idx = make_cmds
.iter()
.position(|c| c.table == "products")
.unwrap();
let orders_idx = make_cmds.iter().position(|c| c.table == "orders").unwrap();
let items_idx = make_cmds
.iter()
.position(|c| c.table == "order_items")
.unwrap();
assert!(users_idx < orders_idx, "users (0 FK) before orders (1 FK)");
assert!(
products_idx < items_idx,
"products (0 FK) before order_items (2 FK)"
);
assert!(
orders_idx < items_idx,
"orders (1 FK) before order_items (2 FK)"
);
}
#[test]
fn diff_new_table_preserves_multi_column_foreign_key() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let old = Schema::default();
let mut new = Schema::default();
new.add_table(
Table::new("schedules")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
new.add_index(
Index::new(
"idx_schedules_route_schedule",
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)
.unique(),
);
new.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text))
.foreign_key(MultiColumnForeignKey::new(
vec!["route_id".to_string(), "schedule_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)),
);
let cmds = diff_schemas(&old, &new);
let schedules_idx = cmds
.iter()
.position(|c| matches!(c.action, Action::Make) && c.table == "schedules")
.expect("schedules create command should exist");
let trips_idx = cmds
.iter()
.position(|c| matches!(c.action, Action::Make) && c.table == "trips")
.expect("trips create command should exist");
let unique_idx = cmds
.iter()
.position(|c| {
matches!(c.action, Action::Index)
&& c.index_def
.as_ref()
.is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
})
.expect("unique index command should exist");
let add_fk_idx = cmds
.iter()
.position(|c| matches!(c.action, Action::Alter) && c.table == "trips")
.expect("composite FK ALTER command should exist");
assert!(schedules_idx < unique_idx);
assert!(trips_idx < unique_idx);
assert!(unique_idx < add_fk_idx);
let trips_cmd = cmds
.iter()
.find(|c| matches!(c.action, Action::Make) && c.table == "trips")
.expect("trips create command should exist");
assert!(
trips_cmd.table_constraints.is_empty(),
"composite foreign keys should not be emitted inline on CREATE TABLE"
);
let add_fk_cmd = &cmds[add_fk_idx];
assert!(
add_fk_cmd
.table_constraints
.iter()
.any(|constraint| matches!(
constraint,
crate::ast::TableConstraint::ForeignKey {
columns,
ref_table,
ref_columns,
..
} if columns == &["route_id", "schedule_id"]
&& ref_table == "schedules"
&& ref_columns == &["route_id", "schedule_id"]
)),
"diff should preserve composite FK table constraint"
);
let sql = add_fk_cmd.to_sql();
assert!(
sql.contains(
"ALTER TABLE trips ADD FOREIGN KEY (route_id, schedule_id) REFERENCES schedules(route_id, schedule_id)"
),
"generated SQL should include composite foreign key, got: {sql}"
);
}
#[test]
fn diff_existing_table_adds_multi_column_foreign_key() {
use super::super::types::ColumnType;
use crate::transpiler::ToSql;
let mut old = Schema::default();
old.add_table(
Table::new("schedules")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
old.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
let mut new = old.clone();
new.add_index(
Index::new(
"idx_schedules_route_schedule",
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)
.unique(),
);
new.tables
.get_mut("trips")
.expect("trips table should exist")
.multi_column_fks
.push(MultiColumnForeignKey::new(
vec!["route_id".to_string(), "schedule_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
));
let cmds = diff_schemas(&old, &new);
let unique_idx = cmds
.iter()
.position(|c| {
matches!(c.action, Action::Index)
&& c.index_def
.as_ref()
.is_some_and(|idx| idx.name == "idx_schedules_route_schedule")
})
.expect("unique index command should exist");
let add_fk_idx = cmds
.iter()
.position(|c| matches!(c.action, Action::Alter) && c.table == "trips")
.expect("composite FK ALTER command should exist");
assert!(unique_idx < add_fk_idx);
let add_fk_cmd = &cmds[add_fk_idx];
let sql = add_fk_cmd.to_sql();
assert!(
sql.contains(
"ALTER TABLE trips ADD FOREIGN KEY (route_id, schedule_id) REFERENCES schedules(route_id, schedule_id)"
),
"generated SQL should add composite foreign key, got: {sql}"
);
}
#[test]
fn state_diff_support_rejects_added_multi_column_foreign_key_on_existing_table() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(
Table::new("schedules")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
old.add_index(
Index::new(
"idx_schedules_route_schedule",
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)
.unique(),
);
old.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
let mut new = old.clone();
new.tables
.get_mut("trips")
.expect("trips table should exist")
.multi_column_fks
.push(MultiColumnForeignKey::new(
vec!["route_id".to_string(), "schedule_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
));
let err =
diff_schemas_checked(&old, &new).expect_err("added composite FK should fail closed");
assert!(err.contains("add multi-column foreign keys"));
assert!(err.contains("trips."));
}
#[test]
fn state_diff_support_rejects_removed_multi_column_foreign_key() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(
Table::new("schedules")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
old.add_index(
Index::new(
"idx_schedules_route_schedule",
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)
.unique(),
);
old.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text))
.foreign_key(MultiColumnForeignKey::new(
vec!["route_id".to_string(), "schedule_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)),
);
let mut new = old.clone();
new.tables
.get_mut("trips")
.expect("trips table should exist")
.multi_column_fks
.clear();
let err =
diff_schemas_checked(&old, &new).expect_err("removed composite FK should fail closed");
assert!(err.contains("multi-column foreign keys"));
assert!(err.contains("trips."));
}
#[test]
fn state_diff_support_rejects_changed_multi_column_foreign_key() {
use super::super::types::ColumnType;
let mut old = Schema::default();
old.add_table(
Table::new("schedules")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text)),
);
old.add_index(
Index::new(
"idx_schedules_route_schedule",
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)
.unique(),
);
old.add_table(
Table::new("trips")
.column(Column::new("route_id", ColumnType::Text))
.column(Column::new("schedule_id", ColumnType::Text))
.foreign_key(MultiColumnForeignKey::new(
vec!["route_id".to_string(), "schedule_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
)),
);
let mut new = old.clone();
new.tables
.get_mut("trips")
.expect("trips table should exist")
.multi_column_fks[0] = MultiColumnForeignKey::new(
vec!["schedule_id".to_string(), "route_id".to_string()],
"schedules",
vec!["route_id".to_string(), "schedule_id".to_string()],
);
let err =
diff_schemas_checked(&old, &new).expect_err("changed composite FK should fail closed");
assert!(err.contains("multi-column foreign keys"));
assert!(err.contains("trips."));
}
}