siderite_migrations/
migration.rs1use crate::error::MigrationError;
4use crate::hash::fnv1a_hex;
5use crate::operation::Operation;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Migration {
15 pub id: String,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub app: Option<String>,
20 #[serde(default)]
22 pub dependencies: Vec<String>,
23 pub operations: Vec<Operation>,
25 #[serde(default = "default_true")]
31 pub atomic: bool,
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub replaces: Vec<String>,
36 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 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 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 pub fn refresh_checksum(&mut self) -> Result<(), MigrationError> {
99 self.checksum = self.compute_checksum()?;
100 Ok(())
101 }
102
103 pub fn is_irreversible(&self) -> bool {
105 self.operations
106 .iter()
107 .any(Operation::is_unconditionally_irreversible)
108 }
109
110 pub fn sequence(&self) -> Option<u32> {
113 parse_sequence(&self.id)
114 }
115}
116
117pub 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
127pub 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
138pub 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
160pub 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}