news 2026/9/24 15:07:07

PostGraphile V5 迁移指南:用 Grafast 计划(Plans)重写 makeExtendSchemaPlugin

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostGraphile V5 迁移指南:用 Grafast 计划(Plans)重写 makeExtendSchemaPlugin
  • 后端
  • API网关

【免费下载链接】crystal

🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!

项目地址:https://gitcode.com/gh_mirrors/cry/crystal
点击查看免费下载

导读:PostGraphile V5 用全新的 Grafast规划与执行引擎取代了 V4 的 "lookahead" 系统,这使得makeExtendSchemaPlugin时代的一系列变通手段(@requires@pgField@pgQueryselectGraphQLResultFromTable、Savepoints、QueryBuilder 等)全部失去存在意义。本篇基于 PostGraphile V5 官方迁移文档,逐项对照 V4 旧写法与 V5 新写法,手把手教你把扩展 Schema 的插件从 "resolver + 指令" 范式重写为 "plan + 标准步骤" 范式,并补充仓库源码级实现依据,帮助你一次性完成迁移。

迁移背景:从 Lookahead 到 Grafast计划引擎

PostGraphile V4 的扩展机制建立在 "look-ahead"(前瞻)系统之上——系统在解析阶段提前探测查询需要哪些数据,再以各种 hack 手段把这些信息塞给 resolver。这套体系虽然可用,但既脆弱又难以理解。V5 将执行内核替换为 Grafast规划与执行引擎后,绝大多数过去的变通手段都不再需要,其中包括:

  • 指令@requires@pgField@pgQuery
  • 辅助函数selectGraphQLResultFromTableembed
  • Savepoints(保存点)
  • context.pgClient.query
  • QueryBuilder 的 "named children"
  • QueryBuilder 本身
  • build.getTypeAndIdentifiersFromNodeId
  • 为确保引用类型之前先加载类型而编写的各种 hack

(官方文档标注的 TODO:仍需为@scope指令寻找替代方案。)

这一切变化的核心原因只有一个:V5 用计划(plans)取代了 resolver。从技术上说,与外部系统交互时你仍然可以继续使用 resolver,但上述指令行为必须用计划来复刻——既然都要学,不如直接全面拥抱 plans。

这一范式变化在仓库源码中也有直接体现。例如 PgV4SimpleSubscriptionsPlugin.ts 就是 V5 官方插件中一个完整的extendSchema用例,它不再注册任何 resolver,而是通过typeDefs+objects+plans的组合声明订阅字段:

// postgraphile/postgraphile/src/plugins/PgV4SimpleSubscriptionsPlugin.ts(节选) export const PgV4SimpleSubscriptionsPlugin = extendSchema((build) => { return { typeDefs: [ gql` extend type Subscription { listen(topic: String!): ListenPayload } type ListenPayload { event: String } `, ], objects: { Subscription: { plans: { listen: { subscribePlan(_, { $topic }) { const $pgSubscriber = context().get("pgSubscriber"); const $derivedTopic = lambda( $topic, (topic) => `postgraphile:${topic}`, ); return listen($pgSubscriber, $derivedTopic, jsonParse); }, plan($event) { return $event; }, }, }, }, }, }; });

在继续之前,先明确 V5 插件工厂函数的入口变化:

-const { makeExtendSchemaPlugin, gql } = require("graphile-utils"); +const { extendSchema, gql } = require("postgraphile/utils");

makeExtendSchemaPlugin在 V5 中由extendSchema取代,回调接收build对象,返回的配置从{ typeDefs, resolvers }变为{ typeDefs, plans }(更推荐{ typeDefs, objects },见下文selectGraphQLResultFromTable一节)。

@requires:改为从父计划中.get()字段

V4 中@requires(columns: [...])用来确保传入 resolver 的父对象携带指定列(尽管这些列可能被转换为驼峰命名,导致大小写不一致的困扰)。

在 V5 的计划中,你只需对父计划调用.get(...)即可取到对应列。下面是官方文档中一个 V4 示例的完整迁移对照,功能是把price_in_us_cents通过convertUsdToAud函数转换为澳元:

