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 f3fa485c feat(avro): apply column default values when reading missing 
fields (#800)
f3fa485c is described below

commit f3fa485c0a35419ebdc61ad08446f5cfdfc33d4c
Author: Xin Huang <[email protected]>
AuthorDate: Sun Aug 2 20:49:26 2026 -0700

    feat(avro): apply column default values when reading missing fields (#800)
    
    ## What
    
    Part 3 of 4 of Iceberg v3 column default-value support (POC #731), built
    on the
    schema layer (#746) and the Parquet read path (#792).
    
    When a column is present in the read (table) schema but absent from an
    Avro data
    file — because the column was added after those rows were written — fill
    it with
    the column's v3 `initial-default` instead of `null`.
    
    ## Changes
    
    - **Avro projection** (`avro_schema_util.cc`): when a field is missing
    from the
      file and carries an `initial-default`, project it as
    `FieldProjection::Kind::kDefault`, mirroring the generic / Parquet
    paths.
    - **Avro decode** (`avro_data_util.cc`, `avro_direct_decoder.cc`):
    materialize
    the `kDefault` branch through an Avro-local `AppendDefaultToBuilder`
    helper.
      It reuses the shared `ToArrowScalar` conversion, while keeping Avro's
    row-by-row `ArrayBuilder` append behavior out of the shared Arrow
    utility.
    
    ## Tests
    
    - `avro_data_test`: `AppendDefaultToBuilder` appends a value and casts
    to the
    builder type; `AppendDatumToBuilder` fills missing required and optional
      default fields.
    - `avro_test`: end-to-end — write an Avro file with an old schema, then
    read it
    through `ReaderFactoryRegistry` with an evolved schema carrying defaults
      (`ReadMissingFieldsWithDefaults`).
    
    ## Stack
    
    1. #746 — schema: represent / serialize / validate (merged)
    2. #792 — read path: Parquet (merged)
    3. **this PR** — read path: Avro
    4. schema evolution: `addColumn` / `updateColumnDefault` (#793)
---
 src/iceberg/avro/avro_data_util.cc         | 152 ++++++++++++++++++++
 src/iceberg/avro/avro_data_util_internal.h |  37 +++++
 src/iceberg/avro/avro_direct_decoder.cc    |   3 +
 src/iceberg/avro/avro_reader.cc            |   2 +
 src/iceberg/avro/avro_schema_util.cc       |   4 +
 src/iceberg/test/avro_data_test.cc         | 219 +++++++++++++++++++++++++++++
 src/iceberg/test/avro_test.cc              |  29 ++++
 7 files changed, 446 insertions(+)

diff --git a/src/iceberg/avro/avro_data_util.cc 
b/src/iceberg/avro/avro_data_util.cc
index 7931b071..9be8907d 100644
--- a/src/iceberg/avro/avro_data_util.cc
+++ b/src/iceberg/avro/avro_data_util.cc
@@ -17,12 +17,15 @@
  * under the License.
  */
 
+#include <span>
+
 #include <arrow/array/builder_binary.h>
 #include <arrow/array/builder_decimal.h>
 #include <arrow/array/builder_nested.h>
 #include <arrow/array/builder_primitive.h>
 #include <arrow/extension_type.h>
 #include <arrow/json/from_string.h>
+#include <arrow/scalar.h>
 #include <arrow/type.h>
 #include <arrow/util/decimal.h>
 #include <avro/Generic.hh>
@@ -31,6 +34,7 @@
 #include <avro/Types.hh>
 
 #include "iceberg/arrow/arrow_status_internal.h"
+#include "iceberg/arrow/literal_util_internal.h"
 #include "iceberg/avro/avro_data_util_internal.h"
 #include "iceberg/avro/avro_schema_util_internal.h"
 #include "iceberg/metadata_columns.h"
@@ -88,6 +92,8 @@ Status AppendStructToBuilder(const ::avro::NodePtr& avro_node,
                                                      metadata_context, 
field_builder));
     } else if (field_projection.kind == FieldProjection::Kind::kNull) {
       ICEBERG_ARROW_RETURN_NOT_OK(field_builder->AppendNull());
+    } else if (field_projection.kind == FieldProjection::Kind::kDefault) {
+      ICEBERG_RETURN_UNEXPECTED(AppendDefaultToBuilder(field_projection, 
field_builder));
     } else if (field_projection.kind == FieldProjection::Kind::kMetadata) {
       int32_t field_id = expected_field.field_id();
       if (field_id == MetadataColumns::kFilePathColumnId) {
@@ -466,6 +472,10 @@ Status AppendFieldToBuilder(const ::avro::NodePtr& 
avro_node,
     return {};
   }
 
+  if (projection.kind == FieldProjection::Kind::kDefault) {
+    return AppendDefaultToBuilder(projection, array_builder);
+  }
+
   const bool is_row_lineage =
       MetadataColumns::IsRowLineageColumn(projected_field.field_id());
 
@@ -497,6 +507,148 @@ Status AppendFieldToBuilder(const ::avro::NodePtr& 
avro_node,
 
 }  // namespace
 
+namespace {
+
+Result<std::shared_ptr<::arrow::Scalar>> MakeDefaultScalar(
+    const Literal& literal, const std::shared_ptr<::arrow::DataType>& 
builder_type) {
+  // The builder's own memory pool is not exposed, so the small scalar buffer 
uses the
+  // default pool.
+  ICEBERG_ASSIGN_OR_RAISE(std::shared_ptr<::arrow::Scalar> scalar,
+                          arrow::ToArrowScalar(literal, 
::arrow::default_memory_pool()));
+
+  // For an extension builder (e.g. `arrow.uuid`) target its storage type: 
ToArrowScalar
+  // yields the storage scalar (fixed_size_binary(16) for uuid) and 
Scalar::CastTo has no
+  // kernel that targets an extension type. This mirrors MakeDefaultArray's 
extension
+  // handling.
+  std::shared_ptr<::arrow::DataType> target_type = builder_type;
+  if (target_type->id() == ::arrow::Type::EXTENSION) {
+    target_type = internal::checked_cast<const 
::arrow::ExtensionType&>(*target_type)
+                      .storage_type();
+  }
+
+  if (!scalar->type->Equals(*target_type)) {
+    ICEBERG_ARROW_ASSIGN_OR_RETURN(scalar, scalar->CastTo(target_type));
+  }
+  return scalar;
+}
+
+Status PrepareStructDefaultScalars(std::span<FieldProjection> projections,
+                                   ::arrow::ArrayBuilder* builder);
+
+// Recurse into whatever nested builder this projection describes, so a 
default cached for
+// a struct field is found at any nesting depth (e.g. 
`list<list<struct<...>>>`) instead
+// of only when the collection's child is an immediate struct.
+Status PrepareNestedDefaultScalars(FieldProjection& projection,
+                                   ::arrow::ArrayBuilder* builder) {
+  if (projection.kind != FieldProjection::Kind::kProjected ||
+      projection.children.empty()) {
+    return {};
+  }
+
+  switch (builder->type()->id()) {
+    case ::arrow::Type::STRUCT:
+      return PrepareStructDefaultScalars(projection.children, builder);
+    case ::arrow::Type::LIST: {
+      // List projections store a single child for the element.
+      auto* list_builder = 
internal::checked_cast<::arrow::ListBuilder*>(builder);
+      return PrepareNestedDefaultScalars(projection.children[0],
+                                         list_builder->value_builder());
+    }
+    case ::arrow::Type::LARGE_LIST: {
+      auto* list_builder = 
internal::checked_cast<::arrow::LargeListBuilder*>(builder);
+      return PrepareNestedDefaultScalars(projection.children[0],
+                                         list_builder->value_builder());
+    }
+    case ::arrow::Type::MAP: {
+      auto* map_builder = 
internal::checked_cast<::arrow::MapBuilder*>(builder);
+      if (projection.children.size() >= 1) {
+        ICEBERG_RETURN_UNEXPECTED(PrepareNestedDefaultScalars(
+            projection.children[0], map_builder->key_builder()));
+      }
+      if (projection.children.size() >= 2) {
+        ICEBERG_RETURN_UNEXPECTED(PrepareNestedDefaultScalars(
+            projection.children[1], map_builder->item_builder()));
+      }
+      return {};
+    }
+    default:
+      return {};
+  }
+}
+
+Status PrepareStructDefaultScalars(std::span<FieldProjection> projections,
+                                   ::arrow::ArrayBuilder* builder) {
+  auto* struct_builder = 
internal::checked_cast<::arrow::StructBuilder*>(builder);
+  if (static_cast<size_t>(struct_builder->num_fields()) != projections.size()) 
{
+    return InvalidArgument(
+        "Inconsistent number of struct builder fields ({}) and projections 
({})",
+        struct_builder->num_fields(), projections.size());
+  }
+
+  for (size_t i = 0; i < projections.size(); ++i) {
+    auto& field_projection = projections[i];
+    auto* field_builder = struct_builder->field_builder(static_cast<int>(i));
+
+    if (field_projection.kind == FieldProjection::Kind::kDefault) {
+      // Get-or-create the single Avro attributes container: another Avro 
attribute may
+      // have created it already, so guard on `default_scalar` being unset 
rather than on
+      // the container's presence (otherwise a pre-existing container would 
skip
+      // preparation and silently fall back to per-row scalar rebuilding).
+      std::shared_ptr<AvroExtraAttributes> attrs;
+      if (field_projection.attributes == nullptr) {
+        attrs = std::make_shared<AvroExtraAttributes>();
+        field_projection.attributes = attrs;
+      } else {
+        // Avro attaches only AvroExtraAttributes; checked_pointer_cast 
asserts that
+        // invariant in debug rather than silently reinterpreting a foreign 
attributes
+        // type.
+        attrs = internal::checked_pointer_cast<AvroExtraAttributes>(
+            field_projection.attributes);
+      }
+      if (attrs->default_scalar == nullptr) {
+        ICEBERG_ASSIGN_OR_RAISE(
+            attrs->default_scalar,
+            MakeDefaultScalar(std::get<Literal>(field_projection.from),
+                              field_builder->type()));
+      }
+      continue;
+    }
+
+    ICEBERG_RETURN_UNEXPECTED(
+        PrepareNestedDefaultScalars(field_projection, field_builder));
+  }
+  return {};
+}
+
+}  // namespace
+
+Status PrepareDefaultScalars(SchemaProjection& projection,
+                             ::arrow::ArrayBuilder* root_builder) {
+  return PrepareStructDefaultScalars(projection.fields, root_builder);
+}
+
+Status AppendDefaultToBuilder(const Literal& literal, ::arrow::ArrayBuilder* 
builder) {
+  ICEBERG_ASSIGN_OR_RAISE(std::shared_ptr<::arrow::Scalar> scalar,
+                          MakeDefaultScalar(literal, builder->type()));
+  ICEBERG_ARROW_RETURN_NOT_OK(builder->AppendScalar(*scalar));
+  return {};
+}
+
+Status AppendDefaultToBuilder(const FieldProjection& projection,
+                              ::arrow::ArrayBuilder* builder) {
+  // Avro projections carry a single attributes type, so once one is attached 
it is an
+  // AvroExtraAttributes; use checked_cast instead of a per-row dynamic_cast.
+  if (projection.attributes != nullptr) {
+    const auto& attrs =
+        internal::checked_cast<const 
AvroExtraAttributes&>(*projection.attributes);
+    if (attrs.default_scalar != nullptr) {
+      
ICEBERG_ARROW_RETURN_NOT_OK(builder->AppendScalar(*attrs.default_scalar));
+      return {};
+    }
+  }
+  return AppendDefaultToBuilder(std::get<Literal>(projection.from), builder);
+}
+
 Status AppendDatumToBuilder(const ::avro::NodePtr& avro_node,
                             const ::avro::GenericDatum& avro_datum,
                             const SchemaProjection& projection,
diff --git a/src/iceberg/avro/avro_data_util_internal.h 
b/src/iceberg/avro/avro_data_util_internal.h
index 281187f1..9bb86c13 100644
--- a/src/iceberg/avro/avro_data_util_internal.h
+++ b/src/iceberg/avro/avro_data_util_internal.h
@@ -19,14 +19,51 @@
 
 #pragma once
 
+#include <memory>
+
 #include <arrow/array/builder_base.h>
+#include <arrow/scalar.h>
 #include <avro/GenericDatum.hh>
 
 #include "iceberg/arrow/metadata_column_util_internal.h"
+#include "iceberg/expression/literal.h"
 #include "iceberg/schema_util.h"
 
 namespace iceberg::avro {
 
+/// \brief Avro-specific per-field projection attributes.
+///
+/// `FieldProjection` has a single attributes slot, so all Avro-side 
attributes live in
+/// one container (mirroring `ParquetExtraAttributes`) rather than separate 
subclasses
+/// that could not coexist. `default_scalar` is the Arrow scalar for a 
`kDefault` field,
+/// materialized once (see `PrepareDefaultScalars`) so row-by-row decode only 
needs
+/// `AppendScalar` instead of repeating `ToArrowScalar` / `CastTo` per row.
+struct AvroExtraAttributes : FieldProjection::ExtraAttributes {
+  std::shared_ptr<::arrow::Scalar> default_scalar;
+};
+
+/// \brief Precompute cast Arrow scalars for every `kDefault` field under 
`projection`.
+///
+/// Walks `root_builder` in lockstep with the projection so each default is 
cast to the
+/// builder's Arrow type once per scan. Safe to call repeatedly; existing
+/// `AvroExtraAttributes` entries are left unchanged.
+Status PrepareDefaultScalars(SchemaProjection& projection,
+                             ::arrow::ArrayBuilder* root_builder);
+
+/// \brief Append a literal once to `builder` while decoding Avro row-by-row.
+///
+/// Used to materialize `FieldProjection::Kind::kDefault`. Shares 
`ToArrowScalar` with
+/// Parquet's batch path (`MakeDefaultArray`); the append shape stays 
Avro-local because
+/// Avro builds Arrow arrays via per-row `ArrayBuilder`s rather than 
whole-column arrays.
+/// Prefer the `FieldProjection` overload after `PrepareDefaultScalars` so the 
scalar is
+/// reused across rows.
+Status AppendDefaultToBuilder(const Literal& literal, ::arrow::ArrayBuilder* 
builder);
+
+/// \brief Append a `kDefault` projection, reusing a scalar cached on
+/// `projection.attributes` when present.
+Status AppendDefaultToBuilder(const FieldProjection& projection,
+                              ::arrow::ArrayBuilder* builder);
+
 /// \brief Append an Avro datum to an Arrow array builder.
 ///
 /// This function handles schema evolution by using the provided projection to 
map
diff --git a/src/iceberg/avro/avro_direct_decoder.cc 
b/src/iceberg/avro/avro_direct_decoder.cc
index b66a9310..8f563b3b 100644
--- a/src/iceberg/avro/avro_direct_decoder.cc
+++ b/src/iceberg/avro/avro_direct_decoder.cc
@@ -29,6 +29,7 @@
 #include <avro/Types.hh>
 
 #include "iceberg/arrow/arrow_status_internal.h"
+#include "iceberg/avro/avro_data_util_internal.h"
 #include "iceberg/avro/avro_direct_decoder_internal.h"
 #include "iceberg/avro/avro_schema_util_internal.h"
 #include "iceberg/metadata_columns.h"
@@ -209,6 +210,8 @@ Status DecodeStructToBuilder(const ::avro::NodePtr& 
avro_node, ::avro::Decoder&
     auto* field_builder = 
struct_builder->field_builder(static_cast<int>(proj_idx));
     if (field_projection.kind == FieldProjection::Kind::kNull) {
       ICEBERG_ARROW_RETURN_NOT_OK(field_builder->AppendNull());
+    } else if (field_projection.kind == FieldProjection::Kind::kDefault) {
+      ICEBERG_RETURN_UNEXPECTED(AppendDefaultToBuilder(field_projection, 
field_builder));
     } else if (field_projection.kind == FieldProjection::Kind::kMetadata) {
       int32_t field_id = expected_field.field_id();
       if (field_id == MetadataColumns::kFilePathColumnId) {
diff --git a/src/iceberg/avro/avro_reader.cc b/src/iceberg/avro/avro_reader.cc
index b0977bcd..f333a38c 100644
--- a/src/iceberg/avro/avro_reader.cc
+++ b/src/iceberg/avro/avro_reader.cc
@@ -434,6 +434,8 @@ class AvroReader::Impl {
                            builder_result.status().message());
     }
     context_->builder_ = builder_result.MoveValueUnsafe();
+    ICEBERG_RETURN_UNEXPECTED(
+        PrepareDefaultScalars(projection_, context_->builder_.get()));
     backend_->InitReadContext(backend_->GetReaderSchema());
 
     return {};
diff --git a/src/iceberg/avro/avro_schema_util.cc 
b/src/iceberg/avro/avro_schema_util.cc
index 14b464ce..5e6bee95 100644
--- a/src/iceberg/avro/avro_schema_util.cc
+++ b/src/iceberg/avro/avro_schema_util.cc
@@ -752,6 +752,10 @@ Result<FieldProjection> ProjectStruct(const StructType& 
struct_type,
                                            iter->second.local_index, 
prune_source));
     } else if (MetadataColumns::IsMetadataColumn(field_id)) {
       child_projection.kind = FieldProjection::Kind::kMetadata;
+    } else if (expected_field.initial_default() != nullptr) {
+      // Rows written before the field existed assume its `initial-default` 
value.
+      child_projection.kind = FieldProjection::Kind::kDefault;
+      child_projection.from = *expected_field.initial_default();
     } else if (expected_field.optional()) {
       child_projection.kind = FieldProjection::Kind::kNull;
     } else {
diff --git a/src/iceberg/test/avro_data_test.cc 
b/src/iceberg/test/avro_data_test.cc
index 7731f58d..4ea7855c 100644
--- a/src/iceberg/test/avro_data_test.cc
+++ b/src/iceberg/test/avro_data_test.cc
@@ -17,10 +17,16 @@
  * under the License.
  */
 
+#include <memory>
 #include <ranges>
+#include <vector>
 
+#include <arrow/array/builder_nested.h>
+#include <arrow/array/builder_primitive.h>
 #include <arrow/c/bridge.h>
 #include <arrow/json/from_string.h>
+#include <arrow/memory_pool.h>
+#include <arrow/type.h>
 #include <arrow/util/decimal.h>
 #include <avro/Compiler.hh>
 #include <avro/Generic.hh>
@@ -31,6 +37,7 @@
 
 #include "iceberg/avro/avro_data_util_internal.h"
 #include "iceberg/avro/avro_schema_util_internal.h"
+#include "iceberg/expression/literal.h"
 #include "iceberg/schema.h"
 #include "iceberg/schema_internal.h"
 #include "iceberg/schema_util.h"
@@ -662,6 +669,218 @@ TEST(AppendDatumToBuilderTest, 
StructWithMissingOptionalField) {
                                                      avro_data, 
expected_json));
 }
 
+TEST(AppendDatumToBuilderTest, StructWithMissingDefaultFields) {
+  Schema iceberg_schema({
+      SchemaField::MakeRequired(1, "id", iceberg::int32()),
+      // Missing required field with an initial-default: filled with the 
default.
+      SchemaField(2, "score", iceberg::int64(), /*optional=*/false, /*doc=*/{},
+                  std::make_shared<const Literal>(Literal::Long(100))),
+      // Missing optional field with an initial-default: also filled, not null.
+      SchemaField(3, "grade", iceberg::string(), /*optional=*/true, /*doc=*/{},
+                  std::make_shared<const Literal>(Literal::String("A"))),
+  });
+
+  // Create Avro schema that only has the id field (missing score and grade).
+  std::string avro_schema_json = R"({
+    "type": "record",
+    "name": "person",
+    "fields": [
+      {"name": "id", "type": "int", "field-id": 1}
+    ]
+  })";
+  auto avro_schema = ::avro::compileJsonSchemaFromString(avro_schema_json);
+
+  std::vector<::avro::GenericDatum> avro_data;
+  for (int i = 0; i < 2; ++i) {
+    ::avro::GenericDatum avro_datum(avro_schema.root());
+    auto& record = avro_datum.value<::avro::GenericRecord>();
+    record.fieldAt(0).value<int32_t>() = i + 1;
+    avro_data.push_back(avro_datum);
+  }
+
+  const std::string expected_json = R"([
+    {"id": 1, "score": 100, "grade": "A"},
+    {"id": 2, "score": 100, "grade": "A"}
+  ])";
+  ASSERT_NO_FATAL_FAILURE(VerifyAppendDatumToBuilder(iceberg_schema, 
avro_schema.root(),
+                                                     avro_data, 
expected_json));
+}
+
+TEST(AppendDatumToBuilderTest, NestedListOfListStructWithMissingDefaultField) {
+  // A default on a struct nested two collection levels deep must still be 
filled, which
+  // requires recursing through both list builders rather than only an 
immediate struct.
+  auto inner_struct = std::make_shared<StructType>(std::vector<SchemaField>{
+      SchemaField::MakeRequired(5, "x", iceberg::int32()),
+      SchemaField(6, "y", iceberg::int64(), /*optional=*/false, /*doc=*/{},
+                  std::make_shared<const Literal>(Literal::Long(7))),
+  });
+  auto inner_list =
+      std::make_shared<ListType>(SchemaField::MakeRequired(4, "element", 
inner_struct));
+  auto outer_list =
+      std::make_shared<ListType>(SchemaField::MakeRequired(3, "element", 
inner_list));
+  Schema iceberg_schema({
+      SchemaField::MakeRequired(1, "id", iceberg::int32()),
+      SchemaField::MakeRequired(2, "matrix", outer_list),
+  });
+
+  // The Avro schema's innermost record only has `x`, so `y` comes from its 
default.
+  std::string avro_schema_json = R"({
+    "type": "record",
+    "name": "outer",
+    "fields": [
+      {"name": "id", "type": "int", "field-id": 1},
+      {"name": "matrix", "field-id": 2, "type": {
+        "type": "array", "element-id": 3, "items": {
+          "type": "array", "element-id": 4, "items": {
+            "type": "record", "name": "point",
+            "fields": [{"name": "x", "type": "int", "field-id": 5}]
+          }
+        }
+      }}
+    ]
+  })";
+  auto avro_schema = ::avro::compileJsonSchemaFromString(avro_schema_json);
+
+  std::vector<::avro::GenericDatum> avro_data;
+  ::avro::GenericDatum avro_datum(avro_schema.root());
+  auto& record = avro_datum.value<::avro::GenericRecord>();
+  record.fieldAt(0).value<int32_t>() = 1;
+  auto& outer_array = record.fieldAt(1).value<::avro::GenericArray>();
+  ::avro::GenericDatum inner_datum(avro_schema.root()->leafAt(1)->leafAt(0));
+  auto& inner_array = inner_datum.value<::avro::GenericArray>();
+  for (int32_t x : {10, 20}) {
+    ::avro::GenericDatum 
point_datum(avro_schema.root()->leafAt(1)->leafAt(0)->leafAt(0));
+    point_datum.value<::avro::GenericRecord>().fieldAt(0).value<int32_t>() = x;
+    inner_array.value().push_back(point_datum);
+  }
+  outer_array.value().push_back(inner_datum);
+  avro_data.push_back(avro_datum);
+
+  const std::string expected_json = R"([
+    {"id": 1, "matrix": [[{"x": 10, "y": 7}, {"x": 20, "y": 7}]]}
+  ])";
+  ASSERT_NO_FATAL_FAILURE(VerifyAppendDatumToBuilder(iceberg_schema, 
avro_schema.root(),
+                                                     avro_data, 
expected_json));
+}
+
+TEST(AppendDefaultToBuilderTest, AppendsValue) {
+  ::arrow::Int64Builder builder;
+  ASSERT_THAT(AppendDefaultToBuilder(Literal::Long(42), &builder), IsOk());
+  ASSERT_THAT(AppendDefaultToBuilder(Literal::Long(42), &builder), IsOk());
+
+  std::shared_ptr<::arrow::Array> array;
+  ASSERT_TRUE(builder.Finish(&array).ok());
+  ASSERT_EQ(array->length(), 2);
+  const auto& long_array = static_cast<const ::arrow::Int64Array&>(*array);
+  ASSERT_EQ(long_array.Value(0), 42);
+  ASSERT_EQ(long_array.Value(1), 42);
+}
+
+TEST(AppendDefaultToBuilderTest, CastsToBuilderType) {
+  // The literal's natural type (int32) differs from the builder type (int64); 
the value
+  // is cast to the builder type.
+  ::arrow::Int64Builder builder;
+  ASSERT_THAT(AppendDefaultToBuilder(Literal::Int(7), &builder), IsOk());
+
+  std::shared_ptr<::arrow::Array> array;
+  ASSERT_TRUE(builder.Finish(&array).ok());
+  ASSERT_EQ(array->length(), 1);
+  ASSERT_EQ(static_cast<const ::arrow::Int64Array&>(*array).Value(0), 7);
+}
+
+TEST(AppendDefaultToBuilderTest, ReusesPreparedScalar) {
+  auto pool = ::arrow::default_memory_pool();
+  auto child = std::make_shared<::arrow::Int64Builder>(pool);
+  ::arrow::StructBuilder struct_builder(
+      ::arrow::struct_({::arrow::field("d", ::arrow::int64())}), pool, 
{child});
+
+  FieldProjection projection;
+  projection.kind = FieldProjection::Kind::kDefault;
+  projection.from = Literal::Long(42);
+
+  SchemaProjection schema_projection;
+  schema_projection.fields.push_back(projection);
+  ASSERT_THAT(PrepareDefaultScalars(schema_projection, &struct_builder), 
IsOk());
+  ASSERT_NE(dynamic_cast<const AvroExtraAttributes*>(
+                schema_projection.fields[0].attributes.get()),
+            nullptr);
+
+  auto* field_builder = struct_builder.field_builder(0);
+  ASSERT_THAT(AppendDefaultToBuilder(schema_projection.fields[0], 
field_builder), IsOk());
+  ASSERT_THAT(AppendDefaultToBuilder(schema_projection.fields[0], 
field_builder), IsOk());
+
+  std::shared_ptr<::arrow::Array> array;
+  ASSERT_TRUE(field_builder->Finish(&array).ok());
+  ASSERT_EQ(array->length(), 2);
+  const auto& long_array = static_cast<const ::arrow::Int64Array&>(*array);
+  ASSERT_EQ(long_array.Value(0), 42);
+  ASSERT_EQ(long_array.Value(1), 42);
+}
+
+TEST(AppendDefaultToBuilderTest, PreparesScalarWhenContainerAlreadyExists) {
+  // The Avro attributes container may already exist (e.g. attached by another 
Avro
+  // attribute) with default_scalar unset. Preparation must still fill 
default_scalar
+  // rather than skip on the container's mere presence, otherwise the append 
path falls
+  // back to rebuilding the scalar per row.
+  auto pool = ::arrow::default_memory_pool();
+  auto child = std::make_shared<::arrow::Int64Builder>(pool);
+  ::arrow::StructBuilder struct_builder(
+      ::arrow::struct_({::arrow::field("d", ::arrow::int64())}), pool, 
{child});
+
+  FieldProjection projection;
+  projection.kind = FieldProjection::Kind::kDefault;
+  projection.from = Literal::Long(42);
+  // Pre-attach an empty container, simulating another attribute having 
created it.
+  projection.attributes = std::make_shared<AvroExtraAttributes>();
+
+  SchemaProjection schema_projection;
+  schema_projection.fields.push_back(projection);
+  ASSERT_THAT(PrepareDefaultScalars(schema_projection, &struct_builder), 
IsOk());
+
+  const auto* attrs = dynamic_cast<const AvroExtraAttributes*>(
+      schema_projection.fields[0].attributes.get());
+  ASSERT_NE(attrs, nullptr);
+  ASSERT_NE(attrs->default_scalar, nullptr);
+}
+
+TEST(AppendDefaultToBuilderTest, PreparesScalarUnderNestedCollections) {
+  // A default under `list<list<struct<...>>>` must be prepared too, so 
decoding reuses
+  // the cached scalar instead of rebuilding it for every element.
+  auto pool = ::arrow::default_memory_pool();
+  auto leaf = std::make_shared<::arrow::Int64Builder>(pool);
+  auto point_builder = std::make_shared<::arrow::StructBuilder>(
+      ::arrow::struct_({::arrow::field("y", ::arrow::int64())}), pool,
+      std::vector<std::shared_ptr<::arrow::ArrayBuilder>>{leaf});
+  auto inner_list = std::make_shared<::arrow::ListBuilder>(pool, 
point_builder);
+  auto outer_list = std::make_shared<::arrow::ListBuilder>(pool, inner_list);
+  ::arrow::StructBuilder root_builder(
+      ::arrow::struct_({::arrow::field("matrix", outer_list->type())}), pool,
+      std::vector<std::shared_ptr<::arrow::ArrayBuilder>>{outer_list});
+
+  FieldProjection leaf_default;
+  leaf_default.kind = FieldProjection::Kind::kDefault;
+  leaf_default.from = Literal::Long(7);
+
+  FieldProjection point_projection;
+  point_projection.kind = FieldProjection::Kind::kProjected;
+  point_projection.children.push_back(leaf_default);
+
+  FieldProjection inner_element;
+  inner_element.kind = FieldProjection::Kind::kProjected;
+  inner_element.children.push_back(point_projection);
+
+  FieldProjection outer_element;
+  outer_element.kind = FieldProjection::Kind::kProjected;
+  outer_element.children.push_back(inner_element);
+
+  SchemaProjection schema_projection;
+  schema_projection.fields.push_back(outer_element);
+  ASSERT_THAT(PrepareDefaultScalars(schema_projection, &root_builder), IsOk());
+
+  const auto& prepared = 
schema_projection.fields[0].children[0].children[0].children[0];
+  ASSERT_NE(dynamic_cast<const 
AvroExtraAttributes*>(prepared.attributes.get()), nullptr);
+}
+
 TEST(AppendDatumToBuilderTest, NestedStructWithMissingOptionalFields) {
   Schema iceberg_schema({
       SchemaField::MakeRequired(1, "id", iceberg::int32()),
diff --git a/src/iceberg/test/avro_test.cc b/src/iceberg/test/avro_test.cc
index 604cf5b0..20156a57 100644
--- a/src/iceberg/test/avro_test.cc
+++ b/src/iceberg/test/avro_test.cc
@@ -42,6 +42,7 @@
 #include "iceberg/avro/avro_register.h"
 #include "iceberg/avro/avro_stream_internal.h"
 #include "iceberg/avro/avro_writer.h"
+#include "iceberg/expression/literal.h"
 #include "iceberg/file_reader.h"
 #include "iceberg/metadata_columns.h"
 #include "iceberg/schema.h"
@@ -318,6 +319,34 @@ TEST_F(AvroReaderTest, ReadTwoFields) {
   ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
 }
 
+TEST_F(AvroReaderTest, ReadMissingFieldsWithDefaults) {
+  // The file contains only fields 1 and 2; the projected schema adds fields 3 
and 4
+  // with initial-defaults, which are filled for all rows written before the 
columns
+  // existed.
+  CreateSimpleAvroFile();
+
+  auto schema = std::make_shared<Schema>(std::vector<SchemaField>{
+      SchemaField::MakeRequired(1, "id", std::make_shared<IntType>()),
+      SchemaField::MakeOptional(2, "name", std::make_shared<StringType>()),
+      SchemaField(3, "score", std::make_shared<LongType>(), /*optional=*/false,
+                  /*doc=*/{}, std::make_shared<const 
Literal>(Literal::Long(100))),
+      SchemaField(4, "status", std::make_shared<StringType>(), 
/*optional=*/true,
+                  /*doc=*/{}, std::make_shared<const 
Literal>(Literal::String("active"))),
+  });
+
+  auto reader_result = ReaderFactoryRegistry::Open(
+      FileFormatType::kAvro,
+      {.path = temp_avro_file_, .io = file_io_, .projection = schema});
+  ASSERT_THAT(reader_result, IsOk());
+  auto reader = std::move(reader_result.value());
+
+  ASSERT_NO_FATAL_FAILURE(VerifyNextBatch(*reader,
+                                          R"([[1, "Alice", 100, "active"],
+                                              [2, "Bob", 100, "active"],
+                                              [3, "Charlie", 100, 
"active"]])"));
+  ASSERT_NO_FATAL_FAILURE(VerifyExhausted(*reader));
+}
+
 TEST_F(AvroReaderTest, RoundTripWithGenericFileIO) {
   file_io_ = std::make_shared<iceberg::test::StdFileIO>();
   temp_avro_file_ = CreateNewTempFilePathWithSuffix(".avro");

Reply via email to