SMARTCAMP Engineer Blog

スマートキャンプ株式会社(SMARTCAMP Co., Ltd.)のエンジニアブログです。業務で取り入れた新しい技術や試行錯誤を知見として共有していきます。

既存アプリケーションのフィルタ機能を題材に Prisma を試し書きしてみた話

こんにちは!スマートキャンプでインサイドセールス管理システム『BALES CLOUD』を開発・運用している中川です。

今回は、上記のプロダクトが有しているフィルター機能を、個人的な興味から Prisma でトレースして作ってみたところ、良いポイントがいくつもあったので紹介したいと思います!

また、Prisma を試すにあたって、既存の DB からスキーマを生成して実行環境を用意したので、そのあたりについても説明した記事になります。

Prisma とは

  • Node.js や TypeScript で使用出来る ORM
  • DBMS は PostgreSQL、MySQL、SQLite に対応
  • データベースのスキーマをデータモデルに変換したうえで、データモデルに対して型推論が効く
    • VSCode などエディタ上でクエリを書く際の DX が良い

チュートリアルやドキュメントなど、非常に詳しくまとまっているため、詳細は以下の公式サイトを参照してください。

www.prisma.io

余談ですが、Prisma の現行バージョンは 2 でして、1 からはアーキテクチャごと刷新されています。
GraphQL とは完全に切り離されていたりするので、バージョン 1 の Prisma を触っていた方は再度学び直す必要がありそうです。

前提:どういったプロダクトか

はじめに、前提としてどういったプロダクトのどういった機能について話すのか簡単に説明させてください。

プロダクトのアーキテクチャについて

以下の構成の SPA です。(インフラ部分は省略) フロント:Vue.js バックエンド:Ruby on Rails データベース:PostgreSQL

今回お話するのは Ruby on Rails 部分において Active Record を駆使して実現しているフィルター機能の部分になります。

プロダクトのデータ構造について

このプロダクトは主に顧客情報を管理する機能を提供していて、その顧客ひとりひとりの情報をcall_targetsテーブルの 1 レコードとして永続化しています。

直接call_targetsテーブルのカラムとして持っている情報(電話番号、住所など)もあれば、別のテーブルで管理してリレーションさせている情報もあります。
たとえば、ある顧客との商談ひとつひとつを記録するopportunitiesテーブルなどです。
こういったcall_targetsテーブルとリレーションを持ったテーブルが20 個以上存在している状況です。

フィルター機能について

f:id:mkt0225:20201118150407g:plain

こちらの GIF で操作している「絞り込み」の部分で、要は絞り込み検索になります。
前述したcall_targetsテーブルを軸として、条件に合致した顧客だけを絞り込んでいく、プロダクトの使い勝手を左右する機能です。

また、この条件というのも豊富に用意されており、call_targets自身が持つ電話番号などはもとより、商談の有無(前述したopportunitiesテーブル上にリレーションされているレコードがあるかどうか)や、ユーザーが自由に追加出来るカスタム項目に対しての入力値を絞り込めるようなものになっています。
フィルタ条件同士はフィルタの種類(会社名、電話番号、など)が違えば共存が可能で、条件間は AND 検索になります。

さらに、条件のそれぞれに対してそれを「含む」のか「含まない」のか、を指定出来るようにもなっています。

f:id:mkt0225:20201118142813p:plain
会社名でフィルタリングする

課題

実は、このフィルター機能はいくつか課題を抱えています。
書き直してみるモチベーションにもなったところでもあるので、箇条書きで簡単に説明します。

  • Active Record ではうまく実現できない部分がある
    • 複雑な条件だと生の SQL が登場したりする
  • 条件をパースして適切なフィルタオブジェクトに変換している
    • 条件をConditionクラスとして表し、使う条件ごとにインスタンス化するなど可読性を上げる工夫をしているが、どうしても"機能に対して手間がかかっている感"が拭えない
  • (上記のことなどから)コードが複雑で、見通しが悪い
  • フィルタに関連するコードが散らばっている

