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