読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

Rails4でSTI(単一継承テーブル)を行う

Rails中級 Rails Model

RailsのActiveRecordを使って次のようにすることでSTI(Single Table Inheritance: 単一継承テーブル)を行うことができます。

  • テーブルにtype(string)カラムを持たせる
  • そのテーブルに対応するモデルクラスを継承したクラスを作成する

例えば、本(Book)とコンピューター(Computer)の商品を扱っており、一部カラムが同じで一部カラムが違うので、次のように1つのテーブルを使い、3つのモデルを作成することで、Rails側でbooksテーブルとcomputersテーブルが存在しているようにBookオブジェクトとComputerオブジェクトを扱うことができるようになります。

f:id:nipe880324:20141204221147p:plain:w480


これにより、

Product.all  # => Book, Computerモデルを含めたすべてレコードをproductsテーブルから取得
Computer.all # => typeが"Computer"のレコードのみproductsテーブルから取得
Book.all     # => typeが"Book"    のレコードのみproductsテーブルから取得

といったように、

  • コンピューターと本のすべての商品を透過的に取り扱うことができる
  • 本だけ、コンピューターだけといったそれぞれのモデルだけで取り扱いもできる

といったようにできます。



動作確認

  • Rails 4.1
  • ActiveRecord 4.1.7

目次

  1. Railsプロジェクトの作成
  2. STIの実装
  3. STIに合わせてコントローラー、ビュー、ルーティングを修正
  4. STIで各モデルに各々の処理をさせる


1. Railsプロジェクトの作成

Railsプロジェクトの作成する。

rails new sti_test
cd sti_test


GeneratorでProduct、Maker、Cpu、Authorを作成し、マイグレートする。

rails g scaffold Product name price:integer maker_id:integer cpu_id:integer author_id:integer
rails g model maker name
rails g model cpu name
rails g model author name
rake db:migrate


初期データを作成する。

# db/seeds.rb
%w(Celeron Corei5 Xeno).each { |name| Cpu.create! name: name }
%w(Lenobo HB TOSHIBO).each { |name| Maker.create! name: name }
%w(田中 加藤 佐藤).each { |name| Author.create! name: name }


そして、初期データを投入する。

rake db:seed


各モデルにリレーションとバリデーション定義を追加する。

# app/models/cpu.rb
class Cpu < ActiveRecord::Base
  has_many :products
  validates :name, presence: true
end


# app/models/maker.rb
class Maker < ActiveRecord::Base
  has_many :products
  validates :name, presence: true
end


# app/models/author.rb
class Author < ActiveRecord::Base
  has_many :products
  validates :name, presence: true
end


# app/models/product.rb
class Product < ActiveRecord::Base
  belongs_to :cpu
  belongs_to :maker
  belongs_to :author

  validates :name,  presence: true
  validates :price, presence: true
end

これで、一通りの準備ができました。




2. STIの実装

では、STIを実装していきます。

まず、productsテーブルにtypeカラムを追加します。

rails g migration add_type_to_products type:string


Book, Computerオブジェクトを取得する場合にtypeカラムを検索条件として使うので、インデックスをつけておきます。

# db/migrate/20141204133904_add_type_to_products.rb
class AddTypeToProducts < ActiveRecord::Migration
  def change
    add_column :products, :type, :string
    add_index  :products, :type  # インデックスも追加
  end
end


マイグレートを実行します。

rake db:migrate


そして、バリデーションも追加しておきます。

# app/models/products.rb
class Product < ActiveRecord::Base
  belongs_to :cpu
  belongs_to :maker
  belongs_to :author

  validates :name,  presence: true
  validates :price, presence: true
  validates :type,  presence: true
end


次に、Productモデルを継承したBookモデルとComputerモデルを作成します。
ついでに、バリデーションも追加しておきます。

# app/models/product.rb
class Book < Product
  validates :author_id, presence: true
end


# app/models/computer.rb
class Computer < Product
  validates :cpu_id,   presence: true
  validates :maker_id, presence: true
end


これで、Railsにより自動的にSTIが行われるようになります。
挙動をコンソールで確認してみましょう。

# typeに"Computer"を設定すると、自動的にComputerモデルが作成される
Product.new type: "Computer"
# => <Computer id: nil, name: nil, price: nil, type: "Computer", maker_id: nil, cpu_id: nil, author_id: nil, created_at: nil, updated_at: nil>

