在 Keystone 6 中使用 graphql-ts 扩展 GraphQL Schema:自定义 Query、Mutation 与类型实战
2026/9/24 19:41:08 网站建设 项目流程
  • 后端

【免费下载链接】keystone

The superpowered headless CMS for Node.js — built with GraphQL and React

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

本文以仓库中的 extend-graphql-schema-graphql-ts 示例 为核心,完整讲解如何通过graphql.extendGraphqlSchema配置项与@graphql-ts/schema@graphql-ts/extend为 Keystone 自动生成的 GraphQL API 增加自定义查询、变更与全新对象类型。读完本文,你将掌握g.extend的类型安全扩展写法、base.object复用现有类型、context.dbcontext.query的选型差异,以及如何利用base.schema.extensions.scope区分外部 API 与内部 schema。

示例项目概览:一个最小可运行的 Schema 扩展工程

该示例位于 examples/extend-graphql-schema-graphql-ts,是一个独立的、可直接运行的 Keystone 6 工程,其目录结构如下:

文件作用
keystone.tsKeystone 入口配置,声明 SQLite 数据库并在graphql段接入extendGraphqlSchema
schema.ts定义Post/Author两个列表,并用g.extend编写全部自定义 Query 与 Mutation
schema.graphqlKeystone 自动生成并提交入库的 GraphQL Schema 文件,可直接核对扩展结果
schema.prismaKeystone 依据列表定义自动生成的 Prisma 模型文件
prisma.config.tsPrisma 配置(schema 路径、迁移目录、数据源 URL)
package.json工程脚本与依赖声明

README 中明确说明该项目建立在 Keystone 官方的 Blog 示例项目之上:从 schema.ts 的列表定义可以确认,它沿用了博客场景中"作者(Author)— 文章(Post)"的一对多关系模型,并在此基础上演示如何为这套模型扩充聚合统计、批量发布等原生 CRUD 之外的能力。

环境准备与快速启动

按照 README 的 Instructions,运行方式如下:

  1. 克隆 Keystone 仓库到本地;
  2. 在仓库根目录执行pnpm install安装依赖(仓库使用 pnpm workspace 管理所有 packages 与 examples,根目录存在 pnpm-workspace.yaml,示例中的@keystone-6/core依赖以workspace:^形式指向本地源码包);
  3. 进入示例目录并启动开发服务器:
cd examples/extend-graphql-schema-graphql-ts pnpm dev

pnpm dev实际执行的是keystone dev(见 package.json),它会启动开发服务器并在localhost:3000提供两个入口:

  • Admin UIhttp://localhost:3000):用于在数据库中创建Post/Author数据,方便后续验证自定义接口;
  • GraphQL Playgroundhttp://localhost:3000/api/graphql):可直接运行 GraphQL 查询与变更,是验证自定义 Query / Mutation 的最快途径。

工程还提供了完整的构建与运行脚本:

pnpm build # keystone build,生成 Prisma Client、GraphQL Schema 与类型产物 pnpm start # keystone start,以生产模式启动 pnpm check # keystone postinstall,校验工程配置

数据库使用 SQLite,数据文件默认为file:./keystone-example.db,可通过环境变量DATABASE_URL覆盖(见 keystone.ts 与 prisma.config.ts)。

接入方式:graphql.extendGraphqlSchema配置项

扩展逻辑的挂载点位于 Keystone 配置的graphql.extendGraphqlSchema选项。完整的配置入口如下:

// keystone.ts import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3' import { config } from '@keystone-6/core' import { lists, extendGraphqlSchema } from './schema' export default config({ db: { provider: 'sqlite', prismaClientOptions: () => ({ adapter: new PrismaBetterSqlite3({ url: process.env.DATABASE_URL || 'file:./keystone-example.db', }), }), }, graphql: { extendGraphqlSchema, }, lists, })

