ActiveRecordを支える技術 – Arelとは何者なのか?(全5回) その2


2014年 05月 04日

今回は、以下のような単純なSQLをarelで生成してみます。

select * from products;

Arelで書くと以下のようになります。

product = Arel::Table.new(:products)
product.project('*').to_sql # select * from products;

上記コードでなぜ

select * from products;

というsqlが生成されるのかを見ていきましょう。

以下、目次となります。

単純なSQL文を生成 – Arel::Tableを読む

まずはじめ。Arel::Tableです。ここからスタートです。
ということで、lib/arel/table.rb から読み進めることとしましょう。

こいつはproject (projection、つまり射影のこと) という関数を持っているみたいです。

この関数の定義を見てみましょう。
lib/arel/table.rb 内に定義されているようです。

  def project *things
    from(self).project(*things)
  end

projectまた呼び出してる。。。無限ループ?

from関数も lib/arel/table.rb に定義されているようです。

  def from table
    SelectManager.new(@engine, table)
  end

SelectManagerというクラス返してますね。
ということは、先ほどのproject関数内で呼び出してるprojectはArel::Tableのprojectではなく、SelectManagerのprojectということになります。

ためしにpryで確認してみます。

[4] pry(main)> product.class
=> Arel::Table
[5] pry(main)> product.project('*').class
=> Arel::SelectManager
[6] pry(main)> product.project('*').where('id = 3').class
=> Arel::SelectManager

もうArel::Tableの出番は終わったようです。
ここからはArel::SelectManagerのソースを読んでいく事とします。

単純なSQL文を生成 – Arel::SelectManagerを読む

SelectManager.rbは lib/arel/select_manager.rbにあります。

# 一部コメント、コードを略しています
module Arel
  class SelectManager < Arel::TreeManager
    include Arel::Crud

    STRING_OR_SYMBOL_CLASS = [Symbol, String]

    def initialize engine, table = nil
      super(engine)
      @ast   = Nodes::SelectStatement.new
      @ctx    = @ast.cores.last
      from table
    end

    def from table
      table = Nodes::SqlLiteral.new(table) if String === table
      case table
      when Nodes::Join
        @ctx.source.right << table
      else
        @ctx.source.left = table
      end
      self
    end

    def project *projections
      @ctx.projections.concat projections.map { |x|
        STRING_OR_SYMBOL_CLASS.include?(x.class) ? Nodes::SqlLiteral.new(x.to_s) : x
      }
      self
    end
end

SelectManagerはArel::TreeManager というクラスを継承しているようです。
ツリーマネージャ? 木構造か何かを持っているんでしょうか。
@ast は AST(abstract syntax tree, 抽象構文木)の事? なにやら内部で構文木を作っていそうな雰囲気です。

まずは initializeから読んでいきます。
ast, ctxは置いといて、最初にfrom関数を呼び出しています。

from関数内、一番はじめの以下の部分。

table = Nodes::SqlLiteral.new(table) if String === table

tableは先ほどのArel::Tableのことです。String型ではないので、この行は実行されませんね。

ということは、その下のコード。

 @ctx.source.left = table

が呼ばれることとなります。source.left にテーブルを入れる?
@ast, @ctxが何者かを見ていく必要がありますね。pryでのぞいてみましょう。

