This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new dbc50f4cad5 Follow-up to #18941: routing-filter pruner-invariant test
+ construction cleanup (#18951)
dbc50f4cad5 is described below
commit dbc50f4cad5e64b5d3a02a00ea4ac3acf781fea2
Author: Xiang Fu <[email protected]>
AuthorDate: Fri Jul 10 20:39:10 2026 +0200
Follow-up to #18941: routing-filter pruner-invariant test + construction
cleanup (#18951)
* Follow-up to #18941: routing-filter test + construction cleanup
#18941 fixed segment pruners throwing "No enum constant
org.apache.pinot.sql.FilterKind.<op>" when a boolean scalar function is used
directly as a predicate, by wrapping non-FilterKind operators as
EQUALS(fn, true). This is a small follow-up on top of it.
Test: add testMixedPredicateYieldsOnlyFilterKindOperatorsForPruners, which
asserts the invariant pruners actually depend on -- every operator they
traverse
resolves via FilterKind.valueOf -- over a whole normalized filter tree
rather
than a single wrapped node. It uses the shape of a reported failure, where
arrayContainsString canonicalized to the lowercase operator
"arraycontainsstring" and sat directly under AND.
Cleanup: build the EQUALS/AND expressions via
RequestUtils.getFunctionExpression
instead of hand-rolling Expression/Function and their operand lists in three
places (addFilterExpression, the always-false literal branch, and
wrapAsEqualsTrue). RequestUtils.getFunction already allocates a mutable
ArrayList for operands, so this is behavior-preserving; it also drops the
now
unused ExpressionType import.
* Address review: assert pruner-visited nodes are FUNCTION expressions
Segment pruners dereference filterExpression.getFunctionCall()
unconditionally
before resolving the operator, so a non-FUNCTION node NPEs them -- which is
precisely what ensureFilterIsFunctionExpression exists to prevent. The
invariant
helper returned early on a null function call, so a regression producing a
non-FUNCTION node would have passed silently.
Assert the function call is non-null instead of returning, and broaden the
predicate under test to mix all the shapes that arrive non-FUNCTION or
non-FilterKind: a boolean scalar function, a bare boolean identifier, and a
constant-folded FALSE literal. Also assert no operand is dropped.
Verified the assertion has teeth: reverting the identifier-wrapping in
ensureFilterIsFunctionExpression makes the test fail on the bare IDENTIFIER
node rather than pass.
---
.../planner/logical/LeafStageToPinotQuery.java | 30 ++++++---------
.../planner/logical/LeafStageToPinotQueryTest.java | 44 ++++++++++++++++++++++
2 files changed, 55 insertions(+), 19 deletions(-)
diff --git
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQuery.java
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQuery.java
index f6777b9dcc3..c6fca972052 100644
---
a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQuery.java
+++
b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQuery.java
@@ -30,7 +30,6 @@ import org.apache.calcite.rel.core.TableScan;
import org.apache.commons.lang3.EnumUtils;
import org.apache.pinot.common.request.DataSource;
import org.apache.pinot.common.request.Expression;
-import org.apache.pinot.common.request.ExpressionType;
import org.apache.pinot.common.request.Function;
import org.apache.pinot.common.request.PinotQuery;
import org.apache.pinot.common.utils.request.RequestUtils;
@@ -137,11 +136,8 @@ public class LeafStageToPinotQuery {
pinotQuery.setFilterExpression(filterExpression);
return;
}
- Function andFunction = new Function(FilterKind.AND.name());
- andFunction.setOperands(new ArrayList<>(List.of(existingFilter,
filterExpression)));
- Expression combined = new Expression(ExpressionType.FUNCTION);
- combined.setFunctionCall(andFunction);
- pinotQuery.setFilterExpression(combined);
+ pinotQuery.setFilterExpression(
+ RequestUtils.getFunctionExpression(FilterKind.AND.name(),
existingFilter, filterExpression));
}
/**
@@ -218,24 +214,20 @@ public class LeafStageToPinotQuery {
// don't unnecessarily scan everything.
if (expression.getLiteral() != null &&
expression.getLiteral().isSetBoolValue()
&& !expression.getLiteral().getBoolValue()) {
- Function alwaysFalse = new Function(FilterKind.EQUALS.name());
- alwaysFalse.setOperands(new ArrayList<>(List.of(
- RequestUtils.getLiteralExpression(0),
- RequestUtils.getLiteralExpression(1))));
- Expression wrapped = new Expression(ExpressionType.FUNCTION);
- wrapped.setFunctionCall(alwaysFalse);
- return wrapped;
+ // EQUALS(0, 1) — a predicate that is always false.
+ return RequestUtils.getFunctionExpression(FilterKind.EQUALS.name(),
+ RequestUtils.getLiteralExpression(0),
RequestUtils.getLiteralExpression(1));
}
// LITERAL true or non-boolean literals have no filter constraint so we
skip pruning
return null;
}
- /** Wraps a boolean-valued expression as the standard predicate {@code
EQUALS(expression, true)}. */
+ /**
+ * Wraps a boolean-valued expression as the standard predicate {@code
EQUALS(expression, true)}. The operand list
+ * is mutable so downstream rewriters (e.g. {@code
PredicateComparisonRewriter}) can modify it.
+ */
private static Expression wrapAsEqualsTrue(Expression expression) {
- Function equalsFunction = new Function(FilterKind.EQUALS.name());
- equalsFunction.setOperands(new ArrayList<>(List.of(expression,
RequestUtils.getLiteralExpression(true))));
- Expression wrapped = new Expression(ExpressionType.FUNCTION);
- wrapped.setFunctionCall(equalsFunction);
- return wrapped;
+ return RequestUtils.getFunctionExpression(FilterKind.EQUALS.name(),
expression,
+ RequestUtils.getLiteralExpression(true));
}
}
diff --git
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQueryTest.java
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQueryTest.java
index 76ac02898af..da441625044 100644
---
a/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQueryTest.java
+++
b/pinot-query-planner/src/test/java/org/apache/pinot/query/planner/logical/LeafStageToPinotQueryTest.java
@@ -39,6 +39,7 @@ import org.apache.pinot.common.request.Function;
import org.apache.pinot.common.request.PinotQuery;
import org.apache.pinot.common.utils.request.RequestUtils;
import org.apache.pinot.query.type.TypeFactory;
+import org.apache.pinot.sql.FilterKind;
import org.mockito.Mockito;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@@ -237,6 +238,49 @@ public class LeafStageToPinotQueryTest {
assertEquals(operand.getFunctionCall().getOperands().get(0).getFunctionCall().getOperator(),
"contains");
}
+ @Test
+ public void testMixedPredicateYieldsOnlyFilterKindOperatorsForPruners() {
+ // Shape of a reported failure: a scalar function canonicalizes to a
lowercase operator
+ // ("arraycontainsstring") and sits directly under AND, so pruners threw
+ // "No enum constant org.apache.pinot.sql.FilterKind.arraycontainsstring".
+ // Assert the two invariants pruners actually rely on, over the whole
normalized tree rather than a single
+ // wrapped node: every visited node is a FUNCTION (pruners dereference
getFunctionCall() unconditionally),
+ // and every operator resolves via FilterKind.valueOf.
+ // The AND mixes all the shapes that arrive non-FUNCTION or
non-FilterKind: a boolean scalar function, a
+ // bare boolean identifier, and a constant-folded FALSE literal.
+ Expression notEquals = makeCompound("NOT_EQUALS",
+ RequestUtils.getIdentifierExpression("label"),
RequestUtils.getLiteralExpression("Low"));
+ Expression scalarFn = makeCompound("arraycontainsstring",
+ RequestUtils.getIdentifierExpression("groups"),
RequestUtils.getLiteralExpression("y"));
+ Expression andExpr = makeCompound("AND", notEquals, scalarFn,
makeEquals("status", "open"),
+ RequestUtils.getIdentifierExpression("isActive"),
RequestUtils.getLiteralExpression(false));
+
+ Expression result =
LeafStageToPinotQuery.ensureFilterIsFunctionExpression(andExpr);
+
+ assertNotNull(result);
+ assertEquals(result.getFunctionCall().getOperator(), "AND");
+ assertEquals(result.getFunctionCall().getOperands().size(), 5, "No operand
should be dropped");
+ assertPrunerTraversableOperatorsAreFilterKinds(result);
+ }
+
+ /**
+ * Mirrors how segment pruners walk a filter: on each node they visit they
dereference the function call and
+ * resolve its operator via {@code FilterKind.valueOf}, then recurse only
into the operands of AND/OR/NOT.
+ * A non-FUNCTION node would NPE the pruners and an unknown operator would
throw, so both are asserted here.
+ */
+ private static void
assertPrunerTraversableOperatorsAreFilterKinds(Expression expression) {
+ // Pruners dereference getFunctionCall() unconditionally, so a
non-FUNCTION node here would NPE them.
+ Function function = expression.getFunctionCall();
+ assertNotNull(function, "Pruner-visited node must be a FUNCTION
expression, got: " + expression);
+ // Must not throw — this is exactly what segment pruners call on every
node they visit.
+ FilterKind filterKind = FilterKind.valueOf(function.getOperator());
+ if (filterKind == FilterKind.AND || filterKind == FilterKind.OR ||
filterKind == FilterKind.NOT) {
+ for (Expression operand : function.getOperands()) {
+ assertPrunerTraversableOperatorsAreFilterKinds(operand);
+ }
+ }
+ }
+
// --- AND/OR handling (shared logic, tested symmetrically) ---
@Test
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]