Skip to content

Commit 881b3b5

Browse files
authored
feat: represent, serialize and validate v3 column default values (#746)
Part 1 of a multi-part split of #730 (column default values, item 2 of #637). The full end-to-end implementation is in #731, kept open as the proof-of-concept; this series lands it in reviewable pieces. This PR is the **schema foundation** — representing, serializing and validating v3 column default values. It is purely additive and changes no read or write behavior on its own. ## What's in this PR - **`SchemaField`** carries `initial-default` / `write-default`, stored as `std::shared_ptr<const Literal>` (immutable payload shared across copies, like the adjacent `type_`; the C++ analog of Java's `final Literal<?>`). They are set via the constructor. Getters return `std::optional<std::reference_wrapper<const Literal>>` for reading (the `Schema::FindFieldByName` idiom); `initial_default_ptr()` / `write_default_ptr()` expose the shared pointer so a rebuilt field (e.g. ID reassignment) shares the value instead of copying it. - **JSON serde**: parse/write `initial-default` / `write-default` using the existing single-value serialization (all primitive types). - **`Schema::Validate`**: version-gates the `initial-default` to format v3 (`kMinFormatVersionDefaultValues`) — it reinterprets how existing data files are read, so it requires the v3 reader contract. The `write-default` only affects values written going forward and is **not** version-gated (matching Java's `Schema.checkCompatibility`, which gates only the initial default). Both defaults are otherwise validated to be non-null primitive literals matching the field type. - **Generic projection**: a column missing from a data file with an `initial-default` maps to `FieldProjection::Kind::kDefault` carrying the literal (the per-format readers consume this in the follow-up PRs). ## Follow-ups (stacked on this PR) - read path — Parquet (`literal_util` + parquet projection/materialization) - read path — Avro - schema evolution (`UpdateSchema` add/update column defaults) ## Testing Added tests
1 parent 3664d99 commit 881b3b5

13 files changed

Lines changed: 593 additions & 14 deletions

src/iceberg/json_serde.cc

Lines changed: 60 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"
@@ -49,6 +51,7 @@
4951
#include "iceberg/util/json_util_internal.h"
5052
#include "iceberg/util/macros.h"
5153
#include "iceberg/util/string_util.h"
54+
#include "iceberg/util/temporal_util.h"
5255
#include "iceberg/util/timepoint.h"
5356

5457
namespace iceberg {
@@ -324,6 +327,12 @@ Result<nlohmann::json> ToJson(const SchemaField& field) {
324327
if (!field.doc().empty()) {
325328
json[kDoc] = field.doc();
326329
}
330+
if (field.initial_default() != nullptr) {
331+
ICEBERG_ASSIGN_OR_RAISE(json[kInitialDefault], ToJson(*field.initial_default()));
332+
}
333+
if (field.write_default() != nullptr) {
334+
ICEBERG_ASSIGN_OR_RAISE(json[kWriteDefault], ToJson(*field.write_default()));
335+
}
327336
return json;
328337
}
329338

@@ -337,7 +346,6 @@ Result<nlohmann::json> ToJson(const Type& type) {
337346
for (const auto& field : struct_type.fields()) {
338347
ICEBERG_ASSIGN_OR_RAISE(auto field_json, ToJson(field));
339348
fields_json.push_back(std::move(field_json));
340-
// TODO(gangwu): add default values
341349
}
342350
json[kFields] = fields_json;
343351
return json;
@@ -628,16 +636,66 @@ Result<std::unique_ptr<Type>> TypeFromJson(const nlohmann::json& json) {
628636
}
629637
}
630638

639+
namespace {
640+
641+
// The spec's JSON single-value form for `timestamptz` / `timestamptz_ns` default
642+
// values requires a UTC offset. The shared timestamp parser accepts any offset and
643+
// silently normalizes to UTC, which would let C++ accept default metadata that Java
644+
// rejects and then rewrite the offset on serialization. Enforce UTC for these
645+
// defaults at parse time, where the original offset is still visible.
646+
Status ValidateTimestamptzDefaultIsUtc(const Type& type, const nlohmann::json& value) {
647+
const auto type_id = type.type_id();
648+
if (type_id != TypeId::kTimestampTz && type_id != TypeId::kTimestampTzNs) {
649+
return {};
650+
}
651+
if (!value.is_string()) {
652+
return JsonParseError("Invalid timestamptz default {} for {}: expected a string",
653+
SafeDumpJson(value), type.ToString());
654+
}
655+
const auto str = value.get<std::string>();
656+
ICEBERG_ASSIGN_OR_RAISE(bool is_utc, TemporalUtils::IsUtcOffset(str));
657+
if (!is_utc) {
658+
return JsonParseError(
659+
"Invalid timestamptz default '{}' for {}: default values must use a UTC offset",
660+
str, type.ToString());
661+
}
662+
return {};
663+
}
664+
665+
} // namespace
666+
631667
Result<std::unique_ptr<SchemaField>> FieldFromJson(const nlohmann::json& json) {
632668
ICEBERG_ASSIGN_OR_RAISE(
633669
auto type, GetJsonValue<nlohmann::json>(json, kType).and_then(TypeFromJson));
634670
ICEBERG_ASSIGN_OR_RAISE(auto field_id, GetJsonValue<int32_t>(json, kId));
635671
ICEBERG_ASSIGN_OR_RAISE(auto name, GetJsonValue<std::string>(json, kName));
636672
ICEBERG_ASSIGN_OR_RAISE(auto required, GetJsonValue<bool>(json, kRequired));
637673
ICEBERG_ASSIGN_OR_RAISE(auto doc, GetJsonValueOrDefault<std::string>(json, kDoc));
674+
ICEBERG_ASSIGN_OR_RAISE(auto initial_default_json,
675+
GetJsonValueOptional<nlohmann::json>(json, kInitialDefault));
676+
ICEBERG_ASSIGN_OR_RAISE(auto write_default_json,
677+
GetJsonValueOptional<nlohmann::json>(json, kWriteDefault));
678+
679+
std::shared_ptr<const Literal> initial_default;
680+
if (initial_default_json.has_value()) {
681+
ICEBERG_RETURN_UNEXPECTED(
682+
ValidateTimestamptzDefaultIsUtc(*type, *initial_default_json));
683+
ICEBERG_ASSIGN_OR_RAISE(Literal literal,
684+
LiteralFromJson(*initial_default_json, type.get()));
685+
initial_default = std::make_shared<const Literal>(std::move(literal));
686+
}
687+
std::shared_ptr<const Literal> write_default;
688+
if (write_default_json.has_value()) {
689+
ICEBERG_RETURN_UNEXPECTED(
690+
ValidateTimestamptzDefaultIsUtc(*type, *write_default_json));
691+
ICEBERG_ASSIGN_OR_RAISE(Literal literal,
692+
LiteralFromJson(*write_default_json, type.get()));
693+
write_default = std::make_shared<const Literal>(std::move(literal));
694+
}
638695

639696
return std::make_unique<SchemaField>(field_id, std::move(name), std::move(type),
640-
!required, doc);
697+
!required, doc, std::move(initial_default),
698+
std::move(write_default));
641699
}
642700

643701
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(),
127+
field.write_default()};
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() != nullptr &&
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(),
466+
TableMetadata::kMinFormatVersionDefaultValues);
467+
}
468+
if (field.initial_default() != nullptr || field.write_default() != nullptr) {
469+
ICEBERG_RETURN_UNEXPECTED(field.Validate());
470+
}
451471
}
452472

