乐于分享
好东西不私藏

DuckDB 插件开发实战:Mac 版 Everything–表函数三板斧——Bind、Init、Execute 与扩展入口

DuckDB 插件开发实战:Mac 版 Everything–表函数三板斧——Bind、Init、Execute 与扩展入口

表函数三板斧——Bind、Init、Execute 与扩展入口

—— 让 SQL 能查外部数据源

本文是《DuckDB 插件开发实战:Mac 版 Everything》系列的第 3 篇。上一篇从零走了一遍 apfs 插件的构建过程,这篇来拆解 DuckDB 表函数的核心模型——三个回调函数是怎么配合的,以及扩展入口文件怎么把它们组装起来。

版本基线:本文所有代码、行数、类名、宏呈现的形式都基于 duckdb-apfs 仓库 duckdb/ submodule 锁定的 DuckDB v1.5.2STANDARD_VECTOR_SIZE = 2048duckdb/src/include/duckdb/common/vector_size.hpp:16)。本文引用的 apfs_extension.cpp = 180 行apfs_fts_scanner.cpp = 184 行LoadInternal = 27 行apfs_extension.hpp = 22 行 均为作者环境实测,读者环境会有细微差异。仅基本功能实现,实际运行体验仍与Windows版本有不小差距,源码见参考资料。


标量函数 vs 表函数

DuckDB 扩展能注册两种函数:

-- 标量函数:输入一行,输出一个值
SELECT apfs_has_changes('/tmp'TIMESTAMP'2026-04-01');
-- → true

-- 表函数:输出一张表,可以在 FROM 里用
SELECT*FROM apfs_scan('/tmp''fts');
-- → 多行多列结果
类型
注册方式
返回值
SQL 用法
标量函数
ScalarFunction
单个值
SELECT func()
表函数
TableFunction
多行多列
SELECT * FROM func()

apfs 扩展注册了 2 个表函数(apfs_scanapfs_search)和 1 个标量函数(apfs_has_changes)。这篇重点讲表函数——因为它是”把外部数据源接入 SQL”的核心。


表函数的三个回调

每个 DuckDB 表函数由三个 C++ 回调函数组成:

③ Execute② Init① BindSQL 引擎③ Execute② Init① BindSQL 引擎调用 1 次,返回 BindData调用 1 次,打开数据源,返回 GlobalStateloop[每批最多 2048 行]返回 0 行 → 扫描结束解析参数,定义输出列传递 BindData传递 GlobalState请求数据返回 DataChunk

Bind:告诉 DuckDB “我要返回什么”

// src/apfs_extension.cpp
static unique_ptr<FunctionData> ApfsScanBindInternal(
    ClientContext &context,
    TableFunctionBindInput &input,
    vector<LogicalType> &return_types,
    vector<string> &names,
const string &mode_str)

{
// 1. 读取参数
auto path = input.inputs[0].GetValue<string>();

// 2. 定义输出列(11 列)
DefineApfsColumns(return_types, names);

// 3. 返回 BindData(传给后续阶段)
if (mode == "spotlight") {
auto bind_data = make_uniq<ApfsSpotlightScanBindData>();
        bind_data->root_path = path;
return std::move(bind_data);
    } else {
auto bind_data = make_uniq<ApfsFtsScanBindData>();
        bind_data->root_path = path;
return std::move(bind_data);
    }
}

Bind 做三件事:

  1. 1. 解析参数——从 input.inputs[] 读取用户传的 path 和 mode
  2. 2. 定义输出列——往 return_types 和 names 里塞列定义
  3. 3. 返回 BindData——一个自定义结构体,把参数传给 Init 和 Execute

BindData 就是一个继承自 TableFunctionData 的结构体,你想存什么就存什么:

// src/include/apfs_fts_scanner.hpp
structApfsFtsScanBindData : public TableFunctionData {
    string root_path;  // 就一个字段:要扫描的路径
};

Init:准备好扫描资源

// src/apfs_fts_scanner.cpp
unique_ptr<GlobalTableFunctionState> ApfsFtsScanInit(
    ClientContext &context,
    TableFunctionInitInput &input)

