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