Skip to content

Commit 83bacb7

Browse files
Fix IsNull/NotNull binding ignoring ancestor nullability for required nested fields
BoundIsNull/BoundNotNull folded to AlwaysFalse()/AlwaysTrue() at bind time using only a field's own required flag. A required leaf field can still be absent from a row if an optional ancestor struct containing it is null, so plan_files() was silently dropping matching files once IsNull/NotNull hit a required field under an optional parent. Add Schema.is_field_required_in_path to walk the existing ancestor index and check the full path, and move the fold from BoundIsNull/BoundNotNull.__new__ into IsNull.bind()/NotNull.bind(), where the schema is already in scope. BoundReference and all other public signatures are unchanged. This mirrors Java's fix for the same bug (apache/iceberg#14270), which similarly reverted an earlier attempt to carry ancestor-nullability on the Accessor/BoundTerm itself (apache/iceberg#13804) in favor of a schema-level ancestor walk done only at bind time.
1 parent 068aae5 commit 83bacb7

4 files changed

Lines changed: 94 additions & 10 deletions

File tree

‎pyiceberg/expressions/__init__.py‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -568,11 +568,6 @@ def __getnewargs__(self) -> tuple[BoundTerm]:
568568

569569

570570
class BoundIsNull(BoundUnaryPredicate):
571-
def __new__(cls, term: BoundTerm) -> BooleanExpression: # pylint: disable=W0221
572-
if term.ref().field.required:
573-
return AlwaysFalse()
574-
return super().__new__(cls)
575-
576571
def __invert__(self) -> BoundNotNull:
577572
"""Transform the Expression into its negated version."""
578573
return BoundNotNull(self.term)
@@ -583,11 +578,6 @@ def as_unbound(self) -> type[IsNull]:
583578

584579

585580
class BoundNotNull(BoundUnaryPredicate):
586-
def __new__(cls, term: BoundTerm) -> BooleanExpression: # pylint: disable=W0221
587-
if term.ref().field.required:
588-
return AlwaysTrue()
589-
return super().__new__(cls)
590-
591581
def __invert__(self) -> BoundIsNull:
592582
"""Transform the Expression into its negated version."""
593583
return BoundIsNull(self.term)
@@ -607,6 +597,13 @@ def __invert__(self) -> NotNull:
607597
"""Transform the Expression into its negated version."""
608598
return NotNull(self.term)
609599

600+
def bind(self, schema: Schema, case_sensitive: bool = True) -> BooleanExpression:
601+
"""Bind the term, folding to AlwaysFalse() if the field and all its ancestors are required."""
602+
bound_term = self.term.bind(schema, case_sensitive)
603+
if schema.is_field_required_in_path(bound_term.ref().field.field_id):
604+
return AlwaysFalse()
605+
return BoundIsNull(bound_term)
606+
610607
@property
611608
def as_bound(self) -> type[BoundIsNull]: # type: ignore
612609
return BoundIsNull
@@ -622,6 +619,13 @@ def __invert__(self) -> IsNull:
622619
"""Transform the Expression into its negated version."""
623620
return IsNull(self.term)
624621

622+
def bind(self, schema: Schema, case_sensitive: bool = True) -> BooleanExpression:
623+
"""Bind the term, folding to AlwaysTrue() if the field and all its ancestors are required."""
624+
bound_term = self.term.bind(schema, case_sensitive)
625+
if schema.is_field_required_in_path(bound_term.ref().field.field_id):
626+
return AlwaysTrue()
627+
return BoundNotNull(bound_term)
628+
625629
@property
626630
def as_bound(self) -> type[BoundNotNull]: # type: ignore
627631
return BoundNotNull

‎pyiceberg/schema.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,26 @@ def accessor_for_field(self, field_id: int) -> Accessor:
282282

283283
return self._lazy_id_to_accessor[field_id]
284284

285+
def is_field_required_in_path(self, field_id: int) -> bool:
286+
"""Check whether a field and every struct ancestor on its path to the root are required.
287+
288+
Args:
289+
field_id (int): The ID of the field.
290+
291+
Returns:
292+
bool: True if the field and all of its ancestors are required, False otherwise.
293+
"""
294+
if not self.find_field(field_id).required:
295+
return False
296+
297+
parent_id = self._lazy_id_to_parent.get(field_id)
298+
while parent_id is not None:
299+
if not self.find_field(parent_id).required:
300+
return False
301+
parent_id = self._lazy_id_to_parent.get(parent_id)
302+
303+
return True
304+
285305
def identifier_field_names(self) -> set[str]:
286306
"""Return the names of the identifier fields.
287307

‎tests/expressions/test_expressions.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,16 @@ def test_bound_is_not_null(term: BoundReference) -> None:
818818
assert bound_not_null == eval(repr(bound_not_null))
819819

820820

821+
def test_bound_is_null_direct_construction_does_not_fold_even_for_required_field() -> None:
822+
# The required-field fold now lives in IsNull.bind()/NotNull.bind(), not here.
823+
required_term = BoundReference(
824+
field=NestedField(field_id=1, name="foo", field_type=StringType(), required=True),
825+
accessor=Accessor(position=0),
826+
)
827+
assert isinstance(BoundIsNull(required_term), BoundIsNull)
828+
assert isinstance(BoundNotNull(required_term), BoundNotNull)
829+
830+
821831
def test_is_null() -> None:
822832
ref = Reference("a")
823833
is_null = IsNull(ref)
@@ -1292,6 +1302,31 @@ def test_nested_bind() -> None:
12921302
assert IsNull(Reference("foo.bar")).bind(schema) == bound
12931303

12941304

1305+
def test_is_null_required_field_under_optional_ancestor_does_not_bind_to_always_false() -> None:
1306+
# "bar" is required but "foo" is optional, so "bar" can still be missing.
1307+
schema = Schema(
1308+
NestedField(1, "foo", StructType(NestedField(2, "bar", StringType(), required=True)), required=False),
1309+
schema_id=1,
1310+
)
1311+
bound = IsNull(Reference("foo.bar")).bind(schema)
1312+
assert bound != AlwaysFalse()
1313+
assert isinstance(bound, BoundIsNull)
1314+
1315+
bound_not_null = NotNull(Reference("foo.bar")).bind(schema)
1316+
assert bound_not_null != AlwaysTrue()
1317+
assert isinstance(bound_not_null, BoundNotNull)
1318+
1319+
1320+
def test_is_null_required_field_under_required_ancestor_binds_to_always_false() -> None:
1321+
# Both "foo" and "bar" are required, so this still folds.
1322+
schema = Schema(
1323+
NestedField(1, "foo", StructType(NestedField(2, "bar", StringType(), required=True)), required=True),
1324+
schema_id=1,
1325+
)
1326+
assert IsNull(Reference("foo.bar")).bind(schema) == AlwaysFalse()
1327+
assert NotNull(Reference("foo.bar")).bind(schema) == AlwaysTrue()
1328+
1329+
12951330
def test_bind_dot_name() -> None:
12961331
schema = Schema(NestedField(1, "foo.bar", StringType()), schema_id=1)
12971332
bound = BoundIsNull(BoundReference(schema.find_field(1), schema.accessor_for_field(1)))

‎tests/test_schema.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,31 @@ def __getitem__(self, pos: int) -> Any:
445445
assert inner_accessor.get(container) == "name"
446446

447447

448+
def test_is_field_required_in_path() -> None:
449+
schema = Schema(
450+
NestedField(1, "req_top", StringType(), required=True),
451+
NestedField(2, "opt_top", StringType(), required=False),
452+
NestedField(
453+
3,
454+
"opt_struct",
455+
StructType(NestedField(4, "req_child", StringType(), required=True)),
456+
required=False,
457+
),
458+
NestedField(
459+
5,
460+
"req_struct",
461+
StructType(NestedField(6, "req_grandchild", StringType(), required=True)),
462+
required=True,
463+
),
464+
schema_id=1,
465+
)
466+
467+
assert schema.is_field_required_in_path(1) is True
468+
assert schema.is_field_required_in_path(2) is False
469+
assert schema.is_field_required_in_path(4) is False # required leaf, optional parent
470+
assert schema.is_field_required_in_path(6) is True # required leaf and ancestors
471+
472+
448473
def test_serialize_schema(table_schema_with_full_nested_fields: Schema) -> None:
449474
actual = table_schema_with_full_nested_fields.model_dump_json()
450475
expected = (

0 commit comments

Comments
 (0)