Files
core/crates/dever-runtime/src/sqlite/migration.rs
T

484 lines
15 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
use deadpool_sqlite::rusqlite::{Connection, OptionalExtension, params};
use crate::database::{Field, Migration, MigrationPhase, Model, Schema};
use crate::orm::Error;
use super::{database_error, interact_error, sqlite_values};
const CREATE_HISTORY: &str = "\
CREATE TABLE IF NOT EXISTS _dever_history (\
model TEXT NOT NULL,\
kind TEXT NOT NULL,\
name TEXT NOT NULL,\
revision TEXT NOT NULL,\
definition TEXT NOT NULL,\
applied_at INTEGER NOT NULL DEFAULT (unixepoch()),\
PRIMARY KEY (model, kind, name)\
)";
pub(super) async fn apply(connection: &deadpool_sqlite::Object, model: Model) -> Result<(), Error> {
connection
.interact(move |connection| transaction(connection, model))
.await
.map_err(interact_error)?
}
fn transaction(connection: &mut Connection, model: Model) -> Result<(), Error> {
connection
.execute_batch("BEGIN IMMEDIATE; PRAGMA defer_foreign_keys = ON;")
.map_err(database_error)?;
let result = apply_locked(connection, &model);
let result = match result {
Ok(()) => match connection.execute_batch("COMMIT") {
Ok(()) => Ok(()),
Err(commit) => rollback(connection, database_error(commit)),
},
Err(error) => rollback(connection, error),
};
connection.flush_prepared_statement_cache();
result
}
fn rollback(connection: &Connection, error: Error) -> Result<(), Error> {
match connection.execute_batch("ROLLBACK") {
Ok(()) => Err(error),
Err(rollback) => Err(error.cause(rollback)),
}
}
fn apply_locked(connection: &Connection, model: &Model) -> Result<(), Error> {
connection
.execute_batch(CREATE_HISTORY)
.map_err(database_error)?;
let table_exists = table_exists(connection, &model.schema.table)?;
let previous = load_schema(connection, &model.schema.model)?;
match (table_exists, previous.as_ref()) {
(false, None) => create_model(connection, model)?,
(true, Some(previous)) => update_model(connection, model, previous)?,
(true, None) => {
return Err(Error::migration(format!(
"table '{}' exists without Dever schema history",
model.schema.table
)));
}
(false, Some(_)) => {
return Err(Error::migration(format!(
"Dever schema history exists but table '{}' is missing",
model.schema.table
)));
}
}
apply_seed(connection, model, previous.as_ref())?;
store_history(connection, &model.schema)?;
Ok(())
}
fn create_model(connection: &Connection, model: &Model) -> Result<(), Error> {
connection
.execute_batch(&model.create_table.sqlite)
.map_err(database_error)?;
create_indexes(connection, &model.create_indexes)
}
fn update_model(connection: &Connection, model: &Model, previous: &Schema) -> Result<(), Error> {
if previous.table != model.schema.table {
return Err(Error::migration(format!(
"Model '{}' cannot rename table '{}' to '{}' automatically",
model.schema.model, previous.table, model.schema.table
)));
}
validate_migrations(connection, &model.schema)?;
crate::database::validate_migration_order(previous, &model.schema)?;
validate_catalog(connection, previous)?;
let pending = unapplied_migrations(connection, &model.schema)?;
apply_data(connection, model, &pending, MigrationPhase::Before)?;
if previous.revision == model.schema.revision {
create_indexes(connection, &model.create_indexes)?;
} else if fields_match(&previous.fields, &model.schema.fields) {
synchronize_indexes(connection, previous, model)?;
} else {
rebuild_table(connection, previous, model, &pending)?;
}
apply_data(connection, model, &pending, MigrationPhase::After)
}
fn apply_data(
connection: &Connection,
model: &Model,
pending: &[Migration],
phase: MigrationPhase,
) -> Result<(), Error> {
for statement in &model.data_migrations {
if statement.phase != phase
|| !pending
.iter()
.any(|migration| migration.name == statement.name)
{
continue;
}
let parameters = sqlite_values(statement.parameters.clone());
connection
.execute(
statement.sql.sqlite,
deadpool_sqlite::rusqlite::params_from_iter(parameters),
)
.map_err(database_error)?;
}
Ok(())
}
fn rebuild_table(
connection: &Connection,
previous: &Schema,
model: &Model,
new_migrations: &[Migration],
) -> Result<(), Error> {
let allowed_drops = new_migrations
.iter()
.flat_map(|migration| migration.drops.iter().cloned())
.collect::<BTreeSet<_>>();
let previous_fields = previous
.fields
.iter()
.map(|field| (field.name.as_str(), field))
.collect::<BTreeMap<_, _>>();
let mut copied = Vec::new();
let mut consumed = BTreeSet::new();
for target in &model.schema.fields {
let source = previous_fields
.get(target.name.as_str())
.copied()
.or_else(|| {
target
.rename_from
.as_deref()
.and_then(|name| previous_fields.get(name).copied())
});
let Some(source) = source else {
continue;
};
if !compatible_types(&source.ty, &target.ty) {
return Err(Error::migration(format!(
"field '{}.{}' cannot change type from '{}' to '{}' automatically",
model.schema.model, target.name, source.ty, target.ty
)));
}
if !consumed.insert(source.name.as_str()) {
return Err(Error::migration(format!(
"field '{}.{}' reuses migration source '{}'",
model.schema.model, target.name, source.name
)));
}
copied.push((target.name.as_str(), source.name.as_str()));
}
for source in &previous.fields {
if !consumed.contains(source.name.as_str()) && !allowed_drops.contains(&source.name) {
return Err(Error::migration(format!(
"field '{}.{}' was removed without a named migrate drop",
model.schema.model, source.name
)));
}
}
connection
.execute_batch(&model.create_temporary_table)
.map_err(database_error)?;
let targets = copied
.iter()
.map(|(target, _)| quote(target))
.collect::<Vec<_>>()
.join(", ");
let sources = copied
.iter()
.map(|(_, source)| quote(source))
.collect::<Vec<_>>()
.join(", ");
let temporary = temporary_table(&model.schema);
connection
.execute_batch(&format!(
"INSERT INTO {} ({targets}) SELECT {sources} FROM {};\nDROP TABLE {};\nALTER TABLE {} RENAME TO {};",
quote(&temporary),
quote(&model.schema.table),
quote(&model.schema.table),
quote(&temporary),
quote(&model.schema.table),
))
.map_err(database_error)?;
create_indexes(connection, &model.create_indexes)?;
let mut foreign_keys = connection
.prepare("PRAGMA foreign_key_check")
.map_err(database_error)?;
if foreign_keys
.query([])
.map_err(database_error)?
.next()
.map_err(database_error)?
.is_some()
{
return Err(Error::migration(format!(
"Model '{}' migration would leave invalid foreign keys",
model.schema.model
)));
}
Ok(())
}
fn synchronize_indexes(
connection: &Connection,
previous: &Schema,
model: &Model,
) -> Result<(), Error> {
for index in &previous.indexes {
connection
.execute_batch(&format!("DROP INDEX IF EXISTS {}", quote(&index.name)))
.map_err(database_error)?;
}
create_indexes(connection, &model.create_indexes)
}
fn create_indexes(
connection: &Connection,
indexes: &[crate::database::SchemaSql],
) -> Result<(), Error> {
for index in indexes {
connection
.execute_batch(&index.sqlite)
.map_err(database_error)?;
}
Ok(())
}
fn apply_seed(
connection: &Connection,
model: &Model,
previous: Option<&Schema>,
) -> Result<(), Error> {
if previous.is_some_and(|schema| schema.seed_revision == model.schema.seed_revision) {
return Ok(());
}
for seed in &model.seeds {
let parameters = sqlite_values(seed.parameters.clone());
connection
.execute(
seed.sql.sqlite,
deadpool_sqlite::rusqlite::params_from_iter(parameters),
)
.map_err(database_error)?;
}
Ok(())
}
fn validate_catalog(connection: &Connection, schema: &Schema) -> Result<(), Error> {
let recorded = load_catalog(connection, &schema.model)?.ok_or_else(|| {
Error::migration(format!(
"Model '{}' has no recorded database catalog",
schema.model
))
})?;
if catalog(connection, &schema.table)? != recorded {
return Err(Error::migration(format!(
"table '{}' does not match its recorded Dever schema",
schema.table
)));
}
Ok(())
}
fn validate_migrations(connection: &Connection, schema: &Schema) -> Result<(), Error> {
let applied = load_migrations(connection, &schema.model)?;
let desired = schema
.migrations
.iter()
.map(|migration| (migration.name.as_str(), migration))
.collect::<BTreeMap<_, _>>();
for (name, revision) in applied {
let Some(migration) = desired.get(name.as_str()) else {
return Err(Error::migration(format!(
"applied migration '{}.{}' was removed from source",
schema.model, name
)));
};
if migration.revision != revision {
return Err(Error::migration(format!(
"applied migration '{}.{}' was modified",
schema.model, name
)));
}
}
Ok(())
}
fn unapplied_migrations(connection: &Connection, schema: &Schema) -> Result<Vec<Migration>, Error> {
let applied = load_migrations(connection, &schema.model)?;
Ok(schema
.migrations
.iter()
.filter(|migration| !applied.contains_key(&migration.name))
.cloned()
.collect())
}
fn table_exists(connection: &Connection, table: &str) -> Result<bool, Error> {
connection
.query_row(
"SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
[table],
|_| Ok(()),
)
.optional()
.map(|value| value.is_some())
.map_err(database_error)
}
fn load_schema(connection: &Connection, model: &str) -> Result<Option<Schema>, Error> {
let definition = connection
.query_row(
"SELECT definition FROM _dever_history WHERE model = ?1 AND kind = 'schema' AND name = 'current'",
[model],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(database_error)?;
definition
.map(|definition| {
serde_json::from_str(&definition)
.map_err(|error| Error::migration(format!("invalid Dever schema history: {error}")))
})
.transpose()
}
fn load_catalog(connection: &Connection, model: &str) -> Result<Option<String>, Error> {
connection
.query_row(
"SELECT definition FROM _dever_history WHERE model = ?1 AND kind = 'catalog' AND name = 'current'",
[model],
|row| row.get(0),
)
.optional()
.map_err(database_error)
}
fn catalog(connection: &Connection, table: &str) -> Result<String, Error> {
let table_sql = connection
.query_row(
"SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?1",
[table],
|row| row.get::<_, String>(0),
)
.map_err(database_error)?;
let mut statement = connection
.prepare(
"SELECT name, sql FROM sqlite_schema WHERE type = 'index' AND tbl_name = ?1 AND substr(name, 1, 11) = '_dever_idx_' ORDER BY name",
)
.map_err(database_error)?;
let indexes = statement
.query_map([table], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(database_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(database_error)?;
serde_json::to_string(&(table_sql, indexes))
.map_err(|error| Error::migration(format!("cannot encode SQLite catalog: {error}")))
}
fn load_migrations(
connection: &Connection,
model: &str,
) -> Result<BTreeMap<String, String>, Error> {
let mut statement = connection
.prepare(
"SELECT name, revision FROM _dever_history WHERE model = ?1 AND kind = 'migration'",
)
.map_err(database_error)?;
statement
.query_map([model], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(database_error)?
.collect::<Result<BTreeMap<_, _>, _>>()
.map_err(database_error)
}
fn store_history(connection: &Connection, schema: &Schema) -> Result<(), Error> {
let definition = serde_json::to_string(schema).map_err(|error| {
Error::migration(format!("cannot encode Dever schema history: {error}"))
})?;
store_history_row(
connection,
&schema.model,
"schema",
"current",
&schema.revision,
&definition,
)?;
for migration in &schema.migrations {
store_history_row(
connection,
&schema.model,
"migration",
&migration.name,
&migration.revision,
"",
)?;
}
store_history_row(
connection,
&schema.model,
"seed",
"current",
&schema.seed_revision,
"",
)?;
store_history_row(
connection,
&schema.model,
"catalog",
"current",
&schema.revision,
&catalog(connection, &schema.table)?,
)
}
fn store_history_row(
connection: &Connection,
model: &str,
kind: &str,
name: &str,
revision: &str,
definition: &str,
) -> Result<(), Error> {
connection
.execute(
"INSERT INTO _dever_history (model, kind, name, revision, definition) VALUES (?1, ?2, ?3, ?4, ?5) \
ON CONFLICT (model, kind, name) DO UPDATE SET revision = excluded.revision, definition = excluded.definition, applied_at = unixepoch() WHERE _dever_history.kind <> 'migration'",
params![model, kind, name, revision, definition],
)
.map(|_| ())
.map_err(database_error)
}
fn fields_match(previous: &[Field], desired: &[Field]) -> bool {
previous == desired
}
fn compatible_types(previous: &str, desired: &str) -> bool {
previous == desired
|| (previous.starts_with("Text") && desired.starts_with("Text"))
|| (previous.starts_with("Bytes") && desired.starts_with("Bytes"))
|| (previous == "Int" && desired == "Float")
|| (sqlite_integer_reference(previous) && sqlite_integer_reference(desired))
}
fn sqlite_integer_reference(ty: &str) -> bool {
ty == "Int" || ty.starts_with("id:")
}
pub fn temporary_table(schema: &Schema) -> String {
format!("_dever_{}_{}", schema.table, &schema.revision[..8])
}
fn quote(identifier: &str) -> String {
format!("\"{}\"", identifier.replace('"', "\"\""))
}