从 Keystone 源码的类型定义看,该配置项签名是extendGraphqlSchema?: (schema: GraphQLSchema) => GraphQLSchema(见 packages/core/src/types/config/index.ts)——它接收一个由 Keystone 根据列表定义生成的GraphQLSchema,并要求返回一个合法的 GraphQL Schema。在 packages/core/src/lib/graphql.ts 中,createGraphQLSchema先基于列表构建基础 schema,再执行:

// merge in the user defined graphQL API return config.graphql?.extendGraphqlSchema?.(graphQLSchema) ?? graphQLSchema

也就是说,无论你使用g.extend@graphql-tools/schemamergeSchemas,还是其他第三方 schema 工具,只要传入一个「输入基础 schema、输出新 schema」的函数即可完成扩展。本示例选择的是 Keystone 官方推荐的graphql-ts方案,即g.extend(base => ({ query, mutation }))

数据模型:Post 与 Author 列表

扩展逻辑作用的对象是 schema.ts 中定义的两个列表:

export const lists = { Post: list({ access: allowAll, fields: { title: text({ validation: { isRequired: true } }), status: select({ type: 'enum', options: [ { label: 'Draft', value: 'draft' }, { label: 'Published', value: 'published' }, { label: 'Banned', value: 'banned' }, ], }), content: text(), publishDate: timestamp(), author: relationship({ ref: 'Author.posts', many: false }), }, }), Author: list({ access: allowAll, fields: { name: text({ validation: { isRequired: true } }), email: text({ isIndexed: 'unique', validation: { isRequired: true } }), posts: relationship({ ref: 'Post.author', many: true }), }, }), } satisfies Lists

值得注意的点:

  • status是一个select枚举字段,取值draft/published/banned,后续publishPostbanPost两个自定义 Mutation 正是围绕它实现的;
  • satisfies Listslists接受Lists类型检查,同时保留字面量类型推断,便于g.extend内联扩展时对base.object('Post')等字符串引用保持精确;
  • 对应的 Prisma 模型(schema.prisma)由 Keystone 自动生成,Post.authorAuthor.posts通过关系字段Post_author关联。

g.extend 的类型安全机制:从gWithContextg

示例中扩展代码的头部有一行容易被忽略的关键声明:

import { gWithContext, list } from '@keystone-6/core' import type { Context, Lists } from './generated/keystone/types' const g = gWithContext<Context>() type g<T> = gWithContext.infer<T>

这里的Context来自 Keystone 生成的./generated/keystone/typeskeystone dev/build时自动生成),它描述了每个列表的精确返回类型。gWithContext的底层实现在 packages/core/src/types/schema/gWithContext.ts,它本质上是@graphql-ts/schemaGWithContext、Keystone 标量集合与@graphql-ts/extendextend函数的组合:

export function gWithContext<Context extends KeystoneContext<any>>(): GWithContext<Context> & typeof scalars & { extend: typeof extend } { return { ...baseGWithContext<Context>(), ...scalars, extend, } }

而 Keystone 默认导出的g则是预先绑定到 Keystone 默认KeystoneContext的单例(见 packages/core/src/types/schema/g.ts):

export const g = gWithContext<KeystoneContext>()

用法要点:如果你的 resolver 只需使用 Keystone 标准的Context,直接import { g } from '@keystone-6/core'即可;只有当你的应用给 context 注入了自定义字段、需要更精确的类型时,才用gWithContext<MyContext>()重新绑定。本示例采用后者,展示了面向自定义 context 的推荐写法。

g.extend的回调参数base提供两个核心能力:

  • base.object('Post'):从基础 schema 中按名称取出已有对象类型(不存在则抛错),用于复用 Keystone 生成的列表类型作为返回类型;
  • base.schema.extensions.scope:读取基础 schema 上携带的'public' | 'internal'标记(来源见下文),用于区分当前构建的是对外 API 还是内部 schema。

自定义类型 Statistics:聚合统计的三种字段写法

示例用g.object定义了一个全新的Statistics对象类型,演示"定义新类型 + 在 resolver 中组合查询":

