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 f0d15accad [python] Fix predicate pruning with incomplete statistics 
(#8978)
f0d15accad is described below

commit f0d15accadd707b127520d966fc80497f0ea8460
Author: XiaoHongbo <[email protected]>
AuthorDate: Sat Aug 1 20:36:25 2026 +0800

    [python] Fix predicate pruning with incomplete statistics (#8978)
---
 paimon-python/pypaimon/common/predicate.py         | 26 +++++++-
 .../pypaimon/manifest/schema/simple_stats.py       |  3 +-
 .../pypaimon/manifest/simple_stats_evolution.py    | 16 +++--
 paimon-python/pypaimon/table/row/projected_row.py  |  2 +-
 .../tests/manifest/simple_stats_evolutions_test.py | 50 +++++++++++++++
 paimon-python/pypaimon/tests/predicates_test.py    | 75 ++++++++++++++++++++++
 6 files changed, 160 insertions(+), 12 deletions(-)

diff --git a/paimon-python/pypaimon/common/predicate.py 
b/paimon-python/pypaimon/common/predicate.py
index 540705aa5c..ed1f434a96 100644
--- a/paimon-python/pypaimon/common/predicate.py
+++ b/paimon-python/pypaimon/common/predicate.py
@@ -72,15 +72,35 @@ class Predicate:
         if self.method == 'or':
             return any(p.test_by_simple_stats(stat, row_count) for p in 
self.literals)
 
-        null_count = stat.null_counts[self.index]
+        index = self.index
+        if index is None or index < 0:
+            # Missing stats cannot prove that the file does not match.
+            return True
+
+        null_count = (
+            stat.null_counts[index]
+            if stat.null_counts is not None and index < len(stat.null_counts)
+            else None
+        )
 
         if self.method == 'isNull':
             return null_count is None or null_count > 0
         if self.method == 'isNotNull':
             return null_count is None or row_count is None or null_count < 
row_count
 
-        min_value = stat.min_values.get_field(self.index)
-        max_value = stat.max_values.get_field(self.index)
+        try:
+            min_value = (
+                stat.min_values.get_field(index)
+                if index < len(stat.min_values)
+                else None
+            )
+            max_value = (
+                stat.max_values.get_field(index)
+                if index < len(stat.max_values)
+                else None
+            )
+        except IndexError:
+            return True
 
         if min_value is None or max_value is None or (null_count is not None 
and null_count == row_count):
             # invalid stats, skip validation
diff --git a/paimon-python/pypaimon/manifest/schema/simple_stats.py 
b/paimon-python/pypaimon/manifest/schema/simple_stats.py
index 0aa25abdde..059b050cd5 100644
--- a/paimon-python/pypaimon/manifest/schema/simple_stats.py
+++ b/paimon-python/pypaimon/manifest/schema/simple_stats.py
@@ -18,6 +18,7 @@
 from dataclasses import dataclass
 from typing import List
 from typing import ClassVar
+from typing import Optional
 
 from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.table.row.internal_row import InternalRow
@@ -27,7 +28,7 @@ from pypaimon.table.row.internal_row import InternalRow
 class SimpleStats:
     min_values: InternalRow
     max_values: InternalRow
-    null_counts: List[int]
+    null_counts: Optional[List[Optional[int]]]
 
     _empty_stats: ClassVar[object] = None
 
diff --git a/paimon-python/pypaimon/manifest/simple_stats_evolution.py 
b/paimon-python/pypaimon/manifest/simple_stats_evolution.py
index 601933238b..c76202b7e7 100644
--- a/paimon-python/pypaimon/manifest/simple_stats_evolution.py
+++ b/paimon-python/pypaimon/manifest/simple_stats_evolution.py
@@ -37,7 +37,7 @@ class SimpleStatsEvolution:
 
         # Create empty values for optimization
         self.empty_values = GenericRow([None] * len(self.field_names), 
data_fields)
-        self.empty_null_counts = [0] * len(self.field_names)
+        self.empty_null_counts = [None] * len(self.field_names)
 
     def evolution(self, stats: SimpleStats, row_count: Optional[int],
                   stats_fields: Optional[List[str]]) -> 'SimpleStats':
@@ -95,28 +95,30 @@ class SimpleStatsEvolution:
         projected_row = ProjectedRow.from_index_mapping(index_mapping)
         return projected_row.replace_row(row)
 
-    def _project_array(self, array: List[Any], index_mapping: List[int]) -> 
List[Any]:
+    def _project_array(self, array: Optional[List[Any]], index_mapping: 
List[int]) -> List[Any]:
         """Project array based on index mapping."""
         if not array:
-            return [0] * len(index_mapping)
+            return [None] * len(index_mapping)
 
         projected = []
         for mapped_index in index_mapping:
             if mapped_index >= 0 and mapped_index < len(array):
                 projected.append(array[mapped_index])
             else:
-                projected.append(0)  # Default value for missing fields
+                projected.append(None)
 
         return projected
 
-    def _evolve_null_counts(self, null_counts: List[Any], index_mapping: 
List[int],
+    def _evolve_null_counts(self, null_counts: Optional[List[Any]], 
index_mapping: List[int],
                             not_found_value: int) -> List[Any]:
         """Evolve null counts with schema evolution mapping."""
         evolved = []
         for mapped_index in index_mapping:
-            if mapped_index >= 0 and mapped_index < len(null_counts):
+            if mapped_index < 0:
+                evolved.append(not_found_value)
+            elif null_counts is not None and mapped_index < len(null_counts):
                 evolved.append(null_counts[mapped_index])
             else:
-                evolved.append(not_found_value)  # Use row count for missing 
fields
+                evolved.append(None)
 
         return evolved
diff --git a/paimon-python/pypaimon/table/row/projected_row.py 
b/paimon-python/pypaimon/table/row/projected_row.py
index 5fc66b792e..ec96c0d193 100644
--- a/paimon-python/pypaimon/table/row/projected_row.py
+++ b/paimon-python/pypaimon/table/row/projected_row.py
@@ -65,7 +65,7 @@ class ProjectedRow(InternalRow):
 
     def __len__(self) -> int:
         """Returns the number of fields in this row."""
-        return len(self.row)
+        return len(self.index_mapping)
 
     def __str__(self) -> str:
         """String representation of the projected row."""
diff --git 
a/paimon-python/pypaimon/tests/manifest/simple_stats_evolutions_test.py 
b/paimon-python/pypaimon/tests/manifest/simple_stats_evolutions_test.py
index 98d6433812..9f134a96cf 100644
--- a/paimon-python/pypaimon/tests/manifest/simple_stats_evolutions_test.py
+++ b/paimon-python/pypaimon/tests/manifest/simple_stats_evolutions_test.py
@@ -17,6 +17,7 @@
 
 import unittest
 
+from pypaimon.common.predicate import Predicate
 from pypaimon.manifest.simple_stats_evolutions import SimpleStatsEvolutions
 from pypaimon.schema.data_types import DataField, AtomicType
 from pypaimon.manifest.schema.simple_stats import SimpleStats
@@ -42,6 +43,22 @@ class SimpleStatsEvolutionsTest(unittest.TestCase):
         evolution = evolutions.get_or_create(0)
         self.assertIsNone(evolution.index_mapping)
 
+    def test_predicate_with_incomplete_projected_stats(self):
+        fields = self._make_fields([(0, 'a', 'INT'), (1, 'b', 'INT')])
+        evolution = SimpleStatsEvolutions(lambda _: fields, 0).get_or_create(0)
+        predicate = Predicate(method='equal', index=1, field='b', 
literals=[15])
+
+        for min_values, max_values in (([1], [10, 20]), ([1, 2], [10])):
+            stats = SimpleStats(
+                GenericRow(min_values, fields[:len(min_values)]),
+                GenericRow(max_values, fields[:len(max_values)]),
+                [0, 0],
+            )
+            evolved = evolution.evolution(stats, 10, ['a', 'b'])
+
+            with self.subTest(min_values=min_values, max_values=max_values):
+                self.assertTrue(predicate.test_by_simple_stats(evolved, 10))
+
     def test_added_column(self):
         """New column: mapping is -1, null_count = row_count."""
         data_fields = self._make_fields([(0, 'a', 'INT'), (1, 'b', 'INT')])
@@ -59,6 +76,39 @@ class SimpleStatsEvolutionsTest(unittest.TestCase):
         self.assertIsNone(evolved.max_values.get_field(2))
         self.assertEqual(evolved.null_counts, [0, 5, 500])
 
+    def test_missing_null_counts_remain_unknown(self):
+        fields = self._make_fields([(0, 'a', 'INT'), (1, 'b', 'INT')])
+        schemas = {0: fields}
+        evolution = SimpleStatsEvolutions(
+            lambda sid: schemas[sid], 0).get_or_create(0)
+        stats = SimpleStats(GenericRow([], []), GenericRow([], []), [])
+        predicate = Predicate(method='isNull', index=0, field='a')
+
+        for stats_fields in ([], ['a', 'b']):
+            evolved = evolution.evolution(
+                stats, row_count=100, stats_fields=stats_fields)
+            with self.subTest(stats_fields=stats_fields):
+                self.assertEqual(evolved.null_counts, [None, None])
+                self.assertTrue(predicate.test_by_simple_stats(evolved, 100))
+
+    def test_schema_evolution_preserves_unknown_null_counts(self):
+        data_fields = self._make_fields([(0, 'a', 'INT')])
+        table_fields = self._make_fields([(0, 'a', 'INT'), (1, 'b', 'INT')])
+        schemas = {0: data_fields, 1: table_fields}
+        evolution = SimpleStatsEvolutions(
+            lambda sid: schemas[sid], 1).get_or_create(0)
+        stats = SimpleStats(
+            GenericRow([1], data_fields), GenericRow([10], data_fields), [])
+
+        evolved = evolution.evolution(
+            stats, row_count=100, stats_fields=None)
+
+        self.assertEqual(evolved.null_counts, [None, 100])
+        self.assertTrue(
+            Predicate(method='isNull', index=0, field='a')
+            .test_by_simple_stats(evolved, 100)
+        )
+
     def test_dropped_column(self):
         """Dropped column is excluded from mapping."""
         data_fields = self._make_fields([(0, 'a', 'INT'), (1, 'b', 'STRING'), 
(2, 'c', 'BIGINT')])
diff --git a/paimon-python/pypaimon/tests/predicates_test.py 
b/paimon-python/pypaimon/tests/predicates_test.py
index 9bcea3b39a..d6f92963bf 100644
--- a/paimon-python/pypaimon/tests/predicates_test.py
+++ b/paimon-python/pypaimon/tests/predicates_test.py
@@ -28,8 +28,10 @@ import pyarrow.dataset as ds
 from pypaimon import CatalogFactory, Schema
 from pypaimon.common.predicate import Predicate
 from pypaimon.manifest.schema.simple_stats import SimpleStats
+from pypaimon.schema.data_types import DataField
 from pypaimon.table.row.generic_row import GenericRow, GenericRowDeserializer
 from pypaimon.table.row.offset_row import OffsetRow
+from pypaimon.table.row.projected_row import ProjectedRow
 
 
 def _check_filtered_result(read_builder, expected_df):
@@ -443,6 +445,79 @@ class PredicateTest(unittest.TestCase):
         )
         self.assertTrue(pred.test_by_simple_stats(stat_positive, 10))
 
+    def test_by_simple_stats_with_incomplete_fields(self):
+        fields = [DataField(0, 'f0', 'INT'), DataField(1, 'f1', 'INT')]
+        predicate = Predicate(method='equal', index=1, field='f1', 
literals=[15])
+        stats = [
+            SimpleStats(
+                min_values=GenericRow([1], fields[:1]),
+                max_values=GenericRow([10, 20], fields),
+                null_counts=[0, 0],
+            ),
+            SimpleStats(
+                min_values=GenericRow([1, 2], fields),
+                max_values=GenericRow([10], fields[:1]),
+                null_counts=[0, 0],
+            ),
+            SimpleStats(
+                min_values=GenericRow([1], fields[:1]),
+                max_values=GenericRow([10], fields[:1]),
+                null_counts=[0],
+            ),
+        ]
+
+        for stat in stats:
+            with self.subTest(stat=stat):
+                self.assertTrue(predicate.test_by_simple_stats(stat, 10))
+
+    def test_by_simple_stats_without_null_counts(self):
+        fields = [DataField(0, 'f0', 'INT')]
+        predicate = Predicate(method='equal', index=0, field='f0', 
literals=[20])
+        for null_counts in (None, []):
+            stat = SimpleStats(
+                min_values=GenericRow([1], fields),
+                max_values=GenericRow([10], fields),
+                null_counts=null_counts,
+            )
+            with self.subTest(null_counts=null_counts):
+                self.assertFalse(predicate.test_by_simple_stats(stat, 10))
+
+    def test_by_simple_stats_with_invalid_index(self):
+        fields = [DataField(0, 'f0', 'INT')]
+        stat = SimpleStats(
+            min_values=GenericRow([1], fields),
+            max_values=GenericRow([10], fields),
+            null_counts=[0],
+        )
+        for index in (None, -1):
+            predicate = Predicate(
+                method='equal', index=index, field='_ROW_ID', literals=[5])
+            with self.subTest(index=index):
+                self.assertTrue(predicate.test_by_simple_stats(stat, 10))
+
+    def test_by_simple_stats_null_predicate_without_null_counts(self):
+        fields = [DataField(0, 'f0', 'INT')]
+        stat = SimpleStats(
+            min_values=GenericRow([1], fields),
+            max_values=GenericRow([10], fields),
+            null_counts=None,
+        )
+        for method in ('isNull', 'isNotNull'):
+            predicate = Predicate(method=method, index=0, field='f0')
+            with self.subTest(method=method):
+                self.assertTrue(predicate.test_by_simple_stats(stat, 10))
+
+    def test_by_simple_stats_with_projected_rows(self):
+        fields = [DataField(0, 'f0', 'INT'), DataField(1, 'f1', 'INT')]
+        row = GenericRow([1, 2], fields)
+        min_values = ProjectedRow.from_index_mapping([0]).replace_row(row)
+        max_values = ProjectedRow.from_index_mapping([0]).replace_row(row)
+        stat = SimpleStats(min_values, max_values, [0, 0])
+        predicate = Predicate(method='equal', index=1, field='f1', 
literals=[2])
+
+        self.assertEqual(len(min_values), 1)
+        self.assertTrue(predicate.test_by_simple_stats(stat, 10))
+
     def test_filter_with_null_and_or(self):
         p_gt = Predicate(method='greaterThan', index=1, field='score', 
literals=[10])
         p_null = Predicate(method='isNull', index=1, field='score', 
literals=[])

Reply via email to