use super::*;
fn registered_migration_paths(source: &str) -> Result<BTreeSet<String>, String> {
let file = syn::parse_file(source).map_err(|error| format!("migrations.rs: {error}"))?;
let mut constants = BTreeMap::new();
for item in &file.items {
if let syn::Item::Const(item) = item {
if constants.insert(item.ident.to_string(), item).is_some() {
return Err(format!("duplicate migration constant {}", item.ident));
}
}
}
let registry = constants.get("MIGRATIONS").ok_or("missing MIGRATIONS")?;
let Expr::Reference(reference) = registry.expr.as_ref() else {
return Err("MIGRATIONS must reference a literal array".into());
};
let Expr::Array(array) = reference.expr.as_ref() else {
return Err("MIGRATIONS must reference a literal array".into());
};
if array.elems.is_empty() {
return Err("MIGRATIONS must not be empty".into());
}
let mut paths = BTreeSet::new();
for entry in &array.elems {
let Expr::Struct(entry) = entry else {
return Err("unrecognized VersionedMigration registration".into());
};
if !entry.path.is_ident("VersionedMigration")
|| entry.qself.is_some()
|| entry.rest.is_some()
{
return Err("unrecognized VersionedMigration registration".into());
}
let fields = entry
.fields
.iter()
.filter(|field| matches!(&field.member, syn::Member::Named(name) if name == "up"))
.collect::<Vec<_>>();
let [field] = fields.as_slice() else {
return Err("migration must have one up field".into());
};
let Expr::Path(up) = &field.expr else {
return Err("migration up must name one include constant".into());
};
if up.qself.is_some() {
return Err("unresolved migration up path".into());
}
let name = up.path.get_ident().ok_or("unresolved migration up path")?;
let constant = constants
.get(&name.to_string())
.ok_or_else(|| format!("missing migration constant {name}"))?;
let Expr::Macro(include) = constant.expr.as_ref() else {
return Err(format!("unresolved migration include {name}"));
};
if !include.mac.path.is_ident("include_str") {
return Err(format!("unresolved migration include {name}"));
}
let literal = syn::parse2::<syn::LitStr>(include.mac.tokens.clone())
.map_err(|error| format!("migration include {name}: {error}"))?;
let value = literal.value();
let filename = value
.strip_prefix("../sql/")
.filter(|filename| {
filename
.strip_suffix(".sql")
.is_some_and(|stem| !stem.is_empty())
&& filename
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"_.-".contains(&byte))
})
.ok_or_else(|| format!("migration include {name} must name a file in ../sql"))?;
paths.insert(format!("khive-db/sql/{filename}"));
}
Ok(paths)
}
fn registered_migration_sources(
source: &str,
mut read: impl FnMut(&str) -> Result<String, String>,
) -> Result<Vec<(String, String)>, String> {
registered_migration_paths(source)?
.into_iter()
.map(|path| read(&path).map(|source| (path, source)))
.collect()
}
const MIGRATION_RUNNERS: [&str; 2] = [
"khive-db/src/migrations.rs",
"khive-db/src/session_identity_migration.rs",
];
fn runner_include_paths(source: &str) -> Result<BTreeSet<String>, String> {
struct Includes(BTreeSet<String>);
impl<'ast> Visit<'ast> for Includes {
fn visit_macro(&mut self, mac: &'ast Macro) {
if mac.path.is_ident("include_str") {
if let Ok(literal) = syn::parse2::<syn::LitStr>(mac.tokens.clone()) {
if let Some(name) = literal.value().strip_prefix("../sql/") {
self.0.insert(format!("khive-db/sql/{name}"));
}
}
}
syn::visit::visit_macro(self, mac);
}
}
let file = syn::parse_file(source).map_err(|error| error.to_string())?;
let mut includes = Includes(BTreeSet::new());
includes.visit_file(&file);
Ok(includes.0)
}
fn opens_with_schema_statement(sql: &str) -> bool {
sql.lines()
.find(|line| {
let line = line.trim();
!line.is_empty() && !line.starts_with("--")
})
.is_none_or(|line| {
let word = line
.trim_start()
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.next()
.unwrap_or_default();
["CREATE", "ALTER", "DROP", "PRAGMA", "BEGIN", "COMMIT"]
.iter()
.any(|keyword| word.eq_ignore_ascii_case(keyword))
})
}
fn migration_population(
registrations: &str,
runners: &[(&str, String)],
sql_files: &BTreeMap<String, String>,
) -> Result<Vec<(String, String)>, String> {
let mut paths = registered_migration_paths(registrations)?;
for (name, source) in runners {
paths.extend(runner_include_paths(source).map_err(|error| format!("{name}: {error}"))?);
}
paths.extend(
sql_files
.iter()
.filter(|(_, sql)| opens_with_schema_statement(sql))
.map(|(path, _)| path.clone()),
);
paths
.into_iter()
.map(|path| {
let sql = sql_files
.get(&path)
.ok_or_else(|| format!("missing {path}"))?;
Ok((path, sql.clone()))
})
.collect()
}
pub(super) fn live_migration_sources() -> Vec<(String, String)> {
let crates = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates directory")
.to_path_buf();
let read = |path: &str| {
std::fs::read_to_string(crates.join(path)).unwrap_or_else(|error| panic!("{path}: {error}"))
};
let runners = MIGRATION_RUNNERS
.iter()
.map(|path| (*path, read(path)))
.collect::<Vec<_>>();
let mut sql_files = BTreeMap::new();
let sql_dir = crates.join("khive-db/sql");
for entry in
std::fs::read_dir(&sql_dir).unwrap_or_else(|error| panic!("{}: {error}", sql_dir.display()))
{
let path = entry.expect("SQL source entry").path();
if path.extension().is_some_and(|extension| extension == "sql") {
let name = path.file_name().expect("SQL source name").to_string_lossy();
let key = format!("khive-db/sql/{name}");
let sql = read(&key);
sql_files.insert(key, sql);
}
}
migration_population(&runners[0].1, &runners, &sql_files)
.expect("resolve migration SQL population")
}
#[test]
fn migration_population_follows_registrations_instead_of_neighboring_queries() {
let registry = r#"
const RECONCILE_VERSION: u32 = 5;
const RECONCILE: &str = include_str!("../sql/reconcile.sql");
const APPLICATION: &str = include_str!("../sql/application.sql");
const MIGRATIONS: &[VersionedMigration] = &[
VersionedMigration { version: RECONCILE_VERSION, name: "reconcile", up: RECONCILE },
];
"#;
let sql = BTreeMap::from([
(
"khive-db/sql/reconcile.sql",
"UPDATE notes SET properties = '{}' WHERE id = 'old'; CREATE INDEX after_reconciliation ON notes(id);",
),
(
"khive-db/sql/application.sql",
"INSERT INTO notes (id, properties) VALUES (?1, ?2);",
),
]);
let read = |path: &str| {
sql.get(path)
.map(|source| (*source).to_owned())
.ok_or_else(|| format!("missing {path}"))
};
let sources = registered_migration_sources(registry, read).unwrap();
assert_eq!(sources.len(), 1);
let sites = scan_migration_sources(&sources);
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].key, "khive-db/sql/reconcile.sql::statement_1");
assert_eq!(sites[0].route_class, RouteClass::Migration);
let registered_application = registry.replace("up: RECONCILE", "up: APPLICATION");
let sources = registered_migration_sources(®istered_application, read).unwrap();
let sites = scan_migration_sources(&sources);
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].key, "khive-db/sql/application.sql::statement_1");
assert_eq!(sites[0].route_class, RouteClass::Migration);
}
#[test]
fn migration_population_refuses_unresolved_or_missing_registrations() {
let registry = r#"
const UP: &str = include_str!("../sql/migration.sql");
const MIGRATIONS: &[VersionedMigration] = &[
VersionedMigration { version: 1, name: "fixture", up: UP },
];
"#;
assert!(registered_migration_paths(®istry.replace("up: UP", "up: UNKNOWN")).is_err());
assert!(registered_migration_paths(®istry.replace("../sql/", "../../")).is_err());
assert!(registered_migration_sources(registry, |_| Err("missing SQL".into())).is_err());
}
#[test]
fn migration_population_keeps_runner_helpers_and_schema_files_but_not_queries() {
let registry = r#"
const RECONCILE: &str = include_str!("../sql/reconcile.sql");
const MIGRATIONS: &[VersionedMigration] = &[
VersionedMigration { version: 5, name: "reconcile", up: RECONCILE },
];
"#;
let helper = r#"
fn stage(conn: &Connection) {
conn.execute_batch(include_str!("../sql/stage.sql")).unwrap();
}
"#;
let sql = BTreeMap::from([
(
"khive-db/sql/reconcile.sql".to_string(),
"CREATE INDEX reconcile ON notes(id);".to_string(),
),
(
"khive-db/sql/stage.sql".to_string(),
"UPDATE notes SET properties = '{}' WHERE id = 'staged';".to_string(),
),
(
"khive-db/sql/notes-ddl.sql".to_string(),
"-- schema\nCREATE TABLE notes (id TEXT);\nUPDATE notes SET properties = '{}';"
.to_string(),
),
(
"khive-db/sql/message-insert.sql".to_string(),
"INSERT INTO notes (id, properties) VALUES (?1, ?2)".to_string(),
),
]);
let runners = [
("migrations.rs", registry.to_string()),
("helper.rs", helper.to_string()),
];
let sources = migration_population(registry, &runners, &sql).unwrap();
let keys = scan_migration_sources(&sources)
.into_iter()
.map(|site| site.key)
.collect::<Vec<_>>();
assert_eq!(
keys,
[
"khive-db/sql/notes-ddl.sql::statement_2",
"khive-db/sql/stage.sql::statement_1"
]
);
let missing = BTreeMap::from([(
"khive-db/sql/reconcile.sql".to_string(),
"CREATE INDEX reconcile ON notes(id);".to_string(),
)]);
assert!(migration_population(registry, &runners, &missing).is_err());
}
#[test]
fn schema_statement_rule_matches_the_sql_lint() {
assert!(opens_with_schema_statement(
"-- header\n\n ALTER TABLE notes ADD COLUMN x TEXT;"
));
assert!(opens_with_schema_statement("pragma foreign_keys = off;"));
assert!(opens_with_schema_statement(
"BEGIN;\nUPDATE notes SET kind = 'x';"
));
assert!(opens_with_schema_statement(""));
assert!(!opens_with_schema_statement(
"-- header\nUPDATE notes SET properties = '{}';"
));
assert!(!opens_with_schema_statement("CREATE_LOG_ROW;"));
}
#[test]
fn live_migration_population_keeps_runner_helpers_and_excludes_store_queries() {
let paths = live_migration_sources()
.into_iter()
.map(|(path, _)| path)
.collect::<BTreeSet<_>>();
for kept in [
"khive-db/sql/021-attachments-b-claim-fences.sql",
"khive-db/sql/040b-session-source-scope-swap.sql",
"khive-db/sql/044-comm-outbound-due-a-columns.sql",
"khive-db/sql/notes-ddl.sql",
] {
assert!(paths.contains(kept), "{kept} left the migration population");
}
assert!(!paths.contains("khive-db/sql/comm-recipient-message-note-insert.sql"));
}
#[test]
fn registered_migration_property_writer_inventory_is_unchanged() {
let sites = scan_migration_sources(&live_migration_sources());
let inventory = sites
.into_iter()
.map(|site| (site.key, site.target, site.write_count, site.route_class))
.collect::<Vec<_>>();
assert_eq!(
inventory,
vec![(
"khive-db/sql/005-unique-comm-external-id.sql::statement_1".into(),
Substrate::Note,
1,
RouteClass::Migration
)]
);
}