一、先搞懂核心概念
本篇教程想做的事情其实很简单:写一个C语言的自定义函数(UDF),这个函数能像SQL查询一样从PostgreSQL数据库里查数据,然后把查到的多行结果完整地返回出来。
- SPI(Server Programming Interface):就像C语言函数和PostgreSQL数据库之间的“桥梁”,让你的C代码能执行SQL查询、获取结果。
- SRF(Set-Returning Functions):直译是“返回集合的函数”,简单说就是能返回多行数据的函数(普通函数只能返回一个值,SRF能返回一张“小表”)。
- UDF:用户自定义函数,就是你自己写的、PostgreSQL能调用的函数。
SPI的完整说明请查阅官方文档:https://www.postgresql.org/docs/18/spi.html
二、从0到1写代码
第一步:准备头文件(抄就行)
首先要把PostgreSQL提供的“工具包”包含进来,这些头文件是写C语言UDF的基础,就像做饭要先准备厨具:
#include "postgres.h" // PostgreSQL核心头文件,必须有
#include "fmgr.h" // 函数管理相关,UDF必备
#include "funcapi.h" // SRF相关功能,返回多行数据靠它
#include "access/htup_details.h" // 处理数据行(元组)用的
#include "executor/spi.h" // SPI接口的核心头文件
#include "utils/builtins.h" // 一些基础工具函数
第二步:必加的“魔法语句”
这行代码是PostgreSQL识别C语言扩展的标志,少了它函数用不了,直接复制:
PG_MODULE_MAGIC;
第三步:声明函数信息
告诉PostgreSQL你的函数叫什么名字,版本是V1(固定写法):
PG_FUNCTION_INFO_V1(get_all_empsalary); // get_all_empsalary是你的函数名,可改
第四步:写核心函数(分两段理解)
函数整体结构是:第一次调用时初始化(查数据库、准备数据) + 每次调用返回一行数据,就像发传单:第一次先把所有传单准备好,之后每次发一张,发完为止。
完整函数代码(带大白话注释)
// 函数的标准声明,Datum是PostgreSQL里“任意数据类型”的统称
Datum
get_all_empsalary(PG_FUNCTION_ARGS)
{
// 1. 定义需要用到的变量(先记着,后面会用)
FuncCallContext *funcctx; // SRF的上下文,记录“发了多少张传单”
int call_cntr; // 已经返回了多少行数据
int max_calls; // 总共要返回多少行数据
AttInMetadata *attinmeta; // 数据类型转换的辅助工具
/* === 第一段:第一次调用时执行(准备所有“传单”)=== */
if (SRF_IS_FIRSTCALL()) // 判断是不是第一次调用这个函数
{
MemoryContext oldcontext; // 内存上下文,不用深究,照抄就行
TupleDesc tupdesc; // 描述“每行数据有哪些列”
SPIPlanPtr plan; // SPI的查询计划
int spi_rc; // SPI执行结果的返回码
// 初始化SRF上下文(固定写法)
funcctx = SRF_FIRSTCALL_INIT();
// 切换内存上下文(防止内存泄露,照抄)
oldcontext = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
// 1. 连接SPI“桥梁”(必须先连,才能查数据库)
if (SPI_connect() != SPI_OK_CONNECT)
elog(ERROR, "get_all_empsalary: SPI_connect failed"); // 连接失败就报错
// 2. 准备要执行的SQL查询(把你要查的SQL写在这里)
// 参数说明:SQL语句、参数个数(0表示无参数)、参数类型(NULL)
if ((plan = SPI_prepare("SELECT depname, empno, salary, enroll_date FROM empsalary ORDER BY empno",
0, NULL)) == NULL)
elog(ERROR, "get_all_empsalary: SPI_prepare failed"); // 准备SQL失败报错
// 3. 执行SQL查询(true表示要返回结果,0表示返回所有行)
spi_rc = SPI_execute_plan(plan, NULL, NULL, true, 0);
if (spi_rc != SPI_OK_SELECT)
elog(ERROR, "get_all_empsalary: SPI_execute_plan failed"); // 执行SQL失败报错
// 4. 检查有没有查到数据(没查到就直接结束)
if (SPI_processed == 0)
{
SPI_finish(); // 断开SPI连接
SRF_RETURN_DONE(funcctx); // 告诉PostgreSQL:没数据要返回
}
// 5. 准备返回数据的格式(描述每行有哪些列、什么类型)
tupdesc = SPI_tuptable->tupdesc; // 获取查询结果的列信息
tupdesc = CreateTupleDescCopy(tupdesc); // 复制一份,防止被销毁
attinmeta = TupleDescGetAttInMetadata(tupdesc); // 初始化类型转换工具
funcctx->attinmeta = attinmeta; // 保存到上下文,后面要用
// 6. 保存查询结果(把查到的所有数据存起来,后面慢慢返回)
funcctx->user_fctx = SPI_tuptable;
// 7. 记录总共要返回多少行数据(SPI_processed是查到的行数)
funcctx->max_calls = SPI_processed;
// 切回原来的内存上下文(固定写法)
MemoryContextSwitchTo(oldcontext);
}
/* === 第二段:每次调用都执行(发一张“传单”)=== */
funcctx = SRF_PERCALL_SETUP(); // 初始化每次调用的上下文(固定写法)
call_cntr = funcctx->call_cntr; // 获取已经返回的行数
max_calls = funcctx->max_calls; // 获取总共要返回的行数
attinmeta = funcctx->attinmeta; // 获取类型转换工具
if (call_cntr < max_calls) // 如果还有数据没返回
{
char **values; // 存储一行数据的所有列值
HeapTuple tuple; // 要返回的一行数据(元组)
// 取出之前保存的查询结果
SPITupleTable *tuptable = (SPITupleTable *) funcctx->user_fctx;
// 取出当前要返回的那一行数据
HeapTuple spi_tuple = tuptable->vals[call_cntr];
TupleDesc tupdesc = tuptable->tupdesc;
// 1. 分配内存,存储4列数据(你的SQL查了4列,所以是4)
values = (char **) palloc(4 * sizeof(char *));
// 2. 逐个取出列的值(数字是列的位置,从1开始)
values[0] = SPI_getvalue(spi_tuple, tupdesc, 1); // depname列
values[1] = SPI_getvalue(spi_tuple, tupdesc, 2); // empno列
values[2] = SPI_getvalue(spi_tuple, tupdesc, 3); // salary列
values[3] = SPI_getvalue(spi_tuple, tupdesc, 4); // enroll_date列
// 3. 把列值组装成PostgreSQL能识别的“行”(元组)
tuple = BuildTupleFromCStrings(attinmeta, values);
// 4. 释放临时内存(防止内存泄露)
pfree(values);
// 5. 返回这一行数据,同时记录“已经发了多少张传单”
SRF_RETURN_NEXT(funcctx, HeapTupleGetDatum(tuple));
}
else // 所有数据都返回完了
{
SPI_finish(); // 断开SPI连接(必须关,不然占资源)
SRF_RETURN_DONE(funcctx); // 告诉PostgreSQL:数据返回完了
}
}
第五步:编译C代码(这里依然使用CMake)
cmake_minimum_required(VERSION 3.25)
project(spi_demo C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_BUILD_TYPE debug)
list(APPEND flags "-fPIC")
find_program(PG_CONFIG pg_config REQUIRED)
execute_process(COMMAND ${PG_CONFIG} --includedir-server
OUTPUT_VARIABLE POSTGRESQL_INCLUDE_DIR
OUTPUT_STRIP_TRAILING_WHITESPACE)
execute_process(COMMAND ${PG_CONFIG} --libdir
OUTPUT_VARIABLE POSTGRESQL_LIB_DIR
OUTPUT_STRIP_TRAILING_WHITESPACE)
execute_process(COMMAND ${PG_CONFIG} --libs
OUTPUT_VARIABLE POSTGRESQL_LIBS
OUTPUT_STRIP_TRAILING_WHITESPACE)
add_library(spi_demo SHARED spi_demo.c)
# 设置生成的共享库名称,去掉lib前缀
set_target_properties(spi_demo PROPERTIES PREFIX "")
# 最终so文件名
set_target_properties(spi_demo PROPERTIES OUTPUT_NAME spi_demo)
# 安装目录
install(TARGETS spi_demo LIBRARY DESTINATION ${POSTGRESQL_LIB_DIR})
target_compile_options(spi_demo PUBLIC "-fPIC" "-g3" "-O0")
target_link_options(spi_demo PUBLIC "-shared")
target_include_directories(spi_demo PUBLIC ${POSTGRESQL_INCLUDE_DIR})
target_link_directories(spi_demo PUBLIC ${POSTGRESQL_LIB_DIR})
cmake . -DCMAKE_EXPORT_COMPILE_COMMANDS=1
make
sudo cp spi_demo.so /usr/local/pgsql18/lib/spi_demo.1.0.so
第六步:在数据库里创建函数(SQL语句)
就像给数据库“注册”你的C函数,让它知道怎么调用:
-- 第一步:定义返回类型(和你查询的列对应,列名、类型要一致)
CREATE TYPE empsalary_type AS (
depname text, -- 对应SQL里的depname列
empno bigint, -- 对应empno列
salary int, -- 对应salary列
enroll_date date -- 对应enroll_date列
);
-- 第二步:创建函数
CREATE OR REPLACE FUNCTION get_all_empsalary()
RETURNS SETOF empsalary_type -- SETOF表示返回多行,类型是上面定义的empsalary_type
AS '$libdir/spi_demo.1.0', 'get_all_empsalary' -- 第一个是编译后的文件路径,第二个是C函数名
LANGUAGE C STRICT; -- 语言是C,STRICT表示参数为NULL时直接返回NULL

第七步:调用函数(验证结果)
执行下面的SQL,就能看到和直接查empsalary表一样的结果:
SELECT * FROM get_all_empsalary();

三、新手避坑指南(重点!)
- 列的位置别写错:
SPI_getvalue的第三个参数是列的位置(从1开始),比如你查的是a,b,c,那a是1,b是2,c是3,错了会返回错误的值; - 内存要释放:用
palloc分配的内存,一定要用pfree释放,不然会导致数据库内存泄露; - SPI连接要关:最后一定要调用
SPI_finish(),不然会占用数据库连接资源;
思考:本例中返回数据集时在数据库中额外创建了个一类型
empsalary_type,有没有更好的办法?