首页
/ Supabase 数据库函数编写指南:从安全总则到生产级 PostgreSQL 函数模板

Supabase 数据库函数编写指南:从安全总则到生产级 PostgreSQL 函数模板

2026-09-06 18:42:52作者:丁柯新Fawn

本指南基于开源仓库 Supabase 官方整理的 数据库函数编写规则(AI 编码助手 Cursor Rules 之一)展开。该文档沉淀了一套"写安全、可优化、易维护的 PostgreSQL 函数"的工程规范,适用于在 Supabase 平台上编写业务函数、RPC(Remote Procedure Call)与触发器函数。读完本文,你将掌握 SECURITY INVOKER/DEFINER 的取舍、search_path 加固、IMMUTABLE/STABLE/VOLATILE 声明等核心原则,并能直接复用仓库内置的五组 SQL 模板,在真实迁移文件中对照落地。

一、规则文档的定位:一套写给"函数作者"的权威清单

在仓库中,examples/prompts/database-functions.mdexamples/prompts 目录下的一组"代码生成规则"之一,同目录还包含 code-format-sql.mddatabase-create-migration.mddatabase-rls-policies.md 等姊妹规则。这些文件带有统一的 YAML frontmatter:

description: Guidelines for writing Supabase database functions
alwaysApply: false

其中 description 用于让编码助手理解该规则的主题;alwaysApply: false 表示这是一条按需加载的规则(仅在用户请求"编写数据库函数"这类任务时才注入上下文)。它既可以作为 Cursor 等 AI 助手的系统规则,也可以当作人类开发者复查自己 SQL 时的自查清单——这也是本文把它当作权威方法论展开的原因。

Supabase 的核心是 Postgres 本身:业务逻辑既可以用客户端 SDK 拼装,也可以下沉为数据库函数,通过 PostgREST 以 RPC 形式暴露给前端。规则文档的全部内容,正是在回答一个关键问题:如何在 Supabase 上写出既安全又高性能的数据库函数

二、总则一:默认 SECURITY INVOKER,只在必要时用 SECURITY DEFINER

规则的第一条总则,是要求函数默认以调用者的权限执行:

  • SECURITY INVOKER(PostgreSQL 默认值):函数以调用该函数的用户的权限执行。表所有者是否能访问、RLS 是否放行,都取决于调用者身份,天然更贴合前端直连数据库的访问控制模型。
  • SECURITY DEFINER:函数以**函数创建者(owner)**的权限执行,可绕过调用者对底层对象的权限限制。它便于封装"受限入口",但同时扩大了提权面,一旦函数内有注入点或逻辑漏洞,后果更严重。

规则明确要求:默认使用 SECURITY INVOKER;只有存在明确需求时才使用 SECURITY DEFINER,并且必须向使用者解释清楚理由。这一点在本仓库的真实迁移中得到了印证——supabase/migrations/20260728035858_create_interfaces_feedback.sql 中,向 interfaces_feedback 表插入反馈的唯一通道就是 security definer 函数:

-- 表本身不开放 INSERT 授权,也不存在 INSERT 策略
create function public.submit_interfaces_feedback(
	feedback text,
	user_agent text default null,
	user_id text default null,
	project_ref text default null,
	metadata jsonb default null
)
returns uuid
security definer
set search_path = ''
language plpgsql
as $$
...
$$;

该迁移的注释解释了"为什么必须用 SECURITY DEFINER":提交必须经由该函数,以便服务端生成 delete_token只返回一次给提交者(见该文件第 55-57 行注释,其中明确写了 "There is deliberately no insert grant or policy on the table itself")。这是规则中"明确需求 + 说明理由"的教科书式案例。

再比如 supabase/migrations/20240626184716_misc_database_fixes.sql 中查询系统表的 ipv6_active_status 函数,也以 security definer 声明——因为函数需要代表应用去读取被保护的平台内部表。可见:决定因素是"调用者自己是否有权访问所需对象",而不是"图省事"

三、总则二:把 search_path 置空,并用全限定名引用一切对象

规则第二条总则可能是本仓库所有函数迁移中出现频率最高、也最容易被忽略的安全加固:

set search_path = ''

为什么必须这么做? search_path 决定 PostgreSQL 在解析未限定对象名(如直接写 order_items)时依次查找哪些 schema。如果它包含不受信任的 schema,攻击者可以在其中创建同名对象(如伪装的 public.users 或恶意函数),诱使你的函数在不知情的情况下引用恶意对象——这就是所谓的 schema 提权 / search_path 劫持风险。把 search_path 固定为空字符串后,任何对象引用都不会被隐式解析,所有访问目标必须在代码里写死:

from public.order_items          -- schema.table 全限定

