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 8b18dda8 feat: add Java-compatible array predicate pushdown (#732)
8b18dda8 is described below
commit 8b18dda8d4fc44ba9d54360bbb93b571b8970ddf
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Aug 19 19:05:26 2026 +0800
feat: add Java-compatible array predicate pushdown (#732)
---
Cargo.lock | 1 +
.../integrations/datafusion/src/filter_pushdown.rs | 548 ++++++++++++++++++++-
crates/paimon/Cargo.toml | 1 +
crates/paimon/src/arrow/format/orc.rs | 5 +-
crates/paimon/src/arrow/format/parquet.rs | 24 +
crates/paimon/src/arrow/residual.rs | 405 ++++++++++++++-
crates/paimon/src/btree/query.rs | 6 +
crates/paimon/src/predicate_stats.rs | 38 +-
crates/paimon/src/spec/binary_row.rs | 34 ++
crates/paimon/src/spec/predicate.rs | 266 +++++++++-
.../paimon/src/table/bitmap_global_index_reader.rs | 146 +++++-
11 files changed, 1434 insertions(+), 40 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 02d2665a..627ac1d4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4587,6 +4587,7 @@ dependencies = [
"log",
"lru 0.18.2",
"lz4_flex 0.13.1",
+ "lzokay-native",
"md-5 0.10.6",
"opendal-core",
"opendal-http-transport-reqwest",
diff --git a/crates/integrations/datafusion/src/filter_pushdown.rs
b/crates/integrations/datafusion/src/filter_pushdown.rs
index 33fd2aca..cdbe6460 100644
--- a/crates/integrations/datafusion/src/filter_pushdown.rs
+++ b/crates/integrations/datafusion/src/filter_pushdown.rs
@@ -15,6 +15,8 @@
// specific language governing permissions and limitations
// under the License.
+use datafusion::arrow::array::Array;
+use datafusion::arrow::datatypes::DataType as ArrowDataType;
use datafusion::common::{Column, ScalarValue};
use datafusion::logical_expr::expr::{InList, ScalarFunction};
use datafusion::logical_expr::{
@@ -168,6 +170,13 @@ impl<'a> FilterTranslator<'a> {
// must keep its residual filter for NULL / three-valued semantics.
Expr::Not(inner) => {
let inner = self.translate(inner.as_ref())?;
+ // A positive inexact predicate is only guaranteed to be a
+ // conservative superset. Negating it would turn that into a
+ // subset and could remove rows before DataFusion's residual
+ // runs (notably for floating-array NaN payload semantics).
+ if inner.requires_residual {
+ return None;
+ }
Some(TranslatedPredicate {
predicate: Predicate::negate(inner.predicate),
requires_residual: true,
@@ -311,6 +320,18 @@ impl<'a> FilterTranslator<'a> {
}
fn translate_scalar_function(&self, func: &ScalarFunction) ->
Option<TranslatedPredicate> {
+ if matches!(
+ func.name(),
+ "array_has"
+ | "list_has"
+ | "array_has_any"
+ | "list_has_any"
+ | "arrays_overlap"
+ | "array_has_all"
+ | "list_has_all"
+ ) {
+ return self.translate_array_function(func);
+ }
// 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
@@ -334,6 +355,104 @@ impl<'a> FilterTranslator<'a> {
self.exact(predicate)
}
+ fn translate_array_function(&self, func: &ScalarFunction) ->
Option<TranslatedPredicate> {
+ if func.args.len() != 2 {
+ return None;
+ }
+ let (field, comparison_element_type) =
self.resolve_array_field(&func.args[0])?;
+ let DataType::Array(array_type) = field.data_type() else {
+ return None;
+ };
+ let predicate = match func.name() {
+ "array_has" | "list_has" => {
+ let literal = extract_array_scalar_literal(
+ &func.args[1],
+ array_type.element_type(),
+ &comparison_element_type,
+ )?;
+ self.predicate_builder
+ .array_contains(field.name(), literal)
+ .ok()?
+ }
+ "array_has_any" | "list_has_any" | "arrays_overlap" => {
+ let literals = extract_array_literals(
+ &func.args[1],
+ array_type.element_type(),
+ &comparison_element_type,
+ )?;
+ self.predicate_builder
+ .arrays_overlap(field.name(), literals)
+ .ok()?
+ }
+ "array_has_all" | "list_has_all" => {
+ let literals = extract_array_literals(
+ &func.args[1],
+ array_type.element_type(),
+ &comparison_element_type,
+ )?;
+ // DataFusion 54's empty-needle fast path currently returns
true
+ // even for a NULL haystack, while Paimon/Java
ARRAY_CONTAINS_ALL
+ // returns false for NULL arrays. Pushing it would remove rows
+ // before DataFusion can apply its own semantics.
+ if literals.is_empty() {
+ return None;
+ }
+ self.predicate_builder
+ .array_contains_all(field.name(), literals)
+ .ok()?
+ }
+ _ => return None,
+ };
+ Some(TranslatedPredicate {
+ predicate,
+ // Paimon's core residual follows Java Float.compare /
Double.compare
+ // and canonicalizes all NaNs. DataFusion's Arrow equality keeps
NaN
+ // payloads distinct, so retain its residual for floating arrays.
+ requires_residual: matches!(
+ array_type.element_type(),
+ DataType::Float(_) | DataType::Double(_)
+ ),
+ })
+ }
+
+ /// Resolve an ARRAY column, accepting only the lossless element-wise casts
+ /// inserted by DataFusion's array function type coercion.
+ fn resolve_array_field(&self, expr: &Expr) -> Option<(&'a DataField,
ArrowDataType)> {
+ match expr {
+ Expr::Column(_) => {
+ let field = self.resolve_field(expr)?;
+ let DataType::Array(array_type) = field.data_type() else {
+ return None;
+ };
+ let comparison_type =
+
paimon::arrow::paimon_type_to_arrow(array_type.element_type()).ok()?;
+ Some((field, comparison_type))
+ }
+ Expr::Cast(cast) => {
+ let field = self.resolve_field(cast.expr.as_ref())?;
+ let DataType::Array(array_type) = field.data_type() else {
+ return None;
+ };
+ // Paimon ARRAY columns are Arrow List values. DataFusion's
+ // numeric coercion keeps that container and only widens its
+ // element. In particular, List -> FixedSizeList is
+ // value-dependent and must not be erased here.
+ let ArrowDataType::List(target_field) = cast.field.data_type()
else {
+ return None;
+ };
+ if array_type.element_type().is_nullable() &&
!target_field.is_nullable() {
+ return None;
+ }
+ let target_element = target_field.data_type();
+ if !is_lossless_array_element_cast(array_type.element_type(),
target_element) {
+ return None;
+ }
+ Some((field, target_element.clone()))
+ }
+ _ => None,
+ }
+ }
+
fn translate_like(&self, like: &Like) -> Option<TranslatedPredicate> {
// ILIKE has no equivalent in Paimon's predicate model.
if like.case_insensitive {
@@ -398,6 +517,114 @@ fn extract_scalar_literal(expr: &Expr) ->
Option<&ScalarValue> {
}
}
+fn extract_array_scalar_literal(
+ expr: &Expr,
+ element_type: &DataType,
+ comparison_element_type: &ArrowDataType,
+) -> Option<Datum> {
+ match expr {
+ Expr::Literal(scalar, _) if !scalar.is_null() => {
+ scalar_to_array_datum(scalar, element_type)
+ }
+ Expr::Cast(cast) if cast.field.data_type() == comparison_element_type
=> {
+ let scalar = extract_scalar_literal(cast.expr.as_ref())?;
+ if !is_lossless_arrow_scalar_cast(&scalar.data_type(),
comparison_element_type) {
+ return None;
+ }
+ scalar_to_array_datum(scalar, element_type)
+ }
+ _ => None,
+ }
+}
+
+fn extract_array_literals(
+ expr: &Expr,
+ element_type: &DataType,
+ comparison_element_type: &ArrowDataType,
+) -> Option<Vec<Datum>> {
+ match expr {
+ Expr::ScalarFunction(function) if matches!(function.name(),
"make_array" | "make_list") => {
+ function
+ .args
+ .iter()
+ .map(|expr| {
+ extract_array_scalar_literal(expr, element_type,
comparison_element_type)
+ })
+ .collect()
+ }
+ Expr::Cast(cast)
+ if arrow_list_element_type(cast.field.data_type()) ==
Some(comparison_element_type) =>
+ {
+ extract_array_literals(cast.expr.as_ref(), element_type,
comparison_element_type)
+ }
+ Expr::Literal(scalar, _) => {
+ let values = match scalar {
+ ScalarValue::List(list) if !list.is_null(0) => list.value(0),
+ ScalarValue::LargeList(list) if !list.is_null(0) =>
list.value(0),
+ ScalarValue::FixedSizeList(list) if !list.is_null(0) =>
list.value(0),
+ _ => return None,
+ };
+ (0..values.len())
+ .map(|index| {
+ let scalar = ScalarValue::try_from_array(values.as_ref(),
index).ok()?;
+ if scalar.is_null() {
+ return None;
+ }
+ scalar_to_array_datum(&scalar, element_type)
+ })
+ .collect()
+ }
+ _ => None,
+ }
+}
+
+fn scalar_to_array_datum(scalar: &ScalarValue, element_type: &DataType) ->
Option<Datum> {
+ if let (DataType::Float(_), ScalarValue::Float64(Some(value))) =
(element_type, scalar) {
+ let narrowed = *value as f32;
+ return ((narrowed as f64).to_bits() ==
value.to_bits()).then_some(Datum::Float(narrowed));
+ }
+ scalar_to_datum(scalar, element_type)
+}
+
+fn arrow_list_element_type(data_type: &ArrowDataType) ->
Option<&ArrowDataType> {
+ match data_type {
+ ArrowDataType::List(field) => Some(field.data_type()),
+ _ => None,
+ }
+}
+
+fn is_lossless_array_element_cast(source: &DataType, target: &ArrowDataType)
-> bool {
+ if paimon::arrow::paimon_type_to_arrow(source).ok().as_ref() ==
Some(target) {
+ return true;
+ }
+ matches!(
+ (source, target),
+ (
+ DataType::TinyInt(_),
+ ArrowDataType::Int16 | ArrowDataType::Int32 | ArrowDataType::Int64
+ ) | (
+ DataType::SmallInt(_),
+ ArrowDataType::Int32 | ArrowDataType::Int64
+ ) | (DataType::Int(_), ArrowDataType::Int64)
+ | (DataType::Float(_), ArrowDataType::Float64)
+ )
+}
+
+fn is_lossless_arrow_scalar_cast(source: &ArrowDataType, target:
&ArrowDataType) -> bool {
+ source == target
+ || matches!(
+ (source, target),
+ (
+ ArrowDataType::Int8,
+ ArrowDataType::Int16 | ArrowDataType::Int32 |
ArrowDataType::Int64
+ ) | (
+ ArrowDataType::Int16,
+ ArrowDataType::Int32 | ArrowDataType::Int64
+ ) | (ArrowDataType::Int32, ArrowDataType::Int64)
+ | (ArrowDataType::Float32, ArrowDataType::Float64)
+ )
+}
+
fn reverse_comparison_operator(op: Operator) -> Option<Operator> {
match op {
Operator::Eq => Some(Operator::Eq),
@@ -546,7 +773,8 @@ mod tests {
use paimon::catalog::Identifier;
use paimon::io::FileIOBuilder;
use paimon::spec::{
- IntType, LocalZonedTimestampType, Schema, TableSchema, TimeType,
TimestampType, VarCharType,
+ ArrayType, BigIntType, FloatType, IntType, LocalZonedTimestampType,
PredicateOperator,
+ Schema, SmallIntType, TableSchema, TimeType, TimestampType,
VarCharType,
};
use paimon::table::Table;
@@ -567,6 +795,10 @@ mod tests {
"lzts_col",
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(9).unwrap()),
)
+ .column(
+ "items",
+
DataType::Array(ArrayType::new(DataType::Int(IntType::new()))),
+ )
.partition_keys(["dt", "hr"])
.build()
.unwrap(),
@@ -603,6 +835,320 @@ mod tests {
}
}
+ #[test]
+ fn test_translate_datafusion_array_membership_functions() {
+ use datafusion::functions_nested::expr_fn::{
+ array_has, array_has_all, array_has_any, make_array,
+ };
+
+ let column = Expr::Column(Column::from_name("items"));
+ let cases = [
+ (
+ array_has(column.clone(), lit(2)),
+ PredicateOperator::ArrayContains,
+ vec![Datum::Int(2)],
+ ),
+ (
+ array_has_any(column.clone(), make_array(vec![lit(1),
lit(3)])),
+ PredicateOperator::ArraysOverlap,
+ vec![Datum::Int(1), Datum::Int(3)],
+ ),
+ (
+ array_has_all(column, make_array(vec![lit(2), lit(2),
lit(4)])),
+ PredicateOperator::ArrayContainsAll,
+ vec![Datum::Int(2), Datum::Int(2), Datum::Int(4)],
+ ),
+ ];
+
+ let fields = test_fields();
+ for (filter, expected_op, expected_literals) in cases {
+ let predicate = build_pushed_predicate(&[filter], &fields)
+ .expect("array membership function should translate");
+ assert!(matches!(
+ predicate,
+ Predicate::Leaf { op, literals, .. }
+ if op == expected_op && literals == expected_literals
+ ));
+ }
+ }
+
+ #[test]
+ fn test_empty_array_has_all_falls_open_for_datafusion_null_semantics() {
+ use datafusion::functions_nested::expr_fn::{array_has_all, make_array};
+
+ let filter = array_has_all(
+ Expr::Column(Column::from_name("items")),
+ make_array(Vec::<Expr>::new()),
+ );
+ let fields = test_fields();
+
+ assert!(build_pushed_predicate(std::slice::from_ref(&filter),
&fields).is_none());
+ assert_eq!(
+ classify_filter_pushdown(&filter, &fields, true,
is_exact_filter_pushdown),
+ TableProviderFilterPushDown::Unsupported
+ );
+ }
+
+ #[test]
+ fn test_float_array_membership_keeps_datafusion_residual() {
+ use datafusion::functions_nested::expr_fn::array_has;
+
+ let fields = vec![DataField::new(
+ 1,
+ "items".to_string(),
+ DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )];
+ let filter = array_has(
+ Expr::Column(Column::from_name("items")),
+ lit(f32::from_bits(0x7fc0_1234)),
+ );
+ let analysis = analyze_filters(std::slice::from_ref(&filter), &fields,
true);
+
+ assert!(analysis.pushed_predicate.is_some());
+ assert!(analysis.requires_residual);
+ assert_eq!(
+ classify_filter_pushdown(&filter, &fields, true, |_| true),
+ TableProviderFilterPushDown::Inexact
+ );
+ }
+
+ #[test]
+ fn test_negated_inexact_float_array_membership_falls_open() {
+ use datafusion::functions_nested::expr_fn::array_has;
+
+ let fields = vec![DataField::new(
+ 1,
+ "items".to_string(),
+ DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )];
+ let filter = Expr::Not(Box::new(array_has(
+ Expr::Column(Column::from_name("items")),
+ lit(f32::from_bits(0xffc0_1234)),
+ )));
+
+ // Paimon/Java considers every NaN payload equal, while DataFusion
+ // distinguishes payloads. Negating that inexact positive predicate
+ // would turn its safe superset into a subset and silently drop rows.
+ assert!(build_pushed_predicate(std::slice::from_ref(&filter),
&fields).is_none());
+ assert_eq!(
+ classify_filter_pushdown(&filter, &fields, true, |_| true),
+ TableProviderFilterPushDown::Unsupported
+ );
+ }
+
+ #[test]
+ fn test_translate_analyzer_inserted_array_widening_casts() {
+ use datafusion::arrow::datatypes::DataType as ArrowDataType;
+ use datafusion::functions_nested::expr_fn::{array_has, array_has_any,
make_array};
+ use datafusion::logical_expr::Cast;
+
+ let fields = test_fields();
+ let widened_items = Expr::Cast(Cast::new(
+ Box::new(Expr::Column(Column::from_name("items"))),
+ ArrowDataType::new_list(ArrowDataType::Int64, true),
+ ));
+ let cases = [
+ (
+ array_has(widened_items.clone(), lit(2_i64)),
+ PredicateOperator::ArrayContains,
+ vec![Datum::Int(2)],
+ ),
+ (
+ array_has_any(widened_items, make_array(vec![lit(1_i64),
lit(3_i64)])),
+ PredicateOperator::ArraysOverlap,
+ vec![Datum::Int(1), Datum::Int(3)],
+ ),
+ ];
+
+ for (filter, expected_op, expected_literals) in cases {
+ let predicate = build_pushed_predicate(&[filter], &fields)
+ .expect("lossless analyzer-inserted widening cast should
translate");
+ assert!(matches!(
+ predicate,
+ Predicate::Leaf { op, literals, .. }
+ if op == expected_op && literals == expected_literals
+ ));
+ }
+
+ let out_of_range = array_has(
+ Expr::Cast(Cast::new(
+ Box::new(Expr::Column(Column::from_name("items"))),
+ ArrowDataType::new_list(ArrowDataType::Int64, true),
+ )),
+ lit(i64::MAX),
+ );
+ assert!(build_pushed_predicate(&[out_of_range], &fields).is_none());
+
+ let fixed_size_cast = array_has(
+ Expr::Cast(Cast::new(
+ Box::new(Expr::Column(Column::from_name("items"))),
+ ArrowDataType::new_fixed_size_list(ArrowDataType::Int64, 2,
true),
+ )),
+ lit(2_i64),
+ );
+ assert!(build_pushed_predicate(&[fixed_size_cast], &fields).is_none());
+
+ let non_nullable_elements = array_has(
+ Expr::Cast(Cast::new(
+ Box::new(Expr::Column(Column::from_name("items"))),
+ ArrowDataType::new_list(ArrowDataType::Int64, false),
+ )),
+ lit(2_i64),
+ );
+ assert!(build_pushed_predicate(&[non_nullable_elements],
&fields).is_none());
+
+ let fixed_size_literals = array_has_any(
+ Expr::Column(Column::from_name("items")),
+ Expr::Cast(Cast::new(
+ Box::new(make_array(vec![lit(1_i32), lit(3_i32), lit(5_i32)])),
+ ArrowDataType::new_fixed_size_list(ArrowDataType::Int32, 2,
true),
+ )),
+ );
+ assert!(build_pushed_predicate(&[fixed_size_literals],
&fields).is_none());
+
+ let long_fields = vec![DataField::new(
+ 1,
+ "items".to_string(),
+
DataType::Array(ArrayType::new(DataType::BigInt(BigIntType::new()))),
+ )];
+ let widened_literal = Expr::Cast(Cast::new(Box::new(lit(2_i32)),
ArrowDataType::Int64));
+ let predicate = build_pushed_predicate(
+ &[array_has(
+ Expr::Column(Column::from_name("items")),
+ widened_literal,
+ )],
+ &long_fields,
+ )
+ .expect("losslessly widened scalar literal should translate");
+ assert!(matches!(
+ predicate,
+ Predicate::Leaf { literals, .. } if literals ==
vec![Datum::Long(2)]
+ ));
+
+ let float_fields = vec![DataField::new(
+ 1,
+ "items".to_string(),
+ DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ )];
+ let widened_floats = Expr::Cast(Cast::new(
+ Box::new(Expr::Column(Column::from_name("items"))),
+ ArrowDataType::new_list(ArrowDataType::Float64, true),
+ ));
+ assert!(build_pushed_predicate(
+ &[array_has(widened_floats.clone(), lit(1.0_f64))],
+ &float_fields,
+ )
+ .is_some());
+ assert!(
+ build_pushed_predicate(&[array_has(widened_floats, lit(1.1_f64))],
&float_fields)
+ .is_none()
+ );
+ }
+
+ #[tokio::test]
+ async fn test_translate_array_membership_after_datafusion_sql_analysis() {
+ use datafusion::arrow::datatypes::{
+ DataType as ArrowDataType, Field as ArrowField, Schema as
ArrowSchema,
+ };
+ use datafusion::datasource::empty::EmptyTable;
+ use datafusion::logical_expr::LogicalPlan;
+ use datafusion::prelude::SessionContext;
+ use std::sync::Arc;
+
+ fn filter_expr(plan: &LogicalPlan) -> Option<&Expr> {
+ match plan {
+ LogicalPlan::Filter(filter) => Some(&filter.predicate),
+ LogicalPlan::TableScan(scan) => scan.filters.first(),
+ other => other.inputs().into_iter().find_map(filter_expr),
+ }
+ }
+
+ let ctx = SessionContext::new();
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![ArrowField::new(
+ "items",
+ ArrowDataType::new_list(ArrowDataType::Int32, true),
+ true,
+ )]));
+ ctx.register_table("t", Arc::new(EmptyTable::new(arrow_schema)))
+ .unwrap();
+ let fields = vec![DataField::new(
+ 1,
+ "items".to_string(),
+ DataType::Array(ArrayType::new(DataType::Int(IntType::new()))),
+ )];
+ let cases = [
+ (
+ "SELECT * FROM t WHERE array_has(items, 2)",
+ PredicateOperator::ArrayContains,
+ vec![Datum::Int(2)],
+ ),
+ (
+ "SELECT * FROM t WHERE array_has_any(items, [1, 3])",
+ PredicateOperator::ArraysOverlap,
+ vec![Datum::Int(1), Datum::Int(3)],
+ ),
+ (
+ "SELECT * FROM t WHERE array_has_all(items, [1, 3])",
+ PredicateOperator::ArrayContainsAll,
+ vec![Datum::Int(1), Datum::Int(3)],
+ ),
+ ];
+ for (sql, expected_op, expected_literals) in cases {
+ let plan = ctx.state().create_logical_plan(sql).await.unwrap();
+ let plan = ctx.state().optimize(&plan).unwrap();
+ let filter = filter_expr(&plan).expect("optimized plan should
retain the filter");
+ assert!(filter.to_string().contains("CAST"));
+
+ let predicate =
build_pushed_predicate(std::slice::from_ref(filter), &fields)
+ .expect("analyzed SQL array predicate should translate");
+ assert!(matches!(
+ predicate,
+ Predicate::Leaf { op, literals, .. }
+ if op == expected_op && literals == expected_literals
+ ));
+ }
+
+ let numeric_schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new(
+ "small_items",
+ ArrowDataType::new_list(ArrowDataType::Int16, true),
+ true,
+ ),
+ ArrowField::new(
+ "float_items",
+ ArrowDataType::new_list(ArrowDataType::Float32, true),
+ true,
+ ),
+ ]));
+ ctx.register_table("numeric_arrays",
Arc::new(EmptyTable::new(numeric_schema)))
+ .unwrap();
+ let numeric_fields = vec![
+ DataField::new(
+ 1,
+ "small_items".to_string(),
+
DataType::Array(ArrayType::new(DataType::SmallInt(SmallIntType::new()))),
+ ),
+ DataField::new(
+ 2,
+ "float_items".to_string(),
+
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
+ ),
+ ];
+ for sql in [
+ "SELECT * FROM numeric_arrays WHERE array_has(small_items, 2)",
+ "SELECT * FROM numeric_arrays WHERE array_has(float_items, 1.0)",
+ ] {
+ let plan = ctx.state().create_logical_plan(sql).await.unwrap();
+ let plan = ctx.state().optimize(&plan).unwrap();
+ let filter = filter_expr(&plan).expect("optimized plan should
retain the filter");
+ assert!(filter.to_string().contains("CAST"));
+ assert!(
+ build_pushed_predicate(std::slice::from_ref(filter),
&numeric_fields).is_some(),
+ "analyzed numeric ARRAY predicate should translate: {filter}"
+ );
+ }
+ }
+
#[test]
fn test_translate_time32_millisecond_literal() {
let filter =
Expr::Column(Column::from_name("time_col")).eq(Expr::Literal(
diff --git a/crates/paimon/Cargo.toml b/crates/paimon/Cargo.toml
index 4c0bd8dd..5283ea27 100644
--- a/crates/paimon/Cargo.toml
+++ b/crates/paimon/Cargo.toml
@@ -102,6 +102,7 @@ crc32fast = "1"
zstd = "0.13"
snap = "1"
lz4_flex = "0.13"
+lzokay-native = { version = "0.1", default-features = false, features =
["decompress"] }
arrow-array = { workspace = true }
arrow-arith = { workspace = true }
arrow-buffer = { workspace = true }
diff --git a/crates/paimon/src/arrow/format/orc.rs
b/crates/paimon/src/arrow/format/orc.rs
index 6ebbeb5f..fd2e3811 100644
--- a/crates/paimon/src/arrow/format/orc.rs
+++ b/crates/paimon/src/arrow/format/orc.rs
@@ -296,7 +296,10 @@ fn build_orc_leaf_predicate(
| PredicateOperator::Contains
| PredicateOperator::Like
| PredicateOperator::Between
- | PredicateOperator::NotBetween => None,
+ | PredicateOperator::NotBetween
+ | PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll => None,
}
}
diff --git a/crates/paimon/src/arrow/format/parquet.rs
b/crates/paimon/src/arrow/format/parquet.rs
index 76c76892..444ff8f8 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -903,6 +903,9 @@ fn predicate_supported_for_parquet_row_filter(op:
PredicateOperator) -> bool {
| PredicateOperator::Like
| PredicateOperator::Between
| PredicateOperator::NotBetween
+ | PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll
)
}
@@ -968,6 +971,27 @@ fn parquet_row_filter_literals_supported(
}
Ok(true)
}
+ PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll => {
+ let DataType::Array(array_type) = file_data_type else {
+ return Ok(false);
+ };
+ if matches!(op, PredicateOperator::ArrayContains) &&
literals.len() != 1 {
+ return Ok(false);
+ }
+ for literal in literals {
+ if crate::arrow::residual::literal_scalar_for_arrow_filter(
+ literal,
+ array_type.element_type(),
+ )?
+ .is_none()
+ {
+ return Ok(false);
+ }
+ }
+ Ok(true)
+ }
}
}
diff --git a/crates/paimon/src/arrow/residual.rs
b/crates/paimon/src/arrow/residual.rs
index 93159af6..5ddeb95d 100644
--- a/crates/paimon/src/arrow/residual.rs
+++ b/crates/paimon/src/arrow/residual.rs
@@ -49,9 +49,9 @@ use crate::spec::{is_row_id_column, DataField, DataType,
Datum, Predicate, Predi
use crate::Error;
use arrow_array::{
Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Datum as
ArrowDatum, Decimal128Array,
- Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array,
RecordBatch, Scalar,
- StringArray, Time32MillisecondArray, TimestampMicrosecondArray,
TimestampMillisecondArray,
- TimestampNanosecondArray,
+ FixedSizeListArray, Float32Array, Float64Array, Int16Array, Int32Array,
Int64Array, Int8Array,
+ LargeListArray, ListArray, RecordBatch, Scalar, StringArray,
Time32MillisecondArray,
+ TimestampMicrosecondArray, TimestampMillisecondArray,
TimestampNanosecondArray,
};
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,
@@ -327,6 +327,14 @@ pub(crate) fn evaluate_exact_leaf_predicate(
op: PredicateOperator,
literals: &[Datum],
) -> Result<BooleanArray, ArrowError> {
+ if matches!(
+ op,
+ PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll
+ ) {
+ return evaluate_array_membership_predicate(array, data_type, op,
literals);
+ }
// Decimals are compared by mathematical value across scales (Paimon
// `datum_cmp`/`decimal_cmp`). Arrow scalar comparison requires the
literal to
// be representable at the column scale, which fails for a finer-scale
literal
@@ -378,6 +386,176 @@ pub(crate) fn evaluate_exact_leaf_predicate(
PredicateOperator::Between | PredicateOperator::NotBetween => {
evaluate_between_predicate(array, data_type, op, literals)
}
+ PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll => unreachable!("handled before
scalar dispatch"),
+ }
+}
+
+fn evaluate_array_membership_predicate(
+ array: &ArrayRef,
+ data_type: &DataType,
+ op: PredicateOperator,
+ literals: &[Datum],
+) -> Result<BooleanArray, ArrowError> {
+ let DataType::Array(array_type) = data_type else {
+ return Err(ArrowError::ComputeError(format!(
+ "array predicate {op} requires an ARRAY column, got {data_type:?}"
+ )));
+ };
+ if matches!(op, PredicateOperator::ArrayContains) && literals.len() != 1 {
+ return Err(unconvertible_literal_error(op, data_type));
+ }
+
+ #[derive(Clone, Copy)]
+ enum ListLayout<'a> {
+ List(&'a ListArray),
+ Large(&'a LargeListArray),
+ Fixed(&'a FixedSizeListArray),
+ }
+ let layout = if let Some(list) =
array.as_any().downcast_ref::<ListArray>() {
+ ListLayout::List(list)
+ } else if let Some(list) = array.as_any().downcast_ref::<LargeListArray>()
{
+ ListLayout::Large(list)
+ } else if let Some(list) =
array.as_any().downcast_ref::<FixedSizeListArray>() {
+ ListLayout::Fixed(list)
+ } else {
+ return Err(ArrowError::ComputeError(format!(
+ "array predicate {op} requires an Arrow list array, got {:?}",
+ array.data_type()
+ )));
+ };
+ let values = match layout {
+ ListLayout::List(list) => list.values(),
+ ListLayout::Large(list) => list.values(),
+ ListLayout::Fixed(list) => list.values(),
+ };
+
+ let mut row_ranges = Vec::with_capacity(array.len());
+ for row in 0..array.len() {
+ if array.is_null(row) {
+ row_ranges.push(None);
+ continue;
+ }
+ let (start, end) = match layout {
+ ListLayout::List(list) => {
+ let offsets = list.value_offsets();
+ (
+ usize::try_from(offsets[row]),
+ usize::try_from(offsets[row + 1]),
+ )
+ }
+ ListLayout::Large(list) => {
+ let offsets = list.value_offsets();
+ (
+ usize::try_from(offsets[row]),
+ usize::try_from(offsets[row + 1]),
+ )
+ }
+ ListLayout::Fixed(list) => {
+ let start = usize::try_from(list.value_offset(row));
+ let end = usize::try_from(list.value_offset(row) +
list.value_length());
+ (start, end)
+ }
+ };
+ let (start, end) = match (start, end) {
+ (Ok(start), Ok(end)) => (start, end),
+ _ => {
+ return Err(ArrowError::ComputeError(
+ "array predicate encountered a negative list
offset".to_string(),
+ ))
+ }
+ };
+ row_ranges.push(Some((start, end)));
+ }
+
+ // Fold one child mask at a time. Besides bounding temporary memory to one
+ // flattened-child mask, the specialized equality below preserves Java's
+ // Float.compare / Double.compare NaN semantics and decimal by-value
+ // comparison for nested elements.
+ let contains_all = matches!(op, PredicateOperator::ArrayContainsAll);
+ let mut result = row_ranges
+ .iter()
+ .map(|range| range.is_some() && contains_all)
+ .collect::<Vec<_>>();
+ for literal in literals {
+ let mask = evaluate_array_element_equality(values,
array_type.element_type(), literal, op)?;
+ for (row, range) in row_ranges.iter().enumerate() {
+ let Some((start, end)) = range else {
+ continue;
+ };
+ if (contains_all && !result[row]) || (!contains_all &&
result[row]) {
+ continue;
+ }
+ let matches_literal = (*start..*end).any(|element|
mask.value(element));
+ if contains_all {
+ result[row] &= matches_literal;
+ } else {
+ result[row] |= matches_literal;
+ }
+ }
+ }
+ Ok(BooleanArray::from(result))
+}
+
+fn evaluate_array_element_equality(
+ values: &ArrayRef,
+ element_type: &DataType,
+ literal: &Datum,
+ op: PredicateOperator,
+) -> Result<BooleanArray, ArrowError> {
+ match element_type {
+ DataType::Float(_) => {
+ let expected = float32_literal(literal)
+ .ok_or_else(|| unconvertible_literal_error(op, element_type))?;
+ let values = values
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .ok_or_else(|| {
+ ArrowError::ComputeError(
+ "FLOAT array elements require an Arrow
Float32Array".to_string(),
+ )
+ })?;
+ Ok(BooleanArray::from_iter(values.iter().map(|value| {
+ Some(value.is_some_and(|value| {
+ (value.is_nan() && expected.is_nan()) || value.to_bits()
== expected.to_bits()
+ }))
+ })))
+ }
+ DataType::Double(_) => {
+ let expected = float64_literal(literal)
+ .ok_or_else(|| unconvertible_literal_error(op, element_type))?;
+ let values = values
+ .as_any()
+ .downcast_ref::<Float64Array>()
+ .ok_or_else(|| {
+ ArrowError::ComputeError(
+ "DOUBLE array elements require an Arrow
Float64Array".to_string(),
+ )
+ })?;
+ Ok(BooleanArray::from_iter(values.iter().map(|value| {
+ Some(value.is_some_and(|value| {
+ (value.is_nan() && expected.is_nan()) || value.to_bits()
== expected.to_bits()
+ }))
+ })))
+ }
+ DataType::Decimal(_) => Ok(sanitize_filter_mask(evaluate_decimal_leaf(
+ values,
+ PredicateOperator::Eq,
+ std::slice::from_ref(literal),
+ )?)),
+ _ => {
+ let Some(scalar) = literal_scalar_for_arrow_filter(literal,
element_type)
+ .map_err(|error| ArrowError::ComputeError(error.to_string()))?
+ else {
+ return Err(unconvertible_literal_error(op, element_type));
+ };
+ Ok(sanitize_filter_mask(evaluate_column_predicate(
+ values,
+ &scalar,
+ PredicateOperator::Eq,
+ )?))
+ }
}
}
@@ -409,8 +587,7 @@ fn evaluate_decimal_leaf(
// Compare one column value (as a Datum::Decimal at the column scale)
against a
// literal Datum. `precision` is irrelevant to `datum_cmp` (it compares by
- // value), so any value is fine. `None` means the cross-scale normalization
- // overflowed i128 — surface it rather than silently drop rows.
+ // value), so any value is fine.
let cmp = |v: i128, lit: &Datum| -> Result<Ordering, ArrowError> {
let cell = Datum::Decimal {
unscaled: v,
@@ -419,7 +596,7 @@ fn evaluate_decimal_leaf(
};
crate::spec::datum_cmp(&cell, lit).ok_or_else(|| {
ArrowError::ComputeError(
- "decimal comparison overflowed while normalizing
scales".to_string(),
+ "decimal column compared against a non-decimal
literal".to_string(),
)
})
};
@@ -743,7 +920,10 @@ fn evaluate_column_predicate(
| PredicateOperator::In
| PredicateOperator::NotIn
| PredicateOperator::Between
- | PredicateOperator::NotBetween =>
Ok(BooleanArray::new_null(column.len())),
+ | PredicateOperator::NotBetween
+ | PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll =>
Ok(BooleanArray::new_null(column.len())),
}
}
@@ -991,7 +1171,13 @@ fn float64_literal(literal: &Datum) -> Option<f64> {
#[cfg(test)]
mod tests {
use super::*;
- use crate::spec::{row_id_leaf, IntType, VarCharType, ROW_ID_FIELD_NAME};
+ use crate::spec::{
+ row_id_leaf, ArrayType, DecimalType, DoubleType, FloatType, IntType,
VarCharType,
+ ROW_ID_FIELD_NAME,
+ };
+ use arrow_array::builder::{
+ Decimal128Builder, Float32Builder, Float64Builder, Int32Builder,
ListBuilder,
+ };
use arrow_array::{Int32Array, StringArray};
use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema
as ArrowSchema};
use std::sync::Arc;
@@ -1049,6 +1235,209 @@ mod tests {
RecordBatch::try_new(schema,
vec![Arc::new(StringArray::from(values))]).unwrap()
}
+ fn int_list_array() -> ArrayRef {
+ let element = Arc::new(ArrowField::new("element",
ArrowDataType::Int32, true));
+ let mut builder =
ListBuilder::new(Int32Builder::new()).with_field(element);
+
+ builder.values().append_value(1);
+ builder.values().append_null();
+ builder.values().append_value(2);
+ builder.values().append_value(2);
+ builder.append(true);
+
+ builder.values().append_value(2);
+ builder.values().append_value(3);
+ builder.append(true);
+
+ builder.append(true); // empty array
+ builder.append(false); // null array
+ Arc::new(builder.finish())
+ }
+
+ fn bool_values(mask: &BooleanArray) -> Vec<bool> {
+ mask.iter().map(|value| value.unwrap_or(false)).collect()
+ }
+
+ #[test]
+ fn test_array_membership_predicates_match_java_multivalue_semantics() {
+ let array = int_list_array();
+ let data_type =
DataType::Array(ArrayType::new(DataType::Int(IntType::new())));
+
+ let contains = evaluate_exact_leaf_predicate(
+ &array,
+ &data_type,
+ PredicateOperator::ArrayContains,
+ &[Datum::Int(2)],
+ )
+ .unwrap();
+ assert_eq!(bool_values(&contains), vec![true, true, false, false]);
+
+ let overlap = evaluate_exact_leaf_predicate(
+ &array,
+ &data_type,
+ PredicateOperator::ArraysOverlap,
+ &[Datum::Int(9), Datum::Int(2), Datum::Int(2)],
+ )
+ .unwrap();
+ assert_eq!(bool_values(&overlap), vec![true, true, false, false]);
+
+ let contains_all = evaluate_exact_leaf_predicate(
+ &array,
+ &data_type,
+ PredicateOperator::ArrayContainsAll,
+ &[Datum::Int(1), Datum::Int(2), Datum::Int(1)],
+ )
+ .unwrap();
+ assert_eq!(bool_values(&contains_all), vec![true, false, false,
false]);
+
+ let contains_all_empty = evaluate_exact_leaf_predicate(
+ &array,
+ &data_type,
+ PredicateOperator::ArrayContainsAll,
+ &[],
+ )
+ .unwrap();
+ assert_eq!(
+ bool_values(&contains_all_empty),
+ vec![true, true, true, false]
+ );
+ }
+
+ #[test]
+ fn test_array_membership_uses_java_nan_and_signed_zero_semantics() {
+ let float_element = Arc::new(ArrowField::new("element",
ArrowDataType::Float32, true));
+ let mut floats =
ListBuilder::new(Float32Builder::new()).with_field(float_element);
+ floats.values().append_value(f32::from_bits(0x7fc0_0001));
+ floats.append(true);
+ floats.values().append_value(0.0);
+ floats.append(true);
+ floats.values().append_value(-0.0);
+ floats.append(true);
+ let floats: ArrayRef = Arc::new(floats.finish());
+ let float_type =
DataType::Array(ArrayType::new(DataType::Float(FloatType::new())));
+
+ let nan = evaluate_exact_leaf_predicate(
+ &floats,
+ &float_type,
+ PredicateOperator::ArrayContains,
+ &[Datum::Float(f32::from_bits(0xffc0_1234))],
+ )
+ .unwrap();
+ assert_eq!(bool_values(&nan), vec![true, false, false]);
+ let positive_zero = evaluate_exact_leaf_predicate(
+ &floats,
+ &float_type,
+ PredicateOperator::ArrayContains,
+ &[Datum::Float(0.0)],
+ )
+ .unwrap();
+ assert_eq!(bool_values(&positive_zero), vec![false, true, false]);
+
+ let double_element = Arc::new(ArrowField::new("element",
ArrowDataType::Float64, true));
+ let mut doubles =
ListBuilder::new(Float64Builder::new()).with_field(double_element);
+ doubles
+ .values()
+ .append_value(f64::from_bits(0x7ff8_0000_0000_0001));
+ doubles.append(true);
+ doubles.values().append_value(-0.0);
+ doubles.append(true);
+ let doubles: ArrayRef = Arc::new(doubles.finish());
+ let double_type =
DataType::Array(ArrayType::new(DataType::Double(DoubleType::new())));
+ let double_nan = evaluate_exact_leaf_predicate(
+ &doubles,
+ &double_type,
+ PredicateOperator::ArraysOverlap,
+ &[Datum::Double(f64::from_bits(0xfff8_0000_0000_4321))],
+ )
+ .unwrap();
+ assert_eq!(bool_values(&double_nan), vec![true, false]);
+ }
+
+ #[test]
+ fn test_decimal_array_membership_compares_by_value_across_scales() {
+ let decimal_arrow_type = ArrowDataType::Decimal128(10, 2);
+ let element = Arc::new(ArrowField::new("element",
decimal_arrow_type.clone(), true));
+ let values =
Decimal128Builder::new().with_data_type(decimal_arrow_type);
+ let mut decimals = ListBuilder::new(values).with_field(element);
+ decimals.values().append_value(100);
+ decimals.values().append_value(200);
+ decimals.append(true);
+ decimals.values().append_value(110);
+ decimals.append(true);
+ decimals.append(false);
+ let decimals: ArrayRef = Arc::new(decimals.finish());
+ let decimal_type = DataType::Array(ArrayType::new(DataType::Decimal(
+ DecimalType::new(10, 2).unwrap(),
+ )));
+
+ let contains = evaluate_exact_leaf_predicate(
+ &decimals,
+ &decimal_type,
+ PredicateOperator::ArrayContains,
+ &[Datum::Decimal {
+ unscaled: 10,
+ precision: 2,
+ scale: 1,
+ }],
+ )
+ .unwrap();
+ assert_eq!(bool_values(&contains), vec![true, false, false]);
+
+ let contains_all = evaluate_exact_leaf_predicate(
+ &decimals,
+ &decimal_type,
+ PredicateOperator::ArrayContainsAll,
+ &[
+ Datum::Decimal {
+ unscaled: 1,
+ precision: 1,
+ scale: 0,
+ },
+ Datum::Decimal {
+ unscaled: 2,
+ precision: 1,
+ scale: 0,
+ },
+ ],
+ )
+ .unwrap();
+ assert_eq!(bool_values(&contains_all), vec![true, false, false]);
+ }
+
+ #[test]
+ fn test_decimal_array_membership_handles_extreme_scale_differences() {
+ let decimal_arrow_type = ArrowDataType::Decimal128(38, 38);
+ let element = Arc::new(ArrowField::new("element",
decimal_arrow_type.clone(), true));
+ let values =
Decimal128Builder::new().with_data_type(decimal_arrow_type);
+ let mut decimals = ListBuilder::new(values).with_field(element);
+ decimals.values().append_value(10_i128.pow(38) - 1);
+ decimals.append(true);
+ let decimals: ArrayRef = Arc::new(decimals.finish());
+ let decimal_type = DataType::Array(ArrayType::new(DataType::Decimal(
+ DecimalType::new(38, 38).unwrap(),
+ )));
+ let two = Datum::Decimal {
+ unscaled: 2,
+ precision: 1,
+ scale: 0,
+ };
+
+ for op in [
+ PredicateOperator::ArrayContains,
+ PredicateOperator::ArraysOverlap,
+ PredicateOperator::ArrayContainsAll,
+ ] {
+ let mask = evaluate_exact_leaf_predicate(
+ &decimals,
+ &decimal_type,
+ op,
+ std::slice::from_ref(&two),
+ )
+ .expect("valid cross-scale decimal comparison must not overflow");
+ assert_eq!(bool_values(&mask), vec![false]);
+ }
+ }
+
fn int_values(batch: &RecordBatch) -> Vec<i32> {
batch
.column(0)
diff --git a/crates/paimon/src/btree/query.rs b/crates/paimon/src/btree/query.rs
index df4c2756..2573dc16 100644
--- a/crates/paimon/src/btree/query.rs
+++ b/crates/paimon/src/btree/query.rs
@@ -138,6 +138,12 @@ where
})
.await
}
+ PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll => Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ format!("BTree index does not support {op}"),
+ )),
}
}
}
diff --git a/crates/paimon/src/predicate_stats.rs
b/crates/paimon/src/predicate_stats.rs
index 57d01e76..d2b8308f 100644
--- a/crates/paimon/src/predicate_stats.rs
+++ b/crates/paimon/src/predicate_stats.rs
@@ -69,6 +69,15 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
PredicateOperator::NotIn => {
return true;
}
+ PredicateOperator::ArrayContains => {
+ return all_null != Some(true);
+ }
+ PredicateOperator::ArraysOverlap => {
+ return !literals.is_empty() && all_null != Some(true);
+ }
+ PredicateOperator::ArrayContainsAll => {
+ return all_null != Some(true);
+ }
PredicateOperator::EndsWith | PredicateOperator::Contains => {
// String min/max ordering carries no information about suffix /
// substring matches, so fail open.
@@ -203,7 +212,10 @@ pub(crate) fn data_leaf_may_match<T: StatsAccessor>(
| PredicateOperator::EndsWith
| PredicateOperator::Contains
| PredicateOperator::Between
- | PredicateOperator::NotBetween => true,
+ | PredicateOperator::NotBetween
+ | PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll => true,
}
}
@@ -301,7 +313,10 @@ pub(crate) fn data_leaf_must_match<T: StatsAccessor>(
| PredicateOperator::StartsWith
| PredicateOperator::EndsWith
| PredicateOperator::Contains
- | PredicateOperator::Like => false,
+ | PredicateOperator::Like
+ | PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll => false,
}
}
@@ -908,6 +923,25 @@ mod tests {
));
}
+ #[test]
+ fn array_contains_prunes_all_null_file_like_java() {
+ let dt =
DataType::Array(crate::spec::ArrayType::new(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::ArrayContains,
+ &[Datum::Int(1)],
+ &stats,
+ ));
+ }
+
#[test]
fn between_falls_open_when_stats_missing() {
let dt = DataType::Int(IntType::new());
diff --git a/crates/paimon/src/spec/binary_row.rs
b/crates/paimon/src/spec/binary_row.rs
index 236cf91e..4c1b816b 100644
--- a/crates/paimon/src/spec/binary_row.rs
+++ b/crates/paimon/src/spec/binary_row.rs
@@ -945,6 +945,13 @@ pub fn extract_datum_from_arrow(
.ok_or_else(|| type_mismatch_err("Date", col_idx))?;
Datum::Date(arr.value(row_idx))
}
+ DataType::Time(_) => {
+ let arr = col
+ .as_any()
+ .downcast_ref::<arrow_array::Time32MillisecondArray>()
+ .ok_or_else(|| type_mismatch_err("Time", col_idx))?;
+ Datum::Time(arr.value(row_idx))
+ }
DataType::Decimal(d) => {
let arr = col
.as_any()
@@ -1556,6 +1563,33 @@ mod tests {
use super::*;
use crate::variant::GenericVariant;
+ #[test]
+ fn test_extract_time_datum_from_arrow() {
+ let schema =
+
std::sync::Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
+ "time_col",
+
arrow_schema::DataType::Time32(arrow_schema::TimeUnit::Millisecond),
+ true,
+ )]));
+ let batch = arrow_array::RecordBatch::try_new(
+ schema,
+ vec![std::sync::Arc::new(
+ arrow_array::Time32MillisecondArray::from(vec![Some(12_345),
None]),
+ )],
+ )
+ .unwrap();
+ let data_type = DataType::Time(crate::spec::TimeType::new(3).unwrap());
+
+ assert_eq!(
+ extract_datum_from_arrow(&batch, 0, 0, &data_type).unwrap(),
+ Some(Datum::Time(12_345))
+ );
+ assert_eq!(
+ extract_datum_from_arrow(&batch, 1, 0, &data_type).unwrap(),
+ None
+ );
+ }
+
#[test]
fn test_empty_binary_row() {
let row = BinaryRow::new(0);
diff --git a/crates/paimon/src/spec/predicate.rs
b/crates/paimon/src/spec/predicate.rs
index 66946abd..c168e769 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -196,18 +196,59 @@ pub(crate) fn datum_cmp(lhs: &Datum, rhs: &Datum) ->
Option<Ordering> {
/// Compare two decimals by mathematical value.
///
-/// Normalizes both to the larger scale, then compares unscaled values.
-/// E.g. `(10, scale=1)` vs `(100, scale=2)` → both represent 1.0 → equal.
+/// Compares the sign, decimal exponent, then significant digits. Padding the
+/// shorter significand with trailing zeroes is equivalent to normalizing both
+/// values to the larger scale, without overflowing `i128`.
fn decimal_cmp(ua: i128, sa: u32, ub: i128, sb: u32) -> Option<Ordering> {
if sa == sb {
- return ua.partial_cmp(&ub);
+ return Some(ua.cmp(&ub));
}
- let (na, nb) = if sa < sb {
- (ua.checked_mul(pow10_i128(sb - sa))?, ub)
+ let normalized = if sa < sb {
+ 10_i128
+ .checked_pow(sb - sa)
+ .and_then(|factor| ua.checked_mul(factor))
+ .map(|scaled| scaled.cmp(&ub))
} else {
- (ua, ub.checked_mul(pow10_i128(sa - sb))?)
+ 10_i128
+ .checked_pow(sa - sb)
+ .and_then(|factor| ub.checked_mul(factor))
+ .map(|scaled| ua.cmp(&scaled))
};
- na.partial_cmp(&nb)
+ if normalized.is_some() {
+ return normalized;
+ }
+
+ let sign_a = ua.signum();
+ let sign_b = ub.signum();
+ if sign_a != sign_b {
+ return Some(sign_a.cmp(&sign_b));
+ }
+ if sign_a == 0 {
+ return Some(Ordering::Equal);
+ }
+
+ let digits_a = ua.unsigned_abs().to_string();
+ let digits_b = ub.unsigned_abs().to_string();
+ let exponent_a = digits_a.len() as i64 - i64::from(sa);
+ let exponent_b = digits_b.len() as i64 - i64::from(sb);
+ let mut magnitude = exponent_a.cmp(&exponent_b);
+ if magnitude == Ordering::Equal {
+ let a = digits_a.as_bytes();
+ let b = digits_b.as_bytes();
+ for index in 0..a.len().max(b.len()) {
+ let digit_a = a.get(index).copied().unwrap_or(b'0');
+ let digit_b = b.get(index).copied().unwrap_or(b'0');
+ magnitude = digit_a.cmp(&digit_b);
+ if magnitude != Ordering::Equal {
+ break;
+ }
+ }
+ }
+ Some(if sign_a < 0 {
+ magnitude.reverse()
+ } else {
+ magnitude
+ })
}
/// Match Java `CompareUtils.compare(byte[], byte[])`, which compares bytes as
@@ -216,19 +257,6 @@ fn java_bytes_cmp(a: &[u8], b: &[u8]) -> Ordering {
a.cmp(b)
}
-/// 10^exp as i128. Returns i128::MAX for exponents that would overflow.
-fn pow10_i128(exp: u32) -> i128 {
- const MAX_EXP: u32 = 38; // 10^38 fits in i128
- if exp > MAX_EXP {
- return i128::MAX;
- }
- let mut result: i128 = 1;
- for _ in 0..exp {
- result = result.saturating_mul(10);
- }
- result
-}
-
// PredicateOperator
// ---------------------------------------------------------------------------
@@ -248,6 +276,9 @@ pub enum PredicateOperator {
StartsWith,
EndsWith,
Contains,
+ ArrayContains,
+ ArraysOverlap,
+ ArrayContainsAll,
Like,
Between,
NotBetween,
@@ -269,6 +300,9 @@ impl fmt::Display for PredicateOperator {
Self::StartsWith => write!(f, "STARTS_WITH"),
Self::EndsWith => write!(f, "ENDS_WITH"),
Self::Contains => write!(f, "CONTAINS"),
+ Self::ArrayContains => write!(f, "ARRAY_CONTAINS"),
+ Self::ArraysOverlap => write!(f, "ARRAYS_OVERLAP"),
+ Self::ArrayContainsAll => write!(f, "ARRAY_CONTAINS_ALL"),
Self::Like => write!(f, "LIKE"),
Self::Between => write!(f, "BETWEEN"),
Self::NotBetween => write!(f, "NOT BETWEEN"),
@@ -560,7 +594,10 @@ impl fmt::Display for Predicate {
write!(f, "{column} {op}")?;
match op {
PredicateOperator::IsNull | PredicateOperator::IsNotNull
=> {}
- PredicateOperator::In | PredicateOperator::NotIn => {
+ PredicateOperator::In
+ | PredicateOperator::NotIn
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll => {
write!(f, " (")?;
for (i, lit) in literals.iter().enumerate() {
if i > 0 {
@@ -722,6 +759,17 @@ fn parse_rest_leaf(
.ok_or_else(|| rest_json_err(format!("unknown field `{field}`")))?;
let function = str_prop(value, "function")?;
+ let literal_data_type = match function {
+ "ARRAY_CONTAINS" | "ARRAYS_OVERLAP" | "ARRAY_CONTAINS_ALL" => match
data_type {
+ DataType::Array(array) => array.element_type(),
+ other => {
+ return Err(rest_json_err(format!(
+ "{function} requires an ARRAY field, got {other:?}"
+ )))
+ }
+ },
+ _ => data_type,
+ };
// Convert every literal up front (like Java `deserializeLiterals`) so a
// malformed extra literal fails the parse. JSON null -> `None` (SQL NULL).
let literals: Vec<Option<Datum>> = value
@@ -731,7 +779,7 @@ fn parse_rest_leaf(
.iter()
.map(|v| match v {
serde_json::Value::Null => Ok(None),
- v => Ok(Some(json_to_datum(v, data_type)?)),
+ v => Ok(Some(json_to_datum(v, literal_data_type)?)),
})
.collect::<Result<Vec<_>>>()?;
// Binary/ternary leaves test false for every row on a null literal
@@ -788,6 +836,22 @@ fn parse_rest_leaf(
"STARTS_WITH" => binary(one_literal()?, &|l|
builder.starts_with(field, l)),
"ENDS_WITH" => binary(one_literal()?, &|l| builder.ends_with(field,
l)),
"CONTAINS" => binary(one_literal()?, &|l| builder.contains(field, l)),
+ "ARRAY_CONTAINS" => binary(one_literal()?, &|l|
builder.array_contains(field, l)),
+ "ARRAYS_OVERLAP" => {
+ let datums =
literals.iter().flatten().cloned().collect::<Vec<_>>();
+ if datums.is_empty() {
+ Ok(Predicate::AlwaysFalse)
+ } else {
+ builder.arrays_overlap(field, datums)
+ }
+ }
+ "ARRAY_CONTAINS_ALL" => {
+ if literals.iter().any(Option::is_none) {
+ Ok(Predicate::AlwaysFalse)
+ } else {
+ builder.array_contains_all(field,
literals.iter().flatten().cloned().collect())
+ }
+ }
"LIKE" => match one_literal()? {
Some(l) => {
if let Datum::String(pattern) = &l {
@@ -1131,6 +1195,25 @@ impl PredicateBuilder {
self.string_leaf(field, PredicateOperator::Contains, pattern)
}
+ /// Build an element-membership predicate over an ARRAY column.
+ pub fn array_contains(&self, field: &str, element: Datum) ->
Result<Predicate> {
+ self.array_leaf(field, PredicateOperator::ArrayContains, vec![element])
+ }
+
+ /// Build an any-element membership predicate over an ARRAY column.
+ pub fn arrays_overlap(&self, field: &str, elements: Vec<Datum>) ->
Result<Predicate> {
+ self.array_leaf(field, PredicateOperator::ArraysOverlap, elements)
+ }
+
+ /// Build an all-elements membership predicate over an ARRAY column.
+ ///
+ /// An empty literal list is retained as a leaf: it matches non-null
arrays,
+ /// while the multivalue index must fall back because it cannot distinguish
+ /// null arrays from empty arrays.
+ pub fn array_contains_all(&self, field: &str, elements: Vec<Datum>) ->
Result<Predicate> {
+ self.array_leaf(field, PredicateOperator::ArrayContainsAll, elements)
+ }
+
/// `field LIKE '<pattern>'` with optional `escape` character (default
`\`).
/// Mirrors Java `LikeOptimization`: rewrites `prefix%` / `%suffix` /
/// `%mid%` / no-wildcard patterns into [`PredicateOperator::StartsWith`] /
@@ -1219,6 +1302,31 @@ impl PredicateBuilder {
self.leaf(field, op, vec![pattern])
}
+ fn array_leaf(
+ &self,
+ field: &str,
+ op: PredicateOperator,
+ literals: Vec<Datum>,
+ ) -> Result<Predicate> {
+ let (index, canonical, data_type) = self.resolve_field(field)?;
+ let DataType::Array(array_type) = &data_type else {
+ return Err(Error::ConfigInvalid {
+ message: format!("{op} requires an ARRAY field, got
{data_type:?}"),
+ });
+ };
+ Self::validate_literal_count(op, &literals)?;
+ for literal in &literals {
+ validate_datum_matches_type(literal, array_type.element_type())?;
+ }
+ Ok(Predicate::Leaf {
+ column: canonical,
+ index,
+ data_type,
+ op,
+ literals,
+ })
+ }
+
// -- internal --
/// Resolve field name to index + type, validate literals, and build a
leaf predicate.
@@ -1302,6 +1410,7 @@ impl PredicateBuilder {
message: format!("{op} expects at least 1 literal, got 0"),
});
}
+ PredicateOperator::ArraysOverlap |
PredicateOperator::ArrayContainsAll => return Ok(()),
PredicateOperator::Between | PredicateOperator::NotBetween => (2,
literals.len()),
_ => (1, literals.len()),
};
@@ -1562,6 +1671,11 @@ fn eval_leaf(op: PredicateOperator, datum:
Option<&Datum>, literals: &[Datum]) -
},
PredicateOperator::Between => eval_between(val, literals),
PredicateOperator::NotBetween => !eval_between(val, literals),
+ PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll => {
+ unreachable!("array predicates are evaluated against Arrow
list arrays")
+ }
// IsNull/IsNotNull are handled in the outer match above.
PredicateOperator::IsNull | PredicateOperator::IsNotNull =>
unreachable!(),
}
@@ -2328,6 +2442,33 @@ mod tests {
assert_eq!(a, b);
}
+ #[test]
+ fn test_decimal_extreme_scale_comparison_does_not_overflow() {
+ let two = Datum::Decimal {
+ unscaled: 2,
+ precision: 1,
+ scale: 0,
+ };
+ let below_one = Datum::Decimal {
+ unscaled: 10_i128.pow(38) - 1,
+ precision: 38,
+ scale: 38,
+ };
+ assert!(two > below_one);
+
+ let negative_two = Datum::Decimal {
+ unscaled: -2,
+ precision: 1,
+ scale: 0,
+ };
+ let above_negative_one = Datum::Decimal {
+ unscaled: -(10_i128.pow(38) - 1),
+ precision: 38,
+ scale: 38,
+ };
+ assert!(negative_two < above_negative_one);
+ }
+
// ======================== PartialOrd ========================
#[test]
@@ -3168,6 +3309,87 @@ mod tests {
assert!(Predicate::from_rest_json(json, &fields).is_ok());
}
+ #[test]
+ fn test_array_predicate_builder_uses_element_literals() {
+ let fields = vec![
+ DataField::new(
+ 0,
+ "items".to_string(),
+ DataType::Array(ArrayType::new(DataType::Int(IntType::new()))),
+ ),
+ DataField::new(1, "id".to_string(), DataType::Int(IntType::new())),
+ ];
+ let builder = PredicateBuilder::new(&fields);
+
+ let contains = builder.array_contains("items", Datum::Int(2)).unwrap();
+ assert!(matches!(
+ contains,
+ Predicate::Leaf {
+ op: PredicateOperator::ArrayContains,
+ literals,
+ ..
+ } if literals == vec![Datum::Int(2)]
+ ));
+ let overlap = builder
+ .arrays_overlap("items", vec![Datum::Int(1), Datum::Int(3)])
+ .unwrap();
+ assert!(matches!(
+ overlap,
+ Predicate::Leaf {
+ op: PredicateOperator::ArraysOverlap,
+ literals,
+ ..
+ } if literals == vec![Datum::Int(1), Datum::Int(3)]
+ ));
+ assert!(builder.array_contains_all("items", vec![]).is_ok());
+ assert!(builder.array_contains("id", Datum::Int(2)).is_err());
+ assert!(builder
+ .array_contains("items", Datum::String("2".to_string()))
+ .is_err());
+ }
+
+ #[test]
+ fn test_from_rest_json_parses_java_array_predicates_and_nulls() {
+ let fields = vec![DataField::new(
+ 0,
+ "items".to_string(),
+ DataType::Array(ArrayType::new(DataType::Int(IntType::new()))),
+ )];
+
+ for (function, literals, expected_op) in [
+ ("ARRAY_CONTAINS", "[2]", PredicateOperator::ArrayContains),
+ ("ARRAYS_OVERLAP", "[1,2]", PredicateOperator::ArraysOverlap),
+ (
+ "ARRAY_CONTAINS_ALL",
+ "[1,2]",
+ PredicateOperator::ArrayContainsAll,
+ ),
+ ] {
+ let parsed = Predicate::from_rest_json(
+ &rest_leaf_json_typed(function, "items", "ARRAY<INT>",
literals),
+ &fields,
+ )
+ .unwrap();
+ assert!(matches!(
+ parsed,
+ Predicate::Leaf { op, .. } if op == expected_op
+ ));
+ }
+
+ for (function, literals) in [
+ ("ARRAY_CONTAINS", "[null]"),
+ ("ARRAYS_OVERLAP", "[null]"),
+ ("ARRAY_CONTAINS_ALL", "[1,null]"),
+ ] {
+ let parsed = Predicate::from_rest_json(
+ &rest_leaf_json_typed(function, "items", "ARRAY<INT>",
literals),
+ &fields,
+ )
+ .unwrap();
+ assert!(matches!(parsed, Predicate::AlwaysFalse));
+ }
+ }
+
#[test]
fn test_from_rest_json_rejects_empty_compounds() {
let fields = test_fields();
diff --git a/crates/paimon/src/table/bitmap_global_index_reader.rs
b/crates/paimon/src/table/bitmap_global_index_reader.rs
index 151aac8e..ab867b78 100644
--- a/crates/paimon/src/table/bitmap_global_index_reader.rs
+++ b/crates/paimon/src/table/bitmap_global_index_reader.rs
@@ -274,6 +274,12 @@ impl BitmapGlobalIndexReader {
return self.is_not_null().await;
}
match op {
+ PredicateOperator::ArrayContains
+ | PredicateOperator::ArraysOverlap
+ | PredicateOperator::ArrayContainsAll => Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ format!("Scalar bitmap index does not support {op}"),
+ )),
PredicateOperator::Eq => {
let key = serialize_bitmap_datum(&literals[0], data_type);
self.equal(&key, data_type).await
@@ -654,7 +660,7 @@ async fn read_compressible_block(reader: &dyn FileRead,
block: BlockInfo) -> io:
BlockCompressionType::None => Ok(block_bytes.to_vec()),
BlockCompressionType::Zstd => {
let mut cursor = Cursor::new(block_bytes);
- let uncompressed_size = decode_var_int(&mut cursor)? as usize;
+ let uncompressed_size = decode_uncompressed_size(&mut cursor)?;
let compressed_start = cursor.position() as usize;
let compressed_data = &block_bytes[compressed_start..];
let mut decompressed = vec![0u8; uncompressed_size];
@@ -670,14 +676,80 @@ async fn read_compressible_block(reader: &dyn FileRead,
block: BlockInfo) -> io:
}
Ok(decompressed)
}
- _ => Err(io::Error::new(
- io::ErrorKind::Unsupported,
+ BlockCompressionType::Lz4 | BlockCompressionType::Lzo => {
+ let mut cursor = Cursor::new(block_bytes);
+ let uncompressed_size = decode_uncompressed_size(&mut cursor)?;
+ let compressed_start = cursor.position() as usize;
+ decompress_java_header_block(
+ &block_bytes[compressed_start..],
+ uncompressed_size,
+ compression_type,
+ )
+ }
+ }
+}
+
+fn decode_uncompressed_size(input: &mut impl Read) -> io::Result<usize> {
+ let size = decode_var_int(input)?;
+ usize::try_from(size).map_err(|_| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("Invalid bitmap block uncompressed size: {size}"),
+ )
+ })
+}
+
+/// Decode Java's LZ4/LZO block envelope:
+/// `[compressed_len: i32 LE][original_len: i32 LE][raw codec payload]`.
+fn decompress_java_header_block(
+ block: &[u8],
+ expected_size: usize,
+ compression_type: BlockCompressionType,
+) -> io::Result<Vec<u8>> {
+ if block.len() < 8 {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "Compressed bitmap block is shorter than the Java codec header",
+ ));
+ }
+ let compressed_len = i32::from_le_bytes(block[..4].try_into().unwrap());
+ let original_len = i32::from_le_bytes(block[4..8].try_into().unwrap());
+ let (compressed_len, original_len) = match (
+ usize::try_from(compressed_len),
+ usize::try_from(original_len),
+ ) {
+ (Ok(compressed_len), Ok(original_len))
+ if original_len == expected_size
+ && compressed_len <= block.len().saturating_sub(8)
+ && ((original_len == 0) == (compressed_len == 0)) =>
+ {
+ (compressed_len, original_len)
+ }
+ _ => {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "Invalid Java LZ4/LZO bitmap block lengths",
+ ))
+ }
+ };
+ let payload = &block[8..8 + compressed_len];
+ let decompressed = match compression_type {
+ BlockCompressionType::Lz4 => lz4_flex::block::decompress(payload,
original_len)
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData,
error))?,
+ BlockCompressionType::Lzo => lzokay_native::decompress_all(payload,
Some(original_len))
+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData,
error))?,
+ _ => unreachable!("only Java header codecs use this decoder"),
+ };
+ if decompressed.len() != original_len {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
format!(
- "Bitmap global index compression type {:?} is not supported",
- compression_type
+ "Bitmap block decompressed size mismatch: expected
{original_len}, got {}",
+ decompressed.len()
),
- )),
+ ));
}
+ Ok(decompressed)
}
fn compute_crc32(data: &[u8], compression_type: BlockCompressionType) -> u32 {
@@ -1047,6 +1119,68 @@ mod tests {
(reader, entries)
}
+ fn java_codec_envelope(compressed: &[u8], original_len: usize) -> Vec<u8> {
+ let mut encoded = Vec::with_capacity(8 + compressed.len());
+
encoded.extend_from_slice(&i32::try_from(compressed.len()).unwrap().to_le_bytes());
+
encoded.extend_from_slice(&i32::try_from(original_len).unwrap().to_le_bytes());
+ encoded.extend_from_slice(compressed);
+ encoded
+ }
+
+ #[tokio::test]
+ async fn test_decode_java_lz4_and_lzo_bitmap_blocks() {
+ use base64::Engine;
+
+ let original = b"java-compatible multivalue bitmap block".repeat(16);
+
+ let lz4 = lz4_flex::block::compress(&original);
+ assert_eq!(
+ decompress_java_header_block(
+ &java_codec_envelope(&lz4, original.len()),
+ original.len(),
+ BlockCompressionType::Lz4,
+ )
+ .unwrap(),
+ original
+ );
+
+ // Produced by Airlift 2.0.3 `LzoCompressor`, the implementation used
+ // by Java Paimon. Keep this as a cross-language golden rather than a
+ // Rust self-roundtrip.
+ let lzo = base64::engine::general_purpose::STANDARD
+
.decode("OGphdmEtY29tcGF0aWJsZSBtdWx0aXZhbHVlIGJpdG1hcCBibG9jayAAACWYAAJibG9jaxEAAA==")
+ .unwrap();
+ assert_eq!(
+ decompress_java_header_block(
+ &java_codec_envelope(&lzo, original.len()),
+ original.len(),
+ BlockCompressionType::Lzo,
+ )
+ .unwrap(),
+ original
+ );
+
+ let mut block = Vec::new();
+ encode_var_int(&mut block,
i32::try_from(original.len()).unwrap()).unwrap();
+ block.extend_from_slice(&java_codec_envelope(&lzo, original.len()));
+ let block_len = block.len();
+ let crc = compute_crc32(&block, BlockCompressionType::Lzo);
+ block.push(BlockCompressionType::Lzo as u8);
+ block.extend_from_slice(&crc.to_le_bytes());
+ assert_eq!(
+ read_compressible_block(
+ &BytesFileRead(Bytes::from(block)),
+ BlockInfo {
+ offset: 0,
+ length: block_len,
+ },
+ )
+ .await
+ .unwrap(),
+ original
+ );
+ }
+
#[test]
fn test_bitmap_floating_residual_sensitive_operator_set() {
for op in [