Skip to main content

waypoint_core/
hooks.rs

1//! SQL callback hooks that run before/after migrations (Flyway-compatible).
2
3use std::collections::HashMap;
4use std::fmt;
5use std::path::PathBuf;
6
7#[cfg(feature = "postgres")]
8use tokio_postgres::Client;
9
10use crate::config::HooksConfig;
11#[cfg(feature = "postgres")]
12use crate::db;
13use crate::db::DbClient;
14use crate::error::{Result, WaypointError};
15use crate::placeholder::replace_placeholders;
16
17/// The phase at which a hook runs.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum HookType {
20    /// Runs once before the entire migration run begins.
21    BeforeMigrate,
22    /// Runs once after the entire migration run completes.
23    AfterMigrate,
24    /// Runs before each individual migration is applied.
25    BeforeEachMigrate,
26    /// Runs after each individual migration is applied.
27    AfterEachMigrate,
28}
29
30impl fmt::Display for HookType {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        match self {
33            HookType::BeforeMigrate => write!(f, "beforeMigrate"),
34            HookType::AfterMigrate => write!(f, "afterMigrate"),
35            HookType::BeforeEachMigrate => write!(f, "beforeEachMigrate"),
36            HookType::AfterEachMigrate => write!(f, "afterEachMigrate"),
37        }
38    }
39}
40
41/// A hook SQL script discovered on disk or specified in config.
42#[derive(Debug, Clone)]
43pub struct ResolvedHook {
44    /// The phase at which this hook should be executed.
45    pub hook_type: HookType,
46    /// Filename of the hook SQL script.
47    pub script_name: String,
48    /// Raw SQL content of the hook file.
49    pub sql: String,
50}
51
52/// File prefixes that indicate hook callback files (Flyway-compatible).
53type HookPrefixEntry = (&'static str, fn() -> HookType);
54const HOOK_PREFIXES: &[HookPrefixEntry] = &[
55    ("beforeEachMigrate", || HookType::BeforeEachMigrate),
56    ("afterEachMigrate", || HookType::AfterEachMigrate),
57    ("beforeMigrate", || HookType::BeforeMigrate),
58    ("afterMigrate", || HookType::AfterMigrate),
59];
60
61/// Check if a filename is a hook callback file (not a migration).
62///
63/// Applies the same `prefix.sql` / `prefix__*.sql` rule as [`scan_hooks`].
64/// It used to accept any `prefix*`, which meant `beforeMigrate_typo.sql`
65/// was claimed as a hook here, then rejected by `scan_hooks` — and so ran as
66/// neither. `scan_migrations` skips whatever this returns true for, so a
67/// mismatch between the two is a file that silently does nothing.
68pub fn is_hook_file(filename: &str) -> bool {
69    let Some(stem) = filename.strip_suffix(".sql") else {
70        return false;
71    };
72    HOOK_PREFIXES.iter().any(|(prefix, _)| {
73        stem.strip_prefix(prefix)
74            .is_some_and(|rest| rest.is_empty() || rest.starts_with("__"))
75    })
76}
77
78/// Scan migration locations for SQL callback hook files.
79///
80/// Recognizes:
81///   - `beforeMigrate.sql` / `beforeMigrate__*.sql`
82///   - `afterMigrate.sql` / `afterMigrate__*.sql`
83///   - `beforeEachMigrate.sql` / `beforeEachMigrate__*.sql`
84///   - `afterEachMigrate.sql` / `afterEachMigrate__*.sql`
85///
86/// Multiple files per hook type are sorted alphabetically.
87pub fn scan_hooks(locations: &[PathBuf]) -> Result<Vec<ResolvedHook>> {
88    let mut hooks = Vec::new();
89
90    for location in locations {
91        if !location.exists() {
92            continue;
93        }
94
95        let entries = std::fs::read_dir(location).map_err(|e| {
96            WaypointError::IoError(std::io::Error::new(
97                e.kind(),
98                format!(
99                    "Failed to read hook directory '{}': {}",
100                    location.display(),
101                    e
102                ),
103            ))
104        })?;
105
106        let mut files: Vec<_> = entries
107            .filter_map(|e| e.ok())
108            .filter(|e| e.path().is_file())
109            .collect();
110
111        // Sort alphabetically for deterministic ordering
112        files.sort_by_key(|e| e.file_name());
113
114        for entry in files {
115            let path = entry.path();
116            let filename = match path.file_name().and_then(|n| n.to_str()) {
117                Some(name) => name.to_string(),
118                None => continue,
119            };
120
121            if !filename.ends_with(".sql") {
122                continue;
123            }
124
125            // Check each hook prefix
126            for (prefix, type_fn) in HOOK_PREFIXES {
127                if filename.starts_with(prefix) {
128                    // Must be exactly `prefix.sql` or `prefix__*.sql`
129                    let rest = &filename[prefix.len()..filename.len() - 4]; // strip prefix and .sql
130                    if rest.is_empty() || rest.starts_with("__") {
131                        let sql = std::fs::read_to_string(&path)?;
132                        hooks.push(ResolvedHook {
133                            hook_type: type_fn(),
134                            script_name: filename.clone(),
135                            sql,
136                        });
137                        break;
138                    }
139                }
140            }
141        }
142    }
143
144    // Sort within each hook type alphabetically by script name
145    hooks.sort_by(|a, b| {
146        a.hook_type
147            .to_string()
148            .cmp(&b.hook_type.to_string())
149            .then_with(|| a.script_name.cmp(&b.script_name))
150    });
151
152    Ok(hooks)
153}
154
155/// Load hook SQL files specified in the TOML `[hooks]` config section.
156pub fn load_config_hooks(config: &HooksConfig) -> Result<Vec<ResolvedHook>> {
157    let mut hooks = Vec::new();
158
159    let sections: &[(HookType, &[PathBuf])] = &[
160        (HookType::BeforeMigrate, &config.before_migrate),
161        (HookType::AfterMigrate, &config.after_migrate),
162        (HookType::BeforeEachMigrate, &config.before_each_migrate),
163        (HookType::AfterEachMigrate, &config.after_each_migrate),
164    ];
165
166    for (hook_type, paths) in sections {
167        for path in *paths {
168            let sql = std::fs::read_to_string(path).map_err(|e| {
169                WaypointError::IoError(std::io::Error::new(
170                    e.kind(),
171                    format!("Failed to read hook file '{}': {}", path.display(), e),
172                ))
173            })?;
174
175            let script_name = path
176                .file_name()
177                .and_then(|n| n.to_str())
178                .unwrap_or_else(|| path.to_str().unwrap_or("unknown"))
179                .to_string();
180
181            hooks.push(ResolvedHook {
182                hook_type: hook_type.clone(),
183                script_name,
184                sql,
185            });
186        }
187    }
188
189    Ok(hooks)
190}
191
192/// Run all hooks of a given type.
193///
194/// Returns total execution time in milliseconds.
195#[cfg(feature = "postgres")]
196pub async fn run_hooks(
197    client: &Client,
198    hooks: &[ResolvedHook],
199    phase: &HookType,
200    placeholders: &HashMap<String, String>,
201) -> Result<(usize, i32)> {
202    let mut total_ms = 0;
203    let mut count = 0;
204
205    for hook in hooks.iter().filter(|h| &h.hook_type == phase) {
206        log::info!("Running {} hook: {}", phase, hook.script_name);
207
208        let sql = replace_placeholders(&hook.sql, placeholders)?;
209
210        match db::execute_in_transaction(client, &sql).await {
211            Ok(exec_time) => {
212                total_ms += exec_time;
213                count += 1;
214            }
215            Err(e) => {
216                let reason = match &e {
217                    WaypointError::DatabaseError(db_err) => crate::error::format_db_error(db_err),
218                    other => other.to_string(),
219                };
220                return Err(WaypointError::HookFailed {
221                    phase: phase.to_string(),
222                    script: hook.script_name.clone(),
223                    reason,
224                });
225            }
226        }
227    }
228
229    Ok((count, total_ms))
230}
231
232/// Run all hooks of a given phase (dialect-aware entry).
233///
234/// On PostgreSQL each hook is wrapped in a transaction (matching the legacy
235/// `run_hooks` PG entry). On MySQL hooks execute via `execute_raw` — MySQL DDL
236/// auto-commits, so a transaction wrapper would buy nothing for DDL hooks.
237/// Returns `(hook_count, total_ms)`.
238pub async fn run_hooks_db(
239    client: &DbClient,
240    hooks: &[ResolvedHook],
241    phase: &HookType,
242    placeholders: &HashMap<String, String>,
243) -> Result<(usize, i32)> {
244    let mut total_ms = 0;
245    let mut count = 0;
246
247    for hook in hooks.iter().filter(|h| &h.hook_type == phase) {
248        log::info!("Running {} hook: {}", phase, hook.script_name);
249
250        let sql = replace_placeholders(&hook.sql, placeholders)?;
251
252        let exec_result = match client.dialect_kind() {
253            crate::dialect::DialectKind::Postgres => client.execute_in_transaction(&sql).await,
254            crate::dialect::DialectKind::Mysql => client.execute_raw(&sql).await,
255        };
256
257        match exec_result {
258            Ok(exec_time) => {
259                total_ms += exec_time;
260                count += 1;
261            }
262            Err(e) => {
263                // Match the legacy `run_hooks` error format: when the cause is
264                // a tokio_postgres::Error, surface the inner DbError detail/hint
265                // (`format_db_error`) without the "Database error: " prefix
266                // that WaypointError::Display would prepend.
267                #[cfg(feature = "postgres")]
268                let reason = match &e {
269                    WaypointError::DatabaseError(db_err) => crate::error::format_db_error(db_err),
270                    other => other.to_string(),
271                };
272                #[cfg(not(feature = "postgres"))]
273                let reason = e.to_string();
274                return Err(WaypointError::HookFailed {
275                    phase: phase.to_string(),
276                    script: hook.script_name.clone(),
277                    reason,
278                });
279            }
280        }
281    }
282
283    Ok((count, total_ms))
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use std::fs;
290
291    fn create_temp_dir(name: &str) -> PathBuf {
292        let dir = std::env::temp_dir().join(format!("waypoint_hooks_test_{}", name));
293        let _ = fs::remove_dir_all(&dir);
294        fs::create_dir_all(&dir).unwrap();
295        dir
296    }
297
298    #[test]
299    fn test_is_hook_file() {
300        assert!(is_hook_file("beforeMigrate.sql"));
301        assert!(is_hook_file("afterMigrate.sql"));
302        assert!(is_hook_file("beforeEachMigrate.sql"));
303        assert!(is_hook_file("afterEachMigrate.sql"));
304        assert!(is_hook_file("beforeMigrate__Disable_triggers.sql"));
305        assert!(is_hook_file("afterMigrate__Refresh_views.sql"));
306
307        assert!(!is_hook_file("V1__Create_table.sql"));
308        assert!(!is_hook_file("R__Create_view.sql"));
309        assert!(!is_hook_file("beforeMigrate.txt"));
310        assert!(!is_hook_file("random.sql"));
311
312        // Must agree with `scan_hooks`, which requires `prefix.sql` or
313        // `prefix__*.sql`. A near-miss is not a hook — claiming it here while
314        // `scan_hooks` rejects it makes the file run as neither, silently.
315        assert!(!is_hook_file("beforeMigrate_typo.sql"));
316        assert!(!is_hook_file("beforeMigrateXYZ.sql"));
317        assert!(!is_hook_file("afterMigrate-extra.sql"));
318    }
319
320    #[test]
321    fn test_is_hook_file_agrees_with_scan_hooks() {
322        let dir = create_temp_dir("agree");
323        let names = [
324            "beforeMigrate.sql",
325            "beforeMigrate__ok.sql",
326            "beforeMigrate_typo.sql",
327            "afterMigrateXYZ.sql",
328            "V1__Real.sql",
329        ];
330        for n in &names {
331            fs::write(dir.join(n), "SELECT 1;").unwrap();
332        }
333
334        let collected: std::collections::HashSet<String> = scan_hooks(std::slice::from_ref(&dir))
335            .unwrap()
336            .into_iter()
337            .map(|h| h.script_name)
338            .collect();
339
340        for n in &names {
341            assert_eq!(
342                is_hook_file(n),
343                collected.contains(*n),
344                "{n}: is_hook_file says {}, scan_hooks says {}",
345                is_hook_file(n),
346                collected.contains(*n)
347            );
348        }
349
350        let _ = fs::remove_dir_all(&dir);
351    }
352
353    #[test]
354    fn test_scan_hooks_finds_callback_files() {
355        let dir = create_temp_dir("scan");
356        fs::write(dir.join("beforeMigrate.sql"), "SELECT 1;").unwrap();
357        fs::write(dir.join("afterMigrate__Refresh_views.sql"), "SELECT 2;").unwrap();
358        fs::write(dir.join("V1__Create_table.sql"), "CREATE TABLE t(id INT);").unwrap();
359        fs::write(dir.join("R__Create_view.sql"), "CREATE VIEW v AS SELECT 1;").unwrap();
360
361        let hooks = scan_hooks(std::slice::from_ref(&dir)).unwrap();
362
363        assert_eq!(hooks.len(), 2);
364
365        let before: Vec<_> = hooks
366            .iter()
367            .filter(|h| h.hook_type == HookType::BeforeMigrate)
368            .collect();
369        let after: Vec<_> = hooks
370            .iter()
371            .filter(|h| h.hook_type == HookType::AfterMigrate)
372            .collect();
373        assert_eq!(before.len(), 1);
374        assert_eq!(before[0].script_name, "beforeMigrate.sql");
375        assert_eq!(after.len(), 1);
376        assert_eq!(after[0].script_name, "afterMigrate__Refresh_views.sql");
377
378        let _ = fs::remove_dir_all(&dir);
379    }
380
381    #[test]
382    fn test_scan_hooks_multiple_sorted_alphabetically() {
383        let dir = create_temp_dir("multi");
384        fs::write(dir.join("beforeMigrate__B_second.sql"), "SELECT 2;").unwrap();
385        fs::write(dir.join("beforeMigrate__A_first.sql"), "SELECT 1;").unwrap();
386        fs::write(dir.join("beforeMigrate.sql"), "SELECT 0;").unwrap();
387
388        let hooks = scan_hooks(std::slice::from_ref(&dir)).unwrap();
389
390        assert_eq!(hooks.len(), 3);
391        assert_eq!(hooks[0].script_name, "beforeMigrate.sql");
392        assert_eq!(hooks[1].script_name, "beforeMigrate__A_first.sql");
393        assert_eq!(hooks[2].script_name, "beforeMigrate__B_second.sql");
394
395        let _ = fs::remove_dir_all(&dir);
396    }
397
398    #[test]
399    fn test_load_config_hooks() {
400        let dir = create_temp_dir("config");
401        let hook_file = dir.join("pre.sql");
402        fs::write(&hook_file, "SET work_mem = '256MB';").unwrap();
403
404        let config = HooksConfig {
405            before_migrate: vec![hook_file],
406            after_migrate: vec![],
407            before_each_migrate: vec![],
408            after_each_migrate: vec![],
409        };
410
411        let hooks = load_config_hooks(&config).unwrap();
412        assert_eq!(hooks.len(), 1);
413        assert_eq!(hooks[0].hook_type, HookType::BeforeMigrate);
414        assert_eq!(hooks[0].sql, "SET work_mem = '256MB';");
415
416        let _ = fs::remove_dir_all(&dir);
417    }
418
419    #[test]
420    fn test_load_config_hooks_missing_file() {
421        let config = HooksConfig {
422            before_migrate: vec![PathBuf::from("/nonexistent/hook.sql")],
423            after_migrate: vec![],
424            before_each_migrate: vec![],
425            after_each_migrate: vec![],
426        };
427
428        assert!(load_config_hooks(&config).is_err());
429    }
430}