sqlx_core/migrate/
source.rs

1use crate::error::BoxDynError;
2use crate::migrate::{Migration, MigrationType};
3use futures_core::future::BoxFuture;
4
5use std::borrow::Cow;
6use std::fmt::Debug;
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11/// In the default implementation, a MigrationSource is a directory which
12/// contains the migration SQL scripts. All these scripts must be stored in
13/// files with names using the format `<VERSION>_<DESCRIPTION>.sql`, where
14/// `<VERSION>` is a string that can be parsed into `i64` and its value is
15/// greater than zero, and `<DESCRIPTION>` is a string.
16///
17/// Files that don't match this format are silently ignored.
18///
19/// You can create a new empty migration script using sqlx-cli:
20/// `sqlx migrate add <DESCRIPTION>`.
21///
22/// Note that migrations for each database are tracked using the
23/// `_sqlx_migrations` table (stored in the database). If a migration's hash
24/// changes and it has already been run, this will cause an error.
25pub trait MigrationSource<'s>: Debug {
26    fn resolve(self) -> BoxFuture<'s, Result<Vec<Migration>, BoxDynError>>;
27}
28
29impl<'s> MigrationSource<'s> for &'s Path {
30    fn resolve(self) -> BoxFuture<'s, Result<Vec<Migration>, BoxDynError>> {
31        Box::pin(async move {
32            let canonical = self.canonicalize()?;
33            let migrations_with_paths =
34                crate::rt::spawn_blocking(move || resolve_blocking(&canonical)).await?;
35
36            Ok(migrations_with_paths.into_iter().map(|(m, _p)| m).collect())
37        })
38    }
39}
40
41impl MigrationSource<'static> for PathBuf {
42    fn resolve(self) -> BoxFuture<'static, Result<Vec<Migration>, BoxDynError>> {
43        Box::pin(async move { self.as_path().resolve().await })
44    }
45}
46
47#[derive(thiserror::Error, Debug)]
48#[error("{message}")]
49pub struct ResolveError {
50    message: String,
51    #[source]
52    source: Option<io::Error>,
53}
54
55// FIXME: paths should just be part of `Migration` but we can't add a field backwards compatibly
56// since it's `#[non_exhaustive]`.
57pub fn resolve_blocking(path: &Path) -> Result<Vec<(Migration, PathBuf)>, ResolveError> {
58    let s = fs::read_dir(path).map_err(|e| ResolveError {
59        message: format!("error reading migration directory {}: {e}", path.display()),
60        source: Some(e),
61    })?;
62
63    let mut migrations = Vec::new();
64
65    for res in s {
66        let entry = res.map_err(|e| ResolveError {
67            message: format!(
68                "error reading contents of migration directory {}: {e}",
69                path.display()
70            ),
71            source: Some(e),
72        })?;
73
74        let entry_path = entry.path();
75
76        let metadata = fs::metadata(&entry_path).map_err(|e| ResolveError {
77            message: format!(
78                "error getting metadata of migration path {}",
79                entry_path.display()
80            ),
81            source: Some(e),
82        })?;
83
84        if !metadata.is_file() {
85            // not a file; ignore
86            continue;
87        }
88
89        let file_name = entry.file_name();
90        // This is arguably the wrong choice,
91        // but it really only matters for parsing the version and description.
92        //
93        // Using `.to_str()` and returning an error if the filename is not UTF-8
94        // would be a breaking change.
95        let file_name = file_name.to_string_lossy();
96
97        let parts = file_name.splitn(2, '_').collect::<Vec<_>>();
98
99        if parts.len() != 2 || !parts[1].ends_with(".sql") {
100            // not of the format: <VERSION>_<DESCRIPTION>.<REVERSIBLE_DIRECTION>.sql; ignore
101            continue;
102        }
103
104        let version: i64 = parts[0].parse()
105            .map_err(|_e| ResolveError {
106                message: format!("error parsing migration filename {file_name:?}; expected integer version prefix (e.g. `01_foo.sql`)"),
107                source: None,
108            })?;
109
110        let migration_type = MigrationType::from_filename(parts[1]);
111
112        // remove the `.sql` and replace `_` with ` `
113        let description = parts[1]
114            .trim_end_matches(migration_type.suffix())
115            .replace('_', " ")
116            .to_owned();
117
118        let sql = fs::read_to_string(&entry_path).map_err(|e| ResolveError {
119            message: format!(
120                "error reading contents of migration {}: {e}",
121                entry_path.display()
122            ),
123            source: Some(e),
124        })?;
125
126        // opt-out of migration transaction
127        let no_tx = sql.starts_with("-- no-transaction");
128
129        migrations.push((
130            Migration::new(
131                version,
132                Cow::Owned(description),
133                migration_type,
134                Cow::Owned(sql),
135                no_tx,
136            ),
137            entry_path,
138        ));
139    }
140
141    // Ensure that we are sorted by version in ascending order.
142    migrations.sort_by_key(|(m, _)| m.version);
143
144    Ok(migrations)
145}