Skip to content
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ set(EXTENSION_SOURCES
src/function/iceberg_scalar_functions.cpp
src/function/metadata/iceberg_column_stats.cpp
src/function/metadata/iceberg_load_table_response.cpp
src/function/metadata/iceberg_view_metadata.cpp
src/function/metadata/iceberg_metadata.cpp
src/function/metadata/iceberg_partition_stats.cpp
src/function/metadata/iceberg_snapshots.cpp
Expand Down
119 changes: 119 additions & 0 deletions src/catalog/rest/api/catalog_api.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,125 @@ rest_api_objects::LoadTableResult IRCAPI::CommitNewTable(ClientContext &context,
}
}

// ─── View operations ─────────────────────────────────────────────────────────

vector<rest_api_objects::TableIdentifier> IRCAPI::GetViews(ClientContext &context, IcebergCatalog &catalog,
const IcebergSchemaEntry &schema) {
vector<rest_api_objects::TableIdentifier> all_identifiers;
string page_token;

do {
auto url_builder = catalog.GetBaseUrl();
url_builder.AddPrefixComponent(catalog.prefix, catalog.prefix_is_one_component);
url_builder.AddPathComponent(IRCPathComponent::RegularComponent("namespaces"));
url_builder.AddPathComponent(IRCPathComponent::NamespaceComponent(schema.namespace_items));
url_builder.AddPathComponent(IRCPathComponent::RegularComponent("views"));
if (!page_token.empty()) {
url_builder.SetParam("pageToken", IRCPathComponent::RegularComponent(page_token));
}

HTTPHeaders headers(*context.db);
auto response = catalog.auth_handler->Request(RequestType::GET_REQUEST, context, url_builder, headers);
if (!response->Success()) {
if (response->status == HTTPStatusCode::Forbidden_403 ||
response->status == HTTPStatusCode::Unauthorized_401 ||
response->status == HTTPStatusCode::NotFound_404) {
DUCKDB_LOG_WARNING(context, "GET %s returned status code %s", url_builder.GetURLEncoded(),
EnumUtil::ToString(response->status));
return all_identifiers;
}
auto url = url_builder.GetURLEncoded();
ThrowException(url, *response, "GET");
}

auto doc = ICUtils::APIResultToDoc(response->body);
auto *root = yyjson_doc_get_root(doc.get());
auto list_response = rest_api_objects::ListTablesResponse::FromJSON(root);

if (list_response.has_identifiers) {
all_identifiers.insert(all_identifiers.end(), std::make_move_iterator(list_response.identifiers.begin()),
std::make_move_iterator(list_response.identifiers.end()));
}

if (list_response.has_next_page_token) {
page_token = list_response.next_page_token.value;
} else {
page_token.clear();
}
} while (!page_token.empty());

return all_identifiers;
}

APIResult<unique_ptr<const rest_api_objects::LoadViewResult>> IRCAPI::GetView(ClientContext &context,
IcebergCatalog &catalog,
const IcebergSchemaEntry &schema,
const string &view_name) {
auto ret = APIResult<unique_ptr<const rest_api_objects::LoadViewResult>>();

auto url_builder = catalog.GetBaseUrl();
url_builder.AddPrefixComponent(catalog.prefix, catalog.prefix_is_one_component);
url_builder.AddPathComponent(IRCPathComponent::RegularComponent("namespaces"));
url_builder.AddPathComponent(IRCPathComponent::NamespaceComponent(schema.namespace_items));
url_builder.AddPathComponent(IRCPathComponent::RegularComponent("views"));
url_builder.AddPathComponent(IRCPathComponent::RegularComponent(view_name));

HTTPHeaders headers(*context.db);
auto response = catalog.auth_handler->Request(RequestType::GET_REQUEST, context, url_builder, headers);
if (response->status != HTTPStatusCode::OK_200) {
std::unique_ptr<yyjson_doc, YyjsonDocDeleter> out_doc;
yyjson_val *error_obj = ICUtils::GetErrorMessage(response->body, out_doc);
if (error_obj == nullptr) {
throw InvalidConfigurationException(response->body);
}
ret.has_error = true;
ret.status_ = response->status;
ret.error_ = rest_api_objects::IcebergErrorResponse::FromJSON(error_obj);
return ret;
}
ret.has_error = false;
auto doc = ICUtils::APIResultToDoc(response->body);
auto *root = yyjson_doc_get_root(doc.get());
ret.result_ = make_uniq<const rest_api_objects::LoadViewResult>(rest_api_objects::LoadViewResult::FromJSON(root));
return ret;
}

