[Rails] STI（単一テーブル継承）とメタプログラミングでDRY


## 概要

コピペコードが増えがちなサンプルアプリケーションの設計を例にとって、
STI（単一テーブル継承）とメタプログラミングでDRY（重複排除）してみる。

## 題材

ユーザが保持している楽曲をジャンルごとに管理するようなアプリケーション。
ユーザページでは、ジャンル別に登録曲を一覧（もっと言うとCRUD）できる。

こんなイメージですね。

```
kidachi_さん
あなたの登録曲一覧
  Rock
    ほげRock
    ふがRock
  Pops
    未登録です。    
  Jazz
    ふーJazz
    ばーJazz
```


何も考えないで作ると、rock/pops/jazzそれぞれのモデル、ビュー、コントローラに
似たような記述・コピペが増えそうな予感を感じて頂けたでしょうか。

では、それを防ぐために、まずはSTIから。

（※追記）
実は上記だけの要件であれば
userテーブル、musicテーブル、genreテーブルのみを用意して
`user has_many genres through musics`のassosiationでも実現可能
（そもそもrock/pops/jazzモデルを用意する必要がない）だったりします。

実際は「今後それぞれのgenreごとに特有な処理を複数追加していきたい」というケースを想定して、
各genre個別のモデルを用意することを前提にしています。
ちょっと要件の例がいまいちだったかもしれず、申し訳ありません。。



## STI（Single Table Inheritance/単一テーブル継承）とは

「継承」と言えば、各クラスの共通項目が括りだされた
スーパークラスと、非共通項目のサブクラスを用いることで
コードの重複を防ぐ仕組みですが、それを
<strong>db（テーブル）設計にも適用しよう</strong>というのがSTIです。
（単一テーブル継承、という名前そのままですね。）

## 具体例

先ほどの楽曲管理アプリのテーブルをざっくり設計してみると、
以下のようなイメージになると思います。

