📚

ActiveRecord の 不具合修正 PR を読み解く

21 min read

ActiveRecord の 不具合修正 PR を読み解く

こんにちは, いっせいです。

先日すんさんが、Rails ActiveRecord includesとselect別名カラムについて という記事を公開してくれました。
「ActiveRecord includesを使う際、selectの別名カラムを参照できなくなる事象について」という内容でした。
よろしければそちらをまずご覧ください。

その記事では不具合を解消したPRが紹介されていました

https://github.com/rails/rails/pull/35210

実は以前私も同じような現象に遭遇したことがあり、上記のPRで解決することは知っていました。
しかしPRのコードを見ただけでは「なぜこれで解決するのだろう?」というのがわからないままでした。

今回はその実際に簡単なクエリを書いて実行過程を見ていくことで、そのPRを理解しようという内容です。

結構長くなります :bow:

該当の修正箇所

該当の修正箇所はこちらです。

activerecord/lib/active_record/associations/join_dependency.rb

 def instantiate(result_set, &block)
        primary_key = aliases.column_alias(join_root, join_root.primary_key)
        seen = Hash.new { |i, object_id|
          i[object_id] = Hash.new { |j, child_class|
            j[child_class] = {}
          }
        }

        model_cache = Hash.new { |h, klass| h[klass] = {} }
        parents = model_cache[join_root]
+
        column_aliases = aliases.column_aliases join_root
+       column_aliases += explicit_selections(column_aliases, result_set)

        message_bus = ActiveSupport::Notifications.instrumenter

	@@ -135,6 +137,13 @@ def apply_column_aliases(relation)
      private
        attr_reader :alias_tracker

+       def explicit_selections(root_column_aliases, result_set)
+         root_names = root_column_aliases.map(&:name).to_set
+         result_set.columns
+           .reject { |n| root_names.include?(n) || n =~ /\At\d+_r\d+\z/ }
+           .map { |n| Aliases::Column.new(n, n) }
+       end

ここだけ見てもなぜ修正できるのかわかりませんでした。

環境

Ruby: 2.6.5
Rails: 5.2.4.5

ということでバグ修正前の環境です。

実験したコード

CustomerDepartment に属するとします。

簡単に書くと以下のような関係です。

class Customer
  belongs_to :department
end

class Department
  has_many :customers
end

そして以下のコードを rails runner で実行し読んでいきます。

binding.pry

puts Customer.eager_load(:department).select('1 as "foo"').first.foo

なお気になったメソッドをピックアップしているため、適宜メソッドはスキップしています :bow:

コードリーディング

eager_load は以下からはじまります。

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/querying.rb#L12-L14
  delegate :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or,
            :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending,
            :having, :create_with, :distinct, :references, :none, :unscope, :merge, to: :all

一旦 all が実行され、以下に移ります

https://github.com/rails/rails/blob/8bec77cc0f1fd47677a331a64f68c5918efd2ca9/activerecord/lib/active_record/relation/query_methods.rb#L133
def eager_load(*args)
  check_if_method_has_arguments!(:eager_load, args)
  spawn.eager_load!(*args)
end

1行目では引数のチェックをしています。

https://github.com/rails/rails/blob/8bec77cc0f1fd47677a331a64f68c5918efd2ca9/activerecord/lib/active_record/relation/query_methods.rb#L134

2行目の eager_load! は以下のようになっています。

https://github.com/rails/rails/blob/8bec77cc0f1fd47677a331a64f68c5918efd2ca9/activerecord/lib/active_record/relation/query_methods.rb#L138-L141
def eager_load!(*args) # :nodoc:
  self.eager_load_values += args
  self
end

eager_load_values という変数に eager_load させるための association 文字列を代入しているだけです。

個人的には「え?それだけしかしてないの?」とびっくりでした。
実際に department などはどこで取得するのでしょうか。

