18
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

RubyAdvent Calendar 2021

Day 11

【コードリーディング】Rails 1.0 ActiveRecordのソースコードを読んでみた

Last updated at Posted at 2021-10-31

#はじめに
Railsポートフォリオ作成でActiveRecordを使っており、作成当時は正直内部処理がどうなっているのか分かっていませんでし、気にもしていませんでした笑

そういった中、実務案件で直接SQLを書く機会があり、ActiveRecordって実際どういう風にSQLに置換して実行しているのか気になりはじめました。

そこで、ActiveRecordのソースコードでSQLに置き換えている仕組みの部分のコードを読んで理解した部分を書き留めたいと思います。

#今回読んだソースコード
今回はコードの流れをシンプルに見たかったので最新のソースコードではなくRails v1.0.0のソースコードを読みました。とは言ってもコードは約1800行もあり、読むのになかなか骨が折れました笑

その中でもController部分でよく使用するfindメソッドに絞って読んだものを記載します。
ソースコード元はこちら

#そもそもActiveRecordって?
ActiveRecordはRailsにおいてデータベースからデータを取り出したり、データベースにデータを保存したりする役割(MVCでいうMのModelの部分)を担っています。

ActiveRecordのメリットは主に以下の2点かなと感じております。
・簡易的な記述でSQL文を発行できる
・MySQL、PostgreSQLなど様々なデータベースに対し共通の記述で対応することが可能

O/Rマッパーと呼ばれるデータベースとプログラムを橋渡しする役目を担うものがActiveRecordに含まれているため、多様なデータベースからデータを取り出したり、データベースにデータを保存したりといった処理がスムーズに行うことができます。

#早速コードを読んでいく

active_record/base.rb/find
def find(*args)
  options = extract_options_from_args!(args)

  #省略

  case args.first
    when :first
      find(:all, options.merge(options[:include] ? { } : { :limit => 1 })).first
    when :all
      records = options[:include] ? find_with_associations(options) : find_by_sql(construct_finder_sql(options))
      records.each { |record| record.readonly! } if options[:readonly]
      records
    else
      return args.first if args.first.kind_of?(Array) && args.first.empty?
      expects_array = args.first.kind_of?(Array)
      
      conditions = " AND (#{sanitize_sql(options[:conditions])})" if options[:conditions]

      ids = args.flatten.compact.uniq
      case ids.size
        when 0
          raise RecordNotFound, "Couldn't find #{name} without an ID#{conditions}"
        when 1
          if result = find(:first, options.merge({ :conditions => "#{table_name}.#{primary_key} = #{sanitize(ids.first)}#{conditions}" }))
            return expects_array ? [ result ] : result
          else
            raise RecordNotFound, "Couldn't find #{name} with ID=#{ids.first}#{conditions}"
          end
        else
          ids_list = ids.map { |id| sanitize(id) }.join(',')
          result   = find(:all, options.merge({ :conditions => "#{table_name}.#{primary_key} IN (#{ids_list})#{conditions}"}))
          if result.size == ids.size
            return result
          else
            raise RecordNotFound, "Couldn't find all #{name.pluralize} with IDs (#{ids_list})#{conditions}"
          end
      end
  end
end

最初の部分はallやfirstなど、find内に記載した引数に対し条件分岐するコードが記載されていました。

はじめにextract_options!で可変長引数(柔軟な引数指定)を受け取れるメソッドを使い引数を受け取りoptionsへ代入

:all:firstを引数としており、:allの中でさらに分岐が行われている模様。

:firstの場合
inclued(モジュールを呼び出す)されなければ、optionsに{ :limit => 1 }を代入し、:all
id指定の場合はconditionsに"#{table_name}.#{primary_key} = #{id}"を追加し:all

:allでやっていること
:includeオプションありの場合find_with_associations(options)
:includeオプションなしの場合find_by_sql(construct_finder_sql(options))

今回はfind_by_sql(construct_finder_sql(options))を読み進めていきたいと思います。

##find_by_sqlメソッド

まずはfind_by_sql(construct_finder_sql(options))のfind_by_sqlメソッドから

active_record/base.rb/find_by_sql(sql)
def find_by_sql(sql)
  connection.select_all(sanitize_sql(sql), "#{name} Load").collect! { |record| instantiate(record) }
end

find_byは今でも残る重要なメソッドですよね!なんだか知ってるメソッドがあるだけで嬉しい気持ちになりました笑

find_byメソッドはid以外のカラムを検索条件としたい場合に使用しますが、まさにカラムをとってきて返している処理が書かれています。

ここで気になったのはsanitize_sqlinstantiate

###sanitize_sqlメソッド
公式ドキュメントはこちら

これによるとサニタイズした値を入れることによって意図しないSQLの挙動を防ぐことができるみたいですね。このメソッドはSQLインジェクション攻撃対策のために使用されています。

サニタイズってなに?

