1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

名前空間を切ったActiveRecordモデルのアソシエーションで気を付けること

Posted at

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_nameforeign_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_nameforeign_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_nameforeign_key を書く記述が増えてしまうので厄介ではあります。

しかし、考えてみれば名前空間で区切らなければならないということは、アプリケーションで扱うモデルの数や構造が複雑ということが推測されます。例えば今回の例で取り上げた Building も、 Company::Building のがあれば House::Building という概念もほかの名前空間に存在しているかもしれません。このように似たような名前のクラスがある場合に、アソシエーションの関連先のクラスを勝手に推測されたら、かえってどのクラスと紐付いているのかわからなくて不安になるかもしれませんね。

結論としては、ActiveRecordがいろいろと推測してくれる機能は便利だけれども、それに頼り切らないようにして、書くべきことは自分で書いて適切に管理していくべき、ということですね。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?