453473
return {};

src/iceberg/schema_field.cc

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,39 @@
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

33+
namespace {
34+
35+
// A null default value is modeled as the absence of a default (matching Java), so it is
36+
// not stored.
37+
std::shared_ptr<const Literal> DropNullDefault(std::shared_ptr<const Literal> value) {
38+
if (value != nullptr && value->IsNull()) {
39+
return nullptr;
40+
}
41+
return value;
42+
}
43+
44+
} // namespace
45+
3046
SchemaField::SchemaField(int32_t field_id, std::string_view name,
31-
std::shared_ptr<Type> type, bool optional, std::string_view doc)
47+
std::shared_ptr<Type> type, bool optional, std::string_view doc,
48+
std::shared_ptr<const Literal> initial_default,
49+
std::shared_ptr<const Literal> write_default)
3250
: field_id_(field_id),
3351
name_(name),
3452
type_(std::move(type)),
3553
optional_(optional),
36-
doc_(doc) {}
54+
doc_(doc),
55+
initial_default_(DropNullDefault(std::move(initial_default))),
56+
write_default_(DropNullDefault(std::move(write_default))) {}
3757

3858
SchemaField SchemaField::MakeOptional(int32_t field_id, std::string_view name,
3959
std::shared_ptr<Type> type, std::string_view doc) {
@@ -55,13 +75,74 @@ bool SchemaField::optional() const { return optional_; }
5575

5676
std::string_view SchemaField::doc() const { return doc_; }
5777

78+
const std::shared_ptr<const Literal>& SchemaField::initial_default() const {
79+
return initial_default_;
80+
}
81+
82+
const std::shared_ptr<const Literal>& SchemaField::write_default() const {
83+
return write_default_;
84+
}
85+
86+
namespace {
87+
88+
Status ValidateDefault(const SchemaField& field, const Literal& value,
89+
std::string_view kind) {
90+
// A null default is modeled as absence and dropped at construction, so it never reaches
91+
// here; only the out-of-range cast sentinels need rejecting.
92+
if (value.IsAboveMax() || value.IsBelowMin()) {
93+
return InvalidSchema("Invalid {} value for {}: value is out of range", kind,
94+
field.name());
95+
}
96+
if (field.type() == nullptr) {
97+
return InvalidSchema("Invalid {} value for {}: field has no type", kind,
98+
field.name());
99+
}
100+
// The spec requires unknown/variant/geometry/geography columns to default to null, so a
101+
// non-null default on them is invalid (a null default was already dropped as absence).
102+
switch (field.type()->type_id()) {
103+
case TypeId::kUnknown:
104+
case TypeId::kVariant:
105+
case TypeId::kGeometry:
106+
case TypeId::kGeography:
107+
return InvalidSchema("Invalid {} value for {}: type {} cannot have a default value",
108+
kind, field.name(), *field.type());
109+
default:
110+
break;
111+
}
112+
// Defaults are otherwise only supported on primitive fields. The spec also permits JSON
113+
// single-value defaults for struct/list/map (e.g. an empty struct `{}` whose sub-field
114+
// defaults live in field metadata); that matches the current Java model's gap and is
115+
// left as a follow-up.
116+
if (!field.type()->is_primitive()) {
117+
return InvalidSchema(
118+
"Invalid {} value for {}: default values are only supported for primitive types",
119+
kind, field.name());
120+
}
121+
// Defaults are stored verbatim (no implicit cast), so a default whose literal type does
122+
// not match the field type is invalid.
123+
if (*value.type() != *field.type()) {
124+
return InvalidSchema("{} of field {} has type {} but expected {}", kind, field.name(),
125+
*value.type(), *field.type());
126+
}
127+
return {};
128+
}
129+
130+
} // namespace
131+
58132
Status SchemaField::Validate() const {
59133
if (name_.empty()) [[unlikely]] {
60134
return InvalidSchema("SchemaField cannot have empty name");
61135
}
62136
if (type_ == nullptr) [[unlikely]] {
63137
return InvalidSchema("SchemaField cannot have null type");
64138
}
139+
if (initial_default_ != nullptr) {
140+
ICEBERG_RETURN_UNEXPECTED(
141+
ValidateDefault(*this, *initial_default_, "initial-default"));
142+
}
143+
if (write_default_ != nullptr) {
144+
ICEBERG_RETURN_UNEXPECTED(ValidateDefault(*this, *write_default_, "write-default"));
145+
}
65146
return {};
66147
}
67148

@@ -72,9 +153,23 @@ std::string SchemaField::ToString() const {
72153
return result;
73154
}
74155

156+
namespace {
157+
158+
bool DefaultEquals(const std::shared_ptr<const Literal>& lhs,
159+
const std::shared_ptr<const Literal>& rhs) {
160+
if (lhs == nullptr || rhs == nullptr) {
161+
return lhs == rhs;
162+
}
163+
return *lhs == *rhs;
164+
}
165+
166+
} // namespace
167+
75168
bool SchemaField::Equals(const SchemaField& other) const {
76169
return field_id_ == other.field_id_ && name_ == other.name_ && *type_ == *other.type_ &&
77-
optional_ == other.optional_;
170+
optional_ == other.optional_ &&
171+
DefaultEquals(initial_default_, other.initial_default_) &&
172+
DefaultEquals(write_default_, other.write_default_);
78173
}
79174

80175
} // namespace iceberg

