blob: db90aa14a9a539097dc7116ec0dee64886857614 [file]
/*
* 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 "iceberg/catalog/memory/in_memory_catalog.h"
#include <filesystem>
#include <string>
#include <unordered_map>
#include <arrow/filesystem/localfs.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include "iceberg/arrow/arrow_io_internal.h"
#include "iceberg/partition_spec.h"
#include "iceberg/schema.h"
#include "iceberg/sort_order.h"
#include "iceberg/table.h"
#include "iceberg/table_identifier.h"
#include "iceberg/table_metadata.h"
#include "iceberg/table_requirement.h"
#include "iceberg/table_update.h"
#include "iceberg/test/matchers.h"
#include "iceberg/test/mock_catalog.h"
#include "iceberg/test/mock_io.h"
#include "iceberg/test/test_resource.h"
#include "iceberg/transaction.h"
#include "iceberg/update/update_properties.h"
#include "iceberg/util/uuid.h"
namespace iceberg {
class InMemoryCatalogTest : public ::testing::Test {
protected:
void SetUp() override {
file_io_ = arrow::ArrowFileSystemFileIO::MakeLocalFileIO();
std::unordered_map<std::string, std::string> properties = {{"prop1", "val1"}};
ICEBERG_UNWRAP_OR_FAIL(
catalog_,
InMemoryCatalog::Make("test_catalog", file_io_, "/tmp/warehouse/", properties));
}
void TearDown() override {
// Clean up the temporary files created for the table metadata
for (const auto& path : created_temp_paths_) {
std::error_code ec;
if (std::filesystem::is_directory(path, ec)) {
std::filesystem::remove_all(path, ec);
} else {
std::filesystem::remove(path, ec);
}
}
}
std::string GenerateTestTableLocation(std::string table_name) {
std::filesystem::path temp_dir = std::filesystem::temp_directory_path();
const auto info = ::testing::UnitTest::GetInstance()->current_test_info();
auto table_location = std::format("{}/iceberg_test_{}_{}/{}/", temp_dir.string(),
info->test_suite_name(), info->name(), table_name);
// generate a unique directory for the table
std::error_code ec;
std::filesystem::create_directories(table_location + "metadata", ec);
if (ec) {
throw std::runtime_error(
std::format("Failed to create temporary directory: {}, error message: {}",
table_location, ec.message()));
}
created_temp_paths_.push_back(table_location);
return table_location;
}
std::shared_ptr<FileIO> file_io_;
std::shared_ptr<InMemoryCatalog> catalog_;
// Used to store temporary paths created during the test
std::vector<std::string> created_temp_paths_;
};
TEST_F(InMemoryCatalogTest, InvalidMetricsReporterImplReturnsError) {
std::unordered_map<std::string, std::string> properties = {
{"metrics-reporter-impl", "this-reporter-type-does-not-exist"}};
EXPECT_THAT(
InMemoryCatalog::Make("test_catalog", file_io_, "/tmp/warehouse/", properties),
IsError(ErrorKind::kInvalidArgument));
}
TEST_F(InMemoryCatalogTest, CatalogName) {
EXPECT_EQ(catalog_->name(), "test_catalog");
auto tablesRs = catalog_->ListTables(Namespace{{}});
EXPECT_THAT(tablesRs, IsOk());
ASSERT_TRUE(tablesRs->empty());
}
TEST_F(InMemoryCatalogTest, ListTables) {
auto tablesRs = catalog_->ListTables(Namespace{{}});
EXPECT_THAT(tablesRs, IsOk());
ASSERT_TRUE(tablesRs->empty());
}
TEST_F(InMemoryCatalogTest, TableExists) {
TableIdentifier tableIdent{.ns = {}, .name = "t1"};
auto result = catalog_->TableExists(tableIdent);
EXPECT_THAT(result, HasValue(::testing::Eq(false)));
}
TEST_F(InMemoryCatalogTest, CreateTable) {
TableIdentifier table_ident{.ns = {}, .name = "t1"};
auto schema = std::make_shared<Schema>(
std::vector<SchemaField>{SchemaField::MakeRequired(1, "x", int64())},
/*schema_id=*/1);
auto spec = PartitionSpec::Unpartitioned();
auto sort_order = SortOrder::Unsorted();
auto metadata_location = GenerateTestTableLocation(table_ident.name);
// Create table successfully
auto table = catalog_->CreateTable(table_ident, schema, spec, sort_order,
metadata_location, {{"property1", "value1"}});
EXPECT_THAT(table, IsOk());
EXPECT_EQ(table.value()->full_name(), "test_catalog.t1");
// Create table already exists
auto table2 = catalog_->CreateTable(table_ident, schema, spec, sort_order,
metadata_location, {{"property1", "value1"}});
EXPECT_THAT(table2, IsError(ErrorKind::kAlreadyExists));
}
TEST_F(InMemoryCatalogTest, RegisterTable) {
TableIdentifier tableIdent{.ns = {}, .name = "t1"};
ICEBERG_UNWRAP_OR_FAIL(auto metadata,
ReadTableMetadataFromResource("TableMetadataV2Valid.json"));
auto table_location = GenerateTestTableLocation(tableIdent.name);
auto metadata_location = std::format("{}v1.metadata.json", table_location);
auto status = TableMetadataUtil::Write(*file_io_, metadata_location, *metadata);
EXPECT_THAT(status, IsOk());
auto table = catalog_->RegisterTable(tableIdent, metadata_location);
EXPECT_THAT(table, IsOk());
ASSERT_EQ(table.value()->name().name, "t1");
EXPECT_EQ(table.value()->full_name(), "test_catalog.t1");
ASSERT_EQ(table.value()->location(), "s3://bucket/test/location");
}
TEST_F(InMemoryCatalogTest, CreateTableNonexistentNamespace) {
TableIdentifier table_ident{.ns = Namespace{.levels = {"missing"}}, .name = "t1"};
auto schema = std::make_shared<Schema>(
std::vector<SchemaField>{SchemaField::MakeRequired(1, "id", int64())},
/*schema_id=*/1);
auto spec = PartitionSpec::Unpartitioned();
auto sort_order = SortOrder::Unsorted();
// Use an explicit location whose metadata directory already exists (the local
// FileIO does not create parent dirs). A write-before-validate bug would land a
// detectable orphan there. GenerateTestTableLocation is unique per test and
// auto-cleaned via created_temp_paths_.
auto table_location = GenerateTestTableLocation(table_ident.name);
auto table =
catalog_->CreateTable(table_ident, schema, spec, sort_order, table_location, {});
EXPECT_THAT(table, IsError(ErrorKind::kNoSuchNamespace));
// The namespace check must run before any metadata file is written, so a
// failed create must leave no orphaned metadata file behind.
std::error_code ec;
size_t metadata_files = 0;
for (auto it = std::filesystem::recursive_directory_iterator(table_location, ec);
it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) {
if (it->path().extension() == ".json") {
++metadata_files;
}
}
EXPECT_EQ(metadata_files, 0);
}
TEST_F(InMemoryCatalogTest, RegisterTableNonexistentNamespace) {
TableIdentifier table_ident{.ns = Namespace{.levels = {"missing"}}, .name = "t1"};
ICEBERG_UNWRAP_OR_FAIL(auto metadata,
ReadTableMetadataFromResource("TableMetadataV2Valid.json"));
auto table_location = GenerateTestTableLocation(table_ident.name);
auto metadata_location = std::format("{}v1.metadata.json", table_location);
ASSERT_THAT(TableMetadataUtil::Write(*file_io_, metadata_location, *metadata), IsOk());
// Registering into a nonexistent namespace reports NoSuchNamespace. The dead
// if (!NamespaceExists(...)) check tested the Result<bool>'s has_value() rather
// than the contained bool, so this previously fell through to kUnknownError.
auto table = catalog_->RegisterTable(table_ident, metadata_location);
EXPECT_THAT(table, IsError(ErrorKind::kNoSuchNamespace));
}
TEST_F(InMemoryCatalogTest, RefreshTable) {
TableIdentifier table_ident{.ns = {}, .name = "t1"};
auto schema = std::make_shared<Schema>(
std::vector<SchemaField>{SchemaField::MakeRequired(1, "x", int64())},
/*schema_id=*/1);
auto io = std::make_shared<MockFileIO>();
auto catalog = std::make_shared<MockCatalog>();
// Mock 1st call to LoadTable
EXPECT_CALL(*catalog, LoadTable(::testing::_))
.WillOnce(::testing::Return(
Table::Make(table_ident,
std::make_shared<TableMetadata>(
TableMetadata{.schemas = {schema},
.current_schema_id = 1,
.current_snapshot_id = 1,
.snapshots = {std::make_shared<Snapshot>(Snapshot{
.snapshot_id = 1,
.sequence_number = 1,
})}}),
"s3://location/1.json", io, catalog)));
auto load_table_result = catalog->LoadTable(table_ident);
ASSERT_THAT(load_table_result, IsOk());
auto loaded_table = std::move(load_table_result.value());
ASSERT_EQ(loaded_table->current_snapshot().value()->snapshot_id, 1);
// Mock 2nd call to LoadTable
EXPECT_CALL(*catalog, LoadTable(::testing::_))
.WillOnce(::testing::Return(
Table::Make(table_ident,
std::make_shared<TableMetadata>(
TableMetadata{.schemas = {schema},
.current_schema_id = 1,
.current_snapshot_id = 2,
.snapshots = {std::make_shared<Snapshot>(Snapshot{
.snapshot_id = 1,
.sequence_number = 1,
}),
std::make_shared<Snapshot>(Snapshot{
.snapshot_id = 2,
.sequence_number = 2,
})}}),
"s3://location/2.json", io, catalog)));
auto refreshed_result = loaded_table->Refresh();
ASSERT_THAT(refreshed_result, IsOk());
// check table is refreshed
ASSERT_EQ(loaded_table->current_snapshot().value()->snapshot_id, 2);
}
TEST_F(InMemoryCatalogTest, UpdateTable) {
// First, create and register a table
TableIdentifier table_ident{.ns = {}, .name = "t1"};
ICEBERG_UNWRAP_OR_FAIL(auto metadata,
ReadTableMetadataFromResource("TableMetadataV2Valid.json"));
auto table_location = GenerateTestTableLocation(table_ident.name);
auto metadata_location = std::format("{}/metadata/00001-{}.metadata.json",
table_location, Uuid::GenerateV7().ToString());
metadata->location = table_location;
auto status = TableMetadataUtil::Write(*file_io_, metadata_location, *metadata);
EXPECT_THAT(status, IsOk());
auto table = catalog_->RegisterTable(table_ident, metadata_location);
EXPECT_THAT(table, IsOk());
ASSERT_EQ(table.value()->name().name, "t1");
ASSERT_EQ(table.value()->metadata_file_location(), metadata_location);
// Prepare updates - add a new property
std::vector<std::unique_ptr<TableUpdate>> updates;
auto property_update = std::make_unique<table::SetProperties>(
std::unordered_map<std::string, std::string>{{"property2", "value2"}});
updates.push_back(std::move(property_update));
// Prepare requirements - assert table must exist
std::vector<std::unique_ptr<TableRequirement>> requirements;
requirements.push_back(std::make_unique<table::AssertUUID>(metadata->table_uuid));
// Perform the update on a nonexist table
TableIdentifier nonexist_table_ident{.ns = {}, .name = "nonexist_table"};
auto res = catalog_->UpdateTable(nonexist_table_ident, std::move(requirements),
std::move(updates));
EXPECT_THAT(res, IsError(ErrorKind::kNotFound));
// Verify requirements failed on an exist table
std::vector<std::unique_ptr<TableRequirement>> bad_requirements;
bad_requirements.push_back(std::make_unique<table::AssertUUID>("invalid-uuid"));
res =
catalog_->UpdateTable(table_ident, std::move(bad_requirements), std::move(updates));
EXPECT_THAT(res, IsError(ErrorKind::kCommitFailed));
// Perform the update
auto update_result = catalog_->UpdateTable(table_ident, requirements, updates);
EXPECT_THAT(update_result, IsOk());
// Verify the update by loading the table and checking properties
auto load_result = catalog_->LoadTable(table_ident);
EXPECT_THAT(load_result, IsOk());
auto updated_table = std::move(load_result.value());
// Verify that metadata file was updated (should have a new version)
EXPECT_EQ(table.value()->uuid(), updated_table->uuid());
EXPECT_GT(updated_table->last_updated_ms(), table.value()->last_updated_ms());
EXPECT_THAT(updated_table->metadata_file_location(),
testing::HasSubstr("metadata/00002-"));
}
TEST_F(InMemoryCatalogTest, StageCreateTable) {
TableIdentifier table_ident{.ns = {}, .name = "t1"};
auto schema = std::make_shared<Schema>(
std::vector<SchemaField>{SchemaField::MakeRequired(1, "x", int64())},
/*schema_id=*/1);
auto spec = PartitionSpec::Unpartitioned();
auto sort_order = SortOrder::Unsorted();
// Stage table
ICEBERG_UNWRAP_OR_FAIL(
auto staged_table,
catalog_->StageCreateTable(table_ident, schema, spec, sort_order,
GenerateTestTableLocation(table_ident.name), {}));
EXPECT_EQ(staged_table->table()->full_name(), "test_catalog.t1");
// Perform the update
ICEBERG_UNWRAP_OR_FAIL(auto update_properties, staged_table->NewUpdateProperties());
EXPECT_THAT(update_properties->Set("property1", "value1").Commit(), IsOk());
auto res1 = staged_table->Commit();
EXPECT_THAT(res1, IsOk());
auto created_table = res1.value();
EXPECT_EQ("t1", created_table->name().name);
EXPECT_EQ(created_table->full_name(), "test_catalog.t1");
EXPECT_EQ("value1", created_table->metadata()->properties.Get(
TableProperties::Entry<std::string>("property1", "")));
// Staged already exist table
auto res = catalog_->StageCreateTable(table_ident, schema, spec, sort_order,
GenerateTestTableLocation(table_ident.name), {});
EXPECT_THAT(res, IsError(ErrorKind::kAlreadyExists));
// Stage create ok but commit already exist
TableIdentifier table_ident2{.ns = {}, .name = "t2"};
ICEBERG_UNWRAP_OR_FAIL(
auto staged_table2,
catalog_->StageCreateTable(table_ident2, schema, spec, sort_order,
GenerateTestTableLocation(table_ident2.name), {}));
ICEBERG_UNWRAP_OR_FAIL(
auto created_table2,
catalog_->CreateTable(table_ident2, schema, spec, sort_order,
GenerateTestTableLocation(table_ident2.name), {}));
auto commit_res = staged_table2->Commit();
EXPECT_THAT(commit_res, IsError(ErrorKind::kAlreadyExists));
}
TEST_F(InMemoryCatalogTest, DropTable) {
TableIdentifier tableIdent{.ns = {}, .name = "t1"};
auto result = catalog_->DropTable(tableIdent, false);
EXPECT_THAT(result, IsOk());
}
TEST_F(InMemoryCatalogTest, RenameTable) {
// Create a table first.
TableIdentifier ident{.ns = {}, .name = "t1"};
auto schema = std::make_shared<Schema>(
std::vector<SchemaField>{SchemaField::MakeRequired(1, "x", int64())},
/*schema_id=*/1);
auto spec = PartitionSpec::Unpartitioned();
auto sort_order = SortOrder::Unsorted();
ICEBERG_UNWRAP_OR_FAIL(
auto table, catalog_->CreateTable(ident, schema, spec, sort_order,
GenerateTestTableLocation(ident.name), {}));
// Rename to itself is a no-op.
EXPECT_THAT(catalog_->RenameTable(ident, ident), IsOk());
// Rename non-existent source table.
TableIdentifier nonexist{.ns = {}, .name = "nonexist"};
EXPECT_THAT(catalog_->RenameTable(nonexist, ident), IsError(ErrorKind::kNoSuchTable));
// Rename to an existing destination table.
TableIdentifier ident2{.ns = {}, .name = "t2"};
ICEBERG_UNWRAP_OR_FAIL(
auto table2, catalog_->CreateTable(ident2, schema, spec, sort_order,
GenerateTestTableLocation(ident2.name), {}));
EXPECT_THAT(catalog_->RenameTable(ident, ident2), IsError(ErrorKind::kAlreadyExists));
// Drop ident2 to clear the destination, then rename.
EXPECT_THAT(catalog_->DropTable(ident2, /*purge=*/false), IsOk());
// Rename ident -> ident2.
TableIdentifier renamed{.ns = {}, .name = "t2"};
EXPECT_THAT(catalog_->RenameTable(ident, renamed), IsOk());
EXPECT_THAT(catalog_->TableExists(ident), HasValue(::testing::Eq(false)));
EXPECT_THAT(catalog_->TableExists(renamed), HasValue(::testing::Eq(true)));
// Load the renamed table to verify it's intact.
auto loaded = catalog_->LoadTable(renamed);
ASSERT_THAT(loaded, IsOk());
EXPECT_EQ(loaded.value()->name().name, "t2");
EXPECT_EQ(loaded.value()->uuid(), table->uuid());
}
TEST_F(InMemoryCatalogTest, Namespace) {
Namespace ns{.levels = {"n1", "n2"}};
std::unordered_map<std::string, std::string> properties = {{"prop1", "val1"},
{"prop2", "val2"}};
EXPECT_THAT(catalog_->CreateNamespace(ns, properties), IsOk());
EXPECT_THAT(catalog_->CreateNamespace(ns, properties),
IsError(ErrorKind::kAlreadyExists));
EXPECT_THAT(catalog_->NamespaceExists(ns), HasValue(::testing::Eq(true)));
EXPECT_THAT(catalog_->NamespaceExists(Namespace{.levels = {"n1", "n3"}}),
HasValue(::testing::Eq(false)));
auto childNs = catalog_->ListNamespaces(Namespace{.levels = {"n1"}});
EXPECT_THAT(childNs, IsOk());
ASSERT_EQ(childNs->size(), 1U);
ASSERT_EQ(childNs->at(0).levels.size(), 2U);
ASSERT_EQ(childNs->at(0).levels.at(1), "n2");
auto propsRs = catalog_->GetNamespaceProperties(ns);
EXPECT_THAT(propsRs, IsOk());
ASSERT_EQ(propsRs->size(), 2U);
ASSERT_EQ(propsRs.value().at("prop1"), "val1");
ASSERT_EQ(propsRs.value().at("prop2"), "val2");
EXPECT_THAT(catalog_->UpdateNamespaceProperties(
ns, {{"prop2", "val2-updated"}, {"prop3", "val3"}}, {"prop1"}),
IsOk());
propsRs = catalog_->GetNamespaceProperties(ns);
EXPECT_THAT(propsRs, IsOk());
ASSERT_EQ(propsRs->size(), 2U);
ASSERT_EQ(propsRs.value().at("prop2"), "val2-updated");
ASSERT_EQ(propsRs.value().at("prop3"), "val3");
}
} // namespace iceberg