Skip to main content

datafusion_expr/logical_plan/
display.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
18//! This module provides logic for displaying LogicalPlans in various styles
19
20use std::collections::HashMap;
21use std::fmt;
22
23use crate::{
24    Aggregate, DescribeTable, Distinct, DistinctOn, DmlStatement, Expr, Filter, Join,
25    Limit, LogicalPlan, Partitioning, Projection, RecursiveQuery, Repartition, Sort,
26    Subquery, SubqueryAlias, TableProviderFilterPushDown, TableScan, Unnest, Values,
27    Window, expr_vec_fmt,
28};
29
30use crate::dml::CopyTo;
31use arrow::datatypes::Schema;
32use datafusion_common::display::GraphvizBuilder;
33use datafusion_common::tree_node::{TreeNodeRecursion, TreeNodeVisitor};
34use datafusion_common::{Column, DataFusionError, internal_datafusion_err};
35use serde::Serialize;
36use serde::ser::SerializeMap;
37
38/// Formats plans with a single line per node. For example:
39///
40/// Projection: id
41///    Filter: state Eq Utf8(\"CO\")\
42///       CsvScan: employee.csv projection=Some([0, 3])";
43pub struct IndentVisitor<'a, 'b> {
44    f: &'a mut fmt::Formatter<'b>,
45    /// If true, includes summarized schema information
46    with_schema: bool,
47    /// The current indent
48    indent: usize,
49}
50
51impl<'a, 'b> IndentVisitor<'a, 'b> {
52    /// Create a visitor that will write a formatted LogicalPlan to f. If `with_schema` is
53    /// true, includes schema information on each line.
54    pub fn new(f: &'a mut fmt::Formatter<'b>, with_schema: bool) -> Self {
55        Self {
56            f,
57            with_schema,
58            indent: 0,
59        }
60    }
61}
62
63impl<'n> TreeNodeVisitor<'n> for IndentVisitor<'_, '_> {
64    type Node = LogicalPlan;
65
66    fn f_down(
67        &mut self,
68        plan: &'n LogicalPlan,
69    ) -> datafusion_common::Result<TreeNodeRecursion> {
70        if self.indent > 0 {
71            writeln!(self.f)?;
72        }
73        write!(self.f, "{:indent$}", "", indent = self.indent * 2)?;
74        write!(self.f, "{}", plan.display())?;
75        if self.with_schema {
76            write!(self.f, " {}", display_schema(plan.schema().as_arrow()))?;
77        }
78
79        self.indent += 1;
80        Ok(TreeNodeRecursion::Continue)
81    }
82
83    fn f_up(
84        &mut self,
85        _plan: &'n LogicalPlan,
86    ) -> datafusion_common::Result<TreeNodeRecursion> {
87        self.indent -= 1;
88        Ok(TreeNodeRecursion::Continue)
89    }
90}
91
92/// Print the schema in a compact representation to `buf`
93///
94/// For example: `foo:Utf8` if `foo` can not be null, and
95/// `foo:Utf8;N` if `foo` is nullable.
96///
97/// ```
98/// use arrow::datatypes::{DataType, Field, Schema};
99/// # use datafusion_expr::logical_plan::display_schema;
100/// let schema = Schema::new(vec![
101///     Field::new("id", DataType::Int32, false),
102///     Field::new("first_name", DataType::Utf8, true),
103/// ]);
104///
105/// assert_eq!(
106///     "[id:Int32, first_name:Utf8;N]",
107///     format!("{}", display_schema(&schema))
108/// );
109/// ```
110pub fn display_schema(schema: &Schema) -> impl fmt::Display + '_ {
111    struct Wrapper<'a>(&'a Schema);
112
113    impl fmt::Display for Wrapper<'_> {
114        fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
115            write!(f, "[")?;
116            for (idx, field) in self.0.fields().iter().enumerate() {
117                if idx > 0 {
118                    write!(f, ", ")?;
119                }
120                let nullable_str = if field.is_nullable() { ";N" } else { "" };
121                write!(f, "{}:{}{}", field.name(), field.data_type(), nullable_str)?;
122            }
123            write!(f, "]")
124        }
125    }
126    Wrapper(schema)
127}
128
129/// Formats plans for graphical display using the `DOT` language. This
130/// format can be visualized using software from
131/// [`graphviz`](https://graphviz.org/)
132pub struct GraphvizVisitor<'a, 'b> {
133    f: &'a mut fmt::Formatter<'b>,
134    graphviz_builder: GraphvizBuilder,
135    /// If true, includes summarized schema information
136    with_schema: bool,
137
138    /// Holds the ids (as generated from `graphviz_builder` of all
139    /// parent nodes
140    parent_ids: Vec<usize>,
141}
142
143impl<'a, 'b> GraphvizVisitor<'a, 'b> {
144    pub fn new(f: &'a mut fmt::Formatter<'b>) -> Self {
145        Self {
146            f,
147            graphviz_builder: GraphvizBuilder::default(),
148            with_schema: false,
149            parent_ids: Vec::new(),
150        }
151    }
152
153    /// Sets a flag which controls if the output schema is displayed
154    pub fn set_with_schema(&mut self, with_schema: bool) {
155        self.with_schema = with_schema;
156    }
157
158    pub fn pre_visit_plan(&mut self, label: &str) -> fmt::Result {
159        self.graphviz_builder.start_cluster(self.f, label)
160    }
161
162    pub fn post_visit_plan(&mut self) -> fmt::Result {
163        self.graphviz_builder.end_cluster(self.f)
164    }
165
166    pub fn start_graph(&mut self) -> fmt::Result {
167        self.graphviz_builder.start_graph(self.f)
168    }
169
170    pub fn end_graph(&mut self) -> fmt::Result {
171        self.graphviz_builder.end_graph(self.f)
172    }
173}
174
175impl<'n> TreeNodeVisitor<'n> for GraphvizVisitor<'_, '_> {
176    type Node = LogicalPlan;
177
178    fn f_down(
179        &mut self,
180        plan: &'n LogicalPlan,
181    ) -> datafusion_common::Result<TreeNodeRecursion> {
182        let id = self.graphviz_builder.next_id();
183
184        // Create a new graph node for `plan` such as
185        // id [label="foo"]
186        let label = if self.with_schema {
187            format!(
188                r"{}\nSchema: {}",
189                plan.display(),
190                display_schema(plan.schema().as_arrow())
191            )
192        } else {
193            format!("{}", plan.display())
194        };
195
196        self.graphviz_builder
197            .add_node(self.f, id, &label, None)
198            .map_err(|_e| internal_datafusion_err!("Fail to format"))?;
199
200        // Create an edge to our parent node, if any
201        //  parent_id -> id
202        if let Some(parent_id) = self.parent_ids.last() {
203            self.graphviz_builder
204                .add_edge(self.f, *parent_id, id)
205                .map_err(|_e| internal_datafusion_err!("Fail to format"))?;
206        }
207
208        self.parent_ids.push(id);
209        Ok(TreeNodeRecursion::Continue)
210    }
211
212    fn f_up(
213        &mut self,
214        _plan: &LogicalPlan,
215    ) -> datafusion_common::Result<TreeNodeRecursion> {
216        // always be non-empty as pre_visit always pushes
217        // So it should always be Ok(true)
218        let res = self.parent_ids.pop();
219        res.ok_or(internal_datafusion_err!("Fail to format"))
220            .map(|_| TreeNodeRecursion::Continue)
221    }
222}
223
224/// Builds a [`PgJsonFields`] from `"key": value` pairs, keeping their order.
225macro_rules! pg_fields {
226    ($($key:literal : $value:expr),* $(,)?) => {
227        PgJsonFields(vec![$(($key, serde_json::json!($value))),*])
228    };
229}
230
231/// Node-specific fields of a `pgjson` node, serialized as a JSON object whose
232/// keys keep their insertion order (independent of `serde_json`'s
233/// `preserve_order` feature).
234struct PgJsonFields(Vec<(&'static str, serde_json::Value)>);
235
236impl PgJsonFields {
237    fn push(&mut self, key: &'static str, value: impl Into<serde_json::Value>) {
238        self.0.push((key, value.into()));
239    }
240}
241
242/// One node of the `pgjson` output: the node-specific fields, then
243/// `"Plans"`, then `"Output"` (if the schema is shown).
244struct PgJsonNode {
245    fields: PgJsonFields,
246    plans: Vec<PgJsonNode>,
247    output: Option<Vec<String>>,
248}
249
250impl Serialize for PgJsonNode {
251    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
252        let mut map = serializer.serialize_map(None)?;
253        for (key, value) in &self.fields.0 {
254            map.serialize_entry(key, value)?;
255        }
256        map.serialize_entry("Plans", &self.plans)?;
257        if let Some(output) = &self.output {
258            map.serialize_entry("Output", output)?;
259        }
260        map.end()
261    }
262}
263
264/// Top-level entry of the `pgjson` output.
265#[derive(Serialize)]
266struct PgJsonRoot {
267    #[serde(rename = "Plan")]
268    plan: PgJsonNode,
269}
270
271/// Formats plans to display as postgresql plan json format.
272///
273/// There are already many existing visualizer for this format, for example [dalibo](https://explain.dalibo.com/).
274/// Unfortunately, there is no formal spec for this format, but it is widely used in the PostgreSQL community.
275///
276/// Here is an example of the format:
277///
278/// ```json
279/// [
280///     {
281///         "Plan": {
282///             "Node Type": "Sort",
283///             "Output": [
284///                 "question_1.id",
285///                 "question_1.title",
286///                 "question_1.text",
287///                 "question_1.file",
288///                 "question_1.type",
289///                 "question_1.source",
290///                 "question_1.exam_id"
291///             ],
292///             "Sort Key": [
293///                 "question_1.id"
294///             ],
295///             "Plans": [
296///                 {
297///                     "Node Type": "Seq Scan",
298///                     "Parent Relationship": "Left",
299///                     "Relation Name": "question",
300///                     "Schema": "public",
301///                     "Alias": "question_1",
302///                     "Output": [
303///                        "question_1.id",
304///                         "question_1.title",
305///                        "question_1.text",
306///                         "question_1.file",
307///                         "question_1.type",
308///                         "question_1.source",
309///                         "question_1.exam_id"
310///                     ],
311///                     "Filter": "(question_1.exam_id = 1)"
312///                 }
313///             ]
314///         }
315///     }
316/// ]
317/// ```
318pub struct PgJsonVisitor<'a, 'b> {
319    f: &'a mut fmt::Formatter<'b>,
320
321    /// A mapping from plan node id to the plan node json representation.
322    objects: HashMap<u32, PgJsonNode>,
323
324    next_id: u32,
325
326    /// If true, includes summarized schema information
327    with_schema: bool,
328
329    /// Holds the ids (as generated from `graphviz_builder` of all
330    /// parent nodes
331    parent_ids: Vec<u32>,
332}
333
334impl<'a, 'b> PgJsonVisitor<'a, 'b> {
335    pub fn new(f: &'a mut fmt::Formatter<'b>) -> Self {
336        Self {
337            f,
338            objects: HashMap::new(),
339            next_id: 0,
340            with_schema: false,
341            parent_ids: Vec::new(),
342        }
343    }
344
345    /// Sets a flag which controls if the output schema is displayed
346    pub fn with_schema(&mut self, with_schema: bool) {
347        self.with_schema = with_schema;
348    }
349
350    /// Converts a logical plan node to a json object.
351    fn to_json_value(node: &LogicalPlan) -> PgJsonFields {
352        match node {
353            LogicalPlan::EmptyRelation(_) => {
354                pg_fields!(
355                    "Node Type": "EmptyRelation",
356                )
357            }
358            LogicalPlan::RecursiveQuery(RecursiveQuery { is_distinct, .. }) => {
359                pg_fields!(
360                    "Node Type": "RecursiveQuery",
361                    "Is Distinct": is_distinct,
362                )
363            }
364            LogicalPlan::Values(Values { values, .. }) => {
365                let str_values = values
366                    .iter()
367                    // limit to only 5 values to avoid horrible display
368                    .take(5)
369                    .map(|row| {
370                        let item = row
371                            .iter()
372                            .map(|expr| expr.to_string())
373                            .collect::<Vec<_>>()
374                            .join(", ");
375                        format!("({item})")
376                    })
377                    .collect::<Vec<_>>()
378                    .join(", ");
379
380                let eclipse = if values.len() > 5 { "..." } else { "" };
381
382                let values_str = format!("{str_values}{eclipse}");
383                pg_fields!(
384                    "Node Type": "Values",
385                    "Values": values_str
386                )
387            }
388            LogicalPlan::TableScan(TableScan {
389                source,
390                table_name,
391                filters,
392                fetch,
393                ..
394            }) => {
395                let mut object = pg_fields!(
396                    "Node Type": "TableScan",
397                    "Relation Name": table_name.table(),
398                );
399
400                if let Some(s) = table_name.schema() {
401                    object.push("Schema", serde_json::Value::String(s.to_string()));
402                }
403
404                if let Some(c) = table_name.catalog() {
405                    object.push("Catalog", serde_json::Value::String(c.to_string()));
406                }
407
408                if !filters.is_empty() {
409                    let mut full_filter = vec![];
410                    let mut partial_filter = vec![];
411                    let mut unsupported_filters = vec![];
412                    let filters: Vec<&Expr> = filters.iter().collect();
413
414                    if let Ok(results) = source.supports_filters_pushdown(&filters) {
415                        filters.iter().zip(results.iter()).for_each(
416                            |(x, res)| match res {
417                                TableProviderFilterPushDown::Exact => full_filter.push(x),
418                                TableProviderFilterPushDown::Inexact => {
419                                    partial_filter.push(x)
420                                }
421                                TableProviderFilterPushDown::Unsupported => {
422                                    unsupported_filters.push(x)
423                                }
424                            },
425                        );
426                    }
427
428                    if !full_filter.is_empty() {
429                        object.push(
430                            "Full Filters",
431                            serde_json::Value::String(expr_vec_fmt!(full_filter)),
432                        );
433                    }
434                    if !partial_filter.is_empty() {
435                        object.push(
436                            "Partial Filters",
437                            serde_json::Value::String(expr_vec_fmt!(partial_filter)),
438                        );
439                    }
440                    if !unsupported_filters.is_empty() {
441                        object.push(
442                            "Unsupported Filters",
443                            serde_json::Value::String(expr_vec_fmt!(unsupported_filters)),
444                        );
445                    }
446                }
447
448                if let Some(f) = fetch {
449                    object.push("Fetch", serde_json::Value::Number((*f).into()));
450                }
451
452                object
453            }
454            LogicalPlan::Projection(Projection { expr, .. }) => {
455                pg_fields!(
456                    "Node Type": "Projection",
457                    "Expressions": expr.iter().map(|e| e.to_string()).collect::<Vec<_>>()
458                )
459            }
460            LogicalPlan::Dml(DmlStatement { table_name, op, .. }) => {
461                pg_fields!(
462                    "Node Type": "Projection",
463                    "Operation": op.name(),
464                    "Table Name": table_name.table()
465                )
466            }
467            LogicalPlan::Copy(CopyTo {
468                input: _,
469                output_url,
470                file_type,
471                partition_by: _,
472                options,
473                output_schema: _,
474            }) => {
475                let op_str = options
476                    .iter()
477                    .map(|(k, v)| format!("{k}={v}"))
478                    .collect::<Vec<_>>()
479                    .join(", ");
480                pg_fields!(
481                    "Node Type": "CopyTo",
482                    "Output URL": output_url,
483                    "File Type": format!("{}", file_type.get_ext()),
484                    "Options": op_str
485                )
486            }
487            LogicalPlan::Ddl(ddl) => {
488                pg_fields!(
489                    "Node Type": "Ddl",
490                    "Operation": format!("{}", ddl.display())
491                )
492            }
493            LogicalPlan::Filter(Filter {
494                predicate: expr, ..
495            }) => {
496                pg_fields!(
497                    "Node Type": "Filter",
498                    "Condition": format!("{}", expr)
499                )
500            }
501            LogicalPlan::Window(Window { window_expr, .. }) => {
502                pg_fields!(
503                    "Node Type": "WindowAggr",
504                    "Expressions": expr_vec_fmt!(window_expr)
505                )
506            }
507            LogicalPlan::Aggregate(Aggregate {
508                group_expr,
509                aggr_expr,
510                ..
511            }) => {
512                pg_fields!(
513                    "Node Type": "Aggregate",
514                    "Group By": expr_vec_fmt!(group_expr),
515                    "Aggregates": expr_vec_fmt!(aggr_expr)
516                )
517            }
518            LogicalPlan::Sort(Sort { expr, fetch, .. }) => {
519                let mut object = pg_fields!(
520                    "Node Type": "Sort",
521                    "Sort Key": expr_vec_fmt!(expr),
522                );
523
524                if let Some(fetch) = fetch {
525                    object.push("Fetch", serde_json::Value::Number((*fetch).into()));
526                }
527
528                object
529            }
530            LogicalPlan::Join(Join {
531                on: keys,
532                filter,
533                join_constraint,
534                join_type,
535                ..
536            }) => {
537                let join_expr: Vec<String> =
538                    keys.iter().map(|(l, r)| format!("{l} = {r}")).collect();
539                let filter_expr = filter
540                    .as_ref()
541                    .map(|expr| format!(" Filter: {expr}"))
542                    .unwrap_or_else(|| "".to_string());
543                pg_fields!(
544                    "Node Type": format!("{} Join", join_type),
545                    "Join Constraint": format!("{:?}", join_constraint),
546                    "Join Keys": join_expr.join(", "),
547                    "Filter": format!("{}", filter_expr)
548                )
549            }
550            LogicalPlan::Repartition(Repartition {
551                partitioning_scheme,
552                ..
553            }) => match partitioning_scheme {
554                Partitioning::RoundRobinBatch(n) => {
555                    pg_fields!(
556                        "Node Type": "Repartition",
557                        "Partitioning Scheme": "RoundRobinBatch",
558                        "Partition Count": n
559                    )
560                }
561                Partitioning::Hash(expr, n) => {
562                    let hash_expr: Vec<String> =
563                        expr.iter().map(|e| format!("{e}")).collect();
564
565                    pg_fields!(
566                        "Node Type": "Repartition",
567                        "Partitioning Scheme": "Hash",
568                        "Partition Count": n,
569                        "Partitioning Key": hash_expr
570                    )
571                }
572                Partitioning::Range(range) => {
573                    let range_expr: Vec<String> =
574                        range.ordering().iter().map(|e| format!("{e}")).collect();
575                    let split_points: Vec<String> = range
576                        .split_points()
577                        .iter()
578                        .map(|e| format!("{e}"))
579                        .collect();
580
581                    pg_fields!(
582                        "Node Type": "Repartition",
583                        "Partitioning Scheme": "Range",
584                        "Partition Count": range.partition_count(),
585                        "Partitioning Key": range_expr,
586                        "Split Points": split_points
587                    )
588                }
589                Partitioning::DistributeBy(expr) => {
590                    let dist_by_expr: Vec<String> =
591                        expr.iter().map(|e| format!("{e}")).collect();
592                    pg_fields!(
593                        "Node Type": "Repartition",
594                        "Partitioning Scheme": "DistributeBy",
595                        "Partitioning Key": dist_by_expr
596                    )
597                }
598            },
599            LogicalPlan::Limit(Limit { skip, fetch, .. }) => {
600                let mut object = pg_fields!("Node Type": "Limit");
601                if let Some(s) = skip {
602                    object.push("Skip", s.to_string());
603                }
604                if let Some(f) = fetch {
605                    object.push("Fetch", f.to_string());
606                }
607                object
608            }
609            LogicalPlan::Subquery(Subquery { .. }) => {
610                pg_fields!(
611                    "Node Type": "Subquery"
612                )
613            }
614            LogicalPlan::SubqueryAlias(SubqueryAlias { alias, .. }) => {
615                pg_fields!(
616                    "Node Type": "Subquery",
617                    "Alias": alias.table(),
618                )
619            }
620            LogicalPlan::Statement(statement) => {
621                pg_fields!(
622                    "Node Type": "Statement",
623                    "Statement": format!("{}", statement.display())
624                )
625            }
626            LogicalPlan::Distinct(distinct) => match distinct {
627                Distinct::All(_) => {
628                    pg_fields!(
629                        "Node Type": "DistinctAll"
630                    )
631                }
632                Distinct::On(DistinctOn {
633                    on_expr,
634                    select_expr,
635                    sort_expr,
636                    ..
637                }) => {
638                    let mut object = pg_fields!(
639                        "Node Type": "DistinctOn",
640                        "On": expr_vec_fmt!(on_expr),
641                        "Select": expr_vec_fmt!(select_expr),
642                    );
643                    if let Some(sort_expr) = sort_expr {
644                        object.push(
645                            "Sort",
646                            serde_json::Value::String(expr_vec_fmt!(sort_expr)),
647                        );
648                    }
649
650                    object
651                }
652            },
653            LogicalPlan::Explain { .. } => {
654                pg_fields!(
655                    "Node Type": "Explain"
656                )
657            }
658            LogicalPlan::Analyze { .. } => {
659                pg_fields!(
660                    "Node Type": "Analyze"
661                )
662            }
663            LogicalPlan::Union(_) => {
664                pg_fields!(
665                    "Node Type": "Union"
666                )
667            }
668            LogicalPlan::Extension(e) => {
669                pg_fields!(
670                    "Node Type": e.node.name(),
671                    "Detail": format!("{:?}", e.node)
672                )
673            }
674            LogicalPlan::DescribeTable(DescribeTable { .. }) => {
675                pg_fields!(
676                    "Node Type": "DescribeTable"
677                )
678            }
679            LogicalPlan::Unnest(Unnest {
680                input: plan,
681                list_type_columns: list_col_indices,
682                struct_type_columns: struct_col_indices,
683                ..
684            }) => {
685                let input_columns = plan.schema().columns();
686                let list_type_columns = list_col_indices
687                    .iter()
688                    .map(|(i, unnest_info)| {
689                        format!("{}|depth={:?}", input_columns[*i], unnest_info.depth)
690                    })
691                    .collect::<Vec<String>>();
692                let struct_type_columns = struct_col_indices
693                    .iter()
694                    .map(|i| &input_columns[*i])
695                    .collect::<Vec<&Column>>();
696                pg_fields!(
697                    "Node Type": "Unnest",
698                    "ListColumn": expr_vec_fmt!(list_type_columns),
699                    "StructColumn": expr_vec_fmt!(struct_type_columns),
700                )
701            }
702        }
703    }
704}
705
706impl<'n> TreeNodeVisitor<'n> for PgJsonVisitor<'_, '_> {
707    type Node = LogicalPlan;
708
709    fn f_down(
710        &mut self,
711        node: &'n LogicalPlan,
712    ) -> datafusion_common::Result<TreeNodeRecursion> {
713        let id = self.next_id;
714        self.next_id += 1;
715        let object = PgJsonNode {
716            fields: Self::to_json_value(node),
717            plans: vec![],
718            output: self.with_schema.then(|| {
719                node.schema()
720                    .fields()
721                    .iter()
722                    .map(|f| f.name().to_string())
723                    .collect()
724            }),
725        };
726
727        self.objects.insert(id, object);
728        self.parent_ids.push(id);
729        Ok(TreeNodeRecursion::Continue)
730    }
731
732    fn f_up(
733        &mut self,
734        _node: &Self::Node,
735    ) -> datafusion_common::Result<TreeNodeRecursion> {
736        let id = self.parent_ids.pop().unwrap();
737
738        let current_node = self
739            .objects
740            .remove(&id)
741            .ok_or_else(|| internal_datafusion_err!("Missing current node!"))?;
742
743        if let Some(parent_id) = self.parent_ids.last() {
744            let parent_node = self
745                .objects
746                .get_mut(parent_id)
747                .expect("Missing parent node!");
748            parent_node.plans.push(current_node);
749        } else {
750            // This is the root node
751            let plan = [PgJsonRoot { plan: current_node }];
752            write!(
753                self.f,
754                "{}",
755                serde_json::to_string_pretty(&plan)
756                    .map_err(|e| DataFusionError::External(Box::new(e)))?
757            )?;
758        }
759
760        Ok(TreeNodeRecursion::Continue)
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use arrow::datatypes::{DataType, Field};
767    use insta::assert_snapshot;
768
769    use super::*;
770
771    #[test]
772    fn test_display_empty_schema() {
773        let schema = Schema::empty();
774        assert_snapshot!(display_schema(&schema), @"[]");
775    }
776
777    #[test]
778    fn test_display_schema() {
779        let schema = Schema::new(vec![
780            Field::new("id", DataType::Int32, false),
781            Field::new("first_name", DataType::Utf8, true),
782        ]);
783
784        assert_snapshot!(display_schema(&schema), @"[id:Int32, first_name:Utf8;N]");
785    }
786}