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]