void IRCAPI::CommitNewView(ClientContext &context, IcebergCatalog &catalog, const IcebergSchemaEntry &schema,
const string &json_body) {
auto url_builder = catalog.GetBaseUrl();
url_builder.AddPrefixComponent(catalog.prefix, catalog.prefix_is_one_component);
url_builder.AddPathComponent(IRCPathComponent::RegularComponent("namespaces"));
url_builder.AddPathComponent(IRCPathComponent::NamespaceComponent(schema.namespace_items));
url_builder.AddPathComponent(IRCPathComponent::RegularComponent("views"));

HTTPHeaders headers(*context.db);
headers.Insert("Content-Type", "application/json");
auto response = catalog.auth_handler->Request(RequestType::POST_REQUEST, context, url_builder, headers, json_body);
if (response->status != HTTPStatusCode::OK_200) {
throw InvalidConfigurationException(
"Request to '%s' returned a non-200 status code (%s), with reason: %s, body: %s",
url_builder.GetURLEncoded(), EnumUtil::ToString(response->status), response->reason, response->body);
}
}

void IRCAPI::CommitViewDelete(ClientContext &context, IcebergCatalog &catalog, const vector<string> &schema,
const string &view_name) {
auto url_builder = catalog.GetBaseUrl();
url_builder.AddPrefixComponent(catalog.prefix, catalog.prefix_is_one_component);
url_builder.AddPathComponent(IRCPathComponent::RegularComponent("namespaces"));
url_builder.AddPathComponent(IRCPathComponent::NamespaceComponent(schema));
url_builder.AddPathComponent(IRCPathComponent::RegularComponent("views"));
url_builder.AddPathComponent(IRCPathComponent::RegularComponent(view_name));

HTTPHeaders headers(*context.db);
auto response = catalog.auth_handler->Request(RequestType::DELETE_REQUEST, context, url_builder, headers);
if (response->status != HTTPStatusCode::NoContent_204 && response->status != HTTPStatusCode::OK_200) {
throw InvalidConfigurationException(
"Request to '%s' returned a non-200 status code (%s), with reason: %s, body: %s",
url_builder.GetURLEncoded(), EnumUtil::ToString(response->status), response->reason, response->body);
}
}