{
auto &bind_data = input.bind_data->Cast<ApfsFtsScanBindData>();
auto state = make_uniq<ApfsFtsScanGlobalState>();

// 验证路径存在
structstat path_stat;
if (stat(bind_data.root_path.c_str(), &path_stat) != 0) {
throwIOException("path '%s' does not exist", bind_data.root_path);
    }

// 打开 fts 遍历句柄
char *path_argv[2];
    path_argv[0] = const_cast<char *>(bind_data.root_path.c_str());
    path_argv[1] = nullptr;
    FTS *fts = fts_open(path_argv, FTS_LOGICAL | FTS_NOCHDIR, nullptr);
    state->fts_handle = fts;

return std::move(state);
}

Init 做一件事:打开数据源。对 fts 模式就是 fts_open(),对 spotlight 模式就是 MDQueryCreate() + MDQueryExecute()

GlobalState 也是一个自定义结构体,保存扫描过程中的状态:

structApfsFtsScanGlobalState : public GlobalTableFunctionState {
void *fts_handle = nullptr;  // fts 句柄
bool finished = false;       // 是否扫描完毕

    ~ApfsFtsScanGlobalState() {
if (fts_handle) { fts_close(static_cast<FTS *>(fts_handle)); }
    }
};

注意析构函数里关闭了 fts_handle——资源在 GlobalState 的生命周期内管理,DuckDB 会自动销毁它。

Execute:批量填充数据

// src/apfs_fts_scanner.cpp
voidApfsFtsScanFunction(
    ClientContext &context,
    TableFunctionInput &data,
    DataChunk &output)

{
auto &state = data.global_state->Cast<ApfsFtsScanGlobalState>();

if (state.finished) {
        output.SetCardinality(0);  // 返回 0 行 → DuckDB 停止调用
return;
    }

auto fts = static_cast<FTS *>(state.fts_handle);
idx_t count = 0;

// 一次填充最多 STANDARD_VECTOR_SIZE(2048)行
while (count < STANDARD_VECTOR_SIZE) {
        FTSENT *entry = fts_read(fts);
if (!entry) { state.finished = truebreak; }

if (entry->fts_info == FTS_DP) continue;  // 跳过后序目录
if (entry->fts_info == FTS_ERR) continue;  // 跳过错误项

// 填充 11 列
        output.SetValue(0, count, Value(SanitizeUtf8(entry->fts_path)));
        output.SetValue(1, count, Value(SanitizeUtf8(entry->fts_name)));
        output.SetValue(2, count, Value(ApfsGetFileExtension(name)));
        output.SetValue(3, count, Value::BIGINT(st->st_size));
// ... 其余 7 列类似

        count++;
    }

    output.SetCardinality(count);  // 告诉 DuckDB 这次返回了几行
}

Execute 的核心逻辑:

  1. 1. 循环读取数据源(fts_read / MDQueryGetResultAtIndex)
  2. 2. 填充 DataChunk——output.SetValue(col, row, value)
  3. 3. 每批最多 2048 行STANDARD_VECTOR_SIZE,DuckDB 向量化执行的标准批次大小)
  4. 4. 返回 0 行表示结束——DuckDB 不再调用 Execute
ExecuteDuckDB 执行引擎ExecuteDuckDB 执行引擎请求数据返回 2048 行处理(WHERE/ORDER BY/...)请求数据返回 2048 行处理请求数据返回 1523 行(最后一批不足 2048)请求数据返回 0 行 → 扫描结束

DataChunk 怎么填

DataChunk 是 DuckDB 向量化执行引擎的基本数据单位——一批最多 2048 行、多列的数据块。

apfs 扩展用的是最简单的逐行设值方式:

output.SetValue(列号, 行号, 值);
列号
列名
值的构造方式
0
path
Value(string)
1
name
Value(string)
2
extension
Value(string)
3
size
Value::BIGINT(int64)
4
file_type
Value(string)
5
modified_at
Value::TIMESTAMP(timestamp_t)
6
created_at
Value::TIMESTAMP(timestamp_t)
7
accessed_at
Value::TIMESTAMP(timestamp_t)
8
permissions
Value(string)
9
owner
Value(string)
10
depth
Value::INTEGER(int32)

