Skip to content

Commit 180a9f9

Browse files
committed
fix(schema): align v3 default-value validation/serde with spec and Java
Addresses wgtmac's review feedback: - Enforce UTC offset for timestamptz / timestamptz_ns default values in FieldFromJson. The shared timestamp parser accepts any offset and silently normalizes to UTC, so C++ could accept default metadata Java rejects and rewrite the offset on write. Added TemporalUtils::IsUtcOffset, which reuses the existing timezone-suffix parser. - ValidateDefault now casts the default literal to the field type (Literal::CastTo) instead of requiring an exact type match, matching Java's Types.NestedField (e.g. an int default on a long field). Uncastable or out-of-range defaults are still rejected. - Documented that non-primitive (struct/list/map) defaults remain unsupported, matching Java's current model. - Extended the json_serde round-trip tests with time/timestamptz/timestamp_ns/ timestamptz_ns and a negative case for non-UTC timestamptz defaults; added a schema test for the cast-to-field-type behavior.
1 parent d15a0fe commit 180a9f9

7 files changed

Lines changed: 138 additions & 3 deletions

File tree

build-rest-tests.sh

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Build and run the REST catalog unit tests (target: rest_catalog_test, which
4+
# includes rest_json_serde_test.cc and rest_file_io_test.cc).
5+
#
6+
# Fast path: uses Homebrew LLVM (C++23 + libc++) and Homebrew apache-arrow, so
7+
# Arrow is found via find_package instead of being compiled from source.
8+
#
9+
# One-time install:
10+
# brew install cmake ninja llvm apache-arrow
11+
#
12+
set -euo pipefail
13+
cd "$(dirname "$0")"
14+
15+
LLVM_PREFIX="$(brew --prefix llvm)"
16+
export SDKROOT="${SDKROOT:-$(xcrun --show-sdk-path)}" # help LLVM clang find the macOS SDK
17+
18+
# NOTE: RelWithDebInfo (not Debug) on purpose. The vendored nanoarrow applies
19+
# `-Werror -Wpedantic` only in Debug ($<$<CONFIG:Debug>:...>), and Homebrew's
20+
# Clang 22 flags nanoarrow's use of `__COUNTER__` as a C2y extension, which then
21+
# becomes a fatal error. Non-Debug configs drop those flags.
22+
cmake -S . -B build -G Ninja \
23+
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
24+
-DCMAKE_CXX_COMPILER="${LLVM_PREFIX}/bin/clang++" \
25+
-DCMAKE_C_COMPILER="${LLVM_PREFIX}/bin/clang" \
26+
-DCMAKE_PREFIX_PATH="$(brew --prefix apache-arrow);$(brew --prefix)" \
27+
-DICEBERG_BUILD_REST=ON \
28+
-DICEBERG_BUILD_TESTS=ON
29+
# If the Homebrew Arrow version mismatches and fails to compile, force the
30+
# vendored Arrow 24.0.0 build instead by adding:
31+
# -DFETCHCONTENT_TRY_FIND_PACKAGE_MODE=NEVER
32+
33+
cmake --build build --target rest_catalog_test -j
34+
35+
ctest --test-dir build -R rest_catalog_test --output-on-failure

src/iceberg/json_serde.cc

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
#include "iceberg/util/json_util_internal.h"
5252
#include "iceberg/util/macros.h"
5353
#include "iceberg/util/string_util.h"
54+
#include "iceberg/util/temporal_util.h"
5455
#include "iceberg/util/timepoint.h"
5556

5657
namespace iceberg {
@@ -564,6 +565,35 @@ Result<std::unique_ptr<Type>> TypeFromJson(const nlohmann::json& json) {
564565
}
565566
}
566567

568+
namespace {
569+
570+
// The spec's JSON single-value form for `timestamptz` / `timestamptz_ns` default
571+
// values requires a UTC offset. The shared timestamp parser accepts any offset and
572+
// silently normalizes to UTC, which would let C++ accept default metadata that Java
573+
// rejects and then rewrite the offset on serialization. Enforce UTC for these
574+
// defaults at parse time, where the original offset is still visible.
575+
Status ValidateTimestamptzDefaultIsUtc(const Type& type, const nlohmann::json& value) {
576+
const auto type_id = type.type_id();
577+
if (type_id != TypeId::kTimestampTz && type_id != TypeId::kTimestampTzNs) {
578+
return {};
579+
}
580+
if (!value.is_string()) {
581+
// Let LiteralFromJson report the type mismatch.
582+
return {};
583+
}
584+
const auto str = value.get<std::string>();
585+
ICEBERG_ASSIGN_OR_RAISE(bool is_utc, TemporalUtils::IsUtcOffset(str));
586+
if (!is_utc) {
587+
return JsonParseError(
588+
"Invalid timestamptz default '{}' for {}: default values must use UTC "
589+
"(offset 'Z' or '+00:00')",
590+
str, type.ToString());
591+
}
592+
return {};
593+
}
594+
595+
} // namespace
596+
567597
Result<std::unique_ptr<SchemaField>> FieldFromJson(const nlohmann::json& json) {
568598
ICEBERG_ASSIGN_OR_RAISE(
569599
auto type, GetJsonValue<nlohmann::json>(json, kType).and_then(TypeFromJson));
@@ -578,12 +608,16 @@ Result<std::unique_ptr<SchemaField>> FieldFromJson(const nlohmann::json& json) {
578608

579609
std::shared_ptr<const Literal> initial_default;
580610
if (initial_default_json.has_value()) {
611+
ICEBERG_RETURN_UNEXPECTED(
612+
ValidateTimestamptzDefaultIsUtc(*type, *initial_default_json));
581613
ICEBERG_ASSIGN_OR_RAISE(Literal literal,
582614
LiteralFromJson(*initial_default_json, type.get()));
583615
initial_default = std::make_shared<const Literal>(std::move(literal));
584616
}
585617
std::shared_ptr<const Literal> write_default;
586618
if (write_default_json.has_value()) {
619+
ICEBERG_RETURN_UNEXPECTED(
620+
ValidateTimestamptzDefaultIsUtc(*type, *write_default_json));
587621
ICEBERG_ASSIGN_OR_RAISE(Literal literal,
588622
LiteralFromJson(*write_default_json, type.get()));
589623
write_default = std::make_shared<const Literal>(std::move(literal));

src/iceberg/schema_field.cc

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,25 @@ Status ValidateDefault(const SchemaField& field, const Literal& value,
9393
return InvalidSchema("Invalid {} value for {}: must be a non-null value", kind,
9494
field.name());
9595
}
96+
// Defaults are only supported on primitive fields. The spec also permits JSON
97+
// single-value defaults for struct/list/map (e.g. an empty struct `{}` whose
98+
// sub-field defaults live in field metadata); that matches the current Java model's
99+
// gap and is left as a follow-up.
96100
if (field.type() == nullptr || !field.type()->is_primitive()) {
97101
return InvalidSchema(
98102
"Invalid {} value for {}: default values are only supported for primitive types",
99103
kind, field.name());
100104
}
101-
if (*value.type() != *field.type()) {
102-
return InvalidSchema("{} of field {} has type {} but expected {}", kind, field.name(),
103-
*value.type(), *field.type());
105+
// Match Java (Types.NestedField), which casts the default literal to the field type
106+
// instead of requiring an exact type match (e.g. an int default on a long field, or
107+
// a string default on a date/timestamp/uuid field). Reject only defaults that cannot
108+
// be cast to the field type or fall outside its range (CastTo signals out-of-range as
109+
// an above-max/below-min sentinel).
110+
auto field_type = std::static_pointer_cast<PrimitiveType>(field.type());
111+
auto cast = value.CastTo(field_type);
112+
if (!cast.has_value() || cast->IsAboveMax() || cast->IsBelowMin()) {
113+
return InvalidSchema("{} of field {} has type {} that cannot be cast to {}", kind,
114+
field.name(), *value.type(), *field.type());
104115
}
105116
return {};
106117
}

src/iceberg/test/json_serde_test.cc

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,11 @@ TEST(JsonInternalTest, SchemaFieldDefaultValuesRoundTripAllTypes) {
136136
cases.emplace_back(float32(), Literal::Float(1.5f));
137137
cases.emplace_back(float64(), Literal::Double(2.5));
138138
cases.emplace_back(date(), Literal::Date(19738));
139+
cases.emplace_back(time(), Literal::Time(43200000000LL));
139140
cases.emplace_back(timestamp(), Literal::Timestamp(1719446400000000LL));
141+
cases.emplace_back(timestamp_tz(), Literal::TimestampTz(1719446400000000LL));
142+
cases.emplace_back(timestamp_ns(), Literal::TimestampNs(1719446400000000123LL));
143+
cases.emplace_back(timestamptz_ns(), Literal::TimestampTzNs(1719446400000000123LL));
140144
cases.emplace_back(string(), Literal::String("hello"));
141145
cases.emplace_back(decimal(9, 2), Literal::Decimal(12345, 9, 2));
142146
cases.emplace_back(fixed(3), Literal::Fixed({0x01, 0x02, 0x03}));
@@ -155,6 +159,23 @@ TEST(JsonInternalTest, SchemaFieldDefaultValuesRoundTripAllTypes) {
155159
}
156160
}
157161

162+
// The spec only permits UTC offsets for timestamptz / timestamptz_ns default values.
163+
// A non-UTC offset (which the shared parser would silently normalize) must be rejected,
164+
// while the UTC form is accepted.
165+
TEST(JsonInternalTest, SchemaFieldRejectsNonUtcTimestamptzDefault) {
166+
auto non_utc = nlohmann::json::parse(
167+
R"({"id":1,"name":"ts","required":true,"type":"timestamptz","initial-default":"2024-06-27T05:00:00+05:00"})");
168+
EXPECT_FALSE(FieldFromJson(non_utc).has_value());
169+
170+
auto non_utc_ns = nlohmann::json::parse(
171+
R"({"id":1,"name":"ts","required":true,"type":"timestamptz_ns","write-default":"2024-06-27T05:00:00-08:00"})");
172+
EXPECT_FALSE(FieldFromJson(non_utc_ns).has_value());
173+
174+
auto utc = nlohmann::json::parse(
175+
R"({"id":1,"name":"ts","required":true,"type":"timestamptz","initial-default":"2024-06-27T00:00:00+00:00"})");
176+
EXPECT_TRUE(FieldFromJson(utc).has_value());
177+
}
178+
158179
TEST(JsonInternalTest, SortField) {
159180
auto identity_transform = Transform::Identity();
160181

src/iceberg/test/schema_test.cc

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,23 @@ TEST(SchemaTest, ValidateRejectsMismatchedDefaultValue) {
167167
EXPECT_THAT(status, iceberg::HasErrorMessage("write-default"));
168168
}
169169

170+
TEST(SchemaTest, ValidateCastsDefaultToFieldType) {
171+
// Matching Java, the default literal is cast to the field type rather than required to
172+
// match exactly: an int default on a long field is accepted (int -> long).
173+
iceberg::Schema widened({iceberg::SchemaField(
174+
1, "id", iceberg::int64(), false, /*doc=*/{},
175+
std::make_shared<const iceberg::Literal>(iceberg::Literal::Int(34)))});
176+
EXPECT_THAT(widened.Validate(iceberg::TableMetadata::kSupportedTableFormatVersion),
177+
iceberg::IsOk());
178+
179+
// A default whose type cannot be cast to the field type is still rejected.
180+
iceberg::Schema uncastable({iceberg::SchemaField(
181+
1, "id", iceberg::int32(), false, /*doc=*/{},
182+
std::make_shared<const iceberg::Literal>(iceberg::Literal::String("oops")))});
183+
EXPECT_THAT(uncastable.Validate(iceberg::TableMetadata::kSupportedTableFormatVersion),
184+
iceberg::IsError(iceberg::ErrorKind::kInvalidSchema));
185+
}
186+
170187
TEST(SchemaTest, ReassignIdsPreservesDefaultValues) {
171188
// Reassigning field IDs rebuilds each SchemaField, so the rebuild must carry the
172189
// default values over to the field with the new ID.

src/iceberg/util/temporal_util.cc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,11 @@ Result<int64_t> TemporalUtils::ParseTimestampNsWithZone(std::string_view str) {
444444
/*units_per_micro=*/internal::kNanosPerMicro);
445445
}
446446

447+
Result<bool> TemporalUtils::IsUtcOffset(std::string_view str) {
448+
ICEBERG_ASSIGN_OR_RAISE(auto timestamp_with_offset, ParseTimestampWithZoneSuffix(str));
449+
return timestamp_with_offset.second == 0;
450+
}
451+
447452
#define DISPATCH_EXTRACT_YEAR(type_id) \
448453
case type_id: \
449454
return ExtractYearImpl<type_id>(literal);

src/iceberg/util/temporal_util.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,18 @@ class ICEBERG_EXPORT TemporalUtils {
125125
/// \return The number of nanoseconds since epoch (UTC), or an error.
126126
static Result<int64_t> ParseTimestampNsWithZone(std::string_view str);
127127

128+
/// \brief Reports whether a timestamp-with-zone string uses a UTC offset.
129+
///
130+
/// The ParseTimestamp*WithZone parsers accept any offset and silently normalize it
131+
/// to UTC. The spec's JSON single-value form for `timestamptz` / `timestamptz_ns`
132+
/// default values only permits UTC ("Z" or "+00:00"), so callers that must enforce
133+
/// that rule check the offset here before parsing.
134+
///
135+
/// \param str The timestamp-with-zone string to inspect.
136+
/// \return true if the offset is UTC, false if it is a non-UTC offset, or an error
137+
/// if the timezone suffix cannot be parsed.
138+
static Result<bool> IsUtcOffset(std::string_view str);
139+
128140
/// \brief Extract a date or timestamp year, as years from 1970
129141
static Result<Literal> ExtractYear(const Literal& literal);
130142

0 commit comments

Comments
 (0)