RailsのActiveRecordで想定通りのSQLクエリが発行されずに辛い思いをしたので、SQLまわりのソースコードを読んでいたらArel
というものに当たりました。
どうやらこれがrailsっぽくSQLクエリを書ける機能を提供しているようなので、勉強したことをまとめます。あまり細かいことは気にせずに、Arelを読み解く上での基礎を理解することが目標です。
ソースコードをちゃんと読んだ経験が少ないのでとんちんかんなこと書いてないか不安ですが、温かい目で見てもらえればと思います。
間違っている箇所あればコメント欄よりお願いします。
Arelとは
ArelはActiveRecordの内側でwhere
やselect
のメソッドチェーンからSQLを生成する役割を担っています。またDBによってSQLの文法が若干違ったりしますが、その差異を吸収してくれるのもArelです。
rubygemsによるとArelは、
- Arel Really Exasperates Logicians(Logician=論理学者)
- 複雑なSQLクエリの生成を簡単にできるようにする
- 色んなRDBMSに対応できるようにする
- フレームワーク(特にORM)のフレームワークとして開発された
だそうです。
Arelはrailsやactiverecordとはまだ別のgemとしてrubygemsからダウンロードするのが基本的な使い方です。
ですがゆくゆくはactiverecordに取り込まれることになっており、既にrailsのmasterブランチにはarelが取り込まれて(Merge時のPR)開発もそちらで進んでいるようです(元は別レポジトリ。)
そもそもが内部APIであるためか公式ドキュメントはあんまり充実していないので、使いこなすには自分でソースコードを読んでいくしかなさそうです。
また人によるかも知れませんが、Arelの勉強をする場合はActiveRecordを含まないArel単体でまず見ていく方が理解しやすいです。使い方としては、
users = Arel::Table.new(:users)
query = users.project("id").where(users[:age].gt(20).and(users[:age].lt(30))).order("id")
query.to_sql
# "SELECT id FROM \"users\" WHERE \"users\".\"age\" > 20 AND \"users\".\"age\" < 30 ORDER BY id"
みたいにして簡単にクエリを生成できます。to_sql
で対応するSQLが簡単に得られていることが分かります。
注:記事のarelのバージョンはrubygemsにおいてある最新版の9.0.0です。
主役はArel::SelectManager
Arel::Table
の実質機能を全て管理しています。
Arel::Table#project
やArel::Table#where
を呼ぶと、内部的にはすぐにArel::Table#from
を呼んで処理を移譲します。ちなみにproject
は日本語の「プロジェクト」の意味ではなく、「射影」と言う意味です。「SQLのselect
をarelではproject
と呼ぶ」と理解して良いと思います。
def from
SelectManager.new(self)
end
def where condition
from.where condition
end
def project *things
from.project(*things)
end
このArel::Table#from
はArel::SelectManager
を返し、以後project
やwhere
のチェーンは同じArel::SelectManager
オブジェクトが返されて実現します。
(つまりusers.project("id")
はusers.from.project("id")
と全く同じということです。)
arelの該当ソースコードは下記(最終行に注目してください)
# Arel::SelectManagerの親クラス
def where expr
if Arel::TreeManager === expr
expr = expr.ast
end
@ctx.wheres << expr
self # 自分を返している = チェーン可能
end
def project *projections
# FIXME: converting these to SQLLiterals is probably not good, but
# rails tests require it.
@ctx.projections.concat projections.map { |x|
STRING_OR_SYMBOL_CLASS.include?(x.class) ? Nodes::SqlLiteral.new(x.to_s) : x
}
self # 自分を返している = チェーン可能
end
Arel::SelectManagerの中にはコア(核)がある
Arel::SelectManager
は、@ctx
という変数で1つのcore(Arel::Nodes::SelectCore
)を持っています。
そして1つのcore
は、projections
, wheres
, groups
, havings
などの配列と、source
という二分木を持ちます。本当は複数のコアがあることもあるのですが、この記事の中ではコアは1つしかないものとして考えることにしましょう。
これを組み立てて1つのSQL文が作られます。作られるSQL文はいつでもArel::TreeManager#to_sql
で確認出来ます。(Arel::TreeManager#to_sql
はArel;::SelectManager
の親クラスです。)
project
をチェーンに繋げることはprojections
配列に選択列を追加していくことになり、where
をチェーンに繋げることはwheres
配列に条件を追加していくこととなります。
ここでsource
は二分木と言いましたが、Arel
では他でも二分木を頻繁に使います。
Arel
での二分木の伸び方は、自身のノード(メソッド呼び出し元)を左に、(メソッド)引数の木を右側に持つ新しいノードを作る、という形式でrootの方向に伸びます。
イメージ的には、
new_node = left_node.create_node(right_node)
new_node.left == left_node # true
new_node.right == right_node # true
みたいな感じです。
ノードのクラスはいくつかありますが、ほぼArel::Nodes::Node
かそれを継承したクラスのインスタンスです。
継承したノードクラスというのは主に、Binary
とUnary
です。Unary
クラスは名前の通り1つしか子を持ちません。
さて、コードを見て行きましょう。
このコードの理解が目標です
users = Arel::Table.new(:usrs)
users.where(
users[:age].eq(20).or(users[:age].eq(18)).and(users[:age].gt(15))
)
のwhereの中身について考えてみましょう。よく読むと分かるかもしれませんが、最終的なSQLとしては
users.age = 20 OR users.age =18 AND users.age > 15
に対応します。またそれは、
users.from.to_sql # "SELECT FROM \"users\""
users[:age].eq(20).or(users[:age].eq(18)).and(users[:age].gt(15)).to_sql
# "(\"users\".\"age\" = 20 OR \"users\".\"age\" = 18) AND \"users\".\"age\" > 15"
でも分かります。(from
を付けているのは、to_sql
メソッドが用意されているのがArel::SelectManager
だからです。)
Arelを理解するにはまずノードから
前述しましたが、Arelでは二分木がたくさんでてきます。
上の例で出てきたeq
やor
などは二分木を成長させるメソッドで、引数を右側に、呼び出し元を左とする二分木のrootを生成して返します。
二分木のノードのうち子を持つノードは全てArel::Nodes::Node
かそれを継承するクラスのインスタンスです。
eq
はArel::Nodes::Equality
(Binaryノード)を、or
はArel::Nodes::Grouping
(Unaryノード)を生成して返します。
まず、
users[:age].eq(20)
を見てみましょう。
結論から言うとこの式は全体として、左側にusers[:age]
(Arel::Attributes::Attribute
)、右側に20
(Arel::Nodes::Casted
)をもつ木(Arel::Nodes::Equality
)を作ります。Arel::Attributes::Attribute
は子を持ちません。
Arel::Attributes::Attribute
はStruct(:relation, :name)
を継承しており、users[:age]
の場合はrelation
としてusers
を、name
として"age"
をもちます。
Arel::Nodes::Casted
は一応Arel::Nodes::Node
を継承しているのでノードとして振舞えるし子も持てるのですが、基本的にただの値(この場合は20
)と思って良いと思います。
Arel::Attributes::Attribute
やArel::Nodes::Casted
のように、Arel
では一見単純なrubyのオブジェクトや配列で良さそうなものでも自作クラスのインスタンスにすることが多い印象です。
さてusers[:age].eq(20)
の返り値はArel::Attributes::Attribute#eq
(正確にはこのクラスにincludeされているArel::Predications#eq
)の返り値ですが、前述の通りこれはArel::Nodes::Equality
という小さな木を返します。
eql_node = users[:age].eq(20)
eql_node.class # Arel::Nodes::Equality
eql_node.left
# <struct Arel::Attributes::Attribute relation=#<Arel::Table:0x007fd378afb1c8 @name="users", @type_caster=nil, @table_alias=nil>, name=:age>
eql_node.right
# <Arel::Nodes::Casted:0x007fd37a401118 @val=20, @attribute=#<struct Arel::Attributes::Attribute relation=#<Arel::Table:0x007fd378afb1c8 @name="users", @type_caster=nil, @table_alias=nil>, name=:age>>
先に進みます。
users[:age].eq(20).or(users[:age].eq(18))
次にArel::Nodes::Node#or
が呼ばれて、引数のusers[:age].eq(18)
を右側に、users[:age].eq(20)
を左側にもつArel::Nodes::Grouping
(Unary
クラスから継承)ノードを返します。
def or right
Nodes::Grouping.new Nodes::Or.new(self, right)
end
# Arel::Noees::Orの親クラス
def initialize left, right
super()
@left = left
@right = right
end
users[:age].eq(20).or(users[:age].eq(18)).and(users[:age].gt(15))
最後にArel::Nodes::Node#and
が呼ばれて、users[:age].gt(15)
(Arel::Nodes::GreaterThan
(Binaryから継承))を右側に、呼び出し元の木を左側にもつArel::Nodes::And
(Arel::Nodes::Node
クラスから継承)を返します。Arel::Nodes::And
も持つ子は2つで、left
とright
メソッドで呼び出せるしBinary
と仕組みも一緒です。
次回はデザインパターンとしてのVisitorパターン
を利用している#to_sql
の仕組みについて説明したいと思います。(現在執筆中)
参考文献
- [技術評論社 第43回 Rails 3を支える名脇役たち その1 - Arel] (http://gihyo.jp/dev/serial/01/ruby/0043)
- タイムインターメディア ActiveRecordを支える技術 - Arelとは何者なのか? (全5回)
- Qiita Arelでクエリを書くのはやめた方が良い5つの理由