特別な意味を持つ文字の特別さを無効化する処理のこと

SQLインジェクションとは?

SQLインジェクションは、Webアプリケーションのパラメータを操作してデータベースクエリに影響を与えることを目的とした攻撃手法です。SQLインジェクションは、認証をバイパスする目的でよく使われます。他にも、データを操作したり任意のデータを読み出したりする目的にも使われます。

Rails セキュリティガイド - Railsガイド

不正な「SQL」の命令を注入し、意図しないSQL文を発行し攻撃すること。
例えばDELETE文を発行され、データを全て消されてしまうなど対策をしないとセキュリティ的に非常に危険。

###instantiate

instantiateは読み進めているとメソッドとして定義している箇所がありました。

active_record/base.rb/instantiate
def instantiate(record)
  object = 
    if subclass_name = record[inheritance_column]
      if subclass_name.empty?
        allocate
      else
        require_association_class(subclass_name)
        begin
          compute_type(subclass_name).allocate
        rescue NameError
          raise SubclassNotFound,
            "The single-table inheritance mechanism failed to locate the subclass: '#{record[inheritance_column]}'. " +
            "This error is raised because the column '#{inheritance_column}' is reserved for storing the class in case of inheritance. " +
            "Please rename this column if you didn't intend it to be used for storing the inheritance class " +
            "or overwrite #{self.to_s}.inheritance_column to use another column for that information."
        end
      end
    else
      allocate
    end
  object.instance_variable_set("@attributes", record)
  object
end

ほぼ読む必要はないがallocateによって取得したものが新しいレコードかを判断するメソッドと理解しました。レコードに保存されていないカラムや値の場合はエラーを吐き出す仕様になっている模様。

###find_by_sqlメソッド総括
総括してfind_by_sqlメソッドはサニタイズした値を入れ込む事によりSQLインジェクション攻撃を防ぎつつ、取ってきたレコードを返す。レコードはinstantiateにより既存のレコードかNewレコード化を判断し、場合によりエラーを吐き出す仕様になっていると言う感じですかね。

##construct_finder_sqlメソッド
続いてfind_by_sql(construct_finder_sql(options))のconstruct_finder_sqlメソッドについて読み進めました。

active_record/base.rb/construct_finder_sql
def construct_finder_sql(options)
  sql  = "SELECT #{options[:select] || '*'} FROM #{table_name} "
  add_joins!(sql, options)
  add_conditions!(sql, options[:conditions])
  sql << " GROUP BY #{options[:group]} " if options[:group]
  sql << " ORDER BY #{options[:order]} " if options[:order]
  add_limit!(sql, options)
  sql
end

ここは結構SQL文そのままって印象でした。用途によっていろんなSQL文を発行しテーブルからデータを参照している印象です。

##保存、更新
最後にfindとは関係なくなってしまいますがActiveRecordによる保存、更新処理部分を読み進めて終わりたいと思います。

###saveメソッド

active_record/base.rb/save
def save
  raise ActiveRecord::ReadOnlyRecord if readonly?
  create_or_update
end

saveメソッドによりcreateやupdateを呼び出しています。

###createメソッド

active_record/base.rb/save
def create
  if self.id.nil? and connection.prefetch_primary_key?(self.class.table_name)
    self.id = connection.next_sequence_value(self.class.sequence_name)
  end
  self.id = connection.insert(
    "INSERT INTO #{self.class.table_name} " +
    "(#{quoted_column_names.join(', ')}) " +
    "VALUES(#{attributes_with_quotes.values.join(', ')})",
    "#{self.class.name} Create",
    self.class.primary_key, self.id, self.class.sequence_name
  )
  @new_record = false
end

愚直にテーブル名、カラム名、プライマリーキー、idなどをSQLで挿入していますね。

###updateメソッド

active_record/base.rb/update
def update(id, attributes)
  if id.is_a?(Array)
    idx = -1
    id.collect { |id| idx += 1; update(id, attributes[idx]) }
  else
    object = find(id)
    object.update_attributes(attributes)
    object
  end
end

ここでidから検索しidとattributes(Modelの属性全て)を渡して更新しています。
検証中に失敗した場合はオブジェクトを返しています。

attributesについてはこちらが参考になりました。

##あとがき
今回は初めてOSS(オープンソースソフトウェア)を読んでみました。
ソースコードを辿ってみた結果愚直にSQLを発行していたと言うことがわかりました。

自分で作成したポートフォリオの比じゃないコード量を読み進めていくのは大変でしたが一つ一つメソッドを辿ってく内に理解していく感覚がすごく楽しかったです。

企業の自社サービスのコードはこれの比じゃないくらい膨大な記述量になっているとは思いますが、早くコードを読んでみたいと言う願望が高まっています。

これからも挙動の根幹をコードを読み進めて理解していきたいと思います。
最後までご覧いただきありがとうございました!!

18
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?