diff --git a/Cargo.lock b/Cargo.lock index 7a5db0c251..53e4932cc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2761,9 +2761,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.38" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -3486,6 +3486,7 @@ dependencies = [ "sha2", "smallvec", "sqlx", + "subst", "thiserror 2.0.11", "time", "tokio", @@ -3630,6 +3631,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", + "subst", "syn 2.0.96", ] @@ -3652,6 +3654,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", + "subst", "syn 2.0.96", "tempfile", "tokio", @@ -3848,6 +3851,16 @@ dependencies = [ "syn 2.0.96", ] +[[package]] +name = "subst" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e7942675ea19db01ef8cf15a1e6443007208e6c74568bd64162da26d40160d" +dependencies = [ + "memchr", + "unicode-width 0.1.14", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index 1aef121199..2c15f55d30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,6 +155,7 @@ uuid = "1.1.2" # Common utility crates dotenvy = { version = "0.15.7", default-features = false } +subst = "0.3.7" # Runtimes [workspace.dependencies.async-std] diff --git a/README.md b/README.md index cc0ecf2e66..94a6314043 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,26 @@ opt-level = 3 1 The `dotenv` crate itself appears abandoned as of [December 2021](https://github.com/dotenv-rs/dotenv/issues/74) so we now use the `dotenvy` crate instead. The file format is the same. +## Parameter Substitution for Migrations + +You can parameterize migrations using parameters, either from the environment or passed in from the cli or to the Migrator. + +For example: + +```sql +-- enable-substitution +CREATE USER ${USER_FROM_ENV} WITH PASSWORD ${PASSWORD_FROM_ENV} +-- disable-substituion +``` + +We use the [subst](https://crates.io/crates/subst) to support substitution. sqlx supports + +- Short format: `$NAME` +- Long format: `${NAME}` +- Default values: `${NAME:Bob}` +- Recursive Substitution in Default Values: `${NAME: Bob ${OTHER_NAME: and Alice}}` + + ## Safety This crate uses `#![forbid(unsafe_code)]` to ensure everything is implemented in 100% Safe Rust. diff --git a/sqlx-cli/README.md b/sqlx-cli/README.md index b20461b8fd..eabf68c101 100644 --- a/sqlx-cli/README.md +++ b/sqlx-cli/README.md @@ -65,6 +65,18 @@ any scripts that are still pending. --- +Users can also provide parameters through environment variables or pass them in manually. + +```bash +sqlx migrate run --params-from-env +``` + +```bash +sqlx migrate run --params key:value,key1,value1 +``` + +--- + Users can provide the directory for the migration scripts to `sqlx migrate` subcommands with the `--source` flag. ```bash @@ -105,6 +117,16 @@ Creating migrations/20211001154420_.up.sql Creating migrations/20211001154420_.down.sql ``` +Users can also provide parameters through environment variables or pass them in manually, just as they did with the run command. + +```bash +sqlx migrate revert --params-from-env +``` + +```bash +sqlx migrate revert --params key:value,key1,value1 +``` + ### Enable building in "offline mode" with `query!()` There are 2 steps to building with "offline mode": diff --git a/sqlx-cli/src/database.rs b/sqlx-cli/src/database.rs index 7a9bc6bf2f..791a6b0164 100644 --- a/sqlx-cli/src/database.rs +++ b/sqlx-cli/src/database.rs @@ -57,7 +57,16 @@ pub async fn reset( pub async fn setup(migration_source: &str, connect_opts: &ConnectOpts) -> anyhow::Result<()> { create(connect_opts).await?; - migrate::run(migration_source, connect_opts, false, false, None).await + migrate::run( + migration_source, + connect_opts, + false, + false, + None, + false, + Vec::with_capacity(0), + ) + .await } async fn ask_to_continue_drop(db_url: String) -> bool { diff --git a/sqlx-cli/src/lib.rs b/sqlx-cli/src/lib.rs index cb31205b4f..7906796eb6 100644 --- a/sqlx-cli/src/lib.rs +++ b/sqlx-cli/src/lib.rs @@ -66,6 +66,8 @@ async fn do_run(opt: Opt) -> Result<()> { ignore_missing, connect_opts, target_version, + params_from_env, + params, } => { migrate::run( &source, @@ -73,6 +75,8 @@ async fn do_run(opt: Opt) -> Result<()> { dry_run, *ignore_missing, target_version, + params_from_env, + params, ) .await? } @@ -82,6 +86,8 @@ async fn do_run(opt: Opt) -> Result<()> { ignore_missing, connect_opts, target_version, + params_from_env, + params, } => { migrate::revert( &source, @@ -89,6 +95,8 @@ async fn do_run(opt: Opt) -> Result<()> { dry_run, *ignore_missing, target_version, + params_from_env, + params, ) .await? } diff --git a/sqlx-cli/src/migrate.rs b/sqlx-cli/src/migrate.rs index e00f6de651..50d74f1464 100644 --- a/sqlx-cli/src/migrate.rs +++ b/sqlx-cli/src/migrate.rs @@ -277,6 +277,8 @@ pub async fn run( dry_run: bool, ignore_missing: bool, target_version: Option, + params_from_env: bool, + parameters: Vec<(String, String)>, ) -> anyhow::Result<()> { let migrator = Migrator::new(Path::new(migration_source)).await?; if let Some(target_version) = target_version { @@ -313,6 +315,14 @@ pub async fn run( .map(|m| (m.version, m)) .collect(); + let env_params: HashMap<_, _> = if params_from_env { + std::env::vars().collect() + } else { + HashMap::with_capacity(0) + }; + + let params: HashMap<_, _> = parameters.into_iter().collect(); + for migration in migrator.iter() { if migration.migration_type.is_down_migration() { // Skipping down migrations @@ -331,6 +341,11 @@ pub async fn run( let elapsed = if dry_run || skip { Duration::new(0, 0) + } else if params_from_env { + conn.apply(&migration.process_parameters(&env_params)?) + .await? + } else if !params.is_empty() { + conn.apply(&migration.process_parameters(¶ms)?).await? } else { conn.apply(migration).await? }; @@ -370,6 +385,8 @@ pub async fn revert( dry_run: bool, ignore_missing: bool, target_version: Option, + params_from_env: bool, + parameters: Vec<(String, String)>, ) -> anyhow::Result<()> { let migrator = Migrator::new(Path::new(migration_source)).await?; if let Some(target_version) = target_version { @@ -407,6 +424,15 @@ pub async fn revert( .collect(); let mut is_applied = false; + + let env_params: HashMap<_, _> = if params_from_env { + std::env::vars().collect() + } else { + HashMap::with_capacity(0) + }; + + let params: HashMap<_, _> = parameters.into_iter().collect(); + for migration in migrator.iter().rev() { if !migration.migration_type.is_down_migration() { // Skipping non down migration @@ -420,6 +446,11 @@ pub async fn revert( let elapsed = if dry_run || skip { Duration::new(0, 0) + } else if params_from_env { + conn.revert(&migration.process_parameters(&env_params)?) + .await? + } else if !params.is_empty() { + conn.revert(&migration.process_parameters(¶ms)?).await? } else { conn.revert(migration).await? }; diff --git a/sqlx-cli/src/opt.rs b/sqlx-cli/src/opt.rs index 133ba084f2..21c8667bd1 100644 --- a/sqlx-cli/src/opt.rs +++ b/sqlx-cli/src/opt.rs @@ -1,3 +1,4 @@ +use std::error::Error; use std::ops::{Deref, Not}; use clap::{ @@ -190,6 +191,15 @@ pub enum MigrateCommand { /// pending migrations. If already at the target version, then no-op. #[clap(long)] target_version: Option, + + #[clap(long)] + /// Template parameters for substitution in migrations from environment variables + params_from_env: bool, + + #[clap(long, short, value_parser = parse_key_val::, num_args = 1, value_delimiter=',')] + /// Provide template parameters for substitution in migrations, e.g. --params + /// key:value,key2:value2 + params: Vec<(String, String)>, }, /// Revert the latest migration with a down file. @@ -212,6 +222,15 @@ pub enum MigrateCommand { /// at the target version, then no-op. #[clap(long)] target_version: Option, + + #[clap(long)] + /// Template parameters for substitution in migrations from environment variables + params_from_env: bool, + + #[clap(long, short, value_parser = parse_key_val::, num_args = 1, value_delimiter=',')] + /// Provide template parameters for substitution in migrations, e.g. --params + /// key:value,key2:value2 + params: Vec<(String, String)>, }, /// List all available migrations. @@ -334,3 +353,17 @@ impl Not for IgnoreMissing { !self.ignore_missing } } + +/// Parse a single key-value pair +fn parse_key_val(s: &str) -> Result<(T, U), Box> +where + T: std::str::FromStr, + T::Err: Error + Send + Sync + 'static, + U: std::str::FromStr, + U::Err: Error + Send + Sync + 'static, +{ + let pos = s + .find('=') + .ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?; + Ok((s[..pos].parse()?, s[pos + 1..].parse()?)) +} diff --git a/sqlx-core/Cargo.toml b/sqlx-core/Cargo.toml index f6017a9fee..b1f1fd8dad 100644 --- a/sqlx-core/Cargo.toml +++ b/sqlx-core/Cargo.toml @@ -83,6 +83,7 @@ hashlink = "0.10.0" indexmap = "2.0" event-listener = "5.2.0" hashbrown = "0.15.0" +subst = { workspace = true } [dev-dependencies] sqlx = { workspace = true, features = ["postgres", "sqlite", "mysql", "migrate", "macros", "time", "uuid"] } diff --git a/sqlx-core/src/migrate/error.rs b/sqlx-core/src/migrate/error.rs index 608d55b18d..829f2bd2ed 100644 --- a/sqlx-core/src/migrate/error.rs +++ b/sqlx-core/src/migrate/error.rs @@ -39,4 +39,10 @@ pub enum MigrateError { "migration {0} is partially applied; fix and remove row from `_sqlx_migrations` table" )] Dirty(i64), + + #[error("migration {0} was missing a parameter '{1}' at line {2}, column {3}")] + MissingParameter(String, String, usize, usize), + + #[error("Invalid parameter syntax {0}")] + InvalidParameterSyntax(String), } diff --git a/sqlx-core/src/migrate/migration.rs b/sqlx-core/src/migrate/migration.rs index 9bd7f569d8..b0173a153f 100644 --- a/sqlx-core/src/migrate/migration.rs +++ b/sqlx-core/src/migrate/migration.rs @@ -1,8 +1,12 @@ use std::borrow::Cow; +use std::collections::HashMap; use sha2::{Digest, Sha384}; -use super::MigrationType; +use super::{MigrateError, MigrationType}; + +const ENABLE_SUBSTITUTION: &str = "-- enable-substitution"; +const DISABLE_SUBSTITUTION: &str = "-- disable-substitution"; #[derive(Debug, Clone)] pub struct Migration { @@ -23,7 +27,6 @@ impl Migration { no_tx: bool, ) -> Self { let checksum = Cow::Owned(Vec::from(Sha384::digest(sql.as_bytes()).as_slice())); - Migration { version, description, @@ -33,6 +36,76 @@ impl Migration { no_tx, } } + + fn name(&self) -> String { + let description = self.description.replace(' ', "_"); + match self.migration_type { + MigrationType::Simple => { + format!("{}_{}", self.version, description) + } + MigrationType::ReversibleUp => { + format!("{}_{}.{}", self.version, description, "up") + } + MigrationType::ReversibleDown => { + format!("{}_{}.{}", self.version, description, "down") + } + } + } + + pub fn process_parameters( + &self, + params: &HashMap, + ) -> Result { + let Migration { + version, + description, + migration_type, + sql, + checksum, + no_tx, + } = self; + + let mut new_sql = String::with_capacity(sql.len()); + let mut substitution_enabled = false; + + for (i, line) in sql.lines().enumerate() { + if i != 0 { + new_sql.push('\n') + } + let trimmed_line = line.trim(); + if trimmed_line == ENABLE_SUBSTITUTION { + substitution_enabled = true; + new_sql.push_str(line); + continue; + } else if trimmed_line == DISABLE_SUBSTITUTION { + new_sql.push_str(line); + substitution_enabled = false; + continue; + } + + if substitution_enabled { + let substituted_line = subst::substitute(line, params).map_err(|e| match e { + subst::Error::NoSuchVariable(subst::error::NoSuchVariable { + position, + name, + }) => MigrateError::MissingParameter(self.name(), name, i + 1, position), + _ => MigrateError::InvalidParameterSyntax(e.to_string()), + })?; + new_sql.push_str(&substituted_line); + } else { + new_sql.push_str(line); + } + } + + Ok(Migration { + version: *version, + description: description.clone(), + migration_type: *migration_type, + sql: Cow::Owned(new_sql), + checksum: checksum.clone(), + no_tx: *no_tx, + }) + } } #[derive(Debug, Clone)] @@ -40,3 +113,133 @@ pub struct AppliedMigration { pub version: i64, pub checksum: Cow<'static, [u8]>, } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_migration_process_parameters_with_substitution() -> Result<(), MigrateError> { + const CREATE_USER: &str = r#" + -- enable-substitution + CREATE USER '${substitution_test_user}'; + -- disable-substitution + CREATE TABLE foo ( + id BIG SERIAL PRIMARY KEY + foo TEXT + ); + -- enable-substitution + DROP USER '${substitution_test_user}'; + -- disable-substitution + "#; + const EXPECTED_RESULT: &str = r#" + -- enable-substitution + CREATE USER 'my_user'; + -- disable-substitution + CREATE TABLE foo ( + id BIG SERIAL PRIMARY KEY + foo TEXT + ); + -- enable-substitution + DROP USER 'my_user'; + -- disable-substitution + "#; + + let migration = Migration::new( + 1, + Cow::Owned("test a simple parameter substitution".to_string()), + crate::migrate::MigrationType::Simple, + Cow::Owned(CREATE_USER.to_string()), + true, + ); + let result = migration.process_parameters(&HashMap::from([( + String::from("substitution_test_user"), + String::from("my_user"), + )]))?; + assert_eq!(result.sql, EXPECTED_RESULT); + Ok(()) + } + + #[test] + fn test_migration_process_parameters_no_substitution() -> Result<(), MigrateError> { + const CREATE_TABLE: &str = r#" + CREATE TABLE foo ( + id BIG SERIAL PRIMARY KEY + foo TEXT + ); + "#; + let migration = Migration::new( + 1, + std::borrow::Cow::Owned("test a simple parameter substitution".to_string()), + crate::migrate::MigrationType::Simple, + Cow::Owned(CREATE_TABLE.to_string()), + true, + ); + let result = migration.process_parameters(&HashMap::from([( + String::from("substitution_test_user"), + String::from("my_user"), + )]))?; + assert_eq!(result.sql, CREATE_TABLE); + Ok(()) + } + + #[test] + fn test_migration_process_parameters_missing_key() -> Result<(), MigrateError> { + const CREATE_TABLE: &str = r#" + -- enable-substitution + CREATE TABLE foo ( + id BIG SERIAL PRIMARY KEY + foo TEXT, + field ${TEST_MISSING_KEY} + ); + -- disable-substitution + + "#; + let migration = Migration::new( + 1, + Cow::Owned("test a simple parameter substitution".to_string()), + crate::migrate::MigrationType::Simple, + Cow::Owned(CREATE_TABLE.to_string()), + true, + ); + let Err(MigrateError::MissingParameter(..)) = + migration.process_parameters(&HashMap::with_capacity(0)) + else { + panic!("Missing env var not caught in process parameters missing env var") + }; + Ok(()) + } + + #[test] + fn test_migration_process_parameters_missing_key_with_default_value() -> Result<(), MigrateError> + { + const CREATE_TABLE: &str = r#" + -- enable-substitution + CREATE TABLE foo ( + id BIG SERIAL PRIMARY KEY + foo TEXT, + field ${TEST_MISSING_KEY:TEXT} + ); + -- disable-substitution + "#; + const EXPECTED_CREATE_TABLE: &str = r#" + -- enable-substitution + CREATE TABLE foo ( + id BIG SERIAL PRIMARY KEY + foo TEXT, + field TEXT + ); + -- disable-substitution + "#; + let migration = Migration::new( + 1, + Cow::Owned("test a simple parameter substitution".to_string()), + crate::migrate::MigrationType::Simple, + Cow::Owned(CREATE_TABLE.to_string()), + true, + ); + let result = migration.process_parameters(&HashMap::with_capacity(0))?; + assert_eq!(result.sql, EXPECTED_CREATE_TABLE); + Ok(()) + } +} diff --git a/sqlx-core/src/migrate/migrator.rs b/sqlx-core/src/migrate/migrator.rs index 3209ba6e45..6100508350 100644 --- a/sqlx-core/src/migrate/migrator.rs +++ b/sqlx-core/src/migrate/migrator.rs @@ -23,6 +23,10 @@ pub struct Migrator { pub locking: bool, #[doc(hidden)] pub no_tx: bool, + #[doc(hidden)] + pub template_params: Option>, + #[doc(hidden)] + pub template_parameters_from_env: bool, } fn validate_applied_migrations( @@ -51,8 +55,33 @@ impl Migrator { ignore_missing: false, no_tx: false, locking: true, + template_params: None, + template_parameters_from_env: false, }; + /// Set or update template parameters for migration placeholders. + /// + /// # Examples + /// + /// ```rust + /// # use sqlx_core::migrate::Migrator; + /// let mut migrator = Migrator::DEFAULT; + /// migrator.set_template_parameters(vec![("key", "value"), ("name", "test")]); + /// ``` + pub fn set_template_parameters(&mut self, params: I) -> &Self + where + I: IntoIterator, + K: Into, + V: Into, + { + let map: HashMap = params + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + self.template_params = Some(map); + self + } + /// Creates a new instance with the given source. /// /// # Examples @@ -87,6 +116,15 @@ impl Migrator { self } + /// Specify whether template parameters for migrations should be read from the environment + pub fn set_template_parameters_from_env( + &mut self, + template_paramaters_from_env: bool, + ) -> &Self { + self.template_parameters_from_env = template_paramaters_from_env; + self + } + /// Specify whether or not to lock the database during migration. Defaults to `true`. /// /// ### Warning @@ -165,6 +203,12 @@ impl Migrator { .map(|m| (m.version, m)) .collect(); + let env_params = if self.template_parameters_from_env { + Some(std::env::vars().collect()) + } else { + None + }; + for migration in self.iter() { if migration.migration_type.is_down_migration() { continue; @@ -177,7 +221,14 @@ impl Migrator { } } None => { - conn.apply(migration).await?; + if self.template_parameters_from_env { + conn.apply(&migration.process_parameters(env_params.as_ref().unwrap())?) + .await?; + } else if let Some(params) = self.template_params.as_ref() { + conn.apply(&migration.process_parameters(params)?).await?; + } else { + conn.apply(migration).await?; + } } } } @@ -237,6 +288,12 @@ impl Migrator { .map(|m| (m.version, m)) .collect(); + let env_params = if self.template_parameters_from_env { + Some(std::env::vars().collect()) + } else { + None + }; + for migration in self .iter() .rev() @@ -244,7 +301,14 @@ impl Migrator { .filter(|m| applied_migrations.contains_key(&m.version)) .filter(|m| m.version > target) { - conn.revert(migration).await?; + if self.template_parameters_from_env { + conn.revert(&migration.process_parameters(env_params.as_ref().unwrap())?) + .await?; + } else if let Some(params) = self.template_params.as_ref() { + conn.revert(&migration.process_parameters(params)?).await?; + } else { + conn.revert(migration).await?; + } } // unlock the migrator to allow other migrators to run diff --git a/sqlx-macros-core/Cargo.toml b/sqlx-macros-core/Cargo.toml index 07d9d78862..41ad2748e0 100644 --- a/sqlx-macros-core/Cargo.toml +++ b/sqlx-macros-core/Cargo.toml @@ -64,6 +64,7 @@ proc-macro2 = { version = "1.0.79", default-features = false } serde = { version = "1.0.132", features = ["derive"] } serde_json = { version = "1.0.73" } sha2 = { version = "0.10.0" } +subst = { workspace = true } syn = { version = "2.0.52", default-features = false, features = ["full", "derive", "parsing", "printing", "clone-impls"] } quote = { version = "1.0.26", default-features = false } url = { version = "2.2.2" } diff --git a/sqlx-macros-core/src/migrate.rs b/sqlx-macros-core/src/migrate.rs index c9cf5b8eb1..38cd481cf2 100644 --- a/sqlx-macros-core/src/migrate.rs +++ b/sqlx-macros-core/src/migrate.rs @@ -1,6 +1,7 @@ #[cfg(any(sqlx_macros_unstable, procmacro2_semver_exempt))] extern crate proc_macro; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use proc_macro2::TokenStream; @@ -81,20 +82,26 @@ impl ToTokens for QuoteMigration { } } -pub fn expand_migrator_from_lit_dir(dir: LitStr) -> crate::Result { - expand_migrator_from_dir(&dir.value(), dir.span()) +pub fn expand_migrator_from_lit_dir( + dir: LitStr, + parameters: Option>, +) -> crate::Result { + expand_migrator_from_dir(&dir.value(), dir.span(), parameters) } pub(crate) fn expand_migrator_from_dir( dir: &str, err_span: proc_macro2::Span, + parameters: Option>, ) -> crate::Result { let path = crate::common::resolve_path(dir, err_span)?; - - expand_migrator(&path) + expand_migrator(&path, parameters) } -pub(crate) fn expand_migrator(path: &Path) -> crate::Result { +pub(crate) fn expand_migrator( + path: &Path, + parameters: Option>, +) -> crate::Result { let path = path.canonicalize().map_err(|e| { format!( "error canonicalizing migration directory {}: {e}", @@ -105,7 +112,14 @@ pub(crate) fn expand_migrator(path: &Path) -> crate::Result { // Use the same code path to resolve migrations at compile time and runtime. let migrations = sqlx_core::migrate::resolve_blocking(&path)? .into_iter() - .map(|(migration, path)| QuoteMigration { migration, path }); + .map(|(migration, path)| { + if let Some(ref params) = parameters { + if let Err(e) = migration.process_parameters(params) { + panic!("Error processing parameters: {e}"); + } + } + QuoteMigration { migration, path } + }); #[cfg(any(sqlx_macros_unstable, procmacro2_semver_exempt))] { diff --git a/sqlx-macros-core/src/test_attr.rs b/sqlx-macros-core/src/test_attr.rs index d7c6eb0486..ee70d17fa5 100644 --- a/sqlx-macros-core/src/test_attr.rs +++ b/sqlx-macros-core/src/test_attr.rs @@ -143,7 +143,7 @@ fn expand_advanced(args: AttributeArgs, input: syn::ItemFn) -> crate::Result { - let migrator = crate::migrate::expand_migrator_from_lit_dir(path)?; + let migrator = crate::migrate::expand_migrator_from_lit_dir(path, None)?; quote! { args.migrator(&#migrator); } } MigrationsOpt::InferredPath if !inputs.is_empty() => { @@ -151,7 +151,7 @@ fn expand_advanced(args: AttributeArgs, input: syn::ItemFn) -> crate::Result TokenStream { #[cfg(feature = "migrate")] #[proc_macro] pub fn migrate(input: TokenStream) -> TokenStream { - use syn::LitStr; + use std::collections::HashMap; + use syn::{parse_macro_input, Expr, ExprArray, ExprLit, ExprPath, ExprTuple, Lit, LitStr}; - let input = syn::parse_macro_input!(input as LitStr); - match migrate::expand_migrator_from_lit_dir(input) { + // Extract directory path, handling both direct literals and grouped literals + fn extract_dir(expr: Option) -> LitStr { + match expr { + Some(Expr::Lit(ExprLit { + lit: Lit::Str(literal), + .. + })) => return literal, + Some(Expr::Group(group)) => { + if let Expr::Lit(ExprLit { + lit: Lit::Str(literal), + .. + }) = *group.expr + { + return literal; + } + } + _ => {} + } + panic!("Expected a string literal for the directory path."); + } + + // Extract a `String` value from an expression (either a string literal or a variable) + fn extract_value(expr: Expr, location: &str) -> String { + match expr { + Expr::Lit(ExprLit { + lit: Lit::Str(lit_str), + .. + }) => lit_str.value(), + Expr::Path(ExprPath { path, .. }) => path.segments.last().unwrap().ident.to_string(), + _ => panic!("Expected a string literal or a variable in {location}"), + } + } + + // Parse substitutions, expecting an array of tuples (String, Expr) + fn parse_substitutions(expr: Option) -> Option> { + let Expr::Group(group) = expr? else { + return None; + }; + let Expr::Array(ExprArray { elems, .. }) = *group.expr else { + panic!("Expected an array of tuples (String, Expr)."); + }; + + let mut map = HashMap::new(); + for elem in elems { + let Expr::Tuple(ExprTuple { + elems: tuple_elems, .. + }) = elem + else { + panic!("Expected a tuple (String, Expr). Got {:#?}", elem); + }; + + let mut tuple_elems = tuple_elems.into_iter(); + + let key = extract_value(tuple_elems.next().expect("Missing key in tuple."), "key"); + let value = extract_value( + tuple_elems.next().expect("Missing value in tuple."), + "value", + ); + map.insert(key, value); + } + Some(map) + } + + // Parse input and extract directory and optional parameters + let exp = parse_macro_input!(input as syn::Expr); + let (dir, parameters) = match exp { + Expr::Tuple(ExprTuple { elems, .. }) => { + let mut elems = elems.into_iter(); + (extract_dir(elems.next()), elems.next()) + } + Expr::Lit(ExprLit { + lit: Lit::Str(lit_str), + .. + }) => { + (lit_str, None) + } + Expr::Group(group) => { + if let Expr::Lit(ExprLit { + lit: Lit::Str(lit_str), + .. + }) = *group.expr + { + (lit_str, None) + } else { + panic!("Expected a tuple with directory path and optional parameters, or a string literal for the directory path."); + } + }, + _ => panic!( + "Expected a tuple with directory path and optional parameters, or a string literal for the directory path." + ), + }; + + // Parse substitutions and pass to migration expander + let substitutions = parse_substitutions(parameters); + match migrate::expand_migrator_from_lit_dir(dir, substitutions) { Ok(ts) => ts.into(), Err(e) => { if let Some(parse_err) = e.downcast_ref::() { diff --git a/src/macros/mod.rs b/src/macros/mod.rs index 7f8ff747f9..aecc60c957 100644 --- a/src/macros/mod.rs +++ b/src/macros/mod.rs @@ -809,11 +809,16 @@ macro_rules! query_file_scalar_unchecked ( #[cfg(feature = "migrate")] #[macro_export] macro_rules! migrate { - ($dir:literal) => {{ - $crate::sqlx_macros::migrate!($dir) - }}; - - () => {{ + ($directory:literal, parameters = $parameters:expr) => { + $crate::sqlx_macros::migrate!(($directory, $parameters)); + }; + (parameters = $parameters:expr) => { + $crate::sqlx_macros::migrate!(("./migrations", $parameters)); + }; + ($dir:literal) => { + $crate::sqlx_macros::migrate!($dir); + }; + () => { $crate::sqlx_macros::migrate!("./migrations") - }}; + }; } diff --git a/src/macros/test.md b/src/macros/test.md index 30de8070f6..1a3e1c581d 100644 --- a/src/macros/test.md +++ b/src/macros/test.md @@ -133,7 +133,9 @@ use sqlx::{PgPool, Row}; # migrations: Cow::Borrowed(&[]), # ignore_missing: false, # locking: true, -# no_tx: false +# no_tx: false, +# template_params: None, +# template_parameters_from_env: false, # }; # } diff --git a/tests/ui-tests.rs b/tests/ui-tests.rs index 4a5ca240e1..fbdd14aa6f 100644 --- a/tests/ui-tests.rs +++ b/tests/ui-tests.rs @@ -44,3 +44,12 @@ fn ui_tests() { t.compile_fail("tests/ui/*.rs"); } + +#[test] +fn ui_migrate_tests() { + if cfg!(feature = "migrate") { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/migrate/invalid_key.rs"); + t.compile_fail("tests/ui/migrate/missing_parameter.rs"); + } +} diff --git a/tests/ui/migrate/invalid_key.rs b/tests/ui/migrate/invalid_key.rs new file mode 100644 index 0000000000..3edfaa8aa0 --- /dev/null +++ b/tests/ui/migrate/invalid_key.rs @@ -0,0 +1,4 @@ +fn main() { + //Fails due to invalid key + sqlx::migrate!("foo", parameters = [(123, "foo")]); +} diff --git a/tests/ui/migrate/invalid_key.stderr b/tests/ui/migrate/invalid_key.stderr new file mode 100644 index 0000000000..f92b52d11b --- /dev/null +++ b/tests/ui/migrate/invalid_key.stderr @@ -0,0 +1,8 @@ +error: proc macro panicked + --> tests/ui/migrate/invalid_key.rs:3:5 + | +3 | sqlx::migrate!("foo", parameters = [(123, "foo")]); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: message: Expected a string literal or a variable in key + = note: this error originates in the macro `sqlx::migrate` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/migrate/migrations/20250423195520_create_users.down.sql b/tests/ui/migrate/migrations/20250423195520_create_users.down.sql new file mode 100644 index 0000000000..d2f607c5b8 --- /dev/null +++ b/tests/ui/migrate/migrations/20250423195520_create_users.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/tests/ui/migrate/migrations/20250423195520_create_users.up.sql b/tests/ui/migrate/migrations/20250423195520_create_users.up.sql new file mode 100644 index 0000000000..fc82f2cb9c --- /dev/null +++ b/tests/ui/migrate/migrations/20250423195520_create_users.up.sql @@ -0,0 +1,4 @@ +-- Add up migration script here +-- enable-substitution +CREATE USER ${my_user} WITH ENCRYPTED PASSWORD '${my_password}' INHERIT; +-- disable-substitution diff --git a/tests/ui/migrate/missing_parameter.rs b/tests/ui/migrate/missing_parameter.rs new file mode 100644 index 0000000000..a440b829ce --- /dev/null +++ b/tests/ui/migrate/missing_parameter.rs @@ -0,0 +1,5 @@ +fn main() { + //Fails due to missing migration parameter + let _shaggy = "shaggy"; + sqlx::migrate!("../../../../tests/ui/migrate/migrations", parameters = [("my_user", "scooby"), ("fooby", _shaggy)]); +} diff --git a/tests/ui/migrate/missing_parameter.stderr b/tests/ui/migrate/missing_parameter.stderr new file mode 100644 index 0000000000..0717bc64c4 --- /dev/null +++ b/tests/ui/migrate/missing_parameter.stderr @@ -0,0 +1,8 @@ +error: proc macro panicked + --> tests/ui/migrate/missing_parameter.rs:4:5 + | +4 | sqlx::migrate!("../../../../tests/ui/migrate/migrations", parameters = [("my_user", "scooby"), ("fooby", _shaggy)]); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: message: Error processing parameters: migration 20250423195520_create_users.up was missing a parameter 'my_password' at line 3, column 50 + = note: this error originates in the macro `sqlx::migrate` (in Nightly builds, run with -Z macro-backtrace for more info)