Ruby
Rails
モデリング
STI

Railsでスーパータイプ/サブタイプを表現する方法を比較してみる

More than 1 year has passed since last update.


はじめに

Railsでスーパータイプ・サブタイプの表現する方法は以下のように3つ存在します。


  1. 単一テーブル継承(STI)

  2. 具象クラス継承(CCI)

  3. クラステーブル継承(CTI)

Railsの機能としてSTIがサポートされているため、何も考えずにSTIを選んでしまうことが多い(かつては自分もそうだった)ような気がしますが、

それぞれメリット・デメリットがあるため適切に使い分けるといいことがあります。

まずはそれぞれの簡単な説明をしていきましょう。


それぞれの説明

ここでは例として、次のようなケースをかんがえてみます。(ちょっと無理やりですけどつっこまないで…)

Music というスーパータイプを持つ、 ClassicalEDM という2つのサブタイプがあります。

Musicname 属性を持っています。これは当然2つのサブタイプ両方に必要な属性です。

また、Musicは抽象クラスであるとします。

各サブタイプに特有の属性としては ClassicalconductorEDMbpm という属性を持ち、それぞれ必須な属性です。

entity.png


単一テーブル継承

単一テーブル継承は、ひとつのテーブルにすべてのサブタイプを保存する方法です。関係ない属性はNULLで表現されます。


テーブル構造

Music

id
type
name
conductor
bpm

1
Classical
交響曲第6番
ゴットフリート
NULL

2
EDM
Party People
NULL
144

3
Classical
ABC序曲
アレッサンドロ
NULL


アプリケーション側実装

ActiveRecordモデルのクラスに継承関係をもたせるだけです。

STIはフレームワークでサポートされているので、実装は容易です。


music.rb

class Music < ApplicationRecord

# ...共通のコード
end


classical.rb

class Classical < Music

# ...固有のコード
end


メリット


  • Railsが標準でサポートしているため、設計の難易度は低いと思われます。。

  • サブタイプのレコードを取得するとき、JOINしたりする必要なく単純なSELECT文だけで全属性を取得できます。


  • name属性のようにスーパータイプの属性に対してUNIQUE制約をかけることができます。

  • スーパータイプに存在するキーでサブタイプをまたいで検索をする際はクエリを一回発行するだけで済みます。

  • サブタイプへの参照に外部キー制約を持たせることができます


デメリット


  • あるサブタイプに固有の属性は、ほかのサブタイプの場合NULLが入ります。このため、 Classic だったら conductor は必ず入っていないといけない!という要件をDBのNOT NULL制約としてかけることができません。


    • ただし、bpm属性のようにサブタイプ固有の属性にUNIQUE制約はかけることができます。(一部のDBMSを除きNULLはUNIQUE制約に縛られません)

    • ActiveRecordのレベルでバリデーションをかけることはできますが、同時に更新がおきたときなど突き抜けてしまうことがしばしばあります。。



  • ほかのテーブルに特定のサブタイプだけを参照させる制約をもたせられません。(たとえば、EDMとだけ関連付けたいDJが間違ってClassicalサブタイプのレコードを参照することをDBの制約では防げません。)


具象クラス継承

具象クラス継承は、具象クラスを保存するテーブルをそれぞれ作る方法です。

スーパータイプに対応するテーブルは存在せず(今回はMusicが抽象クラスなので)、サブタイプに対応するテーブルだけが作られます。


テーブル構造

Classical

id
name
conductor

1
交響曲第6番
ゴットフリート

2
ABC序曲
アレッサンドロ

EDM

id
name
bpm

1
Party People
144


アプリケーション側実装

STIと違い、フレームワークでサポートされているものではないのでアプリケーション側の実装は設計次第です。なので、以下に上げているのは自分の考えた例でありもっといいやり方があるかもしれません。

たとえば、次のようにスーパータイプをActiveSupport::Concernモジュールとして定義して、各サブタイプのクラスでincludeするとか。スーパータイプ/サブタイプ→「継承だ!」と思ってしまいがちですが、必ずしもアプリケーション側に継承関係が現れるとは限りません。


music.rb

module Music

extend ActiveSupport::Concern

included do
# ...Music共通のメソッドとかをかく

scope :hoge, -> {...}

def huga
...
end
end
end



Classical.rb

class Classical < ApplicationRecord

