Skip to main content

datafusion_common/
format.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::fmt::{self, Display};
19use std::str::FromStr;
20
21use arrow::compute::CastOptions;
22use arrow::util::display::{DurationFormat, FormatOptions};
23
24use crate::config::{ConfigField, Visit};
25use crate::error::{DataFusionError, Result};
26#[cfg(feature = "sql")]
27use sqlparser::ast::{Expr, UtilityOption, Value, ValueWithSpan};
28
29/// The default [`FormatOptions`] to use within DataFusion
30/// Also see [`crate::config::FormatOptions`]
31pub const DEFAULT_FORMAT_OPTIONS: FormatOptions<'static> =
32    FormatOptions::new().with_duration_format(DurationFormat::Pretty);
33
34/// The default [`CastOptions`] to use within DataFusion
35pub const DEFAULT_CAST_OPTIONS: CastOptions<'static> = CastOptions {
36    safe: false,
37    format_options: DEFAULT_FORMAT_OPTIONS,
38};
39
40/// Output formats for controlling for Explain plans
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42pub enum ExplainFormat {
43    /// Indent mode
44    ///
45    /// Example:
46    /// ```text
47    /// > explain format indent select x from values (1) t(x);
48    /// +---------------+-----------------------------------------------------+
49    /// | plan_type     | plan                                                |
50    /// +---------------+-----------------------------------------------------+
51    /// | logical_plan  | SubqueryAlias: t                                    |
52    /// |               |   Projection: column1 AS x                          |
53    /// |               |     Values: (Int64(1))                              |
54    /// | physical_plan | ProjectionExec: expr=[column1@0 as x]               |
55    /// |               |   DataSourceExec: partitions=1, partition_sizes=[1] |
56    /// |               |                                                     |
57    /// +---------------+-----------------------------------------------------+
58    /// ```
59    Indent,
60    /// Tree mode
61    ///
62    /// Example:
63    /// ```text
64    /// > explain format tree select x from values (1) t(x);
65    /// +---------------+-------------------------------+
66    /// | plan_type     | plan                          |
67    /// +---------------+-------------------------------+
68    /// | physical_plan | ┌───────────────────────────┐ |
69    /// |               | │       ProjectionExec      │ |
70    /// |               | │    --------------------   │ |
71    /// |               | │        x: column1@0       │ |
72    /// |               | └─────────────┬─────────────┘ |
73    /// |               | ┌─────────────┴─────────────┐ |
74    /// |               | │       DataSourceExec      │ |
75    /// |               | │    --------------------   │ |
76    /// |               | │         bytes: 128        │ |
77    /// |               | │       format: memory      │ |
78    /// |               | │          rows: 1          │ |
79    /// |               | └───────────────────────────┘ |
80    /// |               |                               |
81    /// +---------------+-------------------------------+
82    /// ```
83    Tree,
84    /// Postgres Json mode
85    ///
86    /// A displayable structure that produces plan in postgresql JSON format.
87    ///
88    /// Users can use this format to visualize the plan in existing plan
89    /// visualization tools, for example [dalibo](https://explain.dalibo.com/)
90    ///
91    /// Example:
92    /// ```text
93    /// > explain format pgjson select x from values (1) t(x);
94    /// +--------------+--------------------------------------+
95    /// | plan_type    | plan                                 |
96    /// +--------------+--------------------------------------+
97    /// | logical_plan | [                                    |
98    /// |              |   {                                  |
99    /// |              |     "Plan": {                        |
100    /// |              |       "Alias": "t",                  |
101    /// |              |       "Node Type": "Subquery",       |
102    /// |              |       "Output": [                    |
103    /// |              |         "x"                          |
104    /// |              |       ],                             |
105    /// |              |       "Plans": [                     |
106    /// |              |         {                            |
107    /// |              |           "Expressions": [           |
108    /// |              |             "column1 AS x"           |
109    /// |              |           ],                         |
110    /// |              |           "Node Type": "Projection", |
111    /// |              |           "Output": [                |
112    /// |              |             "x"                      |
113    /// |              |           ],                         |
114    /// |              |           "Plans": [                 |
115    /// |              |             {                        |
116    /// |              |               "Node Type": "Values", |
117    /// |              |               "Output": [            |
118    /// |              |                 "column1"            |
119    /// |              |               ],                     |
120    /// |              |               "Plans": [],           |
121    /// |              |               "Values": "(Int64(1))" |
122    /// |              |             }                        |
123    /// |              |           ]                          |
124    /// |              |         }                            |
125    /// |              |       ]                              |
126    /// |              |     }                                |
127    /// |              |   }                                  |
128    /// |              | ]                                    |
129    /// +--------------+--------------------------------------+
130    /// ```
131    PostgresJSON,
132    /// Graphviz mode
133    ///
134    /// Example:
135    /// ```text
136    /// > explain format graphviz select x from values (1) t(x);
137    /// +--------------+------------------------------------------------------------------------+
138    /// | plan_type    | plan                                                                   |
139    /// +--------------+------------------------------------------------------------------------+
140    /// | logical_plan |                                                                        |
141    /// |              | // Begin DataFusion GraphViz Plan,                                     |
142    /// |              | // display it online here: https://dreampuf.github.io/GraphvizOnline   |
143    /// |              |                                                                        |
144    /// |              | digraph {                                                              |
145    /// |              |   subgraph cluster_1                                                   |
146    /// |              |   {                                                                    |
147    /// |              |     graph[label="LogicalPlan"]                                         |
148    /// |              |     2[shape=box label="SubqueryAlias: t"]                              |
149    /// |              |     3[shape=box label="Projection: column1 AS x"]                      |
150    /// |              |     2 -> 3 [arrowhead=none, arrowtail=normal, dir=back]                |
151    /// |              |     4[shape=box label="Values: (Int64(1))"]                            |
152    /// |              |     3 -> 4 [arrowhead=none, arrowtail=normal, dir=back]                |
153    /// |              |   }                                                                    |
154    /// |              |   subgraph cluster_5                                                   |
155    /// |              |   {                                                                    |
156    /// |              |     graph[label="Detailed LogicalPlan"]                                |
157    /// |              |     6[shape=box label="SubqueryAlias: t\nSchema: [x:Int64;N]"]         |
158    /// |              |     7[shape=box label="Projection: column1 AS x\nSchema: [x:Int64;N]"] |
159    /// |              |     6 -> 7 [arrowhead=none, arrowtail=normal, dir=back]                |
160    /// |              |     8[shape=box label="Values: (Int64(1))\nSchema: [column1:Int64;N]"] |
161    /// |              |     7 -> 8 [arrowhead=none, arrowtail=normal, dir=back]                |
162    /// |              |   }                                                                    |
163    /// |              | }                                                                      |
164    /// |              | // End DataFusion GraphViz Plan                                        |
165    /// |              |                                                                        |
166    /// +--------------+------------------------------------------------------------------------+
167    /// ```
168    Graphviz,
169}
170
171/// Implement  parsing strings to `ExplainFormat`
172impl FromStr for ExplainFormat {
173    type Err = DataFusionError;
174
175    fn from_str(format: &str) -> Result<Self, Self::Err> {
176        match format.to_lowercase().as_str() {
177            "indent" => Ok(ExplainFormat::Indent),
178            "tree" => Ok(ExplainFormat::Tree),
179            "pgjson" => Ok(ExplainFormat::PostgresJSON),
180            "graphviz" => Ok(ExplainFormat::Graphviz),
181            _ => Err(DataFusionError::Configuration(format!(
182                "Invalid explain format. Expected 'indent', 'tree', 'pgjson' or 'graphviz'. Got '{format}'"
183            ))),
184        }
185    }
186}
187
188impl Display for ExplainFormat {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        let s = match self {
191            ExplainFormat::Indent => "indent",
192            ExplainFormat::Tree => "tree",
193            ExplainFormat::PostgresJSON => "pgjson",
194            ExplainFormat::Graphviz => "graphviz",
195        };
196        write!(f, "{s}")
197    }
198}
199
200impl ConfigField for ExplainFormat {
201    fn visit<V: Visit>(&self, v: &mut V, key: &str, description: &'static str) {
202        v.some(key, self, description)
203    }
204
205    fn set(&mut self, _: &str, value: &str) -> Result<()> {
206        *self = ExplainFormat::from_str(value)?;
207        Ok(())
208    }
209}
210
211/// Categorizes metrics so the display layer can choose the desired verbosity.
212///
213/// The `datafusion.explain.analyze_level` configuration controls which
214/// type is shown:
215/// - `"dev"` (the default): all metrics are shown.
216/// - `"summary"`: only metrics tagged as `Summary` are shown.
217///
218/// This is orthogonal to [`MetricCategory`], which filters by *what kind*
219/// of value a metric represents (rows / bytes / timing).
220///
221/// # Difference from `EXPLAIN ANALYZE VERBOSE`
222///
223/// The `VERBOSE` keyword controls whether per-partition metrics are shown
224/// (when specified) or aggregated metrics are displayed (when omitted).
225/// In contrast, `MetricType` determines which *levels* of metrics are
226/// displayed.
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
228pub enum MetricType {
229    /// Common metrics for high-level insights (answering which operator is slow)
230    Summary,
231    /// For deep operator-level introspection for developers
232    Dev,
233}
234
235impl MetricType {
236    /// Returns the set of metric types that should be shown for this level.
237    ///
238    /// `Dev` is a superset of `Summary`: when the user selects
239    /// `analyze_level = 'dev'`, both `Summary` and `Dev` metrics are shown.
240    pub fn included_types(self) -> Vec<MetricType> {
241        match self {
242            MetricType::Summary => vec![MetricType::Summary],
243            MetricType::Dev => vec![MetricType::Summary, MetricType::Dev],
244        }
245    }
246}
247
248impl FromStr for MetricType {
249    type Err = DataFusionError;
250
251    fn from_str(s: &str) -> Result<Self, Self::Err> {
252        match s.trim().to_lowercase().as_str() {
253            "summary" => Ok(Self::Summary),
254            "dev" => Ok(Self::Dev),
255            other => Err(DataFusionError::Configuration(format!(
256                "Invalid explain analyze level. Expected 'summary' or 'dev'. Got '{other}'"
257            ))),
258        }
259    }
260}
261
262impl Display for MetricType {
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        match self {
265            Self::Summary => write!(f, "summary"),
266            Self::Dev => write!(f, "dev"),
267        }
268    }
269}
270
271impl ConfigField for MetricType {
272    fn visit<V: Visit>(&self, v: &mut V, key: &str, description: &'static str) {
273        v.some(key, self, description)
274    }
275
276    fn set(&mut self, _: &str, value: &str) -> Result<()> {
277        *self = MetricType::from_str(value)?;
278        Ok(())
279    }
280}
281
282/// Classifies a metric by what it measures.
283///
284/// This is orthogonal to [`MetricType`] (Summary / Dev), which controls
285/// *verbosity*. `MetricCategory` controls *what kind of value* is shown,
286/// so that `EXPLAIN ANALYZE` output can be narrowed to only the categories
287/// that are useful in a given context.
288///
289/// In particular this is useful for testing since metrics differ in their stability across runs:
290/// - [`Rows`](Self::Rows) and [`Bytes`](Self::Bytes) depend only on the plan
291///   and the data, so they are mostly deterministic across runs (given the same
292///   input). Variations can existing e.g. because of non-deterministic ordering
293///   of evaluation between threads.
294///   Running with a single target partition often makes these metrics stable enough to assert on in tests.
295/// - [`Timing`](Self::Timing) depends on hardware, system load, scheduling,
296///   etc., so it varies from run to run even on the same machine.
297///
298/// [`MetricCategory`] is especially useful in sqllogictest (`.slt`) files:
299/// setting `datafusion.explain.analyze_categories = 'rows'` lets a test
300/// assert on row-count metrics without sprinkling `<slt:ignore>` over every
301/// timing value.
302///
303/// Metrics that do not declare a category (the default for custom
304/// `Count` / `Gauge` metrics) are treated as
305/// [`Uncategorized`](Self::Uncategorized) for filtering purposes.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
307pub enum MetricCategory {
308    /// Row counts and related dimensionless counters: `output_rows`,
309    /// `spilled_rows`, `output_batches`, pruning metrics, ratios, etc.
310    ///
311    /// Mostly deterministic given the same plan and data.
312    Rows,
313    /// Byte measurements: `output_bytes`, `spilled_bytes`,
314    /// `current_memory_usage`, `bytes_scanned`, etc.
315    ///
316    /// Mostly deterministic given the same plan and data.
317    Bytes,
318    /// Wall-clock durations and timestamps: `elapsed_compute`,
319    /// operator-defined `Time` metrics, `start_timestamp` /
320    /// `end_timestamp`, etc.
321    ///
322    /// **Non-deterministic** — varies across runs even on the same hardware.
323    Timing,
324    /// Catch-all for metrics that do not fit into [`Rows`](Self::Rows),
325    /// [`Bytes`](Self::Bytes), or [`Timing`](Self::Timing).
326    ///
327    /// Custom `Count` / `Gauge` metrics that are not explicitly assigned
328    /// a category are treated as `Uncategorized` for filtering purposes.
329    ///
330    /// This variant lets users explicitly include or exclude these
331    /// metrics, e.g.:
332    /// ```sql
333    /// SET datafusion.explain.analyze_categories = 'rows, bytes, uncategorized';
334    /// ```
335    Uncategorized,
336}
337
338impl FromStr for MetricCategory {
339    type Err = DataFusionError;
340
341    fn from_str(s: &str) -> Result<Self, Self::Err> {
342        match s.trim().to_lowercase().as_str() {
343            "rows" => Ok(Self::Rows),
344            "bytes" => Ok(Self::Bytes),
345            "timing" => Ok(Self::Timing),
346            "uncategorized" => Ok(Self::Uncategorized),
347            other => Err(DataFusionError::Configuration(format!(
348                "Invalid metric category '{other}'. \
349                 Expected 'rows', 'bytes', 'timing', or 'uncategorized'."
350            ))),
351        }
352    }
353}
354
355impl Display for MetricCategory {
356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357        match self {
358            Self::Rows => write!(f, "rows"),
359            Self::Bytes => write!(f, "bytes"),
360            Self::Timing => write!(f, "timing"),
361            Self::Uncategorized => write!(f, "uncategorized"),
362        }
363    }
364}
365
366/// Controls which [`MetricCategory`] values are shown in `EXPLAIN ANALYZE`.
367///
368/// Set via `SET datafusion.explain.analyze_categories = '...'`.
369///
370/// See [`MetricCategory`] for the determinism properties that motivate
371/// this filter.
372#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
373pub enum ExplainAnalyzeCategories {
374    /// Show all metrics regardless of category (the default).
375    #[default]
376    All,
377    /// Show only metrics whose category is in the list.
378    /// Metrics with no declared category are treated as
379    /// [`Uncategorized`](MetricCategory::Uncategorized) for filtering.
380    ///
381    /// An **empty** vec means "plan only" — suppress all metrics.
382    Only(Vec<MetricCategory>),
383}
384
385impl FromStr for ExplainAnalyzeCategories {
386    type Err = DataFusionError;
387
388    fn from_str(s: &str) -> Result<Self, Self::Err> {
389        let s = s.trim().to_lowercase();
390        match s.as_str() {
391            "all" => Ok(Self::All),
392            "none" => Ok(Self::Only(vec![])),
393            other => {
394                let mut cats = Vec::new();
395                for part in other.split(',') {
396                    cats.push(part.trim().parse::<MetricCategory>()?);
397                }
398                cats.dedup();
399                Ok(Self::Only(cats))
400            }
401        }
402    }
403}
404
405impl Display for ExplainAnalyzeCategories {
406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407        match self {
408            Self::All => write!(f, "all"),
409            Self::Only(cats) if cats.is_empty() => write!(f, "none"),
410            Self::Only(cats) => {
411                let mut first = true;
412                for cat in cats {
413                    if !first {
414                        write!(f, ",")?;
415                    }
416                    first = false;
417                    write!(f, "{cat}")?;
418                }
419                Ok(())
420            }
421        }
422    }
423}
424
425impl ConfigField for ExplainAnalyzeCategories {
426    fn visit<V: Visit>(&self, v: &mut V, key: &str, description: &'static str) {
427        v.some(key, self, description)
428    }
429
430    fn set(&mut self, _: &str, value: &str) -> Result<()> {
431        *self = ExplainAnalyzeCategories::from_str(value)?;
432        Ok(())
433    }
434}
435
436/// Normalized options for a single `EXPLAIN` statement.
437///
438/// This collects the knobs that can be set per-statement from either the
439/// legacy keyword form (`EXPLAIN ANALYZE VERBOSE FORMAT tree ...`) or the
440/// Postgres-style `EXPLAIN (option [arg], ...) ...` form supported on
441/// dialects whose
442/// [`Dialect::supports_explain_with_utility_options`](https://docs.rs/sqlparser/latest/sqlparser/dialect/trait.Dialect.html#method.supports_explain_with_utility_options)
443/// returns `true`.
444///
445/// Fields that are `None` / `false` mean "not set at the statement level" —
446/// the physical planner falls back to the corresponding session config
447/// value.
448#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
449pub struct ExplainStatementOptions {
450    /// Whether to actually execute the plan and gather metrics.
451    ///
452    /// Corresponds to the `ANALYZE` keyword or the `ANALYZE` option.
453    pub analyze: bool,
454    /// Whether to include extra detail in the output.
455    ///
456    /// Corresponds to the `VERBOSE` keyword or the `VERBOSE` option.
457    pub verbose: bool,
458    /// Output format for the plan. When `None`, the session-config
459    /// default (`datafusion.explain.format`) is used.
460    pub format: Option<ExplainFormat>,
461    /// Override for [`MetricType`] (summary / dev) when running
462    /// `EXPLAIN ANALYZE`.
463    pub analyze_level: Option<MetricType>,
464    /// Override for [`ExplainAnalyzeCategories`] (rows / bytes / timing
465    /// / uncategorized) when running `EXPLAIN ANALYZE`.
466    pub analyze_categories: Option<ExplainAnalyzeCategories>,
467    /// Override for `datafusion.explain.show_statistics`.
468    pub show_statistics: Option<bool>,
469}
470
471#[cfg(feature = "sql")]
472impl ExplainStatementOptions {
473    /// Parse a list of [`UtilityOption`] values (produced by sqlparser's
474    /// `parse_utility_options`) into a normalized [`ExplainStatementOptions`].
475    ///
476    /// Argument grammar accepted:
477    /// - `OPTION` — bare, implies `TRUE` for boolean options.
478    /// - `OPTION TRUE` / `OPTION FALSE`
479    /// - `OPTION ON` / `OPTION OFF`
480    /// - `OPTION 1` / `OPTION 0`
481    /// - `OPTION <ident>` or `OPTION '<string>'` for format / level / metrics.
482    ///
483    /// Options recognized by DataFusion are: `ANALYZE`, `VERBOSE`, `FORMAT`,
484    /// `METRICS`, `LEVEL`, `TIMING`, `SUMMARY`, `COSTS`.
485    ///
486    /// Postgres-only options (`BUFFERS`, `WAL`, `SETTINGS`, `GENERIC_PLAN`,
487    /// `MEMORY`) return a helpful "not supported" error. Any other option
488    /// name produces an `unknown EXPLAIN option` error.
489    pub fn from_utility_options(opts: &[UtilityOption]) -> Result<Self> {
490        let mut out = ExplainStatementOptions::default();
491        // Track whether METRICS was explicitly set so TIMING can merge
492        // into it rather than overwrite.
493        let mut metrics_explicit = false;
494
495        for opt in opts {
496            let name = opt.name.value.to_ascii_lowercase();
497            match name.as_str() {
498                "analyze" => {
499                    out.analyze = parse_bool_arg(&opt.arg, &name)?;
500                }
501                "verbose" => {
502                    out.verbose = parse_bool_arg(&opt.arg, &name)?;
503                }
504                "format" => {
505                    let s = parse_ident_or_string_arg(&opt.arg, &name)?;
506                    out.format = Some(ExplainFormat::from_str(&s)?);
507                }
508                "metrics" => {
509                    let s = parse_ident_or_string_arg(&opt.arg, &name)?;
510                    out.analyze_categories =
511                        Some(ExplainAnalyzeCategories::from_str(&s)?);
512                    metrics_explicit = true;
513                }
514                "level" => {
515                    let s = parse_ident_or_string_arg(&opt.arg, &name)?;
516                    out.analyze_level = Some(MetricType::from_str(&s)?);
517                }
518                "timing" => {
519                    let enable = parse_bool_arg(&opt.arg, &name)?;
520                    out.analyze_categories = Some(adjust_timing(
521                        out.analyze_categories.take(),
522                        enable,
523                        metrics_explicit,
524                    ));
525                }
526                "summary" => {
527                    let summary = parse_bool_arg(&opt.arg, &name)?;
528                    out.analyze_level = Some(if summary {
529                        MetricType::Summary
530                    } else {
531                        MetricType::Dev
532                    });
533                }
534                "costs" => {
535                    out.show_statistics = Some(parse_bool_arg(&opt.arg, &name)?);
536                }
537                // Postgres options DataFusion does not model. Give a helpful
538                // pointer rather than silently accepting them.
539                "buffers" | "wal" | "settings" | "generic_plan" | "memory" => {
540                    let upper = name.to_ascii_uppercase();
541                    return Err(DataFusionError::NotImplemented(format!(
542                        "EXPLAIN option {upper} is not supported by DataFusion; \
543                         see METRICS for category filtering"
544                    )));
545                }
546                _ => {
547                    return Err(DataFusionError::Plan(format!(
548                        "unknown EXPLAIN option: {}",
549                        opt.name.value
550                    )));
551                }
552            }
553        }
554
555        Ok(out)
556    }
557}
558
559/// Parse a boolean argument for an EXPLAIN option.
560///
561/// `None` (bare option, e.g. `ANALYZE`) is treated as `true`. Accepts
562/// identifiers `TRUE`/`FALSE`/`ON`/`OFF` (case-insensitive) and the numeric
563/// literals `0` / `1`.
564#[cfg(feature = "sql")]
565fn parse_bool_arg(arg: &Option<Expr>, name: &str) -> Result<bool> {
566    let Some(expr) = arg else {
567        return Ok(true);
568    };
569    match expr {
570        Expr::Identifier(ident) => match ident.value.to_ascii_lowercase().as_str() {
571            "true" | "on" => Ok(true),
572            "false" | "off" => Ok(false),
573            other => Err(DataFusionError::Plan(format!(
574                "expected boolean for EXPLAIN option {name}, got '{other}'"
575            ))),
576        },
577        Expr::Value(ValueWithSpan { value, .. }) => match value {
578            Value::Boolean(b) => Ok(*b),
579            Value::Number(n, _) => match n.as_str() {
580                "0" => Ok(false),
581                "1" => Ok(true),
582                other => Err(DataFusionError::Plan(format!(
583                    "expected boolean (0 or 1) for EXPLAIN option {name}, got '{other}'"
584                ))),
585            },
586            Value::SingleQuotedString(s) | Value::DoubleQuotedString(s) => {
587                match s.to_ascii_lowercase().as_str() {
588                    "true" | "on" | "1" => Ok(true),
589                    "false" | "off" | "0" => Ok(false),
590                    other => Err(DataFusionError::Plan(format!(
591                        "expected boolean for EXPLAIN option {name}, got '{other}'"
592                    ))),
593                }
594            }
595            other => Err(DataFusionError::Plan(format!(
596                "expected boolean for EXPLAIN option {name}, got '{other}'"
597            ))),
598        },
599        other => Err(DataFusionError::Plan(format!(
600            "expected boolean for EXPLAIN option {name}, got '{other}'"
601        ))),
602    }
603}
604
605/// Parse an identifier-or-string argument (used for `FORMAT`, `METRICS`,
606/// `LEVEL`).
607#[cfg(feature = "sql")]
608fn parse_ident_or_string_arg(arg: &Option<Expr>, name: &str) -> Result<String> {
609    let expr = arg.as_ref().ok_or_else(|| {
610        DataFusionError::Plan(format!(
611            "EXPLAIN option {} requires an argument",
612            name.to_ascii_uppercase()
613        ))
614    })?;
615    match expr {
616        Expr::Identifier(ident) => Ok(ident.value.clone()),
617        Expr::Value(ValueWithSpan { value, .. }) => match value {
618            Value::SingleQuotedString(s) | Value::DoubleQuotedString(s) => Ok(s.clone()),
619            other => Err(DataFusionError::Plan(format!(
620                "expected identifier or string for EXPLAIN option {name}, got '{other}'"
621            ))),
622        },
623        other => Err(DataFusionError::Plan(format!(
624            "expected identifier or string for EXPLAIN option {name}, got '{other}'"
625        ))),
626    }
627}
628
629/// Merge a `TIMING on/off` option into an existing `METRICS` selection.
630///
631/// If METRICS was already specified, we only add/remove the Timing category
632/// within that selection. If METRICS was not specified, TIMING effectively
633/// means "Only(Timing)" when on, or "show everything except timing" when off.
634#[cfg(feature = "sql")]
635fn adjust_timing(
636    current: Option<ExplainAnalyzeCategories>,
637    enable: bool,
638    metrics_explicit: bool,
639) -> ExplainAnalyzeCategories {
640    // METRICS was not specified — TIMING alone shapes the selection.
641    if !metrics_explicit {
642        return if enable {
643            ExplainAnalyzeCategories::All
644        } else {
645            ExplainAnalyzeCategories::Only(vec![
646                MetricCategory::Rows,
647                MetricCategory::Bytes,
648                MetricCategory::Uncategorized,
649            ])
650        };
651    }
652
653    // METRICS was specified explicitly earlier — merge into its list. When
654    // METRICS was explicit, `current` is always `Some(_)`; fall back to All
655    // to be safe.
656    match current.unwrap_or(ExplainAnalyzeCategories::All) {
657        ExplainAnalyzeCategories::All if enable => ExplainAnalyzeCategories::All,
658        ExplainAnalyzeCategories::All => {
659            // Everything except timing: rows, bytes, uncategorized.
660            ExplainAnalyzeCategories::Only(vec![
661                MetricCategory::Rows,
662                MetricCategory::Bytes,
663                MetricCategory::Uncategorized,
664            ])
665        }
666        ExplainAnalyzeCategories::Only(mut cats) if enable => {
667            if !cats.contains(&MetricCategory::Timing) {
668                cats.push(MetricCategory::Timing);
669            }
670            ExplainAnalyzeCategories::Only(cats)
671        }
672        ExplainAnalyzeCategories::Only(cats) => ExplainAnalyzeCategories::Only(
673            cats.into_iter()
674                .filter(|c| *c != MetricCategory::Timing)
675                .collect(),
676        ),
677    }
678}
679
680#[cfg(all(test, feature = "sql"))]
681mod explain_options_tests {
682    use super::*;
683    use sqlparser::ast::Ident;
684    use sqlparser::tokenizer::Span;
685
686    fn bare(name: &str) -> UtilityOption {
687        UtilityOption {
688            name: Ident {
689                value: name.to_string(),
690                quote_style: None,
691                span: Span::empty(),
692            },
693            arg: None,
694        }
695    }
696
697    fn with_ident_arg(name: &str, arg: &str) -> UtilityOption {
698        UtilityOption {
699            name: Ident {
700                value: name.to_string(),
701                quote_style: None,
702                span: Span::empty(),
703            },
704            arg: Some(Expr::Identifier(Ident {
705                value: arg.to_string(),
706                quote_style: None,
707                span: Span::empty(),
708            })),
709        }
710    }
711
712    fn with_string_arg(name: &str, arg: &str) -> UtilityOption {
713        UtilityOption {
714            name: Ident {
715                value: name.to_string(),
716                quote_style: None,
717                span: Span::empty(),
718            },
719            arg: Some(Expr::Value(ValueWithSpan {
720                value: Value::SingleQuotedString(arg.to_string()),
721                span: Span::empty(),
722            })),
723        }
724    }
725
726    fn with_bool_arg(name: &str, b: bool) -> UtilityOption {
727        UtilityOption {
728            name: Ident {
729                value: name.to_string(),
730                quote_style: None,
731                span: Span::empty(),
732            },
733            arg: Some(Expr::Value(ValueWithSpan {
734                value: Value::Boolean(b),
735                span: Span::empty(),
736            })),
737        }
738    }
739
740    fn with_number_arg(name: &str, n: &str) -> UtilityOption {
741        UtilityOption {
742            name: Ident {
743                value: name.to_string(),
744                quote_style: None,
745                span: Span::empty(),
746            },
747            arg: Some(Expr::Value(ValueWithSpan {
748                value: Value::Number(n.to_string(), false),
749                span: Span::empty(),
750            })),
751        }
752    }
753
754    #[test]
755    fn bare_analyze_and_verbose() {
756        let opts = ExplainStatementOptions::from_utility_options(&[
757            bare("ANALYZE"),
758            bare("VERBOSE"),
759        ])
760        .unwrap();
761        assert!(opts.analyze);
762        assert!(opts.verbose);
763        assert!(opts.format.is_none());
764    }
765
766    #[test]
767    fn format_from_ident_and_string() {
768        let opts = ExplainStatementOptions::from_utility_options(&[with_ident_arg(
769            "FORMAT", "tree",
770        )])
771        .unwrap();
772        assert_eq!(opts.format, Some(ExplainFormat::Tree));
773
774        let opts = ExplainStatementOptions::from_utility_options(&[with_string_arg(
775            "FORMAT", "pgjson",
776        )])
777        .unwrap();
778        assert_eq!(opts.format, Some(ExplainFormat::PostgresJSON));
779    }
780
781    #[test]
782    fn metrics_and_level() {
783        let opts = ExplainStatementOptions::from_utility_options(&[
784            with_string_arg("METRICS", "rows,bytes"),
785            with_ident_arg("LEVEL", "dev"),
786        ])
787        .unwrap();
788        assert_eq!(
789            opts.analyze_categories,
790            Some(ExplainAnalyzeCategories::Only(vec![
791                MetricCategory::Rows,
792                MetricCategory::Bytes,
793            ]))
794        );
795        assert_eq!(opts.analyze_level, Some(MetricType::Dev));
796    }
797
798    #[test]
799    fn on_off_numeric_bool() {
800        let opts = ExplainStatementOptions::from_utility_options(&[
801            with_ident_arg("ANALYZE", "ON"),
802            with_ident_arg("VERBOSE", "off"),
803            with_bool_arg("COSTS", true),
804        ])
805        .unwrap();
806        assert!(opts.analyze);
807        assert!(!opts.verbose);
808        assert_eq!(opts.show_statistics, Some(true));
809
810        let opts = ExplainStatementOptions::from_utility_options(&[
811            with_number_arg("ANALYZE", "1"),
812            with_number_arg("VERBOSE", "0"),
813        ])
814        .unwrap();
815        assert!(opts.analyze);
816        assert!(!opts.verbose);
817    }
818
819    #[test]
820    fn summary_sugar_sets_level() {
821        let opts = ExplainStatementOptions::from_utility_options(&[with_ident_arg(
822            "SUMMARY", "ON",
823        )])
824        .unwrap();
825        assert_eq!(opts.analyze_level, Some(MetricType::Summary));
826
827        let opts = ExplainStatementOptions::from_utility_options(&[with_bool_arg(
828            "SUMMARY", false,
829        )])
830        .unwrap();
831        assert_eq!(opts.analyze_level, Some(MetricType::Dev));
832    }
833
834    #[test]
835    fn timing_merges_with_metrics() {
836        // METRICS then TIMING off → timing is removed from the list
837        let opts = ExplainStatementOptions::from_utility_options(&[
838            with_string_arg("METRICS", "rows,timing"),
839            with_bool_arg("TIMING", false),
840        ])
841        .unwrap();
842        assert_eq!(
843            opts.analyze_categories,
844            Some(ExplainAnalyzeCategories::Only(vec![MetricCategory::Rows]))
845        );
846
847        // METRICS 'rows' then TIMING on → timing is appended
848        let opts = ExplainStatementOptions::from_utility_options(&[
849            with_string_arg("METRICS", "rows"),
850            with_bool_arg("TIMING", true),
851        ])
852        .unwrap();
853        assert_eq!(
854            opts.analyze_categories,
855            Some(ExplainAnalyzeCategories::Only(vec![
856                MetricCategory::Rows,
857                MetricCategory::Timing,
858            ]))
859        );
860    }
861
862    #[test]
863    fn timing_alone() {
864        let opts = ExplainStatementOptions::from_utility_options(&[with_bool_arg(
865            "TIMING", false,
866        )])
867        .unwrap();
868        assert_eq!(
869            opts.analyze_categories,
870            Some(ExplainAnalyzeCategories::Only(vec![
871                MetricCategory::Rows,
872                MetricCategory::Bytes,
873                MetricCategory::Uncategorized,
874            ]))
875        );
876    }
877
878    #[test]
879    fn unknown_option_rejected() {
880        let err =
881            ExplainStatementOptions::from_utility_options(&[bare("FOO")]).unwrap_err();
882        assert!(
883            err.to_string().contains("unknown EXPLAIN option: FOO"),
884            "got: {err}"
885        );
886    }
887
888    #[test]
889    fn postgres_only_options_rejected() {
890        for pg_only in ["BUFFERS", "WAL", "SETTINGS", "GENERIC_PLAN", "MEMORY"] {
891            let err = ExplainStatementOptions::from_utility_options(&[bare(pg_only)])
892                .unwrap_err();
893            let msg = err.to_string();
894            assert!(
895                msg.contains(pg_only),
896                "msg did not include {pg_only}: {msg}"
897            );
898            assert!(msg.contains("not supported"), "msg: {msg}");
899        }
900    }
901}