SetValue 是最直观但不是最高性能的写法(每次调用有类型检查开销)。对于 apfs 这种 I/O bound 的场景够用了。高性能扩展会用 FlatVector::GetData<T>() 直接写底层 buffer。


注册表函数:把三个回调组装起来

// src/apfs_extension.cpp
staticvoidLoadInternal(ExtensionLoader &loader){

// apfs_scan: 用 TableFunctionSet 注册两个重载
TableFunctionSet scan_set("apfs_scan");

TableFunction scan_one_arg("apfs_scan",
        {LogicalType::VARCHAR},           // 参数类型列表
        ApfsScanFunction,                  // Execute 回调
        ApfsScanBindOneArg,               // Bind 回调
        ApfsScanInit)
;                    // Init 回调
    scan_one_arg.named_parameters["since"] = LogicalType::TIMESTAMP;
    scan_set.AddFunction(scan_one_arg);

// 第二个重载:apfs_scan(path, mode)
TableFunction scan_two_args("apfs_scan",
        {LogicalType::VARCHAR, LogicalType::VARCHAR},
        ApfsScanFunction, ApfsScanBindTwoArgs, ApfsScanInit)
;
    scan_two_args.named_parameters["since"] = LogicalType::TIMESTAMP;
    scan_set.AddFunction(scan_two_args);

    loader.RegisterFunction(scan_set);
}

TableFunction 构造函数的参数顺序:名字 → 参数类型 → Execute → Bind → Init

named_parameters 是可选参数——用户可以写 apfs_scan('/', 'spotlight', since=TIMESTAMP '2026-04-01'),在 Bind 阶段通过 input.named_parameters.find("since") 读取。


Extension 基类

每个 DuckDB 扩展都要继承 Extension 基类(src/include/apfs_extension.hpp,22 行):

classApfsExtension : public Extension {
public:
voidLoad(ExtensionLoader &loader)override;   // 注册所有函数
std::string Name()override;                    // 返回 "apfs"
std::string Version()constoverride;           // 返回版本号
};
方法
调用时机
做什么
Load()
扩展被加载时(启动或 LOAD 命令)
注册 SQL 函数
Name()
DuckDB 需要显示扩展名时
返回 "apfs"
Version()
DuckDB 需要版本信息时
返回编译时宏 EXT_VERSION_APFS

ScalarFunction 的注册方式

标量函数不需要 Bind/Init/Execute 三板斧,只要一个执行函数:

ScalarFunction CreateApfsHasChangesFunction(){
ScalarFunction func("apfs_has_changes",
        {LogicalType::VARCHAR, LogicalType::TIMESTAMP},  // 参数
        LogicalType::BOOLEAN,                             // 返回类型
        ApfsHasChangesFunction)
;                          // 执行函数
    func.stability = FunctionStability::VOLATILE;
return func;
}

FunctionStability::VOLATILE 告诉优化器”这个函数每次调用结果可能不同”——因为文件系统在变化。如果不设这个,DuckDB 可能会缓存结果。


统一入口 + dynamic_cast 分发

apfs_scan 的两种模式共享同一个 Init 和 Execute。怎么区分走哪个引擎?

spotlight

fts

SpotlightScanBindData

FtsScanBindData

Bind 阶段
mode 参数
创建 ApfsSpotlightScanBindData
创建 ApfsFtsScanBindData
Init / Execute 阶段
dynamic_cast 检查 BindData 类型
调用 Spotlight 引擎
调用 fts 引擎
// Bind 阶段:根据 mode 参数创建不同类型的 BindData
static unique_ptr<FunctionData> ApfsScanBindInternal(..., const string &mode_str){
if (mode == "spotlight") {
returnmake_uniq<ApfsSpotlightScanBindData>();
    } else {
returnmake_uniq<ApfsFtsScanBindData>();
    }
}

// Execute 阶段:通过 dynamic_cast 判断类型并分发
staticvoidApfsScanFunction(...){
auto &bind_data = data.bind_data->Cast<TableFunctionData>();
if (dynamic_cast<const ApfsSpotlightScanBindData *>(&bind_data)) {
ApfsSpotlightScanFunction(context, data, output);
    } else {
ApfsFtsScanFunction(context, data, output);
    }
}

模式选择发生在 Bind,分发发生在 Init 和 Execute。 BindData 的类型就是”标签”。


C 入口点

文件最后 6 行,动态加载的关键:

extern"C" {
DUCKDB_CPP_EXTENSION_ENTRY(apfs, loader) {
    duckdb::LoadInternal(loader);
}
}

DUCKDB_CPP_EXTENSION_ENTRY(apfs, loader) 宏展开为 void apfs_duckdb_cpp_init(duckdb::ExtensionLoader &loader) 函数。动态加载时 DuckDB 执行 dlsym(handle, "apfs_duckdb_cpp_init") 找到这个符号并调用。静态链接时通过编译期注册表直接调用,不需要 dlsym


扩展加载的完整流程

静态链接build/release/duckdb

动态加载LOAD 'apfs.duckdb_extension'

用户执行 SELECT * FROM apfs_scan(...)
加载方式
启动时已通过编译期注册表调用 LoadInternal()
apfs_scan/apfs_search/apfs_has_changes 已注册
直接执行
检查版本兼容性
检查签名或 allow_unsigned_extensions=true
dlopen() 加载共享库
dlsym('apfs_duckdb_cpp_init')
调用入口函数 → LoadInternal(loader)

11 列 Schema 的集中定义

三个函数共享相同的列定义,集中在一个辅助函数里:

staticvoidDefineApfsColumns(vector<LogicalType> &return_types, vector<string> &names){
    names.emplace_back("path");        return_types.emplace_back(LogicalType::VARCHAR);
    names.emplace_back("name");        return_types.emplace_back(LogicalType::VARCHAR);
    names.emplace_back("extension");   return_types.emplace_back(LogicalType::VARCHAR);
    names.emplace_back("size");        return_types.emplace_back(LogicalType::BIGINT);
    names.emplace_back("file_type");   return_types.emplace_back(LogicalType::VARCHAR);
    names.emplace_back("modified_at"); return_types.emplace_back(LogicalType::TIMESTAMP);
    names.emplace_back("created_at");  return_types.emplace_back(LogicalType::TIMESTAMP);
    names.emplace_back("accessed_at"); return_types.emplace_back(LogicalType::TIMESTAMP);
    names.emplace_back("permissions"); return_types.emplace_back(LogicalType::VARCHAR);
    names.emplace_back("owner");       return_types.emplace_back(LogicalType::VARCHAR);
    names.emplace_back("depth");       return_types.emplace_back(LogicalType::INTEGER);
}

集中定义有两个好处:修改一处全部生效,三个函数保证 schema 一致。


生命周期总结

mermaid-diagram-2026-05-02-000729.png

你不需要管内存释放——把资源放在 GlobalState 的成员变量里,析构函数自动清理。这是 DuckDB 扩展开发最优雅的地方之一。


总结

  1. 1. 表函数 = Bind + Init + Execute 三个回调
  2. 2. Bind 解析参数 + 定义输出列,返回 BindData
  3. 3. Init 打开数据源,返回 GlobalState
  4. 4. Execute 循环填充 DataChunk(每批最多 2048 行),返回 0 行表示结束
  5. 5. 资源管理靠 GlobalState 析构函数——RAII 风格,不用手动释放
  6. 6. 扩展入口文件 apfs_extension.cpp 共 180 行,其中 LoadInternal 完整 27 行,是所有函数的注册中心
  7. 7. DUCKDB_CPP_EXTENSION_ENTRY 是动态加载的 C 入口点

下一篇深入两个引擎的实现细节:fts(3) 怎么遍历文件系统,Spotlight 怎么查索引。


参考资料

  • • mac-everything
  • • duckdb-apfs
  • • DuckDB 官方文档 – Table Functions
  • • duckdb-apfs 源码:src/apfs_extension.cpp(180 行,Bind/Init/Execute 注册和分发)
  • • duckdb-apfs 源码:src/apfs_fts_scanner.cpp(fts 模式的完整 Bind/Init/Execute)
  • • duckdb-apfs 源码:src/include/apfs_fts_scanner.hpp(BindData 和 GlobalState 定义)
  • • duckdb-apfs 源码:src/include/apfs_extension.hpp(22 行,Extension 子类定义)