この記事はリサーチ・アンド・イノベーション 開発者ブログ のアドベントカレンダーに掲載した記事を一部手直ししたものです。
さて、稼働中のサービスあるあるですが、弊社のサービスも一部古いバージョンの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
これで名前空間を分けた状態で共有するモデルを扱えます。
この構造で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関連はクラス名を指定する必要があるため、前述の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 が宣言されると
通常のクラス名 or Alpha::のついたクラス名 の条件を加えるという動作をします。
さて、これらを踏まえた上で、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 のインスタンスが生成されます。
問題解決のためにrailsのコードを追っかけます。 has_manyにscopeを指定している場合、buildでscopeに合った値が入りますが、その仕組みは以下のようなものでした。 (以下、rails5.0-stableのコードを例に取ります)
company.members#build は ActiveRecord::Associations::CollectionProxy で定義されている。
https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/collection_proxy.rb#L292
def build(attributes = {}, &block)
@association.build(attributes, &block)
end
@association は ActiveRecord::Associations::HasManyAssociation のインスタンスです。
ActiveRecord::Associations::HasManyAssociation#build はActiveRecord::Associations::CollectionAssociation で定義されています。
https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/collection_association.rb
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 クラスで定義されています。
https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/association.rb
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 で定義されています。
https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/associations/collection_association.rb
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 でメタプログラミングされています。
https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/relation/query_methods.rb
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 でオーバライドされています。
https://github.com/rails/rails/blob/5-0-stable/activerecord/lib/active_record/relation/where_clause.rb
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では上記の 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標準に近づけることになりました。 モンキーパッチはアップグレードの敵なのでやめましょう。
余談ですが、このように どちらでも動作するように配慮してしまう ことを個人的に のび犬問題 と呼んでいます。 「ドラえもん」で のび太が「太」の漢字の点が上だったか下だったか分からなくなり、両方に打った という故事(てんとう虫コミックス23巻「透視シールで大ピンチ」)に由来します。 多分他に誰も呼んでないと思う。 プログラミングをする時には曖昧さはできるだけ排除するべきである、という教訓でした。
継承やメタプログラミングが多用される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にもバックポートされています)にアップグレードしなければならないので、卵が先か鶏が先か問題になってしまい、今回は使えませんでした。