use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::dbms::table::{ColumnSnapshot, DataTypeSnapshot, 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>,
pub foreign_key: Option<Option<crate::dbms::table::ForeignKeySnapshot>>,
}
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};
#[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,
},
};
}
#[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);
}
}
}