Skip to content

Commit 38bbdb0

Browse files
committed
fix(schema): review follow-ups — lenient float default parse, must-be-null types, tests
- LiteralFromJson accepts an integer JSON token for float/double defaults (Java writes a float default as a bare integer), so Java-produced v3 metadata parses in C++. - ValidateDefault rejects a non-null default on unknown/variant/geometry/geography fields (the spec requires those to default to null). - Tests: full TableMetadata-level v3 default round-trip; non-primitive + must-be-null default rejection; SchemaField equality distinguishes default values; present-null write-default dropped to absence; integer-encoded float default parses.
1 parent bda2d78 commit 38bbdb0

5 files changed

Lines changed: 91 additions & 7 deletions

File tree

src/iceberg/expression/json_serde.cc

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,13 +342,15 @@ Result<Literal> LiteralFromJson(const nlohmann::json& json, const Type* type) {
342342
return Literal::Long(json.get<int64_t>());
343343

344344
case TypeId::kFloat:
345-
if (!json.is_number_float()) [[unlikely]] {
345+
// Accept an integer JSON token too (e.g. `1`), matching Java's lenient numeric
346+
// parsing; otherwise a float default written as a bare integer fails to read.
347+
if (!json.is_number()) [[unlikely]] {
346348
return JsonParseError("Cannot parse {} as a float value", SafeDumpJson(json));
347349
}
348350
return Literal::Float(json.get<float>());
349351

350352
case TypeId::kDouble:
351-
if (!json.is_number_float()) [[unlikely]] {
353+
if (!json.is_number()) [[unlikely]] {
352354
return JsonParseError("Cannot parse {} as a double value", SafeDumpJson(json));
353355
}
354356
return Literal::Double(json.get<double>());

src/iceberg/schema_field.cc

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,27 @@ Status ValidateDefault(const SchemaField& field, const Literal& value,
131131
return InvalidSchema("Invalid {} value for {}: value is out of range", kind,
132132
field.name());
133133
}
134-
// Defaults are only supported on primitive fields. The spec also permits JSON
135-
// single-value defaults for struct/list/map (e.g. an empty struct `{}` whose
136-
// sub-field defaults live in field metadata); that matches the current Java model's
137-
// gap and is left as a follow-up.
138-
if (field.type() == nullptr || !field.type()->is_primitive()) {
134+
if (field.type() == nullptr) {
135+
return InvalidSchema("Invalid {} value for {}: field has no type", kind,
136+
field.name());
137+
}
138+
// The spec requires unknown/variant/geometry/geography columns to default to null, so a
139+
// non-null default on them is invalid (a null default was already dropped as absence).
140+
switch (field.type()->type_id()) {
141+
case TypeId::kUnknown:
142+
case TypeId::kVariant:
143+
case TypeId::kGeometry:
144+
case TypeId::kGeography:
145+
return InvalidSchema("Invalid {} value for {}: {} fields must default to null",
146+
kind, field.name(), *field.type());
147+
default:
148+
break;
149+
}
150+
// Defaults are otherwise only supported on primitive fields. The spec also permits JSON
151+
// single-value defaults for struct/list/map (e.g. an empty struct `{}` whose sub-field
152+
// defaults live in field metadata); that matches the current Java model's gap and is
153+
// left as a follow-up.
154+
if (!field.type()->is_primitive()) {
139155
return InvalidSchema(
140156
"Invalid {} value for {}: default values are only supported for primitive types",
141157
kind, field.name());

src/iceberg/test/metadata_serde_test.cc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#include <gtest/gtest.h>
2424
#include <nlohmann/json.hpp>
2525

26+
#include "iceberg/expression/literal.h"
2627
#include "iceberg/json_serde_internal.h"
2728
#include "iceberg/partition_field.h"
2829
#include "iceberg/partition_spec.h"
@@ -445,6 +446,25 @@ TEST(MetadataSerdeTest, DeserializePartitionStatisticsFiles) {
445446
ASSERT_EQ(*metadata, expected);
446447
}
447448

449+
TEST(MetadataSerdeTest, V3DefaultValuesRoundTrip) {
450+
// Full TableMetadata path: a v3 schema field's initial/write defaults parse correctly
451+
// (the v3 gate in Schema::Validate is satisfied) and survive a ToJson/FromJson round
452+
// trip. The fixture's field is `x: long` with initial-default 1 and write-default 1.
453+
ICEBERG_UNWRAP_OR_FAIL(
454+
auto metadata, ReadTableMetadataFromResource("TableMetadataV3ValidMinimal.json"));
455+
auto schema_result = metadata->Schema();
456+
ASSERT_TRUE(schema_result.has_value());
457+
const auto& field = schema_result.value()->fields()[0];
458+
ASSERT_NE(field.initial_default(), nullptr);
459+
EXPECT_EQ(*field.initial_default(), Literal::Long(1));
460+
ASSERT_NE(field.write_default(), nullptr);
461+
EXPECT_EQ(*field.write_default(), Literal::Long(1));
462+
463+
ICEBERG_UNWRAP_OR_FAIL(auto json, ToJson(*metadata));
464+
ICEBERG_UNWRAP_OR_FAIL(auto reparsed, TableMetadataFromJson(json));
465+
EXPECT_EQ(*reparsed, *metadata);
466+
}
467+
448468
TEST(MetadataSerdeTest, DeserializeUnsupportedVersion) {
449469
ReadTableMetadataExpectError("TableMetadataUnsupportedVersion.json",
450470
"Cannot read unsupported version");

src/iceberg/test/schema_json_test.cc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,17 @@ TEST(SchemaJsonTest, CrossTypeDefaultNormalizedRoundTrip) {
258258
EXPECT_EQ(original, *reparsed);
259259
}
260260

261+
TEST(SchemaJsonTest, FloatDefaultAcceptsIntegerEncoding) {
262+
// Java writes a float/double default as a bare integer token (e.g. 1, not 1.0); it must
263+
// parse, matching Java's lenient numeric reading.
264+
constexpr std::string_view json =
265+
R"({"fields":[{"id":1,"initial-default":1,"name":"f","required":true,"type":"float"}],"schema-id":1,"type":"struct"})";
266+
ICEBERG_UNWRAP_OR_FAIL(auto schema, SchemaFromJson(nlohmann::json::parse(json)));
267+
const auto& field = schema->fields()[0];
268+
ASSERT_NE(field.initial_default(), nullptr);
269+
EXPECT_EQ(*field.initial_default(), Literal::Float(1.0F));
270+
}
271+
261272
TEST(SchemaJsonTest, UnknownFieldRoundTrip) {
262273
constexpr std::string_view json =
263274
R"({"fields":[{"id":1,"name":"mystery","required":false,"type":"unknown"}],"schema-id":1,"type":"struct"})";

src/iceberg/test/schema_test.cc

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,10 @@ TEST(SchemaTest, NullDefaultModeledAsAbsence) {
199199
// field with no default, and it validates cleanly.
200200
iceberg::SchemaField with_null(
201201
1, "id", iceberg::int32(), /*optional=*/true, /*doc=*/{},
202+
std::make_shared<const iceberg::Literal>(iceberg::Literal::Null(iceberg::int32())),
202203
std::make_shared<const iceberg::Literal>(iceberg::Literal::Null(iceberg::int32())));
203204
EXPECT_EQ(with_null.initial_default(), nullptr);
205+
EXPECT_EQ(with_null.write_default(), nullptr);
204206

205207
iceberg::SchemaField no_default(1, "id", iceberg::int32(), /*optional=*/true);
206208
EXPECT_EQ(with_null, no_default);
@@ -210,6 +212,39 @@ TEST(SchemaTest, NullDefaultModeledAsAbsence) {
210212
iceberg::IsOk());
211213
}
212214

215+
TEST(SchemaTest, EqualsDistinguishesDefaultValues) {
216+
auto field = [](std::shared_ptr<const iceberg::Literal> d) {
217+
return iceberg::SchemaField(1, "id", iceberg::int32(), /*optional=*/true, /*doc=*/{},
218+
std::move(d));
219+
};
220+
// Differ only in default value -> unequal; default vs no-default -> unequal.
221+
EXPECT_NE(field(std::make_shared<const iceberg::Literal>(iceberg::Literal::Int(1))),
222+
field(std::make_shared<const iceberg::Literal>(iceberg::Literal::Int(2))));
223+
EXPECT_NE(field(std::make_shared<const iceberg::Literal>(iceberg::Literal::Int(1))),
224+
field(nullptr));
225+
}
226+
227+
TEST(SchemaTest, ValidateRejectsDefaultOnNonPrimitiveAndMustBeNullTypes) {
228+
// A struct (non-primitive) field with a non-null default is rejected.
229+
iceberg::Schema struct_default({iceberg::SchemaField(
230+
1, "s",
231+
MakeStructType(iceberg::SchemaField(2, "x", iceberg::int32(), /*optional=*/true)),
232+
/*optional=*/true, /*doc=*/{},
233+
std::make_shared<const iceberg::Literal>(iceberg::Literal::Int(1)))});
234+
EXPECT_THAT(
235+
struct_default.Validate(iceberg::TableMetadata::kSupportedTableFormatVersion),
236+
iceberg::IsError(iceberg::ErrorKind::kInvalidSchema));
237+
238+
// unknown/geometry/geography must default to null: a non-null default is rejected.
239+
iceberg::Schema geo_default({iceberg::SchemaField(
240+
1, "g", iceberg::geometry(), /*optional=*/true, /*doc=*/{},
241+
std::make_shared<const iceberg::Literal>(iceberg::Literal::Int(1)))});
242+
auto status =
243+
geo_default.Validate(iceberg::TableMetadata::kSupportedTableFormatVersion);
244+
ASSERT_THAT(status, iceberg::IsError(iceberg::ErrorKind::kInvalidSchema));
245+
EXPECT_THAT(status, iceberg::HasErrorMessage("must default to null"));
246+
}
247+
213248
TEST(SchemaTest, ReassignIdsPreservesDefaultValues) {
214249
// Reassigning field IDs rebuilds each SchemaField, so the rebuild must carry the
215250
// default values over to the field with the new ID.

0 commit comments

Comments
 (0)