Skip to content

Reject out-of-range literals when binding IN / NOT IN - #3916

Open
jackylee-ch wants to merge 4 commits into
apache:mainfrom
jackylee-ch:reject-out-of-range-in-literals
Open

Reject out-of-range literals when binding IN / NOT IN#3916
jackylee-ch wants to merge 4 commits into
apache:mainfrom
jackylee-ch:reject-out-of-range-in-literals

Conversation

@jackylee-ch

@jackylee-ch jackylee-ch commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

SetPredicate.bind keeps the AboveMax/BelowMin sentinel that Literal.to() returns for a literal outside the field's range. The sentinel carries the clamped boundary value, so it matches rows at the boundary:

In("id", [1, 2**40]).bind(schema)     # matches id = 2147483647
NotIn("id", [1, 2**40]).bind(schema)  # excludes id = 2147483647

Drop those literals while building the bound set. Filtering before the set is built matters: a sentinel is == and hash-equal to the boundary literal it clamps to, so collecting first lets it absorb a boundary value the user did write.

LiteralPredicate.bind already folds the same sentinels for the non-set operators, and bindInOperation in the reference implementation filters them in the same place, before the set is built.

The Arrow push-downs disagree about nulls today: ~isin(...) keeps them, field != value drops them. A NOT IN that reduces to a single literal therefore stops returning rows where the column is null, which also makes the scan agree with what a delete using the same filter removes. #3918 (draft) has the details.

Are these changes tested?

Yes, in tests/expressions/test_evaluator.py and tests/io/test_pyarrow.py: the bound form, expression_evaluator, the inclusive and strict metrics evaluators, and an end-to-end scan including after a type promotion. 16 of the 20 cases fail without the change.

Are there any user-facing changes?

IN/NOT IN with a literal outside the column's range no longer matches or excludes rows at the range boundary.

`SetPredicate.bind` kept the result of `Literal.to(field_type)` for every
literal. For a value outside the field's range that result is the
`AboveMax`/`BelowMin` sentinel, whose value is the type's max/min, so the
bound set held a literal the user never wrote.

On an `int` column, `id in (1, 2**40)` matched rows where `id` equals
2147483647, and the `not in` form dropped them. `LiteralPredicate.bind`
already folds these to `AlwaysTrue`/`AlwaysFalse`, and Java's
`bindInOperation` filters them out of the set; do the same here. An empty
set after filtering is already folded by `BoundIn`/`BoundNotIn`.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 7, 2026 03:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The current set-building approach in SetPredicate.bind can de-duplicate a real boundary literal against an out-of-range sentinel and then drop it during filtering, changing semantics for inputs like [IntegerType.max, IntegerType.max + 1].

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR adjusts expression binding in pyiceberg.expressions so IN / NOT IN predicates no longer “clamp” out-of-range literals into the bound set (via AboveMax/BelowMin), aligning behavior with other predicate types and preventing incorrect matches at type boundaries.

Changes:

  • Update SetPredicate.bind to drop AboveMax/BelowMin sentinels produced by Literal.to(field_type) when binding IN / NOT IN.
  • Add evaluator-level regression tests ensuring out-of-range IN / NOT IN no longer matches/excludes the clamped boundary values.
  • Add bind-form assertions verifying folding behavior when the filtered set becomes empty or singleton.
File summaries
File Description
pyiceberg/expressions/__init__.py Filters out-of-range literal sentinels during IN / NOT IN binding so they can’t match boundary values.
tests/expressions/test_evaluator.py Adds regression tests for above-max / below-min literals in IN / NOT IN binding and evaluation.
Review details

Suppressed comments (1)

tests/expressions/test_evaluator.py:1935

  • Add the analogous boundary+out-of-range assertion for the lower bound case too (e.g., [IntegerType.min, IntegerType.min - 1]) to ensure binding never drops a user-provided IntegerType.min when an out-of-range literal is present.
    below_min = IntegerType.min - 1

    assert In("id", [1, below_min]).bind(schema) == EqualTo("id", 1).bind(schema)
    assert NotIn("id", [1, below_min]).bind(schema) == NotEqualTo("id", 1).bind(schema)
    assert In("id", [below_min]).bind(schema) == AlwaysFalse()
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pyiceberg/expressions/__init__.py Outdated
Comment on lines +700 to +706
field_type = bound_term.ref().field.field_type
# Literals outside the field's range can never match, so drop them rather
# than keep the clamped AboveMax/BelowMin sentinel in the bound set
bound_literals = {lit.to(field_type) for lit in self.literals}
return self.as_bound( # type: ignore
bound_term, {lit for lit in bound_literals if not isinstance(lit, (AboveMax, BelowMin))}
)
Comment on lines +1915 to +1926
def test_above_int_bounds_in() -> None:
schema = Schema(NestedField(1, "id", IntegerType(), required=False))
above_max = IntegerType.max + 1

assert In("id", [1, above_max]).bind(schema) == EqualTo("id", 1).bind(schema)
assert NotIn("id", [1, above_max]).bind(schema) == NotEqualTo("id", 1).bind(schema)
assert In("id", [above_max]).bind(schema) == AlwaysFalse()
assert NotIn("id", [above_max]).bind(schema) == AlwaysTrue()

# The clamped literal used to match the field's maximum
assert expression_evaluator(schema, In("id", [1, above_max]), True)(Record(IntegerType.max)) is False
assert expression_evaluator(schema, NotIn("id", [1, above_max]), True)(Record(IntegerType.max)) is True
Collecting the converted literals first let an AboveMax/BelowMin sentinel
absorb a boundary value the user did write: the sentinel's value is the
type's max/min, and Literal equality compares only the value, so
`{IntAboveMax(), LongLiteral(2147483647)}` has one element. Filtering after
that dropped both, turning `id in (2147483647, 2**40)` into AlwaysFalse.

Filter inside the comprehension so a sentinel never enters the set, and add
the boundary-plus-out-of-range case to the tests.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@Fokko Fokko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jackylee-ch for adding this. I believe you hit a edge case here. However, I don't like the idea of dropping these literals when binding:

For example, the following is unexpected for me:

lit = NotIn("id", [1, 2**40])
assert lit.bind(schema).as_unbound() == lit

Which would fail with the change suggested by this PR.

Instead, this should be handled by the evaluators. This is also where we handle the out of bounds case of the non-set operators. I'm curious what the current behavior is when feeding this into the evaluators, maybe we can start with some tests over there.

jackylee-ch and others added 2 commits September 7, 2026 16:01
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
Pushing NotEqualTo down to Arrow drops rows where the column is null. That is
a pre-existing bug, not one this change introduces: a single-literal NOT IN
already folded to NotEqualTo before it. It changes the result of every `!=`
row filter, so it belongs in its own change rather than here.

Drop the null rows from the two scan tests so they no longer depend on it.
`visit_not_in` needs no change, so a NOT IN that keeps two or more literals
still keeps its nulls.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@jackylee-ch

Copy link
Copy Markdown
Contributor Author

bindInOperation filters aboveMax/belowMin at bind time, before building the set — same place and order as here. It cannot move to the evaluators: a sentinel is == and hash-equal to the boundary literal, so the set collapses first. != nulls split out into #3918 (draft).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants