Skip to main content
Version: 4.x

Adding Comments to the Schema

これがどれほどすごいことなのか、じっくりと味わってみましょう -- 私たちはアプリの新しいコンポーネントを構築、デザイン、テストしました。実際にはバックエンドの機能を何も構築していないのに、このコンポーネントはデータをAPIコール(データベースからデータを取得する)で取得するコンポーネントです!RedwoodがStorybookとJestに偽のデータを提供したので、私たちはコンポーネントを動作させることができました。

残念ながら、これだけ柔軟性があっても、タダ飯はないのです。最終的には、バックエンドの仕事をやらなければならないのです。今がその時です。

チュートリアルの最初の部分をご覧になった方は、この流れにある程度慣れているはずです:

  1. モデルを schema.prisma に追加
  2. yarn rw prisma migrate dev コマンドを実行してマイグレーションを作成し、データベースに適用
  3. SDLとサービスを生成

Adding the Comment model

それではやってみましょう:

api/db/schema.prisma
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つのエントリを取得します:

  • postPost という型を持ち、特別な @relation キーワードは Prisma に CommentPost をどのように関連付けるかを伝える。この場合、 postId フィールドは Postid フィールドを参照する
  • postId は通常の Int 型のカラムで、このコメントが参照している Postid を含んでいる

これでクラシックなデータベースモデルができました:

┌───────────┐       ┌───────────┐
│ Post │ │ Comment │
├───────────┤ ├───────────┤
│ id │───┐ │ id │
│ title │ │ │ name │
│ body │ │ │ body │
│ createdAt │ └──<│ postId │
└───────────┘ │ createdAt │
└───────────┘

実際のデータベースには Commentpost という名前のカラムが存在しないことに注意してください -- これは 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" のような名前をつけます。

tip

いまテストスイートランナーが起動中の場合は、再起動する必要があります。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 に変更します:

api/src/graphql/comments.sdl.js
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エラーとは異なるメッセージが表示されるはずです:

image

"Empty" は、セルが正しくレンダリングされたことを意味します!ただ、まだデータベースにコメントがないだけです。 CommentsCell コンポーネントを更新して、この "Empty" メッセージをもう少し親しみやすいものにしましょう:

web/src/components/CommentsCell/CommentsCell.js
export const Empty = () => {
return <div className="text-center text-gray-500">No comments yet</div>
}

image

こっちのほうがいいですね。テストも更新してEmptyコンポーネントの描画をカバーしておきましょう:

web/src/components/CommentsCell/CommentsCell.test.js
it('renders Empty successfully', async () => {
render(<Empty />)
expect(screen.getByText('No comments yet')).toBeInTheDocument()
})

さて、少しばかりサービスに焦点を当てましょう。ユーザが新しいコメントを作成できるようにする機能を追加する必要があります。そして、新しい機能をカバーするテストを追加します。

Building out the Service

ジェネレータを使うことで、データベースから全てのコメントを取得するために必要な関数をすでに手に入れました:

api/src/services/comments/comments.js
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(),
}

また、コメントを1つだけ返す関数と、最後にこの Comment オブジェクトを返す関数を追加しています。これにより、以下のような構文で、GraphQLを通じてコメントに対するネストされたブログ記事のデータを返すことができます(このコードをアプリに追加することは心配しないでください。これは単なる例です)。

query CommentsQuery {
comments {
id
name
body
createdAt
post {
id
title
body
createdAt
}
}
}
info

何か不都合がありそうだとお気づきでしょうか? comments() 関数は すべての コメントを、そしてすべてのコメントだけを返します。これは私たちに噛みつくために戻ってくるのでしょうか?

うーん...

コメントも作成できるようにする必要があります。Redwoodの生成したscaffoldで使われているのと同じ規約を使うことにします:createエンドポイントは単一のパラメータ input を受け取ります。このパラメータは個々のモデルフィールドを持つオブジェクトです。

api/src/services/comments/comments.js
export const createComment = ({ input }) => {
return db.comment.create({
data: input,
})
}

また、この関数を GraphQL で公開する必要があるので、SDL にミューテーションを追加して @skipAuth を使用することにします。これで誰でもアクセスできるようになります:

