PostGraphile 原始插件 API 实战:用 Graphile Engine 钩子深度扩展你的 GraphQL Schema
【免费下载链接】crystal🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址: https://gitcode.com/gh_mirrors/cry/crystal
本篇技术指南以 PostGraphile v4 文档《Schema Plugins — Graphile Engine》为核心,系统讲解如何绕过graphile-utils高级辅助函数,直接使用 Graphile Engine 底层插件钩子(hook)为 PostGraphile 自动生成的 GraphQL Schema 添加根级字段、包装既有 resolver、以及精准移除指定类型与字段。读完本文你将掌握builder.hook()的核心调用约定、GraphQLObjectType:fields与GraphQLObjectType:fields:field两个关键钩子的用法、addArgDataGenerator的 look-ahead 数据请求机制,以及通过--append-plugins将自定义插件加载进 PostGraphile 的完整链路,并能据此在你的项目中编写可直接运行的定制插件。
背景:PostGraphile 的 Schema 就是 Graphile Engine 插件的组合
PostGraphile 的 GraphQL Schema 并非单一程序一次性生成的产物,而是由大量 Graphile Engine 插件逐层构建而成。每个插件只引入一小块功能,并彼此叠加——例如"为表生成 OrderBy 枚举"、"为外键生成关联字段"、"为计算列生成字段"都分别由不同的插件负责。
在 PostGraphile v4 时代,核心 PG 相关插件的源码位于
graphile-engine仓库的packages/graphile-build-pg/src/plugins目录下;插件之间的加载顺序具有语义,该顺序由graphile-build-pg模块src/index.ts中的defaultPlugins导出定义。顺序之所以重要,是因为后加载的插件可以依赖先前插件在build对象上注册的类型、inflector 与工具函数。
你可以通过两种方式干预默认插件列表:
- 追加插件(推荐):在默认插件之前或之后追加自己的插件,使用 CLI 的
--append-plugins或库模式的appendPlugins选项; - 整体替换:完全替换用于构建 Schema 的插件列表(
skipPlugins/appendPlugins的组合可达到等效目的)。
Graphile Engine 插件体系构建在 GraphQL 参考实现(graphql-js)之上,因此编写自定义插件前,建议先熟悉 GraphQL 的字段、类型、resolver 与GraphQLObjectType等基础概念。
当前仓库的 v4 兼容层还保留了对应的 V5 实现线索:在 postgraphile/postgraphile/src/presets/v4.ts 中可以看到appendPlugins?: GraphileConfig.Plugin[]与skipPlugins?: GraphileConfig.Plugin[]选项被声明,底层由PgV4BuildPlugin、PgV4BehaviorPlugin等插件承接近乎相同的职责,说明 v4 插件 API 的设计思想在 v5 中得到了延续(只是从"过程式函数"演进为"声明式对象")。
加载自定义插件:CLI 与库模式
在进入具体钩子编写之前,先明确插件的装载方式。文档 extending.mdx 给出了标准做法。
通过 CLI 加载
# 加载本地文件(注意使用 `pwd` 展开绝对路径) postgraphile \ --append-plugins `pwd`/add-http-bin-plugin.js \ -c postgres:///mydb # 或加载 npm 包插件 postgraphile \ --append-plugins postgraphile-plugin-connection-filter \ -c postgres:///mydb--append-plugins接受逗号分隔的模块规格列表,每条规格是:
- JS 文件的绝对路径,或 npm 模块名;
- 可选地跟一个冒号和导出名:
my-npm-module(要求module.exports = function NpmPlugin(...))或/path/to/module.js:MyPlugin(要求exports.MyPlugin = function MyPlugin(...))。
通过库模式加载
const ConnectionFilterPlugin = require("postgraphile-plugin-connection-filter"); app.use( postgraphile(process.env.DATABASE_URL, "app_public", { appendPlugins: [ConnectionFilterPlugin /* 在此继续追加更多插件 */], graphiql: true, }), );⚠️多版本
graphql冲突:多个版本的graphql同时存在于node_modules会导致类型不匹配等诡异问题。官方强烈建议使用Build对象(钩子的第二个参数)上的build.graphql命名空间,而不是自行require独立版本。
核心概念:builder.hook钩子调用约定
所有原始插件本质是一个接收builder的工厂函数:
function MyPlugin(builder, options) { builder.hook("HookName", (input, build, context) => { // ...过滤与变换... return input; }); }钩子回调的三个参数约定如下:
| 参数 | 含义 |
|---|---|
input | 当前钩子的输入对象,例如GraphQLObjectType:fields钩子的输入就是该对象类型的所有字段映射({ fieldName: FieldConfig }) |
build | Build 对象,内含extend、getTypeByName、pgSql、inflection等大量工具 |
context | 上下文对象,最常用的是context.scope(用于过滤),也包含addArgDataGenerator、Self等属性 |
铁律:钩子回调必须返回输入对象(或经过变换后的新对象),否则下游插件将失去数据源。
添加根级 Query / Mutation 字段
最常见的需求是为 Schema 增加根级字段,例如集成外部服务(REST API、第三方 SDK)。
推荐路线:makeExtendSchemaPlugin
如果只是简单添加字段,官方推荐使用 make-extend-schema-plugin.md 中讲解的makeExtendSchemaPlugin,它可以用类似graphql-tools的语法合并类型与 resolver(可用于 Schema 任意位置,而不仅是根级):
// add-http-bin-plugin.js const { makeExtendSchemaPlugin, gql } = require("graphile-utils"); const fetch = require("node-fetch"); module.exports = makeExtendSchemaPlugin({ typeDefs: gql` extend type Query { httpBinHeaders: JSON } `, resolvers: { Query: { async httpBinHeaders() { const response = await fetch("https://httpbin.org/headers"); return response.json(); }, }, }, });底层路线:GraphQLObjectType:fields钩子
如果你需要 look-ahead 特性,或者要以更自动化的方式批量定义字段,可以降级到原始插件 API。核心思路是挂接GraphQLObjectType:fields钩子,借助context.scope.isRootQuery过滤出根 Query 类型,然后用extend向字段映射追加新字段:
// add-http-bin-plugin-raw.js const fetch = require("node-fetch"); function AddHttpBinPlugin(builder, { pgExtendedTypes }) { builder.hook( "GraphQLObjectType:fields", ( fields, // 输入对象——该 GraphQLObjectType 的字段映射 { extend, getTypeByName }, // Build 对象——实用工具 { scope: { isRootQuery } }, // Context 对象——用于过滤 ) => { if (!isRootQuery) { // 不是我们想修改的对象类型:原样返回输入 return fields; } // 直接复用 Schema 中已有的 JSON 类型,避免重复定义引发类型冲突: const JSONType = getTypeByName("JSON"); return extend(fields, { httpBinHeaders: { type: JSONType, async resolve() { const response = await fetch("https://httpbin.org/headers"); if (pgExtendedTypes) { // 当通过 postgraphile 的 `--dynamic-json` 选项启用时,返回 JSON 对象 return response.json(); } else { // 未启用 Dynamic JSON 时,返回 JSON 字符串 return response.text(); } }, }, }); }, ); } module.exports = AddHttpBinPlugin;要点解读:
- 钩子对每个对象类型都会执行,所以
isRootQuery过滤必不可少; getTypeByName("JSON")用于获取既有类型。新增字段的返回类型不必通过 Graphile Engine 的newWithHooks创建——标准 GraphQL 对象同样可用(如上面的JSONType);但要注意:未通过newWithHooks创建的对象无法被后续插件扩展;- 若要添加 Mutation 根级字段,把
isRootQuery换成isRootMutation即可; pgExtendedTypes来自插件的选项(对应 PostGraphile 的--dynamic-json),演示了插件选项的消费方式。
进一步深化:@pgQuery与selectGraphQLResultFromTable
上述示例的 resolver 只访问外部 HTTP 服务。当新增字段需要返回数据库记录(表、视图、函数返回值)时,直接使用context.pgClient.query无法利用 PostGraphile 的 look-ahead 机制,也就无法正确加载嵌套关联。此时应当使用resolveInfo.graphile.selectGraphQLResultFromTable(返回记录/连接/列表),或在非根级字段上使用@pgQuery指令(v4.4.0+)直接声明数据源,例如在User类型上挂一个pets: PetsConnection @pgQuery(source: ..., withQueryBuilder: ...)。详细用法参见同目录文档 make-extend-schema-plugin.md。
包装既有 resolver:在 SQL 执行前后插入自定义逻辑
有时需要覆盖既有字段的默认行为。由于 PostGraphile 的工作方式——只有根级 Query 字段的 resolver 负责执行 SQL 查询——resolver 包装在最顶层(根级字段)最有价值。
快速路线:makeWrapResolversPlugin
PostGraphile v4.1+ 提供了 make-wrap-resolvers-plugin.md 中的makeWrapResolversPlugin,按"类型名 → 字段名 → 包装函数/规则"声明即可:
module.exports = makeWrapResolversPlugin({ User: { async email(resolve, source, args, context, resolveInfo) { const result = await resolve(); // 调用被包装的原 resolver return result.toLowerCase(); }, }, });⚠️ 由于 look-ahead 的存在,包装 resolver 一般不会改变生成的 SQL。若你想影响系统如何执行(如过滤、排序),只应在根级 resolver 上包装;但用于修饰返回值(掩码私密数据、归一化等)则任意字段都安全。
底层路线:GraphQLObjectType:fields:field钩子 +addArgDataGenerator
当需要更强的控制力时,降级到原始 API。与前文GraphQLObjectType:fields操作"字段列表"不同,GraphQLObjectType:fields:field操作"单个字段",它还能拿到addArgDataGenerator——这是 Graphile Engine look-ahead 系统的入口之一,用于"提前声明"本次查询需要额外请求的数据。
下面的例子包装createLinkMutation:先校验title长度,插入后执行附加任务,全程借助addArgDataGenerator保证link.id无论如何都被请求回来:
function performAnotherTask(linkId) { console.log(`We created link ${linkId}!`); } module.exports = function CreateLinkWrapPlugin(builder) { builder.hook( "GraphQLObjectType:fields:field", ( field, { pgSql: sql }, { scope: { isRootMutation, fieldName }, addArgDataGenerator }, ) => { if (!isRootMutation || fieldName !== "createLink") { // 该钩子对 Schema 中每个对象类型的每个字段都会执行; // 只有根 Mutation 上的 createLink 才需要处理,其余原样返回。 return field; } // 利用 addArgDataGenerator 保证 link.id 总是被 SELECT, // 即使客户端没有请求它。别名以 `__` 开头——GraphQL 规范禁止 // 用户字段以此开头,因此绝不会与用户字段冲突。 addArgDataGenerator(() => ({ pgQuery: (queryBuilder) => { queryBuilder.select( // 从 INSERT 的返回结果中选取 id: sql.query`${queryBuilder.getTableAlias()}.id`, // 在结果数据中的名字: "__createdRecordId", ); }, })); // 字段可能没有显式 resolve,此时回退到默认 resolver: const defaultResolver = (obj) => obj[fieldName]; // 从 field 中摘出旧 resolver(可能不存在): const { resolve: oldResolve = defaultResolver, ...rest } = field; return { // 除 resolve 外全部保留: ...rest, // 新增包装 resolver: async resolve(...resolveParams) { // 1) 在调用旧 resolver 之前做校验(或其他前置动作) const RESOLVE_ARGS_INDEX = 1; const { input: { link: { title }, }, } = resolveParams[RESOLVE_ARGS_INDEX]; if (title.length < 3) { throw new Error("Title is too short!"); } // 2) 调用旧 resolver。注意:除非同时修改传给它的 // 第 4 个参数(AST),否则不应改动其入参,代价很高。 const oldResolveResult = await oldResolve(...resolveParams); // 3) 记录创建成功后的附加任务 await performAnotherTask(oldResolveResult.data.__createdRecordId); // 4) 返回原结果 return oldResolveResult; }, }; }, ); };这个模式的用途非常广:用户注册后发邮件、记录失败的认证尝试、审计日志……几乎任何"在数据库操作前后附加副作用"的场景都可以套用。
从 Schema 中移除元素:删插件 vs 删字段
⚠️ 危险警告
以"移除后再添加同名类型/字段"的方式操作 Schema 可能引发不可预期的后果(类型/字段冲突、钩子行为异常)。官方建议:与其事后移除,不如从源头避免其生成。
💡 想简单阻止某些表、字段、函数或关联进入 Schema?请优先使用 smart-comments.mdx 介绍的 smart comments(如
@omit),而不是编写移除插件。
移除"一类事物":直接移除对应插件
如果想去掉一整类功能,最干净的做法是不加载生成它们的插件。例如:
- 不再允许按表的所有列排序(只允许主键排序)→ 省略
PgOrderAllColumnsPlugin; - 不需要计算列 → 省略
PgComputedColumnsPlugin。
(这些插件源码位于graphile-build-pg的src/plugins/目录,可从其defaultPlugins导出中定位并剔除。)
移除"某个具体事物":挂接到所有者对象
当需要外科手术式的精准移除(如从Foo类型删掉bar字段),应挂接"拥有该事物的对象"的钩子,返回删减后的字段集。下面是一个可复用的插件生成器,用于按对象名+字段名移除单个字段:
const omit = require("lodash/omit"); function removeFieldPluginGenerator(objectName, fieldName) { const fn = function (builder) { builder.hook("GraphQLObjectType:fields", (fields, _, { Self }) => { if (Self.name !== objectName) return fields; return omit(fields, [fieldName]); }); }; // 便于调试: fn.displayName = `RemoveFieldPlugin:${objectName}.${fieldName}`; return fn; } const RemoveFooDotBarPlugin = removeFieldPluginGenerator("Foo", "bar"); module.exports = RemoveFooDotBarPlugin;要点:
context.Self.name用于识别当前正在构建的对象类型,仅在目标类型上执行删除;fn.displayName不是必须的,但能显著改善插件调试与报错信息可读性;- 该示例用于演示移除插件的写法,实际项目中 smart comments 通常是更优解。
从 v4 原始插件到 v5:钩子体系的演进速览
如果你正在把 v4 原始插件迁移到 PostGraphile v5,了解以下对应关系(详见 migrating-custom-plugins.md)有助于理解本文 API 的设计动机:
- v4 的过程式插件
(builder) => { builder.hook(...) }演进为 v5 的声明式对象{ name, version, schema: { hooks: { ... } } }; - 钩子名中的
:被替换为_:GraphQLObjectType:fields→GraphQLObjectType_fields; - v5 的 Grafast 计划系统取代了 v4 的 look-ahead 引擎,
addArgDataGenerator、QueryBuilder、selectGraphQLResultFromTable等 API 不复存在,QueryBuilder.getTableAlias()大致对应$pgSelect.alias,queryBuilder.where(...)对应$pgSelect.where((sql) => ...); - v4 的
newWithHooks被build.registerObjectType/registerInterfaceType等注册方法取代; - v4 的
appendPlugins/skipPlugins选项在 v4.ts 中仍被声明,作为兼容层将 v4 配置转换为 v5 的 Graphile Config 插件体系。
总结与最佳实践清单
| 场景 | 推荐方案 | 底层 API |
|---|---|---|
| 添加字段/类型 | makeExtendSchemaPlugin(graphile-utils) | GraphQLObjectType:fields钩子 +extend |
| 包装 resolver | makeWrapResolversPlugin(v4.1+) | GraphQLObjectType:fields:field钩子 +addArgDataGenerator |
| 需要 look-ahead 的数据库字段 | @pgQuery指令 /selectGraphQLResultFromTable | 数据生成器(v4 特有) |
| 移除整类功能 | 省略对应插件(如PgOrderAllColumnsPlugin) | — |
| 精准移除单个字段 | 优先 smart comments(@omit) | GraphQLObjectType:fields钩子 +omit |
编写原始插件的三条纪律:
- 每个钩子必须返回变换后的输入对象,否则会破坏后续插件链;
- 用
context.scope过滤目标(isRootQuery、isRootMutation、Self.name等),因为钩子对所有匹配对象都会触发; - 优先使用
build.graphql命名空间,避免多版本graphql冲突,并尽量复用 Schema 既有类型(如getTypeByName("JSON"))而不是重新创建。
【免费下载链接】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),仅供参考