1use 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#[derive(Debug, Serialize)]
24pub struct ExplainReport {
25 pub migrations: Vec<MigrationExplain>,
27}
28
29impl ExplainReport {
30 pub fn has_failures(&self) -> bool {
35 self.migrations.iter().any(|m| m.error.is_some())
36 }
37}
38
39#[derive(Debug, Serialize)]
41pub struct MigrationExplain {
42 pub script: String,
44 pub version: Option<String>,
46 pub statements: Vec<StatementExplain>,
48 #[serde(default)]
57 pub error: Option<String>,
58}
59
60#[derive(Debug, Serialize)]
62pub struct StatementExplain {
63 pub statement_preview: String,
65 pub plan: String,
67 pub estimated_rows: Option<f64>,
69 pub estimated_cost: Option<f64>,
71 pub warnings: Vec<String>,
73 pub is_ddl: bool,
75}
76
77#[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 let resolved = crate::migration::scan_migrations(&config.migrations.locations)?;
97
98 let mut migrations = Vec::new();
99
100 client.batch_execute("BEGIN").await?;
108 let mut aborted = false;
109
110 let preview = async {
115 for info in &pending {
116 if aborted {
119 break;
120 }
121
122 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 if let Err(e) = client.batch_execute(trimmed).await {
159 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 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 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 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
261pub 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#[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, 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 error: None,
403 });
404 }
405
406 Ok(ExplainReport { migrations })
407}
408
409#[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 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 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 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 if trimmed.contains("Seq Scan")
481 && let Some(rows) = total_rows
482 && rows > 10000.0
483 {
484 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}