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 #[serde(default)]
58 pub skipped: Vec<SkippedMigration>,
59}
60
61#[derive(Debug, Serialize)]
63pub struct SkippedMigration {
64 pub version: Option<String>,
66 pub script: String,
68 pub expression: String,
70}
71
72#[derive(Debug, Serialize)]
74pub struct MigrateDetail {
75 pub version: Option<String>,
77 pub description: String,
79 pub script: String,
81 pub execution_time_ms: i32,
83}
84
85pub(crate) enum GuardAction {
89 Continue,
91 Skip(String),
97 Error(WaypointError),
99}
100
101pub(crate) fn classify_require(
111 outcome: Result<bool>,
112 expr_str: &str,
113 script: &str,
114 on_require_fail: &crate::guard::OnRequireFail,
115) -> GuardAction {
116 match outcome {
117 Ok(true) => GuardAction::Continue,
118 Ok(false) => match on_require_fail {
119 crate::guard::OnRequireFail::Skip => {
120 log::info!(
121 "Guard require failed, skipping migration; script={}, expr={}",
122 script,
123 expr_str
124 );
125 GuardAction::Skip(expr_str.to_string())
126 }
127 crate::guard::OnRequireFail::Warn => {
128 log::warn!(
129 "Guard require failed (continuing); script={}, expr={}",
130 script,
131 expr_str
132 );
133 GuardAction::Continue
134 }
135 crate::guard::OnRequireFail::Error => GuardAction::Error(WaypointError::GuardFailed {
136 kind: "require".to_string(),
137 script: script.to_string(),
138 expression: expr_str.to_string(),
139 }),
140 },
141 Err(e) => {
142 log::warn!(
143 "Guard evaluation error; script={}, expr={}, error={}",
144 script,
145 expr_str,
146 e
147 );
148 GuardAction::Error(WaypointError::GuardFailed {
149 kind: "require".to_string(),
150 script: script.to_string(),
151 expression: format!("{} ({})", expr_str, describe_guard_error(&e)),
152 })
153 }
154 }
155}
156
157pub(crate) fn classify_ensure(outcome: Result<bool>, expr_str: &str, script: &str) -> Result<()> {
159 match outcome {
160 Ok(true) => Ok(()),
161 Ok(false) => Err(WaypointError::GuardFailed {
162 kind: "ensure".to_string(),
163 script: script.to_string(),
164 expression: expr_str.to_string(),
165 }),
166 Err(e) => Err(WaypointError::GuardFailed {
167 kind: "ensure".to_string(),
168 script: script.to_string(),
169 expression: format!("{} ({})", expr_str, describe_guard_error(&e)),
170 }),
171 }
172}
173
174pub(crate) fn guard_parse_error(
179 kind: &str,
180 script: &str,
181 expr_str: &str,
182 e: &WaypointError,
183) -> WaypointError {
184 WaypointError::GuardFailed {
185 kind: kind.to_string(),
186 script: script.to_string(),
187 expression: format!("{} (parse error: {})", expr_str, e),
188 }
189}
190
191fn describe_guard_error(e: &WaypointError) -> String {
193 format!("evaluation error: {}", e)
194}
195
196pub(crate) struct PendingCriteria<'a> {
201 pub effective_versions: &'a HashSet<String>,
203 pub baseline_version: Option<&'a MigrationVersion>,
205 pub target: Option<&'a MigrationVersion>,
207 pub highest_applied: Option<&'a MigrationVersion>,
209 pub applied_scripts: &'a HashMap<String, Option<i32>>,
211 pub current_env: Option<&'a str>,
213 pub out_of_order: bool,
215 pub dependency_ordering: bool,
222}
223
224#[derive(Debug)]
226pub(crate) struct PendingSelection<'a> {
227 pub versioned: Vec<&'a ResolvedMigration>,
229 pub repeatables: Vec<&'a ResolvedMigration>,
231}
232
233pub(crate) fn select_pending<'a>(
240 resolved: &'a [ResolvedMigration],
241 criteria: &PendingCriteria<'_>,
242) -> Result<PendingSelection<'a>> {
243 let mut versioned: Vec<&ResolvedMigration> = Vec::new();
244
245 for migration in resolved.iter().filter(|m| m.is_versioned()) {
246 if !should_run_in_environment(&migration.directives, criteria.current_env) {
247 continue;
248 }
249 let version = match migration.version() {
251 Some(v) => v,
252 None => continue,
253 };
254
255 if criteria.effective_versions.contains(&version.raw) {
256 continue;
257 }
258 if let Some(baseline) = criteria.baseline_version
259 && version <= baseline
260 {
261 log::debug!("Skipping {} (below baseline)", migration.script);
262 continue;
263 }
264 if let Some(target) = criteria.target
265 && version > target
266 {
267 log::debug!("Skipping {} (above target {})", migration.script, target);
268 continue;
269 }
270 if !criteria.out_of_order
271 && let Some(highest) = criteria.highest_applied
272 && version < highest
273 {
274 return Err(WaypointError::OutOfOrder {
275 version: version.raw.clone(),
276 highest: highest.raw.clone(),
277 });
278 }
279
280 versioned.push(migration);
281 }
282
283 if criteria.dependency_ordering {
284 order_by_dependencies(resolved, &mut versioned)?;
285 } else {
286 versioned.sort_by(|a, b| a.version().cmp(&b.version()));
287 }
288
289 let repeatables: Vec<&ResolvedMigration> = resolved
290 .iter()
291 .filter(|m| !m.is_versioned() && !m.is_undo())
292 .filter(|m| should_run_in_environment(&m.directives, criteria.current_env))
293 .filter(|m| match criteria.applied_scripts.get(&m.script) {
294 None => true,
295 Some(applied) => *applied != Some(m.checksum),
296 })
297 .collect();
298
299 Ok(PendingSelection {
300 versioned,
301 repeatables,
302 })
303}
304
305fn order_by_dependencies(
312 resolved: &[ResolvedMigration],
313 pending: &mut Vec<&ResolvedMigration>,
314) -> Result<()> {
315 let all: Vec<&ResolvedMigration> = resolved.iter().collect();
316 let graph = crate::dependency::DependencyGraph::build(&all, true)?;
320 let order = graph.topological_sort()?;
321
322 let rank: HashMap<&str, usize> = order
323 .iter()
324 .enumerate()
325 .map(|(i, v)| (v.as_str(), i))
326 .collect();
327
328 pending.sort_by_key(|m| {
329 m.version()
330 .and_then(|v| rank.get(v.raw.as_str()).copied())
331 .unwrap_or(usize::MAX)
335 });
336 Ok(())
337}
338
339pub(crate) fn should_run_in_environment(
346 directives: &MigrationDirectives,
347 current_env: Option<&str>,
348) -> bool {
349 if directives.env.is_empty() {
350 return true;
351 }
352 let env = match current_env {
353 Some(e) => e,
354 None => return true,
355 };
356 directives.env.iter().any(|e| e.eq_ignore_ascii_case(env))
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn test_should_run_in_environment_no_directives() {
365 let directives = MigrationDirectives::default();
366 assert!(should_run_in_environment(&directives, Some("production")));
367 assert!(should_run_in_environment(&directives, None));
368 }
369
370 #[test]
371 fn test_should_run_in_environment_matches() {
372 let directives = MigrationDirectives {
373 env: vec!["production".to_string(), "staging".to_string()],
374 ..Default::default()
375 };
376 assert!(should_run_in_environment(&directives, Some("production")));
377 assert!(should_run_in_environment(&directives, Some("staging")));
378 assert!(!should_run_in_environment(&directives, Some("dev")));
379 }
380
381 #[test]
382 fn test_should_run_in_environment_case_insensitive() {
383 let directives = MigrationDirectives {
384 env: vec!["PROD".to_string()],
385 ..Default::default()
386 };
387 assert!(should_run_in_environment(&directives, Some("prod")));
388 assert!(should_run_in_environment(&directives, Some("PROD")));
389 assert!(should_run_in_environment(&directives, Some("Prod")));
390 assert!(!should_run_in_environment(&directives, Some("dev")));
391 }
392
393 #[test]
394 fn test_should_run_in_environment_no_env_configured() {
395 let directives = MigrationDirectives {
396 env: vec!["prod".to_string()],
397 ..Default::default()
398 };
399 assert!(should_run_in_environment(&directives, None));
400 }
401
402 use crate::migration::{MigrationKind, MigrationVersion};
403
404 fn mig(name: &str, depends: &[&str]) -> ResolvedMigration {
405 let (kind, description) = crate::migration::parse_migration_filename(name).unwrap();
406 ResolvedMigration {
407 kind,
408 description,
409 script: name.to_string(),
410 checksum: 1,
411 sql: String::new(),
412 directives: MigrationDirectives {
413 depends: depends.iter().map(|s| s.to_string()).collect(),
414 ..Default::default()
415 },
416 }
417 }
418
419 fn criteria<'a>(
420 applied: &'a HashSet<String>,
421 scripts: &'a HashMap<String, Option<i32>>,
422 highest: Option<&'a MigrationVersion>,
423 ) -> PendingCriteria<'a> {
424 PendingCriteria {
425 effective_versions: applied,
426 baseline_version: None,
427 target: None,
428 highest_applied: highest,
429 applied_scripts: scripts,
430 current_env: None,
431 out_of_order: false,
432 dependency_ordering: false,
433 }
434 }
435
436 #[test]
437 fn select_pending_orders_by_version() {
438 let migs = vec![mig("V10__Ten.sql", &[]), mig("V2__Two.sql", &[])];
439 let applied = HashSet::new();
440 let scripts = HashMap::new();
441 let out = select_pending(&migs, &criteria(&applied, &scripts, None)).unwrap();
442 let order: Vec<&str> = out.versioned.iter().map(|m| m.script.as_str()).collect();
443 assert_eq!(order, vec!["V2__Two.sql", "V10__Ten.sql"]);
444 }
445
446 #[test]
447 fn select_pending_errors_on_out_of_order() {
448 let migs = vec![mig("V1__One.sql", &[])];
451 let mut applied = HashSet::new();
452 applied.insert("5".to_string());
453 let scripts = HashMap::new();
454 let highest = MigrationVersion::parse("5").unwrap();
455 let err = select_pending(&migs, &criteria(&applied, &scripts, Some(&highest))).unwrap_err();
456 assert!(
457 matches!(err, WaypointError::OutOfOrder { .. }),
458 "expected OutOfOrder, got {err:?}"
459 );
460 }
461
462 #[test]
463 fn select_pending_allows_out_of_order_when_enabled() {
464 let migs = vec![mig("V1__One.sql", &[])];
465 let mut applied = HashSet::new();
466 applied.insert("5".to_string());
467 let scripts = HashMap::new();
468 let highest = MigrationVersion::parse("5").unwrap();
469 let mut c = criteria(&applied, &scripts, Some(&highest));
470 c.out_of_order = true;
471 let out = select_pending(&migs, &c).unwrap();
472 assert_eq!(out.versioned.len(), 1);
473 }
474
475 #[test]
476 fn select_pending_honours_depends_directive() {
477 let migs = vec![
480 mig("V1__One.sql", &[]),
481 mig("V2__Two.sql", &["3"]),
482 mig("V3__Three.sql", &[]),
483 ];
484 let applied = HashSet::new();
485 let scripts = HashMap::new();
486 let mut c = criteria(&applied, &scripts, None);
487 c.dependency_ordering = true;
488 let out = select_pending(&migs, &c).unwrap();
489 let order: Vec<&str> = out.versioned.iter().map(|m| m.script.as_str()).collect();
490 assert_eq!(
491 order,
492 vec!["V1__One.sql", "V3__Three.sql", "V2__Two.sql"],
493 "V2 depends on V3 so it must follow it"
494 );
495 }
496
497 #[test]
498 fn select_pending_dependency_ordering_is_version_order_without_directives() {
499 let migs = vec![
500 mig("V1__One.sql", &[]),
501 mig("V2__Two.sql", &[]),
502 mig("V3__Three.sql", &[]),
503 ];
504 let applied = HashSet::new();
505 let scripts = HashMap::new();
506 let mut c = criteria(&applied, &scripts, None);
507 c.dependency_ordering = true;
508 let out = select_pending(&migs, &c).unwrap();
509 let order: Vec<&str> = out.versioned.iter().map(|m| m.script.as_str()).collect();
510 assert_eq!(
511 order,
512 vec!["V1__One.sql", "V2__Two.sql", "V3__Three.sql"],
513 "no directives means dependency ordering degrades to version order"
514 );
515 }
516
517 #[test]
518 fn select_pending_repeatable_reruns_on_checksum_change() {
519 let mut r = mig("V1__One.sql", &[]);
520 r.kind = MigrationKind::Repeatable;
521 r.script = "R__View.sql".to_string();
522 r.checksum = 99;
523 let migs = vec![r];
524 let applied = HashSet::new();
525
526 let mut scripts = HashMap::new();
527 scripts.insert("R__View.sql".to_string(), Some(99));
528 let out = select_pending(&migs, &criteria(&applied, &scripts, None)).unwrap();
529 assert!(
530 out.repeatables.is_empty(),
531 "unchanged checksum must not re-run"
532 );
533
534 let mut scripts = HashMap::new();
535 scripts.insert("R__View.sql".to_string(), Some(1));
536 let out = select_pending(&migs, &criteria(&applied, &scripts, None)).unwrap();
537 assert_eq!(out.repeatables.len(), 1, "changed checksum must re-run");
538 }
539}