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 c31381b  [python] Push down like/startsWith/endsWith/contains 
predicates (#445)
c31381b is described below

commit c31381b85c03d59d0eb834d696af98f40bc3abe5
Author: Junrui Lee <[email protected]>
AuthorDate: Fri Jul 3 18:47:20 2026 +0800

    [python] Push down like/startsWith/endsWith/contains predicates (#445)
---
 bindings/python/src/predicate.rs   | 221 ++++++++++++++++++++++++++++++++++---
 bindings/python/tests/test_read.py |  98 ++++++++++++++--
 2 files changed, 294 insertions(+), 25 deletions(-)

diff --git a/bindings/python/src/predicate.rs b/bindings/python/src/predicate.rs
index cb2c852..0e9962b 100644
--- a/bindings/python/src/predicate.rs
+++ b/bindings/python/src/predicate.rs
@@ -105,7 +105,7 @@ fn float_val(value: &Bound<'_, PyAny>) -> PyResult<f64> {
 
 /// 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"];
+const METHOD_NOT_SUPPORTED: &[&str] = &["not"];
 
 /// Recursively convert a lightweight dict predicate into a Rust [`Predicate`].
 ///
@@ -242,6 +242,25 @@ fn leaf_to_predicate(
             }
             pb.is_not_in(&field, ds)
         }
+        "startsWith" => pb.starts_with(&field, one(to_datums(literals_obj)?)?),
+        "endsWith" => pb.ends_with(&field, one(to_datums(literals_obj)?)?),
+        "contains" => pb.contains(&field, one(to_datums(literals_obj)?)?),
+        "like" => {
+            // 1 literal: pattern with the default '\' escape.
+            // 2 literals: [pattern, escape] where escape is a single character
+            // (SQL `LIKE .. ESCAPE ..`).
+            let mut ds = to_datums(literals_obj)?;
+            let escape = match ds.len() {
+                1 => None,
+                2 => Some(escape_char(ds.pop().unwrap())?),
+                n => {
+                    return Err(PyValueError::new_err(format!(
+                        "'like' expects 1 or 2 literals (pattern[, escape]), 
got {n}"
+                    )));
+                }
+            };
+            pb.like(&field, ds.pop().unwrap(), escape)
+        }
         other => {
             return Err(PyNotImplementedError::new_err(format!(
                 "unknown or unsupported predicate operator '{other}'"
@@ -296,6 +315,21 @@ fn with_field_context(err: PyErr, field: &str, data_type: 
&DataType) -> PyErr {
     })
 }
 
+/// Extract a single-character `like` ESCAPE literal from an already-converted
+/// string `Datum`.
+fn escape_char(datum: Datum) -> PyResult<char> {
+    let Datum::String(s) = datum else {
+        return Err(PyValueError::new_err("'like' escape must be a str 
literal"));
+    };
+    let mut chars = s.chars();
+    match (chars.next(), chars.next()) {
+        (Some(c), None) => Ok(c),
+        _ => Err(PyValueError::new_err(format!(
+            "'like' escape must be a single character, got {s:?}"
+        ))),
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -357,46 +391,197 @@ mod tests {
     }
 
     #[test]
-    fn unsupported_operator_like_raises_not_implemented() {
+    fn unsupported_operator_not_raises_not_implemented() {
         Python::attach(|py| {
             let fields = test_fields();
-            let dict = leaf_dict(py, "like", "name", &[]);
+            let dict = leaf_dict(py, "not", "id", &[1]);
             let err = dict_to_predicate(&dict, &fields).unwrap_err();
             assert!(err.is_instance_of::<PyNotImplementedError>(py));
         });
     }
 
+    // ---- string operators ----
+
+    /// Build a leaf dict with string literals.
+    fn str_leaf_dict<'py>(
+        py: Python<'py>,
+        method: &str,
+        field: &str,
+        literals: &[&str],
+    ) -> 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
+    }
+
+    fn expect_leaf_op(pred: &Predicate, expected: PredicateOperator) {
+        match pred {
+            Predicate::Leaf { op, .. } => assert_eq!(*op, expected),
+            other => panic!("expected Leaf, got {other:?}"),
+        }
+    }
+
     #[test]
-    fn unsupported_operator_not_raises_not_implemented() {
+    fn starts_with_leaf_converts() {
         Python::attach(|py| {
             let fields = test_fields();
-            let dict = leaf_dict(py, "not", "id", &[1]);
+            let dict = str_leaf_dict(py, "startsWith", "name", &["ab"]);
+            let pred = dict_to_predicate(&dict, &fields).unwrap();
+            expect_leaf_op(&pred, PredicateOperator::StartsWith);
+        });
+    }
+
+    #[test]
+    fn ends_with_leaf_converts() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            let dict = str_leaf_dict(py, "endsWith", "name", &["ab"]);
+            let pred = dict_to_predicate(&dict, &fields).unwrap();
+            expect_leaf_op(&pred, PredicateOperator::EndsWith);
+        });
+    }
+
+    #[test]
+    fn contains_leaf_converts() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            let dict = str_leaf_dict(py, "contains", "name", &["ab"]);
+            let pred = dict_to_predicate(&dict, &fields).unwrap();
+            expect_leaf_op(&pred, PredicateOperator::Contains);
+        });
+    }
+
+    #[test]
+    fn like_prefix_pattern_optimizes_to_starts_with() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            let dict = str_leaf_dict(py, "like", "name", &["ab%"]);
+            let pred = dict_to_predicate(&dict, &fields).unwrap();
+            expect_leaf_op(&pred, PredicateOperator::StartsWith);
+        });
+    }
+
+    #[test]
+    fn like_residual_pattern_stays_like() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            let dict = str_leaf_dict(py, "like", "name", &["a%b%c"]);
+            let pred = dict_to_predicate(&dict, &fields).unwrap();
+            expect_leaf_op(&pred, PredicateOperator::Like);
+        });
+    }
+
+    #[test]
+    fn like_accepts_backslash_escape_literal() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            let dict = str_leaf_dict(py, "like", "name", &["100\\%%", "\\"]);
+            let pred = dict_to_predicate(&dict, &fields).unwrap();
+            // Escaped-wildcard patterns are not rewritten by the core's LIKE
+            // optimization; they stay as a residual Like leaf.
+            expect_leaf_op(&pred, PredicateOperator::Like);
+        });
+    }
+
+    #[test]
+    fn like_rejects_non_backslash_escape() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            let dict = str_leaf_dict(py, "like", "name", &["100!%%", "!"]);
             let err = dict_to_predicate(&dict, &fields).unwrap_err();
