1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum MigrationOp {
10 CreateTable { name: String },
11 DropTable { name: String },
12 AddColumn { table: String, column: String },
13 DropColumn { table: String, column: String },
14 AlterColumn { table: String, column: String },
15 AddIndex { table: String, index: String },
16 DropIndex { table: String, index: String },
17 AddUnique { table: String, constraint: String },
18 DropUnique { table: String, constraint: String },
19 AddForeignKey { table: String, constraint: String },
20 DropForeignKey { table: String, constraint: String },
21 AddCheck { table: String, constraint: String },
22 DropCheck { table: String, constraint: String },
23 RawSql(String),
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Migration {
29 pub version: i64,
30 pub name: String,
31 pub ops: Vec<MigrationOp>,
32}
33
34fn json_string(value: &str) -> String {
38 serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
39}
40
41fn canonical_op(op: &MigrationOp) -> String {
48 match op {
49 MigrationOp::CreateTable { name } => {
50 format!(r#"{{"op":"create_table","name":{}}}"#, json_string(name))
51 }
52 MigrationOp::DropTable { name } => {
53 format!(r#"{{"op":"drop_table","name":{}}}"#, json_string(name))
54 }
55 MigrationOp::AddColumn { table, column } => format!(
56 r#"{{"op":"add_column","table":{},"column":{}}}"#,
57 json_string(table),
58 json_string(column)
59 ),
60 MigrationOp::DropColumn { table, column } => format!(
61 r#"{{"op":"drop_column","table":{},"column":{}}}"#,
62 json_string(table),
63 json_string(column)
64 ),
65 MigrationOp::AlterColumn { table, column } => format!(
66 r#"{{"op":"alter_column","table":{},"column":{}}}"#,
67 json_string(table),
68 json_string(column)
69 ),
70 MigrationOp::AddIndex { table, index } => format!(
71 r#"{{"op":"add_index","table":{},"index":{}}}"#,
72 json_string(table),
73 json_string(index)
74 ),
75 MigrationOp::DropIndex { table, index } => format!(
76 r#"{{"op":"drop_index","table":{},"index":{}}}"#,
77 json_string(table),
78 json_string(index)
79 ),
80 MigrationOp::AddUnique { table, constraint } => format!(
81 r#"{{"op":"add_unique","table":{},"constraint":{}}}"#,
82 json_string(table),
83 json_string(constraint)
84 ),
85 MigrationOp::DropUnique { table, constraint } => format!(
86 r#"{{"op":"drop_unique","table":{},"constraint":{}}}"#,
87 json_string(table),
88 json_string(constraint)
89 ),
90 MigrationOp::AddForeignKey { table, constraint } => format!(
91 r#"{{"op":"add_foreign_key","table":{},"constraint":{}}}"#,
92 json_string(table),
93 json_string(constraint)
94 ),
95 MigrationOp::DropForeignKey { table, constraint } => format!(
96 r#"{{"op":"drop_foreign_key","table":{},"constraint":{}}}"#,
97 json_string(table),
98 json_string(constraint)
99 ),
100 MigrationOp::AddCheck { table, constraint } => format!(
101 r#"{{"op":"add_check","table":{},"constraint":{}}}"#,
102 json_string(table),
103 json_string(constraint)
104 ),
105 MigrationOp::DropCheck { table, constraint } => format!(
106 r#"{{"op":"drop_check","table":{},"constraint":{}}}"#,
107 json_string(table),
108 json_string(constraint)
109 ),
110 MigrationOp::RawSql(sql) => {
111 format!(r#"{{"op":"raw_sql","sql":{}}}"#, json_string(sql))
112 }
113 }
114}
115
116fn canonical_content(version: i64, name: &str, ops: &[MigrationOp]) -> String {
122 let ops_json: Vec<String> = ops.iter().map(canonical_op).collect();
123 format!(
124 r#"{{"version":{},"name":{},"ops":[{}]}}"#,
125 version,
126 json_string(name),
127 ops_json.join(",")
128 )
129}
130
131pub fn migration_checksum(version: i64, name: &str, ops: &[MigrationOp]) -> String {
139 let mut hasher = Sha256::new();
140 hasher.update(canonical_content(version, name, ops).as_bytes());
141 hex::encode(hasher.finalize())
142}
143
144impl Migration {
146 pub fn checksum(&self) -> String {
147 migration_checksum(self.version, &self.name, &self.ops)
148 }
149}
150
151pub fn plan_migrations<'a>(applied: &[Migration], desired: &'a [Migration]) -> Vec<&'a Migration> {
160 let max_applied = applied.iter().map(|m| m.version).max().unwrap_or(i64::MIN);
161 let mut pending: Vec<&'a Migration> =
162 desired.iter().filter(|m| m.version > max_applied).collect();
163 pending.sort_by_key(|m| m.version);
164 pending
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 fn migration(version: i64, name: &str) -> Migration {
172 Migration {
173 version,
174 name: name.into(),
175 ops: vec![MigrationOp::CreateTable { name: name.into() }],
176 }
177 }
178
179 #[test]
180 fn checksum_is_stable_and_matches_typescript() {
181 assert_eq!(
185 migration_checksum(
186 1,
187 "init",
188 &[MigrationOp::CreateTable {
189 name: "users".into()
190 }]
191 ),
192 "fe2f521793591207bd4d8645c2631e4b7ce43e30fe7ea5691a2846c74ea71cc3"
193 );
194 assert_eq!(
196 migration_checksum(
197 2,
198 "add_email",
199 &[
200 MigrationOp::AddColumn {
201 table: "users".into(),
202 column: "email".into()
203 },
204 MigrationOp::AddUnique {
205 table: "users".into(),
206 constraint: "uq_email".into()
207 }
208 ]
209 ),
210 "5b05a0c349b9c6091e7bd6329a64e2a0e1960a1867471896458de79ca996f2d3"
211 );
212 assert_eq!(
214 migration_checksum(1, "init", &[]),
215 "6408373a4372a2c49859db2a4548ea43308e5ba7dd3609998ca376606cf09757"
216 );
217 assert_eq!(
219 migration_checksum(
220 3,
221 "alter_payload_type",
222 &[MigrationOp::AlterColumn {
223 table: "weather_cache".into(),
224 column: "payload_json".into()
225 }]
226 ),
227 "eabab2122bc784d989e7b368e93f68d1ba1c08ec82ddd1aa132a94eaf6b5db66"
228 );
229 }
230
231 #[test]
232 fn checksum_changes_with_version_name_or_ops() {
233 let base = migration_checksum(
234 1,
235 "init",
236 &[MigrationOp::CreateTable {
237 name: "users".into(),
238 }],
239 );
240 assert_ne!(
242 base,
243 migration_checksum(
244 2,
245 "init",
246 &[MigrationOp::CreateTable {
247 name: "users".into()
248 }]
249 )
250 );
251 assert_ne!(
253 base,
254 migration_checksum(
255 1,
256 "other",
257 &[MigrationOp::CreateTable {
258 name: "users".into()
259 }]
260 )
261 );
262 assert_ne!(
264 base,
265 migration_checksum(
266 1,
267 "init",
268 &[MigrationOp::CreateTable {
269 name: "accounts".into()
270 }]
271 )
272 );
273 assert_ne!(
275 base,
276 migration_checksum(
277 1,
278 "init",
279 &[MigrationOp::DropTable {
280 name: "users".into()
281 }]
282 )
283 );
284 assert_ne!(base, migration_checksum(1, "init", &[]));
286 }
287
288 #[test]
289 fn plan_migrations_returns_all_when_none_applied() {
290 let desired = vec![migration(1, "a"), migration(2, "b")];
291 let pending = plan_migrations(&[], &desired);
292 assert_eq!(pending.len(), 2);
293 assert_eq!(pending[0].version, 1);
294 assert_eq!(pending[1].version, 2);
295 }
296
297 #[test]
298 fn plan_migrations_skips_applied() {
299 let applied = vec![migration(1, "a")];
300 let desired = vec![migration(1, "a"), migration(2, "b"), migration(3, "c")];
301 let pending = plan_migrations(&applied, &desired);
302 assert_eq!(pending.len(), 2);
303 assert_eq!(pending[0].version, 2);
304 assert_eq!(pending[1].version, 3);
305 }
306
307 #[test]
308 fn plan_migrations_returns_empty_when_fully_applied() {
309 let migrations = vec![migration(1, "a"), migration(2, "b")];
310 let pending = plan_migrations(&migrations, &migrations);
311 assert!(pending.is_empty());
312 }
313}