LEFログ

:smile

Rails の find はどう動く? Active Recordの内部実装を見てみよう!

この記事はフィヨルドブートキャンプ Advent Calendar 2025の22日目の記事です。

昨日はodentakashiさんのgem の PR から学んだ ActiveRecord と lease_connection の話でした!

自分もおでんくんに引き続き、ActiveRecordの話題、いっちゃいます🍢

はじめに

皆さんはRuby on Rails使っていますか? Ruby on RailsにはActive RecordというORMがあります。

RailsのActive Recordを使う上で欠かせないのが、『Ruby on Railsガイド(以下Railsガイド)』です。この公式ドキュメントを読むことで、全体像を理解しながら体系的に学ぶことができます。

railsguides.jp

Ruby on Rails ガイド:体系的に Rails を学ぼう

Railsガイドには載っていないような細かい挙動を調べたいときもあります。そんなときは、『Ruby on Rails API』を調べるのが鉄板です。

api.rubyonrails.org

Ruby on Rails API

この2つのドキュメントを参考にするだけで、基本的には困らないのですが……もっと先へ進んでみたくないですか?

そうです。Ruby on Railsそのもののソースコードです。今回は find を取り上げて、このメソッドを読み解いていきましょう。

find ってそもそもなに?

まずはおさらいから。

ActiveRecord の find は 主キー検索 を行うためのメソッドです。

User.find(1)        # id=1 の User を取得
User.find([1, 2, 3]) # 複数レコードも取得可能

シンプルではありますが、内部ではキャッシュの利用、例外の扱い、SQL の組み立てなど様々な処理が行われます。

どこで定義されている?

では、findメソッドはどこで定義されているでしょうか?

GitHubにある rails/rails で調べてみましょう。

github.com

find メソッドは ActiveRecord の FinderMethods モジュールで定義されています。

具体的には次のファイルです。

それでもって、中のコードを読んでいくと、findが定義されています。

def find(*args)
  return super if block_given?
  find_with_ids(*args)
end

おっ、findは find_with_ids を呼んでいますね。今度はこのメソッドを見てみましょう。

  def find_with_ids(*ids)

    # 中略

    case ids.size
    when 0
      error_message = "Couldn't find #{model_name} without an ID"
      raise RecordNotFound.new(error_message, model_name, primary_key)
    when 1
      result = find_one(ids.first)
      expects_array ? [ result ] : result
    else
      find_some(ids)
    end
  end

このメソッドの最後の方を見ると、idsのサイズが0のときはエラー、1のときはfind_one、それ以外のときはfind_someを呼んでいます。

つまり、findfind_oneかfind_someを呼んでいる!ということです。*1

両方とも追いかけるとややこしいので、今回はidを一つしか渡さないと仮定して、find_oneを潜っていきましょう。

find_oneはこのように定義されています。

  def find_one(id)

    # 中略

    relation = if model.composite_primary_key?
      where(primary_key.zip(id).to_h)
    else
      where(primary_key => id)
    end

    record = relation.take

    raise_record_not_found_exception!(id, 0, 1) unless record

    record
  end

複合キーではない場合と仮定して、ものすごくざっくりと解説してみますが、つまり、whereメソッドで条件に合うものを検索して、そのなかから一つだけを take する。これがfind_oneがやっていることです。

whereはどうなってる?

つまりfindは内部的にwhereを呼んでいることが、Railsの実装を見ていくとわかります。

次にwhereを紹介していきます。*2

whereメソッドはこのファイルで定義されています。

具体的なコードだとここになります。

def where(*args)
  if args.empty?
    WhereChain.new(spawn)
  elsif args.length == 1 && args.first.blank?
    self
  else
    spawn.where!(*args)
  end
end

引数はempty?でもblank?でもないはずなので、とりあえずwhere!を見てみましょう。

def where!(opts, *rest) # :nodoc:
  self.where_clause += build_where_clause(opts, rest)
  self
