use drizzle_types::Dialect;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct Migration {
tag: String,
hash: String,
created_at: i64,
sql: Vec<String>,
}
impl Migration {
pub fn new(tag: &str, sql: &str) -> Self {
let hash = compute_hash(sql);
let created_at = parse_timestamp_from_tag(tag);
let statements = split_statements(sql);
Self {
tag: tag.to_string(),
hash,
created_at,
sql: statements,
}
}
pub fn with_hash(
tag: impl Into<String>,
hash: impl Into<String>,
created_at: i64,
sql: Vec<String>,
) -> Self {
Self {
tag: tag.into(),
hash: hash.into(),
created_at,
sql,
}
}
#[inline]
pub fn tag(&self) -> &str {
&self.tag
}
#[inline]
pub fn hash(&self) -> &str {
&self.hash
}
#[inline]
pub fn created_at(&self) -> i64 {
self.created_at
}
#[inline]
pub fn statements(&self) -> &[String] {
&self.sql
}
#[inline]
pub fn is_empty(&self) -> bool {
self.sql.is_empty() || self.sql.iter().all(|s| s.trim().is_empty())
}
}
#[derive(Debug, Clone)]
pub struct MigrationSet {
migrations: Vec<Migration>,
dialect: Dialect,
table: String,
schema: Option<String>,
}
impl MigrationSet {
pub fn new(migrations: Vec<Migration>, dialect: Dialect) -> Self {
Self {
migrations,
dialect,
table: "__drizzle_migrations".to_string(),
schema: match dialect {
Dialect::PostgreSQL => Some("drizzle".to_string()),
_ => None,
},
}
}
pub fn empty(dialect: Dialect) -> Self {
Self::new(Vec::new(), dialect)
}
pub fn with_table(mut self, table: impl Into<String>) -> Self {
self.table = table.into();
self
}
pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
self.schema = Some(schema.into());
self
}
pub fn from_dir(dir: impl AsRef<Path>, dialect: Dialect) -> Result<Self, MigratorError> {
let dir = dir.as_ref();
if !dir.exists() {
return Ok(Self::empty(dialect));
}
let v3_migrations = discover_v3_migrations(dir)?;
if !v3_migrations.is_empty() {
return Ok(Self::new(v3_migrations, dialect));
}
Self::from_dir_legacy(dir, dialect)
}
pub fn from_dir_legacy(dir: impl AsRef<Path>, dialect: Dialect) -> Result<Self, MigratorError> {
use crate::journal::Journal;
use std::fs;
let dir = dir.as_ref();
let journal_path = dir.join("meta").join("_journal.json");
if !journal_path.exists() {
return Ok(Self::empty(dialect));
}
let journal =
Journal::load(&journal_path).map_err(|e| MigratorError::JournalError(e.to_string()))?;
let mut migrations = Vec::with_capacity(journal.entries.len());
for entry in &journal.entries {
let folder_path = dir.join(&entry.tag).join("migration.sql");
let flat_path = dir.join(format!("{}.sql", entry.tag));
let sql_path = if folder_path.exists() {
folder_path
} else if flat_path.exists() {
flat_path
} else {
return Err(MigratorError::MissingMigration(entry.tag.clone()));
};
let sql_content =
fs::read_to_string(&sql_path).map_err(|e| MigratorError::IoError(e.to_string()))?;
let hash = compute_hash(&sql_content);
let statements = split_statements(&sql_content);
migrations.push(Migration {
tag: entry.tag.clone(),
hash,
created_at: entry.when as i64,
sql: statements,
});
}
Ok(Self::new(migrations, dialect))
}
#[inline]
pub fn all(&self) -> &[Migration] {
&self.migrations
}
pub fn pending<'a>(&'a self, applied_hashes: &[String]) -> impl Iterator<Item = &'a Migration> {
self.migrations
.iter()
.filter(move |m| !applied_hashes.contains(&m.hash))
}
pub fn has_pending(&self, applied_hashes: &[String]) -> bool {
self.migrations
.iter()
.any(|m| !applied_hashes.contains(&m.hash))
}
#[inline]
pub fn dialect(&self) -> Dialect {
self.dialect
}
fn table_ident(&self) -> String {
match (&self.dialect, &self.schema) {
(Dialect::PostgreSQL, Some(schema)) => format!("\"{}\".\"{}\",", schema, self.table),
(Dialect::MySQL, _) => format!("`{}`", self.table),
_ => format!("\"{}\"", self.table),
}
}
pub fn create_schema_sql(&self) -> Option<String> {
self.schema
.as_ref()
.map(|schema| format!("CREATE SCHEMA IF NOT EXISTS \"{}\";", schema))
}
pub fn create_table_sql(&self) -> String {
let table = self.table_ident();
match self.dialect {
Dialect::SQLite => format!(
r#"CREATE TABLE IF NOT EXISTS {} (
id INTEGER PRIMARY KEY,
hash TEXT NOT NULL,
created_at INTEGER
);"#,
table
),
Dialect::PostgreSQL => format!(
r#"CREATE TABLE IF NOT EXISTS {} (
id SERIAL PRIMARY KEY,
hash TEXT NOT NULL,
created_at BIGINT
);"#,
table
),
Dialect::MySQL => format!(
r#"CREATE TABLE IF NOT EXISTS {} (
id INT PRIMARY KEY AUTO_INCREMENT,
hash VARCHAR(255) NOT NULL,
created_at BIGINT
);"#,
table
),
}
}
pub fn record_migration_sql(&self, hash: &str, created_at: i64) -> String {
let table = self.table_ident();
match self.dialect {
Dialect::SQLite | Dialect::PostgreSQL => {
format!(
r#"INSERT INTO {} ("hash", "created_at") VALUES ('{}', {});"#,
table, hash, created_at
)
}
Dialect::MySQL => {
format!(
r#"INSERT INTO {} (`hash`, `created_at`) VALUES ('{}', {});"#,
table, hash, created_at
)
}
}
}
pub fn query_last_applied_sql(&self) -> String {
let table = self.table_ident();
match self.dialect {
Dialect::SQLite | Dialect::PostgreSQL => {
format!(
r#"SELECT id, hash, created_at FROM {} ORDER BY created_at DESC LIMIT 1;"#,
table
)
}
Dialect::MySQL => {
format!(
r#"SELECT id, hash, created_at FROM {} ORDER BY created_at DESC LIMIT 1;"#,
table
)
}
}
}
pub fn query_all_hashes_sql(&self) -> String {
let table = self.table_ident();
format!(r#"SELECT hash FROM {} ORDER BY id;"#, table)
}
pub fn table_exists_sql(&self) -> String {
match self.dialect {
Dialect::SQLite => format!(
"SELECT name FROM sqlite_master WHERE type='table' AND name='{}';",
self.table
),
Dialect::PostgreSQL => {
if let Some(ref schema) = self.schema {
format!(
"SELECT table_name FROM information_schema.tables WHERE table_schema='{}' AND table_name='{}';",
schema, self.table
)
} else {
format!(
"SELECT table_name FROM information_schema.tables WHERE table_name='{}';",
self.table
)
}
}
Dialect::MySQL => format!(
"SELECT table_name FROM information_schema.tables WHERE table_name='{}';",
self.table
),
}
}
}
fn discover_v3_migrations(dir: &Path) -> Result<Vec<Migration>, MigratorError> {
use std::fs;
if !dir.exists() {
return Ok(Vec::new());
}
let mut entries: Vec<_> = fs::read_dir(dir)
.map_err(|e| MigratorError::IoError(e.to_string()))?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|entry| {
let folder_name = entry.file_name().to_string_lossy().to_string();
let snapshot_path = entry.path().join("snapshot.json");
let migration_path = entry.path().join("migration.sql");
if snapshot_path.exists() && migration_path.exists() {
Some((folder_name, migration_path))
} else {
None
}
})
.collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut migrations = Vec::with_capacity(entries.len());
for (tag, sql_path) in entries {
let sql_content =
fs::read_to_string(&sql_path).map_err(|e| MigratorError::IoError(e.to_string()))?;
let hash = compute_hash(&sql_content);
let created_at = parse_timestamp_from_tag(&tag);
let statements = split_statements(&sql_content);
migrations.push(Migration {
tag,
hash,
created_at,
sql: statements,
});
}
Ok(migrations)
}
#[derive(Debug, thiserror::Error)]
pub enum MigratorError {
#[error("Journal error: {0}")]
JournalError(String),
#[error("IO error: {0}")]
IoError(String),
#[error("Missing migration file: {0}")]
MissingMigration(String),
#[error("Migration failed: {0}")]
ExecutionError(String),
}
fn compute_hash(sql: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
sql.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn split_statements(sql: &str) -> Vec<String> {
if sql.contains("--> statement-breakpoint") {
sql.split("--> statement-breakpoint")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else {
split_on_semicolons(sql)
}
}
fn split_on_semicolons(sql: &str) -> Vec<String> {
let mut statements = Vec::new();
let mut current = String::new();
let mut in_string = false;
let mut string_char = ' ';
for ch in sql.chars() {
match ch {
'\'' | '"' if !in_string => {
in_string = true;
string_char = ch;
current.push(ch);
}
c if in_string && c == string_char => {
in_string = false;
current.push(ch);
}
';' if !in_string => {
let stmt = current.trim().to_string();
if !stmt.is_empty() {
statements.push(stmt);
}
current.clear();
}
_ => current.push(ch),
}
}
let stmt = current.trim().to_string();
if !stmt.is_empty() {
statements.push(stmt);
}
statements
}
fn parse_timestamp_from_tag(tag: &str) -> i64 {
if tag.len() >= 14
&& let Ok(ts) = tag[0..14].parse::<i64>()
{
return ts;
}
if tag.len() >= 4
&& let Ok(idx) = tag[0..4].parse::<i64>()
{
return idx;
}
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[macro_export]
macro_rules! migrations {
[$(($tag:expr, $sql:expr)),* $(,)?] => {
vec![
$(
$crate::Migration::new($tag, $sql),
)*
]
};
}