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 66d7ef5c fix: respect nullability in nested struct accessors (#967)
66d7ef5c is described below

commit 66d7ef5c75db7fbdb710d115a5ad52fc12eb677c
Author: advancedxy <[email protected]>
AuthorDate: Sat Oct 10 13:51:50 2026 +0800

    fix: respect nullability in nested struct accessors (#967)
---
 src/iceberg/row/struct_like.cc       |  80 ++++++++++++-----
 src/iceberg/row/struct_like.h        |   9 +-
 src/iceberg/schema.cc                |  13 ++-
 src/iceberg/test/evaluator_test.cc   |   2 +-
 src/iceberg/test/struct_like_test.cc | 161 ++++++++++++++++++++++++++++++++++-
 5 files changed, 235 insertions(+), 30 deletions(-)

diff --git a/src/iceberg/row/struct_like.cc b/src/iceberg/row/struct_like.cc
index 430fd22f..d49fa274 100644
--- a/src/iceberg/row/struct_like.cc
+++ b/src/iceberg/row/struct_like.cc
@@ -76,39 +76,73 @@ Result<Scalar> LiteralToScalar(const Literal& literal) {
   }
 }
 
+namespace {
+
+Result<Scalar> GetCheckedField(const StructLike& row, size_t pos, bool 
optional) {
+  ICEBERG_ASSIGN_OR_RAISE(auto field, row.GetField(pos));
+  if (!optional && std::holds_alternative<std::monostate>(field)) {
+    return InvalidArgument("Required field at position {} is null", pos);
+  }
+  return field;
+}
+
+Result<std::shared_ptr<StructLike>> GetParentStruct(const StructLike& row, 
size_t pos,
+                                                    bool optional) {
+  ICEBERG_ASSIGN_OR_RAISE(auto field, GetCheckedField(row, pos, optional));
+  if (std::holds_alternative<std::monostate>(field)) return 
std::shared_ptr<StructLike>{};
+  if (!std::holds_alternative<std::shared_ptr<StructLike>>(field)) {
+    return InvalidSchema("Encountered non-struct at position {}", pos);
+  }
+  auto parent = std::get<std::shared_ptr<StructLike>>(std::move(field));
+  if (!parent && !optional) {
+    return InvalidArgument("Required field at position {} is null", pos);
+  }
+  return parent;
+}
+
+}  // namespace
+
 StructLikeAccessor::StructLikeAccessor(std::shared_ptr<Type> type,
-                                       std::span<const size_t> position_path)
-    : type_(std::move(type)), position_path_(position_path.begin(), 
position_path.end()) {
-  if (position_path.size() == 1) {
-    accessor_ = [pos =
-                     position_path[0]](const StructLike& struct_like) -> 
Result<Scalar> {
-      return struct_like.GetField(pos);
+                                       std::span<const size_t> position_path,
+                                       std::vector<bool> is_optional)
+    : type_(std::move(type)),
+      position_path_(position_path.begin(), position_path.end()),
+      is_optional_(std::move(is_optional)) {
+  if (position_path_.size() != is_optional_.size()) {
+    accessor_ = [](const StructLike&) -> Result<Scalar> {
+      return InvalidArgument("Optionality count does not match position path");
     };
-  } else if (position_path.size() == 2) {
-    accessor_ = [pos0 = position_path[0], pos1 = position_path[1]](
+    return;
+  }
+  if (position_path_.size() == 1) {
+    accessor_ = [pos = position_path_[0], optional = 
static_cast<bool>(is_optional_[0])](
                     const StructLike& struct_like) -> Result<Scalar> {
-      ICEBERG_ASSIGN_OR_RAISE(auto first_level_field, 
struct_like.GetField(pos0));
-      if 
(!std::holds_alternative<std::shared_ptr<StructLike>>(first_level_field)) {
-        return InvalidSchema("Encountered non-struct in the position path 
[{},{}]", pos0,
-                             pos1);
-      }
-      return 
std::get<std::shared_ptr<StructLike>>(first_level_field)->GetField(pos1);
+      return GetCheckedField(struct_like, pos, optional);
+    };
+  } else if (position_path_.size() == 2) {
+    accessor_ = [pos0 = position_path_[0], pos1 = position_path_[1],
+                 optional = static_cast<bool>(is_optional_[0]),
+                 leaf_optional = static_cast<bool>(is_optional_[1])](
+                    const StructLike& struct_like) -> Result<Scalar> {
+      ICEBERG_ASSIGN_OR_RAISE(auto nested, GetParentStruct(struct_like, pos0, 
optional));
+      if (!nested) return Scalar{std::monostate{}};
+      return GetCheckedField(*nested, pos1, leaf_optional);
     };
-  } else if (!position_path.empty()) {
+  } else if (!position_path_.empty()) {
     accessor_ = [this](const StructLike& struct_like) -> Result<Scalar> {
       std::vector<std::shared_ptr<StructLike>> backups;
+      backups.reserve(position_path_.size() - 1);
       const StructLike* current_struct_like = &struct_like;
       for (size_t i = 0; i < position_path_.size() - 1; ++i) {
-        ICEBERG_ASSIGN_OR_RAISE(auto field,
-                                
current_struct_like->GetField(position_path_[i]));
-        if (!std::holds_alternative<std::shared_ptr<StructLike>>(field)) {
-          return InvalidSchema("Encountered non-struct in the position path 
[{}]",
-                               position_path_);
-        }
-        backups.push_back(std::get<std::shared_ptr<StructLike>>(field));
+        ICEBERG_ASSIGN_OR_RAISE(
+            auto parent,
+            GetParentStruct(*current_struct_like, position_path_[i], 
is_optional_[i]));
+        if (!parent) return Scalar{std::monostate{}};
+        backups.push_back(std::move(parent));
         current_struct_like = backups.back().get();
       }
-      return current_struct_like->GetField(position_path_.back());
+      return GetCheckedField(*current_struct_like, position_path_.back(),
+                             is_optional_.back());
     };
   } else {
     accessor_ = [](const StructLike&) -> Result<Scalar> {
diff --git a/src/iceberg/row/struct_like.h b/src/iceberg/row/struct_like.h
index 7b4bdc3b..77f84e23 100644
--- a/src/iceberg/row/struct_like.h
+++ b/src/iceberg/row/struct_like.h
@@ -31,6 +31,7 @@
 #include <span>
 #include <string_view>
 #include <variant>
+#include <vector>
 
 #include "iceberg/expression/literal.h"
 #include "iceberg/result.h"
@@ -105,9 +106,11 @@ class ICEBERG_EXPORT MapLike {
 class ICEBERG_EXPORT StructLikeAccessor {
  public:
   explicit StructLikeAccessor(std::shared_ptr<Type> type,
-                              std::span<const size_t> position_path);
+                              std::span<const size_t> position_path,
+                              std::vector<bool> is_optional);
 
   /// \brief Get the scalar value at the given position.
+  /// A null optional parent returns monostate; a null required field is an 
error.
   Result<Scalar> Get(const StructLike& struct_like) const {
     return accessor_(struct_like);
   }
@@ -121,13 +124,15 @@ class ICEBERG_EXPORT StructLikeAccessor {
   /// \brief Get the type of the value that this accessor is bound to.
   const Type& type() const { return *type_; }
 
-  /// \brief Get the position path of the value that this accessor bounded to.
+  /// \brief Get the position path of the value that this accessor is bound to.
   const std::vector<size_t>& position_path() const { return position_path_; }
 
  private:
   std::shared_ptr<Type> type_;
   std::function<Result<Scalar>(const StructLike&)> accessor_;
   std::vector<size_t> position_path_;
+  // One entry per position, including the leaf field.
+  std::vector<bool> is_optional_;
 };
 
 }  // namespace iceberg
diff --git a/src/iceberg/schema.cc b/src/iceberg/schema.cc
index 24df6910..8f103a9d 100644
--- a/src/iceberg/schema.cc
+++ b/src/iceberg/schema.cc
@@ -357,7 +357,18 @@ Result<std::unique_ptr<StructLikeAccessor>> 
Schema::GetAccessorById(
     if (!field.has_value()) {
       return NotFound("Cannot get accessor for field id: {}", field_id);
     }
-    return std::make_unique<StructLikeAccessor>(field.value().get().type(), 
it->second);
+    std::vector<bool> is_optional;
+    is_optional.reserve(it->second.size());
+    const StructType* current = this;
+    for (size_t i = 0; i < it->second.size(); ++i) {
+      const auto& field_at_position = current->fields()[it->second[i]];
+      is_optional.push_back(field_at_position.optional());
+      if (i + 1 < it->second.size()) {
+        current = static_cast<const 
StructType*>(field_at_position.type().get());
+      }
+    }
+    return std::make_unique<StructLikeAccessor>(field.value().get().type(), 
it->second,
+                                                std::move(is_optional));
   }
   return NotFound("Cannot get accessor for field id: {}", field_id);
 }
diff --git a/src/iceberg/test/evaluator_test.cc 
b/src/iceberg/test/evaluator_test.cc
index 798f41e1..e365a7d3 100644
--- a/src/iceberg/test/evaluator_test.cc
+++ b/src/iceberg/test/evaluator_test.cc
@@ -330,7 +330,7 @@ TEST_F(EvaluatorTest, NotEqual) {
 
 TEST_F(EvaluatorTest, StartsWith) {
   auto string_schema = std::make_unique<Schema>(
-      std::vector<SchemaField>{SchemaField::MakeRequired(24, "s", string())});
+      std::vector<SchemaField>{SchemaField::MakeOptional(24, "s", string())});
   ICEBERG_UNWRAP_OR_FAIL(
       auto evaluator,
       Evaluator::Make(*string_schema, Expressions::StartsWith("s", "abc")));
diff --git a/src/iceberg/test/struct_like_test.cc 
b/src/iceberg/test/struct_like_test.cc
index ad19d6a0..a5786011 100644
--- a/src/iceberg/test/struct_like_test.cc
+++ b/src/iceberg/test/struct_like_test.cc
@@ -20,7 +20,10 @@
 #include "iceberg/row/struct_like.h"
 
 #include <array>
+#include <memory>
+#include <optional>
 #include <utility>
+#include <vector>
 
 #include <arrow/c/bridge.h>
 #include <arrow/json/from_string.h>
@@ -71,6 +74,7 @@ namespace {
 class SingleFieldStructLike : public StructLike {
  public:
   explicit SingleFieldStructLike(Scalar value) : value_(std::move(value)) {}
+  explicit SingleFieldStructLike(Result<Scalar> value) : 
value_(std::move(value)) {}
 
   Result<Scalar> GetField(size_t pos) const override {
     if (pos != 0) {
@@ -82,9 +86,35 @@ class SingleFieldStructLike : public StructLike {
   size_t num_fields() const override { return 1; }
 
  private:
-  Scalar value_;
+  Result<Scalar> value_;
 };
 
+std::shared_ptr<StructLike> WrapSingleFieldRow(Result<Scalar> field, size_t 
depth) {
+  std::shared_ptr<StructLike> row =
+      std::make_shared<SingleFieldStructLike>(std::move(field));
+  for (size_t i = 1; i < depth; ++i) {
+    row = std::make_shared<SingleFieldStructLike>(Scalar{row});
+  }
+  return row;
+}
+
+std::unique_ptr<Schema> MakeNestedSchema(
+    size_t depth, std::optional<size_t> optional_parent = std::nullopt,
+    bool optional_leaf = false) {
+  auto field =
+      optional_leaf
+          ? SchemaField::MakeOptional(static_cast<int32_t>(depth), "value", 
int32())
+          : SchemaField::MakeRequired(static_cast<int32_t>(depth), "value", 
int32());
+  for (size_t level = depth - 1; level > 0; --level) {
+    auto nested = struct_({field});
+    field =
+        optional_parent == level - 1
+            ? SchemaField::MakeOptional(static_cast<int32_t>(level), "nested", 
nested)
+            : SchemaField::MakeRequired(static_cast<int32_t>(level), "nested", 
nested);
+  }
+  return std::make_unique<Schema>(std::vector<SchemaField>{std::move(field)});
+}
+
 }  // namespace
 
 TEST(ManifestFileStructLike, BasicFields) {
@@ -152,7 +182,7 @@ TEST(StructLikeAccessorTest, GetLiteralUuid) {
   std::string_view uuid_data(reinterpret_cast<const char*>(bytes.data()), 
bytes.size());
   SingleFieldStructLike row(Scalar{uuid_data});
   std::array<size_t, 1> path = {0};
-  StructLikeAccessor accessor(iceberg::uuid(), path);
+  StructLikeAccessor accessor(iceberg::uuid(), path, {false});
 
   ICEBERG_UNWRAP_OR_FAIL(auto literal, accessor.GetLiteral(row));
   EXPECT_EQ(literal.type()->type_id(), TypeId::kUuid);
@@ -163,13 +193,138 @@ TEST(StructLikeAccessorTest, GetLiteralUuid) {
 TEST(StructLikeAccessorTest, GetLiteralUuidRejectsWrongLength) {
   SingleFieldStructLike row(Scalar{std::string_view("not-a-uuid")});
   std::array<size_t, 1> path = {0};
-  StructLikeAccessor accessor(iceberg::uuid(), path);
+  StructLikeAccessor accessor(iceberg::uuid(), path, {false});
 
   auto result = accessor.GetLiteral(row);
   EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument));
   EXPECT_THAT(result, HasErrorMessage("UUID byte array must be exactly 16 
bytes"));
 }
 
+TEST(StructLikeAccessorTest, RequiredAndOptionalLeaf) {
+  SingleFieldStructLike row(Scalar{std::monostate{}});
+  for (bool optional : {false, true}) {
+    auto schema = MakeNestedSchema(1, std::nullopt, optional);
+    ICEBERG_UNWRAP_OR_FAIL(auto accessor, schema->GetAccessorById(1));
+    if (optional) {
+      EXPECT_SCALAR_NULL(accessor->Get(row));
+      ICEBERG_UNWRAP_OR_FAIL(auto literal, accessor->GetLiteral(row));
+      EXPECT_TRUE(literal.IsNull());
+    } else {
+      EXPECT_THAT(accessor->Get(row), IsError(ErrorKind::kInvalidArgument));
+      EXPECT_THAT(accessor->GetLiteral(row), 
IsError(ErrorKind::kInvalidArgument));
+    }
+  }
+}
+
+class NestedStructLikeAccessorTest : public ::testing::TestWithParam<size_t> 
{};
+
+TEST_P(NestedStructLikeAccessorTest, NullParents) {
+  const std::vector<size_t> path(GetParam(), 0);
+  StructLikeAccessor accessor(int32(), path, std::vector<bool>(path.size(), 
true));
+  for (const Scalar null :
+       {Scalar{std::monostate{}}, Scalar{std::shared_ptr<StructLike>{}}}) {
+    for (size_t parent = 0; parent + 1 < path.size(); ++parent) {
+      SCOPED_TRACE(parent);
+      auto row = WrapSingleFieldRow(null, parent + 1);
+      ICEBERG_UNWRAP_OR_FAIL(auto scalar, accessor.Get(*row));
+      EXPECT_TRUE(std::holds_alternative<std::monostate>(scalar));
+      ICEBERG_UNWRAP_OR_FAIL(auto literal, accessor.GetLiteral(*row));
+      EXPECT_TRUE(literal.IsNull());
+      EXPECT_EQ(literal.type()->type_id(), TypeId::kInt);
+    }
+  }
+}
+
+TEST_P(NestedStructLikeAccessorTest, SchemaRequiredAndOptionalParents) {
+  const auto depth = GetParam();
+  auto required_schema = MakeNestedSchema(depth);
+  ICEBERG_UNWRAP_OR_FAIL(auto required_accessor,
+                         
required_schema->GetAccessorById(static_cast<int32_t>(depth)));
+  EXPECT_EQ(required_accessor->position_path(), std::vector<size_t>(depth, 0));
+
+  for (const Scalar null :
+       {Scalar{std::monostate{}}, Scalar{std::shared_ptr<StructLike>{}}}) {
+    for (size_t parent = 0; parent + 1 < depth; ++parent) {
+      SCOPED_TRACE(parent);
+      auto row = WrapSingleFieldRow(null, parent + 1);
+      auto schema = MakeNestedSchema(depth, parent);
+      ICEBERG_UNWRAP_OR_FAIL(auto accessor,
+                             
schema->GetAccessorById(static_cast<int32_t>(depth)));
+      EXPECT_SCALAR_NULL(accessor->Get(*row));
+      ICEBERG_UNWRAP_OR_FAIL(auto literal, accessor->GetLiteral(*row));
+      EXPECT_TRUE(literal.IsNull());
+      EXPECT_EQ(literal.type()->type_id(), TypeId::kInt);
+
+      EXPECT_THAT(required_accessor->Get(*row), 
IsError(ErrorKind::kInvalidArgument));
+      EXPECT_THAT(required_accessor->GetLiteral(*row),
+                  IsError(ErrorKind::kInvalidArgument));
+    }
+  }
+}
+
+TEST_P(NestedStructLikeAccessorTest, ValueAndNullLeaf) {
+  const std::vector<size_t> path(GetParam(), 0);
+  StructLikeAccessor accessor(int32(), path, std::vector<bool>(path.size(), 
true));
+  auto row = WrapSingleFieldRow(Scalar{int32_t{42}}, path.size());
+  ICEBERG_UNWRAP_OR_FAIL(auto value, accessor.GetLiteral(*row));
+  EXPECT_EQ(value, Literal::Int(42));
+
+  auto null_row = WrapSingleFieldRow(Scalar{std::monostate{}}, path.size());
+  ICEBERG_UNWRAP_OR_FAIL(auto null, accessor.GetLiteral(*null_row));
+  EXPECT_TRUE(null.IsNull());
+  EXPECT_EQ(null.type()->type_id(), TypeId::kInt);
+}
+
+TEST_P(NestedStructLikeAccessorTest, RequiredAndOptionalLeaf) {
+  const auto depth = GetParam();
+  auto row = WrapSingleFieldRow(Scalar{std::monostate{}}, depth);
+  for (bool optional : {false, true}) {
+    auto schema = MakeNestedSchema(depth, std::nullopt, optional);
+    ICEBERG_UNWRAP_OR_FAIL(auto accessor,
+                           
schema->GetAccessorById(static_cast<int32_t>(depth)));
+    if (optional) {
+      EXPECT_SCALAR_NULL(accessor->Get(*row));
+      ICEBERG_UNWRAP_OR_FAIL(auto literal, accessor->GetLiteral(*row));
+      EXPECT_TRUE(literal.IsNull());
+    } else {
+      EXPECT_THAT(accessor->Get(*row), IsError(ErrorKind::kInvalidArgument));
+      EXPECT_THAT(accessor->GetLiteral(*row), 
IsError(ErrorKind::kInvalidArgument));
+    }
+  }
+
+  auto schema = MakeNestedSchema(depth, 0);
+  ICEBERG_UNWRAP_OR_FAIL(auto accessor,
+                         schema->GetAccessorById(static_cast<int32_t>(depth)));
+  EXPECT_THAT(accessor->Get(*row), IsError(ErrorKind::kInvalidArgument));
+  auto absent_parent = WrapSingleFieldRow(Scalar{std::monostate{}}, 1);
+  EXPECT_SCALAR_NULL(accessor->Get(*absent_parent));
+}
+
+TEST_P(NestedStructLikeAccessorTest, RejectsNonStructParents) {
+  const std::vector<size_t> path(GetParam(), 0);
+  StructLikeAccessor accessor(int32(), path, std::vector<bool>(path.size(), 
true));
+  for (size_t parent = 0; parent + 1 < path.size(); ++parent) {
+    SCOPED_TRACE(parent);
+    auto row = WrapSingleFieldRow(Scalar{int32_t{42}}, parent + 1);
+    EXPECT_THAT(accessor.Get(*row), IsError(ErrorKind::kInvalidSchema));
+  }
+}
+
+TEST_P(NestedStructLikeAccessorTest, PreservesFieldErrors) {
+  const std::vector<size_t> path(GetParam(), 0);
+  StructLikeAccessor accessor(int32(), path, std::vector<bool>(path.size(), 
true));
+  for (size_t level = 0; level < path.size(); ++level) {
+    SCOPED_TRACE(level);
+    auto row = WrapSingleFieldRow(IOError("Cannot read field"), level + 1);
+    auto result = accessor.GetLiteral(*row);
+    EXPECT_THAT(result, IsError(ErrorKind::kIOError));
+    EXPECT_THAT(result, HasErrorMessage("Cannot read field"));
+  }
+}
+
+INSTANTIATE_TEST_SUITE_P(NestedPaths, NestedStructLikeAccessorTest,
+                         ::testing::Values(size_t{2}, size_t{3}, size_t{4}));
+
 TEST(ManifestFileStructLike, OptionalFields) {
   ManifestFile manifest_file{.manifest_path = "/path/to/manifest2.avro",
                              .manifest_length = 54321,

Reply via email to