use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;
use crate::dbms::table::{
ColumnSnapshot, DataTypeSnapshot, ForeignKeySnapshot, IndexSnapshot, TableSchema,
};
use crate::dbms::value::Value;
use crate::error::DbmsResult;
pub trait Migrate
where
Self: TableSchema,
{
fn default_value(_column: &str) -> Option<Value> {
None
}
fn transform_column(_column: &str, _old: Value) -> DbmsResult<Option<Value>> {
Ok(None)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "candid", derive(candid::CandidType))]
pub enum MigrationOp {
CreateTable {
name: String,
schema: crate::dbms::table::TableSchemaSnapshot,
},
DropTable {
name: String,
},
AddColumn {
table: String,
column: ColumnSnapshot,
},
DropColumn {
table: String,
column: String,
},
RenameColumn {
table: String,
old: String,
new: String,
},
AlterColumn {
table: String,
column: String,
changes: ColumnChanges,
},
WidenColumn {
table: String,
column: String,
old_type: DataTypeSnapshot,
new_type: DataTypeSnapshot,
},
TransformColumn {
column: String,
table: String,
old_type: DataTypeSnapshot,
new_type: DataTypeSnapshot,
},
AddIndex {
table: String,
index: IndexSnapshot,
},
DropIndex {
table: String,
index: IndexSnapshot,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "candid", derive(candid::CandidType))]
pub struct ColumnChanges {
pub nullable: Option<bool>,
pub unique: Option<bool>,
pub auto_increment: Option<bool>,
pub primary_key: Option<bool>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_foreign_key_change"
)]
pub foreign_key: Option<Option<ForeignKeySnapshot>>,
}
fn deserialize_foreign_key_change<'de, D>(
deserializer: D,
) -> Result<Option<Option<ForeignKeySnapshot>>, D::Error>
where
D: Deserializer<'de>,
{
if deserializer.is_human_readable() {
Option::<ForeignKeySnapshot>::deserialize(deserializer).map(Some)
} else {
Option::<Option<ForeignKeySnapshot>>::deserialize(deserializer)
}
}
impl ColumnChanges {
pub fn is_empty(&self) -> bool {
self.nullable.is_none()
&& self.unique.is_none()
&& self.auto_increment.is_none()
&& self.primary_key.is_none()
&& self.foreign_key.is_none()
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "candid", derive(candid::CandidType))]
pub struct MigrationPolicy {
pub allow_destructive: bool,
}
#[derive(Debug, Error, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "candid", derive(candid::CandidType))]
pub enum MigrationError {
#[error("Schema drift: stored schema differs from compiled schema")]
SchemaDrift,
#[error(
"Incompatible type change for column `{column}` in table `{table}`: {old:?} -> {new:?}"
)]
IncompatibleType {
table: String,
column: String,
old: DataTypeSnapshot,
new: DataTypeSnapshot,
},
#[error("Missing default for non-nullable new column `{column}` in table `{table}`")]
DefaultMissing {
table: String,
column: String,
},
#[error("Constraint violation for column `{column}` in table `{table}`: {reason}")]
ConstraintViolation {
table: String,
column: String,
reason: String,
},
#[error("Destructive migration op denied by policy: {op}")]
DestructiveOpDenied {
op: String,
},
#[error("Migration transform aborted for column `{column}` in table `{table}`: {reason}")]
TransformAborted {
table: String,
column: String,
reason: String,
},
#[error(
"No widening rule for column `{column}` in table `{table}`: {old_type:?} -> {new_type:?}"
)]
WideningIncompatible {
table: String,
column: String,
old_type: DataTypeSnapshot,
new_type: DataTypeSnapshot,
},
#[error("Migrate::transform_column returned None for column `{column}` in table `{table}`")]
TransformReturnedNone {
table: String,
column: String,
},
#[error(
"Foreign key violation on column `{column}` in table `{table}`: value `{value}` not present in `{target_table}`"
)]
ForeignKeyViolation {
table: String,
column: String,
target_table: String,
value: String,
},
}
#[cfg(test)]
mod test {
use super::*;
use crate::dbms::table::{
ColumnSnapshot, DataTypeSnapshot, ForeignKeySnapshot, OnDeleteSnapshot,
};
#[test]
fn test_should_default_migration_policy_to_non_destructive() {
let policy = MigrationPolicy::default();
assert!(!policy.allow_destructive);
}
#[test]
fn test_should_detect_empty_column_changes() {
let changes = ColumnChanges::default();
assert!(changes.is_empty());
let nullable = ColumnChanges {
nullable: Some(true),
..Default::default()
};
assert!(!nullable.is_empty());
}
#[test]
fn test_should_display_migration_error() {
let err = MigrationError::SchemaDrift;
assert_eq!(
err.to_string(),
"Schema drift: stored schema differs from compiled schema"
);
let err = MigrationError::IncompatibleType {
table: "users".into(),
column: "id".into(),
old: DataTypeSnapshot::Int32,
new: DataTypeSnapshot::Text,
};
assert!(err.to_string().contains("Incompatible type change"));
let err = MigrationError::DefaultMissing {
table: "users".into(),
column: "email".into(),
};
assert!(err.to_string().contains("Missing default"));
let err = MigrationError::ConstraintViolation {
table: "users".into(),
column: "email".into(),
reason: "duplicate value".into(),
};
assert!(err.to_string().contains("Constraint violation"));
let err = MigrationError::DestructiveOpDenied {
op: "DropTable".into(),
};
assert!(err.to_string().contains("Destructive migration op denied"));
let err = MigrationError::TransformAborted {
table: "users".into(),
column: "id".into(),
reason: "negative ids unsupported".into(),
};
assert!(err.to_string().contains("Migration transform aborted"));
}
#[test]
fn test_should_construct_migration_ops() {
let _drop = MigrationOp::DropTable { name: "old".into() };
let _add = MigrationOp::AddColumn {
table: "users".into(),
column: ColumnSnapshot {
name: "email".into(),
data_type: DataTypeSnapshot::Text,
nullable: true,
auto_increment: false,
unique: false,
primary_key: false,
foreign_key: None,
default: None,
},
};
}
fn sample_foreign_key() -> ForeignKeySnapshot {
ForeignKeySnapshot {
table: "users".into(),
column: "id".into(),
on_delete: OnDeleteSnapshot::Cascade,
}
}
fn foreign_key_states() -> [ColumnChanges; 3] {
[
ColumnChanges {
nullable: Some(true),
..Default::default()
},
ColumnChanges {
foreign_key: Some(None),
..Default::default()
},
ColumnChanges {
foreign_key: Some(Some(sample_foreign_key())),
..Default::default()
},
]
}
#[test]
fn test_should_json_roundtrip_column_changes_foreign_key_states() {
for changes in foreign_key_states() {
let json = serde_json::to_string(&changes).expect("failed to serialize");
let decoded: ColumnChanges =
serde_json::from_str(&json).expect("failed to deserialize");
assert_eq!(decoded, changes, "json: {json}");
assert!(!decoded.is_empty());
}
}
#[test]
fn test_should_serialize_column_changes_foreign_key_states_distinctly() {
let [keep, remove, set] = foreign_key_states()
.map(|changes| serde_json::to_value(&changes).expect("failed to serialize"));
assert_ne!(keep, remove);
assert_ne!(remove, set);
assert_ne!(keep, set);
assert!(keep.get("foreign_key").is_none());
assert_eq!(remove.get("foreign_key"), Some(&serde_json::Value::Null));
assert!(set["foreign_key"].is_object());
}
#[test]
fn test_should_json_deserialize_column_changes_foreign_key_states() {
let keep: ColumnChanges = serde_json::from_str(
r#"{"nullable":null,"unique":null,"auto_increment":null,"primary_key":null}"#,
)
.expect("failed to deserialize keep");
assert_eq!(keep.foreign_key, None);
assert!(keep.is_empty());
let remove: ColumnChanges = serde_json::from_str(
r#"{"nullable":null,"unique":null,"auto_increment":null,"primary_key":null,"foreign_key":null}"#,
)
.expect("failed to deserialize remove");
assert_eq!(remove.foreign_key, Some(None));
assert!(!remove.is_empty());
let set: ColumnChanges = serde_json::from_str(
r#"{"nullable":null,"unique":null,"auto_increment":null,"primary_key":null,"foreign_key":{"table":"users","column":"id","on_delete":"Cascade"}}"#,
)
.expect("failed to deserialize set");
assert_eq!(set.foreign_key, Some(Some(sample_foreign_key())));
}
#[cfg(feature = "candid")]
#[test]
fn test_should_candid_roundtrip_column_changes_foreign_key_states() {
let mut states = foreign_key_states().to_vec();
states.push(ColumnChanges::default());
for changes in states {
let encoded = candid::encode_one(&changes).expect("failed to encode");
let decoded: ColumnChanges = candid::decode_one(&encoded).expect("failed to decode");
assert_eq!(decoded, changes);
}
}
#[cfg(feature = "candid")]
#[test]
fn test_should_candid_roundtrip_migration_policy() {
let policy = MigrationPolicy {
allow_destructive: true,
};
let encoded = candid::encode_one(&policy).expect("failed to encode");
let decoded: MigrationPolicy = candid::decode_one(&encoded).expect("failed to decode");
assert_eq!(policy, decoded);
}
#[cfg(feature = "candid")]
#[test]
fn test_should_candid_roundtrip_migration_error() {
let err = MigrationError::SchemaDrift;
let encoded = candid::encode_one(&err).expect("failed to encode");
let decoded: MigrationError = candid::decode_one(&encoded).expect("failed to decode");
assert_eq!(err, decoded);
}
#[cfg(feature = "candid")]
#[test]
fn test_should_candid_roundtrip_new_migration_error_variants() {
let cases = vec![
MigrationError::DefaultMissing {
table: "users".into(),
column: "email".into(),
},
MigrationError::WideningIncompatible {
table: "users".into(),
column: "id".into(),
old_type: DataTypeSnapshot::Int32,
new_type: DataTypeSnapshot::Uint8,
},
MigrationError::TransformReturnedNone {
table: "users".into(),
column: "id".into(),
},
MigrationError::ForeignKeyViolation {
table: "posts".into(),
column: "owner".into(),
target_table: "users".into(),
value: "Uint32(99)".into(),
},
];
for err in cases {
let encoded = candid::encode_one(&err).expect("failed to encode");
let decoded: MigrationError = candid::decode_one(&encoded).expect("failed to decode");
assert_eq!(err, decoded);
}
}
}