Prisma Clientの使い方を学ぶ

#プログラミング#Typescript#NodeJS#PostgresSQL#Prisma

By tkgstrator at

Prisma

一つ前の記事で Prisma のモデル定義を理解した前提で記事を書いていきます。つまり、自分が理解できていなかったらこの記事の内容を理解できないので、執筆が永遠に終わらないことになります。

Scheme

今回は以下のように scheme.prisma ファイルを定義しました。previewFeatures = ["interactiveTransactions"]を追記していますが、これはデータベースのロールバックするときに使います。

Prisma2 での transaction を ActiveRecord 風に書きたい問題で詳しく解説されているので、こちらを読むと幸せになれます。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["interactiveTransactions"]
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  username  String
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt()
}

model Post {
  id        Int      @id @default(autoincrement())
  text      String
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt()
}

で、このモデルで定義されたデータに対して Prisma Client を使って Typescript からデータを操作したいというのが今回の目標になります。

Prisma Service

prisma.service.tsというファイルをmain.tsと同じところに作成します。何も考えずコピペで OK です。

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();
  }
}

app.module.ts

次に app.module.ts にPrismaServiceを追記します。

これをしておかないと NestJS がエラーを吐きます。

import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { PrismaService } from "./prisma.service";

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

サービスの作成

さて、ユーザを定義したのでユーザを操作できるエンドポイントを作成します。

nest g module users
nest g service users
nest g controller users

のコマンドで必要なファイルを作成します。これだけで必要なものが勝手に作成されるのでありがたいです。

users.controller.ts

以下のようなものを書きます。

コントローラに全ての処理を書いていると長くなりすぎるので、実際の処理の内容はUsersServiceに丸投げする感じです。

import { Controller, Get, Post } from "@nestjs/common";
import { UsersService } from "./users.service";

@Controller("users")
export class UsersController {
  constructor(private readonly service: UsersService) {}

  @Get()
  findAll() {
    this.service.findAll();
  }

  @Post()
  create() {
    this.service.create();
  }
}

簡単に解説しておくとlocalhost:3000/usersにアクセスされたときの処理がここに定義されています。GET リクエストがくるとfindAll()が実行されるイメージです。

users.service.ts

実際に処理を書くのがこのファイルです。

データベースにアクセスしてデータをあれこれするのでPrismaServiceをインポートしておきましょう。

import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma.service";

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

  findAll() {}

  create() {}
}

これで全ての準備は完了です。

あとはやりたいことをfindAll()create()に書いていくだけになります。

NestJS

リクエストで受け取るデータ

API 側がデータを受け取るには主にパスパラータ、クエリパラメータ、ボディパラメータの三つがある。他にはヘッダーで受け取るなどがあるが、ここでは考えないこととする。

これらは NestJS ではそれぞれ次の属性に対応している。いつも@Paramをクエリパラメータだと勘違いしてしまうが、これはパスパラメータなので注意すること。

@Param @Query @Body
Path Form JSON
  • @Param
    • パスパラメータ
  • @Query
    • フォームパラメータ
  • @Body
    • JSON データ、GET では扱えない

リクエストで受け取る型

基本的にネットワーク経由で送られてくるパスパラメータとクエリパラメータは全て文字列型として送信されています。

@Get(':id')
findOne(@Param('id') id: number) {
  console.log(typeof id === 'number'); // false
  return 'This action returns a user';
}

例えば上のコードではデータはnumber型で受け取ると明記しているにも関わらずstring型で受け取ってしまいます。

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  console.log(typeof id === 'number'); // true
  return 'This action returns a user';
}

その時はこのように ValidationPipe を通すことで型チェックと型変換を同時に行うことができます。

バリデーション

返すデータはデータベースに突っ込まれている以上、常に整合性が取れているのだが、リクエストの正当性をチェックしなければいけない。

例えば、ユーザ ID が整数型だとすれば入力として整数値以外が入っていた場合は自動的にエラーを返してほしいわけである。

