尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

PostGraphile wrapPlans 实战:不重写字段也能改变 Plan Resolver 行为 PostGraphile wrapPlans 实战不重写字段也能改变 Plan Resolver 行为【免费下载链接】crystal Graphiles 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: PartialGraphileBuild.Build GraphileBuild.BuildBase, ) PlanWrapperRules;// 方法 2包装所有匹配过滤函数的解析器 function wrapPlansT( 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 依然很高效。下面的插件在createUser、updateUser等 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), }, });sideEffect与lambda签名相同但专门用于带副作用的回调如校验、日志其内部会把hasSideEffects置为 true见 grafast/grafast/src/steps/sideEffect.ts。这里通过fieldArgs.getRaw([input, propName])拿到字段参数的原始值 step包装函数先执行校验副作用再调用底层plan()正常执行 mutation。Rules 对象规则对象是一个二级映射第一级是typeNameGraphQLObjectType 的名字第二级是fieldName该类型下的字段名其值可以是某个字段的规则也可以是该字段的包装函数interface PlanWrapperRules { [typeName: string]: { [fieldName: string]: PlanWrapperRule | PlanWrapperFn; }; } type PlanWrapperRulesGenerator ( build: PartialGraphileBuild.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) // someonesub.example.com - so***su***.com email.replace( /^(.{1,2})[^]*(.{,2})[^.]*\.([A-z]{2,})$/, $1***$2***.$3, ), ); }, }, });这个例子完美呼应了本文开头的叶子字段说明掩码只改变叶子输出不依赖父值 step 类型因此是最安全的包装场景之一。方法 2包装所有匹配过滤器的解析器function wrapPlansT( filter: ( context: GraphileBuild.ContextObjectFieldsField, build: GraphileBuild.Build, field: GrafastFieldConfig, ) T | null, rule: (match: T) PlanWrapperRule | PlanWrapperFn, options?: WrapPlansOptions, ): GraphileConfig.Plugin;方法 2 接收两个函数参数filter对每个字段都会被调用一次命中要包装的字段时返回一个真值否则返回nullrule对每个通过 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 resolvertype 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。autoApplyFieldArgsfieldArgs 的自动应用PlanWrapperRule中的autoApplyFieldArgs默认为true见 makeWrapPlansPlugin.ts#L198-L202此时每次调用底层plan()后Gra*fast* 会自动把字段参数fieldArgs应用到返回的 step 上——即args[1].autoApply($prev)。这意味着你的包装逻辑总是作用在fieldArgs 已应用之后的步骤之上避免因包装函数要附加副作用而产生非法的 plan 层级invalid plan hierarchy。如果出于某些原因你想在 fieldArgs 应用之前操作可以显式设置autoApplyFieldArgs: falsewrapPlans({ 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 didnt return anything; it must return a step or null!返回非 step、非 null 的值会抛出包含实际返回值的错误信息内部用node:util的inspect格式化。源码视角wrapPlans 是如何工作的wrapPlans的完整实现位于 graphile-build/graphile-utils/src/makeWrapPlansPlugin.ts它本质上是一个Graphile Config 插件工厂返回带schema.hooks的插件对象重载签名解析入口函数首先按参数形态区分方法 1rulesOrGeneratoroptions与方法 2filterruleoptions签名不合法会抛出Invalid call signature for wrapPlans...见 makeWrapPlansPlugin.ts#L89-L98。插件默认名为WrapPlansPlugin_NN 为自增计数器默认版本0.0.0disableResolverEmulationWarnings默认为false。buildhook在 schema 构建阶段把解析出的rules或filter挂到build对象上一个以Symbol为键的私有槽位中见 makeWrapPlansPlugin.ts#L139-L159。若方法 1 传入的是生成器函数则在此处以rulesOrGenerator(build)求值得到规则对象。GraphQLObjectType_fields_fieldhook对每个字段执行包装决策见 makeWrapPlansPlugin.ts#L160-L313。方法 2 先调用 filter返回值非真则原样返回字段方法 1 则按rules[Self.name][fieldName]查表。命中的包装项若是函数会被归一化为{ plan: fn }规则对象。包装结果新字段的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 中出现传统 resolverGra*fast* 就进入 resolver 模拟模式此时没有 plan 的字段不再使用默认 plan resolver而wrapPlans()总是会确保字段有 plan若没有 plan 可包装就退而包装defaultPlanResolver。给本应在模拟模式下执行的字段凭空加上 plan会改变喂给 resolver 的数据从而引发难以排查的问题由于包装发生在 schema 构建期而非运行时PostGraphile 无法预知 resolver 模拟是否会被启用因此对大范围包装逻辑的用户发出此警告帮其定位可能出问题的具体字段。详细的官方说明见仓库内的错误页文档 postgraphile/website/postgraphile/errors/wpr.md。三种解决方案为被包装的字段添加一个非默认的plan resolver或避免包装默认 plan resolver或确认 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-L84import MyPlugin from ./myPlugin.mjs; export default { // ...其它配置 plugins: [MyPlugin], };配套的 schema 插件清单extendSchema、wrapPlans、changeNullability、processSchema等与选型指引见 postgraphile/website/postgraphile/extending.mdx。另外 postgraphile/postgraphile/graphile.config.ts 是仓库自带项目对插件/预设加载的真实用法参考。小结wrapPlans是 PostGraphile 定制 schema 的三大核心工具之一与extendSchema、changeNullability并列它把修改既有字段行为的成本降到最低方法 1按{ typeName: { fieldName: wrapper } }规则表适合精准包装少数已知字段也可复用同一个包装函数覆盖一批字段方法 2filter rule适合对满足某种 scope 条件如isRootMutation的大量字段统一施加逻辑包装函数借由plan()透传/覆盖底层参数配合lambda、sideEffect、context等 Gra*fast* 步骤即可实现过滤、校验、日志、脱敏、权限等常见诉求注意autoApplyFieldArgs的默认自动应用行为、返回值必须是 step 或 null 的约束以及 resolver 模拟警告的含义与规避方式。从源码角度wrapPlans的核心机制是在 schema 构建期通过GraphQLObjectType_fields_fieldhook 为命中字段替换 plan 实现并用EXPORTABLE保证替换后的函数可被导出复用——理解这一点你就能自如地把它与其它 Gra*fast* 步骤组合写出既简洁又高性能的 PostGraphile 定制逻辑。【免费下载链接】crystal Graphiles Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址: https://gitcode.com/gh_mirrors/cry/crystal创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表