From 2104d84b213c60f21a7105b0218eb9f17e4c195d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Tue, 27 Jan 2026 20:24:30 +0100 Subject: [PATCH 1/6] feat(orm): support custom migrations This adds support for user-defined migrations that might contain arbitrary code in them. This is primarily useful for data migrations which do not modify the schema, but fill in the data in the database instead. This extends the migration engine to support a new type of migrations, as well as cot-cli with a new command that creates a new custom migration. --- cot-cli/src/args.rs | 14 +++ cot-cli/src/handlers.rs | 22 ++++- cot-cli/src/main.rs | 1 + cot-cli/src/migration_generator.rs | 133 +++++++++++++++++++++++++++-- cot-macros/src/lib.rs | 29 +++++++ cot-macros/src/migration_op.rs | 35 ++++++++ cot/src/db/migrations.rs | 132 ++++++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 10 deletions(-) create mode 100644 cot-macros/src/migration_op.rs diff --git a/cot-cli/src/args.rs b/cot-cli/src/args.rs index 9b09f4f6..c884de4b 100644 --- a/cot-cli/src/args.rs +++ b/cot-cli/src/args.rs @@ -48,6 +48,20 @@ pub enum MigrationCommands { List(MigrationListArgs), /// Generate migrations for a Cot project Make(MigrationMakeArgs), + /// Create a new empty migration + New(MigrationNewArgs), +} + +#[derive(Debug, Args)] +pub struct MigrationNewArgs { + /// Name of the migration + pub name: String, + /// Path to the crate directory to create the migration in [default: current + /// directory] + pub path: Option, + /// Name of the app to use in the migration (default: crate name) + #[arg(long)] + pub app_name: Option, } #[derive(Debug, Args)] diff --git a/cot-cli/src/handlers.rs b/cot-cli/src/handlers.rs index 07071553..3c233640 100644 --- a/cot-cli/src/handlers.rs +++ b/cot-cli/src/handlers.rs @@ -4,9 +4,12 @@ use anyhow::Context; use clap::CommandFactory; use crate::args::{ - Cli, CompletionsArgs, ManpagesArgs, MigrationListArgs, MigrationMakeArgs, ProjectNewArgs, + Cli, CompletionsArgs, ManpagesArgs, MigrationListArgs, MigrationMakeArgs, MigrationNewArgs, + ProjectNewArgs, +}; +use crate::migration_generator::{ + MigrationGeneratorOptions, create_new_migration, list_migrations, make_migrations, }; -use crate::migration_generator::{MigrationGeneratorOptions, list_migrations, make_migrations}; use crate::new_project::{CotSource, new_project}; pub fn handle_new_project( @@ -59,6 +62,21 @@ pub fn handle_migration_make( make_migrations(&path, options).with_context(|| "unable to create migrations") } +pub fn handle_migration_new( + MigrationNewArgs { + name, + path, + app_name, + }: MigrationNewArgs, +) -> anyhow::Result<()> { + let path = path.unwrap_or(PathBuf::from(".")); + let options = MigrationGeneratorOptions { + app_name, + output_dir: None, + }; + create_new_migration(&path, &name, options).with_context(|| "unable to create migration") +} + pub fn handle_cli_manpages( ManpagesArgs { output_dir, create }: ManpagesArgs, ) -> anyhow::Result<()> { diff --git a/cot-cli/src/main.rs b/cot-cli/src/main.rs index f7fda262..c80f9827 100644 --- a/cot-cli/src/main.rs +++ b/cot-cli/src/main.rs @@ -25,6 +25,7 @@ fn main() -> anyhow::Result<()> { Commands::Migration(cmd) => match cmd { MigrationCommands::List(args) => handlers::handle_migration_list(args), MigrationCommands::Make(args) => handlers::handle_migration_make(args), + MigrationCommands::New(args) => handlers::handle_migration_new(args), }, } } diff --git a/cot-cli/src/migration_generator.rs b/cot-cli/src/migration_generator.rs index 7dc40dcf..77567f73 100644 --- a/cot-cli/src/migration_generator.rs +++ b/cot-cli/src/migration_generator.rs @@ -68,6 +68,52 @@ fn make_package_migrations( Ok(()) } +pub fn create_new_migration( + path: &Path, + name: &str, + options: MigrationGeneratorOptions, +) -> anyhow::Result<()> { + let Some(manager) = CargoTomlManager::from_path(path)? else { + bail!("Cargo.toml not found in the specified directory or any parent directory.") + }; + + match manager { + CargoTomlManager::Workspace(workspace) => { + let Some(package) = workspace.get_current_package_manager() else { + bail!( + "Generating migrations for workspaces is not supported yet. \ + Please generate migrations for each package separately." + ); + }; + create_package_new_migration(package, name, options) + } + CargoTomlManager::Package(package) => create_package_new_migration(&package, name, options), + } +} + +fn create_package_new_migration( + manager: &PackageManager, + name: &str, + options: MigrationGeneratorOptions, +) -> anyhow::Result<()> { + let crate_name = manager.get_package_name().to_string(); + let manifest_path = manager.get_manifest_path(); + + let generator = MigrationGenerator::new(manifest_path, crate_name, options); + let migration = generator + .generate_custom_migration(name) + .context("unable to generate migration")?; + + generator + .write_migrations(&migration) + .context("unable to write migrations")?; + generator + .write_migrations_module() + .context("unable to write migrations.rs")?; + + Ok(()) +} + pub fn list_migrations(path: &Path) -> anyhow::Result>> { if let Some(manager) = CargoTomlManager::from_path(path)? { let mut migration_list = HashMap::new(); @@ -166,6 +212,53 @@ impl MigrationGenerator { } } + pub fn generate_custom_migration(&self, name: &str) -> anyhow::Result { + let source_files = self.get_source_files()?; + let AppState { migrations, .. } = self.process_source_files(source_files)?; + let migration_processor = MigrationProcessor::new(migrations)?; + + let migration_name = migration_processor.next_migration_name_with_suffix(name)?; + let dependencies = migration_processor.base_dependencies(); + + // Convert dependencies to Repr + let dependencies_repr: Vec<_> = dependencies.iter().map(Repr::repr).collect(); + + let app_name = self.options.app_name.as_ref().unwrap_or(&self.crate_name); + + let migration_def = quote! { + #[derive(Debug, Copy, Clone)] + pub(super) struct Migration; + + impl ::cot::db::migrations::Migration for Migration { + const APP_NAME: &'static str = #app_name; + const MIGRATION_NAME: &'static str = #migration_name; + const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = &[ + #(#dependencies_repr,)* + ]; + const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ + ::cot::db::migrations::Operation::custom(forwards).backwards(backwards).build(), + ]; + } + + #[::cot::db::migrations::migration_op] + async fn forwards(_ctx: &::cot::db::migrations::MigrationContext) -> ::cot::db::Result<()> { + Ok(()) + } + + #[::cot::db::migrations::migration_op] + async fn backwards(_ctx: &::cot::db::migrations::MigrationContext) -> ::cot::db::Result<()> { + Err(::cot::db::DatabaseError::MigrationError( + ::cot::db::migrations::MigrationEngineError::Custom("Backwards migration not implemented".into()) + )) + } + }; + + Ok(MigrationAsSource::new( + migration_name, + Self::generate_migration(migration_def, TokenStream::new()), + )) + } + pub fn write_migrations(&self, migration: &MigrationAsSource) -> anyhow::Result<()> { print_status_msg( StatusType::Creating, @@ -850,8 +943,25 @@ impl MigrationProcessor { } fn next_migration_name(&self) -> anyhow::Result { + let migration_number = self.get_next_migration_number()?; + let now = chrono::Utc::now(); + let date_time = now.format("%Y%m%d_%H%M%S"); + + Ok(format!( + "{MIGRATIONS_MODULE_PREFIX}{migration_number:04}_auto_{date_time}" + )) + } + + fn next_migration_name_with_suffix(&self, suffix: &str) -> anyhow::Result { + let migration_number = self.get_next_migration_number()?; + Ok(format!( + "{MIGRATIONS_MODULE_PREFIX}{migration_number:04}_{suffix}" + )) + } + + fn get_next_migration_number(&self) -> anyhow::Result { if self.migrations.is_empty() { - return Ok(format!("{MIGRATIONS_MODULE_PREFIX}0001_initial")); + return Ok(1); } let last_migration = self.migrations.last().unwrap(); @@ -865,13 +975,7 @@ impl MigrationProcessor { format!("unable to parse migration number: {}", last_migration.name) })?; - let migration_number = last_migration_number + 1; - let now = chrono::Utc::now(); - let date_time = now.format("%Y%m%d_%H%M%S"); - - Ok(format!( - "{MIGRATIONS_MODULE_PREFIX}{migration_number:04}_auto_{date_time}" - )) + Ok(last_migration_number + 1) } /// Returns the list of dependencies for the next migration, based on the @@ -1467,6 +1571,19 @@ mod tests { ); } + #[test] + fn migration_processor_next_migration_name_with_suffix() { + let migrations = vec![Migration { + app_name: "app1".to_string(), + name: "m_0001_initial".to_string(), + models: vec![], + }]; + let processor = MigrationProcessor::new(migrations).unwrap(); + + let next_name = processor.next_migration_name_with_suffix("custom").unwrap(); + assert_eq!(next_name, "m_0002_custom"); + } + #[test] fn toposort_operations() { let mut operations = vec![ diff --git a/cot-macros/src/lib.rs b/cot-macros/src/lib.rs index 558b4b2b..c35d3d0b 100644 --- a/cot-macros/src/lib.rs +++ b/cot-macros/src/lib.rs @@ -5,6 +5,7 @@ mod dbtest; mod form; mod from_request; mod main_fn; +mod migration_op; mod model; mod query; mod select_as_form_field; @@ -23,6 +24,7 @@ use crate::dbtest::fn_to_dbtest; use crate::form::impl_form_for_struct; use crate::from_request::impl_from_request_head_for_struct; use crate::main_fn::{fn_to_cot_e2e_test, fn_to_cot_main, fn_to_cot_test}; +use crate::migration_op::fn_to_migration_op; use crate::model::impl_model_for_struct; use crate::query::{Query, query_to_tokens}; use crate::select_as_form_field::impl_select_as_form_field_for_enum; @@ -151,6 +153,33 @@ pub fn dbtest(_args: TokenStream, input: TokenStream) -> TokenStream { .into() } +/// An attribute macro that defines a custom migration operation. +/// +/// This macro simplifies writing custom migration operations by allowing you to +/// write them as regular `async` functions. It handles the necessary pinning +/// and boxing of the return type to make it compatible with the migration +/// engine. +/// +/// # Examples +/// +/// ``` +/// use cot::db::Result; +/// use cot::db::migrations::{MigrationContext, migration_op}; +/// +/// #[migration_op] +/// async fn my_migration(ctx: &MigrationContext<'_>) -> Result<()> { +/// // Your migration logic here +/// Ok(()) +/// } +/// ``` +#[proc_macro_attribute] +pub fn migration_op(_args: TokenStream, input: TokenStream) -> TokenStream { + let fn_input = parse_macro_input!(input as ItemFn); + fn_to_migration_op(fn_input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + #[proc_macro_attribute] pub fn main(_args: TokenStream, input: TokenStream) -> TokenStream { let fn_input = parse_macro_input!(input as ItemFn); diff --git a/cot-macros/src/migration_op.rs b/cot-macros/src/migration_op.rs new file mode 100644 index 00000000..e92fa38a --- /dev/null +++ b/cot-macros/src/migration_op.rs @@ -0,0 +1,35 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::ItemFn; + +pub(crate) fn fn_to_migration_op(mut item: ItemFn) -> syn::Result { + if item.sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + &item.sig, + "migration operation must be an `async` function", + )); + } + + item.sig.asyncness = None; + let block = item.block; + let ret_type = item.sig.output; + + let ret_type = match ret_type { + syn::ReturnType::Default => quote! { () }, + syn::ReturnType::Type(_, ty) => quote! { #ty }, + }; + + item.sig.output = syn::parse_quote! { + -> ::std::pin::Pin + Send + '_>> + }; + + item.block = syn::parse_quote! { + { + Box::pin(async move #block) + } + }; + + Ok(quote! { + #item + }) +} diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 67659494..f8a9952e 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -4,7 +4,9 @@ mod sorter; use std::fmt; use std::fmt::{Debug, Formatter}; +use std::future::Future; +pub use cot_macros::migration_op; use sea_query::{ColumnDef, StringLen}; use thiserror::Error; use tracing::{Level, info}; @@ -20,6 +22,9 @@ pub enum MigrationEngineError { /// An error occurred while determining the correct order of migrations. #[error("error while determining the correct order of migrations")] MigrationSortError(#[from] MigrationSorterError), + /// A custom error occurred during a migration. + #[error("error running migration: {0}")] + Custom(String), } /// A migration engine that can run migrations. @@ -424,6 +429,30 @@ impl Operation { RemoveModelBuilder::new() } + /// Returns a builder for a custom operation. + /// + /// # Examples + /// + /// ``` + /// use cot::db::migrations::{CustomOperationFn, Operation}; + /// use cot::db::{Database, Result}; + /// + /// fn forwards( + /// db: &Database, + /// ) -> std::pin::Pin> + Send + '_>> { + /// Box::pin(async move { + /// // do something + /// Ok(()) + /// }) + /// } + /// + /// const OPERATION: Operation = Operation::custom(forwards).build(); + /// ``` + #[must_use] + pub const fn custom(forwards: CustomOperationFn) -> CustomBuilder { + CustomBuilder::new(forwards) + } + /// Runs the operation forwards. /// /// # Errors @@ -501,6 +530,13 @@ impl Operation { let query = sea_query::Table::drop().table(*table_name).to_owned(); database.execute_schema(query).await?; } + OperationInner::Custom { + forwards, + backwards: _, + } => { + let context = MigrationContext { db: database }; + forwards(&context).await?; + } } Ok(()) } @@ -578,11 +614,43 @@ impl Operation { } database.execute_schema(query).await?; } + OperationInner::Custom { + forwards: _, + backwards, + } => { + if let Some(backwards) = backwards { + let context = MigrationContext { db: database }; + backwards(&context).await?; + } else { + return Err(crate::db::DatabaseError::MigrationError( + MigrationEngineError::Custom("Backwards migration not implemented".into()), + )); + } + } } Ok(()) } } +/// A context for a custom migration operation. +/// +/// This structure provides access to the database and other information that +/// might be needed during a migration. +#[derive(Debug)] +pub struct MigrationContext<'a> { + /// The database connection to run the migration against. + pub db: &'a Database, +} + +/// A type alias for a custom migration operation function. +/// +/// Typically, you should use the [`migration_op`] attribute macro to define +/// functions of this type. +pub type CustomOperationFn = + for<'a> fn( + &'a MigrationContext<'a>, + ) -> std::pin::Pin> + Send + 'a>>; + #[derive(Debug, Copy, Clone)] enum OperationInner { /// Create a new model with the given fields. @@ -606,6 +674,10 @@ enum OperationInner { table_name: Identifier, fields: &'static [Field], }, + Custom { + forwards: CustomOperationFn, + backwards: Option, + }, } /// A field in a model. @@ -1536,6 +1608,66 @@ impl RemoveModelBuilder { } } +/// A builder for a custom operation. +/// +/// # Examples +/// +/// ``` +/// use cot::db::migrations::{CustomOperationFn, Operation}; +/// use cot::db::{Database, Result}; +/// +/// fn forwards( +/// db: &Database, +/// ) -> std::pin::Pin> + Send + '_>> { +/// Box::pin(async move { +/// // do something +/// Ok(()) +/// }) +/// } +/// +/// fn backwards( +/// db: &Database, +/// ) -> std::pin::Pin> + Send + '_>> { +/// Box::pin(async move { +/// // undo something +/// Ok(()) +/// }) +/// } +/// +/// const OPERATION: Operation = Operation::custom(forwards).backwards(backwards).build(); +/// ``` +#[derive(Debug, Copy, Clone)] +pub struct CustomBuilder { + forwards: CustomOperationFn, + backwards: Option, +} + +impl CustomBuilder { + #[must_use] + const fn new(forwards: CustomOperationFn) -> Self { + Self { + forwards, + backwards: None, + } + } + + /// Sets the backwards operation. + #[must_use] + pub const fn backwards(mut self, backwards: CustomOperationFn) -> Self { + self.backwards = Some(backwards); + self + } + + /// Builds the operation. + #[must_use] + pub const fn build(self) -> Operation { + Operation::new(OperationInner::Custom { + forwards: self.forwards, + backwards: self.backwards, + }) + } +} + /// A trait for defining a migration. /// /// # Cot CLI Usage From 5cd672c1390b049d4a81c8f1d7a02d6684689dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Tue, 27 Jan 2026 22:17:40 +0100 Subject: [PATCH 2/6] remove dev dep --- cot-cli/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/cot-cli/Cargo.toml b/cot-cli/Cargo.toml index afaed670..0c2fa70a 100644 --- a/cot-cli/Cargo.toml +++ b/cot-cli/Cargo.toml @@ -44,7 +44,6 @@ tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } [dev-dependencies] -cot = { path = "../cot", features = ["test"] } cot-cli = { path = ".", features = ["test_utils"] } assert_cmd.workspace = true insta.workspace = true From 62f8a4155f9f4b0325858f2b2bb1bbc16bf25915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Tue, 27 Jan 2026 22:36:00 +0100 Subject: [PATCH 3/6] adress first review --- cot-cli/tests/migration_generator.rs | 2 +- cot/src/db/migrations.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cot-cli/tests/migration_generator.rs b/cot-cli/tests/migration_generator.rs index 1e71d2bb..d1b7f21d 100644 --- a/cot-cli/tests/migration_generator.rs +++ b/cot-cli/tests/migration_generator.rs @@ -195,7 +195,7 @@ fn custom_migration_compile_test() { let source_files = vec![SourceFile::parse(PathBuf::from("main.rs"), src).unwrap()]; let migration_opt = generator - .generate_custom_migration_from_files("m_0001_custom", source_files) + .generate_custom_migration_from_files("custom", source_files) .unwrap(); let MigrationAsSource { name: migration_name, diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index da28b359..46dd8dbd 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -439,7 +439,7 @@ impl Operation { /// use cot::db::migrations::{MigrationContext, Operation, migration_op}; /// /// #[migration_op] - /// async fn forwards(ctx: MigrationContext) -> Result<()> { + /// async fn forwards(ctx: MigrationContext<'_>) -> Result<()> { /// // do something /// Ok(()) /// } @@ -1622,13 +1622,13 @@ impl RemoveModelBuilder { /// use cot::db::migrations::{MigrationContext, Operation, migration_op}; /// /// #[migration_op] -/// async fn forwards(ctx: MigrationContext) -> Result<()> { +/// async fn forwards(ctx: MigrationContext<'_>) -> Result<()> { /// // do something /// Ok(()) /// } /// /// #[migration_op] -/// async fn backwards(ctx: MigrationContext) -> Result<()> { +/// async fn backwards(ctx: MigrationContext<'_>) -> Result<()> { /// // undo something /// Ok(()) /// } From 9c00186e0a59660a62ee1f6e6358ebaea3b6baf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Wed, 28 Jan 2026 19:44:18 +0100 Subject: [PATCH 4/6] fix miri --- cot/src/db/migrations.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 46dd8dbd..46ca75ee 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -2146,7 +2146,11 @@ mod tests { } #[cot::test] - async fn test_operation_custom() { + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" + )] + async fn operation_custom() { // test only on SQLite because we are using raw SQL let test_db = TestDatabase::new_sqlite().await.unwrap(); @@ -2166,7 +2170,11 @@ mod tests { } #[cot::test] - async fn test_operation_custom_backwards() { + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" + )] + async fn operation_custom_backwards() { // test only on SQLite because we are using raw SQL let test_db = TestDatabase::new_sqlite().await.unwrap(); From 1a77b1c92375035ff6a71902068753f1dc1a572c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Wed, 28 Jan 2026 19:53:09 +0100 Subject: [PATCH 5/6] more tests --- cot-cli/src/handlers.rs | 13 +++++++++++ .../tests/snapshot_testing/migration/mod.rs | 20 +++++++++++++++++ ...t_testing__migration__migration_new-2.snap | 20 +++++++++++++++++ ...t_testing__migration__migration_new-3.snap | 20 +++++++++++++++++ ...t_testing__migration__migration_new-4.snap | 21 ++++++++++++++++++ ...t_testing__migration__migration_new-5.snap | 22 +++++++++++++++++++ ...t_testing__migration__migration_new-6.snap | 22 +++++++++++++++++++ ...hot_testing__migration__migration_new.snap | 20 +++++++++++++++++ cot/src/db/migrations.rs | 20 +++++++++++++++++ 9 files changed, 178 insertions(+) create mode 100644 cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-2.snap create mode 100644 cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-3.snap create mode 100644 cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-4.snap create mode 100644 cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-5.snap create mode 100644 cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-6.snap create mode 100644 cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new.snap diff --git a/cot-cli/src/handlers.rs b/cot-cli/src/handlers.rs index 3c233640..23b34fb9 100644 --- a/cot-cli/src/handlers.rs +++ b/cot-cli/src/handlers.rs @@ -145,6 +145,19 @@ mod tests { assert!(result.is_err()); } + #[test] + fn migration_new_wrong_directory() { + let args = MigrationNewArgs { + name: "test_migration".to_string(), + path: Some(PathBuf::from("nonexistent")), + app_name: None, + }; + + let result = handle_migration_new(args); + + assert!(result.is_err()); + } + #[test] fn generate_manpages() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/cot-cli/tests/snapshot_testing/migration/mod.rs b/cot-cli/tests/snapshot_testing/migration/mod.rs index 7ab72d1f..c56efd4d 100644 --- a/cot-cli/tests/snapshot_testing/migration/mod.rs +++ b/cot-cli/tests/snapshot_testing/migration/mod.rs @@ -114,3 +114,23 @@ fn migration_make_existing_model() { ); } } + +#[test] +#[expect(clippy::cast_possible_truncation)] +fn migration_new() { + let cmd = cot_cli!("migration", "new", "custom"); + for (idx, mut cli) in cot_clis_with_verbosity(&cmd).into_iter().enumerate() { + let filter = Verbosity::::new(idx as u8, 0).filter(); + + let temp_dir = tempfile::TempDir::with_prefix("cot-test-").unwrap(); + test_utils::make_package(temp_dir.path()).unwrap(); + + insta::with_settings!( + { + description => format!("Verbosity level: {filter}"), + filters => [GENERIC_FILTERS, TEMP_PATH_FILTERS, TEMP_PROJECT_FILTERS].concat() + }, + { assert_cmd_snapshot!(cli.current_dir(temp_dir.path())) } + ); + } +} diff --git a/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-2.snap b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-2.snap new file mode 100644 index 00000000..3103d7a9 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-2.snap @@ -0,0 +1,20 @@ +--- +source: cot-cli/tests/snapshot_testing/migration/mod.rs +description: "Verbosity level: error" +info: + program: cot + args: + - migration + - new + - custom + - "-v" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- + Creating Migration 'm_0001_custom' + Creating Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration 'm_0001_custom' diff --git a/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-3.snap b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-3.snap new file mode 100644 index 00000000..800b746a --- /dev/null +++ b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-3.snap @@ -0,0 +1,20 @@ +--- +source: cot-cli/tests/snapshot_testing/migration/mod.rs +description: "Verbosity level: warn" +info: + program: cot + args: + - migration + - new + - custom + - "-vv" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- + Creating Migration 'm_0001_custom' + Creating Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration 'm_0001_custom' diff --git a/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-4.snap b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-4.snap new file mode 100644 index 00000000..e609886f --- /dev/null +++ b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-4.snap @@ -0,0 +1,21 @@ +--- +source: cot-cli/tests/snapshot_testing/migration/mod.rs +description: "Verbosity level: info" +info: + program: cot + args: + - migration + - new + - custom + - "-vvv" +--- +success: true +exit_code: 0 +----- stdout ----- +TIMESTAMP DEBUG cot_cli::migration_generator: Parsing file: "/tmp/TEMP_PATH/src/main.rs" + +----- stderr ----- + Creating Migration 'm_0001_custom' + Creating Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration 'm_0001_custom' diff --git a/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-5.snap b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-5.snap new file mode 100644 index 00000000..50e31ebc --- /dev/null +++ b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-5.snap @@ -0,0 +1,22 @@ +--- +source: cot-cli/tests/snapshot_testing/migration/mod.rs +description: "Verbosity level: debug" +info: + program: cot + args: + - migration + - new + - custom + - "-vvvv" +--- +success: true +exit_code: 0 +----- stdout ----- +TIMESTAMP DEBUG cot_cli::migration_generator: Parsing file: "/tmp/TEMP_PATH/src/main.rs" +TIMESTAMP TRACE cot_cli::migration_generator: Processing file: "main.rs" + +----- stderr ----- + Creating Migration 'm_0001_custom' + Creating Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration 'm_0001_custom' diff --git a/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-6.snap b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-6.snap new file mode 100644 index 00000000..7b137acf --- /dev/null +++ b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new-6.snap @@ -0,0 +1,22 @@ +--- +source: cot-cli/tests/snapshot_testing/migration/mod.rs +description: "Verbosity level: trace" +info: + program: cot + args: + - migration + - new + - custom + - "-vvvvv" +--- +success: true +exit_code: 0 +----- stdout ----- +TIMESTAMP DEBUG cot_cli::migration_generator: Parsing file: "/tmp/TEMP_PATH/src/main.rs" +TIMESTAMP TRACE cot_cli::migration_generator: Processing file: "main.rs" + +----- stderr ----- + Creating Migration 'm_0001_custom' + Creating Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration 'm_0001_custom' diff --git a/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new.snap b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new.snap new file mode 100644 index 00000000..9c125628 --- /dev/null +++ b/cot-cli/tests/snapshot_testing/migration/snapshots/cli__snapshot_testing__migration__migration_new.snap @@ -0,0 +1,20 @@ +--- +source: cot-cli/tests/snapshot_testing/migration/mod.rs +description: "Verbosity level: off" +info: + program: cot + args: + - migration + - new + - custom + - "-q" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- + Creating Migration 'm_0001_custom' + Creating Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration file '/tmp/TEMP_PATH/src/migrations/m_0001_custom.rs' + Created Migration 'm_0001_custom' diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 46ca75ee..51dc1976 100644 --- a/cot/src/db/migrations.rs +++ b/cot/src/db/migrations.rs @@ -2205,6 +2205,26 @@ mod tests { assert!(result.is_err()); } + #[cot::test] + #[cfg_attr( + miri, + ignore = "unsupported operation: can't call foreign function `sqlite3_open_v2`" + )] + async fn operation_custom_backwards_not_implemented() { + // test only on SQLite because we are using raw SQL + let test_db = TestDatabase::new_sqlite().await.unwrap(); + + #[migration_op] + async fn forwards(_ctx: MigrationContext<'_>) -> Result<()> { + Ok(()) + } + + let operation = Operation::custom(forwards).build(); + let result = operation.backwards(&test_db.database()).await; + + assert!(result.is_err()); + } + #[test] fn field_new() { let field = Field::new(Identifier::new("id"), ColumnType::Integer) From 2cca08d58b1ce28609328e6d30d0cdea0220a614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ma=C4=87kowski?= Date: Sun, 1 Feb 2026 16:19:27 +0100 Subject: [PATCH 6/6] more tests, better error messages --- cot-macros/src/migration_op.rs | 11 ++++++++--- cot-macros/tests/compile_tests.rs | 1 + .../tests/ui/attr_migration_op_return_type_invalid.rs | 7 +++++++ .../ui/attr_migration_op_return_type_invalid.stderr | 5 +++++ 4 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 cot-macros/tests/ui/attr_migration_op_return_type_invalid.rs create mode 100644 cot-macros/tests/ui/attr_migration_op_return_type_invalid.stderr diff --git a/cot-macros/src/migration_op.rs b/cot-macros/src/migration_op.rs index e92fa38a..57290ef8 100644 --- a/cot-macros/src/migration_op.rs +++ b/cot-macros/src/migration_op.rs @@ -10,15 +10,20 @@ pub(crate) fn fn_to_migration_op(mut item: ItemFn) -> syn::Result { )); } - item.sig.asyncness = None; let block = item.block; - let ret_type = item.sig.output; + let ret_type = &item.sig.output; let ret_type = match ret_type { - syn::ReturnType::Default => quote! { () }, + syn::ReturnType::Default => { + return Err(syn::Error::new_spanned( + &item.sig, + "migration operation must return `cot::Result<()>`", + )); + } syn::ReturnType::Type(_, ty) => quote! { #ty }, }; + item.sig.asyncness = None; item.sig.output = syn::parse_quote! { -> ::std::pin::Pin + Send + '_>> }; diff --git a/cot-macros/tests/compile_tests.rs b/cot-macros/tests/compile_tests.rs index f74f9de5..0ddab5d6 100644 --- a/cot-macros/tests/compile_tests.rs +++ b/cot-macros/tests/compile_tests.rs @@ -160,6 +160,7 @@ fn attr_migration_op() { let t = trybuild::TestCases::new(); t.pass("tests/ui/attr_migration_op.rs"); t.compile_fail("tests/ui/attr_migration_op_not_async.rs"); + t.compile_fail("tests/ui/attr_migration_op_return_type_invalid.rs"); } #[rustversion::attr( diff --git a/cot-macros/tests/ui/attr_migration_op_return_type_invalid.rs b/cot-macros/tests/ui/attr_migration_op_return_type_invalid.rs new file mode 100644 index 00000000..572bc016 --- /dev/null +++ b/cot-macros/tests/ui/attr_migration_op_return_type_invalid.rs @@ -0,0 +1,7 @@ +#[allow(unused)] +use cot::db::migrations::{MigrationContext, migration_op}; + +#[migration_op] +async fn my_migration(_ctx: MigrationContext<'_>) {} + +fn main() {} diff --git a/cot-macros/tests/ui/attr_migration_op_return_type_invalid.stderr b/cot-macros/tests/ui/attr_migration_op_return_type_invalid.stderr new file mode 100644 index 00000000..774fffcf --- /dev/null +++ b/cot-macros/tests/ui/attr_migration_op_return_type_invalid.stderr @@ -0,0 +1,5 @@ +error: migration operation must return `cot::Result<()>` + --> tests/ui/attr_migration_op_return_type_invalid.rs:5:1 + | +5 | async fn my_migration(_ctx: MigrationContext<'_>) {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^