diff --git a/cot-cli/src/args.rs b/cot-cli/src/args.rs index 45cac3bc..1e35ceec 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..23b34fb9 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<()> { @@ -127,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/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 da3b538d..4fc8a6a3 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(); @@ -157,7 +203,7 @@ impl MigrationGenerator { if operations.is_empty() { Ok(None) } else { - let migration_name = migration_processor.next_migration_name()?; + let migration_name = migration_processor.next_auto_migration_name()?; let dependencies = migration_processor.base_dependencies(); let migration = @@ -166,6 +212,60 @@ impl MigrationGenerator { } } + pub fn generate_custom_migration(&self, name: &str) -> anyhow::Result { + let source_files = self.get_source_files()?; + self.generate_custom_migration_from_files(name, source_files) + } + + pub fn generate_custom_migration_from_files( + &self, + name: &str, + source_files: Vec, + ) -> anyhow::Result { + 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(); + + 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, @@ -847,11 +947,32 @@ impl MigrationProcessor { migration_models.into_values().cloned().collect() } - fn next_migration_name(&self) -> anyhow::Result { + fn next_auto_migration_name(&self) -> anyhow::Result { if self.migrations.is_empty() { return Ok(format!("{MIGRATIONS_MODULE_PREFIX}0001_initial")); } + 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(1); + } + let last_migration = self.migrations.last().unwrap(); let last_migration_number = last_migration .name @@ -871,13 +992,7 @@ impl MigrationProcessor { ) })?; - 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 @@ -1441,7 +1556,7 @@ mod tests { let migrations = vec![]; let processor = MigrationProcessor::new(migrations).unwrap(); - let next_migration_name = processor.next_migration_name().unwrap(); + let next_migration_name = processor.next_auto_migration_name().unwrap(); assert_eq!(next_migration_name, "m_0001_initial"); } @@ -1473,6 +1588,42 @@ 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 create_new_migration_check_files_exist() { + let tempdir = tempfile::tempdir().unwrap(); + let cargo_toml_path = tempdir.path().join("Cargo.toml"); + std::fs::create_dir(tempdir.path().join("src")).unwrap(); + std::fs::write( + &cargo_toml_path, + "[package]\nname = \"testapp\"\nversion = \"0.1.0\"\nedition = \"2021\"", + ) + .unwrap(); + + let options = MigrationGeneratorOptions::default(); + create_new_migration(tempdir.path(), "my_custom", options).unwrap(); + + let migration_file = tempdir.path().join("src/migrations/m_0001_my_custom.rs"); + assert!(migration_file.exists()); + + let migrations_mod = tempdir.path().join("src/migrations.rs"); + assert!(migrations_mod.exists()); + let contents = std::fs::read_to_string(migrations_mod).unwrap(); + assert!(contents.contains("pub mod m_0001_my_custom;")); + } + #[test] fn toposort_operations() { let mut operations = vec![ diff --git a/cot-cli/tests/migration_generator.rs b/cot-cli/tests/migration_generator.rs index 1ee00ef7..d1b7f21d 100644 --- a/cot-cli/tests/migration_generator.rs +++ b/cot-cli/tests/migration_generator.rs @@ -181,6 +181,31 @@ fn create_model_compile_test() { content: migration_content, } = migration_opt; + compile_test(src, &migration_name, &migration_content); +} + +#[test] +#[cfg_attr( + miri, + ignore = "unsupported operation: extern static `pidfd_spawnp` is not supported by Miri" +)] +fn custom_migration_compile_test() { + let generator = test_generator(); + let src = "fn main() {}"; + let source_files = vec![SourceFile::parse(PathBuf::from("main.rs"), src).unwrap()]; + + let migration_opt = generator + .generate_custom_migration_from_files("custom", source_files) + .unwrap(); + let MigrationAsSource { + name: migration_name, + content: migration_content, + } = migration_opt; + + compile_test(src, &migration_name, &migration_content); +} + +fn compile_test(src: &str, migration_name: &str, migration_content: &str) { let source_with_migrations = format!( r" {src} diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_bash.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_bash.snap index 41261203..7ed08866 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_bash.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_bash.snap @@ -82,6 +82,9 @@ _cot() { cot__help__migration,make) cmd="cot__help__migration__make" ;; + cot__help__migration,new) + cmd="cot__help__migration__new" + ;; cot__migration,help) cmd="cot__migration__help" ;; @@ -91,6 +94,9 @@ _cot() { cot__migration,make) cmd="cot__migration__make" ;; + cot__migration,new) + cmd="cot__migration__new" + ;; cot__migration__help,help) cmd="cot__migration__help__help" ;; @@ -100,6 +106,9 @@ _cot() { cot__migration__help,make) cmd="cot__migration__help__make" ;; + cot__migration__help,new) + cmd="cot__migration__help__new" + ;; *) ;; esac @@ -297,7 +306,7 @@ _cot() { return 0 ;; cot__help__migration) - opts="list make" + opts="list make new" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -338,6 +347,20 @@ _cot() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + cot__help__migration__new) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; cot__help__new) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -353,7 +376,7 @@ _cot() { return 0 ;; cot__migration) - opts="-v -q -h --verbose --quiet --help list make help" + opts="-v -q -h --verbose --quiet --help list make new help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -367,7 +390,7 @@ _cot() { return 0 ;; cot__migration__help) - opts="list make help" + opts="list make new help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -422,6 +445,20 @@ _cot() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + cot__migration__help__new) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; cot__migration__list) opts="-v -q -h --verbose --quiet --help [PATH]" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -458,6 +495,24 @@ _cot() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + cot__migration__new) + opts="-v -q -h --app-name --verbose --quiet --help [PATH]" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --app-name) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; cot__new) opts="-v -q -h --name --use-git --cot-path --verbose --quiet --help " if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_elvish.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_elvish.snap index cd7d23fe..66217fe4 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_elvish.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_elvish.snap @@ -63,6 +63,7 @@ set edit:completion:arg-completer[cot] = {|@words| cand --help 'Print help' cand list 'List all migrations for a Cot project' cand make 'Generate migrations for a Cot project' + cand new 'Create a new empty migration' cand help 'Print this message or the help of the given subcommand(s)' } &'cot;migration;list'= { @@ -83,15 +84,27 @@ set edit:completion:arg-completer[cot] = {|@words| cand -h 'Print help' cand --help 'Print help' } + &'cot;migration;new'= { + cand --app-name 'Name of the app to use in the migration (default: crate name)' + cand -v 'Increase logging verbosity' + cand --verbose 'Increase logging verbosity' + cand -q 'Decrease logging verbosity' + cand --quiet 'Decrease logging verbosity' + cand -h 'Print help' + cand --help 'Print help' + } &'cot;migration;help'= { cand list 'List all migrations for a Cot project' cand make 'Generate migrations for a Cot project' + cand new 'Create a new empty migration' cand help 'Print this message or the help of the given subcommand(s)' } &'cot;migration;help;list'= { } &'cot;migration;help;make'= { } + &'cot;migration;help;new'= { + } &'cot;migration;help;help'= { } &'cot;cli'= { @@ -147,11 +160,14 @@ set edit:completion:arg-completer[cot] = {|@words| &'cot;help;migration'= { cand list 'List all migrations for a Cot project' cand make 'Generate migrations for a Cot project' + cand new 'Create a new empty migration' } &'cot;help;migration;list'= { } &'cot;help;migration;make'= { } + &'cot;help;migration;new'= { + } &'cot;help;cli'= { cand manpages 'Generate manpages for the Cot CLI' cand completions 'Generate completions for the Cot CLI' diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_fish.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_fish.snap index ac01602f..3ee74656 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_fish.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_fish.snap @@ -50,12 +50,13 @@ complete -c cot -n "__fish_cot_using_subcommand new" -l use-git -d 'Use the late complete -c cot -n "__fish_cot_using_subcommand new" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand new" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand new" -s h -l help -d 'Print help' -complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make help" -s v -l verbose -d 'Increase logging verbosity' -complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make help" -s q -l quiet -d 'Decrease logging verbosity' -complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make help" -s h -l help -d 'Print help' -complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make help" -f -a "list" -d 'List all migrations for a Cot project' -complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make help" -f -a "make" -d 'Generate migrations for a Cot project' -complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -s v -l verbose -d 'Increase logging verbosity' +complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -s q -l quiet -d 'Decrease logging verbosity' +complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -s h -l help -d 'Print help' +complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -f -a "list" -d 'List all migrations for a Cot project' +complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -f -a "make" -d 'Generate migrations for a Cot project' +complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -f -a "new" -d 'Create a new empty migration' +complete -c cot -n "__fish_cot_using_subcommand migration; and not __fish_seen_subcommand_from list make new help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from list" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from list" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from list" -s h -l help -d 'Print help' @@ -64,8 +65,13 @@ complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subco complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -s q -l quiet -d 'Decrease logging verbosity' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from make" -s h -l help -d 'Print help' +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -l app-name -d 'Name of the app to use in the migration (default: crate name)' -r +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -s v -l verbose -d 'Increase logging verbosity' +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -s q -l quiet -d 'Decrease logging verbosity' +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from new" -s h -l help -d 'Print help' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from help" -f -a "list" -d 'List all migrations for a Cot project' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from help" -f -a "make" -d 'Generate migrations for a Cot project' +complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from help" -f -a "new" -d 'Create a new empty migration' complete -c cot -n "__fish_cot_using_subcommand migration; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcommand_from manpages completions help" -s v -l verbose -d 'Increase logging verbosity' complete -c cot -n "__fish_cot_using_subcommand cli; and not __fish_seen_subcommand_from manpages completions help" -s q -l quiet -d 'Decrease logging verbosity' @@ -90,6 +96,7 @@ complete -c cot -n "__fish_cot_using_subcommand help; and not __fish_seen_subcom complete -c cot -n "__fish_cot_using_subcommand help; and not __fish_seen_subcommand_from new migration cli help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c cot -n "__fish_cot_using_subcommand help; and __fish_seen_subcommand_from migration" -f -a "list" -d 'List all migrations for a Cot project' complete -c cot -n "__fish_cot_using_subcommand help; and __fish_seen_subcommand_from migration" -f -a "make" -d 'Generate migrations for a Cot project' +complete -c cot -n "__fish_cot_using_subcommand help; and __fish_seen_subcommand_from migration" -f -a "new" -d 'Create a new empty migration' complete -c cot -n "__fish_cot_using_subcommand help; and __fish_seen_subcommand_from cli" -f -a "manpages" -d 'Generate manpages for the Cot CLI' complete -c cot -n "__fish_cot_using_subcommand help; and __fish_seen_subcommand_from cli" -f -a "completions" -d 'Generate completions for the Cot CLI' diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_powershell.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_powershell.snap index a7e9a07f..fc9bf656 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_powershell.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_powershell.snap @@ -68,6 +68,7 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') [CompletionResult]::new('list', 'list', [CompletionResultType]::ParameterValue, 'List all migrations for a Cot project') [CompletionResult]::new('make', 'make', [CompletionResultType]::ParameterValue, 'Generate migrations for a Cot project') + [CompletionResult]::new('new', 'new', [CompletionResultType]::ParameterValue, 'Create a new empty migration') [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') break } @@ -91,9 +92,20 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') break } + 'cot;migration;new' { + [CompletionResult]::new('--app-name', '--app-name', [CompletionResultType]::ParameterName, 'Name of the app to use in the migration (default: crate name)') + [CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Increase logging verbosity') + [CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Increase logging verbosity') + [CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') + [CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Decrease logging verbosity') + [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help') + [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help') + break + } 'cot;migration;help' { [CompletionResult]::new('list', 'list', [CompletionResultType]::ParameterValue, 'List all migrations for a Cot project') [CompletionResult]::new('make', 'make', [CompletionResultType]::ParameterValue, 'Generate migrations for a Cot project') + [CompletionResult]::new('new', 'new', [CompletionResultType]::ParameterValue, 'Create a new empty migration') [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)') break } @@ -103,6 +115,9 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { 'cot;migration;help;make' { break } + 'cot;migration;help;new' { + break + } 'cot;migration;help;help' { break } @@ -168,6 +183,7 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { 'cot;help;migration' { [CompletionResult]::new('list', 'list', [CompletionResultType]::ParameterValue, 'List all migrations for a Cot project') [CompletionResult]::new('make', 'make', [CompletionResultType]::ParameterValue, 'Generate migrations for a Cot project') + [CompletionResult]::new('new', 'new', [CompletionResultType]::ParameterValue, 'Create a new empty migration') break } 'cot;help;migration;list' { @@ -176,6 +192,9 @@ Register-ArgumentCompleter -Native -CommandName 'cot' -ScriptBlock { 'cot;help;migration;make' { break } + 'cot;help;migration;new' { + break + } 'cot;help;cli' { [CompletionResult]::new('manpages', 'manpages', [CompletionResultType]::ParameterValue, 'Generate manpages for the Cot CLI') [CompletionResult]::new('completions', 'completions', [CompletionResultType]::ParameterValue, 'Generate completions for the Cot CLI') diff --git a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_zsh.snap b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_zsh.snap index 91430383..c1603c91 100644 --- a/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_zsh.snap +++ b/cot-cli/tests/snapshot_testing/cli/snapshots/cli__snapshot_testing__cli__completions_zsh.snap @@ -100,6 +100,19 @@ _arguments "${_arguments_options[@]}" : \ '::path -- Path to the crate directory to generate migrations for \[default\: current directory\]:_files' \ && ret=0 ;; +(new) +_arguments "${_arguments_options[@]}" : \ +'--app-name=[Name of the app to use in the migration (default\: crate name)]:APP_NAME:_default' \ +'*-v[Increase logging verbosity]' \ +'*--verbose[Increase logging verbosity]' \ +'(-v --verbose)*-q[Decrease logging verbosity]' \ +'(-v --verbose)*--quiet[Decrease logging verbosity]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':name -- Name of the migration:_default' \ +'::path -- Path to the crate directory to create the migration in \[default\: current directory\]:_files' \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_cot__migration__help_commands" \ @@ -120,6 +133,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(new) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -242,6 +259,10 @@ _arguments "${_arguments_options[@]}" : \ (make) _arguments "${_arguments_options[@]}" : \ && ret=0 +;; +(new) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 ;; esac ;; @@ -375,6 +396,7 @@ _cot__help__migration_commands() { local commands; commands=( 'list:List all migrations for a Cot project' \ 'make:Generate migrations for a Cot project' \ +'new:Create a new empty migration' \ ) _describe -t commands 'cot help migration commands' commands "$@" } @@ -388,6 +410,11 @@ _cot__help__migration__make_commands() { local commands; commands=() _describe -t commands 'cot help migration make commands' commands "$@" } +(( $+functions[_cot__help__migration__new_commands] )) || +_cot__help__migration__new_commands() { + local commands; commands=() + _describe -t commands 'cot help migration new commands' commands "$@" +} (( $+functions[_cot__help__new_commands] )) || _cot__help__new_commands() { local commands; commands=() @@ -398,6 +425,7 @@ _cot__migration_commands() { local commands; commands=( 'list:List all migrations for a Cot project' \ 'make:Generate migrations for a Cot project' \ +'new:Create a new empty migration' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'cot migration commands' commands "$@" @@ -407,6 +435,7 @@ _cot__migration__help_commands() { local commands; commands=( 'list:List all migrations for a Cot project' \ 'make:Generate migrations for a Cot project' \ +'new:Create a new empty migration' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'cot migration help commands' commands "$@" @@ -426,6 +455,11 @@ _cot__migration__help__make_commands() { local commands; commands=() _describe -t commands 'cot migration help make commands' commands "$@" } +(( $+functions[_cot__migration__help__new_commands] )) || +_cot__migration__help__new_commands() { + local commands; commands=() + _describe -t commands 'cot migration help new commands' commands "$@" +} (( $+functions[_cot__migration__list_commands] )) || _cot__migration__list_commands() { local commands; commands=() @@ -436,6 +470,11 @@ _cot__migration__make_commands() { local commands; commands=() _describe -t commands 'cot migration make commands' commands "$@" } +(( $+functions[_cot__migration__new_commands] )) || +_cot__migration__new_commands() { + local commands; commands=() + _describe -t commands 'cot migration new commands' commands "$@" +} (( $+functions[_cot__new_commands] )) || _cot__new_commands() { local commands; commands=() diff --git a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration.snap b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration.snap index f0f8cc07..eeceba2a 100644 --- a/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration.snap +++ b/cot-cli/tests/snapshot_testing/help/snapshots/cli__snapshot_testing__help__help_migration.snap @@ -16,6 +16,7 @@ Usage: cot migration [OPTIONS] Commands: list List all migrations for a Cot project make Generate migrations for a Cot project + new Create a new empty migration help Print this message or the help of the given subcommand(s) Options: 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-macros/src/lib.rs b/cot-macros/src/lib.rs index 70d3e943..a1b25970 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; @@ -155,6 +157,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..57290ef8 --- /dev/null +++ b/cot-macros/src/migration_op.rs @@ -0,0 +1,40 @@ +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", + )); + } + + let block = item.block; + let ret_type = &item.sig.output; + + let ret_type = match ret_type { + 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 + '_>> + }; + + item.block = syn::parse_quote! { + { + Box::pin(async move #block) + } + }; + + Ok(quote! { + #item + }) +} diff --git a/cot-macros/tests/compile_tests.rs b/cot-macros/tests/compile_tests.rs index 053cd826..0ddab5d6 100644 --- a/cot-macros/tests/compile_tests.rs +++ b/cot-macros/tests/compile_tests.rs @@ -147,6 +147,22 @@ fn derive_into_response() { t.compile_fail("tests/ui/derive_into_response_invalid_variant_struct.rs"); } +#[rustversion::attr( + not(nightly), + ignore = "only test on nightly for consistent error messages" +)] +#[test] +#[cfg_attr( + miri, + ignore = "unsupported operation: extern static `pidfd_spawnp` is not supported by Miri" +)] +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( not(nightly), ignore = "only test on nightly for consistent error messages" diff --git a/cot-macros/tests/ui/attr_migration_op.rs b/cot-macros/tests/ui/attr_migration_op.rs new file mode 100644 index 00000000..30bb318c --- /dev/null +++ b/cot-macros/tests/ui/attr_migration_op.rs @@ -0,0 +1,9 @@ +use cot::db::Result; +use cot::db::migrations::{MigrationContext, migration_op}; + +#[migration_op] +async fn my_migration(_ctx: MigrationContext<'_>) -> Result<()> { + Ok(()) +} + +fn main() {} diff --git a/cot-macros/tests/ui/attr_migration_op_not_async.rs b/cot-macros/tests/ui/attr_migration_op_not_async.rs new file mode 100644 index 00000000..99b3d854 --- /dev/null +++ b/cot-macros/tests/ui/attr_migration_op_not_async.rs @@ -0,0 +1,11 @@ +#[allow(unused)] +use cot::db::Result; +#[allow(unused)] +use cot::db::migrations::{MigrationContext, migration_op}; + +#[migration_op] +fn my_migration(_ctx: MigrationContext<'_>) -> Result<()> { + Ok(()) +} + +fn main() {} diff --git a/cot-macros/tests/ui/attr_migration_op_not_async.stderr b/cot-macros/tests/ui/attr_migration_op_not_async.stderr new file mode 100644 index 00000000..79f6ccb8 --- /dev/null +++ b/cot-macros/tests/ui/attr_migration_op_not_async.stderr @@ -0,0 +1,5 @@ +error: migration operation must be an `async` function + --> tests/ui/attr_migration_op_not_async.rs:7:1 + | +7 | fn my_migration(_ctx: MigrationContext<'_>) -> Result<()> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 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<'_>) {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/cot/src/db/migrations.rs b/cot/src/db/migrations.rs index 0c6414f6..51dc1976 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 responsible for managing and applying database @@ -425,6 +430,27 @@ impl Operation { RemoveModelBuilder::new() } + /// Returns a builder for a custom operation. + /// + /// # Examples + /// + /// ``` + /// use cot::db::Result; + /// use cot::db::migrations::{MigrationContext, Operation, migration_op}; + /// + /// #[migration_op] + /// async fn forwards(ctx: MigrationContext<'_>) -> Result<()> { + /// // 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 @@ -502,6 +528,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::new(database); + forwards(context).await?; + } } Ok(()) } @@ -579,11 +612,50 @@ impl Operation { } database.execute_schema(query).await?; } + OperationInner::Custom { + forwards: _, + backwards, + } => { + if let Some(backwards) = backwards { + let context = MigrationContext::new(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)] +#[non_exhaustive] +pub struct MigrationContext<'a> { + /// The database connection to run the migration against. + pub db: &'a Database, +} + +impl<'a> MigrationContext<'a> { + fn new(db: &'a Database) -> Self { + Self { db } + } +} + +/// 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( + MigrationContext<'a>, + ) -> std::pin::Pin> + Send + 'a>>; + #[derive(Debug, Copy, Clone)] enum OperationInner { /// Create a new model with the given fields. @@ -607,6 +679,10 @@ enum OperationInner { table_name: Identifier, fields: &'static [Field], }, + Custom { + forwards: CustomOperationFn, + backwards: Option, + }, } /// A field in a model. @@ -1537,6 +1613,60 @@ impl RemoveModelBuilder { } } +/// A builder for a custom operation. +/// +/// # Examples +/// +/// ``` +/// use cot::db::Result; +/// use cot::db::migrations::{MigrationContext, Operation, migration_op}; +/// +/// #[migration_op] +/// async fn forwards(ctx: MigrationContext<'_>) -> Result<()> { +/// // do something +/// Ok(()) +/// } +/// +/// #[migration_op] +/// async fn backwards(ctx: MigrationContext<'_>) -> Result<()> { +/// // 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 @@ -2015,6 +2145,86 @@ mod tests { } } + #[cot::test] + #[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(); + + #[migration_op] + async fn forwards(ctx: MigrationContext<'_>) -> Result<()> { + ctx.db + .raw("CREATE TABLE custom_test (id INTEGER PRIMARY KEY)") + .await?; + Ok(()) + } + + let operation = Operation::custom(forwards).build(); + operation.forwards(&test_db.database()).await.unwrap(); + + let result = test_db.database().raw("SELECT * FROM custom_test").await; + assert!(result.is_ok()); + } + + #[cot::test] + #[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(); + + #[migration_op] + async fn forwards(_ctx: MigrationContext<'_>) -> Result<()> { + panic!("this should not be called"); + } + + #[migration_op] + async fn backwards(ctx: MigrationContext<'_>) -> Result<()> { + ctx.db.raw("DROP TABLE custom_test_back").await?; + Ok(()) + } + + test_db + .database() + .raw("CREATE TABLE custom_test_back (id INTEGER PRIMARY KEY)") + .await + .unwrap(); + + let operation = Operation::custom(forwards).backwards(backwards).build(); + operation.backwards(&test_db.database()).await.unwrap(); + + let result = test_db + .database() + .raw("SELECT * FROM custom_test_back") + .await; + 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)