rest_api_objects::CatalogConfig IRCAPI::GetCatalogConfig(ClientContext &context, IcebergCatalog &catalog,
const string &warehouse) {
auto url_builder = catalog.GetBaseUrl();
Expand Down
170 changes: 142 additions & 28 deletions src/catalog/rest/catalog_entry/schema/iceberg_schema_entry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
#include "duckdb/planner/expression_binder/table_function_binder.hpp"
#include "duckdb/execution/expression_executor.hpp"

#include "duckdb/parser/parsed_data/create_view_info.hpp"
#include "duckdb/catalog/catalog_entry/view_catalog_entry.hpp"

#include "catalog/rest/catalog_entry/table/iceberg_table_information.hpp"
#include "catalog/rest/iceberg_catalog.hpp"
#include "catalog/rest/transaction/iceberg_transaction.hpp"
Expand Down Expand Up @@ -106,30 +109,79 @@ void IcebergSchemaEntry::DropEntry(ClientContext &context, DropInfo &info) {
}

void IcebergSchemaEntry::DropEntry(ClientContext &context, DropInfo &info, bool delete_entry) {
auto table_name = info.name;
// find if info has a table name, if so look for it in
auto table_info_it = tables.GetEntries().find(table_name);
if (table_info_it == tables.GetEntries().end()) {
if (info.if_not_found == OnEntryNotFound::RETURN_NULL) {
auto entry_name = info.name;

// CASCADE is not part of the Iceberg REST spec — reject before any type-specific handling.
if (info.cascade) {
switch (info.type) {
case CatalogType::VIEW_ENTRY:
throw NotImplementedException("DROP VIEW <view_name> CASCADE is not supported for Iceberg views currently");
case CatalogType::TABLE_ENTRY:
throw NotImplementedException(
"DROP TABLE <table_name> CASCADE is not supported for Iceberg tables currently");
default:
throw NotImplementedException("DROP %s CASCADE is not supported for Iceberg currently",
CatalogTypeToString(info.type));
}
}

switch (info.type) {
case CatalogType::VIEW_ENTRY: {
auto &transaction = IcebergTransaction::Get(context, catalog).Cast<IcebergTransaction>();
auto view_key = IcebergTableInformation::GetTableKey(namespace_items, entry_name);

// Check if view was created in this transaction — just remove from created_views
if (transaction.created_views.erase(view_key) > 0) {
tables.InvalidateViewCache(entry_name);
return;
}
throw CatalogException("Table %s does not exist", table_name);

// Load view entries if not yet loaded, then check if view exists
tables.LoadViewEntries(context);
auto view_it = tables.GetViewEntries().find(entry_name);
if (view_it == tables.GetViewEntries().end()) {
if (info.if_not_found == OnEntryNotFound::RETURN_NULL) {
return;
}
throw CatalogException("View %s does not exist", entry_name);
}

if (delete_entry) {
tables.InvalidateViewCache(entry_name);
} else {
IcebergTransaction::DeletedViewInfo info;
info.namespace_items = namespace_items;
info.schema_name = name;
info.view_name = entry_name;
transaction.deleted_views.emplace(view_key, std::move(info));
}
return;
}
if (info.cascade) {
throw NotImplementedException("DROP TABLE <table_name> CASCADE is not supported for Iceberg tables currently");
case CatalogType::TABLE_ENTRY: {
auto table_info_it = tables.GetEntries().find(entry_name);
if (table_info_it == tables.GetEntries().end()) {
if (info.if_not_found == OnEntryNotFound::RETURN_NULL) {
return;
}
throw CatalogException("Table %s does not exist", entry_name);
}
if (delete_entry) {
// Remove the entry from the catalog
tables.GetEntriesMutable().erase(entry_name);
} else {
// Add the table to the transaction's deleted_tables
auto &transaction = IcebergTransaction::Get(context, catalog).Cast<IcebergTransaction>();
auto &table_info = table_info_it->second;
auto &table = transaction.DeleteTable(*table_info);
//! FIXME: what?
// must init schema versions after copy. Schema versions have a pointer to IcebergTableInformation
// if the IcebergTableInformation is moved, then the pointer is no longer valid.
table.InitSchemaVersions();
}
return;
}
if (delete_entry) {
// Remove the entry from the catalog
tables.GetEntriesMutable().erase(table_name);
} else {
// Add the table to the transaction's deleted_tables
auto &transaction = IcebergTransaction::Get(context, catalog).Cast<IcebergTransaction>();
auto &table_info = table_info_it->second;
auto &table = transaction.DeleteTable(*table_info);
//! FIXME: what?
// must init schema versions after copy. Schema versions have a pointer to IcebergTableInformation
// if the IcebergTableInformation is moved, then the pointer is no longer valid.
table.InitSchemaVersions();
default:
throw NotImplementedException("DropEntry not implemented for CatalogType '%s'", CatalogTypeToString(info.type));
}
}

Expand All @@ -153,12 +205,57 @@ optional_ptr<CatalogEntry> IcebergSchemaEntry::CreateIndex(CatalogTransaction tr
throw NotImplementedException("Create Index");
}

string GetUCCreateView(CreateViewInfo &info) {
throw NotImplementedException("Get Create View");
}

optional_ptr<CatalogEntry> IcebergSchemaEntry::CreateView(CatalogTransaction transaction, CreateViewInfo &info) {
throw NotImplementedException("Create View");
if (info.sql.empty() && !info.query) {
throw BinderException("Cannot create view in Iceberg without a query");
}
auto &context = transaction.GetContext();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check that binding_mode is set to "bind on create":

	//! Whether or not to bind the view on create
	CreateViewBindingMode binding_mode = CreateViewBindingMode::BIND_ON_CREATE;

Since we don't want to submit to the Catalog without verifying correctness

if (info.on_conflict == OnCreateConflict::REPLACE_ON_CONFLICT) {
throw NotImplementedException(
"CREATE OR REPLACE not supported in DuckDB-Iceberg. Please use separate Drop and Create Statements");
}

auto existing_entry = GetEntry(transaction, CatalogType::VIEW_ENTRY, info.view_name);
if (existing_entry) {
if (info.on_conflict == OnCreateConflict::IGNORE_ON_CONFLICT) {
// CREATE VIEW IF NOT EXISTS — view already exists, nothing to do.
return existing_entry;
}
// ERROR_ON_CONFLICT
throw CatalogException("View with name \"%s\" already exists", info.view_name);
}

// Generate default column names if the caller gave us types but no names.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do some more work here and potentially bind the sql query to make sure it is valid?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (info.names.empty() && !info.types.empty()) {
for (idx_t i = 0; i < info.types.size(); i++) {
if (i < info.aliases.size() && !info.aliases[i].empty()) {
info.names.push_back(info.aliases[i]);
} else {
info.names.push_back("col" + to_string(i));
}
}
}

// Track the view in the transaction
auto &iceberg_transaction = GetICTransaction(transaction);
auto view_key = IcebergTableInformation::GetTableKey(namespace_items, info.view_name);

auto view_info = unique_ptr_cast<CreateInfo, CreateViewInfo>(info.Copy());
// Preserve the SELECT SQL — ViewCatalogEntry::Initialize() will move the query out,
// so we need the SQL string available at commit time for the REST API request.
if (view_info->query) {
view_info->sql = view_info->query->ToString();
}

iceberg_transaction.created_views.erase(view_key);
iceberg_transaction.created_views.emplace(view_key, std::move(view_info));

// Invalidate stale caches
tables.InvalidateViewCache(info.view_name);

// Return a pointer to an owned entry (avoid dangling pointer)
return tables.GetViewEntry(transaction.GetContext(), info.view_name);
}

optional_ptr<CatalogEntry> IcebergSchemaEntry::CreateType(CatalogTransaction transaction, CreateTypeInfo &info) {
Expand Down Expand Up @@ -735,6 +832,10 @@ void IcebergSchemaEntry::Scan(ClientContext &context, CatalogType type,
if (!CatalogTypeIsSupported(type)) {
return;
}
if (type == CatalogType::VIEW_ENTRY) {
GetCatalogSet(type).ScanViews(context, callback);
return;
}
GetCatalogSet(type).Scan(context, callback);
}
void IcebergSchemaEntry::Scan(CatalogType type, const std::function<void(CatalogEntry &)> &callback) {
Expand All @@ -749,13 +850,26 @@ optional_ptr<CatalogEntry> IcebergSchemaEntry::LookupEntry(CatalogTransaction tr
}
auto &context = transaction.GetContext();
auto &ic_catalog = catalog.Cast<IcebergCatalog>();

// For VIEW_ENTRY, try the view path
if (type == CatalogType::VIEW_ENTRY) {
auto view_entry = GetCatalogSet(type).GetViewEntry(context, lookup_info.GetEntryName());
if (view_entry) {
return view_entry;
}
return nullptr;
}

// For TABLE_ENTRY, use the existing table lookup
auto table_entry = GetCatalogSet(type).GetEntry(context, lookup_info);
if (!table_entry) {
// Try looking up as a view — DuckDB sometimes looks up views as TABLE_ENTRY
auto view_entry = GetCatalogSet(type).GetViewEntry(context, lookup_info.GetEntryName());
if (view_entry) {
return view_entry;
}
// verify the schema exists
if (!IRCAPI::VerifySchemaExistence(context, ic_catalog, name)) {
// set exists to false here
// we would like to throw an error, but this code is also called when listing schemas,
// and throwing an error will abort the listing process.
exists = false;
return nullptr;
}
Expand Down
1 change: 1 addition & 0 deletions src/catalog/rest/iceberg_catalog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ void IcebergCatalog::AddDefaultSupportedEndpoints() {
supported_urls.insert("POST /v1/{prefix}/tables/rename");
// commit updates to multiple tables in an atomic transaction
supported_urls.insert("POST /v1/{prefix}/transactions/commit");
// Views aren't in the spec defaults; catalogs that support them advertise via /v1/config
}

void IcebergCatalog::AddS3TablesEndpoints() {
Expand Down
Loading