| /* |
| * 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 |