NestJSでPrismaのチュートリアルをやってみる(REST編)

13 min read読了の目安(約11800字

去年(2020)の年末頃、いつものようにmizchiさんによる記事

https://zenn.dev/mizchi/articles/cbe81299e145491676f8
を読んで、そのうちPrismaを触ってみようと思いつつ、仕事やらゲームやら羊毛フェルトやらにかまけていたらこんなに時間が経ってしまいました。

ふとモチベが上がったのでやっていこうと思います。

でも一旦はフロントとかJSでフルスタックという文脈ではなく、「JS(TS)でDBアクセスしたい≒APIサーバ書きたい」という文脈での触り方で良かったので、BlitzではなくNestを使うことにします。

幸いなことにNestでもPrismaを使ったチュートリアルのようなレシピが公開されていたので、それを順にやってみました。

https://docs.nestjs.com/recipes/prisma

インストール

Nestのインストールから始めていきます。

yarn global add @nestjs/cli

NestのCLIがインストールできたら、そのCLIを使ってプロジェクトを作ります。

nest new hello-prisma

プロジェクトのディレクトリに入り、Prismaもインストールしていきます。

$ cd hello-prisma
$ yarn add -D prisma

Prismaのインストールができたら、 prisma コマンドを確認してみましょう。

yarn prisma

まずは init から。

yarn prisma init

init をすると、以下のようなファイルが作られるようです。

This command creates a new `prisma` directory with the following contents:

-   `schema.prisma`: Specifies your database connection and contains the database schema
-   `.env`: A [dotenv](https://github.com/motdotla/dotenv) file, typically used to store your database credentials in a group of environment variables

とのことなので見てみます。

% tree -L 2 -I node_modules
.
├── README.md
├── nest-cli.json
├── package.json
├── prisma
│   └── schema.prisma
├── src
│   ├── app.controller.spec.ts
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   └── main.ts
├── test
│   ├── app.e2e-spec.ts
│   └── jest-e2e.json
├── tsconfig.build.json
├── tsconfig.json
└── yarn.lock

できてますね。

.env はそのままdotenvのファイル、 schema.prisma はPrismaで使うスキーマファイルのようです。

それはそうと、Nestを使ったのは初めてだったんですが、デフォルトでTypeScriptになっているのはいいですね。
いちいち yarn add typescript ~~~ みたいなのを打ちたくないですし。

DBの設定

まず、 .env を下記のように書き換えます。

DATABASE_URL="file:./dev.db"

今回はお手軽にSQLiteでやってしまうため、 schema.prismadatasource db も書き換えます。

datasource db {  
  provider = "sqlite"  
  url      = env("DATABASE\_URL")  
}

更に schema.prisma ファイルに下記を追加。

まさにTHEチュートリアルといったモデルですね。

model User {
  id    Int     @default(autoincrement()) @id
  email String  @unique
  name  String?
  posts Post[]
}

model Post {
  id        Int      @default(autoincrement()) @id
  title     String
  content   String?
  published Boolean? @default(false)
  author    User?    @relation(fields: [authorId], references: [id])
  authorId  Int?
}

マイグレーション

ここまで来たらマイグレーションコマンドを叩きます。

yarn prisma migrate dev --name init

何やら出力があったあと、 prisma ディレクトリ以下に色々とできています。

% tree ./prisma
./prisma
├── dev.db
├── dev.db-journal
├── migrations
│   ├── 20210523125820_init
│   │   └── migration.sql
│   └── migration_lock.toml
└── schema.prisma

一応確認してみましょう。

% sqlite3 prisma/dev.db
SQLite version 3.32.3 2020-06-18 14:16:19
Enter ".help" for usage hints.
sqlite> .tables
Post                User                _prisma_migrations

ついでに中身も。

sqlite> .schema
CREATE TABLE IF NOT EXISTS "_prisma_migrations" (
    "id"                    TEXT PRIMARY KEY NOT NULL,
    "checksum"              TEXT NOT NULL,
    "finished_at"           DATETIME,
    "migration_name"        TEXT NOT NULL,
    "logs"                  TEXT,
    "rolled_back_at"        DATETIME,
    "started_at"            DATETIME NOT NULL DEFAULT current_timestamp,
    "applied_steps_count"   INTEGER UNSIGNED NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS "User" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "email" TEXT NOT NULL,
    "name" TEXT
);
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE IF NOT EXISTS "Post" (
    "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    "title" TEXT NOT NULL,
    "content" TEXT,
    "published" BOOLEAN DEFAULT false,
    "authorId" INTEGER,
    FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "User.email_unique" ON "User"("email");

いいですね。

PrismaでNestのコードを書いていく

ここからはNestでこれらにアクセスしていきたいと思います。

ここでPrismaのクライアントを追加しましょう。

Nestからはこのクライアントをベースとして記述していくようです。

yarn add @prisma/client

細かい説明は置いておいて、 src/prisma.service.ts というファイルを作り、下記をコピペします。
どうやらNestからPrismaへの接続を行う部分のようです。

import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient
  implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

続いて User モデルを表すファイル。

import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import {
  User,
  Prisma
} from '@prisma/client';

@Injectable()
export class UserService {
  constructor(private prisma: PrismaService) {}

  async user(userWhereUniqueInput: Prisma.UserWhereUniqueInput): Promise<User | null> {
    return this.prisma.user.findUnique({
      where: userWhereUniqueInput,
    });
  }

  async users(params: {
    skip?: number;
    take?: number;
    cursor?: Prisma.UserWhereUniqueInput;
    where?: Prisma.UserWhereInput;
    orderBy?: Prisma.UserOrderByInput;
  }): Promise<User[]> {
    const { skip, take, cursor, where, orderBy } = params;
    return this.prisma.user.findMany({
      skip,
      take,
      cursor,
      where,
      orderBy,
    });
  }

  async createUser(data: Prisma.UserCreateInput): Promise<User> {
    return this.prisma.user.create({
      data,
    });
  }

  async updateUser(params: {
    where: Prisma.UserWhereUniqueInput;
    data: Prisma.UserUpdateInput;
  }): Promise<User> {
    const { where, data } = params;
    return this.prisma.user.update({
      data,
      where,
    });
  }

  async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
    return this.prisma.user.delete({
      where,
    });
  }
}

定義した覚えのない User 型ができているのがすごいですね。

初回だったので migration でできているようですが、下記のように2回目以降は prisma generate コマンドが必要になるようです。

**NOTE**The `prisma generate` command reads your Prisma schema and updates the generated Prisma Client library inside `node_modules/@prisma/client`.

skip take cursor where orderBy 辺りはなんとなく他のORMを想像しつつ、なんかそんな感じに使えるのかなー、くらいなイメージで捉えておきます。

そして Post モデルを表すファイル。

import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma.service';
import {
  Post,
  Prisma,
} from '@prisma/client';

@Injectable()
export class PostService {
  constructor(private prisma: PrismaService) {}

  async post(postWhereUniqueInput: Prisma.PostWhereUniqueInput): Promise<Post | null> {
    return this.prisma.post.findUnique({
      where: postWhereUniqueInput,
    });
  }

  async posts(params: {
    skip?: number;
    take?: number;
    cursor?: Prisma.PostWhereUniqueInput;
    where?: Prisma.PostWhereInput;
    orderBy?: Prisma.PostOrderByInput;
  }): Promise<Post[]> {
    const { skip, take, cursor, where, orderBy } = params;
    return this.prisma.post.findMany({
      skip,
      take,
      cursor,
      where,
      orderBy,
    });
  }

  async createPost(data: Prisma.PostCreateInput): Promise<Post> {
    return this.prisma.post.create({
      data,
    });
  }

  async updatePost(params: {
    where: Prisma.PostWhereUniqueInput;
    data: Prisma.PostUpdateInput;
  }): Promise<Post> {
    const { data, where } = params;
    return this.prisma.post.update({
      data,
      where,
    });
  }

  async deletePost(where: Prisma.PostWhereUniqueInput): Promise<Post> {
    return this.prisma.post.delete({
      where,
    });
  }
}

Nestアプリケーションとして書いていく

ここまでで一通りDBにアクセスするためのモノはできてきました。

ただ、このままではWebサービスとして公開することはできません。

まずはNestでこれらのモデルを使えるようにするため、Provider(という名前のモジュール?)の設定を行います。

app.module.ts にそれらを記述しましょう。

この辺りは原文には記述がないんですが、これを書かないと動かなかったので合っているハズ。

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaService } from './prisma.service';
import { UserService } from './user.service';
import { PostService } from './post.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService, PrismaService, UserService, PostService],
})
export class AppModule {}

