Skip to main content

refinery_core/
util.rs

1use crate::error::{Error, Kind};
2use crate::runner::Type;
3use crate::Migration;
4use regex::Regex;
5use std::ffi::OsStr;
6use std::path::{Path, PathBuf};
7use std::sync::OnceLock;
8use walkdir::{DirEntry, WalkDir};
9
10#[cfg(not(feature = "int8-versions"))]
11pub type SchemaVersion = i32;
12#[cfg(feature = "int8-versions")]
13pub type SchemaVersion = i64;
14
15const STEM_RE: &str = r"^([U|V])(\d+(?:\.\d+)?)__(\w+)";
16
17/// Matches the stem of a migration file.
18fn file_stem_re() -> &'static Regex {
19    static RE: OnceLock<Regex> = OnceLock::new();
20    RE.get_or_init(|| Regex::new(STEM_RE).unwrap())
21}
22
23/// Matches the stem + extension of a SQL migration file.
24fn file_re_sql() -> &'static Regex {
25    static RE: OnceLock<Regex> = OnceLock::new();
26    RE.get_or_init(|| Regex::new([STEM_RE, r"\.sql$"].concat().as_str()).unwrap())
27}
28
29/// Matches the stem + extension of any migration file.
30fn file_re_all() -> &'static Regex {
31    static RE: OnceLock<Regex> = OnceLock::new();
32    RE.get_or_init(|| Regex::new([STEM_RE, r"\.(rs|sql)$"].concat().as_str()).unwrap())
33}
34
35/// enum containing the migration types used to search for migrations
36/// either just .sql files or both .sql and .rs
37pub enum MigrationType {
38    All,
39    Sql,
40}
41
42impl MigrationType {
43    fn file_match_re(&self) -> &'static Regex {
44        match self {
45            MigrationType::All => file_re_all(),
46            MigrationType::Sql => file_re_sql(),
47        }
48    }
49}
50
51/// Parse a migration filename stem into a prefix, version, and name.
52pub fn parse_migration_name(name: &str) -> Result<(Type, SchemaVersion, String), Error> {
53    let captures = file_stem_re()
54        .captures(name)
55        .filter(|caps| caps.len() == 4)
56        .ok_or_else(|| Error::new(Kind::InvalidName, None))?;
57    let version: SchemaVersion = captures[2]
58        .parse()
59        .map_err(|_| Error::new(Kind::InvalidVersion, None))?;
60
61    let name: String = (&captures[3]).into();
62    let prefix = match &captures[1] {
63        "V" => Type::Versioned,
64        "U" => Type::Unversioned,
65        _ => unreachable!(),
66    };
67
68    Ok((prefix, version, name))
69}
70
71/// find migrations on file system recursively across directories given a location and [MigrationType]
72///
73/// The returned paths are sorted, so that the order doesn't depend on the order in which the
74/// file system happens to return the directory entries. This matters for reproducible builds,
75/// as [`embed_migrations!`] embeds the migrations in the order they are returned here.
76///
77/// [`embed_migrations!`]: https://docs.rs/refinery/latest/refinery/macro.embed_migrations.html
78pub fn find_migration_files(
79    location: impl AsRef<Path>,
80    migration_type: MigrationType,
81) -> Result<impl Iterator<Item = PathBuf>, Error> {
82    let location: &Path = location.as_ref();
83    let location = location.canonicalize().map_err(|err| {
84        Error::new(
85            Kind::InvalidMigrationPath(location.to_path_buf(), err),
86            None,
87        )
88    })?;
89
90    let re = migration_type.file_match_re();
91    let file_paths = WalkDir::new(location)
92        .sort_by_file_name()
93        .into_iter()
94        .filter_map(Result::ok)
95        .map(DirEntry::into_path)
96        // filter by migration file regex
97        .filter(move |entry| {
98            if entry.is_dir() {
99                return false;
100            }
101
102            match entry.file_name().and_then(OsStr::to_str) {
103                Some(file_name) if re.is_match(file_name) => true,
104                Some(file_name) => {
105                    log::warn!(
106                        "File \"{}\" does not adhere to the migration naming convention. Migrations must be named in the format [U|V]{{1}}__{{2}}.sql or [U|V]{{1}}__{{2}}.rs, where {{1}} represents the migration version and {{2}} the name.",
107                        file_name
108                    );
109                    false
110                }
111                None => false,
112            }
113        });
114
115    Ok(file_paths)
116}
117
118/// Loads SQL migrations from a path. This enables dynamic migration discovery, as opposed to
119/// embedding. The resulting collection is ordered by version.
120pub fn load_sql_migrations(location: impl AsRef<Path>) -> Result<Vec<Migration>, Error> {
121    let migration_files = find_migration_files(location, MigrationType::Sql)?;
122
123    let mut migrations = vec![];
124
125    for path in migration_files {
126        let sql = std::fs::read_to_string(path.as_path()).map_err(|e| {
127            let path = path.to_owned();
128            let kind = match e.kind() {
129                std::io::ErrorKind::NotFound => Kind::InvalidMigrationPath(path, e),
130                _ => Kind::InvalidMigrationFile(path, e),
131            };
132
133            Error::new(kind, None)
134        })?;
135
136        //safe to call unwrap as find_migration_filenames returns canonical paths
137        let filename = path
138            .file_stem()
139            .and_then(|file| file.to_os_string().into_string().ok())
140            .unwrap();
141
142        let migration = Migration::unapplied(&filename, &sql)?;
143        migrations.push(migration);
144    }
145
146    migrations.sort();
147    Ok(migrations)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::{find_migration_files, load_sql_migrations, MigrationType};
153    use std::fs;
154    use std::path::PathBuf;
155    use tempfile::TempDir;
156
157    #[test]
158    fn finds_mod_migrations() {
159        let tmp_dir = TempDir::new().unwrap();
160        let migrations_dir = tmp_dir.path().join("migrations");
161        fs::create_dir(&migrations_dir).unwrap();
162        let sql1 = migrations_dir.join("V1__first.rs");
163        fs::File::create(&sql1).unwrap();
164        let sql2 = migrations_dir.join("V2__second.rs");
165        fs::File::create(&sql2).unwrap();
166
167        let mods: Vec<PathBuf> = find_migration_files(migrations_dir, MigrationType::All)
168            .unwrap()
169            .collect();
170        assert_eq!(sql1.canonicalize().unwrap(), mods[0]);
171        assert_eq!(sql2.canonicalize().unwrap(), mods[1]);
172    }
173
174    #[test]
175    fn ignores_mod_files_without_migration_regex_match() {
176        let tmp_dir = TempDir::new().unwrap();
177        let migrations_dir = tmp_dir.path().join("migrations");
178        fs::create_dir(&migrations_dir).unwrap();
179        let sql1 = migrations_dir.join("V1first.rs");
180        fs::File::create(sql1).unwrap();
181        let sql2 = migrations_dir.join("V2second.rs");
182        fs::File::create(sql2).unwrap();
183
184        let mut mods = find_migration_files(migrations_dir, MigrationType::All).unwrap();
185        assert!(mods.next().is_none());
186    }
187
188    #[test]
189    fn finds_sql_migrations() {
190        let tmp_dir = TempDir::new().unwrap();
191        let migrations_dir = tmp_dir.path().join("migrations");
192        fs::create_dir(&migrations_dir).unwrap();
193        let sql1 = migrations_dir.join("V1__first.sql");
194        fs::File::create(&sql1).unwrap();
195        let sql2 = migrations_dir.join("V2__second.sql");
196        fs::File::create(&sql2).unwrap();
197
198        let mods: Vec<PathBuf> = find_migration_files(migrations_dir, MigrationType::All)
199            .unwrap()
200            .collect();
201        assert_eq!(sql1.canonicalize().unwrap(), mods[0]);
202        assert_eq!(sql2.canonicalize().unwrap(), mods[1]);
203    }
204
205    #[test]
206    fn finds_sql_migrations_in_nested_directories() {
207        let tmp_dir = TempDir::new().unwrap();
208        let migrations_dir = tmp_dir.path().join("migrations");
209        let nested_dir = migrations_dir.join("foo");
210        fs::create_dir(&migrations_dir).unwrap();
211        fs::create_dir(&nested_dir).unwrap();
212
213        let sql1 = nested_dir.join("V1__first.sql");
214        let sql2 = nested_dir.join("V2__second.sql");
215        fs::File::create(&sql1).unwrap();
216        fs::File::create(&sql2).unwrap();
217
218        let mods: Vec<PathBuf> = find_migration_files(migrations_dir, MigrationType::All)
219            .unwrap()
220            .collect();
221
222        assert_eq!(sql1.canonicalize().unwrap(), mods[0]);
223        assert_eq!(sql2.canonicalize().unwrap(), mods[1]);
224    }
225
226    #[test]
227    fn finds_unversioned_migrations() {
228        let tmp_dir = TempDir::new().unwrap();
229        let migrations_dir = tmp_dir.path().join("migrations");
230        fs::create_dir(&migrations_dir).unwrap();
231        let sql1 = migrations_dir.join("U1__first.sql");
232        fs::File::create(&sql1).unwrap();
233        let sql2 = migrations_dir.join("U2__second.sql");
234        fs::File::create(&sql2).unwrap();
235
236        let mods: Vec<PathBuf> = find_migration_files(migrations_dir, MigrationType::All)
237            .unwrap()
238            .collect();
239        assert_eq!(sql1.canonicalize().unwrap(), mods[0]);
240        assert_eq!(sql2.canonicalize().unwrap(), mods[1]);
241    }
242
243    #[test]
244    fn ignores_sql_files_without_migration_regex_match() {
245        let tmp_dir = TempDir::new().unwrap();
246        let migrations_dir = tmp_dir.path().join("migrations");
247        fs::create_dir(&migrations_dir).unwrap();
248        let sql1 = migrations_dir.join("V1first.sql");
249        fs::File::create(sql1).unwrap();
250        let sql2 = migrations_dir.join("V2second.sql");
251        fs::File::create(sql2).unwrap();
252
253        let mut mods = find_migration_files(migrations_dir, MigrationType::All).unwrap();
254        assert!(mods.next().is_none());
255    }
256
257    // migration files are embedded in the order they are found, so that order has to be
258    // independent of the order the file system returns the directory entries in, otherwise
259    // builds using embed_migrations! aren't reproducible
260    #[test]
261    fn finds_migrations_in_sorted_order() {
262        let tmp_dir = TempDir::new().unwrap();
263        let migrations_dir = tmp_dir.path().join("migrations");
264        let nested_dir = migrations_dir.join("nested");
265        fs::create_dir(&migrations_dir).unwrap();
266        fs::create_dir(&nested_dir).unwrap();
267
268        // created in an order that is neither sorted nor reversed
269        for file in [
270            migrations_dir.join("V3__third.sql"),
271            nested_dir.join("V2__second.rs"),
272            migrations_dir.join("U4__fourth.sql"),
273            migrations_dir.join("V1__first.sql"),
274        ] {
275            fs::File::create(file).unwrap();
276        }
277
278        let expected: Vec<PathBuf> = [
279            migrations_dir.join("U4__fourth.sql"),
280            migrations_dir.join("V1__first.sql"),
281            migrations_dir.join("V3__third.sql"),
282            nested_dir.join("V2__second.rs"),
283        ]
284        .iter()
285        .map(|path| path.canonicalize().unwrap())
286        .collect();
287
288        let mods: Vec<PathBuf> = find_migration_files(&migrations_dir, MigrationType::All)
289            .unwrap()
290            .collect();
291        assert_eq!(expected, mods);
292    }
293
294    #[test]
295    fn loads_migrations_from_path() {
296        let tmp_dir = TempDir::new().unwrap();
297        let migrations_dir = tmp_dir.path().join("migrations");
298        fs::create_dir(&migrations_dir).unwrap();
299        let sql1 = migrations_dir.join("V1__first.sql");
300        fs::File::create(&sql1).unwrap();
301        let sql2 = migrations_dir.join("V2__second.sql");
302        fs::File::create(&sql2).unwrap();
303        let rs3 = migrations_dir.join("V3__third.rs");
304        fs::File::create(&rs3).unwrap();
305
306        let migrations = load_sql_migrations(migrations_dir).unwrap();
307        assert_eq!(migrations.len(), 2);
308        assert_eq!(&migrations[0].to_string(), "V1__first");
309        assert_eq!(&migrations[1].to_string(), "V2__second");
310    }
311}