1use crate::cli::CliError;
42use crate::cli::commands::compile;
43use crate::client::Client;
44use crate::client::quote_identifier;
45use crate::config::Settings;
46use crate::docker_runtime::{DockerRuntime, DockerRuntimeError};
47use crate::project::ast::Statement;
48use crate::project::compiler::cache::ProjectCache;
49use crate::project::ir::compiled::FullyQualifiedName;
50use crate::project::ir::graph;
51use crate::project::ir::object_id::ObjectId;
52use crate::project::resolve::normalize::NormalizingVisitor;
53use crate::types::stub::{StubTarget, build_stub_statements};
54use crate::types::{ColumnType, DataType, Types};
55use crate::verbose;
56use mz_sql_parser::ast::*;
57use serde::Serialize;
58use std::collections::{BTreeMap, BTreeSet};
59use std::fmt;
60use std::path::Path;
61use tokio_postgres::SimpleQueryMessage;
62
63struct ExplainTarget {
65 object_id: ObjectId,
66 index_name: Option<String>,
67}
68
69enum StagingAction {
71 StubTable {
73 object_id: ObjectId,
74 columns: BTreeMap<String, ColumnType>,
75 },
76 CreateIndex {
78 index: CreateIndexStatement<Raw>,
79 on_object: ObjectId,
80 },
81 CreateView {
83 object_id: ObjectId,
84 stmt: Statement,
85 },
86}
87
88#[derive(Serialize)]
90struct ExplainOutput {
91 object: String,
92 explain_output: String,
93}
94
95impl fmt::Display for ExplainOutput {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 write!(f, "{}", self.explain_output)
98 }
99}
100
101pub async fn run(
111 settings: &Settings,
112 target: &str,
113 overlay: Option<&Path>,
114) -> Result<(), CliError> {
115 let target = parse_target(target)?;
116
117 let fs = match overlay {
118 Some(p) => crate::fs::FileSystem::from_overlay_file(p).map_err(|e| {
119 CliError::Message(format!("failed to load overlay {}: {}", p.display(), e))
120 })?,
121 None => crate::fs::FileSystem::new(),
122 };
123 let project = compile::run_with_fs(settings, false, fs).await?;
124
125 let planned_obj = project.find_object(&target.object_id).ok_or_else(|| {
127 CliError::Message(format!(
128 "object '{}' not found in project",
129 target.object_id
130 ))
131 })?;
132
133 let target_cluster = validate_target(planned_obj, &target)?;
135
136 let (types_lock, types_cache) = load_types_and_cache(settings);
138
139 let get_columns = |id: &ObjectId| -> Option<BTreeMap<String, ColumnType>> {
140 types_cache
141 .as_ref()
142 .and_then(|tc| tc.get_columns(id))
143 .or_else(|| types_lock.get_table(id).cloned())
144 };
145
146 let runtime = DockerRuntime::new().with_image(&settings.docker_image);
148 let client = match runtime.get_client().await {
149 Ok(client) => client,
150 Err(DockerRuntimeError::ContainerStartFailed(e)) => {
151 return Err(CliError::Message(format!(
152 "Docker not available for running explain: {}",
153 e
154 )));
155 }
156 Err(e) => {
157 return Err(CliError::Message(format!(
158 "Failed to start explain environment: {}",
159 e
160 )));
161 }
162 };
163
164 let actions = plan_staging(&project, &target, &target_cluster, &get_columns)?;
166
167 let explain_schema = format!(
169 "_mz_explain_{}",
170 std::time::SystemTime::now()
171 .duration_since(std::time::UNIX_EPOCH)
172 .unwrap_or_default()
173 .as_millis()
174 );
175 let explain_db = target.object_id.expect_database();
176
177 let result = execute_explain(
179 &client,
180 explain_db,
181 &explain_schema,
182 &actions,
183 &target,
184 &planned_obj.typed_object,
185 &target_cluster,
186 )
187 .await;
188
189 let drop_sql = format!(
191 "DROP SCHEMA IF EXISTS {}.{} CASCADE",
192 quote_identifier(explain_db),
193 quote_identifier(&explain_schema),
194 );
195 verbose!("Cleanup: {}", drop_sql);
196 let _ = client.execute(&drop_sql, &[]).await;
197
198 let explain_text = result?;
199
200 let output = ExplainOutput {
201 object: target.object_id.to_string(),
202 explain_output: explain_text,
203 };
204 crate::log::output(&output);
205
206 Ok(())
207}
208
209fn parse_target(target: &str) -> Result<ExplainTarget, CliError> {
211 let (object_part, index_name) = match target.split_once('#') {
212 Some((obj, idx)) => (obj, Some(idx.to_string())),
213 None => (target, None),
214 };
215
216 let parts: Vec<&str> = object_part.split('.').collect();
217 if parts.len() != 3 {
218 return Err(CliError::Message(format!(
219 "expected fully qualified name 'database.schema.object', got '{}'",
220 object_part
221 )));
222 }
223
224 Ok(ExplainTarget {
225 object_id: ObjectId::new(
226 parts[0].to_string(),
227 parts[1].to_string(),
228 parts[2].to_string(),
229 ),
230 index_name,
231 })
232}
233
234fn validate_target(
236 planned_obj: &graph::DatabaseObject,
237 target: &ExplainTarget,
238) -> Result<String, CliError> {
239 match &target.index_name {
240 None => {
241 match &planned_obj.typed_object.stmt {
243 Statement::CreateMaterializedView(mv) => {
244 let cluster = mv
245 .in_cluster
246 .as_ref()
247 .expect("materialized view must have IN CLUSTER")
248 .to_string();
249 Ok(cluster)
250 }
251 other => Err(CliError::Message(format!(
252 "'{}' is a {}, but explain without #index only supports materialized views",
253 target.object_id,
254 other.kind()
255 ))),
256 }
257 }
258 Some(index_name) => {
259 let index = planned_obj
261 .typed_object
262 .indexes
263 .iter()
264 .find(|idx| {
265 idx.name
266 .as_ref()
267 .map(|n| n.to_string() == *index_name)
268 .unwrap_or(false)
269 })
270 .ok_or_else(|| {
271 let available: Vec<String> = planned_obj
272 .typed_object
273 .indexes
274 .iter()
275 .filter_map(|idx| idx.name.as_ref().map(|n| n.to_string()))
276 .collect();
277 CliError::Message(format!(
278 "index '{}' not found on '{}'. Available indexes: {}",
279 index_name,
280 target.object_id,
281 if available.is_empty() {
282 "(none)".to_string()
283 } else {
284 available.join(", ")
285 }
286 ))
287 })?;
288
289 let cluster = index
290 .in_cluster
291 .as_ref()
292 .expect("index must have IN CLUSTER")
293 .to_string();
294 Ok(cluster)
295 }
296 }
297}
298
299fn load_types_and_cache(settings: &Settings) -> (Types, Option<ProjectCache>) {
301 let types_lock = crate::types::load_types_lock(&settings.directory).unwrap_or_default();
302 let types_cache = ProjectCache::open(
303 &settings.directory,
304 settings.profile_name().unwrap_or(""),
305 settings.profile_suffix(),
306 settings.variables(),
307 )
308 .ok()
309 .flatten();
310 if types_cache.is_none() {
311 verbose!("No types cache found; stub tables will use types.lock and AST only");
312 }
313 (types_lock, types_cache)
314}
315
316fn plan_staging(
325 project: &graph::Project,
326 target: &ExplainTarget,
327 target_cluster: &str,
328 get_columns: &dyn Fn(&ObjectId) -> Option<BTreeMap<String, ColumnType>>,
329) -> Result<Vec<StagingAction>, CliError> {
330 let mut actions = Vec::new();
331 let mut visited = BTreeSet::new();
332
333 let target_deps = project
335 .dependency_graph
336 .get(&target.object_id)
337 .cloned()
338 .unwrap_or_default();
339
340 for dep_id in &target_deps {
341 plan_dep(
342 project,
343 dep_id,
344 target_cluster,
345 get_columns,
346 &mut actions,
347 &mut visited,
348 )?;
349 }
350
351 Ok(actions)
352}
353
354fn plan_dep(
356 project: &graph::Project,
357 dep_id: &ObjectId,
358 target_cluster: &str,
359 get_columns: &dyn Fn(&ObjectId) -> Option<BTreeMap<String, ColumnType>>,
360 actions: &mut Vec<StagingAction>,
361 visited: &mut BTreeSet<ObjectId>,
362) -> Result<(), CliError> {
363 if visited.contains(dep_id) {
364 return Ok(());
365 }
366 visited.insert(dep_id.clone());
367
368 if project.external_dependencies.contains(dep_id) {
370 let columns = get_columns_for_stub(dep_id, None, get_columns)?;
371 actions.push(StagingAction::StubTable {
372 object_id: dep_id.clone(),
373 columns,
374 });
375 return Ok(());
376 }
377
378 let planned_obj = project.find_object(dep_id).ok_or_else(|| {
379 CliError::Message(format!("dependency '{}' not found in project", dep_id))
380 })?;
381
382 let matching_indexes: Vec<_> = planned_obj
384 .typed_object
385 .indexes
386 .iter()
387 .filter(|idx| {
388 idx.in_cluster
389 .as_ref()
390 .map(|c| c.to_string() == target_cluster)
391 .unwrap_or(false)
392 })
393 .cloned()
394 .collect();
395
396 if !matching_indexes.is_empty() {
397 let columns =
399 get_columns_for_stub(dep_id, Some(&planned_obj.typed_object.stmt), get_columns)?;
400 actions.push(StagingAction::StubTable {
401 object_id: dep_id.clone(),
402 columns,
403 });
404 for index in matching_indexes {
405 actions.push(StagingAction::CreateIndex {
406 index,
407 on_object: dep_id.clone(),
408 });
409 }
410 } else {
411 match planned_obj.typed_object.stmt.kind() {
412 crate::types::ObjectKind::MaterializedView | crate::types::ObjectKind::Table => {
413 let columns = get_columns_for_stub(
415 dep_id,
416 Some(&planned_obj.typed_object.stmt),
417 get_columns,
418 )?;
419 actions.push(StagingAction::StubTable {
420 object_id: dep_id.clone(),
421 columns,
422 });
423 }
424 crate::types::ObjectKind::View => {
425 let view_deps = project
427 .dependency_graph
428 .get(dep_id)
429 .cloned()
430 .unwrap_or_default();
431 for sub_dep_id in &view_deps {
432 plan_dep(
433 project,
434 sub_dep_id,
435 target_cluster,
436 get_columns,
437 actions,
438 visited,
439 )?;
440 }
441 actions.push(StagingAction::CreateView {
442 object_id: dep_id.clone(),
443 stmt: planned_obj.typed_object.stmt.clone(),
444 });
445 }
446 kind => {
447 return Err(CliError::Message(format!(
448 "dependency '{}' is a {} which cannot be staged for explain",
449 dep_id, kind
450 )));
451 }
452 }
453 }
454
455 Ok(())
456}
457
458fn get_columns_for_stub(
464 object_id: &ObjectId,
465 stmt: Option<&Statement>,
466 get_columns: &dyn Fn(&ObjectId) -> Option<BTreeMap<String, ColumnType>>,
467) -> Result<BTreeMap<String, ColumnType>, CliError> {
468 if let Some(columns) = get_columns(object_id) {
469 return Ok(columns);
470 }
471
472 if let Some(Statement::CreateTable(table)) = stmt {
474 let mut columns = BTreeMap::new();
475 for (position, col) in table.columns.iter().enumerate() {
476 let nullable = !col
477 .options
478 .iter()
479 .any(|opt| matches!(opt.option, ColumnOption::NotNull));
480 columns.insert(
484 col.name.as_str().to_string(),
485 ColumnType {
486 r#type: raw_data_type_to_data_type(&col.data_type),
487 nullable,
488 position,
489 comment: None,
490 },
491 );
492 }
493 return Ok(columns);
494 }
495
496 Err(CliError::Message(format!(
497 "no column schema available for '{}'. Run 'mz-deploy compile' to populate the type cache",
498 object_id
499 )))
500}
501
502fn raw_data_type_to_data_type(data_type: &RawDataType) -> DataType {
506 match data_type {
507 RawDataType::Array(inner) => DataType::Array(Box::new(raw_data_type_to_data_type(inner))),
508 RawDataType::List(inner) => DataType::List(Box::new(raw_data_type_to_data_type(inner))),
509 RawDataType::Map { value_type, .. } => {
510 DataType::Map(Box::new(raw_data_type_to_data_type(value_type)))
511 }
512 RawDataType::Other { .. } => DataType::Named(data_type.to_string()),
513 }
514}
515
516async fn execute_explain(
518 client: &Client,
519 explain_db: &str,
520 explain_schema: &str,
521 actions: &[StagingAction],
522 target: &ExplainTarget,
523 target_typed_obj: &crate::project::ir::compiled::DatabaseObject,
524 target_cluster: &str,
525) -> Result<String, CliError> {
526 let create_db_sql = format!(
530 "CREATE DATABASE IF NOT EXISTS {}",
531 quote_identifier(explain_db),
532 );
533 verbose!("Creating explain database: {}", create_db_sql);
534 client
535 .execute(&create_db_sql, &[])
536 .await
537 .map_err(|e| CliError::Message(format!("failed to create explain database: {}", e)))?;
538
539 let create_schema_sql = format!(
541 "CREATE SCHEMA {}.{}",
542 quote_identifier(explain_db),
543 quote_identifier(explain_schema),
544 );
545 verbose!("Creating explain schema: {}", create_schema_sql);
546 client
547 .execute(&create_schema_sql, &[])
548 .await
549 .map_err(|e| CliError::Message(format!("failed to create explain schema: {}", e)))?;
550
551 let mut stub_seq = 0usize;
553 for action in actions {
554 match action {
555 StagingAction::StubTable { object_id, columns } => {
556 let fqn = object_id.to_string();
557 let qualification = format!(
558 "{}.{}.",
559 quote_identifier(explain_db),
560 quote_identifier(explain_schema),
561 );
562 let target = StubTarget {
563 name: format!("{}{}", qualification, quote_identifier(&fqn)),
564 helper_prefix: qualification,
565 helper_stem: format!("mz_deploy_stub_{}", stub_seq),
569 };
570 stub_seq += 1;
571 let statements = build_stub_statements(object_id, &target, columns)
572 .map_err(|e| CliError::Message(e.to_string()))?;
573 for sql in statements {
574 verbose!("Stub table: {}", sql);
575 client.execute(&sql, &[]).await.map_err(|e| {
576 CliError::Message(format!(
577 "failed to create stub table for '{}': {}",
578 object_id, e
579 ))
580 })?;
581 }
582 }
583 StagingAction::CreateIndex { index, on_object } => {
584 let sql = build_index_sql(index, on_object, explain_db, explain_schema);
585 verbose!("Create index: {}", sql);
586 client
587 .execute(&sql, &[])
588 .await
589 .map_err(|e| CliError::Message(format!("failed to create index: {}", e)))?;
590 }
591 StagingAction::CreateView { object_id, stmt } => {
592 let sql = build_view_sql(stmt, object_id, explain_db, explain_schema);
593 verbose!("Create view: {}", sql);
594 client.execute(&sql, &[]).await.map_err(|e| {
595 CliError::Message(format!("failed to create view '{}': {}", object_id, e))
596 })?;
597 }
598 }
599 }
600
601 create_target(client, explain_db, explain_schema, target, target_typed_obj).await?;
603
604 let explain_sql = build_explain_sql(target, explain_db, explain_schema);
606 verbose!("Running: {}", explain_sql);
607 let messages = client
608 .simple_query(&explain_sql)
609 .await
610 .map_err(|e| CliError::Message(format!("EXPLAIN failed: {}", e)))?;
611
612 let lines = extract_text_from_messages(messages);
613 let text = lines.join("\n");
614
615 let quoted_prefix = format!(
618 "{}.{}.",
619 quote_identifier(explain_db),
620 quote_identifier(explain_schema),
621 );
622 let unquoted_prefix = format!("{}.{}.", explain_db, explain_schema);
623 let text = text
624 .replace("ed_prefix, "")
625 .replace(&unquoted_prefix, "")
626 .replace(
627 "Target cluster: quickstart",
628 &format!("Target cluster: {}", target_cluster),
629 );
630 Ok(text)
631}
632
633async fn create_target(
635 client: &Client,
636 explain_db: &str,
637 explain_schema: &str,
638 target: &ExplainTarget,
639 typed_obj: &crate::project::ir::compiled::DatabaseObject,
640) -> Result<(), CliError> {
641 let fqn: FullyQualifiedName = target.object_id.clone().into();
642 let mut visitor =
643 NormalizingVisitor::explain(&fqn, explain_db.to_string(), explain_schema.to_string());
644
645 match &typed_obj.stmt {
647 Statement::CreateMaterializedView(_) => {
648 let normalized = typed_obj
649 .stmt
650 .clone()
651 .normalize_name_with(&visitor, &fqn.to_item_name())
652 .normalize_dependencies_with(&mut visitor)
653 .normalize_cluster_with(&visitor);
654 let sql = normalized.to_string();
655 verbose!("Create target MV: {}", sql);
656 client.execute(&sql, &[]).await.map_err(|e| {
657 CliError::Message(format!(
658 "failed to create target '{}': {}",
659 target.object_id, e
660 ))
661 })?;
662 }
663 other => {
664 if target.index_name.is_some() {
667 match other.kind() {
673 crate::types::ObjectKind::MaterializedView
674 | crate::types::ObjectKind::Table => {
675 }
677 crate::types::ObjectKind::View => {
678 let normalized = other
679 .clone()
680 .normalize_name_with(&visitor, &fqn.to_item_name())
681 .normalize_dependencies_with(&mut visitor);
682 let sql = normalized.to_string();
683 verbose!("Create target view: {}", sql);
684 client.execute(&sql, &[]).await.map_err(|e| {
685 CliError::Message(format!(
686 "failed to create target '{}': {}",
687 target.object_id, e
688 ))
689 })?;
690 }
691 kind => {
692 return Err(CliError::Message(format!(
693 "'{}' is a {} — cannot create in explain schema",
694 target.object_id, kind
695 )));
696 }
697 }
698 } else {
699 return Err(CliError::Message(format!(
700 "'{}' is a {} — explain only supports materialized views",
701 target.object_id,
702 other.kind()
703 )));
704 }
705 }
706 }
707
708 if target.index_name.is_some() {
710 let mut indexes = typed_obj.indexes.clone();
711 visitor.normalize_index_references(&mut indexes);
712 visitor.normalize_index_clusters(&mut indexes);
713 for index in &indexes {
714 let sql = index.to_string();
715 verbose!("Create target index: {}", sql);
716 client
717 .execute(&sql, &[])
718 .await
719 .map_err(|e| CliError::Message(format!("failed to create index: {}", e)))?;
720 }
721 }
722
723 Ok(())
724}
725
726fn build_index_sql(
728 index: &CreateIndexStatement<Raw>,
729 on_object: &ObjectId,
730 explain_db: &str,
731 explain_schema: &str,
732) -> String {
733 let fqn: FullyQualifiedName = on_object.clone().into();
734 let visitor =
735 NormalizingVisitor::explain(&fqn, explain_db.to_string(), explain_schema.to_string());
736
737 let mut indexes = vec![index.clone()];
738 visitor.normalize_index_references(&mut indexes);
739 visitor.normalize_index_clusters(&mut indexes);
740 indexes.into_iter().next().unwrap().to_string()
741}
742
743fn build_view_sql(
745 stmt: &Statement,
746 object_id: &ObjectId,
747 explain_db: &str,
748 explain_schema: &str,
749) -> String {
750 let fqn: FullyQualifiedName = object_id.clone().into();
751 let mut visitor =
752 NormalizingVisitor::explain(&fqn, explain_db.to_string(), explain_schema.to_string());
753
754 let normalized = stmt
755 .clone()
756 .normalize_name_with(&visitor, &fqn.to_item_name())
757 .normalize_dependencies_with(&mut visitor);
758
759 normalized.to_string()
760}
761
762fn build_explain_sql(target: &ExplainTarget, explain_db: &str, explain_schema: &str) -> String {
764 let flattened_obj = target.object_id.to_string();
765 let qualified_name = format!(
766 "{}.{}.{}",
767 quote_identifier(explain_db),
768 quote_identifier(explain_schema),
769 quote_identifier(&flattened_obj),
770 );
771
772 match &target.index_name {
773 None => {
774 format!("EXPLAIN MATERIALIZED VIEW {}", qualified_name)
775 }
776 Some(index_name) => {
777 let qualified_index = format!(
780 "{}.{}.{}",
781 quote_identifier(explain_db),
782 quote_identifier(explain_schema),
783 quote_identifier(index_name),
784 );
785 format!("EXPLAIN INDEX {}", qualified_index)
786 }
787 }
788}
789
790fn extract_text_from_messages(messages: Vec<SimpleQueryMessage>) -> Vec<String> {
795 let mut lines = Vec::new();
796 for msg in messages {
797 if let SimpleQueryMessage::Row(row) = msg {
798 for i in 0..row.columns().len() {
799 let text: Option<&str> = row.get(i);
800 if let Some(t) = text {
801 lines.push(t.to_string());
802 }
803 }
804 }
805 }
806 lines
807}