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

has_manyにブロック引数を渡してリレーションを拡張する

今日は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引数に指定することで、関連リソースを取得するクエリーにwhereorderといった任意の句を追加することができます。

以下は、テストを高得点順に取得する例です。

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)

注意点として引数付きのスコープブロックを joinsincludes 等に指定することはできません。

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

参考



CONTACT

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