Skip to content

Commit 75689b2

Browse files
committed
Preserve nulls when simplifying out-of-range NOT IN predicates
Keep null rows when NOT IN simplifies to NotEqualTo so Arrow scans agree with the expression evaluator. Cover bounds, metrics, and int-to-long schema evolution with evaluator and file scan regression tests. Generated-by: Codex
1 parent 5d5586e commit 75689b2

3 files changed

Lines changed: 87 additions & 5 deletions

File tree

pyiceberg/io/pyarrow.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,8 @@ def visit_equal(self, term: BoundTerm, literal: Literal[Any]) -> pc.Expression:
919919
return pc.field(self._get_field_name(term)) == _convert_scalar(literal.value, term.ref().field.field_type)
920920

921921
def visit_not_equal(self, term: BoundTerm, literal: Literal[Any]) -> pc.Expression:
922-
return pc.field(self._get_field_name(term)) != _convert_scalar(literal.value, term.ref().field.field_type)
922+
ref = pc.field(self._get_field_name(term))
923+
return ref.is_null(nan_is_null=False) | (ref != _convert_scalar(literal.value, term.ref().field.field_type))
923924

924925
def visit_greater_than_or_equal(self, term: BoundTerm, literal: Literal[Any]) -> pc.Expression:
925926
return pc.field(self._get_field_name(term)) >= _convert_scalar(literal.value, term.ref().field.field_type)

tests/expressions/test_evaluator.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1939,6 +1939,54 @@ def test_below_int_bounds_in() -> None:
19391939
assert expression_evaluator(schema, NotIn("id", [1, below_min]), True)(Record(IntegerType.min)) is True
19401940

19411941

1942+
@pytest.mark.parametrize(
1943+
"literals",
1944+
[
1945+
[IntegerType.max + 1, IntegerType.max + 2],
1946+
[IntegerType.min - 1, IntegerType.min - 2],
1947+
[IntegerType.min - 1, IntegerType.max + 1],
1948+
],
1949+
)
1950+
def test_int_bounds_in_all_literals_out_of_range(literals: list[int]) -> None:
1951+
schema = Schema(NestedField(1, "id", IntegerType(), required=False))
1952+
in_expr = In("id", literals)
1953+
not_in_expr = NotIn("id", literals)
1954+
1955+
assert in_expr.bind(schema) == AlwaysFalse()
1956+
assert not_in_expr.bind(schema) == AlwaysTrue()
1957+
for value in [None, IntegerType.min, 0, IntegerType.max]:
1958+
assert expression_evaluator(schema, in_expr, True)(Record(value)) is False
1959+
assert expression_evaluator(schema, not_in_expr, True)(Record(value)) is True
1960+
1961+
1962+
def test_int_bounds_in_keeps_multiple_literals() -> None:
1963+
schema = Schema(NestedField(1, "id", IntegerType(), required=False))
1964+
literals = [1, 2, IntegerType.min - 1, IntegerType.max + 1]
1965+
in_expr = In("id", literals)
1966+
not_in_expr = NotIn("id", literals)
1967+
1968+
assert in_expr.bind(schema) == In("id", [1, 2]).bind(schema)
1969+
assert not_in_expr.bind(schema) == NotIn("id", [1, 2]).bind(schema)
1970+
values = [None, 1, 2, 3, IntegerType.min, IntegerType.max]
1971+
eval_in = expression_evaluator(schema, in_expr, True)
1972+
eval_not_in = expression_evaluator(schema, not_in_expr, True)
1973+
assert [value for value in values if eval_in(Record(value))] == [1, 2]
1974+
assert [value for value in values if eval_not_in(Record(value))] == [None, 3, IntegerType.min, IntegerType.max]
1975+
1976+
1977+
@pytest.mark.parametrize(
1978+
"boundary,out_of_range",
1979+
[(IntegerType.min, IntegerType.min - 1), (IntegerType.max, IntegerType.max + 1)],
1980+
)
1981+
def test_int_bounds_in_metrics(schema_data_file: Schema, boundary: int, out_of_range: int) -> None:
1982+
bounds = {1: to_bytes(IntegerType(), boundary)}
1983+
data_file = _single_value_metrics_file(boundary, lower_bounds=bounds, upper_bounds=bounds)
1984+
1985+
assert _InclusiveMetricsEvaluator(schema_data_file, In("id", [1, out_of_range])).eval(data_file) == ROWS_CANNOT_MATCH
1986+
assert _StrictMetricsEvaluator(schema_data_file, NotIn("id", [1, out_of_range])).eval(data_file) == ROWS_MUST_MATCH
1987+
assert _StrictMetricsEvaluator(schema_data_file, In("id", [1, boundary, out_of_range])).eval(data_file) == ROWS_MUST_MATCH
1988+
1989+
19421990
def test_int_bounds_in_keeps_the_boundary_value() -> None:
19431991
"""A sentinel is equal to the boundary literal it clamps to, so it must not absorb it."""
19441992
schema = Schema(NestedField(1, "id", IntegerType(), required=False))

