This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new bd9e67c feat(predicate): add string and range leaf operators
(StartsWith/EndsWith/Contains/Like/Between) (#425)
bd9e67c is described below
commit bd9e67c0485faebaa9e2a38afcabc55852cc0038
Author: Junrui Lee <[email protected]>
AuthorDate: Wed Jul 1 14:33:35 2026 +0800
feat(predicate): add string and range leaf operators
(StartsWith/EndsWith/Contains/Like/Between) (#425)
---
Cargo.toml | 2 +
.../integrations/datafusion/src/filter_pushdown.rs | 286 +++++++++-
crates/paimon/Cargo.toml | 2 +
crates/paimon/src/arrow/filtering.rs | 6 +
crates/paimon/src/arrow/format/orc.rs | 8 +
crates/paimon/src/arrow/format/parquet.rs | 273 ++++++++-
crates/paimon/src/arrow/format/vortex.rs | 20 +-
crates/paimon/src/btree/query.rs | 47 ++
crates/paimon/src/predicate_stats.rs | 443 ++++++++++++++-
crates/paimon/src/spec/predicate.rs | 623 +++++++++++++++++++++
crates/paimon/src/table/global_index_scanner.rs | 100 +++-
11 files changed, 1765 insertions(+), 45 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index c72bf55..38591c8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,12 +30,14 @@ rust-version = "1.91.0"
[workspace.dependencies]
arrow = "58.0"
arrow-array = { version = "58.0", features = ["ffi"] }
+arrow-arith = "58.0"
arrow-buffer = "58.0"
arrow-schema = "58.0"
arrow-cast = "58.0"
arrow-ord = "58.0"
arrow-row = "58.0"
arrow-select = "58.0"
+arrow-string = "58.0"
datafusion = "53.0.0"
datafusion-ffi = "53.0.0"
paimon = { version = "0.3.0", path = "crates/paimon" }
diff --git a/crates/integrations/datafusion/src/filter_pushdown.rs
b/crates/integrations/datafusion/src/filter_pushdown.rs
index 0d41827..5a000a8 100644
--- a/crates/integrations/datafusion/src/filter_pushdown.rs
+++ b/crates/integrations/datafusion/src/filter_pushdown.rs
@@ -16,8 +16,10 @@
// under the License.
use datafusion::common::{Column, ScalarValue};
-use datafusion::logical_expr::expr::InList;
-use datafusion::logical_expr::{Between, BinaryExpr, Expr, Operator,
TableProviderFilterPushDown};
+use datafusion::logical_expr::expr::{InList, ScalarFunction};
+use datafusion::logical_expr::{
+ Between, BinaryExpr, Expr, Like, Operator, TableProviderFilterPushDown,
+};
use paimon::spec::{DataField, DataType, Datum, Predicate, PredicateBuilder};
#[derive(Debug)]
@@ -146,6 +148,8 @@ impl<'a> FilterTranslator<'a> {
}
Expr::InList(in_list) => self.translate_in_list(in_list),
Expr::Between(between) => self.translate_between(between),
+ Expr::ScalarFunction(func) => self.translate_scalar_function(func),
+ Expr::Like(like) => self.translate_like(like),
_ => None,
}
}
@@ -249,22 +253,56 @@ impl<'a> FilterTranslator<'a> {
field.data_type(),
)?;
- let predicate = Predicate::and(vec![
- self.predicate_builder
- .greater_or_equal(field.name(), low)
- .ok()?,
- self.predicate_builder
- .less_or_equal(field.name(), high)
- .ok()?,
- ]);
-
+ // Native Between / NotBetween leaf: lets the planner / b-tree
+ // recognize the range as a single op (see
`btree::query::extract_between`).
+ // NotBetween is safe to push because its evaluator, stats prune and
+ // Parquet row filter all treat a NULL operand as non-matching (SQL
+ // three-valued logic), and a data-column range stays Inexact so
+ // DataFusion keeps the residual filter.
if between.negated {
- // Same concern as Expr::Not: negation wraps in Predicate::Not
- // which has incorrect NULL semantics for Exact pushdown.
- None
+ self.predicate_builder
+ .not_between(field.name(), low, high)
+ .ok()
} else {
- Some(predicate)
+ self.predicate_builder.between(field.name(), low, high).ok()
+ }
+ }
+
+ fn translate_scalar_function(&self, func: &ScalarFunction) ->
Option<Predicate> {
+ // DataFusion built-in UDFs surfaced from `LIKE 'x%' / '%x' / '%x%'`
+ // rewrites and direct `starts_with(col, 'x') / ends_with / contains`
+ // calls. Only `(col, literal)` shapes are handled; anything else
+ // (transform on either side, non-string args) falls open to None.
+ if func.args.len() != 2 {
+ return None;
+ }
+ let field = self.resolve_field(&func.args[0])?;
+ let scalar = extract_scalar_literal(&func.args[1])?;
+ let datum = scalar_to_datum(scalar, field.data_type())?;
+
+ match func.name() {
+ "starts_with" => self.predicate_builder.starts_with(field.name(),
datum).ok(),
+ "ends_with" => self.predicate_builder.ends_with(field.name(),
datum).ok(),
+ "contains" => self.predicate_builder.contains(field.name(),
datum).ok(),
+ _ => None,
+ }
+ }
+
+ fn translate_like(&self, like: &Like) -> Option<Predicate> {
+ // Negated and case-insensitive (ILIKE) variants stay unsupported for
+ // now: NOT-LIKE has the same NULL-semantics concern as `Expr::Not`;
+ // ILIKE has no equivalent in paimon's predicate model.
+ if like.negated || like.case_insensitive {
+ return None;
}
+ let field = self.resolve_field(like.expr.as_ref())?;
+ let scalar = extract_scalar_literal(like.pattern.as_ref())?;
+ let datum = scalar_to_datum(scalar, field.data_type())?;
+ // PredicateBuilder::like rejects escape characters other than `\`,
+ // so unsupported escapes naturally fall open via `.ok() -> None`.
+ self.predicate_builder
+ .like(field.name(), datum, like.escape_char)
+ .ok()
}
fn resolve_field(&self, expr: &Expr) -> Option<&'a DataField> {
@@ -598,31 +636,219 @@ mod tests {
}
#[test]
- fn test_translate_negated_between_is_not_supported() {
+ fn test_translate_boolean_literal_is_not_supported() {
let fields = test_fields();
- let filter = Expr::Between(Between::new(
- Box::new(Expr::Column(Column::from_name("hr"))),
- true, // negated
- Box::new(lit(1)),
- Box::new(lit(20)),
- ));
+ for value in [true, false] {
+ let filter = Expr::Literal(ScalarValue::Boolean(Some(value)),
None);
+ assert!(
+ build_pushed_predicate(&[filter], &fields).is_none(),
+ "Boolean literal ({value}) is not a partition predicate and
must not be translated"
+ );
+ }
+ }
+
+ #[test]
+ fn test_translate_starts_with_udf() {
+ let fields = test_fields();
+ let filter = datafusion::functions::string::expr_fn::starts_with(
+ Expr::Column(Column::from_name("dt")),
+ lit("2024"),
+ );
+ let predicate =
+ build_pushed_predicate(&[filter], &fields).expect("starts_with
should translate");
+ match predicate {
+ Predicate::Leaf { op, literals, .. } => {
+ assert_eq!(op, paimon::spec::PredicateOperator::StartsWith);
+ assert_eq!(literals, vec![Datum::String("2024".to_string())]);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_translate_ends_with_udf() {
+ let fields = test_fields();
+ let filter = datafusion::functions::string::expr_fn::ends_with(
+ Expr::Column(Column::from_name("dt")),
+ lit("01-01"),
+ );
+ let predicate =
+ build_pushed_predicate(&[filter], &fields).expect("ends_with
should translate");
+ match predicate {
+ Predicate::Leaf { op, .. } => {
+ assert_eq!(op, paimon::spec::PredicateOperator::EndsWith);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_translate_contains_udf() {
+ let fields = test_fields();
+ let filter = datafusion::functions::string::expr_fn::contains(
+ Expr::Column(Column::from_name("dt")),
+ lit("01"),
+ );
+ let predicate =
+ build_pushed_predicate(&[filter], &fields).expect("contains should
translate");
+ match predicate {
+ Predicate::Leaf { op, .. } => {
+ assert_eq!(op, paimon::spec::PredicateOperator::Contains);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_translate_starts_with_on_non_string_column_falls_open() {
+ let fields = test_fields();
+ // `id` is Int — datum coercion fails and translation returns None.
+ let filter = datafusion::functions::string::expr_fn::starts_with(
+ Expr::Column(Column::from_name("id")),
+ lit("foo"),
+ );
assert!(
build_pushed_predicate(&[filter], &fields).is_none(),
- "Negated BETWEEN should not translate due to NULL semantics"
+ "starts_with on non-string column must not translate"
);
}
+ fn like_filter(pattern: &str, negated: bool, case_insensitive: bool) ->
Expr {
+ Expr::Like(Like::new(
+ negated,
+ Box::new(Expr::Column(Column::from_name("dt"))),
+ Box::new(lit(pattern)),
+ None,
+ case_insensitive,
+ ))
+ }
+
#[test]
- fn test_translate_boolean_literal_is_not_supported() {
+ fn test_translate_like_rewrites_to_starts_with() {
let fields = test_fields();
+ let predicate = build_pushed_predicate(&[like_filter("2024%", false,
false)], &fields)
+ .expect("LIKE prefix% should translate");
+ match predicate {
+ Predicate::Leaf { op, literals, .. } => {
+ assert_eq!(op, paimon::spec::PredicateOperator::StartsWith);
+ assert_eq!(literals, vec![Datum::String("2024".to_string())]);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
- for value in [true, false] {
- let filter = Expr::Literal(ScalarValue::Boolean(Some(value)),
None);
- assert!(
- build_pushed_predicate(&[filter], &fields).is_none(),
- "Boolean literal ({value}) is not a partition predicate and
must not be translated"
- );
+ #[test]
+ fn test_translate_like_rewrites_to_ends_with() {
+ let fields = test_fields();
+ let predicate = build_pushed_predicate(&[like_filter("%01-01", false,
false)], &fields)
+ .expect("LIKE %suffix should translate");
+ match predicate {
+ Predicate::Leaf { op, .. } => {
+ assert_eq!(op, paimon::spec::PredicateOperator::EndsWith);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_translate_like_rewrites_to_contains() {
+ let fields = test_fields();
+ let predicate = build_pushed_predicate(&[like_filter("%01%", false,
false)], &fields)
+ .expect("LIKE %mid% should translate");
+ match predicate {
+ Predicate::Leaf { op, .. } => {
+ assert_eq!(op, paimon::spec::PredicateOperator::Contains);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_translate_like_no_wildcards_rewrites_to_eq() {
+ let fields = test_fields();
+ let predicate = build_pushed_predicate(&[like_filter("2024-01-01",
false, false)], &fields)
+ .expect("LIKE without wildcards should translate to Eq");
+ match predicate {
+ Predicate::Leaf { op, .. } => {
+ assert_eq!(op, paimon::spec::PredicateOperator::Eq);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_translate_like_residual_keeps_like_leaf() {
+ let fields = test_fields();
+ let predicate = build_pushed_predicate(&[like_filter("a%b%c", false,
false)], &fields)
+ .expect("complex LIKE should translate as a Like leaf");
+ match predicate {
+ Predicate::Leaf { op, literals, .. } => {
+ assert_eq!(op, paimon::spec::PredicateOperator::Like);
+ assert_eq!(literals, vec![Datum::String("a%b%c".to_string())]);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_translate_negated_like_falls_open() {
+ let fields = test_fields();
+ assert!(
+ build_pushed_predicate(&[like_filter("a%", true, false)],
&fields).is_none(),
+ "NOT LIKE must not translate (NULL semantics)"
+ );
+ }
+
+ #[test]
+ fn test_translate_ilike_falls_open() {
+ let fields = test_fields();
+ assert!(
+ build_pushed_predicate(&[like_filter("a%", false, true)],
&fields).is_none(),
+ "ILIKE must not translate (case-insensitive not modeled)"
+ );
+ }
+
+ #[test]
+ fn test_translate_between_produces_native_between_leaf() {
+ let fields = test_fields();
+ let filter = Expr::Between(Between::new(
+ Box::new(Expr::Column(Column::from_name("hr"))),
+ false,
+ Box::new(lit(1)),
+ Box::new(lit(20)),
+ ));
+ let predicate =
+ build_pushed_predicate(&[filter], &fields).expect("BETWEEN should
translate");
+ match predicate {
+ Predicate::Leaf { op, literals, .. } => {
+ assert_eq!(op, paimon::spec::PredicateOperator::Between);
+ assert_eq!(literals, vec![Datum::Int(1), Datum::Int(20)]);
+ }
+ other => panic!(
+ "expected native Between leaf, got {other:?} (Stage 3 must not
produce \
+ the legacy GtEq+LtEq And shape)"
+ ),
+ }
+ }
+
+ #[test]
+ fn test_translate_not_between_produces_native_not_between_leaf() {
+ let fields = test_fields();
+ let filter = Expr::Between(Between::new(
+ Box::new(Expr::Column(Column::from_name("hr"))),
+ true,
+ Box::new(lit(1)),
+ Box::new(lit(20)),
+ ));
+ let predicate =
+ build_pushed_predicate(&[filter], &fields).expect("NOT BETWEEN
should translate");
+ match predicate {
+ Predicate::Leaf { op, literals, .. } => {
+ assert_eq!(op, paimon::spec::PredicateOperator::NotBetween);
+ assert_eq!(literals, vec![Datum::Int(1), Datum::Int(20)]);
+ }
+ other => panic!("expected native NotBetween leaf, got {other:?}"),
}
}
}
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index e79c129..7755a8e 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -78,12 +78,14 @@ crc32fast = "1"
zstd = "0.13"
snap = "1"
arrow-array = { workspace = true }
+arrow-arith = { workspace = true }
arrow-buffer = { workspace = true }
arrow-cast = { workspace = true }
arrow-ord = { workspace = true }
arrow-row = { workspace = true }
arrow-schema = { workspace = true }
arrow-select = { workspace = true }
+arrow-string = { workspace = true }
futures = "0.3"
tokio-util = { workspace = true, features = ["compat"] }
parquet = { workspace = true, features = ["async", "zstd", "lz4", "snap"] }
diff --git a/crates/paimon/src/arrow/filtering.rs
b/crates/paimon/src/arrow/filtering.rs
index f88c59b..1f450bd 100644
--- a/crates/paimon/src/arrow/filtering.rs
+++ b/crates/paimon/src/arrow/filtering.rs
@@ -143,6 +143,12 @@ fn predicate_supported_for_reader_pruning(predicate:
&Predicate) -> bool {
| PredicateOperator::GtEq
| PredicateOperator::In
| PredicateOperator::NotIn
+ | PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Like
+ | PredicateOperator::Between
+ | PredicateOperator::NotBetween
)
}
Predicate::AlwaysTrue | Predicate::And(_) | Predicate::Or(_) |
Predicate::Not(_) => false,
diff --git a/crates/paimon/src/arrow/format/orc.rs
b/crates/paimon/src/arrow/format/orc.rs
index f4b078c..a90cfb9 100644
--- a/crates/paimon/src/arrow/format/orc.rs
+++ b/crates/paimon/src/arrow/format/orc.rs
@@ -252,6 +252,14 @@ fn build_orc_leaf_predicate(
}
PredicateOperator::IsNull | PredicateOperator::NotEq |
PredicateOperator::NotIn => None,
PredicateOperator::IsNotNull => None,
+ // String/range ops are not pushed into ORC; returning None falls open
to
+ // the outer stats-prune + arrow row-filter path.
+ PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Like
+ | PredicateOperator::Between
+ | PredicateOperator::NotBetween => None,
}
}
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index d7bfc5b..0d70e41 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -22,14 +22,19 @@ use crate::spec::{DataField, DataType, Datum, Predicate,
PredicateOperator};
use crate::table::{ArrowRecordBatchStream, RowRange};
use crate::Error;
use arrow_array::{
- Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Decimal128Array,
Float32Array,
- Float64Array, Int16Array, Int32Array, Int64Array, Int8Array, RecordBatch,
Scalar, StringArray,
+ Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Datum as
ArrowDatum, Decimal128Array,
+ Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array,
RecordBatch, Scalar,
+ StringArray,
};
use arrow_ord::cmp::{
eq as arrow_eq, gt as arrow_gt, gt_eq as arrow_gt_eq, lt as arrow_lt,
lt_eq as arrow_lt_eq,
neq as arrow_neq,
};
use arrow_schema::ArrowError;
+use arrow_string::like::{
+ contains as arrow_contains, ends_with as arrow_ends_with, like as
arrow_like,
+ starts_with as arrow_starts_with,
+};
use async_trait::async_trait;
use bytes::Bytes;
use futures::future::BoxFuture;
@@ -286,6 +291,12 @@ fn predicate_supported_for_parquet_row_filter(op:
PredicateOperator) -> bool {
| PredicateOperator::GtEq
| PredicateOperator::In
| PredicateOperator::NotIn
+ | PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Like
+ | PredicateOperator::Between
+ | PredicateOperator::NotBetween
)
}
@@ -315,6 +326,32 @@ fn parquet_row_filter_literals_supported(
}
Ok(true)
}
+ PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Like => {
+ // Substring kernels only run against string-typed columns; reject
+ // non-string file types early so the filter falls back to stats
+ // pruning + residual evaluation.
+ if !matches!(file_data_type, DataType::Char(_) |
DataType::VarChar(_)) {
+ return Ok(false);
+ }
+ let Some(literal) = literals.first() else {
+ return Ok(false);
+ };
+ Ok(literal_scalar_for_parquet_filter(literal,
file_data_type)?.is_some())
+ }
+ PredicateOperator::Between | PredicateOperator::NotBetween => {
+ if literals.len() != 2 {
+ return Ok(false);
+ }
+ for literal in literals {
+ if literal_scalar_for_parquet_filter(literal,
file_data_type)?.is_none() {
+ return Ok(false);
+ }
+ }
+ Ok(true)
+ }
}
}
@@ -354,7 +391,11 @@ fn evaluate_exact_leaf_predicate(
| PredicateOperator::Lt
| PredicateOperator::LtEq
| PredicateOperator::Gt
- | PredicateOperator::GtEq => {
+ | PredicateOperator::GtEq
+ | PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Like => {
let Some(literal) = literals.first() else {
return Ok(BooleanArray::from(vec![true; array.len()]));
};
@@ -366,9 +407,48 @@ fn evaluate_exact_leaf_predicate(
let result = evaluate_column_predicate(array, &scalar, op)?;
Ok(sanitize_filter_mask(result))
}
+ PredicateOperator::Between | PredicateOperator::NotBetween => {
+ evaluate_between_predicate(array, data_type, op, literals)
+ }
}
}
+/// `Between` / `NotBetween` translate to `gt_eq(col, low) & lt_eq(col, high)`
+/// (and its negation). `arrow_ord::cmp` produces nullable masks: any null
+/// row makes the comparison null, so a fully-built `Between` mask preserves
+/// nulls. `NotBetween` then negates valid rows and leaves nulls null —
+/// matching SQL three-valued logic; `sanitize_filter_mask` collapses nulls
+/// into `false` to match the predicate evaluator's "NULL → false" rule.
+fn evaluate_between_predicate(
+ array: &ArrayRef,
+ data_type: &DataType,
+ op: PredicateOperator,
+ literals: &[Datum],
+) -> Result<BooleanArray, ArrowError> {
+ let (Some(low), Some(high)) = (literals.first(), literals.get(1)) else {
+ return Ok(BooleanArray::from(vec![true; array.len()]));
+ };
+ let Some(low_scalar) = literal_scalar_for_parquet_filter(low, data_type)
+ .map_err(|e| ArrowError::ComputeError(e.to_string()))?
+ else {
+ return Ok(BooleanArray::from(vec![true; array.len()]));
+ };
+ let Some(high_scalar) = literal_scalar_for_parquet_filter(high, data_type)
+ .map_err(|e| ArrowError::ComputeError(e.to_string()))?
+ else {
+ return Ok(BooleanArray::from(vec![true; array.len()]));
+ };
+ let lo_mask = arrow_gt_eq(array, &low_scalar)?;
+ let hi_mask = arrow_lt_eq(array, &high_scalar)?;
+ let between = arrow_arith::boolean::and_kleene(&lo_mask, &hi_mask)?;
+ let result = match op {
+ PredicateOperator::Between => between,
+ PredicateOperator::NotBetween => arrow_arith::boolean::not(&between)?,
+ _ => unreachable!(),
+ };
+ Ok(sanitize_filter_mask(result))
+}
+
fn evaluate_set_membership_predicate(
array: &ArrayRef,
data_type: &DataType,
@@ -423,13 +503,66 @@ fn evaluate_column_predicate(
PredicateOperator::LtEq => arrow_lt_eq(column, scalar),
PredicateOperator::Gt => arrow_gt(column, scalar),
PredicateOperator::GtEq => arrow_gt_eq(column, scalar),
+ PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Like => {
+ let pattern = pattern_scalar_for_string_kernel(scalar,
column.data_type())?;
+ match op {
+ PredicateOperator::StartsWith => arrow_starts_with(column,
&pattern),
+ PredicateOperator::EndsWith => arrow_ends_with(column,
&pattern),
+ PredicateOperator::Contains => arrow_contains(column,
&pattern),
+ PredicateOperator::Like => arrow_like(column, &pattern),
+ _ => unreachable!(),
+ }
+ }
PredicateOperator::IsNull
| PredicateOperator::IsNotNull
| PredicateOperator::In
- | PredicateOperator::NotIn => Ok(BooleanArray::new_null(column.len())),
+ | PredicateOperator::NotIn
+ | PredicateOperator::Between
+ | PredicateOperator::NotBetween =>
Ok(BooleanArray::new_null(column.len())),
}
}
+/// `arrow_string::like::*` kernels reject mismatched string types — Utf8
column
+/// against Utf8 pattern is fine, but a LargeUtf8 / Utf8View column needs a
+/// pattern of the same flavour. The shared scalar built upstream is always
+/// `StringArray` (Utf8); promote it to match the column when needed.
+fn pattern_scalar_for_string_kernel(
+ scalar: &Scalar<ArrayRef>,
+ column_type: &arrow_schema::DataType,
+) -> Result<Scalar<ArrayRef>, ArrowError> {
+ use arrow_array::{LargeStringArray, StringArray, StringViewArray};
+ use arrow_schema::DataType as ArrowDataType;
+
+ let arr = scalar.get().0;
+ let value = arr
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .and_then(|s| (s.len() == 1 && s.is_valid(0)).then(||
s.value(0).to_string()));
+ let Some(value) = value else {
+ return Ok(scalar.clone());
+ };
+ Ok(match column_type {
+ ArrowDataType::Utf8 =>
Scalar::new(Arc::new(StringArray::from(vec![value])) as ArrayRef),
+ ArrowDataType::LargeUtf8 => {
+ Scalar::new(Arc::new(LargeStringArray::from(vec![value])) as
ArrayRef)
+ }
+ ArrowDataType::Utf8View => {
+ Scalar::new(Arc::new(StringViewArray::from(vec![value])) as
ArrayRef)
+ }
+ ArrowDataType::Dictionary(_, value_type) if value_type.as_ref() ==
&ArrowDataType::Utf8 => {
+ Scalar::new(Arc::new(StringArray::from(vec![value])) as ArrayRef)
+ }
+ other => {
+ return Err(ArrowError::InvalidArgumentError(format!(
+ "string predicate against non-string column type {other:?}"
+ )))
+ }
+ })
+}
+
fn sanitize_filter_mask(mask: BooleanArray) -> BooleanArray {
if mask.null_count() == 0 {
return mask;
@@ -1189,6 +1322,138 @@ mod tests {
assert!(row_filter.is_some());
}
+ // -----------------------------------------------------------------------
+ // String predicate tests (StartsWith / EndsWith / Contains)
+ // -----------------------------------------------------------------------
+
+ fn run_string_op(
+ op: super::PredicateOperator,
+ column: arrow_array::ArrayRef,
+ pattern: &str,
+ ) -> arrow_array::BooleanArray {
+ use crate::spec::VarCharType;
+ let dt = DataType::VarChar(VarCharType::default());
+ super::evaluate_exact_leaf_predicate(
+ &column,
+ &dt,
+ op,
+ &[Datum::String(pattern.to_string())],
+ )
+ .expect("string op should evaluate")
+ }
+
+ #[test]
+ fn test_evaluate_starts_with_string_array() {
+ use arrow_array::StringArray;
+ let arr: arrow_array::ArrayRef = Arc::new(StringArray::from(vec![
+ Some("foo"),
+ Some("foobar"),
+ Some("baz"),
+ None,
+ ]));
+ let mask = run_string_op(super::PredicateOperator::StartsWith, arr,
"foo");
+ let expected = arrow_array::BooleanArray::from(vec![true, true, false,
false]);
+ assert_eq!(mask, expected);
+ }
+
+ #[test]
+ fn test_evaluate_ends_with_large_string_array() {
+ use arrow_array::LargeStringArray;
+ let arr: arrow_array::ArrayRef = Arc::new(LargeStringArray::from(vec![
+ Some("hello"),
+ Some("world"),
+ Some("ello"),
+ None,
+ ]));
+ let mask = run_string_op(super::PredicateOperator::EndsWith, arr,
"ello");
+ let expected = arrow_array::BooleanArray::from(vec![true, false, true,
false]);
+ assert_eq!(mask, expected);
+ }
+
+ #[test]
+ fn test_evaluate_contains_string_view_array() {
+ use arrow_array::StringViewArray;
+ let arr: arrow_array::ArrayRef = Arc::new(StringViewArray::from(vec![
+ Some("apple pie"),
+ Some("banana"),
+ Some("crab apple"),
+ None,
+ ]));
+ let mask = run_string_op(super::PredicateOperator::Contains, arr,
"apple");
+ let expected = arrow_array::BooleanArray::from(vec![true, false, true,
false]);
+ assert_eq!(mask, expected);
+ }
+
+ #[test]
+ fn test_evaluate_like_pattern_with_underscore_and_percent() {
+ use arrow_array::StringArray;
+ let arr: arrow_array::ArrayRef = Arc::new(StringArray::from(vec![
+ Some("foobar"),
+ Some("foox"),
+ Some("zoobar"),
+ None,
+ ]));
+ // f_o% matches "foobar" (f-o-o then anything) and "foox" (f-o-o then
x)
+ // but not "zoobar".
+ let mask = run_string_op(super::PredicateOperator::Like, arr, "f_o%");
+ let expected = arrow_array::BooleanArray::from(vec![true, true, false,
false]);
+ assert_eq!(mask, expected);
+ }
+
+ #[test]
+ fn test_evaluate_like_escaped_percent_treated_literally() {
+ use arrow_array::StringArray;
+ let arr: arrow_array::ArrayRef =
+ Arc::new(StringArray::from(vec![Some("100%"), Some("1000"),
None]));
+ let mask = run_string_op(super::PredicateOperator::Like, arr,
r"100\%");
+ let expected = arrow_array::BooleanArray::from(vec![true, false,
false]);
+ assert_eq!(mask, expected);
+ }
+
+ // -----------------------------------------------------------------------
+ // BETWEEN / NOT BETWEEN row-filter tests
+ // -----------------------------------------------------------------------
+
+ fn run_between(
+ op: super::PredicateOperator,
+ column: arrow_array::ArrayRef,
+ low: i32,
+ high: i32,
+ ) -> arrow_array::BooleanArray {
+ let dt = DataType::Int(IntType::new());
+ super::evaluate_exact_leaf_predicate(&column, &dt, op,
&[Datum::Int(low), Datum::Int(high)])
+ .expect("BETWEEN should evaluate")
+ }
+
+ #[test]
+ fn test_evaluate_between_int_array() {
+ let arr: arrow_array::ArrayRef = Arc::new(Int32Array::from(vec![
+ Some(1),
+ Some(5),
+ Some(10),
+ Some(11),
+ None,
+ ]));
+ let mask = run_between(super::PredicateOperator::Between, arr, 5, 10);
+ let expected = arrow_array::BooleanArray::from(vec![false, true, true,
false, false]);
+ assert_eq!(mask, expected);
+ }
+
+ #[test]
+ fn test_evaluate_not_between_treats_null_as_false() {
+ let arr: arrow_array::ArrayRef = Arc::new(Int32Array::from(vec![
+ Some(1),
+ Some(5),
+ Some(10),
+ Some(11),
+ None,
+ ]));
+ let mask = run_between(super::PredicateOperator::NotBetween, arr, 5,
10);
+ // NULL → false (residual filter convention; matches
sanitize_filter_mask).
+ let expected = arrow_array::BooleanArray::from(vec![true, false,
false, true, false]);
+ assert_eq!(mask, expected);
+ }
+
// -----------------------------------------------------------------------
// merge_byte_ranges tests
// -----------------------------------------------------------------------
diff --git a/crates/paimon/src/arrow/format/vortex.rs
b/crates/paimon/src/arrow/format/vortex.rs
index 1d093a3..0a9717d 100644
--- a/crates/paimon/src/arrow/format/vortex.rs
+++ b/crates/paimon/src/arrow/format/vortex.rs
@@ -545,6 +545,15 @@ fn evaluate_arrow_leaf_predicate(
})?;
Ok(Some(sanitize_filter_mask(mask)))
}
+ // Vortex's scalar comparators don't cover substring/range predicates,
so
+ // fall open: returning None defers them to the outer stats-prune +
arrow
+ // row-filter path, which evaluates them with full NULL (Kleene)
semantics.
+ PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Like
+ | PredicateOperator::Between
+ | PredicateOperator::NotBetween => Ok(None),
}
}
@@ -609,7 +618,16 @@ fn evaluate_column_predicate(
PredicateOperator::IsNull
| PredicateOperator::IsNotNull
| PredicateOperator::In
- | PredicateOperator::NotIn => Ok(BooleanArray::new_null(column.len())),
+ | PredicateOperator::NotIn
+ // String/range ops never reach the scalar comparator: the leaf
+ // dispatcher falls open (returns None) for them, so the value here is
+ // unused. Listed only to keep the match exhaustive.
+ | PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Like
+ | PredicateOperator::Between
+ | PredicateOperator::NotBetween =>
Ok(BooleanArray::new_null(column.len())),
}
}
diff --git a/crates/paimon/src/btree/query.rs b/crates/paimon/src/btree/query.rs
index 3a86bad..0aed9d4 100644
--- a/crates/paimon/src/btree/query.rs
+++ b/crates/paimon/src/btree/query.rs
@@ -96,6 +96,26 @@ where
all_non_null -= excluded;
Ok(all_non_null)
}
+ PredicateOperator::Between => {
+ let from = serialize_datum(&literals[0], data_type);
+ let to = serialize_datum(&literals[1], data_type);
+ self.range_query(&from, &to, true, true).await
+ }
+ PredicateOperator::NotBetween => {
+ let mut all_non_null = self.all_non_null_rows().await?;
+ let from = serialize_datum(&literals[0], data_type);
+ let to = serialize_datum(&literals[1], data_type);
+ let inside = self.range_query(&from, &to, true, true).await?;
+ all_non_null -= inside;
+ Ok(all_non_null)
+ }
+ PredicateOperator::StartsWith
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Like => Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ format!("BTree index does not support op: {op}"),
+ )),
}
}
}
@@ -116,9 +136,36 @@ pub(crate) type ExtractBetweenResult<'a> = (
/// Try to extract a between pattern (lower + upper bound) from predicates.
/// Returns (between_info, remaining_predicates).
+///
+/// Recognizes two shapes:
+/// 1. A native `Between` leaf with two literals — preferred and emitted by the
+/// DataFusion translator since Stage 3.
+/// 2. A `GtEq` / `Gt` paired with a `LtEq` / `Lt` on the same column —
+/// legacy shape, kept for direct `PredicateBuilder` users that still build
+/// the conjunction explicitly.
pub(crate) fn extract_between<'a>(
predicates: &[(PredicateOperator, &'a [Datum], &'a DataType)],
) -> ExtractBetweenResult<'a> {
+ // Shape 1: native Between leaf — single tuple is enough.
+ for (i, (op, literals, dt)) in predicates.iter().enumerate() {
+ if matches!(op, PredicateOperator::Between) && literals.len() == 2 {
+ let between = BetweenInfo {
+ from: &literals[0],
+ to: &literals[1],
+ from_inclusive: true,
+ to_inclusive: true,
+ data_type: dt,
+ };
+ let remaining: Vec<_> = predicates
+ .iter()
+ .enumerate()
+ .filter(|(j, _)| *j != i)
+ .map(|(_, p)| *p)
+ .collect();
+ return (Some(between), remaining);
+ }
+ }
+
if predicates.len() < 2 {
return (None, predicates.to_vec());
}
diff --git a/crates/paimon/src/predicate_stats.rs
b/crates/paimon/src/predicate_stats.rs
index cb44c72..e0efd67 100644
--- a/crates/paimon/src/predicate_stats.rs
+++ b/crates/paimon/src/predicate_stats.rs
@@ -62,12 +62,30 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
PredicateOperator::In | PredicateOperator::NotIn => {
return true;
}
+ PredicateOperator::EndsWith | PredicateOperator::Contains => {
+ // String min/max ordering carries no information about suffix /
+ // substring matches, so fail open.
+ return true;
+ }
+ PredicateOperator::Between | PredicateOperator::NotBetween => {
+ return between_may_match(
+ index,
+ stats_data_type,
+ predicate_data_type,
+ op,
+ literals,
+ stats,
+ all_null,
+ );
+ }
PredicateOperator::Eq
| PredicateOperator::NotEq
| PredicateOperator::Lt
| PredicateOperator::LtEq
| PredicateOperator::Gt
- | PredicateOperator::GtEq => {}
+ | PredicateOperator::GtEq
+ | PredicateOperator::StartsWith
+ | PredicateOperator::Like => {}
}
if all_null == Some(true) {
@@ -112,11 +130,100 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
Some(Ordering::Less | Ordering::Equal)
),
PredicateOperator::GtEq => !matches!(max_value.partial_cmp(literal),
Some(Ordering::Less)),
+ PredicateOperator::StartsWith => {
+ // pat lives in [min, max] iff max >= pat AND min < pat_next, where
+ // pat_next is pat with its last codepoint incremented. If we can't
+ // compute pat_next (last char is char::MAX, increments into the
+ // UTF-16 surrogate range, etc.), fail open.
+ let (pat, min_str, max_str) = match (literal, &min_value,
&max_value) {
+ (Datum::String(p), Datum::String(lo), Datum::String(hi)) => {
+ (p.as_str(), lo.as_str(), hi.as_str())
+ }
+ _ => return true,
+ };
+ // If the file's max is below the pattern (lexicographically), no
+ // string in the file can start with `pat`.
+ if max_str < pat {
+ return false;
+ }
+ // Compute pat_next; if we can, use the [pat, pat_next) range to
+ // also rule out files whose min is already past every pat-prefixed
+ // string. Otherwise just trust the upper bound check.
+ match next_string_for_prefix(pat) {
+ Some(pat_next) => min_str.as_bytes() < pat_next.as_slice(),
+ None => true,
+ }
+ }
+ PredicateOperator::Like => {
+ // Try to extract a literal prefix from the LIKE pattern (the
+ // characters before the first unescaped wildcard). If we get one,
+ // prune as if it were StartsWith; otherwise fail open.
+ let (pattern, min_str, max_str) = match (literal, &min_value,
&max_value) {
+ (Datum::String(p), Datum::String(lo), Datum::String(hi)) => {
+ (p.as_str(), lo.as_str(), hi.as_str())
+ }
+ _ => return true,
+ };
+ let Some(pat) = like_pattern_literal_prefix(pattern) else {
+ return true;
+ };
+ if pat.is_empty() {
+ return true;
+ }
+ if max_str < pat.as_str() {
+ return false;
+ }
+ match next_string_for_prefix(&pat) {
+ Some(pat_next) => min_str.as_bytes() < pat_next.as_slice(),
+ None => true,
+ }
+ }
PredicateOperator::IsNull
| PredicateOperator::IsNotNull
| PredicateOperator::In
- | PredicateOperator::NotIn => true,
+ | PredicateOperator::NotIn
+ | PredicateOperator::EndsWith
+ | PredicateOperator::Contains
+ | PredicateOperator::Between
+ | PredicateOperator::NotBetween => true,
+ }
+}
+
+/// Return the literal prefix of a SQL LIKE pattern up to the first unescaped
+/// `%` or `_`. A backslash escapes the next character (which is appended
+/// literally, mirroring arrow's `like` kernel); a trailing backslash is a
+/// literal backslash.
+fn like_pattern_literal_prefix(pattern: &str) -> Option<String> {
+ let mut out = String::with_capacity(pattern.len());
+ let mut chars = pattern.chars().peekable();
+ while let Some(c) = chars.next() {
+ match c {
+ '%' | '_' => return Some(out),
+ '\\' => match chars.next() {
+ Some(next) => out.push(next),
+ None => out.push('\\'),
+ },
+ other => out.push(other),
+ }
+ }
+ Some(out)
+}
+
+/// Compute the smallest string strictly greater than every string with
`prefix`
+/// as a prefix, by incrementing the last codepoint. Returns `None` if the last
+/// codepoint cannot be incremented within valid Unicode (e.g. `char::MAX`).
+fn next_string_for_prefix(prefix: &str) -> Option<Vec<u8>> {
+ let last_char = prefix.chars().next_back()?;
+ let mut next_code = last_char as u32 + 1;
+ // Skip over the UTF-16 surrogate range, which is not valid scalar Unicode.
+ if (0xD800..=0xDFFF).contains(&next_code) {
+ next_code = 0xE000;
}
+ let next_char = char::from_u32(next_code)?;
+ let mut bytes = prefix.as_bytes()[..prefix.len() -
last_char.len_utf8()].to_vec();
+ let mut buf = [0u8; 4];
+ bytes.extend_from_slice(next_char.encode_utf8(&mut buf).as_bytes());
+ Some(bytes)
}
pub(crate) fn missing_field_may_match(op: PredicateOperator, row_count: i64)
-> bool {
@@ -127,6 +234,60 @@ pub(crate) fn missing_field_may_match(op:
PredicateOperator, row_count: i64) ->
matches!(op, PredicateOperator::IsNull)
}
+/// Stats-prune `field BETWEEN low AND high` (and its negation) by treating it
+/// as the conjunction `field >= low AND field <= high`:
+/// * `Between` may match iff the file's `[min, max]` overlaps `[low, high]`.
+/// * `NotBetween` may match iff some row could fall outside `[low, high]`,
+/// i.e. unless the file's `[min, max]` is entirely inside `[low, high]`.
+///
+/// All-null files are pruned for both ops (NULL comparisons resolve to NULL,
+/// which the evaluator treats as false).
+fn between_may_match<T: StatsAccessor>(
+ index: usize,
+ stats_data_type: &DataType,
+ predicate_data_type: &DataType,
+ op: PredicateOperator,
+ literals: &[Datum],
+ stats: &T,
+ all_null: Option<bool>,
+) -> bool {
+ if all_null == Some(true) {
+ return false;
+ }
+ let (Some(low), Some(high)) = (literals.first(), literals.get(1)) else {
+ return true;
+ };
+ let min_value = match stats
+ .min_value(index, stats_data_type)
+ .and_then(|datum| coerce_stats_datum_for_predicate(datum,
predicate_data_type))
+ {
+ Some(value) => value,
+ None => return true,
+ };
+ let max_value = match stats
+ .max_value(index, stats_data_type)
+ .and_then(|datum| coerce_stats_datum_for_predicate(datum,
predicate_data_type))
+ {
+ Some(value) => value,
+ None => return true,
+ };
+
+ let max_ge_low = !matches!(max_value.partial_cmp(low),
Some(Ordering::Less));
+ let min_le_high = !matches!(min_value.partial_cmp(high),
Some(Ordering::Greater));
+ let overlaps = max_ge_low && min_le_high;
+
+ match op {
+ PredicateOperator::Between => overlaps,
+ PredicateOperator::NotBetween => {
+ // Prune only when [min, max] is entirely inside [low, high].
+ let min_ge_low = !matches!(min_value.partial_cmp(low),
Some(Ordering::Less));
+ let max_le_high = !matches!(max_value.partial_cmp(high),
Some(Ordering::Greater));
+ !(min_ge_low && max_le_high)
+ }
+ _ => unreachable!("between_may_match is only called for
Between/NotBetween"),
+ }
+}
+
fn predicate_may_match_with_schema<T: StatsAccessor>(
predicate: &Predicate,
stats: &T,
@@ -193,3 +354,281 @@ fn coerce_stats_datum_for_predicate(datum: Datum,
predicate_data_type: &DataType
_ => None,
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{IntType, VarCharType};
+
+ struct MockStats {
+ row_count: i64,
+ null_count: Option<i64>,
+ min: Option<Datum>,
+ max: Option<Datum>,
+ }
+
+ impl StatsAccessor for MockStats {
+ fn row_count(&self) -> i64 {
+ self.row_count
+ }
+ fn null_count(&self, _index: usize) -> Option<i64> {
+ self.null_count
+ }
+ fn min_value(&self, _index: usize, _data_type: &DataType) ->
Option<Datum> {
+ self.min.clone()
+ }
+ fn max_value(&self, _index: usize, _data_type: &DataType) ->
Option<Datum> {
+ self.max.clone()
+ }
+ }
+
+ fn varchar() -> DataType {
+ DataType::VarChar(VarCharType::default())
+ }
+
+ fn string_stats(min: &str, max: &str) -> MockStats {
+ MockStats {
+ row_count: 10,
+ null_count: Some(0),
+ min: Some(Datum::String(min.to_string())),
+ max: Some(Datum::String(max.to_string())),
+ }
+ }
+
+ fn run(op: PredicateOperator, lit: &str, stats: &MockStats) -> bool {
+ let dt = varchar();
+ data_leaf_may_match(0, &dt, &dt, op,
&[Datum::String(lit.to_string())], stats)
+ }
+
+ #[test]
+ fn starts_with_prunes_when_max_below_pattern() {
+ let stats = string_stats("aaa", "fooa");
+ assert!(!run(PredicateOperator::StartsWith, "foob", &stats));
+ }
+
+ #[test]
+ fn starts_with_prunes_when_min_past_pattern_range() {
+ // [foo, fop) is the pat range. min "fop" is already past it.
+ let stats = string_stats("fop", "zzz");
+ assert!(!run(PredicateOperator::StartsWith, "foo", &stats));
+ }
+
+ #[test]
+ fn starts_with_keeps_when_pattern_inside_range() {
+ let stats = string_stats("aaa", "zzz");
+ assert!(run(PredicateOperator::StartsWith, "foo", &stats));
+ }
+
+ #[test]
+ fn starts_with_keeps_when_min_equals_pattern() {
+ let stats = string_stats("foo", "foozzz");
+ assert!(run(PredicateOperator::StartsWith, "foo", &stats));
+ }
+
+ #[test]
+ fn starts_with_falls_open_when_stats_missing() {
+ let stats = MockStats {
+ row_count: 5,
+ null_count: Some(0),
+ min: None,
+ max: None,
+ };
+ assert!(run(PredicateOperator::StartsWith, "foo", &stats));
+ }
+
+ #[test]
+ fn ends_with_and_contains_fall_open() {
+ let stats = string_stats("aaa", "zzz");
+ assert!(run(PredicateOperator::EndsWith, "foo", &stats));
+ assert!(run(PredicateOperator::Contains, "foo", &stats));
+ }
+
+ #[test]
+ fn like_with_literal_prefix_prunes_like_starts_with() {
+ // pattern "foo%" → prefix "foo"; max "fooa" already past pattern end?
+ // No: max = "fooa" >= "foo" and min "aaa" < "fop". So this case keeps.
+ let stats = string_stats("aaa", "fooa");
+ assert!(run(PredicateOperator::Like, "foo%", &stats));
+ // pattern "foo%": [foo, fop). file [zaa, zzz] — max < pat → prune.
+ let stats = string_stats("zaa", "zzz");
+ assert!(!run(PredicateOperator::Like, "foo%", &stats));
+ // file [fop, zzz] — min already past prefix range → prune.
+ let stats = string_stats("fop", "zzz");
+ assert!(!run(PredicateOperator::Like, "foo%", &stats));
+ }
+
+ #[test]
+ fn like_without_literal_prefix_falls_open() {
+ let stats = string_stats("aaa", "ccc");
+ // Leading wildcard → no prefix → fail open.
+ assert!(run(PredicateOperator::Like, "%foo%", &stats));
+ // Leading underscore → no prefix → fail open.
+ assert!(run(PredicateOperator::Like, "_oo", &stats));
+ }
+
+ #[test]
+ fn like_with_escaped_wildcard_in_prefix_is_decoded() {
+ // "100\%foo" → literal prefix "100%foo".
+ let stats = string_stats("100", "100%fzz");
+ assert!(run(PredicateOperator::Like, r"100\%foo", &stats));
+ let stats = string_stats("zzz0", "zzz9");
+ assert!(!run(PredicateOperator::Like, r"100\%foo", &stats));
+ }
+
+ #[test]
+ fn missing_field_returns_false_for_string_ops() {
+ // Only IsNull is allowed when the field is missing.
+ for op in [
+ PredicateOperator::StartsWith,
+ PredicateOperator::EndsWith,
+ PredicateOperator::Contains,
+ PredicateOperator::Like,
+ ] {
+ assert!(!missing_field_may_match(op, 5));
+ }
+ }
+
+ // Sanity check: integer ops keep their existing semantics after the new
+ // string variants are interleaved into the dispatcher.
+ #[test]
+ fn integer_eq_still_prunes_outside_range() {
+ let dt = DataType::Int(IntType::new());
+ let stats = MockStats {
+ row_count: 10,
+ null_count: Some(0),
+ min: Some(Datum::Int(0)),
+ max: Some(Datum::Int(100)),
+ };
+ assert!(!data_leaf_may_match(
+ 0,
+ &dt,
+ &dt,
+ PredicateOperator::Eq,
+ &[Datum::Int(500)],
+ &stats,
+ ));
+ }
+
+ fn int_stats(min: i32, max: i32) -> MockStats {
+ MockStats {
+ row_count: 10,
+ null_count: Some(0),
+ min: Some(Datum::Int(min)),
+ max: Some(Datum::Int(max)),
+ }
+ }
+
+ fn run_int(op: PredicateOperator, lits: &[Datum], stats: &MockStats) ->
bool {
+ let dt = DataType::Int(IntType::new());
+ data_leaf_may_match(0, &dt, &dt, op, lits, stats)
+ }
+
+ /// Stage 3 invariant: a `Between` leaf and the equivalent `GtEq+LtEq`
+ /// conjunction must produce identical stats-prune verdicts. If they
+ /// diverge, the DataFusion translator switch (And-of-comparisons →
+ /// Between leaf) silently changes pruning behavior in production.
+ #[test]
+ fn between_matches_gteq_lteq_conjunction() {
+ let cases: &[(i32, i32, i32, i32, bool)] = &[
+ // (min, max, low, high, expected_may_match)
+ (0, 100, 50, 60, true), // overlap inside
+ (0, 100, 200, 300, false), // entirely above
+ (0, 100, -50, -1, false), // entirely below
+ (0, 100, 100, 100, true), // boundary high
+ (0, 100, 0, 0, true), // boundary low
+ (50, 100, 0, 49, false), // low < min < high < max impossible —
fully below
+ (50, 100, 0, 200, true), // file fully inside [low, high]
+ ];
+ for &(min, max, low, high, expected) in cases {
+ let stats = int_stats(min, max);
+ let between = run_int(
+ PredicateOperator::Between,
+ &[Datum::Int(low), Datum::Int(high)],
+ &stats,
+ );
+ let gteq = run_int(PredicateOperator::GtEq, &[Datum::Int(low)],
&stats);
+ let lteq = run_int(PredicateOperator::LtEq, &[Datum::Int(high)],
&stats);
+ assert_eq!(
+ between,
+ gteq && lteq,
+ "Between vs GtEq+LtEq divergence at ({min},{max}) ∩
[{low},{high}]"
+ );
+ assert_eq!(
+ between, expected,
+ "Between unexpected at {min},{max} ∩ [{low},{high}]"
+ );
+ }
+ }
+
+ #[test]
+ fn not_between_prunes_only_when_file_fully_inside_range() {
+ // file [10, 20] ⊆ [0, 100] → all rows are within [0, 100], so NOT
+ // BETWEEN can prune.
+ let stats = int_stats(10, 20);
+ assert!(!run_int(
+ PredicateOperator::NotBetween,
+ &[Datum::Int(0), Datum::Int(100)],
+ &stats,
+ ));
+ // file [0, 100] ⊃ [10, 20] → some rows lie outside, can't prune.
+ let stats = int_stats(0, 100);
+ assert!(run_int(
+ PredicateOperator::NotBetween,
+ &[Datum::Int(10), Datum::Int(20)],
+ &stats,
+ ));
+ // file disjoint with [50, 60] → all rows are outside, can't prune.
+ let stats = int_stats(0, 10);
+ assert!(run_int(
+ PredicateOperator::NotBetween,
+ &[Datum::Int(50), Datum::Int(60)],
+ &stats,
+ ));
+ }
+
+ #[test]
+ fn between_with_all_null_file_is_pruned() {
+ let dt = DataType::Int(IntType::new());
+ let stats = MockStats {
+ row_count: 10,
+ null_count: Some(10),
+ min: None,
+ max: None,
+ };
+ assert!(!data_leaf_may_match(
+ 0,
+ &dt,
+ &dt,
+ PredicateOperator::Between,
+ &[Datum::Int(0), Datum::Int(100)],
+ &stats,
+ ));
+ assert!(!data_leaf_may_match(
+ 0,
+ &dt,
+ &dt,
+ PredicateOperator::NotBetween,
+ &[Datum::Int(0), Datum::Int(100)],
+ &stats,
+ ));
+ }
+
+ #[test]
+ fn between_falls_open_when_stats_missing() {
+ let dt = DataType::Int(IntType::new());
+ let stats = MockStats {
+ row_count: 5,
+ null_count: Some(0),
+ min: None,
+ max: None,
+ };
+ assert!(data_leaf_may_match(
+ 0,
+ &dt,
+ &dt,
+ PredicateOperator::Between,
+ &[Datum::Int(0), Datum::Int(100)],
+ &stats,
+ ));
+ }
+}
diff --git a/crates/paimon/src/spec/predicate.rs
b/crates/paimon/src/spec/predicate.rs
index 73b6c0c..a419f77 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -223,6 +223,12 @@ pub enum PredicateOperator {
GtEq,
In,
NotIn,
+ StartsWith,
+ EndsWith,
+ Contains,
+ Like,
+ Between,
+ NotBetween,
}
impl fmt::Display for PredicateOperator {
@@ -238,6 +244,12 @@ impl fmt::Display for PredicateOperator {
Self::GtEq => write!(f, ">="),
Self::In => write!(f, "IN"),
Self::NotIn => write!(f, "NOT IN"),
+ Self::StartsWith => write!(f, "STARTS_WITH"),
+ Self::EndsWith => write!(f, "ENDS_WITH"),
+ Self::Contains => write!(f, "CONTAINS"),
+ Self::Like => write!(f, "LIKE"),
+ Self::Between => write!(f, "BETWEEN"),
+ Self::NotBetween => write!(f, "NOT BETWEEN"),
}
}
}
@@ -650,6 +662,113 @@ impl PredicateBuilder {
self.leaf(field, PredicateOperator::NotIn, literals)
}
+ // -- string operators --
+
+ /// `field LIKE 'pat%'` shape. Empty pattern → `IsNotNull(field)` (every
+ /// non-null string starts with the empty string). Non-string `pattern`
+ /// → [`Error::ConfigInvalid`].
+ pub fn starts_with(&self, field: &str, pattern: Datum) ->
Result<Predicate> {
+ self.string_leaf(field, PredicateOperator::StartsWith, pattern)
+ }
+
+ /// `field LIKE '%pat'` shape. Empty pattern → `IsNotNull(field)`.
+ pub fn ends_with(&self, field: &str, pattern: Datum) -> Result<Predicate> {
+ self.string_leaf(field, PredicateOperator::EndsWith, pattern)
+ }
+
+ /// `field LIKE '%pat%'` shape. Empty pattern → `IsNotNull(field)`.
+ pub fn contains(&self, field: &str, pattern: Datum) -> Result<Predicate> {
+ self.string_leaf(field, PredicateOperator::Contains, pattern)
+ }
+
+ /// `field LIKE '<pattern>'` with optional `escape` character (default
`\`).
+ /// Mirrors Java `LikeOptimization`: rewrites `prefix%` / `%suffix` /
+ /// `%mid%` / no-wildcard patterns into [`PredicateOperator::StartsWith`] /
+ /// [`PredicateOperator::EndsWith`] / [`PredicateOperator::Contains`] /
+ /// [`PredicateOperator::Eq`]; falls back to a [`PredicateOperator::Like`]
+ /// leaf for anything more complex (`_`, multi-segment `%`, escaped
+ /// wildcards). The `Like` evaluator follows arrow_string `like` kernel
+ /// semantics for the residual cases.
+ ///
+ /// `escape == None` defaults to `\`. Any other ESCAPE character is
+ /// rejected with [`Error::ConfigInvalid`] (the DataFusion translator turns
+ /// that into a fall-open). Empty pattern → [`PredicateOperator::Eq`] of
the
+ /// empty string (SQL semantics: only the empty string matches).
+ pub fn like(&self, field: &str, pattern: Datum, escape: Option<char>) ->
Result<Predicate> {
+ let pattern_str = match &pattern {
+ Datum::String(s) => s.clone(),
+ other => {
+ return Err(Error::ConfigInvalid {
+ message: format!("LIKE requires a string pattern, got
{other}"),
+ });
+ }
+ };
+ let escape_char = escape.unwrap_or('\\');
+ if escape_char != '\\' {
+ return Err(Error::ConfigInvalid {
+ message: format!(
+ "LIKE escape character {escape_char:?} is not supported
(only '\\\\')"
+ ),
+ });
+ }
+
+ match optimize_like_pattern(&pattern_str) {
+ LikeShape::EmptyOrLiteral(s) => self.equal(field,
Datum::String(s)),
+ LikeShape::StartsWith(prefix) => self.starts_with(field,
Datum::String(prefix)),
+ LikeShape::EndsWith(suffix) => self.ends_with(field,
Datum::String(suffix)),
+ LikeShape::Contains(mid) => self.contains(field,
Datum::String(mid)),
+ LikeShape::Residual => self.leaf(field, PredicateOperator::Like,
vec![pattern]),
+ }
+ }
+
+ // -- range operators --
+
+ /// `field BETWEEN low AND high`. SQL semantics: inclusive on both ends.
+ /// `low > high` short-circuits to [`Predicate::AlwaysFalse`] (no value can
+ /// be between an empty range).
+ pub fn between(&self, field: &str, low: Datum, high: Datum) ->
Result<Predicate> {
+ if Self::low_strictly_above_high(&low, &high) {
+ return Ok(Predicate::AlwaysFalse);
+ }
+ self.leaf(field, PredicateOperator::Between, vec![low, high])
+ }
+
+ /// `field NOT BETWEEN low AND high`. SQL three-valued logic: NULL value
+ /// → NULL → treated as false, matching the existing `NotEq` evaluator.
+ /// `low > high` short-circuits to `IsNotNull(field)` (every non-null value
+ /// is "not between" an empty range).
+ pub fn not_between(&self, field: &str, low: Datum, high: Datum) ->
Result<Predicate> {
+ if Self::low_strictly_above_high(&low, &high) {
+ return self.is_not_null(field);
+ }
+ self.leaf(field, PredicateOperator::NotBetween, vec![low, high])
+ }
+
+ fn low_strictly_above_high(low: &Datum, high: &Datum) -> bool {
+ matches!(datum_cmp(low, high), Some(Ordering::Greater))
+ }
+
+ /// Shared body for the three string operators: empty-string short-circuit
+ /// and literal-type guard ([`leaf`] still cross-checks against the column
+ /// type, so non-string columns are rejected there).
+ fn string_leaf(&self, field: &str, op: PredicateOperator, pattern: Datum)
-> Result<Predicate> {
+ match &pattern {
+ // Every non-null string starts with / ends with / contains the
+ // empty string, and a NULL value matches none of them — i.e. the
+ // empty pattern is exactly `IsNotNull`. Folding to `AlwaysTrue`
+ // would wrongly retain NULL rows (and drop the field reference,
+ // keeping the predicate out of the data-pruning path).
+ Datum::String(s) if s.is_empty() => return self.is_not_null(field),
+ Datum::String(_) => {}
+ other => {
+ return Err(Error::ConfigInvalid {
+ message: format!("{op} requires a string pattern, got
{other}"),
+ });
+ }
+ }
+ self.leaf(field, op, vec![pattern])
+ }
+
// -- internal --
/// Resolve field name to index + type, validate literals, and build a
leaf predicate.
@@ -703,6 +822,7 @@ impl PredicateBuilder {
message: format!("{op} expects at least 1 literal, got 0"),
});
}
+ PredicateOperator::Between | PredicateOperator::NotBetween => (2,
literals.len()),
_ => (1, literals.len()),
};
if actual != expected {
@@ -914,6 +1034,40 @@ fn eval_leaf(op: PredicateOperator, datum:
Option<&Datum>, literals: &[Datum]) -
),
PredicateOperator::In => literals.iter().any(|lit|
datum_eq(val, lit)),
PredicateOperator::NotIn => !literals.iter().any(|lit|
datum_eq(val, lit)),
+ PredicateOperator::StartsWith => match (val, literals.first())
{
+ (Datum::String(haystack), Some(Datum::String(needle))) => {
+ haystack.starts_with(needle.as_str())
+ }
+ _ => unreachable!(
+ "STARTS_WITH must have Datum::String value and literal
(validated by builder)"
+ ),
+ },
+ PredicateOperator::EndsWith => match (val, literals.first()) {
+ (Datum::String(haystack), Some(Datum::String(needle))) => {
+ haystack.ends_with(needle.as_str())
+ }
+ _ => unreachable!(
+ "ENDS_WITH must have Datum::String value and literal
(validated by builder)"
+ ),
+ },
+ PredicateOperator::Contains => match (val, literals.first()) {
+ (Datum::String(haystack), Some(Datum::String(needle))) => {
+ haystack.contains(needle.as_str())
+ }
+ _ => unreachable!(
+ "CONTAINS must have Datum::String value and literal
(validated by builder)"
+ ),
+ },
+ PredicateOperator::Like => match (val, literals.first()) {
+ (Datum::String(haystack), Some(Datum::String(pattern))) =>
{
+ like_match(haystack, pattern)
+ }
+ _ => unreachable!(
+ "LIKE must have Datum::String value and literal
(validated by builder)"
+ ),
+ },
+ PredicateOperator::Between => eval_between(val, literals),
+ PredicateOperator::NotBetween => !eval_between(val, literals),
// IsNull/IsNotNull are handled in the outer match above.
PredicateOperator::IsNull | PredicateOperator::IsNotNull =>
unreachable!(),
}
@@ -921,6 +1075,158 @@ fn eval_leaf(op: PredicateOperator, datum:
Option<&Datum>, literals: &[Datum]) -
}
}
+// ---------------------------------------------------------------------------
+// LIKE pattern optimization & evaluation
+// ---------------------------------------------------------------------------
+
+/// Result of LIKE pattern shape analysis. The `String` payloads are the
+/// literal substrings extracted from the pattern (with escape sequences
+/// already decoded).
+enum LikeShape {
+ /// Empty pattern, or a pattern that contains no wildcards / escapes —
+ /// equivalent to `Eq <literal>` (where the literal is the unescaped
+ /// pattern, possibly empty).
+ EmptyOrLiteral(String),
+ /// `prefix%` (exactly one trailing `%`, no other wildcards or escapes).
+ StartsWith(String),
+ /// `%suffix`.
+ EndsWith(String),
+ /// `%mid%` (exactly one leading and one trailing `%`).
+ Contains(String),
+ /// Anything else: `_`, multi-segment `%`, or any escape sequence — must
+ /// fall through to a `Like` leaf evaluator.
+ Residual,
+}
+
+/// Classify a SQL LIKE pattern. The escape character is hardcoded to `\` —
+/// callers wanting any other escape should bypass optimization and surface a
+/// `Like` leaf directly. Any presence of `\` in the pattern forces
+/// [`LikeShape::Residual`] (the simple shape rules don't account for escaped
+/// wildcards).
+fn optimize_like_pattern(pattern: &str) -> LikeShape {
+ if pattern.contains('\\') || pattern.contains('_') {
+ return LikeShape::Residual;
+ }
+ let bytes = pattern.as_bytes();
+ let percent_count = bytes.iter().filter(|b| **b == b'%').count();
+ match percent_count {
+ 0 => LikeShape::EmptyOrLiteral(pattern.to_string()),
+ 1 => {
+ if let Some(prefix) = pattern.strip_suffix('%') {
+ LikeShape::StartsWith(prefix.to_string())
+ } else if let Some(suffix) = pattern.strip_prefix('%') {
+ LikeShape::EndsWith(suffix.to_string())
+ } else {
+ LikeShape::Residual
+ }
+ }
+ 2 if pattern.starts_with('%') && pattern.ends_with('%') => {
+ // `%%` reduces to `Contains('')`, which itself short-circuits to
+ // `IsNotNull` at the StartsWith/EndsWith/Contains builder boundary
+ // — so no special-casing here.
+ LikeShape::Contains(pattern[1..pattern.len() - 1].to_string())
+ }
+ _ => LikeShape::Residual,
+ }
+}
+
+/// Evaluate a SQL LIKE pattern against a value. Implements the backtracking
+/// matcher used by `arrow_string::like::like`:
+/// * `%` matches any (possibly empty) substring,
+/// * `_` matches exactly one character,
+/// * `\X` matches the literal `X` for any character `X` (the backslash is
+/// consumed); a trailing `\` matches a literal backslash.
+fn like_match(value: &str, pattern: &str) -> bool {
+ let value: Vec<char> = value.chars().collect();
+ let pattern: Vec<char> = pattern.chars().collect();
+ like_match_chars(&value, 0, &pattern, 0)
+}
+
+fn like_match_chars(value: &[char], mut vi: usize, pattern: &[char], mut pi:
usize) -> bool {
+ while pi < pattern.len() {
+ match pattern[pi] {
+ '%' => {
+ // Collapse runs of `%` and try every possible suffix.
+ while pi < pattern.len() && pattern[pi] == '%' {
+ pi += 1;
+ }
+ if pi == pattern.len() {
+ return true;
+ }
+ while vi <= value.len() {
+ if like_match_chars(value, vi, pattern, pi) {
+ return true;
+ }
+ if vi == value.len() {
+ return false;
+ }
+ vi += 1;
+ }
+ return false;
+ }
+ '_' => {
+ if vi == value.len() {
+ return false;
+ }
+ vi += 1;
+ pi += 1;
+ }
+ '\\' => {
+ // Mirror arrow's `like` kernel: a backslash consumes the next
+ // character and matches it literally, whatever it is (`%`,
`_`,
+ // `\`, or any other char such as `a`). A trailing backslash
+ // matches a literal backslash.
+ let expected = match pattern.get(pi + 1) {
+ Some(&next) => {
+ pi += 2;
+ next
+ }
+ None => {
+ pi += 1;
+ '\\'
+ }
+ };
+ if vi == value.len() || value[vi] != expected {
+ return false;
+ }
+ vi += 1;
+ }
+ other => {
+ if vi == value.len() || value[vi] != other {
+ return false;
+ }
+ vi += 1;
+ pi += 1;
+ }
+ }
+ }
+ vi == value.len()
+}
+
+// ---------------------------------------------------------------------------
+// BETWEEN evaluation
+// ---------------------------------------------------------------------------
+
+/// Evaluate `value BETWEEN low AND high` (inclusive). `NotBetween` is the
+/// boolean complement at the call site, which is correct for non-null
+/// `value` — null handling already short-circuits in the outer `eval_leaf`.
+/// Returns `false` when either comparison is incomparable (defensive: the
+/// builder validates types up front, so this is unreachable in practice).
+fn eval_between(value: &Datum, literals: &[Datum]) -> bool {
+ let (Some(low), Some(high)) = (literals.first(), literals.get(1)) else {
+ unreachable!("BETWEEN must have 2 literals (validated by builder)");
+ };
+ let above_low = matches!(
+ datum_cmp(value, low),
+ Some(Ordering::Greater | Ordering::Equal)
+ );
+ let below_high = matches!(
+ datum_cmp(value, high),
+ Some(Ordering::Less | Ordering::Equal)
+ );
+ above_low && below_high
+}
+
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1893,4 +2199,321 @@ mod tests {
.project_field_index_inclusive(&mapping)
.is_none());
}
+
+ // ======================== string operators ========================
+
+ #[test]
+ fn test_builder_starts_with() {
+ let pb = PredicateBuilder::new(&test_fields());
+ let pred = pb
+ .starts_with("name", Datum::String("foo".to_string()))
+ .unwrap();
+ match &pred {
+ Predicate::Leaf { op, literals, .. } => {
+ assert_eq!(*op, PredicateOperator::StartsWith);
+ assert_eq!(literals, &[Datum::String("foo".to_string())]);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_builder_ends_with() {
+ let pb = PredicateBuilder::new(&test_fields());
+ let pred = pb
+ .ends_with("name", Datum::String("bar".to_string()))
+ .unwrap();
+ match &pred {
+ Predicate::Leaf { op, .. } => assert_eq!(*op,
PredicateOperator::EndsWith),
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_builder_contains() {
+ let pb = PredicateBuilder::new(&test_fields());
+ let pred = pb
+ .contains("name", Datum::String("baz".to_string()))
+ .unwrap();
+ match &pred {
+ Predicate::Leaf { op, .. } => assert_eq!(*op,
PredicateOperator::Contains),
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_builder_string_ops_empty_pattern_is_not_null() {
+ let pb = PredicateBuilder::new(&test_fields());
+ // An empty pattern matches every non-null string and no NULL, so it is
+ // exactly `IsNotNull` (not `AlwaysTrue`, which would retain NULL
rows).
+ for build in [
+ pb.starts_with("name", Datum::String(String::new())),
+ pb.ends_with("name", Datum::String(String::new())),
+ pb.contains("name", Datum::String(String::new())),
+ ] {
+ assert!(matches!(
+ build.unwrap(),
+ Predicate::Leaf {
+ op: PredicateOperator::IsNotNull,
+ ..
+ }
+ ));
+ }
+ }
+
+ #[test]
+ fn test_builder_string_ops_reject_non_string_pattern() {
+ let pb = PredicateBuilder::new(&test_fields());
+ assert!(pb.starts_with("name", Datum::Int(1)).is_err());
+ assert!(pb.ends_with("name", Datum::Int(1)).is_err());
+ assert!(pb.contains("name", Datum::Int(1)).is_err());
+ }
+
+ #[test]
+ fn test_builder_string_ops_reject_non_string_column() {
+ let pb = PredicateBuilder::new(&test_fields());
+ // `id` is Int, so a String literal fails the cross-check inside
leaf().
+ assert!(pb
+ .starts_with("id", Datum::String("x".to_string()))
+ .is_err());
+ }
+
+ #[test]
+ fn test_eval_string_operators() {
+ let lit = Datum::String("oo".to_string());
+ let val = Datum::String("foobar".to_string());
+
+ assert!(eval_leaf(
+ PredicateOperator::Contains,
+ Some(&val),
+ std::slice::from_ref(&lit),
+ ));
+ assert!(!eval_leaf(
+ PredicateOperator::StartsWith,
+ Some(&val),
+ std::slice::from_ref(&lit),
+ ));
+ assert!(eval_leaf(
+ PredicateOperator::StartsWith,
+ Some(&val),
+ &[Datum::String("foo".to_string())],
+ ));
+ assert!(eval_leaf(
+ PredicateOperator::EndsWith,
+ Some(&val),
+ &[Datum::String("bar".to_string())],
+ ));
+ assert!(!eval_leaf(
+ PredicateOperator::EndsWith,
+ Some(&val),
+ &[Datum::String("baz".to_string())],
+ ));
+ // NULL value → false (SQL three-valued logic).
+ assert!(!eval_leaf(
+ PredicateOperator::StartsWith,
+ None,
+ &[Datum::String("foo".to_string())],
+ ));
+ }
+
+ // ======================== LIKE operator ========================
+
+ fn assert_leaf(pred: &Predicate, expected_op: PredicateOperator,
expected_lit: &str) {
+ match pred {
+ Predicate::Leaf { op, literals, .. } => {
+ assert_eq!(*op, expected_op, "op mismatch");
+ assert_eq!(literals,
&[Datum::String(expected_lit.to_string())]);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_like_optimization_rewrites() {
+ let pb = PredicateBuilder::new(&test_fields());
+ // No wildcards → Eq.
+ assert_leaf(
+ &pb.like("name", Datum::String("foo".to_string()), None)
+ .unwrap(),
+ PredicateOperator::Eq,
+ "foo",
+ );
+ // Empty pattern → Eq("") (only empty string matches).
+ assert_leaf(
+ &pb.like("name", Datum::String(String::new()), None).unwrap(),
+ PredicateOperator::Eq,
+ "",
+ );
+ // prefix% → StartsWith.
+ assert_leaf(
+ &pb.like("name", Datum::String("foo%".to_string()), None)
+ .unwrap(),
+ PredicateOperator::StartsWith,
+ "foo",
+ );
+ // %suffix → EndsWith.
+ assert_leaf(
+ &pb.like("name", Datum::String("%bar".to_string()), None)
+ .unwrap(),
+ PredicateOperator::EndsWith,
+ "bar",
+ );
+ // %mid% → Contains.
+ assert_leaf(
+ &pb.like("name", Datum::String("%baz%".to_string()), None)
+ .unwrap(),
+ PredicateOperator::Contains,
+ "baz",
+ );
+ }
+
+ #[test]
+ fn test_like_residual_for_non_optimizable_patterns() {
+ let pb = PredicateBuilder::new(&test_fields());
+ // `_` keeps Like leaf.
+ assert_leaf(
+ &pb.like("name", Datum::String("f_o".to_string()), None)
+ .unwrap(),
+ PredicateOperator::Like,
+ "f_o",
+ );
+ // Multi-segment % keeps Like leaf.
+ assert_leaf(
+ &pb.like("name", Datum::String("a%b%c".to_string()), None)
+ .unwrap(),
+ PredicateOperator::Like,
+ "a%b%c",
+ );
+ // Escaped wildcards keep Like leaf (optimization is conservative).
+ assert_leaf(
+ &pb.like("name", Datum::String(r"foo\%".to_string()), None)
+ .unwrap(),
+ PredicateOperator::Like,
+ r"foo\%",
+ );
+ }
+
+ #[test]
+ fn test_like_rejects_custom_escape_char() {
+ let pb = PredicateBuilder::new(&test_fields());
+ assert!(pb
+ .like("name", Datum::String("a$%".to_string()), Some('$'))
+ .is_err());
+ }
+
+ #[test]
+ fn test_like_rejects_non_string_pattern() {
+ let pb = PredicateBuilder::new(&test_fields());
+ assert!(pb.like("name", Datum::Int(1), None).is_err());
+ }
+
+ #[test]
+ fn test_like_match_evaluator() {
+ // Patterns that fall back to Like leaf must evaluate correctly.
+ assert!(super::like_match("foobar", "f_o%"));
+ assert!(super::like_match("foobar", "%bar"));
+ assert!(super::like_match("foobar", "f%r"));
+ assert!(!super::like_match("foobar", "f_x%"));
+ // `_` requires exactly one character.
+ assert!(super::like_match("ab", "a_"));
+ assert!(!super::like_match("a", "a_"));
+ assert!(!super::like_match("abc", "a_"));
+ // Escape handling.
+ assert!(super::like_match("100%", "100\\%"));
+ assert!(!super::like_match("1000", "100\\%"));
+ assert!(super::like_match("a_b", "a\\_b"));
+ assert!(!super::like_match("axb", "a\\_b"));
+ // Escaped non-wildcard: `\X` matches literal `X`, not `\X` (arrow
+ // semantics, verified against arrow_string's like_escape test).
+ assert!(super::like_match("a", "\\a"));
+ assert!(!super::like_match("\\a", "\\a"));
+ // Trailing backslash matches a literal backslash.
+ assert!(super::like_match("\\", "\\"));
+ // Empty pattern only matches empty value.
+ assert!(super::like_match("", ""));
+ assert!(!super::like_match("a", ""));
+ }
+
+ // ======================== BETWEEN / NOT BETWEEN ========================
+
+ #[test]
+ fn test_builder_between_keeps_inclusive_range() {
+ let pb = PredicateBuilder::new(&test_fields());
+ let pred = pb.between("id", Datum::Int(1), Datum::Int(10)).unwrap();
+ match &pred {
+ Predicate::Leaf { op, literals, .. } => {
+ assert_eq!(*op, PredicateOperator::Between);
+ assert_eq!(literals, &[Datum::Int(1), Datum::Int(10)]);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_builder_between_low_above_high_short_circuits_to_always_false() {
+ let pb = PredicateBuilder::new(&test_fields());
+ let pred = pb.between("id", Datum::Int(10), Datum::Int(1)).unwrap();
+ assert!(matches!(pred, Predicate::AlwaysFalse));
+ }
+
+ #[test]
+ fn test_builder_not_between_low_above_high_short_circuits_to_is_not_null()
{
+ let pb = PredicateBuilder::new(&test_fields());
+ let pred = pb.not_between("id", Datum::Int(10),
Datum::Int(1)).unwrap();
+ match &pred {
+ Predicate::Leaf { op, .. } => assert_eq!(*op,
PredicateOperator::IsNotNull),
+ other => panic!("expected IsNotNull leaf, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_builder_between_rejects_type_mismatch() {
+ let pb = PredicateBuilder::new(&test_fields());
+ // `id` is Int — String literal violates leaf() type cross-check.
+ assert!(pb
+ .between("id", Datum::String("a".to_string()), Datum::Int(10))
+ .is_err());
+ }
+
+ #[test]
+ fn test_eval_between_inclusive() {
+ let lits = [Datum::Int(5), Datum::Int(10)];
+ for v in [5, 7, 10] {
+ assert!(eval_leaf(
+ PredicateOperator::Between,
+ Some(&Datum::Int(v)),
+ &lits,
+ ));
+ }
+ for v in [4, 11] {
+ assert!(!eval_leaf(
+ PredicateOperator::Between,
+ Some(&Datum::Int(v)),
+ &lits,
+ ));
+ }
+ // NULL value → false.
+ assert!(!eval_leaf(PredicateOperator::Between, None, &lits));
+ }
+
+ #[test]
+ fn test_eval_not_between_complement_with_null_false() {
+ let lits = [Datum::Int(5), Datum::Int(10)];
+ for v in [4, 11] {
+ assert!(eval_leaf(
+ PredicateOperator::NotBetween,
+ Some(&Datum::Int(v)),
+ &lits,
+ ));
+ }
+ for v in [5, 7, 10] {
+ assert!(!eval_leaf(
+ PredicateOperator::NotBetween,
+ Some(&Datum::Int(v)),
+ &lits,
+ ));
+ }
+ // NULL value → false (matches existing NotEq null-semantics).
+ assert!(!eval_leaf(PredicateOperator::NotBetween, None, &lits));
+ }
}
diff --git a/crates/paimon/src/table/global_index_scanner.rs
b/crates/paimon/src/table/global_index_scanner.rs
index cdbccac..dd2ab09 100644
--- a/crates/paimon/src/table/global_index_scanner.rs
+++ b/crates/paimon/src/table/global_index_scanner.rs
@@ -134,6 +134,9 @@ impl GlobalIndexScanner {
data_type,
..
} => {
+ if !is_btree_supported_op(*op) {
+ return Ok(None);
+ }
let field_id = self.find_field_id_by_name(column)?;
let field_id = match field_id {
Some(id) => id,
@@ -161,14 +164,16 @@ impl GlobalIndexScanner {
..
} = child
{
- if let Some(field_id) =
self.find_field_id_by_name(column)? {
- if self.entries_for_field(field_id).is_some() {
-
leaf_groups.entry(field_id).or_default().push((
- *op,
- literals.as_slice(),
- data_type,
- ));
- continue;
+ if is_btree_supported_op(*op) {
+ if let Some(field_id) =
self.find_field_id_by_name(column)? {
+ if
self.entries_for_field(field_id).is_some() {
+
leaf_groups.entry(field_id).or_default().push((
+ *op,
+ literals.as_slice(),
+ data_type,
+ ));
+ continue;
+ }
}
}
}
@@ -270,6 +275,17 @@ impl GlobalIndexScanner {
entry.meta.may_match_between(&from_key, &to_key, &cmp)
});
+ // When a Between conjunct exists but the file does not overlap its
+ // range, the whole AND cannot match — drop the file regardless of
+ // how the remaining predicates evaluate. Without this guard, a
file
+ // outside the Between range but matched by some remaining
predicate
+ // (e.g. `BETWEEN 10 AND 20 AND id >= 0` on a file [30, 40]) would
+ // be retained because `file_result` is initialized from the
+ // remaining bitmap, silently dropping the Between conjunct.
+ if between.is_some() && !between_matches {
+ continue;
+ }
+
if matching_predicates.is_empty() && !between_matches {
continue;
}
@@ -383,6 +399,27 @@ impl GlobalIndexScanner {
}
}
+/// Whether the b-tree global index can evaluate this operator directly.
+/// Operators that fall outside this set bypass the index and are evaluated
+/// later in the read pipeline (stats prune + parquet row filter).
+fn is_btree_supported_op(op: PredicateOperator) -> bool {
+ matches!(
+ op,
+ PredicateOperator::Eq
+ | PredicateOperator::NotEq
+ | PredicateOperator::Lt
+ | PredicateOperator::LtEq
+ | PredicateOperator::Gt
+ | PredicateOperator::GtEq
+ | PredicateOperator::In
+ | PredicateOperator::NotIn
+ | PredicateOperator::IsNull
+ | PredicateOperator::IsNotNull
+ | PredicateOperator::Between
+ | PredicateOperator::NotBetween
+ )
+}
+
/// Convert a RoaringTreemap to merged RowRanges (already sorted and
deduplicated).
fn bitmap_to_ranges(bitmap: &RoaringTreemap) -> Vec<RowRange> {
if bitmap.is_empty() {
@@ -941,4 +978,51 @@ mod tests {
let ranges = result.unwrap();
assert_eq!(ranges, vec![RowRange::new(22, 26)]);
}
+
+ /// Regression for the Between+remaining bug in `evaluate_leaf`. When a
+ /// native `Between` leaf is paired with another conjunct (e.g. `id >= 0`),
+ /// and the file's b-tree key range falls **outside** the Between range
+ /// but is still matched by the remaining predicate, the whole AND must
+ /// produce zero rows. Before the fix, `file_result` was initialized from
+ /// the remaining predicate's bitmap and the Between conjunct was silently
+ /// dropped — the test would observe the file's full row id set instead of
+ /// the empty set.
+ #[tokio::test]
+ async fn test_between_unmatched_file_drops_remaining_match() {
+ let (file_io, table_path, file_name, _tmp) =
+ setup_testdata_table("btree_int_100_no_compress.bin");
+ // File covers keys [0, 198] (row_ids 0..99). Pick a Between range
+ // entirely below 0 so `may_match_between` is false, and a `>= 0`
+ // conjunct that would otherwise scoop up every row in the file.
+ let meta = BTreeIndexMeta::new(Some(le_int_key(0)),
Some(le_int_key(198)), false);
+ let entries = vec![make_global_index_entry(&file_name, 1, 0, 99,
&meta)];
+ let fields = int_schema_fields();
+
+ let predicates = vec![Predicate::and(vec![
+ Predicate::Leaf {
+ column: "id".to_string(),
+ index: 0,
+ data_type: DataType::Int(crate::spec::IntType::new()),
+ op: PredicateOperator::Between,
+ literals: vec![Datum::Int(-100), Datum::Int(-50)],
+ },
+ Predicate::Leaf {
+ column: "id".to_string(),
+ index: 0,
+ data_type: DataType::Int(crate::spec::IntType::new()),
+ op: PredicateOperator::GtEq,
+ literals: vec![Datum::Int(0)],
+ },
+ ])];
+
+ let result = evaluate_global_index(&file_io, &table_path, &entries,
&predicates, &fields)
+ .await
+ .unwrap();
+ let ranges = result.unwrap();
+ assert!(
+ ranges.is_empty(),
+ "Between(-100..-50) AND id>=0 must produce zero rows on a file \
+ whose key range is [0, 198] — got {ranges:?}"
+ );
+ }
}