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

rails
133件のシェア(ちょっぴり話題の記事)

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Rails で STI(Single Table Inheritance)を使った時の、コントローラの実装をどうするか?という事について書いてみたいと思います。

STI(単一テーブル継承)とは

オブジェクト指向の基本概念として、あるクラスを元にサブクラスを定義する継承があります。
この継承は、プログラミング言語ですとと予め機能が提供されていることが多いですが、 RDB ですとテーブル構成によって表現する場合があります。 *1
今回使う STI は、一つのテーブル内に継承関係にあるクラスのカラムを全て持ってしまう方法です。

例として Player クラスを継承した Footballer クラスと Cricketer クラスがあるとします。

class

親クラスに共通のプロパティ、子クラスがそれぞれのプロパティを持っていますが、これを STI で表現すると以下になります。

table

テーブルのレコードがどちらの型にあたるのかのカラム(type)を持っていて、それぞれの型が使うカラムのみにデータが入ります。

Rails ではこの STI がデフォルトでサポートされていて、テーブル定義とクラスの継承によって、簡単に使うことが出来ます。

  • type カラムを保持したテーブルあること。 *2
  • 上記のテーブルにマッピングされたモデルの派生クラスがあること。
  • これで 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 モデルを継承した FootballerCricketer も作成します。

    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 を作成して FootballersControllerCricketersController を子クラスとして定義してもいいのですが、やっぱり 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 メソッドは、そのモデルクラスを返します。

    routes.rb には以下のように定義しました。

    # 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
    

    footballerscricketers の 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/

    脚注

    1. RDB によっては継承機能を実装しているものもあります。
    2. カラム名は変更することが出来ます。