Arelとは
SQLを生成するライブラリーで、ActiveRecordがSQLを生成する際に内部的に使われています。
普段はあまり表に出てこないライブラリーですがArelを使いこなせるようになると複雑なSQLを文字列として実装しなくて良くなるため、ActiveRecordの生成するSQLと親和性の高いSQLを組み立てられるようになります。
Arelを使わないよくある実装
以下のように学生の試験結果を管理する Exam
モデル(exams
テーブル)があります。試験は100点満点で60点以上が合格となります
# t.bigint "student_id", null: false
# t.integer "score", null: false
class Exam < ApplicationRecord
belongs_to :student
scope :pass, -> { where('score >= 60') }
end
合格している学生を取得する実装と発行されるSQLは以下のようになります。
Student.joins(:exams).merge(Exam.pass)
SELECT "students".* FROM "students"
INNER JOIN "exams" ON "exams"."student_id" = "students"."id"
WHERE (score >= 60)
仕様変更
「試験に合格するためにはTOEIC等の試験である一定の得点が必要」という要件が追加されました。TOEICの場合は600点以上となります。
これらの資格試験を管理する StandardTest
モデル(standard_tests
テーブル)を追加します
# t.bigint "student_id", null: false
# t.string "name", null: false
# t.integer "score", null: false
class StandardTest < ApplicationRecord
belongs_to :student
end
新仕様で合格している学生を取得する実装を以下のように行いましたがエラーとなってしまいました。
Student.joins(:exams).merge(Exam.pass).
joins(:standard_tests).merge(
StandardTest.toeic.where('score >= 600')
)
==> PG::AmbiguousColumn: ERROR: column reference "score" is ambiguous (ActiveRecord::StatementInvalid)
発行されるSQLは以下のようになり、score
が曖昧で実行できないことがわかります。
SELECT
"students".*
FROM "students"
INNER JOIN "exams" ON "exams"."student_id" = "students"."id"
INNER JOIN "standard_tests" ON "standard_tests"."student_id" = "students"."id"
WHERE (score >= 60)
AND "standard_tests"."name" = 'toeic'
AND (score >= 600)
こうなってないといけなかった
score
カラムに テーブル名.
をつけて、どのテーブルの score
であるか明示する必要がありました。
class Exam < ApplicationRecord
# scope :pass, -> { where('score >= 60') }
scope :pass, -> { where(%("#{table_name}".score >= 60)) }
end
Student.joins(:exams).merge(Exam.pass).
joins(:standard_tests).merge(
# StandardTest.toeic.where('score >= 600')
StandardTest.toeic.where('"standard_tests".score >= 600')
)
SELECT
"students".*
FROM "students"
INNER JOIN "exams" ON "exams"."student_id" = "students"."id"
INNER JOIN "standard_tests" ON "standard_tests"."student_id" = "students"."id"
WHERE "exams"."score" >= 60
AND "standard_tests"."name" = 'toeic'
AND ("standard_tests".score >= 600)
仮に60点ピッタリのみ合格という仕様ならば、
scope :pass, -> { where(score: 60) }
と書けるので、"exams"."score" = 60
とカラムには自動的ににテーブル名が付加されますが、生SQLを書く場合はカラムにテーブル名をつけてまわるのがお作法となります。
※postgresqlでは 引用識別子 は "
、 MySQLでは `
となるので、利用しているDBMSによって引用識別子を変更するか、引用識別子をつけない対応が必要です。
Arelでかいてみませんか
このお作法は罪深いことに、お作法を守らない実装を利用する人がSQLエラーと対峙することになり、それが複雑な生SQLであった場合デバッグは困難を極めることになると思われます。
そもそも文字列による生SQLではなく、ActiveRecordが利用しているArelを我々も使って生SQLを書かないようにすればこのような問題は発生しないと思います。
上記の生SQLをArelに置き換えてみます
class Exam < ApplicationRecord
# scope :pass, -> { where('score >= 60') }
# scope :pass, -> { where(%("#{table_name}".score >= 60)) }
scope :pass, -> { where(arel_table[:score].gteq(60)) }
end
Student.joins(:exams).merge(Exam.pass).
joins(:standard_tests).merge(
# StandardTest.toeic.where('score >= 600')
# StandardTest.toeic.where('"standard_tests".score >= 600')
StandardTest.toeic.where(StandardTest.arel_table[:score].gteq(600))
)
発行されるSQLは、上記のものと(ほぼ)おなじです。
※ AND ("standard_tests".score >= 600)
の()がとれます
テーブル名の指定の方法がちょっと変わった程度で、文字列の煩雑さが消えて特にscopeの実装はスッキリしたように思います。
※引用識別子は自動的に解決されます
まとめと次回予告
Arelに関しては活用していく方が良いのか使うべきではないのかは賛否が分かれています。
よく知っているSQLならみんな解るけど、普段隠れていてよく知らないArelは使うべきメリットが見当たらないということが論拠の一つになるかと思います。
しかしながらArelに慣れてくれば煩雑な文字列の実装を避けられて、他モデルと組み合わせても安全で標準的なSQLを組み立てられることがメリットになるかと思います。
Arelの最大のデメリットは学習コストが高いことに尽きます。学習コストを下げるべく次回はArelの実装と対応して発行されるSQLの例をたくさんご紹介したいと思います。