代价是需要额外自律:函数体内每一张表、每一个函数调用都必须显式写出 schema。规则甚至要求对操作符这类看似与 schema 无关的语法也做显式处理。本仓库的向量检索函数就是绝佳范例——supabase/migrations/20240626184716_misc_database_fixes.sql 中的 match_page_sections_v2 同时用到了 set search_path = '' 与全限定的 pgvector 负内积操作符:

create or replace function match_page_sections_v2(
  embedding vector(1536),
  match_threshold float,
  min_content_length int
)
returns setof page_section
language plpgsql
set search_path = ''
as $$
#variable_conflict use_variable
begin
  return query
  select *
  from public.page_section
  where length(page_section.content) >= min_content_length
  and (page_section.embedding operator(public.<#>) embedding) * -1 > match_threshold
  order by page_section.embedding operator(public.<#>) embedding;
end;
$$;

operator(public.<#>) 这种写法正是为了在 search_path 为空时仍能精确解析自定义操作符,同时规避名称冲突。此外注意该文件的 1-6 行——函数写完后还能用 alter function ... set search_path = ''; 对历史遗留函数做补救式加固,说明这一安全约定在本仓库中是被系统性执行的。

另一个高频配套关键词 #variable_conflict use_variable 会在同一文件与本仓库的 content.update_error_code 函数中出现,它解决的是函数参数名与表列名同名时 PL/pgSQL 的变量解析歧义,属于"显式、无歧义"编码风格的一部分。

四、总则三:保证 SQL 合法且适配 Supabase 上下文

规则要求函数体内所有查询都是合法的 PostgreSQL SQL,并且与运行上下文(Supabase)兼容。具体到本仓库的实际约束包括:

  • 函数最终可能经 PostgREST 暴露为 RPC,因此返回结构(标量、setof 复合类型、table(...) 输出参数)必须与 API 消费方式匹配——match_page_sections_v2 特意返回 setof page_section 的注释说明就是为了能利用 PostgREST 的资源嵌入(resource embedding)与其它表 JOIN;
  • 函数可能被直接授予 anon / authenticated 角色调用,也可能只作为触发器在服务端内部执行,权限模型不同(详见下文"第八节");
  • 若用到 pgvector 等扩展类型(vector(1536)),需确认扩展已在目标数据库启用。

一句话总结:函数不是"跑通就行"的孤立脚本,而是数据库 API 与安全边界的一部分。

五、最佳实践一:最小化副作用

规则建议:优先编写"返回结果"的函数,而非"修改数据"的函数;除非确有特定目的(例如触发器),否则避免让函数产生副作用。

好处显而易见:

  • 可预测、易测试:同样的入参得到同样的结果,便于在 SQL 编辑器中单独 select 验证;
  • 便于优化器处理(配合后文的 IMMUTABLE / STABLE 声明,函数调用可被缓存或下推);
  • 减少隐式数据变更带来的审计与并发难题。

仓库里 utils.update_timestamp()(见下文)这类函数是"刻意保留副作用"的合法例外——它服务于触发器机制,属于被明确批准的特殊用途。

六、最佳实践二:使用显式类型

函数参数与返回值都应写明具体类型,避免 anyelement、长度缺省的 text/varchar 混用等"宽松类型"带来的歧义与安全隐患。

本仓库的 content.update_error_code 是很好的示范:四个参数 code textservice texthttp_status_code smallint default nullmessage text default null 全部显式声明,并为可选参数提供了 default null,使函数既能全量更新也能做部分字段维护;返回值固定为 boolean,明确表达"本次是否存在真实变更"的语义(第 43-45 行通过 returning true into resultcoalesce(result, false) 精确区分了插入/更新与无变化)。显式类型还直接服务于 PostgREST——它能据参数类型生成可用的 OpenAPI 风格的 RPC 端点。

七、最佳实践三:优先声明 IMMUTABLESTABLE

PostgreSQL 用三种稳定性标签告知优化器"函数可被优化到什么程度":

标签 含义 能否被优化器缓存/重排 适用场景
IMMUTABLE 相同入参永远返回相同结果,且不读表、不改库 完全可被常量折叠、索引(如表达式索引)与缓存 纯计算,如字符串拼接、哈希、数学运算
STABLE 单条 SQL 语句内结果稳定,不修改数据库 语句级可缓存 读取数据库的查询函数
VOLATILE 每次调用结果都可能变化,或会修改数据 不做跨调用假设 写操作、取 now()/随机数等

规则建议:能声明 IMMUTABLESTABLE 就绝不默认 VOLATILE;只有当函数确实修改数据或有副作用时才用 VOLATILE。标签声明错误会有真实代价:把读库函数误标为 IMMUTABLE,可能让优化器缓存过期数据;把纯函数误标为 VOLATILE,则白白丢失建立表达式索引与内联优化的机会。

规则的第五个示例模板正是把这类纯计算函数写成 language sql + IMMUTABLE(见下文章节九),这是所有标签中最容易被优化器利用的组合。

八、最佳实践四:触发器函数必须附带完整的 CREATE TRIGGER

如果函数被用作触发器,规则要求一并给出把函数挂到目标表与事件的 CREATE TRIGGER 语句(如 BEFORE UPDATE),并注明 FOR EACH ROW 等触发粒度,保证代码开箱即用。

本仓库的 supabase/migrations/20250521181337_error_code_table.sql 提供了一个可直接对照的触发器函数实例:

create or replace function utils.update_timestamp()
returns trigger
set search_path = ''
language plpgsql
as $$
begin
    new.updated_at = now();
    return new;
end;
$$;

grant execute on function utils.update_timestamp() to anon;
grant execute on function utils.update_timestamp() to authenticated;

随后它被同一个迁移文件挂到两张内容表上(第 41-44 行、第 81-84 行):

create or replace trigger sync_updated_at_content_service
before update on content.service
for each row
execute function utils.update_timestamp();

注意该实例的工程细节与规则完全呼应:

  • new.updated_at := now(); ... return new; 修改的是 NEW 行而非直接 UPDATE 表,避免了在行级触发器里递归触发自身的经典陷阱;
  • 函数定义在独立的 utils schema,表在 content schema——正是对全限定名与 schema 职责划分的践行;
  • 该迁移文件第 1-10 行先用 alter default privileges 撤销了对 anon/authenticated 的默认函数执行权,再对需要暴露的函数逐个 grant execute这补充了规则未展开的一个 Supabase 细节:PostgreSQL 默认向 PUBLIC 授予函数 EXECUTE,而生产环境中应遵循最小权限,把执行权精确授予 anonauthenticated(或仅保留服务端内部使用)。

九、五组开箱即用的函数模板(原文档完整继承)

规则文档给出了五组模板,覆盖了函数编写的绝大多数形态。以下全部保留原文,并补充逐行要点注释,可直接复制到 SQL 编辑器或迁移文件中使用。

模板一:SECURITY INVOKER 的最简函数

create or replace function my_schema.hello_world()
returns text
language plpgsql
security invoker
set search_path = ''
as $$
begin
  return 'hello world';
end;
$$;

要点:security invokerset search_path = '' 成对出现;returns text 显式声明返回类型;函数体不含任何未限定对象引用,因此不存在 search_path 风险。

模板二:带参数与全限定对象名的函数

create or replace function public.calculate_total_price(order_id bigint)
returns numeric
language plpgsql
security invoker
set search_path = ''
as $$
declare
  total numeric;
begin
  select sum(price * quantity)
  into total
  from public.order_items
  where order_id = calculate_total_price.order_id;

  return total;
end;
$$;

要点:查询语句引用了 public.order_items 全限定表名;where order_id = calculate_total_price.order_id函数名限定参数,避免参数名与潜在列名的歧义。此写法正是触发 #variable_conflict 场景的另一种规避手段。该函数只读不写且依赖表内容,实操中应声明为 stable 以帮助优化器(模板示例未写,属于读者可自行按第七节原则补强的点)。

模板三:行级触发器函数 + 配套触发器

create or replace function my_schema.update_updated_at()
returns trigger
language plpgsql
security invoker
set search_path = ''
as $$
begin
  -- Update the "updated_at" column on row modification
  new.updated_at := now();
  return new;
end;
$$;

create trigger update_updated_at_trigger
before update on my_schema.my_table
for each row
execute function my_schema.update_updated_at();

要点:returns trigger 是触发器函数的硬性要求;触发器的 execute function 语法(PostgreSQL 11+)替代了旧的 execute procedure;规则模板与本仓库 utils.update_timestamp 结构一致,可互相印证。

模板四:带错误处理的函数

create or replace function my_schema.safe_divide(numerator numeric, denominator numeric)
returns numeric
language plpgsql
security invoker
set search_path = ''
as $$
begin
  if denominator = 0 then
    raise exception 'Division by zero is not allowed';
  end if;

  return numerator / denominator;
end;
$$;

要点:用 raise exception 主动抛出语义明确的错误,而不是让数据库报出晦涩的除零错误。结合 Supabase 上下文:该异常会经 PostgREST 转换为 API 错误返回,因此错误文案应当面向调用方可理解。若希望该函数参与表达式索引或常量折叠,同样可进一步声明 immutable——它不读表、无副作用,完全符合条件。

模板五:IMMUTABLE + SQL 语言的纯函数

create or replace function my_schema.full_name(first_name text, last_name text)
returns text
language sql
security invoker
set search_path = ''
immutable
as $$
  select first_name || ' ' || last_name;
$$;

要点:这是"最可优化"的函数形态——language sql 允许优化器将函数体内联展开,immutable 允许常量折叠与表达式索引。full_name 这类确定性字符串拼接,正是建立表达式索引(如 create index on users (my_schema.full_name(first_name, last_name)))的理想候选。注意 as $$ ... $$ 内直接是 SQL 表达式(select)而非 PL/pgSQL 的 begin/end 块。

十、模板与仓库实战的映射:每一条规则都有真实落点

为避免"纸上谈兵",下表把规则条款与本仓库可核查的迁移文件一一对应:

规则 / 模板 仓库中的真实实现(相对路径)
触发器函数 + CREATE TRIGGERset search_path = '' error_code_table.sqlutils.update_timestamp() 及其挂载到 content.service / content.error 的两个触发器
显式参数类型 + default null 可选参数 + 返回明确语义 error_code_update_functions.sqlcontent.update_error_code(code, service, ...)
set search_path = '' + 全限定对象/操作符 + #variable_conflict misc_database_fixes.sqlmatch_page_sections_v2docs_search_embeddingsdocs_search_fts 等检索函数
SECURITY DEFINER 用于封装受限写入口(并附理由) create_interfaces_feedback.sqlpublic.submit_interfaces_feedback()
最小执行权限:撤销默认 EXECUTE、按角色精确 grant error_code_table.sql 中对 utils schema 默认权限的 revoke/grant 组合

此外,这些函数全部按 Supabase CLI 的迁移规范存放在 supabase/migrations 目录,文件以 20260728035858_short_description.sqlYYYYMMDDHHmmss_ 前缀)命名。这与同目录姊妹规则 database-create-migration.md 约定的命名规范完全一致——函数与表结构一样,都应作为版本化迁移被审阅、被回滚、被追踪,而不是随手执行的一次性脚本。本仓库的 docs 站点数据库还包含大量配套的 RLS 策略与函数协作案例(可参考 database-rls-policies.md 了解 auth.uid() 等授权函数),印证了"函数 + 策略"共同构成访问控制闭环的架构。

十一、在 Supabase 项目里如何部署与验证

结合规则文档与本仓库的工程实践,落地一份数据库函数的推荐路径如下:

  1. 写迁移文件:把函数放进以 YYYYMMDDHHmmss_描述.sql 命名的文件(如 20240906123045_create_profiles.sql),放入 supabase/migrations/ 目录。本仓库自身的函数变更(hybrid_search.sql 等)全部遵循该模式。
  2. 推送数据库:通过 Supabase CLI 将迁移应用到本地或远端数据库(例如 supabase db push / 本地 supabase start 场景下的迁移重放)。日常调试也可以直接把 create function ... 粘贴进 Dashboard 的 SQL 编辑器执行。
  3. 验证权限:按第七节模板为函数授予执行权,确认 anon/authenticated 是否能按预期调用 RPC,服务端内部的触发器函数则不需要对客户端授权。
  4. 验证安全声明:用只拥有基础权限的角色实际调用一次函数,确认 SECURITY INVOKER 函数遵循 RLS 与列级授权、SECURITY DEFINER 函数只暴露了最小必要能力。

整个项目由 pnpm-workspace.yaml 组织为 monorepo,而数据库侧代码统一收敛在 supabase/config.tomlsupabase/migrations 之下——这意味着"数据库函数"在 Supabase 工程里是一等公民:它被版本管理、被同行评审、被 CI 保护,而不仅仅是应用层的一段字符串。

十二、把规则文档接入 AI 编码助手:Cursor Rules 的使用方式

如果你正在用 Cursor 等 AI 编程助手开发 Supabase 项目,可以把 examples/prompts/database-functions.md 作为 .cursor/rules 中的一条规则文件。它 frontmatter 中的 description 字段会在用户请求"创建/修改数据库函数"时被检索命中,而 alwaysApply: false 保证规则只在相关任务中被注入,避免无关请求时消耗上下文。其余 database-*.md 规则(迁移、RLS 策略)可按同样的方式配套启用,使 AI 在生成 DDL、函数与策略时遵循一致的安全基线。

结语:把"安全"写进函数的第一行

回顾整份规则,它真正的核心不是语法,而是三条安全心智模型:权限上默认最小化(INVOKER)、解析上默认无歧义(search_path = '' + 全限定名)、能力声明上默认最诚实(IMMUTABLE/STABLE,副作用尽量为零)。对照本仓库数百个真实迁移可以看到,这些不是纸面建议,而是 Supabase 自身文档站点数据库持续遵循的工程纪律。下次新建函数时,不妨以上述五组模板为起点,再问自己一句:这段函数如果被一个只拥有 anon 权限的攻击者精心构造入参调用,会发生什么?

登录后查看全文
热门项目推荐
相关项目推荐