tests/io/test_pyarrow.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@
5959
BoundReference,
6060
BoundStartsWith,
6161
GreaterThan,
62+
In,
6263
Not,
64+
NotIn,
6365
Or,
6466
)
6567
from pyiceberg.expressions.literals import literal
@@ -783,10 +785,41 @@ def test_expr_equal_to_pyarrow(bound_reference: BoundReference) -> None:
783785

784786

785787
def test_expr_not_equal_to_pyarrow(bound_reference: BoundReference) -> None:
786-
assert (
787-
repr(expression_to_pyarrow(BoundNotEqualTo(bound_reference, literal("hello"))))
788-
== '<pyarrow.compute.Expression (foo != "hello")>'
789-
)
788+
table = pa.table({"foo": [None, "hello", "world"]})
789+
expression = expression_to_pyarrow(BoundNotEqualTo(bound_reference, literal("hello")))
790+
assert table.filter(expression).column("foo").to_pylist() == [None, "world"]
791+
792+
793+
@pytest.mark.parametrize("boundary", [IntegerType.min, IntegerType.max])
794+
@pytest.mark.parametrize("valid_values", [[], [1], [1, 2], [IntegerType.min], [IntegerType.max]])
795+
def test_scan_in_out_of_range_literals(catalog: InMemoryCatalog, tmp_path: Path, boundary: int, valid_values: list[int]) -> None:
796+
schema = Schema(NestedField(1, "id", IntegerType(), required=False))
797+
catalog.create_namespace("default")
798+
table = catalog.create_table("default.out_of_range", schema=schema, location=str(tmp_path))
799+
values = [None, IntegerType.min, 1, 2, IntegerType.max]
800+
table.append(pa.table({"id": pa.array(values, type=pa.int32())}))
801+
out_of_range = boundary - 1 if boundary == IntegerType.min else boundary + 1
802+
literals = [*valid_values, out_of_range, out_of_range * 2]
803+
804+
assert table.scan(row_filter=In("id", literals)).to_arrow().column("id").to_pylist() == [
805+
value for value in values if value in valid_values
806+
]
807+
assert table.scan(row_filter=NotIn("id", literals)).to_arrow().column("id").to_pylist() == [
808+
value for value in values if value not in valid_values
809+
]
810+
811+
812+
def test_scan_in_out_of_range_literals_after_type_promotion(catalog: InMemoryCatalog, tmp_path: Path) -> None:
813+
schema = Schema(NestedField(1, "id", IntegerType(), required=False))
814+
catalog.create_namespace("default")
815+
table = catalog.create_table("default.promoted_int", schema=schema, location=str(tmp_path))
816+
table.append(pa.table({"id": pa.array([None, 1, IntegerType.max], type=pa.int32())}))
817+
with table.update_schema() as update:
818+
update.update_column("id", field_type=LongType())
819+
table.append(pa.table({"id": pa.array([2**40], type=pa.int64())}))
820+
821+
assert sorted(table.scan(row_filter=In("id", [1, 2**40])).to_arrow().column("id").to_pylist()) == [1, 2**40]
822+
assert table.scan(row_filter=NotIn("id", [1, 2**40])).to_arrow().column("id").to_pylist() == [None, IntegerType.max]
790823

791824

792825
def test_expr_greater_than_or_equal_equal_to_pyarrow(bound_reference: BoundReference) -> None:

0 commit comments

Comments
 (0)