次に select が実行されました

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation/query_methods.rb#L220-L231
def select(*fields)
  if block_given?
    if fields.any?
      raise ArgumentError, "`select' with block doesn't take arguments."
    end

    return super()
  end

  raise ArgumentError, "Call `select' with at least one field" if fields.empty?
  spawn._select!(*fields)
end

今回はブロックは渡しておらず、引数もちゃんとあるため _select! が実行されます

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation/query_methods.rb#L233-L237
def _select!(*fields) # :nodoc:
  fields.flatten!
  self.select_values += fields
  self
end

Customer::ActiveRecord_Relation という self の select_values に 引数の ["1 as \"foo\""] を代入しています

それがおわると first が実行されます

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation/finder_methods.rb#L121
def first(limit = nil)
  if limit
    find_nth_with_limit(0, limit)
  else
    find_nth 0
  end
end

今回は limit を指定していないので find_nth 0が実行されます。
first(3) のような使い方ができることを初めて知りました。

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation/finder_methods.rb#L517
def find_nth(index)
  @offsets[offset_index + index] ||= find_nth_with_limit(index, 1).first
end
[1] pry(#<Customer::ActiveRecord_Relation>)> @offsets[offset_index + index]
=> nil

なので find_nth_with_limit に処理が移ります。

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation/finder_methods.rb#L520-L537
def find_nth_with_limit(index, limit)
  if loaded?
    records[index, limit] || []
  else
    relation = ordered_relation

    if limit_value
      limit = [limit_value - index, limit].min
    end

    if limit > 0
      relation = relation.offset(offset_index + index) unless index.zero?
      relation.limit(limit).to_a
    else
      []
    end
  end
end

引数はそれぞれ indexは 0, limit は 1 です。

loaded?false でした。

ordered_relation は今回はprimary_keyでソートするというのを order_values に入れています。

limit_value は 1 でした。

なのでここでは結局
relation.limit(limit).to_a が実行されます。

そろそろお気づきの方もいるかもしれませんが、limitlimit_value に 1 を代入しています。

to_ato_aryalias になっています

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation.rb#L194-L197
def to_ary
  records.dup
end
alias to_a to_ary

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation.rb#L199-L202
def records # :nodoc:
  load
  @records
end

ここでやっとデータベースからデータが読み出されようとします

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation.rb#L415-L425
def load(&block)
  exec_queries(&block) unless loaded?

  self
end

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation.rb#L546-L576
def exec_queries(&block)
  skip_query_cache_if_necessary do
    @records =
      if eager_loading?
        apply_join_dependency do |relation, join_dependency|
          if ActiveRecord::NullRelation === relation
            []
          else
            relation = join_dependency.apply_column_aliases(relation)
            rows = connection.select_all(relation.arel, "SQL")
            join_dependency.instantiate(rows, &block)
          end.freeze
        end
      else
        klass.find_by_sql(arel, &block).freeze
      end

    preload = preload_values
    preload += includes_values unless eager_loading?
    preloader = nil
    preload.each do |associations|
      preloader ||= build_preloader
      preloader.preload @records, associations
    end

    @records.each(&:readonly!) if readonly_value

    @loaded = true
    @records
  end
end

いよいよ本丸感ありますね。

skip_query_cache_if_necessaryはまだキャッシュされていないのでブロックが実行されます

eager_loading?eager_load で代入した値の有無をみています。もちろん true です

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation/finder_methods.rb#L385-L402
def apply_join_dependency(eager_loading: group_values.empty?)
  join_dependency = construct_join_dependency
  relation = except(:includes, :eager_load, :preload).joins!(join_dependency)

  if eager_loading && !using_limitable_reflections?(join_dependency.reflections)
    if has_limit_or_offset?
      limited_ids = limited_ids_for(relation)
      limited_ids.empty? ? relation.none! : relation.where!(primary_key => limited_ids)
    end
    relation.limit_value = relation.offset_value = nil
  end

  if block_given?
    yield relation, join_dependency
  else
    relation
  end
end

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/relation/finder_methods.rb#L378-L383
def construct_join_dependency
  including = eager_load_values + includes_values
  ActiveRecord::Associations::JoinDependency.new(
    klass, table, including
  )
end

ここで変数につめたeager_loadの値が使われます。

apply_join_dependency の 4行目 の!using_limitable_reflections?(join_dependency.reflections)false なので割愛します

ブロックが渡されているので13行目が実行されます。

    546:       def exec_queries(&block)
    547:         skip_query_cache_if_necessary do
    548:           @records =
    549:             if eager_loading?
    550:               apply_join_dependency do |relation, join_dependency|
 => 551:                 if ActiveRecord::NullRelation === relation
    552:                   []
    553:                 else
    554:                   relation = join_dependency.apply_column_aliases(relation)
    555:                   rows = connection.select_all(relation.arel, "SQL")
    556:                   join_dependency.instantiate(rows, &block)

ここに戻ってきます。

if の条件は false なので554行目に移ります。

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/associations/join_dependency.rb#L126-L128
    126: def apply_column_aliases(relation)
 => 127:   relation._select!(-> { aliases.columns })
    128: end

また select です。しかし今回は引数が lambda です。
該当部分の _select! が終わるところでは以下のようになっています。

    233: def _select!(*fields) # :nodoc:
    234:   fields.flatten!
    235:   self.select_values += fields
 => 236:   self
    237: end

[3] pry(#<Customer::ActiveRecord_Relation>)> self.select_values
=> ["1 as \"foo\"", #<Proc:0x00007f1de73e4160@/bundle/gems/activerecord-5.2.4.5/lib/active_record/associations/join_dependency.rb:127 (lambda)>]

まだ指定した1 as "foo"は残っていますね。

555行目が実行されます。

    555:                   relation = join_dependency.apply_column_aliases(relation)
 => 556:                   rows = connection.select_all(relation.arel, "SQL")
    557:                   join_dependency.instantiate(rows, &block)

ここで実行されるSQLは

[5] pry(#<Customer::ActiveRecord_Relation>)> relation.arel.to_sql
=> "SELECT  1 as \"foo\", \"customers\".\"id\" AS t0_r0, \"customers\".\"email\" AS t0_r1, (中略), \"departments\".\"id\" AS t1_r0, \"departments\".\"display_name\" AS t1_r1, \"departments\".\"uuid\" AS t1_r2, \"departments\".\"created_at\" AS t1_r3, \"departments\".\"updated_at\" AS t1_r4, \"departments\".\"deleted_at\" AS t1_r5, \"departments\".\"branch_id\" AS t1_r6, \"departments\".\"customers_count\" AS t1_r7 FROM \"customers\" LEFT OUTER JOIN \"departments\" ON \"departments\".\"deleted_at\" IS NULL AND \"departments\".\"id\" = \"customers\".\"department_id\" WHERE \"customers\".\"deleted_at\" IS NULL ORDER BY \"customers\".\"id\" ASC LIMIT $1"

と eager_load でよくみるクエリとなっています。ちゃんと指定した 1 as "foo" も残っています。
戻ってきた値にも存在しています。

[6] pry(#<Customer::ActiveRecord_Relation>)> rows
=> #<ActiveRecord::Result:0x00007f1de722bbe8
 @column_types=
  {"foo"=>#<ActiveModel::Type::Integer:0x00007f1de4b7f7d8 @limit=4, @precision=nil, @range=-2147483648...2147483648, @scale=nil>,
   "t0_r0"=>#<ActiveModel::Type::Integer:0x00007f1de4b7f7d8 @limit=4, @precision=nil, @range=-2147483648...2147483648, @scale=nil>,
   "t0_r1"=>#<ActiveModel::Type::String:0x00007f1de6ea8c28 @limit=nil, @precision=nil, @scale=nil>,
   "t0_r2"=>#<ActiveModel::Type::String:0x00007f1de6ea8c28 @limit=nil, @precision=nil, @scale=nil>,
   "t0_r3"=>#<ActiveModel::Type::String:0x00007f1de6ea8c28 @limit=nil, @precision=nil, @scale=nil>,
(後略)

そして PR で修正されたメソッドが実行されます。

    552:                 if ActiveRecord::NullRelation === relation
    553:                   []
    554:                 else
    555:                   relation = join_dependency.apply_column_aliases(relation)
    556:                   rows = connection.select_all(relation.arel, "SQL")
 => 557:                   join_dependency.instantiate(rows, &block)
    558:                 end.freeze
    559:               end
    560:             else
    561:               klass.find_by_sql(arel, &block).freeze
    562:             end

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/associations/join_dependency.rb#L95-L124
      def instantiate(result_set, &block)
        primary_key = aliases.column_alias(join_root, join_root.primary_key)

        seen = Hash.new { |i, object_id|
          i[object_id] = Hash.new { |j, child_class|
            j[child_class] = {}
          }
        }

        model_cache = Hash.new { |h, klass| h[klass] = {} }
        parents = model_cache[join_root]
        column_aliases = aliases.column_aliases join_root

        message_bus = ActiveSupport::Notifications.instrumenter

        payload = {
          record_count: result_set.length,
          class_name: join_root.base_klass.name
        }

        message_bus.instrument("instantiation.active_record", payload) do
          result_set.each { |row_hash|
            parent_key = primary_key ? row_hash[primary_key] : row_hash
            parent = parents[parent_key] ||= join_root.instantiate(row_hash, column_aliases, &block)
            construct(parent, join_root, row_hash, result_set, seen, model_cache, aliases)
          }
        end

        parents.values
      end

block_given?false でした。

メソッドの1行目の primary_key に代入するメソッドを見ていきます。

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/associations/join_dependency.rb#L134-L141
def aliases
  @aliases ||= Aliases.new join_root.each_with_index.map { |join_part, i|
    columns = join_part.column_names.each_with_index.map { |column_name, j|
      Aliases::Column.new column_name, "t#{i}_r#{j}"
    }
    Aliases::Table.new(join_part, columns)
  }
end

このメソッドでテーブルのカラム名とt0_r1のようなクエリで発行した別名との対応テーブルを作っていました


    33: def column_alias(node, column)
 => 34:   @alias_cache[node][column]
    35: end

pry(#<ActiveRecord::Associations::JoinDependency::Aliases>)> self
=>(中略)
    {"id"=>"t0_r0",
     "email"=>"t0_r1",
     "encrypted_password"=>"t0_r2",
     "reset_password_token"=>"t0_r3",
     "reset_password_sent_at"=>"t0_r4",
     "remember_created_at"=>"t0_r5",
     "sign_in_count"=>"t0_r6",
     "current_sign_in_at"=>"t0_r7",
     "last_sign_in_at"=>"t0_r8",
     "current_sign_in_ip"=>"t0_r9",
     "last_sign_in_ip"=>"t0_r10",
     "last_activities_read_at"=>"t0_r11",
     "uuid"=>"t0_r12",
     (後略)

column_alias メソッドは対応テーブルを取得して、指定したカラムの対応名を取ってきています

@alias_cache は aliases メソッドの中で Aliases インスタンスを生成する時に代入されていました。

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/associations/join_dependency.rb#L12-L16

メソッドの3行目のseenは今回は空のHashを生成するだけでした。
model_cache, parents も同様に空のHashを生成しています。

    101:           }
    102:         }
    103:
    104:         model_cache = Hash.new { |h, klass| h[klass] = {} }
    105:         parents = model_cache[join_root]
 => 106:         column_aliases = aliases.column_aliases join_root
    107:
    108:         message_bus = ActiveSupport::Notifications.instrumenter
    109:
    110:         payload = {
    111:           record_count: result_set.length,

ここは上でみた通り対応表を column_aliases に代入しています。

PRはこのcolumn_aliasesに追加で代入をしていました。
ではこのcolumn_aliasesはどこで使われるのでしょうか?
その数行下の

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/associations/join_dependency.rb#L118
    115:         message_bus.instrument("instantiation.active_record", payload) do
    116:           result_set.each { |row_hash|
    117:             parent_key = primary_key ? row_hash[primary_key] : row_hash
    118:            binding.pry
 => 119:             parent = parents[parent_key] ||= join_root.instantiate(row_hash, column_aliases, &block)
    120:             construct(parent, join_root, row_hash, result_set, seen, model_cache, aliases)
    121:           }
    122:         end
    123:
    124:         parents.values

こちらで使われていました。

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/associations/join_dependency/join_part.rb#L65-L67
    65: def instantiate(row, aliases, &block)
 => 66:   base_klass.instantiate(extract_record(row, aliases), &block)
    67: end

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/associations/join_dependency/join_part.rb#L48-L63
ここでaliasと実際の取得したレコードの値との紐付けを行います
ActiveRecord::Associations::JoinDependency::JoinPart#extract_record:

    48: def extract_record(row, column_names_with_alias)
    49:   # This code is performance critical as it is called per row.
    50:   # see: https://github.com/rails/rails/pull/12185
 => 51:   hash = {}
    52:
    53:   index = 0
    54:   length = column_names_with_alias.length
    55:
    56:   while index < length
    57:     column_name, alias_name = column_names_with_alias[index]
    58:     hash[column_name] = row[alias_name]
    59:     index += 1
    60:   end
    61:
    62:   hash
    63: end

そしてインスタンス化しています。

https://github.com/rails/rails/blob/5-2-stable/activerecord/lib/active_record/persistence.rb#L58-L72

    68: def instantiate(attributes, column_types = {}, &block)
 => 69:   klass = discriminate_class_for_record(attributes)
    70:   attributes = klass.attributes_builder.build_from_database(attributes, column_types)
    71:   klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block)
    72: end

column_aliasesには `1 as "foo"が含まれていないのでインスタンス化する際には除外されてしまっていたんですね。

やっと原因を理解することができました。

それを踏まえてPRの修正を見ていきます。

再掲します

 def instantiate(result_set, &block)
        primary_key = aliases.column_alias(join_root, join_root.primary_key)
        seen = Hash.new { |i, object_id|
          i[object_id] = Hash.new { |j, child_class|
            j[child_class] = {}
          }
        }

        model_cache = Hash.new { |h, klass| h[klass] = {} }
        parents = model_cache[join_root]
+
        column_aliases = aliases.column_aliases join_root
+       column_aliases += explicit_selections(column_aliases, result_set)

        message_bus = ActiveSupport::Notifications.instrumenter

	@@ -135,6 +137,13 @@ def apply_column_aliases(relation)
      private
        attr_reader :alias_tracker

+       def explicit_selections(root_column_aliases, result_set)
+         root_names = root_column_aliases.map(&:name).to_set
+         result_set.columns
+           .reject { |n| root_names.include?(n) || n =~ /\At\d+_r\d+\z/ }
+           .map { |n| Aliases::Column.new(n, n) }
+       end

column_aliases に対応表に乗っていないものを探し、対応表のカラムとして追加しているんですね。

やっと理解できました :bulb:

終わりに

長くなりましたがPRと元のActiveRecordのソースコードを読んでいきました。

数行の修正でも理解するにはこれだけの量を読み込む必要があって大変でした。
しかしその過程でActiveRecordの仕組みの一端を垣間見ることができ、
また first(3) のような自分が知らなかったメソッドの使い方を知ることができました。
ActiveRecordの知識を増やすことができ、大変有益だったと思います。

外に出れない状況が続きますが、その時間でActiveRecordのコードを読むのはおすすめです!

ではまた!