const Statistics = g.object<{ authorId: string }>()({ name: 'Statistics', fields: { draft: g.field({ type: g.Int, resolve({ authorId }, args, context) { return context.query.Post.count({ where: { author: { id: { equals: authorId } }, status: { equals: 'draft' } }, }) }, }), published: g.field({ type: g.Int, resolve({ authorId }, args, context) { return context.query.Post.count({ where: { author: { id: { equals: authorId } }, status: { equals: 'published' } }, }) }, }), latest: g.field({ type: base.object('Post'), async resolve({ authorId }, args, context) { const [post] = await context.db.Post.findMany({ take: 1, orderBy: { publishDate: 'desc' }, where: { author: { id: { equals: authorId } } }, }) return post }, }), }, })

三个字段分别示范了三种典型写法:

  1. draft/published:使用context.query.Post.count统计某个作者对应状态的文章数。context.query返回的是普通数据对象,适合用于Int这类标量结果;
  2. latest:使用context.db.Post.findMany取该作者publishDate最新的一篇文章,返回类型声明为base.object('Post')(即复用 Keystone 的Post类型)。这里必须使用context.db——因为返回类型是 Keystone 的列表对象,context.db提供的是符合 GraphQL 输出格式的内部对象,若误用context.query会因字段格式不符而在客户端解析时出错(源码注释明确警告了这一点);
  3. g.object<{ authorId: string }>()的泛型参数声明了该类型的 source 形状——stats查询的 resolver 只返回{ authorId: id },真正的聚合计算被下放到各字段的 resolver 中执行,这是 GraphQL 对象类型「按需懒解析」的典型用法。

自定义 Mutation:publishPost 与仅内部可见的 banPost

