Grafast 复杂输入处理实战:Baking 与 Applying 双模式完全指南
【免费下载链接】crystal🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址: https://gitcode.com/gh_mirrors/cry/crystal
本文是 Grafast(Graphile Crystal 仓库中的执行引擎)处理复杂 GraphQL 输入的高级指南。当输入数据嵌套较深、需要对输入做数据变换(如把 GraphQL 输入对象映射为后端字段格式)或行为变换(如让请求步骤支持动态过滤、排序、分页)时,Baking 与 Applying 两套可选机制能帮你把重复的输入处理代码收敛为类型级的声明式逻辑,保持代码整洁、可组合、易于推理。读完本文,你将掌握fieldArgs.getBaked()与fieldArgs.apply()的完整用法、Modifier的 fan-out/fan-in 原理,以及如何编写支持动态输入修改的 applyable step。
何时需要这两套机制
原文档开篇给出了一个重要的"免责声明":把原始输入值(参数)从 plan resolver 直接传给 step、留到执行期再处理,是正常且被推荐的做法;如果你的输入不复杂、这样直传没有带来任何问题,那么本文介绍的进阶特性完全可以跳过。
只有当出现以下信号时,才值得引入 Baking / Applying:
- 输入数据结构复杂,处理代码在多处重复出现,难以维护;
- 需要为不同的输入对象类型 / 输入字段定制统一的变换或应用逻辑;
- 希望让逻辑更整洁、可组合、易于推理。
在深入之前,先区分处理复杂输入数据的两种模式:
- Baking(烘焙,数据变换)——把输入数据变换成另一种形态(例如后端的表示形式)。典型场景:GraphQL 的
camelCase输入对象 → 后端snake_case的 DTO。 - Applying(应用,行为变换)——让输入数据去修改 step 将要执行的行为。典型场景:分页、过滤、自定义排序——输入不产生"新数据",而是告诉 step "你要怎么干"。
两种模式的共同点是:在运行时递归遍历输入值树(对象、列表、标量),按需调用每个字段或每种类型对应的逻辑。
Baking:把输入数据烘焙成后端表示
Baking 的本质是data in, data out。你拿到一个 GraphQL 输入对象值,把它变换为后端需要的表示。例如:
input AvatarInput { url: String! # ... } input UserInput { userId: Int! avatar: AvatarInput # ... }运行时可能收到:
{ "userId": 27, "avatar": { "url": "http://..." } }经烘焙后变为后端表示:
{ "user_id": 27, "avatar_url": "http://..." }在 Schema 中定义烘焙
Baking 按输入对象类型定义,通过extensions.grafast.baked(input, info)方法挂载,其类型为InputObjectTypeBakedResolver。该类型的精确定义见 interfaces.ts:
type InputObjectTypeBakedInfo = { schema: GraphQLSchema; type: GraphQLInputObjectType; applyChildren(val: any): void; }; type InputObjectTypeBakedResolver = ( input: Record<string, any>, info: InputObjectTypeBakedInfo, ) => any;参数含义:
input—— 原始 GraphQL 输入对象值;info.applyChildren(parent)—— 如果你希望以不同的 parent 对象继续递归处理子字段,就调用它;这会复用下方 "Applying" 的运行时行为(inputArgsApply);- 返回值 —— 即最终"烘焙后"的表示。
如果某输入对象类型没有实现baked,原始输入会原样透传——这是默认行为。对应地,bakedInput()辅助函数(见 bakedInput.ts)只有在类型是列表、或输入对象类型上定义了baked函数时,才会创建专门的BakedInputStep;否则直接返回原始$value,零额外开销。
运行时机制与 applyChildren
BakedInputStep在unbatchedExecute中调用bakedInputRuntime()(见 bakedInput.ts)完成真正的递归变换,其细节值得注意:
- 值为
null/undefined时原样返回; - 列表类型逐项递归;
- 调用类型的
baked函数,并把applyChildren(parent)注入到info中; applyChildren内部用inputArgsApply以你传入的parent为靶向递归应用各子字段的变换逻辑;- 若你的
baked没有调用applyChildren,框架会自动以baked的返回值作为 parent去应用各子字段——保证子字段逻辑不会丢失; - 整个烘焙过程被
withModifiers()包裹,意味着烘焙过程中产生的Modifier也会被收集并按反向顺序执行(详见下文 Applying 的 fan-in 部分)。
在 plan resolver 中获取烘焙值
在 plan resolver 中,使用fieldArgs.getBaked(path)即可产出某个原始输入值的烘焙版本:
const $baked = fieldArgs.getBaked(["path", "to", "input"]);path可以是字符串(参数名),也可以是string | number组成的路径数组(用于深入到嵌套对象或列表元素)。其实现见 operationPlan-input.ts:先通过getRaw(path)拿到原始输入 step,再用typeAt(path)解析路径对应的输入类型,最后调用bakedInput(inputType, $raw)生成烘焙 step。
Applying:让输入改变 step 的行为
Applying 关注的是用输入值去改变 step 会做什么。它不产出烘焙值,而是把输入应用到一个知道如何接收它们的 step 上——例如一个使用 request builder 去构造发送给数据库、URL 端点或其他数据源请求的 step。
在 Schema 中定义应用
Applying 按输入字段定义,通过inputField.extensions.grafast.apply(target, input, info)方法挂载,其类型为InputObjectFieldApplyResolver,定义同样在 interfaces.ts:
type InputObjectFieldApplyResolver<TParent = any, TData = any, TScope = any> = ( target: TParent, input: TData, info: { schema: GraphQLSchema; fieldName: string; field: GraphQLInputField; scope: TScope; }, ) => any;参数含义:
target—— 传入的父对象(例如 request builder);input—— 该输入字段的原始值;info—— 包含 schema、字段名、字段定义与 scope(实验性,见下文"底层原理")。
apply 方法被期望修改target——可以直接改,也可以通过返回Modifier间接改(见下文 fan-out 与 fan-in)。
apply 方法的返回值有三种可能:
- 返回
undefined—— 不做进一步递归; - 返回一个新的父对象 —— 子字段递归时将以它为 parent;
- (列表类型时)返回一个工厂函数—— 每个列表元素调用一次工厂函数,为该元素生成各自的 parent 对象。
返回工厂函数(例如() => new Thing())是OR这类过滤器的关键:列表中的每个条目都拥有自己的子条件。
示例:简化自postgraphile-plugin-connection-filter
fields["or"] = { apply( parent: PgCondition, value: ReadonlyArray<LogicalOperatorInput> | null, ) { if (value == null) return; const orCondition = parent.orPlan(); // 每个列表条目只有在自身被完整处理后才会加入 orCondition—— // 如果单个条目产生多个子句,必须先以 AND 连接,再并入整个 OR。 return () => orCondition.andPlan(); }, type: new GraphQLList(new GraphQLNonNull(UserFilter)), };Fan-out 与 fan-in:Modifier类
当你返回一个新对象供子字段使用时,就发生了fan-out(扇出):每个子字段可以在隔离的状态下添加自己的修改。但如果你需要在之后fan back in(扇回)——例如收集所有子条件、用OR组合、再把组合结果应用到父对象上——该怎么办?
这就是Modifier登场的地方。
如果返回的对象继承了Modifier类,Grafast会在 apply 过程中跟踪它:当整个输入树遍历完成后,Grafast会以逆序回头遍历收集到的 modifiers,并调用它们的apply()方法。这给了你一个最终钩子,把组合结果推回父对象。
Modifier类的实际实现位于 applyInput.ts:
/** 扇出完成后会以逆序应用 */ const currentModifiers: Modifier<any>[] = []; /** * Modifier 修改它们的 parent(parent 可能是另一个 modifier 或任何其他对象)。 * 首先它们从被应用到其上的子对象(如果有)收集全部需求, * 然后通过 `apply()` 方法把自己应用到 parent 上。 */ export abstract class Modifier<TParent> { protected readonly parent: TParent; constructor(parent: TParent) { this.parent = parent; if (applyingModifiers) { throw new Error( `Must not create new modifier whilst modifiers are being applied!`, ); } currentModifiers.push(this); } /** * 在此方法中,你应该把修改应用到 `this.parent` 上 */ abstract apply(): void; }配套的withModifiers()(见 applyInput.ts)负责收集与逆序执行:cb()执行期间新构造的 modifier 进入当前集合;cb结束(即整个输入树遍历完成)后,从后往前依次调用每个 modifier 的apply()。这也解释了为何在 modifier 应用期间禁止再创建新的 modifier(构造函数会抛错)。
使用 modifier 让OR示例更加干净:每个子条目把条件贡献给 modifier;所有条目处理完毕后,modifier 的apply()被调用,把组合好的OR子句加到它的 parent 上。
在 plan resolver 中应用输入
FieldArgs.apply()是把输入参数应用到 step 上的入口:
function usersPlan($query, fieldArgs) { const $users = UsersStep.find(); // 把所有参数应用到 users step 上 fieldArgs.apply($users); return $users; }也可以只针对某个参数路径,并可选地提供回调,在应用前先变换 step 的值:
fieldArgs.apply($target, ["filter"], (requestBuilder, inputValue) => { // 把 step 的值(如 request builder)转换成 filter builder 对象 return new FilterBuilder(requestBuilder, inputValue); // < 一个 Modifier });apply()的实现细节见 operationPlan-input.ts,有几个值得注意的行为:
- 路径为空时(
fieldArgs.apply($target)),会自动禁用 autoApply并遍历所有参数逐个应用; - 同一个输入路径不能重复应用,重复调用会抛出错误("Multiple applications are not currently supported");
- 若路径对应的值在规划期就是
ConstantStep且数据为undefined(即未传参),会跳过应用——不会产生多余的依赖; - 内部会调用
$target.apply(applyInput(typeAtPath, $valueAtPath, getTargetFromParent)),把"应用动作"作为一元依赖注册到目标 step 上。
Applyable step:能被输入驱动的 step
要让 applying 生效,传给fieldArgs.apply($target)的$target必须是一个applyable step——即一个支持在运行时接受输入驱动的修改的 step。其类型定义在 applyInput.ts:
// 简化后的类型 type ApplyableStep = Step & { apply($cb: Step<(arg: any) => void>): void; };(另有配套的类型守卫isApplyableStep(),通过检测typeof s.apply === "function"判断。)
一个 applyable step 有两项职责,确保所有输入在 step 执行其动作之前都有机会修改 builder:
职责一:规划期收集回调 step
step 必须实现apply($cb: Step<(parent: any) => void>)方法,把$cb注册为一个一元依赖(unary dependency)。由于多个参数可能作用于同一个 step,.apply()可能被调用多次,因此要把所有依赖 ID 存进一个数组:
class MyRequestStep extends Step { applyDepIds: number[] = []; apply($cb: Step<(parent: any) => void>) { this.applyDepIds.push(this.addUnaryDependency($cb)); } // ... }职责二:运行期执行收集到的回调
在execute()中,step 先准备好内部对象(例如 request builder)。注意:这个对象绝不能是Modifier,它应当是那个"可被修改的可变事物"。然后按顺序遍历收集到的回调,把该对象传进去执行;最后用填充完毕的 builder 发起请求:
class MyRequestStep extends Step { // ... async execute(details) { const { values, indexMap } = details; const builder = { //... // 用你已经知道的东西填充 request builder }; // 应用所有 `.apply($cb)` 调用带来的修改 for (const applyDepId of this.applyDepIds) { const applyCallback = values[applyDepId].unaryValue(); applyCallback(builder); } // 执行底层请求,并把结果与输入关联回来 const results = await builder.execute(); return indexMap((batchIndex) => results.getResultForIndex(batchIndex)); } }下面是一个更完整的示例,演示如何用.apply()根据用户输入动态改变数据库结果的排序方式:
import { Step, ExecutionDetails, GrafastResultsList, Maybe } from "grafast"; interface MyQueryBuilder { orderBy(columnName: string, ascending?: boolean): void; } type Callback = (builder: MyQueryBuilder) => void; class MyQueryStep extends Step { private applyDepIds: number[] = []; // [...] // this.foreignKeyDepId = this.addDependency($fkey); // [...] // 只处理 `Step<Callback>` 已够用,但组合类型最灵活。 apply($cb: Step<Maybe<Callback | ReadonlyArray<Callback>>>) { this.applyDepIds.push(this.addUnaryDependency($cb)); } async execute( executionDetails: ExecutionDetails, ): Promise<GrafastResultsList<Record<string, any>>> { const { values, indexMap } = executionDetails; const foreignKeyEV = values[this.foreignKeyDepId]; // 创建 query builder 收集 orderBy 值 const orderBys: string[] = []; const builder: MyQueryBuilder = { orderBy(columnName, asc = true) { orderBys.push(`${columnName} ${asc ? "ASC" : "DESC"}`); }, }; // 对每个 `apply()` 回调,把它作用于 query builder for (const applyDepId of this.applyDepIds) { const callback = values[applyDepId].unaryValue(); if (Array.isArray(callback)) { callback.forEach((cb) => cb(builder)); } else if (callback != null) { callback(builder); } } // 现在可以用 orderBys 构建查询了: const query = ` select * from my_table where foreign_key = any($1) order by ${orderBys} `; // 然后获取数据: const allForeignKeys = indexMap((i) => foreignKeyEV.at(i)); const rows = await runQuery(query, [allForeignKeys]); // 并把与每个输入值对应的正确数据返回: return indexMap((i) => { const foreignKey = foreignKeyEV.at(i); return rows.filter((r) => r.foreign_key === foreignKey); }); } }底层原理
fieldArgs.apply()内部使用了applyInput()step(定义于 applyInput.ts)。正常情况下你不应直接调用它,但在 plan 图中你会看到ApplyInput节点——它是定位规划问题的线索。
applyInput()的另一个作用点是applyScope:这是实验性功能,可提供额外的 scope 值在 applying 过程中透传。scope 值挂在输入对象或枚举类型的extensions.grafast.applyScope()上(applyInput在构造ApplyInputStep时读取namedType.extensions?.grafast?.applyScope?.()作为$scope依赖),并最终出现在InputObjectFieldApplyResolver的info.scope中。
inputArgsApply递归(见 applyInput.ts)完整覆盖了非空、列表、输入对象、标量、枚举五类情况,其中值得注意的规则:
- 列表类型下,每个列表项都会调用工厂函数(若 target 是函数)生成独立子目标,并且每个列表项独立包裹在
withModifiers()中,保证顺序正确; - 输入对象仅对有
extensions.grafast.apply的字段调用 apply 逻辑; - 枚举值的
extensions.grafast.apply也会被执行; undefined值直接跳过。
在 schema 构建端,baked与apply由 makeGrafastSchema.ts 统一接线:InputObjectTypeBakedResolver、InputObjectFieldApplyResolver被读取后写入对应输入类型/字段的extensions.grafast上。
如何选择 Baking 还是 Applying
- 如果你只是需要把数据变换成后端期望的形态,用baking(
baked+fieldArgs.getBaked()); - 如果你需要影响行为——例如告诉 step 如何过滤、排序或分页——用applying(
apply+fieldArgs.apply())。
两种模式可以在同一个 schema 中自由混用,按每个输入的具体诉求选择最合理的方案即可。
完整可运行示例
仓库中的 complexInputs.mts 是一个可直接运行(grafast执行并带断言)的完整示例,它同时演示了三件事:
- 用
fieldArgs.getBaked("patch")+inputObject.baked实现 Baking; - 用
fieldArgs.apply($request, "filter")实现 Applying; - 通过
Modifier实现or: [UserFilterInput!]列表的 fan-out/fan-in。
Schema 与输入
input AvatarPatchInput { url: String! width: Int } input UserPatchInput { displayName: String marketingOptIn: Boolean avatar: AvatarPatchInput } input UserFilterInput { usernameStartsWith: String minAge: Int or: [UserFilterInput!] } type SearchPreview { sql: String! patchJSON: String! } type Query { previewSearch(filter: UserFilterInput, patch: UserPatchInput): SearchPreview! }执行时传入的变量:
{ "filter": { "usernameStartsWith": "benj", "minAge": 18, "or": [{ "minAge": 30 }, { "usernameStartsWith": "alice", "minAge": 25 }] }, "patch": { "displayName": "Benjie", "marketingOptIn": true, "avatar": { "url": "https://cdn.example.com/avatar.png", "width": 128 } } }烘焙端:patch 输入 → 后端 DTO
inputObjects: { AvatarPatchInput: { baked(_input, info) { const baked: Partial<BakedAvatarPatch> = {}; info.applyChildren(baked); // 递归,把子字段烘焙进新对象 return baked as BakedAvatarPatch; }, plans: { url(target: Partial<BakedAvatarPatch>, value: string) { target.avatar_url = value; // camelCase → snake_case }, width(target: Partial<BakedAvatarPatch>, value: number | null) { if (value != null) { target.avatar_width = value; } }, }, }, UserPatchInput: { baked(_input, info) { const baked: Partial<BakedUserPatch> = {}; info.applyChildren(baked); return baked as BakedUserPatch; }, plans: { displayName(target: Partial<BakedUserPatch>, value: string | null) { if (value != null) { target.display_name = value; } }, marketingOptIn(target: Partial<BakedUserPatch>, value: boolean | null) { if (value != null) { target.marketing_opt_in = value; } }, avatar(target: Partial<BakedUserPatch>, value: unknown) { if (value == null) return; const bakedAvatar: Partial<BakedAvatarPatch> = {}; target.avatar = bakedAvatar as BakedAvatarPatch; return bakedAvatar; // 返回新对象以继续递归 }, }, }, // ... }注意这里的plans(在makeGrafastSchema的inputObjects配置中)实际承载的就是前面文档说的extensions.grafast.baked/extensions.grafast.apply逻辑——baked 定义对象级变换,plans 中的每个字段定义"当该字段出现在输入中时对 parent 做什么"。
应用端:filter 输入 → SQL 条件
UserFilterInput: { plans: { usernameStartsWith(target: Filterable, value: Maybe<string>) { if (value == null) return; target.addClause(`username ilike '${value.replace(/'/g, "''")}%'`); }, minAge(target: Filterable, value: Maybe<number>) { if (value == null) return; target.addClause(`age >= ${value}`); }, or(target: Filterable, value: Maybe<ReadonlyArray<unknown>>) { if (value == null) return; if (value.length === 0) return; // 列表条目之间用 "or" 连接 const or = new FilterModifier(target, "or"); // 但每个条目自身是对象,其内部属性之间要用 "and" 连接 return () => new FilterModifier(or, "and"); }, }, }Modifier 与 applyable step
class FilterModifier extends Modifier<Filterable> implements Filterable { private type: BooleanOp; private clauses: string[] = []; constructor(input: Filterable, type: BooleanOp) { super(input); this.type = type; } addClause(clause: string) { this.clauses.push(clause); } apply(): void { if (this.clauses.length === 0) return; if (this.type === "and") { this.parent.addClause(this.clauses.join(" and ")); } else { if (this.clauses.length === 1) { this.parent.addClause(`(${this.clauses[0]})`); } else { this.parent.addClause(`((${this.clauses.join(") or (")}))`); } } } }SearchRequestStep同时展示了两个职责:构造时以烘焙后的 patch step 为普通依赖(addDependency),运行时把apply回调收集到的 clause 与 patch 数据合并,最终通过details.indexMap()把结果与输入一一对应:
class SearchRequestStep extends Step<{ sql: string; patchJSON: string }> { private readonly patchDepId: number; private readonly applyDepIds: number[] = []; constructor($patch: Step<BakedUserPatch>) { super(); this.patchDepId = this.addDependency($patch); } apply($cb: Step<FilterCallbacks>) { this.applyDepIds.push(this.addUnaryDependency($cb)); } execute(details: ExecutionDetails) { const patchDep = details.values[this.patchDepId] as ExecutionValue<BakedUserPatch>; const clauses: string[] = []; // 可放入初始子句 const applyCallbacks = this.applyDepIds .flatMap((id) => details.values[id].unaryValue() as FilterCallbacks) .filter((cb) => cb != null); const filterable: Filterable = { addClause: (clause) => void clauses.push(clause), }; for (const callback of applyCallbacks) { callback(filterable); } const sql = `select * from users${ clauses.length > 0 ? ` where ${clauses.join(" and ")}` : "" }`; // 这里才是真正执行查询的位置——整个 execute 只调用一次 return details.indexMap((i) => { const patch = patchDep.at(i); return { sql, patchJSON: JSON.stringify(patch) }; }); } }plan resolver 与运行结果
objects: { Query: { plans: { previewSearch(_parent, fieldArgs) { // 把 "patch" 输入经过其 baked 变换,得到变换后的值 const $bakedPatch = fieldArgs.getBaked("patch"); // 创建代表搜索请求的 step const $request = searchRequest($bakedPatch); // 把 "filter" 参数(递归地)应用到请求上 fieldArgs.apply($request, "filter"); return $request; }, }, }, }示例的断言结果显示了两套机制协同的产物:
{ "previewSearch": { "sql": "select * from users where username ilike 'benj%' and age >= 18 and ((age >= 30) or (username ilike 'alice%' and age >= 25))", "patchJSON": "{\"display_name\":\"Benjie\",\"marketing_opt_in\":true,\"avatar\":{\"avatar_url\":\"https://cdn.example.com/avatar.png\",\"avatar_width\":128}}" } }可以看到:patch 被正确烘焙为snake_case的 DTO;filter 被应用为带括号分组、正确结合AND/OR优先级的 SQL 条件。
测试佐证:列表输入的 apply 行为
applyInput-list-object-test.ts 是覆盖 Applying 在列表输入上行为的测试,其中FilterModifier(每个列表项一个)与FilterCollectorStep(规划期通过ConstantStep判断实现"常量应用优化"、运行期执行动态回调)给出了另一个可参考的 applyable step 写法。四个用例分别验证:
- 字面量列表:
filters: [{field:"name",value:1},{field:"email",value:2}]正确产出["name=1","email=2"]; - 整个输入是变量:
input: $input同样正确; - 列表字段是变量:
input: { filters: $filters }正确; - 列表中某个元素是变量:
filters: [{...}, $filter]也正确——说明 apply 的递归对"变量与字面量混合嵌套"的输入树同样成立。
这份测试证实了 Applying 机制在设计上完整支持 GraphQL 变量、字面量以及两者任意嵌套组合的输入形态,值得在实现自己的 applyable step 时对标参考。
【免费下载链接】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),仅供参考