1use std::collections::{HashMap, HashSet};
12
13use serde::Serialize;
14
15use crate::directive::MigrationDirectives;
16use crate::error::{Result, WaypointError};
17use crate::migration::{MigrationVersion, ResolvedMigration};
18
19#[allow(deprecated)]
25#[cfg(feature = "mysql")]
26pub use crate::engines::mysql::migrate::execute as execute_mysql;
27#[cfg(feature = "mysql")]
28pub use crate::engines::mysql::migrate::execute_with_options as execute_mysql_with_options;
29#[allow(deprecated)]
30#[cfg(feature = "postgres")]
31pub use crate::engines::postgres::migrate::execute;
32#[cfg(feature = "postgres")]
33pub use crate::engines::postgres::migrate::execute_with_options;
34
35#[derive(Debug, Serialize)]
39pub struct MigrateReport {
40 pub migrations_applied: usize,
42 pub total_time_ms: i32,
44 pub details: Vec<MigrateDetail>,
46 pub hooks_executed: usize,
48 pub hooks_time_ms: i32,
50}
51
52#[derive(Debug, Serialize)]
54pub struct MigrateDetail {
55 pub version: Option<String>,
57 pub description: String,
59 pub script: String,
61 pub execution_time_ms: i32,
63}
64
65pub(crate) enum GuardAction {
69 Continue,
71 Skip,
73 Error(WaypointError),
75}
76
77pub(crate) fn classify_require(
87 outcome: Result<bool>,
88 expr_str: &str,
89 script: &str,
90 on_require_fail: &crate::guard::OnRequireFail,
91) -> GuardAction {
92 match outcome {
93 Ok(true) => GuardAction::Continue,
94 Ok(false) => match on_require_fail {
95 crate::guard::OnRequireFail::Skip => {
96 log::info!(
97 "Guard require failed, skipping migration; script={}, expr={}",
98 script,
99 expr_str
100 );
101 GuardAction::Skip
102 }
103 crate::guard::OnRequireFail::Warn => {
104 log::warn!(
105 "Guard require failed (continuing); script={}, expr={}",
106 script,
107 expr_str
108 );
109 GuardAction::Continue
110 }
111 crate::guard::OnRequireFail::Error => GuardAction::Error(WaypointError::GuardFailed {
112 kind: "require".to_string(),
113 script: script.to_string(),
114 expression: expr_str.to_string(),
115 }),
116 },
117 Err(e) => {
118 log::warn!(
119 "Guard evaluation error; script={}, expr={}, error={}",
120 script,
121 expr_str,
122 e
123 );
124 GuardAction::Error(WaypointError::GuardFailed {
125 kind: "require".to_string(),
126 script: script.to_string(),
127 expression: format!("{} ({})", expr_str, describe_guard_error(&e)),
128 })
129 }
130 }
131}
132
133pub(crate) fn classify_ensure(outcome: Result<bool>, expr_str: &str, script: &str) -> Result<()> {
135 match outcome {
136 Ok(true) => Ok(()),
137 Ok(false) => Err(WaypointError::GuardFailed {
138 kind: "ensure".to_string(),
139 script: script.to_string(),
140 expression: expr_str.to_string(),
141 }),
142 Err(e) => Err(WaypointError::GuardFailed {
143 kind: "ensure".to_string(),
144 script: script.to_string(),
145 expression: format!("{} ({})", expr_str, describe_guard_error(&e)),
146 }),
147 }
148}
149
150pub(crate) fn guard_parse_error(
155 kind: &str,
156 script: &str,
157 expr_str: &str,
158 e: &WaypointError,
159) -> WaypointError {
160 WaypointError::GuardFailed {
161 kind: kind.to_string(),
162 script: script.to_string(),
163 expression: format!("{} (parse error: {})", expr_str, e),
164 }
165}
166
167fn describe_guard_error(e: &WaypointError) -> String {
169 format!("evaluation error: {}", e)
170}
171
172pub(crate) struct PendingCriteria<'a> {
177 pub effective_versions: &'a HashSet<String>,
179 pub baseline_version: Option<&'a MigrationVersion>,
181 pub target: Option<&'a MigrationVersion>,
183 pub highest_applied: Option<&'a MigrationVersion>,
185 pub applied_scripts: &'a HashMap<String, Option<i32>>,
187 pub current_env: Option<&'a str>,
189 pub out_of_order: bool,
191 pub dependency_ordering: bool,
198}
199
200#[derive(Debug)]
202pub(crate) struct PendingSelection<'a> {
203 pub versioned: Vec<&'a ResolvedMigration>,
205 pub repeatables: Vec<&'a ResolvedMigration>,
207}
208
209pub(crate) fn select_pending<'a>(
216 resolved: &'a [ResolvedMigration],
217 criteria: &PendingCriteria<'_>,
218) -> Result<PendingSelection<'a>> {
219 let mut versioned: Vec<&ResolvedMigration> = Vec::new();
220
221 for migration in resolved.iter().filter(|m| m.is_versioned()) {
222 if !should_run_in_environment(&migration.directives, criteria.current_env) {
223 continue;
224 }
225 let version = match migration.version() {
227 Some(v) => v,
228 None => continue,
229 };
230
231 if criteria.effective_versions.contains(&version.raw) {
232 continue;
233 }
234 if let Some(baseline) = criteria.baseline_version
235 && version <= baseline
236 {
237 log::debug!("Skipping {} (below baseline)", migration.script);
238 continue;
239 }
240 if let Some(target) = criteria.target
241 && version > target
242 {
243 log::debug!("Skipping {} (above target {})", migration.script, target);
244 continue;
245 }
246 if !criteria.out_of_order
247 && let Some(highest) = criteria.highest_applied
248 && version < highest
249 {
250 return Err(WaypointError::OutOfOrder {
251 version: version.raw.clone(),
252 highest: highest.raw.clone(),
253 });
254 }
255
256 versioned.push(migration);
257 }
258
259 if criteria.dependency_ordering {
260 order_by_dependencies(resolved, &mut versioned)?;
261 } else {
262 versioned.sort_by(|a, b| a.version().cmp(&b.version()));
263 }
264
265 let repeatables: Vec<&ResolvedMigration> = resolved
266 .iter()
267 .filter(|m| !m.is_versioned() && !m.is_undo())
268 .filter(|m| should_run_in_environment(&m.directives, criteria.current_env))
269 .filter(|m| match criteria.applied_scripts.get(&m.script) {
270 None => true,
271 Some(applied) => *applied != Some(m.checksum),
272 })
273 .collect();
274
275 Ok(PendingSelection {
276 versioned,
277 repeatables,
278 })
279}
280
281fn order_by_dependencies(
288 resolved: &[ResolvedMigration],
289 pending: &mut Vec<&ResolvedMigration>,
290) -> Result<()> {
291 let all: Vec<&ResolvedMigration> = resolved.iter().collect();
292 let graph = crate::dependency::DependencyGraph::build(&all, true)?;
296 let order = graph.topological_sort()?;
297
298 let rank: HashMap<&str, usize> = order
299 .iter()
300 .enumerate()
301 .map(|(i, v)| (v.as_str(), i))
302 .collect();
303
304 pending.sort_by_key(|m| {
305 m.version()
306 .and_then(|v| rank.get(v.raw.as_str()).copied())
307 .unwrap_or(usize::MAX)
311 });
312 Ok(())
313}
314
315pub(crate) fn should_run_in_environment(
322 directives: &MigrationDirectives,
323 current_env: Option<&str>,
324) -> bool {
325 if directives.env.is_empty() {
326 return true;
327 }
328 let env = match current_env {
329 Some(e) => e,
330 None => return true,
331 };
332 directives.env.iter().any(|e| e.eq_ignore_ascii_case(env))
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn test_should_run_in_environment_no_directives() {
341 let directives = MigrationDirectives::default();
342 assert!(should_run_in_environment(&directives, Some("production")));
343 assert!(should_run_in_environment(&directives, None));
344 }
345
346 #[test]
347 fn test_should_run_in_environment_matches() {
348 let directives = MigrationDirectives {
349 env: vec!["production".to_string(), "staging".to_string()],
350 ..Default::default()
351 };
352 assert!(should_run_in_environment(&directives, Some("production")));
353 assert!(should_run_in_environment(&directives, Some("staging")));
354 assert!(!should_run_in_environment(&directives, Some("dev")));
355 }
356
357 #[test]
358 fn test_should_run_in_environment_case_insensitive() {
359 let directives = MigrationDirectives {
360 env: vec!["PROD".to_string()],
361 ..Default::default()
362 };
363 assert!(should_run_in_environment(&directives, Some("prod")));
364 assert!(should_run_in_environment(&directives, Some("PROD")));
365 assert!(should_run_in_environment(&directives, Some("Prod")));
366 assert!(!should_run_in_environment(&directives, Some("dev")));
367 }
368
369 #[test]
370 fn test_should_run_in_environment_no_env_configured() {
371 let directives = MigrationDirectives {
372 env: vec!["prod".to_string()],
373 ..Default::default()
374 };
375 assert!(should_run_in_environment(&directives, None));
376 }
377
378 use crate::migration::{MigrationKind, MigrationVersion};
379
380 fn mig(name: &str, depends: &[&str]) -> ResolvedMigration {
381 let (kind, description) = crate::migration::parse_migration_filename(name).unwrap();
382 ResolvedMigration {
383 kind,
384 description,
385 script: name.to_string(),
386 checksum: 1,
387 sql: String::new(),
388 directives: MigrationDirectives {
389 depends: depends.iter().map(|s| s.to_string()).collect(),
390 ..Default::default()
391 },
392 }
393 }
394
395 fn criteria<'a>(
396 applied: &'a HashSet<String>,
397 scripts: &'a HashMap<String, Option<i32>>,
398 highest: Option<&'a MigrationVersion>,
399 ) -> PendingCriteria<'a> {
400 PendingCriteria {
401 effective_versions: applied,
402 baseline_version: None,
403 target: None,
404 highest_applied: highest,
405 applied_scripts: scripts,
406 current_env: None,
407 out_of_order: false,
408 dependency_ordering: false,
409 }
410 }
411
412 #[test]
413 fn select_pending_orders_by_version() {
414 let migs = vec![mig("V10__Ten.sql", &[]), mig("V2__Two.sql", &[])];
415 let applied = HashSet::new();
416 let scripts = HashMap::new();
417 let out = select_pending(&migs, &criteria(&applied, &scripts, None)).unwrap();
418 let order: Vec<&str> = out.versioned.iter().map(|m| m.script.as_str()).collect();
419 assert_eq!(order, vec!["V2__Two.sql", "V10__Ten.sql"]);
420 }
421
422 #[test]
423 fn select_pending_errors_on_out_of_order() {
424 let migs = vec![mig("V1__One.sql", &[])];
427 let mut applied = HashSet::new();
428 applied.insert("5".to_string());
429 let scripts = HashMap::new();
430 let highest = MigrationVersion::parse("5").unwrap();
431 let err = select_pending(&migs, &criteria(&applied, &scripts, Some(&highest))).unwrap_err();
432 assert!(
433 matches!(err, WaypointError::OutOfOrder { .. }),
434 "expected OutOfOrder, got {err:?}"
435 );
436 }
437
438 #[test]
439 fn select_pending_allows_out_of_order_when_enabled() {
440 let migs = vec![mig("V1__One.sql", &[])];
441 let mut applied = HashSet::new();
442 applied.insert("5".to_string());
443 let scripts = HashMap::new();
444 let highest = MigrationVersion::parse("5").unwrap();
445 let mut c = criteria(&applied, &scripts, Some(&highest));
446 c.out_of_order = true;
447 let out = select_pending(&migs, &c).unwrap();
448 assert_eq!(out.versioned.len(), 1);
449 }
450
451 #[test]
452 fn select_pending_honours_depends_directive() {
453 let migs = vec![
456 mig("V1__One.sql", &[]),
457 mig("V2__Two.sql", &["3"]),
458 mig("V3__Three.sql", &[]),
459 ];
460 let applied = HashSet::new();
461 let scripts = HashMap::new();
462 let mut c = criteria(&applied, &scripts, None);
463 c.dependency_ordering = true;
464 let out = select_pending(&migs, &c).unwrap();
465 let order: Vec<&str> = out.versioned.iter().map(|m| m.script.as_str()).collect();
466 assert_eq!(
467 order,
468 vec!["V1__One.sql", "V3__Three.sql", "V2__Two.sql"],
469 "V2 depends on V3 so it must follow it"
470 );
471 }
472
473 #[test]
474 fn select_pending_dependency_ordering_is_version_order_without_directives() {
475 let migs = vec![
476 mig("V1__One.sql", &[]),
477 mig("V2__Two.sql", &[]),
478 mig("V3__Three.sql", &[]),
479 ];
480 let applied = HashSet::new();
481 let scripts = HashMap::new();
482 let mut c = criteria(&applied, &scripts, None);
483 c.dependency_ordering = true;
484 let out = select_pending(&migs, &c).unwrap();
485 let order: Vec<&str> = out.versioned.iter().map(|m| m.script.as_str()).collect();
486 assert_eq!(
487 order,
488 vec!["V1__One.sql", "V2__Two.sql", "V3__Three.sql"],
489 "no directives means dependency ordering degrades to version order"
490 );
491 }
492
493 #[test]
494 fn select_pending_repeatable_reruns_on_checksum_change() {
495 let mut r = mig("V1__One.sql", &[]);
496 r.kind = MigrationKind::Repeatable;
497 r.script = "R__View.sql".to_string();
498 r.checksum = 99;
499 let migs = vec![r];
500 let applied = HashSet::new();
501
502 let mut scripts = HashMap::new();
503 scripts.insert("R__View.sql".to_string(), Some(99));
504 let out = select_pending(&migs, &criteria(&applied, &scripts, None)).unwrap();
505 assert!(
506 out.repeatables.is_empty(),
507 "unchanged checksum must not re-run"
508 );
509
510 let mut scripts = HashMap::new();
511 scripts.insert("R__View.sql".to_string(), Some(1));
512 let out = select_pending(&migs, &criteria(&applied, &scripts, None)).unwrap();
513 assert_eq!(out.repeatables.len(), 1, "changed checksum must re-run");
514 }
515}