waypoint_core/commands/
explain.rs1use serde::Serialize;
7use tokio_postgres::Client;
8
9use crate::commands::info::{self, MigrationState};
10use crate::config::WaypointConfig;
11use crate::error::Result;
12use crate::placeholder::{build_placeholders, replace_placeholders};
13use crate::sql_parser::split_statements;
14
15#[derive(Debug, Serialize)]
17pub struct ExplainReport {
18 pub migrations: Vec<MigrationExplain>,
20}
21
22#[derive(Debug, Serialize)]
24pub struct MigrationExplain {
25 pub script: String,
27 pub version: Option<String>,
29 pub statements: Vec<StatementExplain>,
31}
32
33#[derive(Debug, Serialize)]
35pub struct StatementExplain {
36 pub statement_preview: String,
38 pub plan: String,
40 pub estimated_rows: Option<f64>,
42 pub estimated_cost: Option<f64>,
44 pub warnings: Vec<String>,
46 pub is_ddl: bool,
48}
49
50pub async fn execute(client: &Client, config: &WaypointConfig) -> Result<ExplainReport> {
52 let infos = info::execute(client, config).await?;
53
54 let pending: Vec<_> = infos
55 .iter()
56 .filter(|i| matches!(i.state, MigrationState::Pending | MigrationState::Outdated))
57 .collect();
58
59 let schema = &config.migrations.schema;
60 let db_user = crate::db::get_current_user(client)
61 .await
62 .unwrap_or_else(|_| "unknown".to_string());
63 let db_name = crate::db::get_current_database(client)
64 .await
65 .unwrap_or_else(|_| "unknown".to_string());
66
67 let resolved = crate::migration::scan_migrations(&config.migrations.locations)?;
69
70 let mut migrations = Vec::new();
71
72 for info in &pending {
73 let migration = resolved.iter().find(|m| m.script == info.script);
75 let sql = match migration {
76 Some(m) => {
77 let placeholders =
78 build_placeholders(&config.placeholders, schema, &db_user, &db_name, &m.script);
79 replace_placeholders(&m.sql, &placeholders)?
80 }
81 None => continue,
82 };
83
84 let statements_raw = split_statements(&sql);
85 let mut statements = Vec::new();
86
87 client.batch_execute("BEGIN").await?;
89
90 for stmt_str in &statements_raw {
91 let trimmed = stmt_str.trim();
92 if trimmed.is_empty() || trimmed.starts_with("--") {
93 continue;
94 }
95
96 let preview: String = trimmed.chars().take(80).collect();
97 let preview = if trimmed.len() > 80 {
98 format!("{}...", preview)
99 } else {
100 preview
101 };
102
103 let upper = trimmed.to_uppercase();
104 let is_ddl = upper.starts_with("CREATE")
105 || upper.starts_with("ALTER")
106 || upper.starts_with("DROP")
107 || upper.starts_with("TRUNCATE");
108
109 if is_ddl {
110 match client.batch_execute(trimmed).await {
112 Ok(()) => {}
113 Err(e) => {
114 log::debug!("DDL statement failed during explain: {}", e);
115 }
116 }
117 statements.push(StatementExplain {
118 statement_preview: preview,
119 plan: "DDL statement — not explainable".to_string(),
120 estimated_rows: None,
121 estimated_cost: None,
122 warnings: vec![],
123 is_ddl: true,
124 });
125 } else {
126 let explain_sql = format!("EXPLAIN (FORMAT TEXT) {}", trimmed);
128 match client.query(&explain_sql, &[]).await {
129 Ok(rows_result) => {
130 let plan_lines: Vec<String> =
131 rows_result.iter().map(|r| r.get::<_, String>(0)).collect();
132 let plan_str = plan_lines.join("\n");
133
134 let (rows, cost, warnings) = extract_plan_info_text(&plan_str);
135
136 statements.push(StatementExplain {
137 statement_preview: preview,
138 plan: plan_str,
139 estimated_rows: rows,
140 estimated_cost: cost,
141 warnings,
142 is_ddl: false,
143 });
144 }
145 Err(e) => {
146 statements.push(StatementExplain {
147 statement_preview: preview,
148 plan: format!("EXPLAIN failed: {}", e),
149 estimated_rows: None,
150 estimated_cost: None,
151 warnings: vec![],
152 is_ddl: false,
153 });
154 }
155 }
156 }
157 }
158
159 let _ = client.batch_execute("ROLLBACK").await;
161
162 migrations.push(MigrationExplain {
163 script: info.script.clone(),
164 version: info.version.clone(),
165 statements,
166 });
167 }
168
169 Ok(ExplainReport { migrations })
170}
171
172fn extract_plan_info_text(plan_text: &str) -> (Option<f64>, Option<f64>, Vec<String>) {
173 let mut warnings = Vec::new();
174 let mut total_rows = None;
175 let mut total_cost = None;
176
177 for line in plan_text.lines() {
179 let trimmed = line.trim();
180 if let Some(cost_start) = trimmed.find("cost=") {
181 let rest = &trimmed[cost_start + 5..];
182 if let Some(dot_dot) = rest.find("..") {
183 let after_dots = &rest[dot_dot + 2..];
184 if let Some(space_pos) = after_dots.find(' ') {
185 if let Ok(cost) = after_dots[..space_pos].parse::<f64>() {
186 if total_cost.is_none() {
187 total_cost = Some(cost);
188 }
189 }
190 }
191 }
192 }
193 if let Some(rows_start) = trimmed.find("rows=") {
194 let rest = &trimmed[rows_start + 5..];
195 let end = rest
196 .find(|c: char| !c.is_ascii_digit())
197 .unwrap_or(rest.len());
198 if let Ok(rows) = rest[..end].parse::<f64>() {
199 if total_rows.is_none() {
200 total_rows = Some(rows);
201 }
202 }
203 }
204
205 if trimmed.contains("Seq Scan") {
207 if let Some(rows) = total_rows {
208 if rows > 10000.0 {
209 let table = trimmed
211 .find("on ")
212 .map(|i| {
213 let after = &trimmed[i + 3..];
214 after.split_whitespace().next().unwrap_or("unknown")
215 })
216 .unwrap_or("unknown");
217 warnings.push(format!(
218 "Sequential Scan on '{}' (~{:.0} rows) — consider adding an index",
219 table, rows
220 ));
221 }
222 }
223 }
224 }
225
226 (total_rows, total_cost, warnings)
227}