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}