-            assert!(err.is_instance_of::<PyNotImplementedError>(py));
+            assert!(err.is_instance_of::<PyValueError>(py));
         });
     }
 
     #[test]
-    fn unsupported_operator_not_without_field_raises_not_implemented() {
+    fn like_rejects_multi_char_escape() {
         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 dict = str_leaf_dict(py, "like", "name", &["a%", "ab"]);
             let err = dict_to_predicate(&dict, &fields).unwrap_err();
-            assert!(err.is_instance_of::<PyNotImplementedError>(py));
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn like_rejects_three_literals() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            let dict = str_leaf_dict(py, "like", "name", &["a%", "\\", "x"]);
+            let err = dict_to_predicate(&dict, &fields).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(py));
+        });
+    }
+
+    #[test]
+    fn string_op_empty_pattern_folds_to_is_not_null() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            for method in ["startsWith", "endsWith", "contains"] {
+                let dict = str_leaf_dict(py, method, "name", &[""]);
+                let pred = dict_to_predicate(&dict, &fields).unwrap();
+                expect_leaf_op(&pred, PredicateOperator::IsNotNull);
+            }
         });
     }
 
     #[test]
-    fn unsupported_operator_like_with_unknown_field_raises_not_implemented() {
+    fn string_op_on_non_string_column_raises_value_error() {
         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", &[]);
+            for method in ["startsWith", "endsWith", "contains", "like"] {
+                let dict = str_leaf_dict(py, method, "id", &["a"]);
+                let err = dict_to_predicate(&dict, &fields).unwrap_err();
+                assert!(err.is_instance_of::<PyValueError>(py), "{method}");
+            }
+        });
+    }
+
+    #[test]
+    fn string_op_wrong_literal_count_raises_value_error() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            for method in ["startsWith", "endsWith", "contains", "like"] {
+                let dict = str_leaf_dict(py, method, "name", &[]);
+                let err = dict_to_predicate(&dict, &fields).unwrap_err();
+                assert!(err.is_instance_of::<PyValueError>(py), "{method} 
zero");
+            }
+            for method in ["startsWith", "endsWith", "contains"] {
+                let dict = str_leaf_dict(py, method, "name", &["a", "b"]);
+                let err = dict_to_predicate(&dict, &fields).unwrap_err();
+                assert!(err.is_instance_of::<PyValueError>(py), "{method} 
two");
+            }
+        });
+    }
+
+    #[test]
+    fn string_op_unknown_field_raises_value_error() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            // Now that string operators are supported, they follow the normal
+            // leaf path: field resolution happens first, so an unknown field 
is
+            // a ValueError (not NotImplementedError as before).
+            let dict = str_leaf_dict(py, "like", "nope", &["x"]);
+            let err = dict_to_predicate(&dict, &fields).unwrap_err();
+            assert!(err.is_instance_of::<PyValueError>(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));
         });
