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..345dac818 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..1c7232ccc 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 { @@ -409,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; @@ -476,6 +744,58 @@ 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() { + 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..70136a586 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. 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/db.rs b/cot/tests/db.rs index ea4887ccc..0b50dd634 100644 --- a/cot/tests/db.rs +++ b/cot/tests/db.rs @@ -1,1201 +1,4 @@ #![cfg(feature = "fake")] #![cfg_attr(miri, ignore)] -use bytes::Bytes; -use cot::auth::PasswordHash; -use cot::common_types::{Email, Password, Url}; -use cot::db::migrations::{Field, Operation}; -use cot::db::query::ExprEq; -use cot::db::{ - Auto, Database, DatabaseError, DatabaseField, ForeignKey, ForeignKeyOnDeletePolicy, - ForeignKeyOnUpdatePolicy, Identifier, LimitedString, Model, model, query, -}; -use cot::test::TestDatabase; -use fake::rand::rngs::StdRng; -use fake::rand::{RngExt, SeedableRng}; -use fake::{Dummy, Fake, Faker}; - -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, -} - -#[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"); -} +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/db_testing/snapshots/migrations/db__db_testing__migrations__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 new file mode 100644 index 000000000..81f2d6d63 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_includes_dependent_apps-2.snap @@ -0,0 +1,8 @@ +--- +source: cot/tests/db_testing/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/db_testing/snapshots/migrations/db__db_testing__migrations__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 new file mode 100644 index 000000000..023ea316b --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_includes_dependent_apps.snap @@ -0,0 +1,19 @@ +--- +source: cot/tests/db_testing/migrations.rs +expression: dry_run_output +--- +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/db_testing/snapshots/migrations/db__db_testing__migrations__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 new file mode 100644 index 000000000..27a48b5db --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app-2.snap @@ -0,0 +1,8 @@ +--- +source: cot/tests/db_testing/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/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app.snap new file mode 100644 index 000000000..b30eb1df3 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_single_app.snap @@ -0,0 +1,19 @@ +--- +source: cot/tests/db_testing/migrations.rs +expression: dry_run_output +--- +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/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero-2.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero-2.snap new file mode 100644 index 000000000..c7aa01e05 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero-2.snap @@ -0,0 +1,10 @@ +--- +source: cot/tests/db_testing/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/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero.snap new file mode 100644 index 000000000..d90490a64 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_engine_rollback_zero.snap @@ -0,0 +1,20 @@ +--- +source: cot/tests/db_testing/migrations.rs +expression: dry_run_output +--- +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/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps-2.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps-2.snap new file mode 100644 index 000000000..62339ed46 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps-2.snap @@ -0,0 +1,6 @@ +--- +source: cot/tests/db_testing/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/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps.snap b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps.snap new file mode 100644 index 000000000..de9990292 --- /dev/null +++ b/cot/tests/db_testing/snapshots/migrations/db__db_testing__migrations__migration_rollback_unrelated_apps.snap @@ -0,0 +1,18 @@ +--- +source: cot/tests/db_testing/migrations.rs +expression: dry_run_output +--- +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