[Rails] STI(Single Table Inheritance)でコントローラも一つに纏める

Rails で STI(Single Table Inheritance)を使った時の、コントローラの実装をどうするか?という事について書いてみたいと思います。
STI(単一テーブル継承)とは
オブジェクト指向の基本概念として、あるクラスを元にサブクラスを定義する継承があります。
この継承は、プログラミング言語ですとと予め機能が提供されていることが多いですが、
RDB ですとテーブル構成によって表現する場合があります。 *1
今回使う STI は、一つのテーブル内に継承関係にあるクラスのカラムを全て持ってしまう方法です。
例として Player クラスを継承した Footballer クラスと Cricketer クラスがあるとします。
親クラスに共通のプロパティ、子クラスがそれぞれのプロパティを持っていますが、これを STI で表現すると以下になります。
テーブルのレコードがどちらの型にあたるのかのカラム(type)を持っていて、それぞれの型が使うカラムのみにデータが入ります。
Rails ではこの STI がデフォルトでサポートされていて、テーブル定義とクラスの継承によって、簡単に使うことが出来ます。
これで STI が実現出来るようになります。
モデルの実装
最初に type カラムを持つように migrate ファイルの作成と、親となるモデル Player を作成します。
$ bundle exec rails g model player name:string club:string 'batting_average:decimal{4,3}’ type:string
# db/migrate/20140514063117_create_players.rb
class CreatePlayers < ActiveRecord::Migration
def change
create_table :players do |t|
t.string :name
t.string :club
t.decimal :batting_average, precision: 4, scale: 3
t.string :type
t.timestamps
end
end
end
Player
# app/models/player.rb class Player < ActiveRecord::Base end
次に Player モデルを継承した Footballer、Cricketer も作成します。
Footballer
$ bundle exec rails g model footballer --parent player
# app/models/footballer.rb class Footballer < Player end
Cricketer
$ bundle exec rails g model cricketer --parent player
# app/models/cricketer.rb class Cricketer < Player end
デフォルトですと type カラムにクラス名の表記(Footballer のようにキャメルケースで)保存されます。
今回は小文字で登録したかったため、player クラスに以下のように追記しました。
# app/models/player.rb
class Player < ActiveRecord::Base
class << self
def find_sti_class(type_name)
type_name.camelize.constantize
end
def sti_name
name.underscore
end
end
end
動作確認
DBマイグレートをした後に、動きを確認してみましょう。
$ bundle exec rake db:migrate
$ bundle exec rails c
> Footballer.create(name: 'David', batting_average: 0.354)
> Cricketer.create(name: 'Emily', club: 'marylebone')
>
> Footballer.all
Footballer Load (0.3ms) SELECT `players`.* FROM `players` WHERE `players`.`type` IN ('footballer')
+----+------------+-------+------+-----------------+-------------------------+-------------------------+
| id | type | name | club | batting_average | created_at | updated_at |
+----+------------+-------+------+-----------------+-------------------------+-------------------------+
| 1 | footballer | David | | 0.354 | 2014-05-14 06:57:59 UTC | 2014-05-14 06:57:59 UTC |
+----+------------+-------+------+-----------------+-------------------------+-------------------------+
1 row in set
>
> Cricketer.all
Cricketer Load (0.3ms) SELECT `players`.* FROM `players` WHERE `players`.`type` IN ('cricketer')
+----+-----------+-------+------------+-----------------+-------------------------+-------------------------+
| id | type | name | club | batting_average | created_at | updated_at |
+----+-----------+-------+------------+-----------------+-------------------------+-------------------------+
| 2 | cricketer | Emily | marylebone | | 2014-05-14 06:58:06 UTC | 2014-05-14 06:58:06 UTC |
+----+-----------+-------+------------+-----------------+-------------------------+-------------------------+
1 row in set
type カラムにそれぞれのクラス名が登録され、検索条件にも自動的に含まれていますね。
コントローラの実装
次にコントローラの実装です。
PlayersController を作成して FootballersController、CricketersController を子クラスとして定義してもいいのですが、やっぱり Ruby なので DRY に書きたいですよね。
PlayersController でそれぞれのリクエストを処理したいと思います。
# app/controllers/players_controller.rb
class PlayersController < ApplicationController
before_action :load_player, only: %i(show update destroy)
def index
render json: player_class.all, status: :ok
end
def show
render json: @player, status: :ok
end
def create
player = player_class.new(player_params)
if player.save
render json: player, status: :created
else
render json: player.errors.full_messages, status: :unprocessable_entity
end
end
def update
if @player.update(player_params)
head :no_content
else
render json: @player.errors.full_messages, status: :unprocessable_entity
end
end
def destroy
@player.destroy
head :no_content
end
private
def load_player
@player = player_class.find(params[:id])
end
def type
params[:type]
end
def player_params
params.require(type.underscore.to_sym).permit(:name, :club, :batting_average)
end
def player_class
type.constantize
end
end
※シンプルな例にするためにレスポンスを json で返すようにしています。
type パラメータに Footballer または、Cricketer が指定されると、player_class メソッドは、そのモデルクラスを返します。
# config/routes.rb
Rails.application.routes.draw do
resources :footballers, controller: :players, type: 'Footballer', except: %i(new edit), defaults: {format: :json}
resources :cricketers, controller: :players, type: 'Cricketer', except: %i(new edit), defaults: {format: :json}
end
footballers と cricketers の controller として players を指定し、type に子モデルのクラス名を指定します。
上記の定義によって、以下のようなルーティングが指定出来ました。
$ bundle exec rake routes
Prefix Verb URI Pattern Controller#Action
footballers GET /footballers(.:format) players#index {:format=>:json, :type=>"Footballer"}
POST /footballers(.:format) players#create {:format=>:json, :type=>"Footballer"}
footballer GET /footballers/:id(.:format) players#show {:format=>:json, :type=>"Footballer"}
PATCH /footballers/:id(.:format) players#update {:format=>:json, :type=>"Footballer"}
PUT /footballers/:id(.:format) players#update {:format=>:json, :type=>"Footballer"}
DELETE /footballers/:id(.:format) players#destroy {:format=>:json, :type=>"Footballer"}
cricketers GET /cricketers(.:format) players#index {:format=>:json, :type=>"Cricketer"}
POST /cricketers(.:format) players#create {:format=>:json, :type=>"Cricketer"}
cricketer GET /cricketers/:id(.:format) players#show {:format=>:json, :type=>"Cricketer"}
PATCH /cricketers/:id(.:format) players#update {:format=>:json, :type=>"Cricketer"}
PUT /cricketers/:id(.:format) players#update {:format=>:json, :type=>"Cricketer"}
DELETE /cricketers/:id(.:format) players#destroy {:format=>:json, :type=>"Cricketer"}
動作確認
定義されたルーティングに対して URL にアクセスして確認してみます。
$ bundle exec rails s
$ curl http://localhost:3000/footballers | jq .
[
{
"updated_at": "2014-05-14T06:57:59.000Z",
"created_at": "2014-05-14T06:57:59.000Z",
"batting_average": "0.354",
"club": null,
"name": "David",
"id": 1
}
]
$
$ curl http://localhost:3000/cricketers | jq .
[
{
"updated_at": "2014-05-14T06:58:06.000Z",
"created_at": "2014-05-14T06:58:06.000Z",
"batting_average": null,
"club": "marylebone",
"name": "Emily",
"id": 2
}
]
それぞれのモデルのデータを返していますね。同じように登録や更新を試すと、モデル毎に動作するのが確認出来ると思います。
まとめ
STI を使った際のコントローラを一つに纏める方法でした。ルーティングでコントローラの入り口を2つに分けることで、コード量は少なくとてもシンプルに実現出来たと思います。ちなみに今回試した環境は、Ruby 2.1.1 と Rails 4.1.1 でした。
参考資料
http://thibaultdenizet.com/tutorial/single-table-inheritance-with-rails-4-part-1/