src/iceberg/schema_field.h

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,14 @@ class ICEBERG_EXPORT SchemaField : public iceberg::util::Formattable {
4646
/// \param[in] type The field type.
4747
/// \param[in] optional Whether values of this field are required or nullable.
4848
/// \param[in] doc Optional documentation string for the field.
49+
/// \param[in] initial_default The v3 `initial-default` value, or null if absent. The
50+
/// field shares ownership of the (immutable) value.
51+
/// \param[in] write_default The v3 `write-default` value, or null if absent. The field
52+
/// shares ownership of the (immutable) value.
4953
SchemaField(int32_t field_id, std::string_view name, std::shared_ptr<Type> type,
50-
bool optional, std::string_view doc = {});
54+
bool optional, std::string_view doc = {},
55+
std::shared_ptr<const Literal> initial_default = nullptr,
56+
std::shared_ptr<const Literal> write_default = nullptr);
5157

5258
/// \brief Construct an optional (nullable) field.
5359
static SchemaField MakeOptional(int32_t field_id, std::string_view name,
@@ -71,6 +77,14 @@ class ICEBERG_EXPORT SchemaField : public iceberg::util::Formattable {
7177
/// \brief Get the field documentation.
7278
std::string_view doc() const;
7379

80+
/// \brief Get the owning pointer to the default value for this field used when reading
81+
/// rows written before the field existed (v3 `initial-default`), or null if absent.
82+
const std::shared_ptr<const Literal>& initial_default() const;
83+
84+
/// \brief Get the owning pointer to the default value for this field used when a writer
85+
/// does not supply a value (v3 `write-default`), or null if absent.
86+
const std::shared_ptr<const Literal>& write_default() const;
87+
7488
[[nodiscard]] std::string ToString() const override;
7589

7690
Status Validate() const;
@@ -100,6 +114,9 @@ class ICEBERG_EXPORT SchemaField : public iceberg::util::Formattable {
100114
std::shared_ptr<Type> type_;
101115
bool optional_;
102116
std::string doc_;
117+
// Immutable default values, shared (not deep-copied) across field copies, like `type_`.
118+
std::shared_ptr<const Literal> initial_default_;
119+
std::shared_ptr<const Literal> write_default_;
103120
};
104121

105122
} // 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() != nullptr) {
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();
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)