1
1
use crate :: error:: BoxDynError ;
2
- use crate :: migrate:: { Migration , MigrationType } ;
2
+ use crate :: migrate:: { migration , Migration , MigrationType } ;
3
3
use futures_core:: future:: BoxFuture ;
4
4
5
5
use std:: borrow:: Cow ;
6
+ use std:: collections:: BTreeSet ;
6
7
use std:: fmt:: Debug ;
7
8
use std:: fs;
8
9
use std:: io;
@@ -28,19 +29,48 @@ pub trait MigrationSource<'s>: Debug {
28
29
29
30
impl < ' s > MigrationSource < ' s > for & ' s Path {
30
31
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
31
43
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 ) ?;
35
46
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
37
50
} )
38
51
}
39
52
}
40
53
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
+ } )
44
74
}
45
75
}
46
76
@@ -52,11 +82,87 @@ pub struct ResolveError {
52
82
source : Option < io:: Error > ,
53
83
}
54
84
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
+
55
147
// FIXME: paths should just be part of `Migration` but we can't add a field backwards compatibly
56
148
// since it's `#[non_exhaustive]`.
149
+ #[ doc( hidden) ]
57
150
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( ) ) ,
60
166
source : Some ( e) ,
61
167
} ) ?;
62
168
@@ -65,7 +171,7 @@ pub fn resolve_blocking(path: &Path) -> Result<Vec<(Migration, PathBuf)>, Resolv
65
171
for res in s {
66
172
let entry = res. map_err ( |e| ResolveError {
67
173
message : format ! (
68
- "error reading contents of migration directory {}: {e} " ,
174
+ "error reading contents of migration directory {}" ,
69
175
path. display( )
70
176
) ,
71
177
source : Some ( e) ,
@@ -126,12 +232,15 @@ pub fn resolve_blocking(path: &Path) -> Result<Vec<(Migration, PathBuf)>, Resolv
126
232
// opt-out of migration transaction
127
233
let no_tx = sql. starts_with ( "-- no-transaction" ) ;
128
234
235
+ let checksum = checksum_with ( & sql, & config. ignored_chars ) ;
236
+
129
237
migrations. push ( (
130
- Migration :: new (
238
+ Migration :: with_checksum (
131
239
version,
132
240
Cow :: Owned ( description) ,
133
241
migration_type,
134
242
Cow :: Owned ( sql) ,
243
+ checksum. into ( ) ,
135
244
no_tx,
136
245
) ,
137
246
entry_path,
@@ -143,3 +252,41 @@ pub fn resolve_blocking(path: &Path) -> Result<Vec<(Migration, PathBuf)>, Resolv
143
252
144
253
Ok ( migrations)
145
254
}
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