ActiveRecordでよくみるSTIをPrismaで再現する（ついでにポリモーフィックも）

STIについてPrismaでもできないのか検証してみました。なお、なるべくの時間短縮のため、コードや図の内容はChatGPTにヒントをもらいながら実装していますので、誤りがあればご連絡いただけると幸いです。

## STI（Single Table Inheritance）

Single Table Inheritance（STI）は、複数のモデルを単一のデータベーステーブルに収納できるオブジェクト指向パターンの1つです。RailsのActiveRecordが代表的ですが、他のプログラミング言語のフレームワークにも同じ概念を実装しているものもあります。

https://railsguides.jp/association_basics.html#%E5%8D%98%E4%B8%80%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB%E7%B6%99%E6%89%BF%EF%BC%88sti%EF%BC%89

クラス図を以下のように定義します。

```mermaid
classDiagram
    class Vehicle {
        +integer id
        +string name
        +integer price
        +string color
        +string type
        +datetime created_at
        +datetime updated_at
    }

    class Car {
    }

    class Truck {
    }

    Vehicle <|-- Car
    Vehicle <|-- Truck
```

DBとしてはCar、Truckは存在せずVehicleだけが作成されます。これが名前の由来となってることがわかると思います。（Single Table→テーブル1つ）

```mermaid
erDiagram
    vehicles {
        integer id
        string name
        integer price
        string color
        string type
        datetime created_at
        datetime updated_at
    }
```

### Ruby + ActiveRecord

Rubyで実装する場合は以下のような感じです。実際はクラス別にファイルを分けて定義することになると思いますが、簡略化して記述します。

```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についての記述があるので真似してみましょう。

https://www.prisma.io/docs/orm/prisma-schema/data-model/table-inheritance#single-table-inheritance-sti

なお、今回の場合Sqlite3がEnumに対応していないのと、EnumのアンチパターンもあるらしいのでStringでtypeを定義します。

参考：

https://zenn.dev/convers39/articles/0e58e17d0da43f

```typescript:schema.prisma
// 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" に設定
}
```

```typescript:main.ts
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層みたいな感じで分割するのではないかと思います。やってみましょう。

```typescript:models/Vehicle.ts
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に指定しようとしてもエラーになることでしょう。


```typescript:models/Car.ts
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));
  }
}

```

```typescript:models/Truck.ts
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` を少し修正してみましょう。

```typescript: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();
  });

```

以下のような結果が返ってきます。

```shell
> 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)` にも対応してみましょう。

```diff_typescript:models/Vehicle.ts
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);
+  }
}
```

```diff_typescript:models/Car.ts
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だと __ポリモーフィック関連付け__ というテクニックを使うこともできます。

https://railsguides.jp/association_basics.html#%E3%83%9D%E3%83%AA%E3%83%A2%E3%83%BC%E3%83%95%E3%82%A3%E3%83%83%E3%82%AF%E9%96%A2%E9%80%A3%E4%BB%98%E3%81%91

ポリモーフィックはSTIとは違い、どちらかというと属性の異なるモデルの双方に同じモデルを関連付けるときに使うものと考えたほうが良さそうです。
上記Railsガイドにも記載のあるとおり、「写真」というモデルが、「社員」と「商品」に関連づく感じですね。

今回は似たような実例でレビューの管理を考えてみます。

楽天やアマゾンのように、「ショップ」と「商品」に対するレビューのポリモーフィック関連です。

```mermaid
erDiagram
  REVIEWS {
    integer id
    integer reviewable_id
    string reviewable_type
    integer rating
    text comment
    datetime created_at
    datetime updated_at
  }
  SHOPS {
    integer id
    string name
    text description
    datetime created_at
    datetime updated_at
  }
  PRODUCTS {
    integer id
    string name
    float price
    datetime created_at
    datetime updated_at
  }

  SHOPS ||--o{ REVIEWS : "has many"
  PRODUCTS ||--o{ REVIEWS : "has many"
```

### ActiveRecord + Ruby で実装

当たり前ですがRuby + ActiveRecordではいとも簡単に実装可能です。DBの設定やMigrationなどは省きます。

```ruby
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!