1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ぼっちアドベントカレンダー by bon10Advent Calendar 2024

Day 22

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

Last updated at Posted at 2024-12-22

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を定義します。

参考:

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" に設定
}
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層みたいな感じで分割するのではないかと思います。やってみましょう。

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に指定しようとしてもエラーになることでしょう。

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));
  }
}

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 を少し修正してみましょう。

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

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);
+  }
}
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だと ポリモーフィック関連付け というテクニックを使うこともできます。

ポリモーフィックは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_idreviewable_type によって参照先が変わります。 id = 1 のデータは、Shop.find(1) になるということです。いい感じですね。

Prisma + TypeScript

作るのが面倒なのでやめました(笑)
ただここまでの実装は冒頭に述べた通りChatGPTの支援を得て実装しているのでなんとかなると思ます。とはいえツギハギの実装にならないようにするには、やはりある程度の知識とスキルは必要だと今はまだ感じています。

ActiveRecordの宣伝なのかPrismaの実装例なのかわからん記事になりましたが、個人的に楽しかったのでOK!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?