end

どうやら、build_where_clauseっていうメソッドの結果を、どんどんオブジェクトに追加していっているらしいということがわかります。

build_where_clauseへ移動します。

  def build_where_clause(opts, rest = []) # :nodoc:
    opts = sanitize_forbidden_attributes(opts)

    if opts.is_a?(Array)
      opts, *rest = opts
    end

    case opts
    when String
      if rest.empty?
        parts = [Arel.sql(opts)]
      elsif rest.first.is_a?(Hash) && /:\w+/.match?(opts)
        parts = [build_named_bound_sql_literal(opts, rest.first)]
      elsif opts.include?("?")
        parts = [build_bound_sql_literal(opts, rest)]
      else
        parts = [Arel.sql(model.sanitize_sql([opts, *rest]))]
      end
    when Hash
      opts = opts.transform_keys do |key|
        if key.is_a?(Array)
          key.map { |k| model.attribute_aliases[k.to_s] || k.to_s }
        else
          key = key.to_s
          model.attribute_aliases[key] || key
        end
      end
      references = PredicateBuilder.references(opts)
      self.references_values |= references unless references.empty?

      parts = predicate_builder.build_from_hash(opts) do |table_name|
        lookup_table_klass_from_join_dependencies(table_name)
      end
    when Arel::Nodes::Node
      parts = [opts]
    else
      raise ArgumentError, "Unsupported argument type: #{opts} (#{opts.class})"
    end

    Relation::WhereClause.new(parts)
  end
  alias :build_having_clause :build_where_clause

一気に難しくなってきました。

この引数に、例えば文字列を渡すとします(User.where("age > 20")みたいなやつ)。すると、when Stringの条件に入ります。

    when String
      if rest.empty?
        parts = [Arel.sql(opts)]
      elsif rest.first.is_a?(Hash) && /:\w+/.match?(opts)
        parts = [build_named_bound_sql_literal(opts, rest.first)]
      elsif opts.include?("?")
        parts = [build_bound_sql_literal(opts, rest)]
      else
        parts = [Arel.sql(model.sanitize_sql([opts, *rest]))]
      end

するとparts = [Arel.sql(opts)]が呼ばれるはず。そして、[Arel.sql("age > 20")]がpartsの中に入ります。*3

最後にRelation::WhereClause.new(parts)が呼び出され、WhereClause オブジェクトが生成されます。

この WhereClause オブジェクトが、Relation オブジェクトの where_clause フィールドに+=でどんどん貯まっていき、最終的にはSQLに変換されて、実行されます。*4

SQLに変換される仕組みや、実行される仕組みまで解説すると、かなり長くなってしまうので、このあたりで止めたいと思います。

おわりに

いかがでしたでしょうか? 後半はちょっとむずかしくなってしまいましたが、前半は分かりやすかったと思います。

RailsRubyのコードでできています。なので、Rubyに慣れて親しんでいれば、Railsのコードは理解できるようになっています。

もし、公式ドキュメントなどを読んでも分からない挙動に遭遇した場合は、Railsそのもののソースコードを読んでみると、原因を特定できるかもしれません。*5

コードを読み解くことで、勉強になる面は多いですし、貢献できる可能性もあります。みなさんもぜひ、トライしてみてください!


明日のAdvent Calendarは ゆーかさんと mh-mobile さんです。🎄

*1:ブロック無しで ID を渡した一般的なケースにおいて

*2:コードが難しいので、自信がない箇所もあります。間違いがあったらごめんなさい。🐧

*3:Arelについては、この記事が詳しく解説していてオススメです。→https://www.timedia.co.jp/tech/activerecord-arel-5-1/

*4:自分の理解が間違っていなければ、WhereClause が積み重なった Relation が、最終的に Arel を通して SQL に変換されて、take や to_a などで DB に対して実行されるはずです。

*5:実際の業務でも、理解を深めるために元のソースコードを読む場面はたびたびありました。