Skip to content

Fix residual ordered comparisons for null identity partitions - #3817

Open
kevinjqliu wants to merge 2 commits into
apache:mainfrom
kevinjqliu:kevinjqliu/codex-null-residual-comparisons
Open

Fix residual ordered comparisons for null identity partitions#3817
kevinjqliu wants to merge 2 commits into
apache:mainfrom
kevinjqliu:kevinjqliu/codex-null-residual-comparisons

Conversation

@kevinjqliu

@kevinjqliu kevinjqliu commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes #3498

Summary

Fix ResidualVisitor for <, <=, >, and >= on nullable identity partitions. Comparing a null partition value (None) with a literal raised TypeError during scan planning.

A null value does not satisfy any ordered comparison with a literal, so the residual is AlwaysFalse.

Supersedes #3520. The NotNaN fix is already in #3689.

Tests

  • Direct residual tests for all four operators with a null identity partition
  • Public DataScan.count() regression test for <

@kevinjqliu
kevinjqliu requested a balanced review from Copilot August 21, 2026 17:52
@kevinjqliu
kevinjqliu marked this pull request as ready for review August 21, 2026 17:52

This comment was marked as outdated.

@kevinjqliu
kevinjqliu marked this pull request as draft August 21, 2026 18:14
@kevinjqliu
kevinjqliu force-pushed the kevinjqliu/codex-null-residual-comparisons branch from 85e420d to 18c8c94 Compare August 21, 2026 18:36
@kevinjqliu kevinjqliu changed the title Fix residual comparisons for null identity partitions Fix residual ordered comparisons for null identity partitions Aug 21, 2026
Comment thread pyiceberg/expressions/visitors.py Outdated
def visit_less_than(self, term: BoundTerm, literal: LiteralValue) -> BooleanExpression:
if term.eval(self.struct) < literal.value:
value = term.eval(self.struct)
if value is None or value < literal.value:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This seems like another issue where AlwaysFalse and AlwaysTrue both evaluate to True.

I'm happy to create a small helper method that we can use in these situations

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this ones slightly different. the issue is that the term.eval(self.struct) can resolve to None, and in python
None < literal.value comparison is a type error.

@kevinjqliu
kevinjqliu force-pushed the kevinjqliu/codex-null-residual-comparisons branch 11 times, most recently from 46c8202 to 70015b5 Compare August 22, 2026 15:43
tanmayrauth and others added 2 commits August 22, 2026 08:54
ResidualVisitor diverged from row-level expression evaluation on null values:

- visit_less_than / visit_less_than_or_equal / visit_greater_than /
  visit_greater_than_or_equal compared the partition value to the literal
  directly. A nullable identity-partitioned column with a None partition value
  raised a TypeError (None < literal), while _ExpressionEvaluator guards with
  "value is not None" and treats the row as non-matching. Add the same guard so
  a null partition value yields AlwaysFalse instead of crashing during scan
  planning (ResidualEvaluator.residual_for).

- visit_not_nan returned AlwaysFalse for a None value because None is not a
  SupportsFloat, whereas _ExpressionEvaluator.visit_not_nan (val == val) treats
  null as satisfying not-NaN. Invert the check so only NaN fails not-NaN and
  null (and any non-float value) passes, matching row evaluation.

Update the test that encoded the old NotNaN(None) -> AlwaysFalse result and add
a regression test covering None partition values for all four ordering
comparisons.

Fixes apache#3498 (partially)
@kevinjqliu
kevinjqliu force-pushed the kevinjqliu/codex-null-residual-comparisons branch from 70015b5 to a459a45 Compare August 22, 2026 15:54

res_eval = residual_evaluator_of(spec=spec, expr=predicate, case_sensitive=True, schema=schema)

assert res_eval.residual_for(Record(None)) == AlwaysFalse()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

resolving to false makes sense because null is not less than 1 😄

Comment thread tests/table/test_init.py

assert len(tasks) == 1
assert tasks[0].residual == EqualTo("y", 2)
assert scan.count() == 1 # Only the y == 2 row matches.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added an user facing test

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.

Visitor and evaluator edge cases can over-prune files or mishandle nulls

4 participants