Skip to main content

waypoint_core/commands/
simulate.rs

1//! Migration simulation: run pending migrations in a throwaway schema
2//! to prove they will succeed before applying to the real schema.
3
4use serde::Serialize;
5
6#[cfg(feature = "postgres")]
7use tokio_postgres::Client;
8
9use crate::config::WaypointConfig;
10#[cfg(feature = "postgres")]
11use crate::db::quote_ident;
12use crate::db::DbClient;
13use crate::dialect::DialectKind;
14use crate::error::{Result, WaypointError};
15use crate::history;
16use crate::migration::scan_migrations;
17use crate::placeholder::{build_placeholders, replace_placeholders};
18#[cfg(feature = "postgres")]
19use crate::schema;
20
21/// Report from a migration simulation.
22#[derive(Debug, Clone, Serialize)]
23pub struct SimulationReport {
24    /// Whether all pending migrations passed simulation.
25    pub passed: bool,
26    /// Number of migrations simulated.
27    pub migrations_simulated: usize,
28    /// Name of the temporary schema used.
29    pub temp_schema: String,
30    /// Errors encountered during simulation.
31    pub errors: Vec<SimulationError>,
32    /// Non-fatal warnings — most commonly partial-replication failures on
33    /// MySQL (e.g. views that reference a database we couldn't recreate in
34    /// the simulation environment). Empty on PG today.
35    #[serde(default)]
36    pub warnings: Vec<String>,
37}
38
39/// An error encountered during simulation.
40#[derive(Debug, Clone, Serialize)]
41pub struct SimulationError {
42    /// The migration script that failed.
43    pub script: String,
44    /// Error message.
45    pub error: String,
46}
47
48/// Execute migration simulation in a throwaway schema (PostgreSQL legacy entry).
49#[cfg(feature = "postgres")]
50pub async fn execute(client: &Client, config: &WaypointConfig) -> Result<SimulationReport> {
51    let schema_name = &config.migrations.schema;
52    let table = &config.migrations.table;
53
54    // Create history table if needed (for querying applied state)
55    history::create_history_table(client, schema_name, table).await?;
56
57    // Generate a unique temp schema name
58    let temp_schema = format!(
59        "waypoint_sim_{}",
60        std::time::SystemTime::now()
61            .duration_since(std::time::UNIX_EPOCH)
62            .unwrap_or_default()
63            .as_millis()
64    );
65
66    let result = run_simulation(client, config, &temp_schema).await;
67
68    // Always clean up the temp schema (retry once on failure)
69    let drop_sql = format!(
70        "DROP SCHEMA IF EXISTS {} CASCADE",
71        quote_ident(&temp_schema)
72    );
73    if let Err(e) = client.batch_execute(&drop_sql).await {
74        log::warn!(
75            "First attempt to drop simulation schema {} failed, retrying: {}",
76            temp_schema,
77            e
78        );
79        if let Err(e2) = client.batch_execute(&drop_sql).await {
80            log::error!(
81                "Failed to drop simulation schema {} after retry: {}",
82                temp_schema,
83                e2
84            );
85        }
86    }
87
88    result
89}
90
91#[cfg(feature = "postgres")]
92async fn run_simulation(
93    client: &Client,
94    config: &WaypointConfig,
95    temp_schema: &str,
96) -> Result<SimulationReport> {
97    let schema_name = &config.migrations.schema;
98    let table = &config.migrations.table;
99
100    // Create the temp schema
101    let create_sql = format!("CREATE SCHEMA {}", quote_ident(temp_schema));
102    client
103        .batch_execute(&create_sql)
104        .await
105        .map_err(|e| WaypointError::SimulationFailed {
106            reason: format!("Failed to create simulation schema: {}", e),
107        })?;
108
109    // Replicate current schema structure into temp schema
110    let snapshot = schema::introspect(client, schema_name).await?;
111    let ddl = schema::to_ddl(&snapshot);
112
113    if !ddl.is_empty() {
114        // Set search_path to temp schema for DDL execution
115        let set_path = format!("SET search_path TO {}", quote_ident(temp_schema));
116        client
117            .batch_execute(&set_path)
118            .await
119            .map_err(|e| WaypointError::SimulationFailed {
120                reason: format!("Failed to set search_path: {}", e),
121            })?;
122
123        // Execute DDL to replicate structure (ignore errors for complex objects)
124        if let Err(e) = client.batch_execute(&ddl).await {
125            log::debug!("Partial schema replication in simulation: {}", e);
126        }
127    }
128
129    // Set search_path to temp schema
130    let set_path = format!("SET search_path TO {}", quote_ident(temp_schema));
131    client
132        .batch_execute(&set_path)
133        .await
134        .map_err(|e| WaypointError::SimulationFailed {
135            reason: format!("Failed to set search_path: {}", e),
136        })?;
137
138    // Get pending migrations
139    let resolved = scan_migrations(&config.migrations.locations)?;
140    let applied = history::get_applied_migrations(client, schema_name, table).await?;
141    let effective = history::effective_applied_versions(&applied);
142
143    let db_user = crate::db::get_current_user(client)
144        .await
145        .unwrap_or_else(|_| "unknown".to_string());
146    let db_name = crate::db::get_current_database(client)
147        .await
148        .unwrap_or_else(|_| "unknown".to_string());
149
150    let mut errors = Vec::new();
151    let mut simulated = 0;
152
153    for migration in &resolved {
154        if migration.is_undo() {
155            continue;
156        }
157        if let Some(version) = migration.version() {
158            if effective.contains(&version.raw) {
159                continue; // Already applied
160            }
161        }
162
163        let placeholders = build_placeholders(
164            &config.placeholders,
165            temp_schema,
166            &db_user,
167            &db_name,
168            &migration.script,
169        );
170        let sql = match replace_placeholders(&migration.sql, &placeholders) {
171            Ok(s) => s,
172            Err(e) => {
173                errors.push(SimulationError {
174                    script: migration.script.clone(),
175                    error: e.to_string(),
176                });
177                continue;
178            }
179        };
180
181        match client.batch_execute(&sql).await {
182            Ok(_) => {
183                simulated += 1;
184            }
185            Err(e) => {
186                errors.push(SimulationError {
187                    script: migration.script.clone(),
188                    error: crate::error::format_db_error(&e),
189                });
190            }
191        }
192    }
193
194    // Restore search_path
195    let restore_path = format!("SET search_path TO {}", quote_ident(schema_name));
196    if let Err(e) = client.batch_execute(&restore_path).await {
197        log::warn!("Failed to restore search_path: {}", e);
198    }
199
200    Ok(SimulationReport {
201        passed: errors.is_empty(),
202        migrations_simulated: simulated,
203        temp_schema: temp_schema.to_string(),
204        errors,
205        warnings: Vec::new(),
206    })
207}
208
209/// Execute migration simulation in a throwaway schema (dialect-aware entry).
210pub async fn execute_db(client: &DbClient, config: &WaypointConfig) -> Result<SimulationReport> {
211    match client.dialect_kind() {
212        #[cfg(feature = "postgres")]
213        DialectKind::Postgres => execute(client.as_postgres()?, config).await,
214        #[cfg(not(feature = "postgres"))]
215        DialectKind::Postgres => Err(WaypointError::ConfigError(
216            "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
217        )),
218        #[cfg(feature = "mysql")]
219        DialectKind::Mysql => execute_mysql(client, config).await,
220        #[cfg(not(feature = "mysql"))]
221        DialectKind::Mysql => Err(WaypointError::ConfigError(
222            "MySQL support is not compiled in (enable the `mysql` feature)".into(),
223        )),
224    }
225}
226
227#[cfg(feature = "mysql")]
228async fn execute_mysql(client: &DbClient, config: &WaypointConfig) -> Result<SimulationReport> {
229    use mysql_async::prelude::*;
230    let pool = client.as_mysql()?;
231    let source_db = client.resolve_schema(&config.migrations.schema).await?;
232    let table = &config.migrations.table;
233
234    history::create_history_table_db(client, &source_db, table).await?;
235
236    let temp_db = format!(
237        "waypoint_sim_{}",
238        std::time::SystemTime::now()
239            .duration_since(std::time::UNIX_EPOCH)
240            .unwrap_or_default()
241            .as_millis()
242    );
243
244    let result = run_simulation_mysql(client, config, &source_db, &temp_db).await;
245
246    // Always drop the temp database (retry once on failure).
247    let mut conn = pool.get_conn().await?;
248    let drop_sql = format!("DROP DATABASE IF EXISTS `{}`", temp_db);
249    if let Err(e) = conn.query_drop(&drop_sql).await {
250        log::warn!(
251            "First attempt to drop simulation database {} failed, retrying: {}",
252            temp_db,
253            e
254        );
255        if let Err(e2) = conn.query_drop(&drop_sql).await {
256            log::error!(
257                "Failed to drop simulation database {} after retry: {}",
258                temp_db,
259                e2
260            );
261        }
262    }
263
264    result
265}
266
267#[cfg(feature = "mysql")]
268async fn run_simulation_mysql(
269    client: &DbClient,
270    config: &WaypointConfig,
271    source_db: &str,
272    temp_db: &str,
273) -> Result<SimulationReport> {
274    use mysql_async::prelude::*;
275    let pool = client.as_mysql()?;
276    let mut conn = pool.get_conn().await?;
277
278    // Create the throwaway database.
279    let create_sql = format!("CREATE DATABASE `{}`", temp_db);
280    conn.query_drop(&create_sql)
281        .await
282        .map_err(|e| WaypointError::SimulationFailed {
283            reason: format!("Failed to create simulation database: {}", e),
284        })?;
285
286    // Replicate source structure into the temp DB. We use SHOW CREATE TABLE
287    // / SHOW CREATE VIEW (same approach as MySQL snapshot) and rewrite the
288    // qualified name to point at the temp DB. Simulation tolerates partial
289    // replication — anything we can't replicate just becomes a SQL error when
290    // the migration references it.
291    let tables: Vec<String> = conn
292        .exec(
293            "SELECT TABLE_NAME FROM information_schema.TABLES \
294             WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' \
295             ORDER BY TABLE_NAME",
296            (source_db,),
297        )
298        .await?;
299
300    conn.query_drop(format!("USE `{}`", temp_db)).await?;
301
302    let mut warnings: Vec<String> = Vec::new();
303
304    for table_name in &tables {
305        let show_stmt = format!("SHOW CREATE TABLE `{}`.`{}`", source_db, table_name);
306        if let Ok(Some((_, create_sql))) = conn.query_first::<(String, String), _>(&show_stmt).await
307        {
308            // The DDL is "CREATE TABLE `name` (...)"; since USE has set our
309            // default database to temp_db it lands there.
310            if let Err(e) = conn.query_drop(&create_sql).await {
311                warnings.push(format!(
312                    "Could not replicate table `{}` into the simulation database: {}. \
313                     Migrations that depend on this table may report misleading errors.",
314                    table_name, e
315                ));
316            }
317        }
318    }
319
320    // Replicate views. SHOW CREATE VIEW returns the DDL with `source_db`.
321    // baked into qualified column refs. We rewrite `source_db`. → empty so
322    // the view binds to the current default database (temp_db, since we
323    // USE'd into it above). This handles the common case where a view
324    // references tables in the same database; cross-database views would
325    // need a proper SQL rewriter — those will fail to replicate and the
326    // dependent migration will surface a clear error.
327    let views: Vec<String> = conn
328        .exec(
329            "SELECT TABLE_NAME FROM information_schema.VIEWS \
330             WHERE TABLE_SCHEMA = ? ORDER BY TABLE_NAME",
331            (source_db,),
332        )
333        .await?;
334    for view_name in &views {
335        let show_stmt = format!("SHOW CREATE VIEW `{}`.`{}`", source_db, view_name);
336        if let Ok(Some(row)) = conn.query_first::<mysql_async::Row, _>(&show_stmt).await {
337            let mut row = row;
338            if let Some(create_sql) = row.take::<String, _>(1) {
339                let other_db = first_other_db_qualifier(&create_sql, source_db);
340                let rewritten = rewrite_view_db_qualifier(&create_sql, source_db);
341                if let Err(e) = conn.query_drop(&rewritten).await {
342                    if let Some(other) = other_db {
343                        warnings.push(format!(
344                            "View `{}` references database `{}` which is not replicated \
345                             into the simulation environment; skipped (error: {}). \
346                             Migrations that read from this view may surface misleading errors.",
347                            view_name, other, e
348                        ));
349                    } else {
350                        warnings.push(format!(
351                            "Could not replicate view `{}` into the simulation database: {}.",
352                            view_name, e
353                        ));
354                    }
355                }
356            }
357        }
358    }
359
360    // Get pending migrations.
361    let resolved = scan_migrations(&config.migrations.locations)?;
362    let applied =
363        history::get_applied_migrations_db(client, source_db, &config.migrations.table).await?;
364    let effective = history::effective_applied_versions(&applied);
365
366    let db_user = client
367        .current_user()
368        .await
369        .unwrap_or_else(|_| "unknown".into());
370    let db_name = client
371        .current_database()
372        .await
373        .unwrap_or_else(|_| "unknown".into());
374
375    let mut errors = Vec::new();
376    let mut simulated = 0;
377
378    for migration in &resolved {
379        if migration.is_undo() {
380            continue;
381        }
382        if let Some(version) = migration.version() {
383            if effective.contains(&version.raw) {
384                continue;
385            }
386        }
387
388        let placeholders = build_placeholders(
389            &config.placeholders,
390            temp_db,
391            &db_user,
392            &db_name,
393            &migration.script,
394        );
395        let sql = match replace_placeholders(&migration.sql, &placeholders) {
396            Ok(s) => s,
397            Err(e) => {
398                errors.push(SimulationError {
399                    script: migration.script.clone(),
400                    error: e.to_string(),
401                });
402                continue;
403            }
404        };
405
406        // Execute via execute_raw which handles MySQL per-statement protocol.
407        // We've USE'd into temp_db so unqualified table refs land there.
408        match client.execute_raw(&sql).await {
409            Ok(_) => simulated += 1,
410            Err(e) => errors.push(SimulationError {
411                script: migration.script.clone(),
412                error: e.to_string(),
413            }),
414        }
415    }
416
417    Ok(SimulationReport {
418        passed: errors.is_empty(),
419        migrations_simulated: simulated,
420        temp_schema: temp_db.to_string(),
421        errors,
422        warnings,
423    })
424}
425
426/// Rewrite `\`source_db\`.` prefixes in a view DDL so the view binds to the
427/// current default database when re-executed.
428///
429/// MySQL's `SHOW CREATE VIEW` returns column references qualified with the
430/// source database. When we replay the DDL into a different database, those
431/// qualifiers would still point at the original — so we strip them and let
432/// the current `USE` provide the binding. Simple string-replace; for views
433/// that legitimately reference *other* databases this won't work and the
434/// replay will fail (surfaced as a SimulationReport warning).
435#[cfg(feature = "mysql")]
436fn rewrite_view_db_qualifier(create_sql: &str, source_db: &str) -> String {
437    let qualifier = format!("`{}`.", source_db);
438    create_sql.replace(&qualifier, "")
439}
440
441/// If a view DDL references a database *other* than `source_db`, return its
442/// name. Used to produce a clearer warning when replication into the
443/// simulation env fails. Looks for backtick-quoted identifiers that are the
444/// *first* segment of a qualified name (preceded by something other than `.`)
445/// and followed by a dot — that's the shape MySQL's `SHOW CREATE VIEW` emits
446/// for database qualifiers. Identifiers preceded by `.` are table/column
447/// names within a qualified reference, not databases.
448#[cfg(feature = "mysql")]
449fn first_other_db_qualifier(create_sql: &str, source_db: &str) -> Option<String> {
450    let bytes = create_sql.as_bytes();
451    let mut i = 0;
452    while i < bytes.len() {
453        if bytes[i] == b'`' {
454            // Find the matching closing backtick.
455            let start = i + 1;
456            let mut j = start;
457            while j < bytes.len() && bytes[j] != b'`' {
458                j += 1;
459            }
460            if j >= bytes.len() {
461                return None;
462            }
463            // Only treat this as a DB qualifier if (a) the char before the
464            // opening backtick is not `.` (otherwise this ident is the table
465            // or column part of `db.table.col`), and (b) the char after the
466            // closing backtick is `.` (this ident has at least one trailing
467            // segment, so it's a leading qualifier).
468            let preceded_by_dot = i > 0 && bytes[i - 1] == b'.';
469            let followed_by_dot = j + 1 < bytes.len() && bytes[j + 1] == b'.';
470            if !preceded_by_dot && followed_by_dot {
471                let ident = &create_sql[start..j];
472                if ident != source_db && !ident.is_empty() {
473                    return Some(ident.to_string());
474                }
475            }
476            i = j + 1;
477        } else {
478            i += 1;
479        }
480    }
481    None
482}
483
484#[cfg(all(test, feature = "mysql"))]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn rewrite_strips_source_db_prefix() {
490        let sql = "CREATE VIEW `v` AS SELECT `db1`.`t`.`c` FROM `db1`.`t`";
491        let out = rewrite_view_db_qualifier(sql, "db1");
492        assert_eq!(out, "CREATE VIEW `v` AS SELECT `t`.`c` FROM `t`");
493    }
494
495    #[test]
496    fn rewrite_preserves_unrelated_db_prefix() {
497        let sql = "CREATE VIEW `v` AS SELECT `other`.`t`.`c` FROM `other`.`t`";
498        let out = rewrite_view_db_qualifier(sql, "db1");
499        assert_eq!(out, sql);
500    }
501
502    #[test]
503    fn rewrite_handles_no_qualifier() {
504        let sql = "CREATE VIEW `v` AS SELECT 1 AS x";
505        let out = rewrite_view_db_qualifier(sql, "db1");
506        assert_eq!(out, sql);
507    }
508
509    #[test]
510    fn first_other_db_detects_cross_db_ref() {
511        let sql = "CREATE VIEW `v` AS SELECT `shared`.`t`.`c` FROM `shared`.`t`";
512        assert_eq!(
513            first_other_db_qualifier(sql, "app"),
514            Some("shared".to_string())
515        );
516    }
517
518    #[test]
519    fn first_other_db_ignores_source_db() {
520        // The source-db prefix is *not* a cross-database reference. Only an
521        // unrelated database name should be flagged.
522        let sql = "CREATE VIEW `v` AS SELECT `app`.`t`.`c` FROM `app`.`t`";
523        assert_eq!(first_other_db_qualifier(sql, "app"), None);
524    }
525
526    #[test]
527    fn first_other_db_returns_none_for_no_qualifier() {
528        let sql = "CREATE VIEW `v` AS SELECT 1 AS x";
529        assert_eq!(first_other_db_qualifier(sql, "app"), None);
530    }
531
532    #[test]
533    fn first_other_db_reports_first_match() {
534        // When multiple foreign DBs are referenced, surface the first one.
535        let sql = "CREATE VIEW `v` AS \
536                   SELECT `shared`.`t`.`c`, `audit`.`log`.`m` \
537                   FROM `shared`.`t` JOIN `audit`.`log`";
538        assert_eq!(
539            first_other_db_qualifier(sql, "app"),
540            Some("shared".to_string())
541        );
542    }
543}