From edcd182d1ac769fd38d0aab0d3db5051c6a8ba1b Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 2 Jun 2026 03:00:20 +0000 Subject: [PATCH 01/24] feat!: Add support for rolling back migrations --- Cargo.lock | 2 + cot/Cargo.toml | 2 +- cot/src/cli.rs | 149 +++++++++++++++++++++++++++++++- cot/src/db/migrations.rs | 109 ++++++++++++++++++++++- cot/src/db/migrations/sorter.rs | 14 ++- cot/src/utils/graph.rs | 4 + 6 files changed, 270 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee5f69c17..cfd9d78ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1675,6 +1675,8 @@ dependencies = [ "chrono", "chrono-tz", "cot", + "tracing", + "tracing-subscriber", ] [[package]] diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 75e9548d8..bc0067e35 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -24,7 +24,7 @@ blake3.workspace = true bytes.workspace = true chrono = { workspace = true, features = ["alloc", "serde", "clock"] } chrono-tz.workspace = true -clap.workspace = true +clap = {workspace = true, features = ["string"] } cot_core.workspace = true cot_macros.workspace = true deadpool-redis = { workspace = true, features = ["tokio-comp", "rt_tokio_1"], optional = true } diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 97652a2e1..5601b252d 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -4,18 +4,21 @@ use std::collections::HashMap; use std::path::PathBuf; use std::str::FromStr; +use crate::{Bootstrapper, Error, Result}; use async_trait::async_trait; pub use clap; use clap::{Arg, ArgMatches, Command, value_parser}; +use cot::db::migrations::{MigrationEngine, SyncDynMigration}; +use cot::project::BootstrappedProject; use derive_more::Debug; -use crate::{Bootstrapper, Error, Result}; - const CONFIG_PARAM: &str = "config"; const COLLECT_STATIC_SUBCOMMAND: &str = "collect-static"; const CHECK_SUBCOMMAND: &str = "check"; const LISTEN_PARAM: &str = "listen"; const COLLECT_STATIC_DIR_PARAM: &str = "dir"; +const MIGRATION_GROUP_SUBCOMMAND: &str = "migration"; +const MIGRATION_ROLLBACK_SUBCOMMAND: &str = "rollback"; /// A central point for configuring the default Command Line Interface (CLI) for /// Cot-powered projects. @@ -91,6 +94,12 @@ impl Cli { cli.add_task(Check); cli.add_task(CollectStatic); + let mut migration_group = + CliTaskGroup::new("migration").about("Database migration commands"); + migration_group.add_task(MigrationRollback); + + cli.add_task(migration_group); + cli } @@ -389,6 +398,142 @@ impl CliTask for Check { } } +/// A group of related sub-tasks under a single parent subcommand. +/// +/// Usage: +/// ``` +/// let mut migration = CliTaskGroup::new("migration") +/// .about("Database migration commands"); +/// migration.add_task(MigrationRollback); +/// migration.add_task(MigrationSquash); +/// cli.add_task(migration); +/// ``` +pub struct CliTaskGroup { + name: String, + about: String, + tasks: HashMap>, +} + +impl CliTaskGroup { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + about: String::new(), + tasks: HashMap::new(), + } + } + + pub fn about(mut self, about: impl Into) -> Self { + self.about = about.into(); + self + } + + pub fn add_task(&mut self, task: C) { + let subcommand = task.subcommand(); + let name = subcommand.get_name().to_owned(); + + assert!( + !self.tasks.contains_key(&name), + "Task with name {name} already exists in group '{}'", + self.name + ); + + self.tasks.insert(name, Box::new(task)); + } +} + +#[async_trait(?Send)] +impl CliTask for CliTaskGroup { + fn subcommand(&self) -> Command { + let name = self.name.clone(); + let mut cmd = Command::new(name); + + if !self.about.is_empty() { + cmd = cmd.about(self.about.clone()); + } + + cmd = cmd.subcommand_required(true).arg_required_else_help(true); + for task in self.tasks.values() { + cmd = cmd.subcommand(task.subcommand()); + } + cmd + } + + async fn execute( + &mut self, + matches: &ArgMatches, + bootstrapper: Bootstrapper, + ) -> Result<()> { + let (sub_name, sub_matches) = matches + .subcommand() + .expect("subcommand_required(true) ensures one is present"); + + self.tasks + .get_mut(sub_name) + .expect("clap only matches registered subcommands") + .execute(sub_matches, bootstrapper) + .await + } +} + +struct MigrationRollback; + +#[async_trait(?Send)] +impl CliTask for MigrationRollback { + fn subcommand(&self) -> Command { + Command::new(MIGRATION_ROLLBACK_SUBCOMMAND) + .about("Rollback migrations up to the specified migration file") + .arg( + Arg::new("file") + .help("The migration filename to roll back to (e.g. 0001_create_users)") + .value_name("FILE") + .required(true), + ) + } + + async fn execute( + &mut self, + matches: &ArgMatches, + bootstrapper: Bootstrapper, + ) -> Result<()> { + let file = matches + .get_one::("file") + .expect("required argument"); + + let bootstrapper = bootstrapper + .with_apps() + .with_database() + .await? + .with_cache() + .await? + .boot() + .await?; + + // migrations are currently tied to crates, so we use the crate name as the app name. + // TODO: cli command should take an explicit crate name as arg when workspaces are supported. + let crate_name = bootstrapper.project().cli_metadata().name; + + let BootstrappedProject { + mut context, + mut handler, + mut error_handler, + } = bootstrapper.finish(); + + #[cfg(feature = "db")] + { + let mut migrations: Vec> = Vec::new(); + for app in context.apps() { + migrations.extend(app.migrations()); + } + let migration_engine = MigrationEngine::new(migrations)?; + migration_engine + .rollback(context.database(), file, crate_name) + .await?; + } + Ok(()) + } +} + /// A macro to generate a [`CliMetadata`] struct from the Cargo manifest. #[macro_export] macro_rules! metadata { diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 51dc19761..c81c8fc5d 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -2,12 +2,13 @@ mod sorter; +pub use cot_macros::migration_op; +use sea_query::{ColumnDef, StringLen}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::fmt; use std::fmt::{Debug, Formatter}; use std::future::Future; - -pub use cot_macros::migration_op; -use sea_query::{ColumnDef, StringLen}; +use std::path::{Path, PathBuf}; use thiserror::Error; use tracing::{Level, info}; @@ -215,6 +216,92 @@ impl MigrationEngine { Ok(()) } + pub async fn rollback(&self, database: &Database, file: &str, app_name: &str) -> Result<()> { + let rollback_plan = self.rollback_plan(file, app_name)?; + + for migration in rollback_plan { + if !Self::is_migration_applied(database, migration).await? { + continue; + } + + let span = tracing::span!( + Level::TRACE, + "rollback_migration", + app_name = migration.app_name(), + migration_name = migration.name() + ); + let _enter = span.enter(); + + info!( + "Rolling back migration {} for app {}", + migration.name(), + migration.app_name() + ); + + for operation in migration.operations().iter().rev() { + operation.backwards(database).await?; + } + + Self::unapply_migration(database, migration).await?; + } + + Ok(()) + } + + fn rollback_plan<'a>( + &'a self, + file: &str, + app_name: &str, + ) -> Result> { + let target_index = self + .migrations + .iter() + .position(|migration| { + migration.app_name() == app_name + && resolve_migration_file_name(migration.name()).contains(&file) + }) + .ok_or_else(|| { + MigrationEngineError::Custom(format!( + "Migration with file name {file} not found for app {app_name}" + )) + })?; + + let mut rollback_indices = HashSet::new(); + // seed all possible migration files in the same app that are dependent on the target migration + rollback_indices.extend( + self.migrations + .iter() + .enumerate() + .filter(|(index, migration)| { + *index > target_index && migration.app_name() == app_name + }) + .map(|(index, _)| index), + ); + + let graph = MigrationSorter::generate_graph(&self.migrations).map_err(|e| { + MigrationEngineError::Custom(format!("Failed to generate migration graph: {}", e)) + })?; + let mut queue = rollback_indices.iter().copied().collect::>(); + // go through every migration dependent on the target migration and add any dependencies to the queue. + // This is also useful in cases where a migration is depended on by migrations in other apps. In this case, + // we need to make sure that we also roll back the migrations in other apps. + while let Some(index) = queue.pop_front() { + for &dependent_index in graph.get_edges(index) { + if rollback_indices.insert(dependent_index) { + queue.push_back(dependent_index); + } + } + } + + Ok(self + .migrations + .iter() + .enumerate() + .rev() + .filter_map(|(index, migration)| rollback_indices.contains(&index).then_some(migration)) + .collect()) + } + async fn is_migration_applied( database: &Database, migration: &MigrationWrapper, @@ -241,6 +328,22 @@ impl MigrationEngine { database.insert(&mut applied_migration).await?; Ok(()) } + + async fn unapply_migration(database: &Database, migration: &MigrationWrapper) -> Result<()> { + query!(AppliedMigration, $app == migration.app_name() && $name == migration.name()) + .delete(database) + .await?; + Ok(()) + } +} + +fn resolve_migration_file_name(file_name: &str) -> Vec<&str> { + let mut names = vec![file_name]; + let migration_number = file_name.split('_').nth(1); + if let Some(migration_number) = migration_number { + names.push(migration_number); + } + names } /// A migration operation that can be run forwards or backwards. diff --git a/cot/src/db/migrations/sorter.rs b/cot/src/db/migrations/sorter.rs index e2d5c7e66..46faf1f96 100644 --- a/cot/src/db/migrations/sorter.rs +++ b/cot/src/db/migrations/sorter.rs @@ -61,11 +61,11 @@ impl<'a, T: DynMigration> MigrationSorter<'a, T> { Ok(()) } - fn toposort(&mut self) -> Result<()> { - let lookup = Self::create_lookup_table(self.migrations)?; - let mut graph = Graph::new(self.migrations.len()); + pub(super) fn generate_graph(migrations: &[T]) -> Result { + let lookup = Self::create_lookup_table(migrations)?; + let mut graph = Graph::new(migrations.len()); - for (index, migration) in self.migrations.iter().enumerate() { + for (index, migration) in migrations.iter().enumerate() { for dependency in migration.dependencies() { let dependency_index = lookup .get(&MigrationLookup::from(dependency)) @@ -74,6 +74,12 @@ impl<'a, T: DynMigration> MigrationSorter<'a, T> { } } + Ok(graph) + } + + fn toposort(&mut self) -> Result<()> { + let mut graph = Self::generate_graph(self.migrations)?; + let mut sorted_indices = graph.toposort()?; apply_permutation(self.migrations, &mut sorted_indices); diff --git a/cot/src/utils/graph.rs b/cot/src/utils/graph.rs index 741f98400..bf6053efc 100644 --- a/cot/src/utils/graph.rs +++ b/cot/src/utils/graph.rs @@ -40,6 +40,10 @@ impl Graph { self.vertex_edges[from].push(to); } + pub(crate) fn get_edges(&self, from: usize) -> &[usize] { + &self.vertex_edges[from] + } + #[must_use] pub(crate) fn vertex_num(&self) -> usize { self.vertex_edges.len() From d069460be4986f8c4d851b1bef781be9f4a663c4 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 4 Jun 2026 23:00:12 +0000 Subject: [PATCH 02/24] add unit tests --- cot/src/cli.rs | 251 +++++++++++++++++++++++++++++++++++++-- cot/src/db/migrations.rs | 226 ++++++++++++++++++++++++++++++++++- 2 files changed, 459 insertions(+), 18 deletions(-) diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 5601b252d..6a4cec02c 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -95,7 +95,7 @@ impl Cli { cli.add_task(CollectStatic); let mut migration_group = - CliTaskGroup::new("migration").about("Database migration commands"); + CliTaskGroup::new(MIGRATION_GROUP_SUBCOMMAND).about("Database migration commands"); migration_group.add_task(MigrationRollback); cli.add_task(migration_group); @@ -400,13 +400,42 @@ impl CliTask for Check { /// A group of related sub-tasks under a single parent subcommand. /// -/// Usage: +/// # Examples +/// /// ``` -/// let mut migration = CliTaskGroup::new("migration") -/// .about("Database migration commands"); -/// migration.add_task(MigrationRollback); -/// migration.add_task(MigrationSquash); -/// cli.add_task(migration); +/// use async_trait::async_trait; +/// use clap::{ArgMatches, Command}; +/// use cot::cli::{Cli, CliTask}; +/// use cot::project::WithConfig; +/// use cot::{Bootstrapper, Project}; +/// +/// struct Frobnicate; +/// +/// #[async_trait(?Send)] +/// impl CliTask for Frobnicate { +/// fn subcommand(&self) -> Command { +/// Command::new("frobnicate") +/// } +/// +/// async fn execute( +/// &mut self, +/// _matches: &ArgMatches, +/// _bootstrapper: Bootstrapper, +/// ) -> cot::Result<()> { +/// println!("Frobnicating..."); +/// +/// Ok(()) +/// } +/// } +/// +/// struct MyProject; +/// impl Project for MyProject { +/// fn register_tasks(&self, cli: &mut Cli) { +/// group_command = CliTaskGroup::new("foo").about("Foo related commands"); +/// group_command.add_task(Frobnicate) +/// cli.add_task(group_command); +/// } +/// } /// ``` pub struct CliTaskGroup { name: String, @@ -466,11 +495,11 @@ impl CliTask for CliTaskGroup { ) -> Result<()> { let (sub_name, sub_matches) = matches .subcommand() - .expect("subcommand_required(true) ensures one is present"); + .expect("subcommand should be present since subcommand_required is true"); self.tasks .get_mut(sub_name) - .expect("clap only matches registered subcommands") + .expect("command should be registered") .execute(sub_matches, bootstrapper) .await } @@ -514,9 +543,9 @@ impl CliTask for MigrationRollback { let crate_name = bootstrapper.project().cli_metadata().name; let BootstrappedProject { - mut context, - mut handler, - mut error_handler, + context, + handler: _, + error_handler: _, } = bootstrapper.finish(); #[cfg(feature = "db")] @@ -559,11 +588,114 @@ mod tests { use tempfile::tempdir; use super::*; + #[cfg(feature = "db")] + use crate::config::DatabaseConfig; use crate::config::ProjectConfig; + #[cfg(feature = "db")] + use crate::db::migrations::{ + Field, Migration, MigrationDependency, Operation, wrap_migrations, + }; + #[cfg(feature = "db")] + use crate::db::{Auto, Database, DatabaseField, Identifier, query}; use crate::project::RegisterAppsContext; use crate::static_files::StaticFile; use crate::{App, AppBuilder}; + #[cfg(feature = "db")] + struct CliRollbackInitial; + + #[cfg(feature = "db")] + impl Migration for CliRollbackInitial { + const APP_NAME: &'static str = "cli_rollback_app"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("cli_rollback_app__initial")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + #[cfg(feature = "db")] + struct CliRollbackSecond; + + #[cfg(feature = "db")] + impl Migration for CliRollbackSecond { + const APP_NAME: &'static str = "cli_rollback_app"; + const MIGRATION_NAME: &'static str = "m_0002_second"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "cli_rollback_app", + "m_0001_initial", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("cli_rollback_app__second")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + #[cfg(feature = "db")] + struct CliRollbackApp; + + #[cfg(feature = "db")] + impl App for CliRollbackApp { + fn name(&self) -> &'static str { + "cli_rollback_app" + } + + fn migrations(&self) -> Vec> { + #[expect(trivial_casts)] + wrap_migrations(&[ + &CliRollbackInitial as &SyncDynMigration, + &CliRollbackSecond as &SyncDynMigration, + ]) + } + } + + #[cfg(feature = "db")] + struct CliRollbackProject; + + #[cfg(feature = "db")] + impl crate::Project for CliRollbackProject { + fn cli_metadata(&self) -> CliMetadata { + CliMetadata { + name: "cli_rollback_app", + version: "0.0.0", + authors: "", + description: "", + } + } + + fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { + apps.register(CliRollbackApp); + } + } + + #[cfg(feature = "db")] + #[derive(::std::fmt::Debug)] + #[crate::db::model(table_name = "cot__migrations", model_type = "internal")] + struct CliAppliedMigration { + #[model(primary_key)] + id: Auto, + app: String, + name: String, + applied: chrono::DateTime, + } + + #[cfg(feature = "db")] + async fn cli_migration_applied(database: &Database, app: &str, name: &str) -> bool { + query!(CliAppliedMigration, $app == app && $name == name) + .exists(database) + .await + .unwrap() + } + #[test] fn cli_new() { let cli = Cli::new(); @@ -621,6 +753,61 @@ mod tests { ); } + #[test] + fn cli_new_includes_migration_rollback_group() { + let cli = Cli::new(); + + let migration_group = cli + .command + .get_subcommands() + .find(|command| command.get_name() == MIGRATION_GROUP_SUBCOMMAND) + .expect("migration group is registered"); + + assert!( + migration_group + .get_subcommands() + .any(|command| command.get_name() == MIGRATION_ROLLBACK_SUBCOMMAND) + ); + } + + #[cot::test] + async fn cli_task_group_dispatches_nested_task() { + use std::sync::atomic::{AtomicBool, Ordering}; + + struct NestedTask; + #[async_trait(?Send)] + impl CliTask for NestedTask { + fn subcommand(&self) -> Command { + Command::new("nested") + } + + async fn execute( + &mut self, + _matches: &ArgMatches, + _bootstrapper: Bootstrapper, + ) -> Result<()> { + TASK_CALLED.store(true, Ordering::SeqCst); + Ok(()) + } + } + + struct TestProject; + impl crate::Project for TestProject {} + + static TASK_CALLED: AtomicBool = AtomicBool::new(false); + TASK_CALLED.store(false, Ordering::SeqCst); + + + let mut group = CliTaskGroup::new("group"); + group.add_task(NestedTask); + let matches = group.subcommand().get_matches_from(["group", "nested"]); + let bootstrapper = Bootstrapper::new(TestProject).with_config(ProjectConfig::default()); + + group.execute(&matches, bootstrapper).await.unwrap(); + + assert!(TASK_CALLED.load(Ordering::SeqCst)); + } + #[test] fn run_server_subcommand() { let matches = RunServer @@ -669,6 +856,46 @@ mod tests { assert!(temp_path.join("test.txt").exists()); } + #[cot::test] + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" + )] + #[cfg(feature = "db")] + async fn migration_rollback_execute_rolls_back_project_app() { + let temp_dir = tempdir().unwrap(); + let db_path = temp_dir.path().join("rollback.sqlite"); + std::fs::File::create(&db_path).unwrap(); + let db_url = format!("sqlite://{}", db_path.display()); + + let setup_database = Database::new(db_url.clone()).await.unwrap(); + let setup_engine = MigrationEngine::new(CliRollbackApp.migrations()).unwrap(); + setup_engine.run(&setup_database).await.unwrap(); + assert!(cli_migration_applied(&setup_database, "cli_rollback_app", "m_0001_initial").await); + assert!(cli_migration_applied(&setup_database, "cli_rollback_app", "m_0002_second").await); + drop(setup_engine); + drop(setup_database); + + let config = ProjectConfig::builder() + .database(DatabaseConfig::builder().url(db_url.clone()).build()) + .build(); + let mut rollback = MigrationRollback; + let matches = MigrationRollback + .subcommand() + .get_matches_from(["rollback", "0001"]); + let bootstrapper = Bootstrapper::new(CliRollbackProject).with_config(config); + + rollback.execute(&matches, bootstrapper).await.unwrap(); + + let verify_database = Database::new(db_url).await.unwrap(); + assert!( + cli_migration_applied(&verify_database, "cli_rollback_app", "m_0001_initial").await + ); + assert!( + !cli_migration_applied(&verify_database, "cli_rollback_app", "m_0002_second").await + ); + } + #[cot::test] async fn check_execute() { let config = r#"secret_key = "123abc""#; diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index c81c8fc5d..8216fc301 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -4,11 +4,10 @@ mod sorter; pub use cot_macros::migration_op; use sea_query::{ColumnDef, StringLen}; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{HashSet, VecDeque}; use std::fmt; use std::fmt::{Debug, Formatter}; use std::future::Future; -use std::path::{Path, PathBuf}; use thiserror::Error; use tracing::{Level, info}; @@ -216,6 +215,8 @@ impl MigrationEngine { Ok(()) } + /// Rollback necessary migrations up until the specified migration in an app. + /// pub async fn rollback(&self, database: &Database, file: &str, app_name: &str) -> Result<()> { let rollback_plan = self.rollback_plan(file, app_name)?; @@ -267,7 +268,8 @@ impl MigrationEngine { })?; let mut rollback_indices = HashSet::new(); - // seed all possible migration files in the same app that are dependent on the target migration + // Seed later migrations in the same app, then include migrations from + // other apps only when they depend on that seed set. rollback_indices.extend( self.migrations .iter() @@ -282,9 +284,6 @@ impl MigrationEngine { MigrationEngineError::Custom(format!("Failed to generate migration graph: {}", e)) })?; let mut queue = rollback_indices.iter().copied().collect::>(); - // go through every migration dependent on the target migration and add any dependencies to the queue. - // This is also useful in cases where a migration is depended on by migrations in other apps. In this case, - // we need to make sure that we also roll back the migrations in other apps. while let Some(index) = queue.pop_front() { for &dependent_index in graph.get_edges(index) { if rollback_indices.insert(dependent_index) { @@ -2130,6 +2129,7 @@ mod tests { use super::*; use crate::db::{ColumnType, DatabaseField, Identifier}; + use crate::{App, auth::db::DatabaseUserApp, session::db::SessionApp}; struct TestMigration; @@ -2157,6 +2157,110 @@ mod tests { const OPERATIONS: &'static [Operation] = &[]; } + struct RollbackApp1Initial; + + impl Migration for RollbackApp1Initial { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_single__first")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + struct RollbackApp10002; + + impl Migration for RollbackApp10002 { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0002_second"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0001_initial", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_app1__second")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + struct RollbackApp1003; + + impl Migration for RollbackApp1003 { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0003_third"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0002_second", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_single__third")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + struct RollbackApp2Initial; + + impl Migration for RollbackApp2Initial { + const APP_NAME: &'static str = "rollback_app2"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_app2__foo")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + struct RollbackDependentInitial; + + impl Migration for RollbackDependentInitial { + const APP_NAME: &'static str = "rollback_dependent"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0002_second", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_dependent__bar")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; + } + + async fn assert_migration_applied(database: Database, app: &str, name: &str, expected: bool) { + let applied = query!(AppliedMigration, $app == app && $name == name) + .exists(&database) + .await + .unwrap(); + + assert_eq!(applied, expected, "{app}::{name}"); + } + + #[cot_macros::dbtest] + async fn test_migration_rollback_no_deps(test_db: &mut TestDatabase) { + let engine = MigrationEngine::new([RollbackApp1Initial]).unwrap(); + engine.run(&test_db.database()).await.unwrap(); + } + #[cot_macros::dbtest] async fn test_migration_engine_run(test_db: &mut TestDatabase) { let engine = MigrationEngine::new([TestMigration]).unwrap(); @@ -2180,6 +2284,116 @@ mod tests { assert!(result.is_ok()); } + #[cot_macros::dbtest] + async fn test_migration_engine_rollback_single_app(test_db: &mut TestDatabase) { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp1003 as &SyncDynMigration, + ]) + .unwrap(); + + engine.run(&test_db.database()).await.unwrap(); + // migrations should be applied + + assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", true).await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0003_third", true).await; + + // rollback everything except the initial migration + engine + .rollback(&test_db.database(), "0001", "rollback_app1") + .await + .unwrap(); + + // the initial migration should stay applied + assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; + // everything else should be unapplied + assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", false).await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0003_third", false).await; + } + + #[cot_macros::dbtest] + async fn test_migration_engine_rollback_preserves_unrelated_apps(test_db: &mut TestDatabase) { + let mut migrations = DatabaseUserApp::new().migrations(); + // combine migrations from multiple apps/crates + #[expect(trivial_casts)] + migrations.extend(wrap_migrations(&[ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ])); + migrations.extend(SessionApp::new().migrations()); + let engine = MigrationEngine::new(migrations).unwrap(); + + engine.run(&test_db.database()).await.unwrap(); + // migrations should be applied across all apps + assert_migration_applied(test_db.database(), "cot", "m_0001_initial", true).await; + assert_migration_applied(test_db.database(), "cot_session", "m_0001_initial", true).await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", true).await; + assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; + + // rollback every migration in the rollback_mixed app except the initial + engine + .rollback(&test_db.database(), "0001", "rollback_app1") + .await + .unwrap(); + + // the initial migration should stay applied + assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; + // everything else in the rollback_mixed app should be unapplied + assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", false).await; + // migrations from other apps should remain unaffected + assert_migration_applied(test_db.database(), "cot", "m_0001_initial", true).await; + assert_migration_applied(test_db.database(), "cot_session", "m_0001_initial", true).await; + assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; + } + + #[cot_macros::dbtest] + async fn test_migration_engine_rollback_includes_dependent_apps(test_db: &mut TestDatabase) { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackDependentInitial as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ]) + .unwrap(); + + engine.run(&test_db.database()).await.unwrap(); + assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", true).await; + assert_migration_applied( + test_db.database(), + "rollback_dependent", + "m_0001_initial", + true, + ) + .await; + assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; + + // rollback everything except the initial migration in the source/independent app + engine + .rollback(&test_db.database(), "0001", "rollback_app1") + .await + .unwrap(); + + assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", false).await; + // the sink/dependent app should also be unapplied/rolled back + assert_migration_applied( + test_db.database(), + "rollback_dependent", + "m_0001_initial", + false, + ) + .await; + // migrations from non-dependent apps should remain unaffected + assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; + } + #[test] fn test_operation_create_model() { const OPERATION_CREATE_MODEL_FIELDS: &[Field; 2] = &[ From 965f4829416d2f8f6b50bfe51c294f74b2e74397 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:00:29 +0000 Subject: [PATCH 03/24] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot/src/cli.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 6a4cec02c..22ffb7f83 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -797,7 +797,6 @@ mod tests { static TASK_CALLED: AtomicBool = AtomicBool::new(false); TASK_CALLED.store(false, Ordering::SeqCst); - let mut group = CliTaskGroup::new("group"); group.add_task(NestedTask); let matches = group.subcommand().get_matches_from(["group", "nested"]); From 851faff7a0155b1c4d4fc6cef67351a9de222e2b Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 4 Jun 2026 23:34:47 +0000 Subject: [PATCH 04/24] clippy fix --- cot/src/cli.rs | 63 ++++++++++++++++++++++++++++++++++++++++ cot/src/db/migrations.rs | 17 +++++++---- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 22ffb7f83..472892e5d 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -437,13 +437,24 @@ impl CliTask for Check { /// } /// } /// ``` +#[derive(Debug)] pub struct CliTaskGroup { name: String, about: String, + #[debug("..")] tasks: HashMap>, } impl CliTaskGroup { + /// Create a subcommand group. + /// + /// # Examples + /// + /// ``` + /// use cot::cli::CliTaskGroup; + /// + /// let group = CliTaskGroup::new("command"); + /// ``` pub fn new(name: impl Into) -> Self { Self { name: name.into(), @@ -452,11 +463,63 @@ impl CliTaskGroup { } } + /// Sets the description of the group, which is displayed in the help message + /// for the group's subcommands. + /// + /// # Example + /// ``` + /// let group = CliTaskGroup::new("command") + /// .about("This is a description for the command group"); + /// ``` + #[must_use] pub fn about(mut self, about: impl Into) -> Self { self.about = about.into(); self } + /// Adds a new task to the subcommand group. + /// + /// # Panics + /// + /// Panics if a task with the same name has already been registered. + /// + /// # Examples + /// + /// ``` + /// use async_trait::async_trait; + /// use clap::{ArgMatches, Command}; + /// use cot::cli::{Cli, CliTask}; + /// use cot::project::WithConfig; + /// use cot::{Bootstrapper, Project}; + /// + /// struct Frobnicate; + /// + /// #[async_trait(?Send)] + /// impl CliTask for Frobnicate { + /// fn subcommand(&self) -> Command { + /// Command::new("frobnicate") + /// } + /// + /// async fn execute( + /// &mut self, + /// _matches: &ArgMatches, + /// _bootstrapper: Bootstrapper, + /// ) -> cot::Result<()> { + /// println!("Frobnicating..."); + /// + /// Ok(()) + /// } + /// } + /// + /// struct MyProject; + /// impl Project for MyProject { + /// fn register_tasks(&self, cli: &mut Cli) { + /// group_command = CliTaskGroup::new("foo").about("Foo related commands"); + /// group_command.add_task(Frobnicate) + /// cli.add_task(group_command); + /// } + /// } + /// ``` pub fn add_task(&mut self, task: C) { let subcommand = task.subcommand(); let name = subcommand.get_name().to_owned(); diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 8216fc301..d0c367c0b 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -215,9 +215,16 @@ impl MigrationEngine { Ok(()) } - /// Rollback necessary migrations up until the specified migration in an app. + /// Roll back necessary migrations up until the specified migration in an app. /// + /// # Errors + /// + /// Returns an error if there is an error while interacting with the database or if + /// there is an error while generating the migration graph or if there is an error + /// while unapplying a migration. pub async fn rollback(&self, database: &Database, file: &str, app_name: &str) -> Result<()> { + info!("Rolling back migrations"); + let rollback_plan = self.rollback_plan(file, app_name)?; for migration in rollback_plan { @@ -243,7 +250,7 @@ impl MigrationEngine { operation.backwards(database).await?; } - Self::unapply_migration(database, migration).await?; + Self::mark_migration_unapplied(database, migration).await?; } Ok(()) @@ -281,7 +288,7 @@ impl MigrationEngine { ); let graph = MigrationSorter::generate_graph(&self.migrations).map_err(|e| { - MigrationEngineError::Custom(format!("Failed to generate migration graph: {}", e)) + MigrationEngineError::Custom(format!("Failed to generate migration graph: {e}")) })?; let mut queue = rollback_indices.iter().copied().collect::>(); while let Some(index) = queue.pop_front() { @@ -328,7 +335,7 @@ impl MigrationEngine { Ok(()) } - async fn unapply_migration(database: &Database, migration: &MigrationWrapper) -> Result<()> { + async fn mark_migration_unapplied(database: &Database, migration: &MigrationWrapper) -> Result<()> { query!(AppliedMigration, $app == migration.app_name() && $name == migration.name()) .delete(database) .await?; @@ -2335,7 +2342,7 @@ mod tests { assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", true).await; assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; - // rollback every migration in the rollback_mixed app except the initial + // rollback every migration in the rollback_app1 app except the initial engine .rollback(&test_db.database(), "0001", "rollback_app1") .await From 921ef7bb30af91c6042d4294c3394587fd66d05d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:35:01 +0000 Subject: [PATCH 05/24] chore(pre-commit.ci): auto fixes from pre-commit hooks --- cot/src/db/migrations.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index d0c367c0b..1d3c69e79 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -335,7 +335,10 @@ impl MigrationEngine { Ok(()) } - async fn mark_migration_unapplied(database: &Database, migration: &MigrationWrapper) -> Result<()> { + async fn mark_migration_unapplied( + database: &Database, + migration: &MigrationWrapper, + ) -> Result<()> { query!(AppliedMigration, $app == migration.app_name() && $name == migration.name()) .delete(database) .await?; From 45ed53170d60036fcd89ee5c4db7942d8f8c3648 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 5 Jun 2026 00:00:33 +0000 Subject: [PATCH 06/24] fix doc tests --- cot/src/cli.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 472892e5d..558b8ee94 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -405,7 +405,7 @@ impl CliTask for Check { /// ``` /// use async_trait::async_trait; /// use clap::{ArgMatches, Command}; -/// use cot::cli::{Cli, CliTask}; +/// use cot::cli::{Cli, CliTask, CliTaskGroup}; /// use cot::project::WithConfig; /// use cot::{Bootstrapper, Project}; /// @@ -431,8 +431,8 @@ impl CliTask for Check { /// struct MyProject; /// impl Project for MyProject { /// fn register_tasks(&self, cli: &mut Cli) { -/// group_command = CliTaskGroup::new("foo").about("Foo related commands"); -/// group_command.add_task(Frobnicate) +/// let mut group_command = CliTaskGroup::new("foo").about("Foo related commands"); +/// group_command.add_task(Frobnicate); /// cli.add_task(group_command); /// } /// } @@ -468,6 +468,8 @@ impl CliTaskGroup { /// /// # Example /// ``` + /// use cot::cli::CliTaskGroup; + /// /// let group = CliTaskGroup::new("command") /// .about("This is a description for the command group"); /// ``` @@ -488,7 +490,7 @@ impl CliTaskGroup { /// ``` /// use async_trait::async_trait; /// use clap::{ArgMatches, Command}; - /// use cot::cli::{Cli, CliTask}; + /// use cot::cli::{Cli, CliTask, CliTaskGroup}; /// use cot::project::WithConfig; /// use cot::{Bootstrapper, Project}; /// @@ -514,8 +516,8 @@ impl CliTaskGroup { /// struct MyProject; /// impl Project for MyProject { /// fn register_tasks(&self, cli: &mut Cli) { - /// group_command = CliTaskGroup::new("foo").about("Foo related commands"); - /// group_command.add_task(Frobnicate) + /// let mut group_command = CliTaskGroup::new("foo").about("Foo related commands"); + /// group_command.add_task(Frobnicate); /// cli.add_task(group_command); /// } /// } From 92564de1d8a1b07a01ac4821bb0639258019a334 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 8 Jun 2026 16:55:50 +0000 Subject: [PATCH 07/24] update lock --- Cargo.lock | 585 ++++++++++++++++++++++++----------------------------- 1 file changed, 265 insertions(+), 320 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfd9d78ab..23c45e646 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -213,14 +213,14 @@ checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" dependencies = [ "rustc-hash", "unicode-ident", - "winnow 1.0.1", + "winnow 1.0.3", ] [[package]] name = "assert_cmd" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" dependencies = [ "anstyle", "bstr", @@ -332,9 +332,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -431,9 +431,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -444,7 +444,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -471,7 +471,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body", "http-body-util", "mime", @@ -510,9 +510,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -575,9 +575,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -609,20 +609,14 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.58" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -637,14 +631,14 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -726,9 +720,9 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.3" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "660c0520455b1013b9bcb0393d5f643d7e4454fb69c915b8d6d2aa0e9a45acc3" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" dependencies = [ "clap", ] @@ -902,7 +896,7 @@ dependencies = [ "futures-util", "grass_compiler", "hex", - "http 1.4.0", + "http 1.4.2", "http-body-util", "humantime", "idna", @@ -930,7 +924,7 @@ dependencies = [ "thiserror 2.0.18", "time", "tokio", - "toml 1.1.0+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "tower", "tower-livereload", "tower-sessions", @@ -1002,7 +996,7 @@ dependencies = [ "futures", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body", "http-body-util", "indexmap", @@ -1065,9 +1059,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "criterion" @@ -1355,9 +1349,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1402,9 +1396,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -1588,7 +1582,7 @@ checksum = "7737298823a6f9ca743e372e8cb03658d55354fbab843424f575706ba9563046" dependencies = [ "base64", "cookie 0.18.1", - "http 1.4.0", + "http 1.4.2", "http-body-util", "hyper", "hyper-tls", @@ -1605,9 +1599,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -1675,15 +1669,16 @@ dependencies = [ "chrono", "chrono-tz", "cot", - "tracing", - "tracing-subscriber", ] [[package]] name = "fragile" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] [[package]] name = "futures" @@ -1839,7 +1834,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1915,9 +1910,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -1986,9 +1981,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2001,7 +1996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", ] [[package]] @@ -2012,7 +2007,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body", "pin-project-lite", ] @@ -2037,21 +2032,20 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -2083,7 +2077,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body", "hyper", "ipnet", @@ -2134,28 +2128,29 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", - "yoke 0.8.1", + "utf8_iter", + "yoke 0.8.3", "zerofrom", - "zerovec 0.11.5", + "zerovec 0.11.6", ] [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", - "litemap 0.8.1", - "tinystr 0.8.2", - "writeable 0.6.2", - "zerovec 0.11.5", + "litemap 0.8.2", + "tinystr 0.8.3", + "writeable 0.6.3", + "zerovec 0.11.6", ] [[package]] @@ -2172,43 +2167,43 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "icu_collections 2.1.1", + "icu_collections 2.2.0", "icu_normalizer_data", "icu_properties", - "icu_provider 2.1.1", + "icu_provider 2.2.0", "smallvec", - "zerovec 0.11.5", + "zerovec 0.11.6", ] [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "icu_collections 2.1.1", + "icu_collections 2.2.0", "icu_locale_core", "icu_properties_data", - "icu_provider 2.1.1", + "icu_provider 2.2.0", "zerotrie", - "zerovec 0.11.5", + "zerovec 0.11.6", ] [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" @@ -2229,17 +2224,17 @@ dependencies = [ [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", - "writeable 0.6.2", - "yoke 0.8.1", + "writeable 0.6.3", + "yoke 0.8.3", "zerofrom", "zerotrie", - "zerovec 0.11.5", + "zerovec 0.11.6", ] [[package]] @@ -2300,9 +2295,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2315,7 +2310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -2362,16 +2357,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2395,27 +2380,32 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror 2.0.18", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] @@ -2439,9 +2429,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -2484,9 +2474,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lettre" -version = "0.11.21" +version = "0.11.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" dependencies = [ "async-std", "async-trait", @@ -2512,9 +2502,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -2524,14 +2514,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.8.1", ] [[package]] @@ -2559,9 +2549,9 @@ checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -2574,9 +2564,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" dependencies = [ "value-bag", ] @@ -2608,9 +2598,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "mime" @@ -2639,9 +2629,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2683,7 +2673,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.0", + "http 1.4.2", "httparse", "memchr", "mime", @@ -2737,16 +2727,16 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -2817,15 +2807,14 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2849,9 +2838,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2973,7 +2962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -3053,9 +3042,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "plain" @@ -3107,11 +3096,11 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "zerovec 0.11.5", + "zerovec 0.11.6", ] [[package]] @@ -3213,9 +3202,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3224,9 +3213,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3240,7 +3229,7 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -3283,15 +3272,15 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -3309,9 +3298,9 @@ dependencies = [ [[package]] name = "redis" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f44e94c96d8870a387d88ce3de3fdd608cbfc0705f03cb343cdde91509d3e49a" +checksum = "a12e6b5f4d8ef33944e833e2b1859ad478deab6e431d7337b30ee2efe21f7543" dependencies = [ "arcstr", "async-lock", @@ -3341,9 +3330,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" dependencies = [ "bitflags", ] @@ -3399,14 +3388,14 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -3504,9 +3493,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "log", "once_cell", @@ -3519,9 +3508,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3531,18 +3520,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -3684,9 +3673,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -3742,9 +3731,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -3766,9 +3755,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -3818,9 +3807,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -3842,6 +3831,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -3850,9 +3855,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -3871,9 +3876,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4014,7 +4019,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.5", + "rand 0.8.6", "rsa", "serde", "sha1", @@ -4053,7 +4058,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "sha2", @@ -4295,12 +4300,12 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", - "zerovec 0.11.5", + "zerovec 0.11.6", ] [[package]] @@ -4330,9 +4335,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4416,17 +4421,17 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime 1.1.0+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.1", + "winnow 1.0.3", ] [[package]] @@ -4440,39 +4445,39 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime 1.1.0+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.1", + "winnow 1.0.3", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow 1.0.3", ] [[package]] name = "toml_writer" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -4498,7 +4503,7 @@ dependencies = [ "axum-core", "cookie 0.18.1", "futures-util", - "http 1.4.0", + "http 1.4.2", "parking_lot", "pin-project-lite", "tower-layer", @@ -4507,20 +4512,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -4536,7 +4541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df210bd982165dee3c20e31deed79a3585ee6b255c070d9c73ae015cc40d2f3" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", "http-body", "pin-project-lite", "tokio", @@ -4556,7 +4561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" dependencies = [ "async-trait", - "http 1.4.0", + "http 1.4.2", "time", "tokio", "tower-cookies", @@ -4576,9 +4581,9 @@ dependencies = [ "async-trait", "base64", "futures", - "http 1.4.0", + "http 1.4.2", "parking_lot", - "rand 0.9.2", + "rand 0.9.4", "serde", "serde_json", "thiserror 2.0.18", @@ -4701,14 +4706,14 @@ dependencies = [ "serde_json", "target-triple", "termcolor", - "toml 1.1.0+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" @@ -4745,9 +4750,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -4846,11 +4851,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -4859,7 +4864,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -4870,9 +4875,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -4883,9 +4888,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.65" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -4893,9 +4898,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4903,9 +4908,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -4916,9 +4921,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -4959,9 +4964,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.92" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -4989,9 +4994,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -5096,15 +5101,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -5132,21 +5128,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -5178,12 +5159,6 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5196,12 +5171,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5214,12 +5183,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5238,12 +5201,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5256,12 +5213,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5274,12 +5225,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5292,12 +5237,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5318,9 +5257,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -5334,6 +5273,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -5421,9 +5366,9 @@ checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xxhash-rust" @@ -5445,12 +5390,12 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", - "yoke-derive 0.8.1", + "yoke-derive 0.8.2", "zerofrom", ] @@ -5468,9 +5413,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -5480,18 +5425,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -5500,18 +5445,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -5527,12 +5472,12 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", - "yoke 0.8.1", + "yoke 0.8.3", "zerofrom", ] @@ -5549,13 +5494,13 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ - "yoke 0.8.1", + "yoke 0.8.3", "zerofrom", - "zerovec-derive 0.11.2", + "zerovec-derive 0.11.3", ] [[package]] @@ -5571,9 +5516,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", From 787f31af2be963805e6f652f7bf65ae0b11b203f Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 9 Jun 2026 18:12:45 +0000 Subject: [PATCH 08/24] remove unwanted test --- cot/src/cli.rs | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 558b8ee94..105ce43b6 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -920,46 +920,6 @@ mod tests { assert!(temp_path.join("test.txt").exists()); } - #[cot::test] - #[cfg_attr( - miri, - ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" - )] - #[cfg(feature = "db")] - async fn migration_rollback_execute_rolls_back_project_app() { - let temp_dir = tempdir().unwrap(); - let db_path = temp_dir.path().join("rollback.sqlite"); - std::fs::File::create(&db_path).unwrap(); - let db_url = format!("sqlite://{}", db_path.display()); - - let setup_database = Database::new(db_url.clone()).await.unwrap(); - let setup_engine = MigrationEngine::new(CliRollbackApp.migrations()).unwrap(); - setup_engine.run(&setup_database).await.unwrap(); - assert!(cli_migration_applied(&setup_database, "cli_rollback_app", "m_0001_initial").await); - assert!(cli_migration_applied(&setup_database, "cli_rollback_app", "m_0002_second").await); - drop(setup_engine); - drop(setup_database); - - let config = ProjectConfig::builder() - .database(DatabaseConfig::builder().url(db_url.clone()).build()) - .build(); - let mut rollback = MigrationRollback; - let matches = MigrationRollback - .subcommand() - .get_matches_from(["rollback", "0001"]); - let bootstrapper = Bootstrapper::new(CliRollbackProject).with_config(config); - - rollback.execute(&matches, bootstrapper).await.unwrap(); - - let verify_database = Database::new(db_url).await.unwrap(); - assert!( - cli_migration_applied(&verify_database, "cli_rollback_app", "m_0001_initial").await - ); - assert!( - !cli_migration_applied(&verify_database, "cli_rollback_app", "m_0002_second").await - ); - } - #[cot::test] async fn check_execute() { let config = r#"secret_key = "123abc""#; From d5445b9f974e590e30818865e29423bcd0299e35 Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 9 Jun 2026 18:47:26 +0000 Subject: [PATCH 09/24] fix linting --- Cargo.lock | 46 +++++++-------- cot/src/cli.rs | 119 +++------------------------------------ cot/src/db/migrations.rs | 21 ++++--- 3 files changed, 41 insertions(+), 145 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3eb78ef6b..8f74654a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1494,7 +1494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1729,9 +1729,12 @@ dependencies = [ [[package]] name = "fragile" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] [[package]] name = "futures" @@ -2410,16 +2413,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2888,15 +2881,14 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2920,9 +2912,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -3399,9 +3391,9 @@ checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -3609,7 +3601,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3666,7 +3658,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4302,7 +4294,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4633,20 +4625,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", "futures-util", "http 1.4.1", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -5160,7 +5152,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 105ce43b6..27ab7ad8c 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -4,14 +4,16 @@ use std::collections::HashMap; use std::path::PathBuf; use std::str::FromStr; -use crate::{Bootstrapper, Error, Result}; use async_trait::async_trait; pub use clap; use clap::{Arg, ArgMatches, Command, value_parser}; +#[cfg(feature = "db")] use cot::db::migrations::{MigrationEngine, SyncDynMigration}; use cot::project::BootstrappedProject; use derive_more::Debug; +use crate::{Bootstrapper, Error, Result}; + const CONFIG_PARAM: &str = "config"; const COLLECT_STATIC_SUBCOMMAND: &str = "collect-static"; const CHECK_SUBCOMMAND: &str = "check"; @@ -463,15 +465,14 @@ impl CliTaskGroup { } } - /// Sets the description of the group, which is displayed in the help message - /// for the group's subcommands. + /// Sets the description of the group, which is displayed in the help + /// message for the group's subcommands. /// /// # Example /// ``` /// use cot::cli::CliTaskGroup; /// - /// let group = CliTaskGroup::new("command") - /// .about("This is a description for the command group"); + /// let group = CliTaskGroup::new("command").about("This is a description for the command group"); /// ``` #[must_use] pub fn about(mut self, about: impl Into) -> Self { @@ -603,8 +604,9 @@ impl CliTask for MigrationRollback { .boot() .await?; - // migrations are currently tied to crates, so we use the crate name as the app name. - // TODO: cli command should take an explicit crate name as arg when workspaces are supported. + // migrations are currently tied to crates, so we use the crate name as the app + // name. TODO: cli command should take an explicit crate name as arg + // when workspaces are supported. let crate_name = bootstrapper.project().cli_metadata().name; let BootstrappedProject { @@ -653,114 +655,11 @@ mod tests { use tempfile::tempdir; use super::*; - #[cfg(feature = "db")] - use crate::config::DatabaseConfig; use crate::config::ProjectConfig; - #[cfg(feature = "db")] - use crate::db::migrations::{ - Field, Migration, MigrationDependency, Operation, wrap_migrations, - }; - #[cfg(feature = "db")] - use crate::db::{Auto, Database, DatabaseField, Identifier, query}; use crate::project::RegisterAppsContext; use crate::static_files::StaticFile; use crate::{App, AppBuilder}; - #[cfg(feature = "db")] - struct CliRollbackInitial; - - #[cfg(feature = "db")] - impl Migration for CliRollbackInitial { - const APP_NAME: &'static str = "cli_rollback_app"; - const MIGRATION_NAME: &'static str = "m_0001_initial"; - const DEPENDENCIES: &'static [MigrationDependency] = &[]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("cli_rollback_app__initial")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; - } - - #[cfg(feature = "db")] - struct CliRollbackSecond; - - #[cfg(feature = "db")] - impl Migration for CliRollbackSecond { - const APP_NAME: &'static str = "cli_rollback_app"; - const MIGRATION_NAME: &'static str = "m_0002_second"; - const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( - "cli_rollback_app", - "m_0001_initial", - )]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("cli_rollback_app__second")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; - } - - #[cfg(feature = "db")] - struct CliRollbackApp; - - #[cfg(feature = "db")] - impl App for CliRollbackApp { - fn name(&self) -> &'static str { - "cli_rollback_app" - } - - fn migrations(&self) -> Vec> { - #[expect(trivial_casts)] - wrap_migrations(&[ - &CliRollbackInitial as &SyncDynMigration, - &CliRollbackSecond as &SyncDynMigration, - ]) - } - } - - #[cfg(feature = "db")] - struct CliRollbackProject; - - #[cfg(feature = "db")] - impl crate::Project for CliRollbackProject { - fn cli_metadata(&self) -> CliMetadata { - CliMetadata { - name: "cli_rollback_app", - version: "0.0.0", - authors: "", - description: "", - } - } - - fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { - apps.register(CliRollbackApp); - } - } - - #[cfg(feature = "db")] - #[derive(::std::fmt::Debug)] - #[crate::db::model(table_name = "cot__migrations", model_type = "internal")] - struct CliAppliedMigration { - #[model(primary_key)] - id: Auto, - app: String, - name: String, - applied: chrono::DateTime, - } - - #[cfg(feature = "db")] - async fn cli_migration_applied(database: &Database, app: &str, name: &str) -> bool { - query!(CliAppliedMigration, $app == app && $name == name) - .exists(database) - .await - .unwrap() - } - #[test] fn cli_new() { let cli = Cli::new(); diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 1d3c69e79..18e894dda 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -2,12 +2,13 @@ mod sorter; -pub use cot_macros::migration_op; -use sea_query::{ColumnDef, StringLen}; use std::collections::{HashSet, VecDeque}; use std::fmt; use std::fmt::{Debug, Formatter}; use std::future::Future; + +pub use cot_macros::migration_op; +use sea_query::{ColumnDef, StringLen}; use thiserror::Error; use tracing::{Level, info}; @@ -215,13 +216,14 @@ impl MigrationEngine { Ok(()) } - /// Roll back necessary migrations up until the specified migration in an app. + /// Roll back necessary migrations up until the specified migration in an + /// app. /// /// # Errors /// - /// Returns an error if there is an error while interacting with the database or if - /// there is an error while generating the migration graph or if there is an error - /// while unapplying a migration. + /// Returns an error if there is an error while interacting with the + /// database or if there is an error while generating the migration + /// graph or if there is an error while unapplying a migration. pub async fn rollback(&self, database: &Database, file: &str, app_name: &str) -> Result<()> { info!("Rolling back migrations"); @@ -2138,8 +2140,10 @@ mod tests { use sea_query::ColumnSpec; use super::*; + use crate::App; + use crate::auth::db::DatabaseUserApp; use crate::db::{ColumnType, DatabaseField, Identifier}; - use crate::{App, auth::db::DatabaseUserApp, session::db::SessionApp}; + use crate::session::db::SessionApp; struct TestMigration; @@ -2384,7 +2388,8 @@ mod tests { .await; assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; - // rollback everything except the initial migration in the source/independent app + // rollback everything except the initial migration in the source/independent + // app engine .rollback(&test_db.database(), "0001", "rollback_app1") .await From d16c593e10c8f9e9cbe75606843efb20a4dc34cd Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 9 Jun 2026 19:59:22 +0000 Subject: [PATCH 10/24] shorten test name so mysql runs --- cot/src/db/migrations.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 18e894dda..381dabe57 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -2329,7 +2329,7 @@ mod tests { } #[cot_macros::dbtest] - async fn test_migration_engine_rollback_preserves_unrelated_apps(test_db: &mut TestDatabase) { + async fn test_migration_rollback_unrelated_apps(test_db: &mut TestDatabase) { let mut migrations = DatabaseUserApp::new().migrations(); // combine migrations from multiple apps/crates #[expect(trivial_casts)] From aa5472c6322dc78bb71dabe4d32b3a5946fd5a9d Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 9 Jun 2026 20:46:02 +0000 Subject: [PATCH 11/24] add some more comments and docs --- cot/src/cli.rs | 7 ++++--- cot/src/db/migrations.rs | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 27ab7ad8c..ef174bf39 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -580,7 +580,7 @@ impl CliTask for MigrationRollback { .about("Rollback migrations up to the specified migration file") .arg( Arg::new("file") - .help("The migration filename to roll back to (e.g. 0001_create_users)") + .help("The migration filename to roll back to (e.g. 0001_initial or 0001)") .value_name("FILE") .required(true), ) @@ -605,8 +605,9 @@ impl CliTask for MigrationRollback { .await?; // migrations are currently tied to crates, so we use the crate name as the app - // name. TODO: cli command should take an explicit crate name as arg - // when workspaces are supported. + // name. + // TODO: cli command should take an explicit crate name as arg when workspaces + // are supported. let crate_name = bootstrapper.project().cli_metadata().name; let BootstrappedProject { diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 381dabe57..8002d9963 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -260,7 +260,7 @@ impl MigrationEngine { fn rollback_plan<'a>( &'a self, - file: &str, + file_name: &str, app_name: &str, ) -> Result> { let target_index = self @@ -268,11 +268,11 @@ impl MigrationEngine { .iter() .position(|migration| { migration.app_name() == app_name - && resolve_migration_file_name(migration.name()).contains(&file) + && resolve_migration_file_names(migration.name()).contains(&file_name) }) .ok_or_else(|| { MigrationEngineError::Custom(format!( - "Migration with file name {file} not found for app {app_name}" + "Migration with file name {file_name} not found for app {app_name}" )) })?; @@ -296,6 +296,8 @@ impl MigrationEngine { while let Some(index) = queue.pop_front() { for &dependent_index in graph.get_edges(index) { if rollback_indices.insert(dependent_index) { + // we found a migration that depends on the one we're rolling back, so let's + // add it to the queue which we will later traverse its dependents as well. queue.push_back(dependent_index); } } @@ -348,7 +350,12 @@ impl MigrationEngine { } } -fn resolve_migration_file_name(file_name: &str) -> Vec<&str> { +/// Resolves the possible migration names that can be used to refer to a +/// migration file. For example, for a migration file named `m_0001_initial`, +/// this function will return both `m_0001_initial` and `0001`. This allows +/// users to refer to migrations using either the full file name or just the +/// migration number when rolling back migrations. +fn resolve_migration_file_names(file_name: &str) -> Vec<&str> { let mut names = vec![file_name]; let migration_number = file_name.split('_').nth(1); if let Some(migration_number) = migration_number { @@ -2357,7 +2364,7 @@ mod tests { // the initial migration should stay applied assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; - // everything else in the rollback_mixed app should be unapplied + // everything else in the rollback_app1 app should be unapplied assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", false).await; // migrations from other apps should remain unaffected assert_migration_applied(test_db.database(), "cot", "m_0001_initial", true).await; From eeb4a6248ccc06863785491cb9a573629fad7c71 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 15 Jun 2026 21:12:55 +0000 Subject: [PATCH 12/24] refactor function name --- cot/src/db/migrations.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 8002d9963..de71c6563 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -268,7 +268,7 @@ impl MigrationEngine { .iter() .position(|migration| { migration.app_name() == app_name - && resolve_migration_file_names(migration.name()).contains(&file_name) + && expand_migration_file_name(migration.name()).contains(&file_name) }) .ok_or_else(|| { MigrationEngineError::Custom(format!( @@ -355,7 +355,7 @@ impl MigrationEngine { /// this function will return both `m_0001_initial` and `0001`. This allows /// users to refer to migrations using either the full file name or just the /// migration number when rolling back migrations. -fn resolve_migration_file_names(file_name: &str) -> Vec<&str> { +fn expand_migration_file_name(file_name: &str) -> Vec<&str> { let mut names = vec![file_name]; let migration_number = file_name.split('_').nth(1); if let Some(migration_number) = migration_number { From 49783515f69a9b0c7e27cb5cf81ba73ae0c48986 Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 25 Jun 2026 22:00:03 +0000 Subject: [PATCH 13/24] address low-hanging fruits in PR comments --- cot/src/cli.rs | 17 +++++++++-------- cot/src/db/migrations.rs | 6 +++++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/cot/src/cli.rs b/cot/src/cli.rs index ef174bf39..c865cf750 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -96,12 +96,14 @@ impl Cli { cli.add_task(Check); cli.add_task(CollectStatic); - let mut migration_group = - CliTaskGroup::new(MIGRATION_GROUP_SUBCOMMAND).about("Database migration commands"); - migration_group.add_task(MigrationRollback); - - cli.add_task(migration_group); + #[cfg(feature = "db")] + { + let mut migration_group = + CliTaskGroup::new(MIGRATION_GROUP_SUBCOMMAND).about("Database migration commands"); + migration_group.add_task(MigrationRollback); + cli.add_task(migration_group); + } cli } @@ -571,6 +573,7 @@ impl CliTask for CliTaskGroup { } } +#[cfg(feature = "db")] struct MigrationRollback; #[async_trait(?Send)] @@ -592,15 +595,13 @@ impl CliTask for MigrationRollback { bootstrapper: Bootstrapper, ) -> Result<()> { let file = matches - .get_one::("file") + .get_one::("migration_name") .expect("required argument"); let bootstrapper = bootstrapper .with_apps() .with_database() .await? - .with_cache() - .await? .boot() .await?; diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index de71c6563..f40de2b15 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -8,6 +8,7 @@ use std::fmt::{Debug, Formatter}; use std::future::Future; pub use cot_macros::migration_op; +use fake::locales::Data; use sea_query::{ColumnDef, StringLen}; use thiserror::Error; use tracing::{Level, info}; @@ -226,6 +227,7 @@ impl MigrationEngine { /// graph or if there is an error while unapplying a migration. pub async fn rollback(&self, database: &Database, file: &str, app_name: &str) -> Result<()> { info!("Rolling back migrations"); + // TODO: use a DB transaction here let rollback_plan = self.rollback_plan(file, app_name)?; @@ -252,7 +254,9 @@ impl MigrationEngine { operation.backwards(database).await?; } - Self::mark_migration_unapplied(database, migration).await?; + if Self::is_migration_applied(database, migration).await? { + Self::mark_migration_unapplied(database, migration).await?; + } } Ok(()) From 99da1253ed8e233d3c398e70c505cbca87a7ec0f Mon Sep 17 00:00:00 2001 From: Elijah Date: Thu, 25 Jun 2026 22:16:04 +0000 Subject: [PATCH 14/24] remove unused dep --- cot/src/db/migrations.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index c3e8dc1c0..c6a2a7d9a 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -8,7 +8,6 @@ use std::fmt::{Debug, Formatter}; use std::future::Future; pub use cot_macros::migration_op; -use fake::locales::Data; use sea_query::{ColumnDef, StringLen}; use thiserror::Error; use tracing::{Level, info}; From b34f1938a34c5fb450bfc31fdeb53bdee0212f17 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 29 Jun 2026 18:23:57 +0000 Subject: [PATCH 15/24] - add dry run - add an app flag which defaults to the current running app --- cot/src/cli.rs | 45 ++++++--- cot/src/db/migrations.rs | 206 +++++++++++++++++++++++++++++++++++---- 2 files changed, 223 insertions(+), 28 deletions(-) diff --git a/cot/src/cli.rs b/cot/src/cli.rs index c865cf750..c39a646e3 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use async_trait::async_trait; pub use clap; -use clap::{Arg, ArgMatches, Command, value_parser}; +use clap::{Arg, ArgAction, ArgMatches, Command, value_parser}; #[cfg(feature = "db")] use cot::db::migrations::{MigrationEngine, SyncDynMigration}; use cot::project::BootstrappedProject; @@ -582,11 +582,27 @@ impl CliTask for MigrationRollback { Command::new(MIGRATION_ROLLBACK_SUBCOMMAND) .about("Rollback migrations up to the specified migration file") .arg( - Arg::new("file") - .help("The migration filename to roll back to (e.g. 0001_initial or 0001)") - .value_name("FILE") + Arg::new("migration_name") + .help( + "The migration name to roll back to (e.g. m_0001_initial, 0001, \ + or zero)", + ) + .value_name("MIGRATION_NAME") .required(true), ) + .arg( + Arg::new("app") + .long("app") + .help("The name of the app to rollback migrations for") + .value_name("APP") + .required(false), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(ArgAction::SetTrue) + .help("Print the Rollback Plan without changing the database"), + ) } async fn execute( @@ -597,6 +613,7 @@ impl CliTask for MigrationRollback { let file = matches .get_one::("migration_name") .expect("required argument"); + let dry_run = matches.get_flag("dry-run"); let bootstrapper = bootstrapper .with_apps() @@ -605,11 +622,11 @@ impl CliTask for MigrationRollback { .boot() .await?; - // migrations are currently tied to crates, so we use the crate name as the app - // name. - // TODO: cli command should take an explicit crate name as arg when workspaces - // are supported. let crate_name = bootstrapper.project().cli_metadata().name; + let app_name = matches + .get_one::("app") + .map(String::as_str) + .unwrap_or(crate_name); let BootstrappedProject { context, @@ -624,9 +641,15 @@ impl CliTask for MigrationRollback { migrations.extend(app.migrations()); } let migration_engine = MigrationEngine::new(migrations)?; - migration_engine - .rollback(context.database(), file, crate_name) - .await?; + if dry_run { + migration_engine + .rollback_dry_run(context.database(), file, app_name, &mut std::io::stderr()) + .await?; + } else { + migration_engine + .rollback(context.database(), file, app_name) + .await?; + } } Ok(()) } diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index c6a2a7d9a..c9db01f3e 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -3,9 +3,10 @@ mod sorter; use std::collections::{HashSet, VecDeque}; -use std::fmt; use std::fmt::{Debug, Formatter}; use std::future::Future; +use std::io::Write; +use std::{fmt, io}; pub use cot_macros::migration_op; use sea_query::{ColumnDef, StringLen}; @@ -16,8 +17,10 @@ use crate::db::migrations::sorter::{MigrationSorter, MigrationSorterError}; use crate::db::relations::{ForeignKeyOnDeletePolicy, ForeignKeyOnUpdatePolicy}; use crate::db::{Auto, ColumnType, Database, DatabaseField, Identifier, Result, model, query}; +const MIGRATION_ZERO_NAME: &str = "zero"; + /// An error that occurred while running migrations. -#[derive(Debug, Clone, Error)] +#[derive(Debug, Error)] #[non_exhaustive] pub enum MigrationEngineError { /// An error occurred while determining the correct order of migrations. @@ -26,6 +29,9 @@ pub enum MigrationEngineError { /// A custom error occurred during a migration. #[error("error running migration: {0}")] Custom(String), + /// An I/O error occurred while writing output (e.g. during dry-run). + #[error("I/O error while writing migration output: {0}")] + Io(#[from] io::Error), } /// A migration engine responsible for managing and applying database @@ -224,11 +230,16 @@ impl MigrationEngine { /// Returns an error if there is an error while interacting with the /// database or if there is an error while generating the migration /// graph or if there is an error while unapplying a migration. - pub async fn rollback(&self, database: &Database, file: &str, app_name: &str) -> Result<()> { + pub async fn rollback( + &self, + database: &Database, + migration_name: &str, + app_name: &str, + ) -> Result<()> { info!("Rolling back migrations"); // TODO: use a DB transaction here - let rollback_plan = self.rollback_plan(file, app_name)?; + let rollback_plan = self.rollback_plan(migration_name, app_name)?; for migration in rollback_plan { if !Self::is_migration_applied(database, migration).await? { @@ -261,23 +272,126 @@ impl MigrationEngine { Ok(()) } + /// Prints the rollback plan for the specified migration target without + /// modifying the database. + /// + /// This method only reads the applied migration table to report which + /// planned migrations are currently applied. It does not run backwards + /// operations and does not mark any migration as unapplied. + /// + /// # Errors + /// + /// Returns an error if the target migration cannot be found, if the + /// migration graph cannot be generated, or if the applied migration + /// state cannot be read from the database. + pub async fn rollback_dry_run( + &self, + database: &Database, + migration_name: &str, + app_name: &str, + output: &mut impl Write, + ) -> Result<()> { + let rollback_plan = self.rollback_plan(migration_name, app_name)?; + + let mut entries = Vec::new(); + for migration in rollback_plan { + let applied = Self::is_migration_applied(database, migration).await?; + entries.push((migration, applied)); + } + + Self::write_dry_run_output(output, migration_name, app_name, &entries) + .map_err(MigrationEngineError::Io)?; + + Ok(()) + } + + fn write_dry_run_output( + output: &mut impl Write, + migration_name: &str, + app_name: &str, + entries: &[(&MigrationWrapper, bool)], + ) -> io::Result<()> { + let mode = if migration_name + .trim() + .eq_ignore_ascii_case(MIGRATION_ZERO_NAME) + { + "zero (all migrations in app rolled back)" + } else { + "exclusive (target migration remains applied)" + }; + + writeln!(output, "Rollback dry run\n")?; + writeln!(output, "Target:")?; + writeln!(output, " app: {app_name}")?; + writeln!(output, " migration: {migration_name}")?; + writeln!(output, " mode: {mode}\n")?; + writeln!(output, "Rollback plan:")?; + + let mut rollback_count = 0; + let mut skipped_count = 0; + + for (migration, applied) in entries { + if *applied { + rollback_count += 1; + writeln!( + output, + " {rollback_count}. {}::{}", + migration.app_name(), + migration.name() + )?; + } else { + skipped_count += 1; + writeln!( + output, + " - {}::{} [skipped – not applied]", + migration.app_name(), + migration.name() + )?; + } + } + + writeln!(output, "\nSummary:")?; + writeln!(output, " to roll back: {rollback_count}")?; + writeln!(output, " skipped: {skipped_count}")?; + writeln!(output, " database changes: none\n")?; + + Ok(()) + } + fn rollback_plan<'a>( &'a self, - file_name: &str, + migration_name: &str, app_name: &str, ) -> Result> { - let target_index = self - .migrations - .iter() - .position(|migration| { - migration.app_name() == app_name - && expand_migration_file_name(migration.name()).contains(&file_name) - }) - .ok_or_else(|| { - MigrationEngineError::Custom(format!( - "Migration with file name {file_name} not found for app {app_name}" + let migration_name = migration_name.trim(); + let target_index = if migration_name.eq_ignore_ascii_case(MIGRATION_ZERO_NAME) { + if !self + .migrations + .iter() + .any(|migration| migration.app_name() == app_name) + { + return Err(MigrationEngineError::Custom(format!( + "No migrations found for app {app_name}" )) - })?; + .into()); + } + None + } else { + Some( + self.migrations + .iter() + .position(|migration| { + migration.app_name() == app_name + && expand_migration_file_name(migration.name()) + .contains(&migration_name) + }) + .ok_or_else(|| { + MigrationEngineError::Custom(format!( + "Migration with file name {migration_name} not found for app {app_name}" + )) + })?, + ) + }; let mut rollback_indices = HashSet::new(); // Seed later migrations in the same app, then include migrations from @@ -287,7 +401,14 @@ impl MigrationEngine { .iter() .enumerate() .filter(|(index, migration)| { - *index > target_index && migration.app_name() == app_name + if migration.app_name() != app_name { + return false; + } + + if let Some(target_index) = target_index { + return *index > target_index; + } + true }) .map(|(index, _)| index), ); @@ -2386,6 +2507,16 @@ mod tests { .unwrap(); engine.run(&test_db.database()).await.unwrap(); + engine + .rollback_dry_run( + &test_db.database(), + "m_0001_initial", + "rollback_app2", + &mut io::stderr(), + ) + .await + .unwrap(); + assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", true).await; assert_migration_applied( @@ -2418,6 +2549,47 @@ mod tests { assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; } + #[cot_macros::dbtest] + async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp1003 as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ]) + .unwrap(); + + engine.run(&test_db.database()).await.unwrap(); + + engine + .rollback_dry_run( + &test_db.database(), + "zero", + "rollback_app1", + &mut io::stderr(), + ) + .await + .unwrap(); + assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", true).await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0003_third", true).await; + assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; + + engine + .rollback(&test_db.database(), "zero", "rollback_app1") + .await + .unwrap(); + // everything should be unapplied + assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", false) + .await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", false).await; + assert_migration_applied(test_db.database(), "rollback_app1", "m_0003_third", false).await; + + // the non dependent apps should be unaffected + assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; + } + #[test] fn test_operation_create_model() { const OPERATION_CREATE_MODEL_FIELDS: &[Field; 2] = &[ From 982e83e6a43454390df0eb9d00967d5234ea5776 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 17 Jul 2026 15:33:56 +0000 Subject: [PATCH 16/24] Snapshot test for migration rollback dry run --- Cargo.lock | 1 + cot/Cargo.toml | 1 + cot/src/cli.rs | 3 +- cot/src/db/migrations.rs | 217 ++++++++++++------ ..._rollback_includes_dependent_apps.snap.new | 20 ++ ...ration_engine_rollback_single_app.snap.new | 20 ++ ...s__migration_engine_rollback_zero.snap.new | 21 ++ ...migration_rollback_unrelated_apps.snap.new | 19 ++ 8 files changed, 232 insertions(+), 70 deletions(-) create mode 100644 cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap.new create mode 100644 cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_single_app.snap.new create mode 100644 cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_zero.snap.new create mode 100644 cot/src/db/snapshots/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap.new diff --git a/Cargo.lock b/Cargo.lock index 2f44247d0..33c1c71ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -937,6 +937,7 @@ dependencies = [ "humantime", "idna", "indexmap", + "insta", "is_terminal_polyfill", "lettre", "mime", diff --git a/cot/Cargo.toml b/cot/Cargo.toml index c40dbdcbd..42f346d34 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -72,6 +72,7 @@ url = { workspace = true, features = ["serde"] } criterion = { workspace = true, features = ["async_tokio"] } fake.workspace = true fantoccini.workspace = true +insta.workspace = true mockall.workspace = true reqwest = { workspace = true, features = ["json"] } rustversion.workspace = true diff --git a/cot/src/cli.rs b/cot/src/cli.rs index c39a646e3..2940d3bd4 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -625,8 +625,7 @@ impl CliTask for MigrationRollback { let crate_name = bootstrapper.project().cli_metadata().name; let app_name = matches .get_one::("app") - .map(String::as_str) - .unwrap_or(crate_name); + .map_or(crate_name, String::as_str); let BootstrappedProject { context, diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index c9db01f3e..f58ad769f 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -2390,15 +2390,36 @@ mod tests { .build()]; } - async fn assert_migration_applied(database: Database, app: &str, name: &str, expected: bool) { + async fn assert_migration_applied(database: &Database, app: &str, name: &str, expected: bool) { let applied = query!(AppliedMigration, $app == app && $name == name) - .exists(&database) + .exists(database) .await .unwrap(); assert_eq!(applied, expected, "{app}::{name}"); } + async fn assert_migrations_applied(db: &Database, expected: &[(&str, &str, bool)]) { + for &(app, name, applied) in expected { + assert_migration_applied(db, app, name, applied).await; + } + } + + async fn dry_run_snapshot( + engine: &MigrationEngine, + db: &Database, + output: &mut Vec, + migration_name: &str, + app_name: &str, + ) -> String { + output.clear(); + engine + .rollback_dry_run(db, migration_name, app_name, output) + .await + .unwrap(); + std::str::from_utf8(output).unwrap().to_owned() + } + #[cot_macros::dbtest] async fn test_migration_rollback_no_deps(test_db: &mut TestDatabase) { let engine = MigrationEngine::new([RollbackApp1Initial]).unwrap(); @@ -2430,6 +2451,7 @@ mod tests { #[cot_macros::dbtest] async fn test_migration_engine_rollback_single_app(test_db: &mut TestDatabase) { + let mut output = Vec::new(); #[expect(trivial_casts)] let engine = MigrationEngine::new([ &RollbackApp1Initial as &SyncDynMigration, @@ -2440,26 +2462,48 @@ mod tests { engine.run(&test_db.database()).await.unwrap(); // migrations should be applied - - assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", true).await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0003_third", true).await; + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_app1", "m_0003_third", true), + ], + ) + .await; // rollback everything except the initial migration + insta::assert_snapshot!( + dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1" + ) + .await + ); engine .rollback(&test_db.database(), "0001", "rollback_app1") .await .unwrap(); - // the initial migration should stay applied - assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; - // everything else should be unapplied - assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", false).await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0003_third", false).await; + assert_migrations_applied( + &test_db.database(), + &[ + // the initial migration should stay applied + ("rollback_app1", "m_0001_initial", true), + // everything else should be unapplied + ("rollback_app1", "m_0002_second", false), + ("rollback_app1", "m_0003_third", false), + ], + ) + .await; } #[cot_macros::dbtest] async fn test_migration_rollback_unrelated_apps(test_db: &mut TestDatabase) { + let mut output = Vec::new(); let mut migrations = DatabaseUserApp::new().migrations(); // combine migrations from multiple apps/crates #[expect(trivial_casts)] @@ -2473,30 +2517,53 @@ mod tests { engine.run(&test_db.database()).await.unwrap(); // migrations should be applied across all apps - assert_migration_applied(test_db.database(), "cot", "m_0001_initial", true).await; - assert_migration_applied(test_db.database(), "cot_session", "m_0001_initial", true).await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", true).await; - assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; + assert_migrations_applied( + &test_db.database(), + &[ + ("cot", "m_0001_initial", true), + ("cot_session", "m_0001_initial", true), + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; // rollback every migration in the rollback_app1 app except the initial + insta::assert_snapshot!( + dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1" + ) + .await + ); engine .rollback(&test_db.database(), "0001", "rollback_app1") .await .unwrap(); - // the initial migration should stay applied - assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; - // everything else in the rollback_app1 app should be unapplied - assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", false).await; - // migrations from other apps should remain unaffected - assert_migration_applied(test_db.database(), "cot", "m_0001_initial", true).await; - assert_migration_applied(test_db.database(), "cot_session", "m_0001_initial", true).await; - assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; + assert_migrations_applied( + &test_db.database(), + &[ + // the initial migration should stay applied + ("rollback_app1", "m_0001_initial", true), + // everything else in the rollback_app1 app should be unapplied + ("rollback_app1", "m_0002_second", false), + // migrations from other apps should remain unaffected + ("cot", "m_0001_initial", true), + ("cot_session", "m_0001_initial", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; } #[cot_macros::dbtest] async fn test_migration_engine_rollback_includes_dependent_apps(test_db: &mut TestDatabase) { + let mut output = Vec::new(); #[expect(trivial_casts)] let engine = MigrationEngine::new([ &RollbackApp1Initial as &SyncDynMigration, @@ -2507,50 +2574,52 @@ mod tests { .unwrap(); engine.run(&test_db.database()).await.unwrap(); - engine - .rollback_dry_run( - &test_db.database(), - "m_0001_initial", - "rollback_app2", - &mut io::stderr(), - ) - .await - .unwrap(); - assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", true).await; - assert_migration_applied( - test_db.database(), - "rollback_dependent", - "m_0001_initial", - true, + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_dependent", "m_0001_initial", true), + ("rollback_app2", "m_0001_initial", true), + ], ) .await; - assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; // rollback everything except the initial migration in the source/independent // app + insta::assert_snapshot!( + dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1" + ) + .await + ); engine .rollback(&test_db.database(), "0001", "rollback_app1") .await .unwrap(); - assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", false).await; - // the sink/dependent app should also be unapplied/rolled back - assert_migration_applied( - test_db.database(), - "rollback_dependent", - "m_0001_initial", - false, + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", false), + // the sink/dependent app should also be unapplied/rolled back + ("rollback_dependent", "m_0001_initial", false), + // migrations from non-dependent apps should remain unaffected + ("rollback_app2", "m_0001_initial", true), + ], ) .await; - // migrations from non-dependent apps should remain unaffected - assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; } #[cot_macros::dbtest] async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { + let mut output = Vec::new(); #[expect(trivial_casts)] let engine = MigrationEngine::new([ &RollbackApp1Initial as &SyncDynMigration, @@ -2562,32 +2631,44 @@ mod tests { engine.run(&test_db.database()).await.unwrap(); - engine - .rollback_dry_run( + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_app1", "m_0003_third", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; + + insta::assert_snapshot!( + dry_run_snapshot( + &engine, &test_db.database(), + &mut output, "zero", - "rollback_app1", - &mut io::stderr(), + "rollback_app1" ) .await - .unwrap(); - assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", true).await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", true).await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0003_third", true).await; - assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; - + ); engine .rollback(&test_db.database(), "zero", "rollback_app1") .await .unwrap(); - // everything should be unapplied - assert_migration_applied(test_db.database(), "rollback_app1", "m_0001_initial", false) - .await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0002_second", false).await; - assert_migration_applied(test_db.database(), "rollback_app1", "m_0003_third", false).await; - // the non dependent apps should be unaffected - assert_migration_applied(test_db.database(), "rollback_app2", "m_0001_initial", true).await; + assert_migrations_applied( + &test_db.database(), + &[ + // everything should be unapplied + ("rollback_app1", "m_0001_initial", false), + ("rollback_app1", "m_0002_second", false), + ("rollback_app1", "m_0003_third", false), + // the non dependent apps should be unaffected + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; } #[test] diff --git a/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap.new b/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap.new new file mode 100644 index 000000000..139d18266 --- /dev/null +++ b/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap.new @@ -0,0 +1,20 @@ +--- +source: cot/src/db/migrations.rs +assertion_line: 2577 +expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"0001\",\n\"rollback_app1\").await" +--- +Rollback dry run + +Target: + app: rollback_app1 + migration: 0001 + mode: exclusive (target migration remains applied) + +Rollback plan: + 1. rollback_dependent::m_0001_initial + 2. rollback_app1::m_0002_second + +Summary: + to roll back: 2 + skipped: 0 + database changes: none diff --git a/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_single_app.snap.new b/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_single_app.snap.new new file mode 100644 index 000000000..90271ee77 --- /dev/null +++ b/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_single_app.snap.new @@ -0,0 +1,20 @@ +--- +source: cot/src/db/migrations.rs +assertion_line: 2476 +expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"0001\",\n\"rollback_app1\").await" +--- +Rollback dry run + +Target: + app: rollback_app1 + migration: 0001 + mode: exclusive (target migration remains applied) + +Rollback plan: + 1. rollback_app1::m_0003_third + 2. rollback_app1::m_0002_second + +Summary: + to roll back: 2 + skipped: 0 + database changes: none diff --git a/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_zero.snap.new b/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_zero.snap.new new file mode 100644 index 000000000..001230d5b --- /dev/null +++ b/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_zero.snap.new @@ -0,0 +1,21 @@ +--- +source: cot/src/db/migrations.rs +assertion_line: 2624 +expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"zero\",\n\"rollback_app1\").await" +--- +Rollback dry run + +Target: + app: rollback_app1 + migration: zero + mode: zero (all migrations in app rolled back) + +Rollback plan: + 1. rollback_app1::m_0003_third + 2. rollback_app1::m_0002_second + 3. rollback_app1::m_0001_initial + +Summary: + to roll back: 3 + skipped: 0 + database changes: none diff --git a/cot/src/db/snapshots/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap.new b/cot/src/db/snapshots/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap.new new file mode 100644 index 000000000..a9742ef35 --- /dev/null +++ b/cot/src/db/snapshots/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap.new @@ -0,0 +1,19 @@ +--- +source: cot/src/db/migrations.rs +assertion_line: 2526 +expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"0001\",\n\"rollback_app1\").await" +--- +Rollback dry run + +Target: + app: rollback_app1 + migration: 0001 + mode: exclusive (target migration remains applied) + +Rollback plan: + 1. rollback_app1::m_0002_second + +Summary: + to roll back: 1 + skipped: 0 + database changes: none From 197bd5dc8ff6e839a5da46dc250f65eaa281e0b7 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 17 Jul 2026 21:20:39 +0000 Subject: [PATCH 17/24] move snapshot testing into tests module --- cot/src/db/migrations.rs | 59 ----- cot/tests/cli.rs | 2 + cot/tests/snapshot_testing/migration/mod.rs | 215 ++++++++++++++++++ ...ine_rollback_includes_dependent_apps.snap} | 3 +- ...migration_engine_rollback_single_app.snap} | 3 +- ...tion__migration_engine_rollback_zero.snap} | 3 +- ...n__migration_rollback_unrelated_apps.snap} | 3 +- cot/tests/snapshot_testing/mod.rs | 1 + 8 files changed, 222 insertions(+), 67 deletions(-) create mode 100644 cot/tests/cli.rs create mode 100644 cot/tests/snapshot_testing/migration/mod.rs rename cot/{src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap.new => tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_includes_dependent_apps.snap} (88%) rename cot/{src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_single_app.snap.new => tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_single_app.snap} (88%) rename cot/{src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_zero.snap.new => tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_zero.snap} (89%) rename cot/{src/db/snapshots/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap.new => tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_rollback_unrelated_apps.snap} (87%) create mode 100644 cot/tests/snapshot_testing/mod.rs diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index f58ad769f..02e10b1fa 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -2405,21 +2405,6 @@ mod tests { } } - async fn dry_run_snapshot( - engine: &MigrationEngine, - db: &Database, - output: &mut Vec, - migration_name: &str, - app_name: &str, - ) -> String { - output.clear(); - engine - .rollback_dry_run(db, migration_name, app_name, output) - .await - .unwrap(); - std::str::from_utf8(output).unwrap().to_owned() - } - #[cot_macros::dbtest] async fn test_migration_rollback_no_deps(test_db: &mut TestDatabase) { let engine = MigrationEngine::new([RollbackApp1Initial]).unwrap(); @@ -2451,7 +2436,6 @@ mod tests { #[cot_macros::dbtest] async fn test_migration_engine_rollback_single_app(test_db: &mut TestDatabase) { - let mut output = Vec::new(); #[expect(trivial_casts)] let engine = MigrationEngine::new([ &RollbackApp1Initial as &SyncDynMigration, @@ -2473,16 +2457,6 @@ mod tests { .await; // rollback everything except the initial migration - insta::assert_snapshot!( - dry_run_snapshot( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1" - ) - .await - ); engine .rollback(&test_db.database(), "0001", "rollback_app1") .await @@ -2503,7 +2477,6 @@ mod tests { #[cot_macros::dbtest] async fn test_migration_rollback_unrelated_apps(test_db: &mut TestDatabase) { - let mut output = Vec::new(); let mut migrations = DatabaseUserApp::new().migrations(); // combine migrations from multiple apps/crates #[expect(trivial_casts)] @@ -2530,16 +2503,6 @@ mod tests { .await; // rollback every migration in the rollback_app1 app except the initial - insta::assert_snapshot!( - dry_run_snapshot( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1" - ) - .await - ); engine .rollback(&test_db.database(), "0001", "rollback_app1") .await @@ -2563,7 +2526,6 @@ mod tests { #[cot_macros::dbtest] async fn test_migration_engine_rollback_includes_dependent_apps(test_db: &mut TestDatabase) { - let mut output = Vec::new(); #[expect(trivial_casts)] let engine = MigrationEngine::new([ &RollbackApp1Initial as &SyncDynMigration, @@ -2588,16 +2550,6 @@ mod tests { // rollback everything except the initial migration in the source/independent // app - insta::assert_snapshot!( - dry_run_snapshot( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1" - ) - .await - ); engine .rollback(&test_db.database(), "0001", "rollback_app1") .await @@ -2619,7 +2571,6 @@ mod tests { #[cot_macros::dbtest] async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { - let mut output = Vec::new(); #[expect(trivial_casts)] let engine = MigrationEngine::new([ &RollbackApp1Initial as &SyncDynMigration, @@ -2642,16 +2593,6 @@ mod tests { ) .await; - insta::assert_snapshot!( - dry_run_snapshot( - &engine, - &test_db.database(), - &mut output, - "zero", - "rollback_app1" - ) - .await - ); engine .rollback(&test_db.database(), "zero", "rollback_app1") .await diff --git a/cot/tests/cli.rs b/cot/tests/cli.rs new file mode 100644 index 000000000..a530284e9 --- /dev/null +++ b/cot/tests/cli.rs @@ -0,0 +1,2 @@ +#[cfg(not(miri))] +mod snapshot_testing; diff --git a/cot/tests/snapshot_testing/migration/mod.rs b/cot/tests/snapshot_testing/migration/mod.rs new file mode 100644 index 000000000..b463ba69a --- /dev/null +++ b/cot/tests/snapshot_testing/migration/mod.rs @@ -0,0 +1,215 @@ +use cot::App; +use cot::auth::db::DatabaseUserApp; +use cot::db::migrations::{ + Field, Migration, MigrationDependency, MigrationEngine, Operation, SyncDynMigration, + wrap_migrations, +}; +use cot::db::{Database, DatabaseField, Identifier}; +use cot::session::db::SessionApp; +use cot::test::TestDatabase; + +struct RollbackApp1Initial; + +impl Migration for RollbackApp1Initial { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_single__first")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackApp10002; + +impl Migration for RollbackApp10002 { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0002_second"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0001_initial", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_app1__second")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackApp1003; + +impl Migration for RollbackApp1003 { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0003_third"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0002_second", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_single__third")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackApp2Initial; + +impl Migration for RollbackApp2Initial { + const APP_NAME: &'static str = "rollback_app2"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_app2__foo")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackDependentInitial; + +impl Migration for RollbackDependentInitial { + const APP_NAME: &'static str = "rollback_dependent"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0002_second", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_dependent__bar")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +async fn dry_run_snapshot( + engine: &MigrationEngine, + db: &Database, + output: &mut Vec, + migration_name: &str, + app_name: &str, +) -> String { + output.clear(); + engine + .rollback_dry_run(db, migration_name, app_name, output) + .await + .unwrap(); + std::str::from_utf8(output).unwrap().to_owned() +} + +#[cot_macros::dbtest] +async fn test_migration_engine_rollback_single_app(test_db: &mut TestDatabase) { + let mut output = Vec::new(); + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp1003 as &SyncDynMigration, + ]) + .unwrap(); + + engine.run(&test_db.database()).await.unwrap(); + + insta::assert_snapshot!( + dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1" + ) + .await + ); +} + +#[cot_macros::dbtest] +async fn test_migration_rollback_unrelated_apps(test_db: &mut TestDatabase) { + let mut output = Vec::new(); + let mut migrations = DatabaseUserApp::new().migrations(); + + #[expect(trivial_casts)] + migrations.extend(wrap_migrations(&[ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ])); + migrations.extend(SessionApp::new().migrations()); + let engine = MigrationEngine::new(migrations).unwrap(); + engine.run(&test_db.database()).await.unwrap(); + + insta::assert_snapshot!( + dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1" + ) + .await + ); +} + +#[cot_macros::dbtest] +async fn test_migration_engine_rollback_includes_dependent_apps(test_db: &mut TestDatabase) { + let mut output = Vec::new(); + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackDependentInitial as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ]) + .unwrap(); + engine.run(&test_db.database()).await.unwrap(); + + insta::assert_snapshot!( + dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1" + ) + .await + ); +} + +#[cot_macros::dbtest] +async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { + let mut output = Vec::new(); + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp1003 as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ]) + .unwrap(); + engine.run(&test_db.database()).await.unwrap(); + + insta::assert_snapshot!( + dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "zero", + "rollback_app1" + ) + .await + ); +} diff --git a/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap.new b/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_includes_dependent_apps.snap similarity index 88% rename from cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap.new rename to cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_includes_dependent_apps.snap index 139d18266..4b7a22f5a 100644 --- a/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap.new +++ b/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_includes_dependent_apps.snap @@ -1,6 +1,5 @@ --- -source: cot/src/db/migrations.rs -assertion_line: 2577 +source: cot/tests/snapshot_testing/migration/mod.rs expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"0001\",\n\"rollback_app1\").await" --- Rollback dry run diff --git a/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_single_app.snap.new b/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_single_app.snap similarity index 88% rename from cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_single_app.snap.new rename to cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_single_app.snap index 90271ee77..26306f56a 100644 --- a/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_single_app.snap.new +++ b/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_single_app.snap @@ -1,6 +1,5 @@ --- -source: cot/src/db/migrations.rs -assertion_line: 2476 +source: cot/tests/snapshot_testing/migration/mod.rs expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"0001\",\n\"rollback_app1\").await" --- Rollback dry run diff --git a/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_zero.snap.new b/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_zero.snap similarity index 89% rename from cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_zero.snap.new rename to cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_zero.snap index 001230d5b..d2107b971 100644 --- a/cot/src/db/snapshots/cot__db__migrations__tests__migration_engine_rollback_zero.snap.new +++ b/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_zero.snap @@ -1,6 +1,5 @@ --- -source: cot/src/db/migrations.rs -assertion_line: 2624 +source: cot/tests/snapshot_testing/migration/mod.rs expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"zero\",\n\"rollback_app1\").await" --- Rollback dry run diff --git a/cot/src/db/snapshots/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap.new b/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_rollback_unrelated_apps.snap similarity index 87% rename from cot/src/db/snapshots/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap.new rename to cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_rollback_unrelated_apps.snap index a9742ef35..6eea72242 100644 --- a/cot/src/db/snapshots/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap.new +++ b/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_rollback_unrelated_apps.snap @@ -1,6 +1,5 @@ --- -source: cot/src/db/migrations.rs -assertion_line: 2526 +source: cot/tests/snapshot_testing/migration/mod.rs expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"0001\",\n\"rollback_app1\").await" --- Rollback dry run diff --git a/cot/tests/snapshot_testing/mod.rs b/cot/tests/snapshot_testing/mod.rs new file mode 100644 index 000000000..5a8970e1f --- /dev/null +++ b/cot/tests/snapshot_testing/mod.rs @@ -0,0 +1 @@ +mod migration; From ac361eae655c2a2b4432c8791cf4bd8afe3c107a Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 17 Jul 2026 23:07:28 +0000 Subject: [PATCH 18/24] add logs to rollback cli and some refactor --- Cargo.lock | 2 +- cot-cli/Cargo.toml | 1 - cot-cli/src/migration_generator.rs | 3 +- cot-cli/src/new_project.rs | 3 +- cot-cli/src/utils.rs | 69 -------------------------- cot/Cargo.toml | 1 + cot/src/db/migrations.rs | 9 ++++ cot/src/lib.rs | 2 +- cot/src/utils.rs | 2 + cot/src/utils/cli.rs | 77 ++++++++++++++++++++++++++++++ 10 files changed, 94 insertions(+), 75 deletions(-) create mode 100644 cot/src/utils/cli.rs diff --git a/Cargo.lock b/Cargo.lock index 14a5124d1..ceeceeb41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -910,6 +910,7 @@ version = "0.7.0" dependencies = [ "ahash", "aide", + "anstyle", "askama", "async-trait", "axum", @@ -977,7 +978,6 @@ dependencies = [ name = "cot-cli" version = "0.7.0" dependencies = [ - "anstyle", "anyhow", "assert_cmd", "cargo_toml", diff --git a/cot-cli/Cargo.toml b/cot-cli/Cargo.toml index 0dd89af59..341b564bd 100644 --- a/cot-cli/Cargo.toml +++ b/cot-cli/Cargo.toml @@ -20,7 +20,6 @@ path = "src/main.rs" workspace = true [dependencies] -anstyle.workspace = true anyhow.workspace = true cargo_toml.workspace = true chrono.workspace = true diff --git a/cot-cli/src/migration_generator.rs b/cot-cli/src/migration_generator.rs index b5010bd37..5d5c4ebe6 100644 --- a/cot-cli/src/migration_generator.rs +++ b/cot-cli/src/migration_generator.rs @@ -7,6 +7,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, bail}; use cot::db::migrations::{DynMigration, MigrationEngine}; +use cot::utils::cli::{StatusType, print_status_msg}; use cot_codegen::model::{Field, Model, ModelArgs, ModelOpts, ModelType}; use cot_codegen::symbol_resolver::SymbolResolver; use darling::FromMeta; @@ -18,7 +19,7 @@ use quote::{ToTokens, format_ident, quote}; use syn::{Meta, parse_quote}; use tracing::{debug, trace}; -use crate::utils::{CargoTomlManager, PackageManager, StatusType, print_status_msg}; +use crate::utils::{CargoTomlManager, PackageManager}; pub fn make_migrations(path: &Path, options: MigrationGeneratorOptions) -> anyhow::Result<()> { let Some(manager) = CargoTomlManager::from_path(path)? else { diff --git a/cot-cli/src/new_project.rs b/cot-cli/src/new_project.rs index 491ee2a1c..9eee95593 100644 --- a/cot-cli/src/new_project.rs +++ b/cot-cli/src/new_project.rs @@ -1,12 +1,11 @@ use std::path::Path; +use cot::utils::cli::{StatusType, print_status_msg}; use heck::ToPascalCase; use rand::rngs::{StdRng, SysRng}; use rand::{Rng, SeedableRng}; use tracing::trace; -use crate::utils::{StatusType, print_status_msg}; - macro_rules! project_file { ($name:literal) => { ($name, include_str!(concat!("project_template/", $name))) diff --git a/cot-cli/src/utils.rs b/cot-cli/src/utils.rs index 6ec4cbcf8..b4aa09ca6 100644 --- a/cot-cli/src/utils.rs +++ b/cot-cli/src/utils.rs @@ -1,78 +1,9 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use anstyle::{AnsiColor, Color, Effects, Style}; use anyhow::{Context, bail}; use cargo_toml::Manifest; -pub(crate) fn print_status_msg(status: StatusType, message: &str) { - let style = status.style(); - let status_str = status.as_str(); - - eprintln!("{style}{status_str:>12}{style:#} {message}"); -} - -#[derive(Debug, Clone, Copy)] -pub(crate) enum StatusType { - // In-Progress Ops - Creating, - Adding, - Modifying, - Removing, - // Completed Ops - Created, - Added, - Modified, - Removed, - - // Status types - #[expect(dead_code)] - Error, // Should be used in Error handling inside remove operations - #[expect(dead_code)] - Warning, // Should be used as cautionary messages. - Notice, -} - -impl StatusType { - fn style(self) -> Style { - let base_style = Style::new() | Effects::BOLD; - - match self { - // In-Progress => Brighter colors - StatusType::Creating => base_style.fg_color(Some(Color::Ansi(AnsiColor::BrightGreen))), - StatusType::Adding => base_style.fg_color(Some(Color::Ansi(AnsiColor::BrightCyan))), - StatusType::Removing => { - base_style.fg_color(Some(Color::Ansi(AnsiColor::BrightMagenta))) - } - StatusType::Modifying => base_style.fg_color(Some(Color::Ansi(AnsiColor::BrightBlue))), - // Completed => Dimmed colors - StatusType::Created => base_style.fg_color(Some(Color::Ansi(AnsiColor::Green))), - StatusType::Added => base_style.fg_color(Some(Color::Ansi(AnsiColor::Cyan))), - StatusType::Removed => base_style.fg_color(Some(Color::Ansi(AnsiColor::Magenta))), - StatusType::Modified => base_style.fg_color(Some(Color::Ansi(AnsiColor::Blue))), - // Status types - StatusType::Warning => base_style.fg_color(Some(Color::Ansi(AnsiColor::Yellow))), - StatusType::Error => base_style.fg_color(Some(Color::Ansi(AnsiColor::Red))), - StatusType::Notice => base_style.fg_color(Some(Color::Ansi(AnsiColor::White))), - } - } - fn as_str(self) -> &'static str { - match self { - StatusType::Creating => "Creating", - StatusType::Adding => "Adding", - StatusType::Modifying => "Modifying", - StatusType::Removing => "Removing", - StatusType::Created => "Created", - StatusType::Added => "Added", - StatusType::Modified => "Modified", - StatusType::Removed => "Removed", - StatusType::Warning => "Warning", - StatusType::Error => "Error", - StatusType::Notice => "Notice", - } - } -} - #[derive(Debug)] pub(crate) enum CargoTomlManager { Workspace(WorkspaceManager), diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 42f346d34..51d0fb7d4 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -17,6 +17,7 @@ workspace = true [dependencies] aide = { workspace = true, optional = true } +anstyle.workspace = true askama = { workspace = true, features = ["std"] } async-trait.workspace = true axum = { workspace = true, features = ["http1", "tokio"] } diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 02e10b1fa..8b322edfa 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -16,6 +16,7 @@ use tracing::{Level, info}; use crate::db::migrations::sorter::{MigrationSorter, MigrationSorterError}; use crate::db::relations::{ForeignKeyOnDeletePolicy, ForeignKeyOnUpdatePolicy}; use crate::db::{Auto, ColumnType, Database, DatabaseField, Identifier, Result, model, query}; +use crate::utils::cli::{StatusType, print_status_msg}; const MIGRATION_ZERO_NAME: &str = "zero"; @@ -259,6 +260,10 @@ impl MigrationEngine { migration.name(), migration.app_name() ); + print_status_msg( + StatusType::RollingBack, + &format!("Migration {}::{}", migration.app_name(), migration.name()), + ); for operation in migration.operations().iter().rev() { operation.backwards(database).await?; @@ -267,6 +272,10 @@ impl MigrationEngine { if Self::is_migration_applied(database, migration).await? { Self::mark_migration_unapplied(database, migration).await?; } + print_status_msg( + StatusType::RolledBack, + &format!("Migration {}::{}", migration.app_name(), migration.name()), + ); } Ok(()) diff --git a/cot/src/lib.rs b/cot/src/lib.rs index 1479cf2df..c34512fba 100644 --- a/cot/src/lib.rs +++ b/cot/src/lib.rs @@ -80,7 +80,7 @@ pub mod session; pub mod static_files; #[cfg(feature = "test")] pub mod test; -pub(crate) mod utils; +pub mod utils; #[cfg(feature = "openapi")] pub use aide; diff --git a/cot/src/utils.rs b/cot/src/utils.rs index 21ec17e10..727cc65a7 100644 --- a/cot/src/utils.rs +++ b/cot/src/utils.rs @@ -1,4 +1,6 @@ +//! Cot utilities pub(crate) mod accept_header_parser; pub(crate) mod chrono; +pub mod cli; #[cfg(feature = "db")] pub(crate) mod graph; diff --git a/cot/src/utils/cli.rs b/cot/src/utils/cli.rs new file mode 100644 index 000000000..26938582e --- /dev/null +++ b/cot/src/utils/cli.rs @@ -0,0 +1,77 @@ +//! Utilities for CLI +use anstyle::{AnsiColor, Color, Effects, Style}; + +#[doc(hidden)] // Not part of Cot's public API; used by the CLI. +pub fn print_status_msg(status: StatusType, message: &str) { + let style = status.style(); + let status_str = status.as_str(); + + eprintln!("{style}{status_str:>12}{style:#} {message}"); +} + +#[doc(hidden)] +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] // Not part of Cot's public API; used by the CLI. +pub enum StatusType { + // In-Progress Ops + Creating, + Adding, + Modifying, + Removing, + RollingBack, + // Completed Ops + Created, + Added, + Modified, + Removed, + RolledBack, + + // Status types + Error, // Should be used in Error handling inside remove operations + Warning, // Should be used as cautionary messages. + Notice, +} + +impl StatusType { + fn style(self) -> Style { + let base_style = Style::new() | Effects::BOLD; + + match self { + // In-Progress => Brighter colors + StatusType::Creating => base_style.fg_color(Some(Color::Ansi(AnsiColor::BrightGreen))), + StatusType::Adding => base_style.fg_color(Some(Color::Ansi(AnsiColor::BrightCyan))), + StatusType::Removing | StatusType::RollingBack => { + base_style.fg_color(Some(Color::Ansi(AnsiColor::BrightMagenta))) + } + StatusType::Modifying => base_style.fg_color(Some(Color::Ansi(AnsiColor::BrightBlue))), + // Completed => Dimmed colors + StatusType::Created => base_style.fg_color(Some(Color::Ansi(AnsiColor::Green))), + StatusType::Added => base_style.fg_color(Some(Color::Ansi(AnsiColor::Cyan))), + StatusType::Removed | StatusType::RolledBack => { + base_style.fg_color(Some(Color::Ansi(AnsiColor::Magenta))) + } + StatusType::Modified => base_style.fg_color(Some(Color::Ansi(AnsiColor::Blue))), + // Status types + StatusType::Warning => base_style.fg_color(Some(Color::Ansi(AnsiColor::Yellow))), + StatusType::Error => base_style.fg_color(Some(Color::Ansi(AnsiColor::Red))), + StatusType::Notice => base_style.fg_color(Some(Color::Ansi(AnsiColor::White))), + } + } + fn as_str(self) -> &'static str { + match self { + StatusType::Creating => "Creating", + StatusType::Adding => "Adding", + StatusType::Modifying => "Modifying", + StatusType::Removing => "Removing", + StatusType::Created => "Created", + StatusType::Added => "Added", + StatusType::Modified => "Modified", + StatusType::Removed => "Removed", + StatusType::Warning => "Warning", + StatusType::Error => "Error", + StatusType::Notice => "Notice", + StatusType::RollingBack => "Rolling Back", + StatusType::RolledBack => "Rolled Back", + } + } +} From 1220134d0c78343b1415ae109bb332fdccf2ee3a Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 21 Jul 2026 13:20:14 +0000 Subject: [PATCH 19/24] move snapshot tests back --- cot/src/cli.rs | 1 + cot/src/db/migrations.rs | 78 +++++++ cot/tests/cli.rs | 2 - cot/tests/snapshot_testing/migration/mod.rs | 215 ------------------ cot/tests/snapshot_testing/mod.rs | 1 - ...ine_rollback_includes_dependent_apps.snap} | 4 +- ...migration_engine_rollback_single_app.snap} | 4 +- ...ests__migration_engine_rollback_zero.snap} | 4 +- ...s__migration_rollback_unrelated_apps.snap} | 4 +- 9 files changed, 87 insertions(+), 226 deletions(-) delete mode 100644 cot/tests/cli.rs delete mode 100644 cot/tests/snapshot_testing/migration/mod.rs delete mode 100644 cot/tests/snapshot_testing/mod.rs rename cot/tests/{snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_includes_dependent_apps.snap => snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap} (65%) rename cot/tests/{snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_single_app.snap => snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app.snap} (64%) rename cot/tests/{snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_zero.snap => snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero.snap} (67%) rename cot/tests/{snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_rollback_unrelated_apps.snap => snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap} (62%) diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 2940d3bd4..f40440e34 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -576,6 +576,7 @@ impl CliTask for CliTaskGroup { #[cfg(feature = "db")] struct MigrationRollback; +#[cfg(feature = "db")] #[async_trait(?Send)] impl CliTask for MigrationRollback { fn subcommand(&self) -> Command { diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 8b322edfa..19b93b766 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -2284,6 +2284,8 @@ mod tests { use crate::db::{ColumnType, DatabaseField, Identifier}; use crate::session::db::SessionApp; + const SNAPSHOT_RELATIVE_PATH: &str = "../../tests/snapshots/migrations"; + struct TestMigration; impl Migration for TestMigration { @@ -2414,6 +2416,21 @@ mod tests { } } + async fn dry_run_snapshot( + engine: &MigrationEngine, + db: &Database, + output: &mut Vec, + migration_name: &str, + app_name: &str, + ) -> String { + output.clear(); + engine + .rollback_dry_run(db, migration_name, app_name, output) + .await + .unwrap(); + std::str::from_utf8(output).unwrap().to_owned() + } + #[cot_macros::dbtest] async fn test_migration_rollback_no_deps(test_db: &mut TestDatabase) { let engine = MigrationEngine::new([RollbackApp1Initial]).unwrap(); @@ -2452,6 +2469,7 @@ mod tests { &RollbackApp1003 as &SyncDynMigration, ]) .unwrap(); + let mut output = Vec::new(); engine.run(&test_db.database()).await.unwrap(); // migrations should be applied @@ -2466,6 +2484,20 @@ mod tests { .await; // rollback everything except the initial migration + let snapshot_text = dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + snapshot_text, + ); + }); engine .rollback(&test_db.database(), "0001", "rollback_app1") .await @@ -2495,6 +2527,8 @@ mod tests { &RollbackApp2Initial as &SyncDynMigration, ])); migrations.extend(SessionApp::new().migrations()); + let mut output = Vec::new(); + let engine = MigrationEngine::new(migrations).unwrap(); engine.run(&test_db.database()).await.unwrap(); @@ -2512,6 +2546,20 @@ mod tests { .await; // rollback every migration in the rollback_app1 app except the initial + let snapshot_text = dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + snapshot_text, + ); + }); engine .rollback(&test_db.database(), "0001", "rollback_app1") .await @@ -2543,6 +2591,7 @@ mod tests { &RollbackApp2Initial as &SyncDynMigration, ]) .unwrap(); + let mut output = Vec::new(); engine.run(&test_db.database()).await.unwrap(); @@ -2559,6 +2608,20 @@ mod tests { // rollback everything except the initial migration in the source/independent // app + let snapshot_text = dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + snapshot_text, + ); + }); engine .rollback(&test_db.database(), "0001", "rollback_app1") .await @@ -2588,6 +2651,7 @@ mod tests { &RollbackApp2Initial as &SyncDynMigration, ]) .unwrap(); + let mut output = Vec::new(); engine.run(&test_db.database()).await.unwrap(); @@ -2602,6 +2666,20 @@ mod tests { ) .await; + let snapshot_text = dry_run_snapshot( + &engine, + &test_db.database(), + &mut output, + "zero", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + snapshot_text, + ); + }); engine .rollback(&test_db.database(), "zero", "rollback_app1") .await diff --git a/cot/tests/cli.rs b/cot/tests/cli.rs deleted file mode 100644 index a530284e9..000000000 --- a/cot/tests/cli.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[cfg(not(miri))] -mod snapshot_testing; diff --git a/cot/tests/snapshot_testing/migration/mod.rs b/cot/tests/snapshot_testing/migration/mod.rs deleted file mode 100644 index b463ba69a..000000000 --- a/cot/tests/snapshot_testing/migration/mod.rs +++ /dev/null @@ -1,215 +0,0 @@ -use cot::App; -use cot::auth::db::DatabaseUserApp; -use cot::db::migrations::{ - Field, Migration, MigrationDependency, MigrationEngine, Operation, SyncDynMigration, - wrap_migrations, -}; -use cot::db::{Database, DatabaseField, Identifier}; -use cot::session::db::SessionApp; -use cot::test::TestDatabase; - -struct RollbackApp1Initial; - -impl Migration for RollbackApp1Initial { - const APP_NAME: &'static str = "rollback_app1"; - const MIGRATION_NAME: &'static str = "m_0001_initial"; - const DEPENDENCIES: &'static [MigrationDependency] = &[]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_single__first")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; -} - -struct RollbackApp10002; - -impl Migration for RollbackApp10002 { - const APP_NAME: &'static str = "rollback_app1"; - const MIGRATION_NAME: &'static str = "m_0002_second"; - const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( - "rollback_app1", - "m_0001_initial", - )]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_app1__second")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; -} - -struct RollbackApp1003; - -impl Migration for RollbackApp1003 { - const APP_NAME: &'static str = "rollback_app1"; - const MIGRATION_NAME: &'static str = "m_0003_third"; - const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( - "rollback_app1", - "m_0002_second", - )]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_single__third")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; -} - -struct RollbackApp2Initial; - -impl Migration for RollbackApp2Initial { - const APP_NAME: &'static str = "rollback_app2"; - const MIGRATION_NAME: &'static str = "m_0001_initial"; - const DEPENDENCIES: &'static [MigrationDependency] = &[]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_app2__foo")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; -} - -struct RollbackDependentInitial; - -impl Migration for RollbackDependentInitial { - const APP_NAME: &'static str = "rollback_dependent"; - const MIGRATION_NAME: &'static str = "m_0001_initial"; - const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( - "rollback_app1", - "m_0002_second", - )]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_dependent__bar")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; -} - -async fn dry_run_snapshot( - engine: &MigrationEngine, - db: &Database, - output: &mut Vec, - migration_name: &str, - app_name: &str, -) -> String { - output.clear(); - engine - .rollback_dry_run(db, migration_name, app_name, output) - .await - .unwrap(); - std::str::from_utf8(output).unwrap().to_owned() -} - -#[cot_macros::dbtest] -async fn test_migration_engine_rollback_single_app(test_db: &mut TestDatabase) { - let mut output = Vec::new(); - #[expect(trivial_casts)] - let engine = MigrationEngine::new([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp1003 as &SyncDynMigration, - ]) - .unwrap(); - - engine.run(&test_db.database()).await.unwrap(); - - insta::assert_snapshot!( - dry_run_snapshot( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1" - ) - .await - ); -} - -#[cot_macros::dbtest] -async fn test_migration_rollback_unrelated_apps(test_db: &mut TestDatabase) { - let mut output = Vec::new(); - let mut migrations = DatabaseUserApp::new().migrations(); - - #[expect(trivial_casts)] - migrations.extend(wrap_migrations(&[ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ])); - migrations.extend(SessionApp::new().migrations()); - let engine = MigrationEngine::new(migrations).unwrap(); - engine.run(&test_db.database()).await.unwrap(); - - insta::assert_snapshot!( - dry_run_snapshot( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1" - ) - .await - ); -} - -#[cot_macros::dbtest] -async fn test_migration_engine_rollback_includes_dependent_apps(test_db: &mut TestDatabase) { - let mut output = Vec::new(); - #[expect(trivial_casts)] - let engine = MigrationEngine::new([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackDependentInitial as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ]) - .unwrap(); - engine.run(&test_db.database()).await.unwrap(); - - insta::assert_snapshot!( - dry_run_snapshot( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1" - ) - .await - ); -} - -#[cot_macros::dbtest] -async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { - let mut output = Vec::new(); - #[expect(trivial_casts)] - let engine = MigrationEngine::new([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp1003 as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ]) - .unwrap(); - engine.run(&test_db.database()).await.unwrap(); - - insta::assert_snapshot!( - dry_run_snapshot( - &engine, - &test_db.database(), - &mut output, - "zero", - "rollback_app1" - ) - .await - ); -} diff --git a/cot/tests/snapshot_testing/mod.rs b/cot/tests/snapshot_testing/mod.rs deleted file mode 100644 index 5a8970e1f..000000000 --- a/cot/tests/snapshot_testing/mod.rs +++ /dev/null @@ -1 +0,0 @@ -mod migration; diff --git a/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_includes_dependent_apps.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap similarity index 65% rename from cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_includes_dependent_apps.snap rename to cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap index 4b7a22f5a..91a870bc2 100644 --- a/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_includes_dependent_apps.snap +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap @@ -1,6 +1,6 @@ --- -source: cot/tests/snapshot_testing/migration/mod.rs -expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"0001\",\n\"rollback_app1\").await" +source: cot/src/db/migrations.rs +expression: snapshot_text --- Rollback dry run diff --git a/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_single_app.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app.snap similarity index 64% rename from cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_single_app.snap rename to cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app.snap index 26306f56a..7abf461ef 100644 --- a/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_single_app.snap +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app.snap @@ -1,6 +1,6 @@ --- -source: cot/tests/snapshot_testing/migration/mod.rs -expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"0001\",\n\"rollback_app1\").await" +source: cot/src/db/migrations.rs +expression: snapshot_text --- Rollback dry run diff --git a/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_zero.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero.snap similarity index 67% rename from cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_zero.snap rename to cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero.snap index d2107b971..f4ea9e8b0 100644 --- a/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_engine_rollback_zero.snap +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero.snap @@ -1,6 +1,6 @@ --- -source: cot/tests/snapshot_testing/migration/mod.rs -expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"zero\",\n\"rollback_app1\").await" +source: cot/src/db/migrations.rs +expression: snapshot_text --- Rollback dry run diff --git a/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_rollback_unrelated_apps.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap similarity index 62% rename from cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_rollback_unrelated_apps.snap rename to cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap index 6eea72242..8f83a542f 100644 --- a/cot/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_rollback_unrelated_apps.snap +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap @@ -1,6 +1,6 @@ --- -source: cot/tests/snapshot_testing/migration/mod.rs -expression: "dry_run_snapshot(&engine, &test_db.database(), &mut output, \"0001\",\n\"rollback_app1\").await" +source: cot/src/db/migrations.rs +expression: snapshot_text --- Rollback dry run From a5e11cdfad43362476a39f2b9a7745f608edf6af Mon Sep 17 00:00:00 2001 From: Elijah Date: Tue, 21 Jul 2026 14:09:46 +0000 Subject: [PATCH 20/24] add snapshot to rollback command --- cot/src/cli.rs | 2 +- cot/src/db/migrations.rs | 112 +++++++++++++----- cot/src/utils/cli.rs | 19 ++- ...ne_rollback_includes_dependent_apps-2.snap | 8 ++ ...igration_engine_rollback_single_app-2.snap | 8 ++ ...sts__migration_engine_rollback_zero-2.snap | 10 ++ ...__migration_rollback_unrelated_apps-2.snap | 6 + 7 files changed, 131 insertions(+), 34 deletions(-) create mode 100644 cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps-2.snap create mode 100644 cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app-2.snap create mode 100644 cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero-2.snap create mode 100644 cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps-2.snap diff --git a/cot/src/cli.rs b/cot/src/cli.rs index f40440e34..303513384 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -647,7 +647,7 @@ impl CliTask for MigrationRollback { .await?; } else { migration_engine - .rollback(context.database(), file, app_name) + .rollback(context.database(), file, app_name, &mut std::io::stderr()) .await?; } } diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 19b93b766..90b428602 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -16,7 +16,7 @@ use tracing::{Level, info}; use crate::db::migrations::sorter::{MigrationSorter, MigrationSorterError}; use crate::db::relations::{ForeignKeyOnDeletePolicy, ForeignKeyOnUpdatePolicy}; use crate::db::{Auto, ColumnType, Database, DatabaseField, Identifier, Result, model, query}; -use crate::utils::cli::{StatusType, print_status_msg}; +use crate::utils::cli::{StatusType, write_status_msg}; const MIGRATION_ZERO_NAME: &str = "zero"; @@ -236,6 +236,7 @@ impl MigrationEngine { database: &Database, migration_name: &str, app_name: &str, + output: &mut impl Write, ) -> Result<()> { info!("Rolling back migrations"); // TODO: use a DB transaction here @@ -260,10 +261,12 @@ impl MigrationEngine { migration.name(), migration.app_name() ); - print_status_msg( + write_status_msg( + output, StatusType::RollingBack, &format!("Migration {}::{}", migration.app_name(), migration.name()), - ); + ) + .map_err(MigrationEngineError::Io)?; for operation in migration.operations().iter().rev() { operation.backwards(database).await?; @@ -272,10 +275,12 @@ impl MigrationEngine { if Self::is_migration_applied(database, migration).await? { Self::mark_migration_unapplied(database, migration).await?; } - print_status_msg( + write_status_msg( + output, StatusType::RolledBack, &format!("Migration {}::{}", migration.app_name(), migration.name()), - ); + ) + .map_err(MigrationEngineError::Io)?; } Ok(()) @@ -2416,7 +2421,7 @@ mod tests { } } - async fn dry_run_snapshot( + async fn migration_rollback_dry_run( engine: &MigrationEngine, db: &Database, output: &mut Vec, @@ -2431,6 +2436,21 @@ mod tests { std::str::from_utf8(output).unwrap().to_owned() } + async fn migration_rollback( + engine: &MigrationEngine, + db: &Database, + output: &mut Vec, + migration_name: &str, + app_name: &str, + ) -> String { + output.clear(); + engine + .rollback(db, migration_name, app_name, output) + .await + .unwrap(); + std::str::from_utf8(output).unwrap().to_owned() + } + #[cot_macros::dbtest] async fn test_migration_rollback_no_deps(test_db: &mut TestDatabase) { let engine = MigrationEngine::new([RollbackApp1Initial]).unwrap(); @@ -2484,7 +2504,7 @@ mod tests { .await; // rollback everything except the initial migration - let snapshot_text = dry_run_snapshot( + let dry_run_output = migration_rollback_dry_run( &engine, &test_db.database(), &mut output, @@ -2495,13 +2515,21 @@ mod tests { insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { insta::assert_snapshot!( - snapshot_text, + dry_run_output, ); }); - engine - .rollback(&test_db.database(), "0001", "rollback_app1") - .await - .unwrap(); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); assert_migrations_applied( &test_db.database(), @@ -2546,7 +2574,7 @@ mod tests { .await; // rollback every migration in the rollback_app1 app except the initial - let snapshot_text = dry_run_snapshot( + let dry_run_output = migration_rollback_dry_run( &engine, &test_db.database(), &mut output, @@ -2557,13 +2585,21 @@ mod tests { insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { insta::assert_snapshot!( - snapshot_text, + dry_run_output, ); }); - engine - .rollback(&test_db.database(), "0001", "rollback_app1") - .await - .unwrap(); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); assert_migrations_applied( &test_db.database(), @@ -2608,7 +2644,7 @@ mod tests { // rollback everything except the initial migration in the source/independent // app - let snapshot_text = dry_run_snapshot( + let dry_run_output = migration_rollback_dry_run( &engine, &test_db.database(), &mut output, @@ -2619,13 +2655,21 @@ mod tests { insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { insta::assert_snapshot!( - snapshot_text, + dry_run_output, ); }); - engine - .rollback(&test_db.database(), "0001", "rollback_app1") - .await - .unwrap(); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); assert_migrations_applied( &test_db.database(), @@ -2666,7 +2710,7 @@ mod tests { ) .await; - let snapshot_text = dry_run_snapshot( + let dry_run_output = migration_rollback_dry_run( &engine, &test_db.database(), &mut output, @@ -2677,13 +2721,21 @@ mod tests { insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { insta::assert_snapshot!( - snapshot_text, + dry_run_output, ); }); - engine - .rollback(&test_db.database(), "zero", "rollback_app1") - .await - .unwrap(); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "zero", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); assert_migrations_applied( &test_db.database(), diff --git a/cot/src/utils/cli.rs b/cot/src/utils/cli.rs index 26938582e..8385a3715 100644 --- a/cot/src/utils/cli.rs +++ b/cot/src/utils/cli.rs @@ -1,17 +1,30 @@ //! Utilities for CLI + +use std::io; +use std::io::Write; + use anstyle::{AnsiColor, Color, Effects, Style}; #[doc(hidden)] // Not part of Cot's public API; used by the CLI. pub fn print_status_msg(status: StatusType, message: &str) { + write_status_msg(&mut io::stderr(), status, message) + .expect("writing to stderr should not fail"); +} + +pub(crate) fn write_status_msg( + output: &mut impl Write, + status: StatusType, + message: &str, +) -> io::Result<()> { let style = status.style(); let status_str = status.as_str(); - eprintln!("{style}{status_str:>12}{style:#} {message}"); + writeln!(output, "{style}{status_str:>12}{style:#} {message}") } -#[doc(hidden)] +#[doc(hidden)] // Not part of Cot's public API; used by the CLI. #[derive(Debug, Clone, Copy)] -#[non_exhaustive] // Not part of Cot's public API; used by the CLI. +#[non_exhaustive] pub enum StatusType { // In-Progress Ops Creating, diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps-2.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps-2.snap new file mode 100644 index 000000000..3d2956c30 --- /dev/null +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps-2.snap @@ -0,0 +1,8 @@ +--- +source: cot/src/db/migrations.rs +expression: rollback_output +--- +Rolling Back Migration rollback_dependent::m_0001_initial + Rolled Back Migration rollback_dependent::m_0001_initial +Rolling Back Migration rollback_app1::m_0002_second + Rolled Back Migration rollback_app1::m_0002_second diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app-2.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app-2.snap new file mode 100644 index 000000000..08f77ff8d --- /dev/null +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app-2.snap @@ -0,0 +1,8 @@ +--- +source: cot/src/db/migrations.rs +expression: rollback_output +--- +Rolling Back Migration rollback_app1::m_0003_third + Rolled Back Migration rollback_app1::m_0003_third +Rolling Back Migration rollback_app1::m_0002_second + Rolled Back Migration rollback_app1::m_0002_second diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero-2.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero-2.snap new file mode 100644 index 000000000..44785e6a5 --- /dev/null +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero-2.snap @@ -0,0 +1,10 @@ +--- +source: cot/src/db/migrations.rs +expression: rollback_output +--- +Rolling Back Migration rollback_app1::m_0003_third + Rolled Back Migration rollback_app1::m_0003_third +Rolling Back Migration rollback_app1::m_0002_second + Rolled Back Migration rollback_app1::m_0002_second +Rolling Back Migration rollback_app1::m_0001_initial + Rolled Back Migration rollback_app1::m_0001_initial diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps-2.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps-2.snap new file mode 100644 index 000000000..e963d8ae2 --- /dev/null +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps-2.snap @@ -0,0 +1,6 @@ +--- +source: cot/src/db/migrations.rs +expression: rollback_output +--- +Rolling Back Migration rollback_app1::m_0002_second + Rolled Back Migration rollback_app1::m_0002_second From 8cd5172dcae4a3b069ff5ceb131940bbde0203f5 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 26 Jul 2026 22:40:22 +0000 Subject: [PATCH 21/24] move tests to `tests` dir --- cot/Cargo.toml | 2 +- cot/src/db/migrations.rs | 416 ----------------- cot/tests/db.rs | 430 +++++++++++++++++- ...e_rollback_includes_dependent_apps-2.snap} | 2 +- ...ine_rollback_includes_dependent_apps.snap} | 4 +- ...gration_engine_rollback_single_app-2.snap} | 2 +- ...migration_engine_rollback_single_app.snap} | 4 +- ...db__migration_engine_rollback_zero-2.snap} | 2 +- ...> db__migration_engine_rollback_zero.snap} | 4 +- ..._migration_rollback_unrelated_apps-2.snap} | 2 +- ...b__migration_rollback_unrelated_apps.snap} | 4 +- 11 files changed, 442 insertions(+), 430 deletions(-) rename cot/tests/snapshots/migrations/{cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps-2.snap => db__migration_engine_rollback_includes_dependent_apps-2.snap} (90%) rename cot/tests/snapshots/migrations/{cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap => db__migration_engine_rollback_includes_dependent_apps.snap} (83%) rename cot/tests/snapshots/migrations/{cot__db__migrations__tests__migration_engine_rollback_single_app-2.snap => db__migration_engine_rollback_single_app-2.snap} (89%) rename cot/tests/snapshots/migrations/{cot__db__migrations__tests__migration_engine_rollback_single_app.snap => db__migration_engine_rollback_single_app.snap} (83%) rename cot/tests/snapshots/migrations/{cot__db__migrations__tests__migration_engine_rollback_zero-2.snap => db__migration_engine_rollback_zero-2.snap} (92%) rename cot/tests/snapshots/migrations/{cot__db__migrations__tests__migration_engine_rollback_zero.snap => db__migration_engine_rollback_zero.snap} (84%) rename cot/tests/snapshots/migrations/{cot__db__migrations__tests__migration_rollback_unrelated_apps-2.snap => db__migration_rollback_unrelated_apps-2.snap} (83%) rename cot/tests/snapshots/migrations/{cot__db__migrations__tests__migration_rollback_unrelated_apps.snap => db__migration_rollback_unrelated_apps.snap} (81%) diff --git a/cot/Cargo.toml b/cot/Cargo.toml index 51d0fb7d4..345dac818 100644 --- a/cot/Cargo.toml +++ b/cot/Cargo.toml @@ -25,7 +25,7 @@ blake3.workspace = true bytes.workspace = true chrono = { workspace = true, features = ["alloc", "serde", "clock"] } chrono-tz.workspace = true -clap = {workspace = true, features = ["string"] } +clap = { workspace = true, features = ["string"] } cot_core.workspace = true cot_macros.workspace = true deadpool-redis = { workspace = true, features = ["tokio-comp", "rt_tokio_1"], optional = true } diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 90b428602..70136a586 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -2284,12 +2284,7 @@ mod tests { use cot::test::TestDatabase; use super::*; - use crate::App; - use crate::auth::db::DatabaseUserApp; use crate::db::{ColumnType, DatabaseField, Identifier}; - use crate::session::db::SessionApp; - - const SNAPSHOT_RELATIVE_PATH: &str = "../../tests/snapshots/migrations"; struct TestMigration; @@ -2317,146 +2312,6 @@ mod tests { const OPERATIONS: &'static [Operation] = &[]; } - struct RollbackApp1Initial; - - impl Migration for RollbackApp1Initial { - const APP_NAME: &'static str = "rollback_app1"; - const MIGRATION_NAME: &'static str = "m_0001_initial"; - const DEPENDENCIES: &'static [MigrationDependency] = &[]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_single__first")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; - } - - struct RollbackApp10002; - - impl Migration for RollbackApp10002 { - const APP_NAME: &'static str = "rollback_app1"; - const MIGRATION_NAME: &'static str = "m_0002_second"; - const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( - "rollback_app1", - "m_0001_initial", - )]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_app1__second")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; - } - - struct RollbackApp1003; - - impl Migration for RollbackApp1003 { - const APP_NAME: &'static str = "rollback_app1"; - const MIGRATION_NAME: &'static str = "m_0003_third"; - const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( - "rollback_app1", - "m_0002_second", - )]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_single__third")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; - } - - struct RollbackApp2Initial; - - impl Migration for RollbackApp2Initial { - const APP_NAME: &'static str = "rollback_app2"; - const MIGRATION_NAME: &'static str = "m_0001_initial"; - const DEPENDENCIES: &'static [MigrationDependency] = &[]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_app2__foo")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; - } - - struct RollbackDependentInitial; - - impl Migration for RollbackDependentInitial { - const APP_NAME: &'static str = "rollback_dependent"; - const MIGRATION_NAME: &'static str = "m_0001_initial"; - const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( - "rollback_app1", - "m_0002_second", - )]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_dependent__bar")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; - } - - async fn assert_migration_applied(database: &Database, app: &str, name: &str, expected: bool) { - let applied = query!(AppliedMigration, $app == app && $name == name) - .exists(database) - .await - .unwrap(); - - assert_eq!(applied, expected, "{app}::{name}"); - } - - async fn assert_migrations_applied(db: &Database, expected: &[(&str, &str, bool)]) { - for &(app, name, applied) in expected { - assert_migration_applied(db, app, name, applied).await; - } - } - - async fn migration_rollback_dry_run( - engine: &MigrationEngine, - db: &Database, - output: &mut Vec, - migration_name: &str, - app_name: &str, - ) -> String { - output.clear(); - engine - .rollback_dry_run(db, migration_name, app_name, output) - .await - .unwrap(); - std::str::from_utf8(output).unwrap().to_owned() - } - - async fn migration_rollback( - engine: &MigrationEngine, - db: &Database, - output: &mut Vec, - migration_name: &str, - app_name: &str, - ) -> String { - output.clear(); - engine - .rollback(db, migration_name, app_name, output) - .await - .unwrap(); - std::str::from_utf8(output).unwrap().to_owned() - } - - #[cot_macros::dbtest] - async fn test_migration_rollback_no_deps(test_db: &mut TestDatabase) { - let engine = MigrationEngine::new([RollbackApp1Initial]).unwrap(); - engine.run(&test_db.database()).await.unwrap(); - } - #[cot_macros::dbtest] async fn test_migration_engine_run(test_db: &mut TestDatabase) { let engine = MigrationEngine::new([TestMigration]).unwrap(); @@ -2480,277 +2335,6 @@ mod tests { assert!(result.is_ok()); } - #[cot_macros::dbtest] - async fn test_migration_engine_rollback_single_app(test_db: &mut TestDatabase) { - #[expect(trivial_casts)] - let engine = MigrationEngine::new([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp1003 as &SyncDynMigration, - ]) - .unwrap(); - let mut output = Vec::new(); - - engine.run(&test_db.database()).await.unwrap(); - // migrations should be applied - assert_migrations_applied( - &test_db.database(), - &[ - ("rollback_app1", "m_0001_initial", true), - ("rollback_app1", "m_0002_second", true), - ("rollback_app1", "m_0003_third", true), - ], - ) - .await; - - // rollback everything except the initial migration - let dry_run_output = migration_rollback_dry_run( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!( - dry_run_output, - ); - }); - - let rollback_output = migration_rollback( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!(rollback_output); - }); - - assert_migrations_applied( - &test_db.database(), - &[ - // the initial migration should stay applied - ("rollback_app1", "m_0001_initial", true), - // everything else should be unapplied - ("rollback_app1", "m_0002_second", false), - ("rollback_app1", "m_0003_third", false), - ], - ) - .await; - } - - #[cot_macros::dbtest] - async fn test_migration_rollback_unrelated_apps(test_db: &mut TestDatabase) { - let mut migrations = DatabaseUserApp::new().migrations(); - // combine migrations from multiple apps/crates - #[expect(trivial_casts)] - migrations.extend(wrap_migrations(&[ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ])); - migrations.extend(SessionApp::new().migrations()); - let mut output = Vec::new(); - - let engine = MigrationEngine::new(migrations).unwrap(); - - engine.run(&test_db.database()).await.unwrap(); - // migrations should be applied across all apps - assert_migrations_applied( - &test_db.database(), - &[ - ("cot", "m_0001_initial", true), - ("cot_session", "m_0001_initial", true), - ("rollback_app1", "m_0001_initial", true), - ("rollback_app1", "m_0002_second", true), - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; - - // rollback every migration in the rollback_app1 app except the initial - let dry_run_output = migration_rollback_dry_run( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!( - dry_run_output, - ); - }); - - let rollback_output = migration_rollback( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!(rollback_output); - }); - - assert_migrations_applied( - &test_db.database(), - &[ - // the initial migration should stay applied - ("rollback_app1", "m_0001_initial", true), - // everything else in the rollback_app1 app should be unapplied - ("rollback_app1", "m_0002_second", false), - // migrations from other apps should remain unaffected - ("cot", "m_0001_initial", true), - ("cot_session", "m_0001_initial", true), - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; - } - - #[cot_macros::dbtest] - async fn test_migration_engine_rollback_includes_dependent_apps(test_db: &mut TestDatabase) { - #[expect(trivial_casts)] - let engine = MigrationEngine::new([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackDependentInitial as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ]) - .unwrap(); - let mut output = Vec::new(); - - engine.run(&test_db.database()).await.unwrap(); - - assert_migrations_applied( - &test_db.database(), - &[ - ("rollback_app1", "m_0001_initial", true), - ("rollback_app1", "m_0002_second", true), - ("rollback_dependent", "m_0001_initial", true), - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; - - // rollback everything except the initial migration in the source/independent - // app - let dry_run_output = migration_rollback_dry_run( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!( - dry_run_output, - ); - }); - - let rollback_output = migration_rollback( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!(rollback_output); - }); - - assert_migrations_applied( - &test_db.database(), - &[ - ("rollback_app1", "m_0001_initial", true), - ("rollback_app1", "m_0002_second", false), - // the sink/dependent app should also be unapplied/rolled back - ("rollback_dependent", "m_0001_initial", false), - // migrations from non-dependent apps should remain unaffected - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; - } - - #[cot_macros::dbtest] - async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { - #[expect(trivial_casts)] - let engine = MigrationEngine::new([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp1003 as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ]) - .unwrap(); - let mut output = Vec::new(); - - engine.run(&test_db.database()).await.unwrap(); - - assert_migrations_applied( - &test_db.database(), - &[ - ("rollback_app1", "m_0001_initial", true), - ("rollback_app1", "m_0002_second", true), - ("rollback_app1", "m_0003_third", true), - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; - - let dry_run_output = migration_rollback_dry_run( - &engine, - &test_db.database(), - &mut output, - "zero", - "rollback_app1", - ) - .await; - - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!( - dry_run_output, - ); - }); - - let rollback_output = migration_rollback( - &engine, - &test_db.database(), - &mut output, - "zero", - "rollback_app1", - ) - .await; - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!(rollback_output); - }); - - assert_migrations_applied( - &test_db.database(), - &[ - // everything should be unapplied - ("rollback_app1", "m_0001_initial", false), - ("rollback_app1", "m_0002_second", false), - ("rollback_app1", "m_0003_third", false), - // the non dependent apps should be unaffected - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; - } - #[test] fn test_operation_create_model() { const OPERATION_CREATE_MODEL_FIELDS: &[Field; 2] = &[ diff --git a/cot/tests/db.rs b/cot/tests/db.rs index ea4887ccc..33d577f2c 100644 --- a/cot/tests/db.rs +++ b/cot/tests/db.rs @@ -2,19 +2,27 @@ #![cfg_attr(miri, ignore)] use bytes::Bytes; +use cot::App; use cot::auth::PasswordHash; +use cot::auth::db::DatabaseUserApp; use cot::common_types::{Email, Password, Url}; -use cot::db::migrations::{Field, Operation}; +use cot::db::migrations::{ + Field, Migration, MigrationDependency, MigrationEngine, Operation, SyncDynMigration, + wrap_migrations, +}; use cot::db::query::ExprEq; use cot::db::{ Auto, Database, DatabaseError, DatabaseField, ForeignKey, ForeignKeyOnDeletePolicy, ForeignKeyOnUpdatePolicy, Identifier, LimitedString, Model, model, query, }; +use cot::session::db::SessionApp; use cot::test::TestDatabase; use fake::rand::rngs::StdRng; use fake::rand::{RngExt, SeedableRng}; use fake::{Dummy, Fake, Faker}; +const SNAPSHOT_RELATIVE_PATH: &str = "snapshots/migrations"; + struct WeekdaySetFaker; impl Dummy for chrono::WeekdaySet { @@ -75,6 +83,111 @@ struct TestModel { name: String, } +// mirror the internal AppliedMigration model +#[derive(Debug)] +#[model(table_name = "cot__migrations", model_type = "internal")] +struct AppliedMigration { + #[model(primary_key)] + id: Auto, + app: String, + name: String, + applied: chrono::DateTime, +} +struct RollbackApp1Initial; + +impl Migration for RollbackApp1Initial { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_single__first")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackApp10002; + +impl Migration for RollbackApp10002 { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0002_second"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0001_initial", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_app1__second")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackApp1003; + +impl Migration for RollbackApp1003 { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0003_third"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0002_second", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_single__third")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackApp2Initial; + +impl Migration for RollbackApp2Initial { + const APP_NAME: &'static str = "rollback_app2"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_app2__foo")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackDependentInitial; + +impl Migration for RollbackDependentInitial { + const APP_NAME: &'static str = "rollback_dependent"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0002_second", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_dependent__bar")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +#[cot_macros::dbtest] +async fn test_migration_rollback_no_deps(test_db: &mut TestDatabase) { + let engine = MigrationEngine::new([RollbackApp1Initial]).unwrap(); + engine.run(&test_db.database()).await.unwrap(); +} + #[cot_macros::dbtest] async fn model_crud(test_db: &mut TestDatabase) { migrate_test_model(&*test_db).await; @@ -1199,3 +1312,318 @@ async fn bulk_insert_with_fixed_pk(test_db: &mut TestDatabase) { .unwrap(); assert_eq!(model300.name, "test300"); } + +async fn assert_migration_applied(database: &Database, app: &str, name: &str, expected: bool) { + let applied = query!(AppliedMigration, $app == app && $name == name) + .exists(database) + .await + .unwrap(); + assert_eq!(applied, expected, "{app}::{name}"); +} + +async fn assert_migrations_applied(db: &Database, expected: &[(&str, &str, bool)]) { + for &(app, name, applied) in expected { + assert_migration_applied(db, app, name, applied).await; + } +} + +async fn migration_rollback_dry_run( + engine: &MigrationEngine, + db: &Database, + output: &mut Vec, + migration_name: &str, + app_name: &str, +) -> String { + output.clear(); + engine + .rollback_dry_run(db, migration_name, app_name, output) + .await + .unwrap(); + std::str::from_utf8(output).unwrap().to_owned() +} + +async fn migration_rollback( + engine: &MigrationEngine, + db: &Database, + output: &mut Vec, + migration_name: &str, + app_name: &str, +) -> String { + output.clear(); + engine + .rollback(db, migration_name, app_name, output) + .await + .unwrap(); + std::str::from_utf8(output).unwrap().to_owned() +} + +#[cot_macros::dbtest] +async fn test_migration_engine_rollback_single_app(test_db: &mut TestDatabase) { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp1003 as &SyncDynMigration, + ]) + .unwrap(); + let mut output = Vec::new(); + + engine.run(&test_db.database()).await.unwrap(); + // migrations should be applied + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_app1", "m_0003_third", true), + ], + ) + .await; + + // rollback everything except the initial migration + let dry_run_output = migration_rollback_dry_run( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + dry_run_output, + ); + }); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); + + assert_migrations_applied( + &test_db.database(), + &[ + // the initial migration should stay applied + ("rollback_app1", "m_0001_initial", true), + // everything else should be unapplied + ("rollback_app1", "m_0002_second", false), + ("rollback_app1", "m_0003_third", false), + ], + ) + .await; +} + +#[cot_macros::dbtest] +async fn test_migration_rollback_unrelated_apps(test_db: &mut TestDatabase) { + let mut migrations = DatabaseUserApp::new().migrations(); + // combine migrations from multiple apps/crates + #[expect(trivial_casts)] + migrations.extend(wrap_migrations(&[ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ])); + migrations.extend(SessionApp::new().migrations()); + let mut output = Vec::new(); + + let engine = MigrationEngine::new(migrations).unwrap(); + + engine.run(&test_db.database()).await.unwrap(); + // migrations should be applied across all apps + assert_migrations_applied( + &test_db.database(), + &[ + ("cot", "m_0001_initial", true), + ("cot_session", "m_0001_initial", true), + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; + + // rollback every migration in the rollback_app1 app except the initial + let dry_run_output = migration_rollback_dry_run( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + dry_run_output, + ); + }); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); + + assert_migrations_applied( + &test_db.database(), + &[ + // the initial migration should stay applied + ("rollback_app1", "m_0001_initial", true), + // everything else in the rollback_app1 app should be unapplied + ("rollback_app1", "m_0002_second", false), + // migrations from other apps should remain unaffected + ("cot", "m_0001_initial", true), + ("cot_session", "m_0001_initial", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; +} + +#[cot_macros::dbtest] +async fn test_migration_engine_rollback_includes_dependent_apps(test_db: &mut TestDatabase) { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackDependentInitial as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ]) + .unwrap(); + let mut output = Vec::new(); + + engine.run(&test_db.database()).await.unwrap(); + + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_dependent", "m_0001_initial", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; + + // rollback everything except the initial migration in the source/independent + // app + let dry_run_output = migration_rollback_dry_run( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + dry_run_output, + ); + }); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); + + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", false), + // the sink/dependent app should also be unapplied/rolled back + ("rollback_dependent", "m_0001_initial", false), + // migrations from non-dependent apps should remain unaffected + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; +} + +#[cot_macros::dbtest] +async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp1003 as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ]) + .unwrap(); + let mut output = Vec::new(); + + engine.run(&test_db.database()).await.unwrap(); + + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_app1", "m_0003_third", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; + + let dry_run_output = migration_rollback_dry_run( + &engine, + &test_db.database(), + &mut output, + "zero", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + dry_run_output, + ); + }); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "zero", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); + + assert_migrations_applied( + &test_db.database(), + &[ + // everything should be unapplied + ("rollback_app1", "m_0001_initial", false), + ("rollback_app1", "m_0002_second", false), + ("rollback_app1", "m_0003_third", false), + // the non dependent apps should be unaffected + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; +} diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps-2.snap b/cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps-2.snap similarity index 90% rename from cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps-2.snap rename to cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps-2.snap index 3d2956c30..b5230c5b4 100644 --- a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps-2.snap +++ b/cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps-2.snap @@ -1,5 +1,5 @@ --- -source: cot/src/db/migrations.rs +source: cot/tests/db.rs expression: rollback_output --- Rolling Back Migration rollback_dependent::m_0001_initial diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap b/cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps.snap similarity index 83% rename from cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap rename to cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps.snap index 91a870bc2..8e1c152bf 100644 --- a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap +++ b/cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps.snap @@ -1,6 +1,6 @@ --- -source: cot/src/db/migrations.rs -expression: snapshot_text +source: cot/tests/db.rs +expression: dry_run_output --- Rollback dry run diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app-2.snap b/cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app-2.snap similarity index 89% rename from cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app-2.snap rename to cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app-2.snap index 08f77ff8d..b13824ce4 100644 --- a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app-2.snap +++ b/cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app-2.snap @@ -1,5 +1,5 @@ --- -source: cot/src/db/migrations.rs +source: cot/tests/db.rs expression: rollback_output --- Rolling Back Migration rollback_app1::m_0003_third diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app.snap b/cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app.snap similarity index 83% rename from cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app.snap rename to cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app.snap index 7abf461ef..ed7a8efa2 100644 --- a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app.snap +++ b/cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app.snap @@ -1,6 +1,6 @@ --- -source: cot/src/db/migrations.rs -expression: snapshot_text +source: cot/tests/db.rs +expression: dry_run_output --- Rollback dry run diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero-2.snap b/cot/tests/snapshots/migrations/db__migration_engine_rollback_zero-2.snap similarity index 92% rename from cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero-2.snap rename to cot/tests/snapshots/migrations/db__migration_engine_rollback_zero-2.snap index 44785e6a5..26796dd7e 100644 --- a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero-2.snap +++ b/cot/tests/snapshots/migrations/db__migration_engine_rollback_zero-2.snap @@ -1,5 +1,5 @@ --- -source: cot/src/db/migrations.rs +source: cot/tests/db.rs expression: rollback_output --- Rolling Back Migration rollback_app1::m_0003_third diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero.snap b/cot/tests/snapshots/migrations/db__migration_engine_rollback_zero.snap similarity index 84% rename from cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero.snap rename to cot/tests/snapshots/migrations/db__migration_engine_rollback_zero.snap index f4ea9e8b0..32ce59c44 100644 --- a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero.snap +++ b/cot/tests/snapshots/migrations/db__migration_engine_rollback_zero.snap @@ -1,6 +1,6 @@ --- -source: cot/src/db/migrations.rs -expression: snapshot_text +source: cot/tests/db.rs +expression: dry_run_output --- Rollback dry run diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps-2.snap b/cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps-2.snap similarity index 83% rename from cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps-2.snap rename to cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps-2.snap index e963d8ae2..ac87c2a72 100644 --- a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps-2.snap +++ b/cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps-2.snap @@ -1,5 +1,5 @@ --- -source: cot/src/db/migrations.rs +source: cot/tests/db.rs expression: rollback_output --- Rolling Back Migration rollback_app1::m_0002_second diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap b/cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps.snap similarity index 81% rename from cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap rename to cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps.snap index 8f83a542f..ec581982d 100644 --- a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap +++ b/cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps.snap @@ -1,6 +1,6 @@ --- -source: cot/src/db/migrations.rs -expression: snapshot_text +source: cot/tests/db.rs +expression: dry_run_output --- Rollback dry run From f1ac2913333f60d577167b2b2214a7e6159a8fff Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 26 Jul 2026 23:35:29 +0000 Subject: [PATCH 22/24] refactor tests --- cot/tests/db.rs | 1627 +---------------- cot/tests/db_testing/fields.rs | 431 +++++ cot/tests/db_testing/migrations.rs | 432 +++++ cot/tests/db_testing/mod.rs | 26 + cot/tests/db_testing/query.rs | 511 ++++++ cot/tests/db_testing/relations.rs | 247 +++ ...e_rollback_includes_dependent_apps-2.snap} | 2 +- ...ine_rollback_includes_dependent_apps.snap} | 2 +- ...gration_engine_rollback_single_app-2.snap} | 2 +- ...migration_engine_rollback_single_app.snap} | 2 +- ...ns__migration_engine_rollback_zero-2.snap} | 2 +- ...ions__migration_engine_rollback_zero.snap} | 2 +- ..._migration_rollback_unrelated_apps-2.snap} | 2 +- ...s__migration_rollback_unrelated_apps.snap} | 2 +- 14 files changed, 1656 insertions(+), 1634 deletions(-) create mode 100644 cot/tests/db_testing/fields.rs create mode 100644 cot/tests/db_testing/migrations.rs create mode 100644 cot/tests/db_testing/mod.rs create mode 100644 cot/tests/db_testing/query.rs create mode 100644 cot/tests/db_testing/relations.rs rename cot/tests/{snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps-2.snap => db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_includes_dependent_apps-2.snap} (87%) rename cot/tests/{snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps.snap => db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_includes_dependent_apps.snap} (88%) rename cot/tests/{snapshots/migrations/db__migration_engine_rollback_single_app-2.snap => db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app-2.snap} (87%) rename cot/tests/{snapshots/migrations/db__migration_engine_rollback_single_app.snap => db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app.snap} (88%) rename cot/tests/{snapshots/migrations/db__migration_engine_rollback_zero-2.snap => db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero-2.snap} (90%) rename cot/tests/{snapshots/migrations/db__migration_engine_rollback_zero.snap => db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero.snap} (89%) rename cot/tests/{snapshots/migrations/db__migration_rollback_unrelated_apps-2.snap => db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps-2.snap} (79%) rename cot/tests/{snapshots/migrations/db__migration_rollback_unrelated_apps.snap => db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps.snap} (87%) diff --git a/cot/tests/db.rs b/cot/tests/db.rs index 33d577f2c..0b50dd634 100644 --- a/cot/tests/db.rs +++ b/cot/tests/db.rs @@ -1,1629 +1,4 @@ #![cfg(feature = "fake")] #![cfg_attr(miri, ignore)] -use bytes::Bytes; -use cot::App; -use cot::auth::PasswordHash; -use cot::auth::db::DatabaseUserApp; -use cot::common_types::{Email, Password, Url}; -use cot::db::migrations::{ - Field, Migration, MigrationDependency, MigrationEngine, Operation, SyncDynMigration, - wrap_migrations, -}; -use cot::db::query::ExprEq; -use cot::db::{ - Auto, Database, DatabaseError, DatabaseField, ForeignKey, ForeignKeyOnDeletePolicy, - ForeignKeyOnUpdatePolicy, Identifier, LimitedString, Model, model, query, -}; -use cot::session::db::SessionApp; -use cot::test::TestDatabase; -use fake::rand::rngs::StdRng; -use fake::rand::{RngExt, SeedableRng}; -use fake::{Dummy, Fake, Faker}; - -const SNAPSHOT_RELATIVE_PATH: &str = "snapshots/migrations"; - -struct WeekdaySetFaker; - -impl Dummy for chrono::WeekdaySet { - fn dummy_with_rng(_: &WeekdaySetFaker, rng: &mut R) -> Self { - use chrono::Weekday; - - let mut set = chrono::WeekdaySet::EMPTY; - let weekdays = [ - Weekday::Mon, - Weekday::Tue, - Weekday::Wed, - Weekday::Thu, - Weekday::Fri, - Weekday::Sat, - Weekday::Sun, - ]; - - for weekday in weekdays { - if rng.random_bool(0.5) { - set.insert(weekday); - } - } - - set - } -} - -struct EmailFaker; - -impl Dummy for Email { - fn dummy_with_rng(_: &EmailFaker, rng: &mut R) -> Self { - let username: String = (0..10) - .map(|_| (0x61u8 + (rng.next_u32() % 26) as u8) as char) - .collect(); - let domain: String = (0..10) - .map(|_| (0x61u8 + (rng.next_u32() % 26) as u8) as char) - .collect(); - Email::new(format!("{username}@{domain}.com")).expect("Generated email should be valid") - } -} - -struct UrlFaker; - -impl Dummy for Url { - fn dummy_with_rng(_config: &UrlFaker, rng: &mut R) -> Self { - let domain: String = (0..10) - .map(|_| (0x61u8 + (rng.next_u32() % 26) as u8) as char) - .collect(); - Url::new(format!("https://{domain}.com")).expect("Generated URL should be valid") - } -} - -#[derive(Debug, PartialEq)] -#[model] -struct TestModel { - #[model(primary_key)] - id: Auto, - name: String, -} - -// mirror the internal AppliedMigration model -#[derive(Debug)] -#[model(table_name = "cot__migrations", model_type = "internal")] -struct AppliedMigration { - #[model(primary_key)] - id: Auto, - app: String, - name: String, - applied: chrono::DateTime, -} -struct RollbackApp1Initial; - -impl Migration for RollbackApp1Initial { - const APP_NAME: &'static str = "rollback_app1"; - const MIGRATION_NAME: &'static str = "m_0001_initial"; - const DEPENDENCIES: &'static [MigrationDependency] = &[]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_single__first")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; -} - -struct RollbackApp10002; - -impl Migration for RollbackApp10002 { - const APP_NAME: &'static str = "rollback_app1"; - const MIGRATION_NAME: &'static str = "m_0002_second"; - const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( - "rollback_app1", - "m_0001_initial", - )]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_app1__second")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; -} - -struct RollbackApp1003; - -impl Migration for RollbackApp1003 { - const APP_NAME: &'static str = "rollback_app1"; - const MIGRATION_NAME: &'static str = "m_0003_third"; - const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( - "rollback_app1", - "m_0002_second", - )]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_single__third")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; -} - -struct RollbackApp2Initial; - -impl Migration for RollbackApp2Initial { - const APP_NAME: &'static str = "rollback_app2"; - const MIGRATION_NAME: &'static str = "m_0001_initial"; - const DEPENDENCIES: &'static [MigrationDependency] = &[]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_app2__foo")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; -} - -struct RollbackDependentInitial; - -impl Migration for RollbackDependentInitial { - const APP_NAME: &'static str = "rollback_dependent"; - const MIGRATION_NAME: &'static str = "m_0001_initial"; - const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( - "rollback_app1", - "m_0002_second", - )]; - const OPERATIONS: &'static [Operation] = &[Operation::create_model() - .table_name(Identifier::new("rollback_dependent__bar")) - .fields(&[ - Field::new(Identifier::new("id"), ::TYPE) - .primary_key() - .auto(), - ]) - .build()]; -} - -#[cot_macros::dbtest] -async fn test_migration_rollback_no_deps(test_db: &mut TestDatabase) { - let engine = MigrationEngine::new([RollbackApp1Initial]).unwrap(); - engine.run(&test_db.database()).await.unwrap(); -} - -#[cot_macros::dbtest] -async fn model_crud(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - assert_eq!(TestModel::objects().all(&**test_db).await.unwrap(), vec![]); - - // Create - let mut model = TestModel { - id: Auto::fixed(1), - name: "test".to_owned(), - }; - model.save(&**test_db).await.unwrap(); - - // Read - let objects = TestModel::objects().all(&**test_db).await.unwrap(); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].name, "test"); - - // Update (& read again) - model.name = "test2".to_owned(); - model.save(&**test_db).await.unwrap(); - let objects = TestModel::objects().all(&**test_db).await.unwrap(); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].name, "test2"); - - // Delete - TestModel::objects() - .filter(::Fields::id.eq(1)) - .delete(&**test_db) - .await - .unwrap(); - - assert_eq!(TestModel::objects().all(&**test_db).await.unwrap(), vec![]); -} - -#[cot_macros::dbtest] -async fn model_insert(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - // Insert - let mut model = TestModel { - id: Auto::fixed(1), - name: "test".to_owned(), - }; - let result = model.insert(&**test_db).await; - assert!(result.is_ok()); - - // Can't insert the same model instance again - let result = model.insert(&**test_db).await; - assert!(result.is_err()); - - // Read the model from the database - let objects = TestModel::objects().all(&**test_db).await.unwrap(); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].name, "test"); -} - -#[cot_macros::dbtest] -async fn model_update(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - // Insert - let mut model = TestModel { - id: Auto::fixed(1), - name: "test".to_owned(), - }; - let result = model.insert(&**test_db).await; - assert!(result.is_ok()); - - // Update - model.name = "test2".to_owned(); - let result = model.update(&**test_db).await; - assert!(result.is_ok()); - - // Can't update non-existing object - let mut model = TestModel { - id: Auto::fixed(2), - name: "test3".to_owned(), - }; - let result = model.update(&**test_db).await; - assert!(result.is_err()); - - // Read the model from the database - let objects = TestModel::objects().all(&**test_db).await.unwrap(); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].name, "test2"); -} - -#[cot_macros::dbtest] -async fn model_macro_filtering(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - assert_eq!(TestModel::objects().all(&**test_db).await.unwrap(), vec![]); - - let mut model = TestModel { - id: Auto::auto(), - name: "test".to_owned(), - }; - model.save(&**test_db).await.unwrap(); - let objects = query!(TestModel, $name == "test") - .all(&**test_db) - .await - .unwrap(); - assert_eq!(objects.len(), 1); - assert_eq!(objects[0].name, "test"); - - let objects = query!(TestModel, $name == "t") - .all(&**test_db) - .await - .unwrap(); - assert!(objects.is_empty()); -} - -#[cot_macros::dbtest] -async fn raw_as_maps_rows_to_model(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - let mut model1 = TestModel { - id: Auto::fixed(1), - name: "test1".to_owned(), - }; - model1.save(&**test_db).await.unwrap(); - let mut model2 = TestModel { - id: Auto::fixed(2), - name: "test2".to_owned(), - }; - model2.save(&**test_db).await.unwrap(); - - let mut objects = test_db - .raw_as::("SELECT * FROM cot__test_model") - .await - .unwrap(); - objects.sort_by_key(|o| o.id); - assert_eq!(objects, vec![model1, model2]); -} - -#[cot_macros::dbtest] -async fn raw_executes_statement(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - let mut model = TestModel { - id: Auto::fixed(1), - name: "test".to_owned(), - }; - model.save(&**test_db).await.unwrap(); - - let result = test_db - .raw("UPDATE cot__test_model SET name = 'updated'") - .await - .unwrap(); - assert_eq!(result.rows_affected().0, 1); - - let objects = TestModel::objects().all(&**test_db).await.unwrap(); - assert_eq!(objects[0].name, "updated"); -} - -#[cot_macros::dbtest] -async fn raw_returns_error_for_invalid_sql(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - let result = test_db.raw("NOT A VALID SQL STATEMENT").await; - assert!(result.is_err()); -} - -// `raw_with`/`raw_as_with` need bound-parameter placeholders in the SQL text -// itself (`?` on SQLite/MySQL vs. `$1, $2, ...` on PostgreSQL), so a single -// `dbtest` function body can't exercise all three backends. These are -// therefore SQLite-only. - -#[cfg(feature = "sqlite")] -#[cot::test] -#[cfg_attr( - miri, - ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" -)] -async fn raw_with_executes_parameterized_statement() { - let db = TestDatabase::new_sqlite() - .await - .expect("failed to create SQLite test database"); - migrate_test_model(&db).await; - - let mut model = TestModel { - id: Auto::fixed(1), - name: "test".to_owned(), - }; - model.save(&*db).await.unwrap(); - - let params: &[&dyn cot::db::ToDbValue] = &[&"updated", &1_i32]; - let result = db - .raw_with("UPDATE cot__test_model SET name = ? WHERE id = ?", params) - .await - .unwrap(); - assert_eq!(result.rows_affected().0, 1); - - let objects = TestModel::objects().all(&*db).await.unwrap(); - assert_eq!(objects[0].name, "updated"); - - db.cleanup() - .await - .expect("failed to clean up SQLite test database"); -} - -#[cfg(feature = "sqlite")] -#[cot::test] -#[cfg_attr( - miri, - ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" -)] -async fn raw_as_with_maps_parameterized_rows_to_model() { - let db = TestDatabase::new_sqlite() - .await - .expect("failed to create SQLite test database"); - migrate_test_model(&db).await; - - let mut model1 = TestModel { - id: Auto::fixed(1), - name: "test1".to_owned(), - }; - model1.save(&*db).await.unwrap(); - let mut model2 = TestModel { - id: Auto::fixed(2), - name: "test2".to_owned(), - }; - model2.save(&*db).await.unwrap(); - - let objects = db - .raw_as_with::("SELECT * FROM cot__test_model WHERE name = ?", &[&"test1"]) - .await - .unwrap(); - assert_eq!(objects, vec![model1]); - - let objects = db - .raw_as_with::("SELECT * FROM cot__test_model WHERE name = ?", &[&"test2"]) - .await - .unwrap(); - assert_eq!(objects, vec![model2]); - - db.cleanup() - .await - .expect("failed to clean up SQLite test database"); -} - -async fn migrate_test_model(db: &Database) { - CREATE_TEST_MODEL.forwards(db).await.unwrap(); -} - -const CREATE_TEST_MODEL: Operation = Operation::create_model() - .table_name(Identifier::new("cot__test_model")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("name"), ::TYPE), - ]) - .build(); - -macro_rules! all_fields_migration_field { - ($name:ident, $ty:ty) => { - Field::new( - Identifier::new(concat!("field_", stringify!($name))), - <$ty as DatabaseField>::TYPE, - ) - .set_null(<$ty as DatabaseField>::NULLABLE) - }; - ($ty:ty) => { - Field::new( - Identifier::new(concat!("field_", stringify!($ty))), - <$ty as DatabaseField>::TYPE, - ) - .set_null(<$ty as DatabaseField>::NULLABLE) - }; -} - -#[derive(Debug, PartialEq, Dummy)] -#[model] -struct AllFieldsModel { - #[dummy(expr = "Auto::auto()")] - #[model(primary_key)] - id: Auto, - field_bool: bool, - field_i8: i8, - field_i16: i16, - field_i32: i32, - field_i64: i64, - field_u8: u8, - field_u16: u16, - field_u32: u32, - // SQLite only allows us to store signed integers, so we're generating numbers that do not - // exceed i64::MAX - #[dummy(faker = "0..i64::MAX as u64")] - field_u64: u64, - field_f32: f32, - field_f64: f64, - field_date: chrono::NaiveDate, - field_time: chrono::NaiveTime, - #[dummy(faker = "fake::chrono::Precision::<6>")] - field_datetime: chrono::NaiveDateTime, - #[dummy(faker = "fake::chrono::Precision::<6>")] - field_datetime_timezone: chrono::DateTime, - field_string: String, - field_blob: Vec, - #[dummy(expr = "Bytes::from_static(b\"test bytes\")")] - field_bytes: Bytes, - field_option: Option, - field_limited_string: LimitedString<10>, - field_option_limited_string: Option>, - #[dummy(faker = "WeekdaySetFaker")] - field_weekday_set: chrono::WeekdaySet, - #[dummy(faker = "EmailFaker")] - field_email: Email, - #[dummy(faker = "EmailFaker")] - field_option_email: Option, - #[dummy(faker = "UrlFaker")] - field_url: Url, - #[dummy(faker = "UrlFaker")] - field_option_url: Option, -} - -async fn migrate_all_fields_model(db: &Database) { - CREATE_ALL_FIELDS_MODEL.forwards(db).await.unwrap(); -} - -const CREATE_ALL_FIELDS_MODEL: Operation = Operation::create_model() - .table_name(Identifier::new("cot__all_fields_model")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - all_fields_migration_field!(bool), - all_fields_migration_field!(i8), - all_fields_migration_field!(i16), - all_fields_migration_field!(i32), - all_fields_migration_field!(i64), - all_fields_migration_field!(u8), - all_fields_migration_field!(u16), - all_fields_migration_field!(u32), - all_fields_migration_field!(u64), - all_fields_migration_field!(f32), - all_fields_migration_field!(f64), - all_fields_migration_field!(date, chrono::NaiveDate), - all_fields_migration_field!(time, chrono::NaiveTime), - all_fields_migration_field!(datetime, chrono::NaiveDateTime), - all_fields_migration_field!(datetime_timezone, chrono::DateTime), - all_fields_migration_field!(string, String), - all_fields_migration_field!(blob, Vec), - all_fields_migration_field!(bytes, Bytes), - all_fields_migration_field!(option, Option), - all_fields_migration_field!(limited_string, LimitedString<10>), - all_fields_migration_field!(option_limited_string, Option>), - all_fields_migration_field!(weekday_set, chrono::WeekdaySet), - all_fields_migration_field!(email, Email), - all_fields_migration_field!(option_email, Option), - all_fields_migration_field!(url, Url), - all_fields_migration_field!(option_url, Option), - all_fields_migration_field!(option_password_hash, Option), - ]) - .build(); - -#[cot_macros::dbtest] -async fn all_fields_model(db: &mut TestDatabase) { - migrate_all_fields_model(db).await; - - assert_eq!(AllFieldsModel::objects().all(&**db).await.unwrap(), vec![]); - - let r = &mut StdRng::seed_from_u64(123_785); - let mut models = (0..100) - .map(|_| Faker.fake_with_rng(r)) - .collect::>(); - for model in &mut models { - model.save(&**db).await.unwrap(); - } - - let mut models_from_db: Vec<_> = AllFieldsModel::objects().all(&**db).await.unwrap(); - normalize_datetimes(&mut models); - normalize_datetimes(&mut models_from_db); - - assert_eq!(models.len(), models_from_db.len()); - for model in &models { - assert!( - models_from_db.contains(model), - "Could not find model {model:?} in models_from_db: {models_from_db:?}", - ); - } -} - -/// Normalize the datetimes to UTC. -fn normalize_datetimes(data: &mut Vec) { - for model in data { - model.field_datetime_timezone = model.field_datetime_timezone.with_timezone( - &chrono::FixedOffset::east_opt(0).expect("UTC timezone is always valid"), - ); - } -} - -macro_rules! run_migrations { - ( $db:ident, $( $operations:ident ),* ) => { - struct TestMigration; - - impl cot::db::migrations::Migration for TestMigration { - const APP_NAME: &'static str = "cot"; - const DEPENDENCIES: &'static [cot::db::migrations::MigrationDependency] = &[]; - const MIGRATION_NAME: &'static str = "test_migration"; - const OPERATIONS: &'static [Operation] = &[ $($operations),* ]; - } - - cot::db::migrations::MigrationEngine::new( - cot::db::migrations::wrap_migrations(&[&TestMigration]) - ) - .unwrap() - .run(&**$db) - .await - .unwrap(); - }; -} - -#[cot_macros::dbtest] -async fn password_hash_field(db: &TestDatabase) { - #[derive(Debug, Clone)] - #[model] - struct PasswordHashModel { - #[model(primary_key)] - id: Auto, - password: PasswordHash, - } - - const CREATE_OPTIONAL_PASSWORD_HASH_MODEL: Operation = Operation::create_model() - .table_name(Identifier::new("cot__password_hash_model")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - Field::new( - Identifier::new("password"), - ::TYPE, - ), - ]) - .build(); - - run_migrations!(db, CREATE_OPTIONAL_PASSWORD_HASH_MODEL); - - let generated_password: String = Faker.fake(); - let mut password_model = PasswordHashModel { - id: Auto::auto(), - password: PasswordHash::from_password(&Password::new(&generated_password)), - }; - password_model.save(&**db).await.unwrap(); - - let models = PasswordHashModel::objects().all(&**db).await.unwrap(); - - assert_eq!(models.len(), 1); - assert_eq!( - models[0].password.as_str(), - password_model.password.as_str() - ); -} - -#[cot_macros::dbtest] -async fn password_hash_option(db: &TestDatabase) { - #[derive(Debug, Clone)] - #[model] - struct PasswordHashModel { - #[model(primary_key)] - id: Auto, - password: Option, - } - - const CREATE_OPTIONAL_PASSWORD_HASH_MODEL: Operation = Operation::create_model() - .table_name(Identifier::new("cot__password_hash_model")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - Field::new( - Identifier::new("password"), - as DatabaseField>::TYPE, - ) - .set_null( as DatabaseField>::NULLABLE), - ]) - .build(); - - run_migrations!(db, CREATE_OPTIONAL_PASSWORD_HASH_MODEL); - - let generated_password: String = Faker.fake(); - let mut with_password = PasswordHashModel { - id: Auto::auto(), - password: Some(PasswordHash::from_password(&Password::new( - &generated_password, - ))), - }; - with_password.save(&**db).await.unwrap(); - - let mut without_password = PasswordHashModel { - id: Auto::auto(), - password: None, - }; - without_password.save(&**db).await.unwrap(); - - let models = PasswordHashModel::objects().all(&**db).await.unwrap(); - - assert_eq!(models.len(), 2); - assert_eq!( - models[0].password.as_ref().unwrap().as_str(), - with_password.password.as_ref().unwrap().as_str() - ); - assert!(models[1].password.is_none()); -} - -#[cot_macros::dbtest] -async fn foreign_keys(db: &mut TestDatabase) { - #[derive(Debug, Clone, PartialEq)] - #[model] - struct Artist { - #[model(primary_key)] - id: Auto, - name: String, - } - - #[derive(Debug, Clone, PartialEq)] - #[model] - struct Track { - #[model(primary_key)] - id: Auto, - artist: ForeignKey, - name: String, - } - - const CREATE_ARTIST: Operation = Operation::create_model() - .table_name(Identifier::new("cot__artist")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - Field::new(Identifier::new("name"), ::TYPE), - ]) - .build(); - const CREATE_TRACK: Operation = Operation::create_model() - .table_name(Identifier::new("cot__track")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - Field::new( - Identifier::new("artist"), - as DatabaseField>::TYPE, - ) - .foreign_key( - ::TABLE_NAME, - ::PRIMARY_KEY_NAME, - ForeignKeyOnDeletePolicy::Restrict, - ForeignKeyOnUpdatePolicy::Restrict, - ), - Field::new(Identifier::new("name"), ::TYPE), - ]) - .build(); - - run_migrations!(db, CREATE_ARTIST, CREATE_TRACK); - - let mut artist = Artist { - id: Auto::auto(), - name: "artist".to_owned(), - }; - artist.save(&**db).await.unwrap(); - - let mut track = Track { - id: Auto::auto(), - artist: ForeignKey::from(&artist), - name: "track".to_owned(), - }; - track.save(&**db).await.unwrap(); - - let mut track = Track::objects().all(&**db).await.unwrap()[0].clone(); - let artist_from_db = track.artist.get(&**db).await.unwrap(); - assert_eq!(artist_from_db, &artist); - - let error = query!(Artist, $id == artist.id) - .delete(&**db) - .await - .unwrap_err(); - // expected foreign key violation - assert!(matches!(error, DatabaseError::DatabaseEngineError(_))); - - query!(Track, $artist == &artist) - .delete(&**db) - .await - .unwrap(); - query!(Artist, $id == artist.id) - .delete(&**db) - .await - .unwrap(); - // no error should be thrown -} - -#[cot_macros::dbtest] -async fn foreign_keys_option(db: &mut TestDatabase) { - #[derive(Debug, Clone, PartialEq)] - #[model] - struct Parent { - #[model(primary_key)] - id: Auto, - } - - #[derive(Debug, Clone, PartialEq)] - #[model] - struct Child { - #[model(primary_key)] - id: Auto, - parent: Option>, - } - - const CREATE_PARENT: Operation = Operation::create_model() - .table_name(Identifier::new("cot__parent")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - ]) - .build(); - const CREATE_CHILD: Operation = Operation::create_model() - .table_name(Identifier::new("cot__child")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - Field::new( - Identifier::new("parent"), - > as DatabaseField>::TYPE, - ) - .set_null(> as DatabaseField>::NULLABLE) - .foreign_key( - ::TABLE_NAME, - ::PRIMARY_KEY_NAME, - ForeignKeyOnDeletePolicy::SetNone, - ForeignKeyOnUpdatePolicy::SetNone, - ), - ]) - .build(); - - run_migrations!(db, CREATE_PARENT, CREATE_CHILD); - - // Test child with `None` parent - let mut child = Child { - id: Auto::auto(), - parent: None, - }; - child.save(&**db).await.unwrap(); - - let child = Child::objects().all(&**db).await.unwrap()[0].clone(); - assert_eq!(child.parent, None); - - query!(Child, $id == child.id).delete(&**db).await.unwrap(); - - // Test child with `Some` parent - let mut parent = Parent { id: Auto::auto() }; - parent.save(&**db).await.unwrap(); - - let mut child = Child { - id: Auto::auto(), - parent: Some(ForeignKey::from(&parent)), - }; - child.save(&**db).await.unwrap(); - - let child = Child::objects().all(&**db).await.unwrap()[0].clone(); - let mut parent_fk = child.parent.unwrap(); - let parent_from_db = parent_fk.get(&**db).await.unwrap(); - assert_eq!(parent_from_db, &parent); - - // Check none policy - query!(Parent, $id == parent.id) - .delete(&**db) - .await - .unwrap(); - let child = Child::objects().all(&**db).await.unwrap()[0].clone(); - assert_eq!(child.parent, None); -} - -#[cot_macros::dbtest] -async fn foreign_keys_cascade(db: &mut TestDatabase) { - #[derive(Debug, Clone, PartialEq)] - #[model] - struct Parent { - #[model(primary_key)] - id: Auto, - } - - #[derive(Debug, Clone, PartialEq)] - #[model] - struct Child { - #[model(primary_key)] - id: Auto, - parent: Option>, - } - - const CREATE_PARENT: Operation = Operation::create_model() - .table_name(Identifier::new("cot__parent")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - ]) - .build(); - const CREATE_CHILD: Operation = Operation::create_model() - .table_name(Identifier::new("cot__child")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - Field::new( - Identifier::new("parent"), - > as DatabaseField>::TYPE, - ) - .set_null(> as DatabaseField>::NULLABLE) - .foreign_key( - ::TABLE_NAME, - ::PRIMARY_KEY_NAME, - ForeignKeyOnDeletePolicy::Cascade, - ForeignKeyOnUpdatePolicy::Cascade, - ), - ]) - .build(); - - run_migrations!(db, CREATE_PARENT, CREATE_CHILD); - - // with parent - let mut parent = Parent { id: Auto::auto() }; - parent.save(&**db).await.unwrap(); - - let mut child = Child { - id: Auto::auto(), - parent: Some(ForeignKey::from(&parent)), - }; - child.save(&**db).await.unwrap(); - - let child = Child::objects().all(&**db).await.unwrap()[0].clone(); - let mut parent_fk = child.parent.unwrap(); - let parent_from_db = parent_fk.get(&**db).await.unwrap(); - assert_eq!(parent_from_db, &parent); - - // Check cascade policy - query!(Parent, $id == parent.id) - .delete(&**db) - .await - .unwrap(); - assert!(Child::objects().all(&**db).await.unwrap().is_empty()); -} - -// Check different types for the primary key -#[derive(Debug, PartialEq)] -#[model] -struct TestModelu32Key { - #[model(primary_key)] - id: Auto, - name: String, -} - -#[derive(Debug, PartialEq)] -#[model] -struct TestModelu64Key { - #[model(primary_key)] - id: Auto, - name: String, -} - -#[derive(Debug, PartialEq)] -#[model] -struct TestModeli64Key { - #[model(primary_key)] - id: Auto, - name: String, -} - -#[derive(Debug, PartialEq)] -#[model] -struct TestModelStringKey { - #[model(primary_key)] - id: String, - name: String, -} - -#[cot_macros::dbtest] -#[expect(clippy::too_many_lines)] -async fn weekday_set_field_functionality(db: &mut TestDatabase) { - use chrono::Weekday; - - #[derive(Debug, PartialEq)] - #[model] - struct WeekdaySetModel { - #[model(primary_key)] - id: Auto, - schedule: chrono::WeekdaySet, - optional_schedule: Option, - } - - const CREATE_WEEKDAY_SET_MODEL: Operation = Operation::create_model() - .table_name(Identifier::new("cot__weekday_set_model")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - Field::new( - Identifier::new("schedule"), - ::TYPE, - ), - Field::new( - Identifier::new("optional_schedule"), - as DatabaseField>::TYPE, - ) - .set_null( as DatabaseField>::NULLABLE), - ]) - .build(); - - run_migrations!(db, CREATE_WEEKDAY_SET_MODEL); - - // Test empty WeekdaySet - let mut model1 = WeekdaySetModel { - id: Auto::auto(), - schedule: chrono::WeekdaySet::EMPTY, - optional_schedule: None, - }; - model1.save(&**db).await.unwrap(); - - // Test WeekdaySet with all weekdays - let mut all_days = chrono::WeekdaySet::EMPTY; - for day in [ - Weekday::Mon, - Weekday::Tue, - Weekday::Wed, - Weekday::Thu, - Weekday::Fri, - Weekday::Sat, - Weekday::Sun, - ] { - all_days.insert(day); - } - let mut model2 = WeekdaySetModel { - id: Auto::auto(), - schedule: all_days, - optional_schedule: Some(chrono::WeekdaySet::EMPTY), - }; - model2.save(&**db).await.unwrap(); - - // Test WeekdaySet with specific weekdays (weekdays only) - let mut weekdays_only = chrono::WeekdaySet::EMPTY; - for day in [ - Weekday::Mon, - Weekday::Tue, - Weekday::Wed, - Weekday::Thu, - Weekday::Fri, - ] { - weekdays_only.insert(day); - } - let mut model3 = WeekdaySetModel { - id: Auto::auto(), - schedule: weekdays_only, - optional_schedule: Some(weekdays_only), - }; - model3.save(&**db).await.unwrap(); - - // Test WeekdaySet with weekend only - let mut weekend_only = chrono::WeekdaySet::EMPTY; - weekend_only.insert(Weekday::Sat); - weekend_only.insert(Weekday::Sun); - let mut model4 = WeekdaySetModel { - id: Auto::auto(), - schedule: weekend_only, - optional_schedule: Some(all_days), - }; - model4.save(&**db).await.unwrap(); - - // Retrieve all models and verify they match - let models_from_db = WeekdaySetModel::objects().all(&**db).await.unwrap(); - assert_eq!(models_from_db.len(), 4); - - // Find and verify each model - let db_model1 = models_from_db.iter().find(|m| m.id == model1.id).unwrap(); - assert_eq!(db_model1.schedule, chrono::WeekdaySet::EMPTY); - assert_eq!(db_model1.optional_schedule, None); - - let db_model2 = models_from_db.iter().find(|m| m.id == model2.id).unwrap(); - assert_eq!(db_model2.schedule, all_days); - assert_eq!(db_model2.optional_schedule, Some(chrono::WeekdaySet::EMPTY)); - - let db_model3 = models_from_db.iter().find(|m| m.id == model3.id).unwrap(); - assert_eq!(db_model3.schedule, weekdays_only); - assert_eq!(db_model3.optional_schedule, Some(weekdays_only)); - - let db_model4 = models_from_db.iter().find(|m| m.id == model4.id).unwrap(); - assert_eq!(db_model4.schedule, weekend_only); - assert_eq!(db_model4.optional_schedule, Some(all_days)); - - // Test querying by WeekdaySet - let weekend_models = query!(WeekdaySetModel, $schedule == weekend_only) - .all(&**db) - .await - .unwrap(); - assert_eq!(weekend_models.len(), 1); - assert_eq!(weekend_models[0].id, model4.id); - - // Test updating WeekdaySet - let mut model_to_update = models_from_db - .into_iter() - .find(|m| m.id == model1.id) - .unwrap(); - model_to_update.schedule = weekdays_only; - model_to_update.optional_schedule = Some(weekend_only); - model_to_update.save(&**db).await.unwrap(); - - let updated_model = WeekdaySetModel::get_by_primary_key(&**db, model_to_update.id) - .await - .unwrap() - .unwrap(); - assert_eq!(updated_model.schedule, weekdays_only); - assert_eq!(updated_model.optional_schedule, Some(weekend_only)); -} - -#[cot_macros::dbtest] -async fn bulk_insert_basic(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - let mut models = vec![ - TestModel { - id: Auto::auto(), - name: "test1".to_owned(), - }, - TestModel { - id: Auto::auto(), - name: "test2".to_owned(), - }, - TestModel { - id: Auto::auto(), - name: "test3".to_owned(), - }, - ]; - - TestModel::bulk_insert(&**test_db, &mut models) - .await - .unwrap(); - - assert!(matches!(models[0].id, Auto::Fixed(_))); - assert!(matches!(models[1].id, Auto::Fixed(_))); - assert!(matches!(models[2].id, Auto::Fixed(_))); - - let objects = TestModel::objects().all(&**test_db).await.unwrap(); - assert_eq!(objects.len(), 3); - - let names: Vec<_> = objects.iter().map(|m| m.name.as_str()).collect(); - assert!(names.contains(&"test1")); - assert!(names.contains(&"test2")); - assert!(names.contains(&"test3")); - - // Verify IDs match between models and database - for model in &models { - if let Auto::Fixed(_) = model.id { - let db_model = TestModel::get_by_primary_key(&**test_db, model.id) - .await - .unwrap() - .unwrap(); - assert_eq!(db_model.name, model.name); - } - } -} - -#[cot_macros::dbtest] -async fn bulk_insert_or_update(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - let mut models = vec![ - TestModel { - id: Auto::auto(), - name: "test1".to_owned(), - }, - TestModel { - id: Auto::auto(), - name: "test2".to_owned(), - }, - TestModel { - id: Auto::auto(), - name: "test3".to_owned(), - }, - ]; - TestModel::bulk_insert(&**test_db, &mut models) - .await - .unwrap(); - - let mut models = vec![ - TestModel { - id: models[0].id, - name: "test1_updated".to_owned(), - }, - TestModel { - id: models[2].id, - name: "test3_updated".to_owned(), - }, - ]; - TestModel::bulk_insert_or_update(&**test_db, &mut models) - .await - .unwrap(); - - let objects = TestModel::objects().all(&**test_db).await.unwrap(); - assert_eq!(objects.len(), 3); - - let names: Vec<_> = objects.iter().map(|m| m.name.as_str()).collect(); - assert!(names.contains(&"test1_updated")); - assert!(names.contains(&"test2")); - assert!(names.contains(&"test3_updated")); -} - -#[cot_macros::dbtest] -async fn bulk_insert_empty(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - let mut models: Vec = vec![]; - let result = TestModel::bulk_insert(&**test_db, &mut models).await; - - assert!(result.is_ok()); - let objects = TestModel::objects().all(&**test_db).await.unwrap(); - assert_eq!(objects.len(), 0); -} - -#[cot_macros::dbtest] -async fn bulk_insert_large_batch(test_db: &mut TestDatabase) { - const BATCH_SIZE: usize = 100_000; - - migrate_test_model(&*test_db).await; - - let mut models: Vec = (0..BATCH_SIZE) - .map(|i| TestModel { - id: Auto::auto(), - name: format!("test{i}"), - }) - .collect(); - - TestModel::bulk_insert(&**test_db, &mut models) - .await - .unwrap(); - - for model in &models { - assert!(matches!(model.id, Auto::Fixed(_))); - } - - let objects = TestModel::objects().all(&**test_db).await.unwrap(); - assert_eq!(objects.len(), BATCH_SIZE); -} - -#[cot_macros::dbtest] -async fn bulk_insert_no_values(test_db: &mut TestDatabase) { - #[derive(Debug, PartialEq)] - #[model] - struct PkOnlyModel { - #[model(primary_key)] - id: Auto, - } - - const CREATE_PK_ONLY_MODEL: Operation = Operation::create_model() - .table_name(Identifier::new("cot__pk_only_model")) - .fields(&[ - Field::new(Identifier::new("id"), as DatabaseField>::TYPE) - .primary_key() - .auto(), - ]) - .build(); - - async fn migrate_pk_only_model(db: &Database) { - CREATE_PK_ONLY_MODEL.forwards(db).await.unwrap(); - } - - const BATCH_SIZE: usize = 17; - - migrate_pk_only_model(&*test_db).await; - - let mut models: Vec = (0..BATCH_SIZE) - .map(|_| PkOnlyModel { id: Auto::auto() }) - .collect(); - - let result = PkOnlyModel::bulk_insert(&**test_db, &mut models).await; - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - DatabaseError::BulkInsertNoValueColumns - )); -} - -#[cot_macros::dbtest] -async fn bulk_insert_with_fixed_pk(test_db: &mut TestDatabase) { - migrate_test_model(&*test_db).await; - - let mut models = vec![ - TestModel { - id: Auto::fixed(100), - name: "test100".to_owned(), - }, - TestModel { - id: Auto::fixed(200), - name: "test200".to_owned(), - }, - TestModel { - id: Auto::fixed(300), - name: "test300".to_owned(), - }, - ]; - - TestModel::bulk_insert(&**test_db, &mut models) - .await - .unwrap(); - - let model100 = TestModel::get_by_primary_key(&**test_db, Auto::fixed(100)) - .await - .unwrap() - .unwrap(); - assert_eq!(model100.name, "test100"); - - let model200 = TestModel::get_by_primary_key(&**test_db, Auto::fixed(200)) - .await - .unwrap() - .unwrap(); - assert_eq!(model200.name, "test200"); - - let model300 = TestModel::get_by_primary_key(&**test_db, Auto::fixed(300)) - .await - .unwrap() - .unwrap(); - assert_eq!(model300.name, "test300"); -} - -async fn assert_migration_applied(database: &Database, app: &str, name: &str, expected: bool) { - let applied = query!(AppliedMigration, $app == app && $name == name) - .exists(database) - .await - .unwrap(); - assert_eq!(applied, expected, "{app}::{name}"); -} - -async fn assert_migrations_applied(db: &Database, expected: &[(&str, &str, bool)]) { - for &(app, name, applied) in expected { - assert_migration_applied(db, app, name, applied).await; - } -} - -async fn migration_rollback_dry_run( - engine: &MigrationEngine, - db: &Database, - output: &mut Vec, - migration_name: &str, - app_name: &str, -) -> String { - output.clear(); - engine - .rollback_dry_run(db, migration_name, app_name, output) - .await - .unwrap(); - std::str::from_utf8(output).unwrap().to_owned() -} - -async fn migration_rollback( - engine: &MigrationEngine, - db: &Database, - output: &mut Vec, - migration_name: &str, - app_name: &str, -) -> String { - output.clear(); - engine - .rollback(db, migration_name, app_name, output) - .await - .unwrap(); - std::str::from_utf8(output).unwrap().to_owned() -} - -#[cot_macros::dbtest] -async fn test_migration_engine_rollback_single_app(test_db: &mut TestDatabase) { - #[expect(trivial_casts)] - let engine = MigrationEngine::new([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp1003 as &SyncDynMigration, - ]) - .unwrap(); - let mut output = Vec::new(); - - engine.run(&test_db.database()).await.unwrap(); - // migrations should be applied - assert_migrations_applied( - &test_db.database(), - &[ - ("rollback_app1", "m_0001_initial", true), - ("rollback_app1", "m_0002_second", true), - ("rollback_app1", "m_0003_third", true), - ], - ) - .await; - - // rollback everything except the initial migration - let dry_run_output = migration_rollback_dry_run( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!( - dry_run_output, - ); - }); - - let rollback_output = migration_rollback( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!(rollback_output); - }); - - assert_migrations_applied( - &test_db.database(), - &[ - // the initial migration should stay applied - ("rollback_app1", "m_0001_initial", true), - // everything else should be unapplied - ("rollback_app1", "m_0002_second", false), - ("rollback_app1", "m_0003_third", false), - ], - ) - .await; -} - -#[cot_macros::dbtest] -async fn test_migration_rollback_unrelated_apps(test_db: &mut TestDatabase) { - let mut migrations = DatabaseUserApp::new().migrations(); - // combine migrations from multiple apps/crates - #[expect(trivial_casts)] - migrations.extend(wrap_migrations(&[ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ])); - migrations.extend(SessionApp::new().migrations()); - let mut output = Vec::new(); - - let engine = MigrationEngine::new(migrations).unwrap(); - - engine.run(&test_db.database()).await.unwrap(); - // migrations should be applied across all apps - assert_migrations_applied( - &test_db.database(), - &[ - ("cot", "m_0001_initial", true), - ("cot_session", "m_0001_initial", true), - ("rollback_app1", "m_0001_initial", true), - ("rollback_app1", "m_0002_second", true), - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; - - // rollback every migration in the rollback_app1 app except the initial - let dry_run_output = migration_rollback_dry_run( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!( - dry_run_output, - ); - }); - - let rollback_output = migration_rollback( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!(rollback_output); - }); - - assert_migrations_applied( - &test_db.database(), - &[ - // the initial migration should stay applied - ("rollback_app1", "m_0001_initial", true), - // everything else in the rollback_app1 app should be unapplied - ("rollback_app1", "m_0002_second", false), - // migrations from other apps should remain unaffected - ("cot", "m_0001_initial", true), - ("cot_session", "m_0001_initial", true), - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; -} - -#[cot_macros::dbtest] -async fn test_migration_engine_rollback_includes_dependent_apps(test_db: &mut TestDatabase) { - #[expect(trivial_casts)] - let engine = MigrationEngine::new([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackDependentInitial as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ]) - .unwrap(); - let mut output = Vec::new(); - - engine.run(&test_db.database()).await.unwrap(); - - assert_migrations_applied( - &test_db.database(), - &[ - ("rollback_app1", "m_0001_initial", true), - ("rollback_app1", "m_0002_second", true), - ("rollback_dependent", "m_0001_initial", true), - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; - - // rollback everything except the initial migration in the source/independent - // app - let dry_run_output = migration_rollback_dry_run( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!( - dry_run_output, - ); - }); - - let rollback_output = migration_rollback( - &engine, - &test_db.database(), - &mut output, - "0001", - "rollback_app1", - ) - .await; - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!(rollback_output); - }); - - assert_migrations_applied( - &test_db.database(), - &[ - ("rollback_app1", "m_0001_initial", true), - ("rollback_app1", "m_0002_second", false), - // the sink/dependent app should also be unapplied/rolled back - ("rollback_dependent", "m_0001_initial", false), - // migrations from non-dependent apps should remain unaffected - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; -} - -#[cot_macros::dbtest] -async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { - #[expect(trivial_casts)] - let engine = MigrationEngine::new([ - &RollbackApp1Initial as &SyncDynMigration, - &RollbackApp10002 as &SyncDynMigration, - &RollbackApp1003 as &SyncDynMigration, - &RollbackApp2Initial as &SyncDynMigration, - ]) - .unwrap(); - let mut output = Vec::new(); - - engine.run(&test_db.database()).await.unwrap(); - - assert_migrations_applied( - &test_db.database(), - &[ - ("rollback_app1", "m_0001_initial", true), - ("rollback_app1", "m_0002_second", true), - ("rollback_app1", "m_0003_third", true), - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; - - let dry_run_output = migration_rollback_dry_run( - &engine, - &test_db.database(), - &mut output, - "zero", - "rollback_app1", - ) - .await; - - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!( - dry_run_output, - ); - }); - - let rollback_output = migration_rollback( - &engine, - &test_db.database(), - &mut output, - "zero", - "rollback_app1", - ) - .await; - insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { - insta::assert_snapshot!(rollback_output); - }); - - assert_migrations_applied( - &test_db.database(), - &[ - // everything should be unapplied - ("rollback_app1", "m_0001_initial", false), - ("rollback_app1", "m_0002_second", false), - ("rollback_app1", "m_0003_third", false), - // the non dependent apps should be unaffected - ("rollback_app2", "m_0001_initial", true), - ], - ) - .await; -} +mod db_testing; diff --git a/cot/tests/db_testing/fields.rs b/cot/tests/db_testing/fields.rs new file mode 100644 index 000000000..3a643f9b3 --- /dev/null +++ b/cot/tests/db_testing/fields.rs @@ -0,0 +1,431 @@ +use bytes::Bytes; +use cot::auth::PasswordHash; +use cot::common_types::{Email, Password, Url}; +use cot::db::migrations::{Field, Operation}; +use cot::db::{Auto, Database, DatabaseField, Identifier, LimitedString, Model}; +use cot::test::TestDatabase; +use cot_macros::{model, query}; +use fake::rand::rngs::StdRng; +use fake::rand::{RngExt, SeedableRng}; +use fake::{Dummy, Fake, Faker}; + +use crate::db_testing::run_migrations; + +struct WeekdaySetFaker; + +impl Dummy for chrono::WeekdaySet { + fn dummy_with_rng(_: &WeekdaySetFaker, rng: &mut R) -> Self { + use chrono::Weekday; + + let mut set = chrono::WeekdaySet::EMPTY; + let weekdays = [ + Weekday::Mon, + Weekday::Tue, + Weekday::Wed, + Weekday::Thu, + Weekday::Fri, + Weekday::Sat, + Weekday::Sun, + ]; + + for weekday in weekdays { + if rng.random_bool(0.5) { + set.insert(weekday); + } + } + + set + } +} + +struct EmailFaker; + +impl Dummy for Email { + fn dummy_with_rng(_: &EmailFaker, rng: &mut R) -> Self { + let username: String = (0..10) + .map(|_| (0x61u8 + (rng.next_u32() % 26) as u8) as char) + .collect(); + let domain: String = (0..10) + .map(|_| (0x61u8 + (rng.next_u32() % 26) as u8) as char) + .collect(); + Email::new(format!("{username}@{domain}.com")).expect("Generated email should be valid") + } +} + +struct UrlFaker; + +impl Dummy for Url { + fn dummy_with_rng(_config: &UrlFaker, rng: &mut R) -> Self { + let domain: String = (0..10) + .map(|_| (0x61u8 + (rng.next_u32() % 26) as u8) as char) + .collect(); + Url::new(format!("https://{domain}.com")).expect("Generated URL should be valid") + } +} + +#[derive(Debug, PartialEq, Dummy)] +#[model] +struct AllFieldsModel { + #[dummy(expr = "Auto::auto()")] + #[model(primary_key)] + id: Auto, + field_bool: bool, + field_i8: i8, + field_i16: i16, + field_i32: i32, + field_i64: i64, + field_u8: u8, + field_u16: u16, + field_u32: u32, + // SQLite only allows us to store signed integers, so we're generating numbers that do not + // exceed i64::MAX + #[dummy(faker = "0..i64::MAX as u64")] + field_u64: u64, + field_f32: f32, + field_f64: f64, + field_date: chrono::NaiveDate, + field_time: chrono::NaiveTime, + #[dummy(faker = "fake::chrono::Precision::<6>")] + field_datetime: chrono::NaiveDateTime, + #[dummy(faker = "fake::chrono::Precision::<6>")] + field_datetime_timezone: chrono::DateTime, + field_string: String, + field_blob: Vec, + #[dummy(expr = "Bytes::from_static(b\"test bytes\")")] + field_bytes: Bytes, + field_option: Option, + field_limited_string: LimitedString<10>, + field_option_limited_string: Option>, + #[dummy(faker = "WeekdaySetFaker")] + field_weekday_set: chrono::WeekdaySet, + #[dummy(faker = "EmailFaker")] + field_email: Email, + #[dummy(faker = "EmailFaker")] + field_option_email: Option, + #[dummy(faker = "UrlFaker")] + field_url: Url, + #[dummy(faker = "UrlFaker")] + field_option_url: Option, +} + +async fn migrate_all_fields_model(db: &Database) { + CREATE_ALL_FIELDS_MODEL.forwards(db).await.unwrap(); +} + +macro_rules! all_fields_migration_field { + ($name:ident, $ty:ty) => { + Field::new( + Identifier::new(concat!("field_", stringify!($name))), + <$ty as DatabaseField>::TYPE, + ) + .set_null(<$ty as DatabaseField>::NULLABLE) + }; + ($ty:ty) => { + Field::new( + Identifier::new(concat!("field_", stringify!($ty))), + <$ty as DatabaseField>::TYPE, + ) + .set_null(<$ty as DatabaseField>::NULLABLE) + }; +} + +const CREATE_ALL_FIELDS_MODEL: Operation = Operation::create_model() + .table_name(Identifier::new("cot__all_fields_model")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + all_fields_migration_field!(bool), + all_fields_migration_field!(i8), + all_fields_migration_field!(i16), + all_fields_migration_field!(i32), + all_fields_migration_field!(i64), + all_fields_migration_field!(u8), + all_fields_migration_field!(u16), + all_fields_migration_field!(u32), + all_fields_migration_field!(u64), + all_fields_migration_field!(f32), + all_fields_migration_field!(f64), + all_fields_migration_field!(date, chrono::NaiveDate), + all_fields_migration_field!(time, chrono::NaiveTime), + all_fields_migration_field!(datetime, chrono::NaiveDateTime), + all_fields_migration_field!(datetime_timezone, chrono::DateTime), + all_fields_migration_field!(string, String), + all_fields_migration_field!(blob, Vec), + all_fields_migration_field!(bytes, Bytes), + all_fields_migration_field!(option, Option), + all_fields_migration_field!(limited_string, LimitedString<10>), + all_fields_migration_field!(option_limited_string, Option>), + all_fields_migration_field!(weekday_set, chrono::WeekdaySet), + all_fields_migration_field!(email, Email), + all_fields_migration_field!(option_email, Option), + all_fields_migration_field!(url, Url), + all_fields_migration_field!(option_url, Option), + all_fields_migration_field!(option_password_hash, Option), + ]) + .build(); + +#[cot_macros::dbtest] +async fn all_fields_model(db: &mut TestDatabase) { + migrate_all_fields_model(db).await; + + assert_eq!(AllFieldsModel::objects().all(&**db).await.unwrap(), vec![]); + + let r = &mut StdRng::seed_from_u64(123_785); + let mut models = (0..100) + .map(|_| Faker.fake_with_rng(r)) + .collect::>(); + for model in &mut models { + model.save(&**db).await.unwrap(); + } + + let mut models_from_db: Vec<_> = AllFieldsModel::objects().all(&**db).await.unwrap(); + normalize_datetimes(&mut models); + normalize_datetimes(&mut models_from_db); + + assert_eq!(models.len(), models_from_db.len()); + for model in &models { + assert!( + models_from_db.contains(model), + "Could not find model {model:?} in models_from_db: {models_from_db:?}", + ); + } +} + +/// Normalize the datetimes to UTC. +fn normalize_datetimes(data: &mut Vec) { + for model in data { + model.field_datetime_timezone = model.field_datetime_timezone.with_timezone( + &chrono::FixedOffset::east_opt(0).expect("UTC timezone is always valid"), + ); + } +} + +#[cot_macros::dbtest] +async fn password_hash_field(db: &TestDatabase) { + #[derive(Debug, Clone)] + #[model] + struct PasswordHashModel { + #[model(primary_key)] + id: Auto, + password: PasswordHash, + } + + const CREATE_OPTIONAL_PASSWORD_HASH_MODEL: Operation = Operation::create_model() + .table_name(Identifier::new("cot__password_hash_model")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("password"), + ::TYPE, + ), + ]) + .build(); + + run_migrations!(db, CREATE_OPTIONAL_PASSWORD_HASH_MODEL); + + let generated_password: String = Faker.fake(); + let mut password_model = PasswordHashModel { + id: Auto::auto(), + password: PasswordHash::from_password(&Password::new(&generated_password)), + }; + password_model.save(&**db).await.unwrap(); + + let models = PasswordHashModel::objects().all(&**db).await.unwrap(); + + assert_eq!(models.len(), 1); + assert_eq!( + models[0].password.as_str(), + password_model.password.as_str() + ); +} + +#[cot_macros::dbtest] +async fn password_hash_option(db: &TestDatabase) { + #[derive(Debug, Clone)] + #[model] + struct PasswordHashModel { + #[model(primary_key)] + id: Auto, + password: Option, + } + + const CREATE_OPTIONAL_PASSWORD_HASH_MODEL: Operation = Operation::create_model() + .table_name(Identifier::new("cot__password_hash_model")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("password"), + as DatabaseField>::TYPE, + ) + .set_null( as DatabaseField>::NULLABLE), + ]) + .build(); + + run_migrations!(db, CREATE_OPTIONAL_PASSWORD_HASH_MODEL); + + let generated_password: String = Faker.fake(); + let mut with_password = PasswordHashModel { + id: Auto::auto(), + password: Some(PasswordHash::from_password(&Password::new( + &generated_password, + ))), + }; + with_password.save(&**db).await.unwrap(); + + let mut without_password = PasswordHashModel { + id: Auto::auto(), + password: None, + }; + without_password.save(&**db).await.unwrap(); + + let models = PasswordHashModel::objects().all(&**db).await.unwrap(); + + assert_eq!(models.len(), 2); + assert_eq!( + models[0].password.as_ref().unwrap().as_str(), + with_password.password.as_ref().unwrap().as_str() + ); + assert!(models[1].password.is_none()); +} + +#[cot_macros::dbtest] +#[expect(clippy::too_many_lines)] +async fn weekday_set_field_functionality(db: &mut TestDatabase) { + use chrono::Weekday; + + #[derive(Debug, PartialEq)] + #[model] + struct WeekdaySetModel { + #[model(primary_key)] + id: Auto, + schedule: chrono::WeekdaySet, + optional_schedule: Option, + } + + const CREATE_WEEKDAY_SET_MODEL: Operation = Operation::create_model() + .table_name(Identifier::new("cot__weekday_set_model")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("schedule"), + ::TYPE, + ), + Field::new( + Identifier::new("optional_schedule"), + as DatabaseField>::TYPE, + ) + .set_null( as DatabaseField>::NULLABLE), + ]) + .build(); + + run_migrations!(db, CREATE_WEEKDAY_SET_MODEL); + + // Test empty WeekdaySet + let mut model1 = WeekdaySetModel { + id: Auto::auto(), + schedule: chrono::WeekdaySet::EMPTY, + optional_schedule: None, + }; + model1.save(&**db).await.unwrap(); + + // Test WeekdaySet with all weekdays + let mut all_days = chrono::WeekdaySet::EMPTY; + for day in [ + Weekday::Mon, + Weekday::Tue, + Weekday::Wed, + Weekday::Thu, + Weekday::Fri, + Weekday::Sat, + Weekday::Sun, + ] { + all_days.insert(day); + } + let mut model2 = WeekdaySetModel { + id: Auto::auto(), + schedule: all_days, + optional_schedule: Some(chrono::WeekdaySet::EMPTY), + }; + model2.save(&**db).await.unwrap(); + + // Test WeekdaySet with specific weekdays (weekdays only) + let mut weekdays_only = chrono::WeekdaySet::EMPTY; + for day in [ + Weekday::Mon, + Weekday::Tue, + Weekday::Wed, + Weekday::Thu, + Weekday::Fri, + ] { + weekdays_only.insert(day); + } + let mut model3 = WeekdaySetModel { + id: Auto::auto(), + schedule: weekdays_only, + optional_schedule: Some(weekdays_only), + }; + model3.save(&**db).await.unwrap(); + + // Test WeekdaySet with weekend only + let mut weekend_only = chrono::WeekdaySet::EMPTY; + weekend_only.insert(Weekday::Sat); + weekend_only.insert(Weekday::Sun); + let mut model4 = WeekdaySetModel { + id: Auto::auto(), + schedule: weekend_only, + optional_schedule: Some(all_days), + }; + model4.save(&**db).await.unwrap(); + + // Retrieve all models and verify they match + let models_from_db = WeekdaySetModel::objects().all(&**db).await.unwrap(); + assert_eq!(models_from_db.len(), 4); + + // Find and verify each model + let db_model1 = models_from_db.iter().find(|m| m.id == model1.id).unwrap(); + assert_eq!(db_model1.schedule, chrono::WeekdaySet::EMPTY); + assert_eq!(db_model1.optional_schedule, None); + + let db_model2 = models_from_db.iter().find(|m| m.id == model2.id).unwrap(); + assert_eq!(db_model2.schedule, all_days); + assert_eq!(db_model2.optional_schedule, Some(chrono::WeekdaySet::EMPTY)); + + let db_model3 = models_from_db.iter().find(|m| m.id == model3.id).unwrap(); + assert_eq!(db_model3.schedule, weekdays_only); + assert_eq!(db_model3.optional_schedule, Some(weekdays_only)); + + let db_model4 = models_from_db.iter().find(|m| m.id == model4.id).unwrap(); + assert_eq!(db_model4.schedule, weekend_only); + assert_eq!(db_model4.optional_schedule, Some(all_days)); + + // Test querying by WeekdaySet + let weekend_models = query!(WeekdaySetModel, $schedule == weekend_only) + .all(&**db) + .await + .unwrap(); + assert_eq!(weekend_models.len(), 1); + assert_eq!(weekend_models[0].id, model4.id); + + // Test updating WeekdaySet + let mut model_to_update = models_from_db + .into_iter() + .find(|m| m.id == model1.id) + .unwrap(); + model_to_update.schedule = weekdays_only; + model_to_update.optional_schedule = Some(weekend_only); + model_to_update.save(&**db).await.unwrap(); + + let updated_model = WeekdaySetModel::get_by_primary_key(&**db, model_to_update.id) + .await + .unwrap() + .unwrap(); + assert_eq!(updated_model.schedule, weekdays_only); + assert_eq!(updated_model.optional_schedule, Some(weekend_only)); +} diff --git a/cot/tests/db_testing/migrations.rs b/cot/tests/db_testing/migrations.rs new file mode 100644 index 000000000..e4b53adff --- /dev/null +++ b/cot/tests/db_testing/migrations.rs @@ -0,0 +1,432 @@ +use cot::App; +use cot::auth::db::DatabaseUserApp; +use cot::db::migrations::{ + Field, Migration, MigrationDependency, MigrationEngine, Operation, SyncDynMigration, + wrap_migrations, +}; +use cot::db::{Auto, Database, DatabaseField, Identifier}; +use cot::session::db::SessionApp; +use cot::test::TestDatabase; +use cot_macros::{model, query}; + +const SNAPSHOT_RELATIVE_PATH: &str = "snapshots/migrations"; + +// mirror the internal AppliedMigration model +#[derive(Debug)] +#[model(table_name = "cot__migrations", model_type = "internal")] +struct AppliedMigration { + #[model(primary_key)] + id: Auto, + app: String, + name: String, + applied: chrono::DateTime, +} +struct RollbackApp1Initial; + +impl Migration for RollbackApp1Initial { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_single__first")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackApp10002; + +impl Migration for RollbackApp10002 { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0002_second"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0001_initial", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_app1__second")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackApp1003; + +impl Migration for RollbackApp1003 { + const APP_NAME: &'static str = "rollback_app1"; + const MIGRATION_NAME: &'static str = "m_0003_third"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0002_second", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_single__third")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackApp2Initial; + +impl Migration for RollbackApp2Initial { + const APP_NAME: &'static str = "rollback_app2"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_app2__foo")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +struct RollbackDependentInitial; + +impl Migration for RollbackDependentInitial { + const APP_NAME: &'static str = "rollback_dependent"; + const MIGRATION_NAME: &'static str = "m_0001_initial"; + const DEPENDENCIES: &'static [MigrationDependency] = &[MigrationDependency::migration( + "rollback_app1", + "m_0002_second", + )]; + const OPERATIONS: &'static [Operation] = &[Operation::create_model() + .table_name(Identifier::new("rollback_dependent__bar")) + .fields(&[ + Field::new(Identifier::new("id"), ::TYPE) + .primary_key() + .auto(), + ]) + .build()]; +} + +#[cot_macros::dbtest] +async fn test_migration_rollback_no_deps(test_db: &mut TestDatabase) { + let engine = MigrationEngine::new([RollbackApp1Initial]).unwrap(); + engine.run(&test_db.database()).await.unwrap(); +} + +async fn assert_migration_applied(database: &Database, app: &str, name: &str, expected: bool) { + let applied = query!(AppliedMigration, $app == app && $name == name) + .exists(database) + .await + .unwrap(); + assert_eq!(applied, expected, "{app}::{name}"); +} + +async fn assert_migrations_applied(db: &Database, expected: &[(&str, &str, bool)]) { + for &(app, name, applied) in expected { + assert_migration_applied(db, app, name, applied).await; + } +} + +async fn migration_rollback_dry_run( + engine: &MigrationEngine, + db: &Database, + output: &mut Vec, + migration_name: &str, + app_name: &str, +) -> String { + output.clear(); + engine + .rollback_dry_run(db, migration_name, app_name, output) + .await + .unwrap(); + std::str::from_utf8(output).unwrap().to_owned() +} + +async fn migration_rollback( + engine: &MigrationEngine, + db: &Database, + output: &mut Vec, + migration_name: &str, + app_name: &str, +) -> String { + output.clear(); + engine + .rollback(db, migration_name, app_name, output) + .await + .unwrap(); + std::str::from_utf8(output).unwrap().to_owned() +} + +#[cot_macros::dbtest] +async fn test_migration_engine_rollback_single_app(test_db: &mut TestDatabase) { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp1003 as &SyncDynMigration, + ]) + .unwrap(); + let mut output = Vec::new(); + + engine.run(&test_db.database()).await.unwrap(); + // migrations should be applied + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_app1", "m_0003_third", true), + ], + ) + .await; + + // rollback everything except the initial migration + let dry_run_output = migration_rollback_dry_run( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + dry_run_output, + ); + }); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); + + assert_migrations_applied( + &test_db.database(), + &[ + // the initial migration should stay applied + ("rollback_app1", "m_0001_initial", true), + // everything else should be unapplied + ("rollback_app1", "m_0002_second", false), + ("rollback_app1", "m_0003_third", false), + ], + ) + .await; +} + +#[cot_macros::dbtest] +async fn test_migration_rollback_unrelated_apps(test_db: &mut TestDatabase) { + let mut migrations = DatabaseUserApp::new().migrations(); + // combine migrations from multiple apps/crates + #[expect(trivial_casts)] + migrations.extend(wrap_migrations(&[ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ])); + migrations.extend(SessionApp::new().migrations()); + let mut output = Vec::new(); + + let engine = MigrationEngine::new(migrations).unwrap(); + + engine.run(&test_db.database()).await.unwrap(); + // migrations should be applied across all apps + assert_migrations_applied( + &test_db.database(), + &[ + ("cot", "m_0001_initial", true), + ("cot_session", "m_0001_initial", true), + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; + + // rollback every migration in the rollback_app1 app except the initial + let dry_run_output = migration_rollback_dry_run( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + dry_run_output, + ); + }); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); + + assert_migrations_applied( + &test_db.database(), + &[ + // the initial migration should stay applied + ("rollback_app1", "m_0001_initial", true), + // everything else in the rollback_app1 app should be unapplied + ("rollback_app1", "m_0002_second", false), + // migrations from other apps should remain unaffected + ("cot", "m_0001_initial", true), + ("cot_session", "m_0001_initial", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; +} + +#[cot_macros::dbtest] +async fn test_migration_engine_rollback_includes_dependent_apps(test_db: &mut TestDatabase) { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackDependentInitial as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ]) + .unwrap(); + let mut output = Vec::new(); + + engine.run(&test_db.database()).await.unwrap(); + + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_dependent", "m_0001_initial", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; + + // rollback everything except the initial migration in the source/independent + // app + let dry_run_output = migration_rollback_dry_run( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + dry_run_output, + ); + }); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "0001", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); + + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", false), + // the sink/dependent app should also be unapplied/rolled back + ("rollback_dependent", "m_0001_initial", false), + // migrations from non-dependent apps should remain unaffected + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; +} + +#[cot_macros::dbtest] +async fn test_migration_engine_rollback_zero(test_db: &mut TestDatabase) { + #[expect(trivial_casts)] + let engine = MigrationEngine::new([ + &RollbackApp1Initial as &SyncDynMigration, + &RollbackApp10002 as &SyncDynMigration, + &RollbackApp1003 as &SyncDynMigration, + &RollbackApp2Initial as &SyncDynMigration, + ]) + .unwrap(); + let mut output = Vec::new(); + + engine.run(&test_db.database()).await.unwrap(); + + assert_migrations_applied( + &test_db.database(), + &[ + ("rollback_app1", "m_0001_initial", true), + ("rollback_app1", "m_0002_second", true), + ("rollback_app1", "m_0003_third", true), + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; + + let dry_run_output = migration_rollback_dry_run( + &engine, + &test_db.database(), + &mut output, + "zero", + "rollback_app1", + ) + .await; + + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!( + dry_run_output, + ); + }); + + let rollback_output = migration_rollback( + &engine, + &test_db.database(), + &mut output, + "zero", + "rollback_app1", + ) + .await; + insta::with_settings!({snapshot_path => SNAPSHOT_RELATIVE_PATH}, { + insta::assert_snapshot!(rollback_output); + }); + + assert_migrations_applied( + &test_db.database(), + &[ + // everything should be unapplied + ("rollback_app1", "m_0001_initial", false), + ("rollback_app1", "m_0002_second", false), + ("rollback_app1", "m_0003_third", false), + // the non dependent apps should be unaffected + ("rollback_app2", "m_0001_initial", true), + ], + ) + .await; +} diff --git a/cot/tests/db_testing/mod.rs b/cot/tests/db_testing/mod.rs new file mode 100644 index 000000000..bb312d5c3 --- /dev/null +++ b/cot/tests/db_testing/mod.rs @@ -0,0 +1,26 @@ +mod fields; +mod migrations; +mod query; +mod relations; + +macro_rules! run_migrations { + ( $db:ident, $( $operations:ident ),* ) => { + struct TestMigration; + + impl cot::db::migrations::Migration for TestMigration { + const APP_NAME: &'static str = "cot"; + const DEPENDENCIES: &'static [cot::db::migrations::MigrationDependency] = &[]; + const MIGRATION_NAME: &'static str = "test_migration"; + const OPERATIONS: &'static [Operation] = &[ $($operations),* ]; + } + + cot::db::migrations::MigrationEngine::new( + cot::db::migrations::wrap_migrations(&[&TestMigration]) + ) + .unwrap() + .run(&**$db) + .await + .unwrap(); + }; +} +pub(super) use run_migrations; diff --git a/cot/tests/db_testing/query.rs b/cot/tests/db_testing/query.rs new file mode 100644 index 000000000..7e4303fcb --- /dev/null +++ b/cot/tests/db_testing/query.rs @@ -0,0 +1,511 @@ +use cot::db::migrations::{Field, Operation}; +use cot::db::query::ExprEq; +use cot::db::{Auto, Database, DatabaseError, DatabaseField, Identifier, Model}; +use cot::test::TestDatabase; +use cot_macros::{model, query}; + +#[derive(Debug, PartialEq)] +#[model] +struct TestModel { + #[model(primary_key)] + id: Auto, + name: String, +} + +// Check different types for the primary key +#[derive(Debug, PartialEq)] +#[model] +struct TestModelu32Key { + #[model(primary_key)] + id: Auto, + name: String, +} + +#[derive(Debug, PartialEq)] +#[model] +struct TestModelu64Key { + #[model(primary_key)] + id: Auto, + name: String, +} + +#[derive(Debug, PartialEq)] +#[model] +struct TestModeli64Key { + #[model(primary_key)] + id: Auto, + name: String, +} + +#[derive(Debug, PartialEq)] +#[model] +struct TestModelStringKey { + #[model(primary_key)] + id: String, + name: String, +} + +async fn migrate_test_model(db: &Database) { + CREATE_TEST_MODEL.forwards(db).await.unwrap(); +} + +const CREATE_TEST_MODEL: Operation = Operation::create_model() + .table_name(Identifier::new("cot__test_model")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("name"), ::TYPE), + ]) + .build(); + +#[cot_macros::dbtest] +async fn model_crud(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + assert_eq!(TestModel::objects().all(&**test_db).await.unwrap(), vec![]); + + // Create + let mut model = TestModel { + id: Auto::fixed(1), + name: "test".to_owned(), + }; + model.save(&**test_db).await.unwrap(); + + // Read + let objects = TestModel::objects().all(&**test_db).await.unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].name, "test"); + + // Update (& read again) + model.name = "test2".to_owned(); + model.save(&**test_db).await.unwrap(); + let objects = TestModel::objects().all(&**test_db).await.unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].name, "test2"); + + // Delete + TestModel::objects() + .filter(::Fields::id.eq(1)) + .delete(&**test_db) + .await + .unwrap(); + + assert_eq!(TestModel::objects().all(&**test_db).await.unwrap(), vec![]); +} + +#[cot_macros::dbtest] +async fn model_insert(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + // Insert + let mut model = TestModel { + id: Auto::fixed(1), + name: "test".to_owned(), + }; + let result = model.insert(&**test_db).await; + assert!(result.is_ok()); + + // Can't insert the same model instance again + let result = model.insert(&**test_db).await; + assert!(result.is_err()); + + // Read the model from the database + let objects = TestModel::objects().all(&**test_db).await.unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].name, "test"); +} + +#[cot_macros::dbtest] +async fn model_update(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + // Insert + let mut model = TestModel { + id: Auto::fixed(1), + name: "test".to_owned(), + }; + let result = model.insert(&**test_db).await; + assert!(result.is_ok()); + + // Update + model.name = "test2".to_owned(); + let result = model.update(&**test_db).await; + assert!(result.is_ok()); + + // Can't update non-existing object + let mut model = TestModel { + id: Auto::fixed(2), + name: "test3".to_owned(), + }; + let result = model.update(&**test_db).await; + assert!(result.is_err()); + + // Read the model from the database + let objects = TestModel::objects().all(&**test_db).await.unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].name, "test2"); +} + +#[cot_macros::dbtest] +async fn model_macro_filtering(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + assert_eq!(TestModel::objects().all(&**test_db).await.unwrap(), vec![]); + + let mut model = TestModel { + id: Auto::auto(), + name: "test".to_owned(), + }; + model.save(&**test_db).await.unwrap(); + let objects = query!(TestModel, $name == "test") + .all(&**test_db) + .await + .unwrap(); + assert_eq!(objects.len(), 1); + assert_eq!(objects[0].name, "test"); + + let objects = query!(TestModel, $name == "t") + .all(&**test_db) + .await + .unwrap(); + assert!(objects.is_empty()); +} + +#[cot_macros::dbtest] +async fn raw_as_maps_rows_to_model(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + let mut model1 = TestModel { + id: Auto::fixed(1), + name: "test1".to_owned(), + }; + model1.save(&**test_db).await.unwrap(); + let mut model2 = TestModel { + id: Auto::fixed(2), + name: "test2".to_owned(), + }; + model2.save(&**test_db).await.unwrap(); + + let mut objects = test_db + .raw_as::("SELECT * FROM cot__test_model") + .await + .unwrap(); + objects.sort_by_key(|o| o.id); + assert_eq!(objects, vec![model1, model2]); +} + +#[cot_macros::dbtest] +async fn raw_executes_statement(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + let mut model = TestModel { + id: Auto::fixed(1), + name: "test".to_owned(), + }; + model.save(&**test_db).await.unwrap(); + + let result = test_db + .raw("UPDATE cot__test_model SET name = 'updated'") + .await + .unwrap(); + assert_eq!(result.rows_affected().0, 1); + + let objects = TestModel::objects().all(&**test_db).await.unwrap(); + assert_eq!(objects[0].name, "updated"); +} + +#[cot_macros::dbtest] +async fn raw_returns_error_for_invalid_sql(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + let result = test_db.raw("NOT A VALID SQL STATEMENT").await; + assert!(result.is_err()); +} + +// `raw_with`/`raw_as_with` need bound-parameter placeholders in the SQL text +// itself (`?` on SQLite/MySQL vs. `$1, $2, ...` on PostgreSQL), so a single +// `dbtest` function body can't exercise all three backends. These are +// therefore SQLite-only. + +#[cfg(feature = "sqlite")] +#[cot::test] +#[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" +)] +async fn raw_with_executes_parameterized_statement() { + let db = TestDatabase::new_sqlite() + .await + .expect("failed to create SQLite test database"); + migrate_test_model(&db).await; + + let mut model = TestModel { + id: Auto::fixed(1), + name: "test".to_owned(), + }; + model.save(&*db).await.unwrap(); + + let params: &[&dyn cot::db::ToDbValue] = &[&"updated", &1_i32]; + let result = db + .raw_with("UPDATE cot__test_model SET name = ? WHERE id = ?", params) + .await + .unwrap(); + assert_eq!(result.rows_affected().0, 1); + + let objects = TestModel::objects().all(&*db).await.unwrap(); + assert_eq!(objects[0].name, "updated"); + + db.cleanup() + .await + .expect("failed to clean up SQLite test database"); +} + +#[cfg(feature = "sqlite")] +#[cot::test] +#[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" +)] +async fn raw_as_with_maps_parameterized_rows_to_model() { + let db = TestDatabase::new_sqlite() + .await + .expect("failed to create SQLite test database"); + migrate_test_model(&db).await; + + let mut model1 = TestModel { + id: Auto::fixed(1), + name: "test1".to_owned(), + }; + model1.save(&*db).await.unwrap(); + let mut model2 = TestModel { + id: Auto::fixed(2), + name: "test2".to_owned(), + }; + model2.save(&*db).await.unwrap(); + + let objects = db + .raw_as_with::("SELECT * FROM cot__test_model WHERE name = ?", &[&"test1"]) + .await + .unwrap(); + assert_eq!(objects, vec![model1]); + + let objects = db + .raw_as_with::("SELECT * FROM cot__test_model WHERE name = ?", &[&"test2"]) + .await + .unwrap(); + assert_eq!(objects, vec![model2]); + + db.cleanup() + .await + .expect("failed to clean up SQLite test database"); +} +#[cot_macros::dbtest] +async fn bulk_insert_basic(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + let mut models = vec![ + TestModel { + id: Auto::auto(), + name: "test1".to_owned(), + }, + TestModel { + id: Auto::auto(), + name: "test2".to_owned(), + }, + TestModel { + id: Auto::auto(), + name: "test3".to_owned(), + }, + ]; + + TestModel::bulk_insert(&**test_db, &mut models) + .await + .unwrap(); + + assert!(matches!(models[0].id, Auto::Fixed(_))); + assert!(matches!(models[1].id, Auto::Fixed(_))); + assert!(matches!(models[2].id, Auto::Fixed(_))); + + let objects = TestModel::objects().all(&**test_db).await.unwrap(); + assert_eq!(objects.len(), 3); + + let names: Vec<_> = objects.iter().map(|m| m.name.as_str()).collect(); + assert!(names.contains(&"test1")); + assert!(names.contains(&"test2")); + assert!(names.contains(&"test3")); + + // Verify IDs match between models and database + for model in &models { + if let Auto::Fixed(_) = model.id { + let db_model = TestModel::get_by_primary_key(&**test_db, model.id) + .await + .unwrap() + .unwrap(); + assert_eq!(db_model.name, model.name); + } + } +} + +#[cot_macros::dbtest] +async fn bulk_insert_or_update(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + let mut models = vec![ + TestModel { + id: Auto::auto(), + name: "test1".to_owned(), + }, + TestModel { + id: Auto::auto(), + name: "test2".to_owned(), + }, + TestModel { + id: Auto::auto(), + name: "test3".to_owned(), + }, + ]; + TestModel::bulk_insert(&**test_db, &mut models) + .await + .unwrap(); + + let mut models = vec![ + TestModel { + id: models[0].id, + name: "test1_updated".to_owned(), + }, + TestModel { + id: models[2].id, + name: "test3_updated".to_owned(), + }, + ]; + TestModel::bulk_insert_or_update(&**test_db, &mut models) + .await + .unwrap(); + + let objects = TestModel::objects().all(&**test_db).await.unwrap(); + assert_eq!(objects.len(), 3); + + let names: Vec<_> = objects.iter().map(|m| m.name.as_str()).collect(); + assert!(names.contains(&"test1_updated")); + assert!(names.contains(&"test2")); + assert!(names.contains(&"test3_updated")); +} + +#[cot_macros::dbtest] +async fn bulk_insert_empty(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + let mut models: Vec = vec![]; + let result = TestModel::bulk_insert(&**test_db, &mut models).await; + + assert!(result.is_ok()); + let objects = TestModel::objects().all(&**test_db).await.unwrap(); + assert_eq!(objects.len(), 0); +} + +#[cot_macros::dbtest] +async fn bulk_insert_large_batch(test_db: &mut TestDatabase) { + const BATCH_SIZE: usize = 100_000; + + migrate_test_model(&*test_db).await; + + let mut models: Vec = (0..BATCH_SIZE) + .map(|i| TestModel { + id: Auto::auto(), + name: format!("test{i}"), + }) + .collect(); + + TestModel::bulk_insert(&**test_db, &mut models) + .await + .unwrap(); + + for model in &models { + assert!(matches!(model.id, Auto::Fixed(_))); + } + + let objects = TestModel::objects().all(&**test_db).await.unwrap(); + assert_eq!(objects.len(), BATCH_SIZE); +} + +#[cot_macros::dbtest] +async fn bulk_insert_no_values(test_db: &mut TestDatabase) { + #[derive(Debug, PartialEq)] + #[model] + struct PkOnlyModel { + #[model(primary_key)] + id: Auto, + } + + const CREATE_PK_ONLY_MODEL: Operation = Operation::create_model() + .table_name(Identifier::new("cot__pk_only_model")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + ]) + .build(); + + async fn migrate_pk_only_model(db: &Database) { + CREATE_PK_ONLY_MODEL.forwards(db).await.unwrap(); + } + + const BATCH_SIZE: usize = 17; + + migrate_pk_only_model(&*test_db).await; + + let mut models: Vec = (0..BATCH_SIZE) + .map(|_| PkOnlyModel { id: Auto::auto() }) + .collect(); + + let result = PkOnlyModel::bulk_insert(&**test_db, &mut models).await; + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + DatabaseError::BulkInsertNoValueColumns + )); +} + +#[cot_macros::dbtest] +async fn bulk_insert_with_fixed_pk(test_db: &mut TestDatabase) { + migrate_test_model(&*test_db).await; + + let mut models = vec![ + TestModel { + id: Auto::fixed(100), + name: "test100".to_owned(), + }, + TestModel { + id: Auto::fixed(200), + name: "test200".to_owned(), + }, + TestModel { + id: Auto::fixed(300), + name: "test300".to_owned(), + }, + ]; + + TestModel::bulk_insert(&**test_db, &mut models) + .await + .unwrap(); + + let model100 = TestModel::get_by_primary_key(&**test_db, Auto::fixed(100)) + .await + .unwrap() + .unwrap(); + assert_eq!(model100.name, "test100"); + + let model200 = TestModel::get_by_primary_key(&**test_db, Auto::fixed(200)) + .await + .unwrap() + .unwrap(); + assert_eq!(model200.name, "test200"); + + let model300 = TestModel::get_by_primary_key(&**test_db, Auto::fixed(300)) + .await + .unwrap() + .unwrap(); + assert_eq!(model300.name, "test300"); +} diff --git a/cot/tests/db_testing/relations.rs b/cot/tests/db_testing/relations.rs new file mode 100644 index 000000000..ef695beed --- /dev/null +++ b/cot/tests/db_testing/relations.rs @@ -0,0 +1,247 @@ +use cot::db::migrations::{Field, Operation}; +use cot::db::{ + Auto, DatabaseError, DatabaseField, ForeignKey, ForeignKeyOnDeletePolicy, + ForeignKeyOnUpdatePolicy, Identifier, Model, +}; +use cot::test::TestDatabase; +use cot_macros::{model, query}; + +use crate::db_testing::run_migrations; + +#[cot_macros::dbtest] +async fn foreign_keys(db: &mut TestDatabase) { + #[derive(Debug, Clone, PartialEq)] + #[model] + struct Artist { + #[model(primary_key)] + id: Auto, + name: String, + } + + #[derive(Debug, Clone, PartialEq)] + #[model] + struct Track { + #[model(primary_key)] + id: Auto, + artist: ForeignKey, + name: String, + } + + const CREATE_ARTIST: Operation = Operation::create_model() + .table_name(Identifier::new("cot__artist")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new(Identifier::new("name"), ::TYPE), + ]) + .build(); + const CREATE_TRACK: Operation = Operation::create_model() + .table_name(Identifier::new("cot__track")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("artist"), + as DatabaseField>::TYPE, + ) + .foreign_key( + ::TABLE_NAME, + ::PRIMARY_KEY_NAME, + ForeignKeyOnDeletePolicy::Restrict, + ForeignKeyOnUpdatePolicy::Restrict, + ), + Field::new(Identifier::new("name"), ::TYPE), + ]) + .build(); + + run_migrations!(db, CREATE_ARTIST, CREATE_TRACK); + + let mut artist = Artist { + id: Auto::auto(), + name: "artist".to_owned(), + }; + artist.save(&**db).await.unwrap(); + + let mut track = Track { + id: Auto::auto(), + artist: ForeignKey::from(&artist), + name: "track".to_owned(), + }; + track.save(&**db).await.unwrap(); + + let mut track = Track::objects().all(&**db).await.unwrap()[0].clone(); + let artist_from_db = track.artist.get(&**db).await.unwrap(); + assert_eq!(artist_from_db, &artist); + + let error = query!(Artist, $id == artist.id) + .delete(&**db) + .await + .unwrap_err(); + // expected foreign key violation + assert!(matches!(error, DatabaseError::DatabaseEngineError(_))); + + query!(Track, $artist == &artist) + .delete(&**db) + .await + .unwrap(); + query!(Artist, $id == artist.id) + .delete(&**db) + .await + .unwrap(); + // no error should be thrown +} + +#[cot_macros::dbtest] +async fn foreign_keys_option(db: &mut TestDatabase) { + #[derive(Debug, Clone, PartialEq)] + #[model] + struct Parent { + #[model(primary_key)] + id: Auto, + } + + #[derive(Debug, Clone, PartialEq)] + #[model] + struct Child { + #[model(primary_key)] + id: Auto, + parent: Option>, + } + + const CREATE_PARENT: Operation = Operation::create_model() + .table_name(Identifier::new("cot__parent")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + ]) + .build(); + const CREATE_CHILD: Operation = Operation::create_model() + .table_name(Identifier::new("cot__child")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("parent"), + > as DatabaseField>::TYPE, + ) + .set_null(> as DatabaseField>::NULLABLE) + .foreign_key( + ::TABLE_NAME, + ::PRIMARY_KEY_NAME, + ForeignKeyOnDeletePolicy::SetNone, + ForeignKeyOnUpdatePolicy::SetNone, + ), + ]) + .build(); + + run_migrations!(db, CREATE_PARENT, CREATE_CHILD); + + // Test child with `None` parent + let mut child = Child { + id: Auto::auto(), + parent: None, + }; + child.save(&**db).await.unwrap(); + + let child = Child::objects().all(&**db).await.unwrap()[0].clone(); + assert_eq!(child.parent, None); + + query!(Child, $id == child.id).delete(&**db).await.unwrap(); + + // Test child with `Some` parent + let mut parent = Parent { id: Auto::auto() }; + parent.save(&**db).await.unwrap(); + + let mut child = Child { + id: Auto::auto(), + parent: Some(ForeignKey::from(&parent)), + }; + child.save(&**db).await.unwrap(); + + let child = Child::objects().all(&**db).await.unwrap()[0].clone(); + let mut parent_fk = child.parent.unwrap(); + let parent_from_db = parent_fk.get(&**db).await.unwrap(); + assert_eq!(parent_from_db, &parent); + + // Check none policy + query!(Parent, $id == parent.id) + .delete(&**db) + .await + .unwrap(); + let child = Child::objects().all(&**db).await.unwrap()[0].clone(); + assert_eq!(child.parent, None); +} + +#[cot_macros::dbtest] +async fn foreign_keys_cascade(db: &mut TestDatabase) { + #[derive(Debug, Clone, PartialEq)] + #[model] + struct Parent { + #[model(primary_key)] + id: Auto, + } + + #[derive(Debug, Clone, PartialEq)] + #[model] + struct Child { + #[model(primary_key)] + id: Auto, + parent: Option>, + } + + const CREATE_PARENT: Operation = Operation::create_model() + .table_name(Identifier::new("cot__parent")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + ]) + .build(); + const CREATE_CHILD: Operation = Operation::create_model() + .table_name(Identifier::new("cot__child")) + .fields(&[ + Field::new(Identifier::new("id"), as DatabaseField>::TYPE) + .primary_key() + .auto(), + Field::new( + Identifier::new("parent"), + > as DatabaseField>::TYPE, + ) + .set_null(> as DatabaseField>::NULLABLE) + .foreign_key( + ::TABLE_NAME, + ::PRIMARY_KEY_NAME, + ForeignKeyOnDeletePolicy::Cascade, + ForeignKeyOnUpdatePolicy::Cascade, + ), + ]) + .build(); + + run_migrations!(db, CREATE_PARENT, CREATE_CHILD); + + // with parent + let mut parent = Parent { id: Auto::auto() }; + parent.save(&**db).await.unwrap(); + + let mut child = Child { + id: Auto::auto(), + parent: Some(ForeignKey::from(&parent)), + }; + child.save(&**db).await.unwrap(); + + let child = Child::objects().all(&**db).await.unwrap()[0].clone(); + let mut parent_fk = child.parent.unwrap(); + let parent_from_db = parent_fk.get(&**db).await.unwrap(); + assert_eq!(parent_from_db, &parent); + + // Check cascade policy + query!(Parent, $id == parent.id) + .delete(&**db) + .await + .unwrap(); + assert!(Child::objects().all(&**db).await.unwrap().is_empty()); +} diff --git a/cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps-2.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_includes_dependent_apps-2.snap similarity index 87% rename from cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps-2.snap rename to cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_includes_dependent_apps-2.snap index b5230c5b4..81f2d6d63 100644 --- a/cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps-2.snap +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_includes_dependent_apps-2.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db.rs +source: cot/tests/db_testing/migrations.rs expression: rollback_output --- Rolling Back Migration rollback_dependent::m_0001_initial diff --git a/cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_includes_dependent_apps.snap similarity index 88% rename from cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps.snap rename to cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_includes_dependent_apps.snap index 8e1c152bf..023ea316b 100644 --- a/cot/tests/snapshots/migrations/db__migration_engine_rollback_includes_dependent_apps.snap +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_includes_dependent_apps.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db.rs +source: cot/tests/db_testing/migrations.rs expression: dry_run_output --- Rollback dry run diff --git a/cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app-2.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app-2.snap similarity index 87% rename from cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app-2.snap rename to cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app-2.snap index b13824ce4..27a48b5db 100644 --- a/cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app-2.snap +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app-2.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db.rs +source: cot/tests/db_testing/migrations.rs expression: rollback_output --- Rolling Back Migration rollback_app1::m_0003_third diff --git a/cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app.snap similarity index 88% rename from cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app.snap rename to cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app.snap index ed7a8efa2..b30eb1df3 100644 --- a/cot/tests/snapshots/migrations/db__migration_engine_rollback_single_app.snap +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db.rs +source: cot/tests/db_testing/migrations.rs expression: dry_run_output --- Rollback dry run diff --git a/cot/tests/snapshots/migrations/db__migration_engine_rollback_zero-2.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero-2.snap similarity index 90% rename from cot/tests/snapshots/migrations/db__migration_engine_rollback_zero-2.snap rename to cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero-2.snap index 26796dd7e..c7aa01e05 100644 --- a/cot/tests/snapshots/migrations/db__migration_engine_rollback_zero-2.snap +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero-2.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db.rs +source: cot/tests/db_testing/migrations.rs expression: rollback_output --- Rolling Back Migration rollback_app1::m_0003_third diff --git a/cot/tests/snapshots/migrations/db__migration_engine_rollback_zero.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero.snap similarity index 89% rename from cot/tests/snapshots/migrations/db__migration_engine_rollback_zero.snap rename to cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero.snap index 32ce59c44..d90490a64 100644 --- a/cot/tests/snapshots/migrations/db__migration_engine_rollback_zero.snap +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db.rs +source: cot/tests/db_testing/migrations.rs expression: dry_run_output --- Rollback dry run diff --git a/cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps-2.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps-2.snap similarity index 79% rename from cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps-2.snap rename to cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps-2.snap index ac87c2a72..62339ed46 100644 --- a/cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps-2.snap +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps-2.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db.rs +source: cot/tests/db_testing/migrations.rs expression: rollback_output --- Rolling Back Migration rollback_app1::m_0002_second diff --git a/cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps.snap similarity index 87% rename from cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps.snap rename to cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps.snap index ec581982d..de9990292 100644 --- a/cot/tests/snapshots/migrations/db__migration_rollback_unrelated_apps.snap +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps.snap @@ -1,5 +1,5 @@ --- -source: cot/tests/db.rs +source: cot/tests/db_testing/migrations.rs expression: dry_run_output --- Rollback dry run From b93f5d1c6650736d06ac8699b8e7462148d6159d Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 27 Jul 2026 17:12:59 +0000 Subject: [PATCH 23/24] move cli task group test into integration tests --- cot/src/cli.rs | 37 ------------------------------------- cot/tests/cli.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 37 deletions(-) create mode 100644 cot/tests/cli.rs diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 303513384..9d3732b4d 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -759,43 +759,6 @@ mod tests { ); } - #[cot::test] - async fn cli_task_group_dispatches_nested_task() { - use std::sync::atomic::{AtomicBool, Ordering}; - - struct NestedTask; - #[async_trait(?Send)] - impl CliTask for NestedTask { - fn subcommand(&self) -> Command { - Command::new("nested") - } - - async fn execute( - &mut self, - _matches: &ArgMatches, - _bootstrapper: Bootstrapper, - ) -> Result<()> { - TASK_CALLED.store(true, Ordering::SeqCst); - Ok(()) - } - } - - struct TestProject; - impl crate::Project for TestProject {} - - static TASK_CALLED: AtomicBool = AtomicBool::new(false); - TASK_CALLED.store(false, Ordering::SeqCst); - - let mut group = CliTaskGroup::new("group"); - group.add_task(NestedTask); - let matches = group.subcommand().get_matches_from(["group", "nested"]); - let bootstrapper = Bootstrapper::new(TestProject).with_config(ProjectConfig::default()); - - group.execute(&matches, bootstrapper).await.unwrap(); - - assert!(TASK_CALLED.load(Ordering::SeqCst)); - } - #[test] fn run_server_subcommand() { let matches = RunServer diff --git a/cot/tests/cli.rs b/cot/tests/cli.rs new file mode 100644 index 000000000..308fdc2ce --- /dev/null +++ b/cot/tests/cli.rs @@ -0,0 +1,43 @@ +use async_trait::async_trait; +use clap::{ArgMatches, Command}; +use cot::cli::{CliTask, CliTaskGroup}; +use cot::config::ProjectConfig; +use cot::project::WithConfig; +use cot::{Bootstrapper, Project}; + +struct TestProject; +impl Project for TestProject {} + +#[cot::test] +async fn cli_task_group_dispatches_nested_task() { + use std::sync::atomic::{AtomicBool, Ordering}; + + struct NestedTask; + #[async_trait(?Send)] + impl CliTask for NestedTask { + fn subcommand(&self) -> Command { + Command::new("nested") + } + + async fn execute( + &mut self, + _matches: &ArgMatches, + _bootstrapper: Bootstrapper, + ) -> cot_core::Result<()> { + TASK_CALLED.store(true, Ordering::SeqCst); + Ok(()) + } + } + + static TASK_CALLED: AtomicBool = AtomicBool::new(false); + TASK_CALLED.store(false, Ordering::SeqCst); + + let mut group = CliTaskGroup::new("group"); + group.add_task(NestedTask); + let matches = group.subcommand().get_matches_from(["group", "nested"]); + let bootstrapper = Bootstrapper::new(TestProject).with_config(ProjectConfig::default()); + + group.execute(&matches, bootstrapper).await.unwrap(); + + assert!(TASK_CALLED.load(Ordering::SeqCst)); +} From 0386701e60526ffc02d4abbda361761e74ae4a88 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 27 Jul 2026 17:23:21 +0000 Subject: [PATCH 24/24] move tests back into cli.rs and move use statement to the top --- cot/src/cli.rs | 37 +++++++++++++++++++++++++++++++++++++ cot/tests/cli.rs | 43 ------------------------------------------- 2 files changed, 37 insertions(+), 43 deletions(-) delete mode 100644 cot/tests/cli.rs diff --git a/cot/src/cli.rs b/cot/src/cli.rs index 9d3732b4d..1c7232ccc 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -675,6 +675,8 @@ use crate::static_files::StaticFiles; #[cfg(test)] mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use clap::Command; use cot::test::serial_guard; use tempfile::tempdir; @@ -759,6 +761,41 @@ mod tests { ); } + #[cot::test] + async fn cli_task_group_dispatches_nested_task() { + struct NestedTask; + #[async_trait(?Send)] + impl CliTask for NestedTask { + fn subcommand(&self) -> Command { + Command::new("nested") + } + + async fn execute( + &mut self, + _matches: &ArgMatches, + _bootstrapper: Bootstrapper, + ) -> Result<()> { + TASK_CALLED.store(true, Ordering::SeqCst); + Ok(()) + } + } + + struct TestProject; + impl crate::Project for TestProject {} + + static TASK_CALLED: AtomicBool = AtomicBool::new(false); + TASK_CALLED.store(false, Ordering::SeqCst); + + let mut group = CliTaskGroup::new("group"); + group.add_task(NestedTask); + let matches = group.subcommand().get_matches_from(["group", "nested"]); + let bootstrapper = Bootstrapper::new(TestProject).with_config(ProjectConfig::default()); + + group.execute(&matches, bootstrapper).await.unwrap(); + + assert!(TASK_CALLED.load(Ordering::SeqCst)); + } + #[test] fn run_server_subcommand() { let matches = RunServer diff --git a/cot/tests/cli.rs b/cot/tests/cli.rs deleted file mode 100644 index 308fdc2ce..000000000 --- a/cot/tests/cli.rs +++ /dev/null @@ -1,43 +0,0 @@ -use async_trait::async_trait; -use clap::{ArgMatches, Command}; -use cot::cli::{CliTask, CliTaskGroup}; -use cot::config::ProjectConfig; -use cot::project::WithConfig; -use cot::{Bootstrapper, Project}; - -struct TestProject; -impl Project for TestProject {} - -#[cot::test] -async fn cli_task_group_dispatches_nested_task() { - use std::sync::atomic::{AtomicBool, Ordering}; - - struct NestedTask; - #[async_trait(?Send)] - impl CliTask for NestedTask { - fn subcommand(&self) -> Command { - Command::new("nested") - } - - async fn execute( - &mut self, - _matches: &ArgMatches, - _bootstrapper: Bootstrapper, - ) -> cot_core::Result<()> { - TASK_CALLED.store(true, Ordering::SeqCst); - Ok(()) - } - } - - static TASK_CALLED: AtomicBool = AtomicBool::new(false); - TASK_CALLED.store(false, Ordering::SeqCst); - - let mut group = CliTaskGroup::new("group"); - group.add_task(NestedTask); - let matches = group.subcommand().get_matches_from(["group", "nested"]); - let bootstrapper = Bootstrapper::new(TestProject).with_config(ProjectConfig::default()); - - group.execute(&matches, bootstrapper).await.unwrap(); - - assert!(TASK_CALLED.load(Ordering::SeqCst)); -}