Skip to content

Add migration parameters #3805

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,23 @@ opt-level = 3
<sup>1</sup> 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.

## Parameterizing migrations

You can parameterize migrations using environment variables and a comment annotation. For example:

```sql
-- +sqlx envsub on
CREATE USER ${USER_FROM_ENV} WITH PASSWORD ${PASSWORD_FROM_ENV}
-- +sqlx envsub off
```

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.
Expand Down
4 changes: 2 additions & 2 deletions sqlx-cli/src/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ pub async fn run(
let elapsed = if dry_run || skip {
Duration::new(0, 0)
} else {
conn.apply(migration).await?
conn.apply(&migration.process_parameters()?).await?
};
let text = if skip {
"Skipped"
Expand Down Expand Up @@ -421,7 +421,7 @@ pub async fn revert(
let elapsed = if dry_run || skip {
Duration::new(0, 0)
} else {
conn.revert(migration).await?
conn.revert(&migration.process_parameters()?).await?
};
let text = if skip {
"Skipped"
Expand Down
1 change: 1 addition & 0 deletions sqlx-cli/src/migration.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use anyhow::{bail, Context};
use regex::Regex;
use console::style;
use std::fs::{self, File};
use std::io::{Read, Write};
Expand Down
3 changes: 2 additions & 1 deletion sqlx-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ log = { version = "0.4.18", default-features = false }
memchr = { version = "2.4.1", default-features = false }
once_cell = "1.9.0"
percent-encoding = "2.1.0"
regex = { version = "1.5.5", optional = true }
regex = { version = "1.5.5"}
serde = { version = "1.0.132", features = ["derive", "rc"], optional = true }
serde_json = { version = "1.0.73", features = ["raw_value"], optional = true }
sha2 = { version = "0.10.0", default-features = false, optional = true }
Expand All @@ -82,6 +82,7 @@ hashlink = "0.10.0"
indexmap = "2.0"
event-listener = "5.2.0"
hashbrown = "0.15.0"
subst = "0.3.7"

[dev-dependencies]
sqlx = { workspace = true, features = ["postgres", "sqlite", "mysql", "migrate", "macros", "time", "uuid"] }
Expand Down
3 changes: 3 additions & 0 deletions sqlx-core/src/migrate/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ 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")]
MissingParameter(String),
}
133 changes: 131 additions & 2 deletions sqlx-core/src/migrate/migration.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
use std::borrow::Cow;
use std::sync::OnceLock;

use regex::Regex;
use sha2::{Digest, Sha384};

use super::MigrationType;
use super::{MigrateError, MigrationType};

static ENV_SUB_REGEX: OnceLock<Regex> = OnceLock::new();

#[derive(Debug, Clone)]
pub struct Migration {
Expand All @@ -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,
Expand All @@ -33,10 +36,136 @@ impl Migration {
no_tx,
}
}

pub fn process_parameters(&self) -> Result<Self, MigrateError> {
let Migration {
version,
description,
migration_type,
sql,
checksum,
no_tx,
} = self;
let re = ENV_SUB_REGEX.get_or_init(|| {
Regex::new(r"--\s?+\+sqlx envsub on((.|\n|\r)*?)--\s?+\+sqlx envsub off").unwrap()
});
let mut new_sql = String::with_capacity(sql.len());
let mut last_match = 0;
//Use re.captures_iter over replace_all for fallibility
for cap in re.captures_iter(sql) {
let m = cap.get(1).unwrap();
new_sql.push_str(&sql[last_match..m.start()]);
let replacement = subst::substitute(&cap[1], &subst::Env)
.map_err(|e| MigrateError::MissingParameter(e.to_string()))?;
new_sql.push_str(&replacement);
last_match = m.end();
}
new_sql.push_str(&sql[last_match..]);
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)]
pub struct AppliedMigration {
pub version: i64,
pub checksum: Cow<'static, [u8]>,
}

#[cfg(test)]
mod test {
use super::*;

use std::env;

#[test]
fn test_migration_process_parameters_with_envsub() -> Result<(), MigrateError> {
const CREATE_USER: &str = r#"
-- +sqlx envsub on
CREATE USER '${envsub_test_user}';
-- +sqlx envsub off
CREATE TABLE foo (
id BIG SERIAL PRIMARY KEY
foo TEXT
);
-- +sqlx envsub on
DROP USER '${envsub_test_user}';
-- +sqlx envsub off
"#;
const EXPECTED_RESULT: &str = r#"
-- +sqlx envsub on
CREATE USER 'my_user';
-- +sqlx envsub off
CREATE TABLE foo (
id BIG SERIAL PRIMARY KEY
foo TEXT
);
-- +sqlx envsub on
DROP USER 'my_user';
-- +sqlx envsub off
"#;

env::set_var("envsub_test_user", "my_user");
let migration = Migration::new(
1,
Cow::Owned("test a simple envsub".to_string()),
crate::migrate::MigrationType::Simple,
Cow::Owned(CREATE_USER.to_string()),
true,
);
let result = migration.process_parameters()?;
assert_eq!(result.sql, EXPECTED_RESULT);
Ok(())
}

#[test]
fn test_migration_process_parameters_no_envsub() -> 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 envsub".to_string()),
crate::migrate::MigrationType::Simple,
Cow::Owned(CREATE_TABLE.to_string()),
true,
);
let result = migration.process_parameters()?;
assert_eq!(result.sql, CREATE_TABLE);
Ok(())
}

#[test]
fn test_migration_process_parameters_missing_env_var() -> Result<(), MigrateError> {
const CREATE_TABLE: &str = r#"
-- +sqlx envsub on
CREATE TABLE foo (
id BIG SERIAL PRIMARY KEY
foo TEXT,
field ${TEST_MISSING_ENV_VAR_FIELD}
);
-- +sqlx envsub off
"#;
env::set_var("envsub_test_user", "my_user");
let migration = Migration::new(
1,
Cow::Owned("test a simple envsub".to_string()),
crate::migrate::MigrationType::Simple,
Cow::Owned(CREATE_TABLE.to_string()),
true,
);
let Err(MigrateError::MissingParameter(_)) = migration.process_parameters() else {
panic!("Missing env var not caught in process parameters missing env var")
};
Ok(())
}
}
2 changes: 1 addition & 1 deletion sqlx-core/src/migrate/migrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ impl Migrator {
}
}
None => {
conn.apply(migration).await?;
conn.apply(&migration.process_parameters()?).await?;
}
}
}
Expand Down
Loading