This is an automated email from the ASF dual-hosted git repository.
apurtell pushed a commit to branch PHOENIX-7876-feature
in repository https://gitbox.apache.org/repos/asf/phoenix.git
The following commit(s) were added to refs/heads/PHOENIX-7876-feature by this
push:
new 3af242090a PHOENIX-7903 Functional index match rule EXPLAIN disclosure
(#2523)
3af242090a is described below
commit 3af242090a47d07b597cce36754c493f7f8c1958
Author: Andrew Purtell <[email protected]>
AuthorDate: Fri Jun 12 11:21:40 2026 -0700
PHOENIX-7903 Functional index match rule EXPLAIN disclosure (#2523)
Co-authored-by: Claude Opus 4.8[1m] <[email protected]>
---
.../apache/phoenix/compile/StatementContext.java | 48 +++++++++++
.../apache/phoenix/optimize/QueryOptimizer.java | 96 ++++++++++++++++++----
.../parse/IndexExpressionParseNodeRewriter.java | 52 +++++++++++-
.../apache/phoenix/compile/QueryOptimizerTest.java | 42 ++++++++++
4 files changed, 221 insertions(+), 17 deletions(-)
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/StatementContext.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/StatementContext.java
index d5eac400e1..c2ad404027 100644
---
a/phoenix-core-client/src/main/java/org/apache/phoenix/compile/StatementContext.java
+++
b/phoenix-core-client/src/main/java/org/apache/phoenix/compile/StatementContext.java
@@ -21,6 +21,7 @@ import java.sql.SQLException;
import java.text.Format;
import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.Iterator;
@@ -106,6 +107,8 @@ public class StatementContext {
private List<String> appliedRewrites;
private int derivedTableFlattenCount;
private List<Pair<ParseNode, String>> indexExpressionSubstitutions;
+ private Map<String, List<String>> appliedIndexExpressionMatches;
+ private Set<String> functionalIndexNames;
private Set<Pair<String, String>> partialIndexCheckedSet;
private Map<String, List<Expression>> serverParsedProjections;
private StatementContext parentContext;
@@ -148,6 +151,8 @@ public class StatementContext {
this.appliedRewrites = context.appliedRewrites;
this.derivedTableFlattenCount = context.derivedTableFlattenCount;
this.indexExpressionSubstitutions = context.indexExpressionSubstitutions;
+ this.appliedIndexExpressionMatches = context.appliedIndexExpressionMatches;
+ this.functionalIndexNames = context.functionalIndexNames;
this.partialIndexCheckedSet = context.partialIndexCheckedSet;
this.serverParsedProjections = context.serverParsedProjections;
this.parentContext = context.parentContext;
@@ -216,6 +221,8 @@ public class StatementContext {
this.appliedRewrites = new ArrayList<>();
this.derivedTableFlattenCount = 0;
this.indexExpressionSubstitutions = new ArrayList<>();
+ this.appliedIndexExpressionMatches = Maps.newLinkedHashMap();
+ this.functionalIndexNames = Sets.newHashSet();
this.partialIndexCheckedSet = Sets.newHashSet();
this.serverParsedProjections = null;
this.parentContext = null;
@@ -509,6 +516,8 @@ public class StatementContext {
this.appliedRewrites = source.appliedRewrites;
this.derivedTableFlattenCount = source.derivedTableFlattenCount;
this.indexExpressionSubstitutions = source.indexExpressionSubstitutions;
+ this.appliedIndexExpressionMatches = source.appliedIndexExpressionMatches;
+ this.functionalIndexNames = source.functionalIndexNames;
this.partialIndexCheckedSet = source.partialIndexCheckedSet;
this.serverParsedProjections = source.serverParsedProjections;
}
@@ -530,6 +539,45 @@ public class StatementContext {
indexExpressionSubstitutions.add(new Pair<>(source, indexColumnName));
}
+ /**
+ * Records the path expressions that actually substituted against this query
for the given
+ * functional index. Used by the optimizer to label the chosen index's rule
as
+ * {@code matches <expr>} and to distinguish functional index rejections
that matched no query
+ * expression. Deduplicated per index, preserving first seen order.
+ */
+ public void recordAppliedIndexExpressionMatches(String indexName,
+ Collection<String> expressions) {
+ if (expressions == null || expressions.isEmpty()) {
+ return;
+ }
+ List<String> matches =
+ appliedIndexExpressionMatches.computeIfAbsent(indexName, k -> new
ArrayList<>());
+ for (String expression : expressions) {
+ if (!matches.contains(expression)) {
+ matches.add(expression);
+ }
+ }
+ }
+
+ /**
+ * Returns the path expressions that actually substituted against this query
for the given
+ * functional index, or an empty list if none were recorded.
+ */
+ public List<String> getAppliedIndexExpressionMatches(String indexName) {
+ List<String> matches = appliedIndexExpressionMatches.get(indexName);
+ return matches == null ? Collections.emptyList() : matches;
+ }
+
+ /** Marks the given index as a functional index. */
+ public void recordFunctionalIndex(String indexName) {
+ functionalIndexNames.add(indexName);
+ }
+
+ /** Returns whether the given index was recorded as a functional index
during rewriting. */
+ public boolean isFunctionalIndex(String indexName) {
+ return functionalIndexNames.contains(indexName);
+ }
+
/**
* Dedup set used by the optimizer so a partial index applicability
breadcrumb is recorded only
* once per (table, index) pair even when the same index is scored across
multiple paths.
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
index b2cea9c761..b16a0ed22e 100644
---
a/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
+++
b/phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java
@@ -495,6 +495,7 @@ public class QueryOptimizer {
isHinted, indexSelect, resolver);
}
+ @SuppressWarnings("rawtypes")
private AddPlanResult addPlan(PhoenixStatement statement, SelectStatement
select, PTable index,
List<? extends PDatum> targetColumns, ParallelIteratorFactory
parallelIteratorFactory,
QueryPlan dataPlan, boolean isHinted, SelectStatement indexSelect,
ColumnResolver resolver)
@@ -531,12 +532,22 @@ public class QueryOptimizer {
}
// translate nodes that match expressions that are indexed to the
// associated column parse node
+ IndexExpressionParseNodeRewriter indexExpressionRewriter =
+ new IndexExpressionParseNodeRewriter(index, null,
statement.getConnection(),
+ indexSelect.getUdfParseNodes());
SelectStatement rewrittenIndexSelect =
- ParseNodeRewriter.rewrite(indexSelect, new
IndexExpressionParseNodeRewriter(index, null,
- statement.getConnection(), indexSelect.getUdfParseNodes()));
+ ParseNodeRewriter.rewrite(indexSelect, indexExpressionRewriter);
+ // Record which functional index expressions actually matched this
query, so the
+ // optimizer can label the chosen index's rule and distinguish
functional index rejections
+ // that matched nothing.
+ if (indexExpressionRewriter.hasFunctionalColumns()) {
+
dataPlan.getContext().recordFunctionalIndex(index.getTableName().getString());
+ }
+
dataPlan.getContext().recordAppliedIndexExpressionMatches(index.getTableName().getString(),
+
indexExpressionRewriter.getAppliedFunctionalSubstitutions().values());
QueryCompiler compiler = new QueryCompiler(statement,
rewrittenIndexSelect, resolver,
targetColumns, parallelIteratorFactory,
dataPlan.getContext().getSequenceManager(),
- isProjected, true, dataPlans);
+ isProjected, true,
dataPlans).withRewriteContext(dataPlan.getContext());
QueryPlan plan = compiler.compile();
if (indexTable.getIndexType() == IndexType.UNCOVERED_GLOBAL) {
@@ -579,10 +590,8 @@ public class QueryOptimizer {
maintainer.getIndexedColumnInfo();
for (org.apache.hadoop.hbase.util.Pair<String, String> pair :
indexedColumns) {
// The first member of the pair is the column family. For the data
table PK columns, the
- // column
- // family is set to null. The data PK columns should not be added
to the set of data
- // columns
- // to join back to index rows
+ // column family is set to null. The data PK columns should not be
added to the set of
+ // data columns to join back to index rows
if (pair.getFirst() != null) {
PColumn pColumn =
dataTable.getColumnForColumnName(pair.getSecond());
// The following adds the column to the set
@@ -598,8 +607,7 @@ public class QueryOptimizer {
indexTable = indexTableRef.getTable();
indexState = indexTable.getIndexState();
// Checking number of columns handles the wildcard cases correctly, as
in that case the
- // index
- // must contain all columns from the data table to be able to be used.
+ // index must contain all columns from the data table to be able to be
used.
if (
indexState == PIndexState.ACTIVE || indexState ==
PIndexState.PENDING_ACTIVE
|| (indexState == PIndexState.PENDING_DISABLE &&
isUnderPendingDisableThreshold(
@@ -687,7 +695,8 @@ public class QueryOptimizer {
return AddPlanResult.rejected(index,
OptimizerReasons.REASON_DEGENERATE_RANGE);
}
}
- return AddPlanResult.rejected(index,
OptimizerReasons.REASON_DOES_NOT_COVER_PROJECTION);
+ return AddPlanResult.rejected(index, adjustReasonForFunctionalIndex(index,
+ OptimizerReasons.REASON_DOES_NOT_COVER_PROJECTION,
dataPlan.getContext()));
}
// returns true if we can still use the index
@@ -935,12 +944,59 @@ public class QueryOptimizer {
* can use this inline at a {@code return} site.
*/
private static QueryPlan recordDecision(QueryPlan winner, String rule,
DecisionState state) {
+ String functionalRule = functionalIndexRule(winner);
+ if (functionalRule != null) {
+ rule = functionalRule;
+ }
winner.setOptimizerDecision(
new
OptimizerDecision(winner.getTableRef().getTable().getTableName().getString(),
rule,
state == null ? null : state.getRejections()));
return winner;
}
+ /**
+ * Returns {@link OptimizerReasons#matches(String)} for {@code winner} when
it is a functional
+ * index whose indexed expression actually substituted a path expression in
the user's query, or
+ * {@code null} when the functional index rule override does not apply. The
applied matches are
+ * only ever recorded for genuine expression substitutions, so a non-empty
match list implies the
+ * chosen plan is a functional index that matched.
+ */
+ private static String functionalIndexRule(QueryPlan winner) {
+ PTable table = winner.getTableRef().getTable();
+ List<String> matches =
+
winner.getContext().getAppliedIndexExpressionMatches(table.getTableName().getString());
+ if (matches.isEmpty()) {
+ return null;
+ }
+ return OptimizerReasons.matches(matches.get(0));
+ }
+
+ /**
+ * Swaps a generic coverage driven rejection reason for
+ * {@link OptimizerReasons#REASON_PATH_EXPRESSION_DOES_NOT_MATCH} when
{@code index} is a
+ * functional index for which no query expression matched its indexed
expression. Other reasons,
+ * and functional indexes that did match an expression, keep their original
reason.
+ */
+ private static String adjustReasonForFunctionalIndex(PTable index, String
reason,
+ StatementContext rewriteContext) {
+ if (!rewriteContext.isFunctionalIndex(index.getTableName().getString())) {
+ return reason;
+ }
+ if (
+ !OptimizerReasons.REASON_DOES_NOT_COVER_PROJECTION.equals(reason)
+ && !OptimizerReasons.REASON_NO_PK_PREFIX_BOUND.equals(reason)
+ &&
!OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE.equals(reason)
+ ) {
+ return reason;
+ }
+ if (
+
!rewriteContext.getAppliedIndexExpressionMatches(index.getTableName().getString()).isEmpty()
+ ) {
+ return reason;
+ }
+ return OptimizerReasons.REASON_PATH_EXPRESSION_DOES_NOT_MATCH;
+ }
+
/** First plan in {@code plans} that is not {@code exclude}, or {@code null}
if there is none. */
private static QueryPlan firstOther(List<QueryPlan> plans, QueryPlan
exclude) {
for (QueryPlan plan : plans) {
@@ -998,9 +1054,11 @@ public class QueryOptimizer {
continue;
}
if (adjustedBoundCount(plan, boundRanges) < winnerBound) {
- state.reject(table, OptimizerReasons.REASON_NO_PK_PREFIX_BOUND);
+ state.reject(table, adjustReasonForFunctionalIndex(table,
+ OptimizerReasons.REASON_NO_PK_PREFIX_BOUND, winner.getContext()));
} else if (table.getIndexType() == IndexType.LOCAL && winnerNonLocal) {
- state.reject(table,
OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE);
+ state.reject(table, adjustReasonForFunctionalIndex(table,
+ OptimizerReasons.REASON_LOCAL_INDEX_LOSES_TO_GLOBAL_BY_RULE,
winner.getContext()));
}
}
}
@@ -1055,10 +1113,20 @@ public class QueryOptimizer {
IndexStatementRewriter.translate(FACTORY.select(select, newFrom),
resolver, replacement);
for (TableRef indexTableRef : replacement.values()) {
// replace expressions with corresponding matching columns for
functional indexes
- indexSelect = ParseNodeRewriter.rewrite(indexSelect,
+ IndexExpressionParseNodeRewriter indexExpressionRewriter =
new IndexExpressionParseNodeRewriter(indexTableRef.getTable(),
indexTableRef.getTableAlias(), connection,
indexSelect.getUdfParseNodes(),
- breadcrumbContext));
+ breadcrumbContext);
+ indexSelect = ParseNodeRewriter.rewrite(indexSelect,
indexExpressionRewriter);
+ // Surface which functional index expressions actually matched so the
optimizer can label
+ // the chosen index's rule and distinguish functional index rejections
that matched nothing.
+ if (indexExpressionRewriter.hasFunctionalColumns()) {
+ breadcrumbContext
+
.recordFunctionalIndex(indexTableRef.getTable().getTableName().getString());
+ }
+ breadcrumbContext.recordAppliedIndexExpressionMatches(
+ indexTableRef.getTable().getTableName().getString(),
+ indexExpressionRewriter.getAppliedFunctionalSubstitutions().values());
}
return indexSelect;
diff --git
a/phoenix-core-client/src/main/java/org/apache/phoenix/parse/IndexExpressionParseNodeRewriter.java
b/phoenix-core-client/src/main/java/org/apache/phoenix/parse/IndexExpressionParseNodeRewriter.java
index 8d93f6415f..de3faef662 100644
---
a/phoenix-core-client/src/main/java/org/apache/phoenix/parse/IndexExpressionParseNodeRewriter.java
+++
b/phoenix-core-client/src/main/java/org/apache/phoenix/parse/IndexExpressionParseNodeRewriter.java
@@ -19,6 +19,7 @@ package org.apache.phoenix.parse;
import java.sql.SQLException;
import java.util.Collections;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.phoenix.compile.ColumnResolver;
@@ -44,6 +45,19 @@ public class IndexExpressionParseNodeRewriter extends
ParseNodeRewriter {
private final Map<ParseNode, ParseNode> indexedParseNodeToColumnParseNodeMap;
+ /**
+ * Maps the rewritten parse node of a functional index column to a [colName,
expressionStr] pair.
+ * Plain PK columns are excluded.
+ */
+ private final Map<ParseNode, String[]> indexedParseNodeToFunctionalColumn;
+
+ /**
+ * Records the functional index substitutions that actually fired against
the query, mapping the
+ * index column name to the expression string. Insertion order is preserved
so callers can report
+ * matches in first-seen order.
+ */
+ private final Map<String, String> appliedFunctionalSubstitutions = new
LinkedHashMap<>();
+
public IndexExpressionParseNodeRewriter(PTable index, String alias,
PhoenixConnection connection,
Map<String, UDFParseNode> udfParseNodes) throws SQLException {
this(index, alias, connection, udfParseNodes, null);
@@ -54,6 +68,7 @@ public class IndexExpressionParseNodeRewriter extends
ParseNodeRewriter {
throws SQLException {
indexedParseNodeToColumnParseNodeMap =
Maps.newHashMapWithExpectedSize(index.getColumns().size());
+ indexedParseNodeToFunctionalColumn =
Maps.newHashMapWithExpectedSize(index.getColumns().size());
NamedTableNode tableNode =
NamedTableNode.create(alias,
TableName.create(index.getParentSchemaName().getString(),
index.getParentTableName().getString()), Collections.<ColumnDef>
emptyList());
@@ -80,6 +95,14 @@ public class IndexExpressionParseNodeRewriter extends
ParseNodeRewriter {
columnParseNode = NODE_FACTORY.cast(columnParseNode,
expressionDataType, null, null);
}
indexedParseNodeToColumnParseNodeMap.put(indexedParseNode,
columnParseNode);
+ // Only true functional columns defined over an expression get an
applied match entry. A
+ // plain indexed or PK column's expression string parses to a bare
column reference. An
+ // expression column (e.g. UPPER(NAME)) parses to a compound node.
+ if (!(expressionParseNode instanceof ColumnParseNode)) {
+ // Trim leading/trailing whitespace
+ indexedParseNodeToFunctionalColumn.put(indexedParseNode,
+ new String[] { colName, expressionStr.trim() });
+ }
if (breadcrumbContext != null) {
breadcrumbContext.addAppliedRewrite("INDEX EXPRESSION " +
expressionStr + " AS " + colName);
breadcrumbContext.addIndexExpressionSubstitution(indexedParseNode,
colName);
@@ -90,9 +113,32 @@ public class IndexExpressionParseNodeRewriter extends
ParseNodeRewriter {
@Override
protected ParseNode leaveCompoundNode(CompoundParseNode node,
List<ParseNode> children,
CompoundNodeFactory factory) {
- return indexedParseNodeToColumnParseNodeMap.containsKey(node)
- ? indexedParseNodeToColumnParseNodeMap.get(node)
- : super.leaveCompoundNode(node, children, factory);
+ ParseNode replacement = indexedParseNodeToColumnParseNodeMap.get(node);
+ if (replacement == null) {
+ return super.leaveCompoundNode(node, children, factory);
+ }
+ // A functional index substitution actually fired for this query node.
+ String[] functionalColumn = indexedParseNodeToFunctionalColumn.get(node);
+ if (functionalColumn != null) {
+ appliedFunctionalSubstitutions.put(functionalColumn[0],
functionalColumn[1]);
+ }
+ return replacement;
+ }
+
+ /**
+ * Returns the functional index substitutions that actually fired against
the query, keyed by
+ * index column name with the expression string as the value, in first-seen
order.
+ */
+ public Map<String, String> getAppliedFunctionalSubstitutions() {
+ return Collections.unmodifiableMap(appliedFunctionalSubstitutions);
+ }
+
+ /**
+ * Returns {@code true} if the index this rewriter was built for has at
least one functional
+ * column, regardless of whether any such column matched the query.
+ */
+ public boolean hasFunctionalColumns() {
+ return !indexedParseNodeToFunctionalColumn.isEmpty();
}
}
diff --git
a/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java
b/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java
index 9ee22f54f7..abd027ca1a 100644
---
a/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java
+++
b/phoenix-core/src/test/java/org/apache/phoenix/compile/QueryOptimizerTest.java
@@ -511,6 +511,48 @@ public class QueryOptimizerTest extends
BaseConnectionlessQueryTest {
.indexRejected(0, "INDEX_TEST_TABLE_INDEX_D",
OptimizerReasons.REASON_NO_PK_PREFIX_BOUND);
}
+ @Test
+ public void testFunctionalIndexChosenRuleMatchesExpression() throws
Exception {
+ Connection conn = DriverManager.getConnection(getUrl());
+ conn.createStatement().execute(
+ "create table fe_match (id varchar not null primary key, name varchar,
val varchar)");
+ conn.createStatement().execute("create index fe_match_upper_idx on
fe_match (UPPER(name))");
+ // The functional index's indexed expression matches the query's
UPPER(NAME) path expression,
+ // so the chosen index's rule is overridden to the "matches <expr>" form.
Assert on the rule
+ // prefix.
+ assertPlan(conn, "select id from fe_match where UPPER(name) = 'ABC'")
+
.table("FE_MATCH_UPPER_IDX").indexRule(OptimizerReasons.matches("UPPER(NAME)"));
+ }
+
+ @Test
+ public void testFunctionalIndexRejectedPathExpressionDoesNotMatch() throws
Exception {
+ Connection conn = DriverManager.getConnection(getUrl());
+ conn.createStatement().execute(
+ "create table fe_nomatch (id varchar not null primary key, name varchar,
val varchar, other varchar)");
+ conn.createStatement().execute("create index fe_nomatch_upper_idx on
fe_nomatch (UPPER(name))");
+ conn.createStatement()
+ .execute("create index fe_nomatch_val_idx on fe_nomatch (val) include
(other)");
+ // The query never references UPPER(NAME), so the functional index matched
no path expression
+ // and its generic "does not cover projection" rejection is swapped for
the more specific
+ // "path expression does not match".
+ assertPlan(conn, "select val, other from fe_nomatch where val = 'x'")
+
.table("FE_NOMATCH_VAL_IDX").indexRejectedContains("FE_NOMATCH_UPPER_IDX",
+ OptimizerReasons.REASON_PATH_EXPRESSION_DOES_NOT_MATCH);
+ }
+
+ @Test
+ public void testFunctionalIndexRejectedDoesNotCoverProjection() throws
Exception {
+ Connection conn = DriverManager.getConnection(getUrl());
+ conn.createStatement().execute(
+ "create table fe_cover (id varchar not null primary key, name varchar,
val varchar)");
+ conn.createStatement().execute("create index fe_cover_upper_idx on
fe_cover (UPPER(name))");
+ // The functional index's expression DOES match UPPER(NAME) in the query,
but VAL is not in the
+ // index, so the rejection stays "does not cover projection" rather than
being swapped.
+ assertPlan(conn, "select UPPER(name), val from fe_cover where UPPER(name)
= 'ABC'")
+ .table("FE_COVER").indexRejectedContains("FE_COVER_UPPER_IDX",
+ OptimizerReasons.REASON_DOES_NOT_COVER_PROJECTION);
+ }
+
@Test
public void testCharArrayLength() throws Exception {
Connection conn = DriverManager.getConnection(getUrl());