Skip to content

Commit b67a83b

Browse files
committed
feat: add support for ignored_chars config to sqlx_core::migrate
1 parent 3934629 commit b67a83b

File tree

4 files changed

+239
-17
lines changed

4 files changed

+239
-17
lines changed

sqlx-core/src/migrate/migration.rs

+56-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
use std::borrow::Cow;
2-
31
use sha2::{Digest, Sha384};
2+
use std::borrow::Cow;
43

54
use super::MigrationType;
65

@@ -22,8 +21,26 @@ impl Migration {
2221
sql: Cow<'static, str>,
2322
no_tx: bool,
2423
) -> Self {
25-
let checksum = Cow::Owned(Vec::from(Sha384::digest(sql.as_bytes()).as_slice()));
24+
let checksum = checksum(&sql);
25+
26+
Self::with_checksum(
27+
version,
28+
description,
29+
migration_type,
30+
sql,
31+
checksum.into(),
32+
no_tx,
33+
)
34+
}
2635

36+
pub(crate) fn with_checksum(
37+
version: i64,
38+
description: Cow<'static, str>,
39+
migration_type: MigrationType,
40+
sql: Cow<'static, str>,
41+
checksum: Cow<'static, [u8]>,
42+
no_tx: bool,
43+
) -> Self {
2744
Migration {
2845
version,
2946
description,
@@ -40,3 +57,39 @@ pub struct AppliedMigration {
4057
pub version: i64,
4158
pub checksum: Cow<'static, [u8]>,
4259
}
60+
61+
pub fn checksum(sql: &str) -> Vec<u8> {
62+
Vec::from(Sha384::digest(sql).as_slice())
63+
}
64+
65+
pub fn checksum_fragments<'a>(fragments: impl Iterator<Item = &'a str>) -> Vec<u8> {
66+
let mut digest = Sha384::new();
67+
68+
for fragment in fragments {
69+
digest.update(fragment);
70+
}
71+
72+
digest.finalize().to_vec()
73+
}
74+
75+
#[test]
76+
fn fragments_checksum_equals_full_checksum() {
77+
// Copied from `examples/postgres/axum-social-with-tests/migrations/3_comment.sql`
78+
let sql = "\
79+
create table comment (\r\n\
80+
\tcomment_id uuid primary key default gen_random_uuid(),\r\n\
81+
\tpost_id uuid not null references post(post_id),\r\n\
82+
\tuser_id uuid not null references \"user\"(user_id),\r\n\
83+
\tcontent text not null,\r\n\
84+
\tcreated_at timestamptz not null default now()\r\n\
85+
);\r\n\
86+
\r\n\
87+
create index on comment(post_id, created_at);\r\n\
88+
";
89+
90+
// Should yield a string for each character
91+
let fragments_checksum = checksum_fragments(sql.split(""));
92+
let full_checksum = checksum(sql);
93+
94+
assert_eq!(fragments_checksum, full_checksum);
95+
}

sqlx-core/src/migrate/migrator.rs

+22
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ pub struct Migrator {
2323
pub locking: bool,
2424
#[doc(hidden)]
2525
pub no_tx: bool,
26+
#[doc(hidden)]
27+
pub table_name: Cow<'static, str>,
2628
}
2729

2830
fn validate_applied_migrations(
@@ -51,6 +53,7 @@ impl Migrator {
5153
ignore_missing: false,
5254
no_tx: false,
5355
locking: true,
56+
table_name: Cow::Borrowed("_sqlx_migrations"),
5457
};
5558

5659
/// Creates a new instance with the given source.
@@ -81,6 +84,25 @@ impl Migrator {
8184
})
8285
}
8386

