You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@calcite.apache.org by ch...@apache.org on 2020/09/14 02:04:15 UTC

[calcite] branch master updated: [CALCITE-4197] Provide utility to visualize RelNode plans

This is an automated email from the ASF dual-hosted git repository.

chunwei pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/calcite.git


The following commit(s) were added to refs/heads/master by this push:
     new 99c0fef  [CALCITE-4197] Provide utility to visualize RelNode plans
99c0fef is described below

commit 99c0fefeca7caf638de875133f8c836472e8b28c
Author: liyafan82 <fa...@foxmail.com>
AuthorDate: Fri Sep 11 16:00:24 2020 +0800

    [CALCITE-4197] Provide utility to visualize RelNode plans
---
 .../java/org/apache/calcite/plan/RelOptUtil.java   |   4 +
 .../calcite/rel/externalize/RelDotWriter.java      | 286 +++++++++++++++++++++
 .../calcite/rel/externalize/RelWriterImpl.java     |   4 +-
 .../org/apache/calcite/sql/SqlExplainFormat.java   |   5 +-
 .../org/apache/calcite/plan/RelWriterTest.java     | 114 ++++++--
 .../calcite/plan/volcano/VolcanoPlannerTest.java   |  36 +++
 .../org/apache/calcite/test/HepPlannerTest.java    |  29 +++
 .../apache/calcite/test/SqlToRelConverterTest.java |  18 ++
 8 files changed, 468 insertions(+), 28 deletions(-)

diff --git a/core/src/main/java/org/apache/calcite/plan/RelOptUtil.java b/core/src/main/java/org/apache/calcite/plan/RelOptUtil.java
index 8e30ee5..6398e08 100644
--- a/core/src/main/java/org/apache/calcite/plan/RelOptUtil.java
+++ b/core/src/main/java/org/apache/calcite/plan/RelOptUtil.java
@@ -39,6 +39,7 @@ import org.apache.calcite.rel.core.Project;
 import org.apache.calcite.rel.core.RelFactories;
 import org.apache.calcite.rel.core.Sort;
 import org.apache.calcite.rel.core.TableScan;
+import org.apache.calcite.rel.externalize.RelDotWriter;
 import org.apache.calcite.rel.externalize.RelJsonWriter;
 import org.apache.calcite.rel.externalize.RelWriterImpl;
 import org.apache.calcite.rel.externalize.RelXmlWriter;
@@ -2091,6 +2092,9 @@ public abstract class RelOptUtil {
       planWriter = new RelJsonWriter();
       rel.explain(planWriter);
       return ((RelJsonWriter) planWriter).asString();
+    case DOT:
+      planWriter = new RelDotWriter(pw, detailLevel, false);
+      break;
     default:
       planWriter = new RelWriterImpl(pw, detailLevel, false);
     }
