はじめに
Ruby on Railsを使った開発に携わった際にある1つのモデルクラスを複数のモデルクラスが継承している実装を見かけ、後に単一テーブル継承(Single Table Inheritance)を使ったやり方だと知ったので、調べたことを記事にしてみました✨
単一テーブル継承(STI)とは?
STIとは、オブジェクト指向の概念の一つである「継承」をデータベースのテーブル設計に反映させたもので、同じカラムを持つ複数のテーブルを一つのテーブルにまとめた設計のことを指します。
例として、動物を扱うアプリケーションを作成する場合で考えてみます。
動物には「ライオン」や「パンダ」、「ペンギン」などの種類がありますが、それぞれの動物は共通した属性(例:名前、体重、年齢、性別など)を持つことがあります。本来であればそれぞれテーブルを作ってカラムを追加していくのが通常ですが、STIを使うことで単一のテーブルで全ての動物を管理することができます。
STIの利点
STIを使用すると、共通の属性や振る舞いを持つ複数のモデルを1つのテーブルで管理できます。これにより、重複するコードを削減し、保守性を高めることができます。共通の属性やメソッドは親クラスに実装し、子クラスでは独自の振る舞いを追加することができます。
また、Don't Repeat Yourself(DRY)原則に基づいて、同じ属性や関連性を持つ複数のテーブルを作成する必要がなくなります。データベースの冗長性を減らし、変更が発生した場合でも一箇所で修正するだけで済むため、コードの一貫性が保たれます。
STIの適切な使用ケース
- モデル間に共通の属性や振る舞いがあり、それらの違いが最小限である場合
- 異なるモデルのインスタンスを統一して扱いたい場合(例:動物を一覧表示する場合にDogとCatを区別せずに表示する)
STIを使った開発の例
実際にRailsを使ってSTI設計を使った開発手法を説明していきます。
開発概要
今回は例として、各動物園の動物データを種別ごとに管理する設計のモデル作成までをやっていきます。
データベース設計
前置きで説明した通り、STIを実現するためには共通の属性を持つ複数のテーブルが同じテーブルを使うような設計になります。以下の要件も加えてER図を書いてみます。
テーブル要件
- 動物園テーブルに、各動物ごとでテーブルを関連付けする
- 各動物(ライオン、パンダ、ペンギン)は同じカラムを使用する
- カラム: 名前、体重、年齢、性別
ER図
lions、pandas、penguinsは分かりやすいように書いただけで、実際には存在しない擬似テーブルです。全てのデータはanimalsテーブルに保存されます。
ここで確認すべきポイントは以下の通りです
ポイント
- 動物の種別が増やしたい場合に既存のテーブルを変更する必要がなく、擬似テーブルを追加することで実現できる
- 各擬似テーブルは親テーブルで定義された属性に加えて、個別で属性を追加することができる
ポイント2の場合、親テーブルのカラムに擬似テーブルで定義した属性が追加されます。
animalsテーブルで定義されているtype
カラムについては次の節で説明します。
マイグレーションとモデル
ER図をもとに、Zooモデルと擬似テーブルのベースとなるAnimalモデルを作成します。
% bundle exec rails g model Zoo
% bundle exec rails g model Animal
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
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
注意
継承元である親モデルには必ず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モデルを継承したモデルを生成できます。なお、マイグレーションファイルを生成されません。これは前述でもある通り、子モデルに対応するテーブルは実在しない設計となっているためです。
生成されたモデルには次のようになります。
class Lion < Animal
end
class Pandas < Animal
end
class Penguin < Animal
end
注意
子モデルの継承元がAnimalになっていることを確認しておきましょう!
これにより、Animalモデルに追加されたvalidateやpublicメソッドなどが子モデルにも追加されるようになります。
アソシエーションの設定
せっかくSTIを使用したテーブル設計を組んでも、モデルでアソシエーションを組まないと意味がありません。以下はSTIを生かしたアソシエーションの組み方です。
class Zoo < ApplicationRecord
has_many :animals, dependent: :destroy
has_many :lions, dependent: :destroy
has_many :pandas, dependent: :destroy
has_many :pengins, dependent: :destroy
end
class Animal < ApplicationRecord
# ここで定義したvalidateやメソッドは子モデルにも反映される
end
class Lion < Animal
belongs_to :zoo
end
class Pandas < Animal
belongs_to :zoo
end
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は言わずもがなで共通性を強調するのが特徴であるSTIが役立つパターンではありません。2については単一のテーブル利用するため、データによっては不要なカラムが存在することになります。そうなると、大量のレコードが存在する場合に、クエリの実行の際に条件分岐が発生するため、パフォーマンスの低下が生じる可能性があります。
上記のように、STIを無理に使用するよりも、独立したテーブルやモデルを作成する方が望ましい場合は、そちらを選択しましょう。