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 9740674c fix: preserve Java empty string predicates for search 
refinement (#1096)
9740674c is described below

commit 9740674cfff33a8908f1cebc7997bc48b4f76b87
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 10 22:32:05 2026 +0800

    fix: preserve Java empty string predicates for search refinement (#1096)
---
 bindings/python/src/predicate.rs                   |  22 +++-
 crates/paimon/src/spec/predicate.rs                | 115 ++++++++++++---------
 .../table/full_text_search_builder/parity_tests.rs | 115 +++++++++++++++++++++
 3 files changed, 198 insertions(+), 54 deletions(-)

diff --git a/bindings/python/src/predicate.rs b/bindings/python/src/predicate.rs
index 1986bad5..df15470b 100644
--- a/bindings/python/src/predicate.rs
+++ b/bindings/python/src/predicate.rs
@@ -906,13 +906,29 @@ mod tests {
     }
 
     #[test]
-    fn string_op_empty_pattern_folds_to_is_not_null() {
+    fn string_op_empty_pattern_preserves_java_operator() {
         Python::attach(|py| {
             let fields = test_fields();
-            for method in ["startsWith", "endsWith", "contains"] {
+            for (method, op) in [
+                ("startsWith", PredicateOperator::StartsWith),
+                ("endsWith", PredicateOperator::EndsWith),
+                ("contains", PredicateOperator::Contains),
+            ] {
                 let dict = str_leaf_dict(py, method, "name", &[""]);
                 let pred = dict_to_predicate(&dict, &fields, true).unwrap();
-                expect_leaf_op(&pred, PredicateOperator::IsNotNull);
+                expect_leaf_op(&pred, op);
+            }
+        });
+    }
+
+    #[test]
+    fn like_empty_and_all_wildcard_patterns_stay_like() {
+        Python::attach(|py| {
+            let fields = test_fields();
+            for pattern in ["", "%", "%%", "%%%"] {
+                let dict = str_leaf_dict(py, "like", "name", &[pattern]);
+                let pred = dict_to_predicate(&dict, &fields, true).unwrap();
+                expect_leaf_op(&pred, PredicateOperator::Like);
             }
         });
     }
diff --git a/crates/paimon/src/spec/predicate.rs 
b/crates/paimon/src/spec/predicate.rs
index e3107e8e..21c5ec81 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -1436,19 +1436,18 @@ impl PredicateBuilder {
 
     // -- string operators --
 
-    /// `field LIKE 'pat%'` shape. Empty pattern → `IsNotNull(field)` (every
-    /// non-null string starts with the empty string). Non-string `pattern`
-    /// → [`Error::ConfigInvalid`].
+    /// `field LIKE 'pat%'` shape. Retains an empty pattern as a string leaf,
+    /// matching Java. Non-string `pattern` → [`Error::ConfigInvalid`].
     pub fn starts_with(&self, field: &str, pattern: Datum) -> 
Result<Predicate> {
         self.string_leaf(field, PredicateOperator::StartsWith, pattern)
     }
 
-    /// `field LIKE '%pat'` shape. Empty pattern → `IsNotNull(field)`.
+    /// `field LIKE '%pat'` shape, including an empty pattern.
     pub fn ends_with(&self, field: &str, pattern: Datum) -> Result<Predicate> {
         self.string_leaf(field, PredicateOperator::EndsWith, pattern)
     }
 
-    /// `field LIKE '%pat%'` shape. Empty pattern → `IsNotNull(field)`.
+    /// `field LIKE '%pat%'` shape, including an empty pattern.
     pub fn contains(&self, field: &str, pattern: Datum) -> Result<Predicate> {
         self.string_leaf(field, PredicateOperator::Contains, pattern)
     }
@@ -1477,14 +1476,13 @@ impl PredicateBuilder {
     /// `%mid%` / no-wildcard patterns into [`PredicateOperator::StartsWith`] /
     /// [`PredicateOperator::EndsWith`] / [`PredicateOperator::Contains`] /
     /// [`PredicateOperator::Eq`]; falls back to a [`PredicateOperator::Like`]
-    /// leaf for anything more complex (`_`, multi-segment `%`, escaped
-    /// wildcards). The `Like` evaluator follows arrow_string `like` kernel
-    /// semantics for the residual cases.
+    /// leaf for empty or all-wildcard patterns and anything more complex
+    /// (`_`, multi-segment `%`, escaped wildcards). The `Like` evaluator 
follows
+    /// arrow_string `like` kernel semantics for the residual cases.
     ///
     /// `escape == None` defaults to `\`. Any other ESCAPE character is
     /// rejected with [`Error::ConfigInvalid`] (the DataFusion translator turns
-    /// that into a fall-open). Empty pattern → [`PredicateOperator::Eq`] of 
the
-    /// empty string (SQL semantics: only the empty string matches).
+    /// that into a fall-open).
     pub fn like(&self, field: &str, pattern: Datum, escape: Option<char>) -> 
Result<Predicate> {
         let pattern_str = match &pattern {
             Datum::String(s) => s.clone(),
@@ -1504,7 +1502,7 @@ impl PredicateBuilder {
         }
 
         match optimize_like_pattern(&pattern_str) {
-            LikeShape::EmptyOrLiteral(s) => self.equal(field, 
Datum::String(s)),
+            LikeShape::Literal(s) => self.equal(field, Datum::String(s)),
             LikeShape::StartsWith(prefix) => self.starts_with(field, 
Datum::String(prefix)),
             LikeShape::EndsWith(suffix) => self.ends_with(field, 
Datum::String(suffix)),
             LikeShape::Contains(mid) => self.contains(field, 
Datum::String(mid)),
@@ -1539,17 +1537,11 @@ impl PredicateBuilder {
         matches!(datum_cmp(low, high), Some(Ordering::Greater))
     }
 
-    /// Shared body for the three string operators: empty-string short-circuit
-    /// and literal-type guard ([`leaf`] still cross-checks against the column
-    /// type, so non-string columns are rejected there).
+    /// Shared literal-type guard for the three string operators. Preserve
+    /// empty string leaves: search refinement depends on the original 
operator,
+    /// even when row evaluation is equivalent to `IsNotNull`.
     fn string_leaf(&self, field: &str, op: PredicateOperator, pattern: Datum) 
-> Result<Predicate> {
         match &pattern {
-            // Every non-null string starts with / ends with / contains the
-            // empty string, and a NULL value matches none of them — i.e. the
-            // empty pattern is exactly `IsNotNull`. Folding to `AlwaysTrue`
-            // would wrongly retain NULL rows (and drop the field reference,
-            // keeping the predicate out of the data-pruning path).
-            Datum::String(s) if s.is_empty() => return self.is_not_null(field),
             Datum::String(_) => {}
             other => {
                 return Err(Error::ConfigInvalid {
@@ -1944,10 +1936,8 @@ fn eval_leaf(op: PredicateOperator, datum: 
Option<&Datum>, literals: &[Datum]) -
 /// literal substrings extracted from the pattern (with escape sequences
 /// already decoded).
 enum LikeShape {
-    /// Empty pattern, or a pattern that contains no wildcards / escapes —
-    /// equivalent to `Eq <literal>` (where the literal is the unescaped
-    /// pattern, possibly empty).
-    EmptyOrLiteral(String),
+    /// Nonempty pattern with no wildcards or escapes, equivalent to `Eq`.
+    Literal(String),
     /// `prefix%` (exactly one trailing `%`, no other wildcards or escapes).
     StartsWith(String),
     /// `%suffix`.
@@ -1965,14 +1955,14 @@ enum LikeShape {
 /// [`LikeShape::Residual`] (the simple shape rules don't account for escaped
 /// wildcards).
 fn optimize_like_pattern(pattern: &str) -> LikeShape {
-    if pattern.contains('\\') || pattern.contains('_') {
+    if pattern.is_empty() || pattern.contains('\\') || pattern.contains('_') {
         return LikeShape::Residual;
     }
     let bytes = pattern.as_bytes();
     let percent_count = bytes.iter().filter(|b| **b == b'%').count();
     match percent_count {
-        0 => LikeShape::EmptyOrLiteral(pattern.to_string()),
-        1 => {
+        0 => LikeShape::Literal(pattern.to_string()),
+        1 if pattern.len() > 1 => {
             if let Some(prefix) = pattern.strip_suffix('%') {
                 LikeShape::StartsWith(prefix.to_string())
             } else if let Some(suffix) = pattern.strip_prefix('%') {
@@ -1981,10 +1971,7 @@ fn optimize_like_pattern(pattern: &str) -> LikeShape {
                 LikeShape::Residual
             }
         }
-        2 if pattern.starts_with('%') && pattern.ends_with('%') => {
-            // `%%` reduces to `Contains('')`, which itself short-circuits to
-            // `IsNotNull` at the StartsWith/EndsWith/Contains builder boundary
-            // — so no special-casing here.
+        2 if pattern.len() > 2 && pattern.starts_with('%') && 
pattern.ends_with('%') => {
             LikeShape::Contains(pattern[1..pattern.len() - 1].to_string())
         }
         _ => LikeShape::Residual,
@@ -3273,22 +3260,28 @@ mod tests {
     }
 
     #[test]
-    fn test_builder_string_ops_empty_pattern_is_not_null() {
+    fn test_builder_string_ops_preserve_empty_pattern() {
         let pb = PredicateBuilder::new(&test_fields());
-        // An empty pattern matches every non-null string and no NULL, so it is
-        // exactly `IsNotNull` (not `AlwaysTrue`, which would retain NULL 
rows).
-        for build in [
-            pb.starts_with("name", Datum::String(String::new())),
-            pb.ends_with("name", Datum::String(String::new())),
-            pb.contains("name", Datum::String(String::new())),
+        for (build, op) in [
+            (
+                pb.starts_with("name", Datum::String(String::new())),
+                PredicateOperator::StartsWith,
+            ),
+            (
+                pb.ends_with("name", Datum::String(String::new())),
+                PredicateOperator::EndsWith,
+            ),
+            (
+                pb.contains("name", Datum::String(String::new())),
+                PredicateOperator::Contains,
+            ),
         ] {
-            assert!(matches!(
-                build.unwrap(),
-                Predicate::Leaf {
-                    op: PredicateOperator::IsNotNull,
-                    ..
-                }
-            ));
+            assert_leaf(&build.unwrap(), op, "");
+            let literals = [Datum::String(String::new())];
+            for value in ["", "paimon"] {
+                assert!(eval_leaf(op, Some(&Datum::String(value.into())), 
&literals));
+            }
+            assert!(!eval_leaf(op, None, &literals));
         }
     }
 
@@ -3369,12 +3362,6 @@ mod tests {
             PredicateOperator::Eq,
             "foo",
         );
-        // Empty pattern → Eq("") (only empty string matches).
-        assert_leaf(
-            &pb.like("name", Datum::String(String::new()), None).unwrap(),
-            PredicateOperator::Eq,
-            "",
-        );
         // prefix% → StartsWith.
         assert_leaf(
             &pb.like("name", Datum::String("foo%".to_string()), None)
@@ -3424,6 +3411,32 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_like_empty_and_all_wildcard_patterns_preserve_java_leaves() {
+        let pb = PredicateBuilder::new(&test_fields());
+        for pattern in ["", "%", "%%", "%%%"] {
+            let predicate = pb
+                .like("name", Datum::String(pattern.into()), None)
+                .unwrap();
+            assert_leaf(&predicate, PredicateOperator::Like, pattern);
+            let literals = [Datum::String(pattern.into())];
+            assert!(eval_leaf(
+                PredicateOperator::Like,
+                Some(&Datum::String(String::new())),
+                &literals
+            ));
+            assert_eq!(
+                eval_leaf(
+                    PredicateOperator::Like,
+                    Some(&Datum::String("paimon".into())),
+                    &literals
+                ),
+                !pattern.is_empty()
+            );
+            assert!(!eval_leaf(PredicateOperator::Like, None, &literals));
+        }
+    }
+
     #[test]
     fn test_like_rejects_custom_escape_char() {
         let pb = PredicateBuilder::new(&test_fields());
diff --git a/crates/paimon/src/table/full_text_search_builder/parity_tests.rs 
b/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
index fde501e5..b8948262 100644
--- a/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
+++ b/crates/paimon/src/table/full_text_search_builder/parity_tests.rs
@@ -101,6 +101,32 @@ async fn index(table: &Table, kind: &str, column: &str) {
         .unwrap();
 }
 
+async fn append_labels(table: &Table, start: i32, labels: &[Option<&str>]) {
+    let schema = Arc::new(ArrowSchema::new(vec![
+        Field::new("id", ArrowType::Int32, true),
+        Field::new("text", ArrowType::Utf8, true),
+        Field::new("label", ArrowType::Utf8, true),
+        Field::new("pt", ArrowType::Int32, true),
+    ]));
+    let columns: Vec<ArrayRef> = vec![
+        Arc::new(Int32Array::from_iter_values(
+            start..start + labels.len() as i32,
+        )),
+        Arc::new(StringArray::from(vec!["paimon"; labels.len()])),
+        Arc::new(StringArray::from(labels.to_vec())),
+        Arc::new(Int32Array::from(vec![0; labels.len()])),
+    ];
+    let mut write = TableWrite::new(table, "data".into()).unwrap();
+    write
+        .write_arrow_batch(&RecordBatch::try_new(schema, columns).unwrap())
+        .await
+        .unwrap();
+    TableCommit::new(table.clone(), "data".into())
+        .commit(write.prepare_commit().await.unwrap())
+        .await
+        .unwrap();
+}
+
 fn query(table: &Table) -> FullTextSearchBuilder<'_> {
     let mut builder = table.new_full_text_search_builder();
     builder
@@ -178,6 +204,95 @@ async fn 
scalar_modes_and_refinement_follow_java_before_top_k() {
     assert_eq!(ids(search.execute_scored().await.unwrap()), vec![5]);
 }
 
+#[tokio::test]
+async fn empty_string_filters_preserve_java_search_refinement() {
+    for kind in ["btree", "bitmap"] {
+        let table = new_table(false);
+        append_labels(
+            &table,
+            0,
+            &[
+                Some(""),
+                None,
+                Some("keep"),
+                Some("drop"),
+                Some(""),
+                Some("keep"),
+                Some("drop"),
+                None,
+            ],
+        )
+        .await;
+        index(&table, "full-text", "text").await;
+        index(&table, kind, "label").await;
+        let pb = PredicateBuilder::new(table.schema().fields());
+        let filters = [
+            (
+                "like-empty",
+                pb.like("label", Datum::String("".into()), None).unwrap(),
+            ),
+            (
+                "like-percent",
+                pb.like("label", Datum::String("%".into()), None).unwrap(),
+            ),
+            (
+                "like-double-percent",
+                pb.like("label", Datum::String("%%".into()), None).unwrap(),
+            ),
+            (
+                "contains",
+                pb.contains("label", Datum::String("".into())).unwrap(),
+            ),
+            (
+                "ends-with",
+                pb.ends_with("label", Datum::String("".into())).unwrap(),
+            ),
+            (
+                "starts-with",
+                pb.starts_with("label", Datum::String("".into())).unwrap(),
+            ),
+        ];
+        for partial in [false, true] {
+            if partial {
+                append_labels(&table, 8, &[Some(""), None, Some("keep"), 
Some("drop")]).await;
+            }
+            for refine in [false, true] {
+                let table = table.copy_with_options(HashMap::from([(
+                    "global-index.filter.refine-from-data".into(),
+                    refine.to_string(),
+                )]));
+                for (name, filter) in &filters {
+                    let mut expected = if refine || *name == "starts-with" {
+                        if *name == "like-empty" {
+                            vec![0, 4]
+                        } else {
+                            vec![0, 2, 3, 4, 5, 6]
+                        }
+                    } else {
+                        vec![]
+                    };
+                    if partial {
+                        expected.extend(if *name == "like-empty" {
+                            vec![8]
+                        } else {
+                            vec![8, 10, 11]
+                        });
+                    }
+                    assert_eq!(
+                        ids(query(&table)
+                            .with_filter(filter.clone())
+                            .execute_scored()
+                            .await
+                            .unwrap()),
+                        expected,
+                        "index={kind}, filter={name}, refine={refine}, 
partial={partial}",
+                    );
+                }
+            }
+        }
+    }
+}
+
 async fn user_ids(table: &Table, result: SearchResult) -> Vec<i32> {
     let mut builder = table.new_read_builder();
     builder.with_projection(&["id", ROW_ID_FIELD_NAME]).unwrap();

Reply via email to