diff --git a/core/src/main/java/org/apache/calcite/rel/externalize/RelDotWriter.java b/core/src/main/java/org/apache/calcite/rel/externalize/RelDotWriter.java
new file mode 100644
index 0000000..d2b2b47
--- /dev/null
+++ b/core/src/main/java/org/apache/calcite/rel/externalize/RelDotWriter.java
@@ -0,0 +1,286 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.calcite.rel.externalize;
+
+import org.apache.calcite.plan.hep.HepRelVertex;
+import org.apache.calcite.plan.volcano.RelSubset;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.RelWriter;
+import org.apache.calcite.rel.metadata.RelMetadataQuery;
+import org.apache.calcite.sql.SqlExplainLevel;
+import org.apache.calcite.util.ImmutableBeans;
+import org.apache.calcite.util.Pair;
+import org.apache.calcite.util.Util;
+
+import com.google.common.collect.HashMultimap;
+import com.google.common.collect.Multimap;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+/**
+ * Utility to dump a rel node plan in dot format.
+ */
+public class RelDotWriter extends RelWriterImpl {
+
+  //~ Instance fields --------------------------------------------------------
+
+  /**
+   * Adjacent list of the plan graph.
+   */
+  private final Map<RelNode, List<RelNode>> outArcTable = new LinkedHashMap<>();
+
+  private Map<RelNode, String> nodeLabels = new HashMap<>();
+
+  private Multimap<RelNode, String> nodeStyles = HashMultimap.create();
+
+  private final WriteOption option;
+
+  //~ Constructors -----------------------------------------------------------
+
+  public RelDotWriter(
+      PrintWriter pw, SqlExplainLevel detailLevel,
+      boolean withIdPrefix) {
+    this(pw, detailLevel, withIdPrefix, WriteOption.DEFAULT);
+  }
+
+  public RelDotWriter(
+      PrintWriter pw, SqlExplainLevel detailLevel,
+      boolean withIdPrefix, WriteOption option) {
+    super(pw, detailLevel, withIdPrefix);
+    this.option = option;
+  }
+
+  //~ Methods ----------------------------------------------------------------
+
+  protected void explain_(RelNode rel,
+                          List<Pair<String, Object>> values) {
+    // get inputs
+    List<RelNode> inputs = getInputs(rel);
+    outArcTable.put(rel, inputs);
+
+    // generate node label
+    String label = getRelNodeLabel(rel, values);
+    nodeLabels.put(rel, label);
+
+    if (highlightNode(rel)) {
+      nodeStyles.put(rel, "bold");
+    }
+
+    explainInputs(inputs);
+  }
+
+  protected String getRelNodeLabel(
+      RelNode rel,
+      List<Pair<String, Object>> values) {
+    List<String> labels = new ArrayList<>();
+    StringBuilder sb = new StringBuilder();
+
+    final RelMetadataQuery mq = rel.getCluster().getMetadataQuery();
+    if (withIdPrefix) {
+      sb.append(rel.getId()).append(":");
+    }
+    sb.append(rel.getRelTypeName());
+    labels.add(sb.toString());
+    sb.setLength(0);
+
+    if (detailLevel != SqlExplainLevel.NO_ATTRIBUTES) {
+      for (Pair<String, Object> value : values) {
+        if (value.right instanceof RelNode) {
+          continue;
+        }
+        sb.append(value.left)
+            .append(" = ")
+            .append(value.right);
+        labels.add(sb.toString());
+        sb.setLength(0);
+      }
+    }
+
+    switch (detailLevel) {
+    case ALL_ATTRIBUTES:
+      sb.append("rowcount = ")
+          .append(mq.getRowCount(rel))
+          .append(" cumulative cost = ")
+          .append(mq.getCumulativeCost(rel))
+          .append(" ");
+    }
+    switch (detailLevel) {
+    case NON_COST_ATTRIBUTES:
+    case ALL_ATTRIBUTES:
+      if (!withIdPrefix) {
+        // If we didn't print the rel id at the start of the line, print
+        // it at the end.
+        sb.append("id = ").append(rel.getId());
+      }
+      break;
+    }
+    labels.add(sb.toString().trim());
+    sb.setLength(0);
+
+    // format labels separately and then concat them
+    int leftSpace = option.maxNodeLabelLength();
+    List<String> newlabels = new ArrayList<>();
+    for (int i = 0; i < labels.size(); i++) {
+      if (option.maxNodeLabelLength() != -1 && leftSpace <= 0) {
+        if (i < labels.size() - 1) {
+          // this is not the last label, but we have to stop here
+          newlabels.add("...");
+        }
+        break;
+      }
+      String formatted = formatNodeLabel(labels.get(i), option.maxNodeLabelLength());
+      newlabels.add(formatted);
+      leftSpace -= formatted.length();
+    }
+
+    return "\"" + String.join("\\n", newlabels) + "\"";
+  }
+
+  private List<RelNode> getInputs(RelNode parent) {
+    return parent.getInputs().stream().map(child -> {
+      if (child instanceof HepRelVertex) {
+        return ((HepRelVertex) child).getCurrentRel();
+      } else if (child instanceof RelSubset) {
+        RelSubset subset = (RelSubset) child;
+        return Util.first(subset.getBest(), subset.getOriginal());
+      } else {
+        return child;
+      }
+    }).collect(Collectors.toList());
+  }
+
+  private void explainInputs(List<RelNode> inputs) {
+    for (RelNode input : inputs) {
+      if (input == null || nodeLabels.containsKey(input)) {
+        continue;
+      }
+      input.explain(this);
+    }
+  }
+
+  public RelWriter done(RelNode node) {
+    int numOfVisitedNodes = nodeLabels.size();
+    super.done(node);
+    if (numOfVisitedNodes == 0) {
+      // When we enter this method call, no node
+      // has been visited. So the current node must be the root of the plan.
+      // Now we are exiting the method, all nodes in the plan
+      // have been visited, so it is time to dump the plan.
+
+      pw.println("digraph {");
+
+      // print nodes with styles
+      for (RelNode rel : nodeStyles.keySet()) {
+        String style = String.join(",", nodeStyles.get(rel));
+        pw.println(nodeLabels.get(rel) + " [style=\"" + style + "\"]");
+      }
+
+      // ordinary arcs
+      for (Map.Entry<RelNode, List<RelNode>> entry : outArcTable.entrySet()) {
+        RelNode src = entry.getKey();
+        String srcDesc = nodeLabels.get(src);
+        for (int i = 0; i < entry.getValue().size(); i++) {
+          RelNode dst = entry.getValue().get(i);
+
+          // label is the ordinal of the arc
+          // arc direction from child to parent, to reflect the direction of data flow
+          pw.println(nodeLabels.get(dst) + " -> " + srcDesc + " [label=\"" + i + "\"]");
+        }
+      }
+      pw.println("}");
+      pw.flush();
+    }
+    return this;
+  }
+
+  /**
+   * Format the label into multiple lines according to the options.
+   * @param label the original label.
+   * @param limit the maximal length of the formatted label.
+   *              -1 means no limit.
+   * @return the formatted label.
+   */
+  private String formatNodeLabel(String label, int limit) {
+    label = label.trim();
+
+    // escape quotes in the label.
+    label.replace("\"", "\\\"");
+
+    boolean trimmed = false;
+    if (limit != -1 && label.length() > limit) {
+      label = label.substring(0, limit);
+      trimmed = true;
+    }
+
+    if (option.maxNodeLabelPerLine() == -1) {
+      // no need to split into multiple lines.
+      return label + (trimmed ? "..." : "");
+    }
+
+    List<String> descParts = new ArrayList<>();
+    for (int idx = 0; idx < label.length(); idx += option.maxNodeLabelPerLine()) {
+      int endIdx = idx + option.maxNodeLabelPerLine() > label.length() ? label.length()
+          : idx + option.maxNodeLabelPerLine();
+      descParts.add(label.substring(idx, endIdx));
+    }
+
+    return String.join("\\n", descParts) + (trimmed ? "..." : "");
+  }
+
+  boolean highlightNode(RelNode node) {
+    return option.nodePredicate() != null && option.nodePredicate().test(node);
+  }
+
+  /**
+   * Options for displaying the rel node plan in dot format.
+   */
+  public interface WriteOption {
+
+    /** Default configuration. */
+    WriteOption DEFAULT = ImmutableBeans.create(WriteOption.class);
+
+    /**
+     * The max length of node labels.
+     * If the label is too long, the visual display would be messy.
+     * -1 means no limit to the label length.
+     */
+    @ImmutableBeans.Property
+    @ImmutableBeans.IntDefault(100)
+    int maxNodeLabelLength();
+
+    /**
+     * The max length of node label in a line.
+     * -1 means no limitation.
+     */
+    @ImmutableBeans.Property
+    @ImmutableBeans.IntDefault(20)
+    int maxNodeLabelPerLine();
+
+    /**
+     * Predicate for nodes that need to be highlighted.
+     */
+    @ImmutableBeans.Property
+    Predicate<RelNode> nodePredicate();
+  }
+}
diff --git a/core/src/main/java/org/apache/calcite/rel/externalize/RelWriterImpl.java b/core/src/main/java/org/apache/calcite/rel/externalize/RelWriterImpl.java
index 3fd1ebc..9fa52d8 100644
--- a/core/src/main/java/org/apache/calcite/rel/externalize/RelWriterImpl.java
+++ b/core/src/main/java/org/apache/calcite/rel/externalize/RelWriterImpl.java
@@ -37,8 +37,8 @@ public class RelWriterImpl implements RelWriter {
   //~ Instance fields --------------------------------------------------------
 
   protected final PrintWriter pw;
-  private final SqlExplainLevel detailLevel;
-  private final boolean withIdPrefix;
+  protected final SqlExplainLevel detailLevel;
+  protected final boolean withIdPrefix;
   protected final Spacer spacer = new Spacer();
   private final List<Pair<String, Object>> values = new ArrayList<>();
 
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlExplainFormat.java b/core/src/main/java/org/apache/calcite/sql/SqlExplainFormat.java
index 5c25bba..ba9f602 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlExplainFormat.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlExplainFormat.java
@@ -27,5 +27,8 @@ public enum SqlExplainFormat implements Symbolizable {
   XML,
 
   /** Indicates that the plan should be output in JSON format. */
-  JSON
+  JSON,
+
+  /** Indicates that the plan should be output in dot format. */
+  DOT
 }
diff --git a/core/src/test/java/org/apache/calcite/plan/RelWriterTest.java b/core/src/test/java/org/apache/calcite/plan/RelWriterTest.java
index 340eab3..7cd1951 100644
--- a/core/src/test/java/org/apache/calcite/plan/RelWriterTest.java
+++ b/core/src/test/java/org/apache/calcite/plan/RelWriterTest.java
@@ -69,6 +69,8 @@ import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import java.io.IOException;
 import java.math.BigDecimal;
@@ -76,6 +78,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
+import java.util.stream.Stream;
 
 import static org.apache.calcite.test.Matchers.isLinux;
 
@@ -414,6 +417,10 @@ class RelWriterTest {
       + "  ]\n"
       + "}";
 
+  static Stream<SqlExplainFormat> explainFormats() {
+    return Stream.of(SqlExplainFormat.TEXT, SqlExplainFormat.DOT);
+  }
+
   /**
    * Unit test for {@link org.apache.calcite.rel.externalize.RelJsonWriter} on
    * a simple tree of relational expressions, consisting of a table and a
@@ -640,7 +647,9 @@ class RelWriterTest {
     assertThat(s, isLinux(expected));
   }
 
-  @Test void testAggregateWithAlias() {
+  @ParameterizedTest
+  @MethodSource("explainFormats")
+  void testAggregateWithAlias(SqlExplainFormat format) {
     final FrameworkConfig config = RelBuilderTest.config().build();
     final RelBuilder builder = RelBuilder.create(config);
     // The rel node stands for sql: SELECT max(SAL) as max_sal from EMP group by JOB;
@@ -658,13 +667,27 @@ class RelWriterTest {
     final RelJsonWriter jsonWriter = new RelJsonWriter();
     rel.explain(jsonWriter);
     final String relJson = jsonWriter.asString();
-    String s = deserializeAndDumpToTextFormat(getSchema(rel), relJson);
-    final String expected = ""
-        + "LogicalProject(max_sal=[$1])\n"
-        + "  LogicalAggregate(group=[{0}], max_sal=[MAX($1)])\n"
-        + "    LogicalProject(JOB=[$2], SAL=[$5])\n"
-        + "      LogicalTableScan(table=[[scott, EMP]])\n";
-
+    String s = deserializeAndDump(getSchema(rel), relJson, format);
+    String expected = null;
+    switch (format) {
+    case TEXT:
+      expected = ""
+          + "LogicalProject(max_sal=[$1])\n"
+          + "  LogicalAggregate(group=[{0}], max_sal=[MAX($1)])\n"
+          + "    LogicalProject(JOB=[$2], SAL=[$5])\n"
+          + "      LogicalTableScan(table=[[scott, EMP]])\n";
+      break;
+    case DOT:
+      expected = "digraph {\n"
+          + "\"LogicalAggregate\\ngroup = {0}\\nmax_sal = MAX($1)\\n\" -> "
+          + "\"LogicalProject\\nmax_sal = $1\\n\" [label=\"0\"]\n"
+          + "\"LogicalProject\\nJOB = $2\\nSAL = $5\\n\" -> \"LogicalAggregate\\ngroup = "
+          + "{0}\\nmax_sal = MAX($1)\\n\" [label=\"0\"]\n"
+          + "\"LogicalTableScan\\ntable = [scott, EMP]\\n\" -> \"LogicalProject\\nJOB = $2\\nSAL = "
+          + "$5\\n\" [label=\"0\"]\n"
+          + "}\n";
+      break;
+    }
     assertThat(s, isLinux(expected));
   }
 
@@ -732,7 +755,9 @@ class RelWriterTest {
     assertThat(s, isLinux(expected));
   }
 
-  @Test void testCorrelateQuery() {
+  @ParameterizedTest
+  @MethodSource("explainFormats")
+  void testCorrelateQuery(SqlExplainFormat format) {
     final FrameworkConfig config = RelBuilderTest.config().build();
     final RelBuilder builder = RelBuilder.create(config);
     final Holder<RexCorrelVariable> v = Holder.of(null);
@@ -747,13 +772,28 @@ class RelWriterTest {
     RelJsonWriter jsonWriter = new RelJsonWriter();
     relNode.explain(jsonWriter);
     final String relJson = jsonWriter.asString();
-    String s = deserializeAndDumpToTextFormat(getSchema(relNode), relJson);
-    final String expected = ""
-        + "LogicalCorrelate(correlation=[$cor0], joinType=[inner], requiredColumns=[{7}])\n"
-        + "  LogicalTableScan(table=[[scott, EMP]])\n"
-        + "  LogicalFilter(condition=[=($0, $cor0.DEPTNO)])\n"
-        + "    LogicalTableScan(table=[[scott, DEPT]])\n";
-
+    String s = deserializeAndDump(getSchema(relNode), relJson, format);
+    String expected = null;
+    switch (format) {
+    case TEXT:
+      expected = ""
+          + "LogicalCorrelate(correlation=[$cor0], joinType=[inner], requiredColumns=[{7}])\n"
+          + "  LogicalTableScan(table=[[scott, EMP]])\n"
+          + "  LogicalFilter(condition=[=($0, $cor0.DEPTNO)])\n"
+          + "    LogicalTableScan(table=[[scott, DEPT]])\n";
+      break;
+    case DOT:
+      expected = "digraph {\n"
+          + "\"LogicalTableScan\\ntable = [scott, EMP]\\n\" -> \"LogicalCorrelate\\ncorrelation = "
+          + "$cor0\\njoinType = inner\\nrequiredColumns = {7\\n}\\n\" [label=\"0\"]\n"
+          + "\"LogicalFilter\\ncondition = =($0, $c\\nor0.DEPTNO)\\n\" -> "
+          + "\"LogicalCorrelate\\ncorrelation = $cor0\\njoinType = inner\\nrequiredColumns = "
+          + "{7\\n}\\n\" [label=\"1\"]\n"
+          + "\"LogicalTableScan\\ntable = [scott, DEPT\\n]\\n\" -> \"LogicalFilter\\ncondition = ="
+          + "($0, $c\\nor0.DEPTNO)\\n\" [label=\"0\"]\n"
+          + "}\n";
+      break;
+    }
     assertThat(s, isLinux(expected));
   }
 
@@ -826,7 +866,9 @@ class RelWriterTest {
     assertThat(s, isLinux(expected));
   }
 
-  @Test void testUDAF() {
+  @ParameterizedTest
+  @MethodSource("explainFormats")
+  void testUDAF(SqlExplainFormat format) {
     final FrameworkConfig config = RelBuilderTest.config().build();
     final RelBuilder builder = RelBuilder.create(config);
     final RelNode rel = builder
@@ -839,11 +881,24 @@ class RelWriterTest {
         .build();
     final String relJson = RelOptUtil.dumpPlan("", rel,
         SqlExplainFormat.JSON, SqlExplainLevel.EXPPLAN_ATTRIBUTES);
-    final String result = deserializeAndDumpToTextFormat(getSchema(rel), relJson);
-    final String expected = ""
-        + "LogicalAggregate(group=[{0}], agg#0=[myAggFunc($1)])\n"
-        + "  LogicalProject(ENAME=[$1], DEPTNO=[$7])\n"
-        + "    LogicalTableScan(table=[[scott, EMP]])\n";
+    final String result = deserializeAndDump(getSchema(rel), relJson, format);
+    String expected = null;
+    switch (format) {
+    case TEXT:
+      expected = ""
+          + "LogicalAggregate(group=[{0}], agg#0=[myAggFunc($1)])\n"
+          + "  LogicalProject(ENAME=[$1], DEPTNO=[$7])\n"
+          + "    LogicalTableScan(table=[[scott, EMP]])\n";
+      break;
+    case DOT:
+      expected = "digraph {\n"
+          + "\"LogicalProject\\nENAME = $1\\nDEPTNO = $7\\n\" -> \"LogicalAggregate\\ngroup = "
+          + "{0}\\nagg#0 = myAggFunc($1\\n)\\n\" [label=\"0\"]\n"
+          + "\"LogicalTableScan\\ntable = [scott, EMP]\\n\" -> \"LogicalProject\\nENAME = "
+          + "$1\\nDEPTNO = $7\\n\" [label=\"0\"]\n"
+          + "}\n";
+      break;
+    }
     assertThat(result, isLinux(expected));
   }
 
@@ -881,9 +936,10 @@ class RelWriterTest {
 
   /**
    * Deserialize a relnode from the json string by {@link RelJsonReader},
-   * and dump it to text format.
+   * and dump it to the given format.
    */
-  private String deserializeAndDumpToTextFormat(RelOptSchema schema, String relJson) {
+  private String deserializeAndDump(
+      RelOptSchema schema, String relJson, SqlExplainFormat format) {
     String s =
         Frameworks.withPlanner((cluster, relOptSchema, rootSchema) -> {
           final RelJsonReader reader = new RelJsonReader(
@@ -894,13 +950,21 @@ class RelWriterTest {
           } catch (IOException e) {
             throw TestUtil.rethrow(e);
           }
-          return RelOptUtil.dumpPlan("", node, SqlExplainFormat.TEXT,
+          return RelOptUtil.dumpPlan("", node, format,
               SqlExplainLevel.EXPPLAN_ATTRIBUTES);
         });
     return s;
   }
 
   /**
+   * Deserialize a relnode from the json string by {@link RelJsonReader},
+   * and dump it to text format.
+   */
+  private String deserializeAndDumpToTextFormat(RelOptSchema schema, String relJson) {
+    return deserializeAndDump(schema, relJson, SqlExplainFormat.TEXT);
+  }
+
+  /**
    * Creates a mock {@link RelNode} that contains OVER. The SQL is as follows:
    *
    * <blockquote>
diff --git a/core/src/test/java/org/apache/calcite/plan/volcano/VolcanoPlannerTest.java b/core/src/test/java/org/apache/calcite/plan/volcano/VolcanoPlannerTest.java
index b45dced..fddb5de 100644
--- a/core/src/test/java/org/apache/calcite/plan/volcano/VolcanoPlannerTest.java
+++ b/core/src/test/java/org/apache/calcite/plan/volcano/VolcanoPlannerTest.java
@@ -35,8 +35,10 @@ import org.apache.calcite.rel.convert.ConverterImpl;
 import org.apache.calcite.rel.convert.ConverterRule;
 import org.apache.calcite.rel.core.JoinRelType;
 import org.apache.calcite.rel.core.RelFactories;
+import org.apache.calcite.rel.externalize.RelDotWriter;
 import org.apache.calcite.rel.logical.LogicalProject;
 import org.apache.calcite.rel.rules.CoreRules;
+import org.apache.calcite.sql.SqlExplainLevel;
 import org.apache.calcite.tools.RelBuilder;
 import org.apache.calcite.util.ImmutableBeans;
 import org.apache.calcite.util.Pair;
@@ -44,6 +46,8 @@ import org.apache.calcite.util.Pair;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
+import java.io.PrintWriter;
+import java.io.StringWriter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -129,6 +133,38 @@ class VolcanoPlannerTest {
     assertTrue(result instanceof PhysSingleRel);
   }
 
+  @Test void testPlanToDot() {
+    VolcanoPlanner planner = new VolcanoPlanner();
+    planner.addRelTraitDef(ConventionTraitDef.INSTANCE);
+
+    RelOptCluster cluster = newCluster(planner);
+    NoneLeafRel leafRel =
+        new NoneLeafRel(
+            cluster,
+            "a");
+    NoneSingleRel singleRel =
+        new NoneSingleRel(
+            cluster,
+            leafRel);
+    RelNode convertedRel =
+        planner.changeTraits(
+            singleRel,
+            cluster.traitSetOf(PHYS_CALLING_CONVENTION));
+    planner.setRoot(convertedRel);
+
+    StringWriter sw = new StringWriter();
+    PrintWriter pw = new PrintWriter(sw);
+
+    RelDotWriter planWriter = new RelDotWriter(pw, SqlExplainLevel.NO_ATTRIBUTES, false);
+    planner.getRoot().explain(planWriter);
+    String planStr = sw.toString();
+
+    assertThat(
+        planStr, isLinux("digraph {\n"
+            + "\"NoneLeafRel\\n\" -> \"NoneSingleRel\\n\" [label=\"0\"]\n"
+            + "}\n"));
+  }
+
   /** Test case for
    * <a href="https://issues.apache.org/jira/browse/CALCITE-3118">[CALCITE-3118]
    * VolcanoRuleCall should look at RelSubset rather than RelSet
diff --git a/core/src/test/java/org/apache/calcite/test/HepPlannerTest.java b/core/src/test/java/org/apache/calcite/test/HepPlannerTest.java
index c486523..3a2c516 100644
--- a/core/src/test/java/org/apache/calcite/test/HepPlannerTest.java
+++ b/core/src/test/java/org/apache/calcite/test/HepPlannerTest.java
@@ -24,15 +24,22 @@ import org.apache.calcite.plan.hep.HepProgram;
 import org.apache.calcite.plan.hep.HepProgramBuilder;
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.RelRoot;
+import org.apache.calcite.rel.externalize.RelDotWriter;
 import org.apache.calcite.rel.logical.LogicalIntersect;
 import org.apache.calcite.rel.logical.LogicalUnion;
 import org.apache.calcite.rel.rules.CoerceInputsRule;
 import org.apache.calcite.rel.rules.CoreRules;
+import org.apache.calcite.sql.SqlExplainLevel;
 
 import com.google.common.collect.ImmutableList;
 
 import org.junit.jupiter.api.Test;
 
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+import static org.apache.calcite.test.Matchers.isLinux;
+
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -155,6 +162,28 @@ class HepPlannerTest extends RelOptTestBase {
         best.getDigest(), "LogicalUnion");
   }
 
+  @Test void testPlanToDot() {
+    HepProgramBuilder programBuilder = HepProgram.builder();
+    HepPlanner planner =
+        new HepPlanner(
+            programBuilder.build());
+    RelRoot root = tester.convertSqlToRel("select name from sales.dept");
+    planner.setRoot(root.rel);
+
+    StringWriter sw = new StringWriter();
+    PrintWriter pw = new PrintWriter(sw);
+
+    RelDotWriter planWriter = new RelDotWriter(pw, SqlExplainLevel.EXPPLAN_ATTRIBUTES, false);
+    planner.getRoot().explain(planWriter);
+    String planStr = sw.toString();
+
+    assertThat(
+        planStr, isLinux("digraph {\n"
+            + "\"LogicalTableScan\\ntable = [CATALOG, SA\\nLES, DEPT]\\n\" -> "
+            + "\"LogicalProject\\nNAME = $1\\n\" [label=\"0\"]\n"
+            + "}\n"));
+  }
+
   private void assertIncludesExactlyOnce(String message, String digest, String substring) {
     int pos = 0;
     int cnt = 0;
diff --git a/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java b/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
index 0f76773..d0a928b 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
@@ -33,6 +33,7 @@ import org.apache.calcite.rel.RelRoot;
 import org.apache.calcite.rel.RelShuttleImpl;
 import org.apache.calcite.rel.RelVisitor;
 import org.apache.calcite.rel.core.CorrelationId;
+import org.apache.calcite.rel.externalize.RelDotWriter;
 import org.apache.calcite.rel.externalize.RelXmlWriter;
 import org.apache.calcite.rel.logical.LogicalCalc;
 import org.apache.calcite.rel.logical.LogicalFilter;
@@ -2233,6 +2234,23 @@ class SqlToRelConverterTest extends SqlToRelTestBase {
         Util.toLinux(sw.toString()));
   }
 
+  @Test void testExplainAsDot() {
+    String sql = "select 1 + 2, 3 from (values (true))";
+    final RelNode rel = tester.convertSqlToRel(sql).rel;
+    StringWriter sw = new StringWriter();
+    PrintWriter pw = new PrintWriter(sw);
+    RelDotWriter planWriter =
+        new RelDotWriter(pw, SqlExplainLevel.EXPPLAN_ATTRIBUTES, false);
+    rel.explain(planWriter);
+    pw.flush();
+    TestUtil.assertEqualsVerbose(
+        "digraph {\n"
+            + "\"LogicalValues\\ntuples = [{ true }]\\n\" -> \"LogicalProject\\nEXPR$0 = +(1, 2)"
+            + "\\nEXPR$1 = 3\\n\" [label=\"0\"]\n"
+            + "}\n",
+        Util.toLinux(sw.toString()));
+  }
+
   /** Test case for
    * <a href="https://issues.apache.org/jira/browse/CALCITE-412">[CALCITE-412]
    * RelFieldTrimmer: when trimming Sort, the collation and trait set don't