最後に、外部からWebサービスとしてアクセスできるようにルーティング部分を書いていきます。

対象のファイルは app.controller.ts です。

import {
  Controller,
  Get,
  Param,
  Post,
  Body,
  Put,
  Delete,
} from '@nestjs/common';
import { UserService } from './user.service';
import { PostService } from './post.service';
import { User as UserModel, Post as PostModel } from '@prisma/client';

@Controller()
export class AppController {
  constructor(
    private readonly userService: UserService,
    private readonly postService: PostService,
  ) {}

  @Get('post/:id')
  async getPostById(@Param('id') id: string): Promise<PostModel> {
    return this.postService.post({ id: Number(id) });
  }

  @Get('feed')
  async getPublishedPosts(): Promise<PostModel[]> {
    return this.postService.posts({
      where: { published: true },
    });
  }

  @Get('filtered-posts/:searchString')
  async getFilteredPosts(
    @Param('searchString') searchString: string,
  ): Promise<PostModel[]> {
    return this.postService.posts({
      where: {
        OR: [
          {
            title: { contains: searchString },
          },
          {
            content: { contains: searchString },
          },
        ],
      },
    });
  }

  @Post('post')
  async createDraft(
    @Body() postData: { title: string; content?: string; authorEmail: string },
  ): Promise<PostModel> {
    const { title, content, authorEmail } = postData;
    return this.postService.createPost({
      title,
      content,
      author: {
        connect: { email: authorEmail },
      },
    });
  }

