All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.trino.sql.planner.planprinter.PlanPrinter Maven / Gradle / Ivy

/*
 * Licensed 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 io.trino.sql.planner.planprinter;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CaseFormat;
import com.google.common.base.Joiner;
import com.google.common.collect.Collections2;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Streams;
import com.google.errorprone.annotations.FormatMethod;
import io.airlift.json.JsonCodec;
import io.airlift.stats.TDigest;
import io.airlift.units.Duration;
import io.trino.Session;
import io.trino.client.NodeVersion;
import io.trino.cost.PlanCostEstimate;
import io.trino.cost.PlanNodeStatsAndCostSummary;
import io.trino.cost.PlanNodeStatsEstimate;
import io.trino.cost.StatsAndCosts;
import io.trino.execution.QueryStats;
import io.trino.execution.StageInfo;
import io.trino.execution.StageStats;
import io.trino.execution.TableInfo;
import io.trino.metadata.FunctionManager;
import io.trino.metadata.Metadata;
import io.trino.metadata.ResolvedFunction;
import io.trino.metadata.TableHandle;
import io.trino.plugin.base.metrics.TDigestHistogram;
import io.trino.spi.connector.ColumnHandle;
import io.trino.spi.expression.FunctionName;
import io.trino.spi.function.CatalogSchemaFunctionName;
import io.trino.spi.function.table.Argument;
import io.trino.spi.function.table.DescriptorArgument;
import io.trino.spi.function.table.ScalarArgument;
import io.trino.spi.predicate.Domain;
import io.trino.spi.predicate.NullableValue;
import io.trino.spi.predicate.Range;
import io.trino.spi.predicate.TupleDomain;
import io.trino.spi.statistics.ColumnStatisticMetadata;
import io.trino.spi.statistics.TableStatisticType;
import io.trino.spi.type.Type;
import io.trino.sql.DynamicFilters;
import io.trino.sql.ir.Comparison;
import io.trino.sql.ir.Expression;
import io.trino.sql.ir.Reference;
import io.trino.sql.ir.Row;
import io.trino.sql.planner.OrderingScheme;
import io.trino.sql.planner.Partitioning;
import io.trino.sql.planner.PartitioningScheme;
import io.trino.sql.planner.PlanFragment;
import io.trino.sql.planner.SubPlan;
import io.trino.sql.planner.Symbol;
import io.trino.sql.planner.iterative.GroupReference;
import io.trino.sql.planner.plan.AdaptivePlanNode;
import io.trino.sql.planner.plan.AggregationNode;
import io.trino.sql.planner.plan.AggregationNode.Aggregation;
import io.trino.sql.planner.plan.ApplyNode;
import io.trino.sql.planner.plan.AssignUniqueId;
import io.trino.sql.planner.plan.Assignments;
import io.trino.sql.planner.plan.CorrelatedJoinNode;
import io.trino.sql.planner.plan.DistinctLimitNode;
import io.trino.sql.planner.plan.DynamicFilterId;
import io.trino.sql.planner.plan.DynamicFilterSourceNode;
import io.trino.sql.planner.plan.EnforceSingleRowNode;
import io.trino.sql.planner.plan.ExceptNode;
import io.trino.sql.planner.plan.ExchangeNode;
import io.trino.sql.planner.plan.ExchangeNode.Scope;
import io.trino.sql.planner.plan.ExplainAnalyzeNode;
import io.trino.sql.planner.plan.FilterNode;
import io.trino.sql.planner.plan.GroupIdNode;
import io.trino.sql.planner.plan.IndexJoinNode;
import io.trino.sql.planner.plan.IndexSourceNode;
import io.trino.sql.planner.plan.IntersectNode;
import io.trino.sql.planner.plan.JoinNode;
import io.trino.sql.planner.plan.LimitNode;
import io.trino.sql.planner.plan.MarkDistinctNode;
import io.trino.sql.planner.plan.MergeProcessorNode;
import io.trino.sql.planner.plan.MergeWriterNode;
import io.trino.sql.planner.plan.OffsetNode;
import io.trino.sql.planner.plan.OutputNode;
import io.trino.sql.planner.plan.PatternRecognitionNode;
import io.trino.sql.planner.plan.PatternRecognitionNode.Measure;
import io.trino.sql.planner.plan.PlanFragmentId;
import io.trino.sql.planner.plan.PlanNode;
import io.trino.sql.planner.plan.PlanNodeId;
import io.trino.sql.planner.plan.PlanVisitor;
import io.trino.sql.planner.plan.ProjectNode;
import io.trino.sql.planner.plan.RefreshMaterializedViewNode;
import io.trino.sql.planner.plan.RemoteSourceNode;
import io.trino.sql.planner.plan.RowNumberNode;
import io.trino.sql.planner.plan.RowsPerMatch;
import io.trino.sql.planner.plan.SampleNode;
import io.trino.sql.planner.plan.SemiJoinNode;
import io.trino.sql.planner.plan.SimpleTableExecuteNode;
import io.trino.sql.planner.plan.SkipToPosition;
import io.trino.sql.planner.plan.SortNode;
import io.trino.sql.planner.plan.SpatialJoinNode;
import io.trino.sql.planner.plan.StatisticAggregations;
import io.trino.sql.planner.plan.StatisticAggregationsDescriptor;
import io.trino.sql.planner.plan.StatisticsWriterNode;
import io.trino.sql.planner.plan.TableDeleteNode;
import io.trino.sql.planner.plan.TableExecuteNode;
import io.trino.sql.planner.plan.TableFinishNode;
import io.trino.sql.planner.plan.TableFunctionNode;
import io.trino.sql.planner.plan.TableFunctionNode.TableArgumentProperties;
import io.trino.sql.planner.plan.TableFunctionProcessorNode;
import io.trino.sql.planner.plan.TableScanNode;
import io.trino.sql.planner.plan.TableUpdateNode;
import io.trino.sql.planner.plan.TableWriterNode;
import io.trino.sql.planner.plan.TopNNode;
import io.trino.sql.planner.plan.TopNRankingNode;
import io.trino.sql.planner.plan.UnionNode;
import io.trino.sql.planner.plan.UnnestNode;
import io.trino.sql.planner.plan.ValuesNode;
import io.trino.sql.planner.plan.WindowNode;
import io.trino.sql.planner.rowpattern.AggregationValuePointer;
import io.trino.sql.planner.rowpattern.ClassifierValuePointer;
import io.trino.sql.planner.rowpattern.ExpressionAndValuePointers;
import io.trino.sql.planner.rowpattern.LogicalIndexPointer;
import io.trino.sql.planner.rowpattern.MatchNumberValuePointer;
import io.trino.sql.planner.rowpattern.ScalarValuePointer;
import io.trino.sql.planner.rowpattern.ir.IrLabel;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Stream;

import static com.google.common.base.CaseFormat.UPPER_UNDERSCORE;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.base.Verify.verify;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static io.airlift.json.JsonCodec.mapJsonCodec;
import static io.airlift.units.DataSize.succinctBytes;
import static io.airlift.units.Duration.succinctNanos;
import static io.trino.execution.StageInfo.getAllStages;
import static io.trino.metadata.GlobalFunctionCatalog.builtinFunctionName;
import static io.trino.metadata.GlobalFunctionCatalog.isBuiltinFunctionName;
import static io.trino.metadata.LanguageFunctionManager.isInlineFunction;
import static io.trino.server.DynamicFilterService.DynamicFilterDomainStats;
import static io.trino.server.protocol.spooling.SpooledBlock.SPOOLING_METADATA_COLUMN_NAME;
import static io.trino.server.protocol.spooling.SpooledBlock.SPOOLING_METADATA_TYPE;
import static io.trino.spi.function.table.DescriptorArgument.NULL_DESCRIPTOR;
import static io.trino.sql.DynamicFilters.extractDynamicFilters;
import static io.trino.sql.ir.Booleans.TRUE;
import static io.trino.sql.ir.IrUtils.combineConjunctsWithDuplicates;
import static io.trino.sql.planner.SystemPartitioningHandle.SINGLE_DISTRIBUTION;
import static io.trino.sql.planner.plan.JoinType.INNER;
import static io.trino.sql.planner.plan.RowsPerMatch.WINDOW;
import static io.trino.sql.planner.planprinter.JsonRenderer.JsonRenderedNode;
import static io.trino.sql.planner.planprinter.PlanNodeStatsSummarizer.aggregateStageStats;
import static io.trino.sql.planner.planprinter.TextRenderer.formatDouble;
import static io.trino.sql.planner.planprinter.TextRenderer.formatPositions;
import static io.trino.sql.planner.planprinter.TextRenderer.indentString;
import static java.lang.Math.abs;
import static java.lang.String.format;
import static java.util.Arrays.stream;
import static java.util.Objects.requireNonNull;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.function.Function.identity;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toCollection;
import static java.util.stream.Collectors.toList;

public class PlanPrinter
{
    private static final JsonCodec> DISTRIBUTED_PLAN_CODEC =
            mapJsonCodec(PlanFragmentId.class, JsonRenderedNode.class);
    private static final CatalogSchemaFunctionName COUNT_NAME = builtinFunctionName("count");

    private final PlanRepresentation representation;
    private final Function tableInfoSupplier;
    private final Map dynamicFilterDomainStats;
    private final ValuePrinter valuePrinter;
    private final Anonymizer anonymizer;

    // NOTE: do NOT add Metadata or Session to this class.  The plan printer must be usable outside of a transaction.
    @VisibleForTesting
    PlanPrinter(
            PlanNode planRoot,
            Function tableInfoSupplier,
            Map dynamicFilterDomainStats,
            ValuePrinter valuePrinter,
            StatsAndCosts estimatedStatsAndCosts,
            Optional> stats,
            Anonymizer anonymizer)
    {
        requireNonNull(planRoot, "planRoot is null");
        requireNonNull(tableInfoSupplier, "tableInfoSupplier is null");
        requireNonNull(dynamicFilterDomainStats, "dynamicFilterDomainStats is null");
        requireNonNull(valuePrinter, "valuePrinter is null");
        requireNonNull(estimatedStatsAndCosts, "estimatedStatsAndCosts is null");
        requireNonNull(stats, "stats is null");
        requireNonNull(anonymizer, "anonymizer is null");

        this.tableInfoSupplier = tableInfoSupplier;
        this.dynamicFilterDomainStats = ImmutableMap.copyOf(dynamicFilterDomainStats);
        this.valuePrinter = valuePrinter;
        this.anonymizer = anonymizer;

        Optional totalScheduledTime = stats.map(s -> new Duration(s.values().stream()
                .mapToLong(planNode -> planNode.getPlanNodeScheduledTime().toMillis())
                .sum(), MILLISECONDS));

        Optional totalCpuTime = stats.map(s -> new Duration(s.values().stream()
                .mapToLong(planNode -> planNode.getPlanNodeCpuTime().toMillis())
                .sum(), MILLISECONDS));

        Optional totalBlockedTime = stats.map(s -> new Duration(s.values().stream()
                .mapToLong(planNode -> planNode.getPlanNodeBlockedTime().toMillis())
                .sum(), MILLISECONDS));

        this.representation = new PlanRepresentation(planRoot, totalCpuTime, totalScheduledTime, totalBlockedTime);

        Visitor visitor = new Visitor(estimatedStatsAndCosts, stats);
        planRoot.accept(visitor, new Context(Optional.empty(), false));
    }

    private String toText(boolean verbose, int level)
    {
        return new TextRenderer(verbose, level).render(representation);
    }

    @VisibleForTesting
    String toJson()
    {
        return new JsonRenderer().render(representation);
    }

    JsonRenderedNode toJsonRenderedNode()
    {
        return new JsonRenderer().renderJson(representation, representation.getRoot(), false);
    }

    public static String jsonFragmentPlan(PlanNode root, Metadata metadata, FunctionManager functionManager, Session session)
    {
        TableInfoSupplier tableInfoSupplier = new TableInfoSupplier(metadata, session);
        ValuePrinter valuePrinter = new ValuePrinter(metadata, functionManager, session);
        return new PlanPrinter(
                root,
                tableInfoSupplier,
                ImmutableMap.of(),
                valuePrinter,
                StatsAndCosts.empty(),
                Optional.empty(),
                new NoOpAnonymizer())
                .toJson();
    }

    public static String jsonLogicalPlan(
            PlanNode plan,
            Session session,
            Metadata metadata,
            FunctionManager functionManager,
            StatsAndCosts estimatedStatsAndCosts)
    {
        TableInfoSupplier tableInfoSupplier = new TableInfoSupplier(metadata, session);
        ValuePrinter valuePrinter = new ValuePrinter(metadata, functionManager, session);
        return new PlanPrinter(
                plan,
                tableInfoSupplier,
                ImmutableMap.of(),
                valuePrinter,
                estimatedStatsAndCosts,
                Optional.empty(),
                new NoOpAnonymizer())
                .toJson();
    }

    public static String jsonDistributedPlan(
            StageInfo outputStageInfo,
            Session session,
            Metadata metadata,
            FunctionManager functionManager,
            Anonymizer anonymizer)
    {
        List allStages = getAllStages(Optional.of(outputStageInfo));
        Map tableInfos = allStages.stream()
                .map(StageInfo::getTables)
                .map(Map::entrySet)
                .flatMap(Collection::stream)
                .collect(toImmutableMap(Entry::getKey, Entry::getValue));

        ValuePrinter valuePrinter = new ValuePrinter(metadata, functionManager, session);
        List planFragments = allStages.stream()
                .map(StageInfo::getPlan)
                .filter(Objects::nonNull)
                .collect(toImmutableList());

        return jsonDistributedPlan(
                planFragments,
                tableScanNode -> tableInfos.get(tableScanNode.getId()),
                valuePrinter,
                anonymizer);
    }

    public static String jsonDistributedPlan(SubPlan plan, Metadata metadata, FunctionManager functionManager, Session session)
    {
        TableInfoSupplier tableInfoSupplier = new TableInfoSupplier(metadata, session);
        ValuePrinter valuePrinter = new ValuePrinter(metadata, functionManager, session);
        return jsonDistributedPlan(
                plan.getAllFragments(),
                tableInfoSupplier,
                valuePrinter,
                new NoOpAnonymizer());
    }

    private static String jsonDistributedPlan(
            List fragments,
            Function tableInfoSupplier,
            ValuePrinter valuePrinter,
            Anonymizer anonymizer)
    {
        Map anonymizedPlan = fragments.stream()
                .collect(toImmutableMap(
                        PlanFragment::getId,
                        planFragment -> new PlanPrinter(
                                planFragment.getRoot(),
                                tableInfoSupplier,
                                ImmutableMap.of(),
                                valuePrinter,
                                planFragment.getStatsAndCosts(),
                                Optional.empty(),
                                anonymizer)
                                .toJsonRenderedNode()));
        return DISTRIBUTED_PLAN_CODEC.toJson(anonymizedPlan);
    }

    public static String textLogicalPlan(
            PlanNode plan,
            Metadata metadata,
            FunctionManager functionManager,
            StatsAndCosts estimatedStatsAndCosts,
            Session session,
            int level,
            boolean verbose)
    {
        return textLogicalPlan(plan, metadata, functionManager, estimatedStatsAndCosts, session, level, verbose, Optional.empty());
    }

    public static String textLogicalPlan(
            PlanNode plan,
            Metadata metadata,
            FunctionManager functionManager,
            StatsAndCosts estimatedStatsAndCosts,
            Session session,
            int level,
            boolean verbose,
            Optional version)
    {
        TableInfoSupplier tableInfoSupplier = new TableInfoSupplier(metadata, session);
        ValuePrinter valuePrinter = new ValuePrinter(metadata, functionManager, session);
        StringBuilder builder = new StringBuilder();
        version.ifPresent(v -> builder.append(format("Trino version: %s\n", v)));
        builder.append(new PlanPrinter(
                plan,
                tableInfoSupplier,
                ImmutableMap.of(),
                valuePrinter,
                estimatedStatsAndCosts,
                Optional.empty(),
                new NoOpAnonymizer())
                .toText(verbose, level));
        return builder.toString();
    }

    public static String textDistributedPlan(
            StageInfo outputStageInfo,
            QueryStats queryStats,
            Metadata metadata,
            FunctionManager functionManager,
            Session session,
            boolean verbose,
            NodeVersion version)
    {
        return textDistributedPlan(
                outputStageInfo,
                queryStats,
                new ValuePrinter(metadata, functionManager, session),
                verbose,
                new NoOpAnonymizer(),
                version);
    }

    public static String textDistributedPlan(
            StageInfo outputStageInfo,
            QueryStats queryStats,
            ValuePrinter valuePrinter,
            boolean verbose,
            Anonymizer anonymizer,
            NodeVersion version)
    {
        List allStages = getAllStages(Optional.of(outputStageInfo));
        Map tableInfos = allStages.stream()
                .map(StageInfo::getTables)
                .map(Map::entrySet)
                .flatMap(Collection::stream)
                .collect(toImmutableMap(Entry::getKey, Entry::getValue));

        StringBuilder builder = new StringBuilder();
        Map aggregatedStats = aggregateStageStats(allStages);

        Map dynamicFilterDomainStats = queryStats.getDynamicFiltersStats()
                .getDynamicFilterDomainStats().stream()
                .collect(toImmutableMap(DynamicFilterDomainStats::getDynamicFilterId, identity()));

        builder.append(format("Trino version: %s\n", version));
        builder.append(format("Queued: %s, Analysis: %s, Planning: %s, Execution: %s\n",
                queryStats.getQueuedTime().convertToMostSuccinctTimeUnit(),
                queryStats.getAnalysisTime().convertToMostSuccinctTimeUnit(),
                queryStats.getPlanningTime().convertToMostSuccinctTimeUnit(),
                queryStats.getExecutionTime().convertToMostSuccinctTimeUnit()));

        for (StageInfo stageInfo : allStages) {
            builder.append(formatFragment(
                    tableScanNode -> tableInfos.get(tableScanNode.getId()),
                    dynamicFilterDomainStats,
                    valuePrinter,
                    stageInfo.getPlan(),
                    Optional.of(stageInfo),
                    Optional.of(aggregatedStats),
                    verbose,
                    anonymizer));
        }

        return builder.toString();
    }

    public static String textDistributedPlan(SubPlan plan, Metadata metadata, FunctionManager functionManager, Session session, boolean verbose, NodeVersion version)
    {
        TableInfoSupplier tableInfoSupplier = new TableInfoSupplier(metadata, session);
        ValuePrinter valuePrinter = new ValuePrinter(metadata, functionManager, session);
        StringBuilder builder = new StringBuilder();
        builder.append(format("Trino version: %s\n", version));
        for (PlanFragment fragment : plan.getAllFragments()) {
            builder.append(formatFragment(
                    tableInfoSupplier,
                    ImmutableMap.of(), valuePrinter, fragment, Optional.empty(), Optional.empty(), verbose, new NoOpAnonymizer()));
        }

        return builder.toString();
    }

    private static String formatFragment(
            Function tableInfoSupplier,
            Map dynamicFilterDomainStats,
            ValuePrinter valuePrinter,
            PlanFragment fragment,
            Optional stageInfo,
            Optional> planNodeStats,
            boolean verbose,
            Anonymizer anonymizer)
    {
        StringBuilder builder = new StringBuilder();
        builder.append(format("Fragment %s [%s]\n",
                fragment.getId(),
                anonymizer.anonymize(fragment.getPartitioning())));

        if (stageInfo.isPresent()) {
            StageStats stageStats = stageInfo.get().getStageStats();

            double avgPositionsPerTask = stageInfo.get().getTasks().stream().mapToLong(task -> task.stats().getProcessedInputPositions()).average().orElse(Double.NaN);
            double squaredDifferences = stageInfo.get().getTasks().stream().mapToDouble(task -> Math.pow(task.stats().getProcessedInputPositions() - avgPositionsPerTask, 2)).sum();
            double sdAmongTasks = Math.sqrt(squaredDifferences / stageInfo.get().getTasks().size());

            builder.append(indentString(1))
                    .append(format("CPU: %s, Scheduled: %s, Blocked %s (Input: %s, Output: %s), Input: %s (%s); per task: avg.: %s std.dev.: %s, Output: %s (%s)\n",
                            stageStats.getTotalCpuTime().convertToMostSuccinctTimeUnit(),
                            stageStats.getTotalScheduledTime().convertToMostSuccinctTimeUnit(),
                            stageStats.getTotalBlockedTime().convertToMostSuccinctTimeUnit(),
                            stageStats.getInputBlockedTime().convertToMostSuccinctTimeUnit(),
                            stageStats.getOutputBlockedTime().convertToMostSuccinctTimeUnit(),
                            formatPositions(stageStats.getProcessedInputPositions()),
                            stageStats.getProcessedInputDataSize(),
                            formatDouble(avgPositionsPerTask),
                            formatDouble(sdAmongTasks),
                            formatPositions(stageStats.getOutputPositions()),
                            stageStats.getOutputDataSize()));
            Optional outputBufferUtilization = stageInfo.get().getStageStats().getOutputBufferUtilization();
            if (verbose && outputBufferUtilization.isPresent()) {
                builder.append(indentString(1))
                        .append(format("Output buffer active time: %s, buffer utilization distribution (%%): {p01=%s, p05=%s, p10=%s, p25=%s, p50=%s, p75=%s, p90=%s, p95=%s, p99=%s, max=%s}\n",
                                succinctNanos(outputBufferUtilization.get().getTotal()),
                                // scale ratio to percentages
                                formatDouble(outputBufferUtilization.get().getP01() * 100),
                                formatDouble(outputBufferUtilization.get().getP05() * 100),
                                formatDouble(outputBufferUtilization.get().getP10() * 100),
                                formatDouble(outputBufferUtilization.get().getP25() * 100),
                                formatDouble(outputBufferUtilization.get().getP50() * 100),
                                formatDouble(outputBufferUtilization.get().getP75() * 100),
                                formatDouble(outputBufferUtilization.get().getP90() * 100),
                                formatDouble(outputBufferUtilization.get().getP95() * 100),
                                formatDouble(outputBufferUtilization.get().getP99() * 100),
                                formatDouble(outputBufferUtilization.get().getMax() * 100)));
            }

            TDigest taskOutputDistribution = new TDigest();
            stageInfo.get().getTasks().forEach(task -> taskOutputDistribution.add(task.stats().getOutputDataSize().toBytes()));
            TDigest taskInputDistribution = new TDigest();
            stageInfo.get().getTasks().forEach(task -> taskInputDistribution.add(task.stats().getProcessedInputDataSize().toBytes()));

            if (verbose) {
                builder.append(indentString(1))
                        .append(format("Task output distribution: %s\n", formatSizeDistribution(taskOutputDistribution)));
                builder.append(indentString(1))
                        .append(format("Task input distribution: %s\n", formatSizeDistribution(taskInputDistribution)));
            }

            if (taskInputDistribution.valueAt(0.99) > taskInputDistribution.valueAt(0.49) * 2) {
                builder.append(indentString(1))
                        .append("Amount of input data processed by the workers for this stage might be skewed\n");
            }
        }

        PartitioningScheme partitioningScheme = fragment.getOutputPartitioningScheme();
        List layout = partitioningScheme.getOutputLayout().stream()
                .map(anonymizer::anonymize)
                .filter(value -> !value.equals(SPOOLING_METADATA_COLUMN_NAME))
                .collect(toImmutableList());
        builder.append(indentString(1))
                .append(format("Output layout: [%s]\n",
                        Joiner.on(", ").join(layout)));

        boolean replicateNullsAndAny = partitioningScheme.isReplicateNullsAndAny();
        List arguments = partitioningScheme.getPartitioning().getArguments().stream()
                .map(argument -> {
                    if (argument.isConstant()) {
                        NullableValue constant = argument.getConstant();
                        String printableValue = valuePrinter.castToVarchar(constant.getType(), constant.getValue());
                        return constant.getType().getDisplayName() + "(" + anonymizer.anonymize(constant.getType(), printableValue) + ")";
                    }
                    return anonymizer.anonymize(argument.getColumn());
                })
                .collect(toImmutableList());
        builder.append(indentString(1));
        String hashColumn = partitioningScheme.getHashColumn().map(anonymizer::anonymize).map(column -> "[" + column + "]").orElse("");
        if (replicateNullsAndAny) {
            builder.append(format("Output partitioning: %s (replicate nulls and any) [%s]%s",
                    anonymizer.anonymize(partitioningScheme.getPartitioning().getHandle()),
                    Joiner.on(", ").join(arguments),
                    hashColumn));
        }
        else {
            builder.append(format("Output partitioning: %s [%s]%s\n",
                    anonymizer.anonymize(partitioningScheme.getPartitioning().getHandle()),
                    Joiner.on(", ").join(arguments),
                    hashColumn));
        }
        partitioningScheme.getPartitionCount().ifPresent(partitionCount -> builder.append(format("%sOutput partition count: %s\n", indentString(1), partitionCount)));
        fragment.getPartitionCount().ifPresent(partitionCount -> builder.append(format("%sInput partition count: %s\n", indentString(1), partitionCount)));

        builder.append(
                        new PlanPrinter(
                                fragment.getRoot(),
                                tableInfoSupplier,
                                dynamicFilterDomainStats,
                                valuePrinter,
                                fragment.getStatsAndCosts(),
                                planNodeStats,
                                anonymizer).toText(verbose, 1))
                .append("\n");

        return builder.toString();
    }

    private static String formatSizeDistribution(TDigest digest)
    {
        return format("{count=%s, p01=%s, p05=%s, p10=%s, p25=%s, p50=%s, p75=%s, p90=%s, p95=%s, p99=%s, max=%s}",
                formatDouble(digest.getCount()),
                succinctBytes((long) digest.valueAt(0.01)),
                succinctBytes((long) digest.valueAt(0.05)),
                succinctBytes((long) digest.valueAt(0.10)),
                succinctBytes((long) digest.valueAt(0.25)),
                succinctBytes((long) digest.valueAt(0.50)),
                succinctBytes((long) digest.valueAt(0.75)),
                succinctBytes((long) digest.valueAt(0.90)),
                succinctBytes((long) digest.valueAt(0.95)),
                succinctBytes((long) digest.valueAt(0.99)),
                succinctBytes((long) digest.getMax()));
    }

    public static String graphvizLogicalPlan(PlanNode plan)
    {
        // TODO: This should move to something like GraphvizRenderer
        PlanFragment fragment = new PlanFragment(
                new PlanFragmentId("graphviz_plan"),
                plan,
                ImmutableSet.of(),
                SINGLE_DISTRIBUTION,
                Optional.empty(),
                ImmutableList.of(plan.getId()),
                new PartitioningScheme(Partitioning.create(SINGLE_DISTRIBUTION, ImmutableList.of()), plan.getOutputSymbols()),
                StatsAndCosts.empty(),
                ImmutableList.of(),
                ImmutableMap.of(),
                Optional.empty());
        return GraphvizPrinter.printLogical(ImmutableList.of(fragment));
    }

    public static String graphvizDistributedPlan(SubPlan plan)
    {
        return GraphvizPrinter.printDistributed(plan);
    }

    private class Visitor
            extends PlanVisitor
    {
        private final StatsAndCosts estimatedStatsAndCosts;
        private final Optional> stats;

        public Visitor(StatsAndCosts estimatedStatsAndCosts, Optional> stats)
        {
            this.estimatedStatsAndCosts = requireNonNull(estimatedStatsAndCosts, "estimatedStatsAndCosts is null");
            this.stats = requireNonNull(stats, "stats is null");
        }

        @Override
        public Void visitExplainAnalyze(ExplainAnalyzeNode node, Context context)
        {
            addNode(node, "ExplainAnalyze", context);
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitJoin(JoinNode node, Context context)
        {
            List criteriaExpressions = node.getCriteria().stream()
                    .map(JoinNode.EquiJoinClause::toExpression)
                    .collect(toImmutableList());

            NodeRepresentation nodeOutput;
            if (node.isCrossJoin()) {
                checkState(criteriaExpressions.isEmpty());
                checkState(node.getFilter().isEmpty());
                nodeOutput = addNode(node, "CrossJoin", context);
            }
            else {
                ImmutableMap.Builder descriptor = ImmutableMap.builder()
                        .put("criteria", Joiner.on(" AND ").join(anonymizeExpressions(criteriaExpressions)));
                node.getFilter().ifPresent(filter -> descriptor.put("filter", formatFilter(filter)));
                descriptor.put("hash", formatHash(node.getLeftHashSymbol(), node.getRightHashSymbol()));
                node.getDistributionType().ifPresent(distribution -> descriptor.put("distribution", distribution.name()));
                nodeOutput = addNode(node, node.getType().getJoinLabel(), descriptor.buildOrThrow(), node.getReorderJoinStatsAndCost(), context);
            }

            node.getDistributionType().ifPresent(distributionType -> nodeOutput.appendDetails("Distribution: %s", distributionType));
            if (node.isMaySkipOutputDuplicates()) {
                nodeOutput.appendDetails("maySkipOutputDuplicates = %s", node.isMaySkipOutputDuplicates());
            }
            if (!node.getDynamicFilters().isEmpty()) {
                nodeOutput.appendDetails("dynamicFilterAssignments = %s", printDynamicFilterAssignments(node.getDynamicFilters()));
            }
            node.getLeft().accept(this, new Context(context.isInitialPlan()));
            node.getRight().accept(this, new Context(context.isInitialPlan()));

            return null;
        }

        @Override
        public Void visitSpatialJoin(SpatialJoinNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(node,
                    node.getType().getJoinLabel(),
                    ImmutableMap.of("filter", formatFilter(node.getFilter())),
                    context);

            nodeOutput.appendDetails("Distribution: %s", node.getDistributionType());
            node.getLeft().accept(this, new Context(context.isInitialPlan()));
            node.getRight().accept(this, new Context(context.isInitialPlan()));

            return null;
        }

        @Override
        public Void visitSemiJoin(SemiJoinNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(node,
                    "SemiJoin",
                    ImmutableMap.of(
                            "criteria", anonymizer.anonymize(node.getSourceJoinSymbol()) + " = " + anonymizer.anonymize(node.getFilteringSourceJoinSymbol()),
                            "hash", formatHash(node.getSourceHashSymbol(), node.getFilteringSourceHashSymbol())),
                    context);
            node.getDistributionType().ifPresent(distributionType -> nodeOutput.appendDetails("Distribution: %s", distributionType));
            node.getDynamicFilterId().ifPresent(dynamicFilterId -> nodeOutput.appendDetails("dynamicFilterId: %s", dynamicFilterId));
            node.getSource().accept(this, new Context(context.isInitialPlan()));
            node.getFilteringSource().accept(this, new Context(context.isInitialPlan()));

            return null;
        }

        @Override
        public Void visitDynamicFilterSource(DynamicFilterSourceNode node, Context context)
        {
            addNode(
                    node,
                    "DynamicFilterSource",
                    ImmutableMap.of("dynamicFilterAssignments", printDynamicFilterAssignments(node.getDynamicFilters())),
                    context);
            node.getSource().accept(this, new Context(context.isInitialPlan()));
            return null;
        }

        @Override
        public Void visitIndexSource(IndexSourceNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(node,
                    "IndexSource",
                    ImmutableMap.of(
                            "indexedTable", anonymizer.anonymize(node.getIndexHandle()),
                            "lookup", formatSymbols(node.getLookupSymbols())),
                    context);

            for (Entry entry : node.getAssignments().entrySet()) {
                if (node.getOutputSymbols().contains(entry.getKey())) {
                    nodeOutput.appendDetails("%s := %s", anonymizer.anonymize(entry.getKey()), anonymizer.anonymize(entry.getValue()));
                }
            }
            return null;
        }

        @Override
        public Void visitIndexJoin(IndexJoinNode node, Context context)
        {
            List joinExpressions = new ArrayList<>();
            for (IndexJoinNode.EquiJoinClause clause : node.getCriteria()) {
                joinExpressions.add(new Comparison(Comparison.Operator.EQUAL,
                        clause.getProbe().toSymbolReference(),
                        clause.getIndex().toSymbolReference()));
            }

            addNode(node,
                    format("%sIndexJoin", node.getType().getJoinLabel()),
                    ImmutableMap.of(
                            "criteria", Joiner.on(" AND ").join(anonymizeExpressions(joinExpressions)),
                            "hash", formatHash(node.getProbeHashSymbol(), node.getIndexHashSymbol())),
                    context);
            node.getProbeSource().accept(this, new Context(context.isInitialPlan()));
            node.getIndexSource().accept(this, new Context(context.isInitialPlan()));

            return null;
        }

        @Override
        public Void visitOffset(OffsetNode node, Context context)
        {
            addNode(node,
                    "Offset",
                    ImmutableMap.of("count", String.valueOf(node.getCount())),
                    context);
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitLimit(LimitNode node, Context context)
        {
            addNode(node,
                    format("Limit%s", node.isPartial() ? "Partial" : ""),
                    ImmutableMap.of(
                            "count", String.valueOf(node.getCount()),
                            "withTies", formatBoolean(node.isWithTies()),
                            "inputPreSortedBy", formatSymbols(node.getPreSortedInputs())),
                    context);
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitDistinctLimit(DistinctLimitNode node, Context context)
        {
            addNode(node,
                    format("DistinctLimit%s", node.isPartial() ? "Partial" : ""),
                    ImmutableMap.of(
                            "limit", String.valueOf(node.getLimit()),
                            "hash", formatHash(node.getHashSymbol())),
                    context);
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitAggregation(AggregationNode node, Context context)
        {
            String type = "";
            if (node.getStep() != AggregationNode.Step.SINGLE) {
                type = node.getStep().name();
            }
            if (node.isStreamable()) {
                type = format("%s (STREAMING)", type);
            }
            String keys = "";
            if (!node.getGroupingKeys().isEmpty()) {
                keys = formatSymbols(node.getGroupingKeys());
            }

            NodeRepresentation nodeOutput = addNode(
                    node,
                    "Aggregate",
                    ImmutableMap.of("type", type, "keys", keys, "hash", formatHash(node.getHashSymbol())),
                    context);

            node.getAggregations().forEach((symbol, aggregation) ->
                    nodeOutput.appendDetails("%s := %s", anonymizer.anonymize(symbol), formatAggregation(anonymizer, aggregation)));

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitGroupId(GroupIdNode node, Context context)
        {
            // grouping sets are easier to understand in terms of inputs
            List anonymizedInputGroupingSetSymbols = node.getGroupingSets().stream()
                    .map(set -> set.stream()
                            .map(symbol -> node.getGroupingColumns().get(symbol))
                            .collect(toImmutableList()))
                    .map(this::formatSymbols)
                    .collect(toImmutableList());

            NodeRepresentation nodeOutput = addNode(
                    node,
                    "GroupId",
                    ImmutableMap.of("symbols", formatCollection(anonymizedInputGroupingSetSymbols, Objects::toString)),
                    context);

            for (Entry mapping : node.getGroupingColumns().entrySet()) {
                nodeOutput.appendDetails("%s := %s", anonymizer.anonymize(mapping.getKey()), anonymizer.anonymize(mapping.getValue()));
            }

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitMarkDistinct(MarkDistinctNode node, Context context)
        {
            addNode(node,
                    "MarkDistinct",
                    ImmutableMap.of(
                            "distinct", formatOutputs(node.getDistinctSymbols()),
                            "marker", anonymizer.anonymize(node.getMarkerSymbol()),
                            "hash", formatHash(node.getHashSymbol())),
                    context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitWindow(WindowNode node, Context context)
        {
            ImmutableMap.Builder descriptor = ImmutableMap.builder();
            if (!node.getPartitionBy().isEmpty()) {
                List prePartitioned = node.getPartitionBy().stream()
                        .filter(node.getPrePartitionedInputs()::contains)
                        .collect(toImmutableList());

                List notPrePartitioned = node.getPartitionBy().stream()
                        .filter(column -> !node.getPrePartitionedInputs().contains(column))
                        .collect(toImmutableList());

                StringBuilder builder = new StringBuilder();
                if (!prePartitioned.isEmpty()) {
                    builder.append("<")
                            .append(Joiner.on(", ").join(anonymize(prePartitioned)))
                            .append(">");
                    if (!notPrePartitioned.isEmpty()) {
                        builder.append(", ");
                    }
                }
                if (!notPrePartitioned.isEmpty()) {
                    builder.append(Joiner.on(", ").join(anonymize(notPrePartitioned)));
                }
                descriptor.put("partitionBy", format("[%s]", builder));
            }
            if (node.getOrderingScheme().isPresent()) {
                descriptor.put("orderBy", formatOrderingScheme(node.getOrderingScheme().get(), node.getPreSortedOrderPrefix()));
            }

            NodeRepresentation nodeOutput = addNode(
                    node,
                    "Window",
                    descriptor.put("hash", formatHash(node.getHashSymbol())).buildOrThrow(),
                    context);

            for (Entry entry : node.getWindowFunctions().entrySet()) {
                WindowNode.Function function = entry.getValue();
                String frameInfo = formatFrame(function.getFrame());

                nodeOutput.appendDetails(
                        "%s := %s(%s) %s",
                        anonymizer.anonymize(entry.getKey()),
                        formatFunctionName(function.getResolvedFunction()),
                        Joiner.on(", ").join(anonymizeExpressions(function.getArguments())),
                        frameInfo);
            }
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitPatternRecognition(PatternRecognitionNode node, Context context)
        {
            ImmutableMap.Builder descriptor = ImmutableMap.builder();
            if (!node.getPartitionBy().isEmpty()) {
                List prePartitioned = node.getPartitionBy().stream()
                        .filter(node.getPrePartitionedInputs()::contains)
                        .collect(toImmutableList());

                List notPrePartitioned = node.getPartitionBy().stream()
                        .filter(column -> !node.getPrePartitionedInputs().contains(column))
                        .collect(toImmutableList());

                StringBuilder builder = new StringBuilder();
                if (!prePartitioned.isEmpty()) {
                    builder.append("<")
                            .append(Joiner.on(", ").join(anonymize(prePartitioned)))
                            .append(">");
                    if (!notPrePartitioned.isEmpty()) {
                        builder.append(", ");
                    }
                }
                if (!notPrePartitioned.isEmpty()) {
                    builder.append(Joiner.on(", ").join(anonymize(notPrePartitioned)));
                }
                descriptor.put("partitionBy", format("[%s]", builder));
            }
            if (node.getOrderingScheme().isPresent()) {
                descriptor.put("orderBy", formatOrderingScheme(node.getOrderingScheme().get(), node.getPreSortedOrderPrefix()));
            }

            NodeRepresentation nodeOutput = addNode(
                    node,
                    "PatternRecognition",
                    descriptor.put("hash", formatHash(node.getHashSymbol())).buildOrThrow(),
                    context);

            if (node.getCommonBaseFrame().isPresent()) {
                nodeOutput.appendDetails("base frame: %s", formatFrame(node.getCommonBaseFrame().get()));
            }
            for (Entry entry : node.getWindowFunctions().entrySet()) {
                WindowNode.Function function = entry.getValue();
                nodeOutput.appendDetails(
                        "%s := %s(%s)",
                        anonymizer.anonymize(entry.getKey()),
                        formatFunctionName(function.getResolvedFunction()),
                        Joiner.on(", ").join(anonymizeExpressions(function.getArguments())));
            }

            for (Entry entry : node.getMeasures().entrySet()) {
                nodeOutput.appendDetails(
                        "%s := %s",
                        anonymizer.anonymize(entry.getKey()),
                        anonymizer.anonymize(entry.getValue().getExpressionAndValuePointers().getExpression()));
                appendValuePointers(nodeOutput, entry.getValue().getExpressionAndValuePointers());
            }
            if (node.getRowsPerMatch() != WINDOW) {
                nodeOutput.appendDetails("%s", formatRowsPerMatch(node.getRowsPerMatch()));
            }
            nodeOutput.appendDetails("%s", formatSkipTo(node.getSkipToPosition(), node.getSkipToLabels()));
            nodeOutput.appendDetails("pattern[%s] (%s)", node.getPattern(), node.isInitial() ? "INITIAL" : "SEEK");

            for (Entry entry : node.getVariableDefinitions().entrySet()) {
                nodeOutput.appendDetails("%s := %s", entry.getKey().getName(), anonymizer.anonymize(entry.getValue().getExpression()));
                appendValuePointers(nodeOutput, entry.getValue());
            }

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        private void appendValuePointers(NodeRepresentation nodeOutput, ExpressionAndValuePointers expressionAndPointers)
        {
            for (ExpressionAndValuePointers.Assignment assignment : expressionAndPointers.getAssignments()) {
                String value = switch (assignment.valuePointer()) {
                    case AggregationValuePointer pointer -> format(
                            "%s%s(%s)%s",
                            pointer.getSetDescriptor().isRunning() ? "RUNNING " : "FINAL ",
                            formatFunctionName(pointer.getFunction()),
                            Joiner.on(", ").join(anonymizeExpressions(pointer.getArguments())),
                            pointer.getSetDescriptor().getLabels().stream()
                                    .map(IrLabel::getName)
                                    .collect(joining(", ", "{", "}")));
                    case ScalarValuePointer pointer -> format("%s[%s]", anonymizer.anonymize(pointer.getInputSymbol()), formatLogicalIndexPointer(pointer.getLogicalIndexPointer()));
                    case ClassifierValuePointer pointer -> format("%s[%s]", "classifier", formatLogicalIndexPointer(pointer.getLogicalIndexPointer()));
                    case MatchNumberValuePointer pointer -> "match_number";
                };

                nodeOutput.appendDetails("%s%s := %s", indentString(1), anonymizer.anonymize(assignment.symbol()), value);
            }
        }

        private String formatFrame(WindowNode.Frame frame)
        {
            StringBuilder builder = new StringBuilder(frame.getType().toString());

            frame.getStartValue()
                    .map(anonymizer::anonymize)
                    .ifPresent(value -> builder.append(" ").append(value));
            builder.append(" ").append(frame.getStartType());

            frame.getEndValue()
                    .map(anonymizer::anonymize)
                    .ifPresent(value -> builder.append(" ").append(value));
            builder.append(" ").append(frame.getEndType());

            return builder.toString();
        }

        private String formatLogicalIndexPointer(LogicalIndexPointer pointer)
        {
            StringBuilder builder = new StringBuilder();
            int physicalOffset = pointer.getPhysicalOffset();
            if (physicalOffset > 0) {
                builder.append("NEXT(");
            }
            else if (physicalOffset < 0) {
                builder.append("PREV(");
            }
            builder.append(pointer.isRunning() ? "RUNNING " : "FINAL ");
            builder.append(pointer.isLast() ? "LAST(" : "FIRST(");
            builder.append(pointer.getLabels().stream()
                    .map(IrLabel::getName)
                    .collect(joining(", ", "{", "}")));
            if (pointer.getLogicalOffset() > 0) {
                builder
                        .append(", ")
                        .append(pointer.getLogicalOffset());
            }
            builder.append(")");
            if (physicalOffset != 0) {
                builder
                        .append(", ")
                        .append(abs(physicalOffset))
                        .append(")");
            }
            return builder.toString();
        }

        private String formatRowsPerMatch(RowsPerMatch rowsPerMatch)
        {
            return switch (rowsPerMatch) {
                case ONE -> "ONE ROW PER MATCH";
                case ALL_SHOW_EMPTY -> "ALL ROWS PER MATCH SHOW EMPTY MATCHES";
                case ALL_OMIT_EMPTY -> "ALL ROWS PER MATCH OMIT EMPTY MATCHES";
                case ALL_WITH_UNMATCHED -> "ALL ROWS PER MATCH WITH UNMATCHED ROWS";
                default -> throw new IllegalArgumentException("unexpected rowsPer match value: " + rowsPerMatch.name());
            };
        }

        private String formatSkipTo(SkipToPosition position, Set labels)
        {
            return switch (position) {
                case PAST_LAST -> "AFTER MATCH SKIP PAST LAST ROW";
                case NEXT -> "AFTER MATCH SKIP TO NEXT ROW";
                case FIRST -> "AFTER MATCH SKIP TO FIRST " + labels;
                case LAST -> "AFTER MATCH SKIP TO LAST " + labels;
            };
        }

        @Override
        public Void visitTopNRanking(TopNRankingNode node, Context context)
        {
            ImmutableMap.Builder descriptor = ImmutableMap.builder();
            descriptor.put("partitionBy", formatSymbols(node.getPartitionBy()));
            descriptor.put("orderBy", formatOrderingScheme(node.getOrderingScheme()));

            NodeRepresentation nodeOutput = addNode(
                    node,
                    "TopNRanking",
                    descriptor
                            .put("limit", String.valueOf(node.getMaxRankingPerPartition()))
                            .put("hash", formatHash(node.getHashSymbol()))
                            .buildOrThrow(),
                    context);

            nodeOutput.appendDetails("%s := %s", anonymizer.anonymize(node.getRankingSymbol()), node.getRankingType());

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitRowNumber(RowNumberNode node, Context context)
        {
            ImmutableMap.Builder descriptor = ImmutableMap.builder();
            if (!node.getPartitionBy().isEmpty()) {
                descriptor.put("partitionBy", formatSymbols(node.getPartitionBy()));
            }

            if (node.getMaxRowCountPerPartition().isPresent()) {
                descriptor.put("limit", String.valueOf(node.getMaxRowCountPerPartition().get()));
            }

            NodeRepresentation nodeOutput = addNode(
                    node,
                    "RowNumber",
                    descriptor.put("hash", formatHash(node.getHashSymbol())).buildOrThrow(),
                    context);
            nodeOutput.appendDetails("%s := %s", anonymizer.anonymize(node.getRowNumberSymbol()), "row_number()");

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitTableScan(TableScanNode node, Context context)
        {
            TableHandle table = node.getTable();
            TableInfo tableInfo = tableInfoSupplier.apply(node);
            NodeRepresentation nodeOutput;
            nodeOutput = addNode(node, "TableScan", ImmutableMap.of("table", anonymizer.anonymize(table, tableInfo)), context);
            printTableScanInfo(nodeOutput, node, tableInfo);
            PlanNodeStats nodeStats = stats.map(s -> s.get(node.getId())).orElse(null);
            if (nodeStats != null) {
                StringBuilder inputDetailBuilder = new StringBuilder();
                ImmutableList.Builder argsBuilder = ImmutableList.builder();
                buildFormatString(
                        inputDetailBuilder,
                        argsBuilder,
                        "Input: %s (%s)",
                        formatPositions(nodeStats.getPlanNodeInputPositions()),
                        nodeStats.getPlanNodeInputDataSize().toString());
                addPhysicalInputStats(nodeStats, inputDetailBuilder, argsBuilder);
                appendDetailsFromBuilder(nodeOutput, inputDetailBuilder, argsBuilder);
            }
            return null;
        }

        @Override
        public Void visitValues(ValuesNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(node, "Values", context);
            if (node.getRows().isEmpty()) {
                for (int i = 0; i < node.getRowCount(); i++) {
                    nodeOutput.appendDetails("()");
                }
                return null;
            }
            List nodeRows = node.getRows().get();
            List rows = nodeRows.stream()
                    .map(row -> {
                        if (row instanceof Row) {
                            return ((Row) row).items().stream()
                                    .map(anonymizer::anonymize)
                                    .collect(joining(", ", "(", ")"));
                        }
                        return anonymizer.anonymize(row);
                    })
                    .limit(11)
                    .collect(toCollection(ArrayList::new));
            if (nodeRows.size() > 11) {
                rows.set(10, "(... %s more rows ...)".formatted(nodeRows.size() - 10));
            }
            for (String row : rows) {
                nodeOutput.appendDetails("%s", row);
            }
            return null;
        }

        @Override
        public Void visitFilter(FilterNode node, Context context)
        {
            return visitScanFilterAndProjectInfo(node, Optional.of(node), Optional.empty(), context);
        }

        @Override
        public Void visitProject(ProjectNode node, Context context)
        {
            if (node.getSource() instanceof FilterNode) {
                return visitScanFilterAndProjectInfo(node, Optional.of((FilterNode) node.getSource()), Optional.of(node), context);
            }

            return visitScanFilterAndProjectInfo(node, Optional.empty(), Optional.of(node), context);
        }

        private Void visitScanFilterAndProjectInfo(
                PlanNode node,
                Optional filterNode,
                Optional projectNode,
                Context context)
        {
            checkState(projectNode.isPresent() || filterNode.isPresent());

            PlanNode sourceNode;
            if (filterNode.isPresent()) {
                sourceNode = filterNode.get().getSource();
            }
            else {
                sourceNode = projectNode.get().getSource();
            }

            Optional scanNode;
            if (sourceNode instanceof TableScanNode) {
                scanNode = Optional.of((TableScanNode) sourceNode);
            }
            else {
                scanNode = Optional.empty();
            }

            String operatorName = "";
            ImmutableMap.Builder descriptor = ImmutableMap.builder();

            if (scanNode.isPresent()) {
                operatorName += "Scan";
                descriptor.put("table", anonymizer.anonymize(scanNode.get().getTable(), tableInfoSupplier.apply(scanNode.get())));
            }

            List dynamicFilters = ImmutableList.of();
            if (filterNode.isPresent()) {
                operatorName += "Filter";
                Expression predicate = filterNode.get().getPredicate();
                DynamicFilters.ExtractResult extractResult = extractDynamicFilters(predicate);
                descriptor.put("filterPredicate", formatFilter(combineConjunctsWithDuplicates(extractResult.getStaticConjuncts())));
                if (!extractResult.getDynamicConjuncts().isEmpty()) {
                    dynamicFilters = extractResult.getDynamicConjuncts();
                    descriptor.put("dynamicFilters", printDynamicFilters(dynamicFilters));
                }
            }

            if (projectNode.isPresent()) {
                operatorName += "Project";
            }

            List allNodes = Stream.of(scanNode, filterNode, projectNode)
                    .filter(Optional::isPresent)
                    .map(Optional::get)
                    .map(PlanNode::getId)
                    .collect(toList());

            NodeRepresentation nodeOutput = addNode(
                    node,
                    operatorName,
                    descriptor.buildOrThrow(),
                    allNodes,
                    ImmutableList.of(sourceNode),
                    ImmutableList.of(),
                    Optional.empty(),
                    context);

            projectNode.ifPresent(value -> printAssignments(nodeOutput, value.getAssignments()));

            if (scanNode.isPresent()) {
                printTableScanInfo(nodeOutput, scanNode.get(), tableInfoSupplier.apply(scanNode.get()));
                PlanNodeStats nodeStats = stats.map(s -> s.get(node.getId())).orElse(null);
                if (nodeStats != null) {
                    // Add to 'details' rather than 'statistics', since these stats are node-specific
                    double filtered = 100.0d * (nodeStats.getPlanNodeInputPositions() - nodeStats.getPlanNodeOutputPositions()) / nodeStats.getPlanNodeInputPositions();
                    StringBuilder inputDetailBuilder = new StringBuilder();
                    ImmutableList.Builder argsBuilder = ImmutableList.builder();
                    buildFormatString(
                            inputDetailBuilder,
                            argsBuilder,
                            "Input: %s (%s), Filtered: %s%%",
                            formatPositions(nodeStats.getPlanNodeInputPositions()),
                            nodeStats.getPlanNodeInputDataSize().toString(),
                            formatDouble(filtered));
                    addPhysicalInputStats(nodeStats, inputDetailBuilder, argsBuilder);
                    appendDetailsFromBuilder(nodeOutput, inputDetailBuilder, argsBuilder);
                }
                List collectedDomainStats = dynamicFilters.stream()
                        .map(DynamicFilters.Descriptor::getId)
                        .map(dynamicFilterDomainStats::get)
                        .filter(Objects::nonNull)
                        .collect(toImmutableList());
                if (!collectedDomainStats.isEmpty()) {
                    nodeOutput.appendDetails("Dynamic filters: ");
                    if (anonymizer instanceof NoOpAnonymizer) {
                        collectedDomainStats.forEach(stats -> nodeOutput.appendDetails(
                                "    - %s, %s, collection time=%s",
                                stats.getDynamicFilterId(),
                                stats.getSimplifiedDomain(),
                                stats.getCollectionDuration().map(Duration::toString).orElse("uncollected")));
                    }
                    else {
                        collectedDomainStats.forEach(stats -> nodeOutput.appendDetails(
                                "    - %s, collection time=%s",
                                stats.getDynamicFilterId(),
                                stats.getCollectionDuration().map(Duration::toString).orElse("uncollected")));
                    }
                }
                return null;
            }

            sourceNode.accept(this, new Context(context.isInitialPlan()));
            return null;
        }

        private static void addPhysicalInputStats(PlanNodeStats nodeStats, StringBuilder inputDetailBuilder, ImmutableList.Builder argsBuilder)
        {
            if (nodeStats.getPlanNodePhysicalInputDataSize().toBytes() > 0) {
                buildFormatString(inputDetailBuilder, argsBuilder, ", Physical input: %s", nodeStats.getPlanNodePhysicalInputDataSize().toString());
                buildFormatString(inputDetailBuilder, argsBuilder, ", Physical input time: %s", nodeStats.getPlanNodePhysicalInputReadTime().convertToMostSuccinctTimeUnit().toString());
            }
            // Some connectors may report physical input time but not physical input data size
            else if (nodeStats.getPlanNodePhysicalInputReadTime().getValue() > 0) {
                buildFormatString(inputDetailBuilder, argsBuilder, ", Physical input time: %s", nodeStats.getPlanNodePhysicalInputReadTime().convertToMostSuccinctTimeUnit().toString());
            }
        }

        @FormatMethod
        private static void buildFormatString(StringBuilder formatBuilder, ImmutableList.Builder argsBuilder, String formatFragment, String... fragmentArgs)
        {
            formatBuilder.append(formatFragment);
            argsBuilder.add(fragmentArgs);
        }

        @SuppressWarnings("FormatStringAnnotation") // verified by building the format and args using #buildFormatString
        private void appendDetailsFromBuilder(NodeRepresentation nodeOutput, StringBuilder inputDetailBuilder, ImmutableList.Builder argsBuilder)
        {
            nodeOutput.appendDetails(inputDetailBuilder.toString(), argsBuilder.build().toArray());
        }

        private String printDynamicFilters(Collection filters)
        {
            return filters.stream()
                    .map(filter -> anonymizer.anonymize(filter.getInput()) + " " + filter.getOperator().getValue() + " #" + filter.getId())
                    .collect(joining(", ", "{", "}"));
        }

        private String printDynamicFilterAssignments(Map filters)
        {
            return filters.entrySet().stream()
                    .map(filter -> anonymizer.anonymize(filter.getValue()) + " -> #" + filter.getKey())
                    .collect(joining(", ", "{", "}"));
        }

        private void printTableScanInfo(NodeRepresentation nodeOutput, TableScanNode node, TableInfo tableInfo)
        {
            TupleDomain predicate = tableInfo.getPredicate();

            if (predicate.isNone()) {
                nodeOutput.appendDetails(":: NONE");
            }
            else {
                // first, print output columns and their constraints
                for (Entry assignment : node.getAssignments().entrySet()) {
                    ColumnHandle column = assignment.getValue();
                    nodeOutput.appendDetails("%s := %s", anonymizer.anonymize(assignment.getKey()), anonymizer.anonymize(column));
                    printConstraint(nodeOutput, column, predicate);
                }

                // then, print constraints for columns that are not in the output
                if (!predicate.isAll()) {
                    Set outputs = ImmutableSet.copyOf(node.getAssignments().values());

                    predicate.getDomains().get()
                            .entrySet().stream()
                            .filter(entry -> !outputs.contains(entry.getKey()))
                            .forEach(entry -> {
                                ColumnHandle column = entry.getKey();
                                nodeOutput.appendDetails("%s", anonymizer.anonymize(column));
                                printConstraint(nodeOutput, column, predicate);
                            });
                }
            }
        }

        @Override
        public Void visitUnnest(UnnestNode node, Context context)
        {
            String name;
            if (node.getJoinType() == INNER) {
                name = "CrossJoin Unnest";
            }
            else {
                name = node.getJoinType().getJoinLabel() + " Unnest";
            }

            List unnestInputs = node.getMappings().stream()
                    .map(UnnestNode.Mapping::getInput)
                    .collect(toImmutableList());

            ImmutableMap.Builder descriptor = ImmutableMap.builder();
            if (!node.getReplicateSymbols().isEmpty()) {
                descriptor.put("replicate", formatOutputs(node.getReplicateSymbols()));
            }
            descriptor.put("unnest", formatOutputs(unnestInputs));
            addNode(node, name, descriptor.buildOrThrow(), context);
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitOutput(OutputNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(
                    node,
                    "Output",
                    ImmutableMap.of("columnNames", formatCollection(Collections2.filter(node.getColumnNames(), this::isNonSpooledColumn), anonymizer::anonymizeColumn)),
                    context);
            for (int i = 0; i < node.getColumnNames().size(); i++) {
                String name = node.getColumnNames().get(i);
                Symbol symbol = node.getOutputSymbols().get(i);

                if (symbol.type().equals(SPOOLING_METADATA_TYPE)) {
                    continue;
                }

                if (!name.equals(symbol.name())) {
                    nodeOutput.appendDetails("%s := %s", anonymizer.anonymizeColumn(name), anonymizer.anonymize(symbol));
                }
            }
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        private boolean isNonSpooledColumn(String columnName)
        {
            return !columnName.equals(SPOOLING_METADATA_COLUMN_NAME);
        }

        @Override
        public Void visitTopN(TopNNode node, Context context)
        {
            addNode(node,
                    format("TopN%s", node.getStep() == TopNNode.Step.PARTIAL ? "Partial" : ""),
                    ImmutableMap.of(
                            "count", String.valueOf(node.getCount()),
                            "orderBy", formatOrderingScheme(node.getOrderingScheme())),
                    context);
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitSort(SortNode node, Context context)
        {
            addNode(node,
                    format("%sSort", node.isPartial() ? "Partial" : ""),
                    ImmutableMap.of("orderBy", formatOrderingScheme(node.getOrderingScheme())),
                    context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitRemoteSource(RemoteSourceNode node, Context context)
        {
            addNode(node,
                    format("Remote%s", node.getOrderingScheme().isPresent() ? "Merge" : "Source"),
                    ImmutableMap.of("sourceFragmentIds", formatCollection(node.getSourceFragmentIds(), Objects::toString)),
                    ImmutableList.of(),
                    ImmutableList.of(),
                    ImmutableList.of(),
                    Optional.empty(),
                    context);

            return null;
        }

        @Override
        public Void visitUnion(UnionNode node, Context context)
        {
            addNode(node, "Union", context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitIntersect(IntersectNode node, Context context)
        {
            addNode(node,
                    "Intersect",
                    ImmutableMap.of("isDistinct", formatBoolean(node.isDistinct())),
                    context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitExcept(ExceptNode node, Context context)
        {
            addNode(node,
                    "Except",
                    ImmutableMap.of("isDistinct", formatBoolean(node.isDistinct())),
                    context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitRefreshMaterializedView(RefreshMaterializedViewNode node, Context context)
        {
            addNode(node,
                    "RefreshMaterializedView",
                    ImmutableMap.of("viewName", anonymizer.anonymize(node.getViewName())),
                    context);
            return null;
        }

        @Override
        public Void visitTableWriter(TableWriterNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(node, "TableWriter", context);
            for (int i = 0; i < node.getColumnNames().size(); i++) {
                String name = node.getColumnNames().get(i);
                Symbol symbol = node.getColumns().get(i);
                nodeOutput.appendDetails("%s := %s", anonymizer.anonymizeColumn(name), anonymizer.anonymize(symbol));
            }

            if (node.getStatisticsAggregation().isPresent()) {
                verify(node.getStatisticsAggregationDescriptor().isPresent(), "statisticsAggregationDescriptor is not present");
                printStatisticAggregations(nodeOutput, node.getStatisticsAggregation().get(), node.getStatisticsAggregationDescriptor().get());
            }

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitStatisticsWriterNode(StatisticsWriterNode node, Context context)
        {
            addNode(node,
                    "StatisticsWriter",
                    ImmutableMap.of("target", anonymizer.anonymize(node.getTarget())),
                    context);
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitTableFinish(TableFinishNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(
                    node,
                    "TableCommit",
                    ImmutableMap.of("target", anonymizer.anonymize(node.getTarget())),
                    context);

            if (node.getStatisticsAggregation().isPresent()) {
                verify(node.getStatisticsAggregationDescriptor().isPresent(), "statisticsAggregationDescriptor is not present");
                printStatisticAggregations(nodeOutput, node.getStatisticsAggregation().get(), node.getStatisticsAggregationDescriptor().get());
            }

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        private void printStatisticAggregations(NodeRepresentation nodeOutput, StatisticAggregations aggregations, StatisticAggregationsDescriptor descriptor)
        {
            nodeOutput.appendDetails("Collected statistics:");
            printStatisticAggregationsInfo(nodeOutput, descriptor.getTableStatistics(), descriptor.getColumnStatistics(), aggregations.getAggregations());
            nodeOutput.appendDetails("%sgrouped by => [%s]", indentString(1), getStatisticGroupingSetsInfo(descriptor.getGrouping()));
        }

        private String getStatisticGroupingSetsInfo(Map columnMappings)
        {
            return columnMappings.entrySet().stream()
                    .map(entry -> format("%s := %s", anonymizer.anonymize(entry.getValue()), anonymizer.anonymizeColumn(entry.getKey())))
                    .collect(joining(", "));
        }

        private void printStatisticAggregationsInfo(
                NodeRepresentation nodeOutput,
                Map tableStatistics,
                Map columnStatistics,
                Map aggregations)
        {
            nodeOutput.appendDetails("aggregations =>");
            for (Entry tableStatistic : tableStatistics.entrySet()) {
                nodeOutput.appendDetails("%s%s => [%s := %s]",
                        indentString(1),
                        anonymizer.anonymize(tableStatistic.getValue()),
                        tableStatistic.getKey(),
                        formatAggregation(anonymizer, aggregations.get(tableStatistic.getValue())));
            }

            for (Entry columnStatistic : columnStatistics.entrySet()) {
                String aggregationName;
                if (columnStatistic.getKey().getStatisticTypeIfPresent().isPresent()) {
                    aggregationName = columnStatistic.getKey().getStatisticType().name();
                }
                else {
                    FunctionName aggregation = columnStatistic.getKey().getAggregation();
                    if (aggregation.getCatalogSchema().isPresent()) {
                        aggregationName = aggregation.getCatalogSchema().get() + "." + aggregation.getName();
                    }
                    else {
                        aggregationName = aggregation.getName();
                    }
                }
                nodeOutput.appendDetails(
                        "%s%s[%s] => [%s := %s]",
                        indentString(1),
                        aggregationName,
                        anonymizer.anonymizeColumn(columnStatistic.getKey().getColumnName()),
                        anonymizer.anonymize(columnStatistic.getValue()),
                        formatAggregation(anonymizer, aggregations.get(columnStatistic.getValue())));
            }
        }

        @Override
        public Void visitSample(SampleNode node, Context context)
        {
            addNode(node,
                    "Sample",
                    ImmutableMap.of(
                            "type", node.getSampleType().name(),
                            "ratio", String.valueOf(node.getSampleRatio())),
                    context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitExchange(ExchangeNode node, Context context)
        {
            if (node.getOrderingScheme().isPresent()) {
                addNode(node,
                        format("%sMerge", UPPER_UNDERSCORE.to(CaseFormat.UPPER_CAMEL, node.getScope().toString())),
                        ImmutableMap.of("orderBy", formatOrderingScheme(node.getOrderingScheme().get())),
                        context);
            }
            else if (node.getScope() == Scope.LOCAL) {
                addNode(node,
                        "LocalExchange",
                        ImmutableMap.of(
                                "partitioning", anonymizer.anonymize(node.getPartitioningScheme().getPartitioning().getHandle()),
                                "isReplicateNullsAndAny", formatBoolean(node.getPartitioningScheme().isReplicateNullsAndAny()),
                                "hashColumn", formatHash(node.getPartitioningScheme().getHashColumn()),
                                "arguments", formatCollection(node.getPartitioningScheme().getPartitioning().getArguments(), anonymizer::anonymize)),
                        context);
            }
            else {
                addNode(node,
                        format("%sExchange", UPPER_UNDERSCORE.to(CaseFormat.UPPER_CAMEL, node.getScope().toString())),
                        ImmutableMap.of(
                                "partitionCount", node.getPartitioningScheme().getPartitionCount().map(String::valueOf).orElse(""),
                                "scaleWriters", formatBoolean(node.getPartitioningScheme().getPartitioning().getHandle().isScaleWriters()),
                                "type", node.getType().name(),
                                "isReplicateNullsAndAny", formatBoolean(node.getPartitioningScheme().isReplicateNullsAndAny()),
                                "hashColumn", formatHash(node.getPartitioningScheme().getHashColumn())),
                        context);
            }
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitAdaptivePlanNode(AdaptivePlanNode node, Context context)
        {
            addNode(
                    node,
                    "AdaptivePlan",
                    ImmutableMap.of(),
                    ImmutableList.of(node.getId()),
                    ImmutableList.of(node.getCurrentPlan()),
                    ImmutableList.of(node.getInitialPlan()),
                    Optional.empty(),
                    context);
            node.getInitialPlan().accept(this, new Context("Initial Plan", true));
            node.getCurrentPlan().accept(this, new Context("Current Plan", false));
            return null;
        }

        @Override
        public Void visitTableExecute(TableExecuteNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(node, "TableExecute", context);
            for (int i = 0; i < node.getColumnNames().size(); i++) {
                String name = node.getColumnNames().get(i);
                Symbol symbol = node.getColumns().get(i);
                nodeOutput.appendDetails("%s := %s", anonymizer.anonymizeColumn(name), anonymizer.anonymize(symbol));
            }

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitSimpleTableExecuteNode(SimpleTableExecuteNode node, Context context)
        {
            addNode(node,
                    "SimpleTableExecute",
                    ImmutableMap.of("table", anonymizer.anonymize(node.getExecuteHandle())),
                    context);
            return null;
        }

        @Override
        public Void visitMergeWriter(MergeWriterNode node, Context context)
        {
            addNode(node,
                    "MergeWriter",
                    ImmutableMap.of("table", anonymizer.anonymize(node.getTarget())),
                    context);
            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitMergeProcessor(MergeProcessorNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(node, "MergeProcessor", context);
            nodeOutput.appendDetails("target: %s", anonymizer.anonymize(node.getTarget()));
            nodeOutput.appendDetails("merge row column: %s", anonymizer.anonymize(node.getMergeRowSymbol()));
            nodeOutput.appendDetails("row id column: %s", anonymizer.anonymize(node.getRowIdSymbol()));
            nodeOutput.appendDetails("redistribution columns: %s", anonymize(node.getRedistributionColumnSymbols()));
            nodeOutput.appendDetails("data columns: %s", anonymize(node.getDataColumnSymbols()));

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitTableDelete(TableDeleteNode node, Context context)
        {
            addNode(node,
                    "TableDelete",
                    ImmutableMap.of("target", anonymizer.anonymize(node.getTarget())),
                    context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitTableUpdate(TableUpdateNode node, Context context)
        {
            addNode(node,
                    "TableUpdate",
                    ImmutableMap.of("target", anonymizer.anonymize(node.getTarget())),
                    context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitEnforceSingleRow(EnforceSingleRowNode node, Context context)
        {
            addNode(node, "EnforceSingleRow", context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitAssignUniqueId(AssignUniqueId node, Context context)
        {
            addNode(node, "AssignUniqueId", context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitGroupReference(GroupReference node, Context context)
        {
            addNode(node,
                    "GroupReference",
                    ImmutableMap.of("groupId", String.valueOf(node.getGroupId())),
                    ImmutableList.of(),
                    Optional.empty(),
                    context);

            return null;
        }

        @Override
        public Void visitApply(ApplyNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(
                    node,
                    "Apply",
                    ImmutableMap.of("correlation", formatSymbols(node.getCorrelation())),
                    context);
            printAssignments(nodeOutput, node.getSubqueryAssignments());

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitCorrelatedJoin(CorrelatedJoinNode node, Context context)
        {
            addNode(node,
                    "CorrelatedJoin",
                    ImmutableMap.of(
                            "correlation", formatSymbols(node.getCorrelation()),
                            "filter", formatFilter(node.getFilter())),
                    context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        public Void visitTableFunction(TableFunctionNode node, Context context)
        {
            NodeRepresentation nodeOutput = addNode(
                    node,
                    "TableFunction",
                    ImmutableMap.of("name", node.getName()),
                    context);

            if (!node.getArguments().isEmpty()) {
                nodeOutput.appendDetails("Arguments:");

                Map tableArguments = node.getTableArgumentProperties().stream()
                        .collect(toImmutableMap(TableArgumentProperties::argumentName, identity()));

                node.getArguments().entrySet()
                        .forEach(entry -> nodeOutput.appendDetails("%s", formatArgument(entry.getKey(), entry.getValue(), tableArguments)));

                if (!node.getCopartitioningLists().isEmpty()) {
                    nodeOutput.appendDetails("%s", node.getCopartitioningLists().stream()
                            .map(list -> list.stream().collect(joining(", ", "(", ")")))
                            .collect(joining(", ", "Co-partition: [", "]")));
                }
            }

            for (int i = 0; i < node.getSources().size(); i++) {
                node.getSources().get(i).accept(this, new Context(node.getTableArgumentProperties().get(i).argumentName(), context.isInitialPlan()));
            }

            return null;
        }

        private String formatArgument(String argumentName, Argument argument, Map tableArguments)
        {
            if (argument instanceof ScalarArgument scalarArgument) {
                return formatScalarArgument(argumentName, scalarArgument);
            }
            if (argument instanceof DescriptorArgument descriptorArgument) {
                return formatDescriptorArgument(argumentName, descriptorArgument);
            }
            else {
                TableArgumentProperties argumentProperties = tableArguments.get(argumentName);
                return formatTableArgument(argumentName, argumentProperties);
            }
        }

        private String formatScalarArgument(String argumentName, ScalarArgument argument)
        {
            return format(
                    "%s => ScalarArgument{type=%s, value=%s}",
                    argumentName,
                    argument.getType().getDisplayName(),
                    anonymizer.anonymize(
                            argument.getType(),
                            valuePrinter.castToVarchar(argument.getType(), argument.getValue())));
        }

        private String formatDescriptorArgument(String argumentName, DescriptorArgument argument)
        {
            String descriptor;
            if (argument.equals(NULL_DESCRIPTOR)) {
                descriptor = "NULL";
            }
            else {
                descriptor = argument.getDescriptor().orElseThrow().getFields().stream()
                        .map(field -> anonymizer.anonymizeColumn(field.getName().orElseThrow()) + field.getType().map(type -> " " + type.getDisplayName()).orElse(""))
                        .collect(joining(", ", "(", ")"));
            }
            return format("%s => DescriptorArgument{%s}", argumentName, descriptor);
        }

        private String formatTableArgument(String argumentName, TableArgumentProperties argumentProperties)
        {
            StringBuilder properties = new StringBuilder();
            if (argumentProperties.rowSemantics()) {
                properties.append("row semantics");
            }
            argumentProperties.specification().ifPresent(specification -> {
                properties
                        .append("partition by: [")
                        .append(Joiner.on(", ").join(anonymize(specification.partitionBy())))
                        .append("]");
                specification.orderingScheme().ifPresent(orderingScheme -> {
                    properties
                            .append(", order by: ")
                            .append(formatOrderingScheme(orderingScheme));
                });
            });
            properties.append("required columns: [")
                    .append(Joiner.on(", ").join(anonymize(argumentProperties.requiredColumns())))
                    .append("]");
            if (argumentProperties.pruneWhenEmpty()) {
                properties.append(", prune when empty");
            }
            if (argumentProperties.passThroughSpecification().declaredAsPassThrough()) {
                properties.append(", pass through columns");
            }
            return format("%s => TableArgument{%s}", argumentName, properties);
        }

        @Override
        public Void visitTableFunctionProcessor(TableFunctionProcessorNode node, Context context)
        {
            ImmutableMap.Builder descriptor = ImmutableMap.builder();

            descriptor.put("name", node.getName());

            descriptor.put("properOutputs", format("[%s]", Joiner.on(", ").join(anonymize(node.getProperOutputs()))));

            node.getSpecification().ifPresent(specification -> {
                if (!specification.partitionBy().isEmpty()) {
                    List prePartitioned = specification.partitionBy().stream()
                            .filter(node.getPrePartitioned()::contains)
                            .collect(toImmutableList());

                    List notPrePartitioned = specification.partitionBy().stream()
                            .filter(column -> !node.getPrePartitioned().contains(column))
                            .collect(toImmutableList());

                    StringBuilder builder = new StringBuilder();
                    if (!prePartitioned.isEmpty()) {
                        builder.append(anonymize(prePartitioned).stream()
                                .collect(joining(", ", "<", ">")));
                        if (!notPrePartitioned.isEmpty()) {
                            builder.append(", ");
                        }
                    }
                    if (!notPrePartitioned.isEmpty()) {
                        builder.append(Joiner.on(", ").join(anonymize(notPrePartitioned)));
                    }
                    descriptor.put("partitionBy", format("[%s]", builder));
                }
                specification.orderingScheme().ifPresent(orderingScheme -> descriptor.put("orderBy", formatOrderingScheme(orderingScheme, node.getPreSorted())));
            });

            addNode(node, "TableFunctionProcessor", descriptor.put("hash", formatHash(node.getHashSymbol())).buildOrThrow(), context);

            return processChildren(node, new Context(context.isInitialPlan()));
        }

        @Override
        protected Void visitPlan(PlanNode node, Context context)
        {
            throw new UnsupportedOperationException("not yet implemented: " + node.getClass().getName());
        }

        private Void processChildren(PlanNode node, Context context)
        {
            for (PlanNode child : node.getSources()) {
                child.accept(this, context);
            }

            return null;
        }

        private void printAssignments(NodeRepresentation nodeOutput, Assignments assignments)
        {
            for (Entry entry : assignments.getMap().entrySet()) {
                if (entry.getValue() instanceof Reference && ((Reference) entry.getValue()).name().equals(entry.getKey().name())) {
                    // skip identity assignments
                    continue;
                }
                nodeOutput.appendDetails("%s := %s", anonymizer.anonymize(entry.getKey()), anonymizer.anonymize(entry.getValue()));
            }
        }

        private void printAssignments(NodeRepresentation nodeOutput, Map assignments)
        {
            for (Entry entry : assignments.entrySet()) {
                String assignment = switch (entry.getValue()) {
                    case ApplyNode.In in -> "%s IN %s".formatted(anonymizer.anonymize(in.value()), anonymizer.anonymize(in.reference()));
                    case ApplyNode.Exists unused -> "EXISTS";
                    case ApplyNode.QuantifiedComparison comparison -> "%s %s %s %s".formatted(
                            anonymizer.anonymize(comparison.value()),
                            switch (comparison.operator()) {
                                case EQUAL -> "=";
                                case NOT_EQUAL -> "<>";
                                case LESS_THAN -> "<";
                                case LESS_THAN_OR_EQUAL -> "<=";
                                case GREATER_THAN -> ">";
                                case GREATER_THAN_OR_EQUAL -> ">=";
                            },
                            comparison.quantifier(),
                            anonymizer.anonymize(comparison.reference()));
                };

                nodeOutput.appendDetails("%s := %s", anonymizer.anonymize(entry.getKey()), assignment);
            }
        }

        private void printConstraint(NodeRepresentation nodeOutput, ColumnHandle column, TupleDomain constraint)
        {
            checkArgument(!constraint.isNone());
            Map domains = constraint.getDomains().get();
            if (domains.containsKey(column)) {
                nodeOutput.appendDetails("    :: %s", formatDomain(domains.get(column).simplify()));
            }
        }

        private String formatDomain(Domain domain)
        {
            ImmutableList.Builder parts = ImmutableList.builder();

            if (domain.isNullAllowed()) {
                parts.add("NULL");
            }

            Type type = domain.getType();

            domain.getValues().getValuesProcessor().consume(
                    ranges -> {
                        for (Range range : ranges.getOrderedRanges()) {
                            StringBuilder builder = new StringBuilder();
                            if (range.isSingleValue()) {
                                String value = anonymizer.anonymize(type, valuePrinter.castToVarchar(type, range.getSingleValue()));
                                builder.append('[').append(value).append(']');
                            }
                            else {
                                builder.append(range.isLowInclusive() ? '[' : '(');

                                if (range.isLowUnbounded()) {
                                    builder.append("");
                                }
                                else {
                                    builder.append(anonymizer.anonymize(type, valuePrinter.castToVarchar(type, range.getLowBoundedValue())));
                                }

                                builder.append(", ");

                                if (range.isHighUnbounded()) {
                                    builder.append("");
                                }
                                else {
                                    builder.append(anonymizer.anonymize(type, valuePrinter.castToVarchar(type, range.getHighBoundedValue())));
                                }

                                builder.append(range.isHighInclusive() ? ']' : ')');
                            }
                            parts.add(builder.toString());
                        }
                    },
                    discreteValues -> discreteValues.getValues().stream()
                            .map(value -> anonymizer.anonymize(type, valuePrinter.castToVarchar(type, value)))
                            .sorted() // Sort so the values will be printed in predictable order
                            .forEach(parts::add),
                    allOrNone -> {
                        if (allOrNone.isAll()) {
                            parts.add("ALL VALUES");
                        }
                    });

            return "[" + Joiner.on(", ").join(parts.build()) + "]";
        }

        private String formatFilter(Expression filter)
        {
            return filter.equals(TRUE) ? "" : anonymizer.anonymize(filter);
        }

        private String formatBoolean(boolean value)
        {
            return value ? "true" : "";
        }

        private String formatOrderingScheme(OrderingScheme orderingScheme, int preSortedOrderPrefix)
        {
            List orderBy = Stream.concat(
                            orderingScheme.orderBy().stream()
                                    .limit(preSortedOrderPrefix)
                                    .map(symbol -> "<" + anonymizer.anonymize(symbol) + " " + orderingScheme.ordering(symbol) + ">"),
                            orderingScheme.orderBy().stream()
                                    .skip(preSortedOrderPrefix)
                                    .map(symbol -> anonymizer.anonymize(symbol) + " " + orderingScheme.ordering(symbol)))
                    .collect(toImmutableList());
            return formatCollection(orderBy, Objects::toString);
        }

        private String formatOrderingScheme(OrderingScheme orderingScheme)
        {
            return formatCollection(orderingScheme.orderBy(), input -> anonymizer.anonymize(input) + " " + orderingScheme.ordering(input));
        }

        @SafeVarargs
        private String formatHash(Optional... hashes)
        {
            List symbols = stream(hashes)
                    .filter(Optional::isPresent)
                    .map(Optional::get)
                    .collect(toImmutableList());
            return formatSymbols(symbols);
        }

        private String formatSymbols(Collection symbols)
        {
            return formatCollection(symbols, anonymizer::anonymize);
        }

        private List anonymize(Collection symbols)
        {
            return symbols.stream()
                    .map(anonymizer::anonymize)
                    .collect(toImmutableList());
        }

        private List anonymizeExpressions(List expressions)
        {
            return expressions.stream()
                    .map(anonymizer::anonymize)
                    .collect(toImmutableList());
        }

        private String formatOutputs(Iterable outputs)
        {
            return Streams.stream(outputs)
                    .map(input -> anonymizer.anonymize(input) + ":" + input.type().getDisplayName())
                    .collect(joining(", ", "[", "]"));
        }

        public NodeRepresentation addNode(PlanNode node, String name, Context context)
        {
            return addNode(node, name, ImmutableMap.of(), context);
        }

        public NodeRepresentation addNode(PlanNode node, String name, Map descriptor, Context context)
        {
            return addNode(node, name, descriptor, node.getSources(), Optional.empty(), context);
        }

        public NodeRepresentation addNode(PlanNode node, String name, Map descriptor, Optional reorderJoinStatsAndCost, Context context)
        {
            return addNode(node, name, descriptor, node.getSources(), reorderJoinStatsAndCost, context);
        }

        public NodeRepresentation addNode(PlanNode node, String name, Map descriptor, List children, Optional reorderJoinStatsAndCost, Context context)
        {
            return addNode(node, name, descriptor, ImmutableList.of(node.getId()), children, ImmutableList.of(), reorderJoinStatsAndCost, context);
        }

        public NodeRepresentation addNode(
                PlanNode rootNode,
                String name,
                Map descriptor,
                List allNodes,
                List children,
                List initialChildren,
                Optional reorderJoinStatsAndCost,
                Context context)
        {
            List childrenIds = children.stream().map(PlanNode::getId).collect(toImmutableList());
            List initialChildrenIds = initialChildren.stream().map(PlanNode::getId).collect(toImmutableList());
            List estimatedStats = allNodes.stream()
                    .map(nodeId -> estimatedStatsAndCosts.getStats().getOrDefault(nodeId, PlanNodeStatsEstimate.unknown()))
                    .collect(toList());
            List estimatedCosts = allNodes.stream()
                    .map(nodeId -> estimatedStatsAndCosts.getCosts().getOrDefault(nodeId, PlanCostEstimate.unknown()))
                    .collect(toList());
            name = context.tag()
                    .map(tagName -> format("[%s] ", tagName))
                    .orElse("") + name;

            NodeRepresentation nodeOutput = new NodeRepresentation(
                    rootNode.getId(),
                    name,
                    rootNode.getClass().getSimpleName(),
                    descriptor,
                    rootNode.getOutputSymbols().stream()
                            .map(s -> new Symbol(s.type(), anonymizer.anonymize(s)))
                            .collect(toImmutableList()),
                    stats.map(s -> s.get(rootNode.getId())),
                    estimatedStats,
                    estimatedCosts,
                    reorderJoinStatsAndCost,
                    childrenIds,
                    initialChildrenIds);

            if (context.isInitialPlan()) {
                representation.addInitialNode(nodeOutput);
            }
            else {
                representation.addNode(nodeOutput);
            }
            return nodeOutput;
        }
    }

    private static  String formatCollection(Collection collection, Function formatter)
    {
        return collection.stream()
                .map(formatter)
                .collect(joining(", ", "[", "]"));
    }

    public static String formatAggregation(Anonymizer anonymizer, Aggregation aggregation)
    {
        StringBuilder builder = new StringBuilder();
        List anonymizedArguments = aggregation.getArguments().stream()
                .map(anonymizer::anonymize)
                .collect(toImmutableList());
        String arguments = Joiner.on(", ").join(anonymizedArguments);
        if (aggregation.getArguments().isEmpty() && COUNT_NAME.equals(aggregation.getResolvedFunction().signature().getName())) {
            arguments = "*";
        }
        if (aggregation.isDistinct()) {
            arguments = "DISTINCT " + arguments;
        }

        builder.append(formatFunctionName(aggregation.getResolvedFunction()))
                .append('(').append(arguments);

        aggregation.getOrderingScheme().ifPresent(orderingScheme -> builder.append(' ').append(orderingScheme.orderBy().stream()
                .map(input -> anonymizer.anonymize(input) + " " + orderingScheme.ordering(input))
                .collect(joining(", "))));

        builder.append(')');

        aggregation.getFilter()
                .map(anonymizer::anonymize)
                .ifPresent(expression -> builder.append(" FILTER (WHERE ").append(expression).append(")"));

        aggregation.getMask()
                .map(anonymizer::anonymize)
                .ifPresent(symbol -> builder.append(" (mask = ").append(symbol).append(")"));
        return builder.toString();
    }

    private static String formatFunctionName(ResolvedFunction function)
    {
        CatalogSchemaFunctionName name = function.signature().getName();
        if (isInlineFunction(name) || isBuiltinFunctionName(name)) {
            return name.getFunctionName();
        }
        return name.toString();
    }

    private record Context(Optional tag, boolean isInitialPlan)
    {
        public Context(boolean isInitialPlan)
        {
            this(Optional.empty(), isInitialPlan);
        }

        public Context(String tag, boolean isInitialPlan)
        {
            this(Optional.of(tag), isInitialPlan);
        }

        private Context
        {
            requireNonNull(tag, "tag is null");
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy