news 2026/9/23 22:20:13

RedwoodJS 教程实战:从 Prisma 建模到 Service 测试,为博客添加完整评论功能

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RedwoodJS 教程实战:从 Prisma 建模到 Service 测试,为博客添加完整评论功能

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 暴露出去。如果你已经完成教程前半部分,对这个流程不会陌生:

  1. schema.prisma中新增一个数据模型
  2. 运行yarn rw prisma migrate dev创建迁移并应用到数据库
  3. 生成 SDL 与 Service

下面按此顺序完整走一遍。

第一步:在 schema.prisma 中定义 Comment 模型

打开api/db/schema.prisma,在原有模型(PostContactUser)之外新增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模型中,大部分字段(idnamebodycreatedAt)与之前见过的写法一致,真正的新知识点是关系的两端

  • post:类型为Post,配合@relation(fields: [postId], references: [id])告诉 Prisma:Comment通过自身字段postId去引用Post表的id字段;
  • postId:一个普通的Int列,存放所关联Postid,也就是数据库里的外键列。

同时在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 支持的任何选项(如selectinclude)来定制数据。

场景的嵌套结构含义如下:

  • comment:本组数据对应的模型名;
  • one/two:给这组数据起的友好名字,测试中用它引用;
  • data:真正要写入数据库的字段内容;
  • post:由于Comment必须关联一篇Post,这里用 Prisma 的**嵌套写入(nested create)**语法一并创建被关联的 Post;
  • 可选地,还可以加select/include来定制取回对象中是否包含关联字段。

测试收到scenario参数时,data这一层会被解包,于是可以直接写scenario.comment.one.name之类的引用。

为什么生成的数据全是 "String"?生成器对业务数据一无所知,只知道schema.prisma中字段的类型(StringIntegerDateTime),所以会填入满足类型校验的最简数据。实际项目中应当替换成贴近真实业务的数据。

替换成更真实的数据

把占位数据换成贴近博客真实场景的内容,并把记录名从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.', }, }, }, }, }, })

这里没有为idcreatedAt提供值——它们在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) }) })

几个值得注意的细节:

  1. 场景数据是"入库后的真实数据"scenario.post.bark.id之所以可用,是因为场景数据在被插入数据库后,你拿到的不只是定义的那几个字段,还包括数据库生成的id、默认值createdAt等完整记录;
  2. post: { connect: { id } }是 Prisma 的 connect 语法:用来关联一条已存在的记录。当然也可以直接传postId: scenario.post.bark.id(所谓 "unchecked" 输入),但 Prisma 生态中connect是更正统的写法;
  3. TypeScript 下的类型约束:如果CreateCommentArgsinput类型被声明为Prisma.CommentCreateInput,那么直接传postId会触发类型错误——它不符合该接口定义。要让postId可用,需要把接口改成Prisma.CommentUncheckedCreateInput,或二者取并集Prisma.CommentCreateInput | Prisma.CommentUncheckedCreateInput(Prisma 允许两种输入方式,但同一份输入内不可混用);
  4. 关于createdAt的断言:只验证它不为null即可。若要精确比较时间戳,需要冻结 JavaScript 的Date对象,使测试执行期间的"当前时间"保持不变,这在单元测试里相当麻烦,教程中不做展开。

为什么场景数据要起barkjane这种名字?是为了让测试读起来像自然语言:"janeredwood-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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 22:19:54

旅游景点情感分析:细粒度属性级建模与BERT微调实践

简介&#xff1a;本资源是一套面向计算机专业本科生的毕业设计实战项目&#xff0c;聚焦旅游景点评论的细粒度情感分析任务&#xff0c;适用于Python Web开发、自然语言处理与数据库应用等课程实践或毕设选题参考。项目基于Django框架构建Web系统&#xff0c;集成RNCC情感分析模…

作者头像 李华
网站建设 2026/9/23 22:16:48

基于arXiv API的每日论文自动分析系统:从数据拉取到趋势追踪

1. 一份日报的诞生&#xff1a;为什么要做 arXiv 每日论文分析每天早上刷 arXiv 的人不少&#xff0c;但真正能把当天上百篇新论文筛明白的人不多。我自己做计算机视觉和机器人方向的研究&#xff0c;前几年养成了一个习惯&#xff1a;每天花二十分钟扫一遍 arXiv 的 cs.CV、cs…

作者头像 李华
网站建设 2026/9/23 22:15:13

VCS验证实战指南:内存初始化、竞态诊断与XMR跨模块调试

简介&#xff1a;本资源为Synopsys官方发布的VCS用户指南&#xff08;S-2021.09版&#xff09;&#xff0c;面向IC设计验证工程师、数字电路验证初学者及EDA工具使用者&#xff0c;系统解决VCS Verification Continuum平台的部署、配置、仿真执行与问题排查等核心实践问题。手册…

作者头像 李华
网站建设 2026/9/23 22:10:47

非定常气动理论包解析:Theodorsen函数复现与数据验证

简介&#xff1a;泰德森理论是非定常空气动力学中用于分析周期性运动升力的经典解析方法&#xff0c;可计算翼面在非定常气流中的瞬态响应。这一压缩包面向空气动力学专业学生、飞行器设计人员与 MATLAB 仿真爱好者&#xff0c;重点解决二维翼型非定常升力求解、机动飞行与颤振…

作者头像 李华
网站建设 2026/9/23 22:08:32

PyQt5与深度学习实战:智慧课堂专注度分析系统构建全解析

简介&#xff1a;基于PyQt5与深度学习的线下课堂学生专注度分析系统&#xff0c;以完整Python源码、设计文档及预训练模型打包形式呈现&#xff0c;重点面向计算机相关专业学生的毕业设计、课程设计及项目立项演示。项目代码经功能验证&#xff0c;可稳定运行&#xff0c;支持在…

作者头像 李华