以上がプロダクトとフィルタ機能の概要、そして抱えている課題になります。

前提としているデータモデルについて

今回定義しているモデルはそれぞれ以下のような役割を担っています。

  • call_targets: 架電対象の情報が保存される
  • call_results: ひとつひとつの架電結果が保存される
  • hearing_ranks: ヒアリングに成功した場合に記録され、架電対象のランクなどが保存される

モデル同士の関係性は、まず call_targets モデルがあり、1:多でリレーションされる形で call_results モデル、さらに call_results モデルから 1:多で hearing_ranks モデルが存在するような形です。

Prisma のデータモデルで表すと以下のようなコードになります。

// 今回の記事で扱わないカラムは省略しています

model call_targets {
  id               Int               @id @default(autoincrement())
  company_name     String                @default("")
  company_address  String                @default("")
  call_results     call_results[]
}

model call_results {
  id               Int                    @id @default(autoincrement())
  call_target_id   Int?
  comment          String?
  call_targets     call_targets?          @relation(fields: [call_target_id], references: [id])
  hearing_ranks    hearing_ranks[]
}

model hearing_ranks {
  id               Int           @id @default(autoincrement())
  call_result_id   Int?
  rank             Int
  call_results     call_results? @relation(fields: [call_result_id], references: [id])
}

プロダクトに関しての前提条件の説明は以上になります。
ではさっそく、Prisma をこのプロダクトのデータベースに対してセットアップして試していきます!

Prisma をセットアップする

公式サイトで紹介されている手順に沿って以下のように進めていきます。
数手順で完了する手軽さが素晴らしいですね。

今回の作業用ディレクトリの作成と Prisma のための準備

$ mkdir prisma-handson && cd prisma-handson
$ npm init -y

Prisma CLI のインストール

$ npm install @prisma/cli --save-dev

Prisma schema を作成

$ npx prisma init

新しく prisma ディレクトリが作られ、なかに .env と schema.prisma ファイルが作られていることが確認できれば OK です。
さらに、実行結果に表示されている Next Steps に従っていくつか設定を進めます。

.env に DB の接続情報を設定する

.env を開き、以下のように接続情報を入力し保存します。

DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"

schema.prisma に 接続先の DBMS を設定する

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

上記のprovider部分を、 postgresqlmysqlsqliteの 3 つから接続先の DBMS と一致するように選びます。

データベースのスキーマから Prisma のデータモデルを生成する

$ npx prisma introspect

Environment variables loaded from /Users/user/prisma-handson/prisma/.env
Prisma schema loaded from prisma/schema.prisma

Introspecting based on datasource defined in prisma/schema.prisma …

✔ Introspected 63 models and wrote them into prisma/schema.prisma in 1.86s

Run prisma generate to generate Prisma Client.

ここで schema.prisma を見てみると、無事データモデルが作成されていることが確認出来るかと思います。

f:id:mkt0225:20201122171642p:plain
リレーションやNull許可、デフォルト値なども漏れなく作成されました

また、このスキーマファイルは定義ジャンプも効くようになっているため、たとえばデータモデルのリレーションに登場した他のデータモデルがどうなっているか詳細を見たいといったときに便利です。

Prisma Client をセットアップする

コード上から import して使用することになるクライアントを生成します。
下記のコードでパッケージのインストールとクライアントの生成が完結します。

$ npm install @prisma/client

Environment variables loaded from /Users/user/prisma-handson/prisma/.env
Prisma schema loaded from prisma/schema.prisma

✔ Generated Prisma Client (version: 2.11.0) to ./node_modules/@prisma/client in 1.70s

You can now start using Prisma Client in your code:

import { PrismaClient } from '@prisma/client'
// or const { PrismaClient } = require('@prisma/client')

const prisma = new PrismaClient()

Explore the full API: http://pris.ly/d/client

以上で Prisma のセットアップは完了です!

動作確認する

それでは、まずは動作確認も兼ねて簡単なクエリを実行してみます。

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  const call_targets = await prisma.call_targets.findMany();
  console.log(call_targets);
}

