今日はhas_manyのブロック引数に関して取り上げます。
以下のモデルを想定します。
# attributes
#
# name: 氏名
# attendance_count: 出席回数
class Student < ApplicationRecord
has_many :exams
end
# attributes
#
# student_id: 学生ID
# subject: 教科
# period: テスト実施回
# score: 得点
class Exam < ApplicationRecord
belongs_to :student
end
スコープブロック
has_many のブロックと聞くとまずスコープブロックが思いつくと思います。
今回のテーマのブロック引数とは異なりますが、先にスコープブロックに関して触れていきます。
スコープブロックは has_many の第2引数に指定することで、関連リソースを取得するクエリーにwhere
やorder
といった任意の句を追加することができます。
以下は、テストを高得点順に取得する例です。
class Student < ApplicationRecord
has_many :exams, -> { order(score: :desc) }
end
student = Student.find(1)
studnt.exams.to_sql
#=>
# SELECT "exams".* FROM "exams" WHERE "exams"."student_id" = 1
# ORDER BY "score" DESC
スコープブロックの引数
スコープブロックに引数を設定すると関連元オブジェクトを参照できます。
学生出席回数が5回未満の場合テストは無効とする例を示します。
class Student < ApplicationRecord
has_many :exams, -> student { student.attendance_count >= 5 ? all : none }
end
good_student.attendance_count #=> 10
good_student.exams.to_sql
# => SELECT "exams".* FROM "exams" WHERE "exams"."student_id" = 1
no_good_student.attendance_count #=> 0
no_good_student.exams.to_sql
# => SELECT "exams".* FROM "exams" WHERE "exams"."student_id" = 2 AND (1=0)
注意点として引数付きのスコープブロックを joins
や includes
等に指定することはできません。
Student.joins(:exam).to_sql
#=>
# `check_eager_loadable!': The association scope 'exams' is instance dependent
# (the scope block takes an argument).
# Eager loading instance dependent scopes is not supported. (ArgumentError)
ブロック引数
has_many
にブロック引数を渡すことで、関連先リソースを取得するリレーションを拡張することができます。
第2引数に渡すスコープブロックとは全くの別物になります。
以下に学生の教科ごとの平均点を出力するメソッドを追加する例を示します。
class Student < ApplicationRecord
has_many :exams do
def subject_averages
# SELECT
# AVG("exams"."score") AS "average_score",
# "exams"."subject" AS "exams_subject"
# FROM "exams"
# WHERE "exams"."student_id" = $student_id
# GROUP BY "exams"."subject"
group(:subject).average(:score)
end
end
end
student.exams.pluck(:period, :subject, :score)
#=>
# [
# [1, "math", 80], [1, "japanese", 80], [1, "english", 66],
# [2, "math", 56], [2, "japanese", 87], [2, "english", 99]
# ]
student.exams.subject_averages
#=> {"math"=>0.68e2, "japanese"=>0.835e2, "english"=>0.825e2}
student.exams.where(subject: 'japanese').subject_averages
#=> {"japanese"=>0.835e2}
ブロック内で定義したメソッドの self
はhas_manyが生成したリレーションそのものになります。
class Student < ApplicationRecord
has_many :exams do
def return_self
self
end
end
end
student = Student.find(1)
exams = student.exams.where(subject: 'japanese')
in_blocked_exams = exams.return_self
exams.object_id == in_blocked_exams #=> true
関連元オブジェクトの取得
has_many が生成するリレーションは ActiveRecord::Associations::CollectionProxy
を継承します。
#proxy_association#owner
から関連元オブジェクトが取得できます。
exams = Student.find(1).exams
exams.proxy_association.owner # => <Student id: 1, ...>
このため、ブロック引数内で定義したメソッドでは、関連元オブジェクトを用いたロジックを実装可能です。
以下は受験者ごとに試験レポートを出力する実装例です。
class Student
has_many :exams do
def report
average = -> exams { Rational(exams.pluck(:score).sum, values.size).round(2).to_f }
student = proxy_association.owner
{
student_id: student.id,
student_name: student.name,
student: records.count,
exam_times: pluck(:period).uniq.size,
subject_averages: records.group_by(&:subject).transform_values(&average),
}
end
end
delegate :report, to: :exams, prefix: true # define Student#exams_report
end
Student.includes(:exams).map(&:exams_report)
#=>
# [
# {
# :student_id=>1, :student_name=>"kazz", :exam_times=>2,
# :subject_averages=>{"math"=>68.0, "japanese"=>83.5, "english"=>82.5}
# },
# {
# :student_id=>2, :student_name=>"tester", :exam_times=>1,
# :subject_averages=>{"english"=>97.0, "japanese"=>93.0, "math"=>87.0}
# }
#]
引数付きスコープブロックでできなかった includes(:exams)
を使うことができます。
ただし、実装するメソッド内ではキャッシュを使うように実装しておく必要があります
class Student
has_many :exams do
def total_sql_sum_scores
sum(:score) # SELECT SUM("exams"."score")
end
def total_scores_array_sum
records.pluck(:score).sum # [80, ...].sum
end
end
delegate :total_sql_sum_scores, :total_scores_array_sum, to: :exams
end
SQLを用いる実装の場合N+1問題が発生します。
Student.includes(:exams).map(&:total_sql_sum_scores)
# Student Load (0.6ms) SELECT "students".* FROM "students"
# Exam Load (0.5ms) SELECT "exams".* FROM "exams" WHERE "exams"."student_id" IN ($1, $2) [["student_id", 1], ["student_id", 2]]
# Exam Sum (1.1ms) SELECT SUM("exams"."score") FROM "exams" WHERE "exams"."student_id" = $1 [["student_id", 1]]
# Exam Sum (0.4ms) SELECT SUM("exams"."score") FROM "exams" WHERE "exams"."student_id" = $1 [["student_id", 2]]
キャッシュを利用するとN+1問題を抑制することができます。
Student.includes(:exams).map(&:total_scores_array_sum)
# Student Load (2.2ms) SELECT "students".* FROM "students"
# Exam Load (1.4ms) SELECT "exams".* FROM "exams" WHERE "exams"."student_id" IN ($1, $2) [["student_id", 1], ["student_id", 2]]
ブロック引数の分離
has_manyのブロック引数が肥大化していくと、参照元クラスは本来の実装とは直接関係のないコードで埋め尽くされてしまいます。
class Student
has_many :exams do
def feature1
...
end
...
end
# Studentの本来の実装が埋もれる
end
以下のように has_manyのオプション extend:
にモジュールとして設定することで関心の分離が実現できます。
module ExamsExtension
def feature1
...
end
...
end
class Student
has_many :exams, extend: ExamsExtension
end
参考
- § 4.6 関連付けの拡張 -- Active Record の関連付け - Railsガイド
- Rails API Association extensions --
ActiveRecord::Associations::ClassMethods