Railsで単一テーブル継承（STI）を使ってみる

# はじめに
Ruby on Railsを使った開発に携わった際にある１つのモデルクラスを複数のモデルクラスが継承している実装を見かけ、後に単一テーブル継承(Single Table Inheritance)を使ったやり方だと知ったので、調べたことを記事にしてみました✨

# 単一テーブル継承（STI）とは？
STIとは、オブジェクト指向の概念の一つである「継承」をデータベースのテーブル設計に反映させたもので、同じカラムを持つ複数のテーブルを一つのテーブルにまとめた設計のことを指します。

例として、動物を扱うアプリケーションを作成する場合で考えてみます。
動物には「ライオン」や「パンダ」、「ペンギン」などの種類がありますが、それぞれの動物は共通した属性（例：名前、体重、年齢、性別など）を持つことがあります。本来であればそれぞれテーブルを作ってカラムを追加していくのが通常ですが、STIを使うことで単一のテーブルで全ての動物を管理することができます。

## STIの利点

STIを使用すると、共通の属性や振る舞いを持つ複数のモデルを1つのテーブルで管理できます。これにより、重複するコードを削減し、保守性を高めることができます。共通の属性やメソッドは親クラスに実装し、子クラスでは独自の振る舞いを追加することができます。
また、Don't Repeat Yourself（DRY）原則に基づいて、同じ属性や関連性を持つ複数のテーブルを作成する必要がなくなります。データベースの冗長性を減らし、変更が発生した場合でも一箇所で修正するだけで済むため、コードの一貫性が保たれます。

## STIの適切な使用ケース

- モデル間に共通の属性や振る舞いがあり、それらの違いが最小限である場合
- 異なるモデルのインスタンスを統一して扱いたい場合（例：動物を一覧表示する場合にDogとCatを区別せずに表示する）


# STIを使った開発の例
実際にRailsを使ってSTI設計を使った開発手法を説明していきます。

## 開発概要
今回は例として、各動物園の動物データを種別ごとに管理する設計のモデル作成までをやっていきます。


## データベース設計
前置きで説明した通り、STIを実現するためには共通の属性を持つ複数のテーブルが同じテーブルを使うような設計になります。以下の要件も加えてER図を書いてみます。

:::note info
テーブル要件
- 動物園テーブルに、各動物ごとでテーブルを関連付けする
- 各動物（ライオン、パンダ、ペンギン）は同じカラムを使用する
    - カラム: 名前、体重、年齢、性別
:::

### ER図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1241122/412b2a26-4178-ed4c-4bdf-6cf4974cd1e7.png)
lions、pandas、penguinsは分かりやすいように書いただけで、実際には存在しない擬似テーブルです。全てのデータはanimalsテーブルに保存されます。
ここで確認すべきポイントは以下の通りです

:::note info
ポイント
1. 動物の種別が増やしたい場合に既存のテーブルを変更する必要がなく、擬似テーブルを追加することで実現できる
2. 各擬似テーブルは親テーブルで定義された属性に加えて、個別で属性を追加することができる
:::

ポイント2の場合、親テーブルのカラムに擬似テーブルで定義した属性が追加されます。
animalsテーブルで定義されている`type`カラムについては次の節で説明します。


## マイグレーションとモデル
ER図をもとに、Zooモデルと擬似テーブルのベースとなるAnimalモデルを作成します。
```
% bundle exec rails g model Zoo
% bundle exec rails g model Animal
```

```ruby:db/migrate/20230617061006_create_zoos.rb
class CreateZoos < ActiveRecord::Migration[7.0]
  def change
    create_table :zoos do |t|
      t.string :name, null: false
      t.string :location, null: false

      t.timestamps
    end
  end
end
```

```ruby:db/migrate/20230617061510_create_animals.rb
class CreateAnimals < ActiveRecord::Migration[7.0]
  def change
    create_table :animals do |t|
      t.references :zoo, null: false, foreign_key: true
      t.string :name, null: false
      t.integer :age, null: false
      t.integer :gender, default: 0
      # typeカラムを忘れずつける
      t.string :type, null: false

      t.timestamps
    end
  end
end
```
:::note warn
注意
継承元である親モデルには必ず`type`カラムを定義しておく必要があります。
`type`カラムは、ActiveRecordが標準で用意してくれるカラムで、どのテーブルに関わるデータかを識別する役割を持ちます。
例えば、lionsのデータを保存すると`type`カラムにはモデル名の`Lion`が保存されます。

ここで注意なのが、自作のカラムで`type`というカラムを定義してしまうと、STIの機能だと判断されてRenameしろと怒られてしまうので、気をつけましょう！
:::

次に、Animalモデルを継承して3つのモデルをそれぞれ生成します
```
% bundle exec rails g model Lion  --parent=Animal
% bundle exec rails g model Panda --parent=Animal
% bundle exec rails g model Penguin  --parent=Animal
```
`--parent=Animal`を付け加えることで、Animalモデルを継承したモデルを生成できます。なお、マイグレーションファイルを生成されません。これは前述でもある通り、子モデルに対応するテーブルは実在しない設計となっているためです。

生成されたモデルには次のようになります。

```ruby:lions.rb
class Lion < Animal
end
```

```ruby:pandas.rb
class Pandas < Animal
end
```

```ruby:penguins.rb
class Penguin < Animal
end
```

:::note warn
注意
子モデルの継承元がAnimalになっていることを確認しておきましょう！
:::


これにより、Animalモデルに追加されたvalidateやpublicメソッドなどが子モデルにも追加されるようになります。

## アソシエーションの設定
せっかくSTIを使用したテーブル設計を組んでも、モデルでアソシエーションを組まないと意味がありません。以下はSTIを生かしたアソシエーションの組み方です。

