【Rails】正しいSTIの作り方

# 環境

- Rails 5.2.3

# やり方

## マイグレーションファイルの作成

```
$ bundle exec rails g model ChannelFair channel:references fair:references
      invoke  active_record
      create    db/migrate/xxxxxxxxxxxxxx_create_channel_fairs.rb
      create    app/models/channel_fair.rb
```

生成されるマイグレーションファイル

```ruby
class CreateChannelFairs < ActiveRecord::Migration[5.2]
  def change
    create_table :channel_fairs do |t|
      t.references :channel, foreign_key: true
      t.references :fair, foreign_key: true

      t.timestamps
    end
  end
end
```

## マイグレーションファイルを編集

- `t.references`はindexも作成してくれます。
  - `foreign_key: true`があれば、外部キー制約もつけてくれます。
  - 今回はSTIのため、`foreign_key: true`を削除し、テーブル名とカラム名を指定して外部キー制約を手動で設定します。(※3)
- `t.references`を使いつつ、中間テーブルでは、以下の通り変更します。
  - 複合キーでunique制約を張ります。（※1）
  - 上記のunique制約で`channel_id`に対するindexも張られるため、`index: false`を指定します。(※2)
  - `fair_id`に対するindexも不要であれば、`index: false`を指定します。

- STIのため、`type`カラムを追加します。(※4)

```ruby
class CreateChannelFairs < ActiveRecord::Migration[5.2]
  def change
    add_column :channels, :type, :string # (※4)

    create_table :channel_fairs do |t|
      t.references :channel, null: false, index: false # (※2)
      t.references :fair, null: false

      t.timestamps
    end

    # (※3)
    add_foreign_key :channel_fairs, :channels, column: :channel_id
    add_foreign_key :channel_fairs, :channels, column: :fair_id

    add_index :channel_fairs, [:channel_id, :fair_id], unique: true # (※1)
  end
end
```

## モデル

```ruby:app/model/channel_fair.rb
class ChannelFair < ApplicationRecord
  belongs_to :channel
  belongs_to :fair

  validates :channel_id, presence: true
  validates :fair_id, presence: true
  validates :channel_id, uniqueness: { scope: :fair_id }
end
```

```ruby:app/model/fair.rb
class Fair < Channel
  has_many :channel_fairs, dependent: :destroy
  has_many :channels, through: :channel_fairs
end
```

- 以下、(※A)の部分は要件に応じて、主にviewに連動して必要であれば追加します。
- 基本的にtypeカラムにvalidationは不要ですが（子クラスで指定した文字列以外は`validaion error`が発生します）、空文字列は許可されているため、それも防ぎたい場合はvalidationを追加しましょう。

```ruby:app/model/channel.rb
class Channel < ApplicationRecord
  # （※A）
  TYPES = %w(Fair)
  TYPES_WITH_BLANK = [''] + TYPES

  has_many :channel_fairs, dependent: :destroy
  has_many :fairs, through: :channel_fairs

  scope :only_channels, -> { where(type: nil) }
  scope :only_fairs, -> { where(type: 'Fair') }

  # （※A)
  before_validation do
    self.type = nil if type.blank?
  end

  def channel?
    type.nil?
  end

  def fair?
    type == 'Fair'
  end
end
```

## コントローラ

- [`becomes`](https://github.com/rails/rails/blob/d682b0d4e245e321ff5f0a459e8d771c3cf73677/activerecord/lib/active_record/persistence.rb#L180)を使う必要があるかもしれません。

```ruby:app/controllers/channels_controller.rb
  def create
     :
    # 子クラスと親クラスでcontrollerをまとめている場合に必要になるかもしれません
    @channel = Channel.find(params[:id]).becomes(Channel)
     :
  end

  def channel_params
    params.require(type_key).permit(
      :type,
        :
    )
  end

  def type_key
    return :channel if params.key?(:channel)
    return :fair    if params.key?(:fair)
  end
```

`becomes`については、[decent_exposure](https://github.com/hashrocket/decent_exposure)を使っている場合は、以下のような変更をする必要があるかもしれません。

```ruby
  - expose :channel, find_by: :slug, scope: -> { channels }
  + expose :channel, find: ->(id, scope){ scope.find_by!(slug: id).becomes(Channel) }, scope: -> { channels }
```

## ビュー

`select_box`で、こういう感じのコードが必要になるかもしれません。

```ruby
<%= f.select :type, Channel::TYPES_WITH_BLANK %>
```

## ファクトリー

```spec/factories/channels.rb
FactoryBot.define do
  factory :channel do
    ..
  end

  factory :fair, class: Fair, parent: :channel do
  end
end
```

## スペック

```ruby:spec/models/channel_spec.rb
describe Channel, type: :model do
  let(:channel) { create(:channel) }

  it 'has type with nil' do
    expect(channel.type).to be_nil
  end

  it 'has many fairs' do
    channel.fairs << create_list(:fair, 2)
    expect(channel.fairs).to be_many
  end
end
```

```ruby:spec/models/fair_spec.rb
describe Fair, type: :model do
  let(:fair) { create(:fair) }

  it 'has type with Fair' do
    expect(fair.type).to eq('Fair')
  end

  it 'has many channels' do
    fair.channels << create_list(:channel, 2)
    expect(fair.channels).to be_many
  end
end
```