From 9bc23e19fbdfa8c8fb916402d2d2c37d014cf675 Mon Sep 17 00:00:00 2001 From: Zhuo Wang Date: Thu, 2 Jul 2026 15:09:32 +0800 Subject: [PATCH] feat(inspect): implement SnapshotsTable scanning - Add Scan() virtual method and Scan() convenience overload to MetadataTable - Add SnapshotSelection struct for time-travel snapshot resolution - Add supports_time_travel() concrete method driven by kind() - Implement SnapshotsTable::Scan() to materialize snapshot rows via ArrowRowBuilder --- src/iceberg/inspect/metadata_table.cc | 17 ++ src/iceberg/inspect/metadata_table.h | 36 ++++ src/iceberg/inspect/snapshots_table.cc | 45 +++++ src/iceberg/inspect/snapshots_table.h | 7 + src/iceberg/test/CMakeLists.txt | 7 +- src/iceberg/test/history_table_test.cc | 54 ++++++ src/iceberg/test/metadata_table_test.cc | 80 ++------ src/iceberg/test/metadata_table_test_base.h | 161 ++++++++++++++++ src/iceberg/test/snapshots_table_test.cc | 198 ++++++++++++++++++++ 9 files changed, 540 insertions(+), 65 deletions(-) create mode 100644 src/iceberg/test/history_table_test.cc create mode 100644 src/iceberg/test/metadata_table_test_base.h create mode 100644 src/iceberg/test/snapshots_table_test.cc diff --git a/src/iceberg/inspect/metadata_table.cc b/src/iceberg/inspect/metadata_table.cc index 5e9504003..4298e3653 100644 --- a/src/iceberg/inspect/metadata_table.cc +++ b/src/iceberg/inspect/metadata_table.cc @@ -35,6 +35,23 @@ MetadataTable::MetadataTable(std::shared_ptr source_table, MetadataTable::~MetadataTable() = default; +bool MetadataTable::supports_time_travel() const noexcept { + // Time travel is supported for tables that read from a single snapshot's + // manifests. Tables that scan all snapshots or return in-memory history do + // not. + switch (kind()) { + case Kind::kSnapshots: + case Kind::kHistory: + return false; + } + return false; +} + +Result MetadataTable::Scan( + std::optional /*snapshot_selection*/) { + return NotSupported("Scan is not supported for this metadata table type"); +} + Result> MetadataTable::Make(std::shared_ptr
table, Kind kind) { if (table == nullptr) [[unlikely]] { diff --git a/src/iceberg/inspect/metadata_table.h b/src/iceberg/inspect/metadata_table.h index 7d0ac22da..7a0f507df 100644 --- a/src/iceberg/inspect/metadata_table.h +++ b/src/iceberg/inspect/metadata_table.h @@ -20,14 +20,28 @@ #pragma once #include +#include +#include +#include "iceberg/arrow_c_data.h" #include "iceberg/iceberg_export.h" #include "iceberg/result.h" #include "iceberg/table_identifier.h" #include "iceberg/type_fwd.h" +#include "iceberg/util/timepoint.h" namespace iceberg { +/// \brief Parameters for snapshot selection (time travel). +struct SnapshotSelection { + /// \brief The snapshot ID to read. + std::optional snapshot_id; + /// \brief Read the snapshot that was current at this timestamp. + std::optional as_of_timestamp; + /// \brief Read the snapshot referenced by this named ref (branch or tag). + std::optional ref_name; +}; + /// \brief Base class for Iceberg metadata tables. class ICEBERG_EXPORT MetadataTable { public: @@ -43,6 +57,28 @@ class ICEBERG_EXPORT MetadataTable { virtual Kind kind() const noexcept = 0; + /// \brief Whether this metadata table supports time-travel queries. + /// + /// Time travel is supported for tables that read from a single snapshot's + /// manifests (e.g., Entries, Files, Manifests, Partitions). Tables that + /// scan all snapshots (All*) or return in-memory history (Snapshots, + /// History, Refs) do not support time travel. + bool supports_time_travel() const noexcept; + + /// \brief Scan the metadata table using the current snapshot. + /// + /// Convenience overload — delegates to Scan(std::nullopt). + Result Scan() { return Scan(std::nullopt); } + + /// \brief Scan the metadata table and return all rows as an Arrow struct array. + /// + /// The returned ArrowArray is a struct array where each element is one row. + /// The caller takes ownership and must call ArrowArrayRelease when done. + /// + /// The default implementation returns NotSupported. Subclasses override this + /// to materialize their data. + virtual Result Scan(std::optional snapshot_selection); + const TableIdentifier& name() const { return identifier_; } const std::shared_ptr& schema() const { return schema_; } diff --git a/src/iceberg/inspect/snapshots_table.cc b/src/iceberg/inspect/snapshots_table.cc index 4b0c3ce9f..96d06a276 100644 --- a/src/iceberg/inspect/snapshots_table.cc +++ b/src/iceberg/inspect/snapshots_table.cc @@ -19,15 +19,18 @@ #include "iceberg/inspect/snapshots_table.h" +#include #include #include #include +#include "iceberg/arrow_row_builder_internal.h" #include "iceberg/schema.h" #include "iceberg/schema_field.h" #include "iceberg/table.h" #include "iceberg/table_identifier.h" #include "iceberg/type.h" +#include "iceberg/util/macros.h" namespace iceberg { namespace { @@ -65,4 +68,46 @@ Result> SnapshotsTable::Make( return std::unique_ptr(new SnapshotsTable(std::move(table))); } +Result SnapshotsTable::Scan( + std::optional /*snapshot_selection*/) { + ICEBERG_ASSIGN_OR_RAISE(auto builder, ArrowRowBuilder::Make(*schema())); + + for (const auto& snapshot : source_table()->snapshots()) { + // column 0: committed_at (timestamptz → int64 micros) + ICEBERG_RETURN_UNEXPECTED(AppendInt( + builder.column(0), std::chrono::duration_cast( + snapshot->timestamp_ms.time_since_epoch()) + .count())); + + // column 1: snapshot_id (long) + ICEBERG_RETURN_UNEXPECTED(AppendInt(builder.column(1), snapshot->snapshot_id)); + + // column 2: parent_id (long, optional) + if (snapshot->parent_snapshot_id.has_value()) { + ICEBERG_RETURN_UNEXPECTED( + AppendInt(builder.column(2), *snapshot->parent_snapshot_id)); + } else { + ICEBERG_RETURN_UNEXPECTED(AppendNull(builder.column(2))); + } + + // column 3: operation (string, optional) + auto op = snapshot->Operation(); + if (op.has_value()) { + ICEBERG_RETURN_UNEXPECTED(AppendString(builder.column(3), *op)); + } else { + ICEBERG_RETURN_UNEXPECTED(AppendNull(builder.column(3))); + } + + // column 4: manifest_list (string, optional) + ICEBERG_RETURN_UNEXPECTED(AppendString(builder.column(4), snapshot->manifest_list)); + + // column 5: summary (map) + ICEBERG_RETURN_UNEXPECTED(AppendStringMap(builder.column(5), snapshot->summary)); + + ICEBERG_RETURN_UNEXPECTED(builder.FinishRow()); + } + + return std::move(builder).Finish(); +} + } // namespace iceberg diff --git a/src/iceberg/inspect/snapshots_table.h b/src/iceberg/inspect/snapshots_table.h index 50017796f..e4c741f65 100644 --- a/src/iceberg/inspect/snapshots_table.h +++ b/src/iceberg/inspect/snapshots_table.h @@ -37,6 +37,13 @@ class ICEBERG_EXPORT SnapshotsTable : public MetadataTable { Kind kind() const noexcept override { return Kind::kSnapshots; } + /// \brief Scan all snapshots as rows. + /// + /// The snapshots table always returns every known snapshot, so the + /// snapshot_selection parameter is ignored. + Result Scan( + std::optional /*snapshot_selection*/) override; + private: explicit SnapshotsTable(std::shared_ptr
table); }; diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt index b3592d8e2..3b438bc51 100644 --- a/src/iceberg/test/CMakeLists.txt +++ b/src/iceberg/test/CMakeLists.txt @@ -188,7 +188,12 @@ if(ICEBERG_BUILD_BUNDLE) add_iceberg_test(catalog_test USE_BUNDLE SOURCES in_memory_catalog_test.cc) - add_iceberg_test(metadata_table_test USE_BUNDLE SOURCES metadata_table_test.cc) + add_iceberg_test(metadata_table_test + USE_BUNDLE + SOURCES + history_table_test.cc + metadata_table_test.cc + snapshots_table_test.cc) add_iceberg_test(eval_expr_test USE_BUNDLE diff --git a/src/iceberg/test/history_table_test.cc b/src/iceberg/test/history_table_test.cc new file mode 100644 index 000000000..b27bdef30 --- /dev/null +++ b/src/iceberg/test/history_table_test.cc @@ -0,0 +1,54 @@ +/* + * 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. + */ + +/// \file history_table_test.cc +/// Unit tests for HistoryTable. + +#include +#include + +#include "iceberg/inspect/metadata_table.h" +#include "iceberg/schema.h" +#include "iceberg/schema_field.h" +#include "iceberg/test/matchers.h" +#include "iceberg/test/metadata_table_test_base.h" +#include "iceberg/type.h" + +namespace iceberg { +namespace { + +std::shared_ptr MakeHistorySchema() { + return std::make_shared(std::vector{ + SchemaField::MakeRequired(1, "made_current_at", timestamp_tz()), + SchemaField::MakeRequired(2, "snapshot_id", int64()), + SchemaField::MakeOptional(3, "parent_id", int64()), + SchemaField::MakeRequired(4, "is_current_ancestor", boolean())}); +} + +} // namespace + +class HistoryTableTest : public MetadataTableTestBase {}; + +TEST_F(HistoryTableTest, SchemaMatchesIcebergSchema) { + ICEBERG_UNWRAP_OR_FAIL(auto history_table, + MetadataTable::Make(table_, MetadataTable::Kind::kHistory)); + EXPECT_TRUE(*history_table->schema() == *MakeHistorySchema()); +} + +} // namespace iceberg diff --git a/src/iceberg/test/metadata_table_test.cc b/src/iceberg/test/metadata_table_test.cc index 1e0a664c3..fd59af3b3 100644 --- a/src/iceberg/test/metadata_table_test.cc +++ b/src/iceberg/test/metadata_table_test.cc @@ -33,88 +33,40 @@ #include "iceberg/type.h" namespace iceberg { -namespace { - -std::shared_ptr MakeSnapshotsSchema() { - return std::make_shared(std::vector{ - SchemaField::MakeRequired(1, "committed_at", timestamp_tz()), - SchemaField::MakeRequired(2, "snapshot_id", int64()), - SchemaField::MakeOptional(3, "parent_id", int64()), - SchemaField::MakeOptional(4, "operation", string()), - SchemaField::MakeOptional(5, "manifest_list", string()), - SchemaField::MakeOptional( - 6, "summary", - std::make_shared(SchemaField::MakeRequired(7, "key", string()), - SchemaField::MakeRequired(8, "value", string())))}); -} - -std::shared_ptr MakeHistorySchema() { - return std::make_shared(std::vector{ - SchemaField::MakeRequired(1, "made_current_at", timestamp_tz()), - SchemaField::MakeRequired(2, "snapshot_id", int64()), - SchemaField::MakeOptional(3, "parent_id", int64()), - SchemaField::MakeRequired(4, "is_current_ancestor", boolean())}); -} - -} // namespace class MetadataTableTest : public ::testing::Test { protected: void SetUp() override { - io_ = std::make_shared(); - catalog_ = std::make_shared(); - auto schema = std::make_shared( std::vector{SchemaField::MakeRequired(1, "id", int64()), SchemaField::MakeOptional(2, "name", string())}, 1); - metadata_ = std::make_shared( + auto metadata = std::make_shared( TableMetadata{.format_version = 2, .schemas = {schema}, .current_schema_id = 1}); - TableIdentifier source_ident{.ns = Namespace{.levels = {"db"}}, - .name = "source_table"}; - auto source_table_result = - Table::Make(source_ident, metadata_, "s3://bucket/meta.json", io_, catalog_); - EXPECT_THAT(source_table_result, IsOk()); - source_table_ = *source_table_result; - - auto snapshots_table_result = - MetadataTable::Make(source_table_, MetadataTable::Kind::kSnapshots); - EXPECT_THAT(snapshots_table_result, IsOk()); - snapshots_table_ = std::move(*snapshots_table_result); + TableIdentifier ident{.ns = Namespace{.levels = {"db"}}, .name = "source_table"}; + ICEBERG_UNWRAP_OR_FAIL(table_, Table::Make(ident, metadata, "s3://bucket/meta.json", + std::make_shared(), + std::make_shared())); } - std::shared_ptr io_; - std::shared_ptr catalog_; - std::shared_ptr metadata_; - std::shared_ptr
source_table_; - std::unique_ptr snapshots_table_; + std::shared_ptr
table_; }; -TEST_F(MetadataTableTest, Constructor) { - EXPECT_EQ(snapshots_table_->kind(), MetadataTable::Kind::kSnapshots); - EXPECT_EQ(snapshots_table_->source_table(), source_table_); - EXPECT_EQ(snapshots_table_->name().name, "source_table.snapshots"); - EXPECT_EQ(snapshots_table_->name().ns.levels, (std::vector{"db"})); - EXPECT_NE(snapshots_table_->schema(), nullptr); -} - -TEST_F(MetadataTableTest, SnapshotsSchemaMatchesIcebergSchema) { - EXPECT_TRUE(*snapshots_table_->schema() == *MakeSnapshotsSchema()); -} - -TEST_F(MetadataTableTest, HistorySchemaMatchesIcebergSchema) { - auto history_table_result = - MetadataTable::Make(source_table_, MetadataTable::Kind::kHistory); - ASSERT_THAT(history_table_result, IsOk()); - - EXPECT_TRUE(*(*history_table_result)->schema() == *MakeHistorySchema()); -} - TEST_F(MetadataTableTest, FactoryRejectsNullSourceTable) { auto result = MetadataTable::Make(nullptr, MetadataTable::Kind::kSnapshots); EXPECT_THAT(result, IsError(ErrorKind::kInvalidArgument)); EXPECT_THAT(result, HasErrorMessage("Table cannot be null")); } +TEST_F(MetadataTableTest, SupportsTimeTravel) { + ICEBERG_UNWRAP_OR_FAIL(auto snapshots_table, + MetadataTable::Make(table_, MetadataTable::Kind::kSnapshots)); + EXPECT_FALSE(snapshots_table->supports_time_travel()); + + ICEBERG_UNWRAP_OR_FAIL(auto history_table, + MetadataTable::Make(table_, MetadataTable::Kind::kHistory)); + EXPECT_FALSE(history_table->supports_time_travel()); +} + } // namespace iceberg diff --git a/src/iceberg/test/metadata_table_test_base.h b/src/iceberg/test/metadata_table_test_base.h new file mode 100644 index 000000000..bd75cf083 --- /dev/null +++ b/src/iceberg/test/metadata_table_test_base.h @@ -0,0 +1,161 @@ +/* + * 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. + */ + +/// \file metadata_table_test_base.h +/// Shared test base for all metadata table tests. +/// +/// Provides common helpers (FinishAndImport, MakeTestSnapshots, +/// MakeTableWithSnapshots) and the MockFileIO + MockCatalog fixture that +/// every metadata table test needs. + +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "iceberg/schema.h" +#include "iceberg/schema_field.h" +#include "iceberg/schema_internal.h" +#include "iceberg/snapshot.h" +#include "iceberg/table.h" +#include "iceberg/table_identifier.h" +#include "iceberg/table_metadata.h" +#include "iceberg/test/matchers.h" +#include "iceberg/test/mock_catalog.h" +#include "iceberg/test/mock_io.h" +#include "iceberg/type.h" +#include "iceberg/util/timepoint.h" + +namespace iceberg { + +/// \brief Base class for all metadata table tests. +/// +/// Provides MockFileIO and MockCatalog instances plus helpers shared across +/// metadata table tests (SnapshotsTable, HistoryTable, RefsTable, ...). +class MetadataTableTestBase : public ::testing::Test { + protected: + void SetUp() override { + io_ = std::make_shared(); + catalog_ = std::make_shared(); + + auto schema = std::make_shared( + std::vector{SchemaField::MakeRequired(1, "id", int64()), + SchemaField::MakeOptional(2, "name", string())}, + 1); + metadata_ = std::make_shared( + TableMetadata{.format_version = 2, .schemas = {schema}, .current_schema_id = 1}); + + TableIdentifier source_ident{.ns = Namespace{.levels = {"db"}}, + .name = "source_table"}; + ICEBERG_UNWRAP_OR_FAIL(table_, Table::Make(source_ident, metadata_, + "s3://bucket/meta.json", io_, catalog_)); + } + + /// \brief Import a Scan()-produced ArrowArray into an Arrow RecordBatch. + static std::shared_ptr<::arrow::RecordBatch> FinishAndImport(ArrowArray array, + const Schema& schema) { + ArrowSchema c_schema; + EXPECT_THAT(ToArrowSchema(schema, &c_schema), IsOk()); + auto arrow_schema = ::arrow::ImportSchema(&c_schema).ValueOrDie(); + + // ImportRecordBatch takes ownership of the array and releases it. + return ::arrow::ImportRecordBatch(&array, arrow_schema).ValueOrDie(); + } + + /// \brief Create two snapshots matching the Java TestDataTaskParser test data. + /// + /// Snapshot 1: id=1, no parent, timestamp=1234567890000, operation="append" + /// Snapshot 2: id=2, parent=1, timestamp=9876543210000, operation="append" + static std::pair, std::shared_ptr> + MakeTestSnapshots() { + std::unordered_map summary1{ + {"added-data-files", "1"}, {"added-records", "1"}, + {"added-files-size", "10"}, {"changed-partition-count", "1"}, + {"total-records", "1"}, {"total-files-size", "10"}, + {"total-data-files", "1"}, {"total-delete-files", "0"}, + {"total-position-deletes", "0"}, {"total-equality-deletes", "0"}, + {"operation", "append"}, + }; + + std::unordered_map summary2{ + {"added-data-files", "1"}, {"added-records", "1"}, + {"added-files-size", "10"}, {"changed-partition-count", "1"}, + {"total-records", "2"}, {"total-files-size", "20"}, + {"total-data-files", "2"}, {"total-delete-files", "0"}, + {"total-position-deletes", "0"}, {"total-equality-deletes", "0"}, + {"operation", "append"}, + }; + + auto snap1 = std::make_shared(Snapshot{ + .snapshot_id = 1, + .parent_snapshot_id = std::nullopt, + .sequence_number = 1, + .timestamp_ms = TimePointMsFromUnixMs(1234567890000), + .manifest_list = "file:/tmp/manifest1.avro", + .summary = std::move(summary1), + .schema_id = 1, + }); + + auto snap2 = std::make_shared(Snapshot{ + .snapshot_id = 2, + .parent_snapshot_id = 1, + .sequence_number = 2, + .timestamp_ms = TimePointMsFromUnixMs(9876543210000), + .manifest_list = "file:/tmp/manifest2.avro", + .summary = std::move(summary2), + .schema_id = 1, + }); + + return {snap1, snap2}; + } + + /// \brief Create a Table with the given snapshots. + Result> MakeTableWithSnapshots( + std::vector> snapshots, int64_t current_snapshot_id) { + auto schema = std::make_shared( + std::vector{SchemaField::MakeRequired(1, "id", int64()), + SchemaField::MakeOptional(2, "name", string())}, + 1); + auto metadata = std::make_shared(TableMetadata{ + .format_version = 2, + .schemas = {schema}, + .current_schema_id = 1, + .current_snapshot_id = current_snapshot_id, + .snapshots = std::move(snapshots), + }); + + TableIdentifier ident{.ns = Namespace{.levels = {"db"}}, .name = "test_table"}; + return Table::Make(ident, metadata, "s3://bucket/meta.json", io_, catalog_); + } + + std::shared_ptr io_; + std::shared_ptr catalog_; + std::shared_ptr metadata_; + std::shared_ptr
table_; +}; + +} // namespace iceberg diff --git a/src/iceberg/test/snapshots_table_test.cc b/src/iceberg/test/snapshots_table_test.cc new file mode 100644 index 000000000..88e241b4b --- /dev/null +++ b/src/iceberg/test/snapshots_table_test.cc @@ -0,0 +1,198 @@ +/* + * 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. + */ + +/// \file snapshots_table_test.cc +/// Unit tests for SnapshotsTable. +/// +/// Test data matches the Java TestDataTaskParser.createDataTask() fixture: +/// two snapshots with ids 1 and 2, parent relationship null→1, and +/// identical summary keys (10 data counters + operation). + +#include +#include + +#include +#include +#include +#include +#include + +#include "iceberg/inspect/metadata_table.h" +#include "iceberg/schema.h" +#include "iceberg/schema_field.h" +#include "iceberg/test/matchers.h" +#include "iceberg/test/metadata_table_test_base.h" +#include "iceberg/type.h" + +namespace iceberg { +namespace { + +std::shared_ptr MakeSnapshotsSchema() { + return std::make_shared(std::vector{ + SchemaField::MakeRequired(1, "committed_at", timestamp_tz()), + SchemaField::MakeRequired(2, "snapshot_id", int64()), + SchemaField::MakeOptional(3, "parent_id", int64()), + SchemaField::MakeOptional(4, "operation", string()), + SchemaField::MakeOptional(5, "manifest_list", string()), + SchemaField::MakeOptional( + 6, "summary", + std::make_shared(SchemaField::MakeRequired(7, "key", string()), + SchemaField::MakeRequired(8, "value", string())))}); +} + +} // namespace + +class SnapshotsTableTest : public MetadataTableTestBase { + protected: + void SetUp() override { + MetadataTableTestBase::SetUp(); + + auto [snap1, snap2] = MakeTestSnapshots(); + snap1_ = snap1; + snap2_ = snap2; + + ICEBERG_UNWRAP_OR_FAIL( + table_, MakeTableWithSnapshots({snap1, snap2}, /*current_snapshot_id=*/2)); + + ICEBERG_UNWRAP_OR_FAIL(snapshots_table_, + MetadataTable::Make(table_, MetadataTable::Kind::kSnapshots)); + } + + std::shared_ptr
table_; + std::shared_ptr snap1_; + std::shared_ptr snap2_; + std::unique_ptr snapshots_table_; +}; + +TEST_F(SnapshotsTableTest, Construct) { + ICEBERG_UNWRAP_OR_FAIL(snapshots_table_, + MetadataTable::Make(MetadataTableTestBase::table_, + MetadataTable::Kind::kSnapshots)); + EXPECT_EQ(snapshots_table_->kind(), MetadataTable::Kind::kSnapshots); + EXPECT_EQ(snapshots_table_->source_table(), MetadataTableTestBase::table_); + EXPECT_EQ(snapshots_table_->name().name, "source_table.snapshots"); + EXPECT_EQ(snapshots_table_->name().ns.levels, (std::vector{"db"})); + EXPECT_NE(snapshots_table_->schema(), nullptr); +} + +TEST_F(SnapshotsTableTest, SchemaMatchesIcebergSchema) { + ICEBERG_UNWRAP_OR_FAIL(snapshots_table_, + MetadataTable::Make(MetadataTableTestBase::table_, + MetadataTable::Kind::kSnapshots)); + EXPECT_TRUE(*snapshots_table_->schema() == *MakeSnapshotsSchema()); +} + +TEST_F(SnapshotsTableTest, ScanReturnsCorrectRowCount) { + ICEBERG_UNWRAP_OR_FAIL(auto array, snapshots_table_->Scan()); + auto batch = FinishAndImport(std::move(array), *snapshots_table_->schema()); + EXPECT_EQ(batch->num_rows(), 2); + EXPECT_EQ(batch->num_columns(), 6); +} + +TEST_F(SnapshotsTableTest, ScanReturnsSnapshotsInOrder) { + // Snapshots should be returned in the order they were stored. + ICEBERG_UNWRAP_OR_FAIL(auto array, snapshots_table_->Scan()); + auto batch = FinishAndImport(std::move(array), *snapshots_table_->schema()); + + // Column 1: snapshot_id (long) + auto snapshot_ids = std::static_pointer_cast<::arrow::Int64Array>(batch->column(1)); + EXPECT_EQ(snapshot_ids->Value(0), 1); + EXPECT_EQ(snapshot_ids->Value(1), 2); +} + +TEST_F(SnapshotsTableTest, ScanCommittedAtMicros) { + // committed_at should be in microseconds since epoch, matching Java's + // snap.timestampMillis() * 1000. + ICEBERG_UNWRAP_OR_FAIL(auto array, snapshots_table_->Scan()); + auto batch = FinishAndImport(std::move(array), *snapshots_table_->schema()); + + auto col = std::static_pointer_cast<::arrow::TimestampArray>(batch->column(0)); + EXPECT_EQ(col->Value(0), 1234567890000 * 1000); // snap1 timestamp in micros + EXPECT_EQ(col->Value(1), 9876543210000 * 1000); // snap2 timestamp in micros +} + +TEST_F(SnapshotsTableTest, ScanParentId) { + // First snapshot has no parent (null), second has parent_id = 1. + ICEBERG_UNWRAP_OR_FAIL(auto array, snapshots_table_->Scan()); + auto batch = FinishAndImport(std::move(array), *snapshots_table_->schema()); + + auto col = std::static_pointer_cast<::arrow::Int64Array>(batch->column(2)); + EXPECT_TRUE(col->IsNull(0)); + EXPECT_FALSE(col->IsNull(1)); + EXPECT_EQ(col->Value(1), 1); +} + +TEST_F(SnapshotsTableTest, ScanOperation) { + // Both snapshots have operation = "append". + ICEBERG_UNWRAP_OR_FAIL(auto array, snapshots_table_->Scan()); + auto batch = FinishAndImport(std::move(array), *snapshots_table_->schema()); + + auto col = std::static_pointer_cast<::arrow::StringArray>(batch->column(3)); + EXPECT_EQ(col->GetString(0), "append"); + EXPECT_EQ(col->GetString(1), "append"); +} + +TEST_F(SnapshotsTableTest, ScanManifestList) { + ICEBERG_UNWRAP_OR_FAIL(auto array, snapshots_table_->Scan()); + auto batch = FinishAndImport(std::move(array), *snapshots_table_->schema()); + + auto col = std::static_pointer_cast<::arrow::StringArray>(batch->column(4)); + EXPECT_EQ(col->GetString(0), "file:/tmp/manifest1.avro"); + EXPECT_EQ(col->GetString(1), "file:/tmp/manifest2.avro"); +} + +TEST_F(SnapshotsTableTest, ScanSummaryMap) { + // The summary column is a map. Verify it is non-null and + // contains the expected number of entries. + ICEBERG_UNWRAP_OR_FAIL(auto array, snapshots_table_->Scan()); + auto batch = FinishAndImport(std::move(array), *snapshots_table_->schema()); + + auto col = std::static_pointer_cast<::arrow::MapArray>(batch->column(5)); + EXPECT_FALSE(col->IsNull(0)); + EXPECT_FALSE(col->IsNull(1)); + // Each summary has 11 entries (10 data + 1 operation). + EXPECT_EQ(col->value_length(0), 11); + EXPECT_EQ(col->value_length(1), 11); +} + +TEST_F(SnapshotsTableTest, ScanSnapshotSelectionIgnored) { + // SnapshotsTable always returns all snapshots regardless of selection. + SnapshotSelection sel{.snapshot_id = 999}; + ICEBERG_UNWRAP_OR_FAIL(auto array, snapshots_table_->Scan(sel)); + auto batch = FinishAndImport(std::move(array), *snapshots_table_->schema()); + // Should still return all 2 snapshots, not filtered to snapshot 999. + EXPECT_EQ(batch->num_rows(), 2); +} + +TEST_F(SnapshotsTableTest, ScanEmptySnapshotList) { + // A table with zero snapshots should return zero rows. + ICEBERG_UNWRAP_OR_FAIL(auto empty_table, + MakeTableWithSnapshots({}, /*current_snapshot_id=*/-1)); + + ICEBERG_UNWRAP_OR_FAIL( + snapshots_table_, + MetadataTable::Make(empty_table, MetadataTable::Kind::kSnapshots)); + + ICEBERG_UNWRAP_OR_FAIL(auto array, snapshots_table_->Scan(std::nullopt)); + auto batch = FinishAndImport(std::move(array), *snapshots_table_->schema()); + EXPECT_EQ(batch->num_rows(), 0); + EXPECT_EQ(batch->num_columns(), 6); +} + +} // namespace iceberg