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 <placeholder>` 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 > 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<NULL_TYPE>, 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<NULL_TYPE> - and the wrapper was NOT recognized, so + * `k > 0 OR element_at(array_concat(array(NULL), array(NULL)), 1)` kept an ARRAY<NULL_TYPE> + * 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]
