Skip to main content

siderite_migrations/
migration.rs

1//! A single migration file: id, dependencies, operations, checksum.
2
3use crate::error::MigrationError;
4use crate::hash::fnv1a_hex;
5use crate::operation::Operation;
6use serde::{Deserialize, Serialize};
7
8/// On-disk (and in-memory) migration.
9///
10/// `id` is a zero-padded sequence plus a slug (`0001_initial`). `checksum` is
11/// a 16-digit hex FNV-1a of the canonical JSON of id, dependencies,
12/// operations, atomic and replaces (the checksum field itself is excluded).
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Migration {
15    /// `0001_initial`.
16    pub id: String,
17    /// Optional app label. Single-app projects leave this unset.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub app: Option<String>,
20    /// Ids of migrations that must be applied first.
21    #[serde(default)]
22    pub dependencies: Vec<String>,
23    /// Ordered operations.
24    pub operations: Vec<Operation>,
25    /// Wrap execution in a transaction when the backend supports it.
26    ///
27    /// No effect on SQLite: the whole `migrate`/`rollback` run is already
28    /// one transaction there (the lock), so a run is all-or-nothing. MySQL
29    /// never wraps DDL regardless of this flag.
30    #[serde(default = "default_true")]
31    pub atomic: bool,
32    /// Ids this squash replaces. The loader treats this migration as applied
33    /// when every replaced id is in the history table.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub replaces: Vec<String>,
36    /// Content checksum (see module docs).
37    pub checksum: String,
38}
39
40fn default_true() -> bool {
41    true
42}
43
44#[derive(Serialize)]
45struct ChecksumBody<'a> {
46    id: &'a str,
47    dependencies: &'a [String],
48    operations: &'a [Operation],
49    atomic: bool,
50    replaces: &'a [String],
51}
52
53impl Migration {
54    /// Build a migration and fill in its checksum.
55    ///
56    /// # Errors
57    /// JSON encoding of the checksum body (does not fail for these types).
58    pub fn new(
59        id: impl Into<String>,
60        dependencies: Vec<String>,
61        operations: Vec<Operation>,
62        atomic: bool,
63        replaces: Vec<String>,
64    ) -> Result<Self, MigrationError> {
65        let mut migration = Self {
66            id: id.into(),
67            app: None,
68            dependencies,
69            operations,
70            atomic,
71            replaces,
72            checksum: String::new(),
73        };
74        migration.checksum = migration.compute_checksum()?;
75        Ok(migration)
76    }
77
78    /// FNV-1a of the canonical checksum body.
79    ///
80    /// # Errors
81    /// JSON encoding failure.
82    pub fn compute_checksum(&self) -> Result<String, MigrationError> {
83        let body = ChecksumBody {
84            id: &self.id,
85            dependencies: &self.dependencies,
86            operations: &self.operations,
87            atomic: self.atomic,
88            replaces: &self.replaces,
89        };
90        let bytes = serde_json::to_vec(&body)?;
91        Ok(fnv1a_hex(&bytes))
92    }
93
94    /// Refresh [`checksum`](Self::checksum) from current fields.
95    ///
96    /// # Errors
97    /// JSON encoding failure.
98    pub fn refresh_checksum(&mut self) -> Result<(), MigrationError> {
99        self.checksum = self.compute_checksum()?;
100        Ok(())
101    }
102
103    /// Whether any operation is unconditionally irreversible.
104    pub fn is_irreversible(&self) -> bool {
105        self.operations
106            .iter()
107            .any(Operation::is_unconditionally_irreversible)
108    }
109
110    /// Leading sequence number from `0001_initial` → `1`. `None` if the id
111    /// does not start with digits.
112    pub fn sequence(&self) -> Option<u32> {
113        parse_sequence(&self.id)
114    }
115}
116
117/// Leading decimal digits of a migration id.
118pub fn parse_sequence(id: &str) -> Option<u32> {
119    let digits: String = id.chars().take_while(|c| c.is_ascii_digit()).collect();
120    if digits.is_empty() {
121        None
122    } else {
123        digits.parse().ok()
124    }
125}
126
127/// Next id: max existing sequence + 1, zero-padded to at least 4 digits, plus `slug`.
128pub fn next_id(existing: &[Migration], slug: &str) -> String {
129    let max = existing
130        .iter()
131        .filter_map(Migration::sequence)
132        .max()
133        .unwrap_or(0);
134    let slug = sanitize_slug(slug);
135    format!("{:04}_{slug}", max.saturating_add(1))
136}
137
138/// Keep ASCII alphanumeric and underscores; everything else becomes `_`.
139pub fn sanitize_slug(slug: &str) -> String {
140    let mut out = String::new();
141    let mut last_underscore = false;
142    for ch in slug.chars() {
143        let ok = ch.is_ascii_alphanumeric() || ch == '_';
144        if ok {
145            out.push(ch.to_ascii_lowercase());
146            last_underscore = ch == '_';
147        } else if !last_underscore && !out.is_empty() {
148            out.push('_');
149            last_underscore = true;
150        }
151    }
152    let out = out.trim_matches('_').to_owned();
153    if out.is_empty() {
154        "auto".to_owned()
155    } else {
156        out
157    }
158}
159
160/// Slug derived from the first operation (`initial`, `add_book_isbn`, …).
161pub fn slug_from_operations(operations: &[Operation], is_first: bool) -> String {
162    if operations.is_empty() {
163        return "empty".to_owned();
164    }
165    if is_first
166        && operations
167            .iter()
168            .all(|op| matches!(op, Operation::CreateModel { .. }))
169    {
170        return "initial".to_owned();
171    }
172    match &operations[0] {
173        Operation::CreateModel { model } => format!("create_{}", snake(&model.name)),
174        Operation::DeleteModel { name } => format!("delete_{}", snake(name)),
175        Operation::RenameModel { new_name, .. } => format!("rename_{}", snake(new_name)),
176        Operation::AddField { model, field } => {
177            format!("add_{}_{}", snake(model), snake(&field.name))
178        }
179        Operation::RemoveField { model, name } => {
180            format!("remove_{}_{}", snake(model), snake(name))
181        }
182        Operation::AlterField { model, name, .. } => {
183            format!("alter_{}_{}", snake(model), snake(name))
184        }
185        Operation::RenameField {
186            model, new_name, ..
187        } => format!("rename_{}_{}", snake(model), snake(new_name)),
188        Operation::CreateIndex { model, index } => {
189            format!("index_{}_{}", snake(model), snake(&index.name))
190        }
191        Operation::DeleteIndex { model, name } => {
192            format!("drop_index_{}_{}", snake(model), snake(name))
193        }
194        Operation::AddConstraint { model, constraint } => {
195            format!("constraint_{}_{}", snake(model), snake(constraint.name()))
196        }
197        Operation::DeleteConstraint { model, name } => {
198            format!("drop_constraint_{}_{}", snake(model), snake(name))
199        }
200        Operation::RunSQL { .. } => "run_sql".to_owned(),
201        Operation::RunRust { name, .. } => format!("run_{}", snake(name)),
202    }
203}
204
205fn snake(name: &str) -> String {
206    let mut out = String::new();
207    for (i, ch) in name.chars().enumerate() {
208        if ch.is_uppercase() {
209            if i > 0 {
210                out.push('_');
211            }
212            out.extend(ch.to_lowercase());
213        } else if ch == '-' {
214            out.push('_');
215        } else {
216            out.push(ch);
217        }
218    }
219    sanitize_slug(&out)
220}