This is an automated email from the ASF dual-hosted git repository.

wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new a036fae8 fix: respect ancestor nullability when folding IS NULL/NOT 
NULL predicates (#995)
a036fae8 is described below

commit a036fae8ec947b6ea8da1cae36a666add5eea461
Author: Gang Wu <[email protected]>
AuthorDate: Tue Oct 13 17:57:12 2026 +0800

    fix: respect ancestor nullability when folding IS NULL/NOT NULL predicates 
(#995)
    
    A required field nested in an optional struct, list, or map can be null 
when its
    parent is null, so IS NULL and NOT NULL must not be folded for it. Only fold
    when the field and all of its ancestors are required, matching Java's
    UnboundPredicate#bindUnaryOperation.
---
 src/iceberg/expression/predicate.cc | 47 ++++++++++++++++++++++++++++++++-----
 src/iceberg/expression/predicate.h  |  2 +-
 src/iceberg/test/predicate_test.cc  | 39 ++++++++++++++++++++++++++++++
 3 files changed, 81 insertions(+), 7 deletions(-)

diff --git a/src/iceberg/expression/predicate.cc 
b/src/iceberg/expression/predicate.cc
index 9af590f1..383edbc2 100644
--- a/src/iceberg/expression/predicate.cc
+++ b/src/iceberg/expression/predicate.cc
@@ -25,11 +25,13 @@
 #include "iceberg/expression/expressions.h"
 #include "iceberg/expression/literal.h"
 #include "iceberg/result.h"
+#include "iceberg/schema.h"
 #include "iceberg/transform.h"
 #include "iceberg/type.h"
 #include "iceberg/util/checked_cast.h"
 #include "iceberg/util/formatter_internal.h"
 #include "iceberg/util/macros.h"
+#include "iceberg/util/type_util.h"
 
 namespace iceberg {
 
@@ -230,7 +232,7 @@ Result<std::shared_ptr<Expression>> 
UnboundPredicateImpl<B>::Bind(
   ICEBERG_ASSIGN_OR_RAISE(auto bound_term, BASE::term()->Bind(schema, 
case_sensitive));
 
   if (values_.empty()) {
-    return BindUnaryOperation(std::move(bound_term));
+    return BindUnaryOperation(schema, std::move(bound_term));
   }
 
   if (BASE::op() == Expression::Operation::kIn ||
@@ -257,30 +259,63 @@ bool StartsWith(const Literal& lhs, const Literal& rhs) {
   return false;
 }
 
+/// \brief Returns true if all ancestors of the field with the given ID are 
required.
+///
+/// A required field can still produce null values if it is nested in an 
optional struct,
+/// list, or map.
+Result<bool> AllAncestorsAreRequired(const Schema& schema, int32_t field_id) {
+  auto id_to_parent = IndexParents(schema);
+  auto parent_it = id_to_parent.find(field_id);
+  while (parent_it != id_to_parent.end()) {
+    ICEBERG_ASSIGN_OR_RAISE(auto parent, 
schema.FindFieldById(parent_it->second));
+    if (!parent.has_value()) {
+      return InvalidSchema("Cannot find parent field {} of field {}", 
parent_it->second,
+                           field_id);
+    }
+    if (parent.value().get().optional()) {
+      return false;
+    }
+    parent_it = id_to_parent.find(parent_it->second);
+  }
+  return true;
+}
+
 }  // namespace
 
 template <typename B>
 Result<std::shared_ptr<Expression>> 
UnboundPredicateImpl<B>::BindUnaryOperation(
-    std::shared_ptr<B> bound_term) const {
+    const Schema& schema, std::shared_ptr<B> bound_term) const {
   switch (BASE::op()) {
-    case Expression::Operation::kIsNull:
+    case Expression::Operation::kIsNull: {
       if (!bound_term->MayProduceNull()) {
-        return Expressions::AlwaysFalse();
+        ICEBERG_ASSIGN_OR_RAISE(
+            bool all_ancestors_required,
+            AllAncestorsAreRequired(schema, 
bound_term->reference()->field_id()));
+        if (all_ancestors_required) {
+          return Expressions::AlwaysFalse();
+        }
       }
       if (bound_term->type()->type_id() == TypeId::kUnknown) {
         return Expressions::AlwaysTrue();
       }
       return BoundUnaryPredicate::Make(Expression::Operation::kIsNull,
                                        std::move(bound_term));
-    case Expression::Operation::kNotNull:
+    }
+    case Expression::Operation::kNotNull: {
       if (!bound_term->MayProduceNull()) {
-        return Expressions::AlwaysTrue();
+        ICEBERG_ASSIGN_OR_RAISE(
+            bool all_ancestors_required,
+            AllAncestorsAreRequired(schema, 
bound_term->reference()->field_id()));
+        if (all_ancestors_required) {
+          return Expressions::AlwaysTrue();
+        }
       }
       if (bound_term->type()->type_id() == TypeId::kUnknown) {
         return Expressions::AlwaysFalse();
       }
       return BoundUnaryPredicate::Make(Expression::Operation::kNotNull,
                                        std::move(bound_term));
+    }
     case Expression::Operation::kIsNan:
     case Expression::Operation::kNotNan:
       if (!IsFloatingType(bound_term->type()->type_id())) {
diff --git a/src/iceberg/expression/predicate.h 
b/src/iceberg/expression/predicate.h
index af883fe1..152c6576 100644
--- a/src/iceberg/expression/predicate.h
+++ b/src/iceberg/expression/predicate.h
@@ -150,7 +150,7 @@ class ICEBERG_EXPORT UnboundPredicateImpl : public 
UnboundPredicate,
                        std::vector<Literal> values);
 
   Result<std::shared_ptr<Expression>> BindUnaryOperation(
-      std::shared_ptr<B> bound_term) const;
+      const Schema& schema, std::shared_ptr<B> bound_term) const;
   Result<std::shared_ptr<Expression>> BindLiteralOperation(
       std::shared_ptr<B> bound_term) const;
   Result<std::shared_ptr<Expression>> BindInOperation(
diff --git a/src/iceberg/test/predicate_test.cc 
b/src/iceberg/test/predicate_test.cc
index 5d810e69..13423c66 100644
--- a/src/iceberg/test/predicate_test.cc
+++ b/src/iceberg/test/predicate_test.cc
@@ -384,6 +384,45 @@ TEST_F(PredicateTest, 
UnboundPredicateBindUnaryWithUnknownType) {
   EXPECT_EQ(bound_not_null->op(), Expression::Operation::kFalse);
 }
 
+TEST_F(PredicateTest, UnboundPredicateBindUnaryWithNestedStructs) {
+  // A required field nested in an optional struct can still produce null 
values
+  auto nested_schema = std::make_shared<Schema>(
+      std::vector<SchemaField>{
+          SchemaField::MakeOptional(1, "optional_parent",
+                                    
std::make_shared<StructType>(std::vector<SchemaField>{
+                                        SchemaField::MakeRequired(2, "leaf", 
int32())})),
+          SchemaField::MakeRequired(3, "required_parent",
+                                    
std::make_shared<StructType>(std::vector<SchemaField>{
+                                        SchemaField::MakeRequired(4, "leaf", 
int32())})),
+      },
+      /*schema_id=*/0);
+
+  auto is_null_optional_parent = Expressions::IsNull("optional_parent.leaf");
+  auto bound_is_null_optional =
+      is_null_optional_parent->Bind(*nested_schema, /*case_sensitive=*/true);
+  ASSERT_THAT(bound_is_null_optional, IsOk());
+  EXPECT_EQ(bound_is_null_optional.value()->op(), 
Expression::Operation::kIsNull);
+
+  auto not_null_optional_parent = Expressions::NotNull("optional_parent.leaf");
+  auto bound_not_null_optional =
+      not_null_optional_parent->Bind(*nested_schema, /*case_sensitive=*/true);
+  ASSERT_THAT(bound_not_null_optional, IsOk());
+  EXPECT_EQ(bound_not_null_optional.value()->op(), 
Expression::Operation::kNotNull);
+
+  // A required field with required ancestors can never produce null values
+  auto is_null_required_parent = Expressions::IsNull("required_parent.leaf");
+  auto bound_is_null_required =
+      is_null_required_parent->Bind(*nested_schema, /*case_sensitive=*/true);
+  ASSERT_THAT(bound_is_null_required, IsOk());
+  EXPECT_EQ(bound_is_null_required.value()->op(), 
Expression::Operation::kFalse);
+
+  auto not_null_required_parent = Expressions::NotNull("required_parent.leaf");
+  auto bound_not_null_required =
+      not_null_required_parent->Bind(*nested_schema, /*case_sensitive=*/true);
+  ASSERT_THAT(bound_not_null_required, IsOk());
+  EXPECT_EQ(bound_not_null_required.value()->op(), 
Expression::Operation::kTrue);
+}
+
 TEST_F(PredicateTest, UnboundPredicateBindLiteral) {
   auto equal_pred = Expressions::Equal("age", Literal::Int(25));
   auto bound_result = equal_pred->Bind(*schema_, /*case_sensitive=*/true);

Reply via email to