include Music

# ...Classical特有のメソッドとかをかく
end



メリット


  • サブタイプのレコードを取得するとき、単純なSELECT文だけで全属性を取得できます。

  • サブタイプ固有の属性に対してNOT NULL制約をかけることができます。

  • ほかのテーブルに特定のサブタイプだけを参照させる制約をもたせることができます


デメリット


  • Railsフレームワークがサポートしていないので、アプリケーション側の設計が必要です。


  • name 属性のようにスーパータイプの属性に対してUNIQUE制約をかけることができなくなってしまいます。(テーブルをまたいでしまうため、ClassicalEDMに同じnameを持つレコードが入りうる)

  • スーパータイプに存在するキーでサブタイプをまたいで検索をするには、サブタイプのテーブルすべてをみる必要があります。(たとえばClassical, EDMをまたいで"Party People"という名前の曲を探そうと思ったらそれぞれのテーブルにクエリする必要がある)

  • 別のモデルが複数のサブタイプを参照しうる場合、外部キー制約をかけられなくなります。(Classical, EDMどちらとも関連しうるLikeには外部キー制約がかけられない)


クラステーブル継承

スーパータイプにある属性を保存するテーブルと、サブタイプ固有の属性を保存するテーブルを作ってリレーションを張ることによってスーパータイプ/サブタイプを表現します。


テーブル構造

Music

id
name

1
交響曲第6番

2
Party People

3
ABC序曲

Classical

id
music_id
conductor

1
1
ゴットフリート

2
3
アレッサンドロ

EDM

id
music_id
bpm

1
2
144


アプリケーション側実装

こちらもアプリ側の実装はいろいろなやり方があるでしょう。以下も自分の考えた一例であり、もっといいやり方を教えてほしいくらいです。

特に、Railsはテーブル構造とモデルが密結合なので、サブタイプの属性に#attributesメソッドを呼び出したりしてもやりたいこととは違う結果になるでしょう。


music.rb

class Music < ApplicationRecord

has_one :classical
has_one :edm

# ...Music共通のメソッドとかをかく
end



Classical.rb

class Classical < ApplicationRecord

belongs_to :music

# ...Classical特有のメソッドとかをかく

# サブタイプに存在しないメソッドが呼ばれたらスーパータイプに委譲する
# def_delegators つかったほうがいいよね
def method_missing(name, *args)
music.send(name, *args)
end
end


依存関係を


メリット



  • name 属性のようにスーパータイプの属性に対してUNIQUE制約をかけることができます。

  • サブタイプ固有の属性に対してNOT NULL制約をかけることができます。

  • スーパータイプに存在するキーでサブタイプをまたいで検索をする際はクエリを一回発行するだけで済みます。

  • 他のモデルにスーパータイプのテーブルを参照させるかサブタイプのテーブルを参照させるか選ぶことができ、「特定のサブタイプへの参照が誤って他のサブタイプを参照することを防ぐ」か「参照に外部キー制約を持たせる」どちらかを選択することができます。


デメリット


  • Railsフレームワークがサポートしていないので、アプリケーション側の設計が必要です。

  • サブタイプのレコードを取得するとき、スーパータイプのテーブルとサブタイプのテーブルをJOINするかアプリケーション側で組み合わせる必要があります。

  • 例にあげたような実装だと、1つのスーパータイプのレコードに複数のサブタイプからリレーションが張られることがありえます。アプリ側でおきないように頑張りましょう。


まとめ

それぞれの特徴を表にまとめると次のようになります。

STI
CCI
CTI

Railsがサポートしている

単純SELECT文で全属性取得可能

スーパータイプの属性にUNIQUE制約をかけられる(1)

サブタイプの属性にNOT NULL制約をかけられる


一回のクエリでスーパータイプに存在するキーでサブタイプをまたいで検索できる(2)

特定のサブタイプへの参照が誤って他のサブタイプを参照することを防ぐことができる(3)


サブタイプへの参照に外部キー制約を持たせることができる(4)

厳密に証明はできませんが、おそらく(1)と(2)は同値です。また、(3)と(4)は排他になります。CTIの場合はどちらかを選ぶことになります。

Railsがサポートしているからとりあえず・いつでもSTIを使うのではなく、それぞれの特徴を知った上で使い分けれられるようになるとよいですね。自分もそうなりたいです。


参考