@@ -429,7 +614,7 @@ mod tests {
         Python::attach(|py| {
             let fields = test_fields();
             let ok = leaf_dict(py, "equal", "id", &[1]);
-            let bad = leaf_dict(py, "like", "name", &[]);
+            let bad = leaf_dict(py, "not", "name", &[]);
             let children = PyList::empty(py);
             children.append(ok).unwrap();
             children.append(bad).unwrap();
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index 6d0f8f0..8065746 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -174,13 +174,12 @@ def test_filter_bool_literal_converts():
         assert plan is not None
 
 
[email protected]("method", ["like", "startsWith", "not"])
-def test_filter_unsupported_operator_raises(method):
+def test_filter_unsupported_operator_raises():
     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"]})
+                {"method": "not", "field": "name", "literals": ["x"]})
 
 
 def test_filter_unsupported_operator_precedes_shape_errors():
@@ -190,9 +189,92 @@ def 
test_filter_unsupported_operator_precedes_shape_errors():
         # '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 _make_string_table(warehouse):
+    ctx = SQLContext()
+    ctx.register_catalog("paimon", {"warehouse": warehouse})
+    ctx.sql("CREATE SCHEMA paimon.sdb")
+    ctx.sql("CREATE TABLE paimon.sdb.st (id INT, name STRING)")
+    # Two separate INSERTs -> two files, so file stats can prune per-file.
+    ctx.sql("INSERT INTO paimon.sdb.st VALUES (1, 'apple'), (2, 'apricot')")
+    ctx.sql("INSERT INTO paimon.sdb.st VALUES (3, 'banana'), (4, 'cherry')")
+    return PaimonCatalog({"warehouse": warehouse}).get_table("sdb.st")
+
+
+def _read_ids(builder):
+    splits = builder.new_scan().plan().splits()
+    batches = builder.new_read().read(splits)
+    if not batches:
+        return []
+    return sorted(pa.Table.from_batches(batches).column("id").to_pylist())
+
+
+def test_filter_starts_with_prunes_and_reads():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_string_table(warehouse)
+        b = table.new_read_builder().with_filter(
+            {"method": "startsWith", "field": "name", "literals": ["ap"]})
+        assert len(b.new_scan().plan().splits()) == 1
+        assert _read_ids(b) == [1, 2]
+
+
+def test_filter_ends_with_reads_matching_rows():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_string_table(warehouse)
+        b = table.new_read_builder().with_filter(
+            {"method": "endsWith", "field": "name", "literals": ["y"]})
+        assert _read_ids(b) == [4]
+
+
+def test_filter_contains_reads_matching_rows():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_string_table(warehouse)
+        b = table.new_read_builder().with_filter(
+            {"method": "contains", "field": "name", "literals": ["an"]})
+        assert _read_ids(b) == [3]
+
+
+def test_filter_like_prefix_reads_matching_rows():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_string_table(warehouse)
+        b = table.new_read_builder().with_filter(
+            {"method": "like", "field": "name", "literals": ["ap%"]})
+        assert _read_ids(b) == [1, 2]
+
+
+def test_filter_like_residual_pattern_reads_matching_rows():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_string_table(warehouse)
+        # '_' single-char wildcard is not rewritten; exercises the Like 
evaluator.
+        b = table.new_read_builder().with_filter(
+            {"method": "like", "field": "name", "literals": ["b_nana"]})
+        assert _read_ids(b) == [3]
+
+
+def test_filter_like_escape_literal():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_string_table(warehouse)
+        b = table.new_read_builder()
+        # Optional second literal is the ESCAPE character; only '\\' is 
accepted.
+        assert b.with_filter(
+            {"method": "like", "field": "name",
+             "literals": ["100\\%%", "\\"]}).new_scan().plan() is not None
+        with pytest.raises(ValueError):
+            b.with_filter(
+                {"method": "like", "field": "name", "literals": ["100!%%", 
"!"]})
+        with pytest.raises(ValueError):
+            b.with_filter(
+                {"method": "like", "field": "name", "literals": ["a%", "ab"]})
+
+
+def test_filter_string_op_on_non_string_column_raises():
+    with tempfile.TemporaryDirectory() as warehouse:
+        table = _make_string_table(warehouse)
+        b = table.new_read_builder()
+        for method in ["startsWith", "endsWith", "contains", "like"]:
+            with pytest.raises(ValueError):
+                b.with_filter({"method": method, "field": "id", "literals": 
["a"]})
 
 
 def test_filter_unsupported_type_raises():
@@ -258,7 +340,9 @@ def test_filter_compound_with_unsupported_child_fails():
         table = _make_table_with_data(warehouse)
         pred = {"method": "and", "children": [
             {"method": "equal", "field": "id", "literals": [1]},
-            {"method": "like", "field": "name", "literals": ["a%"]},
+            {"method": "not", "children": [
+                {"method": "equal", "field": "name", "literals": ["a"]},
+            ]},
         ]}
         with pytest.raises(NotImplementedError):
             table.new_read_builder().with_filter(pred)

Reply via email to