1use crate::config::Tracking;
58use drizzle_types::Dialect;
59use sha2::{Digest, Sha256};
60
61fn quote_identifier(dialect: Dialect, identifier: &str) -> String {
62 match dialect {
63 Dialect::MySQL => format!("`{}`", identifier.replace('`', "``")),
64 _ => format!("\"{}\"", identifier.replace('"', "\"\"")),
65 }
66}
67
68#[derive(Debug, Clone)]
73pub struct Migration {
74 tag: String,
76 hash: String,
78 created_at: i64,
80 sql: Vec<String>,
82}
83
84#[derive(Debug, Clone, Copy)]
92pub struct SqliteMigrationExecution<'a> {
93 statements: &'a [String],
94 suspends_foreign_keys: bool,
95}
96
97impl<'a> SqliteMigrationExecution<'a> {
98 #[inline]
101 #[must_use]
102 pub const fn suspends_foreign_keys(self) -> bool {
103 self.suspends_foreign_keys
104 }
105
106 pub fn statements(self) -> impl Iterator<Item = &'a str> + 'a {
108 self.statements.iter().filter_map(|statement| {
109 sqlite_foreign_keys_setting(statement)
110 .expect("SQLite migration execution was validated before construction")
111 .is_none()
112 .then_some(statement.as_str())
113 })
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
119pub enum SqliteMigrationExecutionError {
120 #[error("PRAGMA foreign_keys=OFF is nested without a matching ON")]
121 NestedForeignKeysOff,
122 #[error("PRAGMA foreign_keys=ON has no preceding OFF")]
123 ForeignKeysOnWithoutOff,
124 #[error("PRAGMA foreign_keys=OFF has no matching ON")]
125 ForeignKeysOffWithoutOn,
126 #[error("unsupported PRAGMA foreign_keys assignment in migration")]
127 UnsupportedForeignKeysPragma,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum MigrateOutcome {
133 UpToDate,
136 Applied { tags: Vec<String> },
139}
140
141impl MigrateOutcome {
142 #[inline]
144 #[must_use]
145 pub const fn is_up_to_date(&self) -> bool {
146 matches!(self, Self::UpToDate)
147 }
148
149 #[inline]
151 #[must_use]
152 pub fn applied_count(&self) -> usize {
153 match self {
154 Self::UpToDate => 0,
155 Self::Applied { tags } => tags.len(),
156 }
157 }
158
159 #[inline]
161 #[must_use]
162 pub fn applied_tags(&self) -> &[String] {
163 match self {
164 Self::UpToDate => &[],
165 Self::Applied { tags } => tags,
166 }
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct AppliedMigrationMetadata {
172 pub id: Option<i64>,
173 pub hash: String,
174 pub created_at: i64,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct MatchedMigrationMetadata {
179 pub id: Option<i64>,
180 pub hash: String,
181 pub created_at: i64,
182 pub name: String,
183}
184
185impl Migration {
186 #[must_use]
191 pub fn new(tag: &str, sql: &str) -> Self {
192 let hash = compute_hash(sql);
193 let created_at = parse_timestamp_from_tag(tag);
194 let statements = split_statements(sql);
195
196 Self {
197 tag: tag.to_string(),
198 hash,
199 created_at,
200 sql: statements,
201 }
202 }
203
204 pub fn with_hash(
206 tag: impl Into<String>,
207 hash: impl Into<String>,
208 created_at: i64,
209 sql: Vec<String>,
210 ) -> Self {
211 Self {
212 tag: tag.into(),
213 hash: hash.into(),
214 created_at,
215 sql,
216 }
217 }
218
219 #[inline]
221 #[must_use]
222 pub fn tag(&self) -> &str {
223 &self.tag
224 }
225
226 #[inline]
228 #[must_use]
229 pub fn name(&self) -> &str {
230 &self.tag
231 }
232
233 #[inline]
235 #[must_use]
236 pub fn hash(&self) -> &str {
237 &self.hash
238 }
239
240 #[inline]
242 #[must_use]
243 pub const fn created_at(&self) -> i64 {
244 self.created_at
245 }
246
247 #[inline]
253 #[must_use]
254 pub fn statements(&self) -> &[String] {
255 &self.sql
256 }
257
258 pub fn sqlite_execution(
266 &self,
267 ) -> Result<SqliteMigrationExecution<'_>, SqliteMigrationExecutionError> {
268 let mut foreign_keys_disabled = false;
269 let mut suspends_foreign_keys = false;
270 for statement in &self.sql {
271 match sqlite_foreign_keys_setting(statement)? {
272 Some(false) if foreign_keys_disabled => {
273 return Err(SqliteMigrationExecutionError::NestedForeignKeysOff);
274 }
275 Some(false) => {
276 foreign_keys_disabled = true;
277 suspends_foreign_keys = true;
278 }
279 Some(true) if !foreign_keys_disabled => {
280 return Err(SqliteMigrationExecutionError::ForeignKeysOnWithoutOff);
281 }
282 Some(true) => foreign_keys_disabled = false,
283 None => {}
284 }
285 }
286 if foreign_keys_disabled {
287 return Err(SqliteMigrationExecutionError::ForeignKeysOffWithoutOn);
288 }
289 Ok(SqliteMigrationExecution {
290 statements: &self.sql,
291 suspends_foreign_keys,
292 })
293 }
294
295 #[inline]
297 #[must_use]
298 pub fn is_empty(&self) -> bool {
299 self.sql.is_empty() || self.sql.iter().all(|s| s.trim().is_empty())
300 }
301
302 #[must_use]
304 pub fn has_postgres_concurrent_index(&self) -> bool {
305 self.sql
306 .iter()
307 .any(|statement| is_postgres_concurrent_index_statement(statement))
308 }
309}
310
311fn sqlite_foreign_keys_setting(
312 statement: &str,
313) -> Result<Option<bool>, SqliteMigrationExecutionError> {
314 let normalized: String = strip_sql_comments(statement)
315 .trim()
316 .trim_end_matches(';')
317 .chars()
318 .filter(|character| !character.is_ascii_whitespace())
319 .flat_map(char::to_lowercase)
320 .collect();
321 match sqlite_foreign_keys_assignment(&normalized) {
322 Some("=off" | "=0" | "=false" | "=no" | "(off)" | "(0)" | "(false)" | "(no)") => {
323 Ok(Some(false))
324 }
325 Some("=on" | "=1" | "=true" | "=yes" | "(on)" | "(1)" | "(true)" | "(yes)") => {
326 Ok(Some(true))
327 }
328 Some(_) => Err(SqliteMigrationExecutionError::UnsupportedForeignKeysPragma),
329 _ => Ok(None),
330 }
331}
332
333fn sqlite_foreign_keys_assignment(normalized: &str) -> Option<&str> {
334 let pragma = normalized.strip_prefix("pragma")?;
335 for name in [
336 "foreign_keys",
337 "\"foreign_keys\"",
338 "'foreign_keys'",
339 "`foreign_keys`",
340 "[foreign_keys]",
341 ] {
342 if let Some(assignment) = pragma.strip_prefix(name)
343 && matches!(assignment.as_bytes().first(), Some(b'=' | b'('))
344 {
345 return Some(assignment);
346 }
347 if let Some((_, assignment)) = pragma.rsplit_once(&format!(".{name}"))
348 && matches!(assignment.as_bytes().first(), Some(b'=' | b'('))
349 {
350 return Some(assignment);
351 }
352 }
353 None
354}
355
356fn strip_sql_comments(statement: &str) -> String {
357 let mut output = String::with_capacity(statement.len());
358 let mut characters = statement.chars().peekable();
359 let mut quote = None;
360
361 while let Some(character) = characters.next() {
362 if let Some(terminator) = quote {
363 output.push(character);
364 if character == terminator {
365 if terminator != ']' && characters.peek() == Some(&terminator) {
366 output.push(characters.next().expect("peeked quote is present"));
367 } else {
368 quote = None;
369 }
370 }
371 continue;
372 }
373
374 match character {
375 '\'' | '"' | '`' => {
376 quote = Some(character);
377 output.push(character);
378 }
379 '[' => {
380 quote = Some(']');
381 output.push(character);
382 }
383 '-' if characters.peek() == Some(&'-') => {
384 characters.next();
385 for comment_character in characters.by_ref() {
386 if comment_character == '\n' {
387 output.push('\n');
388 break;
389 }
390 }
391 }
392 '/' if characters.peek() == Some(&'*') => {
393 characters.next();
394 let mut closed = false;
395 while let Some(comment_character) = characters.next() {
396 if comment_character == '*' && characters.peek() == Some(&'/') {
397 characters.next();
398 closed = true;
399 break;
400 }
401 }
402 if !closed {
403 output.push_str("/*");
404 }
405 }
406 _ => output.push(character),
407 }
408 }
409
410 output
411}
412
413#[derive(Debug, Clone)]
415pub struct Migrations {
416 list: Vec<Migration>,
418 dialect: Dialect,
420 table: String,
422 schema: Option<String>,
424}
425
426impl Migrations {
427 #[must_use]
429 pub fn new(migrations: Vec<Migration>, dialect: Dialect) -> Self {
430 Self {
431 list: migrations,
432 dialect,
433 table: "__drizzle_migrations".to_string(),
434 schema: match dialect {
435 Dialect::PostgreSQL => Some("drizzle".to_string()),
436 _ => None,
437 },
438 }
439 }
440
441 pub fn with_tracking(migrations: Vec<Migration>, dialect: Dialect, tracking: Tracking) -> Self {
442 Self {
443 list: migrations,
444 dialect,
445 table: tracking.table.into_owned(),
446 schema: tracking.schema.map(std::borrow::Cow::into_owned),
447 }
448 }
449
450 #[must_use]
452 pub fn empty(dialect: Dialect) -> Self {
453 Self::new(Vec::new(), dialect)
454 }
455
456 #[inline]
458 #[must_use]
459 pub fn all(&self) -> &[Migration] {
460 &self.list
461 }
462
463 pub fn pending<'a, S>(&'a self, applied_names: &'a [S]) -> impl Iterator<Item = &'a Migration>
475 where
476 S: AsRef<str>,
477 {
478 self.list.iter().filter(move |m| {
479 let name = m.name();
480 !applied_names.iter().any(|applied| applied.as_ref() == name)
481 })
482 }
483
484 pub fn has_pending<S>(&self, applied_names: &[S]) -> bool
486 where
487 S: AsRef<str>,
488 {
489 self.pending(applied_names).next().is_some()
490 }
491
492 #[inline]
494 #[must_use]
495 pub const fn dialect(&self) -> Dialect {
496 self.dialect
497 }
498
499 #[inline]
501 #[must_use]
502 pub fn table_name(&self) -> &str {
503 &self.table
504 }
505
506 #[inline]
508 #[must_use]
509 pub fn schema_name(&self) -> Option<&str> {
510 self.schema.as_deref()
511 }
512
513 #[inline]
515 #[must_use]
516 pub fn table_ident_sql(&self) -> String {
517 self.table_ident()
518 }
519
520 #[must_use]
522 pub fn postgres_advisory_lock_key(&self) -> i64 {
523 let digest =
524 Sha256::digest(format!("drizzle-rs:migrate:{}", self.table_ident()).as_bytes());
525 i64::from_be_bytes(
526 digest[..8]
527 .try_into()
528 .expect("SHA-256 prefix is eight bytes"),
529 )
530 }
531
532 #[must_use]
534 pub fn has_postgres_concurrent_index(&self) -> bool {
535 self.list
536 .iter()
537 .any(Migration::has_postgres_concurrent_index)
538 }
539
540 #[must_use]
543 pub fn create_name_unique_index_sql(&self) -> Option<String> {
544 if self.dialect == Dialect::MySQL {
545 return None;
546 }
547 let digest = Sha256::digest(self.table_ident().as_bytes());
548 let suffix = digest[..8]
549 .iter()
550 .map(|byte| format!("{byte:02x}"))
551 .collect::<String>();
552 let index = quote_identifier(self.dialect, &format!("drizzle_migration_name_{suffix}"));
553 Some(format!(
554 "CREATE UNIQUE INDEX IF NOT EXISTS {index} ON {} (\"name\") WHERE \"name\" IS NOT NULL;",
555 self.table_ident()
556 ))
557 }
558
559 fn table_ident(&self) -> String {
561 match (&self.dialect, &self.schema) {
562 (Dialect::PostgreSQL, Some(schema)) => format!(
563 "{}.{}",
564 quote_identifier(self.dialect, schema),
565 quote_identifier(self.dialect, &self.table)
566 ),
567 _ => quote_identifier(self.dialect, &self.table),
568 }
569 }
570
571 #[must_use]
573 pub fn create_schema_sql(&self) -> Option<String> {
574 self.schema.as_ref().map(|schema| {
575 format!(
576 "CREATE SCHEMA IF NOT EXISTS {};",
577 quote_identifier(self.dialect, schema)
578 )
579 })
580 }
581
582 #[must_use]
589 pub fn create_table_sql(&self) -> String {
590 let table = self.table_ident();
591
592 match self.dialect {
593 Dialect::SQLite => format!(
594 r"CREATE TABLE IF NOT EXISTS {table} (
595 id INTEGER PRIMARY KEY,
596 hash text NOT NULL,
597 created_at numeric,
598 name text,
599 applied_at TEXT
600);"
601 ),
602 Dialect::PostgreSQL => format!(
603 r"CREATE TABLE IF NOT EXISTS {table} (
604 id SERIAL PRIMARY KEY,
605 hash TEXT NOT NULL,
606 created_at BIGINT,
607 name TEXT,
608 applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
609);"
610 ),
611 Dialect::MySQL => format!(
612 r"CREATE TABLE IF NOT EXISTS {table} (
613 id SERIAL PRIMARY KEY,
614 hash text NOT NULL,
615 created_at BIGINT,
616 name text,
617 applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
618);"
619 ),
620 }
621 }
622
623 #[must_use]
625 pub fn record_migration_sql(&self, migration: &Migration) -> String {
626 let table = self.table_ident();
627 let hash = escape_sql_string(migration.hash());
628 let name = escape_sql_string(migration.name());
629 let created_at = migration.created_at();
630
631 match self.dialect {
632 Dialect::SQLite | Dialect::PostgreSQL => {
633 format!(
634 r#"INSERT INTO {table} ("hash", "created_at", "name", "applied_at") VALUES ('{hash}', {created_at}, '{name}', CURRENT_TIMESTAMP);"#
635 )
636 }
637 Dialect::MySQL => {
638 format!(
639 r"INSERT INTO {table} (`hash`, `created_at`, `name`, `applied_at`) VALUES ('{hash}', {created_at}, '{name}', CURRENT_TIMESTAMP);"
640 )
641 }
642 }
643 }
644
645 #[must_use]
663 pub fn record_migration_started_sql(&self, migration: &Migration) -> String {
664 let table = self.table_ident();
665 let hash = escape_sql_string(migration.hash());
666 let name = escape_sql_string(migration.name());
667 let created_at = migration.created_at();
668
669 match self.dialect {
670 Dialect::SQLite | Dialect::PostgreSQL => {
671 format!(
672 r#"INSERT INTO {table} ("hash", "created_at", "name", "applied_at") VALUES ('{hash}', {created_at}, '{name}', NULL);"#
673 )
674 }
675 Dialect::MySQL => {
676 format!(
677 r"INSERT INTO {table} (`hash`, `created_at`, `name`, `applied_at`) VALUES ('{hash}', {created_at}, '{name}', NULL);"
678 )
679 }
680 }
681 }
682
683 #[must_use]
689 pub fn record_migration_finished_sql(&self, migration: &Migration) -> String {
690 let table = self.table_ident();
691 let name = escape_sql_string(migration.name());
692
693 match self.dialect {
694 Dialect::MySQL => format!(
695 r"UPDATE {table} SET `applied_at` = CURRENT_TIMESTAMP WHERE `name` = '{name}' AND `applied_at` IS NULL;"
696 ),
697 _ => format!(
698 r#"UPDATE {table} SET "applied_at" = CURRENT_TIMESTAMP WHERE "name" = '{name}' AND "applied_at" IS NULL;"#
699 ),
700 }
701 }
702
703 #[must_use]
709 pub fn clear_migration_started_sql(&self, migration: &Migration) -> String {
710 let table = self.table_ident();
711 let name = escape_sql_string(migration.name());
712
713 match self.dialect {
714 Dialect::MySQL => {
715 format!(r"DELETE FROM {table} WHERE `name` = '{name}' AND `applied_at` IS NULL;")
716 }
717 _ => {
718 format!(r#"DELETE FROM {table} WHERE "name" = '{name}' AND "applied_at" IS NULL;"#)
719 }
720 }
721 }
722
723 #[must_use]
731 pub fn backfill_migration_metadata_sql(&self, row: &MatchedMigrationMetadata) -> String {
732 let table = self.table_ident();
733 let name = escape_sql_string(&row.name);
734 let created_at = row.created_at;
735
736 let (name_column, applied_column, applied_expr, where_clause) = match self.dialect {
737 Dialect::MySQL => (
738 "`name`",
739 "`applied_at`",
740 format!("FROM_UNIXTIME({created_at} / 1000)"),
741 row.id.map_or_else(
742 || {
743 format!(
744 "`created_at` = {created_at} AND `hash` = '{}'",
745 escape_sql_string(&row.hash)
746 )
747 },
748 |id| format!("`id` = {id}"),
749 ),
750 ),
751 Dialect::PostgreSQL => (
752 "\"name\"",
753 "\"applied_at\"",
754 format!("to_timestamp({created_at}::double precision / 1000.0)"),
755 row.id.map_or_else(
756 || {
757 format!(
758 "\"created_at\" = {created_at} AND \"hash\" = '{}'",
759 escape_sql_string(&row.hash)
760 )
761 },
762 |id| format!("\"id\" = {id}"),
763 ),
764 ),
765 Dialect::SQLite => (
766 "\"name\"",
767 "\"applied_at\"",
768 format!("datetime({created_at} / 1000, 'unixepoch')"),
769 row.id.map_or_else(
770 || {
771 format!(
772 "\"created_at\" = {created_at} AND \"hash\" = '{}'",
773 escape_sql_string(&row.hash)
774 )
775 },
776 |id| format!("\"id\" = {id}"),
777 ),
778 ),
779 };
780
781 format!(
782 "UPDATE {table} SET {name_column} = '{name}', {applied_column} = {applied_expr} WHERE {where_clause}"
783 )
784 }
785
786 #[must_use]
801 pub fn applied_names_sql(&self) -> String {
802 let table = self.table_ident();
803 match self.dialect {
804 Dialect::MySQL => {
805 format!(
806 "SELECT `name` FROM {table} WHERE `name` IS NOT NULL AND `applied_at` IS NOT NULL ORDER BY id;"
807 )
808 }
809 _ => format!(
810 r#"SELECT "name" FROM {table} WHERE "name" IS NOT NULL AND "applied_at" IS NOT NULL ORDER BY id;"#
811 ),
812 }
813 }
814
815 #[must_use]
822 pub fn applied_records_sql(&self) -> String {
823 let table = self.table_ident();
824 match self.dialect {
825 Dialect::MySQL => {
826 format!(
827 "SELECT `hash`, `name`, (`applied_at` IS NULL) AS dirty FROM {table} WHERE `name` IS NOT NULL ORDER BY id;"
828 )
829 }
830 _ => format!(
831 r#"SELECT "hash", "name", ("applied_at" IS NULL) AS dirty FROM {table} WHERE "name" IS NOT NULL ORDER BY id;"#
832 ),
833 }
834 }
835
836 #[must_use]
842 pub fn dirty_names_sql(&self) -> String {
843 let table = self.table_ident();
844 match self.dialect {
845 Dialect::MySQL => {
846 format!(
847 "SELECT `name` FROM {table} WHERE `name` IS NOT NULL AND `applied_at` IS NULL ORDER BY id;"
848 )
849 }
850 _ => format!(
851 r#"SELECT "name" FROM {table} WHERE "name" IS NOT NULL AND "applied_at" IS NULL ORDER BY id;"#
852 ),
853 }
854 }
855
856 #[must_use]
862 pub fn interrupted_migration_error<S: AsRef<str>>(
863 &self,
864 dirty_names: &[S],
865 ) -> Option<MigratorError> {
866 if dirty_names.is_empty() {
867 return None;
868 }
869
870 let table = self.table_ident();
871 let names = dirty_names
872 .iter()
873 .map(|name| format!("`{}`", name.as_ref()))
874 .collect::<Vec<_>>()
875 .join(", ");
876 let plural = if dirty_names.len() == 1 { "" } else { "s" };
877 let first = escape_sql_string(dirty_names[0].as_ref());
878
879 Some(MigratorError::InterruptedMigration(format!(
880 "migration{plural} {names} {} interrupted mid-apply: the tracking row in {table} has \
881 a NULL `applied_at`, so an earlier run recorded the migration as started but never \
882 recorded it as finished. The database may be in a partially-migrated state, and \
883 re-running the migration as-is would fail (for example with `table already exists`).\n\
884 Recovery options:\n \
885 1. re-run with repair enabled (`drizzle migrate --repair`, or `migrate_with_repair` \
886 on the driver) to reconcile each remaining statement against the live schema\n \
887 2. resolve the partial state by hand, then either complete the row \
888 (UPDATE {table} SET \"applied_at\" = CURRENT_TIMESTAMP WHERE \"name\" = '{first}';) \
889 or discard it and re-run from scratch \
890 (DELETE FROM {table} WHERE \"name\" = '{first}';)",
891 if dirty_names.len() == 1 {
892 "was"
893 } else {
894 "were"
895 },
896 )))
897 }
898
899 pub fn resolve_dirty_migrations<S: AsRef<str>>(
908 &self,
909 dirty_names: &[S],
910 ) -> Result<Vec<&Migration>, MigratorError> {
911 let mut unknown = Vec::new();
912 for name in dirty_names {
913 if !self.list.iter().any(|m| m.name() == name.as_ref()) {
914 unknown.push(name.as_ref().to_string());
915 }
916 }
917
918 if !unknown.is_empty() {
919 return Err(MigratorError::UnrepairableMigration(format!(
920 "cannot repair: the tracking table in {} marks migration(s) {} as interrupted, \
921 but they are not present in the local migration set, so their statements are \
922 unknown. Restore the migration folder(s) and retry, or resolve the partial state \
923 by hand and delete the row(s) from {}.",
924 self.table_ident(),
925 unknown
926 .iter()
927 .map(|name| format!("`{name}`"))
928 .collect::<Vec<_>>()
929 .join(", "),
930 self.table_ident(),
931 )));
932 }
933
934 Ok(self
935 .list
936 .iter()
937 .filter(|m| dirty_names.iter().any(|name| name.as_ref() == m.name()))
938 .collect())
939 }
940
941 #[must_use]
943 pub fn table_exists_sql(&self) -> String {
944 let table = self.table.replace('\'', "''");
945 match self.dialect {
946 Dialect::SQLite => format!(
947 "SELECT name FROM sqlite_master WHERE type='table' AND name='{table}';"
948 ),
949 Dialect::PostgreSQL => self.schema.as_ref().map_or_else(
950 || {
951 format!(
952 "SELECT table_name FROM information_schema.tables WHERE table_name='{table}';"
953 )
954 },
955 |schema| {
956 let schema = schema.replace('\'', "''");
957 format!(
958 "SELECT table_name FROM information_schema.tables WHERE table_schema='{schema}' AND table_name='{table}';"
959 )
960 },
961 ),
962 Dialect::MySQL => format!(
963 "SELECT table_name FROM information_schema.tables WHERE table_name='{table}';"
964 ),
965 }
966 }
967}
968
969#[derive(Debug, thiserror::Error)]
971pub enum MigratorError {
972 #[error("Journal error: {0}")]
973 JournalError(String),
974
975 #[error("IO error: {0}")]
976 IoError(String),
977
978 #[error("Missing migration file: {0}")]
979 MissingMigration(String),
980
981 #[error("Migration failed: {0}")]
982 ExecutionError(String),
983
984 #[error("{0}")]
988 InterruptedMigration(String),
989
990 #[error("{0}")]
993 UnrepairableMigration(String),
994}
995
996#[must_use]
998pub fn is_postgres_concurrent_index_statement(sql: &str) -> bool {
999 let tokens = sql
1000 .split_whitespace()
1001 .take(4)
1002 .map(|token| token.trim_matches(|character: char| !character.is_ascii_alphabetic()))
1003 .map(str::to_ascii_uppercase)
1004 .collect::<Vec<_>>();
1005
1006 matches!(
1007 tokens.as_slice(),
1008 [create, index, concurrently, ..]
1009 if create == "CREATE" && index == "INDEX" && concurrently == "CONCURRENTLY"
1010 ) || matches!(
1011 tokens.as_slice(),
1012 [create, unique, index, concurrently, ..]
1013 if create == "CREATE"
1014 && unique == "UNIQUE"
1015 && index == "INDEX"
1016 && concurrently == "CONCURRENTLY"
1017 ) || matches!(
1018 tokens.as_slice(),
1019 [drop, index, concurrently, ..]
1020 if drop == "DROP" && index == "INDEX" && concurrently == "CONCURRENTLY"
1021 )
1022}
1023
1024pub(crate) fn compute_hash(sql: &str) -> String {
1030 let digest = Sha256::digest(sql.as_bytes());
1031 let mut out = String::with_capacity(digest.len() * 2);
1032
1033 for byte in digest {
1034 use std::fmt::Write;
1035 let _ = write!(&mut out, "{byte:02x}");
1036 }
1037
1038 out
1039}
1040
1041pub(crate) fn split_statements(sql: &str) -> Vec<String> {
1043 split_on_semicolons(sql)
1044}
1045
1046#[derive(Default)]
1056struct StatementState {
1057 header_tokens: Vec<String>,
1059 compound_header: bool,
1061 compound_depth: usize,
1063 pending_begin: bool,
1065 pending_end: bool,
1067 at_body_start: bool,
1070 last_char_wordy: bool,
1072}
1073
1074impl StatementState {
1075 const COMPOUND_KINDS: [&'static str; 4] = ["trigger", "procedure", "function", "event"];
1077 const PLAIN_KINDS: [&'static str; 5] = ["table", "index", "view", "schema", "virtual"];
1080
1081 fn note_significant(&mut self) {
1084 self.pending_begin = false;
1085 self.pending_end = false;
1086 self.at_body_start = false;
1087 }
1088
1089 fn note_token(&mut self, token: &str) {
1091 let lower = token.to_ascii_lowercase();
1092 let was_pending_begin = self.pending_begin;
1093 let was_at_body_start = self.at_body_start;
1094 self.note_significant();
1095
1096 if self.header_tokens.len() < 6 {
1097 self.header_tokens.push(lower.clone());
1098 if self.header_tokens[0] == "create"
1099 && self.header_tokens.len() > 1
1100 && !Self::PLAIN_KINDS.contains(&self.header_tokens[1].as_str())
1101 && self.header_tokens[1..]
1102 .iter()
1103 .any(|t| Self::COMPOUND_KINDS.contains(&t.as_str()))
1104 {
1105 self.compound_header = true;
1106 }
1107 }
1108
1109 match lower.as_str() {
1110 "begin" if self.compound_header && self.compound_depth == 0 => {
1111 self.compound_depth = 1;
1112 self.at_body_start = true;
1113 }
1114 "begin" => self.pending_begin = true,
1115 "atomic" if was_pending_begin => {
1116 self.compound_depth += 1;
1117 self.at_body_start = true;
1118 }
1119 "end" if self.compound_depth > 0 && was_at_body_start => {
1120 self.pending_end = true;
1121 }
1122 _ => {}
1123 }
1124 self.last_char_wordy = true;
1125 }
1126}
1127
1128fn split_on_semicolons(sql: &str) -> Vec<String> {
1134 const BREAKPOINT: &str = "--> statement-breakpoint";
1135
1136 let mut statements = Vec::new();
1137 let mut current = String::new();
1138 let mut pos = 0;
1139
1140 let mut in_single_quote = false;
1141 let mut in_double_quote = false;
1142 let mut in_line_comment = false;
1143 let mut block_comment_depth = 0usize;
1144 let mut dollar_tag: Option<String> = None;
1145 let mut state = StatementState::default();
1146
1147 while pos < sql.len() {
1148 if in_line_comment {
1150 let ch = sql[pos..].chars().next().unwrap_or('\0');
1151 let ch_len = ch.len_utf8();
1152 current.push_str(&sql[pos..pos + ch_len]);
1153 pos += ch_len;
1154 if ch == '\n' {
1155 in_line_comment = false;
1156 }
1157 continue;
1158 }
1159
1160 if block_comment_depth > 0 {
1162 if sql[pos..].starts_with("/*") {
1163 current.push_str("/*");
1164 pos += 2;
1165 block_comment_depth += 1;
1166 continue;
1167 }
1168 if sql[pos..].starts_with("*/") {
1169 current.push_str("*/");
1170 pos += 2;
1171 block_comment_depth = block_comment_depth.saturating_sub(1);
1172 continue;
1173 }
1174
1175 let ch = sql[pos..].chars().next().unwrap_or('\0');
1176 let ch_len = ch.len_utf8();
1177 current.push_str(&sql[pos..pos + ch_len]);
1178 pos += ch_len;
1179 continue;
1180 }
1181
1182 if let Some(tag) = dollar_tag.as_deref() {
1184 if sql[pos..].starts_with(tag) {
1185 current.push_str(tag);
1186 pos += tag.len();
1187 dollar_tag = None;
1188 state.last_char_wordy = true;
1189 continue;
1190 }
1191
1192 let ch = sql[pos..].chars().next().unwrap_or('\0');
1193 let ch_len = ch.len_utf8();
1194 current.push_str(&sql[pos..pos + ch_len]);
1195 pos += ch_len;
1196 continue;
1197 }
1198
1199 if in_single_quote {
1201 if sql[pos..].starts_with("''") {
1202 current.push_str("''");
1203 pos += 2;
1204 continue;
1205 }
1206 if sql[pos..].starts_with('\'') {
1207 current.push('\'');
1208 pos += 1;
1209 in_single_quote = false;
1210 state.last_char_wordy = true;
1211 continue;
1212 }
1213
1214 let ch = sql[pos..].chars().next().unwrap_or('\0');
1215 let ch_len = ch.len_utf8();
1216 current.push_str(&sql[pos..pos + ch_len]);
1217 pos += ch_len;
1218 continue;
1219 }
1220
1221 if in_double_quote {
1223 if sql[pos..].starts_with("\"\"") {
1224 current.push_str("\"\"");
1225 pos += 2;
1226 continue;
1227 }
1228 if sql[pos..].starts_with('"') {
1229 current.push('"');
1230 pos += 1;
1231 in_double_quote = false;
1232 state.last_char_wordy = true;
1233 continue;
1234 }
1235
1236 let ch = sql[pos..].chars().next().unwrap_or('\0');
1237 let ch_len = ch.len_utf8();
1238 current.push_str(&sql[pos..pos + ch_len]);
1239 pos += ch_len;
1240 continue;
1241 }
1242
1243 if sql[pos..].starts_with(BREAKPOINT) && line_prefix_is_whitespace(sql, pos) {
1245 let stmt = current.trim().to_string();
1246 if !stmt.is_empty() {
1247 statements.push(stmt);
1248 }
1249 current.clear();
1250 state = StatementState::default();
1251 pos += BREAKPOINT.len();
1252 continue;
1253 }
1254 if sql[pos..].starts_with("--") {
1255 current.push_str("--");
1256 pos += 2;
1257 in_line_comment = true;
1258 continue;
1259 }
1260 if sql[pos..].starts_with("/*") {
1261 current.push_str("/*");
1262 pos += 2;
1263 block_comment_depth = 1;
1264 continue;
1265 }
1266
1267 if sql[pos..].starts_with('\'') {
1269 current.push('\'');
1270 pos += 1;
1271 in_single_quote = true;
1272 state.note_significant();
1273 state.last_char_wordy = false;
1274 continue;
1275 }
1276 if sql[pos..].starts_with('"') {
1277 current.push('"');
1278 pos += 1;
1279 in_double_quote = true;
1280 state.note_significant();
1281 state.last_char_wordy = false;
1282 continue;
1283 }
1284
1285 if sql[pos..].starts_with('$')
1287 && let Some(tag) = parse_dollar_tag_start(sql, pos)
1288 {
1289 current.push_str(tag);
1290 pos += tag.len();
1291 dollar_tag = Some(tag.to_string());
1292 state.note_significant();
1293 state.last_char_wordy = false;
1294 continue;
1295 }
1296
1297 if sql[pos..].starts_with(';') {
1299 pos += 1;
1300 if state.compound_depth > 0 {
1301 if state.pending_end {
1302 state.pending_end = false;
1303 state.compound_depth -= 1;
1304 }
1305 if state.compound_depth > 0 {
1306 current.push(';');
1307 state.at_body_start = true;
1308 state.last_char_wordy = false;
1309 continue;
1310 }
1311 }
1314 let stmt = current.trim().to_string();
1315 if !stmt.is_empty() {
1316 statements.push(stmt);
1317 }
1318 current.clear();
1319 state = StatementState::default();
1320 continue;
1321 }
1322
1323 let ch = sql[pos..].chars().next().unwrap_or('\0');
1324 if !state.last_char_wordy && (ch.is_ascii_alphabetic() || ch == '_') {
1325 let rest = &sql[pos..];
1326 let token_len = rest
1327 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
1328 .unwrap_or(rest.len());
1329 current.push_str(&rest[..token_len]);
1330 pos += token_len;
1331 state.note_token(&rest[..token_len]);
1332 continue;
1333 }
1334
1335 let ch_len = ch.len_utf8();
1336 current.push_str(&sql[pos..pos + ch_len]);
1337 pos += ch_len;
1338 if !ch.is_whitespace() {
1339 state.note_significant();
1340 }
1341 state.last_char_wordy = ch.is_ascii_alphanumeric() || ch == '_';
1342 }
1343
1344 let stmt = current.trim().to_string();
1346 if !stmt.is_empty() {
1347 statements.push(stmt);
1348 }
1349
1350 statements
1351}
1352
1353fn line_prefix_is_whitespace(sql: &str, pos: usize) -> bool {
1354 let line_start = sql[..pos].rfind('\n').map_or(0, |index| index + 1);
1355 sql[line_start..pos].chars().all(char::is_whitespace)
1356}
1357
1358pub fn match_applied_migration_metadata(
1365 local_migrations: &[Migration],
1366 applied_rows: &[AppliedMigrationMetadata],
1367) -> Result<Vec<MatchedMigrationMetadata>, MigratorError> {
1368 use std::collections::HashMap;
1369
1370 let mut by_created_at = HashMap::<i64, Vec<&Migration>>::new();
1371 let mut by_hash = HashMap::<&str, &Migration>::new();
1372
1373 for migration in local_migrations {
1374 by_created_at
1375 .entry(migration.created_at())
1376 .or_default()
1377 .push(migration);
1378 by_hash.insert(migration.hash(), migration);
1379 }
1380
1381 let mut matched = Vec::with_capacity(applied_rows.len());
1382 let mut unmatched = Vec::new();
1383
1384 for row in applied_rows {
1385 let migration = match by_created_at.get(&row.created_at) {
1386 Some(candidates) if candidates.len() == 1 => Some(candidates[0]),
1387 Some(candidates) if candidates.len() > 1 => {
1388 candidates.iter().copied().find(|m| m.hash() == row.hash)
1389 }
1390 _ => by_hash.get(row.hash.as_str()).copied(),
1391 };
1392
1393 if let Some(migration) = migration {
1394 matched.push(MatchedMigrationMetadata {
1395 id: row.id,
1396 hash: row.hash.clone(),
1397 created_at: row.created_at,
1398 name: migration.name().to_string(),
1399 });
1400 } else {
1401 unmatched.push(format!(
1402 "[id: {:?}, created_at: {}, hash: {}]",
1403 row.id, row.created_at, row.hash
1404 ));
1405 }
1406 }
1407
1408 if unmatched.is_empty() {
1409 Ok(matched)
1410 } else {
1411 Err(MigratorError::ExecutionError(format!(
1412 "database contains applied migrations that do not match local migrations: {}",
1413 unmatched.join(", ")
1414 )))
1415 }
1416}
1417
1418fn escape_sql_string(value: &str) -> String {
1419 value.replace('\'', "''")
1420}
1421
1422fn parse_dollar_tag_start(sql: &str, pos: usize) -> Option<&str> {
1426 if !sql[pos..].starts_with('$') {
1427 return None;
1428 }
1429
1430 let mut i = pos + 1;
1431 while i < sql.len() {
1432 let ch = sql[i..].chars().next()?;
1433 if ch == '$' {
1434 return Some(&sql[pos..=i]);
1435 }
1436 if ch.is_ascii_alphanumeric() || ch == '_' {
1437 i += ch.len_utf8();
1438 continue;
1439 }
1440 return None;
1441 }
1442
1443 None
1444}
1445
1446pub(crate) fn parse_timestamp_from_tag(tag: &str) -> i64 {
1450 if let Some(prefix) = tag.get(0..14)
1452 && let Some(ts) = parse_timestamp_prefix_to_millis(prefix)
1453 {
1454 return ts;
1455 }
1456
1457 if let Some(prefix) = tag.get(0..4)
1459 && let Ok(idx) = prefix.parse::<i64>()
1460 {
1461 return idx;
1463 }
1464
1465 0
1469}
1470
1471fn parse_timestamp_prefix_to_millis(prefix: &str) -> Option<i64> {
1473 if prefix.len() != 14 || !prefix.chars().all(|ch| ch.is_ascii_digit()) {
1474 return None;
1475 }
1476
1477 let year = prefix[0..4].parse::<i32>().ok()?;
1478 let month = prefix[4..6].parse::<u32>().ok()?;
1479 let day = prefix[6..8].parse::<u32>().ok()?;
1480 let hour = prefix[8..10].parse::<u32>().ok()?;
1481 let minute = prefix[10..12].parse::<u32>().ok()?;
1482 let second = prefix[12..14].parse::<u32>().ok()?;
1483
1484 if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
1485 return None;
1486 }
1487
1488 let max_day = days_in_month(year, month);
1489 if day == 0 || day > max_day {
1490 return None;
1491 }
1492
1493 let days = days_from_civil(year, month, day)?;
1494 let day_secs = i64::from(hour) * 3_600 + i64::from(minute) * 60 + i64::from(second);
1495 let secs = days.checked_mul(86_400)?.checked_add(day_secs)?;
1496 secs.checked_mul(1_000)
1497}
1498
1499fn days_from_civil(year: i32, month: u32, day: u32) -> Option<i64> {
1503 let m = i32::try_from(month).ok()?;
1504 let d = i32::try_from(day).ok()?;
1505
1506 let y = year - i32::from(m <= 2);
1507 let era = if y >= 0 { y } else { y - 399 } / 400;
1508 let yoe = y - era * 400;
1509 let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
1510 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1511
1512 Some(i64::from(era) * 146_097 + i64::from(doe) - 719_468)
1513}
1514
1515const fn days_in_month(year: i32, month: u32) -> u32 {
1516 match month {
1517 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1518 4 | 6 | 9 | 11 => 30,
1519 2 if is_leap_year(year) => 29,
1520 2 => 28,
1521 _ => 0,
1522 }
1523}
1524
1525const fn is_leap_year(year: i32) -> bool {
1526 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
1527}
1528
1529#[macro_export]
1546macro_rules! migrations {
1547 [$(($tag:expr, $sql:expr)),* $(,)?] => {
1548 vec![
1549 $(
1550 $crate::Migration::new($tag, $sql),
1551 )*
1552 ]
1553 };
1554}
1555
1556#[cfg(test)]
1557mod tests {
1558 use super::{
1559 AppliedMigrationMetadata, Migration, Migrations, SqliteMigrationExecutionError,
1560 compute_hash, is_postgres_concurrent_index_statement, match_applied_migration_metadata,
1561 parse_timestamp_from_tag, split_on_semicolons, split_statements,
1562 };
1563 use crate::config::Tracking;
1564 use crate::dir::MigrationDir;
1565 use drizzle_types::Dialect;
1566
1567 #[test]
1568 fn sqlite_execution_lifts_foreign_key_pragmas_out_of_transactions() {
1569 let migration = Migration::new(
1570 "0001_rebuild",
1571 "PRAGMA foreign_keys = OFF;\n--> statement-breakpoint\nCREATE TABLE records (id INTEGER);\n--> statement-breakpoint\nPRAGMA foreign_keys=ON;",
1572 );
1573
1574 let execution = migration.sqlite_execution().expect("valid suspension");
1575 assert!(execution.suspends_foreign_keys());
1576 assert_eq!(
1577 execution.statements().collect::<Vec<_>>(),
1578 vec!["CREATE TABLE records (id INTEGER)"]
1579 );
1580 }
1581
1582 #[test]
1583 fn sqlite_execution_rejects_unbalanced_foreign_key_pragmas() {
1584 let missing_on = Migration::new("0001", "PRAGMA foreign_keys=OFF;");
1585 assert_eq!(
1586 missing_on.sqlite_execution().unwrap_err(),
1587 SqliteMigrationExecutionError::ForeignKeysOffWithoutOn
1588 );
1589
1590 let missing_off = Migration::new("0002", "PRAGMA foreign_keys=ON;");
1591 assert_eq!(
1592 missing_off.sqlite_execution().unwrap_err(),
1593 SqliteMigrationExecutionError::ForeignKeysOnWithoutOff
1594 );
1595
1596 let nested = Migration::new(
1597 "0003",
1598 "PRAGMA foreign_keys=OFF;\n--> statement-breakpoint\nPRAGMA foreign_keys=OFF;\n--> statement-breakpoint\nPRAGMA foreign_keys=ON;",
1599 );
1600 assert_eq!(
1601 nested.sqlite_execution().unwrap_err(),
1602 SqliteMigrationExecutionError::NestedForeignKeysOff
1603 );
1604
1605 let unsupported = Migration::new(
1606 "0004",
1607 "PRAGMA foreign_keys=disabled;\n--> statement-breakpoint\nPRAGMA foreign_keys=ON;",
1608 );
1609 assert_eq!(
1610 unsupported.sqlite_execution().unwrap_err(),
1611 SqliteMigrationExecutionError::UnsupportedForeignKeysPragma
1612 );
1613 }
1614
1615 #[test]
1616 fn sqlite_execution_accepts_parenthesized_and_commented_pragmas() {
1617 let migration = Migration::new(
1618 "0001_rebuild",
1619 "-- generated rebuild guard\nPRAGMA /* suspend enforcement */ main.'foreign_keys'(OFF);\n--> statement-breakpoint\nCREATE TABLE records (id INTEGER);\n--> statement-breakpoint\nPRAGMA \"main\".\"foreign_keys\" /* restore enforcement */ (ON);",
1620 );
1621
1622 let execution = migration.sqlite_execution().expect("valid suspension");
1623 assert!(execution.suspends_foreign_keys());
1624 assert_eq!(
1625 execution.statements().collect::<Vec<_>>(),
1626 vec!["CREATE TABLE records (id INTEGER)"]
1627 );
1628 }
1629
1630 #[test]
1631 fn migration_tracking_identifiers_are_escaped_per_dialect() {
1632 let sqlite = Migrations::with_tracking(
1633 Vec::new(),
1634 Dialect::SQLite,
1635 Tracking::new("migration\"records", None::<String>),
1636 );
1637 assert_eq!(sqlite.table_ident_sql(), "\"migration\"\"records\"");
1638 assert!(
1639 sqlite
1640 .create_table_sql()
1641 .starts_with("CREATE TABLE IF NOT EXISTS \"migration\"\"records\"")
1642 );
1643
1644 let postgres = Migrations::with_tracking(
1645 Vec::new(),
1646 Dialect::PostgreSQL,
1647 Tracking::new("migration\"records", Some("audit\"schema")),
1648 );
1649 assert_eq!(
1650 postgres.table_ident_sql(),
1651 "\"audit\"\"schema\".\"migration\"\"records\""
1652 );
1653 assert_eq!(
1654 postgres.create_schema_sql().as_deref(),
1655 Some("CREATE SCHEMA IF NOT EXISTS \"audit\"\"schema\";")
1656 );
1657
1658 let mysql = Migrations::with_tracking(
1659 Vec::new(),
1660 Dialect::MySQL,
1661 Tracking::new("migration`records", None::<String>),
1662 );
1663 assert_eq!(mysql.table_ident_sql(), "`migration``records`");
1664 }
1665
1666 #[test]
1667 fn split_handles_strings_and_comments() {
1668 let sql = "\
1669 CREATE TABLE users(id INTEGER, note TEXT DEFAULT 'a;b');\n\
1670 -- comment with ; should not split\n\
1671 CREATE INDEX users_id_idx ON users(id);\n\
1672 /* block ; comment */\n\
1673 CREATE TABLE posts(id INTEGER);\
1674 ";
1675
1676 let stmts = split_on_semicolons(sql);
1677 assert_eq!(stmts.len(), 3, "unexpected split: {stmts:?}");
1678 assert_eq!(
1679 stmts[0],
1680 "CREATE TABLE users(id INTEGER, note TEXT DEFAULT 'a;b')"
1681 );
1682 assert_eq!(
1683 stmts[1],
1684 "-- comment with ; should not split\nCREATE INDEX users_id_idx ON users(id)"
1685 );
1686 assert_eq!(
1687 stmts[2],
1688 "/* block ; comment */\nCREATE TABLE posts(id INTEGER)"
1689 );
1690 }
1691
1692 #[test]
1693 fn split_handles_dollar_quoted_bodies() {
1694 let sql = "\
1695 CREATE FUNCTION f() RETURNS void AS $$\n\
1696 BEGIN\n\
1697 RAISE NOTICE 'x;y';\n\
1698 END;\n\
1699 $$ LANGUAGE plpgsql;\n\
1700 CREATE TABLE t(id INTEGER);\
1701 ";
1702
1703 let stmts = split_on_semicolons(sql);
1704 assert_eq!(stmts.len(), 2, "unexpected split: {stmts:?}");
1705 assert_eq!(
1706 stmts[0],
1707 "CREATE FUNCTION f() RETURNS void AS $$\nBEGIN\nRAISE NOTICE 'x;y';\nEND;\n$$ LANGUAGE plpgsql"
1708 );
1709 assert_eq!(stmts[1], "CREATE TABLE t(id INTEGER)");
1710 }
1711
1712 #[test]
1713 fn split_handles_tagged_dollar_quotes() {
1714 let sql = "\
1715 DO $body$\n\
1716 BEGIN\n\
1717 PERFORM 1;\n\
1718 END;\n\
1719 $body$;\n\
1720 CREATE TABLE tagged(id INTEGER);\
1721 ";
1722
1723 let stmts = split_on_semicolons(sql);
1724 assert_eq!(stmts.len(), 2, "unexpected split: {stmts:?}");
1725 assert_eq!(stmts[0], "DO $body$\nBEGIN\nPERFORM 1;\nEND;\n$body$");
1726 assert_eq!(stmts[1], "CREATE TABLE tagged(id INTEGER)");
1727 }
1728
1729 #[test]
1730 fn split_keeps_sqlite_trigger_bodies_intact() {
1731 let sql = "\
1732 CREATE TABLE logs(msg TEXT);\n\
1733 CREATE TRIGGER users_ai AFTER INSERT ON users FOR EACH ROW BEGIN\n\
1734 INSERT INTO logs(msg) VALUES ('added;removed');\n\
1735 UPDATE counters SET n = n + 1 WHERE id = 1;\n\
1736 END;\n\
1737 CREATE INDEX logs_msg_idx ON logs(msg);\
1738 ";
1739
1740 let stmts = split_statements(sql);
1741 assert_eq!(stmts.len(), 3, "unexpected split: {stmts:?}");
1742 assert_eq!(stmts[0], "CREATE TABLE logs(msg TEXT)");
1743 assert!(stmts[1].starts_with("CREATE TRIGGER users_ai"));
1744 assert!(
1745 stmts[1].ends_with("END"),
1746 "trigger body truncated: {}",
1747 stmts[1]
1748 );
1749 assert!(stmts[1].contains("VALUES ('added;removed');"));
1750 assert!(stmts[1].contains("WHERE id = 1;"));
1751 assert_eq!(stmts[2], "CREATE INDEX logs_msg_idx ON logs(msg)");
1752 }
1753
1754 #[test]
1755 fn split_trigger_body_with_case_end_stays_intact() {
1756 let sql = "\
1757 CREATE TRIGGER t1 BEFORE UPDATE ON t WHEN (new.n > old.n) BEGIN\n\
1758 UPDATE t SET status = CASE WHEN new.n > 0 THEN 'pos' ELSE 'neg' END;\n\
1759 DELETE FROM audit WHERE id = old.id;\n\
1760 END;\n\
1761 CREATE TABLE afterwards(id INTEGER);\
1762 ";
1763
1764 let stmts = split_statements(sql);
1765 assert_eq!(stmts.len(), 2, "unexpected split: {stmts:?}");
1766 assert!(stmts[0].starts_with("CREATE TRIGGER t1"));
1767 assert!(
1768 stmts[0].ends_with("END"),
1769 "trigger body truncated: {}",
1770 stmts[0]
1771 );
1772 assert!(stmts[0].contains("ELSE 'neg' END;"));
1773 assert_eq!(stmts[1], "CREATE TABLE afterwards(id INTEGER)");
1774 }
1775
1776 #[test]
1777 fn split_keeps_begin_atomic_bodies_intact() {
1778 let sql = "\
1779 CREATE FUNCTION add_one(x int) RETURNS int LANGUAGE SQL BEGIN ATOMIC\n\
1780 SELECT x + 1;\n\
1781 END;\n\
1782 CREATE TABLE t(id INTEGER);\
1783 ";
1784
1785 let stmts = split_statements(sql);
1786 assert_eq!(stmts.len(), 2, "unexpected split: {stmts:?}");
1787 assert!(stmts[0].starts_with("CREATE FUNCTION add_one"));
1788 assert!(
1789 stmts[0].ends_with("END"),
1790 "atomic body truncated: {}",
1791 stmts[0]
1792 );
1793 assert!(stmts[0].contains("SELECT x + 1;"));
1794 assert_eq!(stmts[1], "CREATE TABLE t(id INTEGER)");
1795 }
1796
1797 #[test]
1798 fn split_plain_begin_transaction_still_splits() {
1799 let sql = "BEGIN;\nUPDATE t SET a = 1;\nCOMMIT;";
1800
1801 let stmts = split_statements(sql);
1802 assert_eq!(stmts.len(), 3, "unexpected split: {stmts:?}");
1803 assert_eq!(stmts[0], "BEGIN");
1804 assert_eq!(stmts[1], "UPDATE t SET a = 1");
1805 assert_eq!(stmts[2], "COMMIT");
1806 }
1807
1808 #[test]
1809 fn split_markers_and_trigger_bodies_coexist() {
1810 let sql = "\
1811 CREATE TABLE users(id INTEGER);\n\
1812 --> statement-breakpoint\n\
1813 CREATE TRIGGER trg AFTER DELETE ON users BEGIN\n\
1814 INSERT INTO audit(msg) VALUES ('gone');\n\
1815 END;\n\
1816 --> statement-breakpoint\n\
1817 CREATE TABLE audit(msg TEXT);\
1818 ";
1819
1820 let stmts = split_statements(sql);
1821 assert_eq!(stmts.len(), 3, "unexpected split: {stmts:?}");
1822 assert!(stmts[1].starts_with("CREATE TRIGGER trg"));
1823 assert!(
1824 stmts[1].ends_with("END"),
1825 "trigger body truncated: {}",
1826 stmts[1]
1827 );
1828 }
1829
1830 #[test]
1831 fn breakpoints_split_only_at_top_level_marker_lines() {
1832 let sql = r#"
1833 CREATE TABLE notes(value TEXT DEFAULT '--> statement-breakpoint');
1834 -- ordinary comment containing --> statement-breakpoint
1835 CREATE FUNCTION marker_text() RETURNS text AS $$
1836 BEGIN
1837 RETURN '--> statement-breakpoint';
1838 END;
1839 $$ LANGUAGE plpgsql;
1840 --> statement-breakpoint
1841 CREATE TABLE users(id INTEGER);
1842 "#;
1843
1844 let statements = split_statements(sql);
1845 assert_eq!(statements.len(), 3, "unexpected split: {statements:?}");
1846 assert!(statements[1].contains("ordinary comment containing"));
1847 assert!(statements[1].contains("RETURN '--> statement-breakpoint'"));
1848 assert_eq!(statements[2], "CREATE TABLE users(id INTEGER)");
1849 }
1850
1851 #[test]
1852 fn hash_is_stable_for_same_input() {
1853 let a = compute_hash("CREATE TABLE users(id INTEGER);");
1854 let b = compute_hash("CREATE TABLE users(id INTEGER);");
1855 let c = compute_hash("CREATE TABLE users(id INTEGER PRIMARY KEY);");
1856
1857 assert_eq!(a, b);
1858 assert_ne!(a, c);
1859 assert_eq!(a.len(), 64);
1860 }
1861
1862 #[test]
1863 fn hash_matches_known_value() {
1864 let hash = compute_hash("CREATE TABLE users(id INTEGER);");
1865 assert_eq!(
1866 hash,
1867 "238b0b8f98ac8bb3155ac1081ad6a3ce07cfba14eeaa6beeebf2161091265fcc"
1868 );
1869 }
1870
1871 #[test]
1872 fn concurrent_index_detection_is_token_aware() {
1873 assert!(is_postgres_concurrent_index_statement(
1874 "CREATE INDEX CONCURRENTLY users_email ON users (email)"
1875 ));
1876 assert!(is_postgres_concurrent_index_statement(
1877 "CREATE UNIQUE INDEX CONCURRENTLY users_email ON users (email)"
1878 ));
1879 assert!(is_postgres_concurrent_index_statement(
1880 "DROP INDEX CONCURRENTLY users_email"
1881 ));
1882 assert!(!is_postgres_concurrent_index_statement(
1883 "SELECT 'CREATE INDEX CONCURRENTLY hidden in text'"
1884 ));
1885 }
1886
1887 #[test]
1888 fn postgres_advisory_lock_key_is_stable_per_tracking_table() {
1889 let first = Migrations::with_tracking(
1890 Vec::new(),
1891 Dialect::PostgreSQL,
1892 Tracking::new("migrations", Some("audit")),
1893 );
1894 let same = first.clone();
1895 let different = Migrations::with_tracking(
1896 Vec::new(),
1897 Dialect::PostgreSQL,
1898 Tracking::new("other_migrations", Some("audit")),
1899 );
1900
1901 assert_eq!(
1902 first.postgres_advisory_lock_key(),
1903 same.postgres_advisory_lock_key()
1904 );
1905 assert_ne!(
1906 first.postgres_advisory_lock_key(),
1907 different.postgres_advisory_lock_key()
1908 );
1909 }
1910
1911 #[test]
1912 fn parse_timestamp_tag_matches_drizzle_orm_millis() {
1913 let created_at = parse_timestamp_from_tag("20230331141203_test");
1914 assert_eq!(created_at, 1_680_271_923_000);
1915 }
1916
1917 #[test]
1918 fn pending_is_set_difference_by_folder_name() {
1919 let set = Migrations::new(
1922 vec![
1923 super::Migration::with_hash(
1924 "20230331141203_alpha",
1925 "hash_a",
1926 1_680_271_923_000,
1927 vec!["A".into()],
1928 ),
1929 super::Migration::with_hash(
1930 "20230331141203_beta",
1931 "hash_b",
1932 1_680_271_923_000,
1933 vec!["B".into()],
1934 ),
1935 super::Migration::with_hash(
1936 "20230331141500_gamma",
1937 "hash_c",
1938 1_680_272_100_000,
1939 vec!["C".into()],
1940 ),
1941 ],
1942 Dialect::SQLite,
1943 );
1944
1945 let applied_names = vec!["20230331141203_alpha".to_string()];
1946 let pending: Vec<_> = set
1947 .pending(&applied_names)
1948 .map(|m| m.tag().to_string())
1949 .collect();
1950
1951 assert_eq!(
1952 pending,
1953 vec![
1954 "20230331141203_beta".to_string(),
1955 "20230331141500_gamma".to_string()
1956 ],
1957 "beta shares a created_at with alpha but must still run"
1958 );
1959 assert!(set.has_pending(&applied_names));
1960 }
1961
1962 #[test]
1963 fn pending_skips_already_applied_out_of_order() {
1964 let set = Migrations::new(
1968 vec![
1969 super::Migration::with_hash(
1970 "20240101010101_feature_a",
1971 "hash_a",
1972 1_704_070_861_000,
1973 vec!["A".into()],
1974 ),
1975 super::Migration::with_hash(
1976 "20240102010101_feature_b",
1977 "hash_b",
1978 1_704_157_261_000,
1979 vec!["B".into()],
1980 ),
1981 ],
1982 Dialect::SQLite,
1983 );
1984
1985 let applied_names = vec!["20240102010101_feature_b".to_string()];
1986 let pending: Vec<_> = set
1987 .pending(&applied_names)
1988 .map(|m| m.tag().to_string())
1989 .collect();
1990
1991 assert_eq!(pending, vec!["20240101010101_feature_a".to_string()]);
1992 }
1993
1994 #[test]
1995 fn applied_names_sql_selects_only_non_null_rows() {
1996 let set = Migrations::new(Vec::new(), Dialect::PostgreSQL);
1997 let sql = set.applied_names_sql();
1998 assert!(sql.contains("\"name\" IS NOT NULL"));
1999 assert!(sql.contains("ORDER BY id"));
2000 assert!(sql.contains("\"drizzle\".\"__drizzle_migrations\""));
2002 }
2003
2004 #[test]
2005 fn applied_records_sql_exposes_hash_and_dirty_flag() {
2006 let set = Migrations::new(Vec::new(), Dialect::PostgreSQL);
2007 let sql = set.applied_records_sql();
2008 assert!(sql.contains("\"hash\""));
2009 assert!(sql.contains("(\"applied_at\" IS NULL) AS dirty"));
2010 assert!(!sql.contains("\"applied_at\" IS NOT NULL"));
2013
2014 let mysql = Migrations::new(Vec::new(), Dialect::MySQL);
2015 let sql = mysql.applied_records_sql();
2016 assert!(sql.contains("`hash`"));
2017 assert!(sql.contains("(`applied_at` IS NULL) AS dirty"));
2018 }
2019
2020 fn sample_migration() -> super::Migration {
2021 super::Migration::with_hash(
2022 "20230331141203_test",
2023 "abc123",
2024 1_680_271_923_000,
2025 vec!["CREATE TABLE users(id INTEGER PRIMARY KEY)".to_string()],
2026 )
2027 }
2028
2029 #[test]
2030 fn applied_names_sql_excludes_dirty_rows() {
2031 for dialect in [Dialect::SQLite, Dialect::PostgreSQL, Dialect::MySQL] {
2032 let set = Migrations::new(Vec::new(), dialect);
2033 let applied = set.applied_names_sql();
2034 let dirty = set.dirty_names_sql();
2035
2036 if dialect == Dialect::MySQL {
2037 assert!(applied.contains("`applied_at` IS NOT NULL"), "{applied}");
2038 assert!(dirty.contains("`applied_at` IS NULL"), "{dirty}");
2039 assert!(dirty.contains("`name` IS NOT NULL"), "{dirty}");
2040 } else {
2041 assert!(applied.contains("\"applied_at\" IS NOT NULL"), "{applied}");
2042 assert!(dirty.contains("\"applied_at\" IS NULL"), "{dirty}");
2043 assert!(dirty.contains("\"name\" IS NOT NULL"), "{dirty}");
2044 }
2045 assert!(dirty.contains("ORDER BY id"));
2046 }
2047 }
2048
2049 #[test]
2050 fn two_phase_tracking_sql_marks_then_clears_dirty() {
2051 let migration = sample_migration();
2052 let set = Migrations::new(vec![migration.clone()], Dialect::SQLite);
2053
2054 let started = set.record_migration_started_sql(&migration);
2055 assert!(started.starts_with("INSERT INTO"));
2056 assert!(
2057 started.contains("'20230331141203_test', NULL)"),
2058 "phase 1 must write applied_at NULL explicitly: {started}"
2059 );
2060
2061 let finished = set.record_migration_finished_sql(&migration);
2062 assert!(finished.starts_with("UPDATE"));
2063 assert!(finished.contains("\"applied_at\" = CURRENT_TIMESTAMP"));
2064 assert!(
2065 finished.contains("\"applied_at\" IS NULL"),
2066 "phase 3 must only clear a still-dirty row: {finished}"
2067 );
2068
2069 let cleared = set.clear_migration_started_sql(&migration);
2070 assert!(cleared.starts_with("DELETE FROM"));
2071 assert!(cleared.contains("\"applied_at\" IS NULL"));
2072 }
2073
2074 #[test]
2075 fn two_phase_tracking_sql_quotes_per_dialect() {
2076 let migration = sample_migration();
2077
2078 let postgres = Migrations::new(vec![migration.clone()], Dialect::PostgreSQL);
2079 assert!(
2080 postgres
2081 .record_migration_started_sql(&migration)
2082 .contains("\"drizzle\".\"__drizzle_migrations\"")
2083 );
2084
2085 let mysql = Migrations::with_tracking(
2086 vec![migration.clone()],
2087 Dialect::MySQL,
2088 Tracking::new("__drizzle_migrations", None::<String>),
2089 );
2090 let started = mysql.record_migration_started_sql(&migration);
2091 assert!(started.contains("`hash`"), "{started}");
2092 assert!(started.contains("NULL)"), "{started}");
2093 assert!(
2094 mysql
2095 .record_migration_finished_sql(&migration)
2096 .contains("`applied_at` = CURRENT_TIMESTAMP")
2097 );
2098 }
2099
2100 #[test]
2101 fn started_row_is_not_reported_as_applied() {
2102 let set = Migrations::new(Vec::new(), Dialect::SQLite);
2105 assert_ne!(set.applied_names_sql(), set.dirty_names_sql());
2106 assert!(!set.applied_names_sql().contains("IS NULL ORDER"));
2107 }
2108
2109 #[test]
2110 fn interrupted_migration_error_is_none_when_clean() {
2111 let set = Migrations::new(Vec::new(), Dialect::SQLite);
2112 assert!(
2113 set.interrupted_migration_error::<String>(&[]).is_none(),
2114 "no dirty rows means no error"
2115 );
2116 }
2117
2118 #[test]
2119 fn interrupted_migration_error_names_migration_and_recovery() {
2120 let set = Migrations::new(Vec::new(), Dialect::SQLite);
2121 let error = set
2122 .interrupted_migration_error(&["20230331141203_test"])
2123 .expect("dirty row must produce an error");
2124 let text = error.to_string();
2125
2126 assert!(text.contains("`20230331141203_test`"), "{text}");
2127 assert!(text.contains("interrupted mid-apply"), "{text}");
2128 assert!(text.contains("NULL `applied_at`"), "{text}");
2129 assert!(text.contains("drizzle migrate --repair"), "{text}");
2130 assert!(text.contains("migrate_with_repair"), "{text}");
2131 assert!(
2132 text.contains("UPDATE \"__drizzle_migrations\" SET"),
2133 "{text}"
2134 );
2135 assert!(
2136 text.contains("DELETE FROM \"__drizzle_migrations\""),
2137 "{text}"
2138 );
2139 assert!(matches!(
2140 error,
2141 super::MigratorError::InterruptedMigration(_)
2142 ));
2143 }
2144
2145 #[test]
2146 fn interrupted_migration_error_pluralizes_and_lists_all() {
2147 let set = Migrations::new(Vec::new(), Dialect::SQLite);
2148 let text = set
2149 .interrupted_migration_error(&["a_one", "b_two"])
2150 .expect("dirty rows")
2151 .to_string();
2152 assert!(text.contains("migrations `a_one`, `b_two` were"), "{text}");
2153 }
2154
2155 #[test]
2156 fn backfill_metadata_sql_sets_applied_at_from_created_at() {
2157 let row = super::MatchedMigrationMetadata {
2158 id: Some(7),
2159 hash: "abc".to_string(),
2160 created_at: 1_680_271_923_000,
2161 name: "20230331141203_test".to_string(),
2162 };
2163
2164 let sqlite =
2165 Migrations::new(Vec::new(), Dialect::SQLite).backfill_migration_metadata_sql(&row);
2166 assert!(
2167 sqlite.contains("\"name\" = '20230331141203_test'"),
2168 "{sqlite}"
2169 );
2170 assert!(
2171 sqlite.contains("\"applied_at\" = datetime(1680271923000 / 1000, 'unixepoch')"),
2172 "legacy rows must not look dirty: {sqlite}"
2173 );
2174 assert!(sqlite.contains("\"id\" = 7"), "{sqlite}");
2175 assert!(!sqlite.contains("= NULL"), "{sqlite}");
2176
2177 let postgres =
2178 Migrations::new(Vec::new(), Dialect::PostgreSQL).backfill_migration_metadata_sql(&row);
2179 assert!(postgres.contains("to_timestamp("), "{postgres}");
2180 assert!(!postgres.contains("= NULL"), "{postgres}");
2181 }
2182
2183 #[test]
2184 fn backfill_metadata_sql_falls_back_to_hash_when_id_is_missing() {
2185 let row = super::MatchedMigrationMetadata {
2186 id: None,
2187 hash: "ab'c".to_string(),
2188 created_at: 12,
2189 name: "tag".to_string(),
2190 };
2191 let sql =
2192 Migrations::new(Vec::new(), Dialect::SQLite).backfill_migration_metadata_sql(&row);
2193 assert!(sql.contains("\"created_at\" = 12"), "{sql}");
2194 assert!(sql.contains("\"hash\" = 'ab''c'"), "{sql}");
2195 }
2196
2197 #[test]
2198 fn record_migration_sql_includes_name_and_applied_at() {
2199 let migration = super::Migration::with_hash(
2200 "20230331141203_test",
2201 "abc123",
2202 1_680_271_923_000,
2203 vec!["CREATE TABLE users(id INTEGER PRIMARY KEY)".to_string()],
2204 );
2205 let set = Migrations::new(vec![migration.clone()], Dialect::SQLite);
2206
2207 let sql = set.record_migration_sql(&migration);
2208 assert!(sql.contains("\"name\""));
2209 assert!(sql.contains("\"applied_at\""));
2210 assert!(sql.contains("20230331141203_test"));
2211 }
2212
2213 #[test]
2214 fn match_applied_metadata_prefers_hash_when_created_at_collides() {
2215 let migrations = vec![
2216 super::Migration::with_hash(
2217 "20230331141203_alpha",
2218 "hash_a",
2219 1_680_271_923_000,
2220 vec!["A".to_string()],
2221 ),
2222 super::Migration::with_hash(
2223 "20230331141203_beta",
2224 "hash_b",
2225 1_680_271_923_000,
2226 vec!["B".to_string()],
2227 ),
2228 ];
2229
2230 let matched = match_applied_migration_metadata(
2231 &migrations,
2232 &[AppliedMigrationMetadata {
2233 id: Some(1),
2234 hash: "hash_b".to_string(),
2235 created_at: 1_680_271_923_000,
2236 }],
2237 )
2238 .expect("match metadata");
2239
2240 assert_eq!(matched[0].name, "20230331141203_beta");
2241 }
2242
2243 #[test]
2244 fn match_applied_metadata_errors_for_unmatched_rows() {
2245 let migrations = vec![super::Migration::with_hash(
2246 "20230331141203_alpha",
2247 "hash_a",
2248 1_680_271_923_000,
2249 vec!["A".to_string()],
2250 )];
2251
2252 let err = match_applied_migration_metadata(
2253 &migrations,
2254 &[AppliedMigrationMetadata {
2255 id: Some(9),
2256 hash: "missing_hash".to_string(),
2257 created_at: 1_680_271_924_000,
2258 }],
2259 )
2260 .expect_err("should reject unmatched metadata");
2261
2262 assert!(err.to_string().contains("do not match local migrations"));
2263 }
2264
2265 #[test]
2266 fn from_dir_discovers_v3_migration_without_snapshot_file() {
2267 let dir = tempfile::tempdir().expect("tempdir");
2268 let migration_dir = dir.path().join("20230331141203_test");
2269 std::fs::create_dir_all(&migration_dir).expect("create migration dir");
2270 std::fs::write(
2271 migration_dir.join("migration.sql"),
2272 "CREATE TABLE users(id INTEGER PRIMARY KEY);",
2273 )
2274 .expect("write migration.sql");
2275
2276 let migrations = MigrationDir::new(dir.path())
2277 .discover()
2278 .expect("load migrations");
2279 assert_eq!(migrations.len(), 1);
2280 assert_eq!(migrations[0].created_at(), 1_680_271_923_000);
2281 }
2282
2283 #[test]
2284 fn from_dir_prefers_v3_when_both_formats_present() {
2285 let dir = tempfile::tempdir().expect("tempdir");
2286
2287 let mut journal = crate::journal::Journal::new(Dialect::SQLite);
2288 journal.add_entry("0000_journal_first".to_string(), true);
2289 journal
2290 .save(&dir.path().join("meta").join("_journal.json"))
2291 .expect("write journal");
2292
2293 std::fs::write(
2294 dir.path().join("0000_journal_first.sql"),
2295 "CREATE TABLE from_journal(id INTEGER PRIMARY KEY);",
2296 )
2297 .expect("write legacy migration file");
2298
2299 let v3_dir = dir.path().join("20240101010101_v3_extra");
2301 std::fs::create_dir_all(&v3_dir).expect("create v3 dir");
2302 std::fs::write(
2303 v3_dir.join("migration.sql"),
2304 "CREATE TABLE from_v3(id INTEGER PRIMARY KEY);",
2305 )
2306 .expect("write v3 migration.sql");
2307
2308 let migrations = MigrationDir::new(dir.path())
2309 .discover()
2310 .expect_err("legacy journal should be rejected");
2311 assert!(
2312 migrations
2313 .to_string()
2314 .contains("old drizzle-kit migration folders")
2315 );
2316 }
2317
2318 #[test]
2319 fn from_dir_rejects_legacy_journal_when_no_v3_dirs() {
2320 let dir = tempfile::tempdir().expect("tempdir");
2321
2322 let mut journal = crate::journal::Journal::new(Dialect::SQLite);
2323 journal.add_entry("0000_journal_first".to_string(), true);
2324 journal
2325 .save(&dir.path().join("meta").join("_journal.json"))
2326 .expect("write journal");
2327
2328 std::fs::write(
2329 dir.path().join("0000_journal_first.sql"),
2330 "CREATE TABLE from_journal(id INTEGER PRIMARY KEY);",
2331 )
2332 .expect("write legacy migration file");
2333 let err = MigrationDir::new(dir.path())
2334 .discover()
2335 .expect_err("legacy journal should be rejected");
2336 assert!(
2337 err.to_string()
2338 .contains("old drizzle-kit migration folders")
2339 );
2340 }
2341}