drizzle_migrations/
writer.rs1use crate::naming::{PrefixMode, generate_migration_tag, validate_migration_name};
15use crate::sqlite::statements::Generator as SqliteGenerator;
16use crate::sqlite::{SQLiteSnapshot, SchemaDiff as SqliteSchemaDiff};
17use crate::version::ORIGIN_UUID;
18use drizzle_types::Dialect;
19
20use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23
24#[doc(hidden)]
34pub fn publish_migration_directory(
35 out: &Path,
36 tag: &str,
37 write: impl FnOnce(&Path) -> Result<(), MigrationError>,
38) -> Result<PathBuf, MigrationError> {
39 validate_migration_name(tag).map_err(|error| MigrationError::ConfigError(error.to_string()))?;
40 fs::create_dir_all(out).map_err(|error| MigrationError::IoError(error.to_string()))?;
41
42 let destination = out.join(tag);
43 if destination.exists() {
44 return Err(MigrationError::ConfigError(format!(
45 "migration `{tag}` already exists"
46 )));
47 }
48
49 let staging = out.join(format!(".{tag}.{}.tmp", uuid::Uuid::new_v4()));
50 fs::create_dir(&staging).map_err(|error| MigrationError::IoError(error.to_string()))?;
51
52 if let Err(error) = write(&staging) {
53 let _ = fs::remove_dir_all(&staging);
54 return Err(error);
55 }
56
57 if destination.exists() {
58 let _ = fs::remove_dir_all(&staging);
59 return Err(MigrationError::ConfigError(format!(
60 "migration `{tag}` already exists"
61 )));
62 }
63
64 match fs::rename(&staging, &destination) {
65 Ok(()) => Ok(destination),
66 Err(error) => {
67 let _ = fs::remove_dir_all(&staging);
68 Err(MigrationError::IoError(error.to_string()))
69 }
70 }
71}
72
73pub struct Writer {
92 out: PathBuf,
94 dialect: Dialect,
96 breakpoints: bool,
98 prefix_mode: PrefixMode,
100 custom_name: Option<String>,
102}
103
104impl Writer {
105 pub fn new(out: impl Into<PathBuf>, dialect: Dialect) -> Self {
107 Self {
108 out: out.into(),
109 dialect,
110 breakpoints: true,
111 prefix_mode: PrefixMode::Timestamp, custom_name: None,
113 }
114 }
115
116 #[must_use]
118 pub const fn with_breakpoints(mut self, enabled: bool) -> Self {
119 self.breakpoints = enabled;
120 self
121 }
122
123 #[must_use]
125 pub const fn with_prefix_mode(mut self, mode: PrefixMode) -> Self {
126 self.prefix_mode = mode;
127 self
128 }
129
130 #[must_use]
132 pub fn with_custom_name(mut self, name: impl Into<String>) -> Self {
133 self.custom_name = Some(name.into());
134 self
135 }
136
137 #[must_use]
139 pub fn migrations_dir(&self) -> &Path {
140 &self.out
141 }
142
143 #[must_use]
145 pub const fn dialect(&self) -> Dialect {
146 self.dialect
147 }
148
149 pub fn ensure_dirs(&self) -> io::Result<()> {
156 fs::create_dir_all(self.migrations_dir())?;
157 Ok(())
158 }
159
160 #[must_use]
162 pub fn migration_folder_path(&self, tag: &str) -> PathBuf {
163 self.out.join(tag)
164 }
165
166 #[must_use]
168 pub fn migration_sql_path(&self, tag: &str) -> PathBuf {
169 self.migration_folder_path(tag).join("migration.sql")
170 }
171
172 #[must_use]
174 pub fn snapshot_path(&self, tag: &str) -> PathBuf {
175 self.migration_folder_path(tag).join("snapshot.json")
176 }
177
178 pub fn discover_migrations(&self) -> io::Result<Vec<String>> {
184 if !self.out.exists() {
185 return Ok(Vec::new());
186 }
187
188 let mut folders: Vec<String> = fs::read_dir(&self.out)?
189 .filter_map(std::result::Result::ok)
190 .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()))
191 .filter_map(|entry| {
192 let name = entry.file_name().to_string_lossy().to_string();
193 if entry.path().join("migration.sql").exists() {
196 Some(name)
197 } else {
198 None
199 }
200 })
201 .collect();
202
203 folders.sort();
204 Ok(folders)
205 }
206
207 pub fn load_previous_snapshot(&self) -> io::Result<SQLiteSnapshot> {
214 let migrations = self.discover_migrations()?;
215
216 for tag in migrations.iter().rev() {
219 let snapshot_path = self.snapshot_path(tag);
220 if snapshot_path.exists() {
221 return SQLiteSnapshot::load(&snapshot_path);
222 }
223 }
224
225 Ok(SQLiteSnapshot::new())
226 }
227
228 pub fn write_sqlite_migration(
236 &self,
237 diff: &SqliteSchemaDiff,
238 current_snapshot: &SQLiteSnapshot,
239 ) -> Result<String, MigrationError> {
240 self.ensure_dirs()
242 .map_err(|e| MigrationError::IoError(e.to_string()))?;
243
244 let existing = self
246 .discover_migrations()
247 .map_err(|e| MigrationError::IoError(e.to_string()))?;
248 let idx = u32::try_from(existing.len()).unwrap_or(u32::MAX);
249
250 let tag = match self.prefix_mode {
252 PrefixMode::Timestamp => generate_migration_tag(self.custom_name.as_deref()),
253 _ => crate::naming::generate_migration_tag_with_mode(
254 self.prefix_mode,
255 idx,
256 self.custom_name.as_deref(),
257 ),
258 };
259
260 let generator = SqliteGenerator::new().with_breakpoints(self.breakpoints);
262 let statements = generator.generate_migration(diff);
263
264 if statements.is_empty() {
265 return Err(MigrationError::NoChanges);
266 }
267
268 let sql = generator.statements_to_sql(&statements);
269
270 let mut snapshot = current_snapshot.clone();
272 let prev_ids = if existing.is_empty() {
273 vec![ORIGIN_UUID.to_string()]
274 } else {
275 let prev_snapshot = self
277 .load_previous_snapshot()
278 .map_err(|e| MigrationError::IoError(e.to_string()))?;
279 vec![prev_snapshot.id]
280 };
281 snapshot.prev_ids = prev_ids;
282 snapshot.id = uuid::Uuid::new_v4().to_string();
283
284 publish_migration_directory(&self.out, &tag, |folder| {
285 fs::write(folder.join("migration.sql"), &sql)
286 .map_err(|error| MigrationError::IoError(error.to_string()))?;
287 snapshot
288 .save(&folder.join("snapshot.json"))
289 .map_err(|error| MigrationError::SnapshotError(error.to_string()))
290 })?;
291
292 Ok(tag)
293 }
294
295 pub fn generate_migration_from_snapshots(
302 &self,
303 prev: &SQLiteSnapshot,
304 cur: &SQLiteSnapshot,
305 ) -> Result<String, MigrationError> {
306 let diff = crate::sqlite::diff_snapshots(prev, cur);
307
308 if diff.is_empty() {
309 return Err(MigrationError::NoChanges);
310 }
311
312 self.write_sqlite_migration(&diff, cur)
313 }
314
315 pub fn write_custom_migration(&self) -> Result<String, MigrationError> {
322 self.ensure_dirs()
324 .map_err(|e| MigrationError::IoError(e.to_string()))?;
325
326 let existing = self
328 .discover_migrations()
329 .map_err(|e| MigrationError::IoError(e.to_string()))?;
330 let idx = u32::try_from(existing.len()).unwrap_or(u32::MAX);
331
332 let tag = match self.prefix_mode {
334 PrefixMode::Timestamp => generate_migration_tag(self.custom_name.as_deref()),
335 _ => crate::naming::generate_migration_tag_with_mode(
336 self.prefix_mode,
337 idx,
338 self.custom_name.as_deref(),
339 ),
340 };
341
342 let prev_snapshot = self
344 .load_previous_snapshot()
345 .map_err(|e| MigrationError::IoError(e.to_string()))?;
346
347 let mut snapshot = prev_snapshot.clone();
348 snapshot.prev_ids = if existing.is_empty() {
349 vec![ORIGIN_UUID.to_string()]
350 } else {
351 vec![prev_snapshot.id]
352 };
353 snapshot.id = uuid::Uuid::new_v4().to_string();
354
355 publish_migration_directory(&self.out, &tag, |folder| {
356 let sql = "-- Custom SQL migration file, put your code below! --\n";
357 fs::write(folder.join("migration.sql"), sql)
358 .map_err(|error| MigrationError::IoError(error.to_string()))?;
359 snapshot
360 .save(&folder.join("snapshot.json"))
361 .map_err(|error| MigrationError::SnapshotError(error.to_string()))
362 })?;
363
364 Ok(tag)
365 }
366}
367
368#[derive(Debug, thiserror::Error)]
374pub enum MigrationError {
375 #[error("Configuration error: {0}")]
376 ConfigError(String),
377
378 #[error("IO error: {0}")]
379 IoError(String),
380
381 #[error("No schema changes detected")]
382 NoChanges,
383
384 #[error("Snapshot error: {0}")]
385 SnapshotError(String),
386
387 #[error("Dialect mismatch: cannot diff snapshots from different dialects")]
388 DialectMismatch,
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn publish_directory_is_complete_and_refuses_collisions() {
397 let temp = tempfile::tempdir().expect("create temp directory");
398 let destination = publish_migration_directory(temp.path(), "0001_initial", |folder| {
399 fs::write(folder.join("migration.sql"), "SELECT 1;")
400 .map_err(|error| MigrationError::IoError(error.to_string()))?;
401 fs::write(folder.join("snapshot.json"), "{}")
402 .map_err(|error| MigrationError::IoError(error.to_string()))
403 })
404 .expect("publish migration");
405
406 assert!(destination.join("migration.sql").is_file());
407 assert!(destination.join("snapshot.json").is_file());
408
409 let error = publish_migration_directory(temp.path(), "0001_initial", |_| Ok(()))
410 .expect_err("collision must fail");
411 assert!(matches!(error, MigrationError::ConfigError(_)));
412 assert_eq!(
413 fs::read_to_string(destination.join("migration.sql")).expect("read original"),
414 "SELECT 1;"
415 );
416 }
417
418 #[test]
419 fn publish_directory_cleans_staging_after_write_failure() {
420 let temp = tempfile::tempdir().expect("create temp directory");
421 let error = publish_migration_directory(temp.path(), "0002_broken", |folder| {
422 fs::write(folder.join("migration.sql"), "SELECT 1;")
423 .map_err(|error| MigrationError::IoError(error.to_string()))?;
424 Err(MigrationError::SnapshotError("injected failure".into()))
425 })
426 .expect_err("write failure must propagate");
427
428 assert!(matches!(error, MigrationError::SnapshotError(_)));
429 assert!(!temp.path().join("0002_broken").exists());
430 assert_eq!(fs::read_dir(temp.path()).expect("read output").count(), 0);
431 }
432
433 #[test]
434 fn publish_directory_rejects_unsafe_tag_before_writing() {
435 let temp = tempfile::tempdir().expect("create temp directory");
436 let mut called = false;
437 let error = publish_migration_directory(temp.path(), "../escape", |_| {
438 called = true;
439 Ok(())
440 })
441 .expect_err("unsafe tag must fail");
442
443 assert!(!called);
444 assert!(matches!(error, MigrationError::ConfigError(_)));
445 assert!(
446 !temp
447 .path()
448 .parent()
449 .expect("parent")
450 .join("escape")
451 .exists()
452 );
453 }
454}