use persistant_relational::Migration;
use persistant_relational::catalog::{self, Snapshot};
use sha2::{Digest, Sha384};
use std::path::Path;
#[derive(Debug)]
pub enum LoadError {
Read(String),
UnpairedDown {
version: i64,
},
Unsupported(persistant_sqlx::FromSqlxError),
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LoadError::Read(m) => write!(f, "reading the migrations: {m}"),
LoadError::UnpairedDown { version } => {
write!(f, "migration {version} has a .down.sql but no .up.sql")
}
LoadError::Unsupported(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for LoadError {}
pub async fn load_dir(dir: &Path) -> Result<Vec<Migration>, LoadError> {
let entries = std::fs::read_dir(dir).map_err(|e| LoadError::Read(e.to_string()))?;
let mut ups = Vec::new();
let mut downs = Vec::new();
for entry in entries {
let name = entry
.map_err(|e| LoadError::Read(e.to_string()))?
.file_name()
.to_string_lossy()
.into_owned();
let version = name.split('_').next().and_then(|v| v.parse::<i64>().ok());
if let Some(v) = version {
if name.ends_with(".down.sql") {
downs.push(v);
} else if name.ends_with(".sql") {
ups.push(v);
}
}
}
if let Some(&version) = downs.iter().find(|d| !ups.contains(d)) {
return Err(LoadError::UnpairedDown { version });
}
let migrator = sqlx::migrate::Migrator::new(dir)
.await
.map_err(|e| LoadError::Read(e.to_string()))?;
persistant_sqlx::migrations_from_sqlx(&migrator).map_err(LoadError::Unsupported)
}
pub fn checksum(m: &Migration) -> Vec<u8> {
Sha384::digest(m.sql.as_bytes()).to_vec()
}
pub fn snapshot_from_files(
migrations: &[Migration],
dialect: &str,
) -> Result<(Snapshot, Vec<String>), String> {
let mut ordered: Vec<&Migration> = migrations.iter().collect();
ordered.sort_by_key(|m| m.version);
let mut ddl = String::new();
for m in &ordered {
ddl.push_str(&m.sql);
if !m.sql.ends_with(char::from(10)) {
ddl.push(char::from(10));
}
}
let read = unisql::schema_from_ddl(&ddl, dialect).map_err(|e| e.to_string())?;
let ledger: Vec<(i64, Vec<u8>)> = ordered.iter().map(|m| (m.version, checksum(m))).collect();
let mut meta = catalog::migration_meta(&ledger);
meta.insert("dialect".to_string(), dialect.to_string());
meta.insert("source".to_string(), "ddl".to_string());
let mut snapshot = Snapshot::new(read.schema);
snapshot.meta = meta;
Ok((snapshot, read.skipped))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum State {
Applied,
Pending,
Changed,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Line {
pub version: i64,
pub description: String,
pub state: State,
}
pub fn status(migrations: &[Migration], ledger: &[(i64, Vec<u8>)]) -> Vec<Line> {
let mut out: Vec<Line> = migrations
.iter()
.map(|m| {
let state = match ledger.iter().find(|(v, _)| *v == m.version) {
None => State::Pending,
Some((_, c)) if *c == checksum(m) => State::Applied,
Some(_) => State::Changed,
};
Line {
version: m.version,
description: m.description.clone(),
state,
}
})
.collect();
out.extend(
ledger
.iter()
.filter(|(v, _)| !migrations.iter().any(|m| m.version == *v))
.map(|(v, _)| Line {
version: *v,
description: String::new(),
state: State::Unknown,
}),
);
out.sort_by_key(|l| l.version);
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
pub display: String,
pub local: bool,
}
pub fn describe_target(url: &str) -> Target {
if url.starts_with("sqlite:") {
let path = url.trim_start_matches("sqlite:").trim_start_matches("//");
return Target {
display: format!("sqlite:{path}"),
local: true,
};
}
let Some((scheme, rest)) = url.split_once("://") else {
return Target {
display: "(unreadable URL)".to_string(),
local: false,
};
};
let (rest, query) = rest.split_once('?').unwrap_or((rest, ""));
let (authority, db) = rest.split_once('/').unwrap_or((rest, ""));
let (user, hostport) = match authority.rsplit_once('@') {
Some((cred, hp)) => (cred.split(':').next().unwrap_or(""), hp),
None => ("", authority),
};
let host_from_authority = if let Some(v6) = hostport.strip_prefix('[') {
v6.split(']').next().unwrap_or("")
} else {
hostport.split(':').next().unwrap_or("")
};
let host = query
.split('&')
.find_map(|kv| kv.strip_prefix("host="))
.unwrap_or(host_from_authority);
let local = host.is_empty()
|| host.starts_with('/')
|| host == "localhost"
|| host == "::1"
|| host.starts_with("127.");
let at = if user.is_empty() {
String::new()
} else {
format!("{user}@")
};
let shown_host = if host == host_from_authority {
hostport.to_string()
} else {
format!("{hostport} (host={host})")
};
Target {
display: format!("{scheme}://{at}{shown_host}/{db}"),
local,
}
}