なお、執筆にあたりValidationの項目を参考にした。いや、ホント公式ドキュメントが参考になります。

Validation Pipe

デフォルトで五つのバリデーション用の Pipe が用意されている。この中でもValidationPipeは特に優秀でAuto Validationを設定するだけで基本的には対応できる。

  • ValidationPipe
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe

それぞれ@Param@Queryなどに適用して、入力された値の型が間違いなくそれであることを保証するものである。

これを使わないと入力値は常に文字列型で受け取ってしまうので注意が必要である。

ちなみに ValidationPipe にはいろいろオプションを設定できるので、詳しくは公式ドキュメントを参照されたい。

入力値のバリデーション

users.controller.tsを次のように変更する。FindUserDtoというクラスを作成し、そのクラスに対してバリデーションをかける。

// users.controller.ts
@Get(':id')
find(@Param() request: FindUserDto): Promise<UserModel> {
  return this.service.find(request.id);
}

どこに定義しても良いが、今回はusers.dto.tsに定義した。

import { ApiProperty } from "@nestjs/swagger";
import { Expose, Transform } from "class-transformer";
import { IsInt, IsNumberString, Min } from "class-validator";

export class FindUserDto {
  @Expose() // 変換した値をそのまま利用する
  @Transform((params) => parseInt(params.value, 10)) // 入力された値を10進数整数値に変換する
  @IsInt() // 整数値以外は許可しない
  @Min(1) // 最小値は1
  @ApiProperty() // これを忘れるとSwaggerに表示されない
  id: number;
}

パスパラメータとして受け取るのは常に文字列型なので、このように@Transformで整数型に変換してやらないと常にエラーを返してしまう。

ちなみに、ただ単純に整数値であることを保証したいだけであれば、

@Get(':id')
find(@Param('id', ParseIntPipe) id: number): Promise<UserModel> {
  return this.service.find(id);
}

のようにFindUserDtoを利用せずにParseIntPipeで整数値に変換可能な入力のみを通すことができる。

なお、ParseIntPipeを利用して整数値以外のリクエストを投げた場合、以下のようなエラーメッセージが返ってくる。

{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}

バージョニング

API はバージョンが変わるとレスポンスが変わったり、受理するリクエストが変わったりする。

しかし、単に API を全部変えてしまうと旧 API を使っている人が困ってしまう場合がある。だが、エンドポイントを分けるとそれはそれでめんどくさい。

そこで、NestJS で利用されているバージョン管理機能を利用する。

バージョン管理タイプ

Versioningを読んで得られた知見をまとめる。めんどくさいので全部ではなく、自分が利用できそうだと思ったところをメインに書いていく。

  • URI
  • ヘッダー
  • メディアタイプ
  • カスタム

の四種類でバージョン管理ができる。ヘッダーというのは指定されたヘッダーのキーにバージョンの値を入れておいて、それで分岐する感じである。これを利用しているのがスプラトゥーン 2 で採用されている X-Product Version に該当する。

個人的にはよく使われているのは URI バージョニングの気がしている。これは単にエンドポイントに対してv1v2といったパスが追加される感じである。

今回はこれを利用することにした。

設定方法

やることは簡単でmain.tsに追記する。

// main.ts
app.enableVersioning({
  type: VersioningType.URI,
});

これだけでバージョン管理システムは有効化されている。

// users.controller.ts
@Controller({ path: 'users', version: '1' })

どのバージョンを利用するかはコントローラに設定すれば良い。なお、エンドポイントごとにバージョンを分けることもできる。その場合は以下のように記述する。

import { Controller, Get, Version } from "@nestjs/common";

@Controller()
export class CatsController {
  @Version("1")
  @Get("cats")
  findAllV1() {
    return "This action returns all cats for version 1";
  }

  @Version("2")
  @Get("cats")
  findAllV2() {
    return "This action returns all cats for version 2";
  }
}

