Keystone Query API 完全指南:用context.query以编程方式执行 GraphQL CRUD 操作
【免费下载链接】keystoneThe superpowered headless CMS for Node.js — built with GraphQL and React项目地址: https://gitcode.com/gh_mirrors/key/keystone
导读
Keystone 的 Query API 是一套面向程序员的 CRUD 操作接口,它让你不必手写 GraphQL 文档字符串,就能以context.query.<listName>的方式对系统中的每个列表执行增删改查,并精确控制返回字段。本文以官方参考文档为主体,结合仓库中 Query API 的实现源码 与 GraphQL 执行层、schema 生成代码 以及 API 测试用例,完整讲解九个操作方法、query参数语义、底层执行链路,以及它在 hooks、访问控制、脚本与测试中的典型用法。读完本文,你可以在不启动 HTTP 服务的情况下熟练使用 Query API 完成数据读写、批量操作与字段级投影。
认识 Query API:位于 Context 之上的编程式 CRUD 入口
在 Keystone 中,Context对象是系统所有运行时功能的主要 API 入口。它面向 GraphQL 的每个 resolver 提供,包含query、db、graphql、session、sudo、internal、withRequest、transaction、prisma等一系列能力(详见 Context 总览文档)。
其中的query即 Query API:对于系统中定义的每一个列表(List),它都在context.query.<listName>上暴露一组 CRUD 方法。例如定义了User、Post两个列表后,你可以直接调用context.query.User.*与context.query.Post.*。官方参考文档给出如下完整签名:
{ findOne({ where: { id }, query }), findMany({ where, take, skip, orderBy, query }), count({ where }), createOne({ data, query }), createMany({ data, query }), updateOne({ where: { id }, data, query }), updateMany({ data, query }), deleteOne({ where: { id }, query }), deleteMany({ where, query }), }这些函数的参数与它们对应的 GraphQL API 高度一致,因此你可以在“编程式 API”和“GraphQL API”之间轻松切换——同样的where过滤、take/skip分页、orderBy排序、嵌套关系写入语法,在两种形态下几乎一一对应。
底层原理:Query API 如何映射到 GraphQL schema
官方文档指出:“该 API 中的函数通过直接对 GraphQL API 执行查询(queries)和变更(mutations)来工作。”这一点在源码中得到完全印证。
在 packages/core/src/lib/context/api.ts 中,getQueryFactory从 schema 中取出 Query 与 Mutation 根类型,把每个 Query API 操作绑定到列表对应的 GraphQL 字段名上:
| Query API 方法 | 绑定的 GraphQL 字段(取自已初始化列表的graphql.names) |
|---|---|
findOne | itemQueryName(单条查询) |
findMany | listQueryName(列表查询) |
count | 手工拼接listQueryCountName(where: $where)并调用context.graphql.run |
createOne | createMutationName |
createMany | createManyMutationName |
updateOne | updateMutationName |
updateMany | updateManyMutationName |
deleteOne | deleteMutationName |
deleteMany | deleteManyMutationName |
从源码结构看,这是 Query API 与context.dbAPI 最大的设计差异之一:getQueryFactory走的是 makeContextQueryFn,而context.db走的是makeContextDbFn。对于 Query API,makeContextQueryFn会把你传入的query字段选择字符串解析成 GraphQL fragment(parse('fragment x on <RootType> {...}')),连同由字段参数生成的 variable definitions 一起拼装成完整的 GraphQL 操作文档,随后执行validate(schema, document)做合法性校验,再通过execute把args作为变量值传给当前context执行,最终返回result.data[fieldName]。这意味着:
- 你传入的
query选择集必须对目标返回类型合法,否则会抛出 schema 校验错误; args中的undefined值会被过滤掉再传给 GraphQL 执行层,避免干扰默认值(源码注释说明了这一点);- 如果某个操作因
graphql.omit或 access control 被禁用,对应字段在 schema 上不存在,getQueryFactory会返回一个抛错函数:“This <operation> is not supported by the GraphQL schema: <fieldName>()”。
对应的 GraphQL schema 字段本身由 getQueriesForList 生成——它依据list.graphql.isEnabled(query.one/query.many/query.count)决定是否注册findOne、findMany与count字段,并在withSpan中记录 OpenTelemetry span(query ${info.fieldName},附带keystone.list标签),便于观测每个查询的执行。
findOne:按唯一条件取单条记录
findOne通过where定位一条记录,where通常使用主键id:
const user = await context.query.User.findOne({ where: { id: '...' }, query: 'id name posts { id title }', });需要注意,where使用的是列表的uniqueWhere输入类型(见 getQueriesForList 中对g.nonNull(list.graphql.types.uniqueWhere)的使用),即必须提供能唯一定位的字段(如id,或配置为唯一的字段);对 singleton 列表,该参数有默认值{ id: '1' }。若查询不到记录,返回值为null。
findMany:过滤、分页与排序
findMany返回匹配条件的列表,支持where过滤、take/skip分页和orderBy排序:
const users = await context.query.User.findMany({ where: { name: { startsWith: 'A' } }, take: 10, skip: 20, orderBy: [{ name: 'asc' }], query: 'id name posts { id title }', });其中:
where使用列表的where 输入类型(非 uniqueWhere),支持字段级条件操作符(如startsWith、equals、contains等)以及 AND/OR/NOT 组合;take、skip控制返回条数与偏移量,用于分页;orderBy为数组,可指定多个排序键(如[{ name: 'asc' }, { createdAt: 'desc' }])。
若列表未启用query.many(即graphql.query.many为false),调用findMany会因 schema 中不存在对应字段而抛出上文提到的“not supported”错误。
count:统计符合条件的记录数
count仅接受where,返回满足条件的记录总数(数字类型):
const count = await context.query.User.count({ where: { name: { startsWith: 'A' } }, });从源码看,count的实现与其它方法略有不同:它直接调用context.graphql.run,执行query ($where: <WhereInput>!) { count: <listQueryCountName>(where: $where) }并返回count数值(见 api.ts)。当where省略时默认为空对象,即统计整张表。
createOne / createMany:创建单条与批量创建
createOne创建一条记录,data中可直接使用嵌套关系写入语法:
const user = await context.query.User.createOne({ data: { name: 'Alice', posts: { create: [{ title: 'My first post' }] }, }, query: 'id name posts { id title }', });createMany接收data数组,一次创建多条:
const users = await context.query.User.createMany({ data: [ { name: 'Alice', posts: { create: [{ title: 'Alices first post' }] }, }, { name: 'Bob', posts: { create: [{ title: 'Bobs first post' }] }, }, ], query: 'id name posts { id title }', });这段嵌套写法与 GraphQL mutation 的create/connect语义完全一致——你可以在创建父记录的同时嵌套创建关联子记录,query字段随后会按照你声明的结构返回嵌套结果。在仓库测试中可以看到大量同样的用法,例如 many-to-many 关系测试 中await context.query.Company.createOne({ ... })直接驱动测试夹具数据的构建,说明这是测试与脚本中初始化数据的标准姿势。
updateOne / updateMany:更新单条与批量更新
updateOne用where(uniqueWhere)定位记录,再用data描述变更:
const user = await context.query.User.updateOne({ where: { id: '...' }, data: { name: 'Alice', posts: { create: [{ title: 'My first post' }] }, }, query: 'id name posts { id title }', });updateMany则是“where + data”对的数组:
const users = await context.query.User.updateMany({ data: [ { where: { id: '...' }, data: { name: 'Alice', posts: { create: [{ title: 'Alices first post' }] }, }, }, { where: { id: '...' }, data: { name: 'Bob', posts: { create: [{ title: 'Bobs first post' }] }, }, }, ], query: 'id name posts { id title }', });与createMany不同,updateMany的data数组中的每一项都包含where(uniqueWhere)与data两部分,分别标识要更新的记录及其新值。data中同样支持嵌套关系操作(create、connect、disconnect、set等)。
deleteOne / deleteMany:删除单条与批量删除
deleteOne删除where.id指定的记录,并返回被删除记录的数据(由query决定返回哪些字段):
const user = await context.query.User.deleteOne({ where: { id: '...' }, query: 'id name posts { id title }', });deleteMany的where是一个 uniqueWhere 对象数组,一次删除多条:
const users = await context.query.User.deleteMany({ where: [{ id: '...' }, { id: '...' }], query: 'id name posts { id title }', });注意这里的差异:findMany/count的where是单个过滤条件对象,而deleteMany的where是 uniqueWhere 数组——每个元素用唯一字段(如id)标识一条待删除记录。这与 GraphQL 的deleteMany(where: [UserWhereUniqueInput!]!)签名一一对应。
query参数:字段投影与嵌套选择
所有操作(除count外)都接受query参数。它是一个字符串,指明该操作应返回哪些字段;默认值为'id'(该默认值由 makeContextQueryFn 中的query ?? 'id'实现)。因此即使你不传query,返回值也至少包含id。
query的写法与 GraphQL 内联选择集语法一致:
- 标量字段直接写名字:
'id name'; - 嵌套对象/关系字段用花括号展开:
'posts { id title }'; - 嵌套可继续向下展开任意深度,如
'posts { id title author { id name } }'。
由于query最终被解析为 fragment 选择集并经过 schema 校验(见上文底层原理),拼写错误的字段名会立即抛出校验错误,这为编程式调用提供了类似 GraphQL 的静态安全感。
如何获得一个可用的context
Query API 的典型使用场景包括:access control(访问控制)、hooks(钩子)、测试、GraphQL schema 扩展、数据迁移脚本。获取context有几种途径:
1. 在 resolver / hooks / 自定义扩展中直接使用
Keystone 会把Context作为所有 resolver 的context参数传入,因此在 hooks(如beforeOperation)、访问控制函数、schema 扩展的 resolver 里,直接使用context.query.<listName>即可。此时 access control 与 session 信息会随当前context一并传递。
2. 使用getContext脱离 HTTP 服务运行
官方 get-context 文档 提供了@keystone-6/core/context导出的getContext函数:只要此前运行过keystone build或keystone dev(配置变更后需先重新构建),就可以不启动 HTTP 服务、不触发构建流程直接获得 context——非常适合数据填充脚本、自定义协议(如小型 REST API)以及单元测试:
import { getContext } from '@keystone-6/core/context' import config from './keystone.ts' import * as PrismaModule from './generated/prisma/client.ts' const context = getContext(config, PrismaModule) // ... 接下来即可使用 context.query.User.* 等 API使用getContext时有两点值得注意:
- 这样创建的 context既没有隐式 session,也不是
sudo()context——它不会绕过访问控制; getContext每次调用都会实例化一个新的 Prisma Client,并非全局单例,使用不当可能触发“too many instances of Prisma Client”警告。
仓库中的examples/script示例项目正是利用 Node.js 内置 TypeScript 支持(需要 Node.js 22.18 及以上,并在tsconfig.json中开启"allowImportingTsExtensions": true)以getContext完成数据库种子数据的写入;也可用tsx等工具替代。
访问控制、session 与sudo:Query API 的权限语义
由context.query、context.graphql.run、context.graphql.raw发起的调用,都会把当前context上的访问控制与 session 信息透传过去(见 Context 总览)。也就是说,context.query默认受你的 access control 配置约束——它和 GraphQL API 走的是同一套访问控制、同一套校验。
当你需要临时绕过这些约束时,Context提供了派生新 context 的方法:
sudo():返回绕过 access control 的新 context,适用于context.query、context.db或context.graphql;internal():返回绕过graphql.omit(列表/字段的 API 隐藏配置)的新 context,可读写本应从 GraphQL API 中隐藏的数据;withRequest(req, res):基于请求对象重建 context,其.session由sessionStrategy.get决定;withSession(newSession):用指定 session 替换当前 context 的.session。
与之形成对照的是context.prisma:它直接暴露底层 Prisma Client,始终绕过 GraphQL schema 与访问控制,等价于总是处于.sudo()状态(官方文档明确给出了这一警告)。因此在需要走完整业务规则(访问控制、hooks、校验)的代码路径中,应优先使用context.query,而把context.prisma留给原始数据库操作。
与context.db的对比:何时选谁
context.db(Database API,详见 db-items 文档)与context.query拥有几乎相同的九个方法签名,但存在两个关键差异:
| 维度 | context.query(Query API) | context.db(DB API) |
|---|---|---|
| 执行目标 | 直接对 GraphQL API 执行 query/mutation,走完整 schema 与访问控制 | 直接执行内部 GraphQL resolver |
| 返回对象 | 由query字段选择集投影出的“查询值” | 内部 item 对象,适合从 schema 扩展的 mutation resolver 中直接返回 |
从源码看,context.db走makeContextDbFn(见 graphql.ts),它构造了一个特殊的包装 schema,把字段返回值包进ReturnRawValue类型并解包还原,从而把数据库行对象原样返回给调用方。因此官方建议:在 GraphQL schema 扩展中编写 mutation 需要把数据行作为返回值时,使用context.db;其余面向业务逻辑的读写,使用context.query。
测试中的实战验证
Query API 不仅是运行时 API,也是仓库自动化测试的主力。以 many-to-many 关系测试 为例,测试用例通过context.query.Company.createOne({ data: {...}, query: '...' })准备数据,再通过findMany、updateMany、deleteMany等断言关系型 CRUD 的行为;tests/api-tests 目录下大量测试(如defaults.test.ts、field-groups.test.ts等)均依赖context.query完成数据存取。这意味着 Query API 具备足够的稳定性和表达力,可以直接在你的项目测试与数据脚本中复用相同的模式。
小结
context.query是 Keystone 在“手写 GraphQL 字符串”与“原始数据库驱动”之间提供的第三态:它拥有 GraphQL 的字段投影、校验与访问控制能力,同时保持 JavaScript/TypeScript 调用的简洁与类型可读性。九个方法覆盖了单条/批量/计数的全部 CRUD 形态,query参数提供嵌套选择能力,配合getContext可在脚本与测试中脱离 HTTP 服务直接使用;结合sudo、internal、withSession等 context 派生方法,你可以精确控制每次调用的权限边界。理解 api.ts 与 graphql.ts 的执行链路后,你也能在遇到“operation not supported by the GraphQL schema”或字段校验错误时,快速定位是访问控制、graphql.omit还是字段名拼写导致的问题。
【免费下载链接】keystoneThe superpowered headless CMS for Node.js — built with GraphQL and React项目地址: https://gitcode.com/gh_mirrors/key/keystone
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考