87+
/// Override the name of the table used to track executed migrations.
88+
///
89+
/// May be schema-qualified and/or contain quotes. Defaults to `_sqlx_migrations`.
90+
///
91+
/// Potentially useful for multi-tenant databases.
92+
///
93+
/// ### Warning: Potential Data Loss or Corruption!
94+
/// Changing this option for a production database will likely result in data loss or corruption
95+
/// as the migration machinery will no longer be aware of what migrations have been applied
96+
/// and will attempt to re-run them.
97+
///
98+
/// You should create the new table as a copy of the existing migrations table (with contents!),
99+
/// and be sure all instances of your application have been migrated to the new
100+
/// table before deleting the old one.
101+
pub fn dangerous_set_table_name(&mut self, table_name: impl Into<Cow<'static, str>>) -> &Self {
102+
self.table_name = table_name.into();
103+
self
104+
}
105+
84106
/// Specify whether applied migrations that are missing from the resolved migrations should be ignored.
85107
pub fn set_ignore_missing(&mut self, ignore_missing: bool) -> &Self {
86108
self.ignore_missing = ignore_missing;

sqlx-core/src/migrate/mod.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub use migrate::{Migrate, MigrateDatabase};
1111
pub use migration::{AppliedMigration, Migration};
1212
pub use migration_type::MigrationType;
1313
pub use migrator::Migrator;
14-
pub use source::MigrationSource;
14+
pub use source::{MigrationSource, ResolveConfig, ResolveWith};
1515

1616
#[doc(hidden)]
17-
pub use source::resolve_blocking;
17+
pub use source::{resolve_blocking, resolve_blocking_with_config};

sqlx-core/src/migrate/source.rs

+159-12
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use crate::error::BoxDynError;
2-
use crate::migrate::{Migration, MigrationType};
2+
use crate::migrate::{migration, Migration, MigrationType};
33
use futures_core::future::BoxFuture;
44

55
use std::borrow::Cow;
6+
use std::collections::BTreeSet;
67
use std::fmt::Debug;
78
use std::fs;
89
use std::io;
@@ -28,19 +29,48 @@ pub trait MigrationSource<'s>: Debug {
2829

2930
impl<'s> MigrationSource<'s> for &'s Path {
3031
fn resolve(self) -> BoxFuture<'s, Result<Vec<Migration>, BoxDynError>> {
32+
// Behavior changed from previous because `canonicalize()` is potentially blocking
33+
// since it might require going to disk to fetch filesystem data.
34+
self.to_owned().resolve()
35+
}
36+
}
37+
38+
impl MigrationSource<'static> for PathBuf {
39+
fn resolve(self) -> BoxFuture<'static, Result<Vec<Migration>, BoxDynError>> {
40+
// Technically this could just be `Box::pin(spawn_blocking(...))`
41+
// but that would actually be a breaking behavior change because it would call
42+
// `spawn_blocking()` on the current thread
3143
Box::pin(async move {
32-
let canonical = self.canonicalize()?;
33-
let migrations_with_paths =
34-
crate::rt::spawn_blocking(move || resolve_blocking(&canonical)).await?;
44+
crate::rt::spawn_blocking(move || {
45+
let migrations_with_paths = resolve_blocking(&self)?;
3546

36-
Ok(migrations_with_paths.into_iter().map(|(m, _p)| m).collect())
47+
Ok(migrations_with_paths.into_iter().map(|(m, _p)| m).collect())
48+
})
49+
.await
3750
})
3851
}
3952
}
4053

41-
impl MigrationSource<'static> for PathBuf {
42-
fn resolve(self) -> BoxFuture<'static, Result<Vec<Migration>, BoxDynError>> {
43-
Box::pin(async move { self.as_path().resolve().await })
54+
/// A [`MigrationSource`] implementation with configurable resolution.
55+
///
56+
/// `S` may be `PathBuf`, `&Path` or any type that implements `Into<PathBuf>`.
57+
///
58+
/// See [`ResolveConfig`] for details.
59+
#[derive(Debug)]
60+
pub struct ResolveWith<S>(pub S, pub ResolveConfig);
61+
62+
impl<'s, S: Debug + Into<PathBuf> + Send + 's> MigrationSource<'s> for ResolveWith<S> {
63+
fn resolve(self) -> BoxFuture<'s, Result<Vec<Migration>, BoxDynError>> {
64+
Box::pin(async move {
65+
let path = self.0.into();
66+
let config = self.1;
67+
68+
let migrations_with_paths =
69+
crate::rt::spawn_blocking(move || resolve_blocking_with_config(&path, &config))
70+
.await?;
71+
72+
Ok(migrations_with_paths.into_iter().map(|(m, _p)| m).collect())
73+
})
4474
}
4575
}
4676

@@ -52,11 +82,87 @@ pub struct ResolveError {
5282
source: Option<io::Error>,
5383
}
5484

