use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::external::{ViewSpec, VirtualTableSpec};
use crate::procedure::ProcedureSpec;
use crate::trigger::TriggerSpec;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MigrationOp {
CreateTable {
name: String,
},
DropTable {
name: String,
},
AddColumn {
table: String,
column: String,
},
DropColumn {
table: String,
column: String,
},
AlterColumn {
table: String,
column: String,
},
AddIndex {
table: String,
index: String,
},
DropIndex {
table: String,
index: String,
},
AddUnique {
table: String,
constraint: String,
},
DropUnique {
table: String,
constraint: String,
},
AddForeignKey {
table: String,
constraint: String,
},
DropForeignKey {
table: String,
constraint: String,
},
AddCheck {
table: String,
constraint: String,
},
DropCheck {
table: String,
constraint: String,
},
CreateProcedure {
name: String,
procedure: ProcedureSpec,
},
ReplaceProcedure {
name: String,
procedure: ProcedureSpec,
},
DropProcedure {
name: String,
},
CreateTrigger {
name: String,
trigger: TriggerSpec,
},
ReplaceTrigger {
name: String,
trigger: TriggerSpec,
},
DropTrigger {
name: String,
},
CreateVirtualTable {
table: VirtualTableSpec,
},
DropVirtualTable {
name: String,
},
CreateView {
name: String,
view: ViewSpec,
},
ReplaceView {
name: String,
view: ViewSpec,
},
DropView {
name: String,
},
RawSql(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Migration {
pub version: i64,
pub name: String,
pub ops: Vec<MigrationOp>,
}
fn json_string(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
fn canonical_op(op: &MigrationOp) -> String {
match op {
MigrationOp::CreateTable { name } => {
format!(r#"{{"op":"create_table","name":{}}}"#, json_string(name))
}
MigrationOp::DropTable { name } => {
format!(r#"{{"op":"drop_table","name":{}}}"#, json_string(name))
}
MigrationOp::AddColumn { table, column } => format!(
r#"{{"op":"add_column","table":{},"column":{}}}"#,
json_string(table),
json_string(column)
),
MigrationOp::DropColumn { table, column } => format!(
r#"{{"op":"drop_column","table":{},"column":{}}}"#,
json_string(table),
json_string(column)
),
MigrationOp::AlterColumn { table, column } => format!(
r#"{{"op":"alter_column","table":{},"column":{}}}"#,
json_string(table),
json_string(column)
),
MigrationOp::AddIndex { table, index } => format!(
r#"{{"op":"add_index","table":{},"index":{}}}"#,
json_string(table),
json_string(index)
),
MigrationOp::DropIndex { table, index } => format!(
r#"{{"op":"drop_index","table":{},"index":{}}}"#,
json_string(table),
json_string(index)
),
MigrationOp::AddUnique { table, constraint } => format!(
r#"{{"op":"add_unique","table":{},"constraint":{}}}"#,
json_string(table),
json_string(constraint)
),
MigrationOp::DropUnique { table, constraint } => format!(
r#"{{"op":"drop_unique","table":{},"constraint":{}}}"#,
json_string(table),
json_string(constraint)
),
MigrationOp::AddForeignKey { table, constraint } => format!(
r#"{{"op":"add_foreign_key","table":{},"constraint":{}}}"#,
json_string(table),
json_string(constraint)
),
MigrationOp::DropForeignKey { table, constraint } => format!(
r#"{{"op":"drop_foreign_key","table":{},"constraint":{}}}"#,
json_string(table),
json_string(constraint)
),
MigrationOp::AddCheck { table, constraint } => format!(
r#"{{"op":"add_check","table":{},"constraint":{}}}"#,
json_string(table),
json_string(constraint)
),
MigrationOp::DropCheck { table, constraint } => format!(
r#"{{"op":"drop_check","table":{},"constraint":{}}}"#,
json_string(table),
json_string(constraint)
),
MigrationOp::CreateProcedure { name, procedure } => format!(
r#"{{"op":"create_procedure","name":{},"procedure":{}}}"#,
json_string(name),
procedure.canonical_json()
),
MigrationOp::ReplaceProcedure { name, procedure } => format!(
r#"{{"op":"replace_procedure","name":{},"procedure":{}}}"#,
json_string(name),
procedure.canonical_json()
),
MigrationOp::DropProcedure { name } => {
format!(r#"{{"op":"drop_procedure","name":{}}}"#, json_string(name))
}
MigrationOp::CreateTrigger { name, trigger } => format!(
r#"{{"op":"create_trigger","name":{},"trigger":{}}}"#,
json_string(name),
trigger.canonical_json()
),
MigrationOp::ReplaceTrigger { name, trigger } => format!(
r#"{{"op":"replace_trigger","name":{},"trigger":{}}}"#,
json_string(name),
trigger.canonical_json()
),
MigrationOp::DropTrigger { name } => {
format!(r#"{{"op":"drop_trigger","name":{}}}"#, json_string(name))
}
MigrationOp::CreateVirtualTable { table } => format!(
r#"{{"op":"create_virtual_table","name":{},"module":{},"args":[{}]}}"#,
json_string(&table.name),
json_string(&table.module),
table
.args
.iter()
.map(|arg| json_string(arg))
.collect::<Vec<_>>()
.join(",")
),
MigrationOp::DropVirtualTable { name } => {
format!(
r#"{{"op":"drop_virtual_table","name":{}}}"#,
json_string(name)
)
}
MigrationOp::CreateView { name, view } => format!(
r#"{{"op":"create_view","name":{},"sql":{}}}"#,
json_string(name),
json_string(&view.sql)
),
MigrationOp::ReplaceView { name, view } => format!(
r#"{{"op":"replace_view","name":{},"sql":{}}}"#,
json_string(name),
json_string(&view.sql)
),
MigrationOp::DropView { name } => {
format!(r#"{{"op":"drop_view","name":{}}}"#, json_string(name))
}
MigrationOp::RawSql(sql) => {
format!(r#"{{"op":"raw_sql","sql":{}}}"#, json_string(sql))
}
}
}
fn canonical_content(version: i64, name: &str, ops: &[MigrationOp]) -> String {
let ops_json: Vec<String> = ops.iter().map(canonical_op).collect();
format!(
r#"{{"version":{},"name":{},"ops":[{}]}}"#,
version,
json_string(name),
ops_json.join(",")
)
}
pub fn migration_checksum(version: i64, name: &str, ops: &[MigrationOp]) -> String {
let mut hasher = Sha256::new();
hasher.update(canonical_content(version, name, ops).as_bytes());
hex::encode(hasher.finalize())
}
impl Migration {
pub fn checksum(&self) -> String {
migration_checksum(self.version, &self.name, &self.ops)
}
}
pub fn plan_migrations<'a>(applied: &[Migration], desired: &'a [Migration]) -> Vec<&'a Migration> {
let max_applied = applied.iter().map(|m| m.version).max().unwrap_or(i64::MIN);
let mut pending: Vec<&'a Migration> =
desired.iter().filter(|m| m.version > max_applied).collect();
pending.sort_by_key(|m| m.version);
pending
}
#[cfg(test)]
mod tests {
use super::*;
fn migration(version: i64, name: &str) -> Migration {
Migration {
version,
name: name.into(),
ops: vec![MigrationOp::CreateTable { name: name.into() }],
}
}
#[test]
fn checksum_is_stable_and_matches_typescript() {
assert_eq!(
migration_checksum(
1,
"init",
&[MigrationOp::CreateTable {
name: "users".into()
}]
),
"fe2f521793591207bd4d8645c2631e4b7ce43e30fe7ea5691a2846c74ea71cc3"
);
assert_eq!(
migration_checksum(
2,
"add_email",
&[
MigrationOp::AddColumn {
table: "users".into(),
column: "email".into()
},
MigrationOp::AddUnique {
table: "users".into(),
constraint: "uq_email".into()
}
]
),
"5b05a0c349b9c6091e7bd6329a64e2a0e1960a1867471896458de79ca996f2d3"
);
assert_eq!(
migration_checksum(1, "init", &[]),
"6408373a4372a2c49859db2a4548ea43308e5ba7dd3609998ca376606cf09757"
);
assert_eq!(
migration_checksum(
3,
"alter_payload_type",
&[MigrationOp::AlterColumn {
table: "weather_cache".into(),
column: "payload_json".into()
}]
),
"eabab2122bc784d989e7b368e93f68d1ba1c08ec82ddd1aa132a94eaf6b5db66"
);
}
#[test]
fn checksum_changes_with_version_name_or_ops() {
let base = migration_checksum(
1,
"init",
&[MigrationOp::CreateTable {
name: "users".into(),
}],
);
assert_ne!(
base,
migration_checksum(
2,
"init",
&[MigrationOp::CreateTable {
name: "users".into()
}]
)
);
assert_ne!(
base,
migration_checksum(
1,
"other",
&[MigrationOp::CreateTable {
name: "users".into()
}]
)
);
assert_ne!(
base,
migration_checksum(
1,
"init",
&[MigrationOp::CreateTable {
name: "accounts".into()
}]
)
);
assert_ne!(
base,
migration_checksum(
1,
"init",
&[MigrationOp::DropTable {
name: "users".into()
}]
)
);
assert_ne!(base, migration_checksum(1, "init", &[]));
}
#[test]
fn checksum_covers_trigger_and_virtual_table_ops() {
let trigger = TriggerSpec::new(serde_json::json!({
"name": "users_ai",
"version": 1,
"target": { "kind": "table", "name": "users" },
"timing": "after",
"event": "insert",
"update_of": [],
"target_columns": [],
"program": { "steps": [] },
"enabled": true,
"checksum": "",
"created_epoch": 0,
"updated_epoch": 0
}));
let base = migration_checksum(4, "triggers", &[]);
let with_trigger = migration_checksum(
4,
"triggers",
&[MigrationOp::CreateTrigger {
name: "users_ai".into(),
trigger,
}],
);
let with_virtual_table = migration_checksum(
4,
"triggers",
&[MigrationOp::CreateVirtualTable {
table: VirtualTableSpec::new("docs", "fts_docs", ["prefix=1"]),
}],
);
assert_ne!(base, with_trigger);
assert_ne!(base, with_virtual_table);
assert_ne!(with_trigger, with_virtual_table);
}
#[test]
fn plan_migrations_returns_all_when_none_applied() {
let desired = vec![migration(1, "a"), migration(2, "b")];
let pending = plan_migrations(&[], &desired);
assert_eq!(pending.len(), 2);
assert_eq!(pending[0].version, 1);
assert_eq!(pending[1].version, 2);
}
#[test]
fn plan_migrations_skips_applied() {
let applied = vec![migration(1, "a")];
let desired = vec![migration(1, "a"), migration(2, "b"), migration(3, "c")];
let pending = plan_migrations(&applied, &desired);
assert_eq!(pending.len(), 2);
assert_eq!(pending[0].version, 2);
assert_eq!(pending[1].version, 3);
}
#[test]
fn plan_migrations_returns_empty_when_fully_applied() {
let migrations = vec![migration(1, "a"), migration(2, "b")];
let pending = plan_migrations(&migrations, &migrations);
assert!(pending.is_empty());
}
}