Skip to content

Commit c58050f

Browse files
committed
refactor(schema): normalize default values via a fallible SchemaField::Make factory
Move default-value normalization out of the constructor (which cannot report errors) into a fallible factory, per review feedback. SchemaField::Make casts a cross-type default to the field type and propagates the cast error / rejects out-of-range values instead of swallowing them; it is a no-op when the default already matches the field type. The constructor stores defaults verbatim, and ValidateDefault requires a stored default to match the field type exactly (Make is the cast-accepting path). This keeps the cross-type-accept behavior while ensuring stored defaults are always field-typed, so projection and JSON round-trip equality are correct.
1 parent 462d2d9 commit c58050f

4 files changed

Lines changed: 78 additions & 54 deletions

File tree

src/iceberg/schema_field.cc

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,25 +33,27 @@ namespace iceberg {
3333

3434
namespace {
3535

36-
// Normalizes a default value to the field type. A cross-type default (e.g. an int
37-
// literal on a long field) is accepted, so it is cast to the field type up front;
38-
// otherwise projection, JSON round-trip and equality would observe a literal whose type
39-
// differs from the field. A value that already matches the field type is returned as-is
40-
// (no needless copy), and a value that cannot be cast is left unchanged so Validate()
41-
// can report it.
42-
std::shared_ptr<const Literal> NormalizeDefault(std::shared_ptr<const Literal> value,
43-
const std::shared_ptr<Type>& field_type) {
36+
// Normalizes a default value to the field type so the stored value always matches the
37+
// field. A cross-type default (e.g. an int literal on a long field) is cast to the field
38+
// type; a value that already matches the field type, or a null, is returned unchanged.
39+
// The cast error is propagated and an out-of-range value is rejected rather than
40+
// swallowed.
41+
Result<std::shared_ptr<const Literal>> NormalizeDefault(
42+
std::shared_ptr<const Literal> value, const std::shared_ptr<Type>& field_type) {
4443
if (value == nullptr || field_type == nullptr || !field_type->is_primitive()) {
4544
return value;
4645
}
4746
if (*value->type() == *field_type) {
4847
return value;
4948
}
50-
auto cast = value->CastTo(internal::checked_pointer_cast<PrimitiveType>(field_type));
51-
if (!cast.has_value() || cast->IsAboveMax() || cast->IsBelowMin()) {
52-
return value;
49+
ICEBERG_ASSIGN_OR_RAISE(
50+
auto cast,
51+
value->CastTo(internal::checked_pointer_cast<PrimitiveType>(field_type)));
52+
if (cast.IsAboveMax() || cast.IsBelowMin()) {
53+
return InvalidSchema("default value of type {} is out of range for {}",
54+
*value->type(), *field_type);
5355
}
54-
return std::make_shared<const Literal>(std::move(cast).value());
56+
return std::make_shared<const Literal>(std::move(cast));
5557
}
5658

5759
} // namespace
@@ -65,8 +67,21 @@ SchemaField::SchemaField(int32_t field_id, std::string_view name,
6567
type_(std::move(type)),
6668
optional_(optional),
6769
doc_(doc),
68-
initial_default_(NormalizeDefault(std::move(initial_default), type_)),
69-
write_default_(NormalizeDefault(std::move(write_default), type_)) {}
70+
initial_default_(std::move(initial_default)),
71+
write_default_(std::move(write_default)) {}
72+
73+
Result<SchemaField> SchemaField::Make(int32_t field_id, std::string_view name,
74+
std::shared_ptr<Type> type, bool optional,
75+
std::string_view doc,
76+
std::shared_ptr<const Literal> initial_default,
77+
std::shared_ptr<const Literal> write_default) {
78+
ICEBERG_ASSIGN_OR_RAISE(initial_default,
79+
NormalizeDefault(std::move(initial_default), type));
80+
ICEBERG_ASSIGN_OR_RAISE(write_default,
81+
NormalizeDefault(std::move(write_default), type));
82+
return SchemaField(field_id, name, std::move(type), optional, doc,
83+
std::move(initial_default), std::move(write_default));
84+
}
7085

7186
SchemaField SchemaField::MakeOptional(int32_t field_id, std::string_view name,
7287
std::shared_ptr<Type> type, std::string_view doc) {
@@ -128,15 +143,11 @@ Status ValidateDefault(const SchemaField& field, const Literal& value,
128143
"Invalid {} value for {}: default values are only supported for primitive types",
129144
kind, field.name());
130145
}
131-
// Match Java (Types.NestedField), which casts the default literal to the field type
132-
// instead of requiring an exact type match (e.g. an int default on a long field, or
133-
// a string default on a date/timestamp/uuid field). Reject only defaults that cannot
134-
// be cast to the field type or fall outside its range (CastTo signals out-of-range as
135-
// an above-max/below-min sentinel).
136-
auto field_type = internal::checked_pointer_cast<PrimitiveType>(field.type());
137-
ICEBERG_ASSIGN_OR_RAISE(auto cast, value.CastTo(field_type));
138-
if (cast.IsAboveMax() || cast.IsBelowMin()) {
139-
return InvalidSchema("{} of field {} ({}) is out of range for {}", kind, field.name(),
146+
// A default is normalized to the field type by SchemaField::Make (which casts a
147+
// cross-type default and reports any cast error), so a stored default whose type does
148+
// not match the field type is invalid here.
149+
if (*value.type() != *field.type()) {
150+
return InvalidSchema("{} of field {} has type {} but expected {}", kind, field.name(),
140151
*value.type(), *field.type());
141152
}
142153
return {};

src/iceberg/schema_field.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ class ICEBERG_EXPORT SchemaField : public iceberg::util::Formattable {
6464
static SchemaField MakeRequired(int32_t field_id, std::string_view name,
6565
std::shared_ptr<Type> type, std::string_view doc = {});
6666

67+
/// \brief Construct a field, normalizing the default values to the field type.
68+
///
69+
/// Unlike the constructor (which stores the defaults verbatim), this casts a default
70+
/// whose literal type differs from the field type to the field type — e.g. an int
71+
/// default on a long field — matching the spec/Java cast behavior. Returns an error if
72+
/// a default cannot be cast to the field type or falls outside its range.
73+
static Result<SchemaField> Make(
74+
int32_t field_id, std::string_view name, std::shared_ptr<Type> type, bool optional,
75+
std::string_view doc = {}, std::shared_ptr<const Literal> initial_default = nullptr,
76+
std::shared_ptr<const Literal> write_default = nullptr);
77+
6778
/// \brief Get the field ID.
6879
[[nodiscard]] int32_t field_id() const;
6980

src/iceberg/test/schema_json_test.cc

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -242,16 +242,17 @@ TEST(SchemaJsonTest, NestedFieldWithDefaultValuesRoundTrip) {
242242
}
243243

244244
TEST(SchemaJsonTest, CrossTypeDefaultNormalizedRoundTrip) {
245-
// A programmatically-constructed cross-type default (an int literal on a long field) is
246-
// normalized to the field type at construction, so it survives a ToJson/SchemaFromJson
247-
// round-trip as an equal schema (an un-normalized int literal would compare unequal to
248-
// the re-parsed long literal).
249-
Schema original({SchemaField(1, "id", int64(), /*optional=*/false, /*doc=*/{},
250-
std::make_shared<const Literal>(Literal::Int(34)))});
251-
const auto& field = original.fields()[0];
245+
// A cross-type default (an int literal on a long field) built via SchemaField::Make is
246+
// normalized to the field type, so it survives a ToJson/SchemaFromJson round-trip as an
247+
// equal schema (an un-normalized int literal would compare unequal to the re-parsed
248+
// long literal).
249+
ICEBERG_UNWRAP_OR_FAIL(
250+
auto field, SchemaField::Make(1, "id", int64(), /*optional=*/false, /*doc=*/{},
251+
std::make_shared<const Literal>(Literal::Int(34))));
252252
ASSERT_TRUE(field.initial_default().has_value());
253253
EXPECT_EQ(field.initial_default()->get(), Literal::Long(34));
254254

255+
Schema original({std::move(field)});
255256
ICEBERG_UNWRAP_OR_FAIL(auto json, ToJson(original));
256257
ICEBERG_UNWRAP_OR_FAIL(auto reparsed, SchemaFromJson(json));
257258
EXPECT_EQ(original, *reparsed);

src/iceberg/test/schema_test.cc

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -158,38 +158,39 @@ TEST(SchemaTest, ValidateDoesNotVersionGateWriteDefault) {
158158
}
159159

160160
TEST(SchemaTest, ValidateRejectsMismatchedDefaultValue) {
161+
// The constructor stores defaults verbatim (normalization happens in
162+
// SchemaField::Make), so a stored default whose type differs from the field type is
163+
// rejected by Validate.
161164
iceberg::Schema schema({iceberg::SchemaField(
162165
1, "id", iceberg::int32(), false, /*doc=*/{}, /*initial_default=*/nullptr,
163166
std::make_shared<const iceberg::Literal>(iceberg::Literal::String("oops")))});
164167

165-
// The default literal is cast to the field type; an uncastable type surfaces the
166-
// original cast error.
167168
auto status = schema.Validate(iceberg::TableMetadata::kSupportedTableFormatVersion);
168-
ASSERT_THAT(status, iceberg::IsError(iceberg::ErrorKind::kNotSupported));
169-
EXPECT_THAT(status, iceberg::HasErrorMessage("Cast from String"));
169+
ASSERT_THAT(status, iceberg::IsError(iceberg::ErrorKind::kInvalidSchema));
170+
EXPECT_THAT(status, iceberg::HasErrorMessage("write-default"));
170171
}
171172

172-
TEST(SchemaTest, ValidateCastsDefaultToFieldType) {
173-
// Matching Java, the default literal is cast to the field type rather than required to
174-
// match exactly: an int default on a long field is accepted (int -> long).
175-
iceberg::Schema widened({iceberg::SchemaField(
176-
1, "id", iceberg::int64(), false, /*doc=*/{},
177-
std::make_shared<const iceberg::Literal>(iceberg::Literal::Int(34)))});
178-
EXPECT_THAT(widened.Validate(iceberg::TableMetadata::kSupportedTableFormatVersion),
179-
iceberg::IsOk());
180-
// The stored default is normalized to the field type (long), not kept as the narrower
181-
// int literal, so projection/serialization/equality all observe a long.
182-
const auto& widened_field = widened.fields()[0];
183-
ASSERT_TRUE(widened_field.initial_default().has_value());
184-
EXPECT_EQ(widened_field.initial_default()->get(), iceberg::Literal::Long(34));
185-
186-
// A default outside the field type's range is rejected (CastTo returns an
187-
// above-max/below-min sentinel).
188-
iceberg::Schema out_of_range({iceberg::SchemaField(
189-
1, "id", iceberg::int32(), false, /*doc=*/{},
190-
std::make_shared<const iceberg::Literal>(iceberg::Literal::Long(5'000'000'000)))});
191-
EXPECT_THAT(out_of_range.Validate(iceberg::TableMetadata::kSupportedTableFormatVersion),
173+
TEST(SchemaTest, MakeNormalizesDefaultToFieldType) {
174+
// Make casts a cross-type default to the field type (int -> long) and stores the
175+
// normalized value, so projection/serialization/equality all observe a long.
176+
ICEBERG_UNWRAP_OR_FAIL(
177+
auto field, iceberg::SchemaField::Make(1, "id", iceberg::int64(), false, /*doc=*/{},
178+
std::make_shared<const iceberg::Literal>(
179+
iceberg::Literal::Int(34))));
180+
ASSERT_TRUE(field.initial_default().has_value());
181+
EXPECT_EQ(field.initial_default()->get(), iceberg::Literal::Long(34));
182+
183+
// A default outside the field type's range is rejected by Make.
184+
EXPECT_THAT(iceberg::SchemaField::Make(1, "id", iceberg::int32(), false, /*doc=*/{},
185+
std::make_shared<const iceberg::Literal>(
186+
iceberg::Literal::Long(5'000'000'000))),
192187
iceberg::IsError(iceberg::ErrorKind::kInvalidSchema));
188+
189+
// A default whose type cannot be cast to the field type surfaces the cast error.
190+
EXPECT_THAT(iceberg::SchemaField::Make(1, "id", iceberg::int32(), false, /*doc=*/{},
191+
std::make_shared<const iceberg::Literal>(
192+
iceberg::Literal::String("oops"))),
193+
iceberg::IsError(iceberg::ErrorKind::kNotSupported));
193194
}
194195

195196
TEST(SchemaTest, ReassignIdsPreservesDefaultValues) {

0 commit comments

Comments
 (0)