This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new e1b39e2318c [feature](lance) push down common string and boolean
predicates (#67051)
e1b39e2318c is described below
commit e1b39e2318ca58361e00986ea6bc705e07cc8bda
Author: jay <[email protected]>
AuthorDate: Sun Sep 6 06:15:18 2026 +0800
[feature](lance) push down common string and boolean predicates (#67051)
### What problem does this PR solve?
Lance regular scans currently leave common string predicates and direct
boolean predicates in Doris, so Lance cannot filter rows before Arrow
materialization and transfer.
### What is changed?
- Push two-argument `LIKE` / `NOT LIKE` predicates without backslash
escapes through the existing Substrait filter.
- Push `starts_with(column, literal)` and `ends_with(column, literal)`.
- Push direct boolean-column predicates, including `NOT boolean_column`.
- Keep explicit/custom LIKE escapes, REGEXP, non-literal patterns, and
non-string inputs as Doris residual predicates.
- Extend FE unit and external Lance regression coverage.
### Testing
- `mvn -pl fe-core -am -Dtest=LancePredicateConverterTest
-DfailIfNoTests=false test` (21 passed)
- `test_lance_scalar_predicate_pushdown` against local MinIO + FE + BE
- Manual end-to-end LargeUtf8 `starts_with` verification
### Release note
Improve Lance predicate pushdown for common string and boolean
expressions.
---
.../lance/source/LancePredicateConverter.java | 77 ++++++++++++++++++
.../lance/LancePredicateConverterTest.java | 90 ++++++++++++++++++++++
.../lance/test_lance_scalar_predicate_pushdown.out | 55 +++++++++++++
.../test_lance_scalar_predicate_pushdown.groovy | 68 +++++++++++++++-
4 files changed, 289 insertions(+), 1 deletion(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LancePredicateConverter.java
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LancePredicateConverter.java
index 784896eff09..41fc1d855f8 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LancePredicateConverter.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/datasource/lance/source/LancePredicateConverter.java
@@ -24,14 +24,17 @@ import org.apache.doris.analysis.DateLiteral;
import org.apache.doris.analysis.DecimalLiteral;
import org.apache.doris.analysis.Expr;
import org.apache.doris.analysis.FloatLiteral;
+import org.apache.doris.analysis.FunctionCallExpr;
import org.apache.doris.analysis.InPredicate;
import org.apache.doris.analysis.IntLiteral;
import org.apache.doris.analysis.IsNullPredicate;
import org.apache.doris.analysis.LargeIntLiteral;
+import org.apache.doris.analysis.LikePredicate;
import org.apache.doris.analysis.LiteralExpr;
import org.apache.doris.analysis.NullLiteral;
import org.apache.doris.analysis.SlotRef;
import org.apache.doris.analysis.StringLiteral;
+import org.apache.doris.thrift.TFunctionBinaryType;
import io.substrait.expression.Expression;
import io.substrait.expression.ExpressionCreator;
@@ -131,6 +134,15 @@ public class LancePredicateConverter {
if (expr instanceof IsNullPredicate) {
return convertIsNull((IsNullPredicate) expr);
}
+ if (expr instanceof LikePredicate) {
+ return convertLike((LikePredicate) expr);
+ }
+ if (expr instanceof FunctionCallExpr) {
+ return convertStringFunction((FunctionCallExpr) expr);
+ }
+ if (expr instanceof SlotRef) {
+ return convertBooleanSlot((SlotRef) expr);
+ }
return Optional.empty();
}
@@ -257,6 +269,63 @@ public class LancePredicateConverter {
return Optional.of(comparisonFunction(function,
fieldReference(field)));
}
+ private Optional<Expression> convertLike(LikePredicate predicate) {
+ if (predicate.getOp() != LikePredicate.Operator.LIKE) {
+ return Optional.empty();
+ }
+ return convertStringPredicate("like:str_str", predicate.getChild(0),
predicate.getChild(1), true);
+ }
+
+ private Optional<Expression> convertStringFunction(FunctionCallExpr
function) {
+ if (function.getFnName() == null || function.getFn() == null
+ || function.getFn().getBinaryType() !=
TFunctionBinaryType.BUILTIN
+ || function.getChildren().size() != 2) {
+ return Optional.empty();
+ }
+ String functionName =
function.getFnName().getFunction().toLowerCase(Locale.ROOT);
+ switch (functionName) {
+ case "like":
+ return convertStringPredicate(
+ "like:str_str", function.getChild(0),
function.getChild(1), true);
+ case "starts_with":
+ return convertStringPredicate(
+ "starts_with:str_str", function.getChild(0),
function.getChild(1), false);
+ case "ends_with":
+ return convertStringPredicate(
+ "ends_with:str_str", function.getChild(0),
function.getChild(1), false);
+ default:
+ return Optional.empty();
+ }
+ }
+
+ private Optional<Expression> convertStringPredicate(
+ String function, Expr input, Expr pattern, boolean
rejectEscapedPattern) {
+ SlotRef slot = directSlot(input);
+ LiteralExpr literal = directLiteral(pattern);
+ ResolvedField field = slot == null ? null : findField(slot);
+ if (field == null || !isStringType(field.field.getType()) || !(literal
instanceof StringLiteral)) {
+ return Optional.empty();
+ }
+ String patternValue = literal.getStringValue();
+ // Doris uses backslash as LIKE's default escape character, while the
Substrait function
+ // has no escape argument. Keep escaped LIKE patterns in Doris rather
than changing meaning.
+ if (patternValue.indexOf('\0') >= 0
+ || (rejectEscapedPattern && patternValue.indexOf('\\') >= 0)) {
+ return Optional.empty();
+ }
+ return Optional.of(stringFunction(function, fieldReference(field),
+ ExpressionCreator.string(false, patternValue)));
+ }
+
+ private Optional<Expression> convertBooleanSlot(SlotRef slot) {
+ ResolvedField field = findField(slot);
+ if (field == null || !(field.field.getType() instanceof
ArrowType.Bool)) {
+ return Optional.empty();
+ }
+ return Optional.of(comparisonFunction("equal:any_any",
+ fieldReference(field), ExpressionCreator.bool(false, true)));
+ }
+
// convert doris literal to Substrait literal with arrow type
private Optional<Expression> convertLiteral(ArrowType type, LiteralExpr
literal) {
if (type instanceof ArrowType.Bool && literal instanceof BoolLiteral) {
@@ -450,6 +519,10 @@ public class LancePredicateConverter {
|| type instanceof ArrowType.LargeUtf8;
}
+ private static boolean isStringType(ArrowType type) {
+ return type instanceof ArrowType.Utf8 || type instanceof
ArrowType.LargeUtf8;
+ }
+
// slotref with ordinal index with Substrait Type
private Expression fieldReference(ResolvedField field) {
return FieldReference.newRootStructReference(field.ordinal,
toSubstraitType(field.field));
@@ -502,6 +575,10 @@ public class LancePredicateConverter {
return scalarFunction(DefaultExtensionCatalog.FUNCTIONS_BOOLEAN, key,
arguments);
}
+ private static Expression stringFunction(String key, Expression...
arguments) {
+ return scalarFunction(DefaultExtensionCatalog.FUNCTIONS_STRING, key,
Arrays.asList(arguments));
+ }
+
private static Expression scalarFunction(String uri, String key,
List<Expression> arguments) {
SimpleExtension.ScalarFunctionVariant declaration =
EXTENSIONS.getScalarFunction(
SimpleExtension.FunctionAnchor.of(uri, key));
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LancePredicateConverterTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LancePredicateConverterTest.java
index 66709546786..64e6fd70724 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LancePredicateConverterTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/lance/LancePredicateConverterTest.java
@@ -23,15 +23,19 @@ import org.apache.doris.analysis.DateLiteral;
import org.apache.doris.analysis.DecimalLiteral;
import org.apache.doris.analysis.Expr;
import org.apache.doris.analysis.FunctionCallExpr;
+import org.apache.doris.analysis.FunctionName;
import org.apache.doris.analysis.InPredicate;
import org.apache.doris.analysis.IntLiteral;
import org.apache.doris.analysis.IsNullPredicate;
import org.apache.doris.analysis.LargeIntLiteral;
+import org.apache.doris.analysis.LikePredicate;
import org.apache.doris.analysis.SlotRef;
import org.apache.doris.analysis.StringLiteral;
+import org.apache.doris.catalog.ScalarFunction;
import org.apache.doris.catalog.ScalarType;
import org.apache.doris.catalog.Type;
import org.apache.doris.datasource.lance.source.LancePredicateConverter;
+import org.apache.doris.thrift.TFunctionBinaryType;
import io.substrait.proto.ExtendedExpression;
import org.apache.arrow.vector.types.DateUnit;
@@ -215,6 +219,85 @@ public class LancePredicateConverterTest {
Assertions.assertEquals(3, result.getPushedConjuncts().size());
}
+ @Test
+ public void testStringPredicates() {
+ Expr legacyLike = new LikePredicate(LikePredicate.Operator.LIKE,
+ new SlotRef(null, "label"), new StringLiteral("ready%"));
+ Expr like = stringFunction(
+ "like", new SlotRef(null, "label"), new
StringLiteral("%ead_"));
+ Expr startsWith = stringFunction(
+ "starts_with", new SlotRef(null, "label"), new
StringLiteral("ready"));
+ Expr endsWith = stringFunction(
+ "ends_with", new SlotRef(null, "large_label"), new
StringLiteral("done"));
+
+ LancePredicateConverter.ConversionResult result =
+ converter.convert(Arrays.asList(legacyLike, like, startsWith,
endsWith));
+
+ ExtendedExpression envelope = Assertions.assertDoesNotThrow(
+ () ->
ExtendedExpression.parseFrom(result.getSubstraitFilter()));
+ String serialized = envelope.toString();
+ Assertions.assertTrue(serialized.contains("like:str_str"));
+ Assertions.assertTrue(serialized.contains("starts_with:str_str"));
+ Assertions.assertTrue(serialized.contains("ends_with:str_str"));
+ Assertions.assertEquals(4, result.getPushedConjuncts().size());
+ }
+
+ @Test
+ public void testUnsupportedStringPredicatesRemainResidual() {
+ Expr regexp = new LikePredicate(LikePredicate.Operator.REGEXP,
+ new SlotRef(null, "label"), new StringLiteral("ready.*"));
+ Expr escapedLike = new LikePredicate(LikePredicate.Operator.LIKE,
+ new SlotRef(null, "label"), new StringLiteral("ready\\%"));
+ Expr explicitEscape = stringFunction("like",
+ new SlotRef(null, "label"), new StringLiteral("ready!%"), new
StringLiteral("!"));
+ Expr nonLiteralPattern = stringFunction(
+ "starts_with", new SlotRef(null, "label"), new SlotRef(null,
"event-type"));
+ Expr nonStringInput = stringFunction(
+ "ends_with", new SlotRef(null, "row_id"), new
StringLiteral("1"));
+
+ LancePredicateConverter.ConversionResult result = converter.convert(
+ Arrays.asList(regexp, escapedLike, explicitEscape,
nonLiteralPattern, nonStringInput));
+
+ Assertions.assertEquals(0, result.getSubstraitFilter().length);
+ Assertions.assertTrue(result.getPushedConjuncts().isEmpty());
+ }
+
+ @Test
+ public void testResolvedUdfAndNulStringPredicatesRemainResidual() {
+ FunctionCallExpr udf = stringFunction(
+ "starts_with", new SlotRef(null, "label"), new
StringLiteral("ready"));
+ udf.getFn().setBinaryType(TFunctionBinaryType.JAVA_UDF);
+ Expr legacyNulLike = new LikePredicate(LikePredicate.Operator.LIKE,
+ new SlotRef(null, "label"), new StringLiteral("m\0_"));
+ Expr functionNulLike = stringFunction(
+ "like", new SlotRef(null, "label"), new StringLiteral("m\0_"));
+
+ LancePredicateConverter.ConversionResult result =
+ converter.convert(Arrays.asList(udf, legacyNulLike,
functionNulLike));
+
+ Assertions.assertEquals(0, result.getSubstraitFilter().length);
+ Assertions.assertTrue(result.getPushedConjuncts().isEmpty());
+ }
+
+ @Test
+ public void testDirectBooleanPredicates() {
+ LancePredicateConverter boolConverter = new
LancePredicateConverter(new Schema(
+ Collections.singletonList(Field.nullable("active",
ArrowType.Bool.INSTANCE))));
+ Expr active = new SlotRef(null, "active");
+ Expr notActive = new CompoundPredicate(
+ CompoundPredicate.Operator.NOT, new SlotRef(null, "active"),
null);
+
+ LancePredicateConverter.ConversionResult result =
+ boolConverter.convert(Arrays.asList(active, notActive));
+
+ ExtendedExpression envelope = Assertions.assertDoesNotThrow(
+ () ->
ExtendedExpression.parseFrom(result.getSubstraitFilter()));
+ String serialized = envelope.toString();
+ Assertions.assertTrue(serialized.contains("equal:any_any"));
+ Assertions.assertTrue(serialized.contains("not:bool"));
+ Assertions.assertEquals(2, result.getPushedConjuncts().size());
+ }
+
@Test
public void testNullableNullSafeEqualityPreservesTwoValuedSemantics() {
Expr nullableNullSafeEqual = new
BinaryPredicate(BinaryPredicate.Operator.EQ_FOR_NULL,
@@ -473,6 +556,13 @@ public class LancePredicateConverterTest {
Assertions.assertEquals(1, result.getPushedConjuncts().size());
}
+ private FunctionCallExpr stringFunction(String name, Expr... arguments) {
+ FunctionCallExpr function = new FunctionCallExpr(name,
Arrays.asList(arguments));
+ function.setFn(new ScalarFunction(new FunctionName(name),
+ Collections.nCopies(arguments.length, Type.VARCHAR),
Type.BOOLEAN, false, true));
+ return function;
+ }
+
private void assertNullSafeEqualityComposition(Expr predicate) {
LancePredicateConverter.ConversionResult result =
converter.convert(Collections.singletonList(predicate));
diff --git
a/regression-test/data/external_table_p0/lance/test_lance_scalar_predicate_pushdown.out
b/regression-test/data/external_table_p0/lance/test_lance_scalar_predicate_pushdown.out
index 91b378709d6..595e35993dd 100644
---
a/regression-test/data/external_table_p0/lance/test_lance_scalar_predicate_pushdown.out
+++
b/regression-test/data/external_table_p0/lance/test_lance_scalar_predicate_pushdown.out
@@ -78,6 +78,61 @@
9
10
+-- !select_bool_direct --
+5
+6
+7
+9
+10
+
+-- !select_bool_direct_not --
+2
+3
+4
+8
+
+-- !select_utf8_starts_with --
+7
+8
+
+-- !select_utf8_ends_with --
+4
+6
+
+-- !select_utf8_like_prefix --
+2
+4
+10
+
+-- !select_utf8_like_contains --
+3
+7
+10
+
+-- !select_utf8_like_single_wildcard --
+7
+8
+
+-- !select_utf8_not_like --
+2
+3
+4
+5
+6
+9
+10
+
+-- !select_utf8_like_explicit_escape --
+
+-- !select_utf8_like_nul_residual --
+2
+4
+10
+
+-- !select_utf8_regexp_residual --
+7
+8
+
-- !select_float32_eq --
7
8
diff --git
a/regression-test/suites/external_table_p0/lance/test_lance_scalar_predicate_pushdown.groovy
b/regression-test/suites/external_table_p0/lance/test_lance_scalar_predicate_pushdown.groovy
index edd47dff285..f52f37cdfaa 100644
---
a/regression-test/suites/external_table_p0/lance/test_lance_scalar_predicate_pushdown.groovy
+++
b/regression-test/suites/external_table_p0/lance/test_lance_scalar_predicate_pushdown.groovy
@@ -22,7 +22,7 @@ suite("test_lance_scalar_predicate_pushdown", "p0,external") {
*
* | Lance / Arrow type | Doris type | Operators exercised |
* |---|---|---|
- * | bool | boolean | =, !=, <>, <=>, IN, NOT IN, IS NULL, IS NOT NULL,
OR, NOT |
+ * | bool | boolean | Direct predicate, =, !=, <>, <=>, IN, NOT IN, IS
NULL, IS NOT NULL, OR, NOT |
* | float32 | float | All operators below |
* | float64 | double | All operators below |
* | decimal128 | decimal(18,2) | All operators below |
@@ -84,6 +84,19 @@ suite("test_lance_scalar_predicate_pushdown", "p0,external")
{
}
}
+ Closure verifyResidual = { String query, String expression ->
+ explain {
+ sql(query)
+ notContains "lancePushdownPredicate="
+ check { explainString ->
+ String residual = explainString.readLines()
+ .find { line ->
line.trim().startsWith("predicates:") }
+ return residual != null
+ &&
residual.toLowerCase().contains(expression.toLowerCase())
+ }
+ }
+ }
+
Closure verifyOrderedScalarPushdown = { String tableName, String
typeName, String columnName, Map values ->
String eqQuery = """ SELECT row_id FROM ${tableName} WHERE
${columnName} = ${values.equal} ORDER BY row_id; """
verifyFullyPushedDown(eqQuery, columnName)
@@ -202,10 +215,63 @@ suite("test_lance_scalar_predicate_pushdown",
"p0,external") {
String boolReversedQuery = """ SELECT row_id FROM
predicate_pushdown WHERE true = bool_value ORDER BY row_id; """
verifyFullyPushedDown(boolReversedQuery, "bool_value")
quickTest("select_bool_reversed", boolReversedQuery)
+
+ String boolDirectQuery = """ SELECT row_id FROM predicate_pushdown
WHERE bool_value ORDER BY row_id; """
+ verifyFullyPushedDown(boolDirectQuery, "bool_value")
+ quickTest("select_bool_direct", boolDirectQuery)
+
+ String boolDirectNotQuery = """ SELECT row_id FROM
predicate_pushdown WHERE NOT bool_value ORDER BY row_id; """
+ verifyFullyPushedDown(boolDirectNotQuery, "bool_value")
+ quickTest("select_bool_direct_not", boolDirectNotQuery)
}
verifyBooleanPushdown()
+ String startsWithQuery =
+ """ SELECT row_id FROM predicate_pushdown WHERE
starts_with(utf8_value, 'ten') ORDER BY row_id; """
+ verifyFullyPushedDown(startsWithQuery, "utf8_value")
+ quickTest("select_utf8_starts_with", startsWithQuery)
+
+ String endsWithQuery =
+ """ SELECT row_id FROM predicate_pushdown WHERE
ends_with(utf8_value, 'one') ORDER BY row_id; """
+ verifyFullyPushedDown(endsWithQuery, "utf8_value")
+ quickTest("select_utf8_ends_with", endsWithQuery)
+
+ String likePrefixQuery =
+ """ SELECT row_id FROM predicate_pushdown WHERE utf8_value
LIKE 'm%' ORDER BY row_id; """
+ verifyFullyPushedDown(likePrefixQuery, "utf8_value")
+ quickTest("select_utf8_like_prefix", likePrefixQuery)
+
+ String likeContainsQuery =
+ """ SELECT row_id FROM predicate_pushdown WHERE utf8_value
LIKE '%a%' ORDER BY row_id; """
+ verifyFullyPushedDown(likeContainsQuery, "utf8_value")
+ quickTest("select_utf8_like_contains", likeContainsQuery)
+
+ String likeSingleWildcardQuery =
+ """ SELECT row_id FROM predicate_pushdown WHERE utf8_value
LIKE 'ten-_' ORDER BY row_id; """
+ verifyFullyPushedDown(likeSingleWildcardQuery, "utf8_value")
+ quickTest("select_utf8_like_single_wildcard", likeSingleWildcardQuery)
+
+ String notLikeQuery =
+ """ SELECT row_id FROM predicate_pushdown WHERE utf8_value NOT
LIKE 'ten-%' ORDER BY row_id; """
+ verifyFullyPushedDown(notLikeQuery, "utf8_value")
+ quickTest("select_utf8_not_like", notLikeQuery)
+
+ String explicitEscapeQuery =
+ """ SELECT row_id FROM predicate_pushdown WHERE utf8_value
LIKE 'ten!_%' ESCAPE '!' ORDER BY row_id; """
+ verifyResidual(explicitEscapeQuery, "like")
+ quickTest("select_utf8_like_explicit_escape", explicitEscapeQuery)
+
+ String nulLikeQuery =
+ """ SELECT row_id FROM predicate_pushdown WHERE utf8_value
LIKE 'm\\0_' ORDER BY row_id; """
+ verifyResidual(nulLikeQuery, "like")
+ quickTest("select_utf8_like_nul_residual", nulLikeQuery)
+
+ String regexpQuery =
+ """ SELECT row_id FROM predicate_pushdown WHERE utf8_value
REGEXP '^ten-' ORDER BY row_id; """
+ verifyResidual(regexpQuery, "regexp")
+ quickTest("select_utf8_regexp_residual", regexpQuery)
+
verifyOrderedScalarPushdown("predicate_pushdown", "float32",
"float32_value", [
equal: "10",
threshold: "0",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]