diff --git a/Cargo.lock b/Cargo.lock index 9134f0604..ceeceeb41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -910,6 +910,7 @@ version = "0.7.0" dependencies = [ "ahash", "aide", + "anstyle", "askama", "async-trait", "axum", @@ -937,6 +938,7 @@ dependencies = [ "humantime", "idna", "indexmap", + "insta", "is_terminal_polyfill", "lettre", "mime", @@ -976,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 8f50d1584..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"] } @@ -24,7 +25,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 } @@ -72,6 +73,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 97652a2e1..303513384 100644 --- a/cot/src/cli.rs +++ b/cot/src/cli.rs @@ -6,7 +6,10 @@ 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; use derive_more::Debug; use crate::{Bootstrapper, Error, Result}; @@ -16,6 +19,8 @@ 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 +96,14 @@ impl Cli { cli.add_task(Check); cli.add_task(CollectStatic); + #[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 } @@ -389,6 +402,259 @@ impl CliTask for Check { } } +/// A group of related sub-tasks under a single parent subcommand. +/// +/// # Examples +/// +/// ``` +/// use async_trait::async_trait; +/// use clap::{ArgMatches, Command}; +/// use cot::cli::{Cli, CliTask, CliTaskGroup}; +/// 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) { +/// let mut group_command = CliTaskGroup::new("foo").about("Foo related commands"); +/// group_command.add_task(Frobnicate); +/// cli.add_task(group_command); +/// } +/// } +/// ``` +#[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(), + about: String::new(), + tasks: HashMap::new(), + } + } + + /// 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"); + /// ``` + #[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, CliTaskGroup}; + /// 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) { + /// let mut 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(); + + 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 should be present since subcommand_required is true"); + + self.tasks + .get_mut(sub_name) + .expect("command should be registered") + .execute(sub_matches, bootstrapper) + .await + } +} + +#[cfg(feature = "db")] +struct MigrationRollback; + +#[cfg(feature = "db")] +#[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("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( + &mut self, + matches: &ArgMatches, + bootstrapper: Bootstrapper, + ) -> Result<()> { + let file = matches + .get_one::("migration_name") + .expect("required argument"); + let dry_run = matches.get_flag("dry-run"); + + let bootstrapper = bootstrapper + .with_apps() + .with_database() + .await? + .boot() + .await?; + + let crate_name = bootstrapper.project().cli_metadata().name; + let app_name = matches + .get_one::("app") + .map_or(crate_name, String::as_str); + + let BootstrappedProject { + context, + handler: _, + 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)?; + 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, &mut std::io::stderr()) + .await?; + } + } + Ok(()) + } +} + /// A macro to generate a [`CliMetadata`] struct from the Cargo manifest. #[macro_export] macro_rules! metadata { @@ -476,6 +742,60 @@ 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 diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 1ac4590d1..90b428602 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -2,9 +2,11 @@ mod sorter; -use std::fmt; +use std::collections::{HashSet, VecDeque}; 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}; @@ -14,9 +16,12 @@ 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, write_status_msg}; + +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. @@ -25,6 +30,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 @@ -215,6 +223,233 @@ impl MigrationEngine { Ok(()) } + /// 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, + migration_name: &str, + app_name: &str, + output: &mut impl Write, + ) -> Result<()> { + info!("Rolling back migrations"); + // TODO: use a DB transaction here + + let rollback_plan = self.rollback_plan(migration_name, 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() + ); + 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?; + } + + if Self::is_migration_applied(database, migration).await? { + Self::mark_migration_unapplied(database, migration).await?; + } + write_status_msg( + output, + StatusType::RolledBack, + &format!("Migration {}::{}", migration.app_name(), migration.name()), + ) + .map_err(MigrationEngineError::Io)?; + } + + 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, + migration_name: &str, + app_name: &str, + ) -> Result> { + 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 + // other apps only when they depend on that seed set. + rollback_indices.extend( + self.migrations + .iter() + .enumerate() + .filter(|(index, migration)| { + if migration.app_name() != app_name { + return false; + } + + if let Some(target_index) = target_index { + return *index > target_index; + } + true + }) + .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::>(); + 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); + } + } + } + + 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 +476,30 @@ impl MigrationEngine { database.insert(&mut applied_migration).await?; Ok(()) } + + async fn mark_migration_unapplied( + database: &Database, + migration: &MigrationWrapper, + ) -> Result<()> { + query!(AppliedMigration, $app == migration.app_name() && $name == migration.name()) + .delete(database) + .await?; + Ok(()) + } +} + +/// 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 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 { + names.push(migration_number); + } + names } /// A migration operation that can be run forwards or backwards. @@ -2025,7 +2284,12 @@ 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; @@ -2053,6 +2317,146 @@ 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(); @@ -2076,6 +2480,277 @@ 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/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/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..8385a3715 --- /dev/null +++ b/cot/src/utils/cli.rs @@ -0,0 +1,90 @@ +//! 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(); + + writeln!(output, "{style}{status_str:>12}{style:#} {message}") +} + +#[doc(hidden)] // Not part of Cot's public API; used by the CLI. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +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", + } + } +} 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() 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_includes_dependent_apps.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap new file mode 100644 index 000000000..91a870bc2 --- /dev/null +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_includes_dependent_apps.snap @@ -0,0 +1,19 @@ +--- +source: cot/src/db/migrations.rs +expression: snapshot_text +--- +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/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_single_app.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app.snap new file mode 100644 index 000000000..7abf461ef --- /dev/null +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_single_app.snap @@ -0,0 +1,19 @@ +--- +source: cot/src/db/migrations.rs +expression: snapshot_text +--- +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/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_engine_rollback_zero.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero.snap new file mode 100644 index 000000000..f4ea9e8b0 --- /dev/null +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_engine_rollback_zero.snap @@ -0,0 +1,20 @@ +--- +source: cot/src/db/migrations.rs +expression: snapshot_text +--- +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/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 diff --git a/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap new file mode 100644 index 000000000..8f83a542f --- /dev/null +++ b/cot/tests/snapshots/migrations/cot__db__migrations__tests__migration_rollback_unrelated_apps.snap @@ -0,0 +1,18 @@ +--- +source: cot/src/db/migrations.rs +expression: snapshot_text +--- +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