Help us understand the problem. What is going on with this article?

Rails でシングルじゃないテーブル継承

More than 5 years have passed since last update.

この記事の目的

Rails の STI を使うようなケースで、STI の代わりに PostgreSQL のテーブル継承を使ってみる方法を紹介します。

STI とは

  • Single Table Inheritance
  • 単一テーブル継承
  • モデルクラスを継承で表現し、永続化部分はスーパークラスのテーブル1枚でまかなう

STI の実装例(親クラス)

create_cars.rb
class CreateCars < ActiveRecord::Migration
  def change
    create_table :cars do |t|
      t.integer :weight
      t.string :color
      t.string :type # STI 用のメタデータカラム
      t.timestamps
    end
  end
end
car.rb
class Car < ActiveRecord::Base; end

STI の実装例(子クラス)

create_manual_cars.rb
class CreateManualCars < ActiveRecord::Migration
  def change
    # スーパークラスのテーブルにカラムを追加するだけ
    add_column :cars, :number_of_gears, :integer
  end
end
manual_car.rb
class ManualCar < Car
end
automatic_car.rb
class AutomaticCar < Car
end

STI の便利さ

  • サブクラス特有のカラムを add_column して、スーパークラスを継承するだけで作れちゃう便利
  • スーパークラスに共通処理や属性を持たせることで、サブクラスのコードがスッキリ!
  • メタデータカラムがあるので、親クラスのインスタンスから、子クラスを特定出来たりする
AutomaticCar.create(
  weight: 1000,
  color:  "blue"
) #=> #<AutomaticCar id: 1>

# ダウンキャストっぽい事ができる
Car.find(1).tap{|car|
  break car.type.classify.constantize.find(car.id)
}.class #=> AutomaticCar

STI の注意点

  • スーパークラスからサブクラスの属性に触れちゃう
Car.find(1).number_of_gears #=> 6
Car.find(1).update(number_of_gears: 5)
  • それを防ぐためには、守るコードを追加する必要がある
# gem 'protected_attributes'
class Car < ActiveRecord::Base
  attr_accessible :weight, :color
end

class ManualCar < Car
  attr_accessible :number_of_gears
end

外部キー制約

  • ついに Rails 本体に外部キー制約サポートがくるよー

Support for real foreign keys!
add_foreign_key/remove_foreign_key are now available in migrations.
http://weblog.rubyonrails.org/2014/8/20/Rails-4-2-beta1/

STI と外部キー制約

子クラス特有の属性(a_id とします)を定義するようなケースで、a_id が外部キーであり、a_id に NOT NULL 制約と外部キー制約を付けたいとします。
素の STI では、親や他の子クラスから INSERT したら a_id に NULL が入るようになってますので、NOT NULL 制約を付けるとエラーになります。
じゃあ、外部キーが指してる外部テーブル側に {id: 99999, value: "無し"} みたいなレコードを入れておいて、外部キーに NULL が入りそうになったら、代わりに 99999 を入れることで解決するかというと、動くかもしれないけど辛いですね。

STI の背景

そもそも STI はどこから来たのか

Relational databases don't support inheritance, so when mapping...
http://www.martinfowler.com/eaaCatalog/singleTableInheritance.html

でも、テーブルの継承って機能が PostgreSQL にありますよね。STI がやりたいことって、テーブル継承でも実現できそう!

継承テーブルの作成

class CreateCars < ActiveRecord::Migration
  def change
    create_table :cars do |t|
      t.integer :weight
      t.string :color
      # t.string :type STI じゃないので、これは不要
      t.timestamps
    end
  end
end

class AddManualCar < ActiveRecord::Migration
  def up
    execute <<-SQL
      CREATE TABLE manual_cars(number_of_gears integer)
          INHERITS cars
    SQL
  end

  def down
    drop_table :manual_cars
  end
end

class AddAutomaticCar < ActiveRecord::Migration
  def up
    execute "CREATE TABLE automatic_cars() INHERITS cars"
  end

  def down
    drop_table :automatic_cars
  end
end

INHERITS cars という部分が、cars テーブルの属性を継承する宣言となります。

STI のレールから降りるの儀

# テーブル継承の親になるモデルの振る舞いを表すモジュール
module Parental
  # 親クラスのインスタンスから子クラスのインスタンスに変身する
  def down_cast
    sub_model.find(self)
  end

  private
  # システム行 - tableoid
  # この列は特に、継承階層からの選択問い合わせでは便利です。
  # tableoidはテーブル名を得るためにpg_classのoid列に結合することができます。
  # -- PostgreSQL のマニュアルより
  def sub_model
    relation_name = ActiveRecord::Base.connection.execute(<<-SQL).first["relname"]
      SELECT relname
        FROM #{self.class.to_s.tableize} p, pg_class c
       WHERE p.tableoid = c.oid
         AND p.id = #{self.id}
       LIMIT 1
    SQL

    # クラス名と対応したテーブル名をつける規約に従っている前提で
    relation_name.classify.constantize
  end
end
# テーブル継承の子になるモデルの振る舞いを表すモジュール
module PgInherits
  extend ActiveSupport::Concern

  included do |base|
    # 普通にクラス継承をすると親クラスのテーブル名なので、上書きする
    base.table_name = base.name.tableize
  end
end
# モデルクラスの実装
class Car < ActiveRecord::Base
  include Parental
end

class ManualCar < Car
  include PgInherits
end

class AutomaticCar < Car
  include PgInherits
end

テーブル継承の注意点

  • 多重継承(1つのテーブルに複数の親テーブルを設定)ができるが、多重継承の無いプログラミング言語とマッピングしづらいです
  • 親テーブルで PRIMARY KEY, UNIQUE の宣言をしていても、子テーブルまでは制約が伝播しません
  • 親テーブルに外部キーを持って、外部キー制約をつけていても、子テーブルまで制約が伝播しません
  • NOT NULL や DEFAULT は子テーブルに伝播します
  • PRIMARY KEY, UNIQUE について、親まで遡らないので、子テーブルから重複値を入れ放題
    • 子テーブルに別途制約を付けても、子テーブル内でしかチェックしないので根本解決になりません
    • Rails で困る具体例は id
# 親クラスのレコードを作成
Car.create #=> #<Car id: 1>

# 子クラスで id を明示してレコードを作成
AutomaticCar.create(id: 1) #=> #<AutomaticCar id: 1>

# id: 1 が被ってる
Car.where(id: 1).count #=> 2

ただ、DEFAULT は伝播します。
ID シーケンスは親テーブルと共通なので、ID を直接指定して INSERT や UPDATE をしたりしなければ問題ないです。しなければ。するな。でも fixtures とかががが
回避策としては、下記のように id 属性の使用に制限をかける方法があります。

module PgInherits
  extend ActiveSupport::Concern

  included do |base|
    base.table_name = base.name.tableize
    # gem 'protected_attributes' こいつ便利...
    attr_protected :id
  end
end

まとめ

STI は便利ですが、DB のメンテがしづらくなるので、なるべく避けたいです。今回の方法は STI の代わりになりえると思います。
なお、PostgreSQL のテーブル継承は、今日の他エントリ(PostgreSQL のパーティションテーブル自動生成)でも使っていますので、そちらもご覧いただければと思います。

12 月 18 日

今日は誕生日なので4本の Advent Calendar を書きました。よろしければ、このエントリの他に下記のエントリもご覧ください。

誕生日ですが「ウィッシュリストに入れてるからには読めよ」などと言いながら難しい本を送りつけるなどの行為は何卒

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした