创建项目
动手写代码之前有三个决定:以什么为起点、用什么写、怎么构建。
从模板起步
下面两份模板是同一套骨架 —— DuckDB 的 CI、extension-ci-tools、cdylib crate root、sqllogictest
目录。差别只在于「为 duckfn 接好了多少」。
duckfn 模板
shijianjs/duckfn-extension-template
是本项目自己的模板:官方骨架,但 duckfn 相关的固定动作已经做完了。
git clone https://github.com/shijianjs/duckfn-extension-template my_ext
cd my_ext
rm -rf .git && git init # 可选:丢掉模板的历史,从头开始自己的仓库
just rename my_ext
just rename(即 scripts/rename.sh)就是这份模板存在的理由。扩展名必须在好几处同时一致,漏掉一处
不是编译错误,而是一个加载不起来的扩展 —— DuckDB 按文件名推出要查找的入口点符号,对不上只会让 LOAD
失败,不会给出有用的提示。脚本一次改齐这些地方:
Cargo.toml里的[package] name与[[example]] name;Makefile里的EXTENSION_NAME;- 传给
duckfn_entrypoint!(…)的名字,它会成为入口点符号my_ext_init_c_api; Justfile与 CI 工作流里的extension_name;- README 与文档站里的路径示例,以及
Cargo.lock里那一条。
脚本末尾会打印剩下需要人工过一遍的事情,主要就是把两个示例函数换成自己的 API。其余部分都已经在了:
依赖列表里带着 duckfn 且开了 loadable-extension、src/lib.rs 与 src/wasm_lib.rs 声明同一组
mod、一个标量示例函数与一个聚合示例函数(各配 sqllogictest 文件)、Justfile、docs/ 下的中英双语
Docusaurus 站点(可以删,仓库里没有别的东西依赖它)、发版脚本,以及 community-extension/ 下注册社区
扩展要提交的文件。开发循环写在它的 README.md 与 DEVELOPMENT.md 里,它的 AGENTS.md 也就是
让 AI 助手写代码那一节说的那一份。
官方模板
duckdb/extension-template-rs 是 DuckDB 官方的
Rust 扩展模板,也是上面两份模板的共同底座。想自己接线就从这里起步;但无论从哪份模板起步,都值得知道它
的存在 —— makefile 与 CI 最终都来自这里:
- 自带完整的 GitHub Actions 流水线:为 DuckDB 支持的每个平台构建并测试扩展,打版本 tag 时发布二进制。 这部分不需要自己写。
- 已经接好
extension-ci-tools,提供make configure/make debug/make release/make test—— 与 DuckDB 自身用的是同一套流程,也是 sqllogictest 运行器需要的流程。 - 你正在读的这个仓库就是一个换了函数的同类模板。
git clone --recurse-submodules https://github.com/duckdb/extension-template-rs my_ext
cd my_ext
extension-ci-tools 是 git submodule,所以克隆时要带 --recurse-submodules,或首次构建前先执行
git submodule update --init --recursive。
然后改掉模板里写死的部分 —— Makefile 里的 EXTENSION_NAME、要构建 WebAssembly 时的 [[example]]
目标、传给 duckfn_entrypoint! 的名字 —— 这正是 just rename 自动化的那张清单,另外还有它当时还不
知道的 Justfile 与 CI 条目。
无论从哪份模板起步,都别动这几个 crate root:src/lib.rs、src/wasm_lib.rs 与 src/bin/duckfn.rs
都指向同一个 src/extension/ 模块,这是整套目录结构唯一的规则 —— 为什么、以及破了这条规则会看到什么
error[E0583],见项目结构约定。
用 duckfn、quack-rs 写逻辑
duckfn 是本仓库提供的那一层:它建立在 quack-rs 之上,
用属性宏把普通 Rust 函数变成扩展函数。日常写法就是给函数加一个属性:
#[duck_scalar_function]
pub fn double_it(v: Option<i64>) -> DuckOptionResult<i64> {
Ok(v.map(|x| x * 2))
}
为什么不直接用官方的 duckdb crate?因为它的扩展 API 只覆盖两类函数 —— 标量函数走 vscalar
feature,表函数走 vtab feature,也就是这一行的全部能力:
duckdb = { version = "~1.10505.0", features = ["loadable-extension", "vscalar"] }
聚合函数、SQL 宏、replacement scan、类型转换、嵌套类型都没有对应的注册接口。duckfn 补上的正是
这部分:同一套属性宏同时覆盖标量函数、聚合函数、表函数、SQL 宏、replacement scan 与类型转换。
需要宏没有暴露的能力(手写 LogicalType、向量级操作、C API 的某个角落)时再落到 quack-rs
—— 它是对 DuckDB C API 覆盖最全、文档最完整的绑定;它本来就在你的依赖列表里。
同样功能的两种写法 把官方模板与 quack-rs 示例里的四个函数各写了两遍,
可以直观看到差别。
用官方工具链构建
日常开发直接用 DuckDB 自带的 extension-ci-tools makefile —— 它们与 CI 和 sqllogictest
运行器跑的是同一条流程:
make configure # 只需一次:创建测试运行器用的 Python venv
make debug # -> build/debug/my_ext.duckdb_extension
make debug 会构建 cdylib、追加扩展元数据,并把产物放到 DuckDB 期望的位置,所以你本地
加载的文件与测试运行器、CI 用的是同一个产物:
duckdb -unsigned -c "
LOAD './build/debug/my_ext.duckdb_extension';
SELECT double_it(21);
"
duckfn 模板用一份
Justfile 把这条流程
包了一层:just build(即 make configure + make debug)、just sql "SELECT …"、just repl、
just test、just ci-release,以及文档站与发版相关的 recipe。just rename 已经把
extension_name 改成与 duckfn_entrypoint! 一致。见快速开始。
Windows 上运行 make
make 流程在 Windows 上需要 Git Bash:make(以及调用它的 just recipe)要在 Git Bash 里运行,
而不是 PowerShell —— 见贡献指南。
让 AI 助手写代码
如果扩展交给 AI 助手来写,就在项目里放一份 AGENTS.md。duckfn 模板里已经有一份:
把两个占位符填上 —— 这个扩展做什么、duckfn 的 clone 在哪 —— 这一步就结束了。从别处起步的话,把那份
文件复制过去:AGENTS.md。
它要解决的问题是「依赖能带过去什么、带不过去什么」。cargo 会把 duckfn 与 duckfn-macro
解包到本地 registry,因此运行时与宏的实现 —— 也就是「某个属性收哪些参数、允许哪些返回形状」的
真相来源 —— 就在磁盘上,可以直接读。发布出去的 duckfn 包还不止这些:它一并带着 docs/docs/**
下的文档正文及其简体中文译文,以及示例扩展 —— test/extension/** 连同 test/sql/ 下的
sqllogictest 用例 —— 所以用户指南、示例页和一份完整可抄的扩展都能直接从解包后的 crate 里读,
确切清单见构建与发布。
包带不过去的是它们周围的那一整套仓库:issue 历史、CI 工作流、文档站自己的工具链,以及
AGENTS.md 这个文件本身。所以 AGENTS.md 把知识源指向本仓库的本地 clone,并明确要求助手查不到时
就停下来问,而不是凭印象编一个属性出来 —— 两个占位符里有一个就是它的路径。
凡是能从代码里读出来的(扩展名、crate 名、duckfn 版本)一律不抄进去,这样它就不会变成第二份会过期的 真相。