ExecutionPlan.java

package li.selman.persistencetest.plan;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

/**
 * Stable, derived facts about a query's execution plan - never the raw plan text, which changes across
 * harmless Postgres version/statistics differences.
 *
 * @param root the plan's root node.
 */
public record ExecutionPlan(PlanNode root) {

    /** Whether any node in the plan uses an index scan of any kind (index, index-only, or bitmap). */
    public boolean usesIndex() {
        return anyNode(node -> node.nodeType().contains("Index"));
    }

    /** Whether any node in the plan uses the index named {@code indexName}. */
    public boolean usesIndex(String indexName) {
        return anyNode(node -> indexName.equals(node.indexName()));
    }

    /** Whether no node in the plan is a sequential scan. */
    public boolean avoidsSequentialScan() {
        return !anyNode(node -> "Seq Scan".equals(node.nodeType()));
    }

    /** Whether the root node's planner row estimate is less than {@code rows}. */
    public boolean estimatedRowsLessThan(long rows) {
        return root.planRows() < rows;
    }

    /** Whether any node uses an index scan against {@code table} (case-insensitive). */
    public boolean usesAnyIndexOn(String table) {
        return anyNode(node -> node.nodeType().contains("Index") && table.equalsIgnoreCase(node.relationName()));
    }

    /** Every node in the plan, in depth-first order, root first. */
    public List<PlanNode> allNodes() {
        List<PlanNode> nodes = new ArrayList<>();
        collect(root, nodes);
        return List.copyOf(nodes);
    }

    private boolean anyNode(Predicate<PlanNode> predicate) {
        return allNodes().stream().anyMatch(predicate);
    }

    private static void collect(PlanNode node, List<PlanNode> into) {
        into.add(node);
        for (PlanNode child : node.children()) {
            collect(child, into);
        }
    }
}