database_migration/definition/
mod.rs1use crate::config::{DEFAULT_EXCLUDED_FILES, MIGRATION_KEY_FORMAT_STR};
2use crate::error::{DefinitionError, FilePatternError};
3use crate::migration::{Migration, MigrationKind, NewMigration};
4use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
5use regex::Regex;
6use std::borrow::Cow;
7use std::ffi::OsStr;
8use std::fmt::{self, Display, Write};
9use std::path::{Path, PathBuf};
10use std::str::FromStr;
11
12pub trait ParseMigration {
13 type Err;
14
15 fn parse_migration(&self) -> Result<Migration, Self::Err>;
16}
17
18pub const SCRIPT_FILE_EXTENSION: &str = ".surql";
19pub const UP_SCRIPT_FILE_EXTENSION: &str = ".up.surql";
20pub const DOWN_SCRIPT_FILE_EXTENSION: &str = ".down.surql";
21
22fn parse_migration(path: &Path, filename: &str) -> Result<Migration, DefinitionError> {
23 if !filename.ends_with(SCRIPT_FILE_EXTENSION) {
24 return Err(DefinitionError::InvalidFilename);
25 }
26 let up = filename.ends_with(UP_SCRIPT_FILE_EXTENSION);
27 let down = filename.ends_with(DOWN_SCRIPT_FILE_EXTENSION);
28 let (kind, ext_len) = match (up, down) {
29 (false, false) => (MigrationKind::Up, SCRIPT_FILE_EXTENSION.len()),
30 (true, false) => (MigrationKind::Up, UP_SCRIPT_FILE_EXTENSION.len()),
31 (false, true) => (MigrationKind::Down, DOWN_SCRIPT_FILE_EXTENSION.len()),
32 (true, true) => return Err(DefinitionError::AmbiguousDirection),
33 };
34 if filename.contains(".up.") && filename.contains(".down.") {
35 return Err(DefinitionError::AmbiguousDirection);
36 }
37 let len = filename.len();
38 if len < 8 + ext_len {
39 return Err(DefinitionError::MissingDate);
40 }
41 let date_substr = &filename[0..8];
42 let date = NaiveDate::parse_from_str(date_substr, "%Y%m%d")
43 .map_err(|err| DefinitionError::InvalidDate(err.to_string()))?;
44 if len < 15 + ext_len || &filename[8..9] != "_" {
45 return Err(DefinitionError::MissingTime);
46 }
47 let time_substr = &filename[9..15];
48 let time = NaiveTime::parse_from_str(time_substr, "%H%M%S")
49 .map_err(|err| DefinitionError::InvalidTime(err.to_string()))?;
50 let key = NaiveDateTime::new(date, time);
51 let title = if len < 17 + ext_len || &filename[15..16] != "_" {
52 ""
53 } else {
54 &filename[16..len - ext_len].replace('_', " ")
55 };
56 let mut script_path = PathBuf::from(path);
57 script_path.push(filename);
58
59 Ok(Migration {
60 key,
61 title: title.to_string(),
62 kind,
63 script_path,
64 })
65}
66
67impl ParseMigration for str {
68 type Err = DefinitionError;
69
70 fn parse_migration(&self) -> Result<Migration, Self::Err> {
71 let (path, filename) = self
72 .rfind('/')
73 .map_or(("", self), |index| (&self[..index], &self[index + 1..]));
74
75 parse_migration(Path::new(path), filename)
76 }
77}
78
79impl ParseMigration for OsStr {
80 type Err = DefinitionError;
81
82 fn parse_migration(&self) -> Result<Migration, Self::Err> {
83 let path_str = self.to_str().ok_or(DefinitionError::InvalidUtf8Character)?;
84 ParseMigration::parse_migration(path_str)
85 }
86}
87
88impl ParseMigration for Path {
89 type Err = DefinitionError;
90
91 fn parse_migration(&self) -> Result<Migration, Self::Err> {
92 let path = self.parent().unwrap_or_else(|| Self::new(""));
93 let filename = self.file_name().ok_or(DefinitionError::InvalidFilename)?;
94 let filename = filename
95 .to_str()
96 .ok_or(DefinitionError::InvalidUtf8Character)?;
97
98 parse_migration(path, filename)
99 }
100}
101
102pub trait GetFilename {
103 fn get_filename(&self, migration: &NewMigration) -> String;
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[must_use]
108pub struct MigrationFilenameStrategy {
109 pub up_postfix: bool,
110}
111
112impl Default for MigrationFilenameStrategy {
113 fn default() -> Self {
114 Self { up_postfix: true }
115 }
116}
117
118impl MigrationFilenameStrategy {
119 pub const fn with_up_postfix(mut self, up_postfix: bool) -> Self {
120 self.up_postfix = up_postfix;
121 self
122 }
123}
124
125impl GetFilename for MigrationFilenameStrategy {
126 fn get_filename(&self, migration: &NewMigration) -> String {
127 let key = migration.key.format(MIGRATION_KEY_FORMAT_STR).to_string();
128 let title = migration.title.replace(' ', "_");
129 let extension = match (migration.kind, self.up_postfix) {
130 (MigrationKind::Up, true) => UP_SCRIPT_FILE_EXTENSION,
131 (MigrationKind::Up, false) => SCRIPT_FILE_EXTENSION,
132 (MigrationKind::Down, _) => DOWN_SCRIPT_FILE_EXTENSION,
133 (MigrationKind::Baseline, _) => panic!("baselines do not have migration scripts"),
134 };
135 if title.is_empty() {
136 format!("{key}{extension}")
137 } else {
138 format!("{key}_{title}{extension}")
139 }
140 }
141}
142
143#[derive(Clone, Debug)]
144struct FilePattern {
145 pattern: String,
146 regex: Regex,
147 filename_pattern: bool,
148}
149
150impl FilePattern {
151 #[allow(clippy::missing_const_for_fn)]
152 fn is_filename_pattern(&self) -> bool {
153 self.filename_pattern
154 }
155
156 fn is_match(&self, haystack: &str) -> bool {
157 self.regex.is_match(haystack)
158 }
159}
160
161impl PartialEq for FilePattern {
162 fn eq(&self, other: &Self) -> bool {
163 self.pattern == other.pattern
164 }
165}
166
167impl Display for FilePattern {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 f.write_str(&self.pattern)
170 }
171}
172
173impl FromStr for FilePattern {
174 type Err = FilePatternError;
175
176 fn from_str(pattern_str: &str) -> Result<Self, Self::Err> {
177 if pattern_str.is_empty() {
178 Err(FilePatternError::EmptySubPatternNotAllowed)
179 } else {
180 let invalid_chars = scan_for_invalid_characters(pattern_str);
181 if invalid_chars.is_empty() {
182 let filename_pattern = !pattern_str.contains('/');
183 let mut regex_pattern = String::from("^");
184 regex_pattern.push_str(
185 &pattern_str
186 .replace('.', "\\.")
187 .replace("**", ".?")
188 .replace('*', "[^/]*")
189 .replace(".?", ".*"),
190 );
191 regex_pattern.push('$');
192 Regex::new(®ex_pattern)
193 .map_err(|err| FilePatternError::InvalidPattern(err.to_string()))
194 .map(|regex| Self {
195 pattern: pattern_str.into(),
196 regex,
197 filename_pattern,
198 })
199 } else {
200 Err(FilePatternError::InvalidCharacter(invalid_chars))
201 }
202 }
203 }
204}
205
206#[derive(Clone, Debug, PartialEq)]
207pub struct ExcludedFiles {
208 pattern: Vec<FilePattern>,
209}
210
211impl Default for ExcludedFiles {
212 fn default() -> Self {
213 DEFAULT_EXCLUDED_FILES.parse()
214 .unwrap_or_else(|err| panic!("failed to create default `ExcludedFiles`: {err} -- THIS IS AN IMPLEMENTATION ERROR! Please file a bug."))
215 }
216}
217
218impl ExcludedFiles {
219 pub const fn empty() -> Self {
220 Self {
221 pattern: Vec::new(),
222 }
223 }
224
225 pub fn matches(&self, path: &Path) -> bool {
226 let filename = path
227 .file_name()
228 .map_or(Cow::Borrowed(""), OsStr::to_string_lossy);
229 if filename.is_empty() {
230 return false;
231 }
232 let path_str = path.to_string_lossy().replace('\\', "/");
233 self.pattern.iter().any(|p| {
234 if p.is_filename_pattern() {
235 p.is_match(&filename)
236 } else {
237 p.is_match(&path_str)
238 }
239 })
240 }
241}
242
243impl Display for ExcludedFiles {
244 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245 let mut first = true;
246 for pattern in &self.pattern {
247 if first {
248 first = false;
249 } else {
250 f.write_char('|')?;
251 }
252 write!(f, "{pattern}")?;
253 }
254 Ok(())
255 }
256}
257
258impl FromStr for ExcludedFiles {
259 type Err = FilePatternError;
260
261 fn from_str(s: &str) -> Result<Self, Self::Err> {
262 if s.is_empty() {
263 return Ok(Self {
264 pattern: Vec::new(),
265 });
266 }
267 let pattern = s
268 .split('|')
269 .map(FromStr::from_str)
270 .collect::<Result<Vec<_>, _>>()?;
271
272 Ok(Self { pattern })
273 }
274}
275
276fn scan_for_invalid_characters(s: &str) -> Vec<char> {
277 s.chars().filter(|c| !is_valid_pattern_char(*c)).collect()
278}
279
280fn is_valid_pattern_char(c: char) -> bool {
281 match c {
282 '*' | ' ' | '_' | '-' | '.' | '/' => true,
283 _ if c.is_alphanumeric() => true,
284 _ => false,
285 }
286}
287
288#[cfg(test)]
289mod tests;