Skip to main content

sir_eel/
migrator.rs

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
20// constructors
21impl 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
39// methods
40impl Migrator {
41    /// Generate a new migration file in the configured `migrations_dir` named
42    /// by normalizing the given `input_name`.
43    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, // ignore entries that don't have an "_"
102                    })
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        // files must have .surql extension
276        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        // file names must start with timestamp
281        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        // Run migrations
332        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}