はじめに:Arelって何?
みなさん、Arel(アレル)ってご存知ですか?
ArelはActive Recordの内部で使用されるSQL生成ライブラリです。
Railsのクエリの書き方をググると、ときどきArelを使った実装例が見つかるので、見たことがある、もしくは何度か使ったことがある、という人もいると思います。
Arelをよく知らない人のために、Arelの利用例をちょっと見てみましょう。
たとえば「コメント文中に、"ruby"が含まれるユーザープロフィールを検索したい」という場合、Rails標準のクエリインターフェースを使うと条件部分のSQLを文字列で書く必要があります。(PostgreSQL環境を想定)
Profile.where(
"profiles.comment ILIKE ?", "%ruby%"
).to_sql
#=> SELECT "profiles".*
# FROM "profiles"
# WHERE (profiles.comment ILIKE '%ruby%')
しかし、Arelを使うと上のクエリが次のように書けます。
Profile.where(
Profile.arel_table[:comment].matches("%ruby%")
).to_sql
#=> SELECT "profiles".*
# FROM "profiles"
# WHERE ("profiles"."comment" ILIKE '%ruby%')
ご覧のとおり、Arelを使うと文字列でSQLを書くことなく、Rubyのコードとしてクエリを書くことができます。
なので、Arelは「SQLを書かなくて済む!」とか「Rubyのコードとして完結するので美しい!」といったメリットを強調されることが多いです。
ですが、個人的には「Arelは(やんごとなき理由がない限り)使わない方が良い」、と僕は考えています。
その理由を以下に5つ挙げていきます。
理由その1: ArelはRailsのプライベートAPIだから
ArelはRailsのプライベートAPIという位置づけです。
そのため、「サポートしないよ」「使うべきじゃないよ」というのがRails開発チームのスタンスです。
The use of Arel from Active Record is not supported. It is an internal, private API.
Active RecordからArelを使うのはサポートされていません。Arelは内部用のプライベートAPIです。
First thing, arel_table is private API of Rails and should not be used in applications.
始めに断っておきますが、arel_tableはRailsのプライベートAPIです。アプリケーション内で使うべきではありません。
Railsの公式ドキュメントであるRailsガイドを見ても、Arelに関する説明は出てきません。
また、ArelのREADMEには次のように書いてあります。
It is intended to be a framework framework; that is, you can build your own ORM with it, focusing on innovative object and collection modeling as opposed to database compatibility and query generation.
Arelはフレームワークのフレームワークとして使われることを想定しています。つまり、Arelを使えば自分独自のORM(O/Rマッピングツール)を構築できます。Arelを使うことでデータベースの互換性やクエリ生成ロジックに悩まされることなく、革新的なオブジェクトとコレクションのモデリングを構築できるのです。
こうした公式発言を読むと、Railsアプリケーション内でカジュアルに利用するのはNGだ、と見なすことができます。
2016.8.12追記:Rails 5.1ではArelがパブリックAPIになるかも?
(2017.4.28 追記)
Rails 5.1がリリースされましたが、僕が調べる限りArelの位置付けは特に変わりがないようです。(プライベートAPIのままっぽい)
もし「ここを読むとパブリックAPIになったって書いてあるよ」というような情報があれば、コメント等で教えてください。
RailsコミッタのSean Griffin(@sgrif)さんいわく、ArelはRails 5.1でパブリックAPIになるそうです。
情報源はこちらのポッドキャストで、5分~7分あたりでArel関連の話が展開されています。
74: A Dip in the Connection Pool | The Bike Shed
英語が完全に聞き取れなかったので、本当にそうなのか質問してみたところ、"Yes, it will be stable after 5.1"との回答をもらいました。
@jnchito Yes, it will be stable after 5.1
— Sean Griffin (@sgrif) August 11, 2016
というわけで、Rails 5.1以降はArelとの付き合い方が変わる可能性があります。
以下の内容はRails 5.0までの情報として読んでください。
理由その2: バージョンアップで動かなくなることがよくあるから
これは理由その1の「プライベートAPIだから」という理由とほぼイコールなのですが、Arelを使ってちょっと凝ったクエリを構築すると、Railsのバージョンアップ時にエラーが出て動かなくなる場合があります。
僕は何度かこの問題に遭遇して、そのたびに解決に時間を食っています。
もちろん、Railsをバージョンアップすると今まで動いていたコードが動かなくなる、というケースはときどきありますが、パブリックなAPIであれば通常、変更履歴やアップグレードガイドにAPIの変更点や移行方法が提示されます。
また、完全に廃止する前に警告のログを出してくれることもよくあります。
しかし、ArelはプライベートAPIなので、そういったヒントになる情報が上がってきません。
それまで動いてたコードが突然予期せずエラーになります。
なので、ググって同じようなエラーに遭遇している人を探したり、コードを追っかけて原因と対策を調査したりする必要があります。
Arelでエラーが起きると、パブリックAPIで仕様変更があったときに比べて、解決までの時間がかかります。
理由その3: 絶対Arelじゃなきゃダメ!というケースは滅多にないから
僕は基本的にActive Recordのクエリインターフェース(where(name: 'jnchito')
やjoins(:users)
等)か、文字列SQL(where("comment ILIKE ?", comment)
等)でクエリを構築しています。
また複雑な集計クエリは、SELECT文を全体を文字列SQLで書いて実行することもあります。
長年RailsでいろいろなWebアプリケーションを作ってきましたが、僕の経験上、「ここはArelを使わざるを得ない」というケースに遭遇したことは一度もありません。
「ArelでSQLを構築した方がSQLの構文エラー(テーブルの別名の衝突等)が起きにくい」とか「RDBMSを変更してもそのまま動く」といったメリットは"理論上"想像はできますが、実際に問題が起きたことはありません。
また、SQLを使う場合でもcreated_at
やname
のような複数のテーブルに存在しそうなカラムは、"comments.created_at > ?"
のように、必ず「テーブル名.カラム名」で書くようにすれば大半のケースでは名前の衝突は発生しないはずです。
仮にその問題が起きるケースがあったとしても、YAGNI(You ain't gonna need it、「たぶんそれ、いらへんで」の略)の精神で、問題が起きたときに対処すればいいと考えています。
理由その4: Arelは読み書きに時間がかかるから
ArelとSQLの得手不得手をパターン分けすると、次のようになると思います。
- SQLもArelも両方苦手
- SQLは得意、Arelは苦手
- SQLは苦手、Arelは得意
- SQLもArelも両方得意
この中でArelを使った方が圧倒的に読み書きが速くなるのは3の「SQLは苦手、Arelは得意」のパターンのみです。
が!そんな人っていったいどれくらいいるんでしょうか・・・。
Arelで複雑なクエリを書こうと思ったら、SQLの知識が不可欠になると思うので、現実的にパターン3の人は滅多にいないと思います。
また、4の「SQLもArelも両方得意」という人でも、おそらく「SQLを考えてから、Arelでコードを書く」という手順を踏むと思います。
であれば、「Arelで書かなければいけない特別な理由」がない限り、SQLで書いた方が速いはずです。
コードを読む場合も同様で、SQLよりもArelを読む方が圧倒的に速い、というケースは滅多にないと思います。
実際にSQLとArelのコードを比較してみる
参考までに、ちょっと複雑なクエリをSQLとArelで書いてみましょう。
たとえば「プロフィールに好きな食べ物と嫌いな食べ物を登録できるRailsアプリケーション」があったとします。
モデルは次のようになっています。
# プロフィール
class Profile < ActiveRecord::Base
has_many :food_likings
has_many :food_dislikings
end
# 食べ物
class Food < ActiveRecord::Base
end
# 好きな食べ物
class FoodLiking < ActiveRecord::Base
belongs_to :profile
belongs_to :food
end
# 嫌いな食べ物
class FoodDisliking < ActiveRecord::Base
belongs_to :profile
belongs_to :food
end
ER図で表すとこんな感じです。
ここに次のようなデータが入っています。
(Liking、Dislikingは関連するデータをカンマ区切りで表示しています)
Name | Comment | Liking | Disliking |
---|---|---|---|
Alice | Hi. | tomato, tomato-soup | onion |
Bob | Hello. | onion | tomato |
Chris | Tomato is beautiful. | tomato-soup | |
Dave | Sleepy... | onion | tomato-soup |
問題
ここから「コメント、好きな食べ物、嫌いな食べ物のいずれかに 'tomato' が含まれるプロフィール」を検索するにはどうすればいいでしょうか?
なお、検索実行時は以下の条件も加えることにします。
- 大文字小文字の違いは無視する
- 食べ物は完全一致(tomatoは良いがtomato-soupはダメ)、コメントは部分一致で検索する
つまり、ここではDaveだけが除外され、AliceとBobとChrisが抽出されればOK、ということになります。
(ピンと来ない人はじっくり問題とデータを見てください)
SQLで抽出する場合
僕であれば次のようなscopeを定義します。
class Profile < ActiveRecord::Base
scope :keyword_search, ->(keyword) do
where(<<-SQL, food_name: keyword.downcase, comment: "%#{keyword}%")
-- 好きな食べ物にキーワードが一致する場合
(
EXISTS (
SELECT *
FROM food_likings fl
INNER JOIN foods f
ON f.id = fl.food_id
WHERE
profiles.id = fl.profile_id
AND LOWER(f.name) = :food_name
)
) OR
-- 嫌いな食べ物にキーワードが一致する場合
(
EXISTS (
SELECT *
FROM food_dislikings fd
INNER JOIN foods f
ON f.id = fd.food_id
WHERE
profiles.id = fd.profile_id
AND LOWER(f.name) = :food_name
)
) OR
-- プロフィールコメントにキーワードが含まれる場合
profiles.comment ILIKE :comment
SQL
end
end
SQLが苦手な人が見ると「???」かもしれませんが、SQLが得意な人であれば「ふむふむ・・・ああ、なるほど」となると思います。
Arelで抽出する場合
一方、上と同じ条件をArelで構築してみました。
class Profile < ActiveRecord::Base
scope :keyword_search, -> (keyword) do
profiles = arel_table
foods = Food.arel_table
food_likings = FoodLiking.arel_table
food_dislikings = FoodDisliking.arel_table
lower_name = Arel::Nodes::NamedFunction.new("LOWER", [foods[:name]])
food_likings_condition =
food_likings
.project(Arel.star)
.join(foods)
.on(foods[:id].eq(food_likings[:food_id]))
.where(profiles[:id].eq(food_likings[:profile_id]))
.where(lower_name.eq(keyword.downcase))
.exists
food_dislikings_condition =
food_dislikings
.project(Arel.star)
.join(foods)
.on(foods[:id].eq(food_dislikings[:food_id]))
.where(profiles[:id].eq(food_dislikings[:profile_id]))
.where(lower_name.eq(keyword.downcase))
.exists
comment_condition = profiles[:comment].matches("%#{keyword}%")
where(food_likings_condition.or(food_dislikings_condition).or(comment_condition))
end
end
いかがでしょう?
「おお、やっぱりRubyで書くとわかりやすいですね!!」・・・となる人がどれくらいいるでしょうか??
もちろん、わかりやすい/わかりにくい、は個人の主観によるので、「SQLは見たくない、Arelの方が落ち着く」という人もいるかもしれません。
ですが、僕は「素直にSQLで書けばいいものを、わざわざなぜ??」と思わざるを得ません。
しかも、僕は普段Arelを使わない人なので、今回SQLと同等のArelの書き方を調べるのにかなりの時間を費やしてしまいました。
「SQLを考える => Arelに翻訳する」という手間をわざわざかけなくても、「SQLでさっさと書いちゃえばいいじゃん」というのが、僕の感想です。
理由その5: Arelを使っても生SQLは完全には排除できないから
Arelを使う理由が「生SQLを書きたくない」「RubyのコードにSQLを登場させたくない」という感情的・美意識的な理由なのであれば、それは捨てるべきじゃないかと思います。
そもそもArelを使っても、完全に生SQLを排除することはできません。
たとえば、この記事を執筆している時点(2016年8月)ではArelはSQLのCASE WHENをサポートしていないようです。
(Arel 7.1からCASE WHENもサポートされているようです → 参考1 / 参考2)
なので、CASE WHENを使いたいときは生SQLが顔を出します。
# https://github.com/rails/arel/blob/master/README.md
photo_clicks = Arel::Nodes::SqlLiteral.new(<<-SQL
CASE WHEN condition1 THEN calculation1
WHEN condition2 THEN calculation2
WHEN condition3 THEN calculation3
ELSE default_calculation END
SQL
)
photos.project(photo_clicks.as("photo_clicks"))
# => SELECT CASE WHEN condition1 THEN calculation1
# WHEN condition2 THEN calculation2
# WHEN condition3 THEN calculation3
# ELSE default_calculation END
# FROM "photos"
SQLは見た目によらず(?)非常に高度な機能を持っています。
また、Window関数やWITH句(Common Table Expression、共通テーブル式)など、RDBMSの方言として便利な機能も使えるようになっています。
このように、SQLは「SQLというひとつの言語」なので、それをRubyで完全にラップするというのは不可能だろうと僕は考えています。
ゆえに僕は「Active Recordのクエリインターフェースでカバーできる範囲はクエリインターフェースを使う」「クエリインターフェースの範疇を超えてしまう場合はクエリは生SQLを書く」というルールでクエリを書くようにしています。
まとめ:ArelはRailsの裏メニュー?
というわけで、今回はArelでクエリを書くのをやめた方が良い5つの理由を書きました。
最大の理由は最初の「プライベートAPIだから」で終わっていて、残りの4つは蛇足みたいなものです。
ArelはRailsの裏メニューだと僕は考えています。
焼き肉屋さんでたとえると、馴染みの大将に「生レバーある?こっそり出してよ」と頼むようなものです。(こっちは法律違反ですが)
使わずに済むなら使わない、使うなら自己責任で使う、あまりカジュアルにArelの使い方を紹介しない(=初心者をArelに誘導しない)、というのがArelとの望ましい付き合い方じゃないかな、と僕は思います。
これまで気軽にArelでクエリを書いていた人は、(特殊で例外的なケースを除いて)脱Arelを検討していきましょう。
捕捉:Squeelはどうなの?
生SQLは書きたくない、でもActive Recordのクエリインターフェースじゃ機能が足りない、という場合にときどき登場するのがSqueelというgemです。
このgemはActive Recordよりも高度なクエリインターフェースを提供してくれます。
たとえば、冒頭で紹介したLIKEを使うSQLは次のように書くことができます。
Profile.where { comment =~ '%ruby%' }.to_sql
#=> SELECT "profiles".*
# FROM "profiles"
# WHERE "profiles"."comment" ILIKE '%ruby%'
ただし、このgemは最近開発が停滞しており、GitHubを見ると最終のcommitが2015年2月になっています。(2016年8月10日時点)
Rails 5の対応もあまり進展がないようです。
ポピュラーなgemなのでそのうちRails 5対応がされるかもしれませんが、徐々に衰退しそうな気配があるので、新たに導入するのは避けた方がよい気がします。