-const { makeExtendSchemaPlugin, gql } = require("graphile-utils"); +const { extendSchema, gql } = require("postgraphile/utils"); const { convertUsdToAud } = require("ficticious-npm-library"); +const { lambda } = require('postgraphile/grafast'); -const MyForeignExchangePlugin = makeExtendSchemaPlugin((build, options) => { +const MyForeignExchangePlugin = extendSchema((build) => { + const { options } = build; return { typeDefs: gql` extend type Product { - priceInAuCents: Int! @requires(columns: ["price_in_us_cents"]) + priceInAuCents: Int! } `, - resolvers: { + plans: { Product: { - priceInAuCents: async (product) => { - // Note that the columns are converted to fields, so the case changes - // from `price_in_us_cents` to `priceInUsCents` - const { priceInUsCents } = product; - return await convertUsdToAud(priceInUsCents); - }, + priceInAuCents($product) { + const $cents = $product.get('price_in_us_cents'); + return lambda($cents, cents => convertUsdToAud(cents)); + }, }, }, }; });

这里有两个值得注意的设计细节:

  1. $product.get(...)接收数据库原始列名(snake_case),不再有 V4 中大小写换算的麻烦;
  2. lambda是逐值转换。如果convertUsdToAud能一次批量转换多个币值,更高效的做法是用loadOne只调用一次,而不是用lambda每个值调用一次。

@pgField:指令消失,计划自然接管

@pgField从诞生起就是一个 workaround,在 V5 中它已无意义——只要你把正确的计划挂到正确的字段上,一切都会按预期工作,而且比 V4 的许多模式(尤其是 mutation payload 相关)更高效、更直白。

官方文档在此给出了一条实用建议:不要总想着在一个字段里做完所有事。更好的做法是给子字段各自分配计划,这样相关逻辑只在字段确实被请求时才执行,代码也会更简洁。这正是计划系统 "按需执行" 的核心价值——未被请求的子计划根本不会执行。

@pgQuery:内联 SQL 改为计划

V4 中@pgQuery用于把 SQL 内联进 GraphQL 操作,通常是作为性能优化,绕过 PostgreSQL 未能内联的计算列函数等问题。

V5 中这一需求交给计划处理。根据目标不同,你有多种计划可选。

叶子字段场景——需要在数据库而非 JS 中完成计算时,可以使用 SQL 表达式:

-module.exports = makeExtendSchemaPlugin(build => { +module.exports = extendSchema(build => { const { pgSql: sql } = build; return { typeDefs: gql` extend type User { - nameWithSuffix(suffix: String!): String! @pgQuery( - fragment: ${embed( - (queryBuilder, args) => - sql.fragment`(${queryBuilder.getTableAlias()}.name || ' ' || ${sql.value( - args.suffix - )}::text)` - )} - ) + nameWithSuffix(suffix: String!): String! } `, + objects: { + User: { + plans: { + nameWithSuffix($user, { $suffix }) { + return $user.select( + sql`${$user.getClassStep().alias}.name || ' ' || ${$user.placeholder($suffix, TYPES.text)}`, + TYPES.text, + ); + } + } + } + } }; });

关于 SQL 注入:上面的代码不是SQL 注入示例。它使用sql标签模板字符串函数(来自 pg-sql2 模块)确保所有参数都被正确处理为绑定参数,而不是字符串拼接。这正是pg-sql2设计的核心价值——所有值都必须通过sql.value(...)placeholder(...)包装。

更优的 JS 方案——官方文档指出,这个问题在 JS 中处理更简单也更高性能:

+ plans: { + User: { + nameWithSuffix($user, { $suffix }) { + return lambda( + [$user.get("name"), $suffix], + ([name, suffix]) => `${name} ${suffix}`, + ); + }, + }, + },

SQL 表达式计划的更多细节,可参考 @dataplan/pg 的文档与源码,例如 steps 目录 下的pgClassExpressionpgSelect等步骤实现。

@pgSubscription:订阅逻辑迁入subscribePlan

V4 中,@pgSubscription(来自@graphile/pg-pubsub)让你在 SDL 中嵌入一个 topic 生成器。V5 中应移除该指令,改为在 GrafastsubscribePlan中使用listen(...)承载逻辑。

V4 写法

import { makeExtendSchemaPlugin, gql, embed } from "graphile-utils"; const currentUserTopicFromContext = async (_args, context) => { if (!context.jwtClaims?.user_id) throw new Error("You're not logged in"); return `graphql:user:${context.jwtClaims.user_id}`; }; export default makeExtendSchemaPlugin(() => ({ typeDefs: gql` extend type Subscription { currentUserUpdated: UserSubscriptionPayload @pgSubscription(topic: ${embed(currentUserTopicFromContext)}) } type UserSubscriptionPayload { user: User event: String } `, resolvers: { UserSubscriptionPayload: { user(event) { /* ... */ }, }, }, }));

V5 写法

import { extendSchema } from "postgraphile/utils"; export default extendSchema((build) => { const { grafast: { context, get, listen, lambda }, dataplanJson: { jsonParse }, pgResources: { users }, } = build; return { typeDefs: /* GraphQL */ ` extend type Subscription { currentUserUpdated: UserSubscriptionPayload } type UserSubscriptionPayload { user: User event: String } `, objects: { Subscription: { plans: { currentUserUpdated: { subscribePlan(_$root, _args) { const $pgSubscriber = context().get("pgSubscriber"); const $userId = get(context().get("jwtClaims"), "user_id"); const $topic = lambda($id, (id) => `graphql:user:${id}`); return listen($pgSubscriber, $topic, jsonParse); }, plan($event) { return $event; }, }, }, }, UserSubscriptionPayload: { plans: { user($payload) { const $id = get($payload, "subject"); return users.get({ id: $id }); }, }, }, }, }; });

迁移的关键点在于:topic 选择现在是subscribePlan中的普通代码,而不是指令元数据context()拿到 GraphQL 上下文,get从上下文中提取字段,lambdauser_id转换为 topic 字符串,listen负责订阅 pgSubscriber 并解析事件,jsonParse把原始消息解析为结构化数据。

如果 V4 的 topic 来自字段参数,则在subscribePlan中用fieldArgs.getRaw(...)取原始参数:

const $forumId = fieldArgs.getRaw("forumId");

然后把这个 step 用于构造$topic即可。订阅相关的更多指导,参见 Realtime 与 Subscriptions。

selectGraphQLResultFromTable:被pgResource.execute取代

V4 中这个方法用于从 GraphQL resolver 内发起 "look-ahead" 增强数据获取,但始终有引入 N+1 问题的风险。许多用户觉得它令人困惑,经常拿它来给自己取数据在 resolver 里用——这完全偏离了它的设计意图。

V5 中不再需要这个辅助函数:每个计划步骤都被自动纳入规划系统,N+1 问题由 Grafast自动解决。获取数据的入口与填充数据的入口合二为一,不再有歧义。

官方文档演示了如何把 V4 文档中的示例移植到 V5:先找到代表match_user函数的pgResource,再为Query.matchingUser字段添加计划,把searchText参数传入函数执行:

-module.exports = makeExtendSchemaPlugin((build) => { +module.exports = extendSchema((build) => { + const matchUser = build.pgResources.match_user; return { typeDefs: /* GraphQL */ ` type Query { matchingUser(searchText: String!): User } `, - resolvers: { + plans: { Query: { - matchingUser: async (parent, args, context, resolveInfo) => { - const [row] = await resolveInfo.graphile.selectGraphQLResultFromTable( - sql.fragment`(select * from match_user(${sql.value( - args.searchText, - )}))`, - () => {}, // no-op - ); - return row; - }, + matchingUser($parent, { $searchText }) { + return matchUser.execute({ step: $searchText }); + }, }, }, }; });

注意:typeDefs/plans模式已弃用。上面展示的是最省事的迁移路径,但由于难以做到类型安全,官方更推荐新的typeDefs/objects模式——把计划放进对象类型内部:

-module.exports = makeExtendSchemaPlugin((build) => { +module.exports = extendSchema((build) => { + const matchUser = build.pgResources.match_user; return { typeDefs: /* GraphQL */ ` type Query { matchingUser(searchText: String!): User } `, - resolvers: { + objects: { Query: { - matchingUser: async (parent, args, context, resolveInfo) => { - const [row] = await resolveInfo.graphile.selectGraphQLResultFromTable( - sql.fragment`(select * from match_user(${sql.value( - args.searchText, - )}))`, - () => {}, // no-op - ); - return row; - }, + plans: { + matchingUser($parent, { $searchText }) { + return matchUser.execute({ step: $searchText }); + }, + }, }, }, }; });

你可以选择一次性迁移到新模式,也可以分两阶段过渡(先plansobjects)。

embed:没有替代品

目前没有embed的替代方案。理论上你不再需要它——如果确实遇到了非用它不可的场景,建议去 Graphile 官方社区(Discord)询问。在仓库中你仍能看到embed的残留形态,例如 PgV4SimpleSubscriptionsPlugin.ts 使用了EXPORTABLE(来自 graphile-utils)来把闭包序列化为可导出的代码,这是比embed更规范的同类场景解法。

Savepoints:按需事务取代保存点

PostGraphile V4 中,每个 GraphQL 请求都被包裹在一个事务里。为了符合 GraphQL 规范,每个 mutation 又必须包在SAVEPOINT中,确保单个 mutation 失败时其他 mutation 不会被回滚(即所谓的 "partial success" 部分成功)。

V5 中事务改为按需创建,savepoint 不再必要。这对关注SAVEPOINT子事务性能开销的应用(PostgreSQL 子事务在大量使用时确有性能影响)是个好消息。

context.pgClient.query:按需客户端 + 专用步骤

V4 在每个 GraphQL 请求开始时就准备一个 Postgres 客户端并放入事务(即使不需要),把它塞进 GraphQL context 的pgClient字段供 mutation 使用。

V5 的 Postgres 客户端改为按需供应

  • 自定义读取:使用loadOneWithPgClient()/loadManyWithPgClient(),这样仍能受益于 Grafast的批量(batching)能力;
  • mutation:根据是否需要显式事务,选择sideEffectWithPgClient()sideEffectWithPgClientTransaction()(仓库中还有直接暴露的withPgClientTransaction)。

需要说明的是,这里的 pgClient 是一个通用适配器(见 executor.ts 中的PgClient接口),你可以把喜欢的 Postgres 客户端(pgpostgrespg-promise等)接到上面使用。这些辅助函数均在 @dataplan/pg 的导出 中公开,其底层实现(如SideEffectWithPgClientStep类)位于 withPgClient.ts,核心思想是把回调执行建模为一个标准的 Grafaststep,从而纳入整个计划系统。

官方文档给出的完整自定义 mutation 示例:

import { object } from "postgraphile/grafast"; import { withPgClientTransaction } from "postgraphile/@dataplan/pg"; import { extendSchema } from "postgraphile/utils"; export default extendSchema((build) => { const { sql } = build; /** * 这里的 'executor' 告诉我们正在与哪个数据库通信,这是默认 executor。 * * 如果你要连接多个数据库,可以从 registry 中获取;默认 executor 名为 * `main`,但你可以通过 `pgServices` 配置项覆盖它并添加额外的 * sources/executors: * * const executor = build.input.pgRegistry.pgExecutors.main; */ const executor = build.pgExecutor; return { typeDefs: /* GraphQL */ ` input MyCustomMutationInput { count: Int } type MyCustomMutationPayload { numbers: [Int!] } extend type Mutation { """ 一个示例 mutation:本身不做有意义的事,用 Postgres 的 generate_series() 返回一组数字。 """ myCustomMutation(input: MyCustomMutationInput!): MyCustomMutationPayload } `, objects: { Mutation: { plans: { myCustomMutation(_$root, { $input: { $count } }) { /** * 这个 step 决定作为第二个参数传给 `withPgClientTransaction` * 回调的数据。通常是字段参数、GraphQL 上下文细节, * 或之前已执行 step 的数据。 */ const $data = object({ count: $count, }); // 回调会收到一个处于事务中的 client;它返回的普通数据 // 就是该 step 的结果;如果回调抛错,事务回滚且错误成为 // 该 step 的结果。 const $transactionResult = withPgClientTransaction( executor, $data, async (client, data) => { // 来自上面 $data step 的数据 const { count } = data; // 执行一些 SQL const { rows } = await client.query( sql.compile( sql`select i from generate_series(1, ${sql.value( count ?? 1, )}) as i;`, ), ); // 做一点异步工作(比如调用 Stripe 等) await sleep(2); // 在事务内再执行一些 SQL await client.query(sql.compile(sql`select 1;`)); // 返回稍后需要的任何数据 return rows.map((row) => row.i); }, ); return $transactionResult; }, }, }, MyCustomMutationPayload: { plans: { numbers($transactionResult) { return $transactionResult; }, }, }, }, }; });

这个示例展示了 V5 自定义 mutation 的完整骨架:extendSchema定义输入/载荷类型,objects.Mutation.plans里用withPgClientTransaction(executor, $data, callback)拿到事务内客户端,执行任意多条 SQL 与异步工作,返回值自动成为 payload 计划的数据来源。

QueryBuilder "named children":直接使用 Grafast步骤

这个概念在 V5 中已无用处,可以移植为更直接的 Grafast步骤。如果迁移中遇到困难,可在 Graphile 官方社区(Discord)寻求帮助。

QueryBuilder 本身:由pgSelect等步骤取代

QueryBuilder 在 V5 中已不存在,取而代之的是pgSelect及类似步骤上的辅助方法。你不再需要手动操作getTableAlias()、拼接 fragment 等底层细节——计划的表达方式更接近 "想要什么数据" 而非 "如何拼 SQL"。

build.getTypeAndIdentifiersFromNodeId:由specFromNodeId取代

这个辅助函数被specFromNodeId取代。每个实现 Node 接口的 GraphQL 类型都会注册一个 "node ID handler";如果你明确知道typeName,可以通过build.getNodeIdHandler(typeName)拿到它。由此可以确定编码 NodeID 所用的 codec,再把这两者连同 node ID 一起交给specFromNodeId,它会返回节点的规格(specification),典型形如{ id: $id }(其中$id是一个可执行 step),但不同节点类型可能有很大差异。

源码中的实现印证了这一点(见 node.ts):specFromNodeId接收 handler 与$idstep,内部先用lambda+decodeNodeIdWithHandler完成解码与handler.match(decoded)校验,再通过handler.getSpec($decoded)生成规格。当预期对象类型已知时(例如updateUser(id: ID!, ...)mutation),应优先使用specFromNodeId,它避免了NodeStep的额外多态开销。

示例:

const typeName = "User"; const handler = build.getNodeIdHandler(typeName); const objects = { Mutation: { plans: { updateUser(parent, fieldArgs) { const spec = specFromNodeId(handler, fieldArgs.$id); const plan = object({ result: pgUpdateSingle(userSource, spec) }); fieldArgs.apply(plan); return plan; }, }, }, };

pgUpdateSingle在仓库中的实现位于 pgUpdateSingle.ts,它与specFromNodeId产出的 spec 配合,即可在不触碰多态机制的情况下完成按 NodeID 的更新。

迁移速查:一张表看完所有对应关系

V4 旧机制V5 新机制说明
makeExtendSchemaPluginextendSchema(来自postgraphile/utils回调签名从(build, options)变为(build),options 在build.options
resolverplan(plansobjects.*.plans计划按需执行,未被请求的字段不执行
@requires(columns: [...])$parent.get('col_name')直接按数据库列名取值
@pgField直接为字段写计划指令本身不再有意义
@pgQuerySQL 表达式计划 或lambda数据库内计算用$user.select(...),JS 内计算用lambda
@pgSubscriptionsubscribePlan+listen(...)topic 选择从指令元数据变为普通代码
selectGraphQLResultFromTablepgResource.execute({ step })N+1 由 Grafast自动解决
embed无替代不再需要
Savepoints按需事务不再为每个请求预建事务
context.pgClient.queryloadOneWithPgClient/withPgClientTransaction客户端按需供应,仍可批量
QueryBuilder named children直接 Grafast步骤概念移除
QueryBuilderpgSelect等步骤的辅助方法底层查询构建被计划系统接管
build.getTypeAndIdentifiersFromNodeIdspecFromNodeId+build.getNodeIdHandler(typeName)已知类型时避免多态开销

迁移策略建议

最后给出三条实战建议:

  1. 先按原样平移:优先把typeDefs/resolvers平移为typeDefs/plans,这是最省事的迁移路径,官方文档也承认这一点;
  2. 再演进到类型安全模式:有余力时把plans收进objects内部,享受类型安全的收益;
  3. 逐字段而非逐插件迁移@pgQuery@pgSubscription等机制相互独立,可以按字段逐个替换,不必一次性推倒重来。

PostGraphile V5 的迁移本质上是一次思维转换:从 "告诉系统取哪些数据"(resolver + lookahead)转向 "声明数据如何从数据库流向客户端"(plan)。理解了这个转换,makeExtendSchemaPlugin时代的每一项 hack 都能在 Grafast的计划世界里找到更干净、更高效的归宿。

  • 后端
  • API网关

【免费下载链接】crystal

🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!

项目地址:https://gitcode.com/gh_mirrors/cry/crystal
点击查看免费下载
上一篇:实时音频导入神器:为Unreal Engine注入动态音频处理能力
下一篇:sbt性能优化:10个技巧让你的构建速度翻倍

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

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

ToastFish 使用指南:用 Windows 通知栏推送背单词的完整方法

ToastFish 使用指南:用 Windows 通知栏推送背单词的完整方法 【免费下载链接】ToastFish 一个利用摸鱼时间背单词的软件。 项目地址: https://gitcode.com/GitHub_Trending/to/ToastFish ToastFish 是一款运行在 Windows 通知栏(Toast&#xff09…

作者头像 李华