PrismaスキーマからMVCでいうモデルを自動生成するやつ作ったよ feat. frourio & prisma generator
こんにちは、もう2024年も終わってしまいそうですね(遠い目)。
さて、タイトルのようなものを作ったのですが、経緯が大事かと思うので、2024年を振り返りながら書いてみようかと思います。
経緯というか2024年の振り返り
2024年序盤はtRPCが話題をかっさらい、honoがすべてを持っていったように見えますが、frourioばっかりやっていました。frourioについては下記をどうぞ。
さて、昨年〜今年は@solufa さんとfrourioを使った官公庁向けアプリケーション開発を頑張っていたのがかなり心に残っています。@solufaさん、非常にお世話になりました。ありがとうございました🙇 (ちなみにあれからも浮気することなく、受託したアプリケーションはfrourioで開発し続けています)
ひょんなきっかけでPrisma generatorの記事をみかけて自分で作ってみた
Prisma generatorはよく使っていたのですが、まさかここまで簡単かと思わずに避けていました。とあるプロジェクトで余裕のできた時間に、こちらも作成並行するという形でgeneratorを作ってみたところ、思ったよりもすぐに実践投入できることが判明しました。それがv1リリースまででした。
さらに調査をすすめていくと、色々なメリットが感じられるようになったのです。
- モデルの自動生成によってPrismaスキーマを使ったかなり前衛的なスキーマ駆動開発を実現
- Prismaでは困難なDBアクセス時の
Json
フィールド型への型ヒント搭載 - Prismaでmigrationコマンドを叩かなくても、自動生成されたモデルのみでコードを書き進めることができる(地味にDX向上に最も貢献したポイント)
aspidaでフロントエンドをつないで、実際にできたAPIをたたいみて、はじめてprismaから例外が返される(テーブルが存在しない、カラムが存在しない等)。そこで、はじめて型だけでプログラムを書いていたことを思い出す圧倒的なDXを実現できたような気がします。(ベースはfrourioのおかげ)
Prismaスキーマとアプリケーション層を接続するレイヤーとして、prisma generatorを作成しよう、というコンセプトがきまってきたのでv2リリースでした。現在は安定版として提供し、自社のfrourioプロジェクトで全て導入しています。 少なくともうちの会社が存続し、frourioを使っている期間はメンテナンスを実施予定です。
他のPrisma ORMを使ったフルスタックTypeScriptプロジェクトでも利用ができるとは思いますが、あくまで自社の受託開発の効率化のニュアンスが強いので、気になった方はフォークして掘り下げてみてください。(とはいえ同じペインを持った人には最新リリースでも十分すぎるDX向上になるかと思います)
フルスタックTypeScriptプロジェクトの隠れた割れ窓としてのPrisma ORM
そんな感じでfrourioの一般的な構成(バックエンド:fastify/Node.js/Prisma/Docker, フロントエンド:Next.js/Node.js)で半年以上開発をし続けていたのですが、Prismaを扱う上で個人的に気になったペインがあります。
- Prismaから返されたオブジェクトをプロジェクトで使いまわすと、スキーマ変更がプロジェクト全体に波及してしまう
- Prismaのincludeによってネストされたオブジェクトに対して適切な型を渡すのが非常に面倒
(includeの有無を考え出してそれを手動で用意するとなるとなおさらしんどい) - 簡易的な対策として、Prismaから返されたオブジェクトに対するラッパーオブジェクト・クラスのようなものを用意すると、スキーマ変更に対する変更は1.よりも減るが、全くおなじようなデータの詰替えクラスを書くのがめちゃくちゃ辛い(ここで開発生産性の低下を感じはじめて、手を打つことに)
Prismaで一定規模までアプリケーションをメンテナンスしていると陥りがちだと思います。どういうことか具体例で説明します。
1. Prismaから返されたオブジェクトをプロジェクトで使いまわすと、スキーマ変更がプロジェクト全体に波及してしまう
例えばこういう最小限のUserモデルを定義した場合を考えます。
■Prismaスキーマ
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
books Book[]
}
現場でよくみるRepository層(レポジトリパターンの実装)でクエリをしてみます。現場でよく見る感じだと、コマンド・クエリ等の分離はせず、再利用可能なDB操作を保持するレイヤーのイメージです。MVCアーキテクチャ+サービス層+レポジトリ層のような、ある種原始的な構成を考えます。
■レポジトリ実装
async findUserById(args: { id: number }) {
return prisma.user.findUnique({
where: { id: args. id },
include: {
posts: true,
books: true
}
})
}
■サービスもしくはコントローラ実装
まあたとえばですが、userに紐づくものからXXやYYするような処理は比較的面倒でしょう。
const userId = 1
const user = await findUserById({ id: userId })
if (user.posts.length > 0) {
user.posts.map((post) => {
// 1.ユーザーに紐づくpostsにアクセスする処理
})
}
if (user.books.length > 0) {
user.books.map((book) => {
// 2.ユーザーに紐づくbooksにアクセスする処理
})
}
これに対して破壊的な変更をするのは簡単ですw userに紐づく1. postsと2. booksモデルそれぞれのPrismaモデルフィールドをガッツリ変更してしまえばいいのです。そうすると、1.
と2.
の箇所に大量の型エラーが発生します。
。。。これ、1ファイルならいいですが100ファイルくらい一気に発生したらイライラしませんか?
整理しましょう。
- schema.prismaを更新
- この次点でprismaの内部で型が自動で生成される
- prismaクライアントをコールしている箇所は2.更新された型で参照するため、スキーマのアップデートがダイレクトでコードベース全体に即座に波及する
- 運が悪いと3.をすべて直さなければ次に進めない
特に厄介なのが、これによって型がなおらなければ次のタスクに取り組めない場合です。prismaのスキーマをアップデートし、型チェックが通らず、100行くらいのコードをずっと修正しつづける、、、これは非常にDXがよろしく無いと思いませんか。
これについては、変更を閉じ込めることができればよいのですが、自前でprismaスキーマと1:1のモデルファイルを作っていたらprismaを使っている意味がありません。なぜスキーマ定義をいれる同じデータ定義のモデルファイルを自分でつくらないといけないのでしょうか?(後述の3つめの対策でやってしまうんですが、、w)
2.Prismaのincludeによってネストされたオブジェクトに対して適切な型を渡すのが非常に面倒
これは頻繁に話題になるトピックだと思います。さっきとおなじUserモデルを考えてみます。
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
books Book[]
}
2-1: 対策① 自前で型定義を書く
これに対して、自前で書くことをまずは考えてみます。include(リレーション)を含めた型定義はこのようになるでしょう。
interface User {
id: number
email: string
name?: string | null
posts: Post[] // 1. この型も必要
books: Book[] // 2. この型も必要
}
ここまでで、Userだけならまだしもリレーション先の型を持ってくるのが非常にしんどいですね。このためだけに型を書き続けるのもイケていない。。じゃあ、Prismaの型をインポートすればどうでしょうか?
2-2: 対策② Prismaの自動生成型から頑張って組む:include部分を&で拡張してみる
import { User as PrismaUser, Post as PrismaPost, Book as PrismaBook } from "@prisma/client"
expoty type User = PrismaUser & { posts: PrismaPost[] } & { book: PrismaBook [] }
このコードをみてどう思うでしょうか。直感的で非常にわかりやすいですね。
...これを容赦なくフロントエンドで使ってみます。
const { data: userData } = useAspidaSwr(apiClient.user._id(userId))
if (userData) {
return (
// なぜか型チェックが通るが、実際はcreatedAtにtoISOStrig()メソッドはないと言われる
<>{userData.createdAt.toISOString()}</>
)
}
さて、ここで問題が発生します。型チェックは通ったにもかかわらず、なぜか「実際はcreatedAtにtoISOString()メソッドはない」とフロントエンドのエラーが発生します。これはなぜでしょうか。これがわかればフルスタックTS中級者ですw
今回はDateTimeだから軽度ですが、バックエンドからはDate
型で型推論がなされます。RESTful APIはJSON形式でやりとりされるので、Date
型はありえませんね。正確にはバックエンドAPIからなんらかのシリアライズがなされてstring
形式でフロントエンドに到着しているはずです。(ちなみにですがJsonフィールドの場合はもっと重度です)
2-3: 対策③ Prismaの型で型パズルを断行する
すこし上級者だとこういう型もあることをひらめくのではないでしょうか。私は恥ずかしながらgeneratorを作っている際に気が付きました。(他にもいわゆるfull type
すべての関連型を得る方法はあるでしょう)
import { Prisma } from '@prisma/client'
// 1. Define a User type that includes the "cars" relation.
const userWithCars = Prisma.validator<Prisma.UserArgs>()({
include: { cars: true },
})
// 2: This type will include many users and all their cars
type UserWithCars = Prisma.UserGetPayload<typeof userWithCars>[]
勘のよい方ならわかるかもしれませんが、これは常にincludeを自分で操作する必要があり、いつどのパターンでも行ける代物ではありません。しかも、Prisma.validatorの中身は通常のオブジェクトなのです。型ではないので気楽にGenerics<T>
によって変幻自在にincludeを変えられる代物でもないのです。いつでも指定したincludeが入ってしまっているので、すべてのパターンを生成する必要があるのです。。。都度、includeを選定した型を作っておくのはこれはしんどいですね。
3.簡易的な対策として、Prismaから返されたオブジェクトに対するラッパーオブジェクト・クラスのようなものを用意する
ここまででかなりシンドイことが発覚してきたかと思います。ここでもさらにシンドイことが起こっていまいました。
ここまで考えてそろそろ疲れた私は、「ああ、Prismaで提供された無名の型で生きていくしか無いのかあ、辛いなあ。それくらいなら自前でモデル書こうかなあ」となってきました。最も愚直な方法が最も応用が効くなんてことはよくあることです。こういうシンプルなモデルファイルを作って、prismaからクエリされたものを詰め込むことを考え出しました。
結果として、今回のgeneratorを作る際の原型となりました。
import type { PostModelDto, BookModelDto } from "."
import { PostModel, BookModel } from "."
import { User as PrismaUser, Post as PrismaPost, Book as PrismaBook } from "prisma/client"
export type UserModelDto = {
id: number;
email: string;
name?: string | null;
createdAt: string;
posts: PostModelDto[];
books: BookModelDto[];
};
export class UserModel {
private readonly _id: number;
private readonly _email: string;
private readonly _name?: string | null;
private readonly _createdAt: Date;
private readonly _posts: PostModel[];
private readonly _books: BookModel[];
constructor(args: {
id: number
email: string
name?: string | null
createdAt: Date
posts: PostModel[]
books: BookModel[]
}) {
this._id = args.id;
this._email = args.email;
this._name = args.name;
this._createdAt = args.createdAt;
this._posts = args.posts;
this._books = args.books;
}
static fromPrismaValue(args: {
self: PrismaUser
posts: PrismaPost[]
books: PrismaBook[]
}) {
return new UserModel({
id: args.self.id,
email: args.self.email,
name: args.self.name,
createdAt: args.self.createdAt,
posts: args.posts,
books: args.books,
});
}
toDto() {
return {
id: this._id,
email: this._email,
name: this._name,
createdAt: this._createdAt?.toISOString() ?? null,
posts: this._posts,
books: this._books,
};
}
get id() {
return this._id;
}
get email() {
return this._email;
}
get name() {
return this._name;
}
get createdAt() {
return this._createdAt;
}
get posts() {
return this._posts;
}
get books() {
return this._books;
}
}
こんな感じで使えます。
async findUserById(args: { id: number }) {
const user = await prisma.user.findUnique({
where: { id: args. id },
include: {
posts: true,
books: true
}
})
return UserModel.fromPrismaValue({
self: user,
posts: user.posts.map(PostModel.fromPrismaValue),
books: user.books.map(BookModel.fromPrismaValue)
})
}
こういったモデルをメンテナンスすることにしました。これによって、Prismaのスキーマを変更すると、ダイレクトにプロジェクト全体に型チェックエラーが波及するケースは随分と減りました。
まあ、それは想定どおりだったんですが、いかんせん、モデルファイルを手動で書くだけで平均して100~200行ほどコードを生産する必要があり、課題となりました。いつか自動化しよう。。。と思っていたのですが、prisma generatorを開発すれば実践できると知ったので、3ヶ月程度、受託開発プロジェクトの傍らでやってみました。
3. prisma generator化
prisma generatorとはなんぞや、ということですが平たくいうとprismaがスキーマ定義をデータで渡せる為それを読み取って何らかの処理を実行するプラグインというとわかりやすいでしょうか。
最も有名なgeneratorはprisma-client-js
でしょう。
generator client {
provider = "prisma-client-js"
}
このような定義を見たことがある人は多いハズ。要するにprisma clientもgeneratorによって生成されているわけです。他にはファクトリを自動生成できるfabbricaとかもzennで度々話題になっていますね。
そう、これを作ればいいのです。基本的にはnode:fs
によるファイル生成をすればいいのです。というわけで早速やってみました。
ゴールはこれです。schema.prismaにgeneratorを入力すればモデルファイルが出力される。
v2.1.3の最新インターフェイスだとこんな感じです。
generator frourio_framework_prisma_model_generator {
provider = "frourio-framework-prisma-model-generator"
output = "__generated__/models"
additionalTypePath = "./@additionalType"
}
3-1. モデルファイルの型定義を考える
上記であげたinclude
を含めたモデルリレーションのデータを型情報を保持したまま、さらにフロントエンドまで一貫して利用できるものに仕立てる必要がありました。紆余曲折して到着したのは次のような一種の型パズルです。(よりよいやり方ご存知でしたら教えてほしいです)
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
const includeBook = {
include: {
author: true,
Polymorphic: true,
Book_Post: true,
},
};
type BookWithIncludes = PartialBy<
Prisma.BookGetPayload<typeof includeBook>,
keyof (typeof includeBook)['include']
>;
-
Prisma.XXXGetPayload<typeof {include}>
によっていわゆるすべての関連したリレーション含む型を取得する - そのままだと、includeすべてが常に
true
すなわちincludeのtrue/falseの切り替えができていないので、includeフィールドを再帰的にオプショナルフィールドに変換します(PartialBy
)
これによって、適切にPrismaの完全型をリレーション含めて(特にincludeの切り替えを実装しつつ)実現することができました。完成した自動生成ファイルはこのようになります。
import {
Prisma,
User as PrismaUser,
Post as PrismaPost,
Book as PrismaBook,
} from '@prisma/client';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
const includePost = {
include: {
author: true,
Polymorphic: true,
Book_Post: true,
},
};
type PostWithIncludes = PartialBy<
Prisma.PostGetPayload<typeof includePost>,
keyof (typeof includePost)['include']
>;
const includeBook = {
include: {
author: true,
Polymorphic: true,
Book_Post: true,
},
};
type BookWithIncludes = PartialBy<
Prisma.BookGetPayload<typeof includeBook>,
keyof (typeof includeBook)['include']
>;
export type UserModelDto = {
id: number;
email: string;
name?: string | null;
createdAt: string;
posts: PostWithIncludes[];
books: BookWithIncludes[];
};
export type UserModelConstructorArgs = {
id: number;
email: string;
name?: string | null;
createdAt: Date;
posts: PostWithIncludes[];
books: BookWithIncludes[];
};
export type UserModelFromPrismaValueArgs = {
self: PrismaUser;
posts: PostWithIncludes[];
books: BookWithIncludes[];
};
export class UserModel {
private readonly _id: number;
private readonly _email: string;
private readonly _name?: string | null;
private readonly _createdAt: Date;
private readonly _posts: PostWithIncludes[];
private readonly _books: BookWithIncludes[];
constructor(args: UserModelConstructorArgs) {
this._id = args.id;
this._email = args.email;
this._name = args.name;
this._createdAt = args.createdAt;
this._posts = args.posts;
this._books = args.books;
}
static fromPrismaValue(args: UserModelFromPrismaValueArgs) {
return new UserModel({
id: args.self.id,
email: args.self.email,
name: args.self.name,
createdAt: args.self.createdAt,
posts: args.posts,
books: args.books,
});
}
toDto() {
return {
id: this._id,
email: this._email,
name: this._name,
createdAt: this._createdAt?.toISOString() ?? null,
posts: this._posts,
books: this._books,
};
}
get id() {
return this._id;
}
get email() {
return this._email;
}
get name() {
return this._name;
}
get createdAt() {
return this._createdAt;
}
get posts() {
return this._posts;
}
get books() {
return this._books;
}
}
これによって、ネストされたリレーション(includeのネスト)も型情報を保持したままバックエンドからフロントエンドへと送ることができるようになりました。
async findUserById(args: { id: number }) {
const user = await prisma.user.findUnique({
where: { id: args. id },
include: {
posts: true,
books: {
include: {
order: true // 例えばこんなリレーションがあっても、適切に型をフロントエンドまで持ち込むことができる
}
}
}
})
return UserModel.fromPrismaValue({
self: user,
posts: user.posts,
books: user.books
})
}
これで、何も考えずにincludeがネストされたクエリを一気通貫した型安全とともに利用できるようになりました。これが完成したのがv2リリース以降だったのですが、圧倒的に生産性が向上したのはいうまでもありません。
3-2. (おまけ)Jsonフィールドにもついでに対応する
Prisma単体だと、Jsonフィールドに型付けをするのは非常に難しいです。
主にPostgreSQLエンジンでの利用に限られていますが、爆発的に普及しているRDBMSであることは疑いようがなく、仕様頻度は低いですが、ごくたまにデータベース設計でJSONカラムを正当化できる場面があります。
PrismaがDBアクセスしてからいい感じにオブジェクトに変換してくれているのですが、型情報を失ってしまっており、これが非常に惜しいポイントです。Prismaスキーマに紐づくモデルを自動生成しているので、ついでにアノテーションから型情報を注入させてみます。
これが参考というか元ネタになっています。インターフェイスをさらに理解しやすくする工夫を施しました。
additionalTypePath
というパスにJSONに当てたい型を詰め込みます。
generator model {
provider = "node ./lib/generators/model/generator.js"
output = "./__generated__/model"
additionalTypePath = "./@additionalType/index.ts"
}
export type JsonObject = {
foo: string;
bar: number;
};
export type JsonArray = JsonObject[];
これをPrismaスキーマでアノテーションします。
model JsonField {
id Int @id @default(autoincrement())
rawJson Json
jsonObject Json /// @json(type: [JsonObject]) // <-これを追加
jsonArray Json /// @json(type: [JsonArray]) // <-これを追加
}
3本コメントで、Prisma.PrismaDMMF.ModelのField型からdocumentation
としてアノテーションを読み込むことができます。これを利用してコード生成ロジックを組み込みます。
// 型をimportしてくる
private generateAdditionalTypeImport(args: { model: PrismaDMMF.Model }) {
if (!args.model.fields.find((field) => field.type === "Json")) {
return "";
}
const imports = args.model.fields.map((field) => {
if (field.type === "Json" && field.documentation) {
const parsed = parseFieldDocumentation({
field,
});
if (parsed) {
return parsed.type?.jsonType;
}
}
});
return `import {
${[...new Set(imports)].filter((i) => i).join(", ")}
} from '${this._additionalTypePath}';`;
}
あとはimportした型を適切に上書きしていく。field.documentationがあればそれに引き合わせていくことでJsonフィールドへ型付けをすることが可能になりました。
しかし現段階では未だ完全ではなく、ネストされたリレーションではフロントエンドからJsonにあてられたアノテーション型がよみこめなくなっています。ただ、ホバーによってPrisma自動生成のdocumentation情報を読み込むことができるので、型を探すヒントはある状態です。
まとめ
というかんじで2024年の下半期はprisma generatorを開発し続けていました。あまり目立たない縁の下の力持ちという感じですが、圧倒的な生産性の向上を感じておりコード生成の恩恵に預かれています。ありがたや〜。
というわけでもしよければフォークしたりインストールしたり中のコードみてみてください。
Pull Requestはまだオープンにはできてはいないのですが要望があれば徐々に変えていきます。
フルスタックTypeScript最高〜〜〜!
Discussion