RNIアドベントカレンダー1日目 railsアップグレードとのび犬問題
リサーチ・アンド・イノベーションの横山です。 アウトプットをさぼってさぼってもう5年ほどになりますが、うっかり「うちもアドベントカレンダーってやらないんですか?」と口走ってしまったため言い出しっぺの法則で記事を書くことになりました。 しばらくの間お付き合いください。
自己紹介
10年以上流しのエンジニアをしております。
いろいろな会社で働いてきましたが、ここRNIはお酒好きな人が多いのでやりたいことをやらせて貰えるのでなかなか居心地が良く、気が付けば5年ほどお世話になっています。
Rails2.0の頃からrailsで仕事をしており、趣味で書いていた頃も含めるとruby歴は20年以上になるでしょうか。
ハチドリ本も持っています。ハチドリだったの知ったのは最近ですけど。
railsアップグレードへの長い道のり
さて、稼働中のサービスあるあるですが、弊社のサービスも一部古いバージョンのrubyやrailsで動いています。 アップグレードしたいと思いつつ、最近まで人手不足で手が出せませんでしたが、余裕ができたので今年大きく動き始めました。 最も懸念となっているのがrails5.1で動いているサービスです。 ruby2.xがEOLとなったので(なる前からアップグレードは始めていたのですが)一刻も早くアップグレードしなくてはなりません。 中断を挟みつつ1年近くかけてようやく終わりが見えてきましたが、途中でぶつかった最も大きな壁について書いてみます。
背景
問題のサービスは(歴史的経緯から)別のサービスと密に結合しており、同じRDBMS上に2つのデータベースを置いて相互に読み書きできるようにしています。 以後はこれをサービスAlpha、サービスBravoとします。 AlphaのActiveRecordのクラスは素直に実装されており、基底クラスでデータベース名としてbravoを付加し、テーブル名にalphaを付加するようにしている以外、特に意識せず利用できるようになっています。
class Bravo < ApplicationRecord self.abstract_class = true establish_connection :"bravo_#{Rails.env}" class << self private def database_name Rails.configuration.database_configuration["bravo_#{Rails.env}"]['database'] end end end class Company < Bravo self.table_name = "#{database_name}.alpha_company" ... end
BravoからAlphaのクラスを使う時は、NamespaceにAlpha::を付加しています。
module Alpha def self.table_name_prefix 'alpha_' end end class Alpha::Company < ActiveRecord::Base ... end
これで名前空間を分けた状態で共有するモデルを扱えます。
polymorphic関連
この構造でbelongs_toやhas_manyなどrailsの関連はすべて透過的に使えるのですが、polymorphic関連だけ問題があります。 おさらいになりますが、polymorphic関連とは関連付けられるモデルのクラスを限定せず、規定のカラムを用意することで関連先のモデルを限定せずに関連付けられる仕組みのことです。 例えばMemberというクラスがCompanyまたはPartnerクラスに関連付けられる仕組み場合は以下のようにします。
class Company < ApplicaionRecord has_many :members, as: :employer end class Partner < ApplicaionRecord has_many :members, as: :employer end class Member < ApplicaionRecord belongs_to :employer, polymorphic: true end
Memberモデルにemployer_id, employer_typeというカラムを用意し、employer_typeに関連付けるモデルのクラス(CompanyまたはPartner)を入れると、いずれかのクラスに関連付けられ、partner.employerで呼び出すことができます。
名前空間を付与したクラスのpolymorphic関連
polymorphic関連はクラス名を指定する必要があるため、前述のnamespace付加と組み合わさると正しく動作しません。 そこでAlpha側に以下のようなコードが書かれていました。 (concerns以下に置かれ、必要なモデルでincludeしている)
module PolymorphicBravo module ClassMethods def has_many_polymorphic_bravo(relation_name, as:, **options) class_names = [name, "Alpha::#{name}"] has_many relation_name, -> { where("#{as}_type" => class_names) }, options.merge(foreign_key: "#{as}_id") end def has_one_polymorphic_bravo(relation_name, as:, **options) class_names = [name, "Alpha::#{name}"] has_one relation_name, -> { where("#{as}_type" => class_names) }, options.merge(foreign_key: "#{as}_id") end end end
has_many_polymorphic_bravo
が宣言されると
- has_manyを定義する
- has_manyのscopeとしてtypeカラムに
通常のクラス名 or Alpha::のついたクラス名
の条件を加える - foreign_keyを設定する
という動作をします。
railsアップグレードで発生した現象
さて、これらを踏まえた上で、rubyとrailsを交互にアップグレードして行きましたが、rails6.0にアップグレードしたところで上記のpolymorphic_bravoが動作しなくなりました。 具体的には
class Company has_many_polymorphic_bravo :members, as: :employer end class Member < ApplicaionRecord belongs_to :employer, polymorphic: true end
このようなモデルで company.members.build
した場合に、 employer_id: company.id, employer_type: 'Alpha::Company'
なMemberインスタンスが生成されなければなりませんが、rails6では employer_id: company.id, employer_type: nil
のインスタンスが生成されます。
そもそもなぜtypeカラムに値が入る(入っていた)のか?
問題解決のためにrailsのコードを追っかけます。 has_manyにscopeを指定している場合、buildでscopeに合った値が入りますが、その仕組みは以下のようなものでした。 (以下、rails5.0-stableのコードを例に取ります)
company.members#build
は ActiveRecord::Associations::CollectionProxy
で定義されている。
def build(attributes = {}, &block) @association.build(attributes, &block) end
@association
は ActiveRecord::Associations::HasManyAssociation
のインスタンスです。
ActiveRecord::Associations::HasManyAssociation#build
はActiveRecord::Associations::CollectionAssociation
で定義されています。
def build(attributes = {}, &block) if attributes.is_a?(Array) attributes.collect { |attr| build(attr, &block) } else add_to_target(build_record(attributes)) do |record| yield(record) if block_given? end end end
build_record
があからさまに怪しいです。
このメソッドは継承元の ActiveRecord::Associations::Association
クラスで定義されています。
def build_record(attributes) reflection.build_association(attributes) do |record| initialize_attributes(record, attributes) end end
initialize_attributes
も同クラス。
def initialize_attributes(record, except_from_scope_attributes = nil) #:nodoc: except_from_scope_attributes ||= {} skip_assign = [reflection.foreign_key, reflection.type].compact assigned_keys = record.changed assigned_keys += except_from_scope_attributes.keys.map(&:to_s) attributes = create_scope.except(*(assigned_keys - skip_assign)) record.assign_attributes(attributes) set_inverse_instance(record) end
はい、 create_scope
が怪しいですね。
こちらは ActiveRecord::Associations::CollectionAssociation
で定義されています。
def create_scope scope.scope_for_create.stringify_keys end
scopeは同クラスで
def scope scope = super scope.none! if null_scope? scope end
super
は ActiveRecord::Associations::Association#scope
なので
def scope target_scope.merge!(association_scope) end
target_scopeは同クラス。
def target_scope AssociationRelation.create(klass, klass.arel_table, klass.predicate_builder, self).merge!(klass.all) end
なんだか複雑なコードだけども、とりあえず AssociationRelation
のインスタンスを返していることが分かります。
create_scope
に戻って AssociationRelation#scope_for_create
を探すと、継承元の Relation
クラスに定義されていました。
https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/relation.rb
def scope_for_create @scope_for_create ||= where_values_hash.merge(create_with_value) end
where_values_hash
は同クラス。
def where_values_hash(relation_table_name = table_name) where_clause.to_h(relation_table_name) end
where_clause
は includeされたモジュール QueryMethods
でメタプログラミングされています。
Relation::CLAUSE_METHODS.each do |name| class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name}_clause # def where_clause @values[:#{name}] || new_#{name}_clause # @values[:where] || new_where_clause end # end # def #{name}_clause=(value) # def where_clause=(value) assert_mutability! # assert_mutability! @values[:#{name}] = value # @values[:where] = value end # end CODE end
とりあえず置いといて、 to_h
は一見組み込みメソッドだけど、requireされた ActiveRecord::Relation::WhereClause
でオーバライドされています。
def to_h(table_name = nil) equalities = predicates.grep(Arel::Nodes::Equality) if table_name equalities = equalities.select do |node| node.left.relation.name == table_name end end binds = self.binds.map { |attr| [attr.name, attr.value] }.to_h equalities.map { |node| name = node.left.name [name, binds.fetch(name.to_s) { case node.right when Array then node.right.map(&:val) when Arel::Nodes::Casted, Arel::Nodes::Quoted node.right.val end }] }.to_h end
詳細は省きますが、ここでscopeに与えられたwhere節がハッシュに変換され、 scope_for_create.stringify_keys
に渡ってbuildされるモデルのattributesに追加されています。
rails6で動作しない理由
rails6では上記の scope_for_create
のところが少し違って
https://github.com/rails/rails/blob/6-1-stable/activerecord/lib/active_record/relation.rb
def scope_for_create hash = where_clause.to_h(klass.table_name, equality_only: true) create_with_value.each { |k, v| hash[k.to_s] = v } unless create_with_value.empty? hash end
となっています。
ActiveRecord::Relation::WhereClause#to_h
も変更され
def to_h(table_name = nil, equality_only: false) equalities(predicates, equality_only).each_with_object({}) do |node, hash| next if table_name&.!= node.left.relation.name name = node.left.name.to_s value = extract_node_value(node.right) hash[name] = value end
となりました。
equalities
はメソッドに切り出され
def equalities(predicates, equality_only) equalities = [] predicates.each do |node| if equality_only ? Arel::Nodes::Equality === node : equality_node?(node) equalities << node elsif node.is_a?(Arel::Nodes::And) equalities.concat equalities(node.children, equality_only) end end equalities end
になっています。
詳細は省きますが、where_clauseノードが Arel::Nodes::Equality
もしくは Arel::Nodes::And
以外は条件に入らないようになっています。
predicates
は Arel::Nodes::*
クラスの配列で、 クエリを構造体にしたものが入っています。
そして、 通常の(=で比較する)WHERE条件は Arel::Nodes::Equality
クラスですが、 IN句 は Arel::Nodes::Casted
クラスになります。
つまり、rails5では Arel::Nodes::Casted
クラスもscope_for_createのハッシュに格納されていましたが、 6では弾かれるようになりました。
これがbuildでtypeカラムがnilになる原因でした。
どうしてそうなった?
バグ修正 です。 この修正が入ったのは PR#41319。
If a scope has IN cluase, scope_for_create which is passed to assign_attributes will include array values, and it will cause weird behaviors.
大変ごもっともです。だっておかしいもん。
IN句に与えられた配列がハッシュの値として格納された結果、配列の最初の値が初期値として与えられていました。
つまり has_many_polymorphic_bravo
はこのバグによって一見正常に動作していたに過ぎなかったのです。
めでたしめでたし。
めでたくない。これを再実装するのは困難を極め、結局データ構造を大幅に変更してrails標準に近づけることになりました。 モンキーパッチはアップグレードの敵なのでやめましょう。
余談1 ところで「のび犬」って?
余談ですが、このように どちらでも動作するように配慮してしまう ことを個人的に のび犬問題 と呼んでいます。 「ドラえもん」で のび太が「太」の漢字の点が上だったか下だったか分からなくなり、両方に打った という故事(てんとう虫コミックス23巻「透視シールで大ピンチ」)に由来します。 多分他に誰も呼んでないと思う。 プログラミングをする時には曖昧さはできるだけ排除するべきである、という教訓でした。
余談2 調査方法
継承やメタプログラミングが多用されるrailsでは、コードを追っていくのが難しいですね。 今回はrails console+pry byebugを活用して追跡しました。 例えばrails console上で以下のようにして、コンテキストを切り替えて深く潜っていくことができます。
> company = company.new > cd company #=> コンテキストがcompanyに切り替わる > members #=> company.membersを返す > @name #=> インスタンス変数の中身も見れる
メソッドの定義を探すには以下のようにします。
> company.members.method(:build).source_location #=> .../versions/2.6.3/lib/ruby/gems/2.6.0/gems/activerecord-5.0.7.2/lib/active_record/associations/collection_association.rb"
これらを上手く使えば、変数やメソッドの戻り値を参照しつつコードを追っていくことができます。
また、debug gemを使えばコードにブレークポイントを埋め込まずとも、コマンドラインオプションで任意の場所にブレークポイントを仕込むことができます。railsの任意の地点に潜っていくことができるので非常に便利です。 が、これを使うためにはruby3.0+(2.7にもバックポートされています)にアップグレードしなければならないので、卵が先か鶏が先か問題になってしまい、今回は使えませんでした。