[13] pry(#<Arel::SelectManager>)> @ast
=> #<Arel::Nodes::SelectStatement:0x007fb1f360cb68
 @cores=
  [#<Arel::Nodes::SelectCore:0x007fb1f360ca28
    @groups=[],
    @having=nil,
    @projections=[],
    @set_quantifier=nil,
    @source=#<Arel::Nodes::JoinSource:0x007fb1f360c780 @left=nil, @right=[]>,
    @top=nil,
    @wheres=[],
    @windows=[]>],
 @limit=nil,
 @lock=nil,
 @offset=nil,
 @orders=[],
 @with=nil>

@ctx は @ast.cores.last と同じです。

coresは Arel::Node::SelectCoreというクラスのようです。
group, having, projections, source 等の変数を持っているようです。

SelectCore内のsourceはArel::Nodes::JoinSourceというクラスのようです。
left, rightという変数を持っているようですね。

ここのleftに、先ほどのTableを入れているようです。
図にしてみると、以下のような感じでしょうか。

01-4e8cce1c-e4a9-f2ae-dd5a-105ffcc5a844.png

SelectManagerクラスのinitializeとfromの動きが分かりました。

次は本題。project関数です。

    def project *projections
      @ctx.projections.concat projections.map { |x|
        STRING_OR_SYMBOL_CLASS.include?(x.class) ? Nodes::SqlLiteral.new(x.to_s) : x
      }
      self
    end

要は@ctx( @ctxは@astの@cores[0] の事)のprojections変数に Nodes::SqlLiteral型のクラスを入れているようです。

ここでいう x は

product.project('*')

の ‘*’ の部分ですね。この * という文字をSqlLiteral型でラップしているようです。

先ほどのように図にしてみます。

02-revise-45a86e02-508f-a2a3-0c51-aaaa3506f663.png

project関数は副作用のある操作のようです。呼び出すと自身の@ctxのprojections変数に値を入れてますもんね。
そして、自分自身(self)を最後に返します。selfはSelectManagerです。

単純なSQL文を生成 – projectをチェーンしてみる

project関数は自身の@ctx, projections変数に指定した値を追加後、自分自身を返しています。
自分自身を返している、ということはメソッドチェーンできますね。

例えば、以下のようなコードが書けます。

product = Arel::Table.new(:products)
product.project('id').project('name').to_sql # "SELECT id, name FROM `products`"

上記のコード実行後は、SelectManager内の@cores[0]の状態は以下のようになります。
03-revise-2c44fd94-4793-114d-92ec-0c07341c7d8b.png

単純なSQL文を生成 – to_sqlでSQLを生成する

最後はto_sqlです。この関数を実行すると、SelectManager内の@astを文字列に変換してくれます。
to_sqlの実装を見ていきましょう。

to_sqlは lib/arel/tree_manager.rb にて定義されています。
TreeManagerはSelectManagerの継承元でしたよね。

module Arel
  class TreeManager
    attr_reader :ast, :engine

    attr_accessor :bind_values

    def initialize engine
      @engine = engine
      @ctx    = nil
      @bind_values = []
    end

    def visitor
      engine.connection.visitor
    end

    def to_sql
      collector = Arel::Collectors::SQLString.new
      collector = visitor.accept @ast, collector
      collector.value
    end
end

collectorは単なるStringだと思ってください。
visitor.acceptが@astを引数にとってます。こいつがsql文を生成してそうです。acceptを見てみましょう。

このvisitor自身のclassはengineによって変わります。
今回はActiveRecordのmysql engineを流用してしまっているため、visitorはActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::BindSubstitution クラスとなります。

ActiveRecord内を追っていくのはやめ、pryでstep実行していき、どのacceptを呼び出しているのか追いかけてみます。

行き着いた先はArel::Visitors::Reduceです。 lib/arel/visitors.reduce.rb に定義されています。

module Arel
  module Visitors
    class Reduce < Arel::Visitors::Visitor
      def accept object, collector
        visit object, collector
      end

      private

      def visit object, collector
        send dispatch[object.class], object, collector
      rescue NoMethodError => e
        # 例外を投げる、略
      end
  end
end

acceptは単にvisitに処理を委譲してるだけです。
visitに渡ってきているobject とは @ast、つまりSelectStatementです。

dispatchとは何でしょう。pryでのぞいてみます。

[34] pry(#<ActiveRecord::...略::BindSubstitution>)> dispatch
=> {Arel::Nodes::SelectStatement=>"visit_Arel_Nodes_SelectStatement",
 Arel::Nodes::SqlLiteral=>"visit_Arel_Nodes_SqlLiteral",
 Arel::Nodes::JoinSource=>"visit_Arel_Nodes_JoinSource",
 Arel::Table=>"visit_Arel_Table"}

どうやらclass名と関数名の変換表のようです。
objectのクラス型がArel::Nodes::SelectStatementだったら、visit_Arel_Nodes_SelectStatementという関数を呼び出しています。
つまり、visit関数で起こっている事を分かりやすく書くと以下のようになります。

# Arel::Nodes::SelectStatementが渡ってきた場合
def visit(object, collector)
  visit_Arel_Nodes_SelectStatement(object, collector)
end

visitとかacceptとかdispatchとかいう変数名で察している方もいるかもしれません。そう、これデザインパターンでいうVisitorパターンって奴です。

このvisit関数群は、lib/arel/visitors/to_sql.rb に定義されています。
一部はvisitors/mysql.rb や visitors/oracle.rb 等に定義されていますが、これはDB依存のクエリを作り出す時に呼ばれます。

lib/arel/visitors/to_sql.rb は、やってきたNodeやTableに従って文字列を出力しているだけです。

いちいちto_sql.rbのvisitor関数群を書いていくと長くなってしまうので、ここは疑似コードとさせてください。興味のある方は to_sql.rbを読んでみてください。結構単純です。

def visit_Arel_Nodes_SelectStatement o, collector

   collector = coresに対して visit_Arel_Nodes_SelectCore を呼ぶ

   if order句があったら
     collector << ' ORDER BY'
     collector << coresのorders内の要素をカンマ区切りで結合した文字列
   end

   collector << if limit句があったらlimit句の文字列
   collector << if offset句があったらoffset句の文字列
   ...
end

def visit_Arel_Nodes_SelectCore o, collector
  collector << "SELECT"

  if o.top
    collector << " "
    collector = visit o.top, collector
  end

  # 略

  # ここでprojectionsの中身を見てる
  unless o.projections.empty?
    collector << SPACE

    len = o.projections.length - 1

    # projectionsの中の要素は Arel::SqlLiteralなので、
    # visit_Arel_Nodes_SqlLiteral関数が各要素に対して呼ばれ、文字列をcollectorに追記
    o.projections.each_with_index do |x, i|
      collector = visit(x, collector)
      collector << COMMA unless len == i
    end
  end

  if o.source && !o.source.empty?
    collector << " FROM "
    # o.sourceは今はJoinSourceなので、visit_Arel_Nodes_JoinSourceが呼ばれる
    collector = visit o.source, collector
  end

  if wheresがあったら。。。
    collector << 'WHERE'
    collector << wheresをvisit、結果を文字列で返す
  end

  if groupsがあったら。。。
    group句を文字列に、結果をcollectorに
  end
  if havingがあったら。。。
    ...
  end
  if limit, offset等あったら。。。
    ...
  end

  collector
end

def visit_Arel_Nodes_JoinSource o, collector
  if o.left
    # 今はleftはArel::Tableなので、visit_Arel_Tableが呼ばれる
    collector = visit o.left, collector
  end
  if o.right.any?
    collector << " " if o.left
    collector = inject_join o.right, collector, ' '
  end
  collector
end

def visit_Arel_Table o, collector
  if o.table_alias
    collector << "#{quote_table_name o.name} #{quote_table_name o.table_alias}"
  else
    # 単にテーブル名をquoteで囲って出しているだけ
    collector << quote_table_name(o.name)
  end
end

# visit_Arel_SqlLiteralはliteralのエイリアス
def literal o, collector
  collector << o.to_s
end

@ast の中のprojectionsとかwheres変数を見て、単にSQL文を生成しているだけです。
@ast さえうまく構築できてしまえば、後は単純に@ast内をたどっていって文字列を出力するだけですね。

まとめ

今回は単純なselect文をArelが生成する過程を学びました。
こうやってソースコードを読んでみると、魔法のような技術に思えるArelも意外と単純に見えてきます。

以下、今日学んだことのメモです。

  • Arel::Tableのprojectを呼び出すと、SelectManagerのインスタンスが返ってくる
  • SelectManagerは内部で@astというSelectStatement型の変数を持っている
  • SelectManagerのproject関数は、引数として渡された文字列をSqlLiteralでラップ後、@astの@projections変数にその値を入れる
  • to_sqlでSQLを生成、内部ではvisitorパターンが使われている
  • to_sqlはSELECT, FROM, WHERE, GROUP BY, HAVING, LIMIT, OFFSET という順でSQL文を生成し、collectorに生成した文字列を入れる
  • collectorは単なるStringのラッパ。この中に生成された文字列が入っている

次回はもうちょっと複雑なSQLをArelが生成する過程を追ってみます。