| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| #include <chrono> | |
| #include <functional> | |
| #include <memory> | |
| #include <string> | |
| #include <unordered_map> | |
| #include <gtest/gtest.h> | |
| #include <nlohmann/json.hpp> | |
| #include "iceberg/expression/expression.h" | |
| #include "iceberg/metrics/commit_report.h" | |
| #include "iceberg/metrics/counter.h" | |
| #include "iceberg/metrics/json_serde_internal.h" | |
| #include "iceberg/metrics/metrics_context.h" | |
| #include "iceberg/metrics/metrics_reporter.h" | |
| #include "iceberg/metrics/scan_report.h" | |
| #include "iceberg/metrics/timer.h" | |
| namespace iceberg { | |
| namespace { | |
| int64_t DurationInUnit(std::chrono::nanoseconds duration, std::string_view unit) { | |
| if (unit == "nanoseconds") return duration.count(); | |
| if (unit == "microseconds") { | |
| return std::chrono::duration_cast<std::chrono::microseconds>(duration).count(); | |
| } | |
| if (unit == "milliseconds") { | |
| return std::chrono::duration_cast<std::chrono::milliseconds>(duration).count(); | |
| } | |
| if (unit == "seconds") { | |
| return std::chrono::duration_cast<std::chrono::seconds>(duration).count(); | |
| } | |
| if (unit == "minutes") { | |
| return std::chrono::duration_cast<std::chrono::minutes>(duration).count(); | |
| } | |
| if (unit == "hours") { | |
| return std::chrono::duration_cast<std::chrono::hours>(duration).count(); | |
| } | |
| if (unit == "days") { | |
| return std::chrono::duration_cast<std::chrono::days>(duration).count(); | |
| } | |
| return duration.count(); | |
| } | |
| } // namespace | |
| // --------------------------------------------------------------------------- | |
| // Counter | |
| // --------------------------------------------------------------------------- | |
| TEST(DefaultCounterTest, IncrementByOne) { | |
| DefaultCounter c; | |
| EXPECT_EQ(c.value(), 0); | |
| c.Increment(); | |
| EXPECT_EQ(c.value(), 1); | |
| } | |
| TEST(DefaultCounterTest, IncrementByAmount) { | |
| DefaultCounter c; | |
| c.Increment(42); | |
| EXPECT_EQ(c.value(), 42); | |
| c.Increment(8); | |
| EXPECT_EQ(c.value(), 50); | |
| } | |
| class DefaultCounterUnitTest : public ::testing::TestWithParam<CounterUnit> {}; | |
| TEST_P(DefaultCounterUnitTest, UnitRoundTrips) { | |
| DefaultCounter c(GetParam()); | |
| EXPECT_EQ(c.unit(), GetParam()); | |
| EXPECT_FALSE(c.IsNoop()); | |
| auto parsed = CounterUnitFromString(ToString(GetParam())); | |
| ASSERT_TRUE(parsed.has_value()); | |
| EXPECT_EQ(parsed.value(), GetParam()); | |
| } | |
| INSTANTIATE_TEST_SUITE_P(Units, DefaultCounterUnitTest, | |
| ::testing::Values(CounterUnit::kCount, CounterUnit::kBytes), | |
| [](const auto& info) { | |
| return info.param == CounterUnit::kCount ? "Count" : "Bytes"; | |
| }); | |
| TEST(DefaultCounterUnitTest, UnknownUnitReturnsError) { | |
| auto result = CounterUnitFromString("rows"); | |
| EXPECT_FALSE(result.has_value()); | |
| } | |
| TEST(DefaultCounterUnitTest, ParsesUnitsCaseInsensitively) { | |
| auto count = CounterUnitFromString("COUNT"); | |
| ASSERT_TRUE(count.has_value()); | |
| EXPECT_EQ(count.value(), CounterUnit::kCount); | |
| auto bytes = CounterUnitFromString("Bytes"); | |
| ASSERT_TRUE(bytes.has_value()); | |
| EXPECT_EQ(bytes.value(), CounterUnit::kBytes); | |
| } | |
| TEST(NoopCounterTest, IncrementIsNoop) { | |
| auto noop = Counter::Noop(); | |
| EXPECT_TRUE(noop->IsNoop()); | |
| noop->Increment(); | |
| noop->Increment(100); | |
| } | |
| TEST(NoopCounterTest, AccessorsReturnSentinel) { | |
| auto noop = Counter::Noop(); | |
| EXPECT_EQ(noop->value(), -1); | |
| EXPECT_EQ(noop->unit(), CounterUnit::kUndefined); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Timer | |
| // --------------------------------------------------------------------------- | |
| TEST(DefaultTimerTest, RaiiRecordsOnce) { | |
| DefaultTimer t; | |
| EXPECT_EQ(t.Count(), 0); | |
| { | |
| auto timed = t.Start(); | |
| } | |
| EXPECT_EQ(t.Count(), 1); // RAII guard called Record() exactly once | |
| } | |
| TEST(DefaultTimerTest, ExplicitStopRecordsOnce) { | |
| DefaultTimer t; | |
| auto timed = t.Start(); | |
| timed.Stop(); | |
| EXPECT_EQ(t.Count(), 1); | |
| // Destructor must not double-record. | |
| } | |
| TEST(DefaultTimerTest, RecordDirect) { | |
| DefaultTimer t; | |
| t.Record(std::chrono::nanoseconds{1000}); | |
| t.Record(std::chrono::nanoseconds{500}); | |
| EXPECT_EQ(t.Count(), 2); | |
| EXPECT_EQ(t.TotalDuration(), std::chrono::nanoseconds{1500}); | |
| } | |
| TEST(DefaultTimerTest, RejectsNegativeDuration) { | |
| DefaultTimer t; | |
| t.Record(std::chrono::nanoseconds{1000}); | |
| EXPECT_NO_THROW(t.Record(std::chrono::nanoseconds{-1})); | |
| EXPECT_EQ(t.Count(), 1); | |
| EXPECT_EQ(t.TotalDuration(), std::chrono::nanoseconds{1000}); | |
| } | |
| TEST(DefaultTimerTest, MoveDoesNotDoubleRecord) { | |
| DefaultTimer t; | |
| { | |
| auto a = t.Start(); | |
| auto b = std::move(a); // a is moved-from; destructor must not record | |
| } // b records exactly once on destruction | |
| EXPECT_EQ(t.Count(), 1); | |
| } | |
| TEST(DefaultTimerTest, MultipleStopsAreNoop) { | |
| DefaultTimer t; | |
| auto timed = t.Start(); | |
| timed.Stop(); | |
| EXPECT_NO_THROW(timed.Stop()); | |
| EXPECT_EQ(t.Count(), 1); | |
| } | |
| TEST(NoopTimerTest, TimingIsNoop) { | |
| auto noop = Timer::Noop(); | |
| EXPECT_TRUE(noop->IsNoop()); | |
| { | |
| auto timed = noop->Start(); | |
| } | |
| } | |
| TEST(NoopTimerTest, AccessorsReturnSentinels) { | |
| auto noop = Timer::Noop(); | |
| EXPECT_EQ(noop->Count(), -1); | |
| EXPECT_EQ(noop->TotalDuration(), std::chrono::nanoseconds{-1}); | |
| EXPECT_EQ(noop->Unit(), "undefined"); | |
| } | |
| TEST(DefaultTimerTest, UnitIsNanoseconds) { | |
| DefaultTimer t; | |
| EXPECT_EQ(t.Unit(), "nanoseconds"); | |
| } | |
| TEST(DefaultTimerTest, UnitCanBeSpecified) { | |
| DefaultTimer t(TimerUnit::kMilliseconds); | |
| EXPECT_EQ(t.Unit(), "milliseconds"); | |
| } | |
| struct DurationConversionParam { | |
| std::string name; | |
| std::chrono::nanoseconds input; | |
| std::chrono::nanoseconds expected; | |
| }; | |
| class DefaultTimerDurationConversionTest | |
| : public ::testing::TestWithParam<DurationConversionParam> {}; | |
| TEST_P(DefaultTimerDurationConversionTest, RecordsAndConverts) { | |
| DefaultTimer t; | |
| t.Record(GetParam().input); | |
| EXPECT_EQ(t.TotalDuration(), GetParam().expected); | |
| EXPECT_EQ(t.Count(), 1); | |
| } | |
| INSTANTIATE_TEST_SUITE_P( | |
| DurationConversion, DefaultTimerDurationConversionTest, | |
| ::testing::Values( | |
| DurationConversionParam{"Microseconds", | |
| std::chrono::duration_cast<std::chrono::nanoseconds>( | |
| std::chrono::microseconds{5}), | |
| std::chrono::nanoseconds{5000}}, | |
| DurationConversionParam{"Milliseconds", | |
| std::chrono::duration_cast<std::chrono::nanoseconds>( | |
| std::chrono::milliseconds{2}), | |
| std::chrono::nanoseconds{2000000}}), | |
| [](const auto& info) { return info.param.name; }); | |
| TEST(DefaultTimerTest, TimeVoidCallableRecordsOnce) { | |
| DefaultTimer t; | |
| t.Time([&] { /* intentional no-op */ }); | |
| // Verify the count was incremented; duration is not checked because a | |
| // no-op body may measure as 0 ns depending on clock resolution. | |
| EXPECT_EQ(t.Count(), 1); | |
| } | |
| TEST(DefaultTimerTest, TimeNonVoidCallableReturnsResult) { | |
| DefaultTimer t; | |
| int result = t.Time([&] { return 42; }); | |
| EXPECT_EQ(result, 42); | |
| EXPECT_EQ(t.Count(), 1); | |
| // Even for Noop callable is still invoked. | |
| int called = 0; | |
| Timer::Noop()->Time([&] { ++called; }); | |
| EXPECT_EQ(called, 1); | |
| } | |
| TEST(DefaultTimerTest, TimePreservesReferenceReturn) { | |
| DefaultTimer t; | |
| int value = 42; | |
| int& result = t.Time([&]() -> int& { return value; }); | |
| EXPECT_EQ(&result, &value); | |
| result = 43; | |
| EXPECT_EQ(value, 43); | |
| EXPECT_EQ(t.Count(), 1); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // MetricsContext | |
| // --------------------------------------------------------------------------- | |
| TEST(DefaultMetricsContextTest, SameNameReturnsFreshObjects) { | |
| DefaultMetricsContext ctx; | |
| auto c1 = ctx.GetCounter("foo", CounterUnit::kCount); | |
| auto c2 = ctx.GetCounter("foo", CounterUnit::kCount); | |
| EXPECT_NE(c1.get(), c2.get()); | |
| auto t1 = ctx.GetTimer("dur"); | |
| auto t2 = ctx.GetTimer("dur"); | |
| EXPECT_NE(t1.get(), t2.get()); | |
| } | |
| TEST(DefaultMetricsContextTest, DifferentNamesReturnDifferentObjects) { | |
| DefaultMetricsContext ctx; | |
| auto c1 = ctx.GetCounter("a", CounterUnit::kCount); | |
| auto c2 = ctx.GetCounter("b", CounterUnit::kCount); | |
| EXPECT_NE(c1.get(), c2.get()); | |
| } | |
| TEST(DefaultMetricsContextTest, GetTimerUsesRequestedUnit) { | |
| DefaultMetricsContext ctx; | |
| auto timer = ctx.GetTimer("latency", TimerUnit::kSeconds); | |
| ASSERT_NE(timer, nullptr); | |
| EXPECT_EQ(timer->Unit(), "seconds"); | |
| } | |
| TEST(NoopMetricsContextTest, ReturnsNoopInstances) { | |
| auto noop_ctx = MetricsContext::Noop(); | |
| EXPECT_TRUE(noop_ctx->GetCounter("x", CounterUnit::kCount)->IsNoop()); | |
| EXPECT_TRUE(noop_ctx->GetTimer("y")->IsNoop()); | |
| } | |
| TEST(NoopMetricsContextTest, ReturnsSameSharedPtrEachCall) { | |
| // Verify the static-shared_ptr fix: no new control block per call. | |
| auto noop_ctx = MetricsContext::Noop(); | |
| auto c1 = noop_ctx->GetCounter("a", CounterUnit::kCount); | |
| auto c2 = noop_ctx->GetCounter("b", CounterUnit::kCount); | |
| EXPECT_EQ(c1.get(), c2.get()); // same noop singleton | |
| auto t1 = noop_ctx->GetTimer("x"); | |
| auto t2 = noop_ctx->GetTimer("y"); | |
| EXPECT_EQ(t1.get(), t2.get()); | |
| } | |
| TEST(DefaultMetricsContextTest, OneArgGetCounterDefaultsToCount) { | |
| DefaultMetricsContext ctx; | |
| auto c = ctx.GetCounter("hits"); | |
| EXPECT_NE(c, nullptr); | |
| EXPECT_EQ(c->unit(), CounterUnit::kCount); | |
| EXPECT_NE(ctx.GetCounter("hits").get(), c.get()); | |
| } | |
| TEST(DefaultMetricsContextTest, RepeatedScanMetricsDoNotShareCounters) { | |
| DefaultMetricsContext ctx; | |
| auto first = ScanMetrics::Make(ctx); | |
| first->result_data_files->Increment(5); | |
| auto second = ScanMetrics::Make(ctx); | |
| auto result = second->ToResult(); | |
| ASSERT_TRUE(result.result_data_files.has_value()); | |
| EXPECT_EQ(result.result_data_files->value, 0); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // ScanMetrics | |
| // --------------------------------------------------------------------------- | |
| TEST(ScanMetricsTest, MakePopulatesResult) { | |
| DefaultMetricsContext ctx; | |
| auto m = ScanMetrics::Make(ctx); | |
| m->result_data_files->Increment(5); | |
| m->total_file_size_in_bytes->Increment(1024); | |
| m->total_planning_duration->Record(std::chrono::nanoseconds{500}); | |
| auto r = m->ToResult(); | |
| ASSERT_TRUE(r.result_data_files.has_value()); | |
| EXPECT_EQ(r.result_data_files->value, 5); | |
| EXPECT_EQ(r.result_data_files->unit, CounterUnit::kCount); | |
| ASSERT_TRUE(r.total_file_size_in_bytes.has_value()); | |
| EXPECT_EQ(r.total_file_size_in_bytes->value, 1024); | |
| EXPECT_EQ(r.total_file_size_in_bytes->unit, CounterUnit::kBytes); | |
| ASSERT_TRUE(r.total_planning_duration.has_value()); | |
| EXPECT_EQ(r.total_planning_duration->count, 1); | |
| EXPECT_EQ(r.total_planning_duration->total_duration, std::chrono::nanoseconds{500}); | |
| } | |
| TEST(ScanMetricsTest, ToResultForwardsTimerUnit) { | |
| DefaultMetricsContext ctx; | |
| auto m = ScanMetrics::Make(ctx); | |
| m->total_planning_duration->Record(std::chrono::nanoseconds{100}); | |
| auto r = m->ToResult(); | |
| ASSERT_TRUE(r.total_planning_duration.has_value()); | |
| EXPECT_EQ(r.total_planning_duration->unit, "nanoseconds"); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // CommitMetrics | |
| // --------------------------------------------------------------------------- | |
| TEST(CommitMetricsTest, NoopProducesMissingMetrics) { | |
| auto m = CommitMetrics::Noop(); | |
| auto result = m->ToResult(); | |
| EXPECT_FALSE(result.total_duration.has_value()); | |
| EXPECT_FALSE(result.attempts.has_value()); | |
| } | |
| TEST(CommitMetricsTest, TimerAndAttemptsPopulated) { | |
| DefaultMetricsContext ctx; | |
| auto m = CommitMetrics::Make(ctx); | |
| m->total_duration->Record(std::chrono::nanoseconds{2000}); | |
| m->attempts->Increment(3); | |
| auto result = m->ToResult(); | |
| ASSERT_TRUE(result.total_duration.has_value()); | |
| EXPECT_EQ(result.total_duration->count, 1); | |
| EXPECT_EQ(result.total_duration->total_duration, std::chrono::nanoseconds{2000}); | |
| ASSERT_TRUE(result.attempts.has_value()); | |
| EXPECT_EQ(result.attempts->value, 3); | |
| EXPECT_EQ(result.attempts->unit, CounterUnit::kCount); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // JSON serde — CounterResult / TimerResult | |
| // --------------------------------------------------------------------------- | |
| // --------------------------------------------------------------------------- | |
| // CounterResult serde — parameterized round-trip | |
| // --------------------------------------------------------------------------- | |
| class CounterResultRoundTripTest : public ::testing::TestWithParam<CounterResult> {}; | |
| TEST_P(CounterResultRoundTripTest, RoundTrip) { | |
| const CounterResult original = GetParam(); | |
| auto json = ToJson(original); | |
| auto result = CounterResultFromJson(json); | |
| ASSERT_TRUE(result.has_value()); | |
| EXPECT_EQ(result.value(), original); | |
| } | |
| INSTANTIATE_TEST_SUITE_P( | |
| CounterResultSerde, CounterResultRoundTripTest, | |
| ::testing::Values(CounterResult{.unit = CounterUnit::kBytes, .value = 1024}, | |
| CounterResult{.unit = CounterUnit::kCount, .value = 42}), | |
| [](const auto& info) { | |
| return info.param.unit == CounterUnit::kBytes ? "BytesUnit" : "CountUnit"; | |
| }); | |
| class TimerResultRoundTripTest : public ::testing::TestWithParam<TimerResult> {}; | |
| TEST_P(TimerResultRoundTripTest, RoundTrip) { | |
| const auto& input = GetParam(); | |
| auto json = ToJson(input); | |
| ASSERT_TRUE(json.has_value()); | |
| EXPECT_EQ(json.value()["time-unit"], input.unit); | |
| EXPECT_EQ(json.value()["count"], input.count); | |
| EXPECT_EQ(json.value()["total-duration"], | |
| DurationInUnit(input.total_duration, input.unit)); | |
| auto result = TimerResultFromJson(json.value()); | |
| ASSERT_TRUE(result.has_value()); | |
| EXPECT_EQ(result.value(), input); | |
| } | |
| TEST(TimerResultSerdeTest, ParsesJavaTimeUnits) { | |
| nlohmann::json json = { | |
| {"count", 3}, | |
| {"time-unit", "hours"}, | |
| {"total-duration", 10}, | |
| }; | |
| auto result = TimerResultFromJson(json); | |
| ASSERT_TRUE(result.has_value()); | |
| EXPECT_EQ(result.value().unit, "hours"); | |
| EXPECT_EQ(result.value().count, 3); | |
| EXPECT_EQ(result.value().total_duration, std::chrono::hours{10}); | |
| } | |
| TEST(TimerResultSerdeTest, SerializesDurationInDeclaredTimeUnit) { | |
| TimerResult input{ | |
| .unit = "hours", .count = 44, .total_duration = std::chrono::hours{23}}; | |
| auto json = ToJson(input); | |
| ASSERT_TRUE(json.has_value()); | |
| EXPECT_EQ(json.value()["time-unit"], "hours"); | |
| EXPECT_EQ(json.value()["count"], 44); | |
| EXPECT_EQ(json.value()["total-duration"], 23); | |
| } | |
| TEST(TimerResultSerdeTest, InvalidUnitToJsonReturnsError) { | |
| TimerResult input{ | |
| .unit = "fortnights", .count = 1, .total_duration = std::chrono::nanoseconds{1}}; | |
| auto json = ToJson(input); | |
| EXPECT_FALSE(json.has_value()); | |
| } | |
| TEST(TimerResultSerdeTest, ParsesTimeUnitCaseInsensitively) { | |
| nlohmann::json json = { | |
| {"count", 3}, | |
| {"time-unit", "HOURS"}, | |
| {"total-duration", 10}, | |
| }; | |
| auto result = TimerResultFromJson(json); | |
| ASSERT_TRUE(result.has_value()); | |
| EXPECT_EQ(result.value().unit, "hours"); | |
| EXPECT_EQ(result.value().total_duration, std::chrono::hours{10}); | |
| } | |
| INSTANTIATE_TEST_SUITE_P( | |
| TimerResultSerde, TimerResultRoundTripTest, | |
| ::testing::Values( | |
| TimerResult{.count = 3, .total_duration = std::chrono::nanoseconds{9876}}, | |
| TimerResult{.count = 2, .total_duration = std::chrono::nanoseconds{5000}}, | |
| TimerResult{.unit = "nanoseconds", | |
| .count = 1, | |
| .total_duration = std::chrono::nanoseconds{999}}), | |
| [](const auto& info) { return "Count" + std::to_string(info.param.count); }); | |
| // --------------------------------------------------------------------------- | |
| // JSON serde — ScanReport / CommitReport | |
| // --------------------------------------------------------------------------- | |
| TEST(ScanReportSerdeTest, RoundTrip) { | |
| ScanReport report; | |
| report.table_name = "cat.db.t"; | |
| report.snapshot_id = 42; | |
| report.schema_id = 1; | |
| report.filter = True::Instance(); | |
| report.scan_metrics.result_data_files = CounterResult{.value = 7}; | |
| report.scan_metrics.total_file_size_in_bytes = | |
| CounterResult{.unit = CounterUnit::kBytes, .value = 8192}; | |
| report.scan_metrics.total_planning_duration = | |
| TimerResult{.count = 1, .total_duration = std::chrono::nanoseconds{100000}}; | |
| report.projected_field_ids = {1, 2}; | |
| report.projected_field_names = {"id", "name"}; | |
| auto json_result = ToJson(report); | |
| ASSERT_TRUE(json_result.has_value()); | |
| EXPECT_TRUE(json_result.value().contains("filter")); | |
| EXPECT_TRUE(json_result.value().contains("metrics")); | |
| EXPECT_FALSE(json_result.value().contains("scan-metrics")); | |
| auto result = ScanReportFromJson(json_result.value()); | |
| ASSERT_TRUE(result.has_value()); | |
| const auto& r = result.value(); | |
| EXPECT_EQ(r.table_name, "cat.db.t"); | |
| EXPECT_EQ(r.snapshot_id, 42); | |
| ASSERT_TRUE(r.scan_metrics.result_data_files.has_value()); | |
| EXPECT_EQ(r.scan_metrics.result_data_files->value, 7); | |
| EXPECT_EQ(r.scan_metrics.result_data_files->unit, CounterUnit::kCount); | |
| ASSERT_TRUE(r.scan_metrics.total_file_size_in_bytes.has_value()); | |
| EXPECT_EQ(r.scan_metrics.total_file_size_in_bytes->value, 8192); | |
| EXPECT_EQ(r.scan_metrics.total_file_size_in_bytes->unit, CounterUnit::kBytes); | |
| ASSERT_TRUE(r.scan_metrics.total_planning_duration.has_value()); | |
| EXPECT_EQ(r.scan_metrics.total_planning_duration->count, 1); | |
| EXPECT_EQ(r.scan_metrics.total_planning_duration->total_duration, | |
| std::chrono::nanoseconds{100000}); | |
| EXPECT_EQ(r.projected_field_ids, (std::vector<int32_t>{1, 2})); | |
| } | |
| TEST(ScanReportSerdeTest, RoundTripWithAlwaysTrueFilter) { | |
| ScanReport report; | |
| report.table_name = "db.t"; | |
| report.snapshot_id = 1; | |
| report.filter = True::Instance(); | |
| report.schema_id = 4; | |
| auto json_result = ToJson(report); | |
| ASSERT_TRUE(json_result.has_value()); | |
| EXPECT_TRUE(json_result.value().contains("projected-field-ids")); | |
| EXPECT_TRUE(json_result.value().contains("projected-field-names")); | |
| auto result = ScanReportFromJson(json_result.value()); | |
| ASSERT_TRUE(result.has_value()); | |
| ASSERT_NE(result.value().filter, nullptr); | |
| EXPECT_EQ(result.value().filter->op(), Expression::Operation::kTrue); | |
| } | |
| TEST(ScanReportSerdeTest, DefaultsMissingFilterToAlwaysTrueOnWrite) { | |
| ScanReport report; | |
| report.table_name = "db.t"; | |
| report.snapshot_id = 1; | |
| report.schema_id = 4; | |
| auto json_result = ToJson(report); | |
| ASSERT_TRUE(json_result.has_value()); | |
| ASSERT_TRUE(json_result.value().contains("filter")); | |
| EXPECT_TRUE(json_result.value()["filter"].is_boolean()); | |
| EXPECT_TRUE(json_result.value()["filter"].get<bool>()); | |
| auto result = ScanReportFromJson(json_result.value()); | |
| ASSERT_TRUE(result.has_value()); | |
| ASSERT_NE(result.value().filter, nullptr); | |
| EXPECT_EQ(result.value().filter->op(), Expression::Operation::kTrue); | |
| } | |
| TEST(ScanReportSerdeTest, ParsesJavaMetricsFieldName) { | |
| nlohmann::json json = { | |
| {"table-name", "db.t"}, | |
| {"snapshot-id", 1}, | |
| {"filter", true}, | |
| {"schema-id", 4}, | |
| {"projected-field-ids", nlohmann::json::array()}, | |
| {"projected-field-names", nlohmann::json::array()}, | |
| {"metrics", nlohmann::json{{"total-planning-duration", | |
| nlohmann::json{{"count", 1}, | |
| {"time-unit", "nanoseconds"}, | |
| {"total-duration", 600}}}}}, | |
| }; | |
| auto result = ScanReportFromJson(json); | |
| ASSERT_TRUE(result.has_value()); | |
| ASSERT_TRUE(result.value().scan_metrics.total_planning_duration.has_value()); | |
| EXPECT_EQ(result.value().scan_metrics.total_planning_duration->total_duration, | |
| std::chrono::nanoseconds{600}); | |
| } | |
| TEST(ScanReportSerdeTest, MalformedProjectionReturnsError) { | |
| nlohmann::json json = { | |
| {"table-name", "db.t"}, | |
| {"snapshot-id", 1}, | |
| {"filter", true}, | |
| {"schema-id", 4}, | |
| {"projected-field-ids", nlohmann::json::array({"1"})}, | |
| {"projected-field-names", nlohmann::json::array()}, | |
| {"metrics", nlohmann::json::object()}, | |
| }; | |
| auto result = ScanReportFromJson(json); | |
| EXPECT_FALSE(result.has_value()); | |
| } | |
| TEST(CommitReportSerdeTest, RoundTrip) { | |
| CommitReport report; | |
| report.table_name = "cat.db.t"; | |
| report.snapshot_id = 99; | |
| report.sequence_number = 5; | |
| report.operation = "append"; | |
| report.commit_metrics.total_duration = | |
| TimerResult{.count = 1, .total_duration = std::chrono::nanoseconds{200000}}; | |
| report.commit_metrics.attempts = CounterResult{.value = 1}; | |
| report.commit_metrics.added_data_files = CounterResult{.value = 3}; | |
| report.commit_metrics.added_records = CounterResult{.value = 1000}; | |
| auto json_result = ToJson(report); | |
| ASSERT_TRUE(json_result.has_value()); | |
| EXPECT_TRUE(json_result.value().contains("metrics")); | |
| EXPECT_FALSE(json_result.value().contains("commit-metrics")); | |
| auto result = CommitReportFromJson(json_result.value()); | |
| ASSERT_TRUE(result.has_value()); | |
| const auto& r = result.value(); | |
| EXPECT_EQ(r.table_name, "cat.db.t"); | |
| EXPECT_EQ(r.snapshot_id, 99); | |
| EXPECT_EQ(r.sequence_number, 5); | |
| EXPECT_EQ(r.operation, "append"); | |
| ASSERT_TRUE(r.commit_metrics.total_duration.has_value()); | |
| EXPECT_EQ(r.commit_metrics.total_duration->count, 1); | |
| EXPECT_EQ(r.commit_metrics.total_duration->total_duration, | |
| std::chrono::nanoseconds{200000}); | |
| ASSERT_TRUE(r.commit_metrics.added_data_files.has_value()); | |
| EXPECT_EQ(r.commit_metrics.added_data_files->value, 3); | |
| ASSERT_TRUE(r.commit_metrics.added_records.has_value()); | |
| EXPECT_EQ(r.commit_metrics.added_records->value, 1000); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // ScanMetricsResult::From | |
| // --------------------------------------------------------------------------- | |
| TEST(ScanMetricsResultTest, FromDelegatesToToResult) { | |
| DefaultMetricsContext ctx; | |
| auto m = ScanMetrics::Make(ctx); | |
| m->result_data_files->Increment(7); | |
| m->total_planning_duration->Record(std::chrono::nanoseconds{12345}); | |
| auto via_from = ScanMetricsResult::From(*m); | |
| auto via_to_result = m->ToResult(); | |
| EXPECT_EQ(via_from.result_data_files, via_to_result.result_data_files); | |
| ASSERT_TRUE(via_from.total_planning_duration.has_value()); | |
| ASSERT_TRUE(via_to_result.total_planning_duration.has_value()); | |
| EXPECT_EQ(via_from.total_planning_duration->count, | |
| via_to_result.total_planning_duration->count); | |
| EXPECT_EQ(via_from.total_planning_duration->total_duration, | |
| via_to_result.total_planning_duration->total_duration); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // CommitMetricsResult::From | |
| // --------------------------------------------------------------------------- | |
| TEST(CommitMetricsResultTest, FromWithEmptySummaryYieldsMissingFileCounts) { | |
| DefaultMetricsContext ctx; | |
| auto live = CommitMetrics::Make(ctx); | |
| live->total_duration->Record(std::chrono::nanoseconds{5000}); | |
| live->attempts->Increment(); | |
| auto result = CommitMetricsResult::From(*live, {}); | |
| ASSERT_TRUE(result.total_duration.has_value()); | |
| EXPECT_EQ(result.total_duration->count, 1); | |
| EXPECT_EQ(result.total_duration->total_duration, std::chrono::nanoseconds{5000}); | |
| ASSERT_TRUE(result.attempts.has_value()); | |
| EXPECT_EQ(result.attempts->value, 1); | |
| EXPECT_EQ(result.attempts->unit, CounterUnit::kCount); | |
| EXPECT_FALSE(result.added_data_files.has_value()); | |
| EXPECT_FALSE(result.removed_data_files.has_value()); | |
| EXPECT_FALSE(result.total_data_files.has_value()); | |
| EXPECT_FALSE(result.added_records.has_value()); | |
| EXPECT_FALSE(result.total_records.has_value()); | |
| EXPECT_FALSE(result.kept_manifest_count.has_value()); | |
| EXPECT_FALSE(result.created_manifest_count.has_value()); | |
| } | |
| TEST(CommitMetricsResultTest, FromParsesSnapshotSummary) { | |
| DefaultMetricsContext ctx; | |
| auto live = CommitMetrics::Make(ctx); | |
| live->total_duration->Record(std::chrono::nanoseconds{8000}); | |
| live->attempts->Increment(2); | |
| std::unordered_map<std::string, std::string> summary = { | |
| {"added-data-files", "3"}, {"deleted-data-files", "1"}, | |
| {"total-data-files", "10"}, {"added-records", "1000"}, | |
| {"deleted-records", "200"}, {"total-records", "5000"}, | |
| {"added-files-size", "4096"}, {"removed-files-size", "1024"}, | |
| {"total-files-size", "20480"}, {"manifests-created", "2"}, | |
| {"manifests-kept", "5"}, {"manifests-replaced", "1"}, | |
| {"entries-processed", "8"}, | |
| }; | |
| auto result = CommitMetricsResult::From(*live, summary); | |
| // Live metrics. | |
| ASSERT_TRUE(result.total_duration.has_value()); | |
| EXPECT_EQ(result.total_duration->count, 1); | |
| EXPECT_EQ(result.total_duration->total_duration, std::chrono::nanoseconds{8000}); | |
| ASSERT_TRUE(result.attempts.has_value()); | |
| EXPECT_EQ(result.attempts->value, 2); | |
| EXPECT_EQ(result.attempts->unit, CounterUnit::kCount); | |
| // Snapshot-summary fields — verify both value and unit. | |
| ASSERT_TRUE(result.added_data_files.has_value()); | |
| EXPECT_EQ(result.added_data_files->value, 3); | |
| EXPECT_EQ(result.added_data_files->unit, CounterUnit::kCount); | |
| ASSERT_TRUE(result.removed_data_files.has_value()); | |
| EXPECT_EQ(result.removed_data_files->value, 1); | |
| ASSERT_TRUE(result.total_data_files.has_value()); | |
| EXPECT_EQ(result.total_data_files->value, 10); | |
| ASSERT_TRUE(result.added_records.has_value()); | |
| EXPECT_EQ(result.added_records->value, 1000); | |
| ASSERT_TRUE(result.removed_records.has_value()); | |
| EXPECT_EQ(result.removed_records->value, 200); | |
| ASSERT_TRUE(result.total_records.has_value()); | |
| EXPECT_EQ(result.total_records->value, 5000); | |
| ASSERT_TRUE(result.added_files_size_bytes.has_value()); | |
| EXPECT_EQ(result.added_files_size_bytes->value, 4096); | |
| EXPECT_EQ(result.added_files_size_bytes->unit, CounterUnit::kBytes); | |
| ASSERT_TRUE(result.removed_files_size_bytes.has_value()); | |
| EXPECT_EQ(result.removed_files_size_bytes->value, 1024); | |
| EXPECT_EQ(result.removed_files_size_bytes->unit, CounterUnit::kBytes); | |
| ASSERT_TRUE(result.total_files_size_bytes.has_value()); | |
| EXPECT_EQ(result.total_files_size_bytes->value, 20480); | |
| EXPECT_EQ(result.total_files_size_bytes->unit, CounterUnit::kBytes); | |
| ASSERT_TRUE(result.created_manifest_count.has_value()); | |
| EXPECT_EQ(result.created_manifest_count->value, 2); | |
| ASSERT_TRUE(result.kept_manifest_count.has_value()); | |
| EXPECT_EQ(result.kept_manifest_count->value, 5); | |
| ASSERT_TRUE(result.replaced_manifest_count.has_value()); | |
| EXPECT_EQ(result.replaced_manifest_count->value, 1); | |
| ASSERT_TRUE(result.processed_manifest_entries_count.has_value()); | |
| EXPECT_EQ(result.processed_manifest_entries_count->value, 8); | |
| } | |
| TEST(CommitMetricsResultTest, FromHandlesMissingAndUnparseableKeys) { | |
| std::unordered_map<std::string, std::string> summary = { | |
| {"added-data-files", "not-a-number"}, | |
| // "deleted-data-files" intentionally absent | |
| }; | |
| auto result = CommitMetricsResult::From(*CommitMetrics::Noop(), summary); | |
| EXPECT_FALSE(result.added_data_files.has_value()); | |
| EXPECT_FALSE(result.removed_data_files.has_value()); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Metrics JSON serde — CounterResult (additional cases) | |
| // --------------------------------------------------------------------------- | |
| TEST(CounterResultSerdeTest, MissingUnitReturnsError) { | |
| nlohmann::json json; | |
| json["value"] = 7; | |
| auto result = CounterResultFromJson(json); | |
| EXPECT_FALSE(result.has_value()); | |
| } | |
| TEST(CounterResultSerdeTest, UnknownUnitReturnsError) { | |
| nlohmann::json json; | |
| json["unit"] = "rows"; | |
| json["value"] = 7; | |
| auto result = CounterResultFromJson(json); | |
| EXPECT_FALSE(result.has_value()); | |
| } | |
| TEST(CounterResultSerdeTest, MissingValueReturnsError) { | |
| nlohmann::json json; | |
| json["unit"] = "count"; | |
| // Missing "value" key — must return an error. | |
| auto result = CounterResultFromJson(json); | |
| EXPECT_FALSE(result.has_value()); | |
| } | |
| TEST(CounterResultSerdeTest, ParsesUnitCaseInsensitively) { | |
| nlohmann::json json; | |
| json["unit"] = "COUNT"; | |
| json["value"] = 7; | |
| auto result = CounterResultFromJson(json); | |
| ASSERT_TRUE(result.has_value()); | |
| EXPECT_EQ(result.value().unit, CounterUnit::kCount); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Metrics JSON serde — ScanMetricsResult | |
| // --------------------------------------------------------------------------- | |
| TEST(ScanMetricsResultSerdeTest, AllFieldsRoundTrip) { | |
| ScanMetricsResult m; | |
| m.total_planning_duration = | |
| TimerResult{.count = 2, .total_duration = std::chrono::nanoseconds{50000}}; | |
| m.result_data_files = CounterResult{.unit = CounterUnit::kCount, .value = 10}; | |
| m.result_delete_files = CounterResult{.unit = CounterUnit::kCount, .value = 2}; | |
| m.scanned_data_manifests = CounterResult{.unit = CounterUnit::kCount, .value = 5}; | |
| m.scanned_delete_manifests = CounterResult{.unit = CounterUnit::kCount, .value = 1}; | |
| m.total_data_manifests = CounterResult{.unit = CounterUnit::kCount, .value = 8}; | |
| m.total_delete_manifests = CounterResult{.unit = CounterUnit::kCount, .value = 3}; | |
| m.total_file_size_in_bytes = | |
| CounterResult{.unit = CounterUnit::kBytes, .value = 131072}; | |
| m.total_delete_file_size_in_bytes = | |
| CounterResult{.unit = CounterUnit::kBytes, .value = 4096}; | |
| m.skipped_data_manifests = CounterResult{.unit = CounterUnit::kCount, .value = 3}; | |
| m.skipped_delete_manifests = CounterResult{.unit = CounterUnit::kCount, .value = 2}; | |
| m.skipped_data_files = CounterResult{.unit = CounterUnit::kCount, .value = 7}; | |
| m.skipped_delete_files = CounterResult{.unit = CounterUnit::kCount, .value = 1}; | |
| m.indexed_delete_files = CounterResult{.unit = CounterUnit::kCount, .value = 4}; | |
| m.equality_delete_files = CounterResult{.unit = CounterUnit::kCount, .value = 2}; | |
| m.positional_delete_files = CounterResult{.unit = CounterUnit::kCount, .value = 1}; | |
| m.dvs = CounterResult{.unit = CounterUnit::kCount, .value = 3}; | |
| auto json = ToJson(m); | |
| ASSERT_TRUE(json.has_value()); | |
| auto result = ScanMetricsResultFromJson(json.value()); | |
| ASSERT_TRUE(result.has_value()); | |
| EXPECT_EQ(result.value(), m); | |
| } | |
| TEST(ScanMetricsResultSerdeTest, MissingFieldsRemainUnset) { | |
| // JSON with only one field set; all others remain missing. | |
| nlohmann::json json = nlohmann::json::object(); | |
| json["result-data-files"] = nlohmann::json{{"unit", "count"}, {"value", 5}}; | |
| auto result = ScanMetricsResultFromJson(json); | |
| ASSERT_TRUE(result.has_value()); | |
| ASSERT_TRUE(result.value().result_data_files.has_value()); | |
| EXPECT_EQ(result.value().result_data_files->value, 5); | |
| EXPECT_FALSE(result.value().result_delete_files.has_value()); | |
| EXPECT_FALSE(result.value().total_file_size_in_bytes.has_value()); | |
| } | |
| TEST(ScanMetricsResultSerdeTest, NonObjectReturnsError) { | |
| EXPECT_FALSE(ScanMetricsResultFromJson(nlohmann::json::array()).has_value()); | |
| EXPECT_FALSE(ScanMetricsResultFromJson(nlohmann::json{5}).has_value()); | |
| } | |
| TEST(ScanMetricsResultSerdeTest, NullMetricFieldReturnsError) { | |
| nlohmann::json json = nlohmann::json::object(); | |
| json["result-data-files"] = nullptr; | |
| EXPECT_FALSE(ScanMetricsResultFromJson(json).has_value()); | |
| json = nlohmann::json::object(); | |
| json["total-planning-duration"] = nullptr; | |
| EXPECT_FALSE(ScanMetricsResultFromJson(json).has_value()); | |
| } | |
| TEST(ScanMetricsResultSerdeTest, JsonKeyNamesAreKebabCase) { | |
| ScanMetricsResult m; | |
| m.result_data_files = CounterResult{.value = 1}; | |
| m.total_file_size_in_bytes = CounterResult{.unit = CounterUnit::kBytes, .value = 1}; | |
| m.total_planning_duration = | |
| TimerResult{.count = 1, .total_duration = std::chrono::nanoseconds{1}}; | |
| auto json = ToJson(m); | |
| ASSERT_TRUE(json.has_value()); | |
| EXPECT_TRUE(json.value().contains("result-data-files")); | |
| EXPECT_TRUE(json.value().contains("total-file-size-in-bytes")); | |
| EXPECT_TRUE(json.value().contains("total-planning-duration")); | |
| // Spot-check that no camelCase or snake_case keys leaked in. | |
| EXPECT_FALSE(json.value().contains("resultDataFiles")); | |
| EXPECT_FALSE(json.value().contains("result_data_files")); | |
| } | |
| TEST(ScanMetricsResultSerdeTest, InvalidTimerUnitToJsonReturnsError) { | |
| ScanMetricsResult m; | |
| m.total_planning_duration = TimerResult{ | |
| .unit = "fortnights", .count = 1, .total_duration = std::chrono::nanoseconds{1}}; | |
| auto json = ToJson(m); | |
| EXPECT_FALSE(json.has_value()); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Metrics JSON serde — CommitMetricsResult | |
| // --------------------------------------------------------------------------- | |
| TEST(CommitMetricsResultSerdeTest, EmptyResultProducesEmptyJsonObject) { | |
| CommitMetricsResult empty{}; | |
| auto json = ToJson(empty); | |
| ASSERT_TRUE(json.has_value()); | |
| EXPECT_TRUE(json.value().is_object()); | |
| EXPECT_TRUE(json.value().empty()); | |
| } | |
| TEST(CommitMetricsResultSerdeTest, AllFieldsRoundTrip) { | |
| CommitMetricsResult m; | |
| m.total_duration = | |
| TimerResult{.count = 1, .total_duration = std::chrono::nanoseconds{1000}}; | |
| m.attempts = CounterResult{.unit = CounterUnit::kCount, .value = 2}; | |
| m.added_data_files = CounterResult{.unit = CounterUnit::kCount, .value = 3}; | |
| m.removed_data_files = CounterResult{.unit = CounterUnit::kCount, .value = 1}; | |
| m.total_data_files = CounterResult{.unit = CounterUnit::kCount, .value = 10}; | |
| m.added_delete_files = CounterResult{.unit = CounterUnit::kCount, .value = 2}; | |
| m.added_equality_delete_files = CounterResult{.unit = CounterUnit::kCount, .value = 1}; | |
| m.added_positional_delete_files = | |
| CounterResult{.unit = CounterUnit::kCount, .value = 1}; | |
| m.added_dvs = CounterResult{.unit = CounterUnit::kCount, .value = 4}; | |
| m.removed_positional_delete_files = | |
| CounterResult{.unit = CounterUnit::kCount, .value = 0}; | |
| m.removed_dvs = CounterResult{.unit = CounterUnit::kCount, .value = 1}; | |
| m.removed_equality_delete_files = | |
| CounterResult{.unit = CounterUnit::kCount, .value = 1}; | |
| m.removed_delete_files = CounterResult{.unit = CounterUnit::kCount, .value = 2}; | |
| m.total_delete_files = CounterResult{.unit = CounterUnit::kCount, .value = 5}; | |
| m.added_records = CounterResult{.unit = CounterUnit::kCount, .value = 500}; | |
| m.removed_records = CounterResult{.unit = CounterUnit::kCount, .value = 100}; | |
| m.total_records = CounterResult{.unit = CounterUnit::kCount, .value = 2000}; | |
| m.added_files_size_bytes = CounterResult{.unit = CounterUnit::kBytes, .value = 8192}; | |
| m.removed_files_size_bytes = CounterResult{.unit = CounterUnit::kBytes, .value = 1024}; | |
| m.total_files_size_bytes = CounterResult{.unit = CounterUnit::kBytes, .value = 65536}; | |
| m.added_positional_deletes = CounterResult{.unit = CounterUnit::kCount, .value = 20}; | |
| m.removed_positional_deletes = CounterResult{.unit = CounterUnit::kCount, .value = 5}; | |
| m.total_positional_deletes = CounterResult{.unit = CounterUnit::kCount, .value = 50}; | |
| m.added_equality_deletes = CounterResult{.unit = CounterUnit::kCount, .value = 10}; | |
| m.removed_equality_deletes = CounterResult{.unit = CounterUnit::kCount, .value = 3}; | |
| m.total_equality_deletes = CounterResult{.unit = CounterUnit::kCount, .value = 30}; | |
| m.kept_manifest_count = CounterResult{.unit = CounterUnit::kCount, .value = 4}; | |
| m.created_manifest_count = CounterResult{.unit = CounterUnit::kCount, .value = 2}; | |
| m.replaced_manifest_count = CounterResult{.unit = CounterUnit::kCount, .value = 1}; | |
| m.processed_manifest_entries_count = | |
| CounterResult{.unit = CounterUnit::kCount, .value = 12}; | |
| auto json = ToJson(m); | |
| ASSERT_TRUE(json.has_value()); | |
| EXPECT_TRUE(json.value().contains("manifests-kept")); | |
| EXPECT_TRUE(json.value().contains("manifests-created")); | |
| EXPECT_TRUE(json.value().contains("manifests-replaced")); | |
| EXPECT_TRUE(json.value().contains("manifest-entries-processed")); | |
| EXPECT_FALSE(json.value().contains("kept-manifest-count")); | |
| EXPECT_FALSE(json.value().contains("created-manifest-count")); | |
| EXPECT_FALSE(json.value().contains("replaced-manifest-count")); | |
| EXPECT_FALSE(json.value().contains("processed-manifest-entries-count")); | |
| auto result = CommitMetricsResultFromJson(json.value()); | |
| ASSERT_TRUE(result.has_value()); | |
| EXPECT_EQ(result.value(), m); | |
| } | |
| TEST(CommitMetricsResultSerdeTest, ParsesJavaManifestMetricFieldNames) { | |
| nlohmann::json json = { | |
| {"manifests-kept", nlohmann::json{{"unit", "count"}, {"value", 6}}}, | |
| {"manifests-created", nlohmann::json{{"unit", "count"}, {"value", 10}}}, | |
| {"manifests-replaced", nlohmann::json{{"unit", "count"}, {"value", 5}}}, | |
| {"manifest-entries-processed", nlohmann::json{{"unit", "count"}, {"value", 20}}}, | |
| }; | |
| auto result = CommitMetricsResultFromJson(json); | |
| ASSERT_TRUE(result.has_value()); | |
| ASSERT_TRUE(result.value().kept_manifest_count.has_value()); | |
| EXPECT_EQ(result.value().kept_manifest_count->value, 6); | |
| ASSERT_TRUE(result.value().created_manifest_count.has_value()); | |
| EXPECT_EQ(result.value().created_manifest_count->value, 10); | |
| ASSERT_TRUE(result.value().replaced_manifest_count.has_value()); | |
| EXPECT_EQ(result.value().replaced_manifest_count->value, 5); | |
| ASSERT_TRUE(result.value().processed_manifest_entries_count.has_value()); | |
| EXPECT_EQ(result.value().processed_manifest_entries_count->value, 20); | |
| } | |
| TEST(CommitMetricsResultSerdeTest, ExplicitZeroValueFieldsAreSerialized) { | |
| CommitMetricsResult m; | |
| m.added_data_files = CounterResult{.value = 5}; | |
| m.removed_data_files = CounterResult{.value = 0}; | |
| auto json = ToJson(m); | |
| ASSERT_TRUE(json.has_value()); | |
| EXPECT_TRUE(json.value().contains("added-data-files")); | |
| EXPECT_TRUE(json.value().contains("removed-data-files")); | |
| EXPECT_EQ(json.value()["removed-data-files"]["value"], 0); | |
| EXPECT_FALSE(json.value().contains("total-duration")); | |
| EXPECT_FALSE(json.value().contains("attempts")); | |
| } | |
| TEST(CommitMetricsResultSerdeTest, MissingFieldsRemainUnset) { | |
| nlohmann::json json = nlohmann::json::object(); | |
| json["added-data-files"] = nlohmann::json{{"unit", "count"}, {"value", 9}}; | |
| auto result = CommitMetricsResultFromJson(json); | |
| ASSERT_TRUE(result.has_value()); | |
| ASSERT_TRUE(result.value().added_data_files.has_value()); | |
| EXPECT_EQ(result.value().added_data_files->value, 9); | |
| EXPECT_FALSE(result.value().removed_data_files.has_value()); | |
| EXPECT_FALSE(result.value().total_duration.has_value()); | |
| } | |
| TEST(CommitMetricsResultSerdeTest, NonObjectReturnsError) { | |
| EXPECT_FALSE(CommitMetricsResultFromJson(nlohmann::json::array()).has_value()); | |
| EXPECT_FALSE(CommitMetricsResultFromJson(nlohmann::json{5}).has_value()); | |
| } | |
| TEST(CommitMetricsResultSerdeTest, NullMetricFieldReturnsError) { | |
| nlohmann::json json = nlohmann::json::object(); | |
| json["added-data-files"] = nullptr; | |
| EXPECT_FALSE(CommitMetricsResultFromJson(json).has_value()); | |
| json = nlohmann::json::object(); | |
| json["total-duration"] = nullptr; | |
| EXPECT_FALSE(CommitMetricsResultFromJson(json).has_value()); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Metrics JSON serde — CommitReport (additional cases) | |
| // --------------------------------------------------------------------------- | |
| TEST(CommitReportSerdeTest, EmptyMetricsSerializedAsEmptyObject) { | |
| CommitReport report; | |
| report.table_name = "db.t"; | |
| report.snapshot_id = 1; | |
| report.sequence_number = 1; | |
| report.operation = "append"; | |
| auto json = ToJson(report); | |
| ASSERT_TRUE(json.has_value()); | |
| EXPECT_TRUE(json.value().contains("metrics")); | |
| EXPECT_TRUE(json.value()["metrics"].empty()); | |
| } | |
| TEST(CommitReportSerdeTest, InvalidTimerUnitToJsonReturnsError) { | |
| CommitReport report; | |
| report.table_name = "db.t"; | |
| report.snapshot_id = 1; | |
| report.sequence_number = 1; | |
| report.operation = "append"; | |
| report.commit_metrics.total_duration = TimerResult{ | |
| .unit = "fortnights", .count = 1, .total_duration = std::chrono::nanoseconds{1}}; | |
| auto json = ToJson(report); | |
| EXPECT_FALSE(json.has_value()); | |
| } | |
| TEST(ScanReportSerdeTest, MetadataNullReturnsError) { | |
| nlohmann::json json = { | |
| {"table-name", "db.t"}, | |
| {"snapshot-id", 1}, | |
| {"filter", "true"}, | |
| {"schema-id", 1}, | |
| {"projected-field-ids", nlohmann::json::array()}, | |
| {"projected-field-names", nlohmann::json::array()}, | |
| {"metrics", nlohmann::json::object()}, | |
| {"metadata", nullptr}, | |
| }; | |
| EXPECT_FALSE(ScanReportFromJson(json).has_value()); | |
| } | |
| TEST(CommitReportSerdeTest, MetadataNullReturnsError) { | |
| nlohmann::json json = { | |
| {"table-name", "db.t"}, | |
| {"snapshot-id", 1}, | |
| {"sequence-number", 1}, | |
| {"operation", "append"}, | |
| {"metrics", nlohmann::json::object()}, | |
| {"metadata", nullptr}, | |
| }; | |
| EXPECT_FALSE(CommitReportFromJson(json).has_value()); | |
| } | |
| struct ReportRequiredFieldParam { | |
| std::string name; | |
| nlohmann::json json; | |
| std::function<bool(const nlohmann::json&)> has_value; | |
| }; | |
| class ReportRequiredFieldTest | |
| : public ::testing::TestWithParam<ReportRequiredFieldParam> {}; | |
| TEST_P(ReportRequiredFieldTest, MissingRequiredFieldReturnsError) { | |
| EXPECT_FALSE(GetParam().has_value(GetParam().json)); | |
| } | |
| INSTANTIATE_TEST_SUITE_P( | |
| RequiredFields, ReportRequiredFieldTest, | |
| ::testing::Values( | |
| ReportRequiredFieldParam{ | |
| "ScanMissingSnapshotId", nlohmann::json{{"table-name", "t"}}, | |
| [](const nlohmann::json& j) { return ScanReportFromJson(j).has_value(); }}, | |
| ReportRequiredFieldParam{ | |
| "ScanMissingFilter", | |
| nlohmann::json{{"table-name", "t"}, | |
| {"snapshot-id", 1}, | |
| {"schema-id", 1}, | |
| {"projected-field-ids", nlohmann::json::array()}, | |
| {"projected-field-names", nlohmann::json::array()}, | |
| {"metrics", nlohmann::json::object()}}, | |
| [](const nlohmann::json& j) { return ScanReportFromJson(j).has_value(); }}, | |
| ReportRequiredFieldParam{ | |
| "CommitMissingTableName", | |
| nlohmann::json{{"snapshot-id", 1}, {"sequence-number", 1}}, | |
| [](const nlohmann::json& j) { return CommitReportFromJson(j).has_value(); }}, | |
| ReportRequiredFieldParam{ | |
| "CommitMissingOperation", | |
| nlohmann::json{{"table-name", "t"}, | |
| {"snapshot-id", 1}, | |
| {"sequence-number", 1}, | |
| {"metrics", nlohmann::json::object()}}, | |
| [](const nlohmann::json& j) { return CommitReportFromJson(j).has_value(); }}), | |
| [](const auto& info) { return info.param.name; }); | |
| } // namespace iceberg |