blob: 41953b14dd6bda395513e32ca1c6a8efc50a27a0 [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/sql/sql_catalog.h"
#include <sqlite3.h>
#include <filesystem>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include "iceberg/arrow/arrow_io_internal.h"
#include "iceberg/catalog/sql/catalog_store.h"
#include "iceberg/catalog/sql/connection_uri_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/test/matchers.h"
#include "iceberg/type.h"
namespace iceberg::sql {
namespace {
std::filesystem::path SqliteTestPath(std::string_view name) {
return std::filesystem::temp_directory_path() /
(std::string("iceberg_sql_catalog_") + std::string(name) + ".db");
}
void ExecSql(const std::filesystem::path& path, std::string_view sql) {
sqlite3* raw_db = nullptr;
ASSERT_EQ(sqlite3_open(path.string().c_str(), &raw_db), SQLITE_OK);
std::unique_ptr<sqlite3, decltype(&sqlite3_close)> db(raw_db, sqlite3_close);
char* error_message = nullptr;
const int result =
sqlite3_exec(db.get(), std::string(sql).c_str(), nullptr, nullptr, &error_message);
std::unique_ptr<char, decltype(&sqlite3_free)> error(error_message, sqlite3_free);
ASSERT_EQ(result, SQLITE_OK) << (error ? error.get() : "");
}
std::optional<std::string> QuerySingleText(const std::filesystem::path& path,
std::string_view sql) {
sqlite3* raw_db = nullptr;
if (sqlite3_open(path.string().c_str(), &raw_db) != SQLITE_OK) {
ADD_FAILURE() << "failed to open sqlite database";
return std::nullopt;
}
std::unique_ptr<sqlite3, decltype(&sqlite3_close)> db(raw_db, sqlite3_close);
sqlite3_stmt* raw_stmt = nullptr;
if (sqlite3_prepare_v2(db.get(), std::string(sql).c_str(), -1, &raw_stmt, nullptr) !=
SQLITE_OK) {
ADD_FAILURE() << sqlite3_errmsg(db.get());
return std::nullopt;
}
std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)> stmt(raw_stmt,
sqlite3_finalize);
const int step = sqlite3_step(stmt.get());
if (step != SQLITE_ROW) {
ADD_FAILURE() << "query returned no row";
return std::nullopt;
}
const auto* text = sqlite3_column_text(stmt.get(), 0);
if (text == nullptr) {
return std::nullopt;
}
return std::string(reinterpret_cast<const char*>(text));
}
} // namespace
class SqlCatalogTest : public ::testing::Test {
protected:
void SetUp() override {
file_io_ = arrow::ArrowFileSystemFileIO::MakeLocalFileIO();
auto store =
MakeSqliteCatalogStore({.catalog_name = "test_catalog", .uri = ":memory:"});
ASSERT_THAT(store, IsOk());
SqlCatalogConfig config{
.name = "test_catalog",
.uri = ":memory:",
.warehouse_location = WarehouseLocation(),
};
auto catalog = SqlCatalog::Make(config, file_io_, store.value());
ASSERT_THAT(catalog, IsOk());
store_ = store.value();
catalog_ = catalog.value();
}
void TearDown() override {
std::error_code ec;
std::filesystem::remove_all(WarehouseDir(), ec);
}
std::string WarehouseDir() const {
return (std::filesystem::temp_directory_path() / "iceberg_sql_catalog_test").string();
}
std::string WarehouseLocation() const {
return std::filesystem::path(WarehouseDir()).generic_string();
}
// Returns a table base location whose metadata directory already exists, since
// the local Arrow FileIO does not create parent directories on write.
std::string MakeTableLocation(const std::string& name) const {
auto location = std::filesystem::path(WarehouseDir()) / name;
std::filesystem::create_directories(location / "metadata");
return location.string();
}
std::shared_ptr<Schema> MakeSchema() const {
return std::make_shared<Schema>(
std::vector<SchemaField>{SchemaField::MakeRequired(1, "x", int64())},
/*schema_id=*/1);
}
std::shared_ptr<FileIO> file_io_;
std::shared_ptr<CatalogStore> store_;
std::shared_ptr<SqlCatalog> catalog_;
};
TEST(ConnectionUriTest, ParsesUserHostPortAndDatabase) {
auto parsed = ParseConnectionUri("postgresql://alice:secret@db.example:5432/prod");
ASSERT_THAT(parsed, IsOk());
EXPECT_EQ(parsed->user, "alice");
EXPECT_EQ(parsed->password, "secret");
EXPECT_EQ(parsed->host, "db.example");
EXPECT_EQ(parsed->port, 5432);
EXPECT_EQ(parsed->database, "prod");
}
TEST(ConnectionUriTest, RejectsInvalidPort) {
EXPECT_THAT(ParseConnectionUri("mysql://db.example:/prod"),
IsError(ErrorKind::kInvalidArgument));
EXPECT_THAT(ParseConnectionUri("mysql://db.example:abc/prod"),
IsError(ErrorKind::kInvalidArgument));
EXPECT_THAT(ParseConnectionUri("mysql://db.example:4294967296/prod"),
IsError(ErrorKind::kInvalidArgument));
}
TEST_F(SqlCatalogTest, CatalogName) { EXPECT_EQ(catalog_->name(), "test_catalog"); }
TEST_F(SqlCatalogTest, NamespaceLifecycle) {
Namespace ns{{"db"}};
EXPECT_THAT(catalog_->NamespaceExists(ns), HasValue(::testing::Eq(false)));
EXPECT_THAT(catalog_->CreateNamespace(ns, {{"owner", "alice"}}), IsOk());
EXPECT_THAT(catalog_->NamespaceExists(ns), HasValue(::testing::Eq(true)));
// Duplicate creation fails.
EXPECT_THAT(catalog_->CreateNamespace(ns, {}), IsError(ErrorKind::kAlreadyExists));
// Properties round-trip, sentinel hidden.
auto props = catalog_->GetNamespaceProperties(ns);
ASSERT_THAT(props, IsOk());
EXPECT_EQ(props->size(), 1);
EXPECT_EQ(props->at("owner"), "alice");
// Update + remove.
EXPECT_THAT(
catalog_->UpdateNamespaceProperties(ns, {{"owner", "bob"}, {"team", "x"}}, {}),
IsOk());
EXPECT_THAT(catalog_->UpdateNamespaceProperties(ns, {}, {"team"}), IsOk());
props = catalog_->GetNamespaceProperties(ns);
ASSERT_THAT(props, IsOk());
EXPECT_EQ(props->at("owner"), "bob");
EXPECT_FALSE(props->contains("team"));
EXPECT_THAT(catalog_->DropNamespace(ns), IsOk());
EXPECT_THAT(catalog_->NamespaceExists(ns), HasValue(::testing::Eq(false)));
}
TEST_F(SqlCatalogTest, ListNamespacesReturnsImmediateChildren) {
ASSERT_THAT(catalog_->CreateNamespace(Namespace{{"a"}}, {}), IsOk());
ASSERT_THAT(catalog_->CreateNamespace(Namespace{{"a", "b"}}, {}), IsOk());
ASSERT_THAT(catalog_->CreateNamespace(Namespace{{"a", "c"}}, {}), IsOk());
auto children = catalog_->ListNamespaces(Namespace{{"a"}});
ASSERT_THAT(children, IsOk());
EXPECT_THAT(*children, ::testing::UnorderedElementsAre(Namespace{{"a", "b"}},
Namespace{{"a", "c"}}));
// Parent with children cannot be dropped.
EXPECT_THAT(catalog_->DropNamespace(Namespace{{"a"}}),
IsError(ErrorKind::kNamespaceNotEmpty));
}
TEST_F(SqlCatalogTest, TableLifecycle) {
Namespace ns{{"db"}};
ASSERT_THAT(catalog_->CreateNamespace(ns, {}), IsOk());
TableIdentifier ident{.ns = ns, .name = "t1"};
EXPECT_THAT(catalog_->TableExists(ident), HasValue(::testing::Eq(false)));
const std::string location = MakeTableLocation("t1");
auto created =
catalog_->CreateTable(ident, MakeSchema(), PartitionSpec::Unpartitioned(),
SortOrder::Unsorted(), location, {});
ASSERT_THAT(created, IsOk());
EXPECT_THAT(catalog_->TableExists(ident), HasValue(::testing::Eq(true)));
// Duplicate create fails.
EXPECT_THAT(catalog_->CreateTable(ident, MakeSchema(), PartitionSpec::Unpartitioned(),
SortOrder::Unsorted(), location, {}),
IsError(ErrorKind::kAlreadyExists));
// List + load.
auto tables = catalog_->ListTables(ns);
ASSERT_THAT(tables, IsOk());
ASSERT_EQ(tables->size(), 1);
EXPECT_EQ((*tables)[0].name, "t1");
auto loaded = catalog_->LoadTable(ident);
ASSERT_THAT(loaded, IsOk());
// Rename.
TableIdentifier renamed{.ns = 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)));
EXPECT_THAT(catalog_->RenameTable(renamed, renamed), IsOk());
// Drop.
EXPECT_THAT(catalog_->DropTable(renamed, /*purge=*/false), IsOk());
EXPECT_THAT(catalog_->TableExists(renamed), HasValue(::testing::Eq(false)));
EXPECT_THAT(catalog_->DropTable(renamed, false), IsError(ErrorKind::kNoSuchTable));
}
TEST_F(SqlCatalogTest, CreateTableRequiresNamespace) {
TableIdentifier ident{.ns = Namespace{{"missing"}}, .name = "t1"};
EXPECT_THAT(catalog_->CreateTable(ident, MakeSchema(), PartitionSpec::Unpartitioned(),
SortOrder::Unsorted(), "", {}),
IsError(ErrorKind::kNoSuchNamespace));
}
TEST_F(SqlCatalogTest, CreateTableUsesNamespaceLocationWhenLocationIsMissing) {
Namespace ns{{"db"}};
const auto namespace_location = std::filesystem::path(WarehouseDir()) / "custom_db";
ASSERT_THAT(
catalog_->CreateNamespace(ns, {{"location", namespace_location.generic_string()}}),
IsOk());
const auto table_location = namespace_location / "t1";
std::filesystem::create_directories(table_location / "metadata");
TableIdentifier ident{.ns = ns, .name = "t1"};
auto created = catalog_->CreateTable(
ident, MakeSchema(), PartitionSpec::Unpartitioned(), SortOrder::Unsorted(), "", {});
ASSERT_THAT(created, IsOk());
EXPECT_EQ((*created)->location(), table_location.generic_string());
}
TEST_F(SqlCatalogTest, CreateTableFallsBackToWarehouseNamespacePath) {
Namespace ns{{"a", "b"}};
ASSERT_THAT(catalog_->CreateNamespace(ns, {}), IsOk());
const auto table_location = std::filesystem::path(WarehouseDir()) / "a" / "b" / "t1";
std::filesystem::create_directories(table_location / "metadata");
TableIdentifier ident{.ns = ns, .name = "t1"};
auto created = catalog_->CreateTable(
ident, MakeSchema(), PartitionSpec::Unpartitioned(), SortOrder::Unsorted(), "", {});
ASSERT_THAT(created, IsOk());
EXPECT_EQ((*created)->location(), table_location.generic_string());
}
TEST_F(SqlCatalogTest, StageCreateTableRequiresNamespace) {
TableIdentifier ident{.ns = Namespace{{"missing"}}, .name = "t1"};
EXPECT_THAT(
catalog_->StageCreateTable(ident, MakeSchema(), PartitionSpec::Unpartitioned(),
SortOrder::Unsorted(), "", {}),
IsError(ErrorKind::kNoSuchNamespace));
}
TEST_F(SqlCatalogTest, NamespaceLevelsCannotContainDots) {
EXPECT_THAT(catalog_->CreateNamespace(Namespace{{"a.b"}}, {}),
IsError(ErrorKind::kInvalidArgument));
EXPECT_THAT(catalog_->ListNamespaces(Namespace{{"a.b"}}),
IsError(ErrorKind::kInvalidArgument));
EXPECT_THAT(
catalog_->TableExists(TableIdentifier{.ns = Namespace{{"a.b"}}, .name = "t"}),
IsError(ErrorKind::kInvalidArgument));
}
TEST_F(SqlCatalogTest, ListNamespacesIncludesTableOnlyNamespaces) {
ASSERT_THAT(store_->InsertTable("orphan", "t", "/loc/v1.metadata.json"), IsOk());
auto namespaces = catalog_->ListNamespaces(Namespace{});
ASSERT_THAT(namespaces, IsOk());
EXPECT_THAT(*namespaces, ::testing::Contains(Namespace{{"orphan"}}));
}
TEST_F(SqlCatalogTest, CustomStoreInjection) {
// The same catalog logic works against any CatalogStore; here we just verify the
// injection entry point with the built-in SQLite store.
auto store = MakeSqliteCatalogStore({.catalog_name = "custom", .uri = ":memory:"});
ASSERT_THAT(store, IsOk());
SqlCatalogConfig config{.name = "custom", .warehouse_location = WarehouseDir()};
auto catalog = SqlCatalog::Make(config, file_io_, store.value());
ASSERT_THAT(catalog, IsOk());
EXPECT_EQ(catalog.value()->name(), "custom");
EXPECT_THAT(catalog.value()->CreateNamespace(Namespace{{"db"}}, {}), IsOk());
}
TEST(SqliteCatalogStoreTest, DuplicateKeyMapsToAlreadyExists) {
auto store_result = MakeSqliteCatalogStore({.catalog_name = "c", .uri = ":memory:"});
ASSERT_THAT(store_result, IsOk());
auto store = store_result.value();
ASSERT_THAT(store->Initialize(), IsOk());
EXPECT_THAT(store->InsertTable("db", "t", "/loc/v1.metadata.json"), IsOk());
EXPECT_THAT(store->InsertTable("db", "t", "/loc/v2.metadata.json"),
IsError(ErrorKind::kAlreadyExists));
}
TEST(SqliteCatalogStoreTest, TableRowsUseRecordTypeAndReadLegacyNullRows) {
const auto db_path = SqliteTestPath("record_type");
std::error_code ec;
std::filesystem::remove(db_path, ec);
auto store_result =
MakeSqliteCatalogStore({.catalog_name = "c", .uri = db_path.string()});
ASSERT_THAT(store_result, IsOk());
auto store = store_result.value();
ASSERT_THAT(store->Initialize(), IsOk());
ASSERT_THAT(store->InsertTable("db", "t", "/loc/v1.metadata.json"), IsOk());
auto record_type = QuerySingleText(
db_path,
"SELECT iceberg_type FROM iceberg_tables WHERE catalog_name = 'c' AND "
"table_namespace = 'db' AND table_name = 't'");
ASSERT_TRUE(record_type.has_value());
EXPECT_EQ(*record_type, "TABLE");
ExecSql(db_path,
"INSERT INTO iceberg_tables (catalog_name, table_namespace, table_name, "
"metadata_location, previous_metadata_location, iceberg_type) "
"VALUES ('c', 'legacy', 't', '/loc/legacy.metadata.json', NULL, NULL)");
EXPECT_THAT(store->TableExists("legacy", "t"), HasValue(::testing::Eq(true)));
auto table_names = store->ListTableNames("legacy");
ASSERT_THAT(table_names, IsOk());
EXPECT_THAT(*table_names, ::testing::ElementsAre("t"));
std::filesystem::remove(db_path, ec);
}
TEST(SqliteCatalogStoreTest, RunInTransactionRollsBackOnError) {
auto store_result = MakeSqliteCatalogStore({.catalog_name = "tx", .uri = ":memory:"});
ASSERT_THAT(store_result, IsOk());
auto store = store_result.value();
ASSERT_THAT(store->Initialize(), IsOk());
EXPECT_THAT(store->RunInTransaction([&] {
ICEBERG_RETURN_UNEXPECTED(
store->InsertNamespaceProperty("db", "owner", std::string_view{"alice"}));
return InvalidArgument("abort transaction");
}),
IsError(ErrorKind::kInvalidArgument));
auto props = store->GetNamespaceProperties("db");
ASSERT_THAT(props, IsOk());
EXPECT_TRUE(props->empty());
}
// SQLite ignores `max_connections` and always uses a single connection; setting
// it greater than 1 must be harmless and transactions must still roll back.
TEST(SqliteCatalogStoreTest, MaxConnectionsIgnoredAndTransactionRollsBack) {
const auto db_path = SqliteTestPath("max_connections");
std::error_code ec;
std::filesystem::remove(db_path, ec);
auto store_result = MakeSqliteCatalogStore(
{.catalog_name = "pool", .uri = db_path.string(), .max_connections = 2});
ASSERT_THAT(store_result, IsOk());
auto store = store_result.value();
ASSERT_THAT(store->Initialize(), IsOk());
EXPECT_THAT(store->RunInTransaction([&] {
ICEBERG_RETURN_UNEXPECTED(
store->InsertNamespaceProperty("db", "owner", std::string_view{"alice"}));
return InvalidArgument("abort transaction");
}),
IsError(ErrorKind::kInvalidArgument));
auto props = store->GetNamespaceProperties("db");
ASSERT_THAT(props, IsOk());
EXPECT_TRUE(props->empty());
std::filesystem::remove(db_path, ec);
}
TEST(SqliteCatalogStoreTest, UpdateTableMetadataLocationReturnsAffectedRows) {
auto store_result = MakeSqliteCatalogStore({.catalog_name = "cas", .uri = ":memory:"});
ASSERT_THAT(store_result, IsOk());
auto store = store_result.value();
ASSERT_THAT(store->Initialize(), IsOk());
ASSERT_THAT(store->InsertTable("db", "t", "/loc/v1.metadata.json"), IsOk());
EXPECT_THAT(store->UpdateTableMetadataLocation("db", "t", "/loc/v2.metadata.json",
"/loc/v1.metadata.json",
"/loc/stale.metadata.json"),
HasValue(::testing::Eq(0)));
EXPECT_THAT(store->UpdateTableMetadataLocation("db", "t", "/loc/v2.metadata.json",
"/loc/v1.metadata.json",
"/loc/v1.metadata.json"),
HasValue(::testing::Eq(1)));
}
} // namespace iceberg::sql