Skip to content

Commit c6a2c47

Browse files
jackylee-chclaude
andcommitted
Keep out-of-range literals when binding IN / NOT IN
Reviewer feedback: bind should not drop what the caller wrote. Converting a literal outside the field's range yields an AboveMax/BelowMin sentinel carrying the clamped boundary value, so keep the original literal instead of the sentinel. The bound set then round-trips, and `value_set` leaves the value out so no evaluator sees it -- every BoundBooleanExpressionVisitor receives `value_set`, so that is the one place the range has to be taken into account. Storing the sentinel is not an option: it is `==` and hash-equal to the boundary literal it clamps to, so the set collapses and a boundary value the caller did write can be lost. The tests now assert evaluation results rather than the bound shape, which no longer folds, plus the round trip itself. Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent 3726653 commit c6a2c47

2 files changed

Lines changed: 43 additions & 32 deletions

File tree

pyiceberg/expressions/__init__.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from pyiceberg.expressions.literals import AboveMax, BelowMin, Literal, literal
3131
from pyiceberg.schema import Accessor, Schema
3232
from pyiceberg.typedef import IcebergBaseModel, IcebergRootModel, L, LiteralValue, StructProtocol
33-
from pyiceberg.types import DoubleType, FloatType, NestedField
33+
from pyiceberg.types import DoubleType, FloatType, IcebergType, NestedField
3434
from pyiceberg.utils.singleton import Singleton
3535

3636

@@ -49,6 +49,12 @@ def _to_literal(value: L | Literal[L]) -> Literal[L]:
4949
return literal(value)
5050

5151

52+
def _to_bound_literal(lit: LiteralValue, field_type: IcebergType) -> LiteralValue | None:
53+
"""Convert a literal to the field's type, or None when the field can never hold its value."""
54+
converted = lit.to(field_type)
55+
return None if isinstance(converted, (AboveMax, BelowMin)) else converted
56+
57+
5258
class BooleanExpression(IcebergBaseModel, ABC):
5359
"""An expression that evaluates to a boolean."""
5460

