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


##########
fe/fe-core/src/main/java/org/apache/doris/nereids/spm/capture/PlanCaptureManager.java:
##########
@@ -0,0 +1,1192 @@
+// 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.capture;
+
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.FeConstants;
+import org.apache.doris.common.UserException;
+import org.apache.doris.common.util.MasterDaemon;
+import org.apache.doris.nereids.spm.BaselinePlan;
+import org.apache.doris.nereids.spm.BaselineSource;
+import org.apache.doris.nereids.spm.SPMPlanner;
+import org.apache.doris.nereids.spm.SPMUtils;
+import org.apache.doris.nereids.spm.manager.BaselineManager;
+import org.apache.doris.qe.AutoCloseConnectContext;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.GlobalVariable;
+import org.apache.doris.qe.SessionVariable;
+import org.apache.doris.qe.SqlModeHelper;
+import org.apache.doris.qe.VariableMgr;
+import org.apache.doris.statistics.repository.ResultRow;
+import org.apache.doris.statistics.util.StatisticsUtil;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Supplier;
+
+/**
+ * PlanCaptureManager - SPM auto capture scheduler (Phase 2, design doc 7.2.1 
/ 7.2.4).
+ *
+ * A Leader-FE daemon that periodically scans the audit_log internal table and
+ * automatically creates baselines for high-value queries:
+ *
+ * - only queries executed by the Nereids planner are captured;
+ * - the capture filter (PlanCaptureFilter) enforces the multi-table / 
table-exists /
+ *   regex / performance-threshold rules;
+ * - the baseline is built through the Phase 1 flow 
(SPMPlanner.buildBaselineFromSql:
+ *   SPM-mode optimize + decompile + parameterize) with source = CAPTURE and 
the actual
+ *   query_time filled for candidate ordering;
+ * - duplicate (digest, planSql) baselines are skipped (BaselineManager dedup).
+ *
+ * The whole cycle is guarded by the global session variable 
enable_plan_capture
+ * (default false, tunable via `SET GLOBAL enable_plan_capture = true`), and 
any
+ * failure is logged and skipped so auto capture never breaks the cluster.
+ */
+public class PlanCaptureManager extends MasterDaemon {
+
+    private static final Logger LOG = 
LogManager.getLogger(PlanCaptureManager.class);
+
+    private static final PlanCaptureManager INSTANCE = new 
PlanCaptureManager();
+
+    /**
+     * Re-scan overlap (millis) applied to the watermark: AuditLoader buffers 
events
+     * asynchronously and writes their original event timestamp, so a row can 
become
+     * visible AFTER its window has passed (it would otherwise be excluded 
from every
+     * future window forever). Re-scanning a lagged/overlapping window plus 
query-id
+     * deduplication makes late arrivals capturable without processing an 
execution
+     * twice.
+     */
+    private static final long SCAN_WINDOW_OVERLAP_MS = 300_000L;
+
+    /** Upper bound for the processed-query-id dedup map. */
+    private static final int MAX_TRACKED_QUERY_IDS = 10000;
+
+    /**
+     * Bounded retries for a FAILED capture: the query id stays retryable for 
later
+     * overlapping scans until it either succeeds or reaches this attempt 
count. Marking
+     * the id before processing would make a transient failure permanent - the
+     * overlapping scans would skip the row and the watermark passes it long 
before the
+     * dedup map evicts the entry.
+     */
+    private static final int MAX_CAPTURE_ATTEMPTS = 3;
+
+    /** Durable checkpoint key: the internal table holds exactly one row. */
+    private static final long CHECKPOINT_ID = 1L;
+
+    /** Upper bound for the retry entries written into the checkpoint row (row 
size). */
+    private static final int MAX_PERSISTED_RETRIES = 64;
+
+    /** Table of the durable capture checkpoint (see InternalSchema). */
+    private static final String CHECKPOINT_TABLE =
+            "`__internal_schema`.`spm_capture_checkpoint`";
+
+    private static final String CHECKPOINT_SELECT_SQL =
+            "SELECT `last_scan_timestamp`, `pending_window_start`, 
`pending_window_end`,"
+                    + " `cursor_query_time`, `cursor_time`, `cursor_query_id`,"
+                    + " `failed_attempts`, `retry_queue`, `cursor_tail` FROM " 
+ CHECKPOINT_TABLE
+                    + " WHERE `id` = " + CHECKPOINT_ID + " ORDER BY 
`update_time` DESC LIMIT 1";
+
+    /**
+     * One UPSERT statement: the table is UNIQUE-key(id) with merge-on-write, 
so inserting
+     * the row again REPLACES it atomically. The previous delete-then-insert 
pair was two
+     * separately committed statements: a crash / leadership loss / timeout / 
failed
+     * INSERT after the DELETE left NO row for the next leader, which then 
derived a fresh
+     * window and permanently skipped the deleted pending window's unconsumed 
tail.
+     */
+    private static final String CHECKPOINT_INSERT_SQL =

Review Comment:
   [P1] Keep the checkpoint column layout consistent for upgraded tables. 
`SPM_CAPTURE_CHECKPOINT_SCHEMA` places `cursor_tail` before `failed_attempts`, 
but `upgradeSpmCaptureCheckpointSchema()` adds it with a null `ColumnPosition`, 
which appends it after `update_time`. `CHECKPOINT_INSERT_SQL` is a positional 
`INSERT ... VALUES` in the new order, so on an upgraded table the tail JSON is 
sent to `failed_attempts`, the retry JSON to `update_time`, and `NOW()` to 
`cursor_tail`; checkpoint writes then fail or persist invalid state, losing the 
capture cursor across leader handoff. Add the column at the intended position 
or use an explicit column list/migrate the old layout, and test an upgraded 
table before relying on the checkpoint.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/spm/SPMPlanTreeSupport.java:
##########
@@ -0,0 +1,1789 @@
+// 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.StatementContext;
+import org.apache.doris.nereids.analyzer.UnboundAlias;
+import org.apache.doris.nereids.analyzer.UnboundFunction;
+import org.apache.doris.nereids.analyzer.UnboundOneRowRelation;
+import org.apache.doris.nereids.analyzer.UnboundRelation;
+import org.apache.doris.nereids.analyzer.UnboundTVFRelation;
+import org.apache.doris.nereids.properties.OrderKey;
+import org.apache.doris.nereids.rules.exploration.join.JoinReorderContext;
+import org.apache.doris.nereids.spm.matcher.SPMAstCheckVisitor;
+import org.apache.doris.nereids.spm.matcher.SPMFrozenTreeReplacer;
+import org.apache.doris.nereids.spm.placeholder.SpmConstList;
+import org.apache.doris.nereids.spm.placeholder.SpmConstVar;
+import org.apache.doris.nereids.trees.expressions.Alias;
+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.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.scalar.ConnectionId;
+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.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.logical.LogicalAggregate;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCTE;
+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.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.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.annotations.VisibleForTesting;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+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 {
+
+    /** Expression transform used by transform. */
+    public interface ExprTransform {
+        Expression apply(Expression expr);
+    }
+
+    /**
+     * 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 = expr -> {
+        if (expr instanceof SubqueryExpr) {
+            LogicalPlan subPlan = ((SubqueryExpr) expr).getQueryPlan();
+            LogicalPlan stripped = stripSelectHints(subPlan);
+            if (stripped != subPlan) {
+                return ((SubqueryExpr) expr).withSubquery(stripped);
+            }
+        }
+        return expr;
+    };
+
+    private SPMPlanTreeSupport() {
+    }
+
+    // ==================== 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> {
+        @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.
+            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;
+            }
+        }
+
+        @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) {
+            Plan child = project.child().accept(this, transform);
+            boolean changed = child != project.child();
+            List<NamedExpression> newProjects = 
transformNamed(project.getProjects(), transform);
+            if (newProjects != project.getProjects()) {
+                changed = true;
+            }
+            if (!changed) {
+                return project;
+            }
+            return project.withProjectsAndChild(newProjects, child);
+        }
+
+        @Override
+        public Plan visitLogicalFilter(LogicalFilter<? extends Plan> filter,
+                ExprTransform transform) {
+            Plan child = filter.child().accept(this, transform);
+            boolean changed = child != filter.child();
+            Set<Expression> newConjuncts = 
transformConjuncts(filter.getConjuncts(), transform);
+            if (newConjuncts != filter.getConjuncts()) {
+                changed = true;
+            }
+            if (!changed) {
+                return filter;
+            }
+            return filter.withConjunctsAndChild(newConjuncts, child);
+        }
+
+        @Override
+        public Plan visitLogicalJoin(LogicalJoin<? extends Plan, ? extends 
Plan> join,
+                ExprTransform transform) {
+            // A literal can live directly in a JOIN's ON clause (e.g.
+            // "a JOIN b ON a.k = b.k AND a.cat = 'x'"); it is NOT pushed into 
a Filter
+            // on the unbound (raw) tree that parameterization runs over. 
Without handling
+            // the join's own conjuncts here those literals would stay 
concrete in the
+            // parameterized bind tree, so a structurally identical query with 
a different
+            // literal would fail the Level 3 structural match and never hit 
the baseline.
+            Plan left = join.left().accept(this, transform);
+            Plan right = join.right().accept(this, transform);
+            List<Expression> hash = 
transformExprs(join.getHashJoinConjuncts(), transform);
+            List<Expression> other = 
transformExprs(join.getOtherJoinConjuncts(), transform);
+            List<Expression> mark = 
transformExprs(join.getMarkJoinConjuncts(), transform);
+            boolean changed = left != join.left() || right != join.right()
+                    || hash != join.getHashJoinConjuncts()
+                    || other != join.getOtherJoinConjuncts()
+                    || mark != join.getMarkJoinConjuncts();
+            if (!changed) {
+                return join;
+            }
+            return join.withConjunctsChildren(hash, other, mark, left, right,
+                    new JoinReorderContext());
+        }
+
+        /**
+         * Rebuilds an ASOF / USING join, transforming the MATCH_CONDITION. 
The condition is
+         * stored in {@code matchCondition}, OUTSIDE both children() and
+         * getExpressions() (which returns the USING slots), so the generic 
pass would
+         * leave its literal concrete on the bind side - the Level 3 match 
would then
+         * compare only the USING slots and accept a user variant with a 
different
+         * temporal boundary, replaying the captured one.
+         */
+        private Plan visitUsingJoin(LogicalUsingJoin<? extends Plan, ? extends 
Plan> join,
+                ExprTransform transform) {
+            Plan left = join.left().accept(this, transform);
+            Plan right = join.right().accept(this, transform);
+            Optional<Expression> match = join.getMatchCondition();
+            Optional<Expression> newMatch = match.isPresent()
+                    ? Optional.of(transform.apply(match.get())) : match;
+            if (left == join.left() && right == join.right() && 
newMatch.equals(match)) {
+                return join;
+            }
+            return new LogicalUsingJoin<>(join.getJoinType(), left, right,
+                    join.getUsingSlots(), newMatch, join.getDistributeHint());
+        }
+
+        @Override
+        public Plan visitLogicalHaving(LogicalHaving<? extends Plan> having, 
ExprTransform transform) {
+            Plan child = having.child().accept(this, transform);
+            boolean changed = child != having.child();
+            Set<Expression> newConjuncts = 
transformConjuncts(having.getConjuncts(), transform);
+            if (newConjuncts != having.getConjuncts()) {
+                changed = true;
+            }
+            if (!changed) {
+                return having;
+            }
+            return having.withConjunctsAndChild(newConjuncts, child);
+        }
+
+        @Override
+        public Plan visitLogicalAggregate(LogicalAggregate<? extends Plan> 
aggregate,
+                ExprTransform transform) {
+            Plan child = aggregate.child().accept(this, transform);
+            boolean changed = child != aggregate.child();
+            List<Expression> newGroupBy = new 
ArrayList<>(aggregate.getGroupByExpressions().size());
+            for (Expression groupByExpr : aggregate.getGroupByExpressions()) {
+                Expression newExpr = transform.apply(groupByExpr);
+                newGroupBy.add(newExpr);
+                if (newExpr != groupByExpr) {
+                    changed = true;
+                }
+            }
+            List<NamedExpression> newOutput = 
transformNamed(aggregate.getOutputExpressions(),
+                    transform);
+            if (newOutput != aggregate.getOutputExpressions()) {
+                changed = true;
+            }
+            if (!changed) {
+                return aggregate;
+            }
+            return aggregate.withChildGroupByAndOutput(newGroupBy, newOutput, 
child);
+        }
+
+        @Override
+        public Plan visitUnboundOneRowRelation(UnboundOneRowRelation oneRow,
+                ExprTransform transform) {
+            List<NamedExpression> newProjects = 
transformNamed(oneRow.getProjects(), transform);
+            if (newProjects == oneRow.getProjects()) {
+                return oneRow;
+            }
+            return new UnboundOneRowRelation(oneRow.getRelationId(), 
newProjects);
+        }
+
+        @Override
+        public Plan visitLogicalOneRowRelation(LogicalOneRowRelation oneRow,
+                ExprTransform transform) {
+            List<NamedExpression> newProjects = 
transformNamed(oneRow.getProjects(), transform);
+            if (newProjects == oneRow.getProjects()) {
+                return oneRow;
+            }
+            return new LogicalOneRowRelation(oneRow.getRelationId(), 
newProjects);
+        }
+
+        @Override
+        public Plan visitLogicalSort(LogicalSort<? extends Plan> sort,
+                ExprTransform transform) {
+            // ORDER BY keys can carry literals (e.g. substr(w_warehouse_name, 
1, 20) in a
+            // TPCDS query): they must be parameterized / substituted like any 
other
+            // expression, otherwise the rewritten tree would keep the 
bind-side order key
+            // while group-by / project got the user's value -> analyze error 
/ wrong order.
+            //
+            // EXCEPTION: an ORDER BY whose key is a BARE integer literal is 
an ORDINAL
+            // (position reference, e.g. ORDER BY 1, 2), resolved by 
BindExpression during
+            // analyze. It must NOT be parameterized: parameterizing would 
turn the ordinal
+            // into a placeholder function and the optimizer would then sort 
by the
+            // constant value (projecting it as an extra column) instead of by 
the
+            // referenced output column - silently changing the query's row 
order.
+            Plan child = sort.child().accept(this, transform);
+            boolean changed = child != sort.child();
+            List<OrderKey> orderKeys = sort.getOrderKeys();
+            List<OrderKey> newOrderKeys = new ArrayList<>(orderKeys.size());
+            for (OrderKey orderKey : orderKeys) {
+                Expression keyExpr = orderKey.getExpr();
+                Expression newExpr = keyExpr instanceof IntegerLikeLiteral
+                        ? keyExpr : transform.apply(keyExpr);
+                if (newExpr != keyExpr) {
+                    changed = true;
+                }
+                newOrderKeys.add(orderKey.withExpression(newExpr));
+            }
+            if (!changed) {
+                return sort;
+            }
+            return sort.withOrderKeysAndChild(newOrderKeys, child);
+        }
+
+        @Override
+        public Plan visitLogicalQualify(LogicalQualify<? extends Plan> qualify,
+                ExprTransform transform) {
+            // QUALIFY <window-predicate>: the predicates live on the 
LogicalQualify node
+            // itself (e.g. "row_number() over (...) = 1") and would otherwise 
keep their
+            // literals concrete in the parameterized tree, so a user query 
with a
+            // different QUALIFY threshold never matches the baseline.
+            Plan child = qualify.child().accept(this, transform);
+            boolean changed = child != qualify.child();
+            Set<Expression> newConjuncts = 
transformConjuncts(qualify.getConjuncts(), transform);
+            if (newConjuncts != qualify.getConjuncts()) {
+                changed = true;
+            }
+            if (!changed) {
+                return qualify;
+            }
+            return new LogicalQualify<Plan>(newConjuncts, child);
+        }
+
+        @Override
+        public Plan visitLogicalGenerate(LogicalGenerate<? extends Plan> 
generate,
+                ExprTransform transform) {
+            // LATERAL VIEW / UNNEST: the generator arguments (e.g.
+            // explode(split('a,b,c', ','))) decide which rows/values the 
generator emits -
+            // they are NOT predicates (no WHERE-like semantics), so a 
different argument
+            // means a different query. They are therefore intentionally left 
concrete and
+            // never parameterized: SPM only matches a user query carrying the 
exact same
+            // generator argument. The conjuncts field is the only WHERE-like 
part of this
+            // node (empty at parse time), so it is still transformed for 
future-proofing.
+            Plan child = generate.child().accept(this, transform);
+            boolean changed = child != generate.child();
+            List<Expression> newConjuncts = 
transformExprs(generate.getConjuncts(), transform);
+            if (newConjuncts != generate.getConjuncts()) {
+                changed = true;
+            }
+            if (!changed) {
+                return generate;
+            }
+            return new LogicalGenerate<Plan>(generate.getGenerators(),
+                    generate.getGeneratorOutput(), 
generate.getExpandColumnAlias(),
+                    newConjuncts, child);
+        }
+
+        @Override
+        public Plan visitLogicalRepeat(LogicalRepeat<? extends Plan> repeat,
+                ExprTransform transform) {
+            // GROUPING SETS / ROLLUP / CUBE.
+            // The outputExpressions are the SELECT-list items of the query 
block (constants
+            // like "SELECT 5 AS tag" live here) and need parameterizes select 
items
+            // like any other expression -> parameterize them so a user value 
change can
+            // match and be substituted. The groupingSets are the grouping 
keys (GROUP BY
+            // columns); we does not traverse them -> they stay concrete.
+            Plan child = repeat.child().accept(this, transform);
+            boolean changed = child != repeat.child();
+            List<NamedExpression> newOutput = 
transformNamed(repeat.getOutputExpressions(),
+                    transform);
+            if (newOutput != repeat.getOutputExpressions()) {
+                changed = true;
+            }
+            if (!changed) {
+                return repeat;
+            }
+            // NOTE: the overload that takes the grouping-id VALUES rebuilds 
through a
+            // constructor that forces withInProjection=true, which flips 
toDigest() from
+            // "SELECT <outputs> FROM <child>" to "<child>". A rebuild must 
keep the
+            // parse-time rendering state: only the qualification below 
changes a digest,
+            // not the fact that a node was rebuilt on the way. The 4-arg 
overload reuses
+            // the existing grouping-id values and keeps withInProjection 
as-is.
+            return repeat.withGroupingIdValues(repeat.getGroupingSets(), 
newOutput,
+                    repeat.getGroupingId().orElse(null), child);
+        }
+    }
+
+    /**
+     * Transforms an ordinary (non-Named) expression list in place-free way; 
returns the
+     * original list when no element changed.
+     *
+     * Used for expression LISTS that are not SELECT items and carry no output 
name/alias
+     * of their own, e.g. a join's conjuncts or a LogicalGenerate's conjuncts. 
Example -
+     * the ON / residual predicates of
+     *
+     *     SELECT ... FROM t1 JOIN t2 ON t1.k = t2.k WHERE t2.a > 100 AND t2.b 
= 'x'
+     *
+     * are parameterized one conjunct at a time, so a user query whose literal 
differs
+     * (e.g. t2.a > 200) still structurally matches the baseline. The list 
keeps its
+     * order (no Set normalization - join conjunct order is meaningful).
+     *
+     * @param expressions the expression list to transform
+     * @param transform   the per-expression transform (parameterize or 
substitute)
+     * @return the transformed list, or the original list instance when 
nothing changed
+     */
+    private static List<Expression> transformExprs(List<Expression> 
expressions,
+            ExprTransform transform) {
+        boolean changed = false;
+        List<Expression> newExpressions = new ArrayList<>(expressions.size());
+        for (Expression expression : expressions) {
+            Expression transformed = transform.apply(expression);
+            newExpressions.add(transformed);
+            if (transformed != expression) {
+                changed = true;
+            }
+        }
+        return changed ? newExpressions : expressions;
+    }
+
+    /**
+     * Transforms a NamedExpression list (SELECT items / aggregate outputs) in 
place-free
+     * way; returns the original list when no element changed.
+     *
+     * A NamedExpression is an expression paired with an output name/alias 
(e.g.
+     * Alias(expr, name)). The transform runs over the WHOLE NamedExpression - 
including
+     * the alias name itself when it is derived from the expression text - so 
its inner
+     * literals are parameterized while the output column name is kept 
consistent with
+     * the (also parameterized) bind side. Example - the SELECT list of
+     *
+     *     SELECT l_returnflag, sum(l_quantity * 2) AS total FROM lineitem
+     *     GROUP BY l_returnflag
+     *
+     * is transformed per item: the plain column l_returnflag is unchanged, 
while the
+     * aggregate item's constant 2 becomes a placeholder
+     * (sum(l_quantity * SpmConstVar(1, 2)) AS total), so a similar user query 
with a
+     * different multiplier still matches. Only a transform result that stays a
+     * NamedExpression is adopted (an Alias must not collapse into a bare 
expression,
+     * which would drop the output name).
+     *
+     * @param expressions the NamedExpression list (projects / aggregate 
outputs) to
+     *                    transform
+     * @param transform   the per-expression transform (parameterize or 
substitute)
+     * @return the transformed list, or the original list instance when 
nothing changed
+     */
+    private static List<NamedExpression> transformNamed(List<NamedExpression> 
expressions,
+            ExprTransform transform) {
+        boolean changed = false;
+        List<NamedExpression> newExpressions = new 
ArrayList<>(expressions.size());
+        for (NamedExpression expression : expressions) {
+            Expression transformed = transform.apply(expression);
+            if (transformed instanceof NamedExpression && transformed != 
expression) {
+                newExpressions.add((NamedExpression) transformed);
+                changed = true;
+            } else {
+                newExpressions.add(expression);
+            }
+        }
+        return changed ? newExpressions : expressions;
+    }
+
+    /**
+     * Transforms a conjunct set in a deterministic order; returns the 
original set when
+     * no element changed. The conjuncts of a filter / having are a Set whose 
iteration
+     * order is not stable across separately parsed trees, so they are ordered 
by SQL
+     * text before transforming: the bind tree and the (separately parsed) 
plan tree of
+     * one baseline then parameterize their conjuncts in the same order and the
+     * placeholder ids stay aligned (no cross-tree id skew for identical 
conjuncts).
+     */
+    private static Set<Expression> transformConjuncts(Set<Expression> 
conjuncts,
+            ExprTransform transform) {
+        List<Expression> ordered = new ArrayList<>(conjuncts);
+        ordered.sort(Comparator.comparing(Expression::toSql));
+        boolean changed = false;
+        Set<Expression> newConjuncts = new LinkedHashSet<>(conjuncts.size());
+        for (Expression conjunct : ordered) {
+            Expression transformed = transform.apply(conjunct);
+            newConjuncts.add(transformed);
+            if (transformed != conjunct) {
+                changed = true;
+            }
+        }
+        return changed ? newConjuncts : conjuncts;
+    }
+
+    // ==================== namespace qualification (creation catalog / 
database) ====================
+
+    /**
+     * Returns a copy of the tree where every UNQUALIFIED relation reference 
(nameParts.size()
+     * == 1) that binds to a BASE TABLE is prefixed with the given catalog / 
database, so the
+     * SPM digest / hash key of "FROM t" depends on the query's effective 
namespace. The copy
+     * is only used to compute the matching key / compare against a baseline - 
it is never
+     * analyzed, optimized or executed.
+     *
+     * Without this, "SELECT ... FROM t" has the same key under db1 and db2: a 
baseline
+     * captured under db1 would silently match the same text executed under 
db2 and - because
+     * the frozen planSql is fully qualified - keep executing against db1.t.
+     *
+     * A reference to a CTE alias is NOT prefixed: its binding comes from the 
WITH clause of
+     * the query itself and therefore means the same thing in every database 
(prefixing it
+     * made "WITH c AS (...) SELECT * FROM c" match only in the database it 
was created in).
+     * Whether a single-part name is a CTE reference is decided with the 
analyzer's scoping
+     * rules (AnalyzeCTE): an alias body sees the aliases defined before it, 
plus itself when
+     * it is a real recursive CTE (WITH RECURSIVE plus a self-reference in its 
body); a CTE's
+     * main query sees every alias of that WITH; nested WITH nodes extend the 
enclosing
+     * scope; expression subqueries inherit the scope of the point they appear 
in
+     * (SubExprAnalyzer). A name that is NOT a visible alias - including a 
forward reference
+     * to a later alias and a self reference under a plain (non-RECURSIVE) 
WITH, both of which
+     * bind as base tables - is always prefixed, so this can only narrow the 
match key, never
+     * make a base-table reference namespace-independent.
+     *
+     * @param plan    the parsed (unbound) tree
+     * @param catalog the effective catalog, may be null / empty (then only 
the db is prefixed)
+     * @param db      the effective database; when null / empty the tree is 
returned as-is
+     * @return the qualified tree (a rebuilt copy; the argument is not 
modified)
+     */
+    public static LogicalPlan namespaceQualified(LogicalPlan plan, String 
catalog, String db) {
+        boolean hasCatalog = catalog != null && !catalog.isEmpty();
+        boolean hasDb = db != null && !db.isEmpty();
+        // A TWO-part name (db.t) is relative to the current CATALOG, not to 
the current
+        // database, so it must be prefixed whenever the catalog is known - a 
session can
+        // switch to cat1 WITHOUT a USE db, and an unprefixed db.t would key 
the same text
+        // under cat2 to the same digest while the frozen SQL still reads 
cat1.db.t. Only
+        // when NEITHER the catalog nor the db is known can nothing be made
+        // namespace-independent.
+        if (plan == null || (!hasCatalog && !hasDb)) {
+            return plan;
+        }
+        Plan result = plan.accept(new TreeTransformer(), new 
QualifyTransform(catalog, db));
+        return result instanceof LogicalPlan ? (LogicalPlan) result : plan;
+    }
+
+    /**
+     * Namespace-qualification scope: prefixes single-part base-table 
references with the
+     * effective [catalog, db] and keeps references to the CTE aliases visible 
at the current
+     * point verbatim. Immutable: entering a CTE scope returns a new instance 
whose visible
+     * set is the enclosing one plus the aliases visible in that scope.
+     */
+    private static final class QualifyTransform implements ScopedTransform {
+        private final String catalog;
+        private final String db;
+        /** normalized names of the CTE aliases visible at the current point */
+        private final Set<String> visibleCtes;
+
+        QualifyTransform(String catalog, String db) {
+            this(catalog, db, Collections.emptySet());
+        }
+
+        private QualifyTransform(String catalog, String db, Set<String> 
visibleCtes) {
+            this.catalog = catalog;
+            this.db = db;
+            this.visibleCtes = visibleCtes;
+        }
+
+        @Override
+        public Expression apply(Expression expr) {
+            return qualifyExpression(expr, this);
+        }
+
+        @Override
+        public ExprTransform enterCteMain(LogicalCTE<? extends Plan> cte) {
+            Set<String> extended = new LinkedHashSet<>(visibleCtes);
+            for (LogicalSubQueryAlias<Plan> alias : cte.getAliasQueries()) {
+                extended.add(normalizeCteName(alias.getAlias()));
+            }
+            return new QualifyTransform(catalog, db, 
Collections.unmodifiableSet(extended));
+        }
+
+        @Override
+        public ExprTransform enterCteAlias(LogicalCTE<? extends Plan> cte, int 
aliasIndex) {
+            List<LogicalSubQueryAlias<Plan>> aliases = cte.getAliasQueries();
+            Set<String> extended = new LinkedHashSet<>(visibleCtes);
+            for (int i = 0; i < aliasIndex; i++) {
+                extended.add(normalizeCteName(aliases.get(i).getAlias()));
+            }
+            // 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
+            LogicalSubQueryAlias<Plan> alias = aliases.get(aliasIndex);
+            if (cte.isRecursive() && alias.isRecursiveCte()) {
+                extended.add(normalizeCteName(alias.getAlias()));
+            }
+            return new QualifyTransform(catalog, db, 
Collections.unmodifiableSet(extended));
+        }
+
+        /** Prefixes a one- or two-part relation with the effective namespace. 
*/
+        Plan qualify(UnboundRelation relation) {
+            List<String> parts = relation.getNameParts();
+            if (parts.size() > 2) {
+                return relation; // fully qualified (catalog.db.table): 
nothing to add
+            }
+            if (parts.size() == 1 && isVisibleCte(parts.get(0))) {
+                return relation; // bound by a WITH clause, not a base-table 
reference
+            }
+            // A one-part name is relative to the current db, but a TWO-part 
name is only
+            // relative to the current CATALOG ("db.t" means 
current_catalog.db.t):
+            // leaving "db.t" verbatim would let a baseline created in cat1 
match the
+            // same text executed in cat2, after which the frozen 
fully-qualified replay
+            // keeps reading cat1.db.t. Only three-part names are complete.
+            List<String> qualified = new ArrayList<>(3);
+            if (parts.size() == 1) {
+                if (db == null || db.isEmpty()) {
+                    // A one-part name needs the DATABASE to become absolute; 
prefixing only
+                    // the catalog would turn the table name into a database 
name.
+                    return relation;
+                }
+                if (catalog != null && !catalog.isEmpty()) {
+                    qualified.add(catalog);
+                }
+                qualified.add(db);
+            } else {
+                // two-part name: the catalog completes it
+                if (catalog == null || catalog.isEmpty()) {
+                    return relation;
+                }
+                qualified.add(catalog);
+            }
+            qualified.addAll(parts);
+            try {
+                return copyWithNameParts(relation, qualified);
+            } catch (RuntimeException e) {
+                return relation;
+            }
+        }
+
+        /**
+         * Rebuilds the relation with new name parts while preserving EVERY 
scan modifier
+         * (partition list, tablet ids, hints, sample, index, scan params, 
snapshot):
+         * dropping them here would hide a PARTITION(...) / TABLESAMPLE / FOR 
VERSION
+         * selection from the Level 3 comparison and allow a baseline captured 
under a
+         * different selection to match.
+         */
+        private static UnboundRelation copyWithNameParts(UnboundRelation 
relation,
+                List<String> nameParts) {
+            return new UnboundRelation(relation.getRelationId(), nameParts,
+                    relation.getPartNames(), relation.isTempPart(), 
relation.getTabletIds(),
+                    relation.getHints(), relation.getTableSample(), 
relation.getIndexName(),
+                    relation.getScanParams(), relation.getIndexInSqlString(),
+                    relation.getTableSnapshot());
+        }
+
+        private boolean isVisibleCte(String name) {
+            return !visibleCtes.isEmpty() && 
visibleCtes.contains(normalizeCteName(name));
+        }
+    }
+
+    /** Mirrors the analyzer's CTE name comparison 
(CTEContext.findCTEContext). */
+    private static String normalizeCteName(String name) {
+        int lowerCaseTableNames = GlobalVariable.lowerCaseTableNames;
+        ConnectContext ctx = ConnectContext.get();
+        if (ctx != null && ctx.getCurrentCatalog() != null) {
+            lowerCaseTableNames = 
ctx.getCurrentCatalog().getLowerCaseTableNames();
+        }
+        return lowerCaseTableNames != 0 ? name.toLowerCase(Locale.ROOT) : name;
+    }
+
+    /**
+     * Qualifies the relations of every subquery plan owned by an expression, 
recursively
+     * (IN / scalar / EXISTS subqueries anywhere in the tree). The subquery 
inherits the
+     * qualification scope of the point it appears in, i.e. the CTE aliases 
visible there.
+     */
+    private static Expression qualifyExpression(Expression expr, 
QualifyTransform transform) {
+        if (expr instanceof SubqueryExpr) {
+            LogicalPlan subPlan = ((SubqueryExpr) expr).getQueryPlan();
+            Plan qualified = subPlan.accept(new TreeTransformer(), transform);
+            if (qualified instanceof LogicalPlan && qualified != subPlan) {
+                return ((SubqueryExpr) expr).withSubquery((LogicalPlan) 
qualified);
+            }
+            return expr;
+        }
+        if (expr.children().isEmpty()) {
+            return expr;
+        }
+        boolean changed = false;
+        List<Expression> newChildren = new ArrayList<>(expr.children().size());
+        for (Expression child : expr.children()) {
+            Expression newChild = qualifyExpression(child, transform);
+            newChildren.add(newChild);
+            if (newChild != child) {
+                changed = true;
+            }
+        }
+        return changed ? expr.withChildren(newChildren) : expr;
+    }
+
+    // ==================== hint stripping (in-memory fallback tree) 
====================
+
+    /**
+     * Removes EVERY LogicalSelectHint from a plan tree: the root block, 
nested query
+     * blocks, CTE bodies and expression subqueries. The frozen-text replay 
path re-parses
+     * its planSql INCLUDING the hints deliberately; the in-memory fallback 
tree must not
+     * re-apply the BASELINE's captured SET_VAR on top of a user query that 
came with
+     * different session variables. A root-only peel let an INNER hint survive 
(e.g. a
+     * plan-side SET_VAR(time_zone='+08:00') inside a scalar subquery) and the 
hint was
+     * then applied during ordinary replay analysis, although the matching 
user query is
+     * hint-free and runs under -08:00 - from_unixtime returned different 
values.
+     *
+     * @param plan the parameterized fallback tree
+     * @return the tree without any hint wrapper (the original instance when 
there was none)
+     */
+    public static LogicalPlan stripSelectHints(LogicalPlan plan) {
+        if (plan == null) {
+            return null;
+        }
+        Plan result = plan.accept(new HintStripper(), HINT_STRIP_EXPR);
+        return result instanceof LogicalPlan ? (LogicalPlan) result : plan;
+    }
+
+    /** TreeTransformer that DROPS the LogicalSelectHint wrapper instead of 
rebuilding it. */
+    private static class HintStripper extends TreeTransformer {
+        @Override
+        public Plan visit(Plan plan, ExprTransform transform) {
+            if (plan instanceof LogicalSelectHint) {
+                Plan child = plan.child(0);
+                return child == null ? plan : child.accept(this, transform);
+            }
+            return super.visit(plan, transform);
+        }
+    }
+
+    // ==================== replay-time context expression detection 
====================
+
+    /**
+     * Whether the tree contains an expression whose value belongs to the 
CREATOR's
+     * replay-time context: a session / user variable (@v) or current_user(),
+     * session_user(), database(), connection_id(). Such leaves survive
+     * parameterization, the creator-context optimization then resolves them 
to LITERALS,
+     * and the frozen SQL persists the CREATOR's value - while matching still 
compares the
+     * ORIGINAL unbound bind tree, so a global baseline (e.g.
+     * {@code SELECT current_user(), k FROM t WHERE k = 1}) could match 
another user's
+     * query and return the creator's identity.
+     *
+     * @param plan the parsed (unbound) tree
+     * @return true when such an expression is found anywhere (including 
subqueries and an
+     *         ASOF join's out-of-band MATCH_CONDITION)
+     */
+    public static boolean containsReplayContextExpression(LogicalPlan plan) {
+        return plan.accept(new ReplayContextScanVisitor(), null);
+    }
+
+    /** Whether an expression tree contains a replay-time context expression. 
*/
+    public static boolean containsReplayContextExpression(Expression expr) {
+        if (expr instanceof Variable || expr instanceof CurrentUser || expr 
instanceof SessionUser
+                || expr instanceof Database || expr instanceof ConnectionId) {
+            return true;
+        }
+        if (expr instanceof UnboundFunction) {
+            // the same functions can reach the unbound tree as a plain 
function call
+            // (e.g. "current_user()" with parentheses parses as 
UnboundFunction)
+            String name = ((UnboundFunction) expr).getName();
+            if (name != null) {
+                switch (name.toLowerCase(Locale.ROOT)) {
+                    case "current_user":
+                    case "session_user":
+                    case "database":
+                    case "connection_id":
+                        return true;
+                    default:
+                        break;
+                }
+            }
+        }
+        if (expr instanceof SubqueryExpr) {
+            if (containsReplayContextExpression(((SubqueryExpr) 
expr).getQueryPlan())) {
+                return true;
+            }
+        }
+        for (Expression child : expr.children()) {
+            if (containsReplayContextExpression(child)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** Plan visitor that scans every node's expressions (and subquery plans). 
*/
+    private static class ReplayContextScanVisitor extends PlanVisitor<Boolean, 
Void> {
+        @Override
+        public Boolean visit(Plan plan, Void context) {
+            if (plan instanceof LogicalUsingJoin) {
+                // matchCondition lives outside children() and getExpressions()
+                Optional<Expression> matchCondition =
+                        ((LogicalUsingJoin<?, ?>) plan).getMatchCondition();
+                if (matchCondition.isPresent()
+                        && 
containsReplayContextExpression(matchCondition.get())) {
+                    return true;
+                }
+            }
+            for (Expression expr : plan.getExpressions()) {
+                if (containsReplayContextExpression(expr)) {
+                    return true;
+                }
+            }
+            for (Plan child : plan.children()) {
+                if (child.accept(this, context)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        @Override
+        public Boolean visitLogicalCTE(LogicalCTE<? extends Plan> cte, Void 
context) {
+            if (cte.child(0) != null && cte.child(0).accept(this, context)) {
+                return true;
+            }
+            for (LogicalSubQueryAlias<Plan> aliasQuery : 
cte.getAliasQueries()) {
+                if (aliasQuery.accept(this, context)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
+
+    // ==================== whole-tree placeholder detection 
====================
+
+    /**
+     * Whether the plan tree (including filters/having/projections and every 
subquery
+     * plan reachable from an expression) still contains an unbound 
placeholder.
+     *
+     * @param plan the plan tree to scan
+     * @return true when a SpmConstVar / SpmConstList is found anywhere
+     */
+    public static boolean containsPlaceholder(LogicalPlan plan) {
+        return plan.accept(new PlaceholderScanVisitor(), null);
+    }
+
+    /** Plan visitor that scans every node's expressions (and subquery plans) 
for placeholders. */
+    private static class PlaceholderScanVisitor extends PlanVisitor<Boolean, 
Void> {
+        @Override
+        public Boolean visit(Plan plan, Void context) {
+            if (plan instanceof LogicalUsingJoin) {
+                // The USING join's matchCondition is OUT OF BAND: it is 
neither a child
+                // nor in getExpressions(). A bind/plan pair whose ASOF 
boundary differs
+                // only in an interval literal leaves a plan-only id in this 
field; the
+                // in-memory fallback substitutes the original 
LogicalUsingJoin, so a
+                // visitor that only walks USING slots + children would return 
an invalid
+                // tree (an unresolved placeholder id) to analysis.
+                Optional<Expression> matchCondition =
+                        ((LogicalUsingJoin<?, ?>) plan).getMatchCondition();
+                if (matchCondition.isPresent() && 
containsPlaceholder(matchCondition.get())) {
+                    return true;
+                }
+            }
+            for (Expression expr : plan.getExpressions()) {
+                if (containsPlaceholder(expr)) {
+                    return true;
+                }
+            }
+            for (Plan child : plan.children()) {
+                if (child.accept(this, context)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        @Override
+        public Boolean visitLogicalCTE(LogicalCTE<? extends Plan> cte, Void 
context) {
+            if (cte.child(0) != null && cte.child(0).accept(this, context)) {
+                return true;
+            }
+            for (LogicalSubQueryAlias<Plan> aliasQuery : 
cte.getAliasQueries()) {
+                if (aliasQuery.accept(this, context)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
+
+    /**
+     * Whether an expression tree (recursing into subquery plans) contains a 
placeholder.
+     */
+    public static boolean containsPlaceholder(Expression expr) {
+        if (expr instanceof SpmConstVar || expr instanceof SpmConstList) {
+            return true;
+        }
+        if (expr instanceof SubqueryExpr) {
+            if (containsPlaceholder(((SubqueryExpr) expr).getQueryPlan())) {
+                return true;
+            }
+        }
+        for (Expression child : expr.children()) {
+            if (containsPlaceholder(child)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // ==================== frozen-tree placeholder detection (M3) 
====================
+
+    /**
+     * Whether a plan tree (re-parsed from the frozen planSql) still carries an
+     * unsubstituted placeholder call (_spm_const_var(id) / 
_spm_const_list(id) as a raw
+     * UnboundFunction). Such a call would reach the analyzer as an 
unregistered function
+     * and fail, so the rewrite must reject the tree instead of returning it.
+     *
+     * @param plan the frozen (re-parsed) plan tree to scan
+     * @return true when an unsubstituted placeholder call is found anywhere
+     */
+    public static boolean containsFrozenPlaceholder(Plan plan) {
+        return plan.accept(new FrozenPlaceholderScanVisitor(), null);
+    }
+
+    /** Plan visitor that scans every node's expressions (and subquery plans) 
for
+     * unsubstituted frozen-tree placeholder calls. */
+    private static class FrozenPlaceholderScanVisitor extends 
PlanVisitor<Boolean, Void> {
+        @Override
+        public Boolean visit(Plan plan, Void context) {
+            if (plan instanceof LogicalUsingJoin) {
+                // Same out-of-band field as PlaceholderScanVisitor: an 
unresolved
+                // placeholder id inside the ASOF boundary must reject the 
fallback tree.
+                Optional<Expression> matchCondition =
+                        ((LogicalUsingJoin<?, ?>) plan).getMatchCondition();
+                if (matchCondition.isPresent() && 
containsFrozenPlaceholder(matchCondition.get())) {
+                    return true;
+                }
+            }
+            for (Expression expr : plan.getExpressions()) {
+                if (containsFrozenPlaceholder(expr)) {
+                    return true;
+                }
+            }
+            for (Plan child : plan.children()) {
+                if (child.accept(this, context)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        @Override
+        public Boolean visitLogicalCTE(LogicalCTE<? extends Plan> cte, Void 
context) {
+            if (cte.child(0) != null && cte.child(0).accept(this, context)) {
+                return true;
+            }
+            for (LogicalSubQueryAlias<Plan> aliasQuery : 
cte.getAliasQueries()) {
+                if (aliasQuery.accept(this, context)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
+
+    /** Whether an expression tree contains an unsubstituted frozen-tree 
placeholder call. */
+    public static boolean containsFrozenPlaceholder(Expression expr) {
+        if (SPMFrozenTreeReplacer.isUnsubstitutedPlaceholder(expr)) {
+            return true;
+        }
+        if (expr instanceof SubqueryExpr) {
+            if (containsFrozenPlaceholder(((SubqueryExpr) 
expr).getQueryPlan())) {
+                return true;
+            }
+        }
+        for (Expression child : expr.children()) {
+            if (containsFrozenPlaceholder(child)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // ==================== whole-tree structural check (Level 3) 
====================
+
+    /**
+     * Compares a parameterized bind tree with the user's original tree node 
by node and
+     * expression by expression. Placeholder values are extracted from the 
user side into
+     * placeholderValues (a repeated id must resolve to the same user value).
+     *
+     * @param bindPlan          the parameterized bind tree (contains 
placeholders)
+     * @param userPlan          the user's original tree (contains real 
literals)
+     * @param placeholderValues placeholder id -> user value (filled in here)
+     * @return whether the two trees match
+     */
+    public static boolean check(LogicalPlan bindPlan, LogicalPlan userPlan,
+            Map<Long, Expression> placeholderValues) {
+        return checkPlan(bindPlan, userPlan, placeholderValues, false);
+    }
+
+    /**
+     * Level 3 check for one subquery-EXPRESSION plan pair. Inside a 
subquery's plan the
+     * LIMIT / OFFSET fields are compared EXACTLY (see checkPlan): they are 
plain long
+     * fields that mergeLimits cannot merge (the subquery plan does not sit on 
a
+     * plan-child path of the main tree), so a "subquery LIMIT 2" query must 
not match a
+     * baseline captured with a different subquery limit and replay the 
captured value.
+     */
+    public static boolean checkSubqueryPlan(LogicalPlan bindPlan, LogicalPlan 
userPlan,
+            Map<Long, Expression> placeholderValues) {
+        return checkPlan(bindPlan, userPlan, placeholderValues, true);
+    }
+
+    /** Node-by-node recursive structural check. */
+    private static boolean checkPlan(Plan bind, Plan user, Map<Long, 
Expression> placeholderValues,
+            boolean insideSubquery) {
+        if (bind.getClass() != user.getClass()) {

Review Comment:
   [P1] Include result-affecting SELECT-hint payloads in the baseline match. 
`checkPlan()` compares a `LogicalSelectHint` only through its class, empty 
`getExpressions()`, and child, while `LogicalSelectHint.toDigest()` also drops 
`hints`. Thus a baseline created with `SET_VAR(time_zone='+08:00')` can match 
the same query with `-08:00` and replay the creator's frozen expression/plan, 
even though the parser applies the user's different setting before planning. 
Compare the complete hint list (including nested blocks), or reject 
result-affecting hint differences, and add a different-time-zone/sql-mode 
replay test.



##########
fe/fe-core/src/main/java/org/apache/doris/nereids/parser/LogicalPlanBuilder.java:
##########
@@ -5637,6 +5752,120 @@ public LogicalPlan 
visitAlterSqlBlockRule(AlterSqlBlockRuleContext ctx) {
         return new AlterSqlBlockRuleCommand(stripQuotes(ctx.name.getText()), 
properties);
     }
 
+    // ==================== SPM (SQL Plan Management) commands (Phase 1, 
design doc 6.8) ====================
+
+    @Override
+    public LogicalPlan visitCreateBaselinePlan(CreateBaselinePlanContext ctx) {
+        BaselineScope scope = ctx.GLOBAL() != null ? BaselineScope.GLOBAL
+                : (ctx.SESSION() != null ? BaselineScope.SESSION : 
BaselineScope.GLOBAL);
+        // Decode the STRING_LITERAL tokens properly (stripQuotes alone would 
leave the
+        // escaped quotes intact, e.g. '' / \', breaking the later re-parse of 
bindSql /
+        // planSql by SPMPlanner.buildBaselineFromSql / SPMOptimizer.optimize).
+        String bindSql = 
SqlLiteralUtils.parseStringLiteral(ctx.bindSql.getText());
+        // The WITH clause is optional (CREATE BASELINE PLAN 'sql'): when 
omitted the
+        // bindSql itself is frozen as the planSql (plan == bind).
+        String planSql = ctx.planSql != null
+                ? SqlLiteralUtils.parseStringLiteral(ctx.planSql.getText()) : 
bindSql;
+        return new CreateBaselinePlanCommand(scope, bindSql, planSql);
+    }
+
+    @Override
+    public LogicalPlan visitShowBaselinePlans(ShowBaselinePlansContext ctx) {
+        String pattern = null;
+        String filterColumn = null;
+        String filterValue = null;
+        Expression predicate = null;
+        if (ctx.wildWhere() != null) {
+            if (ctx.wildWhere().LIKE() != null) {
+                pattern = 
stripQuotes(ctx.wildWhere().STRING_LITERAL().getText());

Review Comment:
   [P2] Decode the raw LIKE literal before filtering SHOW BASELINE PLANS. 
`visitShowBaselinePlans()` calls 
`stripQuotes(ctx.wildWhere().STRING_LITERAL().getText())`, which leaves SQL 
doubled quotes/backslash escapes intact; a pattern containing an apostrophe is 
therefore passed to `PatternMatcher` with the wrong characters and misses the 
stored SQL. Use `SqlLiteralUtils.parseStringLiteral` for this branch and add a 
LIKE pattern containing an escaped quote/backslash.



-- 
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