main()
  .catch((e) => {
    throw e;
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

main 関数のなかが実際にクエリを実行している部分です。
findMany メソッドは引数に取得条件を取るため、引数なしの場合は全件取得することになります。

アプリケーション上でいえば、このクエリは単純に一覧を表示するようなシーンで使用するクエリですね。

このファイルを$ ts-node scripts.tsで実行してみたところ、無事に結果が返ってきました。

フィルタ機能を模倣してみる

ここからは、実際のフィルタ条件を紹介しながら、そのフィルタを実現する Prisma のコードを紹介していきます。

複数条件のフィルタ

まずは「会社名(company_name)に テスト を含む」かつ、「住所(company_address)に 東京都 を含む」条件でフィルタしてみます。

// main以外は同様のため省略
async function main() {
  const call_targets = await prisma.call_targets.findMany({
    where: {
      AND: {
        company_name: {
          contains: "テスト",
        },
        company_address: {
          contains: "東京都",
        },
      },
    },
  });
  console.log(call_targets);
}

上記コードで条件を満たしたcall_targetsレコードを絞り込めます。
見ての通りになりますが、各条件をひとつのオブジェクトとして集約させられました。
where, ANDなど、構成する条件ごとにオブジェクトとしてまとめるような構造になり、見通しも良いです。

関連先を条件とするフィルタ

次はcall_targetsモデルの関連先であるcall_resultsモデルの特定のカラムに対して条件を設定するフィルタをかけてみます。

具体的に言うと、「紐付いているコール結果(call_results)のコメント(comment)にテストを含むものが一つでも存在する」条件によるフィルタです。

これを実現するのは以下のコードのようになります。

async function main() {
  const call_targets = await prisma.call_targets.findMany({
    where: {
      AND: {
        call_results: {
          some: {
            comment: {
              contains: "テスト",
            },
          },
        },
      },
    },
  });
  console.log(call_targets);
}

前出の条件でいうcompany_nameといったカラムのように関連するcall_resultsを指定でき、さらにその中で条件としてsomeをオブジェクトのキーの形で設定します。
someは「ひとつでもマッチすること」を表し、他にはevery(すべてがマッチすること)やnone(ひとつもマッチしないこと)といった条件が設定できます。

ネストされた関連先

最後に、ひとつ前のcall_resultsモデルからさらに関連するhearing_ranksモデルの特定のカラムに対して条件を設定するフィルタをかけます。
つまり、call_targetsモデルから見ると二段階の関連をまたぐフィルタです。

今回はrank3より大きいhearing_ranksレコードを関連に持つcall_resultsレコード、をさらに関連に持つcall_targetsレコードを取得するようなフィルタリングを行います。

これは以下のようなコードで実現できます。

async function main() {
  const call_targets = await prisma.call_targets.findMany({
    where: {
      AND: {
        call_results: {
          some: {
            hearing_ranks: {
              some: {
                rank: {
                  gt: 3,
                },
              },
            },
          },
        },
      },
    },
  });
  console.log(call_targets);
}

ネストされた関連先でも問題なくフィルタリングできました。
データモデルのネスト構造がそのままオブジェクトの深さに直結するので、ちょっとウッとなる見た目にはなってしまいますが、ネストが深くなろうと型推論がきっちり効くので、書く分には快適でした。
実アプリケーションで引数に地でこうしたオブジェクトを渡すことはないだろう(きっとビルダー的な関数でフィルタ条件のオブジェクトを組み立てるはず。。。)ことも鑑みて、こうして見た目上のオブジェクトの階層が深くなってしまうことはそこまでデメリットとは感じていません。

まとめ

駆け足になりましたが、Prisma でフィルター機能をいくつか書き換えてみました。
クエリを操作する豊富な API が用意されているため、他の複雑なフィルタ条件についても問題なく再現できそうな感覚を得られました。
プロダクトのフィルター機能を実際に Prisma で書き換える予定はありませんが、いつかやってみたいです!

それでは!

参考

www.prisma.io

www.prisma.io