# typeに"Book"を設定すると、自動的にBookモデルが作成される
Product.new type: "Book"
# => <Book id: nil, name: nil, price: nil, type: "Book", maker_id: nil, cpu_id: nil, author_id: nil, created_at: nil, updated_at: nil>


# typeに""を設定すると、Productモデルが作成される(nil, false, [], {} などもProductモデルが作成される)
Product.new type: ""
# => <Product id: nil, name: nil, price: nil, type: "", maker_id: nil, cpu_id: nil, author_id: nil, created_at: nil, updated_at: nil>

# typeにサブクラス名かブランク以外の値を設定するとActiveRecord::SubclassNotFound例外が発生する
Product.new type: "hoge"
# => ActiveRecord::SubclassNotFound: Invalid single-table inheritance type: hoge is not a subclass of Product


3. STIに合わせてコントローラー、ビュー、ルーティングを修正

まずは、ビューから表示内容を微調整していきます。
一覧画面はidではなく、名前(name)を表示するように修正します。
tryメソッドを使うことで、レシーバー(呼び出し側: maker, cpu, authorなど)がnilの場合、nilが返されるので、メソッドが定義されていないといった例外が発生しないようにできます。

# app/views/products/index.html.erb
...
<h1>Listing products</h1>

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Price</th>
      <!-- 修正箇所 開始 -->
      <th>Type</th>
      <!-- 修正箇所 終了 -->
      <th>Maker</th>
      <th>Cpu</th>
      <th>Author</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @products.each do |product| %>
      <tr>
        <td><%= product.name %></td>
        <td><%= product.price %></td>
        <!-- 修正箇所 開始 -->
        <td><%= product.type %></td>
        <td><%= product.maker.try(:name) %></td>
        <td><%= product.cpu.try(:name) %></td>
        <td><%= product.author.try(:name) %></td>
        <!-- 修正箇所 終了 -->
        <td><%= link_to 'Show', product %></td>
        <td><%= link_to 'Edit', edit_product_path(product) %></td>
        <td><%= link_to 'Destroy', product, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New Product', new_product_path %>


詳細画面もidではなく、名前(name)を表示するように修正します。

# app/views/products/show.html.erb

<p id="notice"><%= notice %></p>

<p>
  <strong>Name:</strong>
  <%= @product.name %>
</p>

<p>
  <strong>Price:</strong>
  <%= @product.price %>
</p>

<!-- 修正箇所 開始-->
<p>
  <strong>Type:</strong>
  <%= @product.type %>
</p>

<p>
  <strong>Maker:</strong>
  <%= @product.maker.try(:name) %>
</p>

<p>
  <strong>Cpu:</strong>
  <%= @product.cpu.try(:name) %>
</p>

<p>
  <strong>Author:</strong>
  <%= @product.author.try(:name) %>
</p>
<!-- 修正箇所 終了 -->

<%= link_to 'Edit', edit_product_path(@product) %> |
<%= link_to 'Back', products_path %>


次に、新規/編集画面で、メーカー、CPU、著者をテキストフィールドからセレクトボックスに変更します。

また、form_foras: :productオプションを追加します。
これは、@productの中身がBookオブジェクトやComputerオブジェクトになる可能性があり、その場合、name属性の値がbook[name]computer[name]などになり、コントローラーのStrongParametersの箇所でエラーになってしまいます。そのため、@productの中身に関係なくname属性をproduct[name]とするようにするために必要です。

# app/views/products/_form.html.erb
<!-- 修正箇所 form_for に as: :product を追加する -->
<%= form_for(@product, as: :product) do |f| %>
  <% if @product.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@product.errors.count, "error") %> prohibited this product from being saved:</h2>

      <ul>
      <% @product.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :name %><br>
    <%= f.text_field :name %>
  </div>
  <div class="field">
    <%= f.label :price %><br>
    <%= f.number_field :price %>
  </div>
  <!-- 修正箇所 開始 -->
  <div class="field">
    <%= f.label :type %><br>
    <%= f.select :type, ["Computer", "Book"].map { |t| [t, t] }, include_blank: true %>
  </div>
  <div class="field">
    <%= f.label :maker_id, "メーカー(Computer時のみ必須)" %><br>
    <%= f.select :maker_id, Maker.all.map { |m| [m.name, m.id] }, include_blank: true %>
  </div>
  <div class="field">
    <%= f.label :cpu_id, "CPU(Computer時のみ必須)" %><br>
    <%= f.select :cpu_id, Cpu.all.map { |c| [c.name, c.id] }, include_blank: true %>
  </div>
  <div class="field">
    <%= f.label :author_id, "著者(Book時のみ必須)" %><br>
    <%= f.select :author_id, Author.all.map { |a| [a.name, a.id] }, include_blank: true %>
  </div>
  <!-- 修正箇所 終了 -->
  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>


