Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Arelのススメ -- Arelを使うメリット

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の例をたくさんご紹介したいと思います。

関連記事

Arelのススメ – Arelを使ってみよう



CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。