TL;DR
- moduleで名前空間を切ったActiveRecordのモデルでは、アソシエーション先のテーブル名や外部キーの類推にmoduleの名前を考慮してくれない
- そのため、moduleで名前空間を切ったActiveRecordのモデルでは、class_nameとforeign_keyを明示的に宣言する必要がある
- ActiveRecordのアソシエーションの実装では、アソシエーション名はスネークケースをキャメルケースにしてモデル名を推測するが、名前空間は考慮されない
- ActiveRecordのアソシエーションの実装では、外部キーはmoduleの中のクラス名のみから推測され、moduleや親クラスの名前は考慮されない
想定する状況
Railsアプリケーションでは、ドメインや用途ごとにモデルクラスに対して名前空間を切って管理することがあります。
例えば、「会社の建物」に関係する一連のモデルを他と分けて管理したいときは、下記のようにファイルを配置することがあります。
models
├ company
| ├ building
| | └ floor.rb # 「会社の建物」に属する「階」モデル
| └ building.rb # 「会社の建物」という親モデル
└ company.rb # table_prefixだけを定義したmodule
下記のように、1つのbuildingは複数のfloorを持っているものとします。
まず、company.rb では、テーブル名のプレフィックスを宣言しています。
module Company
def self.table_name_prefix
'company_'
end
end
building.rb では、has_many で複数のfloorへの関連付けを宣言し、
module Company
class Building < ApplicationRecord
has_many :floors
end
end
floor.rb では、 belongs_to でbuildingにまとめられることを宣言しています。
module Company
class Building::Floor < ApplicationRecord
belongs_to :building
end
end
問題
ここで問題となるのが、この状況ではActiveRecordがアソシエーション先のテーブル名とクラス名、そして関連付け先のモデル名をうまく推測してくれないということです。この状態で関連付けを呼び出そうとすると、「そんなカラム無ぇ!」と怒られます。
irb(main):002:0> Company::Building.first.floors
Company::Building Load (0.2ms) SELECT "company_buildings".* FROM "company_buildings" ORDER BY "company_buildings"."id" ASC LIMIT ? [["LIMIT", 1]]
Company::Building::Floor Load (0.6ms) SELECT "company_building_floors".* FROM "company_building_floors" WHERE "company_building_floors"."building_id" = ? [["building_id", 1]]
An error occurred when inspecting the object: #<ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: company_building_floors.building_id>
Company::Building::Floor Load (0.5ms) SELECT "company_building_floors".* FROM "company_building_floors" WHERE "company_building_floors"."building_id" = ? /* loading for inspect */ LIMIT ? [["building_id", 1], ["LIMIT", 11]]
An error occurred when running Kernel#inspect: #<ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: company_building_floors.building_id>
逆から呼び出すと、今度は何も帰ってきません。
irb(main):010:0> Company::Building::Floor.first.building
Company::Building::Floor Load (0.3ms) SELECT "company_building_floors".* FROM "company_building_floors" ORDER BY "company_building_floors"."id" ASC LIMIT ? [["LIMIT", 1]]
=> nil
解決策
このような状況では、モデルファイルのアソシエーションで、双方が class_name と foreign_key を明示的に指定する必要があります。
ちなみに、この場合 class_name だけではだめです。 foreign_key も一緒に指定する必要があります。
module Company
class Building < ApplicationRecord
has_many :floors, class_name: 'Company::Building::Floor', foreign_key: 'company_building_id'
end
end
module Company
class Building::Floor < ApplicationRecord
belongs_to :building, class_name: 'Company::Building', foreign_key: 'company_building_id'
end
end
なぜこのようになっているのか
このように、Moduleを挟むとActiveRecordはアソシエーション先のモデルを追ってくれなくなります。なぜそのようになっているのか、実際の動作とコードを見て確認してみます。
うまくいくとき、いかないときのアソシエーションを比較してみる
そもそも、うまくいく時といかないときでは内部で処理されるテーブル名や外部キーはどのようになっているのでしょうか。
Railsのアソシエーションは、 ActiveRecord::Reeflection というクラスで管理されています。このクラスで管理されているアソシエーションの実態は、アソシエーションを見たいクラスに対し reflect_on_association(association) クラスを呼び出すことで見ることができます(参考)。
うまくいくとき
irb(main):020:0> Company::Building.reflect_on_association(:floors)
=>
#<ActiveRecord::Reflection::HasManyReflection:0x000074903364af90
@active_record=Company::Building(id: integer, name: string, address: string, number_of_floors: integer, created_at: datetime, updated_at: datetime),
@active_record_primary_key="id",
@class_name="Company::Building::Floor",
@foreign_key="company_building_id",
@inverse_name=nil,
@klass=Company::Building::Floor(id: integer, name: string, level: integer, company_building_id: integer, created_at: datetime, updated_at: datetime),
@name=:floors,
@options={:class_name=>"Company::Building::Floor", :foreign_key=>"company_building_id"},
@plural_name="floors",
@scope=nil>
#<ActiveRecord::Reflection::BelongsToReflection:0x0000708d6851f8b8
@active_record=Company::Building::Floor(id: integer, name: string, level: integer, company_building_id: integer, created_at: datetime, updated_at: datetime),
@class_name="Company::Building",
@foreign_key="company_building_id",
@inverse_name=nil,
@klass=Company::Building(id: integer, name: string, address: string, number_of_floors: integer, created_at: datetime, updated_at: datetime),
@name=:building,
@options={:class_name=>"Company::Building", :foreign_key=>"company_building_id"},
@plural_name="buildings",
@scope=nil>
class_name と foreign_key を明示的に指定したことで、参照する先のActiveRecordのクラス名を表す @class_name と外部キー @foreign_key が適切に反映されていることがわかります。また、参照するクラスそのものである @klass も適切に反映されています。
うまくいかないとき
うまくいかないときのアソシエーションはこちらです。なお、一度アソシエーション先を呼び出さないと ActiveRecord::Reflection に必要な情報が入らないようですので、一度親クラスから子クラス、子クラスから親クラスへ呼び出しを行っています。この辺の挙動についてももう少し深堀りたかったですが、今回は割愛します。
# 一度 Company::Building.first.floorsと Company::Building::Floor.building を呼び出しています。
irb(main):021:0> Company::Building.reflect_on_association(:floors)
=>
#<ActiveRecord::Reflection::HasManyReflection:0x0000708d685057d8
@active_record=Company::Building(id: integer, name: string, address: string, number_of_floors: integer, created_at: datetime, updated_at: datetime),
@active_record_primary_key="id",
@class_name="Floor",
@foreign_key="building_id",
@inverse_name=:building,
@inverse_of=
#<ActiveRecord::Reflection::BelongsToReflection:0x0000708d684c1c18
@active_record=Company::Building::Floor(id: integer, name: string, level: integer, company_building_id: integer, created_at: datetime, updated_at: datetime),
@class_name="Building",
@foreign_key="building_id",
@inverse_name=nil,
@klass=Company::Building(id: integer, name: string, address: string, number_of_floors: integer, created_at: datetime, updated_at: datetime),
@name=:building,
@options={},
@plural_name="buildings",
@scope=nil>,
@klass=Company::Building::Floor(id: integer, name: string, level: integer, company_building_id: integer, created_at: datetime, updated_at: datetime),
@name=:floors,
@options={},
@plural_name="floors",
@scope=nil>
これを見てみると、
-
@class_nameはアソシエーション名をそのままキャメルケースにしただけで、名前空間まで適切に反映できていないことがわかります。 - 一度クエリをしたからか、
@klassは関連先のモデルクラスを適切に反映はしています。 - しかし、
@foreign_keyもアソシエーション名に_idを付けたものになっているため、外部キーが適切に設定されていないことがわかります。
このように、アソシエーション名だけを指定した場合、関連先のクラスや外部キーには名前空間が反映されていないことがわかります。
実際のソースコードを見てみる
では、これらの属性がソースコードでどのようになっているか調べてみましょう。 Activerecord::Reflection のソースコードは、GitHubの rails/activerecord/lib/active_record
/reflection.rb に定義されています。
@class_nameを決める場所
@class_name は、 AbstractReflection というすべてのReflectionクラスの親クラスのフィールドとして定義されています。コンストラクタでは値は入りませんが、 class_name メソッドを通じてクラス名を取得しようとしたときにはじめてフィールドに値が入るようです。
def class_name
@class_name ||= -(options[:class_name] || derive_class_name).to_s
end
ここで、オプションとして class_name: で直接指定したときにはその値を使い、そうでないときは derive_class_name でクラス名を推測していることがわかります。ではその derive_class_name メソッドを見に行きましょう。
def derive_class_name
name.to_s.camelize
end
これを見ると、 name からアソシエーション名を参照してキャメルケースに直しており、さきほど見た ActiveRecord::Reflection クラスの名前と一致します。
つまり、 class_name: を指定しなかった場合、にたとえアソシエーションに名前空間を意識したスネークケースの名前を入れたとしても、関連先のクラスが特定の名前空間に属していることを考慮してくれる機能は備わっていないことがわかります。これが名前空間内のクラス同士のアソシエーションで class_name: に名前空間を入れて指定しなければいけない理由になります。
@foreign_keyを決める場所
def foreign_key(infer_from_inverse_of: true)
@foreign_key ||= if options[:foreign_key]
if options[:foreign_key].is_a?(Array)
options[:foreign_key].map { |fk| -fk.to_s.freeze }.freeze
else
options[:foreign_key].to_s.freeze
end
elsif options[:query_constraints]
options[:query_constraints].map { |fk| -fk.to_s.freeze }.freeze
else
derived_fk = derive_foreign_key(infer_from_inverse_of: infer_from_inverse_of)
if active_record.has_query_constraints?
derived_fk = derive_fk_query_constraints(derived_fk)
end
if derived_fk.is_a?(Array)
derived_fk.map! { |fk| -fk.freeze }
derived_fk.freeze
else
-derived_fk.freeze
end
end
end
...
def derive_foreign_key(infer_from_inverse_of: true)
if belongs_to?
"#{name}_id"
elsif options[:as]
"#{options[:as]}_id"
elsif options[:inverse_of] && infer_from_inverse_of
inverse_of.foreign_key(infer_from_inverse_of: false)
else
active_record.model_name.to_s.foreign_key
end
end
外部キーは、このメソッドで決められています。ここでも、オプションやモデルクラスの本体で外部キーの名前が指定されていればそれを採用し、指定されていなければ derive_foreign_key メソッドから推測します。このメソッドの中では、 belongs_to のアソシエーションの場合、アソシエーション名に _id を付した名前が外部キーの名前として決定されます。やはり、moduleで名前空間を切った場合は考慮されません。
@klassを決める場所
def klass
@klass ||= _klass(class_name)
end
def _klass(class_name) # :nodoc:
if active_record.name.demodulize == class_name
return compute_class("::#{class_name}") rescue NameError
end
compute_class(class_name)
end
def compute_class(name)
name.constantize
end
@klass は下記の場所で決められています。関係先のクラスが格納される @active_record の名前からモジュール名を取り除き、さきほど示した class_name 、つまり自分で指定するか、アソシエーション名から推測したクラスの名前とあっていれば、トップ階層にあるクラスとして推測し、そうでなければ与えられた class_name をそのままクラスを示す定数に変換します。 class_name は先ほどのとおりモジュールを考慮しないので、やはりここでもモジュールを考慮してくれてはいないことがわかりますね。
まとめ
ActiveRecordのアソシエーションは関連先のモデルクラスを指定するのに便利ですが、このようにクラスを名前空間で区切ってしまうと、関連先のモデルを自動で推測してくれなくなります。そのため、Modelに明示的に class_name や foreign_key を書く記述が増えてしまうので厄介ではあります。
しかし、考えてみれば名前空間で区切らなければならないということは、アプリケーションで扱うモデルの数や構造が複雑ということが推測されます。例えば今回の例で取り上げた Building も、 Company::Building のがあれば House::Building という概念もほかの名前空間に存在しているかもしれません。このように似たような名前のクラスがある場合に、アソシエーションの関連先のクラスを勝手に推測されたら、かえってどのクラスと紐付いているのかわからなくて不安になるかもしれませんね。
結論としては、ActiveRecordがいろいろと推測してくれる機能は便利だけれども、それに頼り切らないようにして、書くべきことは自分で書いて適切に管理していくべき、ということですね。