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つの理由