Skip to main content

mz_deploy/cli/commands/
explain.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Explain command — show the EXPLAIN plan for a materialized view or index.
11//!
12//! This command compiles the project, spins up an ephemeral Materialize Docker
13//! container, stages the target object's dependencies in a dedicated schema,
14//! creates the target, and runs `EXPLAIN` to show the query plan.
15//!
16//! ## Target Format
17//!
18//! `database.schema.object` — explain a materialized view
19//! `database.schema.object#index_name` — explain a specific index
20//!
21//! ## Dependency Staging Algorithm
22//!
23//! For each dependency of the target object:
24//!
25//! 1. If the dependency has indexes on the **same cluster** as the target →
26//!    stub as TABLE + create those matching indexes on `quickstart`.
27//! 2. Else if the dependency is a materialized view, table, or table-from-source →
28//!    stub as TABLE only.
29//! 3. Else (plain view) → recursively stage its dependencies, then create it.
30//!
31//! All `IN CLUSTER` clauses are rewritten to `quickstart` via the
32//! `ExplainTransformer`.
33//!
34//! ## Schema Lifecycle
35//!
36//! The target's database is created with `IF NOT EXISTS` and a dedicated
37//! schema `_mz_explain_<timestamp>` is created before staging. The schema is
38//! dropped with `CASCADE` after completion (even on error). The Docker
39//! container itself is reused across runs.
40
41use 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
63/// The parsed explain target: an object and optional index name.
64struct ExplainTarget {
65    object_id: ObjectId,
66    index_name: Option<String>,
67}
68
69/// Actions to stage dependencies before running EXPLAIN.
70enum StagingAction {
71    /// Create a stub TABLE from column schemas.
72    StubTable {
73        object_id: ObjectId,
74        columns: BTreeMap<String, ColumnType>,
75    },
76    /// Create an index on a previously stubbed table.
77    CreateIndex {
78        index: CreateIndexStatement<Raw>,
79        on_object: ObjectId,
80    },
81    /// Create the actual view (for plain views in the "else" case).
82    CreateView {
83        object_id: ObjectId,
84        stmt: Statement,
85    },
86}
87
88/// Output of the explain command.
89#[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
101/// Run the explain command.
102///
103/// Compiles the project, spins up an ephemeral Materialize Docker container,
104/// stages dependencies in a temporary schema, creates the target object, runs
105/// EXPLAIN, and cleans up.
106///
107/// `overlay` optionally points to a JSON file mapping absolute paths to
108/// contents that override the on-disk project files during compilation. The
109/// VSCode extension uses this to compile against unsaved editor buffers.
110pub 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    // Find the target object in the planned project
126    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    // Validate target type and find the target cluster
134    let target_cluster = validate_target(planned_obj, &target)?;
135
136    // Load column schemas for stub tables
137    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    // Connect to ephemeral Materialize Docker container
147    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    // Build the staging plan (pure core)
165    let actions = plan_staging(&project, &target, &target_cluster, &get_columns)?;
166
167    // Generate a unique schema name using timestamp
168    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    // Execute the explain plan with cleanup
178    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    // Always drop the schema (best effort)
190    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
209/// Parse a target string like `database.schema.object` or `database.schema.object#index`.
210fn 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
234/// Validate the target is an MV or index and return the target cluster name.
235fn validate_target(
236    planned_obj: &graph::DatabaseObject,
237    target: &ExplainTarget,
238) -> Result<String, CliError> {
239    match &target.index_name {
240        None => {
241            // Must be a materialized view
242            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            // Find the named index
260            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
299/// Load types.lock and open ProjectCache for stub table column schemas.
300fn 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
316/// Build the staging actions for all transitive dependencies of the target.
317///
318/// This is a pure function (no I/O). It walks the dependency graph and
319/// classifies each dependency according to the staging algorithm:
320///
321/// 1. Has indexes on the target's cluster → stub TABLE + create those indexes
322/// 2. Is MV/table/table-from-source → stub TABLE only
323/// 3. Is a plain view → recursively stage deps, then create the view
324fn 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    // Get the target's direct dependencies
334    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
354/// Recursively plan staging for a single dependency.
355fn 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    // External dependencies get stubbed if we have their types
369    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    // Check if the dependency has indexes on the target's cluster
383    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        // Case 1: Stub as table + create matching indexes
398        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                // Case 2: Stub as table only
414                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                // Case 3: Recursively stage this view's dependencies, then create it
426                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
458/// Get column schemas for a stub table.
459///
460/// Tries, in order:
461/// 1. Column lookup (types cache then types.lock)
462/// 2. `CREATE TABLE` AST columns (if the statement is a CreateTable)
463fn 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    // Try deriving from CREATE TABLE AST
473    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            // Use `as_str()` to get the raw identifier — `to_string()` would
481            // wrap non-bare names in literal quotes, which we'd then re-quote
482            // when building the stub table SQL.
483            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
502/// Convert a parsed `CREATE TABLE` column type into the contract's form.
503///
504/// The grammar has no record production, so this never produces a record.
505fn 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
516/// Execute the staging actions, create the target, and run EXPLAIN.
517async 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    // Create the explain database (the Docker container starts empty, so the
527    // user's target database may not exist). Idempotent so reused containers
528    // are fine.
529    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    // Create the explain schema
540    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    // Execute staging actions
552    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                    // Every stub in the explain schema is named after an
566                    // `ObjectId`, which always contains a dot, so a dot-free
567                    // helper name cannot collide with one.
568                    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 the target object
602    create_target(client, explain_db, explain_schema, target, target_typed_obj).await?;
603
604    // Run EXPLAIN
605    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    // Strip the temporary schema prefix from the output so users see clean object names.
616    // Materialize's EXPLAIN output uses unquoted identifiers, so match both forms.
617    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(&quoted_prefix, "")
625        .replace(&unquoted_prefix, "")
626        .replace(
627            "Target cluster: quickstart",
628            &format!("Target cluster: {}", target_cluster),
629        );
630    Ok(text)
631}
632
633/// Create the target object (MV + indexes if explaining an index).
634async 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    // Create the main statement
646    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            // For index targets, the parent object might not be an MV — stub it
665            // and we only create indexes below
666            if target.index_name.is_some() {
667                // The parent object was already handled by the staging actions
668                // (it's a dependency of itself in a sense, but actually the indexes
669                // are ON this object). We need to make sure it exists in the explain
670                // schema. If it's an MV/table, it was stubbed. If it's something
671                // else, create it.
672                match other.kind() {
673                    crate::types::ObjectKind::MaterializedView
674                    | crate::types::ObjectKind::Table => {
675                        // Already stubbed as a table by the caller — nothing to do
676                    }
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 explaining an index, create all indexes on the target
709    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
726/// Build SQL for creating an index in the explain schema.
727fn 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
743/// Build SQL for creating a view in the explain schema.
744fn 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
762/// Build the EXPLAIN SQL statement.
763fn 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            // Index names in the explain schema are normalized by the visitor
778            // The index name itself is not flattened — it stays as-is
779            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
790/// Extract raw text lines from `SimpleQueryMessage` results.
791///
792/// Concatenates all cell values — the right shape for EXPLAIN output
793/// (a series of single-column rows).
794fn 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}