- 后端
【免费下载链接】keystone
The superpowered headless CMS for Node.js — built with GraphQL and React
本文以仓库中的 extend-graphql-schema-graphql-ts 示例 为核心,完整讲解如何通过graphql.extendGraphqlSchema配置项与@graphql-ts/schema、@graphql-ts/extend为 Keystone 自动生成的 GraphQL API 增加自定义查询、变更与全新对象类型。读完本文,你将掌握g.extend的类型安全扩展写法、base.object复用现有类型、context.db与context.query的选型差异,以及如何利用base.schema.extensions.scope区分外部 API 与内部 schema。
示例项目概览:一个最小可运行的 Schema 扩展工程
该示例位于 examples/extend-graphql-schema-graphql-ts,是一个独立的、可直接运行的 Keystone 6 工程,其目录结构如下:
| 文件 | 作用 |
|---|---|
keystone.ts | Keystone 入口配置,声明 SQLite 数据库并在graphql段接入extendGraphqlSchema |
schema.ts | 定义Post/Author两个列表,并用g.extend编写全部自定义 Query 与 Mutation |
schema.graphql | Keystone 自动生成并提交入库的 GraphQL Schema 文件,可直接核对扩展结果 |
schema.prisma | Keystone 依据列表定义自动生成的 Prisma 模型文件 |
prisma.config.ts | Prisma 配置(schema 路径、迁移目录、数据源 URL) |
package.json | 工程脚本与依赖声明 |
README 中明确说明该项目建立在 Keystone 官方的 Blog 示例项目之上:从 schema.ts 的列表定义可以确认,它沿用了博客场景中"作者(Author)— 文章(Post)"的一对多关系模型,并在此基础上演示如何为这套模型扩充聚合统计、批量发布等原生 CRUD 之外的能力。
环境准备与快速启动
按照 README 的 Instructions,运行方式如下:
- 克隆 Keystone 仓库到本地;
- 在仓库根目录执行
pnpm install安装依赖(仓库使用 pnpm workspace 管理所有 packages 与 examples,根目录存在 pnpm-workspace.yaml,示例中的@keystone-6/core依赖以workspace:^形式指向本地源码包); - 进入示例目录并启动开发服务器:
cd examples/extend-graphql-schema-graphql-ts pnpm devpnpm dev实际执行的是keystone dev(见 package.json),它会启动开发服务器并在localhost:3000提供两个入口:
- Admin UI(
http://localhost:3000):用于在数据库中创建Post/Author数据,方便后续验证自定义接口; - GraphQL Playground(
http://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/schema的mergeSchemas,还是其他第三方 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,后续publishPost、banPost两个自定义 Mutation 正是围绕它实现的;satisfies Lists让lists接受Lists类型检查,同时保留字面量类型推断,便于g.extend内联扩展时对base.object('Post')等字符串引用保持精确;- 对应的 Prisma 模型(schema.prisma)由 Keystone 自动生成,
Post.author与Author.posts通过关系字段Post_author关联。
g.extend 的类型安全机制:从gWithContext到g
示例中扩展代码的头部有一行容易被忽略的关键声明:
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/types(keystone dev/build时自动生成),它描述了每个列表的精确返回类型。gWithContext的底层实现在 packages/core/src/types/schema/gWithContext.ts,它本质上是@graphql-ts/schema的GWithContext、Keystone 标量集合与@graphql-ts/extend的extend函数的组合:
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 }, }), }, })三个字段分别示范了三种典型写法:
draft/published:使用context.query.Post.count统计某个作者对应状态的文章数。context.query返回的是普通数据对象,适合用于Int这类标量结果;latest:使用context.db.Post.findMany取该作者publishDate最新的一篇文章,返回类型声明为base.object('Post')(即复用 Keystone 的Post类型)。这里必须使用context.db——因为返回类型是 Keystone 的列表对象,context.db提供的是符合 GraphQL 输出格式的内部对象,若误用context.query会因字段格式不符而在客户端解析时出错(源码注释明确警告了这一点);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 写入GraphQLSchema的extensions。因此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 }注意两点:
- 文件头部注释明确写着「This file is automatically generated by Keystone, do not modify it manually」,因此扩展 API 的唯一正确入口是
g.extend代码,而不是直接编辑schema.graphql; Mutation中只有publishPost,没有banPost——与「内部 schema 专属」的设计一致;Query中新增的recentPosts、stats以及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/schema的mergeSchemas)两种路线,并提示@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
相关推荐
使用 Nexus 扩展 Keystone 的 GraphQL API:自定义 Query/Mutation 实战指南
使用 Nexus 扩展 Keystone 的 GraphQL API:自定义 Query/Mutation 实战指南 导读 Keystone 会自动根据你的列表
后端使用 @graphql-tools/schema 扩展 Keystone GraphQL API:自定义查询、变更与类型的完整实战
使用 @graphql tools/schema 扩展 Keystone GraphQL API:自定义查询、变更与类型的完整实战 导读 :Keystone 会
后端免费 macOS 录屏教程:QuickRecorder 7 种录制模式快速上手指南
免费 macOS 录屏教程:QuickRecorder 7 种录制模式快速上手指南 录制一堂网课、做一段产品演示,或者想保存别人屏幕上的操作,往往会被商业录屏软
桌面应用音视频屏幕录制
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考