今回は、以下のような単純なSQLをarelで生成してみます。
select * from products;
Arelで書くと以下のようになります。
product = Arel::Table.new(:products)
product.project('*').to_sql # select * from products;
上記コードでなぜ
select * from products;
というsqlが生成されるのかを見ていきましょう。
以下、目次となります。
まずはじめ。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のソースを読んでいく事とします。
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を入れているようです。
図にしてみると、以下のような感じでしょうか。
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型でラップしているようです。
先ほどのように図にしてみます。
project関数は副作用のある操作のようです。呼び出すと自身の@ctxのprojections変数に値を入れてますもんね。
そして、自分自身(self)を最後に返します。selfはSelectManagerです。
project関数は自身の@ctx, projections変数に指定した値を追加後、自分自身を返しています。
自分自身を返している、ということはメソッドチェーンできますね。
例えば、以下のようなコードが書けます。
product = Arel::Table.new(:products)
product.project('id').project('name').to_sql # "SELECT id, name FROM `products`"
上記のコード実行後は、SelectManager内の@cores[0]の状態は以下のようになります。
最後は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も意外と単純に見えてきます。
以下、今日学んだことのメモです。
次回はもうちょっと複雑なSQLをArelが生成する過程を追ってみます。