その他、複数のバージョニングや、デフォルト値などの設定もあるのでこの辺をしっかりと読んでおくように。

スキーマオブジェクト

まだ必要ではないが、将来的に必ず必要になりそうなのでドキュメントだけ載せておく。

カスタムレスポンス

NestJS には後述するように 427 エラーに対応するステータスコードが定義されておらず、また 427 エラーのレスポンスも定義されていないので予め定義しておく。

import { applyDecorators } from "@nestjs/common";
import { ApiResponse } from "@nestjs/swagger";

export const ApiUpgradeRequiredResponse = () => {
  return applyDecorators(
    ApiResponse({
      status: 427,
    })
  );
};

こうしておけば@ApiUpgradeRequiredResponse()として呼び出すことができる。

エラー処理

絶対に必要なのがエラー処理である。エラーの種類はそれこそ無数にあって全部列挙するのは難しいので、今回は基本的なものだけを列挙していく。

エラーに対応するレスポンスを Swagger に定義する場合は以下のものが利用できる。よくあるステータスコードについてはHTTP ステータスコードのドキュメントを参照した。

  • @ApiOkResponse()
    • 200
  • @ApiCreatedResponse()
    • 201
  • @ApiAcceptedResponse()
    • 202
  • @ApiNoContentResponse()
    • 204
  • @ApiMovedPermanentlyResponse()
    • 301
  • @ApiBadRequestResponse()
    • 400
  • @ApiUnauthorizedResponse()
    • 401
  • @ApiForbiddenResponse()
    • 403
  • @ApiNotFoundResponse()
    • 404
  • @ApiMethodNotAllowedResponse()
    • 405
  • @ApiNotAcceptableResponse()
    • 406
  • @ApiRequestTimeoutResponse()
    • 408
  • @ApiConflictResponse()
    • 409
  • @ApiTooManyRequestsResponse()
    • 429
  • @ApiGoneResponse()
    • 410
  • @ApiPayloadTooLargeResponse()
    • 413
  • @ApiUnsupportedMediaTypeResponse()
    • 415
  • @ApiUnprocessableEntityResponse()
    • 422
  • @ApiInternalServerErrorResponse()
    • 500
  • @ApiNotImplementedResponse()
    • 501
  • @ApiBadGatewayResponse()
    • 502
  • @ApiServiceUnavailableResponse()
    • 503
  • @ApiGatewayTimeoutResponse()
    • 504
  • @ApiDefaultResponse()

という感じで、基本的なステータスコードについては対応したレスポンスが予め定義されている。

該当するカラムがないときに 404 を返す

必要そうになりそうなエラー処理として、まずこれが考えられる。

// users.service.ts
find(id: number): Promise<UserModel> {
  return this.prisma.user.findUnique({
    where: { id: Number(id) },
  });
}

ユーザの検索は上のようなコードで実行しているのだが、検索結果が 0 だったときにはエラーを返したいわけである。

で、返すためのエラーはあらかじめいろいろ定義されているのでBuilt-in HTTP exceptionsを読んでおこう。

ちなみに、今回のような場合は以下のコードで実装できる。

検索結果が null ならエラーを返す

find(id: number): Promise<UserModel> {
  return this.prisma.user
    .findUnique({
      where: { id: id },
    })
    .then((user) => {
      if (user === null) {
        throw new NotFoundException();
      }
      return user;
    });
}

findUnique()で検索して、なければNotFoundException()を返せば良い。

tip rejectOnNotFound を利用する Prisma 側にユニーク検索でヒットしなかった場合にエラーを返す仕組みが存在した!なのでnullチェックは不要でした。

async find(id: number): Promise<UserModel> {
  return this.prisma.user
    .findUnique({
      where: { id: id },
      rejectOnNotFound: true,
    })
    .then((user) => {
      return user;
    })
    .catch((error) => {
      throw new NotFoundException();
    });
}

