More than 1 year has 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 を書きました。よろしければ、このエントリの他に下記のエントリもご覧ください。

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