news 2026/9/23 10:44:22

PostGraphile wrapPlans 实战:不重写字段也能改变 Plan Resolver 行为

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostGraphile wrapPlans 实战:不重写字段也能改变 Plan Resolver 行为

PostGraphile wrapPlans 实战:不重写字段也能改变 Plan Resolver 行为

【免费下载链接】crystal🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址: https://gitcode.com/gh_mirrors/cry/crystal

本指南系统讲解 PostGraphile(基于 Gra*fast* 引擎)提供的wrapPlans工具:它用于"包装"PostGraphile 自动生成的字段计划解析器(plan resolver),从而在不重写字段的前提下为其追加过滤、校验、日志、脱敏等逻辑。读完本文,你将掌握wrapPlans的两种调用方式(按字段名包装、按过滤器批量包装)、PlanWrapperFn包装函数的完整语义,以及如何规避 resolver 模拟(resolver emulation)警告并在graphile.config.mjs中加载生成的 schema 插件。

wrapPlans定义于 graphile-build/graphile-utils/src/makeWrapPlansPlugin.ts,并从graphile-utils包导出(见 graphile-build/graphile-utils/src/index.ts#L73);在postgraphile包中它通过postgraphile/utils子路径对外暴露(见 postgraphile/postgraphile/package.json#L218-L222)。

wrapPlans 是什么,何时该用它

PostGraphile 会为它生成 GraphQL API 中每一个字段自动生成 plan resolver。但有时你需要改变这些 plan 的行为——例如:

  • 对某个集合查询追加额外的过滤条件;
  • 在执行 mutation 之前执行附加动作或校验;
  • 对返回结果做脱敏、掩码或权限控制。

与其把这些字段连同行 plan 一起推倒重写,不如用wrapPlans将 PostGraphile 已经生成的 plan "包裹"一层你自己的逻辑。

:::tip 需要的是"新字段"而不是"改行为"? 如果你是想新增字段或类型,extendSchema会更快到达目标。只有当你想保留现有字段、只调整它如何规划与解析时,才应该选择wrapPlans。 :::

:::warning 注意父值的 step 类型约束 GraphQL schema 中的部分字段会要求其父值(parent value)是特定类型的 step 类,包装时如果破坏了这一预期,就可能引发 planning 错误。不过一般来说,修改"叶子字段"(leaf field)的结果(例如掩码用户的 email 地址)无需担心这类问题,因为叶子字段通常不依赖具体的 step 类型。 :::

签名总览:两种调用变体

wrapPlans通过函数重载提供两种略有差异的签名,对应两种调用方式:

// 方法 1:包装已知字段的单个解析器 function wrapPlans( rulesOrGenerator: PlanWrapperRules | PlanWrapperRulesGenerator, options?: WrapPlansOptions, ): GraphileConfig.Plugin; interface PlanWrapperRules { [typeName: string]: { [fieldName: string]: PlanWrapperRule | PlanWrapperFn; }; } interface PlanWrapperRule { /** 计划包装函数 */ plan?: PlanWrapperFn; /** * 设为 false 时,你的包装函数调用底层 plan 时,不再自动应用 fieldArgs。 */ autoApplyFieldArgs?: boolean; } interface WrapPlansOptions { /** 给插件起的名字,便于调试 */ name?: string; /** 插件版本 */ version?: string; /** 插件描述,便于调试 */ description?: string; /** * 若你确信这些 plan 绝不会在 resolver 模拟(resolver emulation)上下文 * 中被调用,从而包装 defaultPlanResolver 不会引发问题,则设为 true。 */ disableResolverEmulationWarnings?: boolean; } type PlanWrapperFn = ( plan: SmartFieldPlanResolver, $source: Step, fieldArgs: FieldArgs, info: FieldInfo, ) => any; type PlanWrapperRulesGenerator = ( build: Partial<GraphileBuild.Build> & GraphileBuild.BuildBase, ) => PlanWrapperRules;
// 方法 2:包装所有匹配过滤函数的解析器 function wrapPlans<T>( filter: ( context: GraphileBuild.ContextObjectFieldsField, build: GraphileBuild.Build, field: GrafastFieldConfig, ) => T | null, rule: (match: T) => PlanWrapperRule | PlanWrapperFn, options?: WrapPlansOptions, ): GraphileConfig.Plugin;
  • 方法 1适合包装一个或两个你已知类型名与字段名的解析器,是便捷的快捷方式;
  • 方法 2适合用同一种方式批量包装大量解析器,更灵活。

两种签名都接受可选的options参数。设置disableResolverEmulationWarnings: true可以屏蔽 resolver 模拟警告——当你的 schema 只使用 Gra*fast* plan resolver、不包含任何传统 resolver 时,该警告本就无关紧要(详见下文"resolver 模拟警告"一节)。

方法 1:包装已知字段的单个解析器

方法 1 中,wrapPlans接收一个包装规则对象(或该规则对象的生成器),并返回一个插件:

function wrapPlans( rulesOrGenerator: PlanWrapperRules | PlanWrapperRulesGenerator, options?: WrapPlansOptions, ): GraphileConfig.Plugin;

示例:将 email 转为小写

下面这个插件包装了User.email字段,用 Gra*fast* 的lambda步骤把底层 plan 产生的 email 值统一转为小写:

import { wrapPlans } from "postgraphile/utils"; import { lambda } from "postgraphile/grafast"; export default wrapPlans({ User: { email(plan) { const $email = plan(); return lambda($email, (email) => email.toLowerCase()); }, }, });

lambda是 Gra*fast* 的核心步骤之一:它把输入 step(此处为$email)的每个值送入回调函数映射为新值(实现见 grafast/grafast/src/steps/lambda.ts)。注意lambda的回调必须只接收一个参数,需要多值时应传入一个 ListStep,并在回调中解构。

示例:同一逻辑批量包装多个字段

当有一批字段需要用完全相同的方式包装时,方法 1 依然很高效。下面的插件在createUserupdateUser等 mutation 执行前,用sideEffect步骤对输入数据做校验,非法则抛错:

import { sideEffect } from "postgraphile/grafast"; function assertValidUserData(data) { if (!data || data.username?.length === 0) { throw new Error("Invalid data"); } } const validateUserData = (propName) => { return (plan, $source, fieldArgs) => { const $user = fieldArgs.getRaw(["input", propName]); // 回调抛出错误即视为校验失败 sideEffect($user, (user) => assertValidUserData(user)); return plan(); }; }; export default wrapPlans({ Mutation: { createUser: validateUserData("user"), updateUser: validateUserData("userPatch"), updateUserById: validateUserData("userPatch"), updateUserByEmail: validateUserData("userPatch"), }, });

sideEffectlambda签名相同,但专门用于带副作用的回调(如校验、日志),其内部会把hasSideEffects置为 true(见 grafast/grafast/src/steps/sideEffect.ts)。这里通过fieldArgs.getRaw(["input", propName])拿到字段参数的原始值 step,包装函数先执行校验副作用,再调用底层plan()正常执行 mutation。

Rules 对象

规则对象是一个二级映射:第一级是typeName(GraphQLObjectType 的名字),第二级是fieldName(该类型下的字段名),其值可以是某个字段的规则,也可以是该字段的包装函数:

interface PlanWrapperRules { [typeName: string]: { [fieldName: string]: PlanWrapperRule | PlanWrapperFn; }; } type PlanWrapperRulesGenerator = ( build: Partial<GraphileBuild.Build> & GraphileBuild.BuildBase, ) => PlanWrapperRules;

如果你需要把规则对象做成函数(即PlanWrapperRulesGenerator),生成器会收到build对象——这在需要读取 preset 的 schema 选项、或从 registry 中取东西时很有用。

示例:非本人则 email 返回 null

下面的插件包装User.email字段:当请求该字段的用户与 email 所属用户不是同一人时返回null。(注意:email 仍然会从数据库取出,只是不返回给该用户。)

import { wrapPlans } from "postgraphile/utils"; import { context, lambda } from "postgraphile/grafast"; export default wrapPlans({ User: { email(plan, $user) { const $userId = $user.get("id"); const $currentUserId = context().get("jwtClaims").get("user_id"); const $email = plan(); return lambda( [$userId, $currentUserId, $email], ([userId, currentUserId, email]) => userId === currentUserId ? email : null, ); }, }, });

这里展示了包装函数的完整形态:第二个参数$source是父字段的 step,可以通过$user.get("id")提取属性;context()是 Gra*fast* 暴露的全局 context step,可像读取普通对象一样链式.get()取 JWT 声明(context()的实现见 grafast/grafast/src/global.ts#L17-L19);由于需要同时依赖多个值,lambda的第一个参数传入了 ListStep[$userId, $currentUserId, $email],回调中再解构。

示例:掩码 email

这个示例复用User.email字段的默认解析器拿到真实值,然后对值做掩码处理而不是直接省略:

import { wrapPlans } from "postgraphile/utils"; import { lambda } from "postgraphile/grafast"; export default wrapPlans({ User: { email(plan) { const $email = plan(); return lambda($email, (email) => // someone@sub.example.com -> so***@su***.com email.replace( /^(.{1,2})[^@]*@(.{,2})[^.]*\.([A-z]{2,})$/, "$1***@$2***.$3", ), ); }, }, });

这个例子完美呼应了本文开头的"叶子字段"说明:掩码只改变叶子输出,不依赖父值 step 类型,因此是最安全的包装场景之一。

方法 2:包装所有匹配过滤器的解析器

function wrapPlans<T>( filter: ( context: GraphileBuild.ContextObjectFieldsField, build: GraphileBuild.Build, field: GrafastFieldConfig, ) => T | null, rule: (match: T) => PlanWrapperRule | PlanWrapperFn, options?: WrapPlansOptions, ): GraphileConfig.Plugin;

方法 2 接收两个函数参数:

  1. filter:对每个字段都会被调用一次,命中要包装的字段时返回一个真值(否则返回null);
  2. rule:对每个通过 filter 的字段被调用,接收 filter 的返回值,必须返回一个包装函数或规则对象。

filter 的调用参数如下:

  • context:字段的Context值,其中context.scope属性最常被使用;
  • build:包含大量辅助工具的Build对象;
  • field:字段本身的规格(field specification)。

filter 的返回值可以是任意真值,里面应带上你构造包装函数所需的一切信息。

示例:在每个 mutation 执行前后打日志

import { wrapPlans } from "postgraphile/utils"; import { sideEffect } from "postgraphile/grafast"; // 示例:在每个 mutation 执行前后打日志 export default wrapPlans( (context) => { if (context.scope.isRootMutation) { return { scope: context.scope }; } return null; }, ({ scope }) => (plan, _, fieldArgs) => { sideEffect(fieldArgs.getRaw(), (args) => { console.log( `Mutation '${scope.fieldName}' starting with arguments:`, args, ); }); const $payload = plan(); sideEffect($payload, (payload) => { console.log(`Mutation '${scope.fieldName}' payload:`, payload); }); return $payload; }, );

这个例子利用context.scope.isRootMutation识别所有根 mutation 字段,用sideEffect在 mutation 开始前记录入参、结束后记录返回的 payload,最后原样返回底层 plan 的结果。

:::note mutation 通常返回object({ result: $step })对于内置 CRUD mutation 和函数 mutation,字段返回的 plan 是一个包含result属性的 object step。底层的 mutation 步骤(如 insert/update/delete 或函数调用)位于result下,因此你可以跨 mutation 类型一致地写const $result = $payload.get("result")。这种一致性是刻意设计给插件作者的;object 包装层也为未来在不破坏现有 plan 的前提下增加额外 payload 字段留出了空间。 :::

Plan resolver 包装函数(PlanWrapperFn)

包装函数与 Gra*fast* 的 plan resolver 类似,只是它在最前面多接收一个参数plan,用于把执行委托给被包装的原 plan resolver:

type PlanWrapperFn = ( plan: SmartFieldPlanResolver, $source: Step, fieldArgs: FieldArgs, info: FieldInfo, ) => any;

参数覆盖与透传语义

当你调用plan函数时,可以可选地传入$source, fieldArgs, info中的任意一个或多个——传入的参数会覆盖原 resolver 本来会收到的对应值;而调用plan()(不带任何参数)时,则原值原样透传。

在底层实现中(见 graphile-build/graphile-utils/src/makeWrapPlansPlugin.ts#L255-L279),这个"智能 plan"是这样构造的:它把你在包装函数里传入的覆盖参数与原 plan 参数合并——overrideParams拼接上planParams中未被覆盖的剩余部分,再调用真正的oldPlan

autoApplyFieldArgs:fieldArgs 的自动应用

PlanWrapperRule中的autoApplyFieldArgs默认为true(见 makeWrapPlansPlugin.ts#L198-L202):此时每次调用底层plan()后,Gra*fast* 会自动把字段参数(fieldArgs)应用到返回的 step 上——即args[1].autoApply($prev)。这意味着你的包装逻辑总是作用在fieldArgs 已应用之后的步骤之上,避免因包装函数要附加副作用而产生非法的 plan 层级(invalid plan hierarchy)。

如果出于某些原因你想在 fieldArgs 应用之前操作,可以显式设置autoApplyFieldArgs: false

wrapPlans({ Mutation: { someMutation: { autoApplyFieldArgs: false, plan(plan, $parent, fieldArgs) { // 在这里 fieldArgs 尚未被自动应用 return plan(); }, }, }, });

该行为的演进记录在 postgraphile/postgraphile/CHANGELOG.md#L776-L783:从某个版本起wrapPlans()会自动应用fieldArgs,使包装作用于 fieldArgs 应用之后,以解决无效 plan 层级问题。

返回值约束:必须返回 step 或 null

包装函数的返回值必须是一个 Gra*fast* step 或null(用于清空字段)。实现中对返回值做了严格检查(见 makeWrapPlansPlugin.ts#L287-L299):

  • 返回undefined会抛出"Your plan wrapper didn't return anything; it must return a step or null!"
  • 返回非 step、非 null 的值会抛出包含实际返回值的错误信息(内部用node:utilinspect格式化)。

源码视角:wrapPlans 是如何工作的

wrapPlans的完整实现位于 graphile-build/graphile-utils/src/makeWrapPlansPlugin.ts,它本质上是一个Graphile Config 插件工厂,返回带schema.hooks的插件对象:

  1. 重载签名解析:入口函数首先按参数形态区分方法 1(rulesOrGenerator+options)与方法 2(filter+rule+options),签名不合法会抛出"Invalid call signature for wrapPlans..."(见 makeWrapPlansPlugin.ts#L89-L98)。插件默认名为WrapPlansPlugin_N(N 为自增计数器),默认版本0.0.0disableResolverEmulationWarnings默认为false

  2. buildhook:在 schema 构建阶段把解析出的rulesfilter挂到build对象上一个以Symbol为键的私有槽位中(见 makeWrapPlansPlugin.ts#L139-L159)。若方法 1 传入的是生成器函数,则在此处以rulesOrGenerator(build)求值得到规则对象。

  3. GraphQLObjectType_fields_fieldhook:对每个字段执行包装决策(见 makeWrapPlansPlugin.ts#L160-L313)。方法 2 先调用 filter,返回值非真则原样返回字段;方法 1 则按rules[Self.name][fieldName]查表。命中的包装项若是函数,会被归一化为{ plan: fn }规则对象。

  4. 包装结果:新字段的plan通过EXPORTABLE声明为一个可导出函数wrappedPlan,其中:

    • smartPlan负责合并覆盖参数并调用oldPlan,同时检查旧 plan 是否真的返回了 step;
    • 随后调用你的planWrapper(smartPlan, $source, fieldArgs, info)
    • 最后校验包装返回值必须是 step 或 null(见 makeWrapPlansPlugin.ts#L242-L312)。

另外,旧名称makeWrapPlansPlugin已重命名并标记为@deprecated,它只是wrapPlans的别名(见 makeWrapPlansPlugin.ts#L319-L320),新代码请统一使用wrapPlans

认识并处理 resolver 模拟(resolver emulation)警告

当你包装的字段原本没有 plan、从而会去包装defaultPlanResolver时,wrapPlans可能打印类似下面的警告(收集并去重后在宏任务中统一输出,见 makeWrapPlansPlugin.ts#L108-L132):

[WARNING]: `wrapPlans(...)` plugin WrapPlansPlugin_1 has wrapped the default plan resolver at field coordinate User.email. If this is an impure schema (one that mixes traditional resolvers with Grafast plan resolvers) then this may result in hard to track down issues - hence this warning. ...

为什么会有这个警告

  • Gra*fast* 以 plan resolver 为基础运行,PostGraphile 内置的一切都使用 plan resolver,默认产出"纯"Gra*fast* schema;
  • 但 Gra*fast* 也支持为传统 GraphQL.js 风格 schema 模拟(emulate)传统 resolver(即字段带resolve/subscribe没有plan)。一旦 schema 中出现传统 resolver,Gra*fast* 就进入 resolver 模拟模式,此时没有 plan 的字段不再使用默认 plan resolver;
  • wrapPlans()总是会确保字段有 plan:若没有 plan 可包装,就退而包装defaultPlanResolver。给本应在模拟模式下执行的字段凭空加上 plan,会改变喂给 resolver 的数据,从而引发难以排查的问题;
  • 由于包装发生在 schema 构建期而非运行时,PostGraphile 无法预知 resolver 模拟是否会被启用,因此对"大范围包装逻辑"的用户发出此警告,帮其定位可能出问题的具体字段。

详细的官方说明见仓库内的错误页文档 postgraphile/website/postgraphile/errors/wpr.md。

三种解决方案

  1. 为被包装的字段添加一个(非默认的)plan resolver;或
  2. 避免包装默认 plan resolver;或
  3. 确认 schema 安全后,在调用wrapPlans()时设置disableResolverEmulationWarnings: true

其中"避免包装默认 plan resolver"可以这样实现(来自 wpr.md 的示例):

const MyPlugin = wrapPlans( (context, build, field) => { const { grafast: { defaultPlanResolver }, } = build; const plan = field.extensions?.grafast?.plan ?? defaultPlanResolver; // 不包装默认 plan resolver if (plan === defaultPlanResolver) return null; // ... }, // ... );

"确认安全后关闭警告"则是:

const MyPlanWrapperPlugin = wrapPlans(rules, { name: "MyPlanWrapperPlugin", disableResolverEmulationWarnings: true, }); // 或方法 2: const MyOtherPlanWrapperPlugin = wrapPlans(filterFn, ruleFn, { name: "MyOtherPlanWrapperPlugin", disableResolverEmulationWarnings: true, });

若你的 schema 是纯 plan resolver(没有任何传统 resolver),该警告可以安全忽略。该警告机制本身也经历过迭代:只在类型没有assertStep时才触发、同类调用会分组合并输出、并新增了命名插件与关闭选项(见 postgraphile/postgraphile/CHANGELOG.md#L525-L531)。

加载 wrapPlans 生成的插件

wrapPlans的返回值就是一个标准的 Graphile Config schema 插件,直接放入 preset 的plugins数组即可(详见 postgraphile/website/postgraphile/extending.mdx#L73-L84):

import MyPlugin from "./myPlugin.mjs"; export default { // ...其它配置 plugins: [MyPlugin], };

配套的 schema 插件清单(extendSchemawrapPlanschangeNullabilityprocessSchema等)与选型指引见 postgraphile/website/postgraphile/extending.mdx。另外 postgraphile/postgraphile/graphile.config.ts 是仓库自带项目对插件/预设加载的真实用法参考。

小结

wrapPlans是 PostGraphile 定制 schema 的三大核心工具之一(与extendSchemachangeNullability并列),它把"修改既有字段行为"的成本降到最低:

  • 方法 1(按{ typeName: { fieldName: wrapper } }规则表)适合精准包装少数已知字段,也可复用同一个包装函数覆盖一批字段;
  • 方法 2(filter + rule)适合对满足某种 scope 条件(如isRootMutation)的大量字段统一施加逻辑;
  • 包装函数借由plan()透传/覆盖底层参数,配合lambdasideEffectcontext等 Gra*fast* 步骤即可实现过滤、校验、日志、脱敏、权限等常见诉求;
  • 注意autoApplyFieldArgs的默认自动应用行为、返回值必须是 step 或 null 的约束,以及 resolver 模拟警告的含义与规避方式。

从源码角度,wrapPlans的核心机制是在 schema 构建期通过GraphQLObjectType_fields_fieldhook 为命中字段替换 plan 实现,并用EXPORTABLE保证替换后的函数可被导出复用——理解这一点,你就能自如地把它与其它 Gra*fast* 步骤组合,写出既简洁又高性能的 PostGraphile 定制逻辑。

【免费下载链接】crystal🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址: https://gitcode.com/gh_mirrors/cry/crystal

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 10:43:47

储能运维工程师证有必要报班吗?从报名学习到考试拿证,报考全攻略

储能是新能源产业的”新增长极”&#xff0c;储能运维工程师是新兴的热门技术岗位。想考证入行&#xff0c;报不报班&#xff1f;本文围绕储能运维工程师证&#xff0c;把自学与报班的差距、费用、选班要点和报考流程讲透。 先说结论&#xff1a;储能技术更新快、专业性强&…

作者头像 李华
网站建设 2026/9/23 10:43:05

2026年值得折腾的Docker项目:自托管、开发工具与监控运维实战

1. 为什么2026年还值得折腾Docker项目1.1 从“能跑就行”到“跑得优雅”的转变如果你在2026年还在用docker run裸奔一个MySQL容器&#xff0c;然后把数据卷随手扔在/var/lib/docker里&#xff0c;那这篇文章就是写给你的。我接触Docker差不多有七八年了&#xff0c;从最早的doc…

作者头像 李华
网站建设 2026/9/23 10:42:29

基于LSTM的时间序列异常检测实战:AIOps竞赛项目全流程拆解

简介&#xff1a;这是一份基于LSTM的异常检测竞赛项目资源&#xff0c;面向AIOps智能运维场景&#xff0c;适合机器学习初学者、相关专业学生及从业者用于学习时序数据异常检测方法。压缩包共14个文件&#xff0c;类型涵盖Python源码、CSV数据集、PNG图表与Markdown说明文档&am…

作者头像 李华
网站建设 2026/9/23 10:42:25

2026高合规场景私有化电子签章公司选型核心判断标准

高合规场景电子签章选型的典型痛点某省级三甲医院信息科年底面临3000份医护职称聘书盖章需求&#xff0c;3名行政人员手工盖章日均仅能完成200份&#xff0c;且医疗敏感文件严禁流出医院内网&#xff0c;现有云签章方案无法满足等保三级合规要求&#xff0c;项目推进陷入停滞。…

作者头像 李华
网站建设 2026/9/23 10:38:41

PSCAD仿真在电力系统过电压分析与保护中的应用

1. 项目背景与核心价值在电力系统运行中&#xff0c;三相空载输电线路的过电压问题一直是困扰运维人员的典型难题。当线路处于空载或轻载状态时&#xff0c;由于电容效应产生的电压升高现象可能达到额定电压的1.5-2倍&#xff0c;这对设备绝缘构成严重威胁。我曾在某500kV变电站…

作者头像 李华