STIについてPrismaでもできないのか検証してみました。なお、なるべくの時間短縮のため、コードや図の内容はChatGPTにヒントをもらいながら実装していますので、誤りがあればご連絡いただけると幸いです。
STI(Single Table Inheritance)
Single Table Inheritance(STI)は、複数のモデルを単一のデータベーステーブルに収納できるオブジェクト指向パターンの1つです。RailsのActiveRecordが代表的ですが、他のプログラミング言語のフレームワークにも同じ概念を実装しているものもあります。
クラス図を以下のように定義します。
DBとしてはCar、Truckは存在せずVehicleだけが作成されます。これが名前の由来となってることがわかると思います。(Single Table→テーブル1つ)
Ruby + ActiveRecord
Rubyで実装する場合は以下のような感じです。実際はクラス別にファイルを分けて定義することになると思いますが、簡略化して記述します。
class Vehicle < ActiveRecord::Base
self.inheritance_column = :type
end
class Car < Vehicle
end
class Truck < Vehicle
end
実際に使う場合は以下です。
Car.create(name: 'Toyota', price: 20000, color: 'red')
Truck.create(name: 'Ford', price: 30000, color: 'blue')
vehiclesテーブルには以下のようにデータが格納されます。
id | name | price | color | type | created_at | updated_at |
---|---|---|---|---|---|---|
1 | Toyota | 20000 | red | Car | 2024-12-22 | 2024-12-22 |
2 | Ford | 30000 | blue | Truck | 2024-12-22 | 2024-12-22 |
TypeScript + Prisma
TypeScript単体で実装しますが、Next.jsやExpressでも使い方やクラス設計は同じ感じでできると思います。
Prismaの公式ドキュメントにSTIについての記述があるので真似してみましょう。
なお、今回の場合Sqlite3がEnumに対応していないのと、EnumのアンチパターンもあるらしいのでStringでtypeを定義します。
参考:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
//url = env("DATABASE_URL")
url = "file:./dev.db"
}
model Vehicle {
id Int @id @default(autoincrement())
name String
price Int
color String
type String // STI 用のフィールド(Car, Truck)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("vehicles") // テーブル名を "vehicles" に設定
}
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
// Carデータを作成
const car = await prisma.vehicle.create({
data: {
name: "Toyota",
price: 20000,
color: "red",
type: "Car",
},
});
// Truckデータを作成
const truck = await prisma.vehicle.create({
data: {
name: "Ford",
price: 30000,
color: "blue",
type: "Truck",
},
});
console.log("Created Car:", car);
console.log("Created Truck:", truck);
// Carを取得
const cars = await prisma.vehicle.findMany({
where: { type: "Car" },
});
console.log("All Cars:", cars);
// Truckを取得
const trucks = await prisma.vehicle.findMany({
where: { type: 'test' },
});
console.log("All Trucks:", trucks);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
一応できましたが・・・・Rubyみたいにかっこよくないですね。 typeもいちいちクエリに指定しなければならないし、Carのデータがほしいのに prisma.vehicle.findMany
とかじゃなく Car.findMany
とかにしたいですよね。普通のWebサービスならこの辺をRepository層とかService層とかModel層みたいな感じで分割するのではないかと思います。やってみましょう。
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export type VehicleType = 'Car' | 'Truck';
export class Vehicle {
id?: number;
name: string;
price: number;
color: string;
type: VehicleType;
createdAt?: Date;
updatedAt?: Date;
constructor(data: Partial<Omit<Vehicle, 'type'>> & { type?: string }) {
this.id = data.id;
this.name = data.name || "";
this.price = data.price || 0;
this.color = data.color || "";
this.type = (data.type as VehicleType) || "Car";
}
static async all(): Promise<Vehicle[]> {
const vehicles = await prisma.vehicle.findMany();
return vehicles.map((v) => new Vehicle(v));
}
static async findByType(type: VehicleType): Promise<Vehicle[]> {
const vehicles = await prisma.vehicle.findMany({ where: { type } });
return vehicles.map((v) => new Vehicle(v));
}
EnumをDBで使わない代わりに、Vehicleモデルの実実装で型による無効なtypeのガードを仕込んでみました。これでどんな実装をしようと、Car/Truck以外をtypeに指定しようとしてもエラーになることでしょう。
import { Vehicle } from "./Vehicle";
export class Car extends Vehicle {
constructor(data: Partial<Vehicle>) {
super({ ...data, type: "Car" });
}
static async all(): Promise<Car[]> {
const cars = await Vehicle.findByType("Car");
return cars.map((c) => new Car(c));
}
}
import { Vehicle } from "./Vehicle";
export class Truck extends Vehicle {
constructor(data: Partial<Vehicle>) {
super({ ...data, type: "Truck" });
}
static async all(): Promise<Truck[]> {
const trucks = await Vehicle.findByType("Truck");
return trucks.map((t) => new Truck(t));
}
}
これでデータ問い合わせ周りをモデルとして表現し、ロジックから隠蔽することができました。
あとは使ってみるだけです。 main.ts
を少し修正してみましょう。
import { PrismaClient } from "@prisma/client";
import { Vehicle } from "./models/Vehicle";
import { Car } from "./models/Car";
import { Truck } from "./models/Truck";
const prisma = new PrismaClient();
async function main() {
// 全ての Vehicle を取得
const allVehicles = await Vehicle.all();
console.log("All Vehicles:", allVehicles);
// 全ての Car を取得
const allCars = await Car.all();
console.log("All Cars:", allCars);
// 全ての Truck を取得
const allTrucks = await Truck.all();
console.log("All Trucks:", allTrucks);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
以下のような結果が返ってきます。
> npx tsx main.ts
All Vehicles: [
Vehicle {
id: 1,
name: 'Toyota',
price: 20000,
color: 'red',
type: 'Car'
},
Vehicle {
id: 2,
name: 'Ford',
price: 30000,
color: 'blue',
type: 'Truck'
}
]
All Cars: [
Car {
id: 1,
name: 'Toyota',
price: 20000,
color: 'red',
type: 'Car'
}
]
All Trucks: [
Truck {
id: 2,
name: 'Ford',
price: 30000,
color: 'blue',
type: 'Truck'
}
]
素晴らしいですね!
また、Car.create(xxx)
にも対応してみましょう。
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export type VehicleType = 'Car' | 'Truck';
export class Vehicle {
id?: number;
name: string;
price: number;
color: string;
type: VehicleType;
createdAt?: Date;
updatedAt?: Date;
constructor(data: Partial<Omit<Vehicle, 'type'>> & { type?: string }) {
this.id = data.id;
this.name = data.name || "";
this.price = data.price || 0;
this.color = data.color || "";
this.type = (data.type as VehicleType) || "Car";
}
static async all(): Promise<Vehicle[]> {
const vehicles = await prisma.vehicle.findMany();
return vehicles.map((v) => new Vehicle(v));
}
static async findByType(type: VehicleType): Promise<Vehicle[]> {
const vehicles = await prisma.vehicle.findMany({ where: { type } });
return vehicles.map((v) => new Vehicle(v));
}
+ static async create(data: Omit<Vehicle, "id" | "type">, type: VehicleType): Promise<Vehicle> {
+ const vehicle = await prisma.vehicle.create({
+ data: { ...data, type },
+ });
+ return new Vehicle(vehicle);
+ }
}
import { Vehicle } from "./Vehicle";
export class Car extends Vehicle {
constructor(data: Partial<Vehicle>) {
super({ ...data, type: "Car" });
}
static async all(): Promise<Car[]> {
const cars = await Vehicle.findByType('Car');
return cars.map((c) => new Car(c));
}
+ static async create(data: Omit<Vehicle, "id" | "type">): Promise<Car> {
+ const car = await Vehicle.create(data, 'Car');
+ return new Car(car);
+ }
}
あとは await Car.create({ name: "Honda", price: 10000, color: "white" });
みたいな感じで使うとクルマのデータが作成できます。
こうやって実装してみると、ActiveRecordがどれだけの実装をエンジニアから隠蔽化することに成功しているか、その凄さが改めて理解できますね。。。本当に素晴らしいライブラリです。
ついでにポリモーフィック関連付け(polymorphic association)
ちなみにもし各継承先のクラスが詳細なデータを持つ場合、Railsだと ポリモーフィック関連付け というテクニックを使うこともできます。
ポリモーフィックはSTIとは違い、どちらかというと属性の異なるモデルの双方に同じモデルを関連付けるときに使うものと考えたほうが良さそうです。
上記Railsガイドにも記載のあるとおり、「写真」というモデルが、「社員」と「商品」に関連づく感じですね。
今回は似たような実例でレビューの管理を考えてみます。
楽天やアマゾンのように、「ショップ」と「商品」に対するレビューのポリモーフィック関連です。
ActiveRecord + Ruby で実装
当たり前ですがRuby + ActiveRecordではいとも簡単に実装可能です。DBの設定やMigrationなどは省きます。
class Review < ApplicationRecord
belongs_to :reviewable, polymorphic: true
end
class Shop < ApplicationRecord
has_many :reviews, as: :reviewable
end
class Product < ApplicationRecord
has_many :reviews, as: :reviewable
end
# 使い方
shop.reviews.create(rating: 5, comment: "Great shop! Highly recommend.")
shop.reviews.create(rating: 4, comment: "Good service but shipping was slow.")
product.reviews.create(rating: 5, comment: "Amazing product! Totally worth it.")
product.reviews.create(rating: 3, comment: "It's okay, but could be better.")
reviewsテーブルには以下のようなデータが作成されます。
id | rating | comment | reviewable_id | reviewable_type | created_at | updated_at |
---|---|---|---|---|---|---|
1 | 5 | "Great shop! Highly recommend." | 1 | "Shop" | 2023-01-01 10:00:00 | 2023-01-01 10:00:00 |
2 | 4 | "Good service but shipping was slow." | 1 | "Shop" | 2023-01-01 11:00:00 | 2023-01-01 11:00:00 |
3 | 5 | "Amazing product! Totally worth it." | 1 | "Product" | 2023-01-02 10:00:00 | 2023-01-02 10:00:00 |
4 | 3 | "It's okay, but could be better." | 1 | "Product" | 2023-01-02 11:00:00 | 2023-01-02 11:00:00 |
reqiewable_id
は reviewable_type
によって参照先が変わります。 id = 1 のデータは、Shop.find(1) になるということです。いい感じですね。
Prisma + TypeScript
作るのが面倒なのでやめました(笑)
ただここまでの実装は冒頭に述べた通りChatGPTの支援を得て実装しているのでなんとかなると思ます。とはいえツギハギの実装にならないようにするには、やはりある程度の知識とスキルは必要だと今はまだ感じています。
ActiveRecordの宣伝なのかPrismaの実装例なのかわからん記事になりましたが、個人的に楽しかったのでOK!