use crate::naming::{PrefixMode, generate_migration_tag, validate_migration_name};
use crate::sqlite::statements::Generator as SqliteGenerator;
use crate::sqlite::{SQLiteSnapshot, SchemaDiff as SqliteSchemaDiff};
use crate::version::ORIGIN_UUID;
use drizzle_types::Dialect;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[doc(hidden)]
pub fn publish_migration_directory(
out: &Path,
tag: &str,
write: impl FnOnce(&Path) -> Result<(), MigrationError>,
) -> Result<PathBuf, MigrationError> {
validate_migration_name(tag).map_err(|error| MigrationError::ConfigError(error.to_string()))?;
fs::create_dir_all(out).map_err(|error| MigrationError::IoError(error.to_string()))?;
let destination = out.join(tag);
if destination.exists() {
return Err(MigrationError::ConfigError(format!(
"migration `{tag}` already exists"
)));
}
let staging = out.join(format!(".{tag}.{}.tmp", uuid::Uuid::new_v4()));
fs::create_dir(&staging).map_err(|error| MigrationError::IoError(error.to_string()))?;
if let Err(error) = write(&staging) {
let _ = fs::remove_dir_all(&staging);
return Err(error);
}
if destination.exists() {
let _ = fs::remove_dir_all(&staging);
return Err(MigrationError::ConfigError(format!(
"migration `{tag}` already exists"
)));
}
match fs::rename(&staging, &destination) {
Ok(()) => Ok(destination),
Err(error) => {
let _ = fs::remove_dir_all(&staging);
Err(MigrationError::IoError(error.to_string()))
}
}
}
pub struct Writer {
out: PathBuf,
dialect: Dialect,
breakpoints: bool,
prefix_mode: PrefixMode,
custom_name: Option<String>,
}
impl Writer {
pub fn new(out: impl Into<PathBuf>, dialect: Dialect) -> Self {
Self {
out: out.into(),
dialect,
breakpoints: true,
prefix_mode: PrefixMode::Timestamp, custom_name: None,
}
}
#[must_use]
pub const fn with_breakpoints(mut self, enabled: bool) -> Self {
self.breakpoints = enabled;
self
}
#[must_use]
pub const fn with_prefix_mode(mut self, mode: PrefixMode) -> Self {
self.prefix_mode = mode;
self
}
#[must_use]
pub fn with_custom_name(mut self, name: impl Into<String>) -> Self {
self.custom_name = Some(name.into());
self
}
#[must_use]
pub fn migrations_dir(&self) -> &Path {
&self.out
}
#[must_use]
pub const fn dialect(&self) -> Dialect {
self.dialect
}
pub fn ensure_dirs(&self) -> io::Result<()> {
fs::create_dir_all(self.migrations_dir())?;
Ok(())
}
#[must_use]
pub fn migration_folder_path(&self, tag: &str) -> PathBuf {
self.out.join(tag)
}
#[must_use]
pub fn migration_sql_path(&self, tag: &str) -> PathBuf {
self.migration_folder_path(tag).join("migration.sql")
}
#[must_use]
pub fn snapshot_path(&self, tag: &str) -> PathBuf {
self.migration_folder_path(tag).join("snapshot.json")
}
pub fn discover_migrations(&self) -> io::Result<Vec<String>> {
if !self.out.exists() {
return Ok(Vec::new());
}
let mut folders: Vec<String> = fs::read_dir(&self.out)?
.filter_map(std::result::Result::ok)
.filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()))
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
if entry.path().join("migration.sql").exists() {
Some(name)
} else {
None
}
})
.collect();
folders.sort();
Ok(folders)
}
pub fn load_previous_snapshot(&self) -> io::Result<SQLiteSnapshot> {
let migrations = self.discover_migrations()?;
for tag in migrations.iter().rev() {
let snapshot_path = self.snapshot_path(tag);
if snapshot_path.exists() {
return SQLiteSnapshot::load(&snapshot_path);
}
}
Ok(SQLiteSnapshot::new())
}
pub fn write_sqlite_migration(
&self,
diff: &SqliteSchemaDiff,
current_snapshot: &SQLiteSnapshot,
) -> Result<String, MigrationError> {
self.ensure_dirs()
.map_err(|e| MigrationError::IoError(e.to_string()))?;
let existing = self
.discover_migrations()
.map_err(|e| MigrationError::IoError(e.to_string()))?;
let idx = u32::try_from(existing.len()).unwrap_or(u32::MAX);
let tag = match self.prefix_mode {
PrefixMode::Timestamp => generate_migration_tag(self.custom_name.as_deref()),
_ => crate::naming::generate_migration_tag_with_mode(
self.prefix_mode,
idx,
self.custom_name.as_deref(),
),
};
let generator = SqliteGenerator::new().with_breakpoints(self.breakpoints);
let statements = generator.generate_migration(diff);
if statements.is_empty() {
return Err(MigrationError::NoChanges);
}
let sql = generator.statements_to_sql(&statements);
let mut snapshot = current_snapshot.clone();
let prev_ids = if existing.is_empty() {
vec![ORIGIN_UUID.to_string()]
} else {
let prev_snapshot = self
.load_previous_snapshot()
.map_err(|e| MigrationError::IoError(e.to_string()))?;
vec![prev_snapshot.id]
};
snapshot.prev_ids = prev_ids;
snapshot.id = uuid::Uuid::new_v4().to_string();
publish_migration_directory(&self.out, &tag, |folder| {
fs::write(folder.join("migration.sql"), &sql)
.map_err(|error| MigrationError::IoError(error.to_string()))?;
snapshot
.save(&folder.join("snapshot.json"))
.map_err(|error| MigrationError::SnapshotError(error.to_string()))
})?;
Ok(tag)
}
pub fn generate_migration_from_snapshots(
&self,
prev: &SQLiteSnapshot,
cur: &SQLiteSnapshot,
) -> Result<String, MigrationError> {
let diff = crate::sqlite::diff_snapshots(prev, cur);
if diff.is_empty() {
return Err(MigrationError::NoChanges);
}
self.write_sqlite_migration(&diff, cur)
}
pub fn write_custom_migration(&self) -> Result<String, MigrationError> {
self.ensure_dirs()
.map_err(|e| MigrationError::IoError(e.to_string()))?;
let existing = self
.discover_migrations()
.map_err(|e| MigrationError::IoError(e.to_string()))?;
let idx = u32::try_from(existing.len()).unwrap_or(u32::MAX);
let tag = match self.prefix_mode {
PrefixMode::Timestamp => generate_migration_tag(self.custom_name.as_deref()),
_ => crate::naming::generate_migration_tag_with_mode(
self.prefix_mode,
idx,
self.custom_name.as_deref(),
),
};
let prev_snapshot = self
.load_previous_snapshot()
.map_err(|e| MigrationError::IoError(e.to_string()))?;
let mut snapshot = prev_snapshot.clone();
snapshot.prev_ids = if existing.is_empty() {
vec![ORIGIN_UUID.to_string()]
} else {
vec![prev_snapshot.id]
};
snapshot.id = uuid::Uuid::new_v4().to_string();
publish_migration_directory(&self.out, &tag, |folder| {
let sql = "-- Custom SQL migration file, put your code below! --\n";
fs::write(folder.join("migration.sql"), sql)
.map_err(|error| MigrationError::IoError(error.to_string()))?;
snapshot
.save(&folder.join("snapshot.json"))
.map_err(|error| MigrationError::SnapshotError(error.to_string()))
})?;
Ok(tag)
}
}
#[derive(Debug, thiserror::Error)]
pub enum MigrationError {
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("IO error: {0}")]
IoError(String),
#[error("No schema changes detected")]
NoChanges,
#[error("Snapshot error: {0}")]
SnapshotError(String),
#[error("Dialect mismatch: cannot diff snapshots from different dialects")]
DialectMismatch,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn publish_directory_is_complete_and_refuses_collisions() {
let temp = tempfile::tempdir().expect("create temp directory");
let destination = publish_migration_directory(temp.path(), "0001_initial", |folder| {
fs::write(folder.join("migration.sql"), "SELECT 1;")
.map_err(|error| MigrationError::IoError(error.to_string()))?;
fs::write(folder.join("snapshot.json"), "{}")
.map_err(|error| MigrationError::IoError(error.to_string()))
})
.expect("publish migration");
assert!(destination.join("migration.sql").is_file());
assert!(destination.join("snapshot.json").is_file());
let error = publish_migration_directory(temp.path(), "0001_initial", |_| Ok(()))
.expect_err("collision must fail");
assert!(matches!(error, MigrationError::ConfigError(_)));
assert_eq!(
fs::read_to_string(destination.join("migration.sql")).expect("read original"),
"SELECT 1;"
);
}
#[test]
fn publish_directory_cleans_staging_after_write_failure() {
let temp = tempfile::tempdir().expect("create temp directory");
let error = publish_migration_directory(temp.path(), "0002_broken", |folder| {
fs::write(folder.join("migration.sql"), "SELECT 1;")
.map_err(|error| MigrationError::IoError(error.to_string()))?;
Err(MigrationError::SnapshotError("injected failure".into()))
})
.expect_err("write failure must propagate");
assert!(matches!(error, MigrationError::SnapshotError(_)));
assert!(!temp.path().join("0002_broken").exists());
assert_eq!(fs::read_dir(temp.path()).expect("read output").count(), 0);
}
#[test]
fn publish_directory_rejects_unsafe_tag_before_writing() {
let temp = tempfile::tempdir().expect("create temp directory");
let mut called = false;
let error = publish_migration_directory(temp.path(), "../escape", |_| {
called = true;
Ok(())
})
.expect_err("unsafe tag must fail");
assert!(!called);
assert!(matches!(error, MigrationError::ConfigError(_)));
assert!(
!temp
.path()
.parent()
.expect("parent")
.join("escape")
.exists()
);
}
}