return { mutation: { publishPost: g.field({ type: base.object('Post'), args: { id: g.arg({ type: g.nonNull(g.ID) }) }, resolve(source, { id }, context) { return context.db.Post.updateOne({ where: { id }, data: { status: 'published', publishDate: new Date().toISOString() }, }) }, }), // only add this mutation to the internal schema (this is not usable from the API) ...(base.schema.extensions.scope === 'internal' ? { banPost: g.field({ type: base.object('Post'), args: { id: g.arg({ type: g.nonNull(g.ID) }) }, resolve(source, { id }, context) { return context.db.Post.updateOne({ where: { id }, data: { status: 'banned' }, }) }, }), } : {}), }, ... }

publishPost接受一个非空的ID参数(g.nonNull(g.ID)),把对应文章的状态改为published并写入当前时间,返回更新后的Post。它直接调用context.db.Post.updateOne,与Statistics.latest同理,因为返回类型是base.object('Post')

banPost演示了一个进阶技巧:通过base.schema.extensions.scope === 'internal'判断当前构建的 schema 是内部还是公开版本,从而把某些变更只暴露给内部 schema。其原理在 packages/core/src/lib/graphql.ts 与 packages/core/src/lib/graphql.ts:createGraphQLSchema会以scope: 'public' | 'internal'两种 scope 分别构建 schema,并把 scope 写入GraphQLSchemaextensions。因此banPost会被合并进内部 schema(供 Admin UI 等内部场景使用),却不会出现在对外 API 中。这一点在 tests/api-tests/extend-graphql-schema.test.ts 中有直接验证:

it('Identifies whether the schema is public or internal', runner(async () => { expect(observedSchemaExtensions.slice(-2)).toEqual([ { scope: 'public' }, { scope: 'internal' }, ]) }))

自定义 Query:recentPosts 与 stats

query: { recentPosts: g.field({ type: g.list(g.nonNull(base.object('Post'))), args: { id: g.arg({ type: g.nonNull(g.ID) }), seconds: g.arg({ type: g.nonNull(g.Int), defaultValue: 600 }), }, resolve(source, { id, seconds }, context) { const cutoff = new Date(Date.now() - seconds * 1000) return context.db.Post.findMany({ where: { author: { id: { equals: id } }, publishDate: { gt: cutoff } }, }) }, }), stats: g.field({ type: Statistics, args: { id: g.arg({ type: g.nonNull(g.ID) }) }, resolve(source, { id }) { return { authorId: id } }, }), },
  • recentPosts返回「某个作者最近seconds秒内发布(publishDate大于截止时间)的文章列表」,返回类型是g.list(g.nonNull(base.object('Post')))——非空元素的Post列表。seconds参数带defaultValue: 600,调用方不传时默认取最近 10 分钟;
  • stats返回上节定义的Statistics类型,resolver 只负责把id塞进 source({ authorId: id }),具体聚合交给字段级 resolver。

这两条查询是标准的 GraphQL 分层 resolver 实践:外层查询只做参数透传,真正的数据获取与过滤下沉到字段或依赖 Keystone 内置 API 完成。

验证扩展结果:自动生成的 schema.graphql

运行pnpm dev后,Keystone 会在工程根目录生成/更新schema.graphql。在该示例提交的 schema.graphql 中,可以直观核对上述扩展是否生效:

type Mutation { createPost(data: PostCreateInput!): Post # ... 其余 CRUD 变更 publishPost(id: ID!): Post } type Query { # ... 其余列表查询 recentPosts(id: ID!, seconds: Int! = 600): [Post!] stats(id: ID!): Statistics } type Statistics { draft: Int published: Int latest: Post }

注意两点:

  1. 文件头部注释明确写着「This file is automatically generated by Keystone, do not modify it manually」,因此扩展 API 的唯一正确入口是g.extend代码,而不是直接编辑schema.graphql
  2. Mutation中只有publishPost,没有banPost——与「内部 schema 专属」的设计一致;Query中新增的recentPostsstats以及Statistics类型与源码定义一一对应,说明g.extend的扩展结果会被完整并入最终 schema。

配套测试与延伸阅读

仓库的 API 测试 tests/api-tests/extend-graphql-schema.test.ts 为extendGraphqlSchema提供了行为级佐证,除了上文提到的 scope 断言外,还覆盖了:

  • 自定义 Query 正常执行(double(x: 10)返回 20);
  • 自定义 Mutation 正常执行(triple(x: 10)返回 30);
  • 扩展字段同样受 access control 约束(quads因访问函数返回 false 而抛出Access denied);
  • Keystone 内置 resolver 不受扩展影响(createUser照常工作)。

这四条用例可以当作你为自定义扩展编写测试时的参照模板。

若想进一步深入,仓库内还有两处高价值资料:

  • GraphQL Schema 扩展指南:系统讲解g.extend与第三方工具(@graphql-tools/schemamergeSchemas)两种路线,并提示@keystone-6/core3.0 之前由graphQLSchemaExtension导出的能力已改为直接使用第三方工具;
  • config.md 中 extendGraphqlSchema 章节:给出配置项的类型签名与基础示例;
  • 同目录下的姊妹示例 extend-graphql-schema-graphql-tools 与 extend-graphql-schema-nexus:分别演示使用@graphql-tools/schema和 Nexus 完成同样的扩展目标,方便对比不同工具链的取舍。

总的来说,本示例是理解 Keystone GraphQL 扩展机制的理想起点:g.extend提供了与@graphql-ts/schema一脉相承的类型安全体验,base.object打通了自定义类型与既有列表类型的复用,而scope机制则让你能够精细控制扩展能力在对外 API 与内部 schema 之间的暴露范围。

  • 后端

【免费下载链接】keystone

The superpowered headless CMS for Node.js — built with GraphQL and React

项目地址:https://gitcode.com/gh_mirrors/key/keystone
点击查看免费下载
上一篇:Chewie性能优化实战:解决Android缓冲状态问题的终极方案
下一篇:Ruby定时任务的分布式锁:基于Whenever的并发控制方案

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询