@@ -698,14 +704,11 @@ def __init__(
698704
def bind(self, schema: Schema, case_sensitive: bool = True) -> BoundSetPredicate:
699705
bound_term = self.term.bind(schema, case_sensitive)
700706
field_type = bound_term.ref().field.field_type
701-
# Literals outside the field's range can never match, so drop them rather than
702-
# keep the clamped AboveMax/BelowMin sentinel in the bound set. Filter while
703-
# building the set: a sentinel is equal to the boundary literal it clamps to,
704-
# so collecting first would let it absorb a boundary value the user did write.
707+
# An out-of-range literal converts to an AboveMax/BelowMin sentinel that carries the
708+
# clamped boundary value, which would then match rows at the boundary. Keep the original
709+
# literal instead, so the bound set still round-trips to what the caller wrote.
705710
bound_literals = {
706-
bound_literal
707-
for bound_literal in (lit.to(field_type) for lit in self.literals)
708-
if not isinstance(bound_literal, (AboveMax, BelowMin))
711+
converted if (converted := _to_bound_literal(lit, field_type)) is not None else lit for lit in self.literals
709712
}
710713
return self.as_bound(bound_term, bound_literals) # type: ignore
711714

@@ -744,7 +747,10 @@ def __init__(self, term: BoundTerm, literals: set[LiteralValue]) -> None:
744747

745748
@cached_property
746749
def value_set(self) -> set[Any]:
747-
return {lit.value for lit in self.literals}
750+
field_type = self.term.ref().field.field_type
751+
# `literals` keeps every literal the caller wrote so the predicate round-trips, but a
752+
# value the field can never hold must not reach an evaluator.
753+
return {converted.value for lit in self.literals if (converted := _to_bound_literal(lit, field_type)) is not None}
748754

749755
def __str__(self) -> str:
750756
"""Return the string representation of the BoundSetPredicate class."""

tests/expressions/test_evaluator.py

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1912,31 +1912,40 @@ def test_strict_metrics_eval_bounds_after_promotion(
19121912
assert evaluator.eval(data_file) == expected
19131913

19141914

1915+
def test_bind_preserves_out_of_range_literals() -> None:
1916+
"""Binding keeps the literals the caller wrote, so the bound predicate still round-trips."""
1917+
schema = Schema(NestedField(1, "id", IntegerType(), required=False))
1918+
literals = [1, IntegerType.max + 1]
1919+
1920+
for predicate in [In("id", literals), NotIn("id", literals)]:
1921+
bound = predicate.bind(schema)
1922+
assert {lit.value for lit in bound.literals} == set(literals)
1923+
assert bound.as_unbound(bound.term.ref().field.name, bound.literals) == predicate
1924+
# Only the values the field can hold reach an evaluator
1925+
assert bound.value_set == {1}
1926+
1927+
19151928
def test_above_int_bounds_in() -> None:
19161929
schema = Schema(NestedField(1, "id", IntegerType(), required=False))
19171930
above_max = IntegerType.max + 1
19181931

1919-
assert In("id", [1, above_max]).bind(schema) == EqualTo("id", 1).bind(schema)
1920-
assert NotIn("id", [1, above_max]).bind(schema) == NotEqualTo("id", 1).bind(schema)
1921-
assert In("id", [above_max]).bind(schema) == AlwaysFalse()
1922-
assert NotIn("id", [above_max]).bind(schema) == AlwaysTrue()
1923-
1924-
# The clamped literal used to match the field's maximum
1932+
# The out-of-range literal used to be clamped to the maximum and match rows there
19251933
assert expression_evaluator(schema, In("id", [1, above_max]), True)(Record(IntegerType.max)) is False
19261934
assert expression_evaluator(schema, NotIn("id", [1, above_max]), True)(Record(IntegerType.max)) is True
1935+
assert expression_evaluator(schema, In("id", [1, above_max]), True)(Record(1)) is True
1936+
assert In("id", [above_max]).bind(schema) == AlwaysFalse()
1937+
assert NotIn("id", [above_max]).bind(schema) == AlwaysTrue()
19271938

19281939

19291940
def test_below_int_bounds_in() -> None:
19301941
schema = Schema(NestedField(1, "id", IntegerType(), required=False))
19311942
below_min = IntegerType.min - 1
19321943

1933-
assert In("id", [1, below_min]).bind(schema) == EqualTo("id", 1).bind(schema)
1934-
assert NotIn("id", [1, below_min]).bind(schema) == NotEqualTo("id", 1).bind(schema)
1935-
assert In("id", [below_min]).bind(schema) == AlwaysFalse()
1936-
assert NotIn("id", [below_min]).bind(schema) == AlwaysTrue()
1937-
19381944
assert expression_evaluator(schema, In("id", [1, below_min]), True)(Record(IntegerType.min)) is False
19391945
assert expression_evaluator(schema, NotIn("id", [1, below_min]), True)(Record(IntegerType.min)) is True
1946+
assert expression_evaluator(schema, In("id", [1, below_min]), True)(Record(1)) is True
1947+
assert In("id", [below_min]).bind(schema) == AlwaysFalse()
1948+
assert NotIn("id", [below_min]).bind(schema) == AlwaysTrue()
19401949

19411950

19421951
@pytest.mark.parametrize(
@@ -1952,8 +1961,6 @@ def test_int_bounds_in_all_literals_out_of_range(literals: list[int]) -> None:
19521961
in_expr = In("id", literals)
19531962
not_in_expr = NotIn("id", literals)
19541963

1955-
assert in_expr.bind(schema) == AlwaysFalse()
1956-
assert not_in_expr.bind(schema) == AlwaysTrue()
19571964
for value in [None, IntegerType.min, 0, IntegerType.max]:
19581965
assert expression_evaluator(schema, in_expr, True)(Record(value)) is False
19591966
assert expression_evaluator(schema, not_in_expr, True)(Record(value)) is True
@@ -1965,8 +1972,6 @@ def test_int_bounds_in_keeps_multiple_literals() -> None:
19651972
in_expr = In("id", literals)
19661973
not_in_expr = NotIn("id", literals)
19671974

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)
19701975
values = [None, 1, 2, 3, IntegerType.min, IntegerType.max]
19711976
eval_in = expression_evaluator(schema, in_expr, True)
19721977
eval_not_in = expression_evaluator(schema, not_in_expr, True)
@@ -1988,13 +1993,13 @@ def test_int_bounds_in_metrics(schema_data_file: Schema, boundary: int, out_of_r
19881993

19891994

19901995
def test_int_bounds_in_keeps_the_boundary_value() -> None:
1991-
"""A sentinel is equal to the boundary literal it clamps to, so it must not absorb it."""
1996+
"""A converted out-of-range literal clamps to the boundary, so it must not shadow it."""
19921997
schema = Schema(NestedField(1, "id", IntegerType(), required=False))
19931998

1994-
assert In("id", [IntegerType.max, IntegerType.max + 1]).bind(schema) == EqualTo("id", IntegerType.max).bind(schema)
1995-
assert NotIn("id", [IntegerType.max, IntegerType.max + 1]).bind(schema) == NotEqualTo("id", IntegerType.max).bind(schema)
1996-
assert In("id", [IntegerType.min, IntegerType.min - 1]).bind(schema) == EqualTo("id", IntegerType.min).bind(schema)
1997-
assert NotIn("id", [IntegerType.min, IntegerType.min - 1]).bind(schema) == NotEqualTo("id", IntegerType.min).bind(schema)
1998-
1999-
assert expression_evaluator(schema, In("id", [IntegerType.max, IntegerType.max + 1]), True)(Record(IntegerType.max)) is True
2000-
assert expression_evaluator(schema, In("id", [IntegerType.min, IntegerType.min - 1]), True)(Record(IntegerType.min)) is True
1999+
for boundary, out_of_range in [(IntegerType.max, IntegerType.max + 1), (IntegerType.min, IntegerType.min - 1)]:
2000+
eval_in = expression_evaluator(schema, In("id", [boundary, out_of_range]), True)
2001+
eval_not_in = expression_evaluator(schema, NotIn("id", [boundary, out_of_range]), True)
2002+
assert eval_in(Record(boundary)) is True
2003+
assert eval_not_in(Record(boundary)) is False
2004+
assert eval_in(Record(0)) is False
2005+
assert eval_not_in(Record(0)) is True

0 commit comments

Comments
 (0)