api/src/graphql/comments.sdl.js
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
}
`
tip

CreateCommentInput 型は、SDL ジェネレータによってすでに作成されています。

コメントを作成するために必要なAPIサイドの作業はこれだけです!しかし、少し考えてみましょう:コメントに対して他に必要なことはないでしょうか?

ここでは、ユーザが既存のコメントを更新できないようにすることにしましょう。そして、個々のコメントを選択する必要はありません(以前、各コメントがそれ自身のAPIリクエストと表示に責務を担う可能性について検討しましたが、私たちはそれをやめました)。

コメントの削除についてはどうですか?ユーザが自分のコメントを削除することはできませんが、ブログのオーナーとして、削除/モデレートはしたいですよね。そこで、削除関数とAPIエンドポイントも必要です。それらを追加しましょう:

api/src/services/comments/comments.js
export const deleteComment = ({ id }) => {
return db.comment.delete({
where: { id },
})
}

ブログのオーナーだけがコメントを削除できるようにしたいので、@requireAuth を使うことにします:

api/src/graphql/comments.sdl.js
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() 関数)を取得できることを確認しています。

api/src/services/comments/comments.test.js
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.test.js を実行するときにロードされるものなので、たまたまこの名前になっています)。

In the section on mocks you said relying on data in the database for testing was dumb?

そうですね、全て同じなのであれば、私たちがコントロールできないソフトウェアに依存することなくテストができるのは素晴らしいことだと思います。

しかしここでの違いは、サービスでは、あなたが書いたロジックのほとんどすべてがデータベースへのデータの出し入れに依存しているので、Prismaが行う可能性のあるすべての呼び出しをモックして傍受しようとするよりも、コードを実行させて 本当に データベースにアクセスする方がはるかに単純であるということです。

もちろん、Prisma自体は現在開発中であり、実装がいつでも変更される可能性があることは言うまでもありません。そのような変化に対応し、常にモックを同期させようとするのは悪夢としか言いようがありません!

とはいえ、もし本当にその気になれば、Jestの mocking utilities を使って、Prismaのインターフェースを完全にモックして、データベースを完全に抽象化することができます。しかし、私たちが警告しなかったとは言わせませんよ!

そのデータはどこから来るのでしょうか?隣にある comments.scenarios.ts ファイルを見てください:

api/src/services/comments.scenarios.js
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がサポートする任意のオプションを使用して、シナリオオブジェクトをカスタマイズすることができます。

The "standard" scenario

ここでエクスポートされるシナリオは "standard" という名前です。コンポーネントのテストとモックを扱ったときに、standardという特別なモックがあり、名前を指定しなければRedwoodがデフォルトで使用したことを覚えていますか?これと同じルールがここでも適用されます!createComment() のテストを追加したときに、別のシナリオをユニークな名前で使用する例を見てみましょう。

シナリオの入れ子構造は、以下のように定義されています:

  • comment: このデータが対象とするモデル名 one, two: テストから参照できるシナリオデータに付けられたフレンドリーな名前
    • data: データベースに格納される実際のデータ
      • name, body, post: スキーマに対応するフィールド。この場合、 CommentPost に関連する必要があるので、シナリオでは post のキーと値を持っている(Prisma の nested create syntax を使う)
    • select, include: オプションで、関連するフィールドを select または include するようにオブジェクトをカスタマイズする using Prisma's syntax

テストで scenario という引数を受け取ると、 data キーがアンラップされ、 scenario.comment.one.name のようにフィールドを参照できるようになります。

Why does every field just contain the string "String"?

サービス(とテストとシナリオ)を生成するときに、私たち(Redwood)がデータについて知っているのは、 schema.prisma で定義されている各フィールドの型、すなわち StringInteger または DateTime だけです。そこで、データをデータベースに取り込むために、Prisma が要求する型を満たす最も単純なデータを追加します。このデータは、アプリが期待する実際のデータに近いものに置き換えるなければなりません。実際のところ...

このシナリオデータを、アプリが期待する実際のデータにより近いものに置き換えてみましょう:

api/src/services/comments/comments.scenarios.js
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.'
}
}
}
}
}
})

レコードの名前を onetwo から、著者の名前である janejohn に変更したことに注意してください。これについては後で詳しく説明します。なぜ idcreatedAt フィールドを含めなかったのでしょうか? schema.prisma でPrismaに、これらのフィールドにデフォルトを割り当てるように指示したので、レコードが作成されたときに自動的に設定されます。

サービスジェネレータによって作成されたテストは、単に同じ数のレコードが返されることを確認するだけなので、ここでデータの内容を変更してもテストには影響しません。

Testing createComment()

最初のサービステストを追加して、createComment() が実際に新しいコメントをデータベースに保存することを確認しましょう。コメントを作成する場合、データベース内の既存のデータについてはあまり気にしないので、ブログ記事だけを含む新しいシナリオを作成しましょう -- ブログ記事は新しいコメントと、コメントの postId フィールドを使ってリンクすることになります。複数のシナリオを作成し、テスト実行時にどのシナリオをデータベースにプリロードするかを指定することができます。 standard シナリオをそのままにして、新しいデータセットで新しいシナリオを作成します:

api/src/services/comments/comments.scenarios.js
export const standard = defineScenario({
// ...
})

export const postOnly = defineScenario({
post: {
bark: {
data: {
title: 'Bark',
body: "A tree's bark is worse than its bite",
}
}
}
})

これで、新しい scenario() テストの第一引数として postOnly シナリオ名を渡すことができます:

api/src/services/comments/comments.test.js
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)
})
})

デフォルトの "standard" の代わりに使う名前付きシナリオを、オプションの第一引数として scenario() に渡します。

シナリオで作成したブログ記事の id を使うことができました。シナリオには、シナリオ自体で定義したいくつかのフィールドだけでなく、データをinsertした後の実際のデータベースデータも含まれているからです。 id に加えて、データベースのデフォルトが now() になっている createdAt にもアクセスすることができました。

What's that 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 であり、このお手軽で簡単なチュートリアルのスコープ外です。

What's up with the names for scenario data? 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番目が良いと言ったなら、思い出してください:あなたはコンピュータのためにコードを書いているのではなく、他の人間のためにコードを書いているのです!コンピュータが理解しやすいコードを作るのはコンパイラの仕事であり、仲間の開発者に理解しやすいコードを作るのは私たちの仕事なのです。

さて、テストが完了したので、私たちのコメントサービスはかなり強固なものになりました。最後のステップは、ユーザが実際にブログ記事にコメントを残せるようにするためのフォームを追加することです。

Mocks vs. Scenarios

モックは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

コレジャナイ...