Skip to content

Commit f3fa485

Browse files
authored
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)
1 parent 587fb69 commit f3fa485

7 files changed

Lines changed: 446 additions & 0 deletions

File tree

src/iceberg/avro/avro_data_util.cc

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@
1717
* under the License.
1818
*/
1919

20+
#include <span>
21+
2022
#include <arrow/array/builder_binary.h>
2123
#include <arrow/array/builder_decimal.h>
2224
#include <arrow/array/builder_nested.h>
2325
#include <arrow/array/builder_primitive.h>
2426
#include <arrow/extension_type.h>
2527
#include <arrow/json/from_string.h>
28+
#include <arrow/scalar.h>
2629
#include <arrow/type.h>
2730
#include <arrow/util/decimal.h>
2831
#include <avro/Generic.hh>
@@ -31,6 +34,7 @@
3134
#include <avro/Types.hh>
3235

3336
#include "iceberg/arrow/arrow_status_internal.h"
37+
#include "iceberg/arrow/literal_util_internal.h"
3438
#include "iceberg/avro/avro_data_util_internal.h"
3539
#include "iceberg/avro/avro_schema_util_internal.h"
3640
#include "iceberg/metadata_columns.h"
@@ -88,6 +92,8 @@ Status AppendStructToBuilder(const ::avro::NodePtr& avro_node,
8892
metadata_context, field_builder));
8993
} else if (field_projection.kind == FieldProjection::Kind::kNull) {
9094
ICEBERG_ARROW_RETURN_NOT_OK(field_builder->AppendNull());
95+
} else if (field_projection.kind == FieldProjection::Kind::kDefault) {
96+
ICEBERG_RETURN_UNEXPECTED(AppendDefaultToBuilder(field_projection, field_builder));
9197
} else if (field_projection.kind == FieldProjection::Kind::kMetadata) {
9298
int32_t field_id = expected_field.field_id();
9399
if (field_id == MetadataColumns::kFilePathColumnId) {
@@ -466,6 +472,10 @@ Status AppendFieldToBuilder(const ::avro::NodePtr& avro_node,
466472
return {};
467473
}
468474

475+
if (projection.kind == FieldProjection::Kind::kDefault) {
476+
return AppendDefaultToBuilder(projection, array_builder);
477+
}
478+
469479
const bool is_row_lineage =
470480
MetadataColumns::IsRowLineageColumn(projected_field.field_id());
471481

@@ -497,6 +507,148 @@ Status AppendFieldToBuilder(const ::avro::NodePtr& avro_node,
497507

498508
} // namespace
499509

510+
namespace {
511+
512+
Result<std::shared_ptr<::arrow::Scalar>> MakeDefaultScalar(
513+
const Literal& literal, const std::shared_ptr<::arrow::DataType>& builder_type) {
514+
// The builder's own memory pool is not exposed, so the small scalar buffer uses the
515+
// default pool.
516+
ICEBERG_ASSIGN_OR_RAISE(std::shared_ptr<::arrow::Scalar> scalar,
517+
arrow::ToArrowScalar(literal, ::arrow::default_memory_pool()));
518+
519+
// For an extension builder (e.g. `arrow.uuid`) target its storage type: ToArrowScalar
520+
// yields the storage scalar (fixed_size_binary(16) for uuid) and Scalar::CastTo has no
521+
// kernel that targets an extension type. This mirrors MakeDefaultArray's extension
522+
// handling.
523+
std::shared_ptr<::arrow::DataType> target_type = builder_type;
524+
if (target_type->id() == ::arrow::Type::EXTENSION) {
525+
target_type = internal::checked_cast<const ::arrow::ExtensionType&>(*target_type)
526+
.storage_type();
527+
}
528+
529+
if (!scalar->type->Equals(*target_type)) {
530+
ICEBERG_ARROW_ASSIGN_OR_RETURN(scalar, scalar->CastTo(target_type));
531+
}
532+
return scalar;
533+
}
534+
535+
Status PrepareStructDefaultScalars(std::span<FieldProjection> projections,
536+
::arrow::ArrayBuilder* builder);
537+
538+
// Recurse into whatever nested builder this projection describes, so a default cached for
539+
// a struct field is found at any nesting depth (e.g. `list<list<struct<...>>>`) instead
540+
// of only when the collection's child is an immediate struct.
541+
Status PrepareNestedDefaultScalars(FieldProjection& projection,
542+
::arrow::ArrayBuilder* builder) {
543+
if (projection.kind != FieldProjection::Kind::kProjected ||
544+
projection.children.empty()) {
545+
return {};
546+
}
547+
548+
switch (builder->type()->id()) {
549+
case ::arrow::Type::STRUCT:
550+
return PrepareStructDefaultScalars(projection.children, builder);
551+
case ::arrow::Type::LIST: {
552+
// List projections store a single child for the element.
553+
auto* list_builder = internal::checked_cast<::arrow::ListBuilder*>(builder);
554+
return PrepareNestedDefaultScalars(projection.children[0],
555+
list_builder->value_builder());
556+
}
557+
case ::arrow::Type::LARGE_LIST: {
558+
auto* list_builder = internal::checked_cast<::arrow::LargeListBuilder*>(builder);
559+
return PrepareNestedDefaultScalars(projection.children[0],
560+
list_builder->value_builder());
561+
}
562+
case ::arrow::Type::MAP: {
563+
auto* map_builder = internal::checked_cast<::arrow::MapBuilder*>(builder);
564+
if (projection.children.size() >= 1) {
565+
ICEBERG_RETURN_UNEXPECTED(PrepareNestedDefaultScalars(
566+
projection.children[0], map_builder->key_builder()));
567+
}
568+
if (projection.children.size() >= 2) {
569+
ICEBERG_RETURN_UNEXPECTED(PrepareNestedDefaultScalars(
570+
projection.children[1], map_builder->item_builder()));
571+
}
572+
return {};
573+
}
574+
default:
575+
return {};
576+
}
577+
}
578+
579+
Status PrepareStructDefaultScalars(std::span<FieldProjection> projections,
580+
::arrow::ArrayBuilder* builder) {
581+
auto* struct_builder = internal::checked_cast<::arrow::StructBuilder*>(builder);
582+
if (static_cast<size_t>(struct_builder->num_fields()) != projections.size()) {
583+
return InvalidArgument(
584+
"Inconsistent number of struct builder fields ({}) and projections ({})",
585+
struct_builder->num_fields(), projections.size());
586+
}
587+
588+
for (size_t i = 0; i < projections.size(); ++i) {
589+
auto& field_projection = projections[i];
590+
auto* field_builder = struct_builder->field_builder(static_cast<int>(i));
591+
592+
if (field_projection.kind == FieldProjection::Kind::kDefault) {
593+
// Get-or-create the single Avro attributes container: another Avro attribute may
594+
// have created it already, so guard on `default_scalar` being unset rather than on
595+
// the container's presence (otherwise a pre-existing container would skip
596+
// preparation and silently fall back to per-row scalar rebuilding).
597+
std::shared_ptr<AvroExtraAttributes> attrs;
598+
if (field_projection.attributes == nullptr) {
599+
attrs = std::make_shared<AvroExtraAttributes>();
600+
field_projection.attributes = attrs;
601+
} else {
602+
// Avro attaches only AvroExtraAttributes; checked_pointer_cast asserts that
603+
// invariant in debug rather than silently reinterpreting a foreign attributes
604+
// type.
605+
attrs = internal::checked_pointer_cast<AvroExtraAttributes>(
606+
field_projection.attributes);
607+
}
608+
if (attrs->default_scalar == nullptr) {
609+
ICEBERG_ASSIGN_OR_RAISE(
610+
attrs->default_scalar,
611+
MakeDefaultScalar(std::get<Literal>(field_projection.from),
612+
field_builder->type()));
613+
}
614+
continue;
615+
}
616+
617+
ICEBERG_RETURN_UNEXPECTED(
618+
PrepareNestedDefaultScalars(field_projection, field_builder));
619+
}
620+
return {};
621+
}
622+
623+
} // namespace
624+
625+
Status PrepareDefaultScalars(SchemaProjection& projection,
626+
::arrow::ArrayBuilder* root_builder) {
627+
return PrepareStructDefaultScalars(projection.fields, root_builder);
628+
}
629+
630+
Status AppendDefaultToBuilder(const Literal& literal, ::arrow::ArrayBuilder* builder) {
631+
ICEBERG_ASSIGN_OR_RAISE(std::shared_ptr<::arrow::Scalar> scalar,
632+
MakeDefaultScalar(literal, builder->type()));
633+
ICEBERG_ARROW_RETURN_NOT_OK(builder->AppendScalar(*scalar));
634+
return {};
635+
}
636+
637+
Status AppendDefaultToBuilder(const FieldProjection& projection,
638+
::arrow::ArrayBuilder* builder) {
639+
// Avro projections carry a single attributes type, so once one is attached it is an
640+
// AvroExtraAttributes; use checked_cast instead of a per-row dynamic_cast.
641+
if (projection.attributes != nullptr) {
642+
const auto& attrs =
643+
internal::checked_cast<const AvroExtraAttributes&>(*projection.attributes);
644+
if (attrs.default_scalar != nullptr) {
645+
ICEBERG_ARROW_RETURN_NOT_OK(builder->AppendScalar(*attrs.default_scalar));
646+
return {};
647+
}
648+
}
649+
return AppendDefaultToBuilder(std::get<Literal>(projection.from), builder);
650+
}
651+
500652
Status AppendDatumToBuilder(const ::avro::NodePtr& avro_node,
501653
const ::avro::GenericDatum& avro_datum,
502654
const SchemaProjection& projection,

src/iceberg/avro/avro_data_util_internal.h

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,51 @@
1919

2020
#pragma once
2121

22+
#include <memory>
23+
2224
#include <arrow/array/builder_base.h>
25+
#include <arrow/scalar.h>
2326
#include <avro/GenericDatum.hh>
2427

2528
#include "iceberg/arrow/metadata_column_util_internal.h"
29+
#include "iceberg/expression/literal.h"
2630
#include "iceberg/schema_util.h"
2731

2832
namespace iceberg::avro {
2933

34+
/// \brief Avro-specific per-field projection attributes.
35+
///
36+
/// `FieldProjection` has a single attributes slot, so all Avro-side attributes live in
37+
/// one container (mirroring `ParquetExtraAttributes`) rather than separate subclasses
38+
/// that could not coexist. `default_scalar` is the Arrow scalar for a `kDefault` field,
39+
/// materialized once (see `PrepareDefaultScalars`) so row-by-row decode only needs
40+
/// `AppendScalar` instead of repeating `ToArrowScalar` / `CastTo` per row.
41+
struct AvroExtraAttributes : FieldProjection::ExtraAttributes {
42+
std::shared_ptr<::arrow::Scalar> default_scalar;
43+
};
44+
45+
/// \brief Precompute cast Arrow scalars for every `kDefault` field under `projection`.
46+
///
47+
/// Walks `root_builder` in lockstep with the projection so each default is cast to the
48+
/// builder's Arrow type once per scan. Safe to call repeatedly; existing
49+
/// `AvroExtraAttributes` entries are left unchanged.
50+
Status PrepareDefaultScalars(SchemaProjection& projection,
51+
::arrow::ArrayBuilder* root_builder);
52+
53+
/// \brief Append a literal once to `builder` while decoding Avro row-by-row.
54+
///
55+
/// Used to materialize `FieldProjection::Kind::kDefault`. Shares `ToArrowScalar` with
56+
/// Parquet's batch path (`MakeDefaultArray`); the append shape stays Avro-local because
57+
/// Avro builds Arrow arrays via per-row `ArrayBuilder`s rather than whole-column arrays.
58+
/// Prefer the `FieldProjection` overload after `PrepareDefaultScalars` so the scalar is
59+
/// reused across rows.
60+
Status AppendDefaultToBuilder(const Literal& literal, ::arrow::ArrayBuilder* builder);
61+
62+
/// \brief Append a `kDefault` projection, reusing a scalar cached on
63+
/// `projection.attributes` when present.
64+
Status AppendDefaultToBuilder(const FieldProjection& projection,
65+
::arrow::ArrayBuilder* builder);
66+
3067
/// \brief Append an Avro datum to an Arrow array builder.
3168
///
3269
/// This function handles schema evolution by using the provided projection to map

src/iceberg/avro/avro_direct_decoder.cc

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
#include <avro/Types.hh>
3030

3131
#include "iceberg/arrow/arrow_status_internal.h"
32+
#include "iceberg/avro/avro_data_util_internal.h"
3233
#include "iceberg/avro/avro_direct_decoder_internal.h"
3334
#include "iceberg/avro/avro_schema_util_internal.h"
3435
#include "iceberg/metadata_columns.h"
@@ -209,6 +210,8 @@ Status DecodeStructToBuilder(const ::avro::NodePtr& avro_node, ::avro::Decoder&
209210
auto* field_builder = struct_builder->field_builder(static_cast<int>(proj_idx));
210211
if (field_projection.kind == FieldProjection::Kind::kNull) {
211212
ICEBERG_ARROW_RETURN_NOT_OK(field_builder->AppendNull());
213+
} else if (field_projection.kind == FieldProjection::Kind::kDefault) {
214+
ICEBERG_RETURN_UNEXPECTED(AppendDefaultToBuilder(field_projection, field_builder));
212215
} else if (field_projection.kind == FieldProjection::Kind::kMetadata) {
213216
int32_t field_id = expected_field.field_id();
214217
if (field_id == MetadataColumns::kFilePathColumnId) {

src/iceberg/avro/avro_reader.cc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,8 @@ class AvroReader::Impl {
434434
builder_result.status().message());
435435
}
436436
context_->builder_ = builder_result.MoveValueUnsafe();
437+
ICEBERG_RETURN_UNEXPECTED(
438+
PrepareDefaultScalars(projection_, context_->builder_.get()));
437439
backend_->InitReadContext(backend_->GetReaderSchema());
438440

439441
return {};

src/iceberg/avro/avro_schema_util.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,10 @@ Result<FieldProjection> ProjectStruct(const StructType& struct_type,
752752
iter->second.local_index, prune_source));
753753
} else if (MetadataColumns::IsMetadataColumn(field_id)) {
754754
child_projection.kind = FieldProjection::Kind::kMetadata;
755+
} else if (expected_field.initial_default() != nullptr) {
756+
// Rows written before the field existed assume its `initial-default` value.
757+
child_projection.kind = FieldProjection::Kind::kDefault;
758+
child_projection.from = *expected_field.initial_default();
755759
} else if (expected_field.optional()) {
756760
child_projection.kind = FieldProjection::Kind::kNull;
757761
} else {

0 commit comments

Comments
 (0)