アップデートでエラーを返す

次は既存のユーザのデータをアップデートしようとした場合にエラーを返すことを想定する。アップデート用のデータを受ける場合にはPrisma.UserUpdateInputという便利な型が使えるのでこれを利用する。

tip 便利な型 Prisma で定義したモデルのものが使える。Bookを定義して、それをアップデートしたい場合にはPrisma.BookUpdateInputを利用すれば良い。

async update(id: number, data: Prisma.UserUpdateInput): Promise<UserModel> {
  return this.prisma.user
    .update({
      where: { id: id },
      data: {
        username: data.username,
      },
    })
    .then((user) => {
      return user;
    })
    .catch((error) => {
      const message = (error as PrismaClientKnownRequestError).meta.cause;
      throw new HttpException(message, HttpStatus.NOT_FOUND);
    });
}

すると上のようなコードが書ける。ただこれは実際に書き込んでエラーが返ってきているが、本来は書き込む前にエラー判定が必要な気もする。

なのでこれをそのまま使うのは良くないような気がする。公式のコードを読むと、

@Put('publish/:id')
async togglePublishPost(@Param('id') id: string): Promise<PostModel> {
  const postData = await this.prismaService.post.findUnique({
    where: { id: Number(id) },
    select: {
      published: true,
    },
  })

  return this.prismaService.post.update({
    where: { id: Number(id) || undefined },
    data: { published: !postData?.published },
  })
}

のようになっており、アップデートをする前に存在チェックを行っていることがわかる。

なのでこれに従ってコードを書き直すと、以下のようになりそうな気がする。

async update(id: number, data: Prisma.UserUpdateInput): Promise<UserModel> {
  return this.prisma.user
    .findUnique({
      where: { id: id },
    })
    .then((user) => {
      if (user === null) {
        throw new NotFoundException();
      }
      return this.prisma.user.update({
        where: { id: id },
        data: {
          username: data.username,
        },
      });
    });
}

こう書けばなければ 404 が返り、それ以外ではデータが正しく更新される。

レスポンスを返す

ページネーション

ページネーションはカラム数が多い場合にサーバへの負荷を考えて結果を分割して返す仕組みである。

返し方はいろいろあるのだが、prevnextを利用するものがデータベースを全件走査しないだけ負荷が低い。何故ならoffset方式の場合は全部で何件データがあるかを予め返す必要があるからだ。

が、よほどデータが多くない限りoffset方式でも問題がないと思われる。実際、NestJS のドキュメントではこの方式のコードが書かれている。では、実際にそのコードを紐解いてみよう。

import { applyDecorators, Type } from "@nestjs/common";
import { ApiOkResponse, ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Expose, Transform } from "class-transformer";
import { IsInt, Max, Min } from "class-validator";

export class PaginatedResponseDto<TData> {
  @Expose()
  @Transform((params) => {
    parseInt(params.value, 10);
  })
  @IsInt()
  @ApiProperty({ type: "integer" })
  total: number;

  @Expose()
  @Transform((params) => {
    parseInt(params.value, 10);
  })
  @IsInt()
  @Max(50)
  @Min(0)
  @ApiProperty({ type: "integer", minimum: 0, maximum: 50 })
  limit: number;

  @Expose()
  @Transform((params) => {
    parseInt(params.value, 10);
  })
  @IsInt()
  @ApiProperty({ type: "integer" })
  offset: number;

  results: TData[];
}

export const ApiPaginatedResponse = <TModel extends Type<any>>(
  model: TModel
) => {
  return applyDecorators(
    ApiOkResponse({
      schema: {
        allOf: [
          { $ref: getSchemaPath(PaginatedResponseDto) },
          {
            properties: {
              results: {
                type: "array",
                items: { $ref: getSchemaPath(model) },
              },
            },
          },
        ],
      },
    })
  );
};

