use crate::sqlite::statements::SqliteGenerator;
use crate::sqlite::{SQLiteSnapshot, SchemaDiff as SqliteSchemaDiff};
use crate::version::ORIGIN_UUID;
use crate::words::{PrefixMode, generate_migration_tag};
use drizzle_types::Dialect;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
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("snapshot.json").exists() {
Some(name)
} else {
None
}
})
.collect();
folders.sort();
Ok(folders)
}
pub fn load_previous_snapshot(&self) -> io::Result<SQLiteSnapshot> {
let migrations = self.discover_migrations()?;
let Some(last_tag) = migrations.last() else {
return Ok(SQLiteSnapshot::new());
};
let snapshot_path = self.snapshot_path(last_tag);
if snapshot_path.exists() {
SQLiteSnapshot::load(&snapshot_path)
} else {
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::words::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 folder_path = self.migration_folder_path(&tag);
fs::create_dir_all(&folder_path).map_err(|e| MigrationError::IoError(e.to_string()))?;
let sql_path = self.migration_sql_path(&tag);
fs::write(&sql_path, &sql).map_err(|e| MigrationError::IoError(e.to_string()))?;
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();
let snapshot_path = self.snapshot_path(&tag);
snapshot
.save(&snapshot_path)
.map_err(|e| MigrationError::IoError(e.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::words::generate_migration_tag_with_mode(
self.prefix_mode,
idx,
self.custom_name.as_deref(),
),
};
let folder_path = self.migration_folder_path(&tag);
fs::create_dir_all(&folder_path).map_err(|e| MigrationError::IoError(e.to_string()))?;
let sql_path = self.migration_sql_path(&tag);
let sql = "-- Custom SQL migration file, put your code below! --\n";
fs::write(&sql_path, sql).map_err(|e| MigrationError::IoError(e.to_string()))?;
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();
let snapshot_path = self.snapshot_path(&tag);
snapshot
.save(&snapshot_path)
.map_err(|e| MigrationError::IoError(e.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,
}