Fokko commented on code in PR #4346:
URL: https://github.com/apache/iceberg-python/pull/4346#discussion_r4542670789


##########
pyiceberg/io/pyarrow.py:
##########
@@ -1721,6 +1733,29 @@ def _get_column_projection_values(
     return projected_missing_fields
 
 
+def _has_nan_sensitive_predicate(expr: BooleanExpression, negated: bool = 
False) -> bool:
+    """Return whether a predicate in the expression can match NaN values that 
pyarrow may prune away.
+
+    Parquet statistics exclude NaN, so a row group whose non-NaN values are 
all equal reports min == max.
+    pyarrow then prunes it for predicates that the NaN values satisfy: IsNaN, 
NotEqualTo and NotIn on a
+    float or double column, and any predicate on such a column under a NOT.
+    """
+    if isinstance(expr, (And, Or)):
+        return _has_nan_sensitive_predicate(expr.left, negated) or 
_has_nan_sensitive_predicate(expr.right, negated)
+    if isinstance(expr, Not):

Review Comment:
   I'm not sure NOT belongs here. The metrics evaluator applies `rewrite_not`, 
so `not x < 6.0` is evaluated as `x >= 6.0` at the manifest level and a file 
with upper bound 5.0 is skipped before we get here. With this change, the NaN 
row comes back for `not x < 6.0` only when the file has another row group that 
survives, and `x >= 6.0` never returns it. Could we instead apply `rewrite_not` 
to the bound filter before `expression_to_pyarrow`, in a separate PR? Then NOT 
is never NaN-sensitive and the `negated` flag can go.



##########
tests/io/test_pyarrow.py:
##########
@@ -4198,6 +4203,61 @@ def 
test_task_to_record_batches_filter_after_positional_deletes_empty_result(tmp
     assert result_batches == []
 
 
+def 
test_task_to_record_batches_keeps_nan_rows_for_nan_sensitive_filters(tmpdir: 
str) -> None:
+    """Regression test: pyarrow must not prune a row group that holds NaN rows 
matching the filter.
+
+    Parquet statistics exclude NaN, so the row group [NaN, 5.0, 5.0] reports 
min == max == 5.0.
+    pyarrow prunes such a row group for IsNaN, NotEqualTo, NotIn and a NOT 
over a float comparison,
+    which drops the NaN row. NotNaN and a NOT over an integer column are 
controls.
+    """
+    from pyiceberg.expressions.visitors import bind
+
+    arrow_schema = pa.schema(
+        (
+            pa.field("id", pa.int32(), nullable=True, 
metadata={PYARROW_PARQUET_FIELD_ID_KEY: "1"}),
+            pa.field("x", pa.float64(), nullable=True, 
metadata={PYARROW_PARQUET_FIELD_ID_KEY: "2"}),
+        )
+    )
+    arrow_table = pa.table(
+        {"id": pa.array([0, 1, 2], type=pa.int32()), "x": 
pa.array([float("nan"), 5.0, 5.0], type=pa.float64())},
+        schema=arrow_schema,
+    )
+    data_file = 
_write_table_to_data_file(f"{tmpdir}/nan_row_group_equal_bounds.parquet", 
arrow_schema, arrow_table)
+
+    table_schema = Schema(
+        NestedField(1, "id", IntegerType(), required=False),
+        NestedField(2, "x", DoubleType(), required=False),
+    )
+    projected_schema = Schema(NestedField(1, "id", IntegerType(), 
required=False))
+
+    cases: list[tuple[BooleanExpression, list[int]]] = [
+        (IsNaN("x"), [0]),
+        (NotEqualTo("x", 5.0), [0]),
+        (NotIn("x", {5.0, 6.0}), [0]),
+        (And(IsNaN("x"), EqualTo("id", 0)), [0]),
+        (Or(IsNaN("x"), EqualTo("id", 2)), [0, 2]),
+        (Not(LessThan("x", 6.0)), [0]),

Review Comment:
   This locks in that NaN matches `NOT(x < 6.0)`, which disagrees with how the 
manifest evaluator treats it (`x >= 6.0`, where NaN does not match). I'd remove 
this case along with the NOT handling.



##########
pyiceberg/io/pyarrow.py:
##########
@@ -1761,21 +1796,35 @@ def _task_to_record_batches(
         )
 
         pyarrow_filter = None
+        scanner_filter = None
+        # Set when the scanner does not apply the whole filter, so each batch 
is filtered after the read
+        filter_batches = False
         if bound_row_filter is not AlwaysTrue():
             translated_row_filter = translate_column_names(
                 bound_row_filter, file_schema, case_sensitive=case_sensitive, 
projected_field_values=projected_missing_fields
             )
             bound_file_filter = bind(file_schema, translated_row_filter, 
case_sensitive=case_sensitive)
             pyarrow_filter = expression_to_pyarrow(bound_file_filter, 
file_schema)
+            if positional_deletes:
+                # Positional deletes must be applied before any row is filtered
+                filter_batches = True
+            elif _has_nan_sensitive_predicate(bound_file_filter):
+                # Parquet statistics exclude NaN, so pyarrow can prune a row 
group that holds NaN rows matching

Review Comment:
   Is there an upstream Arrow issue for pruning on statistics that ignore NaN? 
It would be nice to link it here, so we know when this workaround can be 
removed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to