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