```ruby:zoos.rb
class Zoo < ApplicationRecord
  has_many :animals, dependent: :destroy
  has_many :lions, dependent: :destroy
  has_many :pandas, dependent: :destroy
  has_many :pengins, dependent: :destroy
end
```

```ruby:animals.rb
class Animal < ApplicationRecord
# ここで定義したvalidateやメソッドは子モデルにも反映される
end
```

```ruby:lions.rb
class Lion < Animal
  belongs_to :zoo
end
```

```ruby:pandas.rb
class Pandas < Animal
  belongs_to :zoo
end
```

```ruby:penguins.rb
class Penguin < Animal
  belongs_to :zoo
end
```

実際にコンソールを色々実行してみます。

### 子モデルのデータ作成
```:実行コマンド
% zoo = Zoo.first
% zoo.lions.create(name: 'レオ', age: 2)
=> #<Lion:0x0000000111fa8a68 id: 1, zoo_id: 1, name: "レオ", age: 2, gender: 0, type: "Lion">
```
```:生成クエリ
INSERT INTO "animals" ("zoo_id", "name", "age", "gender", "type") 
VALUES ($1, $2, $3, $4, $5) RETURNING "id"  [["zoo_id", 1], ["name", "動物"], ["age", 20], ["gender", 0], ["type", "Lion"]]
```
`type`カラムに`Lion`が代入されたデータがanimalsテーブルに一件保存されました。

### 子モデルごとのデータ取得
```:実行コマンド
% Lion.all
=>
[#<Lion:0x00000001126eabc8 id: 1, zoo_id: 1, name: "レオ", age: 2, gender: 0, type: "Lion">,
#<Lion:0x00000001126eaad8 id: 4, zoo_id: 1, name: "シンバ", age: 5, gender: 0, type: "Lion">]
```
```:生成クエリ
SELECT "animals".* FROM "animals" WHERE "animals"."type" = $1  [["type", "Lion"]]
```
Lionのデータを全件取得すると、animalsテーブル中の`type: 'Lion'`が検索され出力されました。
`Animal.find_by(type: 'Lion')`と書く必要がなく、lionsテーブルが存在するかのように、ActiveRecordの検索メソッドを使って取得できるのが良いところ。

### 全件取得
まずはanimalsテーブルに保存されデータを全て取得してみます。
```:実行コマンド
% Animal.all
=>
[#<Lion:0x0000000111bb36d8 id: 1, zoo_id: 1, name: "レオ", age: 2, gender: 0, type: "Lion">,
 #<Panda:0x0000000111bb32a0 id: 2, zoo_id: 1, name: "シャンシャン", age: 6, gender: 0, type: "Panda">,
 #<Penguin:0x0000000111bb2ee0 id: 3, zoo_id: 1, name: "ペン太", age: 8, gender: 0, type: "Penguin">,
 #<Lion:0x0000000111bb2940 id: 4, zoo_id: 2, name: "シンバ", age: 5, gender: 0, type: "Lion">,
 #<Panda:0x0000000111bb25a8 id: 5, zoo_id: 2, name: "リンリン", age: 6, gender: 0, type: "Panda">,
 #<Penguin:0x0000000111bb1e50 id: 6, zoo_id: 2, name: "コウテイ", age: 7, gender: 0, type: "Penguin">]
```
```:生成クエリ
SELECT "animals".* FROM "animals"
```

次に動物園ごとのデータを取得してみます、
```:実行コマンド
% zoo = Zoo.first
% zoo.animals
=>
[#<Lion:0x00000001111283f8 id: 1, zoo_id: 1, name: "レオ", age: 2, gender: 0, type: "Lion">,
 #<Panda:0x000000011110ada8 id: 2, zoo_id: 1, name: "シャンシャン", age: 6, gender: 0, type: "Panda">,
 #<Penguin:0x000000011110a038 id: 3, zoo_id: 1, name: "ペン太", age: 8, gender: 0, type: "Penguin">]
```

```生成クエリ
SELECT "animals".* FROM "animals" WHERE "animals"."zoo_id" = $1  [["zoo_id", 1]]
```

一部の動物園にいる動物が簡単に取得できました！
ここからさらに種別を絞りたい場合は、`zoo.lions`のように簡単な指定で取得することができます。
例えば、STIを使わずanimalsテーブルに全て保存するような設計であれば、動物園ごとの動物の取得までは容易ですが、種別ごとの動物の取得となると複雑になり得るでしょう。
または、動物ごとにテーブルを持たせる設計にしている場合だと、動物園ごとの動物の取得の際にクエリの量が膨大になってしまいます。
STIを使えば特に意識することなく、複雑な処理を行えるようになります。


# さいごに
STIを使った開発について書いてきましたが、使ったほうが必ず良くなるというわけではありません。あくまでも**モデル間に共通の属性や振る舞いがあり、それらの違いが最小限である場合**に限ります。以下のパターンでは、STIを使用しないほうがいいです。
1. 異なるモデル間に共通の属性や振る舞いがほとんどなく、差異が大きい場合
2. 異なるモデル間に共通の属性や振る舞いはあるが、それぞれが持つ個別の属性や振る舞いが多い場合

1は言わずもがなで共通性を強調するのが特徴であるSTIが役立つパターンではありません。2については単一のテーブル利用するため、データによっては不要なカラムが存在することになります。そうなると、大量のレコードが存在する場合に、クエリの実行の際に条件分岐が発生するため、パフォーマンスの低下が生じる可能性があります。
上記のように、STIを無理に使用するよりも、独立したテーブルやモデルを作成する方が望ましい場合は、そちらを選択しましょう。