この記事の目的
Rails の STI を使うようなケースで、STI の代わりに PostgreSQL のテーブル継承を使ってみる方法を紹介します。
STI とは
- Single Table Inheritance
- 単一テーブル継承
- モデルクラスを継承で表現し、永続化部分はスーパークラスのテーブル1枚でまかなう
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 Car < ActiveRecord::Base; end
STI の実装例(子クラス)
class CreateManualCars < ActiveRecord::Migration
def change
# スーパークラスのテーブルにカラムを追加するだけ
add_column :cars, :number_of_gears, :integer
end
end
class ManualCar < Car
end
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 を書きました。よろしければ、このエントリの他に下記のエントリもご覧ください。
誕生日ですが「ウィッシュリストに入れてるからには読めよ」などと言いながら難しい本を送りつけるなどの行為は何卒