Adding Comments to the Schema
これがどれほどすごいことなのか、じっくりと味わってみましょう -- 私たちはアプリの新しいコンポーネントを構築、デザイン、テストしました。実際にはバックエンドの機能を何も構築していないのに、このコンポーネントはデータをAPIコール(データベースからデータを取得する)で取得するコンポーネントです!RedwoodがStorybookとJestに偽のデータを提供したので、私たちはコンポーネントを動作させることができました。
残念ながら、これだけ柔軟性があっても、タダ飯はないのです。最終的には、バックエンドの仕事をやらなければならないのです。今がその時です。
チュートリアルの最初の部分をご覧になった方は、この流れにある程度慣れているはずです:
- モデルを
schema.prisma
に追加 yarn rw prisma migrate dev
コマンドを実行してマイグレーションを作成し、データベースに適用- SDLとサービスを生成
Adding the Comment model
それではやってみましょう:
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())
}
これらの行のほとんどは、いままで見てきたものと非常によく似ていますが、これは2つのモデル間の relation の最初の例です。 Comment
はこのリレーションを表すために2つのエントリを取得します:
post
はPost
という型を持ち、特別な@relation
キーワードは Prisma にComment
とPost
をどのように関連付けるかを伝える。この場合、postId
フィールドはPost
のid
フィールドを参照するpostId
は通常のInt
型のカラムで、このコメントが参照しているPost
のid
を含んでいる
これでクラシックなデータベースモデルができました:
┌───────────┐ ┌───────────┐
│ Post │ │ Comment │
├───────────┤ ├───────────┤
│ id │───┐ │ id │
│ title │ │ │ name │
│ body │ │ │ body │
│ createdAt │ └──<│ postId │
└───────────┘ │ createdAt │
└───────────┘
実際のデータベースには Comment
に post
という名前のカラムが存在しないことに注意してください -- これは Prisma ための特別な構文で、Prismaがモデルの相互接続方法を認識し、その接続を参照できるようにします。Prisma を使用して Comment
を検索すると、その名前を使用して、アタッチされた Post
にアクセスすることができます:
db.comment.findUnique({ where: { id: 1 }}).post()
Prismaはまた、便利な comments
フィールドを Post
に追加し、同じ機能を逆に使えるようにしました:
db.post.findUnique({ where: { id: 1 }}).comments()
Running the Migration
これは簡単です:名前を付けて新しいマイグレーションを作成し、それを実行します:
yarn rw prisma migrate dev
プロンプトが表示されたら、 "create comment" のような名前をつけます。
いまテストスイートランナーが起動中の場合は、再起動する必要があります。Ctrl-C を押すか、 q
を押すだけです。Redwood はテストを実行するために、2つ目のテストデータベースを作成します(デフォルトでは .redwood/test.db
にあります)。データベースのマイグレーションは、テストスイートを実行中ではなく、テストスイートを 開始 したときに実行されるので、新しいデータベース構造に対してテストを行うにはテストスイートを再起動する必要があります。
Creating the SDL and Service
次に、SDL (GraphQL インターフェースを定義するもの)とサービス(データベースからレコードを取得するもの)をジェネレータで生成します:
yarn rw g sdl Comment --no-crud
ここで --no-crud
フラグに注目してください。このフラグによって、私たちが構築できる基本的な機能(モデルへの読み取り専用アクセス)から始めることができます。サイトのPostセクションを作成したときに、すべてのCRUDエンドポイントをタダで手に入れたので、ここではその逆を行い、イチから機能を追加する方法を見てみましょう。
このコマンドにより、SDL とサービスの両方が作成されます。生成されたコードに加えるべき変更点は、匿名ユーザによるすべてのコメントの閲覧を許可することです。 @requireAuth
ディレクティブを @skipAuth
に変更します:
- JavaScript
- TypeScript
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
}
`
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
}
`
ここで、(Storybookではなく)ブラウザで実際のアプリを見返すと、先ほどのGraphQLエラーとは異なるメッセージが表示されるはずです:
"Empty" は、セルが正しくレンダリングされたことを意味します!ただ、まだデータベースにコメントがないだけです。 CommentsCell
コンポーネントを更新して、この "Empty" メッセージをもう少し親しみやすいものにしましょう:
- JavaScript
- TypeScript
export const Empty = () => {
return <div className="text-center text-gray-500">No comments yet</div>
}
export const Empty = () => {
return <div className="text-center text-gray-500">No comments yet</div>
}
こっちのほうがいいですね。テストも更新してEmptyコンポーネントの描画をカバーしておきましょう:
- JavaScript
- TypeScript
it('renders Empty successfully', async () => {
render(<Empty />)
expect(screen.getByText('No comments yet')).toBeInTheDocument()
})
it('renders Empty successfully', async () => {
render(<Empty />)
expect(screen.getByText('No comments yet')).toBeInTheDocument()
})
さて、少しばかりサービスに焦点を当てましょう。ユーザが新しいコメントを作成できるようにする機能を追加する必要があります。そして、新しい機能をカバーするテストを追加します。
Building out the Service
ジェネレータを使うことで、データベースから全てのコメントを取得するために必要な関数をすでに手に入れました:
- JavaScript
- TypeScript
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(),
}
import type { Prisma } from '@prisma/client'
import type { ResolverArgs } from '@redwoodjs/graphql-server'
import { db } from 'src/lib/db'
export const comments = () => {
return db.comment.findMany()
}
export const comment = ({ id }: QueryResolvers['comment'] => {
return db.comment.findUnique({
where: { id },
})
}
export const Comment: CommentRelationResolvers = {
post: (_obj, { root }) => {
return db.comment.findUnique({ where: { id: root?.id } }).post()
},
}
また、コメントを1つだけ返す関数と、最後にこの Comment
オブジェクトを返す関数を追加しています。これにより、以下のような構文で、GraphQLを通じてコメントに対するネストされたブログ記事のデータを返すことができます(このコードをアプリに追加することは心配しないでください。これは単なる例です)。
query CommentsQuery {
comments {
id
name
body
createdAt
post {
id
title
body
createdAt
}
}
}
何か不都合がありそうだとお気づきでしょうか? comments()
関数は すべての コメントを、そしてすべてのコメントだけを返します。これは私たちに噛みつくために戻ってくるのでしょうか?
うーん...
コメントも作成できるようにする必要があります。Redwoodの生成したscaffoldで使われているのと同じ規約を使うことにします:createエンドポイントは単一のパラメータ input
を受け取ります。このパラメータは個々のモデルフィールドを持つオブジェクトです。
- JavaScript
- TypeScript
export const createComment = ({ input }) => {
return db.comment.create({
data: input,
})
}
interface CreateCommentArgs {
input: Prisma.CommentCreateInput
}
export const createComment = ({ input }: CreateCommentArgs) => {
return db.comment.create({
data: input,
})
}
また、この関数を GraphQL で公開する必要があるので、SDL にミューテーションを追加して @skipAuth
を使用することにします。これで誰でもアクセスできるようになります:
- JavaScript
- TypeScript
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
}
`
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サイドの作業はこれだけです!しかし、少し考えてみましょう:コメントに対して他に必要なことはないでしょうか?
ここでは、ユーザが既存のコメントを更新できないようにすることにしましょう。そして、個々のコメントを選択する必要はありません(以前、各コメントがそれ自身のAPIリクエストと表示に責務を担う可能性について検討しましたが、私たちはそれをやめました)。
コメントの削除についてはどうですか?ユーザが自分のコメントを削除することはできませんが、ブログのオーナーとして、削除/モデレートはしたいですよね。そこで、削除関数とAPIエンドポイントも必要です。それらを追加しましょう:
- JavaScript
- TypeScript
export const deleteComment = ({ id }) => {
return db.comment.delete({
where: { id },
})
}
export const deleteComment = ({ id }: Prisma.CommentWhereUniqueInput) => {
return db.comment.delete({
where: { id },
})
}
ブログのオーナーだけがコメントを削除できるようにしたいので、@requireAuth
を使うことにします:
- JavaScript
- TypeScript
type Mutation {
createComment(input: CreateCommentInput!): Comment! @skipAuth
deleteComment(id: Int!): Comment! @requireAuth
}
type Mutation {
createComment(input: CreateCommentInput!): Comment! @skipAuth
deleteComment(id: Int!): Comment! @requireAuth
}
deleteComment
渡す引数は1つだけ、削除するコメントのIDで、これは必須の引数です。よくあるパターンは、ユーザや他のシステムに削除されたものの詳細を通知したい場合に備えて削除されたばかりのレコードを返すので、ここでもそうします。しかし、同様に null
を返すこともできます。
Testing the Service
サービスの機能要件が満たせていること、そしてアプリを変更しても動作し続けることを確認しましょう。
api/src/services/comments/comments.test.js
を開いてみると、すでにテストが1つあり、すべてのコメント(サービスとともに生成されたデフォルトの comments()
関数)を取得できることを確認しています。
- JavaScript
- TypeScript
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)
})
})
import { comments } from './comments'
describe('comments', () => {
scenario('returns all comments', async (scenario: StandardScenario) => {
const result = await comments()
expect(result.length).toEqual(Object.keys(scenario.comment).length)
})
})
この scenario()
関数とは何でしょうか?これは Redwood が提供するもので、Jest の組み込み関数である it()
と test()
のように動作しますが、ひとつだけ重要な違いがあります:それは、 scenario
引数で渡されるデータをテストデータベースにあらかじめ登録しておくことです。このデータはデータベース内に存在し、変更した場合にはテスト間でリセットされます。
コメントだけでなく、 schema.prisma
で定義されているすべてのモデルに対してデータ構造を作成することができます(このファイルは comments.test.js
を実行するときにロードされるものなので、たまたまこの名前になっています)。
そうですね、全て同じなのであれば、私たちがコントロールできないソフトウェアに依存することなくテストができるのは素晴らしいことだと思います。
しかしここでの違いは、サービスでは、あなたが書いたロジックのほとんどすべてがデータベースへのデータの出し入れに依存しているので、Prismaが行う可能性のあるすべての呼び出しをモックして傍受しようとするよりも、コードを実行させて 本当に データベースにアクセスする方がはるかに単純であるということです。
もちろん、Prisma自体は現在開発中であり、実装がいつでも変更される可能性があることは言うまでもありません。そのような変化に対応し、常にモックを同期させようとするのは悪夢としか言いようがありません!
とはいえ、もし本当にその気になれば、Jestの mocking utilities を使って、Prismaのインターフェースを完全にモックして、データベースを完全に抽象化することができます。しかし、私たちが警告しなかったとは言わせませんよ!
そのデータはどこから来るのでしょうか?隣にある comments.scenarios.ts
ファイルを見てください:
- JavaScript
- TypeScript
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' } },
},
},
},
})
import type { Prisma } from '@prisma/client'
export const standard = defineScenario<Prisma.CommentCreateArgs>({
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がサポートする任意のオプションを使用して、シナリオオブジェクトをカスタマイズすることができます。
ここでエクスポートされるシナリオは "standard" という名前です。コンポーネントのテストとモックを扱ったときに、standard
という特別なモックがあり、名前を指定しなければRedwoodがデフォルトで使用したことを覚えていますか?これと同じルールがここでも適用されます!createComment()
のテストを追加したときに、別のシナリオをユニークな名前で使用する例を見てみましょう。
シナリオの入れ子構造は、以下のように定義されています:
- comment: このデータが対象とするモデル名
one, two: テストから参照できるシナリオデータに付けられたフレンドリーな名前
- data: データベースに格納される実際のデータ
- name, body, post: スキーマに対応するフィールド。この場合、 Comment は Post に関連する必要があるので、シナリオでは
post
のキーと値を持っている(Prisma の nested create syntax を使う)
- name, body, post: スキーマに対応するフィールド。この場合、 Comment は Post に関連する必要があるので、シナリオでは
- select, include: オプションで、関連するフィールドを
select
またはinclude
するようにオブジェクトをカスタマイズする using Prisma's syntax
- data: データベースに格納される実際のデータ
テストで scenario
という引数を受け取ると、 data
キーがアンラップされ、 scenario.comment.one.name
のようにフィールドを参照できるようになります。
サービス(とテストとシナリオ)を生成するときに、私たち(Redwood)がデータについて知っているのは、 schema.prisma
で定義されている各フィールドの型、すなわち String
、Integer
または DateTime
だけです。そこで、データをデータベースに取り込むために、Prisma が要求する型を満たす最も単純なデータを追加します。このデータは、アプリが期待する実際のデータに近いものに置き換えるなければなりません。実際のところ...
このシナリオデータを、アプリが期待する実際のデータにより近いものに置き換えてみましょう:
- JavaScript
- TypeScript
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.'
}
}
}
}
}
})
import type { Prisma } from '@prisma/client'
export const standard = defineScenario<Prisma.CommentCreateArgs>({
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.',
}
}
}
}
}
})
レコードの名前を one
と two
から、著者の名前である jane
と john
に変更したことに注意してください。これについては後で詳しく説明します。なぜ id
や createdAt
フィールドを含めなかったのでしょうか? schema.prisma
でPrismaに、これらのフィールドにデフォルトを割り当てるように指示したので、レコードが作成されたときに自動的に設定されます。
サービスジェネレータによって作成されたテストは、単に同じ数のレコードが返されることを確認するだけなので、ここでデータの内容を変更してもテストには影響しません。
Testing createComment()
最初のサービステストを追加して、createComment()
が実際に新しいコメントをデータベースに保存することを確認しましょう。コメントを作成する場合、データベース内の既存のデータについてはあまり気にしないので、ブログ記事だけを含む新しいシナリオを作成しましょう -- ブログ記事は新しいコメントと、コメントの postId
フィールドを使ってリンクすることになります。複数のシナリオを作成し、テスト実行時にどのシナリオをデータベースにプリロードするかを指定することができます。 standard
シナリオをそのままにして、新しいデータセットで新しいシナリオを作成します:
- JavaScript
- TypeScript
export const standard = defineScenario({
// ...
})
export const postOnly = defineScenario({
post: {
bark: {
data: {
title: 'Bark',
body: "A tree's bark is worse than its bite",
}
}
}
})
import type { Prisma } from '@prisma/client'
export const standard = defineScenario<Prisma.CommentCreateArgs>({
// ...
})
export const postOnly = defineScenario<Prisma.PostCreateArgs>({
post: {
bark: {
data: {
title: 'Bark',
body: "A tree's bark is worse than its bite",
}
}
}
})
export type StandardScenario = typeof standard
export type PostOnlyScenario = typeof postOnly
これで、新しい scenario()
テストの第一引数として postOnly
シナリオ名を渡すことができます:
- JavaScript
- TypeScript
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)
})
})
import { comments, createComment } from './comments'
import type { StandardScenario, PostOnlyScenario } from './comments.scenarios'
describe('comments', () => {
scenario('returns all comments', async (scenario: StandardScenario) => {
const result = await comments()
expect(result.length).toEqual(Object.keys(scenario.comment).length)
})
scenario(
'postOnly',
'creates a new comment',
async (scenario: PostOnlyScenario) => {
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)
}
)
})
デフォルトの "standard" の代わりに使う名前付きシナリオを、オプションの第一引数として scenario()
に渡します。
シナリオで作成したブログ記事の id
を使うことができました。シナリオには、シナリオ自体で定義したいくつかのフィールドだけでなく、データをinsertした後の実際のデータベースデータも含まれているからです。 id
に加えて、データベースのデフォルトが now()
になっている createdAt
にもアクセスすることができました。
post: { connect: { id } }
nested structure? Can't we simply pass the Post's ID directly here?ご覧いただいているのは、Prismaのコアコンセプトである connect syntax です。そうですね、代わりに、単に postId: scenario.post.bark.id
を渡せばよいでしょう -- いわゆる "未チェックの" 入力として渡すこともできます。しかし、その名の通り、connect syntaxはPrismaの世界では王様です。
createComment()
関数に渡したすべてのフィールドが実際にデータベースに作成されたことをテストし、さらに createdAt
が null でないことを確認します。実際のタイムスタンプが正しいかどうかをテストすることもできますが、それにはJavascriptのDateオブジェクトをフリーズさせる必要があります。そうすれば、テストにどれだけ時間がかかっても、ミリ秒単位で、まさに 今 である new Date
と値を比較することができます。可能ではありますが、 very gnarly であり、このお手軽で簡単なチュートリアルのスコープ外です。
posts.bark
? Really?これにより、テストに関する推論がより簡単になります!あなたはどちらでやりたいですか:
"claire
paid for an ebook
using her visa
credit card."
or:
"user[3]
paid for product[0]
using their cards[2]
credit card?
もしあなたが2番目が良いと言ったなら、思い出してください:あなたはコンピュータのためにコードを書いているのではなく、他の人間のためにコードを書いているのです!コンピュータが理解しやすいコードを作るのはコンパイラの仕事であり、仲間の開発者に理解しやすいコードを作るのは私たちの仕事なのです。
さて、テストが完了したので、私たちのコメントサービスはかなり強固なものになりました。最後のステップは、ユーザが実際にブログ記事にコメントを残せるようにするためのフォームを追加することです。
モックはWebサイトで、シナリオはapiサイドで使われます。モック は "fake" の同義語で、"this is fake data not really in the database" と覚えておくと便利かもしれません(だからこそapiサイドを巻き込まず、単独でストーリーやテストを作成できます)。一方 シナリオ は、データベース内の実際のデータで、私たちが信頼できる既知の状態にあらかじめ設定されています。
mnemonic (ニーモニック=記憶法)を使うとよいのでは?
Mocks : Web :: Scenarios : API:
- Mysterious Weasels Scratched Armor
- Minesweepers Wrecked Subliminal Attorneys
- Martian Warriors Squeezed Apricots
コレジャナイ...