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 209e4e1 [python] Add ReadBuilder.with_filter (predicate pushdown)
(#419)
209e4e1 is described below
commit 209e4e113cc1dffbdb5024b794e718cde7f394ff
Author: Junrui Lee <[email protected]>
AuthorDate: Mon Jun 29 19:21:09 2026 +0800
[python] Add ReadBuilder.with_filter (predicate pushdown) (#419)
---
bindings/python/Cargo.toml | 5 +
.../python/python/pypaimon_rust/datafusion.pyi | 1 +
bindings/python/src/lib.rs | 1 +
bindings/python/src/predicate.rs | 670 +++++++++++++++++++++
bindings/python/src/read.rs | 24 +-
bindings/python/tests/test_read.py | 195 ++++++
6 files changed, 895 insertions(+), 1 deletion(-)
diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml
index 12f0cbd..79d7483 100644
--- a/bindings/python/Cargo.toml
+++ b/bindings/python/Cargo.toml
@@ -35,3 +35,8 @@ paimon-datafusion = { path =
"../../crates/integrations/datafusion", features =
pyo3 = { version = "0.28", features = ["abi3-py310"] }
serde_json = "1.0"
tokio = { workspace = true }
+
+[dev-dependencies]
+# Enables `Python::attach` to auto-initialize an interpreter for Rust unit
tests.
+# Only applied to the test build; the maturin/extension-module build does not
pull dev-dependencies.
+pyo3 = { version = "0.28", features = ["abi3-py310", "auto-initialize"] }
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index cd12609..772f31f 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -50,6 +50,7 @@ class TableScan:
class ReadBuilder:
def with_projection(self, columns: List[str]) -> "ReadBuilder": ...
def with_limit(self, limit: int) -> "ReadBuilder": ...
+ def with_filter(self, predicate: dict) -> "ReadBuilder": ...
def new_scan(self) -> TableScan: ...
class Table:
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index c4db29a..0415412 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -20,6 +20,7 @@ use pyo3::prelude::*;
mod blob;
mod context;
mod error;
+mod predicate;
mod read;
mod schema;
mod table;
diff --git a/bindings/python/src/predicate.rs b/bindings/python/src/predicate.rs
new file mode 100644
index 0000000..cb2c852
--- /dev/null
+++ b/bindings/python/src/predicate.rs
@@ -0,0 +1,670 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use paimon::spec::{DataField, DataType, Datum, Predicate, PredicateBuilder};
+use pyo3::exceptions::{PyNotImplementedError, PyValueError};
+use pyo3::prelude::*;
+use pyo3::types::{PyBool, PyDict, PyList, PyString};
+
+/// Convert a single Python literal into a typed [`Datum`] driven by the target
+/// [`DataType`].
+///
+/// Conversion is strictly DataType-driven (never inferred from the Python
type):
+/// the field's declared type decides how the literal is interpreted and
validated.
+///
+/// Rules:
+/// - `Boolean` accepts only a Python `bool`.
+/// - Integer types (`TinyInt`/`SmallInt`/`Int`/`BigInt`) reject Python `bool`
+/// (which is an `int` subclass) and enforce the target range.
+/// - `Float`/`Double` accept Python `int` or `float` but reject `bool`.
+/// - `Char`/`VarChar` accept only a Python `str` (no implicit
stringification).
+/// - All other types (Date/Time/Timestamp/Decimal/Bytes/complex) are not
+/// supported yet and raise `NotImplementedError`.
+///
+/// Errors:
+/// - `ValueError` for type mismatches and out-of-range integers.
+/// - `NotImplementedError` for unsupported field types (message names the
type).
+pub(crate) fn py_to_datum(value: &Bound<'_, PyAny>, data_type: &DataType) ->
PyResult<Datum> {
+ match data_type {
+ DataType::Boolean(_) => {
+ let b = value
+ .cast::<PyBool>()
+ .map_err(|_| PyValueError::new_err("expected a bool literal
for Boolean field"))?;
+ Ok(Datum::Bool(b.is_true()))
+ }
+ DataType::TinyInt(_) => int_datum(value, i8::MIN as i64, i8::MAX as
i64, |v| {
+ Datum::TinyInt(v as i8)
+ }),
+ DataType::SmallInt(_) => int_datum(value, i16::MIN as i64, i16::MAX as
i64, |v| {
+ Datum::SmallInt(v as i16)
+ }),
+ DataType::Int(_) => int_datum(value, i32::MIN as i64, i32::MAX as i64,
|v| {
+ Datum::Int(v as i32)
+ }),
+ DataType::BigInt(_) => int_datum(value, i64::MIN, i64::MAX,
Datum::Long),
+ DataType::Float(_) => Ok(Datum::Float(float_val(value)? as f32)),
+ DataType::Double(_) => Ok(Datum::Double(float_val(value)?)),
+ DataType::Char(_) | DataType::VarChar(_) => {
+ let s = value
+ .cast::<PyString>()
+ .map_err(|_| PyValueError::new_err("expected a str literal for
String field"))?;
+ Ok(Datum::String(s.to_str()?.to_string()))
+ }
+ other => Err(PyNotImplementedError::new_err(format!(
+ "literal conversion for type {other:?} is not supported yet"
+ ))),
+ }
+}
+
+/// Extract an integer literal, rejecting Python `bool` (an `int` subclass) and
+/// enforcing the inclusive `[lo, hi]` range before building the `Datum`.
+fn int_datum(
+ value: &Bound<'_, PyAny>,
+ lo: i64,
+ hi: i64,
+ make: impl Fn(i64) -> Datum,
+) -> PyResult<Datum> {
+ if value.is_instance_of::<PyBool>() {
+ return Err(PyValueError::new_err("bool is not a valid integer
literal"));
+ }
+ let v: i64 = value
+ .extract()
+ .map_err(|_| PyValueError::new_err("expected an int literal"))?;
+ if v < lo || v > hi {
+ return Err(PyValueError::new_err(format!(
+ "integer literal {v} out of range [{lo}, {hi}]"
+ )));
+ }
+ Ok(make(v))
+}
+
+/// Extract a floating-point literal from a Python `int` or `float`, rejecting
+/// `bool`.
+fn float_val(value: &Bound<'_, PyAny>) -> PyResult<f64> {
+ if value.is_instance_of::<PyBool>() {
+ return Err(PyValueError::new_err("bool is not a valid float literal"));
+ }
+ value
+ .extract::<f64>()
+ .map_err(|_| PyValueError::new_err("expected a numeric literal"))
+}
+
+/// Operators recognized by the lightweight dict format but not translatable
to a
+/// Rust [`Predicate`] for pushdown.
+const METHOD_NOT_SUPPORTED: &[&str] = &["like", "startsWith", "endsWith",
"contains", "not"];
+
+/// Recursively convert a lightweight dict predicate into a Rust [`Predicate`].
+///
+/// The dict shape mirrors the Python predicate tree:
+/// - Leaf: `{"method": <op>, "field": <name>, "literals": [..]}`
+/// - Compound: `{"method": "and"|"or", "children": [<dict>, ..]}`
+///
+/// Field types are resolved authoritatively from `fields` (the table schema);
any
+/// `index`/`data_type` present in the dict is ignored. Literal conversion is
+/// delegated to [`py_to_datum`], driven by the resolved [`DataType`].
+///
+/// There is no partial pushdown: in `and`/`or`, every child is converted and
any
+/// failure propagates, failing the whole predicate.
+///
+/// Errors:
+/// - `ValueError` for unknown fields, missing keys, wrong literal counts,
`None`
+/// literals, empty/missing `children`, non-dict children, or non-list
+/// `literals`/`children`.
+/// - `NotImplementedError` for unsupported operators or unsupported literal
types.
+pub(crate) fn dict_to_predicate(
+ node: &Bound<'_, PyDict>,
+ fields: &[DataField],
+) -> PyResult<Predicate> {
+ let method: String = node
+ .get_item("method")?
+ .ok_or_else(|| PyValueError::new_err("predicate dict missing
'method'"))?
+ .extract()?;
+
+ match method.as_str() {
+ "and" | "or" => {
+ let children = node
+ .get_item("children")?
+ .ok_or_else(|| PyValueError::new_err(format!("'{method}'
requires 'children'")))?;
+ let list = children
+ .cast::<PyList>()
+ .map_err(|_| PyValueError::new_err("'children' must be a
list"))?;
+ if list.is_empty() {
+ return Err(PyValueError::new_err(format!(
+ "'{method}' requires non-empty 'children'"
+ )));
+ }
+ let mut preds = Vec::with_capacity(list.len());
+ for child in list.iter() {
+ let child_dict = child
+ .cast::<PyDict>()
+ .map_err(|_| PyValueError::new_err("each child must be a
dict"))?;
+ // Unsupported child propagates → no partial pushdown.
+ preds.push(dict_to_predicate(child_dict, fields)?);
+ }
+ Ok(if method == "and" {
+ Predicate::and(preds)
+ } else {
+ Predicate::or(preds)
+ })
+ }
+ m if METHOD_NOT_SUPPORTED.contains(&m) =>
Err(PyNotImplementedError::new_err(format!(
+ "predicate operator '{m}' is not supported for Rust pushdown"
+ ))),
+ _ => leaf_to_predicate(&method, node, fields),
+ }
+}
+
+/// Convert a single leaf dict (already known not to be `and`/`or`) into a
+/// [`Predicate`], resolving the field type from the schema.
+fn leaf_to_predicate(
+ method: &str,
+ node: &Bound<'_, PyDict>,
+ fields: &[DataField],
+) -> PyResult<Predicate> {
+ let field: String = node
+ .get_item("field")?
+ .ok_or_else(|| PyValueError::new_err(format!("'{method}' leaf requires
'field'")))?
+ .extract()?;
+
+ // Resolve field DataType from schema (authoritative) for literal
conversion.
+ let data_type = fields
+ .iter()
+ .find(|f| f.name() == field)
+ .map(|f| f.data_type().clone())
+ .ok_or_else(|| PyValueError::new_err(format!("Column '{field}' not
found in schema")))?;
+
+ let literals_obj = node.get_item("literals")?;
+ let pb = PredicateBuilder::new(fields);
+
+ // Convert literals (DataType-driven), wrapping NotImplemented type
messages
+ // with field context.
+ let to_datums = |obj: Option<Bound<'_, PyAny>>| -> PyResult<Vec<Datum>> {
+ let mut out = Vec::new();
+ if let Some(obj) = obj {
+ let list = obj
+ .cast::<PyList>()
+ .map_err(|_| PyValueError::new_err("'literals' must be a
list"))?;
+ for item in list.iter() {
+ if item.is_none() {
+ return Err(PyValueError::new_err(
+ "None is not a valid comparison literal; use
isNull/isNotNull",
+ ));
+ }
+ out.push(
+ py_to_datum(&item, &data_type)
+ .map_err(|e| with_field_context(e, &field,
&data_type))?,
+ );
+ }
+ }
+ Ok(out)
+ };
+
+ let result = match method {
+ "equal" => pb.equal(&field, one(to_datums(literals_obj)?)?),
+ "notEqual" => pb.not_equal(&field, one(to_datums(literals_obj)?)?),
+ "lessThan" => pb.less_than(&field, one(to_datums(literals_obj)?)?),
+ "lessOrEqual" => pb.less_or_equal(&field,
one(to_datums(literals_obj)?)?),
+ "greaterThan" => pb.greater_than(&field,
one(to_datums(literals_obj)?)?),
+ "greaterOrEqual" => pb.greater_or_equal(&field,
one(to_datums(literals_obj)?)?),
+ "isNull" => {
+ ensure_no_literals(method, literals_obj)?;
+ pb.is_null(&field)
+ }
+ "isNotNull" => {
+ ensure_no_literals(method, literals_obj)?;
+ pb.is_not_null(&field)
+ }
+ "in" => {
+ let ds = to_datums(literals_obj)?;
+ if ds.is_empty() {
+ return Err(PyValueError::new_err("'in' requires at least 1
literal"));
+ }
+ pb.is_in(&field, ds)
+ }
+ "notIn" => {
+ let ds = to_datums(literals_obj)?;
+ if ds.is_empty() {
+ return Err(PyValueError::new_err("'notIn' requires at least 1
literal"));
+ }
+ pb.is_not_in(&field, ds)
+ }
+ other => {
+ return Err(PyNotImplementedError::new_err(format!(
+ "unknown or unsupported predicate operator '{other}'"
+ )));
+ }
+ };
+ result.map_err(|e| PyValueError::new_err(e.to_string()))
+}
+
+/// Extract exactly one literal for comparison/equality operators.
+fn one(mut ds: Vec<Datum>) -> PyResult<Datum> {
+ if ds.len() != 1 {
+ return Err(PyValueError::new_err(format!(
+ "expected exactly 1 literal, got {}",
+ ds.len()
+ )));
+ }
+ Ok(ds.pop().unwrap())
+}
+
+/// Validate that a null-check operator (`isNull`/`isNotNull`) carries exactly 0
+/// literals. A missing `literals` key, or one present as an empty list `[]`,
is
+/// accepted; a non-empty list raises `ValueError` (count mismatch); a present
+/// non-list value raises `ValueError` (mirroring how comparison ops reject a
+/// non-list `literals`).
+fn ensure_no_literals(method: &str, obj: Option<Bound<'_, PyAny>>) ->
PyResult<()> {
+ if let Some(obj) = obj {
+ let list = obj
+ .cast::<PyList>()
+ .map_err(|_| PyValueError::new_err("'literals' must be a list"))?;
+ if !list.is_empty() {
+ return Err(PyValueError::new_err(format!(
+ "{method} expects 0 literals, got {}",
+ list.len()
+ )));
+ }
+ }
+ Ok(())
+}
+
+/// Re-wrap an unsupported-literal-type `NotImplementedError` from
[`py_to_datum`]
+/// with field-name context; pass other errors through unchanged.
+fn with_field_context(err: PyErr, field: &str, data_type: &DataType) -> PyErr {
+ Python::attach(|py| {
+ if err.is_instance_of::<PyNotImplementedError>(py) {
+ PyNotImplementedError::new_err(format!(
+ "literal conversion for field '{field}' of type {data_type:?}
is not supported yet"
+ ))
+ } else {
+ err
+ }
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use paimon::spec::{
+ DataField, DataType, Datum, IntType, Predicate, PredicateOperator,
VarCharType,
+ };
+ use pyo3::IntoPyObject;
+ use pyo3::Python;
+
+ fn test_fields() -> Vec<DataField> {
+ vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(
+ 1,
+ "name".to_string(),
+ DataType::VarChar(VarCharType::default()),
+ ),
+ ]
+ }
+
+ /// Build a leaf predicate dict: {"method": .., "field": .., "literals":
[..]}.
+ fn leaf_dict<'py>(
+ py: Python<'py>,
+ method: &str,
+ field: &str,
+ literals: &[i64],
+ ) -> Bound<'py, PyDict> {
+ let d = PyDict::new(py);
+ d.set_item("method", method).unwrap();
+ d.set_item("field", field).unwrap();
+ let lits = PyList::empty(py);
+ for v in literals {
+ lits.append(*v).unwrap();
+ }
+ d.set_item("literals", lits).unwrap();
+ d
+ }
+
+ #[test]
+ fn equal_leaf_converts() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ let dict = leaf_dict(py, "equal", "id", &[1]);
+ let pred = dict_to_predicate(&dict, &fields).unwrap();
+ match &pred {
+ Predicate::Leaf {
+ column,
+ op,
+ literals,
+ ..
+ } => {
+ assert_eq!(column, "id");
+ assert_eq!(*op, PredicateOperator::Eq);
+ assert_eq!(literals, &[Datum::Int(1)]);
+ }
+ other => panic!("expected Leaf, got {other:?}"),
+ }
+ });
+ }
+
+ #[test]
+ fn unsupported_operator_like_raises_not_implemented() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ let dict = leaf_dict(py, "like", "name", &[]);
+ let err = dict_to_predicate(&dict, &fields).unwrap_err();
+ assert!(err.is_instance_of::<PyNotImplementedError>(py));
+ });
+ }
+
+ #[test]
+ fn unsupported_operator_not_raises_not_implemented() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ let dict = leaf_dict(py, "not", "id", &[1]);
+ let err = dict_to_predicate(&dict, &fields).unwrap_err();
+ assert!(err.is_instance_of::<PyNotImplementedError>(py));
+ });
+ }
+
+ #[test]
+ fn unsupported_operator_not_without_field_raises_not_implemented() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ // 'not' with no 'field', only empty 'children': operator support
is
+ // decided before any shape validation.
+ let dict = PyDict::new(py);
+ dict.set_item("method", "not").unwrap();
+ dict.set_item("children", PyList::empty(py)).unwrap();
+ let err = dict_to_predicate(&dict, &fields).unwrap_err();
+ assert!(err.is_instance_of::<PyNotImplementedError>(py));
+ });
+ }
+
+ #[test]
+ fn unsupported_operator_like_with_unknown_field_raises_not_implemented() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ // 'like' with an unknown field: unsupported operator precedes
field
+ // resolution, so NotImplementedError (not the unknown-field
ValueError).
+ let dict = leaf_dict(py, "like", "nope", &[]);
+ let err = dict_to_predicate(&dict, &fields).unwrap_err();
+ assert!(err.is_instance_of::<PyNotImplementedError>(py));
+ });
+ }
+
+ #[test]
+ fn unknown_field_raises_value_error() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ let dict = leaf_dict(py, "equal", "nope", &[1]);
+ let err = dict_to_predicate(&dict, &fields).unwrap_err();
+ assert!(err.is_instance_of::<PyValueError>(py));
+ });
+ }
+
+ #[test]
+ fn empty_children_raises_value_error() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ let dict = PyDict::new(py);
+ dict.set_item("method", "and").unwrap();
+ dict.set_item("children", PyList::empty(py)).unwrap();
+ let err = dict_to_predicate(&dict, &fields).unwrap_err();
+ assert!(err.is_instance_of::<PyValueError>(py));
+ });
+ }
+
+ #[test]
+ fn compound_with_unsupported_child_fails() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ let ok = leaf_dict(py, "equal", "id", &[1]);
+ let bad = leaf_dict(py, "like", "name", &[]);
+ let children = PyList::empty(py);
+ children.append(ok).unwrap();
+ children.append(bad).unwrap();
+ let dict = PyDict::new(py);
+ dict.set_item("method", "and").unwrap();
+ dict.set_item("children", children).unwrap();
+ let err = dict_to_predicate(&dict, &fields).unwrap_err();
+ // No partial pushdown: the unsupported child propagates.
+ assert!(err.is_instance_of::<PyNotImplementedError>(py));
+ });
+ }
+
+ #[test]
+ fn and_compound_converts() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ let c1 = leaf_dict(py, "equal", "id", &[1]);
+ let c2 = leaf_dict(py, "greaterThan", "id", &[0]);
+ let children = PyList::empty(py);
+ children.append(c1).unwrap();
+ children.append(c2).unwrap();
+ let dict = PyDict::new(py);
+ dict.set_item("method", "and").unwrap();
+ dict.set_item("children", children).unwrap();
+ let pred = dict_to_predicate(&dict, &fields).unwrap();
+ match &pred {
+ Predicate::And(ch) => assert_eq!(ch.len(), 2),
+ other => panic!("expected And, got {other:?}"),
+ }
+ });
+ }
+
+ #[test]
+ fn or_compound_converts() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ let c1 = leaf_dict(py, "equal", "id", &[1]);
+ let c2 = leaf_dict(py, "equal", "id", &[2]);
+ let children = PyList::empty(py);
+ children.append(c1).unwrap();
+ children.append(c2).unwrap();
+ let dict = PyDict::new(py);
+ dict.set_item("method", "or").unwrap();
+ dict.set_item("children", children).unwrap();
+ let pred = dict_to_predicate(&dict, &fields).unwrap();
+ match &pred {
+ Predicate::Or(ch) => assert_eq!(ch.len(), 2),
+ other => panic!("expected Or, got {other:?}"),
+ }
+ });
+ }
+
+ #[test]
+ fn none_literal_raises_value_error() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ let d = PyDict::new(py);
+ d.set_item("method", "equal").unwrap();
+ d.set_item("field", "id").unwrap();
+ let lits = PyList::empty(py);
+ lits.append(py.None()).unwrap();
+ d.set_item("literals", lits).unwrap();
+ let err = dict_to_predicate(&d, &fields).unwrap_err();
+ assert!(err.is_instance_of::<PyValueError>(py));
+ });
+ }
+
+ #[test]
+ fn wrong_literal_count_raises_value_error() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ let dict = leaf_dict(py, "equal", "id", &[1, 2]);
+ let err = dict_to_predicate(&dict, &fields).unwrap_err();
+ assert!(err.is_instance_of::<PyValueError>(py));
+ });
+ }
+
+ #[test]
+ fn null_check_with_literals_raises_value_error() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ for method in ["isNull", "isNotNull"] {
+ let dict = leaf_dict(py, method, "name", &[1]);
+ let err = dict_to_predicate(&dict, &fields).unwrap_err();
+ assert!(err.is_instance_of::<PyValueError>(py));
+ }
+ });
+ }
+
+ #[test]
+ fn null_check_accepts_empty_and_missing_literals() {
+ Python::attach(|py| {
+ let fields = test_fields();
+ // Empty literals list is accepted.
+ let with_empty = leaf_dict(py, "isNull", "name", &[]);
+ assert!(dict_to_predicate(&with_empty, &fields).is_ok());
+ // Missing literals key is accepted.
+ let no_lits = PyDict::new(py);
+ no_lits.set_item("method", "isNotNull").unwrap();
+ no_lits.set_item("field", "name").unwrap();
+ assert!(dict_to_predicate(&no_lits, &fields).is_ok());
+ });
+ }
+
+ #[test]
+ fn int_field_accepts_in_range_int() {
+ Python::attach(|py| {
+ let v = 42i64.into_pyobject(py).unwrap();
+ let d = py_to_datum(&v,
&DataType::Int(Default::default())).unwrap();
+ assert_eq!(d, Datum::Int(42));
+ });
+ }
+
+ #[test]
+ fn int_field_rejects_out_of_range() {
+ Python::attach(|py| {
+ let v = 9_999_999_999i64.into_pyobject(py).unwrap();
+ assert!(py_to_datum(&v,
&DataType::Int(Default::default())).is_err());
+ });
+ }
+
+ #[test]
+ fn int_field_rejects_bool() {
+ Python::attach(|py| {
+ let v = true.into_pyobject(py).unwrap();
+ assert!(py_to_datum(v.as_any(),
&DataType::Int(Default::default())).is_err());
+ });
+ }
+
+ #[test]
+ fn boolean_field_accepts_bool() {
+ Python::attach(|py| {
+ let v = true.into_pyobject(py).unwrap();
+ let d = py_to_datum(v.as_any(),
&DataType::Boolean(Default::default())).unwrap();
+ assert_eq!(d, Datum::Bool(true));
+ });
+ }
+
+ #[test]
+ fn boolean_field_rejects_non_bool() {
+ Python::attach(|py| {
+ let v = 1i64.into_pyobject(py).unwrap();
+ assert!(py_to_datum(&v,
&DataType::Boolean(Default::default())).is_err());
+ });
+ }
+
+ #[test]
+ fn tinyint_range_enforced() {
+ Python::attach(|py| {
+ let ok = 127i64.into_pyobject(py).unwrap();
+ assert_eq!(
+ py_to_datum(&ok,
&DataType::TinyInt(Default::default())).unwrap(),
+ Datum::TinyInt(127)
+ );
+ let bad = 128i64.into_pyobject(py).unwrap();
+ assert!(py_to_datum(&bad,
&DataType::TinyInt(Default::default())).is_err());
+ });
+ }
+
+ #[test]
+ fn smallint_range_enforced() {
+ Python::attach(|py| {
+ let ok = (-32768i64).into_pyobject(py).unwrap();
+ assert_eq!(
+ py_to_datum(&ok,
&DataType::SmallInt(Default::default())).unwrap(),
+ Datum::SmallInt(-32768)
+ );
+ let bad = 32768i64.into_pyobject(py).unwrap();
+ assert!(py_to_datum(&bad,
&DataType::SmallInt(Default::default())).is_err());
+ });
+ }
+
+ #[test]
+ fn bigint_accepts_long() {
+ Python::attach(|py| {
+ let v = 9_999_999_999i64.into_pyobject(py).unwrap();
+ assert_eq!(
+ py_to_datum(&v,
&DataType::BigInt(Default::default())).unwrap(),
+ Datum::Long(9_999_999_999)
+ );
+ });
+ }
+
+ #[test]
+ fn float_accepts_int_and_float_rejects_bool() {
+ Python::attach(|py| {
+ let from_int = 3i64.into_pyobject(py).unwrap();
+ assert_eq!(
+ py_to_datum(&from_int,
&DataType::Float(Default::default())).unwrap(),
+ Datum::Float(3.0)
+ );
+ let from_float = 2.5f64.into_pyobject(py).unwrap();
+ assert_eq!(
+ py_to_datum(&from_float,
&DataType::Double(Default::default())).unwrap(),
+ Datum::Double(2.5)
+ );
+ let b = true.into_pyobject(py).unwrap();
+ assert!(py_to_datum(b.as_any(),
&DataType::Double(Default::default())).is_err());
+ });
+ }
+
+ #[test]
+ fn string_field_accepts_str() {
+ Python::attach(|py| {
+ let v = "hello".into_pyobject(py).unwrap();
+ assert_eq!(
+ py_to_datum(v.as_any(),
&DataType::VarChar(Default::default())).unwrap(),
+ Datum::String("hello".to_string())
+ );
+ });
+ }
+
+ #[test]
+ fn string_field_rejects_non_str() {
+ Python::attach(|py| {
+ let v = 5i64.into_pyobject(py).unwrap();
+ assert!(py_to_datum(&v,
&DataType::VarChar(Default::default())).is_err());
+ });
+ }
+
+ #[test]
+ fn timestamp_field_is_not_implemented() {
+ Python::attach(|py| {
+ let v = 0i64.into_pyobject(py).unwrap();
+ let err = py_to_datum(&v,
&DataType::Timestamp(Default::default())).unwrap_err();
+
assert!(err.is_instance_of::<pyo3::exceptions::PyNotImplementedError>(py));
+ });
+ }
+
+ #[test]
+ fn value_errors_use_pyvalueerror() {
+ Python::attach(|py| {
+ let v = 9_999_999_999i64.into_pyobject(py).unwrap();
+ let err = py_to_datum(&v,
&DataType::Int(Default::default())).unwrap_err();
+ assert!(err.is_instance_of::<pyo3::exceptions::PyValueError>(py));
+ });
+ }
+}
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 4e650d1..3a059b1 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -17,19 +17,22 @@
use std::sync::Arc;
+use paimon::spec::Predicate;
use paimon::table::{DataSplit, Table};
use paimon_datafusion::runtime::runtime;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
-use pyo3::types::PyBytes;
+use pyo3::types::{PyBytes, PyDict};
use crate::error::to_py_err;
+use crate::predicate::dict_to_predicate;
#[pyclass(name = "ReadBuilder", module = "pypaimon_rust.datafusion")]
pub struct PyReadBuilder {
table: Arc<Table>,
projection: Option<Vec<String>>,
limit: Option<usize>,
+ filter: Option<Predicate>,
}
impl PyReadBuilder {
@@ -38,6 +41,7 @@ impl PyReadBuilder {
table,
projection: None,
limit: None,
+ filter: None,
}
}
}
@@ -54,11 +58,25 @@ impl PyReadBuilder {
slf
}
+ /// Convert a lightweight dict predicate into a Rust [`Predicate`] and
store
+ /// it for pushdown. Conversion happens immediately, so conversion errors
+ /// (unknown field, type mismatch, unsupported operator/type) surface at
call
+ /// time. Repeated calls overwrite the previously stored filter.
+ fn with_filter<'py>(
+ mut slf: PyRefMut<'py, Self>,
+ predicate: &Bound<'_, PyDict>,
+ ) -> PyResult<PyRefMut<'py, Self>> {
+ let filter = dict_to_predicate(predicate,
slf.table.schema().fields())?;
+ slf.filter = Some(filter);
+ Ok(slf)
+ }
+
fn new_scan(&self) -> PyTableScan {
PyTableScan {
table: Arc::clone(&self.table),
projection: self.projection.clone(),
limit: self.limit,
+ filter: self.filter.clone(),
}
}
}
@@ -68,6 +86,7 @@ pub struct PyTableScan {
table: Arc<Table>,
projection: Option<Vec<String>>,
limit: Option<usize>,
+ filter: Option<Predicate>,
}
#[pymethods]
@@ -84,6 +103,9 @@ impl PyTableScan {
if let Some(limit) = self.limit {
builder.with_limit(limit);
}
+ if let Some(filter) = &self.filter {
+ builder.with_filter(filter.clone());
+ }
let plan = builder.new_scan().plan().await.map_err(to_py_err)?;
Ok::<_, PyErr>(plan.splits().to_vec())
})
diff --git a/bindings/python/tests/test_read.py
b/bindings/python/tests/test_read.py
index 7b3442f..57f814e 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -18,6 +18,8 @@
import pickle
import tempfile
+import pytest
+
from pypaimon_rust.datafusion import PaimonCatalog, SQLContext
@@ -85,3 +87,196 @@ def test_split_pickle_roundtrip():
split = splits[0]
restored = pickle.loads(pickle.dumps(split))
assert restored.row_count() == split.row_count()
+
+
+def _make_partitioned_table(warehouse):
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.pdb")
+ ctx.sql("CREATE TABLE paimon.pdb.pt (dt STRING, id INT) PARTITIONED BY
(dt)")
+ ctx.sql("INSERT INTO paimon.pdb.pt VALUES ('p1', 1), ('p1', 2), ('p2', 3),
('p3', 4)")
+ catalog = PaimonCatalog({"warehouse": warehouse})
+ return catalog.get_table("pdb.pt")
+
+
+def test_filter_equal_converts_and_plans():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ plan = table.new_read_builder().with_filter(
+ {"method": "equal", "field": "id", "literals":
[1]}).new_scan().plan()
+ assert plan is not None
+
+
+def test_filter_prunes_partition_splits():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_partitioned_table(warehouse)
+ unfiltered = len(table.new_read_builder().new_scan().plan().splits())
+ filtered = len(table.new_read_builder().with_filter(
+ {"method": "equal", "field": "dt", "literals":
["p1"]}).new_scan().plan().splits())
+ assert filtered < unfiltered
+
+
+def test_filter_and_or_compound():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ pred = {"method": "and", "children": [
+ {"method": "greaterOrEqual", "field": "id", "literals": [1]},
+ {"method": "lessThan", "field": "id", "literals": [99]},
+ ]}
+ assert table.new_read_builder().with_filter(pred).new_scan().plan() is
not None
+
+
+def test_filter_in_notin():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ b = table.new_read_builder()
+ assert b.with_filter({"method": "in", "field": "id", "literals": [1,
2]}).new_scan().plan() is not None
+ # Fresh builder for notIn (with_filter overwrites; avoid relying on
overwrite here).
+ b2 = table.new_read_builder()
+ assert b2.with_filter({"method": "notIn", "field": "id", "literals":
[1, 2]}).new_scan().plan() is not None
+
+
+def test_filter_isnull_isnotnull():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ b = table.new_read_builder()
+ assert b.with_filter({"method": "isNotNull", "field": "name",
"literals": []}).new_scan().plan() is not None
+ b2 = table.new_read_builder()
+ assert b2.with_filter({"method": "isNull", "field": "name",
"literals": []}).new_scan().plan() is not None
+
+
+def test_filter_null_check_with_literals_raises():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ b = table.new_read_builder()
+ with pytest.raises(ValueError):
+ b.with_filter({"method": "isNull", "field": "name", "literals":
[1]})
+ with pytest.raises(ValueError):
+ b.with_filter({"method": "isNotNull", "field": "name", "literals":
[1]})
+ # Valid empty/missing cases still succeed.
+ assert b.with_filter(
+ {"method": "isNull", "field": "name", "literals":
[]}).new_scan().plan() is not None
+ assert b.with_filter(
+ {"method": "isNull", "field": "name"}).new_scan().plan() is not
None
+
+
+def test_filter_bool_literal_converts():
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.bdb")
+ ctx.sql("CREATE TABLE paimon.bdb.bt (id INT, flag BOOLEAN)")
+ ctx.sql("INSERT INTO paimon.bdb.bt VALUES (1, true), (2, false)")
+ table = PaimonCatalog({"warehouse": warehouse}).get_table("bdb.bt")
+ plan = table.new_read_builder().with_filter(
+ {"method": "equal", "field": "flag", "literals":
[True]}).new_scan().plan()
+ assert plan is not None
+
+
[email protected]("method", ["like", "startsWith", "not"])
+def test_filter_unsupported_operator_raises(method):
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ with pytest.raises(NotImplementedError):
+ table.new_read_builder().with_filter(
+ {"method": method, "field": "name", "literals": ["x"]})
+
+
+def test_filter_unsupported_operator_precedes_shape_errors():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ b = table.new_read_builder()
+ # 'not' with no field -> NotImplementedError, not ValueError about
missing field
+ with pytest.raises(NotImplementedError):
+ b.with_filter({"method": "not", "children": []})
+ # 'like' with unknown field -> NotImplementedError, not ValueError
about unknown field
+ with pytest.raises(NotImplementedError):
+ b.with_filter({"method": "like", "field": "nope", "literals":
["x"]})
+
+
+def test_filter_unsupported_type_raises():
+ # Build a table with a timestamp column, filter on it ->
NotImplementedError
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.tdb")
+ ctx.sql("CREATE TABLE paimon.tdb.tt (id INT, created_at TIMESTAMP)")
+ ctx.sql("INSERT INTO paimon.tdb.tt VALUES (1, TIMESTAMP '2024-01-01
00:00:00')")
+ table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.tt")
+ with pytest.raises(NotImplementedError):
+ table.new_read_builder().with_filter(
+ {"method": "equal", "field": "created_at", "literals": [0]})
+
+
+def test_filter_unknown_field_raises():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ with pytest.raises(ValueError):
+ table.new_read_builder().with_filter(
+ {"method": "equal", "field": "nope", "literals": [1]})
+
+
+def test_filter_type_mismatch_raises():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ b = table.new_read_builder()
+ with pytest.raises(ValueError):
+ b.with_filter({"method": "equal", "field": "id", "literals":
[True]})
+ with pytest.raises(ValueError):
+ b.with_filter({"method": "equal", "field": "id", "literals":
["x"]})
+ with pytest.raises(ValueError):
+ b.with_filter({"method": "equal", "field": "id", "literals":
[None]})
+
+
+def test_filter_out_of_range_raises():
+ # needs a TinyInt column
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+ ctx.sql("CREATE SCHEMA paimon.ndb")
+ ctx.sql("CREATE TABLE paimon.ndb.nt (id INT, small TINYINT)")
+ ctx.sql("INSERT INTO paimon.ndb.nt VALUES (1, 5)")
+ table = PaimonCatalog({"warehouse": warehouse}).get_table("ndb.nt")
+ with pytest.raises(ValueError):
+ table.new_read_builder().with_filter(
+ {"method": "equal", "field": "small", "literals": [9999]})
+
+
+def test_filter_wrong_literal_count_raises():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ b = table.new_read_builder()
+ with pytest.raises(ValueError):
+ b.with_filter({"method": "equal", "field": "id", "literals": [1,
2]})
+ with pytest.raises(ValueError):
+ b.with_filter({"method": "in", "field": "id", "literals": []})
+
+
+def test_filter_compound_with_unsupported_child_fails():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ pred = {"method": "and", "children": [
+ {"method": "equal", "field": "id", "literals": [1]},
+ {"method": "like", "field": "name", "literals": ["a%"]},
+ ]}
+ with pytest.raises(NotImplementedError):
+ table.new_read_builder().with_filter(pred)
+
+
+def test_filter_empty_children_raises():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_table_with_data(warehouse)
+ with pytest.raises(ValueError):
+ table.new_read_builder().with_filter({"method": "and", "children":
[]})
+
+
+def test_filter_overwrite():
+ with tempfile.TemporaryDirectory() as warehouse:
+ table = _make_partitioned_table(warehouse)
+ only_p2 = len(table.new_read_builder().with_filter(
+ {"method": "equal", "field": "dt", "literals":
["p2"]}).new_scan().plan().splits())
+ overwritten = len(table.new_read_builder()
+ .with_filter({"method": "equal", "field": "dt", "literals":
["p1"]})
+ .with_filter({"method": "equal", "field": "dt", "literals":
["p2"]})
+ .new_scan().plan().splits())
+ assert overwritten == only_p2