diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16f34da7..fa7a39f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ concurrency: env: # pg_client requires libpq (PostgreSQL) at build time and a running # PostgreSQL instance at test time (provided by run_pgembed_fixture.py). - EXTENSION_LIST: adbc;azure;delta;duckdb;fts;httpfs;iceberg;json;llm;neo4j;pg_client;postgres;sqlite;unity_catalog;vector;algo;gql + EXTENSION_LIST: adbc;azure;delta;duckdb;ducklake;fts;httpfs;iceberg;json;llm;neo4j;pg_client;postgres;sqlite;unity_catalog;vector;algo;gql jobs: # ───────────────────────────────────────────────────────────────── diff --git a/CMakeLists.txt b/CMakeLists.txt index f7f0860b..aebf4153 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -86,6 +86,7 @@ add_extension_if_enabled_and_skip_32bit("delta") add_extension_if_enabled_and_skip_32bit("iceberg") add_extension_if_enabled_and_skip_32bit("azure") add_extension_if_enabled_and_skip_32bit("unity_catalog") +add_extension_if_enabled_and_skip_32bit("ducklake") add_extension_if_enabled("json") add_extension_if_enabled("fts") add_extension_if_enabled("vector") diff --git a/ducklake/CMakeLists.txt b/ducklake/CMakeLists.txt new file mode 100644 index 00000000..07c7ba21 --- /dev/null +++ b/ducklake/CMakeLists.txt @@ -0,0 +1,37 @@ +if (WIN32 OR BUILD_STATIC_EXTENSION) + set(DuckDB_USE_STATIC_LIBS ON) +else () + set(DuckDB_USE_STATIC_LIBS OFF) +endif () +find_package(DuckDB REQUIRED) + +include_directories( + ${PROJECT_SOURCE_DIR}/src/include + ${CMAKE_BINARY_DIR}/src/include + src/include + ${PROJECT_SOURCE_DIR}/extension/duckdb/src/include + ${PROJECT_SOURCE_DIR}/extension/httpfs/src/include # For S3 configuration + ${DuckDB_INCLUDE_DIRS}) + +add_subdirectory(src/installer) +add_subdirectory(src/main) +add_subdirectory(src/connector) +add_subdirectory(src/storage) +add_subdirectory(src/options) + +build_extension_lib(${BUILD_STATIC_EXTENSION} "ducklake") + +target_link_libraries(lbug_${EXTENSION_LIB_NAME}_extension + PRIVATE + ${DuckDB_LIBRARIES}) + +if (NOT WIN32 AND NOT BUILD_STATIC_EXTENSION) + add_library(ducklake_loader + SHARED + ${PROJECT_SOURCE_DIR}/extension/duckdb/src/loader/duckdb_loader.cpp) + set_extension_properties(ducklake_loader ducklake_loader ducklake) +endif () + +if (APPLE AND NOT BUILD_STATIC_EXTENSION) + set_apple_dynamic_lookup(ducklake_loader) +endif () diff --git a/ducklake/src/connector/CMakeLists.txt b/ducklake/src/connector/CMakeLists.txt new file mode 100644 index 00000000..865df879 --- /dev/null +++ b/ducklake/src/connector/CMakeLists.txt @@ -0,0 +1,9 @@ +add_library(lbug_ducklake_connector + OBJECT + ducklake_connector.cpp + ${PROJECT_SOURCE_DIR}/extension/duckdb/src/connector/duckdb_connector.cpp + ${PROJECT_SOURCE_DIR}/extension/duckdb/src/connector/duckdb_result_converter.cpp) + +set(DUCKLAKE_EXTENSION_OBJECT_FILES + ${DUCKLAKE_EXTENSION_OBJECT_FILES} $ + PARENT_SCOPE) diff --git a/ducklake/src/connector/ducklake_connector.cpp b/ducklake/src/connector/ducklake_connector.cpp new file mode 100644 index 00000000..8fea8323 --- /dev/null +++ b/ducklake/src/connector/ducklake_connector.cpp @@ -0,0 +1,39 @@ +#include "connector/ducklake_connector.h" + +#include "options/ducklake_options.h" +#include + +namespace lbug { +namespace ducklake_extension { + +// Ensures a DuckDB extension is loaded. LOAD is attempted first so that an +// already-installed extension is reused as-is (no network, no repository +// origin check). INSTALL runs only as a fallback when the extension is +// missing, e.g. on a fresh machine. +static void ensureExtensionLoaded(const duckdb_extension::DuckDBConnector& connector, + const std::string& extensionName) { + try { + connector.executeQuery(std::format("load {};", extensionName)); + return; + } catch (const common::Exception&) { + // Not installed yet: fall through to INSTALL. + } + connector.executeQuery(std::format("install {};", extensionName)); + connector.executeQuery(std::format("load {};", extensionName)); +} + +void DuckLakeConnector::connect(const std::string& dbPath, const std::string& catalogName, + const std::string& /*schemaName*/, main::ClientContext* context) { + // Creates an in-memory DuckDB instance, loads the ducklake extension + // (plus httpfs for remote DATA_PATHs such as s3://), then attaches the + // DuckLake catalog inside the embedded instance so its tables can be + // referenced by fully qualified name (e.g. my_lake.main.person). + instance = std::make_unique(nullptr); + connection = std::make_unique(*instance); + ensureExtensionLoaded(*this, "ducklake"); + ensureExtensionLoaded(*this, "httpfs"); + executeQuery(DuckLakeAttachHelper::getAttachQuery(dbPath, catalogName, context)); +} + +} // namespace ducklake_extension +} // namespace lbug diff --git a/ducklake/src/include/connector/ducklake_connector.h b/ducklake/src/include/connector/ducklake_connector.h new file mode 100644 index 00000000..5055beba --- /dev/null +++ b/ducklake/src/include/connector/ducklake_connector.h @@ -0,0 +1,15 @@ +#pragma once + +#include "connector/duckdb_connector.h" + +namespace lbug { +namespace ducklake_extension { + +class DuckLakeConnector : public duckdb_extension::DuckDBConnector { +public: + void connect(const std::string& dbPath, const std::string& catalogName, + const std::string& schemaName, main::ClientContext* context) override; +}; + +} // namespace ducklake_extension +} // namespace lbug diff --git a/ducklake/src/include/main/ducklake_extension.h b/ducklake/src/include/main/ducklake_extension.h new file mode 100644 index 00000000..f42b4d07 --- /dev/null +++ b/ducklake/src/include/main/ducklake_extension.h @@ -0,0 +1,17 @@ +#pragma once + +#include "extension/extension.h" + +namespace lbug { +namespace ducklake_extension { + +class DuckLakeExtension final : public extension::Extension { +public: + static constexpr char EXTENSION_NAME[] = "DUCKLAKE"; + +public: + static void load(main::ClientContext* context); +}; + +} // namespace ducklake_extension +} // namespace lbug diff --git a/ducklake/src/include/options/ducklake_options.h b/ducklake/src/include/options/ducklake_options.h new file mode 100644 index 00000000..6d8d92a2 --- /dev/null +++ b/ducklake/src/include/options/ducklake_options.h @@ -0,0 +1,52 @@ +#pragma once + +#include "common/types/value/value.h" + +namespace lbug { +namespace main { +class Database; +class ClientContext; +} // namespace main + +namespace ducklake_extension { + +// Optional override for the DuckLake `DATA_PATH` ATTACH parameter. When set, +// it is appended to the `ATTACH 'ducklake:...'` statement executed on the +// embedded DuckDB instance as `(DATA_PATH '...', OVERRIDE_DATA_PATH true)`, +// so the given directory is used instead of the data path stored in the +// DuckLake metadata for the current connection (the stored value is left +// untouched). Empty (the default) leaves the stored path untouched, which is +// the common case for file-backed catalogs. The override form is required +// because DuckDB rejects a plain `DATA_PATH` that differs from the stored +// path, and it is what makes relocated catalogs (e.g. checked-in test data) +// queryable. +struct DuckLakeDataPath { + static constexpr const char* NAME = "ducklake_data_path"; + static constexpr common::LogicalTypeID TYPE = common::LogicalTypeID::STRING; + static common::Value getDefaultValue() { return common::Value{std::string()}; } +}; + +// Optional read-only flag, forwarded as `READ_ONLY` to the embedded `ATTACH`. +struct DuckLakeReadOnly { + static constexpr const char* NAME = "ducklake_read_only"; + static constexpr common::LogicalTypeID TYPE = common::LogicalTypeID::BOOL; + static common::Value getDefaultValue() { return common::Value{false}; } +}; + +struct DuckLakeOptions { + static void registerExtensionOptions(main::Database* db); + static void setEnvValue(main::ClientContext* context); + static std::string getDataPath(main::ClientContext* context); + static bool getReadOnly(main::ClientContext* context); +}; + +// Translates the Ladybug ATTACH path and extension options into the +// `ATTACH 'ducklake:...' AS (...)` statement executed on the embedded +// DuckDB instance. +struct DuckLakeAttachHelper { + static std::string getAttachQuery(const std::string& dbPath, const std::string& catalogAlias, + main::ClientContext* context); +}; + +} // namespace ducklake_extension +} // namespace lbug diff --git a/ducklake/src/include/storage/ducklake_storage.h b/ducklake/src/include/storage/ducklake_storage.h new file mode 100644 index 00000000..5f53eb5b --- /dev/null +++ b/ducklake/src/include/storage/ducklake_storage.h @@ -0,0 +1,25 @@ +#pragma once + +#include "storage/storage_extension.h" + +namespace lbug { +namespace main { +class Database; +} // namespace main + +namespace ducklake_extension { + +class DuckLakeStorageExtension final : public storage::StorageExtension { +public: + static constexpr const char* DB_TYPE = "DUCKLAKE"; + + // DuckLake catalogs live in DuckDB, whose default schema is `main`. + static constexpr const char* DEFAULT_SCHEMA_NAME = "main"; + + explicit DuckLakeStorageExtension(main::Database& database); + + bool canHandleDB(std::string dbType) const override; +}; + +} // namespace ducklake_extension +} // namespace lbug diff --git a/ducklake/src/installer/CMakeLists.txt b/ducklake/src/installer/CMakeLists.txt new file mode 100644 index 00000000..ef5e2524 --- /dev/null +++ b/ducklake/src/installer/CMakeLists.txt @@ -0,0 +1,11 @@ +if (NOT WIN32) + add_library(ducklake_installer + SHARED + ducklake_install_func.cpp + ${PROJECT_SOURCE_DIR}/extension/duckdb/src/installer/duckdb_installer.cpp) + set_extension_properties(ducklake_installer ducklake_installer ducklake) +endif () + +if (APPLE) + set_apple_dynamic_lookup(ducklake_installer) +endif () diff --git a/ducklake/src/installer/ducklake_install_func.cpp b/ducklake/src/installer/ducklake_install_func.cpp new file mode 100644 index 00000000..77ee9728 --- /dev/null +++ b/ducklake/src/installer/ducklake_install_func.cpp @@ -0,0 +1,16 @@ +#include "installer/duckdb_installer.h" + +extern "C" { +// Because we link against the static library on windows, we implicitly inherit LBUG_STATIC_DEFINE, +// which cancels out any exporting, so we can't use LBUG_API. +#if defined(_WIN32) +#define INIT_EXPORT __declspec(dllexport) +#else +#define INIT_EXPORT __attribute__((visibility("default"))) +#endif +INIT_EXPORT void install(const std::string& repo, lbug::main::ClientContext& context) { + lbug::extension::InstallExtensionInfo info{"ducklake", repo, false /* forceInstall */}; + lbug::duckdb_extension::DuckDBInstaller installer{info, context}; + installer.install(); +} +} diff --git a/ducklake/src/main/CMakeLists.txt b/ducklake/src/main/CMakeLists.txt new file mode 100644 index 00000000..eebaf4aa --- /dev/null +++ b/ducklake/src/main/CMakeLists.txt @@ -0,0 +1,7 @@ +add_library(ducklake_extension_main + OBJECT + ducklake_extension.cpp) + +set(DUCKLAKE_EXTENSION_OBJECT_FILES + ${DUCKLAKE_EXTENSION_OBJECT_FILES} $ + PARENT_SCOPE) diff --git a/ducklake/src/main/ducklake_extension.cpp b/ducklake/src/main/ducklake_extension.cpp new file mode 100644 index 00000000..90f5a29d --- /dev/null +++ b/ducklake/src/main/ducklake_extension.cpp @@ -0,0 +1,38 @@ + +#include "main/ducklake_extension.h" + +#include "main/client_context.h" +#include "options/ducklake_options.h" +#include "storage/ducklake_storage.h" + +namespace lbug { +namespace ducklake_extension { + +void DuckLakeExtension::load(main::ClientContext* context) { + auto& db = *context->getDatabase(); + db.registerStorageExtension(EXTENSION_NAME, std::make_unique(db)); + DuckLakeOptions::registerExtensionOptions(&db); + DuckLakeOptions::setEnvValue(context); +} + +} // namespace ducklake_extension +} // namespace lbug + +#if defined(BUILD_DYNAMIC_LOAD) +extern "C" { +// Because we link against the static library on windows, we implicitly inherit LBUG_STATIC_DEFINE, +// which cancels out any exporting, so we can't use LBUG_API. +#if defined(_WIN32) +#define INIT_EXPORT __declspec(dllexport) +#else +#define INIT_EXPORT __attribute__((visibility("default"))) +#endif +INIT_EXPORT void init(lbug::main::ClientContext* context) { + lbug::ducklake_extension::DuckLakeExtension::load(context); +} + +INIT_EXPORT const char* name() { + return lbug::ducklake_extension::DuckLakeExtension::EXTENSION_NAME; +} +} +#endif diff --git a/ducklake/src/options/CMakeLists.txt b/ducklake/src/options/CMakeLists.txt new file mode 100644 index 00000000..f74972ca --- /dev/null +++ b/ducklake/src/options/CMakeLists.txt @@ -0,0 +1,7 @@ +add_library(lbug_ducklake_options + OBJECT + ducklake_options.cpp) + +set(DUCKLAKE_EXTENSION_OBJECT_FILES + ${DUCKLAKE_EXTENSION_OBJECT_FILES} $ + PARENT_SCOPE) diff --git a/ducklake/src/options/ducklake_options.cpp b/ducklake/src/options/ducklake_options.cpp new file mode 100644 index 00000000..538f6a6b --- /dev/null +++ b/ducklake/src/options/ducklake_options.cpp @@ -0,0 +1,97 @@ +#include "options/ducklake_options.h" + +#include "common/string_utils.h" +#include "extension/extension.h" +#include "main/client_context.h" +#include "main/database.h" +#include + +namespace lbug { +namespace ducklake_extension { + +using namespace common; + +namespace { + +std::string escapeSingleQuotes(std::string value) { + StringUtils::replaceAll(value, "'", "''"); + return value; +} + +// Reads an option value from the environment. Both the option name as-is and +// its upper-case form are accepted (e.g. `ducklake_data_path` or +// `DUCKLAKE_DATA_PATH`). +std::string getEnvOption(const char* name) { + auto value = main::ClientContext::getEnvVariable(name); + if (value.empty()) { + value = main::ClientContext::getEnvVariable(StringUtils::getUpper(std::string(name))); + } + return value; +} + +void setEnvOption(main::ClientContext* context, const char* name) { + auto value = getEnvOption(name); + if (!value.empty()) { + context->setExtensionOption(name, Value::createValue(value)); + } +} + +} // namespace + +void DuckLakeOptions::registerExtensionOptions(main::Database* db) { + ADD_EXTENSION_OPTION(DuckLakeDataPath); + ADD_EXTENSION_OPTION(DuckLakeReadOnly); +} + +void DuckLakeOptions::setEnvValue(main::ClientContext* context) { + setEnvOption(context, DuckLakeDataPath::NAME); + auto readOnly = getEnvOption(DuckLakeReadOnly::NAME); + if (!readOnly.empty()) { + StringUtils::toLower(readOnly); + context->setExtensionOption(DuckLakeReadOnly::NAME, + Value::createValue(readOnly == "true" || readOnly == "1")); + } +} + +std::string DuckLakeOptions::getDataPath(main::ClientContext* context) { + return context->getCurrentSetting(DuckLakeDataPath::NAME).toString(); +} + +bool DuckLakeOptions::getReadOnly(main::ClientContext* context) { + auto value = context->getCurrentSetting(DuckLakeReadOnly::NAME); + return value.getDataType().getLogicalTypeID() == LogicalTypeID::BOOL && value.getValue(); +} + +std::string DuckLakeAttachHelper::getAttachQuery(const std::string& dbPath, + const std::string& catalogAlias, main::ClientContext* context) { + // Normalize the ATTACH target. An empty path means "read the + // configuration from the default (unnamed) TYPE ducklake secret" + // (`ATTACH 'ducklake:'`). A path that already carries the `ducklake:` + // scheme (e.g. `ducklake:postgres:dbname=...`) is used as-is; anything + // else is treated as the metadata catalog path and prefixed. + std::string target; + if (dbPath.empty()) { + target = "ducklake:"; + } else if (dbPath.rfind("ducklake:", 0) == 0) { + target = dbPath; + } else { + target = "ducklake:" + dbPath; + } + std::string options; + auto dataPath = DuckLakeOptions::getDataPath(context); + if (!dataPath.empty()) { + // OVERRIDE_DATA_PATH is required: DuckDB rejects a DATA_PATH that + // differs from the catalog's stored path without it. + options += + std::format(", DATA_PATH '{}', OVERRIDE_DATA_PATH true", escapeSingleQuotes(dataPath)); + } + if (DuckLakeOptions::getReadOnly(context)) { + options += ", READ_ONLY"; + } + // Strip the leading ", " when options are present. + auto params = options.empty() ? std::string{} : " (" + options.substr(2) + ")"; + return std::format("ATTACH '{}' AS {}{};", escapeSingleQuotes(target), catalogAlias, params); +} + +} // namespace ducklake_extension +} // namespace lbug diff --git a/ducklake/src/storage/CMakeLists.txt b/ducklake/src/storage/CMakeLists.txt new file mode 100644 index 00000000..41002c82 --- /dev/null +++ b/ducklake/src/storage/CMakeLists.txt @@ -0,0 +1,13 @@ +add_library(lbug_ducklake_storage + OBJECT + ducklake_storage.cpp + ${PROJECT_SOURCE_DIR}/extension/duckdb/src/catalog/duckdb_catalog.cpp + ${PROJECT_SOURCE_DIR}/extension/duckdb/src/function/duckdb_scan.cpp + ${PROJECT_SOURCE_DIR}/extension/duckdb/src/function/clear_cache.cpp + ${PROJECT_SOURCE_DIR}/extension/duckdb/src/connector/duckdb_type_converter.cpp + ${PROJECT_SOURCE_DIR}/extension/duckdb/src/catalog/duckdb_table_catalog_entry.cpp +) + +set(DUCKLAKE_EXTENSION_OBJECT_FILES + ${DUCKLAKE_EXTENSION_OBJECT_FILES} $ + PARENT_SCOPE) diff --git a/ducklake/src/storage/ducklake_storage.cpp b/ducklake/src/storage/ducklake_storage.cpp new file mode 100644 index 00000000..3d417612 --- /dev/null +++ b/ducklake/src/storage/ducklake_storage.cpp @@ -0,0 +1,46 @@ +#include "storage/ducklake_storage.h" + +#include "catalog/duckdb_catalog.h" +#include "common/string_utils.h" +#include "connector/ducklake_connector.h" +#include "extension/extension.h" +#include "function/clear_cache.h" +#include "storage/attached_duckdb_database.h" + +namespace lbug { +namespace ducklake_extension { + +std::unique_ptr attachDuckLake(std::string dbName, std::string dbPath, + main::ClientContext* clientContext, const binder::AttachOption& attachOption) { + if (dbName == "") { + dbName = dbPath; + } + auto connector = std::make_unique(); + // The DuckDB-side catalog is attached AS dbName, so DuckDBCatalog must use + // dbName (not dbPath) as its catalog name: information_schema lookups and + // generated SQL reference the attached alias. The catalog is constructed + // before connecting so that ATTACH option validation happens before any + // filesystem or network I/O. + auto catalog = std::make_unique(dbPath, dbName, + DuckLakeStorageExtension::DEFAULT_SCHEMA_NAME, clientContext, *connector, attachOption, + dbName); + connector->connect(dbPath, dbName, DuckLakeStorageExtension::DEFAULT_SCHEMA_NAME, + clientContext); + catalog->init(); + return std::make_unique(dbName, + DuckLakeStorageExtension::DB_TYPE, std::move(catalog), std::move(connector)); +} + +DuckLakeStorageExtension::DuckLakeStorageExtension(main::Database& database) + : StorageExtension{attachDuckLake} { + extension::ExtensionUtils::addStandaloneTableFunc( + database); +} + +bool DuckLakeStorageExtension::canHandleDB(std::string dbType_) const { + common::StringUtils::toUpper(dbType_); + return dbType_ == DB_TYPE; +} + +} // namespace ducklake_extension +} // namespace lbug diff --git a/ducklake/test/test_files/ducklake.test b/ducklake/test/test_files/ducklake.test new file mode 100644 index 00000000..d8a770b8 --- /dev/null +++ b/ducklake/test/test_files/ducklake.test @@ -0,0 +1,61 @@ +-DATASET CSV empty + +-- + +-CASE DuckLakeScan +-SKIP_FSM_LEAK_CHECK +-LOAD_DYNAMIC_EXTENSION ducklake +# Data files are referenced by relative paths, so the checked-in catalog is +# relocatable as long as users.ducklake.files/ sits next to users.ducklake. +# Setting ducklake_data_path exercises the override plumbing (it is forwarded +# as DATA_PATH + OVERRIDE_DATA_PATH to the embedded DuckDB ATTACH). +-STATEMENT CALL ducklake_data_path='${LBUG_ROOT_DIRECTORY}/extension/ducklake/test/test_files/users.ducklake.files/'; +---- ok +-STATEMENT ATTACH '${LBUG_ROOT_DIRECTORY}/extension/ducklake/test/test_files/users.ducklake' as dl (dbtype ducklake); +---- 1 +Attached database successfully. +-STATEMENT LOAD FROM dl.person RETURN name ORDER BY name; +---- 3 +Alice +Bob +Carol +-STATEMENT LOAD FROM dl.knows RETURN src_id, dst_id ORDER BY src_id, dst_id; +---- 4 +0|1 +0|2 +1|0 +2|1 +-STATEMENT MATCH (p:dl.person) RETURN count(*); +---- 1 +3 +-STATEMENT MATCH (p:dl.person) WHERE p.name = 'Alice' RETURN count(*); +---- 1 +1 +-STATEMENT CALL TABLE_INFO('dl.person') RETURN *; +---- ok +-STATEMENT DETACH dl; +---- ok + +# DuckLake stores no PRIMARY KEY / FOREIGN KEY constraints, so rel tables are +# not auto-detected from duckdb_constraints(). Declare the rel explicitly with +# storage pointing at the DuckLake table; the src/dst column convention then +# lets the foreign join pushdown fire (same as SQL catalogs without FKs). +-CASE DuckLakeJoinPushdown +-SKIP_FSM_LEAK_CHECK +-LOAD_DYNAMIC_EXTENSION ducklake +-STATEMENT CALL ducklake_data_path='${LBUG_ROOT_DIRECTORY}/extension/ducklake/test/test_files/users.ducklake.files/'; +---- ok +-STATEMENT ATTACH '${LBUG_ROOT_DIRECTORY}/extension/ducklake/test/test_files/users.ducklake' as dl (dbtype ducklake); +---- 1 +Attached database successfully. +-STATEMENT CREATE REL TABLE knows_rel (FROM dl.person TO dl.person) WITH (storage = 'dl.knows'); +---- 1 +Table knows_rel has been created. +-LOG JoinPushdownOverDuckLake +-STATEMENT MATCH (a:dl.person)-[b:knows_rel]->(c:dl.person) RETURN count(*); +---- 1 +4 +-STATEMENT EXPLAIN MATCH (a:dl.person)-[b:knows_rel]->(c:dl.person) RETURN count(*); +---- ok +-STATEMENT DETACH dl; +---- ok diff --git a/ducklake/test/test_files/users.ducklake b/ducklake/test/test_files/users.ducklake new file mode 100644 index 00000000..88be8a97 Binary files /dev/null and b/ducklake/test/test_files/users.ducklake differ diff --git a/extension_config.cmake b/extension_config.cmake index f77bf2dc..63d2a8aa 100644 --- a/extension_config.cmake +++ b/extension_config.cmake @@ -1,4 +1,4 @@ -set(EXTENSION_LIST adbc azure delta duckdb fts httpfs iceberg json llm pg_client postgres sqlite unity_catalog vector neo4j algo gql) +set(EXTENSION_LIST adbc azure delta duckdb ducklake fts httpfs iceberg json llm pg_client postgres sqlite unity_catalog vector neo4j algo gql) #set(EXTENSION_STATIC_LINK_LIST fts) foreach(extension IN LISTS EXTENSION_STATIC_LINK_LIST)