RNIアドベントカレンダー1日目 railsアップグレードとのび犬問題

リサーチ・アンド・イノベーションの横山です。 アウトプットをさぼってさぼってもう5年ほどになりますが、うっかり「うちもアドベントカレンダーってやらないんですか?」と口走ってしまったため言い出しっぺの法則で記事を書くことになりました。 しばらくの間お付き合いください。

自己紹介

10年以上流しのエンジニアをしております。 いろいろな会社で働いてきましたが、ここRNIはお酒好きな人が多いのでやりたいことをやらせて貰えるのでなかなか居心地が良く、気が付けば5年ほどお世話になっています。 Rails2.0の頃からrailsで仕事をしており、趣味で書いていた頃も含めるとruby歴は20年以上になるでしょうか。 ハチドリ本も持っています。ハチドリだったの知ったのは最近ですけど。

プログラミング言語Ruby(2009)

railsアップグレードへの長い道のり

さて、稼働中のサービスあるあるですが、弊社のサービスも一部古いバージョンのrubyrailsで動いています。 アップグレードしたいと思いつつ、最近まで人手不足で手が出せませんでしたが、余裕ができたので今年大きく動き始めました。 最も懸念となっているのが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アップグレードで発生した現象

さて、これらを踏まえた上で、rubyrailsを交互にアップグレードして行きましたが、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#buildActiveRecord::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

@associationActiveRecord::Associations::HasManyAssociationインスタンスです。 ActiveRecord::Associations::HasManyAssociation#buildActiveRecord::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

superActiveRecord::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で動作しない理由

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 以外は条件に入らないようになっています。 predicatesArel::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にもバックポートされています)にアップグレードしなければならないので、卵が先か鶏が先か問題になってしまい、今回は使えませんでした。