ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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 暴露出去。如果你已经完成教程前半部分对这个流程不会陌生在schema.prisma中新增一个数据模型运行yarn rw prisma migrate dev创建迁移并应用到数据库生成 SDL 与 Service下面按此顺序完整走一遍。第一步在 schema.prisma 中定义 Comment 模型打开api/db/schema.prisma在原有模型Post、Contact、User之外新增Comment模型。这也是本教程中第一次在两个模型之间建立关系relationdatasource 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])告诉 PrismaComment通过自身字段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 一路取到它所属的 Postdb.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 与 ServiceSDLSchema Definition Language用来定义 GraphQL 接口Service 则负责从数据库取数据。用 Redwood 的生成器一次性创建两者yarn rw g sdl Comment --no-crud关键在于--no-crud标志它不会生成全套增删改查接口只提供最基础的只读能力方便我们从零开始逐步添加功能这与之前生成 Post 相关代码时免费获得全部 CRUD正好相反。让匿名用户可以查看评论生成器默认会在 Query 字段上挂requireAuth指令要求登录后才能访问。评论是公开内容因此需要把comments查询上的requireAuth改为skipAuthexport 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回调的ValidatorDirectiveRedwood 再通过 useRedwoodDirective 插件 在 GraphQL 执行链路中触发校验逻辑。简言之skipAuth表示任何请求都放行requireAuth表示必须携带有效身份信息否则抛错。完成这一步后回到真实浏览器而非 Storybook刷新页面之前令人困惑的 GraphQL 报错会消失取而代之的是 Empty 状态——这说明 Cell 已经正确渲染只是数据库里还没有任何评论数据。让 Empty 状态更友好顺手把CommentsCell的 Empty 提示改得更人性化JavaScript 为web/src/components/CommentsCell/CommentsCell.jsxTypeScript 为.tsxexport const Empty () { return div classNametext-center text-gray-500No comments yet/div }同步更新组件测试验证 Empty 渲染web/src/components/CommentsCell/CommentsCell.test.jsx或.tsxit(renders Empty successfully, async () { render(Empty /) expect(screen.getByText(No comments yet)).toBeInTheDocument() })第四步构建 Service生成器已经替我们准备好了两个查询函数以及一个用于嵌套解析的关系 Resolverimport { 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返回被删除的记录便于通知用户或外部系统也可以选择返回nullexport 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 测试 Servicescenario() 是什么打开生成器产出的api/src/services/comments/comments.test.jsTS 为.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.jsTS 为.tsexport 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()的第一个可选参数指定使用它不传则默认使用standardexport const standard defineScenario({ // ... }) export const postOnly defineScenario({ post: { bark: { data: { title: Bark, body: A trees 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.CommentUncheckedCreateInputPrisma 允许两种输入方式但同一份输入内不可混用关于createdAt的断言只验证它不为null即可。若要精确比较时间戳需要冻结 JavaScript 的Date对象使测试执行期间的当前时间保持不变这在单元测试里相当麻烦教程中不做展开。为什么场景数据要起bark、jane这种名字是为了让测试读起来像自然语言jane在redwood-leaves这篇文章下发表了评论而不是 user[3]对post[0]操作。代码首先是写给其他开发者读的可读性优先。总结Mock 与 Scenario 的分工到这里评论的 Service 已经有了扎实的测试保障。梳理一下 RedwoodJS 中两套测试数据体系的分工Mockweb 侧用于组件测试与 Storybook。它是假数据——并不存在于数据库中目的是在完全不依赖 api 侧的情况下隔离地开发与测试组件Scenarioapi 侧用于 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),仅供参考
返回列表