1use super::pool::DbPool;
15use super::{DbError, Value};
16
17#[derive(Debug, Clone)]
21pub struct MigrationStatus {
22 pub version: String,
24 pub applied: bool,
26 pub has_down: bool,
29}
30
31const CREATE_TABLE_SQL: &str =
34 "CREATE TABLE IF NOT EXISTS _schema_migrations \
35 (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)";
36
37async fn ensure_migrations_table(pool: &DbPool) -> Result<(), DbError> {
38 pool.execute(CREATE_TABLE_SQL, &[]).await?;
39 Ok(())
40}
41
42async fn applied_versions(pool: &DbPool) -> Result<Vec<String>, DbError> {
43 let rows = pool.query_rows("SELECT version FROM _schema_migrations", &[]).await?;
44 rows.into_iter().map(|r| r.get::<String>("version")).collect()
45}
46
47async fn last_applied_version(pool: &DbPool) -> Result<Option<String>, DbError> {
51 let rows = pool
52 .query_rows("SELECT version FROM _schema_migrations ORDER BY version DESC LIMIT 1", &[])
53 .await?;
54 rows.into_iter().next().map(|r| r.get::<String>("version")).transpose()
55}
56
57fn read_sql_files(dir: &str) -> Result<Vec<(String, String)>, DbError> {
60 let mut files: Vec<(String, String)> = Vec::new();
61 let entries = std::fs::read_dir(dir)
62 .map_err(|e| DbError::new(format!("cannot read migrations directory '{}': {}", dir, e)))?;
63
64 for entry in entries {
65 let entry = entry.map_err(|e| DbError::new(e.to_string()))?;
66 let path = entry.path();
67 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_owned();
68 if filename.ends_with(".sql") && !filename.ends_with(".down.sql") {
69 let contents = std::fs::read_to_string(&path)
70 .map_err(|e| DbError::new(format!("cannot read '{}': {}", path.display(), e)))?;
71 files.push((filename, contents));
72 }
73 }
74 files.sort_by(|a, b| a.0.cmp(&b.0));
75 Ok(files)
76}
77
78fn down_filename(version: &str) -> String {
81 match version.strip_suffix(".sql") {
82 Some(stem) => format!("{}.down.sql", stem),
83 None => format!("{}.down.sql", version),
84 }
85}
86
87fn down_file_path(dir: &str, version: &str) -> String {
88 format!("{}/{}", dir.trim_end_matches('/'), down_filename(version))
89}
90
91fn down_file_exists(dir: &str, version: &str) -> bool {
92 std::path::Path::new(&down_file_path(dir, version)).exists()
93}
94
95fn read_down_sql(dir: &str, version: &str) -> Result<Option<String>, DbError> {
97 let path = down_file_path(dir, version);
98 match std::fs::read_to_string(&path) {
99 Ok(s) => Ok(Some(s)),
100 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
101 Err(e) => Err(DbError::new(format!("cannot read '{}': {}", path, e))),
102 }
103}
104
105fn now_timestamp() -> String {
106 use std::time::{SystemTime, UNIX_EPOCH};
107 let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
108 let (y, mo, d) = days_to_ymd(secs / 86400);
109 let rem = secs % 86400;
110 format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, rem / 3600, (rem % 3600) / 60, rem % 60)
111}
112
113fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
114 let mut year = 1970u64;
115 loop {
116 let diy = if is_leap(year) { 366 } else { 365 };
117 if days < diy { break; }
118 days -= diy;
119 year += 1;
120 }
121 let months = [31u64, if is_leap(year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
122 let mut month = 1u64;
123 for &dim in &months {
124 if days < dim { break; }
125 days -= dim;
126 month += 1;
127 }
128 (year, month, days + 1)
129}
130
131fn is_leap(y: u64) -> bool {
132 (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
133}
134
135pub async fn run_migrations(pool: &DbPool, dir: &str) -> Result<(), DbError> {
139 ensure_migrations_table(pool).await?;
140 let applied = applied_versions(pool).await?;
141 let files = read_sql_files(dir)?;
142
143 for (filename, sql) in files {
144 if applied.contains(&filename) {
145 continue;
146 }
147
148 let statements: Vec<&str> = sql.split(';').map(str::trim).filter(|s| !s.is_empty()).collect();
150 let version = filename.clone();
151 let ts = now_timestamp();
152 let insert_sql = format!(
153 "INSERT INTO _schema_migrations (version, applied_at) VALUES ({}, {})",
154 super::repository::placeholder(pool.backend(), 1),
155 super::repository::placeholder(pool.backend(), 2),
156 );
157
158 let mut tx = pool.begin().await?;
159 for stmt in &statements {
160 if let Err(e) = tx.execute(stmt, &[]).await {
161 let _ = tx.rollback().await;
162 return Err(DbError::new(format!("migration '{}' failed: {}", filename, e)));
163 }
164 }
165 if let Err(e) = tx.execute(&insert_sql, &[Value::Text(version), Value::Text(ts)]).await {
166 let _ = tx.rollback().await;
167 return Err(e);
168 }
169 tx.commit().await?;
170 }
171 Ok(())
172}
173
174pub async fn migration_status(pool: &DbPool, dir: &str) -> Result<Vec<MigrationStatus>, DbError> {
176 ensure_migrations_table(pool).await?;
177 let applied = applied_versions(pool).await?;
178 let files = read_sql_files(dir)?;
179 Ok(files
180 .into_iter()
181 .map(|(filename, _)| {
182 let is_applied = applied.contains(&filename);
183 let has_down = down_file_exists(dir, &filename);
184 MigrationStatus { version: filename, applied: is_applied, has_down }
185 })
186 .collect())
187}
188
189pub async fn rollback_last(pool: &DbPool, dir: &str) -> Result<Option<String>, DbError> {
199 ensure_migrations_table(pool).await?;
200 let version = match last_applied_version(pool).await? {
201 Some(v) => v,
202 None => return Ok(None),
203 };
204
205 let down_sql = read_down_sql(dir, &version)?.ok_or_else(|| {
206 DbError::new(format!(
207 "no down migration found for '{}': expected '{}'",
208 version,
209 down_filename(&version),
210 ))
211 })?;
212
213 let statements: Vec<&str> = down_sql.split(';').map(str::trim).filter(|s| !s.is_empty()).collect();
214 let delete_sql = format!(
215 "DELETE FROM _schema_migrations WHERE version = {}",
216 super::repository::placeholder(pool.backend(), 1),
217 );
218
219 let mut tx = pool.begin().await?;
220 for stmt in &statements {
221 if let Err(e) = tx.execute(stmt, &[]).await {
222 let _ = tx.rollback().await;
223 return Err(DbError::new(format!("rollback of '{}' failed: {}", version, e)));
224 }
225 }
226 if let Err(e) = tx.execute(&delete_sql, &[Value::Text(version.clone())]).await {
227 let _ = tx.rollback().await;
228 return Err(e);
229 }
230 tx.commit().await?;
231 Ok(Some(version))
232}
233
234pub async fn rollback(pool: &DbPool, dir: &str, n: usize) -> Result<Vec<String>, DbError> {
242 let mut rolled_back = Vec::with_capacity(n);
243 for _ in 0..n {
244 match rollback_last(pool, dir).await? {
245 Some(version) => rolled_back.push(version),
246 None => break,
247 }
248 }
249 Ok(rolled_back)
250}