1use core::result;
2use std::{
3 fs::{self, DirEntry, File},
4 path::PathBuf,
5};
6
7use serde::{Deserialize, Serialize};
8use surrealdb::{
9 Surreal,
10 engine::any::{self, Any},
11};
12
13use crate::{Config, Result};
14
15pub struct Migrator {
16 conn: Surreal<Any>,
17 config: Config,
18}
19
20impl Migrator {
22 pub async fn new(config: Config) -> Result<Self> {
23 fs::create_dir_all(&config.migrations_dir)?;
24 let db = any::connect(&config.db_url).await?;
25 db.use_ns(&config.namespace)
26 .use_db(&config.database)
27 .await?;
28
29 let query = r#"
30 DEFINE TABLE IF NOT EXISTS schema_migrations SCHEMAFULL;
31 DEFINE FIELD IF NOT EXISTS version ON schema_migrations TYPE string;
32 "#;
33 db.query(query).await?;
34
35 Ok(Self { conn: db, config })
36 }
37}
38
39impl Migrator {
41 pub fn generate(&self, input_name: impl Into<String>) -> Result<PathBuf> {
44 let now = chrono::Utc::now();
45 let unixtime = format!("{}", now.timestamp_millis());
46 let filename: String = input_name.into().replace(" ", "_").replace("-", "_");
47 let pathbuf = self
48 .config
49 .migrations_dir
50 .join(format!("{unixtime}_{filename}.surql"));
51
52 File::create(&pathbuf)?;
53
54 Ok(pathbuf)
55 }
56
57 pub async fn run(&self) -> Result<()> {
58 for migration in self.pending_migrations().await? {
59 let surql = fs::read_to_string(&migration.path)?;
60 self.conn.query(surql).await?;
61
62 let _: Option<Version> = self
63 .conn
64 .create("schema_migrations")
65 .content(migration.version)
66 .await?;
67 }
68
69 Ok(())
70 }
71
72 pub async fn pending_migrations(&self) -> Result<Vec<Migration>> {
73 let versions = self.applied_versions().await?;
74 let all_migration_files = self.migration_file_paths()?;
75 let pending = all_migration_files
76 .into_iter()
77 .filter_map(|f| {
78 f.file_name()
79 .to_str()
80 .and_then(|fname| match fname.split_once("_") {
81 Some((ts, rest)) => {
82 let ts = ts.to_string();
83
84 if versions.contains(&ts) {
85 return None;
86 }
87
88 if !rest.ends_with(".surql") {
89 return None;
90 }
91
92 ts.parse::<i64>()
93 .ok()
94 .and_then(chrono::DateTime::from_timestamp_millis)?;
95
96 Some(Migration {
97 version: Version { version: ts },
98 path: f.path(),
99 })
100 }
101 None => None, })
103 })
104 .collect();
105
106 Ok(pending)
107 }
108
109 fn migration_file_paths(&self) -> Result<Vec<DirEntry>> {
110 let mut paths: Vec<_> = fs::read_dir(&self.config.migrations_dir)?
111 .filter_map(result::Result::ok)
112 .collect();
113 paths.sort_by_key(|path| path.path());
114
115 Ok(paths)
116 }
117
118 async fn applied_versions(&self) -> Result<Vec<String>> {
119 let versions: Vec<Version> = self
120 .conn
121 .query("SELECT version FROM schema_migrations ORDER BY version ASC")
122 .await?
123 .take(0)?;
124 Ok(versions.into_iter().map(|v| v.version).collect())
125 }
126}
127
128#[derive(Serialize, Deserialize)]
129pub struct Version {
130 pub version: String,
131}
132
133pub struct Migration {
134 pub version: Version,
135 pub path: PathBuf,
136}
137
138#[cfg(test)]
139mod tests {
140 use std::{ffi::OsStr, time::Duration};
141 type Result<T> = core::result::Result<T, Box<dyn core::error::Error>>;
142
143 use super::*;
144
145 fn build_config() -> Config {
146 let path = tempfile::tempdir().unwrap().path().join("migrations");
147 Config::builder()
148 .migration_dir(path)
149 .database("testdb")
150 .namespace("testns")
151 .db_url("memory")
152 .build()
153 .unwrap()
154 }
155
156 #[tokio::test]
157 async fn test_new_creates_schema_migrations_table() -> Result<()> {
158 let mig = Migrator::new(build_config()).await?;
159
160 #[derive(Deserialize)]
161 struct Fields {
162 pub version: String,
163 }
164 #[derive(Deserialize)]
165 struct Info {
166 pub fields: Fields,
167 }
168
169 let res: Option<Info> = mig
170 .conn
171 .query("info for table schema_migrations")
172 .await?
173 .take(0)?;
174
175 assert!(res.is_some());
176
177 let info = res.unwrap();
178 assert_eq!(
179 info.fields.version,
180 "DEFINE FIELD version ON schema_migrations TYPE string PERMISSIONS FULL"
181 );
182
183 Ok(())
184 }
185
186 #[tokio::test]
187 async fn test_generate_ok() -> Result<()> {
188 let mig = Migrator::new(build_config()).await?;
189 let res = mig.generate("create users")?;
190
191 assert!(res.is_file(), "res is not a file: {res:?}");
192
193 let filename = res.file_name().and_then(OsStr::to_str).unwrap();
194 let (ts, name) = filename.split_once("_").unwrap();
195 let parsed = ts
196 .parse::<i64>()
197 .ok()
198 .and_then(chrono::DateTime::from_timestamp_millis);
199
200 assert!(
201 parsed.is_some(),
202 "migration file did not begin with a timestamp"
203 );
204 assert_eq!(name, "create_users.surql");
205
206 Ok(())
207 }
208
209 #[tokio::test]
210 async fn test_pending_migrations() -> Result<()> {
211 let mig = Migrator::new(build_config()).await?;
212 let pending = mig.pending_migrations().await?;
213
214 assert!(pending.is_empty(), "expected no pending migrations");
215
216 let path = mig.generate("create pokemon")?;
217
218 let pending = mig.pending_migrations().await?;
219 assert_eq!(pending.len(), 1, "expected one pending migration");
220
221 let expected_filename = path.file_name();
222 assert!(
223 pending
224 .iter()
225 .any(|m| m.path.file_name() == expected_filename),
226 "expected generated migration to be pending"
227 );
228
229 fs::write(path, "DEFINE TABLE pokemon;")?;
230 mig.run().await?;
231
232 let pending = mig.pending_migrations().await?;
233 assert!(pending.is_empty(), "expected no pending migrations");
234
235 Ok(())
236 }
237
238 #[tokio::test]
239 async fn test_run() -> Result<()> {
240 let mig = Migrator::new(build_config()).await?;
241 let path = mig.generate("create users")?;
242 let surql = r#"
243 DEFINE TABLE users SCHEMAFULL;
244 DEFINE FIELD name ON users TYPE string;
245 "#;
246 fs::write(path, surql)?;
247
248 mig.run().await?;
249
250 #[derive(Deserialize)]
251 struct Fields {
252 pub name: String,
253 }
254 #[derive(Deserialize)]
255 struct Info {
256 pub fields: Fields,
257 }
258
259 let res: Option<Info> = mig.conn.query("info for table users").await?.take(0)?;
260
261 let info = res.unwrap();
262 assert_eq!(
263 info.fields.name,
264 "DEFINE FIELD name ON users TYPE string PERMISSIONS FULL"
265 );
266
267 Ok(())
268 }
269
270 #[tokio::test]
271 async fn test_run_ignores_irrelevant_files() -> Result<()> {
272 let mig = Migrator::new(build_config()).await?;
273 fs::create_dir_all(&mig.config.migrations_dir)?;
274
275 let filename = format!("{}_foo.txt", chrono::Utc::now().timestamp_millis());
277 let bad_file = mig.config.migrations_dir.join(filename);
278 fs::write(bad_file, "Don't run me in the database")?;
279
280 let bad_file = mig.config.migrations_dir.join("no_timestamp.surql");
282 fs::write(bad_file, "Don't run me in the database")?;
283
284 assert!(mig.run().await.is_ok());
285 Ok(())
286 }
287
288 #[tokio::test]
289 async fn test_run_runs_files_once() -> Result<()> {
290 let mig = Migrator::new(build_config()).await?;
291 let path_fruits = mig.generate("create fruits")?;
292 fs::write(&path_fruits, "DEFINE TABLE fruits")?;
293
294 tokio::time::sleep(Duration::from_millis(2)).await;
295
296 let path_veg = mig.generate("create vegetables")?;
297 fs::write(path_veg, "DEFINE TABLE vegetables")?;
298
299 assert_eq!(
300 mig.applied_versions().await?.len(),
301 0,
302 "schema_migrations should be empty"
303 );
304
305 #[derive(Serialize, Deserialize)]
306 struct Mig {
307 pub version: String,
308 }
309
310 let (ts, _) = path_fruits
311 .file_name()
312 .and_then(OsStr::to_str)
313 .unwrap()
314 .split_once("_")
315 .unwrap();
316 let res: Option<Mig> = mig
317 .conn
318 .create("schema_migrations")
319 .content(Mig {
320 version: ts.to_string(),
321 })
322 .await?;
323
324 assert!(res.is_some());
325 assert_eq!(
326 mig.applied_versions().await?.len(),
327 1,
328 "schema_migrations should have one version"
329 );
330
331 mig.run().await?;
333
334 #[derive(Deserialize)]
335 struct Tables {
336 pub fruits: Option<String>,
337 pub vegetables: Option<String>,
338 }
339
340 #[derive(Deserialize)]
341 struct DbInfo {
342 pub tables: Tables,
343 }
344
345 let res: Option<DbInfo> = mig.conn.query("info for db").await?.take(0)?;
346 let info = res.unwrap();
347
348 assert!(
349 info.tables.fruits.is_none(),
350 "expected not to create fruits"
351 );
352 assert!(
353 info.tables.vegetables.is_some(),
354 "expected to create vegetables"
355 );
356 assert_eq!(
357 mig.applied_versions().await?.len(),
358 2,
359 "schema_migrations should have two versions"
360 );
361
362 Ok(())
363 }
364}