少々長いが、上のようなコードをどこかに定義する。ただ、このままではPaginatedResponseDtoが Swagger に正しく反映されないので、

Query

次にモデルに対するクエリを学ぶ。ドキュメントは今回は Prisma のものを読むことにした。

findUnique

ID またはユニーク属性があるフィールドで検索を行う

名前 必須 意味
where Yes ユニークキーを指定
select No 指定したフィールドを返すかどうか
include No リレーションを返すかどうか
rejectOnNotFound No 該当しない場合にエラーを返す

findFirst

データベースで最初に合致したレコードを返す。

名前 必須 意味
distinct No 特定のフィールドの重複する行をフィルタリグ
where No 合致条件
cursor No 検索結果の中の特別な位置
orderBy No ソートするフィールド
include No リレーションを返すかどうか
select No 指定したフィールドを返すかどうか
skip No 最初の N 件を無視する
take No 1 か −1 を指定、−1 なら最後の一件
rejectOnNotFound No 該当しない場合にエラーを返す

findMany

データベースで合致するレコードの配列を返す。何もしないと全件返ってくるので注意。

名前 必須 意味
where No 合致条件
orderBy No ソートするフィールド
skip No 最初の N 件を無視する
cursor No 検索結果の中の特別な位置
take No 1 か −1 を指定、−1 なら最後の一件
select No 指定したフィールドを返すかどうか
include No リレーションを返すかどうか
distinct No 特定のフィールドの重複する行をフィルタリグ

create

新しいレコードを作成する。Nested create をした場合は全てのレコードが同時に作成される。

名前 必須 意味
data Yes 書き込むデータ
select No 指定したフィールドを返すかどうか
include No リレーションを返すかどうか

createMany

一つのトランザクションで複数のレコードを作成する。

名前 必須 意味
data Yes 書き込むデータ
skipDuplicates No 重複した際の挙動

updateMany

一つのトランザクションで複数のレコードを更新する。

名前 必須 意味
data Yes 書き込むデータ
where No 条件

where で合致したレコードの中身を data で置き換える。

deleteMany

いっぱい消す、以上。

count

条件に合致するレコードの件数を返す。

名前 必須 意味
where No 合致条件
cursor No 検索結果の中の特別な位置
skip No 最初の N 件を無視する
take No 1 か −1 を指定、−1 なら最後の一件
orderBy No ソートするフィールド
select No 指定したフィールドを返すかどうか

null が入っているレコードもカウントされてしまうので、あるフィールドに null が入っているものを除外したい場合には select を使うこと。

aggregate

集約、概要、グループ、そしてよくわからん。まあ多分集約関数のようなもの。

名前 必須 意味
where No 合致条件
orderBy No ソートするフィールド
cursor No 検索結果の中の特別な位置
skip No 最初の N 件を無視する
take No 1 か −1 を指定、−1 なら最後の一件
_count No
_avg No
_sum No
_min No
_max No

groupBy

ドキュメント

名前 必須 意味
where No 合致条件
orderBy No ソートするフィールド
by No グループ化するフィールド
having No 集約値でグループ化することを許可
skip No 最初の N 件を無視する
take No 1 か −1 を指定、−1 なら最後の一件
_count No
_avg No
_sum No
_min No
_max No

クエリオプション

select

include

where

orderBy

distinct

階層クエリ

create

階層クエリはあるレコードを追加するときに別のレコードも同時に追加するようなときに使われる。例えば User モデル作成時に、そのユーザのプロフィールを Profile モデルとして作成するような場合である。

const user = await prisma.user.create({
  data: {
    username: "tkgling",
    profile: {
      create: { commnet: "Hello World" },
    },
  },
});

これは例えば上のようなコードで実装される。逆にプロフィール実装時にユーザを追加することもできる。

const user = await prisma.profile.create({
  data: {
    comment: 'Hello World',
    user: {
|     create: { username: 'tkgling' },
    },
  },
})

