Quantumleap
5344 words
27 minutes
Prisma Clientの使い方を学ぶ

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
PathFormJSON
  • @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, published: Prisma.UserUpdateInput): Promise<UserModel> {
  return this.prisma.user
    .update({
      where: { id: id },
      published: {
        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 },
    published: { published: !postData?.published },
  })
}

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

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

async update(id: number, published: 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 },
        published: {
          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 またはユニーク属性があるフィールドで検索を行う

名前必須意味
whereYesユニークキーを指定
selectNo指定したフィールドを返すかどうか
includeNoリレーションを返すかどうか
rejectOnNotFoundNo該当しない場合にエラーを返す

findFirst#

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

名前必須意味
distinctNo特定のフィールドの重複する行をフィルタリグ
whereNo合致条件
cursorNo検索結果の中の特別な位置
orderByNoソートするフィールド
includeNoリレーションを返すかどうか
selectNo指定したフィールドを返すかどうか
skipNo最初の N 件を無視する
takeNo1 か −1 を指定、−1 なら最後の一件
rejectOnNotFoundNo該当しない場合にエラーを返す

findMany#

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

名前必須意味
whereNo合致条件
orderByNoソートするフィールド
skipNo最初の N 件を無視する
cursorNo検索結果の中の特別な位置
takeNo1 か −1 を指定、−1 なら最後の一件
selectNo指定したフィールドを返すかどうか
includeNoリレーションを返すかどうか
distinctNo特定のフィールドの重複する行をフィルタリグ

create#

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

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

createMany#

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

名前必須意味
dataYes書き込むデータ
skipDuplicatesNo重複した際の挙動

updateMany#

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

名前必須意味
dataYes書き込むデータ
whereNo条件

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

deleteMany#

いっぱい消す、以上。

count#

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

名前必須意味
whereNo合致条件
cursorNo検索結果の中の特別な位置
skipNo最初の N 件を無視する
takeNo1 か −1 を指定、−1 なら最後の一件
orderByNoソートするフィールド
selectNo指定したフィールドを返すかどうか

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

aggregate#

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

名前必須意味
whereNo合致条件
orderByNoソートするフィールド
cursorNo検索結果の中の特別な位置
skipNo最初の N 件を無視する
takeNo1 か −1 を指定、−1 なら最後の一件
_countNo
_avgNo
_sumNo
_minNo
_maxNo

groupBy#

ドキュメント

名前必須意味
whereNo合致条件
orderByNoソートするフィールド
byNoグループ化するフィールド
havingNo集約値でグループ化することを許可
skipNo最初の N 件を無視する
takeNo1 か −1 を指定、−1 なら最後の一件
_countNo
_avgNo
_sumNo
_minNo
_maxNo

クエリオプション#

select#

include#

where#

orderBy#

distinct#

階層クエリ#

create#

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

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

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

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

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

const user = await prisma.user.create({
  published: {
    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" },
  published: {
    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({
  published: {
    comment: "Hello World",
    user: {
      connect: { username: "tkgling" },
    },
  },
});

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

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

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

const user = await prisma.profile.create({
  published: {
    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,
  },
  published: {
    posts: {
      updateMany: {
        where: {
          published: false,
        },
        published: {
          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());
Prisma Clientの使い方を学ぶ
https://fuwari.vercel.app/posts/2022/05/prismaclient/
Author
tkgling
Published at
2022-05-09