![music1.png](https://qiita-image-store.s3.amazonaws.com/0/8790/8b1bc826-35f5-1a39-c2d9-ec044e0d28e3.png "music1.png")


悪くはないのですが、モデル/テーブルに重複情報が
増えてしまいそうですね。

## そこで単一テーブル継承

こうなります。

![music2.png](https://qiita-image-store.s3.amazonaws.com/0/8790/5b1e5d54-fadd-63d7-767e-660501e0a42d.png "music2.png")


スーパークラスを用意して継承の仕組みを使うのと概念は全く一緒です。

一見どうということも無い様に見えますが、実は

- <strong>rock、pops、jazz（etc）テーブルは実在しない</strong>
- <strong>全てのデータは、musicテーブルに保管される</strong>

という特徴を持ちます。

さりげなくmusicテーブルに<strong>「type」</strong>というカラムが追加されていますが、
<strong>ここにrock/pops/jazzといった子の情報が入る</strong>事になります。

また、もしrockテーブルにしか持たないような情報が欲しい場合は、
事前にそれはmusicに定義しておきます。

## Modelの実装

STIを使っても、モデルは通常の継承の形で実装するだけです。

```ruby:music.rb
class Music < ActiveRecord::Base
  belongs_to :user
  validates :name, presence: true
  validates :artist, presence: true
end
```

```ruby:rock.rb
class Rock < Music
end
```

気をつけるのは、
子が継承するのはActiveRecord::Baseでなく親であるMusic
というところくらいでしょうか。

ちなみに、親で設定したリレーションやvalidatesは全て
子クラスにも引き継がれます。

STIについてはここまで。

結論、STIを利用するには、db生成時に

- 親テーブル（今回はmusic）にtypeカラムを持たせる。
- 子の一つに特有なカラムが必要であれば、それも親に持たせておく
- （あとは普通に継承の形でモデルを作る）

だけで良いことなります。


## Cntrollerの実装とメタプログラミング

users/showで該当ユーザの楽曲一覧を表示したい、という仕様でしたね。
つまりshowで@rock/@pops/@jazzの3つをセットしておきたいということ。

ここでメタプログラミングを利用してみます。

```ruby:users_controller.rb
class UsersController < ApplicationController

  GENRE = [
      'rock',
      'pops',
      'jazz'
  ]

  def show
    @user = User.find(current_user.id)
    set_music_by_genre
  end

  private
    def set_music_by_genre
      @music_list = Array.new
      GENRE.each do |music|
        key = "@#{music}"
        if @user.send(music).nil?    # 1
          music = music.gsub(/\b\w/) { |s| s.upcase }    # 2
          value = self.class.const_get(music).new    # 3
        else
          value = @user.send(music)    # 4
        end
        instance_variable_set(key, value)    # 5
        @music_list << instance_variable_get(key)    # 6
      end
    end
end
```

### set_music_by_genreの説明

####1. if @user.send(music).nil?

sendを用いることで、@userに対するメッセージングを動的に行っています。
つまり、@user.rock/@user.pops/@user.jazzを動的に生成しているということ。

ここでは、@userがrock/pops/jazzそれぞれのデータを持っているかどうか
を判別し、既存データを保持している場合はそれを呼び出し（#2,3）、
保持していなければ新規オブジェクトを生成（#4）します。

####2. music.gsub(/\b\w/) { |s| s.upcase }

"Rock"/"Pops"/"Jazz"という文字列を動的に生成。
\#3で使います。

####3. self.class.const_get(music).new

self.class.const_get(str)で、文字列(str)から
クラスやモジュールの定数を取得できます。
つまり、\#2で生成した"Rock"/"Pops"/"Jazz"を
クラス（Rock/Pops/Jazz）に変換し、それぞれnewしています。

####4. @user.send(music)

\#1と同様で、@userに対して動的にメッセージを送っています。

####5. instance_variable_set(key, value)

動的にインスタンス変数を生成。
@rock/@pops/@jazzそれぞれに、既存オブジェクトもしくは新規オブジェクトをセットします。


####6. @media_list << instance_variable_get(key)

viewで用いるために、

```ruby
[
  #<Rock id: nil, type: "Rock", created_at: nil, updated_at: nil>, 
  #<Pops id: nil, type: "Pops", created_at: nil, updated_at: nil>, 
  #<Jazz id: nil, type: "Jazz", created_at: nil, updated_at: nil>
]
```

このようなオブジェクトがセットされた配列を用意します。


## View

```ruby:views/users/show.html.erb
  <% @music_list.each do |music| %>
    <% if music.try(:id).nil? %>
      <!-- 新規登録フォーム -->
    <% else %>
       <!-- 登録済み情報の表示 -->
    <% end %>
  <% end %>
```

showでセットした@music_listをもとに、instance_variable_get()で
動的にオブジェクトを取得し、それに応じて表示処理を行います。

## Controller

新規登録フォームから投げられたデータをもとにcreateする箇所。
ここでもself.class.const_get()でサブクラスに応じた
クラス（Rock/Pops/Jazz）を動的にセットし、保存しています。

```ruby:music_controller.rb
class MusicController < ApplicationController
  before_action :set_music_info
  before_action :music_params

  def create
    const_name = @music_name.gsub(/\b\w/) { |s| s.upcase }
    # サブクラスごとのオブジェクトを初期化
    @music = self.class.const_get(const_name)
    @music.new(music_params)
    respond_to do |format|
      if @music.save!
        ~
      end
    end
  end

  private
    # strong_parameters
    def music_params
      params.require(@music_name).permit(:user_id, :name, :email, :password)
    end

end

```

```rock_controller.rb
class RockController < MusicController
  private
    set_music_info
      @music_name = "rock"
    end
end
```

STIを用いてmusicテーブルが用意されているためか、music_controllerで
各サブクラス（rock/pops/jazz）のsave処理を行うのもごく直感的に行えます。

※個人的には、STIは特別な仕組みというより
#### 継承関係を持たせたいmodelに対して（直感的に）適合するテーブルのインターフェース
なんじゃないかな、と思っています。

## 完成

さて、これでshowページでの楽曲一覧と、楽曲新規登録のロジックができました。

このようなメタな骨組みを用意することで、musicのジャンルを増やす際は
hoge_controller.rbとGENREリストに新たなジャンルを追加するだけで良いことになります。
（もちろんcontroller/modelファイルを用意したりroutes.rb追記したり、は別途必要ですが）

```classic_controller.rb
class ClassicController < MusicController
  private
    set_music_info
      @music_name = "classic"
    end
end
```

```ruby:users_controller.rb
class UsersController < ApplicationController

  GENRE = [
      'rock',
      'pops',
      'jazz',
      'classic'
  ]
```
※GENRE配列は設定ファイルとして外部化した方が良いですね

こんな感じ。

長くなるのでここまでにしますが、メタプログラミングを使うことで
他のロジック部分も同様にコピペコードを撲滅できそうです。


## 参考
Railsで単一テーブル継承（Single Table Inheritance）
http://blog.matake.jp/archives/railssingle_table_inherit

const_get (Module)
http://ref.xaio.jp/ruby/classes/module/const_get

instance_variable_set/get (Object)
http://ref.xaio.jp/ruby/classes/object/instance_variable_set
http://ref.xaio.jp/ruby/classes/object/instance_variable_get

## 最後に

もっと効率の良い設計/書き方があれば、ぜひご教示よろしくお願い致します！