Skip to main content

persistant_migrate/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! The logic behind the `persistant-migrate` command: reading a directory of migrations and comparing
3//! it with a database ledger. The commands themselves are in `main.rs`.
4//!
5//! A directory holds `NNN_name.up.sql` files (with `NNN_name.down.sql` partners) or plain
6//! `NNN_name.sql`. The ledger is sqlx's own `_sqlx_migrations`, so a database can be handled by this
7//! tool or by `sqlx migrate run` interchangeably. **Down files are read and checked for pairing, never
8//! run**: [`Migrates`](persistant_relational::Migrates) has no undo.
9
10use persistant_relational::Migration;
11use sha2::{Digest, Sha384};
12use std::path::Path;
13
14/// Why a directory could not be turned into migrations.
15#[derive(Debug)]
16pub enum LoadError {
17    /// The directory or a file in it could not be read or parsed.
18    Read(String),
19    /// A `.down.sql` file has no `.up.sql` partner of the same version.
20    UnpairedDown {
21        /// The version.
22        version: i64,
23    },
24    /// The migrations cannot be run as they stand (see [`persistant_sqlx::FromSqlxError`]).
25    Unsupported(persistant_sqlx::FromSqlxError),
26}
27
28impl std::fmt::Display for LoadError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            LoadError::Read(m) => write!(f, "reading the migrations: {m}"),
32            LoadError::UnpairedDown { version } => {
33                write!(f, "migration {version} has a .down.sql but no .up.sql")
34            }
35            LoadError::Unsupported(e) => write!(f, "{e}"),
36        }
37    }
38}
39
40impl std::error::Error for LoadError {}
41
42/// The migrations in `dir`, in version order. Each down file must pair with an up file.
43pub async fn load_dir(dir: &Path) -> Result<Vec<Migration>, LoadError> {
44    let entries = std::fs::read_dir(dir).map_err(|e| LoadError::Read(e.to_string()))?;
45    let mut ups = Vec::new();
46    let mut downs = Vec::new();
47    for entry in entries {
48        let name = entry
49            .map_err(|e| LoadError::Read(e.to_string()))?
50            .file_name()
51            .to_string_lossy()
52            .into_owned();
53        let version = name.split('_').next().and_then(|v| v.parse::<i64>().ok());
54        if let Some(v) = version {
55            if name.ends_with(".down.sql") {
56                downs.push(v);
57            } else if name.ends_with(".sql") {
58                ups.push(v);
59            }
60        }
61    }
62    if let Some(&version) = downs.iter().find(|d| !ups.contains(d)) {
63        return Err(LoadError::UnpairedDown { version });
64    }
65    let migrator = sqlx::migrate::Migrator::new(dir)
66        .await
67        .map_err(|e| LoadError::Read(e.to_string()))?;
68    persistant_sqlx::migrations_from_sqlx(&migrator).map_err(LoadError::Unsupported)
69}
70
71/// The checksum the ledger holds for `m`: SHA-384 of its text.
72pub fn checksum(m: &Migration) -> Vec<u8> {
73    Sha384::digest(m.sql.as_bytes()).to_vec()
74}
75
76/// Where one migration stands against a database.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum State {
79    /// Applied, and the text still matches.
80    Applied,
81    /// In the directory, not yet applied.
82    Pending,
83    /// Applied, but the text in the directory is not what was applied.
84    Changed,
85    /// Applied in the database, absent from the directory: the database is newer than this program.
86    Unknown,
87}
88
89/// One line of a status report.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Line {
92    /// The version.
93    pub version: i64,
94    /// The description (empty for an [`State::Unknown`] version).
95    pub description: String,
96    /// Where it stands.
97    pub state: State,
98}
99
100/// Compare `migrations` with the ledger rows `(version, checksum)`.
101pub fn status(migrations: &[Migration], ledger: &[(i64, Vec<u8>)]) -> Vec<Line> {
102    let mut out: Vec<Line> = migrations
103        .iter()
104        .map(|m| {
105            let state = match ledger.iter().find(|(v, _)| *v == m.version) {
106                None => State::Pending,
107                Some((_, c)) if *c == checksum(m) => State::Applied,
108                Some(_) => State::Changed,
109            };
110            Line {
111                version: m.version,
112                description: m.description.clone(),
113                state,
114            }
115        })
116        .collect();
117    out.extend(
118        ledger
119            .iter()
120            .filter(|(v, _)| !migrations.iter().any(|m| m.version == *v))
121            .map(|(v, _)| Line {
122                version: *v,
123                description: String::new(),
124                state: State::Unknown,
125            }),
126    );
127    out.sort_by_key(|l| l.version);
128    out
129}
130
131/// Where a database URL points, as it is safe to print: the password is dropped.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Target {
134    /// The URL without its password or query string.
135    pub display: String,
136    /// Whether the host is this machine (loopback, a unix socket, or an SQLite file).
137    pub local: bool,
138}
139
140/// Describe `url`. A URL that cannot be read is reported as **not** local, so the guard errs closed.
141pub fn describe_target(url: &str) -> Target {
142    if url.starts_with("sqlite:") {
143        let path = url.trim_start_matches("sqlite:").trim_start_matches("//");
144        return Target {
145            display: format!("sqlite:{path}"),
146            local: true,
147        };
148    }
149    let Some((scheme, rest)) = url.split_once("://") else {
150        return Target {
151            display: "(unreadable URL)".to_string(),
152            local: false,
153        };
154    };
155    let (rest, query) = rest.split_once('?').unwrap_or((rest, ""));
156    let (authority, db) = rest.split_once('/').unwrap_or((rest, ""));
157    let (user, hostport) = match authority.rsplit_once('@') {
158        Some((cred, hp)) => (cred.split(':').next().unwrap_or(""), hp),
159        None => ("", authority),
160    };
161    let host_from_authority = if let Some(v6) = hostport.strip_prefix('[') {
162        v6.split(']').next().unwrap_or("")
163    } else {
164        hostport.split(':').next().unwrap_or("")
165    };
166    // A `host=` query parameter overrides the authority (PostgreSQL socket and multi-host URLs).
167    let host = query
168        .split('&')
169        .find_map(|kv| kv.strip_prefix("host="))
170        .unwrap_or(host_from_authority);
171    let local = host.is_empty()
172        || host.starts_with('/')
173        || host == "localhost"
174        || host == "::1"
175        || host.starts_with("127.");
176    let at = if user.is_empty() {
177        String::new()
178    } else {
179        format!("{user}@")
180    };
181    let shown_host = if host == host_from_authority {
182        hostport.to_string()
183    } else {
184        format!("{hostport} (host={host})")
185    };
186    Target {
187        display: format!("{scheme}://{at}{shown_host}/{db}"),
188        local,
189    }
190}