Skip to content

Commit 1ee5b32

Browse files
committed
feat(schema): represent, serialize and validate v3 column default values
First of a multi-part split of column default value support (#730) — the schema foundation the read and evolution paths build on. Purely additive; no read/write behavior change on its own. - SchemaField carries `initial-default` / `write-default` (immutable std::shared_ptr<const Literal>) with copy-preserving WithInitialDefault / WithWriteDefault modifiers; getters return optional<reference_wrapper>. - JSON serde reads/writes `initial-default` / `write-default` via the existing single-value serialization. - Schema::Validate rejects default values below format v3 and validates they are non-null primitive literals matching the field type. - Generic schema projection maps a column missing from a data file with an initial-default to FieldProjection::Kind::kDefault. Read-path application (Parquet/Avro) and schema evolution follow in separate PRs. See #731 for the full end-to-end proof-of-concept.
1 parent c0c6b01 commit 1ee5b32

9 files changed

Lines changed: 470 additions & 10 deletions

File tree

src/iceberg/json_serde.cc

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
#include <nlohmann/json.hpp>
2828

2929
#include "iceberg/constants.h"
30+
#include "iceberg/expression/json_serde_internal.h"
31+
#include "iceberg/expression/literal.h"
3032
#include "iceberg/json_serde_internal.h"
3133
#include "iceberg/name_mapping.h"
3234
#include "iceberg/partition_field.h"
@@ -298,6 +300,15 @@ nlohmann::json ToJson(const SchemaField& field) {
298300
if (!field.doc().empty()) {
299301
json[kDoc] = field.doc();
300302
}
303+
// Defaults are validated to be primitive literals matching the field type, so
304+
// single-value serialization cannot fail here.
305+
if (field.initial_default().has_value()) {
306+
ICEBERG_ASSIGN_OR_THROW(json[kInitialDefault],
307+
ToJson(field.initial_default()->get()));
308+
}
309+
if (field.write_default().has_value()) {
310+
ICEBERG_ASSIGN_OR_THROW(json[kWriteDefault], ToJson(field.write_default()->get()));
311+
}
301312
return json;
302313
}
303314

@@ -310,7 +321,6 @@ nlohmann::json ToJson(const Type& type) {
310321
nlohmann::json fields_json = nlohmann::json::array();
311322
for (const auto& field : struct_type.fields()) {
312323
fields_json.push_back(ToJson(field));
313-
// TODO(gangwu): add default values
314324
}
315325
json[kFields] = fields_json;
316326
return json;
@@ -552,9 +562,27 @@ Result<std::unique_ptr<SchemaField>> FieldFromJson(const nlohmann::json& json) {
552562
ICEBERG_ASSIGN_OR_RAISE(auto name, GetJsonValue<std::string>(json, kName));
553563
ICEBERG_ASSIGN_OR_RAISE(auto required, GetJsonValue<bool>(json, kRequired));
554564
ICEBERG_ASSIGN_OR_RAISE(auto doc, GetJsonValueOrDefault<std::string>(json, kDoc));
565+
ICEBERG_ASSIGN_OR_RAISE(std::optional<nlohmann::json> initial_default_json,
566+
GetJsonValueOptional<nlohmann::json>(json, kInitialDefault));
567+
ICEBERG_ASSIGN_OR_RAISE(std::optional<nlohmann::json> write_default_json,
568+
GetJsonValueOptional<nlohmann::json>(json, kWriteDefault));
569+
570+
std::shared_ptr<const Literal> initial_default;
571+
if (initial_default_json.has_value()) {
572+
ICEBERG_ASSIGN_OR_RAISE(Literal literal,
573+
LiteralFromJson(*initial_default_json, type.get()));
574+
initial_default = std::make_shared<const Literal>(std::move(literal));
575+
}
576+
std::shared_ptr<const Literal> write_default;
577+
if (write_default_json.has_value()) {
578+
ICEBERG_ASSIGN_OR_RAISE(Literal literal,
579+
LiteralFromJson(*write_default_json, type.get()));
580+
write_default = std::make_shared<const Literal>(std::move(literal));
581+
}
555582

556583
return std::make_unique<SchemaField>(field_id, std::move(name), std::move(type),
557-
!required, doc);
584+
!required, doc, std::move(initial_default),
585+
std::move(write_default));
558586
}
559587

560588
Result<std::unique_ptr<Schema>> SchemaFromJson(const nlohmann::json& json) {

src/iceberg/schema.cc

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,15 @@ std::shared_ptr<Type> ReassignTypeIds(const std::shared_ptr<Type>& type,
116116
SchemaField ReassignField(const SchemaField& field, int32_t new_id,
117117
const Schema::GetId& get_id, Schema::IdMap& ids_to_reassigned,
118118
Schema::IdMap& ids_to_original) {
119-
return {new_id, std::string(field.name()),
119+
// Reassigning IDs only rewrites the field ID and nested type IDs; share the field's
120+
// (immutable) default values rather than copying them.
121+
return {new_id,
122+
std::string(field.name()),
120123
ReassignTypeIds(field.type(), get_id, ids_to_reassigned, ids_to_original),
121-
field.optional(), std::string(field.doc())};
124+
field.optional(),
125+
std::string(field.doc()),
126+
field.initial_default_ptr(),
127+
field.write_default_ptr()};
122128
}
123129

124130
std::vector<SchemaField> ReassignIds(std::vector<SchemaField> fields,
@@ -447,7 +453,21 @@ Status Schema::Validate(int32_t format_version) const {
447453
}
448454
}
449455

450-
// TODO(GuoTao.yu): Check default values when they are supported
456+
// Only the initial-default is gated on format version: it changes how existing
457+
// data files are read (rows written before the column existed materialize this
458+
// value), so it requires the v3 reader contract. A write-default only affects
459+
// values written going forward and does not reinterpret existing data.
460+
if (field.initial_default().has_value() &&
461+
format_version < TableMetadata::kMinFormatVersionDefaultValues) {
462+
return InvalidSchema(
463+
"Invalid initial default for {}: non-null default ({}) is not supported "
464+
"until v{}",
465+
field.name(), field.initial_default()->get(),
466+
TableMetadata::kMinFormatVersionDefaultValues);
467+
}
468+
if (field.initial_default().has_value() || field.write_default().has_value()) {
469+
ICEBERG_RETURN_UNEXPECTED(field.Validate());
470+
}
451471
}
452472

453473
return {};

src/iceberg/schema_field.cc

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,26 @@
2121

2222
#include <format>
2323
#include <string_view>
24+
#include <utility>
2425

26+
#include "iceberg/expression/literal.h"
2527
#include "iceberg/type.h"
2628
#include "iceberg/util/formatter.h" // IWYU pragma: keep
29+
#include "iceberg/util/macros.h"
2730

2831
namespace iceberg {
2932

3033
SchemaField::SchemaField(int32_t field_id, std::string_view name,
31-
std::shared_ptr<Type> type, bool optional, std::string_view doc)
34+
std::shared_ptr<Type> type, bool optional, std::string_view doc,
35+
std::shared_ptr<const Literal> initial_default,
36+
std::shared_ptr<const Literal> write_default)
3237
: field_id_(field_id),
3338
name_(name),
3439
type_(std::move(type)),
3540
optional_(optional),
36-
doc_(doc) {}
41+
doc_(doc),
42+
initial_default_(std::move(initial_default)),
43+
write_default_(std::move(write_default)) {}
3744

3845
SchemaField SchemaField::MakeOptional(int32_t field_id, std::string_view name,
3946
std::shared_ptr<Type> type, std::string_view doc) {
@@ -55,13 +62,76 @@ bool SchemaField::optional() const { return optional_; }
5562

5663
std::string_view SchemaField::doc() const { return doc_; }
5764

65+
std::optional<std::reference_wrapper<const Literal>> SchemaField::initial_default()
66+
const {
67+
if (initial_default_ == nullptr) {
68+
return std::nullopt;
69+
}
70+
return std::cref(*initial_default_);
71+
}
72+
73+
std::optional<std::reference_wrapper<const Literal>> SchemaField::write_default() const {
74+
if (write_default_ == nullptr) {
75+
return std::nullopt;
76+
}
77+
return std::cref(*write_default_);
78+
}
79+
80+
const std::shared_ptr<const Literal>& SchemaField::initial_default_ptr() const {
81+
return initial_default_;
82+
}
83+
84+
const std::shared_ptr<const Literal>& SchemaField::write_default_ptr() const {
85+
return write_default_;
86+
}
87+
88+
SchemaField SchemaField::WithInitialDefault(Literal initial_default) const {
89+
SchemaField copy = *this;
90+
copy.initial_default_ = std::make_shared<const Literal>(std::move(initial_default));
91+
return copy;
92+
}
93+
94+
SchemaField SchemaField::WithWriteDefault(Literal write_default) const {
95+
SchemaField copy = *this;
96+
copy.write_default_ = std::make_shared<const Literal>(std::move(write_default));
97+
return copy;
98+
}
99+
100+
namespace {
101+
102+
Status ValidateDefault(const SchemaField& field, const Literal& value,
103+
std::string_view kind) {
104+
if (value.IsNull() || value.IsAboveMax() || value.IsBelowMin()) {
105+
return InvalidSchema("Invalid {} value for {}: must be a non-null value", kind,
106+
field.name());
107+
}
108+
if (field.type() == nullptr || !field.type()->is_primitive()) {
109+
return InvalidSchema("Invalid {} value for {}: {} (must be null)", kind, field.name(),
110+
value);
111+
}
112+
if (*value.type() != *field.type()) {
113+
return InvalidSchema("{} of field {} has type {} but expected {}", kind, field.name(),
114+
*value.type(), *field.type());
115+
}
116+
return {};
117+
}
118+
119+
} // namespace
120+
58121
Status SchemaField::Validate() const {
59122
if (name_.empty()) [[unlikely]] {
60123
return InvalidSchema("SchemaField cannot have empty name");
61124
}
62125
if (type_ == nullptr) [[unlikely]] {
63126
return InvalidSchema("SchemaField cannot have null type");
64127
}
128+
if (initial_default_ != nullptr) {
129+
ICEBERG_RETURN_UNEXPECTED(
130+
ValidateDefault(*this, *initial_default_, "initial-default"));
131+
}
132+
if (write_default_ != nullptr) {
133+
ICEBERG_RETURN_UNEXPECTED(ValidateDefault(*this, *write_default_, "write-default"));
134+
}
65135
return {};
66136
}
67137

@@ -72,9 +142,23 @@ std::string SchemaField::ToString() const {
72142
return result;
73143
}
74144

145+
namespace {
146+
147+
bool DefaultEquals(const std::shared_ptr<const Literal>& lhs,
148+
const std::shared_ptr<const Literal>& rhs) {
149+
if (lhs == nullptr || rhs == nullptr) {
150+
return lhs == rhs;
151+
}
152+
return *lhs == *rhs;
153+
}
154+
155+
} // namespace
156+
75157
bool SchemaField::Equals(const SchemaField& other) const {
76158
return field_id_ == other.field_id_ && name_ == other.name_ && *type_ == *other.type_ &&
77-
optional_ == other.optional_;
159+
optional_ == other.optional_ &&
160+
DefaultEquals(initial_default_, other.initial_default_) &&
161+
DefaultEquals(write_default_, other.write_default_);
78162
}
79163

80164
} // namespace iceberg

src/iceberg/schema_field.h

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
/// type (e.g. a struct).
2525

2626
#include <cstdint>
27+
#include <functional>
2728
#include <memory>
29+
#include <optional>
2830
#include <string>
2931
#include <string_view>
3032

@@ -46,8 +48,14 @@ class ICEBERG_EXPORT SchemaField : public iceberg::util::Formattable {
4648
/// \param[in] type The field type.
4749
/// \param[in] optional Whether values of this field are required or nullable.
4850
/// \param[in] doc Optional documentation string for the field.
51+
/// \param[in] initial_default The v3 `initial-default` value, or null if absent. The
52+
/// field shares ownership of the (immutable) value.
53+
/// \param[in] write_default The v3 `write-default` value, or null if absent. The field
54+
/// shares ownership of the (immutable) value.
4955
SchemaField(int32_t field_id, std::string_view name, std::shared_ptr<Type> type,
50-
bool optional, std::string_view doc = {});
56+
bool optional, std::string_view doc = {},
57+
std::shared_ptr<const Literal> initial_default = nullptr,
58+
std::shared_ptr<const Literal> write_default = nullptr);
5159

5260
/// \brief Construct an optional (nullable) field.
5361
static SchemaField MakeOptional(int32_t field_id, std::string_view name,
@@ -71,6 +79,32 @@ class ICEBERG_EXPORT SchemaField : public iceberg::util::Formattable {
7179
/// \brief Get the field documentation.
7280
std::string_view doc() const;
7381

82+
/// \brief Get the default value for this field used when reading rows written
83+
/// before the field existed (v3 `initial-default`). Empty if absent.
84+
///
85+
/// The returned reference is a non-owning view into a value owned by this field;
86+
/// it remains valid for the lifetime of this SchemaField.
87+
[[nodiscard]] std::optional<std::reference_wrapper<const Literal>> initial_default()
88+
const;
89+
90+
/// \brief Get the default value for this field used when a writer does not
91+
/// supply a value (v3 `write-default`). Empty if absent.
92+
///
93+
/// The returned reference is a non-owning view into a value owned by this field;
94+
/// it remains valid for the lifetime of this SchemaField.
95+
[[nodiscard]] std::optional<std::reference_wrapper<const Literal>> write_default()
96+
const;
97+
98+
/// \brief Get the shared owning pointer to the `initial-default` value, or null if
99+
/// absent. Prefer initial_default() for reading; this exists so a rebuilt field can
100+
/// share the (immutable) value rather than copy it.
101+
[[nodiscard]] const std::shared_ptr<const Literal>& initial_default_ptr() const;
102+
103+
/// \brief Get the shared owning pointer to the `write-default` value, or null if
104+
/// absent. Prefer write_default() for reading; this exists so a rebuilt field can
105+
/// share the (immutable) value rather than copy it.
106+
[[nodiscard]] const std::shared_ptr<const Literal>& write_default_ptr() const;
107+
74108
[[nodiscard]] std::string ToString() const override;
75109

76110
Status Validate() const;
@@ -91,6 +125,18 @@ class ICEBERG_EXPORT SchemaField : public iceberg::util::Formattable {
91125
return copy;
92126
}
93127

128+
/// \brief Return a copy of this field with the given `initial-default` value.
129+
///
130+
/// The returned field takes ownership of the value. To build a field with defaults
131+
/// without an intermediate copy, pass them to the constructor instead.
132+
[[nodiscard]] SchemaField WithInitialDefault(Literal initial_default) const;
133+
134+
/// \brief Return a copy of this field with the given `write-default` value.
135+
///
136+
/// The returned field takes ownership of the value. To build a field with defaults
137+
/// without an intermediate copy, pass them to the constructor instead.
138+
[[nodiscard]] SchemaField WithWriteDefault(Literal write_default) const;
139+
94140
private:
95141
/// \brief Compare two fields for equality.
96142
[[nodiscard]] bool Equals(const SchemaField& other) const;
@@ -100,6 +146,11 @@ class ICEBERG_EXPORT SchemaField : public iceberg::util::Formattable {
100146
std::shared_ptr<Type> type_;
101147
bool optional_;
102148
std::string doc_;
149+
// Default values are owned by this field and never mutated after being set; copies
150+
// of the field share the same payload (reference-counted) instead of deep-copying,
151+
// like `type_` above. Sharing is unobservable because the payload is immutable.
152+
std::shared_ptr<const Literal> initial_default_;
153+
std::shared_ptr<const Literal> write_default_;
103154
};
104155

105156
} // namespace iceberg

src/iceberg/schema_util.cc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,14 @@ Result<FieldProjection> ProjectNested(const Type& expected_type, const Type& sou
172172
iter->second.local_index, prune_source));
173173
} else if (MetadataColumns::IsMetadataColumn(field_id)) {
174174
child_projection.kind = FieldProjection::Kind::kMetadata;
175+
} else if (expected_field.initial_default().has_value()) {
176+
// Rows written before the field existed assume its `initial-default` value.
177+
child_projection.kind = FieldProjection::Kind::kDefault;
178+
child_projection.from = expected_field.initial_default()->get();
175179
} else if (expected_field.optional()) {
176180
child_projection.kind = FieldProjection::Kind::kNull;
177181
} else {
178-
// TODO(gangwu): support default value for v3 and constant value
182+
// TODO(gangwu): support constant value
179183
return InvalidSchema("Missing required field: {}", expected_field.ToString());
180184
}
181185
result.children.emplace_back(std::move(child_projection));

0 commit comments

Comments
 (0)