This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 30bfd4a600 [python] Align IN predicates with SQL null semantics (#9132)
30bfd4a600 is described below

commit 30bfd4a600a930079b15f5e6bacea34b8d3ff4c8
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Aug 9 18:49:09 2026 +0800

    [python] Align IN predicates with SQL null semantics (#9132)
---
 paimon-python/pypaimon/common/predicate.py      | 20 +++++++++--
 paimon-python/pypaimon/tests/predicates_test.py | 46 +++++++++++++++++++++++++
 2 files changed, 63 insertions(+), 3 deletions(-)

diff --git a/paimon-python/pypaimon/common/predicate.py 
b/paimon-python/pypaimon/common/predicate.py
index ed1f434a96..0bd1edd774 100644
--- a/paimon-python/pypaimon/common/predicate.py
+++ b/paimon-python/pypaimon/common/predicate.py
@@ -301,24 +301,38 @@ class In(Tester):
         return val in literals
 
     def test_by_stats(self, min_v, max_v, literals) -> bool:
-        return any(min_v <= l <= max_v for l in literals)
+        return any(
+            min_v <= literal <= max_v
+            for literal in literals
+            if literal is not None
+        )
 
     def test_by_arrow(self, val, literals) -> bool:
-        return val.isin(literals)
+        # Arrow treats null as a set member, while SQL IN never returns true
+        # solely because both the field and an IN literal are null.
+        non_null_literals = [literal for literal in literals if literal is not 
None]
+        if not non_null_literals:
+            return val.is_valid() & val.is_null()
+        return val.isin(non_null_literals) & val.is_valid()
 
 
 class NotIn(Tester):
     name = "notIn"
 
     def test_by_value(self, val, literals) -> bool:
-        if val is None:
+        if val is None or any(literal is None for literal in literals):
             return False
         return val not in literals
 
     def test_by_stats(self, min_v, max_v, literals) -> bool:
+        if any(literal is None for literal in literals):
+            return False
         return not any(min_v == l == max_v for l in literals)
 
     def test_by_arrow(self, val, literals) -> bool:
+        # Any null literal makes SQL NOT IN unknown for every non-matching row.
+        if any(literal is None for literal in literals):
+            return val.is_valid() & val.is_null()
         return (~val.isin(literals)) & val.is_valid()
 
 
diff --git a/paimon-python/pypaimon/tests/predicates_test.py 
b/paimon-python/pypaimon/tests/predicates_test.py
index 1752b72f8a..ff98be7937 100644
--- a/paimon-python/pypaimon/tests/predicates_test.py
+++ b/paimon-python/pypaimon/tests/predicates_test.py
@@ -349,6 +349,18 @@ class PredicateTest(unittest.TestCase):
         predicate = predicate_builder.is_in('f0', [1, 2])
         
_check_filtered_result(table.new_read_builder().with_filter(predicate), 
self.df.loc[0:1])
 
+    def test_is_in_with_null_literal_append(self):
+        table = self.catalog.get_table('default.test_append')
+        predicate_builder = table.new_read_builder().new_predicate_builder()
+
+        predicate = predicate_builder.is_in('f1', [None])
+        _check_filtered_result(
+            table.new_read_builder().with_filter(predicate), self.df.iloc[0:0])
+
+        predicate = predicate_builder.is_in('f1', ['abc', None])
+        _check_filtered_result(
+            table.new_read_builder().with_filter(predicate), self.df.loc[[0]])
+
     def test_is_in_pk(self):
         table = self.catalog.get_table('default.test_pk')
         predicate_builder = table.new_read_builder().new_predicate_builder()
@@ -361,6 +373,13 @@ class PredicateTest(unittest.TestCase):
         predicate = predicate_builder.is_not_in('f0', [1, 2])
         
_check_filtered_result(table.new_read_builder().with_filter(predicate), 
self.df.loc[2:4])
 
+    def test_is_not_in_with_null_literal_append(self):
+        table = self.catalog.get_table('default.test_append')
+        predicate_builder = table.new_read_builder().new_predicate_builder()
+        predicate = predicate_builder.is_not_in('f1', ['abc', None])
+        _check_filtered_result(
+            table.new_read_builder().with_filter(predicate), self.df.iloc[0:0])
+
     def test_is_not_in_pk(self):
         table = self.catalog.get_table('default.test_pk')
         predicate_builder = table.new_read_builder().new_predicate_builder()
@@ -558,6 +577,33 @@ class PredicateTest(unittest.TestCase):
 
         self.assertEqual(scanner.to_table().to_pydict(), {"val": [3]})
 
+    def test_in_and_not_in_null_literal_semantics(self):
+        table = pa.table({"val": [None, 1, 2]})
+
+        in_predicate = Predicate(
+            method='in', index=0, field='val', literals=[1, None])
+        scanner = ds.InMemoryDataset(table).scanner(
+            filter=in_predicate.to_arrow())
+        self.assertEqual(scanner.to_table().to_pydict(), {"val": [1]})
+
+        not_in_predicate = Predicate(
+            method='notIn', index=0, field='val', literals=[1, None])
+        scanner = ds.InMemoryDataset(table).scanner(
+            filter=not_in_predicate.to_arrow())
+        self.assertEqual(scanner.to_table().to_pydict(), {"val": []})
+        self.assertFalse(not_in_predicate.test(OffsetRow([2], 0, 1)))
+
+        fields = [DataField(0, 'val', 'INT')]
+        stats = SimpleStats(
+            min_values=GenericRow([1], fields),
+            max_values=GenericRow([2], fields),
+            null_counts=[0],
+        )
+        self.assertFalse(Predicate(
+            method='in', index=0, field='val', literals=[None]
+        ).test_by_simple_stats(stats, 2))
+        self.assertFalse(not_in_predicate.test_by_simple_stats(stats, 2))
+
     @pytest.mark.python_plan
     def test_pk_reader_with_filter(self):
         pa_schema = pa.schema([

Reply via email to