フォームにタイプ(type)フィールドを追加したので、コントローラーのStrongParametersにもタイプ(type)を追加します。

# app/controllers/products_controller.rb
    def product_params
      params.require(:product).permit(:name, :price, :type, :maker_id, :cpu_id, :author_id)
    end


最後に、ルーティングを設定します。
Bookオブジェクト、Computerオブジェクトの際には、books_pathcomputers_pathに遷移しようとするのでルーティングも追記しておきます。

Rails.application.routes.draw do
  resources :products
  # booksやcomputersのパスでもProductsControllerを使うように設定する
  resources :books,     controller: :products
  resources :computers, controller: :products

  root "products#index"
end


rake routesで設定を確認してみます。
どのURIパターンでも、productsコントローラーが呼ばれるようなルーティングの設定になっています。

rake routes
       Prefix Verb   URI Pattern                   Controller#Action
     products GET    /products(.:format)           products#index
              POST   /products(.:format)           products#create
     ...............

        books GET    /books(.:format)              products#index
              POST   /books(.:format)              products#create
     ...............

    computers GET    /computers(.:format)          products#index
              POST   /computers(.:format)          products#create
     ...............


ここでは、コントローラー/ビューをProductのものを利用しましたが、
服、家具などサブクラスの数が増えたり、サブクラスごとにコントローラーの処理やビューの表示を変えたい場合は、別々のコントローラー/ビューを用意するといいでしょう。
しかし、大きく異なる場合は、そもそもサブクラス化する必要があるのか、別々のテーブル、モデルにするべきかもしれないことを検討するべきだと思います。

では、rails sでサーバーを起動し、画面を表示してみましょう。
登録、編集、削除ができる思います。本番ではJSを使ったり、条件分岐を使って、表示内容を変えると良いと思います。

f:id:nipe880324:20141204225552p:plain:w480




4. STIで各モデルに各々の処理をさせる

せっかく、STIにしたので、ProductをBook、Computerを意識しないで透過的に扱えるようにしたいですね。
そうしたい場合は、次のように各サブクラスに同じ名前のメソッドを定義してあげればよいです。

例えば、BookやComputerオブジェクトに合わせた商品名を表示するメソッド(full_name)を定義してみます。

# app/models/book.rb
class Book < Product
  validates :author_id, presence: true

  def full_name
    "#{name} written by #{author.name}"
  end
end


# app/models/coumputer.rb
class Computer < Product
  validates :cpu_id,   presence: true
  validates :maker_id, presence: true

  def full_name
    "#{name}(#{cpu.name}/#{maker.name})"
  end
end


そして、一覧画面からこのメソッドを呼び出すように修正します。

# app/views/products/index.html.erb
<h1>Listing products</h1>

<table>
  <thead>
    <tr>
      <!-- 修正箇所 開始 -->
      <th>Type</th>
      <th>Name</th>
      <th>Price</th>
      <!-- 修正箇所 終了 -->
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @products.each do |product| %>
      <tr>
        <!-- 修正箇所 開始 -->
        <td><%= product.type %></td>
        <td><%= product.full_name %></td>
        <td><%= product.price %></td>
        <!-- 修正箇所 終了 -->
        <td><%= link_to 'Show', product %></td>
        <td><%= link_to 'Edit', edit_product_path(product) %></td>
        <td><%= link_to 'Destroy', product, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New Product', new_product_path %>


では、画面を確認してみましょう。Book、Computerに合わせた商品名が表示されていることがわかると思います。
f:id:nipe880324:20141204222826p:plain:w480


このようにして、RailsのSTIを使うことで、DBのテーブルを1つのままで、モデルの継承関係を扱うことができます。
以上です。