1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6use crate::procedure::ProcedureSpec;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum MigrationOp {
12 CreateTable {
13 name: String,
14 },
15 DropTable {
16 name: String,
17 },
18 AddColumn {
19 table: String,
20 column: String,
21 },
22 DropColumn {
23 table: String,
24 column: String,
25 },
26 AlterColumn {
27 table: String,
28 column: String,
29 },
30 AddIndex {
31 table: String,
32 index: String,
33 },
34 DropIndex {
35 table: String,
36 index: String,
37 },
38 AddUnique {
39 table: String,
40 constraint: String,
41 },
42 DropUnique {
43 table: String,
44 constraint: String,
45 },
46 AddForeignKey {
47 table: String,
48 constraint: String,
49 },
50 DropForeignKey {
51 table: String,
52 constraint: String,
53 },
54 AddCheck {
55 table: String,
56 constraint: String,
57 },
58 DropCheck {
59 table: String,
60 constraint: String,
61 },
62 CreateProcedure {
63 name: String,
64 procedure: ProcedureSpec,
65 },
66 ReplaceProcedure {
67 name: String,
68 procedure: ProcedureSpec,
69 },
70 DropProcedure {
71 name: String,
72 },
73 RawSql(String),
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Migration {
79 pub version: i64,
80 pub name: String,
81 pub ops: Vec<MigrationOp>,
82}
83
84fn json_string(value: &str) -> String {
88 serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
89}
90
91fn canonical_op(op: &MigrationOp) -> String {
98 match op {
99 MigrationOp::CreateTable { name } => {
100 format!(r#"{{"op":"create_table","name":{}}}"#, json_string(name))
101 }
102 MigrationOp::DropTable { name } => {
103 format!(r#"{{"op":"drop_table","name":{}}}"#, json_string(name))
104 }
105 MigrationOp::AddColumn { table, column } => format!(
106 r#"{{"op":"add_column","table":{},"column":{}}}"#,
107 json_string(table),
108 json_string(column)
109 ),
110 MigrationOp::DropColumn { table, column } => format!(
111 r#"{{"op":"drop_column","table":{},"column":{}}}"#,
112 json_string(table),
113 json_string(column)
114 ),
115 MigrationOp::AlterColumn { table, column } => format!(
116 r#"{{"op":"alter_column","table":{},"column":{}}}"#,
117 json_string(table),
118 json_string(column)
119 ),
120 MigrationOp::AddIndex { table, index } => format!(
121 r#"{{"op":"add_index","table":{},"index":{}}}"#,
122 json_string(table),
123 json_string(index)
124 ),
125 MigrationOp::DropIndex { table, index } => format!(
126 r#"{{"op":"drop_index","table":{},"index":{}}}"#,
127 json_string(table),
128 json_string(index)
129 ),
130 MigrationOp::AddUnique { table, constraint } => format!(
131 r#"{{"op":"add_unique","table":{},"constraint":{}}}"#,
132 json_string(table),
133 json_string(constraint)
134 ),
135 MigrationOp::DropUnique { table, constraint } => format!(
136 r#"{{"op":"drop_unique","table":{},"constraint":{}}}"#,
137 json_string(table),
138 json_string(constraint)
139 ),
140 MigrationOp::AddForeignKey { table, constraint } => format!(
141 r#"{{"op":"add_foreign_key","table":{},"constraint":{}}}"#,
142 json_string(table),
143 json_string(constraint)
144 ),
145 MigrationOp::DropForeignKey { table, constraint } => format!(
146 r#"{{"op":"drop_foreign_key","table":{},"constraint":{}}}"#,
147 json_string(table),
148 json_string(constraint)
149 ),
150 MigrationOp::AddCheck { table, constraint } => format!(
151 r#"{{"op":"add_check","table":{},"constraint":{}}}"#,
152 json_string(table),
153 json_string(constraint)
154 ),
155 MigrationOp::DropCheck { table, constraint } => format!(
156 r#"{{"op":"drop_check","table":{},"constraint":{}}}"#,
157 json_string(table),
158 json_string(constraint)
159 ),
160 MigrationOp::CreateProcedure { name, procedure } => format!(
161 r#"{{"op":"create_procedure","name":{},"procedure":{}}}"#,
162 json_string(name),
163 procedure.canonical_json()
164 ),
165 MigrationOp::ReplaceProcedure { name, procedure } => format!(
166 r#"{{"op":"replace_procedure","name":{},"procedure":{}}}"#,
167 json_string(name),
168 procedure.canonical_json()
169 ),
170 MigrationOp::DropProcedure { name } => {
171 format!(r#"{{"op":"drop_procedure","name":{}}}"#, json_string(name))
172 }
173 MigrationOp::RawSql(sql) => {
174 format!(r#"{{"op":"raw_sql","sql":{}}}"#, json_string(sql))
175 }
176 }
177}
178
179fn canonical_content(version: i64, name: &str, ops: &[MigrationOp]) -> String {
185 let ops_json: Vec<String> = ops.iter().map(canonical_op).collect();
186 format!(
187 r#"{{"version":{},"name":{},"ops":[{}]}}"#,
188 version,
189 json_string(name),
190 ops_json.join(",")
191 )
192}
193
194pub fn migration_checksum(version: i64, name: &str, ops: &[MigrationOp]) -> String {
202 let mut hasher = Sha256::new();
203 hasher.update(canonical_content(version, name, ops).as_bytes());
204 hex::encode(hasher.finalize())
205}
206
207impl Migration {
209 pub fn checksum(&self) -> String {
210 migration_checksum(self.version, &self.name, &self.ops)
211 }
212}
213
214pub fn plan_migrations<'a>(applied: &[Migration], desired: &'a [Migration]) -> Vec<&'a Migration> {
223 let max_applied = applied.iter().map(|m| m.version).max().unwrap_or(i64::MIN);
224 let mut pending: Vec<&'a Migration> =
225 desired.iter().filter(|m| m.version > max_applied).collect();
226 pending.sort_by_key(|m| m.version);
227 pending
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 fn migration(version: i64, name: &str) -> Migration {
235 Migration {
236 version,
237 name: name.into(),
238 ops: vec![MigrationOp::CreateTable { name: name.into() }],
239 }
240 }
241
242 #[test]
243 fn checksum_is_stable_and_matches_typescript() {
244 assert_eq!(
248 migration_checksum(
249 1,
250 "init",
251 &[MigrationOp::CreateTable {
252 name: "users".into()
253 }]
254 ),
255 "fe2f521793591207bd4d8645c2631e4b7ce43e30fe7ea5691a2846c74ea71cc3"
256 );
257 assert_eq!(
259 migration_checksum(
260 2,
261 "add_email",
262 &[
263 MigrationOp::AddColumn {
264 table: "users".into(),
265 column: "email".into()
266 },
267 MigrationOp::AddUnique {
268 table: "users".into(),
269 constraint: "uq_email".into()
270 }
271 ]
272 ),
273 "5b05a0c349b9c6091e7bd6329a64e2a0e1960a1867471896458de79ca996f2d3"
274 );
275 assert_eq!(
277 migration_checksum(1, "init", &[]),
278 "6408373a4372a2c49859db2a4548ea43308e5ba7dd3609998ca376606cf09757"
279 );
280 assert_eq!(
282 migration_checksum(
283 3,
284 "alter_payload_type",
285 &[MigrationOp::AlterColumn {
286 table: "weather_cache".into(),
287 column: "payload_json".into()
288 }]
289 ),
290 "eabab2122bc784d989e7b368e93f68d1ba1c08ec82ddd1aa132a94eaf6b5db66"
291 );
292 }
293
294 #[test]
295 fn checksum_changes_with_version_name_or_ops() {
296 let base = migration_checksum(
297 1,
298 "init",
299 &[MigrationOp::CreateTable {
300 name: "users".into(),
301 }],
302 );
303 assert_ne!(
305 base,
306 migration_checksum(
307 2,
308 "init",
309 &[MigrationOp::CreateTable {
310 name: "users".into()
311 }]
312 )
313 );
314 assert_ne!(
316 base,
317 migration_checksum(
318 1,
319 "other",
320 &[MigrationOp::CreateTable {
321 name: "users".into()
322 }]
323 )
324 );
325 assert_ne!(
327 base,
328 migration_checksum(
329 1,
330 "init",
331 &[MigrationOp::CreateTable {
332 name: "accounts".into()
333 }]
334 )
335 );
336 assert_ne!(
338 base,
339 migration_checksum(
340 1,
341 "init",
342 &[MigrationOp::DropTable {
343 name: "users".into()
344 }]
345 )
346 );
347 assert_ne!(base, migration_checksum(1, "init", &[]));
349 }
350
351 #[test]
352 fn plan_migrations_returns_all_when_none_applied() {
353 let desired = vec![migration(1, "a"), migration(2, "b")];
354 let pending = plan_migrations(&[], &desired);
355 assert_eq!(pending.len(), 2);
356 assert_eq!(pending[0].version, 1);
357 assert_eq!(pending[1].version, 2);
358 }
359
360 #[test]
361 fn plan_migrations_skips_applied() {
362 let applied = vec![migration(1, "a")];
363 let desired = vec![migration(1, "a"), migration(2, "b"), migration(3, "c")];
364 let pending = plan_migrations(&applied, &desired);
365 assert_eq!(pending.len(), 2);
366 assert_eq!(pending[0].version, 2);
367 assert_eq!(pending[1].version, 3);
368 }
369
370 #[test]
371 fn plan_migrations_returns_empty_when_fully_applied() {
372 let migrations = vec![migration(1, "a"), migration(2, "b")];
373 let pending = plan_migrations(&migrations, &migrations);
374 assert!(pending.is_empty());
375 }
376}