use super::{FieldStorage, LayoutField, StorageLayout};
use crate::error::{Error, Result};
use crate::KnownEngines;
fn inspect_compatible(live: FieldStorage, desired: FieldStorage) -> bool {
if live == desired {
return true;
}
matches!(
(live, desired),
(
FieldStorage::String,
FieldStorage::String | FieldStorage::Date | FieldStorage::Json | FieldStorage::Currency
) | (
FieldStorage::Json,
FieldStorage::Currency | FieldStorage::Json
) | (FieldStorage::Integer, FieldStorage::Boolean)
| (FieldStorage::Boolean, FieldStorage::Integer)
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdditiveOp {
AddField(LayoutField),
AddUniqueIndex { field: String },
AddIndex { field: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SafeTweak {
SetNullable { field: String },
SetNotNull { field: String },
SetDefault { field: String, value: String },
DropDefault { field: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LayoutDiff {
pub ops: Vec<AdditiveOp>,
pub tweaks: Vec<SafeTweak>,
}
fn engine_supports_nullability_tweaks(engine_id: &str) -> bool {
matches!(
engine_id,
KnownEngines::POSTGRES | KnownEngines::HYBRID_INDRA_SQL
)
}
fn engine_supports_default_tweaks(engine_id: &str) -> bool {
matches!(
engine_id,
KnownEngines::POSTGRES | KnownEngines::HYBRID_INDRA_SQL
)
}
pub fn additive_ops(desired: &StorageLayout, live: &StorageLayout) -> Result<LayoutDiff> {
layout_diff(desired, live, KnownEngines::SQLITE)
}
pub fn layout_diff(
desired: &StorageLayout,
live: &StorageLayout,
engine_id: &str,
) -> Result<LayoutDiff> {
for live_f in &live.fields {
if !desired.fields.iter().any(|d| d.name == live_f.name) {
return Err(Error::Validation(format!(
"sync_typed_table refuses drop of live field {}.{} (destructive sync is Future)",
live.table, live_f.name
)));
}
if let Some(d) = desired.fields.iter().find(|d| d.name == live_f.name) {
if d.storage != live_f.storage
&& live_f.name != "id"
&& !inspect_compatible(live_f.storage, d.storage)
{
return Err(Error::Validation(format!(
"sync_typed_table refuses type change on {}.{} ({:?} → {:?})",
live.table, live_f.name, live_f.storage, d.storage
)));
}
}
}
let mut ops = Vec::new();
let mut tweaks = Vec::new();
for d in &desired.fields {
if !live.fields.iter().any(|l| l.name == d.name) {
ops.push(AdditiveOp::AddField(d.clone()));
continue;
}
let live_f = live
.fields
.iter()
.find(|l| l.name == d.name)
.expect("live field present after membership check");
if d.unique && !live_f.unique {
ops.push(AdditiveOp::AddUniqueIndex {
field: d.name.clone(),
});
}
if d.indexed && !live_f.indexed && !d.unique {
ops.push(AdditiveOp::AddIndex {
field: d.name.clone(),
});
}
if d.name == "id" {
continue;
}
if d.nullable != live_f.nullable {
if !engine_supports_nullability_tweaks(engine_id) {
return Err(Error::Validation(format!(
"sync_typed_table refuses nullability change on {}.{} on engine {engine_id} \
(SQLite requires table rebuild — Future)",
live.table, d.name
)));
}
if d.nullable {
tweaks.push(SafeTweak::SetNullable {
field: d.name.clone(),
});
} else {
tweaks.push(SafeTweak::SetNotNull {
field: d.name.clone(),
});
}
}
if engine_supports_default_tweaks(engine_id) {
match (&d.default, &live_f.default) {
(Some(want), live_def) if live_def.as_deref() != Some(want.as_str()) => {
tweaks.push(SafeTweak::SetDefault {
field: d.name.clone(),
value: want.clone(),
});
}
(None, Some(_)) => {
tweaks.push(SafeTweak::DropDefault {
field: d.name.clone(),
});
}
_ => {}
}
}
}
Ok(LayoutDiff { ops, tweaks })
}
#[cfg(test)]
mod tests {
use super::*;
fn field(name: &str, nullable: bool) -> LayoutField {
LayoutField {
name: name.into(),
storage: FieldStorage::String,
primary_key: name == "id",
nullable,
unique: name == "id",
indexed: false,
default: None,
record_table: None,
}
}
#[test]
fn add_missing_field() {
let desired = StorageLayout {
table: "t".into(),
fields: vec![
field("id", false),
field("name", true),
field("score", true),
],
};
let live = StorageLayout {
table: "t".into(),
fields: vec![field("id", false), field("name", true)],
};
let diff = layout_diff(&desired, &live, KnownEngines::SQLITE).unwrap();
assert!(matches!(diff.ops[0], AdditiveOp::AddField(ref f) if f.name == "score"));
assert!(diff.tweaks.is_empty());
}
#[test]
fn refuse_orphan_live_field() {
let desired = StorageLayout {
table: "t".into(),
fields: vec![field("id", false), field("name", true)],
};
let live = StorageLayout {
table: "t".into(),
fields: vec![
field("id", false),
field("name", true),
field("legacy", true),
],
};
let err = layout_diff(&desired, &live, KnownEngines::SQLITE).unwrap_err();
assert!(err.to_string().contains("refuses drop"));
}
#[test]
fn postgres_nullability_tweak() {
let desired = StorageLayout {
table: "t".into(),
fields: vec![field("id", false), field("name", true)],
};
let mut live_name = field("name", false);
live_name.nullable = false;
let live = StorageLayout {
table: "t".into(),
fields: vec![field("id", false), live_name],
};
let diff = layout_diff(&desired, &live, KnownEngines::POSTGRES).unwrap();
assert_eq!(
diff.tweaks,
vec![SafeTweak::SetNullable {
field: "name".into()
}]
);
}
#[test]
fn sqlite_nullability_refused() {
let desired = StorageLayout {
table: "t".into(),
fields: vec![field("id", false), field("name", true)],
};
let mut live_name = field("name", false);
live_name.nullable = false;
let live = StorageLayout {
table: "t".into(),
fields: vec![field("id", false), live_name],
};
let err = layout_diff(&desired, &live, KnownEngines::SQLITE).unwrap_err();
assert!(err.to_string().contains("nullability"));
}
#[test]
fn refuse_incompatible_type_change() {
let desired = StorageLayout {
table: "t".into(),
fields: vec![
field("id", false),
LayoutField {
name: "score".into(),
storage: FieldStorage::Integer,
primary_key: false,
nullable: true,
unique: false,
indexed: false,
default: None,
record_table: None,
},
],
};
let live = StorageLayout {
table: "t".into(),
fields: vec![
field("id", false),
LayoutField {
name: "score".into(),
storage: FieldStorage::String,
primary_key: false,
nullable: true,
unique: false,
indexed: false,
default: None,
record_table: None,
},
],
};
let err = layout_diff(&desired, &live, KnownEngines::SQLITE).unwrap_err();
assert!(
err.to_string().contains("refuses type change"),
"unexpected error: {err}"
);
}
}