Rails で STI(Single Table Inheritance / 単一テーブル継承)を最小限に作る

前提

最小限に、です。もろもろ省略していますし、命名もよろしくありません。用語の使い方も厳密ではありません。

が、何よりもまず最初に簡単に知ることが大切だと思うからです。

状況

Fullnameモデルについて

Fullname というモデルがあります。このモデルは以下の属性を持たせたいです。

  • fullname*1
  • this_is_firstname
  • this_is_lastname

FirstnameモデルとLastnameモデルについて

Fullname とは別に、FirstnameLastname というモデルも作りたいと思います。これらの内容は次のとおりです。

  • Firstname は、Fullname にある属性のうち、fullnamethis_is_firstname を持つ
  • Lastname は、Fullname にある属性のうち、fullnamethis_is_lastname を持つ

具体値は?

上記までのモデルを作った場合に、それぞれの属性に次のような値が入ることを想定しています。

  • fullname
    • 田中二郎
  • firstname
    • 二郎
  • lastname
    • 田中

ただ、Firstname モデルと Lastname モデルには、それぞれ一部の属性が無いという設計にしたいとします。

どうするか

前述の「状況」を実現するために考えられる単純な方法としては以下の方法です。つまり、3つのモデルを $ rails g model を実行することで作る方法です。

$ rails g model Fullname
$ rails g model Firstname
$ rails g model Lastname

それぞれのモデルに、それぞれのモデルが必要な属性を作るという方法があります*2

ここで STI を使う(作る)

上記の方法でもよいですが、ここで STI を使ってみます。

必要なファイルを作る

まず、必要なファイルを作ります。ファイルを作るコマンドは次のようになります。

$ rails g model Fullname
$ touch app/models/firstname.rb
$ touch app/models/lastname.rb

FirstnameモデルとLastnameモデルは、最初は空っぽのファイルを作るだけです。

Fullnameモデルの属性をマイグレーションする(データベースにテーブルを作る)

Fullnameモデルには、Firstnameモデルに必要な属性とLastnameに必要な属性を合算したすべての属性(カラム)を詰め込みます。そしてそれはデータベースに実態として作られます。

なので、マイグレーションファイルを作り、$ rails db:migrate しましょう。マイグレーションファイルは以下のとおりです。

class CreateFullnames < ActiveRecord::Migration[6.0]
  def change
    create_table :fullnames do |t|
      t.string :type

      t.string :fullname
      t.string :this_is_firstname
      t.string :this_is_lastname

      t.timestamps
    end
  end
end

ここで最重要なのが :type という属性(カラム)です。この type という属性名は予約語です*3。したがって、t.string :type という書き方は定型文です。

残りのカラムは前述の通り、Firstnameに必要なカラムとLastnameに必要なカラムそれぞれのカラムを合算したカラムです。つまり、fullnamethis_is_firstnamethis_is_lastnameになります。

上記のマイグレーションファイルが書けたら、$ rails db:migrateしましょう。

app/models/firstname.rbとapp/models/lastname.rbの中身を書く

先ほど空っぽのファイルだけを作った、firstname.rblastname.rbの中身を書きます。ポイントは、継承元のスーパークラスを Fullname にすることです。具体的には以下のとおりです。

class Firstname < Fullname
end
class Lastname < Fullname
end

最低限の動作を目指しますので、クラスの中身は空っぽでよいです。

この2つのクラスは、データベースにテーブルを持ちません。データベースという実態に含まれるカラムはすべてFullnameの方に作りましたので、テーブルを持つ必要がありません*4

上記で完成

上記までで、STIの最低限の実装はOKです。

動作確認

動作確認をします。まずレコードを作ります。

テーブルとデータベースが直接紐付いているのは Fullname ですが、FirstnameLastnameからレコードを取り扱うことができます。具体的には以下のとおりです。

Lastname.create(fullname: '田中二郎', this_is_firstname: '二郎')として、Lastnameモデルのレコードを作ってみます。

irb(main):001:0> Lastname.create(fullname: '田中二郎', this_is_firstname: '二郎')
   (0.1ms)  begin transaction
  Lastname Create (0.9ms)  INSERT INTO "fullnames" ("type", "fullname", "this_is_firstname", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["type", "Lastname"], ["fullname", "田中二郎"], ["this_is_firstname", "二郎"], ["created_at", "2020-04-18 12:22:45.112188"], ["updated_at", "2020-04-18 12:22:45.112188"]]
   (0.9ms)  commit transaction
=> #<Lastname id: 1, type: "Lastname", fullname: "田中二郎", this_is_firstname: "二郎", this_is_lastname: nil, created_at: "2020-04-18 12:22:45", updated_at: "2020-04-18 12:22:45">

データベースにテーブルを持たないLastnameモデルの操作により、レコードを作ることができました。このレコードはデータベース内の実態としては、Fullnameのテーブルであるfullnamesに以下のように格納されています*5

まとめ

FirstnameモデルとLastnameモデルという2つのモデルを作りたいという欲求があり、それぞれのモデルの属性に共通の属性があるため、それらをまとめたFullnameモデルというモデルを作りました。

Fullnameモデルだけが物理データベースのテーブルという実体を持ちます。そのテーブルにFirstnameが必要としている属性(カラム)と、Lastnameが必要としている属性(カラム)の全てを合算して作成します。

その上で、Fullnameモデルにtypeという属性を追加します。このtypeという属性には、レコードが、どちらのモデル(Firstnameか、Lastnameか)が元になって作られたレコードなのがが記録されます。

以上により、データベースに実体を持たないFirstnameおよびLastnameというモデルの操作を用いて、Fullnameモデルと結びついているfullnamesというテーブルのレコードを操作できます。

何が嬉しいのか

STIを用いてモデルを設計することで、FirstnameおよびLastnameのモデルでのふるまいをそれぞれのモデル内で書くことができます。それぞれのモデルの用途により、異なる振る舞いを持たせることができるようになります。

2つのモデルに共通の振る舞いは、継承元であるFullnameに書けばいいでしょう。

補足

「2つのモデル」ではなく、それ以上のモデルを継承関係にすることも可能です。

そして状況によっては、STIを用いるのではなく、ポリモーフィック関連やenumなどを用いる方が設計に合致することがあります。STIはいわゆるEAV(Entity Attribute Value)に直面したときの解決策とまずは考えるのが良いのではないでしょうか。

参考

http://blog.matake.jp/archives/railssingle_table_inherit

*1:Fullnameというモデルにfullnameという属性名称は大変よろしくありませんが、便宜上そうさせてください

*2:モデルを作ってからマイグレーションする

*3:なので、この語はコード中に使わないほうがよいでしょう

*4:持ってしまったらSTIではなくなってしまいます

*5:fullnamesしかテーブルの実体が無いので当然そうなります

Powered by はてなブログ