RedwoodJS 教程实战:从 Prisma 建模到 Service 测试,为博客添加完整评论功能
【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood
本篇技术指南以 RedwoodJS 官方教程第 6 章为核心,完整演示如何在 RedwoodJS 全栈框架中为博客应用新增"评论"功能:从在schema.prisma中定义带关系(relation)的Comment模型、执行数据库迁移,到生成 GraphQL SDL 与 Service、按需开放@skipAuth/@requireAuth权限,最后用 Redwood 特有的 Scenario 机制编写真实数据库层的服务测试。读完本文,你将掌握 RedwoodJS 中"数据建模 → 迁移 → SDL/Service → 测试"这一条完整的后端开发链路,并理解 Prisma 关系查询、GraphQL 嵌套解析与测试场景数据的设计思路。
为什么 Cell 跑通之后,仍必须补齐后端
在 RedwoodJS 的 Cell 开发流程中,我们可以在完全没有后端的情况下,用 Storybook 与 Jest 提供的假数据(mock)把组件"设计、实现并测试"完毕。评论列表的 Cell 组件正是这样诞生的:它通过 GraphQL 查询获取数据,而这个数据理论上来自数据库。
但这并不意味着可以永远"免费午餐"。前端组件已经就绪,接下来必须完成真正的后端工作——把评论数据落库并通过 GraphQL 暴露出去。如果你已经完成教程前半部分,对这个流程不会陌生:
- 在
schema.prisma中新增一个数据模型 - 运行
yarn rw prisma migrate dev创建迁移并应用到数据库 - 生成 SDL 与 Service
下面按此顺序完整走一遍。
第一步:在 schema.prisma 中定义 Comment 模型
打开api/db/schema.prisma,在原有模型(Post、Contact、User)之外新增Comment模型。这也是本教程中第一次在两个模型之间建立关系(relation):
datasource db { provider = "sqlite" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" binaryTargets = "native" } model Post { id Int @id @default(autoincrement()) title String body String comments Comment[] createdAt DateTime @default(now()) } model Contact { id Int @id @default(autoincrement()) name String email String message String createdAt DateTime @default(now()) } model User { id Int @id @default(autoincrement()) name String? email String @unique hashedPassword String salt String resetToken String? resetTokenExpiresAt DateTime? } model Comment { id Int @id @default(autoincrement()) name String body String post Post @relation(fields: [postId], references: [id]) postId Int createdAt DateTime @default(now()) }Comment模型中,大部分字段(id、name、body、createdAt)与之前见过的写法一致,真正的新知识点是关系的两端:
post:类型为Post,配合@relation(fields: [postId], references: [id])告诉 Prisma:Comment通过自身字段postId去引用Post表的id字段;postId:一个普通的Int列,存放所关联Post的id,也就是数据库里的外键列。
同时在Post模型里也反向声明了comments Comment[],表示一篇 Post 可以拥有多条 Comment。这种"一对多"的关系在数据库中呈现为经典的模型结构:
┌───────────┐ ┌───────────┐ │ Post │ │ Comment │ ├───────────┤ ├───────────┤ │ id │───┐ │ id │ │ title │ │ │ name │ │ body │ │ │ body │ │ createdAt │ └──<│ postId │ └───────────┘ │ createdAt │ └───────────┘注意:Comment表中并不存在名为post的真实数据库列,它只是 Prisma 用来串联两个模型、并让你在查询代码中引用这条连接的"虚拟"字段。借助它,可以用 Prisma Client 从 Comment 一路取到它所属的 Post:
db.comment.findUnique({ where: { id: 1 } }).post()反向同理,Prisma 为Post自动添加了便捷的comments字段,实现反向查询:
db.post.findUnique({ where: { id: 1 } }).comments()这种@relation(fields: [...], references: [...])的声明方式,在 RedwoodJS 自带的测试工程中也有实际应用,例如__fixtures__/fragment-test-project/api/db/schema.prisma里就存在author User @relation(fields: [authorId], references: [id])以及带onDelete: Cascade级联删除的关系写法,可作为学习 Prisma 关系语法的对照参考。
第二步:运行数据库迁移
模型定义完成后,创建并执行迁移:
yarn rw prisma migrate dev按提示为本次迁移命名,例如create comment。该命令会基于 schema 的差异生成迁移文件并应用到开发数据库。
:::tip 测试数据库需要重启测试进程 如果你此时还开着测试套件,需要先退出(Ctrl-C或按q)。Redwood 会为测试单独创建一份数据库(默认位于.redwood/test.db),并且迁移只会在测试套件启动时应用到这份测试库,而不是运行期间。因此只有重启测试进程,才能基于新的表结构跑测试。 :::
第三步:生成 SDL 与 Service
SDL(Schema Definition Language)用来定义 GraphQL 接口,Service 则负责从数据库取数据。用 Redwood 的生成器一次性创建两者:
yarn rw g sdl Comment --no-crud关键在于--no-crud标志:它不会生成全套增删改查接口,只提供最基础的只读能力,方便我们从零开始逐步添加功能(这与之前生成 Post 相关代码时"免费获得全部 CRUD"正好相反)。
让匿名用户可以查看评论
生成器默认会在 Query 字段上挂@requireAuth指令,要求登录后才能访问。评论是公开内容,因此需要把comments查询上的@requireAuth改为@skipAuth:
export const schema = gql` type Comment { id: Int! name: String! body: String! post: Post! postId: Int! createdAt: DateTime! } type Query { comments: [Comment!]! @skipAuth } input CreateCommentInput { name: String! body: String! postId: Int! } input UpdateCommentInput { name: String body: String postId: Int } `TypeScript 项目使用.ts扩展名的同名文件(api/src/graphql/comments.sdl.ts),内容结构一致。
这里涉及 RedwoodJS 的校验指令(Validator Directive)机制:@requireAuth与@skipAuth本质上是由createValidatorDirective创建的指令,当 GraphQL 字段解析完成(resolved)后,指令函数会对返回值执行校验。在仓库源码 packages/graphql-server/src/directives/makeDirectives.ts 中可以看到,createValidatorDirective(schema, directiveFunc)会把指令包装成带有onResolvedValue回调的ValidatorDirective,Redwood 再通过 useRedwoodDirective 插件 在 GraphQL 执行链路中触发校验逻辑。简言之:@skipAuth表示"任何请求都放行",@requireAuth表示"必须携带有效身份信息,否则抛错"。
完成这一步后,回到真实浏览器(而非 Storybook)刷新页面,之前令人困惑的 GraphQL 报错会消失,取而代之的是 "Empty" 状态——这说明 Cell 已经正确渲染,只是数据库里还没有任何评论数据。
让 Empty 状态更友好
顺手把CommentsCell的 Empty 提示改得更人性化(JavaScript 为web/src/components/CommentsCell/CommentsCell.jsx,TypeScript 为.tsx):
export const Empty = () => { return <div className="text-center text-gray-500">No comments yet</div> }同步更新组件测试,验证 Empty 渲染(web/src/components/CommentsCell/CommentsCell.test.jsx或.tsx):
it('renders Empty successfully', async () => { render(<Empty />) expect(screen.getByText('No comments yet')).toBeInTheDocument() })第四步:构建 Service
生成器已经替我们准备好了两个查询函数,以及一个用于嵌套解析的关系 Resolver:
import { db } from 'src/lib/db' export const comments = () => { return db.comment.findMany() } export const comment = ({ id }) => { return db.comment.findUnique({ where: { id }, }) } export const Comment = { post: (_obj, { root }) => db.comment.findUnique({ where: { id: root.id } }).post(), }TypeScript 版本(api/src/services/comments/comments.ts)在此基础上补充了Prisma类型导入与CommentRelationResolvers类型标注,post解析器同样通过root.id反查所属文章。
末尾这个Comment对象正是关系解析器(relation resolver):它让 GraphQL 能对评论的post字段继续向下取嵌套数据。于是客户端可以用这样的查询一次性拿到"评论 + 所属文章"(以下仅为演示语法,不需要加入应用代码):
query CommentsQuery { comments { id name body createdAt post { id title body createdAt } } }:::info 埋个伏笔 注意现在的comments()会返回全部评论,且只能返回全部。这种"一刀切"的查询在后续章节会带来问题——届时我们会为查询加上按文章筛选的能力。 :::
添加 createComment:复用 Redwood 的约定
创建类接口遵循 Redwood 脚手架(scaffold)的通用约定:接收单个input参数,内含各模型字段,然后直接交给 Prisma 写入:
export const createComment = ({ input }) => { return db.comment.create({ data: input, }) }TypeScript 版本会为参数定义显式接口,约束input的类型:
interface CreateCommentArgs { input: Prisma.CommentCreateInput } export const createComment = ({ input }: CreateCommentArgs) => { return db.comment.create({ data: input, }) }然后把它暴露到 GraphQL。在 SDL 中新增Mutation类型,并同样使用@skipAuth(任何人都可以发表评论):
export const schema = gql` type Comment { id: Int! name: String! body: String! post: Post! postId: Int! createdAt: DateTime! } type Query { comments: [Comment!]! @skipAuth } input CreateCommentInput { name: String! body: String! postId: Int! } input UpdateCommentInput { name: String body: String postId: Int } type Mutation { createComment(input: CreateCommentInput!): Comment! @skipAuth } `
CreateCommentInput输入类型由 SDL 生成器自动创建,无需手写。
至此,api 侧"创建评论"的能力已经齐备。那么还需要为评论提供哪些操作?
- 更新评论:决定不做——用户不应修改已发表的评论;
- 查询单条评论:也不需要——教程项目采用"评论随文章整体加载"的策略,而非每条评论各自发起请求;
- 删除评论:需要!用户不能删自己的评论,但博客作者应该能删除/审核不当评论。
于是补充deleteComment,返回被删除的记录(便于通知用户或外部系统,也可以选择返回null):
export const deleteComment = ({ id }) => { return db.comment.delete({ where: { id }, }) }TypeScript 版本中参数类型为Prisma.CommentWhereUniqueInput。
由于只有博客作者能删除评论,Mutation 上使用@requireAuth保护:
type Mutation { createComment(input: CreateCommentInput!): Comment! @skipAuth deleteComment(id: Int!): Comment! @requireAuth }deleteComment接收且仅接收一个必填参数:待删除评论的id。通过@skipAuth与@requireAuth的对比可以看出 RedwoodJS 权限指令的用法边界——公开操作放行,管理操作鉴权,这是实现"评论可发不可删、作者可删"这类业务规则的标准姿势。
第五步:用 Scenario 测试 Service
scenario() 是什么
打开生成器产出的api/src/services/comments/comments.test.js(TS 为.ts),里面已有一个现成测试,验证"返回所有评论"这个默认查询函数:
import { comments } from './comments' describe('comments', () => { scenario('returns all comments', async (scenario) => { const result = await comments() expect(result.length).toEqual(Object.keys(scenario.comment).length) }) })scenario()是 Redwood 提供的测试函数,用法上类似 Jest 的it()/test(),关键差异在于:它会在测试前向测试数据库预置数据,并把数据通过scenario参数传给你。这些数据在测试间会被重置,你可以放心修改。场景数据可以覆盖schema.prisma中定义的任意模型,而不限于 comments——文件之所以叫comments.scenarios.js,只是因为它是运行comments.test.js时会被加载的那一份。
为什么 Service 测试不像组件测试那样用 mock?在组件测试章节,我们反对依赖数据库;但 Service 的逻辑几乎全部围绕数据的读写,直接让代码真正访问数据库远比 mock 掉 Prisma 的每一次可能调用简单可靠。何况 Prisma 本身仍在快速迭代,持续同步 mock 会是一场噩梦。当然,如果你坚持,也可以使用 Jest 的 mock 工具把 Prisma 接口整体抽象掉——只是不推荐。
场景数据从哪来:defineScenario
场景数据定义在紧挨着测试文件的api/src/services/comments/comments.scenarios.js(TS 为.ts):
export const standard = defineScenario({ comment: { one: { data: { name: 'String', body: 'String', post: { create: { title: 'String', body: 'String' } }, }, }, two: { data: { name: 'String', body: 'String', post: { create: { title: 'String', body: 'String' } }, }, }, }, })defineScenario()会校验你的数据结构与 Prisma 模型定义是否匹配,并把每个场景对象(例如scenario.comment.one)原样传给 Prisma 的create,因此你完全可以使用 Prisma 支持的任何选项(如select、include)来定制数据。
场景的嵌套结构含义如下:
comment:本组数据对应的模型名;one/two:给这组数据起的友好名字,测试中用它引用;data:真正要写入数据库的字段内容;post:由于Comment必须关联一篇Post,这里用 Prisma 的**嵌套写入(nested create)**语法一并创建被关联的 Post;- 可选地,还可以加
select/include来定制取回对象中是否包含关联字段。
测试收到scenario参数时,data这一层会被解包,于是可以直接写scenario.comment.one.name之类的引用。
为什么生成的数据全是 "String"?生成器对业务数据一无所知,只知道
schema.prisma中字段的类型(String、Integer、DateTime),所以会填入满足类型校验的最简数据。实际项目中应当替换成贴近真实业务的数据。
替换成更真实的数据
把占位数据换成贴近博客真实场景的内容,并把记录名从one/two改成作者名jane/john(便于测试可读性):
export const standard = defineScenario({ comment: { jane: { data: { name: 'Jane Doe', body: 'I like trees', post: { create: { title: 'Redwood Leaves', body: 'The quick brown fox jumped over the lazy dog.', }, }, }, }, john: { data: { name: 'John Doe', body: 'Hug a tree today', post: { create: { title: 'Root Systems', body: 'The five boxing wizards jump quickly.', }, }, }, }, }, })这里没有为id和createdAt提供值——它们在schema.prisma中声明了默认值(autoincrement()与now()),创建记录时数据库会自动填充。由于服务生成器产出的测试只校验"返回的记录数量与场景数据一致",修改数据内容不会破坏既有测试。
测试 createComment:多场景与 connect 语法
创建评论时,我们更关心的是"新评论能否正确关联到某篇文章",而不是库里已有多少评论。为此新建一个只含 Post 的场景,命名为postOnly,并通过scenario()的第一个可选参数指定使用它(不传则默认使用standard):
export const standard = defineScenario({ // ... }) export const postOnly = defineScenario({ post: { bark: { data: { title: 'Bark', body: "A tree's bark is worse than its bite", }, }, }, })TypeScript 项目还需要额外导出场景类型供测试使用:
export type StandardScenario = typeof standard export type PostOnlyScenario = typeof postOnly然后在测试文件中新增用例:
import { comments, createComment } from './comments' describe('comments', () => { scenario('returns all comments', async (scenario) => { const result = await comments() expect(result.length).toEqual(Object.keys(scenario.comment).length) }) scenario('postOnly', 'creates a new comment', async (scenario) => { const comment = await createComment({ input: { name: 'Billy Bob', body: 'What is your favorite tree bark?', post: { connect: { id: scenario.post.bark.id }, }, }, }) expect(comment.name).toEqual('Billy Bob') expect(comment.body).toEqual('What is your favorite tree bark?') expect(comment.postId).toEqual(scenario.post.bark.id) expect(comment.createdAt).not.toEqual(null) }) })几个值得注意的细节:
- 场景数据是"入库后的真实数据":
scenario.post.bark.id之所以可用,是因为场景数据在被插入数据库后,你拿到的不只是定义的那几个字段,还包括数据库生成的id、默认值createdAt等完整记录; post: { connect: { id } }是 Prisma 的 connect 语法:用来关联一条已存在的记录。当然也可以直接传postId: scenario.post.bark.id(所谓 "unchecked" 输入),但 Prisma 生态中connect是更正统的写法;- TypeScript 下的类型约束:如果
CreateCommentArgs的input类型被声明为Prisma.CommentCreateInput,那么直接传postId会触发类型错误——它不符合该接口定义。要让postId可用,需要把接口改成Prisma.CommentUncheckedCreateInput,或二者取并集Prisma.CommentCreateInput | Prisma.CommentUncheckedCreateInput(Prisma 允许两种输入方式,但同一份输入内不可混用); - 关于
createdAt的断言:只验证它不为null即可。若要精确比较时间戳,需要冻结 JavaScript 的Date对象,使测试执行期间的"当前时间"保持不变,这在单元测试里相当麻烦,教程中不做展开。
为什么场景数据要起
bark、jane这种名字?是为了让测试读起来像自然语言:"jane在redwood-leaves这篇文章下发表了评论",而不是 "user[3]对post[0]操作"。代码首先是写给其他开发者读的,可读性优先。
总结:Mock 与 Scenario 的分工
到这里,评论的 Service 已经有了扎实的测试保障。梳理一下 RedwoodJS 中两套测试数据体系的分工:
- Mock(web 侧):用于组件测试与 Storybook。它是"假数据"——并不存在于数据库中,目的是在完全不依赖 api 侧的情况下,隔离地开发与测试组件;
- Scenario(api 侧):用于 Service 测试。它是"真数据"——真实写入测试数据库,并被预置为可依赖的已知状态。
可以用一个助记口诀概括:Mocks :Web ::Scenarios :API。
至此,评论的数据库建模、迁移、SDL/Service、权限指令与测试全部就绪。下一步(教程的后续章节)将为博客页面添加评论表单,让用户真正能够提交评论。完整的本篇文章对应的英文原版教程位于 docs/docs/tutorial/chapter6/comments-schema.md,关于指令底层实现可继续阅读 makeDirectives.ts,关于 Prisma 关系模型的实际示例可参考 fragment-test-project 的 schema.prisma。
【免费下载链接】redwoodRedwoodGraphQL项目地址: https://gitcode.com/gh_mirrors/re/redwood
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考