Edited at

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

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

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