また、複数のレコードを一度に作成することもできる。

const user = await prisma.user.create({
  data: {
    username: "tkgling",
    posts: {
      create: [
        {
          title: "This is my first post",
        },
        {
          title: "This is my second post",
        },
      ],
    },
  },
});

このとき、なんで createMany ではないのかが気になったりする。

const user = await prisma.user.update({
  where: { username: "tkgling" },
  data: {
    profile: {
      create: { comment: "Hello World" },
    },
  },
});

こういうコードも書けるらしいが、これがどうなるのか気になる。一見すると where で合致する全てのユーザの Profile が新規作成されそうだ。Profile は一人に一つのはずなので、既に Profile があればこれはバグを引き起こしそうなのだが......

createMany

階層クエリの createMany は一つの親レコードに対して複数のレコードセットを追加する。

多対多のリレーションでは利用できない。例えばprisma.post.create(...)の内部で Category モデルを追加するためにcreateManyを呼ぶことはできない。

ただし、多対一のリレーションでは利用できるのでprisma.user.create(...)の内部で Post モデルを追加することはできる。

set

リレーションの値を上書きする。例えば Post のレコードを別のレコードに上書きできる。詳しくはドキュメントを読めとのこと。

connect

階層クエリの connect は既に存在するレコードのプライマリキーまたはユニーク制約を連携する。

const user = await prisma.profile.create({
  data: {
    comment: "Hello World",
    user: {
      connect: { username: "tkgling" },
    },
  },
});

例えばこのように書けばユーザ名tkglingの Profile が作成される。ユーザ自体は作成されない。

const user = await prisma.profile.create({
  data: {
    comment: "Hello World",
    user: {
      connect: { id: 1 },
    },
  },
});

このようにプライマリキーを指定することもできる。また、バージョン 2.11.0 以降は省略して、

const user = await prisma.profile.create({
  data: {
    comment: "Hello World",
    userId: 1,
  },
});

と書くこともできるようだ。

connectOrCreate

あれば connect、なければ create してくれる大変賢いクエリ。

disconnect

リレーションを削除する。なんで unset という名前にしなかったのかは謎。

upsert

あれば update、なければ create する大変賢いクエリ。なんで insert じゃなくて create なのかは謎(そもそも何故 create なのか

delete

消える。

updateMany

普通の updateMany と違いがよくわからない。例えば、ユーザ ID が 2 で、いいね数が 0 の投稿を非公開にするコードは以下のように書ける。

const result = await prisma.user.update({
  where: {
    id: 2,
  },
  data: {
    posts: {
      updateMany: {
        where: {
          published: false,
        },
        data: {
          likes: 0,
        },
      },
    },
  },
});

deleteMany

たくさん消す。

Guard

現在進行中のプロダクトではあまり必要とされていないが、権限によるアクセス制限が考えられる。

で、それをよしなにやってくれる仕組みが NestJS にはある。あるのだが、どのくらい便利なのかわからないので試してみることにした。

今回もドキュメントを読んで理解をすすめることにした。Guard というやつはパーミッション、権限、アクセスコントロールのようなものを制御してルーティング可能かどうかを返す仕組みらしい。

当然ながらこの仕組みは認証として利用されることが多い。

Guard の種類

Authorization guard

API を叩くときに、そのエンドポイント、リクエストを叩くことができる権限があるかどうかをチェックする。

import { Injectable } from "@nestjs/common";

@Injectable()
export class AuthGuard {
  async canActivate(context) {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}

Role-based authentication

import { Injectable } from "@nestjs/common";

@Injectable()
export class RolesGuard {
  canActivate(context) {
    return true;
  }
}

Guard を適用する

以下のようにすればusers以下のエンドポイントにアクセスする際に Guard が機能する。

@Controller("users")
@UseGuards(new RolesGuard())
export class UsersController {}

もしも全体に適用させたい場合はmain.tsに以下のように書けば良い。

const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());