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 c040921b Support BLOB and row-id predicates in Python native reads 
(#906)
c040921b is described below

commit c040921b745132b1abaaec518d430ea4b590593a
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Sep 21 21:31:37 2026 +0800

    Support BLOB and row-id predicates in Python native reads (#906)
---
 bindings/python/src/predicate.rs    | 26 ++++++++++++--
 bindings/python/src/read.rs         | 24 +++++++++++--
 bindings/python/tests/test_read.py  | 67 +++++++++++++++++++++++++++++++++++++
 crates/paimon/src/spec/predicate.rs | 18 ++++++++++
 4 files changed, 131 insertions(+), 4 deletions(-)

diff --git a/bindings/python/src/predicate.rs b/bindings/python/src/predicate.rs
index 2a505a61..45898335 100644
--- a/bindings/python/src/predicate.rs
+++ b/bindings/python/src/predicate.rs
@@ -47,7 +47,7 @@ use pyo3::types::{
 /// - `Decimal` accepts a `decimal.Decimal` or an `int`, rescaled losslessly to
 ///   the column's scale; anything needing rounding, exceeding the column's
 ///   precision, non-finite, or a binary `float` is rejected.
-/// - `Binary`/`VarBinary` accept Python `bytes` or `bytearray`, copied without
+/// - `Binary`/`VarBinary`/`Blob` accept Python `bytes` or `bytearray`, copied 
without
 ///   decoding or changing their length.
 /// - Complex types are not supported yet and raise
 ///   `NotImplementedError`.
@@ -82,7 +82,7 @@ pub(crate) fn py_to_datum(value: &Bound<'_, PyAny>, 
data_type: &DataType) -> PyR
                 .map_err(|_| PyValueError::new_err("expected a str literal for 
String field"))?;
             Ok(Datum::String(s.to_str()?.to_string()))
         }
-        DataType::Binary(_) | DataType::VarBinary(_) => {
+        DataType::Binary(_) | DataType::VarBinary(_) | DataType::Blob(_) => {
             let bytes = if let Ok(bytes) = value.cast::<PyBytes>() {
                 bytes.as_bytes().to_vec()
             } else if let Ok(bytes) = value.cast::<PyByteArray>() {
@@ -693,6 +693,28 @@ mod tests {
         });
     }
 
+    #[test]
+    fn blob_leaf_accepts_binary_literals() {
+        Python::attach(|py| {
+            let fields = vec![DataField::new(
+                0,
+                "payload".to_string(),
+                DataType::Blob(paimon::spec::BlobType::new()),
+            )];
+            let dict = PyDict::new(py);
+            dict.set_item("method", "equal").unwrap();
+            dict.set_item("field", "payload").unwrap();
+            dict.set_item("literals", vec![PyBytes::new(py, &[0, 255, 0])])
+                .unwrap();
+            let predicate = dict_to_predicate(&dict, &fields, true).unwrap();
+            assert!(matches!(predicate, Predicate::Leaf { literals, .. }
+                if literals == vec![Datum::Bytes(vec![0, 255, 0])]));
+
+            dict.set_item("literals", vec!["not bytes"]).unwrap();
+            assert!(dict_to_predicate(&dict, &fields, true).is_err());
+        });
+    }
+
     #[test]
     fn unsupported_operator_not_raises_not_implemented() {
         Python::attach(|py| {
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 120fdbf2..f3c00ca4 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -22,7 +22,9 @@ use std::sync::{Arc, Mutex};
 use arrow::pyarrow::ToPyArrow;
 use arrow::record_batch::RecordBatch;
 use futures::TryStreamExt;
-use paimon::spec::{DataField, DataType, Predicate, RowType};
+use paimon::spec::{
+    BigIntType, DataField, DataType, Predicate, RowType, ROW_ID_FIELD_ID, 
ROW_ID_FIELD_NAME,
+};
 use paimon::table::{ArrowRecordBatchStream, DataSplit, IncrementalScanMode, 
RowRange, Table};
 use paimon_datafusion::runtime::runtime;
 use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError};
@@ -376,7 +378,25 @@ impl PyReadBuilder {
         mut slf: PyRefMut<'py, Self>,
         predicate: &Bound<'_, PyDict>,
     ) -> PyResult<PyRefMut<'py, Self>> {
-        let filter = dict_to_predicate(predicate, slf.table.schema().fields(), 
slf.case_sensitive)?;
+        let mut fields = slf.table.schema().fields().to_vec();
+        // _ROW_ID is synthesized during read and absent from the table schema.
+        // Appending it preserves every physical column's original predicate 
index.
+        // A real `_row_id` column takes precedence in case-insensitive mode.
+        let has_row_id_field = fields.iter().any(|field| {
+            if slf.case_sensitive {
+                field.name() == ROW_ID_FIELD_NAME
+            } else {
+                field.name().eq_ignore_ascii_case(ROW_ID_FIELD_NAME)
+            }
+        });
+        if !has_row_id_field {
+            fields.push(DataField::new(
+                ROW_ID_FIELD_ID,
+                ROW_ID_FIELD_NAME.to_string(),
+                DataType::BigInt(BigIntType::with_nullable(true)),
+            ));
+        }
+        let filter = dict_to_predicate(predicate, &fields, 
slf.case_sensitive)?;
         slf.filter = Some(filter);
         Ok(slf)
     }
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index a6240546..0892c245 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -204,6 +204,49 @@ def test_with_row_ranges():
             table.new_read_builder().with_row_ranges([(2, 1)])
 
 
[email protected]("data_evolution", [False, True])
[email protected]("case_sensitive", [False, True])
+def test_row_id_filter_with_projection_and_data_predicate(data_evolution, 
case_sensitive):
+    with tempfile.TemporaryDirectory() as warehouse:
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.sql("CREATE SCHEMA paimon.rid")
+        options = "'row-tracking.enabled' = 'true'"
+        if data_evolution:
+            options += ", 'data-evolution.enabled' = 'true'"
+        ctx.sql(f"CREATE TABLE paimon.rid.t (id INT, name STRING) WITH 
({options})")
+        ctx.sql("INSERT INTO paimon.rid.t (id, name) VALUES (1, 'a'), (2, 
'b'), (3, 'c')")
+        table = PaimonCatalog({"warehouse": warehouse}).get_table("rid.t")
+        builder = table.new_read_builder().with_case_sensitive(case_sensitive)
+        builder.with_projection(["name", "_ROW_ID"])
+        row_id_field = "_ROW_ID" if case_sensitive else "_row_id"
+        builder.with_filter({"method": "and", "children": [
+            {"method": "equal", "field": row_id_field, "literals": [1]},
+            {"method": "equal", "field": "id", "literals": [2]},
+        ]})
+        rows = pa.Table.from_batches(builder.new_read().read(
+            builder.new_scan().plan().splits()))
+        assert rows.to_pylist() == [{"name": "b", "_ROW_ID": 1}]
+
+
[email protected]("predicate_field", ["_row_id", "_ROW_ID"])
+def 
test_case_insensitive_filter_uses_real_lowercase_row_id_column(predicate_field):
+    with tempfile.TemporaryDirectory() as warehouse:
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.sql("CREATE SCHEMA paimon.realrowid")
+        ctx.sql("CREATE TABLE paimon.realrowid.t (_row_id BIGINT, id INT)")
+        ctx.sql("INSERT INTO paimon.realrowid.t VALUES (11, 1), (22, 2)")
+        table = PaimonCatalog({"warehouse": 
warehouse}).get_table("realrowid.t")
+        builder = table.new_read_builder().with_case_sensitive(False)
+        builder.with_projection(["_row_id", "id"])
+        builder.with_filter({
+            "method": "equal", "field": predicate_field, "literals": [22],
+        })
+        batches = builder.new_read().read(builder.new_scan().plan().splits())
+        assert pa.Table.from_batches(batches).to_pylist() == [{"_row_id": 22, 
"id": 2}]
+
+
 def test_row_tracking_append_row_ranges_keep_global_row_ids():
     with tempfile.TemporaryDirectory() as warehouse:
         ctx = SQLContext()
@@ -586,6 +629,30 @@ def 
test_filter_binary_literal_rejects_other_types(literal):
                 {"method": "equal", "field": "payload", "literals": [literal]})
 
 
+def test_blob_predicate_with_limit_reads_matching_payload():
+    with tempfile.TemporaryDirectory() as warehouse:
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.sql("CREATE SCHEMA paimon.blobpred")
+        ctx.sql("""CREATE TABLE paimon.blobpred.t (id INT, payload BLOB) WITH (
+            'row-tracking.enabled' = 'true',
+            'data-evolution.enabled' = 'true')""")
+        ctx.sql("""INSERT INTO paimon.blobpred.t (id, payload) VALUES
+            (1, X'01'), (2, X'02'), (3, X'03')""")
+        table = PaimonCatalog({"warehouse": warehouse}).get_table("blobpred.t")
+        builder = table.new_read_builder().with_limit(1).with_filter({
+            "method": "equal", "field": "payload", "literals": [b"\x02"],
+        })
+        batches = builder.new_read().read(builder.new_scan().plan().splits())
+        assert pa.Table.from_batches(batches).to_pylist() == [
+            {"id": 2, "payload": b"\x02"}]
+
+        with pytest.raises(ValueError, match="bytes or bytearray"):
+            table.new_read_builder().with_filter({
+                "method": "equal", "field": "payload", "literals": ["02"],
+            })
+
+
 def test_empty_plan_preserves_snapshot_id():
     with tempfile.TemporaryDirectory() as warehouse:
         ctx = SQLContext()
diff --git a/crates/paimon/src/spec/predicate.rs 
b/crates/paimon/src/spec/predicate.rs
index 82a93eac..dd437fdf 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -1563,6 +1563,7 @@ fn validate_datum_matches_type(datum: &Datum, data_type: 
&DataType) -> Result<()
             | (Datum::Decimal { .. }, DataType::Decimal(_))
             | (Datum::Bytes(_), DataType::Binary(_))
             | (Datum::Bytes(_), DataType::VarBinary(_))
+            | (Datum::Bytes(_), DataType::Blob(_))
             | (Datum::Variant { .. }, DataType::Variant(_))
     );
     if !ok {
@@ -2082,6 +2083,23 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_builder_accepts_blob_bytes_without_accepting_wrong_literal_type() {
+        let fields = vec![DataField::new(
+            0,
+            "payload".to_string(),
+            DataType::Blob(BlobType::new()),
+        )];
+        let pb = PredicateBuilder::new(&fields);
+        let bytes = vec![0, 255, 0];
+        let predicate = pb.equal("payload", 
Datum::Bytes(bytes.clone())).unwrap();
+        assert!(matches!(predicate, Predicate::Leaf { literals, .. }
+            if literals == vec![Datum::Bytes(bytes)]));
+        assert!(pb
+            .equal("payload", Datum::String("wrong".to_string()))
+            .is_err());
+    }
+
     #[test]
     fn test_builder_comparison_ops() {
         let pb = PredicateBuilder::new(&test_fields());

Reply via email to