github-actions[bot] commented on code in PR #68499:
URL: https://github.com/apache/doris/pull/68499#discussion_r4367514115


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/spm/placeholder/SPMPlaceholderBuilder.java:
##########
@@ -0,0 +1,1077 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.spm.placeholder;
+
+import org.apache.doris.nereids.spm.SPMPlanTreeSupport;
+import org.apache.doris.nereids.trees.expressions.And;
+import org.apache.doris.nereids.trees.expressions.CaseWhen;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.InPredicate;
+import org.apache.doris.nereids.trees.expressions.InSubquery;
+import org.apache.doris.nereids.trees.expressions.Like;
+import org.apache.doris.nereids.trees.expressions.Not;
+import org.apache.doris.nereids.trees.expressions.Or;
+import org.apache.doris.nereids.trees.expressions.SubqueryExpr;
+import org.apache.doris.nereids.trees.expressions.WhenClause;
+import org.apache.doris.nereids.trees.expressions.functions.Function;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.ElementAt;
+import org.apache.doris.nereids.trees.expressions.literal.ArrayLiteral;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
+import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
+import org.apache.doris.nereids.trees.expressions.visitor.ExpressionVisitor;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.nereids.types.ArrayType;
+import org.apache.doris.nereids.types.BooleanType;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+
+/**
+ * SPMPlaceholderBuilder
+ *
+ * Replaces the literal constants in a query with
+ * SPM placeholder functions so that one baseline can match structurally 
identical
+ * queries. Core replacement rules:
+ *
+ * - Literal -> SpmConstVar (scalar placeholder with an auto-increment id)
+ * - "IN (constant list)" -> InPredicate(expr, [SpmConstList(id, ...)]) (list 
placeholder)
+ *
+ * The auto-increment id guarantees that placeholders in the same position of 
bindSql and
+ * planSql share the same id (in path A auto-capture both come from the same 
already
+ * parameterized tree, so the ids naturally match).
+ *
+ * Placeholder deduplication follows: a literal
+ * reuses an existing placeholder id only when the literal value (Step 1), its 
parent
+ * expression structure (Step 2, PlaceholderExpr.matches) AND its position 
inside that
+ * parent (child index) are equal. This is important for matching correctness: 
with
+ * value-only dedup, "a = 1 AND b = 1" would assign one id to both literals 
and a user
+ * query "a = 5 AND b = 7" could never match; without the child index, the two 
operands
+ * of "1 + 1" would share one id and a similar query "2 + 1" could never match 
either.
+ */
+public class SPMPlaceholderBuilder extends ExpressionVisitor<Expression, 
Expression>
+        implements SPMPlanTreeSupport.ExprTransform {
+
+    /** Auto-increment placeholder id counter (starts at 1). */
+    private long nextId = 1;
+
+    /** Placeholder record list. */
+    private final List<PlaceholderExpr> placeholderExprs = new ArrayList<>();
+
+    /**
+     * Optional GROUP BY mirror lookup, installed by 
SPMPlanTreeSupport.TreeTransformer for
+     * one clause window (see setGroupByMirrorLookup): maps an expression's
+     * pre-parameterization text to the ALREADY-transformed GROUP BY 
expression that must
+     * be reused - also for NESTED subexpressions (e.g. the substr(...) inside
+     * "HAVING substr(...) = 'x'"), because the analyzer resolves such a clause
+     * expression-wise and demands literal expression equality with the GROUP 
BY under
+     * ONLY_FULL_GROUP_BY. Null outside the window.
+     */
+    private java.util.function.Function<String, Expression> 
groupByMirrorLookup;
+
+    /**
+     * DFS stack of child positions: the top is the index of the expression 
currently
+     * being visited inside its direct parent (empty = parent-less root). Two 
literals
+     * under the same parent (e.g. the operands of "1 + 1") are different 
positions and
+     * must never reuse one placeholder id.
+     */
+    private final Deque<Integer> childIndexStack = new ArrayDeque<>();
+
+    /**
+     * Query-block identity: incremented when entering a nested query block 
(the plan of
+     * a subquery expression). Two literals in DIFFERENT blocks never share a 
placeholder
+     * id even when value / parent / position coincide: in
+     * SELECT * FROM t WHERE a=1 AND EXISTS (SELECT 1 FROM u WHERE a=1) the 
two a=1
+     * literals used to reuse one id, and a user query with outer a=2 / inner 
a=3 was
+     * rejected (one id must resolve to ONE value), so the baseline could 
never hit.
+     * The counter restarts per tree (see startNewTree): bind and plan trees 
traverse the
+     * same structure in the same order, so corresponding blocks keep 
corresponding
+     * numbers and the cross-tree id alignment is preserved.
+     */
+    private long nextBlockId = 1;
+    private long currentBlockId = 0;
+
+    /**
+     * Enclosing query-block ids (see enterQueryBlock()): a nested block's id 
is
+     * restored on exit, so SIBLING derived tables / CTE bodies get distinct 
ids while the
+     * enclosing block numbering resumes - the same discipline the 
subquery-expression
+     * blocks already followed.
+     */
+    private final Deque<Long> blockIdStack = new ArrayDeque<>();
+
+    /**
+     * Projection ITEM positions of the SELECT-list slots currently being 
visited (see
+     * enterProjectionItem(int)): SELECT 1 AS x, 1 AS y must give its two
+     * literals different placeholder ids although value, parent structure and 
child
+     * position coincide (see PlaceholderExpr#matches).
+     */
+    private final Deque<Integer> projectItemStack = new ArrayDeque<>();
+
+    /**
+     * Ordinal of the expression ROOT currently being transformed, counted per 
query block in
+     * walk order (see beginExpressionRoot): two equal literals in DIFFERENT 
roots of one
+     * block (the ON clauses of two joins, two conjuncts of one filter, ...) 
used to share one
+     * placeholder id although they are independent slots - a query that 
varies them
+     * separately (1/1 -> 2/3) was then rejected by recordPlaceholderValue and 
the saved
+     * baseline could never be used (the reviewer's round-58 finding). The 
counting restarts
+     * per tree run AND per block, so the bind and plan trees of one baseline 
traverse the
+     * same structure in the same order and corresponding roots keep 
corresponding ordinals.
+     */
+    private long nextRootOrdinal = 0;
+    private long currentRootOrdinal = -1;
+
+    /**
+     * Depth of an all-NULL CONDITIONAL (CASE / IF / COALESCE) currently 
visited in a Boolean
+     * context (see markerPayload): such a node has NullType and the analyzer 
replaces the whole
+     * operand with a typed constant, so its branch NULLs are parameterized 
BOOLEAN.
+     */
+    private int booleanConditionalDepth;
+
+    /**
+     * Whether a bare integer GROUP BY ordinal references a SELECT item that 
carries a
+     * placeholder (see SPMPlanTreeSupport#visitLogicalAggregate). The ordinal 
itself stays
+     * CONCRETE, but the analyzer resolves it to that item's (parameterized) 
expression, and
+     * the frozen rendering of a placeholder group key is NOT faithful: the 
capture optimizer
+     * rewrote `GROUP BY &lt;placeholder&gt;` into a child column, so a frozen 
replay of
+     * `SELECT 1 AS c, count(*) ... GROUP BY 1` grouped by `k` while the 
user's own query
+     * grouped by the constant. The CREATE keeps the user planSql then and 
replays the
+     * parameterized tree, whose analysis resolves the ordinal in the caller's 
own query.
+     */
+    private boolean parameterizedGroupByOrdinalTarget;
+
+    /** The enclosing block's root-ordinal counter, restored by 
exitQueryBlock(). */
+    private final Deque<Long> rootOrdinalStack = new ArrayDeque<>();
+
+    /** The enclosing root's ordinal, restored by exitQueryBlock() (see 
enterQueryBlock). */
+    private final Deque<Long> currentRootOrdinalStack = new ArrayDeque<>();
+
+    /** Installs / clears the GROUP BY mirror lookup window (null = disabled). 
*/
+    public void setGroupByMirrorLookup(java.util.function.Function<String, 
Expression> lookup) {
+        this.groupByMirrorLookup = lookup;
+    }
+
+    /** See parameterizedGroupByOrdinalTarget. */
+    public boolean hasParameterizedGroupByOrdinalTarget() {
+        return parameterizedGroupByOrdinalTarget;
+    }
+
+    /** Marks one bare integer GROUP BY ordinal whose SELECT item carries 
placeholders. */
+    public void noteParameterizedGroupByOrdinalTarget() {
+        this.parameterizedGroupByOrdinalTarget = true;
+    }
+
+    /**
+     * Parameterizes a list of expressions with this single shared builder 
(entry point).
+     *
+     * Every expression is parameterized in order with the same builder, so 
placeholder
+     * ids stay globally unique and position-aligned across all query blocks 
(the topmost
+     * block's literals get the lowest ids, then derived tables / CTE bodies / 
nested
+     * blocks, in DFS order). This is what keeps the bind expressions of a 
multi-block
+     * baseline matched against the user query block by block.
+     *
+     * @param exprs the raw expressions to parameterize (one per query block)
+     * @return the parameterized expressions (containing SpmConstVar / 
SpmConstList)
+     */
+    public List<Expression> parameterizeExpressions(List<Expression> exprs) {
+        List<Expression> parameterized = new ArrayList<>(exprs.size());
+        for (Expression expr : exprs) {
+            // each element is one query block of the same tree: its own block 
scope
+            startNewTree();
+            beginExpressionRoot();
+            parameterized.add(expr.accept(this, null));
+        }
+        return parameterized;
+    }
+
+    /** Starts one expression ROOT of the current block (see nextRootOrdinal). 
*/
+    private void beginExpressionRoot() {
+        currentRootOrdinal = nextRootOrdinal++;
+    }
+
+    /** Restarts the per-tree query-block numbering (a tree's root block is 
0). */
+    public void startNewTree() {
+        nextBlockId = 1;
+        currentBlockId = 0;
+        blockIdStack.clear();
+        projectItemStack.clear();
+        // one tree = one root-ordinal run: corresponding roots of the bind 
tree and the
+        // (separately parsed) plan tree keep corresponding ordinals
+        nextRootOrdinal = 0;
+        currentRootOrdinal = -1;
+        rootOrdinalStack.clear();
+        currentRootOrdinalStack.clear();
+    }
+
+    /**
+     * The whole-tree parameterization transform: the builder IS an
+     * SPMPlanTreeSupport.ExprTransform, so the plan walk can hand it the query
+     * BLOCK and projection ITEM scopes of the expressions it visits (see
+     * SPMPlanTreeSupport.ExprTransform#enterQueryBlock()). Passing a bare 
lambda
+     * instead would lose those scopes and merge independent literals into one 
placeholder
+     * id.
+     */
+    @Override
+    public Expression apply(Expression expr) {
+        beginExpressionRoot();
+        return expr.accept(this, null);
+    }
+
+    /**
+     * Transform of a SUBQUERY expression's own plan tree: every expression 
the walk hands
+     * over is a block ROOT (the child-position sentinel -1), and the 
query-block /
+     * projection-item scope hooks are forwarded to the enclosing builder so 
nested
+     * derived tables keep their own block ids.
+     */
+    private final class SubqueryBlockTransform implements 
SPMPlanTreeSupport.ExprTransform {
+        @Override
+        public Expression apply(Expression expr) {
+            beginExpressionRoot();
+            childIndexStack.push(-1);
+            try {
+                return expr.accept(SPMPlaceholderBuilder.this, null);
+            } finally {
+                childIndexStack.pop();
+            }
+        }
+
+        @Override
+        public void enterQueryBlock() {
+            SPMPlaceholderBuilder.this.enterQueryBlock();
+        }
+
+        @Override
+        public void exitQueryBlock() {
+            SPMPlaceholderBuilder.this.exitQueryBlock();
+        }
+
+        @Override
+        public void enterProjectionItem(int index) {
+            SPMPlaceholderBuilder.this.enterProjectionItem(index);
+        }
+
+        @Override
+        public void exitProjectionItem() {
+            SPMPlaceholderBuilder.this.exitProjectionItem();
+        }
+    }
+
+    /**
+     * Enters a nested query block (see
+     * 
org.apache.doris.nereids.spm.SPMPlanTreeSupport.ExprTransform#enterQueryBlock()):
+     * the block gets its own monotonic id, the enclosing one is restored by
+     * exitQueryBlock().
+     */
+    @Override
+    public void enterQueryBlock() {
+        blockIdStack.push(currentBlockId);
+        currentBlockId = nextBlockId++;
+        // the root-ordinal counting is per BLOCK as well: a nested block's 
roots must not
+        // shift the enclosing block's ordinals (and vice versa), and the 
enclosing counters
+        // resume on exit
+        rootOrdinalStack.push(nextRootOrdinal);
+        nextRootOrdinal = 0;
+        currentRootOrdinalStack.push(currentRootOrdinal);
+        currentRootOrdinal = -1;
+    }
+
+    /** Leaves the current nested query block and restores the enclosing block 
id. */
+    @Override
+    public void exitQueryBlock() {
+        if (!blockIdStack.isEmpty()) {
+            currentBlockId = blockIdStack.pop();
+        }
+        if (!rootOrdinalStack.isEmpty()) {
+            nextRootOrdinal = rootOrdinalStack.pop();
+        }
+        if (!currentRootOrdinalStack.isEmpty()) {
+            currentRootOrdinal = currentRootOrdinalStack.pop();
+        }
+    }
+
+    /**
+     * Enters one projection item of a projection-like list: every literal of 
its subtree
+     * carries this position, so two equally shaped literals in different 
items (or in a
+     * projection and in a filter) never share one placeholder id.
+     */
+    @Override
+    public void enterProjectionItem(int index) {
+        projectItemStack.push(index);
+    }
+
+    /** Leaves the current projection item. */
+    @Override
+    public void exitProjectionItem() {
+        if (!projectItemStack.isEmpty()) {
+            projectItemStack.pop();
+        }
+    }
+
+    /** Position of the projection item currently being visited; -1 outside a 
SELECT list. */
+    private int currentProjectItem() {
+        Integer top = projectItemStack.peek();
+        return top == null ? -1 : top;
+    }
+
+    public List<PlaceholderExpr> getPlaceholderExprs() {
+        return placeholderExprs;
+    }
+
+    /** Position of the expression currently being visited inside its direct 
parent; -1 at a root. */
+    private int currentChildIndex() {
+        Integer top = childIndexStack.peek();
+        return top == null ? -1 : top;
+    }
+
+    /**
+     * The occurrence path of the literal currently being visited: the child 
positions from the
+     * transformed EXPRESSION ROOT down to the literal, rendered as "0/1/..." 
(empty for a
+     * root literal). Two equal literals can sit under structurally EQUAL 
parents at
+     * different places of one root ("a = 1 AND a = 1": the parents are the 
two And
+     * children), and only the path tells those independent slots apart - see
+     * PlaceholderExpr#occurrencePath.
+     */
+    private String currentOccurrencePath() {
+        if (childIndexStack.isEmpty()) {
+            return "";
+        }
+        Object[] positions = childIndexStack.toArray();
+        StringBuilder sb = new StringBuilder();
+        for (int i = positions.length - 1; i >= 0; i--) {
+            if (sb.length() > 0) {
+                sb.append('/');
+            }
+            sb.append(positions[i]);
+        }
+        return sb.toString();
+    }
+
+    // ==================== literal -> scalar placeholder ====================
+
+    /**
+     * Whether the expression's TYPE is NullType.
+     *
+     * The type is derived structurally because this builder walks the UNBOUND 
plan, where
+     * {@code getDataType()} throws. A node produces NullType when EVERY value 
it can yield is
+     * NULL - a NULL literal, a CASE whose every result does, an IF whose two 
value arguments do,
+     * a COALESCE / IFNULL / NVL / NULLIF / GREATEST / LEAST all of whose 
arguments do. Collected as
+     * a Boolean operand (directly, or through a projected slot) such an 
expression is REPLACED by
+     * the analyzer with a typed constant NULL, which deletes the placeholders 
inside it.
+     *
+     * The test is RECURSIVE on purpose (round-78 #5): a conditional nested in 
the RESULT of
+     * another conditional (`k &gt; 0 OR (CASE WHEN k = 0 THEN ifnull(NULL, 
NULL) ELSE NULL END)`)
+     * yields NullType although its result is not a NULL LITERAL, so a 
literal-only test left its
+     * inner markers NullType and the consumer erased them - a TRUE variant 
then replayed as NULL.
+     * The same recursion covers the custom-signature producers: {@code 
GreatestLeast#customSignature}
+     * returns the arguments' common type, i.e. NullType for {@code 
greatest(NULL, NULL)} (round-78
+     * #1, LEAST shares the path).
+     *
+     * @param expr the expression to classify
+     * @return whether every value the expression can yield is NULL
+     */
+    private static boolean producesNullType(Expression expr) {
+        if (expr == null) {
+            return false;
+        }
+        if (expr.isNullLiteral()) {
+            return true;
+        }
+        List<Expression> children = expr.children();
+        if (expr instanceof CaseWhen) {
+            CaseWhen caseWhen = (CaseWhen) expr;
+            for (WhenClause clause : caseWhen.getWhenClauses()) {
+                if (!producesNullType(clause.getResult())) {
+                    return false;
+                }
+            }
+            return !caseWhen.getDefaultValue().isPresent()
+                    || producesNullType(caseWhen.getDefaultValue().get());
+        }
+        if (isIfConditional(expr)) {
+            return children.size() == 3 && producesNullType(children.get(1))
+                    && producesNullType(children.get(2));
+        }
+        if (isIfNullConditional(expr) || isNullIfConditional(expr)
+                || isGreatestLeastConditional(expr) || 
isCoalesceConditional(expr)) {
+            // every argument contributes a value the result may equal: 
all-NULL arguments make
+            // the whole call NullType (IFNULL / NVL / NULLIF / GREATEST / 
LEAST / COALESCE)
+            if (children.isEmpty()) {
+                return false;
+            }
+            for (Expression child : children) {
+                if (!producesNullType(child)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+        if (isElementAtCall(expr)) {
+            // `element_at(array(NULL), 1)` takes its type from the ARRAY's 
element type and
+            // `element_at(map('x', NULL), 'x')` from the MAP's VALUE type 
(CreateMap derives
+            // MAP<STRING, NULL_TYPE>), so an all-NULL container makes the 
WHOLE call NullType: a
+            // Boolean consumer replaced the two markers of
+            // `k > 0 OR element_at(array(NULL), 1)` with a typed constant and 
the
+            // `element_at(array(TRUE), 1)` variant had nothing to substitute 
(round-79 #3), and
+            // the MAP spelling lost the value marker the same way (round-80 
#1).
+            return !children.isEmpty() && isAllNullContainer(children.get(0));
+        }
+        return false;
+    }
+
+    /**
+     * Whether the expression is a container whose ELEMENT type is NullType 
(see producesNullType):
+     * an all-NULL array or an all-NULL-valued map. Both overloads of 
`element_at` return the
+     * container's element / value type, so either one makes the call a 
NullType producer.
+     *
+     * @param expr the expression to classify
+     * @return whether it is an all-NULL array or map value
+     */
+    private static boolean isAllNullContainer(Expression expr) {
+        return isAllNullArray(expr) || isAllNullMap(expr);
+    }
+
+    /**
+     * Whether the expression builds a MAP whose every VALUE produces NullType 
(see
+     * producesNullType).
+     *
+     * The children of a map constructor alternate KEY, VALUE, so only the odd 
positions feed the
+     * map's value type - and with it what `element_at` returns. Both 
spellings are accepted: the
+     * parsed `map(...)` call and the bound {@code CreateMap}, which is a 
{@link Function} named
+     * {@code map}. A MAP LITERAL ({@code MapLiteral}) is a leaf without 
children and is therefore
+     * not covered - its entries are never parameterized either.
+     *
+     * @param expr the expression to classify
+     * @return whether it is an all-NULL-valued map value
+     */
+    private static boolean isAllNullMap(Expression expr) {
+        if (expr == null || !isMapConstructor(expr)) {
+            return false;
+        }
+        List<Expression> pairs = expr.children();
+        if (pairs.size() < 2) {
+            return false;
+        }
+        for (int i = 1; i < pairs.size(); i += 2) {
+            if (!producesNullType(pairs.get(i))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Whether the expression is an all-NULL array (see producesNullType).
+     *
+     * An array LITERAL whose every element produces NullType has type 
ARRAY&lt;NULL_TYPE&gt;, so a
+     * consumer that reads an ELEMENT (`element_at`) is NullType as well. Both 
spellings are
+     * accepted: the parsed {@link ArrayLiteral} and the unbound `array(...)` 
call, which is only
+     * a {@link Function} named {@code array} before binding.
+     *
+     * An ARRAY_CONCAT WRAPPER is an all-NULL array exactly when every 
concatenated part is one
+     * (round-80): the function derives its element type from its inputs, so 
concatenating only
+     * all-NULL arrays keeps ARRAY&lt;NULL_TYPE&gt; - and the wrapper was NOT 
recognized, so
+     * `k > 0 OR element_at(array_concat(array(NULL), array(NULL)), 1)` kept 
an ARRAY&lt;NULL_TYPE&gt;
+     * marker, a Boolean consumer erased the element_at call and its markers, 
and the
+     * `array(TRUE), array(TRUE)` variant had nothing left to substitute (the 
frozen replay then
+     * returned NULL for a row the caller returns TRUE for).
+     *
+     * @param expr the expression to classify
+     * @return whether it is an all-NULL array value
+     */
+    private static boolean isAllNullArray(Expression expr) {
+        if (expr == null) {
+            return false;
+        }
+        if (expr.isNullLiteral()) {
+            // a NULL literal is untyped ARRAY<NULL_TYPE> in an array position
+            return true;
+        }
+        if (isArrayConcatCall(expr)) {
+            List<Expression> parts = expr.children();
+            if (parts.isEmpty()) {
+                return false;
+            }
+            for (Expression part : parts) {
+                if (!isAllNullArray(part)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+        if (!isArrayConstructor(expr)) {

Review Comment:
   [P1] Preserve NULL markers through array transformations
   
   Capture `SELECT k > 0 OR element_at(array_sort(array(NULL)), 1) AS c FROM t`:
   ```text
   Project(Or(k>phA, ElementAt(ArraySort(Array(phB[NullType])),phC)))
     Scan(t)
   ```
   `isAllNullArray` rejects ARRAY_SORT here, so Boolean-context retyping never 
reaches phB. ArraySort preserves ARRAY<NULL_TYPE>, ElementAt returns NULL_TYPE, 
and `ExpressionAnalyzer.visitOr` replaces the whole element expression with 
Boolean NULL. Frozen replay therefore loses phB/phC. The matching 
`array_sort(array(TRUE))` query still extracts TRUE but cannot substitute it: 
for k=-1 replay returns NULL instead of TRUE. ARRAY_DISTINCT has the same 
type-preserving signature. CREATE's probe only replans the reduced tree. 
Preserve these markers or decline freezing when binding consumes required IDs, 
and cover actual NULL-to-TRUE replay through array transformations. The earlier 
bare-ARRAY/MAP reports are repaired; this is the wrapper branch outside the 
current classifier.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/spm/SPMPlanTreeSupport.java:
##########
@@ -0,0 +1,8883 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.nereids.spm;
+
+import org.apache.doris.analysis.TableScanParams;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.TableIf;
+import org.apache.doris.catalog.View;
+import org.apache.doris.nereids.analyzer.UnboundAlias;
+import org.apache.doris.nereids.analyzer.UnboundFunction;
+import org.apache.doris.nereids.analyzer.UnboundInlineTable;
+import org.apache.doris.nereids.analyzer.UnboundOneRowRelation;
+import org.apache.doris.nereids.analyzer.UnboundRelation;
+import org.apache.doris.nereids.analyzer.UnboundSlot;
+import org.apache.doris.nereids.analyzer.UnboundStar;
+import org.apache.doris.nereids.analyzer.UnboundTVFRelation;
+import org.apache.doris.nereids.analyzer.UnboundVariable;
+import org.apache.doris.nereids.properties.OrderKey;
+import org.apache.doris.nereids.properties.SelectHint;
+import org.apache.doris.nereids.properties.SelectHintLeading;
+import org.apache.doris.nereids.properties.SelectHintSetVar;
+import org.apache.doris.nereids.properties.SelectHintUseMv;
+import org.apache.doris.nereids.rules.exploration.join.JoinReorderContext;
+import org.apache.doris.nereids.spm.matcher.MatchAttempt;
+import org.apache.doris.nereids.spm.matcher.SPMAstCheckVisitor;
+import org.apache.doris.nereids.spm.matcher.SPMFrozenTreeReplacer;
+import org.apache.doris.nereids.spm.placeholder.SPMPlaceholderBuilder;
+import org.apache.doris.nereids.spm.placeholder.SpmConstList;
+import org.apache.doris.nereids.spm.placeholder.SpmConstVar;
+import org.apache.doris.nereids.trees.TableSample;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.And;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.MarkJoinSlotReference;
+import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SubqueryExpr;
+import org.apache.doris.nereids.trees.expressions.Variable;
+import org.apache.doris.nereids.trees.expressions.functions.Function;
+import org.apache.doris.nereids.trees.expressions.functions.generator.Unnest;
+import 
org.apache.doris.nereids.trees.expressions.functions.scalar.ConnectionId;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.CurrentDate;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.CurrentTime;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.CurrentUser;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Database;
+import 
org.apache.doris.nereids.trees.expressions.functions.scalar.EncryptKeyRef;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.Now;
+import org.apache.doris.nereids.trees.expressions.functions.scalar.SessionUser;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLikeLiteral;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.algebra.InlineTable;
+import org.apache.doris.nereids.trees.plans.algebra.OneRowRelation;
+import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCTE;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCatalogRelation;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCheckPolicy;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFileSink;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalGenerate;
+import org.apache.doris.nereids.trees.plans.logical.LogicalHaving;
+import org.apache.doris.nereids.trees.plans.logical.LogicalJoin;
+import org.apache.doris.nereids.trees.plans.logical.LogicalLimit;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOneRowRelation;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalQualify;
+import org.apache.doris.nereids.trees.plans.logical.LogicalRepeat;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSelectHint;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSetOperation;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSink;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSort;
+import org.apache.doris.nereids.trees.plans.logical.LogicalSubQueryAlias;
+import org.apache.doris.nereids.trees.plans.logical.LogicalTopN;
+import org.apache.doris.nereids.trees.plans.logical.LogicalUsingJoin;
+import org.apache.doris.nereids.trees.plans.logical.LogicalView;
+import org.apache.doris.nereids.trees.plans.logical.LogicalWindow;
+import org.apache.doris.nereids.trees.plans.visitor.PlanVisitor;
+import org.apache.doris.nereids.util.RelationUtil;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.GlobalVariable;
+
+import com.google.common.collect.ImmutableList;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * SPMPlanTreeSupport - whole-query plan-tree SPM engine.
+ *
+ * SPM binds and rewrites a whole parsed (still unbound) SELECT plan tree, not 
only the
+ * per-query-block WHERE predicates. The three lifecycle phases run over the 
whole tree:
+ *
+ * - transform: applies an expression transform to every literal-carrying
+ *   expression of every plan node (filter/having predicates, projections, 
aggregate
+ *   group-by/output, ...) and rebuilds the touched nodes. Used both for 
parameterization
+ *   (SPMPlaceholderBuilder) and for substitution (SPMPlaceholderReplacer).
+ * - check: structurally compares a parameterized bind tree against the user's
+ *   original tree node by node and expression by expression, extracting the 
actual user
+ *   values for every placeholder id (Level 3).
+ * - containsPlaceholder: whether a tree still carries an unbound placeholder
+ *   (safety net before a rewritten tree is handed to the analyzer).
+ *
+ * A Literal that lives inside a SubqueryExpr's own query plan (e.g. the 
constant of a
+ * scalar subquery in the SELECT list) is reached through the expression 
transform: the
+ * expression visitors recurse into the subquery plan with transform. Nested
+ * query blocks that appear as plain plan nodes (derived tables, CTE bodies) 
are reached
+ * by the normal children / CTE-alias traversal below.
+ */
+public final class SPMPlanTreeSupport {
+
+    /**
+     * Marker of the per-table NULLABILITY section inside one fingerprint 
entry (see
+     * describeTableForFingerprint). An entry persisted before the section
+     * existed simply lacks it and is still accepted (legacyEntryOf), so the
+     * section can be introduced without invalidating already persisted 
baselines.
+     */
+    private static final String NULLABILITY_SECTION = "nullable:";
+
+    /**
+     * The marker appended by expandedOutputLabels behind the derivable prefix 
of
+     * a PARTLY derivable star expansion (see the star-expansion helper 
returned by
+     * alignRootOutputLabels). Kept in the constants block: a static field may
+     * not sit between methods (checkstyle DeclarationOrder).
+     */
+    private static final String OPEN_TAIL_LABEL = "\u0000spm-open-star-tail";
+
+    /** Expression transform used by transform. */
+    public interface ExprTransform {
+        Expression apply(Expression expr);
+
+        /**
+         * Called while the rebuild ENTERS a nested query block (a derived 
table / CTE body /
+         * subquery alias), with exitQueryBlock() in a finally block. The 
placeholder
+         * builder gives every block its own identity so two literals of 
DIFFERENT blocks can
+         * never share one placeholder id: block 0 was previously shared by 
the outer query
+         * and every derived table, so equal filter literals there (e.g. an 
outer
+         * x = 1 and a derived-table x = 1 whose parent signatures coincide)
+         * merged into one id and a variant changing only one of the values 
could never
+         * match.
+         */
+        default void enterQueryBlock() {
+        }
+
+        /** Matching exit of enterQueryBlock(). */
+        default void exitQueryBlock() {
+        }
+
+        /**
+         * Called while the rebuild enters ONE projection item (SELECT-list 
slot) of a
+         * projection-like list, with exitProjectionItem() in a finally block. 
The
+         * item POSITION is part of a literal's placeholder identity: the 
literals of
+         * SELECT 1 AS x, 1 AS y have the same value, the same child position 
inside
+         * their Alias and (before the position existed) the same parent 
signature, so they
+         * shared one placeholder and a variant with different values per 
column could never
+         * match.
+         */
+        default void enterProjectionItem(int index) {
+        }
+
+        /** Matching exit of enterProjectionItem(int). */
+        default void exitProjectionItem() {
+        }
+    }
+
+    /**
+     * An ExprTransform that also changes what its children see while the tree 
is rebuilt.
+     * Only namespace qualification is scope-sensitive: the set of CTE aliases 
visible in
+     * the query text grows when the rebuild enters a CTE body or a CTE's main 
query, so
+     * those two entry points receive a different transform than the 
surrounding tree.
+     * Plain transforms (parameterize / substitute) are not scope-aware and 
are passed
+     * through unchanged.
+     */
+    private interface ScopedTransform extends ExprTransform {
+        /** The transform to use for the main query of a CTE (sees every alias 
of it). */
+        ExprTransform enterCteMain(LogicalCTE<? extends Plan> cte);
+
+        /** The transform to use for the body of alias #aliasIndex (sees the 
earlier ones). */
+        ExprTransform enterCteAlias(LogicalCTE<? extends Plan> cte, int 
aliasIndex);
+    }
+
+    /** Re-descends into expression subquery plans so their hints are stripped 
as well. */
+    private static final ExprTransform HINT_STRIP_EXPR = 
SPMPlanTreeSupport::stripHintsInExpression;
+
+    /**
+     * Expression transform of stripCheckPolicy: strips the policy markers from
+     * every plan an expression owns (IN / EXISTS / scalar subquery plans, the 
payloads of
+     * a * REPLACE(...), the ASOF MATCH_CONDITION).
+     */
+    private static final ExprTransform STRIP_POLICY_IN_EXPRESSION =
+            SPMPlanTreeSupport::stripPolicyOfExpression;
+
+    private SPMPlanTreeSupport() {
+    }
+
+    /**
+     * Strips the SET_VAR hints of every plan one expression owns, recursing 
through the
+     * expression tree.
+     *
+     * The recursion is what makes the peel reach a subquery NESTED inside an 
item: a
+     * projection item is an {@code UnboundAlias} (or an arithmetic / function 
call around one),
+     * so testing only the expression handed to the transform left a scalar 
subquery carrying a
+     * SET_VAR hint inside it untouched - and the replayed analysis then 
applied the CREATOR's
+     * session variable to a caller that carried no hint at all (round-76 #4).
+     *
+     * @param expression the expression to peel
+     * @return the expression with every owned subquery plan stripped
+     */
+    private static Expression stripHintsInExpression(Expression expression) {
+        Expression result = expression;
+        if (expression instanceof SubqueryExpr) {
+            LogicalPlan subPlan = ((SubqueryExpr) expression).getQueryPlan();
+            LogicalPlan stripped = stripSelectHints(subPlan);
+            if (stripped != null && stripped != subPlan) {
+                result = ((SubqueryExpr) expression).withSubquery(stripped);
+            }
+        }
+        if (result instanceof UnboundStar) {
+            // The REPLACE payloads live OUTSIDE children(), so a subquery 
(with its own
+            // SET_VAR hint) behind one is reached only from here - the same 
payload omission
+            // the function-identity collector had (round-77 #1).
+            UnboundStar star = (UnboundStar) result;
+            List<NamedExpression> replaced = star.getReplacedAlias();
+            if (!replaced.isEmpty()) {
+                List<NamedExpression> newReplaced = new 
ArrayList<>(replaced.size());
+                boolean payloadChanged = false;
+                for (NamedExpression payload : replaced) {
+                    Expression newPayload = stripHintsInExpression(payload);
+                    newReplaced.add(newPayload instanceof NamedExpression
+                            ? (NamedExpression) newPayload : payload);
+                    payloadChanged |= newPayload != payload;
+                }
+                if (payloadChanged) {
+                    result = new UnboundStar(star.getQualifier(), 
star.getExceptedSlots(),
+                            newReplaced, star.getIndexInSqlString());
+                }
+            }
+        }
+        List<Expression> children = result.children();
+        if (children.isEmpty()) {
+            return result;
+        }
+        List<Expression> newChildren = new ArrayList<>(children.size());
+        boolean changed = false;
+        for (Expression child : children) {
+            Expression newChild = stripHintsInExpression(child);
+            newChildren.add(newChild);
+            changed |= newChild != child;
+        }
+        return changed ? result.withChildren(newChildren) : result;
+    }
+
+    /**
+     * The top-level LIMIT / OFFSET a query tree exposes to its CALLER ({limit,
+     * offset}) or null when it has none. The descent follows the wrapper 
chain (result
+     * sink, sort, ...) and STOPS at a projection: a limit BELOW a projection 
is not the
+     * caller-visible top-level limit - and mergeLimitNode cannot transfer the
+     * caller's values there either, which is exactly the situation the replay 
guard in
+     * SPMPlanner checks with this helper.
+     */
+    public static long[] topLevelLimitOf(Plan plan) {
+        Plan node = plan;
+        while (node != null && !(node instanceof LogicalLimit) && !(node 
instanceof LogicalTopN)
+                && !(node instanceof LogicalProject) && node.children().size() 
== 1) {
+            node = node.child(0);
+        }
+        if (node instanceof LogicalLimit) {
+            return new long[] {((LogicalLimit<?>) node).getLimit(),
+                    ((LogicalLimit<?>) node).getOffset()};
+        }
+        if (node instanceof LogicalTopN) {
+            return new long[] {((LogicalTopN<?>) node).getLimit(),
+                    ((LogicalTopN<?>) node).getOffset()};
+        }
+        return null;
+    }
+
+    /**
+     * Whether every row-limiting node of replayed is justified by the 
caller's own
+     * tree userPlan: the MULTISET of (limit, offset, INPUT-IDENTITY) keys the
+     * replay exposes must be contained in the caller's own - a LIMIT VARIANT 
replay
+     * transfers the caller's top-level value POSITIONALLY, so any other cap 
it still
+     * exposes was inherited from the CAPTURED plan (or a manual plan) and 
would silently
+     * truncate the result of the variant (the frozen
+     * SELECT DISTINCT k FROM (SELECT k FROM t ORDER BY k LIMIT 1) s ORDER BY k
+     * LIMIT 2 kept the inner cap at 1 while the caller asked for two keys).
+     *
+     * Comparing INPUT IDENTITIES rather than node paths is deliberate: the 
replay is a
+     * MANUAL frozen plan whose structure may legitimately differ from the 
caller's, so
+     * only what the caller itself asked for (values AND the input they 
truncate) can
+     * justify a cap. The input identity carries the OCCURRENCE of the 
relation it names
+     * two caps over the same table are only equivalent when they truncate
+     * the SAME occurrence, otherwise a cap moved between same-table 
occurrences passed
+     * the multiset check and truncated the wrong side of e.g. a self join. 
The occurrence
+     * is the relation's per-name ORDINAL in walk order - NOT its alias, 
because the
+     * frozen text of a baseline is the DECOMPILED plan and the decompiler 
renames every
+     * derived alias (an identical-text baseline must keep hitting its own 
query: the
+     * alias-based form rejected it because the caller's `x` became `t_1`).
+     *
+     * @param replayed the replayed tree AFTER the limit merge
+     * @param userPlan the caller's own tree
+     * @return whether every replayed cap is one the caller's tree also has
+     */
+    public static boolean rowLimitsWithin(Plan replayed, Plan userPlan) {
+        Map<String, Integer> allowed = new HashMap<>();
+        collectRowLimits(userPlan, allowed);
+        Map<String, Integer> used = new HashMap<>();
+        collectRowLimits(replayed, used);
+        for (Map.Entry<String, Integer> entry : used.entrySet()) {
+            Integer available = allowed.get(entry.getKey());
+            if (available == null || available < entry.getValue()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * The REVERSE direction of rowLimitsWithin: every cap the
+     * CALLER's tree asks for must survive in the replayed tree with the SAME 
values,
+     * ordered slice and input placement. The one-directional check only 
rejected caps the
+     * replay ADDED; a replay that DROPPED a caller cap passed (bind
+     * SELECT k FROM (SELECT k FROM t ORDER BY k LIMIT 1) s ORDER BY k LIMIT 2 
vs
+     * manual plan SELECT k FROM t ORDER BY k LIMIT 2: the replay's sole outer 
cap
+     * is contained in the caller's {inner, outer} set, yet for t={1,2,3} the 
caller
+     * returns ONE row and the replay two). Positive logic: a cap can only be 
honored when
+     * the replay demonstrably carries it.
+     *
+     * @param replayed the replayed tree AFTER the limit merge
+     * @param userPlan the caller's own tree
+     * @return whether every caller cap exists in the replayed tree
+     */
+    public static boolean rowLimitsSurviveReplay(Plan replayed, Plan userPlan) 
{
+        Map<String, Integer> required = new HashMap<>();
+        collectRowLimits(userPlan, required);
+        Map<String, Integer> exposed = new HashMap<>();
+        collectRowLimits(replayed, exposed);
+        for (Map.Entry<String, Integer> entry : required.entrySet()) {
+            Integer present = exposed.get(entry.getKey());
+            if (present == null || present < entry.getValue()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * The equal-top-limit acceptance for the OPTIMIZER-REDUCED caller tree: 
the RAW
+     * caller can contain MORE occurrences of one table than the frozen 
optimal plan,
+     * which collapses them (common-subexpression elimination folds three 
date_dim
+     * scans into one, tpcds q76), so the occurrence-tagged keys of the 
TOP-LEVEL cap
+     * differ although the cap truncates the same (ordered) result. Accept 
when the
+     * caller's and the replay's top-level cap agree on (limit, offset, 
ordered-slice)
+     * and on the occurrence-ordinal-FREE relation NAME set, while every OTHER 
cap on
+     * both sides keeps the strict two-way containment. The caller must 
already have
+     * established that the top-level (limit, offset) is unchanged from the 
stored
+     * bind tree (see SPMPlanner#limitContractPreserved): the value cannot 
come from a
+     * variant's positional transfer, and only the top cap - whose slice is 
the whole
+     * result, not one occurrence of a self-join - may ignore the ordinals.
+     *
+     * @param replayed the replayed tree AFTER the limit merge
+     * @param userPlan the caller's own tree
+     * @return whether the top caps agree modulo occurrence ordinals and 
everything
+     *         else stays strictly contained
+     */
+    static boolean topCapContractPreserved(Plan replayed, Plan userPlan) {
+        long[] top = topLevelLimitOf(userPlan);
+        if (top == null || !Arrays.equals(top, topLevelLimitOf(replayed))) {
+            return false;
+        }
+        Map<String, Integer> userCaps = new HashMap<>();
+        collectRowLimits(userPlan, userCaps);
+        Map<String, Integer> replayCaps = new HashMap<>();
+        collectRowLimits(replayed, replayCaps);
+        String prefix = top[0] + ":" + top[1] + ":";
+        String userTopKey = singleTopCapKey(userCaps, prefix);
+        String replayTopKey = singleTopCapKey(replayCaps, prefix);
+        if (userTopKey == null || replayTopKey == null) {
+            // more than one cap shares the top (limit, offset): keep the 
strict checks
+            return false;
+        }
+        if 
(!stripOccurrenceOrdinals(userTopKey).equals(stripOccurrenceOrdinals(replayTopKey)))
 {
+            return false;
+        }
+        consumeOne(userCaps, userTopKey);
+        consumeOne(replayCaps, replayTopKey);
+        for (Map.Entry<String, Integer> entry : replayCaps.entrySet()) {
+            Integer available = userCaps.get(entry.getKey());
+            if (available == null || available < entry.getValue()) {
+                return false;
+            }
+        }
+        for (Map.Entry<String, Integer> entry : userCaps.entrySet()) {
+            Integer present = replayCaps.get(entry.getKey());
+            if (present == null || present < entry.getValue()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** The single cap key of the given (limit, offset) prefix, or null when 
ambiguous. */
+    private static String singleTopCapKey(Map<String, Integer> caps, String 
prefix) {
+        String found = null;
+        for (Map.Entry<String, Integer> entry : caps.entrySet()) {
+            if (entry.getKey().startsWith(prefix)) {
+                if (found != null) {
+                    return null;
+                }
+                found = entry.getKey();
+            }
+        }
+        return found;
+    }
+
+    /**
+     * Whether the tree's ONLY row cap is its top-level one: a LIMIT VARIANT 
(the caller's
+     * top value differs from the stored bind tree's) whose replay carries no 
OTHER cap
+     * cannot truncate a different slice - the positionally merged top cap 
governs the
+     * whole result (tpcds q76's folded plan has one cap for the entire tree, 
so the
+     * similar query raising LIMIT 100 to 101 replays it unchanged except for 
the value;
+     * see SPMPlanner#limitContractPreserved).
+     */
+    public static boolean onlyTopCap(Plan plan) {
+        long[] top = topLevelLimitOf(plan);
+        if (top == null) {
+            return false;
+        }
+        Map<String, Integer> caps = new HashMap<>();
+        collectRowLimits(plan, caps);
+        return caps.size() == 1
+                && caps.keySet().iterator().next().startsWith(top[0] + ":" + 
top[1] + ":");
+    }
+
+    /** Consumes one instance of the key's multiplicity (multiset 
bookkeeping). */
+    private static void consumeOne(Map<String, Integer> caps, String key) {
+        Integer count = caps.get(key);
+        if (count == null) {
+            return;
+        }
+        if (count <= 1) {
+            caps.remove(key);
+        } else {
+            caps.put(key, count - 1);
+        }
+    }
+
+    /**
+     * One cap key with the per-name occurrence ordinals dropped from its 
input section:
+     * "limit:offset:slice:name#N,name#M" becomes 
"limit:offset:slice:name1,name2"
+     * (sorted names, duplicates collapsed). The head - the limit, the offset 
and the
+     * ordered-slice fingerprint (order-key count and directions) - stays 
comparable.
+     */
+    private static String stripOccurrenceOrdinals(String capKey) {
+        int first = capKey.indexOf(':');
+        int second = first < 0 ? -1 : capKey.indexOf(':', first + 1);
+        int third = second < 0 ? -1 : capKey.indexOf(':', second + 1);
+        if (third < 0) {
+            return capKey;
+        }
+        TreeSet<String> names = new TreeSet<>();
+        String inputs = capKey.substring(third + 1);
+        for (String input : inputs.split(",")) {
+            if (input.isEmpty()) {
+                continue;
+            }
+            int hash = input.lastIndexOf('#');
+            names.add(hash < 0 ? input : input.substring(0, hash));
+        }
+        return capKey.substring(0, third + 1) + String.join(",", names);
+    }
+
+    /**
+     * Whether every cap of the replayed tree is either one the CALLER's own 
tree carries
+     * (same value, offset and input identity, see rowLimitsWithin) or a 
pushdown OF THE
+     * CALLER's own top-level cap: the optimizer folds a top LIMIT n into the 
plan it
+     * plans (the tpcds q28 frozen plan carries one 100:0 cap per store_sales 
scan),
+     * so the frozen plan of the IDENTICAL query legitimately exposes caps the 
RAW
+     * caller tree has no node for. Only caps with EXACTLY the caller's 
top-level
+     * (limit, offset) qualify, and the caller must ask for NO order contract 
of its
+     * own: an equal-valued inner cap below an ordering-dependent slice 
truncates a
+     * DIFFERENT slice (see SPMPlanner#limitContractPreserved).
+     */
+    static boolean replayCapsJustifiedByCallerTopLimit(Plan replayed, Plan 
userPlan) {
+        long[] top = topLevelLimitOf(userPlan);
+        if (top == null) {
+            return false;
+        }
+        boolean ordered = !rootOrderContract(userPlan).isEmpty();
+        // With a caller ORDER BY the folded cap must not cut a different 
slice. That is
+        // guaranteed when the replay still exposes the caller's own TOP-LEVEL 
ordering
+        // AND the folded caps sit on the BRANCHES of a set operation: the 
merge of the
+        // per-branch orders selects the caller's slice among each branch's 
own top rows
+        // (tpcds q49's union branches carry one 100 cap each), while a cap on 
the input
+        // of an ordinary sort would truncate before the ordering exists.
+        if (ordered && !orderContractPreserved(replayed, userPlan)) {
+            return false;
+        }
+        Map<String, Integer> allowed = new HashMap<>();
+        collectRowLimits(userPlan, allowed);
+        Map<String, Integer> used = new HashMap<>();
+        collectRowLimits(replayed, used);
+        String prefix = top[0] + ":" + top[1] + ":";
+        Set<String> topScope = null;
+        List<Set<String>> extraScopes = new ArrayList<>();
+        if (ordered) {
+            // the first set operation below the order/limit wrappers: the 
class-exact
+            // helper cannot be used here (the concrete Union / Intersect / 
Except class is
+            // what the caller-side comparison keys on), so descend on the 
base interface
+            Plan probe = replayed;
+            while (probe != null && !(probe instanceof LogicalSetOperation)
+                    && probe.children().size() == 1) {
+                probe = probe.child(0);
+            }
+            if (!(probe instanceof LogicalSetOperation)) {
+                return false;
+            }
+            topScope = topCapScopeOf(allowed, prefix);
+        }
+        for (Map.Entry<String, Integer> entry : used.entrySet()) {
+            if (allowed.containsKey(entry.getKey())) {
+                continue;
+            }
+            if (!entry.getKey().startsWith(prefix)) {
+                return false;
+            }
+            if (ordered) {
+                Set<String> scope = capKeyRelations(entry.getKey());
+                if (topScope == null || !topScope.containsAll(scope)) {
+                    return false;
+                }
+                extraScopes.add(scope);
+            }
+        }
+        if (ordered) {
+            // one cap per branch: overlapping branch scopes would let two 
folded caps
+            // truncate the same input
+            for (int i = 0; i < extraScopes.size(); i++) {
+                for (int j = i + 1; j < extraScopes.size(); j++) {
+                    for (String relation : extraScopes.get(i)) {
+                        if (extraScopes.get(j).contains(relation)) {
+                            return false;
+                        }
+                    }
+                }
+            }
+        }
+        return true;
+    }
+
+    /** The relation set of the WIDEST caller cap carrying the given (limit, 
offset) prefix. */
+    private static Set<String> topCapScopeOf(Map<String, Integer> allowed, 
String prefix) {
+        Set<String> widest = null;
+        for (String key : allowed.keySet()) {
+            if (key.startsWith(prefix)) {
+                Set<String> relations = capKeyRelations(key);
+                if (widest == null || relations.size() > widest.size()) {
+                    widest = relations;
+                }
+            }
+        }
+        return widest;
+    }
+
+    /** The relation names of one cap key ("limit:offset:slice:r1,r2,..."). */
+    private static Set<String> capKeyRelations(String key) {
+        int first = key.indexOf(':');
+        int second = first < 0 ? -1 : key.indexOf(':', first + 1);
+        int third = second < 0 ? -1 : key.indexOf(':', second + 1);
+        if (third < 0 || third + 1 >= key.length()) {
+            return Set.of();
+        }
+        return new HashSet<>(Arrays.asList(key.substring(third + 
1).split(",")));
+    }
+
+    /**
+     * Collects every row-limiting node of the tree as (limit, offset, INPUT 
IDENTITIES):
+     * the (limit, offset) pair ALONE is not enough - a cap of the same value 
can sit on a
+     * different input than the caller's own cap of that value, and the 
positional merge
+     * leaves it there (a manual plan capped t2 while the caller's own cap
+     * sat on t1; with one matching t1 row and two matching t2 rows the 
variant returned
+     * ONE row where the caller's own plan returns two). The key therefore 
includes every
+     * relation beneath the cap, so a replayed cap only counts as one the 
caller also has
+     * when it truncates the same input.
+     *
+     * The input identities alone are not enough either when the SAME table 
occurs more
+     * than once: a manual plan can move ORDER BY k LIMIT 1 from the first 
table
+     * instance to a second instance of the SAME table, and a caller raising 
only the
+     * outer LIMIT from 1 to 2 then compared 1:0:t against 1:0:t and
+     * accepted the replay - although t={1,2} yields (1,1),(2,1) instead of 
(1,1),(1,2)
+     * . Each input therefore carries its OCCURRENCE ORDINAL among the
+     * relations of the same name, assigned in walk order: a cap moved to 
another
+     * occurrence of the same input gets a different key and the variant is 
skipped (its
+     * placement cannot be proven equivalent), which is always safe - the 
caller's own
+     * query then simply plans normally. The ordinal (rather than the alias 
name) is what
+     * keeps an IDENTICAL-text baseline matchable: its frozen text is the 
DECOMPILED plan,
+     * whose derived aliases are regenerated, while the relative order of the 
relation
+     * occurrences is preserved.
+     *
+     * The walk covers the plans OUTSIDE children() as well - CTE bodies
+     * (LogicalCTE.extraPlans()) and expression-owned subquery plans (IN / 
EXISTS / scalar,
+     * plus * REPLACE payloads) - a cap inside a WITH body was invisible to a
+     * children-only walk, and the caller raising only the OUTER limit kept 
the frozen body
+     * cap for the variant. The identity set keeps a plan reachable through
+     * two paths (a filter's extraPlans() and its predicate expression) from 
counting the
+     * same cap twice.
+     */
+    private static void collectRowLimits(Plan plan, Map<String, Integer> out) {
+        // pass 1: assign every relation its per-name occurrence ordinal in 
walk order;
+        // pass 2: collect the caps with those ordinals (the two walks are the 
same
+        // pre-order traversal - children, extraPlans, expressions - so an 
ordinal is
+        // identical on both sides whenever the two trees render the 
occurrences in the
+        // same relative order)
+        IdentityHashMap<Plan, Integer> relationOrdinals = new 
IdentityHashMap<>();
+        // CTE references are named DIFFERENTLY on the two sides of a 
legitimate replay:
+        // the frozen planSql is the DECOMPILED plan and the decompiler 
regenerates
+        // every WITH alias (the caller's `ws_wh` becomes `t_4`), so a CTE 
reference is
+        // identified by its CTE's DEFINITION ORDER index instead of the alias 
- the
+        // same trick the occurrence ordinal plays for table aliases (see
+        // SPMPlanTreeSupport#relationNameOf).
+        CteOrder cteOrder = cteDefinitionOrder(plan);
+        assignRelationOrdinals(plan, new HashMap<>(), relationOrdinals, 
cteOrder,
+                Collections.newSetFromMap(new IdentityHashMap<>()));
+        collectRowLimits(plan, out, relationOrdinals, cteOrder,
+                Collections.newSetFromMap(new IdentityHashMap<>()));
+    }
+
+    private static void collectRowLimits(Plan plan, Map<String, Integer> out,
+            IdentityHashMap<Plan, Integer> relationOrdinals,
+            CteOrder cteOrder, Set<Plan> visited) {
+        if (plan == null || !visited.add(plan)) {
+            return;
+        }
+        if (plan instanceof LogicalLimit) {
+            out.merge(rowLimitKey(((LogicalLimit<?>) plan).getLimit(),
+                    ((LogicalLimit<?>) plan).getOffset(), plan, 
relationOrdinals, cteOrder),
+                    1, Integer::sum);
+        } else if (plan instanceof LogicalTopN) {
+            out.merge(rowLimitKey(((LogicalTopN<?>) plan).getLimit(),
+                    ((LogicalTopN<?>) plan).getOffset(), plan, 
relationOrdinals, cteOrder),
+                    1, Integer::sum);
+        }
+        for (Plan child : plan.children()) {
+            collectRowLimits(child, out, relationOrdinals, cteOrder, visited);
+        }
+        for (Plan extra : plan.extraPlans()) {
+            collectRowLimits(extra, out, relationOrdinals, cteOrder, visited);
+        }
+        for (Expression expression : plan.getExpressions()) {
+            collectRowLimits(expression, out, relationOrdinals, cteOrder, 
visited);
+        }
+    }
+
+    /** Recurses one expression tree looking for subquery plans (see 
walkSubqueryPlans). */
+    private static void collectRowLimits(Expression expression, Map<String, 
Integer> out,
+            IdentityHashMap<Plan, Integer> relationOrdinals,
+            CteOrder cteOrder, Set<Plan> visited) {
+        if (expression instanceof SubqueryExpr) {
+            collectRowLimits(((SubqueryExpr) expression).getQueryPlan(), out,
+                    relationOrdinals, cteOrder, visited);
+        }
+        if (expression instanceof UnboundStar) {
+            for (NamedExpression replaced : ((UnboundStar) 
expression).getReplacedAlias()) {
+                collectRowLimits(replaced, out, relationOrdinals, cteOrder, 
visited);
+            }
+        }
+        for (Expression child : expression.children()) {
+            collectRowLimits(child, out, relationOrdinals, cteOrder, visited);
+        }
+    }
+
+    /**
+     * The CTE identities one statement's row-limit keys compare (see 
cteDefinitionOrder).
+     *
+     * Two views of the same information: the flat NAME map answers for a name 
whose
+     * definitions are all equivalent (the common case), and the per-REFERENCE 
map keeps
+     * the binding actually VISIBLE at that reference, which is what a 
SHADOWED name needs
+     * (round-79 #7): a nested `WITH c AS ...` reuses the name `c`, and the 
flat map's
+     * first-definition-wins index then gave both references the SAME identity 
- a cap
+     * moved from the outer `c` to the inner one kept its key and passed the 
cap check
+     * although it truncated another CTE.
+     */
+    private static final class CteOrder {
+        /** Normalized alias -> definition-order index (the FIRST definition 
of a name). */
+        private final Map<String, Integer> definitionOrder = new HashMap<>();
+        /** Reference node -> the index of the binding visible at it 
(shadowing resolved). */
+        private final IdentityHashMap<Plan, Integer> referenceIndex = new 
IdentityHashMap<>();
+
+        /**
+         * The identity of one CTE reference.
+         *
+         * @param reference the referencing node
+         * @param written   the name the statement wrote
+         * @return the definition-order index, or null when the name is not a 
CTE
+         */
+        Integer indexOf(Plan reference, String written) {
+            Integer scoped = referenceIndex.get(reference);
+            return scoped != null ? scoped : 
definitionOrder.get(normalizeCteName(written));
+        }
+    }
+
+    /**
+     * Assigns every CTE of the tree (normalized, see normalizeCteName) its
+     * definition-order index AND resolves every reference to the binding 
visible at it.
+     * The two sides of one replay - the caller's raw tree and the parsed 
frozen planSql -
+     * spell the same CTE with different aliases (the decompiler regenerates 
every
+     * derived alias), but the WITH structure and its order are preserved, so 
the
+     * INDEX is the name-independent identity the row-limit keys compare. 
Distinct
+     * CTEs keep distinct indices, so a cap moved between two CTEs still 
changes its
+     * key.
+     *
+     * The walk mirrors walkPlansScoped's scoping rules, carrying the name -> 
index map
+     * instead of the visible-name set so a SHADOWED definition resolves to 
the innermost
+     * binding (round-79 #7).
+     *
+     * @param root the parsed (unbound) tree
+     * @return the identity maps
+     */
+    private static CteOrder cteDefinitionOrder(Plan root) {
+        CteOrder order = new CteOrder();
+        int[] next = {0};
+        assignCteOrder(root, new HashMap<>(), order, next,
+                Collections.newSetFromMap(new IdentityHashMap<>()));
+        return order;
+    }
+
+    /**
+     * One scoped step of cteDefinitionOrder.
+     *
+     * @param root    the node to visit (may be null)
+     * @param visible the name -> index bindings visible at root
+     * @param order   the maps being filled
+     * @param next    the definition-order counter
+     * @param visited the identity set of already-visited nodes
+     */
+    private static void assignCteOrder(Plan root, Map<String, Integer> 
visible, CteOrder order,
+            int[] next, Set<Plan> visited) {
+        if (root == null || !visited.add(root)) {
+            return;
+        }
+        if (root instanceof UnboundRelation) {
+            List<String> parts = ((UnboundRelation) root).getNameParts();
+            if (parts != null && parts.size() == 1) {
+                Integer index = visible.get(normalizeCteName(parts.get(0)));
+                if (index != null) {
+                    order.referenceIndex.put(root, index);
+                }
+            }
+        }
+        if (root instanceof LogicalCTE) {
+            LogicalCTE<? extends Plan> cte = (LogicalCTE<? extends Plan>) root;
+            List<LogicalSubQueryAlias<Plan>> aliases = cte.getAliasQueries();
+            Map<String, Integer> extended = new HashMap<>(visible);
+            for (LogicalSubQueryAlias<Plan> alias : aliases) {
+                String name = normalizeCteName(alias.getAlias());
+                order.definitionOrder.putIfAbsent(name, next[0]);
+                extended.put(name, next[0]++);
+            }
+            assignCteOrder(cte.child(0), extended, order, next, visited);
+            for (int i = 0; i < aliases.size(); i++) {
+                Map<String, Integer> bodyScope = new HashMap<>(visible);
+                for (int j = 0; j < i; j++) {
+                    String name = normalizeCteName(aliases.get(j).getAlias());
+                    bodyScope.put(name, extended.get(name));
+                }
+                // a self reference binds to the WITH clause only in a real 
recursive CTE;
+                // under a plain WITH it is an ordinary base-table reference
+                if (cte.isRecursive() && aliases.get(i).isRecursiveCte()) {
+                    String name = normalizeCteName(aliases.get(i).getAlias());
+                    bodyScope.put(name, extended.get(name));
+                }
+                assignCteOrder(aliases.get(i), bodyScope, order, next, 
visited);
+            }
+            return;
+        }
+        for (Plan child : root.children()) {
+            assignCteOrder(child, visible, order, next, visited);
+        }
+        for (Plan extra : root.extraPlans()) {
+            assignCteOrder(extra, visible, order, next, visited);
+        }
+        for (Expression expression : root.getExpressions()) {
+            assignCteOrderInSubqueries(expression, visible, order, next, 
visited);
+        }
+    }
+
+    /**
+     * Scoped counterpart of walkSubqueryPlans for cteDefinitionOrder.
+     *
+     * @param expression the expression to scan
+     * @param visible    the bindings visible at the expression
+     * @param order      the maps being filled
+     * @param next       the definition-order counter
+     * @param visited    the identity set of already-visited nodes
+     */
+    private static void assignCteOrderInSubqueries(Expression expression,
+            Map<String, Integer> visible, CteOrder order, int[] next, 
Set<Plan> visited) {
+        if (expression instanceof SubqueryExpr) {
+            assignCteOrder(((SubqueryExpr) expression).getQueryPlan(), 
visible, order, next,
+                    visited);
+        }
+        if (expression instanceof UnboundStar) {
+            for (NamedExpression replaced : ((UnboundStar) 
expression).getReplacedAlias()) {
+                assignCteOrderInSubqueries(replaced, visible, order, next, 
visited);
+            }
+        }
+        for (Expression child : expression.children()) {
+            assignCteOrderInSubqueries(child, visible, order, next, visited);
+        }
+    }
+
+    /** Pre-order walk assigning each relation node its per-name occurrence 
ordinal. */
+    private static void assignRelationOrdinals(Plan plan, Map<String, Integer> 
counters,
+            IdentityHashMap<Plan, Integer> ordinals, CteOrder cteOrder,
+            Set<Plan> visited) {
+        if (plan == null || !visited.add(plan)) {
+            return;
+        }
+        String name = relationNameOf(plan, cteOrder);
+        if (name != null) {
+            ordinals.put(plan, counters.merge(name, 1, Integer::sum));
+        }
+        for (Plan child : plan.children()) {
+            assignRelationOrdinals(child, counters, ordinals, cteOrder, 
visited);
+        }
+        for (Plan extra : plan.extraPlans()) {
+            assignRelationOrdinals(extra, counters, ordinals, cteOrder, 
visited);
+        }
+        for (Expression expression : plan.getExpressions()) {
+            assignRelationOrdinals(expression, counters, ordinals, cteOrder, 
visited);
+        }
+    }
+
+    /** Recurses one expression tree for relation ordinals (see 
assignRelationOrdinals). */
+    private static void assignRelationOrdinals(Expression expression,
+            Map<String, Integer> counters, IdentityHashMap<Plan, Integer> 
ordinals,
+            CteOrder cteOrder, Set<Plan> visited) {
+        if (expression instanceof SubqueryExpr) {
+            assignRelationOrdinals(((SubqueryExpr) expression).getQueryPlan(), 
counters,
+                    ordinals, cteOrder, visited);
+        }
+        if (expression instanceof UnboundStar) {
+            for (NamedExpression replaced : ((UnboundStar) 
expression).getReplacedAlias()) {
+                assignRelationOrdinals(replaced, counters, ordinals, cteOrder, 
visited);
+            }
+        }
+        for (Expression child : expression.children()) {
+            assignRelationOrdinals(child, counters, ordinals, cteOrder, 
visited);
+        }
+    }
+
+    /**
+     * The identity of one row-limiting node: its values, its ORDERED SLICE 
and the
+     * occurrence-tagged relations it truncates (see the collectRowLimits 
javadoc).
+     *
+     * The slice is part of the identity: equal (limit, offset,
+     * inputs) is NOT enough when the cap truncates a DIFFERENT slice of the 
same input -
+     * an ASC/DESC change of the TopN picks other rows, and a manual plan may 
keep the cap
+     * at a different position of the same query block. The key therefore 
carries the
+     * cap's sort ARITY with each key's direction and NULL placement ("-" for 
an
+     * order-free LogicalLimit, which truncates an arbitrary slice either way).
+     *
+     * Two further candidate dimensions are deliberately NOT part of the key 
because
+     * they are not comparable ACROSS trees: the order keys' EXPRESSION TEXT 
(the caller
+     * and the frozen replay are different trees whose sort keys reference 
per-tree slots
+     * - the TPCH q02 cap sorts by c_16 in the frozen text and by
+     * s_acctbal for the caller), and whether an AGGREGATE sits below the cap 
(a
+     * scalar subquery exists as a materialized JOIN + aggregate in the 
decompiled frozen
+     * text while the caller's own tree still carries it as an expression node 
- TPCH q02
+     * was rejected by exactly that mismatch although both plans truncate the 
same slice).
+     */
+    private static String rowLimitKey(long limit, long offset, Plan limitNode,
+            IdentityHashMap<Plan, Integer> relationOrdinals,
+            CteOrder cteOrder) {
+        Set<String> inputs = new TreeSet<>();
+        collectRelationInputs(limitNode, relationOrdinals, cteOrder, inputs,
+                Collections.newSetFromMap(new IdentityHashMap<>()));
+        String slice = "-";
+        if (limitNode instanceof LogicalTopN) {
+            List<OrderKey> orderKeys = ((LogicalTopN<?>) 
limitNode).getOrderKeys();
+            StringBuilder keys = new 
StringBuilder(String.valueOf(orderKeys.size()));
+            for (OrderKey key : orderKeys) {
+                keys.append(key.isAsc() ? '+' : '-')
+                        .append(key.isNullFirst() ? 'n' : 'l').append(';');
+            }
+            slice = keys.toString();
+        }
+        return limit + ":" + offset + ":" + slice + ":" + String.join(",", 
inputs);
+    }
+
+    /**
+     * The OCCURRENCE-TAGGED name of every relation beneath a row-limiting 
node (children,
+     * CTE bodies and expression-owned subquery plans included): db.t#N with N 
the
+     * node's per-name ordinal in the tree's walk order. A subtree without a 
relation (a
+     * FROM-less child) contributes nothing - the key then identifies the cap 
by its
+     * values alone, exactly as the caller's equivalent cap does.
+     */
+    private static void collectRelationInputs(Plan plan,
+            IdentityHashMap<Plan, Integer> relationOrdinals,
+            CteOrder cteOrder, Set<String> out,
+            Set<Plan> visited) {
+        if (plan == null || !visited.add(plan)) {
+            return;
+        }
+        String name = relationNameOf(plan, cteOrder);
+        if (name != null) {
+            Integer ordinal = relationOrdinals.get(plan);
+            out.add(ordinal == null ? name : name + "#" + ordinal);
+        }
+        for (Plan child : plan.children()) {
+            collectRelationInputs(child, relationOrdinals, cteOrder, out, 
visited);
+        }
+        for (Plan extra : plan.extraPlans()) {
+            collectRelationInputs(extra, relationOrdinals, cteOrder, out, 
visited);
+        }
+        for (Expression expression : plan.getExpressions()) {
+            collectRelationInputs(expression, relationOrdinals, cteOrder, out, 
visited);
+        }
+    }
+
+    /** Recurses one expression tree for relation inputs (see 
collectRelationInputs). */
+    private static void collectRelationInputs(Expression expression,
+            IdentityHashMap<Plan, Integer> relationOrdinals,
+            CteOrder cteOrder, Set<String> out,
+            Set<Plan> visited) {
+        if (expression instanceof SubqueryExpr) {
+            collectRelationInputs(((SubqueryExpr) expression).getQueryPlan(),
+                    relationOrdinals, cteOrder, out, visited);
+        }
+        if (expression instanceof UnboundStar) {
+            for (NamedExpression replaced : ((UnboundStar) 
expression).getReplacedAlias()) {
+                collectRelationInputs(replaced, relationOrdinals, cteOrder, 
out, visited);
+            }
+        }
+        for (Expression child : expression.children()) {
+            collectRelationInputs(child, relationOrdinals, cteOrder, out, 
visited);
+        }
+    }
+
+    /**
+     * The identity name of one relation node used by the row-limit keys, or 
null when
+     * the node is not a relation.
+     *
+     * A one-part relation bound by a CTE alias is named by its CTE's 
DEFINITION ORDER
+     * index ("cte#N"), not by the alias: the frozen planSql replayed for an
+     * identical-text baseline is the DECOMPILED plan, and the decompiler 
regenerates
+     * every WITH alias (the caller's `ws_wh` becomes `t_4`) while preserving 
the WITH
+     * structure and its order (see SPMPlanTreeSupport#cteDefinitionOrder). 
Comparing
+     * the alias verbatim made the identical query fail its own cap contract. 
The
+     * index keeps distinct CTEs distinct, so a cap moved between two CTEs 
still
+     * changes its key.
+     */
+    private static String relationNameOf(Plan plan, CteOrder cteOrder) {
+        if (plan instanceof UnboundRelation) {
+            List<String> parts = ((UnboundRelation) plan).getNameParts();
+            if (parts == null || parts.isEmpty()) {
+                return null;
+            }
+            if (parts.size() == 1 && cteOrder != null) {
+                Integer cteIndex = cteOrder.indexOf(plan, parts.get(0));
+                if (cteIndex != null) {
+                    return "cte#" + cteIndex;
+                }
+            }
+            return String.join(".", parts);
+        }
+        if (plan instanceof LogicalCatalogRelation) {
+            return ((LogicalCatalogRelation) 
plan).getTable().getNameWithFullQualifiers();
+        }
+        if (plan instanceof UnboundTVFRelation) {
+            return ((UnboundTVFRelation) plan).getFunctionName();
+        }
+        return null;
+    }
+
+    // ==================== whole-tree transform (parameterize / substitute) 
====================
+
+    /**
+     * Rebuilds the whole plan tree applying transform to every 
literal-carrying
+     * expression of every plan node (in bottom-up order). Nodes whose type is 
not
+     * explicitly handled are rebuilt through withChildren only (their
+     * expressions are kept untouched, which only narrows what can be 
parameterized /
+     * substituted - never breaks the tree).
+     *
+     * @param plan      the parsed (unbound) plan tree
+     * @param transform the expression transform (parameterize or substitute)
+     * @return the rebuilt tree (the original instance when nothing changed)
+     */
+    public static LogicalPlan transform(LogicalPlan plan, ExprTransform 
transform) {
+        Plan result = plan.accept(new TreeTransformer(), transform);
+        return result instanceof LogicalPlan ? (LogicalPlan) result : plan;
+    }
+
+    /** Plan visitor that rebuilds every node, transforming its expressions. */
+    private static class TreeTransformer extends PlanVisitor<Plan, 
ExprTransform> {
+
+        /**
+         * GROUP BY mirror records (see mirrorProjectItem): a group-by 
expression's
+         * pre-parameterization text mapped to its transformed self. A select 
item spelling
+         * the SAME expression must reuse those placeholder ids, or the 
parameterized tree
+         * fails NormalizeAggregate ("PROJECT expression ... must appear in 
the GROUP BY
+         * clause"; tpcds q23_1 groups by substr(i_item_desc, 1, 30) and 
selects the same
+         * expression, whose literals otherwise receive their own ids and make 
the two
+         * copies structurally different). Records stay consumable for the 
projection
+         * directly above the aggregate and are dropped once its items were 
processed.
+         */
+        private final java.util.List<GroupByMirror> groupByMirrors = new 
java.util.ArrayList<>();
+
+        /** One recorded group-by mirror (pre-text + the transformed 
expression). */
+        private static final class GroupByMirror {
+            private final String text;
+            private final Expression expression;
+
+            private GroupByMirror(String text, Expression expression) {
+                this.text = text;
+                this.expression = expression;
+            }
+        }
+
+        @Override
+        public Plan visit(Plan plan, ExprTransform transform) {
+            // LogicalUsingJoin.accept() dispatches to this generic visit 
(PlanVisitor has no
+            // using-join overload), so the ASOF MATCH_CONDITION is handled 
here: it is
+            // stored OUTSIDE children() and getExpressions() and would 
otherwise keep its
+            // literal concrete.
+            if (plan instanceof LogicalUsingJoin) {
+                return visitUsingJoin((LogicalUsingJoin<?, ?>) plan, 
transform);
+            }
+            List<Plan> children = plan.children();
+            boolean changed = false;
+            List<Plan> newChildren = new ArrayList<>(children.size());
+            for (Plan child : children) {
+                Plan newChild = child.accept(this, transform);
+                newChildren.add(newChild);
+                if (newChild != child) {
+                    changed = true;
+                }
+            }
+            if (!changed) {
+                return plan;
+            }
+            try {
+                return plan.withChildren(newChildren);
+            } catch (RuntimeException e) {
+                // plan type without withChildren -> keep the original children
+                return plan;
+            }
+        }
+
+        @Override
+        public Plan visitLogicalCTE(LogicalCTE<? extends Plan> cte, 
ExprTransform transform) {
+            // the main query subtree, then the CTE bodies (kept in 
aliasQueries, not in
+            // children()) - a WHERE inside a CTE definition is transformed 
too.
+            // A ScopedTransform needs per-scope contexts here: the main query 
sees every
+            // alias of this WITH, alias body i only the aliases defined 
before it.
+            // A WITH is a QUERY-BLOCK boundary for the GROUP BY mirrors as 
well: its
+            // aggregates record mirrors for their own block's SELECT / HAVING 
/ ORDER BY
+            // only, and nothing recorded inside may be consumed by an item 
list above the
+            // WITH (see visitLogicalSubQueryAlias / mirrorProjectItem).
+            int mirrorMark = groupByMirrors.size();
+            try {
+                ExprTransform mainContext = transform instanceof 
ScopedTransform
+                        ? ((ScopedTransform) transform).enterCteMain(cte) : 
transform;
+                Plan newChild = cte.child(0) == null ? null : 
cte.child(0).accept(this, mainContext);
+                boolean childChanged = newChild != cte.child(0);
+                List<LogicalSubQueryAlias<Plan>> newAliasQueries =
+                        new ArrayList<>(cte.getAliasQueries().size());
+                boolean aliasChanged = false;
+                for (int i = 0; i < cte.getAliasQueries().size(); i++) {
+                    LogicalSubQueryAlias<Plan> aliasQuery = 
cte.getAliasQueries().get(i);
+                    ExprTransform aliasContext = transform instanceof 
ScopedTransform
+                            ? ((ScopedTransform) transform).enterCteAlias(cte, 
i) : transform;
+                    Plan newAliasQuery = aliasQuery.accept(this, aliasContext);
+                    newAliasQueries.add((LogicalSubQueryAlias<Plan>) 
newAliasQuery);
+                    if (newAliasQuery != aliasQuery) {
+                        aliasChanged = true;
+                    }
+                }
+                if (!childChanged && !aliasChanged) {
+                    return cte;
+                }
+                try {
+                    return new LogicalCTE<Plan>(cte.isRecursive(), 
newAliasQueries, newChild);
+                } catch (RuntimeException e) {
+                    return cte;
+                }
+            } finally {
+                dropGroupByMirrorsFrom(mirrorMark);
+            }
+        }
+
+        @Override
+        public Plan visitLogicalSubQueryAlias(LogicalSubQueryAlias<? extends 
Plan> alias,
+                ExprTransform transform) {
+            // A derived table / CTE body opens a nested QUERY BLOCK (see
+            // ExprTransform#enterQueryBlock): its literals must be able to 
distinguish
+            // themselves from equally shaped literals of the outer block or 
of a sibling.
+            // The numbering is per rebuild and both trees of a baseline 
traverse the same
+            // structure, so corresponding blocks keep corresponding ids.
+            transform.enterQueryBlock();
+            int mirrorMark = groupByMirrors.size();
+            try {
+                Plan child = alias.child() == null ? null : 
alias.child().accept(this, transform);
+                if (child == alias.child()) {
+                    return alias;
+                }
+                return alias.withChildren(java.util.List.of(child));
+            } finally {
+                transform.exitQueryBlock();
+                // A derived table / CTE body is its OWN query block: the 
GROUP BY mirrors its
+                // aggregate recorded must be consumable ONLY by that block's 
own SELECT /
+                // HAVING / ORDER BY (all inside this subtree). Leaving them 
behind let the
+                // ENCLOSING block's projection - the item list ABOVE this 
alias - consume the
+                // inner expression's placeholder ids for an equally spelled 
item, so two
+                // DIFFERENT values shared one id and a valid literal variant 
could never use
+                // its baseline (see mirrorProjectItem).
+                dropGroupByMirrorsFrom(mirrorMark);
+            }
+        }
+
+        @Override
+        public Plan visitUnboundRelation(UnboundRelation relation, 
ExprTransform transform) {
+            // of the whole-tree transforms, only namespace qualification 
rewrites relation
+            // references; parameterization / substitution leave them untouched
+            return transform instanceof QualifyTransform
+                    ? ((QualifyTransform) transform).qualify(relation) : 
relation;
+        }
+
+        @Override
+        public Plan visitLogicalProject(LogicalProject<? extends Plan> project,
+                ExprTransform transform) {
+            int mirrorMark = groupByMirrors.size();
+            Plan child = project.child().accept(this, transform);
+            boolean changed = child != project.child();
+            List<NamedExpression> newProjects = 
transformProjectItems(project.getProjects(),
+                    transform, mirrorMark);
+            dropGroupByMirrorsFrom(mirrorMark);
+            if (newProjects != project.getProjects()) {
+                changed = true;
+            }
+            if (!changed) {
+                return project;
+            }
+            return project.withProjectsAndChild(newProjects, child);
+        }
+
+        /**
+         * transformNamed with the GROUP BY mirror: an item whose 
pre-parameterization value
+         * text equals a group-by expression recorded by the aggregate below 
inherits that
+         * expression WITH its placeholder ids, keeping the two structurally 
equal (the
+         * analyzer requires a select expression to literally reappear in the 
GROUP BY: the
+         * ids allocated for one copy cannot be re-derived for the other, see
+         * mirrorProjectItem).
+         */
+        private List<NamedExpression> 
transformProjectItems(List<NamedExpression> expressions,
+                ExprTransform transform, int mirrorMark) {
+            boolean changed = false;
+            List<NamedExpression> newExpressions = new 
ArrayList<>(expressions.size());
+            for (int i = 0; i < expressions.size(); i++) {
+                NamedExpression expression = expressions.get(i);
+                NamedExpression mirrored = mirrorProjectItem(expression, 
mirrorMark);
+                if (mirrored != null) {
+                    newExpressions.add(mirrored);
+                    changed = true;
+                    continue;
+                }
+                // the item's POSITION in the list is part of the placeholder 
identity (see
+                // ExprTransform#enterProjectionItem)
+                transform.enterProjectionItem(i);
+                Expression transformed;
+                try {
+                    transformed = transform.apply(expression);
+                } finally {
+                    transform.exitProjectionItem();
+                }
+                if (transformed instanceof NamedExpression && transformed != 
expression) {
+                    newExpressions.add((NamedExpression) transformed);
+                    changed = true;
+                } else {
+                    newExpressions.add(expression);
+                }
+            }
+            return changed ? newExpressions : expressions;
+        }
+
+        /** One item rewritten to the recorded group-by expression, or null 
when none matches. */
+        private NamedExpression mirrorProjectItem(NamedExpression item, int 
mirrorMark) {
+            // the raw parse tree wraps select items in UnboundAlias, the 
analyzed tree in
+            // Alias: both expose the aliased expression as their only child
+            Expression value = item instanceof Alias || item instanceof 
UnboundAlias
+                    ? item.child(0) : item;
+            String text = value.toString();

Review Comment:
   [P2] Include qualified identities in GROUP BY mirror keys
   
   With valid same-signature scalar UDFs db1.f/db2.f, capture identical 
bind/plan SQL:
   ```sql
   SELECT db1.f(k,1) AS x, count(*) AS n FROM t
   GROUP BY db1.f(k,1), db2.f(k,1)
   ```
   `visitLogicalAggregate` records both transformed group keys by `toString()`, 
which omits UnboundFunction's database. This reverse lookup therefore replaces 
the db1.f output with the last db2.f key. Identical bind/plan SQL shares that 
altered parameterized tree; both group keys exist, so CREATE still plans and 
stores it. The caller's namespace-qualified tree retains db1.f, and 
`SPMAstCheckVisitor` rejects its database against stored db2.f, so the baseline 
cannot match even its identical query. Use a structural identity including 
qualified functions and slot components, including the HAVING/ORDER BY mirror 
lookup. This is intra-tree parameterization, separate from the repaired 
qualified-function checks for manual plan pairs.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to