rust_ef/migration/
history.rs1use super::dialect::MigrationDialect;
4use super::snapshot_io::migration_io_err;
5use super::types::{Migration, ModelSnapshot};
6use crate::error::{EFError, EFResult};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone)]
14pub struct MigrationHistoryEntry {
15 pub migration_id: String,
16 pub product_version: String,
17}
18
19pub const MIGRATION_HISTORY_TABLE: &str = "__ef_migrations_history";
21
22pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION");
24
25pub(super) fn seed_insert_sql(
26 dialect: MigrationDialect,
27 table: &str,
28 columns: &[&str],
29 gen: &dyn crate::provider::ISqlGenerator,
30) -> String {
31 let quoted_table = dialect.quote(table);
32 let quoted_cols: Vec<String> = columns.iter().map(|c| dialect.quote(c)).collect();
33 let placeholders: Vec<String> = (0..columns.len())
34 .map(|i| gen.parameter_placeholder(i + 1))
35 .collect();
36 let cols = quoted_cols.join(", ");
37 let vals = placeholders.join(", ");
38 match dialect {
39 MigrationDialect::Sqlite => {
40 format!("INSERT OR IGNORE INTO {quoted_table} ({cols}) VALUES ({vals})")
41 }
42 MigrationDialect::Postgres => {
43 format!("INSERT INTO {quoted_table} ({cols}) VALUES ({vals}) ON CONFLICT DO NOTHING")
44 }
45 MigrationDialect::MySql => {
46 format!("INSERT IGNORE INTO {quoted_table} ({cols}) VALUES ({vals})")
47 }
48 }
49}
50
51pub(super) fn split_sql_statements(sql: &str, dialect: MigrationDialect) -> Vec<String> {
60 let chars: Vec<char> = sql.chars().collect();
61 let mut statements = Vec::new();
62 let mut current = String::new();
63 let mut in_string = false;
64 let mut in_line_comment = false;
65 let mut in_block_comment = false;
66 let mut dollar_tag: Option<String> = None;
67 let mut i = 0;
68
69 while i < chars.len() {
70 let c = chars[i];
71
72 if in_line_comment {
73 if c == '\n' {
74 in_line_comment = false;
75 current.push('\n');
76 }
77 i += 1;
78 continue;
79 }
80 if in_block_comment {
81 if c == '*' && chars.get(i + 1) == Some(&'/') {
82 in_block_comment = false;
83 i += 2;
84 } else {
85 i += 1;
86 }
87 continue;
88 }
89 if let Some(tag) = &dollar_tag {
90 let tag_chars: Vec<char> = tag.chars().collect();
91 if c == '$' && chars[i..].starts_with(&tag_chars) {
92 current.push_str(tag);
93 i += tag_chars.len();
94 dollar_tag = None;
95 } else {
96 current.push(c);
97 i += 1;
98 }
99 continue;
100 }
101 if in_string {
102 match c {
103 '\\' if dialect == MigrationDialect::MySql => {
105 current.push(c);
106 if let Some(next) = chars.get(i + 1) {
107 current.push(*next);
108 i += 1;
109 }
110 }
111 '\'' if chars.get(i + 1) == Some(&'\'') => {
113 current.push_str("''");
114 i += 1;
115 }
116 '\'' => {
117 in_string = false;
118 current.push(c);
119 }
120 _ => current.push(c),
121 }
122 i += 1;
123 continue;
124 }
125
126 match c {
127 '\'' => {
128 in_string = true;
129 current.push(c);
130 }
131 '-' if chars.get(i + 1) == Some(&'-') => {
132 in_line_comment = true;
133 i += 1;
134 }
135 '/' if chars.get(i + 1) == Some(&'*') => {
136 in_block_comment = true;
137 i += 1;
138 }
139 '$' if dialect == MigrationDialect::Postgres => match dollar_quote_tag(&chars, i) {
140 Some(tag) => {
141 current.push_str(&tag);
142 i += tag.chars().count() - 1;
143 dollar_tag = Some(tag);
144 }
145 None => current.push(c),
146 },
147 '#' if dialect == MigrationDialect::MySql => {
149 in_line_comment = true;
150 }
151 ';' => {
152 let statement = current.trim();
153 if !statement.is_empty() {
154 statements.push(statement.to_string());
155 }
156 current.clear();
157 }
158 _ => current.push(c),
159 }
160 i += 1;
161 }
162
163 let statement = current.trim();
164 if !statement.is_empty() {
165 statements.push(statement.to_string());
166 }
167 statements
168}
169
170fn dollar_quote_tag(chars: &[char], start: usize) -> Option<String> {
177 let rest = &chars[start..];
178 let close = rest.iter().skip(1).position(|c| *c == '$').map(|p| p + 1)?;
179 let tag: String = rest[..=close].iter().collect();
180 let name = &tag[1..tag.len() - 1];
181 let valid_ident = match name.chars().next() {
182 None => true,
183 Some(first) => {
184 (first.is_ascii_alphabetic() || first == '_')
185 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
186 }
187 };
188 valid_ident.then_some(tag)
189}
190
191pub fn validate_migration_id(id: &str) -> EFResult<()> {
196 if id.is_empty() {
197 return Err(EFError::migration("migration id must not be empty"));
198 }
199 if id.len() > 150 {
200 return Err(EFError::migration(format!(
201 "migration id '{id}' exceeds 150 characters"
202 )));
203 }
204 if id == "." || id == ".." {
205 return Err(EFError::migration(format!("invalid migration id '{id}'")));
206 }
207 if id
208 .chars()
209 .any(|c| c == '/' || c == '\\' || c == ':' || c.is_control())
210 {
211 return Err(EFError::migration(format!(
212 "migration id '{id}' must not contain path separators, ':' or control characters"
213 )));
214 }
215 if id.ends_with('.') || id.ends_with(' ') {
218 return Err(EFError::migration(format!(
219 "migration id '{id}' must not end with '.' or a space"
220 )));
221 }
222 Ok(())
223}
224
225pub fn create_migration_history_table_sql(dialect: MigrationDialect) -> String {
227 match dialect {
228 MigrationDialect::Postgres => format!(
229 r#"CREATE TABLE IF NOT EXISTS "{table}" (
230 "migration_id" VARCHAR(150) NOT NULL PRIMARY KEY,
231 "product_version" VARCHAR(32) NOT NULL,
232 "applied_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
233);"#,
234 table = MIGRATION_HISTORY_TABLE
235 ),
236 MigrationDialect::MySql => format!(
237 r#"CREATE TABLE IF NOT EXISTS `{table}` (
238 `migration_id` VARCHAR(150) NOT NULL PRIMARY KEY,
239 `product_version` VARCHAR(32) NOT NULL,
240 `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
241) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"#,
242 table = MIGRATION_HISTORY_TABLE
243 ),
244 MigrationDialect::Sqlite => format!(
245 r#"CREATE TABLE IF NOT EXISTS "{table}" (
246 "migration_id" TEXT NOT NULL PRIMARY KEY,
247 "product_version" TEXT NOT NULL
248);"#,
249 table = MIGRATION_HISTORY_TABLE
250 ),
251 }
252}
253
254#[derive(Debug, Clone)]
268pub struct MigrationStore {
269 root: PathBuf,
270}
271
272impl MigrationStore {
273 pub fn new(root: impl Into<PathBuf>) -> Self {
274 Self { root: root.into() }
275 }
276
277 pub fn root(&self) -> &Path {
278 &self.root
279 }
280
281 pub fn save(&self, migration: &Migration) -> EFResult<()> {
287 validate_migration_id(&migration.id)?;
288 fs::create_dir_all(&self.root).map_err(migration_io_err)?;
289 let dir = self.root.join(&migration.id);
290 fs::create_dir_all(&dir).map_err(migration_io_err)?;
291 fs::write(dir.join("up.sql"), &migration.up_sql).map_err(migration_io_err)?;
292 fs::write(dir.join("down.sql"), &migration.down_sql).map_err(migration_io_err)?;
293 Ok(())
294 }
295
296 pub fn load_all(&self) -> EFResult<Vec<Migration>> {
298 if !self.root.exists() {
299 return Ok(Vec::new());
300 }
301 let mut ids: Vec<String> = fs::read_dir(&self.root)
302 .map_err(migration_io_err)?
303 .filter_map(|e| e.ok())
304 .filter(|e| e.path().is_dir())
305 .filter_map(|e| e.file_name().into_string().ok())
306 .collect();
307 ids.sort();
308 ids.into_iter().map(|id| self.load(&id)).collect()
309 }
310
311 pub fn load(&self, id: &str) -> EFResult<Migration> {
312 validate_migration_id(id)?;
313 let dir = self.root.join(id);
314 let up_sql = fs::read_to_string(dir.join("up.sql")).map_err(migration_io_err)?;
315 let down_sql = fs::read_to_string(dir.join("down.sql")).map_err(migration_io_err)?;
316 Ok(Migration {
317 id: id.to_string(),
318 description: id.to_string(),
319 up_sql,
320 down_sql,
321 })
322 }
323
324 pub fn save_snapshot(&self, snapshot: &ModelSnapshot) -> EFResult<()> {
326 super::snapshot::write_snapshot(self.root.join("model_snapshot.json"), snapshot)
327 }
328
329 pub fn load_snapshot(&self) -> EFResult<Option<ModelSnapshot>> {
330 let path = self.root.join("model_snapshot.json");
331 if !path.exists() {
332 return Ok(None);
333 }
334 let text = fs::read_to_string(path).map_err(migration_io_err)?;
335 super::snapshot_io::parse_model_snapshot_json(&text)
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 fn split(sql: &str, dialect: MigrationDialect) -> Vec<String> {
344 split_sql_statements(sql, dialect)
345 }
346
347 #[test]
348 fn splits_plain_statements_and_drops_empty_ones() {
349 let out = split(
350 "CREATE TABLE a (id INT); ;CREATE TABLE b (id INT)",
351 MigrationDialect::Sqlite,
352 );
353 assert_eq!(
354 out,
355 vec!["CREATE TABLE a (id INT)", "CREATE TABLE b (id INT)"]
356 );
357 }
358
359 #[test]
360 fn semicolon_inside_string_literal_does_not_split() {
361 let out = split(
362 "INSERT INTO t VALUES ('a;b');SELECT 1",
363 MigrationDialect::MySql,
364 );
365 assert_eq!(out, vec!["INSERT INTO t VALUES ('a;b')", "SELECT 1"]);
366 }
367
368 #[test]
369 fn doubled_quote_is_an_escaped_quote() {
370 let out = split(
371 "INSERT INTO t VALUES ('it''s;ok');SELECT 1",
372 MigrationDialect::Postgres,
373 );
374 assert_eq!(out, vec!["INSERT INTO t VALUES ('it''s;ok')", "SELECT 1"]);
375 }
376
377 #[test]
378 fn mysql_backslash_escape_keeps_the_string_open() {
379 let out = split(
380 "INSERT INTO t VALUES ('it\\'s;ok');SELECT 1",
381 MigrationDialect::MySql,
382 );
383 assert_eq!(out, vec!["INSERT INTO t VALUES ('it\\'s;ok')", "SELECT 1"]);
384 }
385
386 #[test]
387 fn postgres_backslash_is_not_an_escape() {
388 let out = split("SELECT 'a\\';SELECT 1", MigrationDialect::Postgres);
390 assert_eq!(out, vec!["SELECT 'a\\'", "SELECT 1"]);
391 }
392
393 #[test]
394 fn line_comments_are_stripped() {
395 let out = split(
396 "-- note; still comment\nSELECT 1;",
397 MigrationDialect::Postgres,
398 );
399 assert_eq!(out, vec!["SELECT 1"]);
400 }
401
402 #[test]
403 fn block_comments_are_stripped() {
404 let out = split("/* a;b */ SELECT 1; /* tail */", MigrationDialect::Sqlite);
405 assert_eq!(out, vec!["SELECT 1"]);
406 }
407
408 #[test]
409 fn postgres_dollar_quoted_body_keeps_semicolons() {
410 let out = split(
411 "CREATE FUNCTION f() RETURNS void AS $$ BEGIN PERFORM 1; END $$ LANGUAGE plpgsql; SELECT 2",
412 MigrationDialect::Postgres,
413 );
414 assert_eq!(out.len(), 2);
415 assert!(out[0].contains("PERFORM 1; END"));
416 assert_eq!(out[1], "SELECT 2");
417 }
418
419 #[test]
420 fn dollar_inside_a_literal_is_not_a_dollar_quote() {
421 let out = split("SELECT '$';SELECT 1", MigrationDialect::Postgres);
422 assert_eq!(out, vec!["SELECT '$'", "SELECT 1"]);
423 }
424
425 #[test]
426 fn postgres_positional_parameters_are_not_dollar_quotes() {
427 let out = split("SELECT $1, $2;SELECT 3", MigrationDialect::Postgres);
428 assert_eq!(out, vec!["SELECT $1, $2", "SELECT 3"]);
429 }
430
431 #[test]
432 fn mysql_hash_comment_is_stripped() {
433 let out = split("# note; here\nSELECT 1;", MigrationDialect::MySql);
434 assert_eq!(out, vec!["SELECT 1"]);
435 }
436}