Skip to main content

waypoint_core/commands/
explain.rs

1//! Enhanced dry-run with EXPLAIN for pending migrations.
2//!
3//! Runs EXPLAIN on each DML statement within a rolled-back transaction
4//! to show execution plans and identify potential issues.
5
6use serde::Serialize;
7
8#[cfg(feature = "postgres")]
9use tokio_postgres::Client;
10
11use crate::commands::info::{self, MigrationState};
12use crate::config::WaypointConfig;
13use crate::db::DbClient;
14use crate::dialect::DialectKind;
15use crate::error::Result;
16#[cfg(any(not(feature = "postgres"), not(feature = "mysql")))]
17use crate::error::WaypointError;
18use crate::placeholder::{build_placeholders, replace_placeholders};
19#[cfg(feature = "postgres")]
20use crate::sql_parser::split_statements;
21
22/// EXPLAIN report for all pending migrations.
23#[derive(Debug, Serialize)]
24pub struct ExplainReport {
25    /// Per-migration EXPLAIN analysis results.
26    pub migrations: Vec<MigrationExplain>,
27}
28
29impl ExplainReport {
30    /// Whether the preview proved a migration will fail.
31    ///
32    /// `migrate --dry-run` routes here, so this is the difference between
33    /// "your migration is fine" and "your migration will fail on statement 2".
34    pub fn has_failures(&self) -> bool {
35        self.migrations.iter().any(|m| m.error.is_some())
36    }
37}
38
39/// EXPLAIN analysis for a single migration.
40#[derive(Debug, Serialize)]
41pub struct MigrationExplain {
42    /// Filename of the migration script.
43    pub script: String,
44    /// Version string, or None for repeatable migrations.
45    pub version: Option<String>,
46    /// EXPLAIN results for each statement in the migration.
47    pub statements: Vec<StatementExplain>,
48    /// The statement failure that stopped the preview, if any.
49    ///
50    /// A DDL statement that fails inside the preview transaction used to be
51    /// swallowed at `debug` level, and PostgreSQL then aborts the transaction
52    /// so every *later* statement fails too — which was also swallowed. The
53    /// dry run therefore listed every statement as if it were fine. Recording
54    /// the first real failure, and stopping there, is the whole point of a
55    /// preview.
56    #[serde(default)]
57    pub error: Option<String>,
58}
59
60/// EXPLAIN analysis for a single statement.
61#[derive(Debug, Serialize)]
62pub struct StatementExplain {
63    /// Truncated preview of the SQL statement (up to 80 characters).
64    pub statement_preview: String,
65    /// Full EXPLAIN output or a status message for DDL statements.
66    pub plan: String,
67    /// Estimated number of rows from the query plan, if available.
68    pub estimated_rows: Option<f64>,
69    /// Estimated total cost from the query plan, if available.
70    pub estimated_cost: Option<f64>,
71    /// Performance warnings derived from the execution plan.
72    pub warnings: Vec<String>,
73    /// Whether this statement is a DDL operation (not explainable).
74    pub is_ddl: bool,
75}
76
77/// Execute explain analysis for pending migrations (PostgreSQL legacy entry).
78#[cfg(feature = "postgres")]
79pub async fn execute(client: &Client, config: &WaypointConfig) -> Result<ExplainReport> {
80    let infos = info::execute(client, config).await?;
81
82    let pending: Vec<_> = infos
83        .iter()
84        .filter(|i| matches!(i.state, MigrationState::Pending | MigrationState::Outdated))
85        .collect();
86
87    let schema = &config.migrations.schema;
88    let db_user = crate::db::get_current_user(client)
89        .await
90        .unwrap_or_else(|_| "unknown".to_string());
91    let db_name = crate::db::get_current_database(client)
92        .await
93        .unwrap_or_else(|_| "unknown".to_string());
94
95    // Scan migration files to get SQL content
96    let resolved = crate::migration::scan_migrations(&config.migrations.locations)?;
97
98    let mut migrations = Vec::new();
99
100    // One transaction for the *whole* preview, rolled back at the end.
101    //
102    // Each migration used to get its own transaction, so V2 could not see the
103    // table V1 had just created — the preview reported a false failure for any
104    // migration that builds on an earlier pending one, which is the normal
105    // case. A real `migrate` applies them in order against accumulating state,
106    // and the preview has to do the same to mean anything.
107    client.batch_execute("BEGIN").await?;
108    let mut aborted = false;
109
110    // The loop body is fallible (placeholder substitution can error), and the
111    // transaction is already open — so its result is captured and the rollback
112    // runs before anything propagates. A bare `?` here would leave the
113    // connection inside an open transaction holding the migrations' DDL.
114    let preview = async {
115    for info in &pending {
116        // A failed statement aborts the transaction, so nothing after it can be
117        // checked. Stop rather than report later migrations as if they had been.
118        if aborted {
119            break;
120        }
121
122        // Find the resolved migration matching this info
123        let migration = resolved.iter().find(|m| m.script == info.script);
124        let sql = match migration {
125            Some(m) => {
126                let placeholders =
127                    build_placeholders(&config.placeholders, schema, &db_user, &db_name, &m.script);
128                replace_placeholders(&m.sql, &placeholders)?
129            }
130            None => continue,
131        };
132
133        let statements_raw = split_statements(&sql);
134        let mut statements = Vec::new();
135        let mut failure: Option<String> = None;
136
137        for stmt_str in &statements_raw {
138            let trimmed = stmt_str.trim();
139            if trimmed.is_empty() || trimmed.starts_with("--") {
140                continue;
141            }
142
143            let preview: String = trimmed.chars().take(80).collect();
144            let preview = if trimmed.len() > 80 {
145                format!("{}...", preview)
146            } else {
147                preview
148            };
149
150            let upper = trimmed.to_uppercase();
151            let is_ddl = upper.starts_with("CREATE")
152                || upper.starts_with("ALTER")
153                || upper.starts_with("DROP")
154                || upper.starts_with("TRUNCATE");
155
156            if is_ddl {
157                // DDL can't be meaningfully EXPLAINed; execute it to build schema state
158                if let Err(e) = client.batch_execute(trimmed).await {
159                    // Stop here. PostgreSQL aborts the whole transaction on a
160                    // failed statement, so every statement after this one would
161                    // fail with "current transaction is aborted" — reporting
162                    // them as if they were checked is worse than not checking.
163                    let reason = crate::error::format_db_error(&e);
164                    statements.push(StatementExplain {
165                        statement_preview: preview,
166                        plan: format!("FAILED: {}", reason),
167                        estimated_rows: None,
168                        estimated_cost: None,
169                        warnings: vec![format!(
170                            "This statement failed during the dry run; `migrate` would fail here: {}",
171                            reason
172                        )],
173                        is_ddl: true,
174                    });
175                    failure = Some(format!("statement {} failed: {}", statements.len(), reason));
176                    break;
177                }
178                statements.push(StatementExplain {
179                    statement_preview: preview,
180                    plan: "DDL statement — not explainable".to_string(),
181                    estimated_rows: None,
182                    estimated_cost: None,
183                    warnings: vec![],
184                    is_ddl: true,
185                });
186            } else {
187                // Try EXPLAIN on DML
188                let explain_sql = format!("EXPLAIN (FORMAT TEXT) {}", trimmed);
189                match client.query(&explain_sql, &[]).await {
190                    Ok(rows_result) => {
191                        let plan_lines: Vec<String> =
192                            rows_result.iter().map(|r| r.get::<_, String>(0)).collect();
193                        let plan_str = plan_lines.join("\n");
194
195                        let (rows, cost, warnings) = extract_plan_info_text(&plan_str);
196
197                        statements.push(StatementExplain {
198                            statement_preview: preview,
199                            plan: plan_str,
200                            estimated_rows: rows,
201                            estimated_cost: cost,
202                            warnings,
203                            is_ddl: false,
204                        });
205                    }
206                    Err(e) => {
207                        // As with DDL above: the transaction is now aborted, so
208                        // stop rather than reporting cascading
209                        // "current transaction is aborted" for every statement
210                        // that follows.
211                        let reason = crate::error::format_db_error(&e);
212                        statements.push(StatementExplain {
213                            statement_preview: preview,
214                            plan: format!("FAILED: {}", reason),
215                            estimated_rows: None,
216                            estimated_cost: None,
217                            warnings: vec![format!(
218                                "This statement failed during the dry run; \
219                                 `migrate` would fail here: {}",
220                                reason
221                            )],
222                            is_ddl: false,
223                        });
224                        failure =
225                            Some(format!("statement {} failed: {}", statements.len(), reason));
226                        break;
227                    }
228                }
229            }
230        }
231
232        aborted = failure.is_some();
233
234        migrations.push(MigrationExplain {
235            script: info.script.clone(),
236            version: info.version.clone(),
237            statements,
238            error: failure,
239        });
240    }
241    Ok::<(), crate::error::WaypointError>(())
242    }
243    .await;
244
245    // The whole preview rests on this rollback: everything above executed the
246    // migrations' DDL for real. A failure leaves the connection inside an open
247    // transaction holding those changes, which matters for library callers that
248    // keep using the client — so say so rather than discard it.
249    if let Err(e) = client.batch_execute("ROLLBACK").await {
250        log::error!(
251            "Failed to roll back the dry-run transaction; this connection may still hold \
252             uncommitted schema changes: {}",
253            e
254        );
255    }
256
257    preview?;
258    Ok(ExplainReport { migrations })
259}
260
261/// Execute explain analysis for pending migrations (dialect-aware entry).
262pub async fn execute_db(client: &DbClient, config: &WaypointConfig) -> Result<ExplainReport> {
263    match client.dialect_kind() {
264        #[cfg(feature = "postgres")]
265        DialectKind::Postgres => execute(client.as_postgres()?, config).await,
266        #[cfg(not(feature = "postgres"))]
267        DialectKind::Postgres => Err(WaypointError::ConfigError(
268            "PostgreSQL support is not compiled in (enable the `postgres` feature)".into(),
269        )),
270        #[cfg(feature = "mysql")]
271        DialectKind::Mysql => execute_mysql(client, config).await,
272        #[cfg(not(feature = "mysql"))]
273        DialectKind::Mysql => Err(WaypointError::ConfigError(
274            "MySQL support is not compiled in (enable the `mysql` feature)".into(),
275        )),
276    }
277}
278
279/// MySQL EXPLAIN path.
280///
281/// Unlike PG we don't wrap in a transaction (MySQL DDL auto-commits anyway).
282/// DDL is reported as "not explainable" — DML gets EXPLAIN FORMAT=JSON. Since
283/// we don't execute the migration's DDL, EXPLAIN on a DML statement that
284/// references an as-yet-uncreated table will fail with a clear "table doesn't
285/// exist" message; that's the right UX since we can't reasonably create then
286/// drop tables for an analysis-only command.
287#[cfg(feature = "mysql")]
288async fn execute_mysql(client: &DbClient, config: &WaypointConfig) -> Result<ExplainReport> {
289    use mysql_async::prelude::*;
290    let pool = client.as_mysql()?;
291    let infos = info::execute_db(client, config).await?;
292
293    let pending: Vec<_> = infos
294        .iter()
295        .filter(|i| matches!(i.state, MigrationState::Pending | MigrationState::Outdated))
296        .collect();
297
298    let schema = client.resolve_schema(&config.migrations.schema).await?;
299    let db_user = client
300        .current_user()
301        .await
302        .unwrap_or_else(|_| "unknown".into());
303    let db_name = client
304        .current_database()
305        .await
306        .unwrap_or_else(|_| "unknown".into());
307
308    let resolved = crate::migration::scan_migrations(&config.migrations.locations)?;
309    let mut migrations = Vec::new();
310
311    for info in &pending {
312        let migration = resolved.iter().find(|m| m.script == info.script);
313        let sql = match migration {
314            Some(m) => {
315                let placeholders = build_placeholders(
316                    &config.placeholders,
317                    &schema,
318                    &db_user,
319                    &db_name,
320                    &m.script,
321                );
322                replace_placeholders(&m.sql, &placeholders)?
323            }
324            None => continue,
325        };
326
327        let mut statements = Vec::new();
328        let mut conn = pool.get_conn().await?;
329
330        for stmt_str in crate::sql_parser::split_mysql_statements(&sql) {
331            let trimmed = stmt_str.trim();
332            if trimmed.is_empty() {
333                continue;
334            }
335
336            let preview: String = trimmed.chars().take(80).collect();
337            let preview = if trimmed.len() > 80 {
338                format!("{}...", preview)
339            } else {
340                preview
341            };
342
343            let upper = trimmed.to_uppercase();
344            let is_ddl = upper.starts_with("CREATE")
345                || upper.starts_with("ALTER")
346                || upper.starts_with("DROP")
347                || upper.starts_with("TRUNCATE")
348                || upper.starts_with("RENAME");
349
350            if is_ddl {
351                statements.push(StatementExplain {
352                    statement_preview: preview,
353                    plan: "DDL statement — not explainable".to_string(),
354                    estimated_rows: None,
355                    estimated_cost: None,
356                    warnings: vec![],
357                    is_ddl: true,
358                });
359            } else {
360                let explain_sql = format!("EXPLAIN FORMAT=JSON {}", trimmed);
361                match conn.query_first::<String, _>(&explain_sql).await {
362                    Ok(Some(plan_json)) => {
363                        let (rows, warnings) = extract_plan_info_mysql(&plan_json);
364                        statements.push(StatementExplain {
365                            statement_preview: preview,
366                            plan: plan_json,
367                            estimated_rows: rows,
368                            estimated_cost: None, // MySQL EXPLAIN doesn't expose unified cost
369                            warnings,
370                            is_ddl: false,
371                        });
372                    }
373                    Ok(None) => statements.push(StatementExplain {
374                        statement_preview: preview,
375                        plan: "EXPLAIN produced no rows".to_string(),
376                        estimated_rows: None,
377                        estimated_cost: None,
378                        warnings: vec![],
379                        is_ddl: false,
380                    }),
381                    Err(e) => statements.push(StatementExplain {
382                        statement_preview: preview,
383                        plan: format!("EXPLAIN failed: {}", e),
384                        estimated_rows: None,
385                        estimated_cost: None,
386                        warnings: vec![],
387                        is_ddl: false,
388                    }),
389                }
390            }
391        }
392
393        migrations.push(MigrationExplain {
394            script: info.script.clone(),
395            version: info.version.clone(),
396            statements,
397            // The MySQL path deliberately does not execute the migration's DDL
398            // (see the note above), so an `EXPLAIN` failure here is expected
399            // whenever a statement references a not-yet-created object. It is
400            // not evidence that `migrate` would fail, so it is not recorded as
401            // a preview failure.
402            error: None,
403        });
404    }
405
406    Ok(ExplainReport { migrations })
407}
408
409/// Extract row estimates and access-type warnings from a MySQL EXPLAIN
410/// FORMAT=JSON plan. We do a coarse JSON-string search rather than parsing
411/// into serde_json::Value because the plan structure varies across MySQL
412/// versions and we only need a couple of signals.
413#[cfg(feature = "mysql")]
414fn extract_plan_info_mysql(plan: &str) -> (Option<f64>, Vec<String>) {
415    let mut warnings = Vec::new();
416    let mut rows = None;
417
418    // "rows_examined_per_scan": N  (MySQL 8.0 query plan)
419    if let Some(idx) = plan.find("\"rows_examined_per_scan\":") {
420        let after = &plan[idx + "\"rows_examined_per_scan\":".len()..];
421        let after = after.trim_start();
422        let end = after
423            .find(|c: char| !c.is_ascii_digit())
424            .unwrap_or(after.len());
425        if let Ok(r) = after[..end].parse::<f64>() {
426            rows = Some(r);
427        }
428    }
429
430    // access_type = ALL means full table scan
431    if plan.contains("\"access_type\": \"ALL\"") || plan.contains("\"access_type\":\"ALL\"") {
432        let big_table = rows.map(|r| r > 10_000.0).unwrap_or(false);
433        if big_table {
434            warnings.push(format!(
435                "Full table scan (~{:.0} rows) — consider adding an index",
436                rows.unwrap_or(0.0)
437            ));
438        } else {
439            warnings.push("Full table scan detected — consider adding an index".to_string());
440        }
441    }
442
443    (rows, warnings)
444}
445
446#[cfg(feature = "postgres")]
447fn extract_plan_info_text(plan_text: &str) -> (Option<f64>, Option<f64>, Vec<String>) {
448    let mut warnings = Vec::new();
449    let mut total_rows = None;
450    let mut total_cost = None;
451
452    // Parse cost and rows from the first line: "Seq Scan on ... (cost=0.00..35.50 rows=2550 width=36)"
453    for line in plan_text.lines() {
454        let trimmed = line.trim();
455        if let Some(cost_start) = trimmed.find("cost=") {
456            let rest = &trimmed[cost_start + 5..];
457            if let Some(dot_dot) = rest.find("..") {
458                let after_dots = &rest[dot_dot + 2..];
459                if let Some(space_pos) = after_dots.find(' ')
460                    && let Ok(cost) = after_dots[..space_pos].parse::<f64>()
461                    && total_cost.is_none()
462                {
463                    total_cost = Some(cost);
464                }
465            }
466        }
467        if let Some(rows_start) = trimmed.find("rows=") {
468            let rest = &trimmed[rows_start + 5..];
469            let end = rest
470                .find(|c: char| !c.is_ascii_digit())
471                .unwrap_or(rest.len());
472            if let Ok(rows) = rest[..end].parse::<f64>()
473                && total_rows.is_none()
474            {
475                total_rows = Some(rows);
476            }
477        }
478
479        // Detect sequential scans
480        if trimmed.contains("Seq Scan")
481            && let Some(rows) = total_rows
482            && rows > 10000.0
483        {
484            // Try to extract table name
485            let table = trimmed
486                .find("on ")
487                .map(|i| {
488                    let after = &trimmed[i + 3..];
489                    after.split_whitespace().next().unwrap_or("unknown")
490                })
491                .unwrap_or("unknown");
492            warnings.push(format!(
493                "Sequential Scan on '{}' (~{:.0} rows) — consider adding an index",
494                table, rows
495            ));
496        }
497    }
498
499    (total_rows, total_cost, warnings)
500}