  @Post('user')
  async signupUser(
    @Body() userData: { name?: string; email: string },
  ): Promise<UserModel> {
    return this.userService.createUser(userData);
  }

  @Put('publish/:id')
  async publishPost(@Param('id') id: string): Promise<PostModel> {
    return this.postService.updatePost({
      where: { id: Number(id) },
      data: { published: true },
    });
  }

  @Delete('post/:id')
  async deletePost(@Param('id') id: string): Promise<PostModel> {
    return this.postService.deletePost({ id: Number(id) });
  }
}

起動と確認

これで完成のようですね。Nestを起動してみます。

yarn start

src/main.ts にある通り、ポートは3000番で立ち上がったので確認してみましょう。

% curl -XGET http://localhost:3000
{"statusCode":404,"message":"Cannot GET /","error":"Not Found"}%

404が返ってきてしまいました。

app.controller.ts をよく見ると / に対するリクエストへの処理は書いていないので当然ですね。

では http://localhost:3000/feed はどうでしょうか。全ての記事を取得してくれるはずです。

% curl -XGET http://localhost:3000/feed
[]

空の配列が返ってきました。記事が無いようです。知ってました。

まずはデータを登録しましょう。

% curl -XPOST -H "Content-Type: application/json" -d '{"title":"ブログタイトル"}' http://localhost:3000/post
{"id":1,"title":"ブログタイトル","content":null,"published":false,"authorId":null}%

そしてなんでチュートリアル的なレシピでこんなめんどくさい仕様にしたのかわかりませんが、この記事データは publish しないと見られないようになっています。

なので publish してあげましょう。

% curl -XPUT http://localhost:3000/publish/1
{"id":1,"title":"ブログタイトル","content":null,"published":true,"authorId":null}

これで取得できるようになったはずです。

% curl -XGET http://localhost:3000/feed
[{"id":1,"title":"ブログタイトル","content":null,"published":true,"authorId":null}]

% curl -XGET http://localhost:3000/post/1
{"id":1,"title":"ブログタイトル","content":null,"published":true,"authorId":null}

feed は全件取得なので配列で、 post/:id は1件取得なのでオブジェクトで返ってきていますね。

まとめ

今まで経験してきたORM(とそれを使うフレームワーク)はわりと規約だったり設定ファイルを覚えておかないといけないものが多かったんですが、NestもPrismaも、規約や設定ファイルというよりはガリガリとコードで書いていく感じなのかなという印象でした。

TypeScriptで記述することによってアノテーションでルーティングが書けたりするのはAndroidのRetrofitを彷彿とさせたり、感触としては悪くないものを感じています。

これがもっと大規模になり、エンドポイントが増えていくことが想定されるのであれば、RESTではなくGraphQLを採用してみたりとか色々といじっていくことができそうです。

今すぐ既存のRailsをリプレースするところまではできないと思いますが、細々と個人開発などで使ってみて、もうちょっと慣れてきたらプロダクションでも使ってみたいと思います。