85+
/// Configuration for migration resolution using [`ResolveWith`].
86+
#[derive(Debug, Default)]
87+
pub struct ResolveConfig {
88+
ignored_chars: BTreeSet<char>,
89+
}
90+
91+
impl ResolveConfig {
92+
/// Return a default, empty configuration.
93+
pub fn new() -> Self {
94+
ResolveConfig {
95+
ignored_chars: BTreeSet::new(),
96+
}
97+
}
98+
99+
/// Ignore a character when hashing migrations.
100+
///
101+
/// The migration SQL string itself will still contain the character,
102+
/// but it will not be included when calculating the checksum.
103+
///
104+
/// This can be used to ignore whitespace characters so changing formatting
105+
/// does not change the checksum.
106+
///
107+
/// Adding the same `char` more than once is a no-op.
108+
///
109+
/// ### Note: Changes Migration Checksum
110+
/// This will change the checksum of resolved migrations,
111+
/// which may cause problems with existing deployments.
112+
///
113+
/// **Use at your own risk.**
114+
pub fn ignore_char(&mut self, c: char) -> &mut Self {
115+
self.ignored_chars.insert(c);
116+
self
117+
}
118+
119+
/// Ignore one or more characters when hashing migrations.
120+
///
121+
/// The migration SQL string itself will still contain these characters,
122+
/// but they will not be included when calculating the checksum.
123+
///
124+
/// This can be used to ignore whitespace characters so changing formatting
125+
/// does not change the checksum.
126+
///
127+
/// Adding the same `char` more than once is a no-op.
128+
///
129+
/// ### Note: Changes Migration Checksum
130+
/// This will change the checksum of resolved migrations,
131+
/// which may cause problems with existing deployments.
132+
///
133+
/// **Use at your own risk.**
134+
pub fn ignore_chars(&mut self, chars: impl IntoIterator<Item = char>) -> &mut Self {
135+
self.ignored_chars.extend(chars);
136+
self
137+
}
138+
139+
/// Iterate over the set of ignored characters.
140+
///
141+
/// Duplicate `char`s are not included.
142+
pub fn ignored_chars(&self) -> impl Iterator<Item = char> + '_ {
143+
self.ignored_chars.iter().copied()
144+
}
145+
}
146+
55147
// FIXME: paths should just be part of `Migration` but we can't add a field backwards compatibly
56148
// since it's `#[non_exhaustive]`.
149+
#[doc(hidden)]
57150
pub fn resolve_blocking(path: &Path) -> Result<Vec<(Migration, PathBuf)>, ResolveError> {
58-
let s = fs::read_dir(path).map_err(|e| ResolveError {
59-
message: format!("error reading migration directory {}: {e}", path.display()),
151+
resolve_blocking_with_config(path, &ResolveConfig::new())
152+
}
153+
154+
#[doc(hidden)]
155+
pub fn resolve_blocking_with_config(
156+
path: &Path,
157+
config: &ResolveConfig,
158+
) -> Result<Vec<(Migration, PathBuf)>, ResolveError> {
159+
let path = path.canonicalize().map_err(|e| ResolveError {
160+
message: format!("error canonicalizing path {}", path.display()),
161+
source: Some(e),
162+
})?;
163+
164+
let s = fs::read_dir(&path).map_err(|e| ResolveError {
165+
message: format!("error reading migration directory {}", path.display()),
60166
source: Some(e),
61167
})?;
62168

@@ -65,7 +171,7 @@ pub fn resolve_blocking(path: &Path) -> Result<Vec<(Migration, PathBuf)>, Resolv
65171
for res in s {
66172
let entry = res.map_err(|e| ResolveError {
67173
message: format!(
68-
"error reading contents of migration directory {}: {e}",
174+
"error reading contents of migration directory {}",
69175
path.display()
70176
),
71177
source: Some(e),
@@ -126,12 +232,15 @@ pub fn resolve_blocking(path: &Path) -> Result<Vec<(Migration, PathBuf)>, Resolv
126232
// opt-out of migration transaction
127233
let no_tx = sql.starts_with("-- no-transaction");
128234

235+
let checksum = checksum_with(&sql, &config.ignored_chars);
236+
129237
migrations.push((
130-
Migration::new(
238+
Migration::with_checksum(
131239
version,
132240
Cow::Owned(description),
133241
migration_type,
134242
Cow::Owned(sql),
243+
checksum.into(),
135244
no_tx,
136245
),
137246
entry_path,
@@ -143,3 +252,41 @@ pub fn resolve_blocking(path: &Path) -> Result<Vec<(Migration, PathBuf)>, Resolv
143252

144253
Ok(migrations)
145254
}
255+
256+
fn checksum_with(sql: &str, ignored_chars: &BTreeSet<char>) -> Vec<u8> {
257+
if ignored_chars.is_empty() {
258+
// This is going to be much faster because it doesn't have to UTF-8 decode `sql`.
259+
return migration::checksum(sql);
260+
}
261+
262+
migration::checksum_fragments(sql.split(|c| ignored_chars.contains(&c)))
263+
}
264+
265+
#[test]
266+
fn checksum_with_ignored_chars() {
267+
// Ensure that `checksum_with` returns the same digest for a given set of ignored chars
268+
// as the equivalent string with the characters removed.
269+
let ignored_chars = [' ', '\t', '\r', '\n'];
270+
271+
// Copied from `examples/postgres/axum-social-with-tests/migrations/3_comment.sql`
272+
let sql = "\
273+
create table comment (\r\n\
274+
\tcomment_id uuid primary key default gen_random_uuid(),\r\n\
275+
\tpost_id uuid not null references post(post_id),\r\n\
276+
\tuser_id uuid not null references \"user\"(user_id),\r\n\
277+
\tcontent text not null,\r\n\
278+
\tcreated_at timestamptz not null default now()\r\n\
279+
);\r\n\
280+
\r\n\
281+
create index on comment(post_id, created_at);\r\n\
282+
";
283+
284+
let stripped_sql = sql.replace(&ignored_chars[..], "");
285+
286+
let ignored_chars = BTreeSet::from(ignored_chars);
287+
288+
let digest_ignored = checksum_with(sql, &ignored_chars);
289+
let digest_stripped = migration::checksum(&stripped_sql);
290+
291+
assert_eq!(digest_ignored, digest_stripped);
292+
}

0 commit comments

Comments
 (0)