こんにちは!スマートキャンプでインサイドセールス管理システム『BALES CLOUD』を開発・運用している中川です。
今回は、上記のプロダクトが有しているフィルター機能を、個人的な興味から Prisma でトレースして作ってみたところ、良いポイントがいくつもあったので紹介したいと思います!
また、Prisma を試すにあたって、既存の DB からスキーマを生成して実行環境を用意したので、そのあたりについても説明した記事になります。
Prisma とは
- Node.js や TypeScript で使用出来る ORM
- DBMS は PostgreSQL、MySQL、SQLite に対応
- データベースのスキーマをデータモデルに変換したうえで、データモデルに対して型推論が効く
- VSCode などエディタ上でクエリを書く際の DX が良い
チュートリアルやドキュメントなど、非常に詳しくまとまっているため、詳細は以下の公式サイトを参照してください。
余談ですが、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 個以上存在している状況です。
フィルター機能について
こちらの GIF で操作している「絞り込み」の部分で、要は絞り込み検索になります。
前述したcall_targets
テーブルを軸として、条件に合致した顧客だけを絞り込んでいく、プロダクトの使い勝手を左右する機能です。
また、この条件というのも豊富に用意されており、call_targets
自身が持つ電話番号などはもとより、商談の有無(前述したopportunities
テーブル上にリレーションされているレコードがあるかどうか)や、ユーザーが自由に追加出来るカスタム項目に対しての入力値を絞り込めるようなものになっています。
フィルタ条件同士はフィルタの種類(会社名、電話番号、など)が違えば共存が可能で、条件間は AND 検索になります。
さらに、条件のそれぞれに対してそれを「含む」のか「含まない」のか、を指定出来るようにもなっています。
課題
実は、このフィルター機能はいくつか課題を抱えています。
書き直してみるモチベーションにもなったところでもあるので、箇条書きで簡単に説明します。
- 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
部分を、 postgresql
、 mysql
、 sqlite
の 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 を見てみると、無事データモデルが作成されていることが確認出来るかと思います。
また、このスキーマファイルは定義ジャンプも効くようになっているため、たとえばデータモデルのリレーションに登場した他のデータモデルがどうなっているか詳細を見たいといったときに便利です。
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
モデルから見ると二段階の関連をまたぐフィルタです。
今回はrank
が3
より大きい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 で書き換える予定はありませんが、いつかやってみたいです!
それでは!