11use crate :: error:: BoxDynError ;
2- use crate :: migrate:: { Migration , MigrationType } ;
2+ use crate :: migrate:: { migration , Migration , MigrationType } ;
33use futures_core:: future:: BoxFuture ;
44
55use std:: borrow:: Cow ;
6+ use std:: collections:: BTreeSet ;
67use std:: fmt:: Debug ;
78use std:: fs;
89use std:: io;
@@ -28,19 +29,48 @@ pub trait MigrationSource<'s>: Debug {
2829
2930impl < ' 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) ]
57150pub 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+ \t comment_id uuid primary key default gen_random_uuid(),\r \n \
275+ \t post_id uuid not null references post(post_id),\r \n \
276+ \t user_id uuid not null references \" user\" (user_id),\r \n \
277+ \t content text not null,\r \n \
278+ \t created_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