目的
多対多のアソシエーションを組んでいて、関連テーブルのデータもincludeなどで取得するとき、
関連テーブル先のデータを中間テーブルのデータで絞り込む方法です。
前提
患者: patientが医師: physicianとの診察予約: appointmentをとります。
診察予約が中間テーブルになります。
Railsガイドを参考にしました。
migrate
# 医師
class CreatePhysicians < ActiveRecord::Migration[6.1]
def change
create_table :physicians do |t|
t.string :name
t.timestamps
end
end
end
# 診察予約(中間テーブル)
class CreateAppointments < ActiveRecord::Migration[6.1]
def change
create_table :appointments do |t|
t.belongs_to :physician, null: false, foreign_key: true
t.belongs_to :patient, null: false, foreign_key: true
t.datetime :appointment_date
t.timestamps
end
end
end
# 患者
class CreatePatients < ActiveRecord::Migration[6.1]
def change
create_table :patients do |t|
t.string :name
t.timestamps
end
end
end
belongs_toはreferencesのエイリアスです。GitHub rails/…/schema_definitions.rb
models
class Physician < ApplicationRecord
has_many :appointments
has_many :patients, through: :appointments
end
class Appointment < ApplicationRecord
belongs_to :physician
belongs_to :patient
end
class Patient < ApplicationRecord
has_many :appointments
has_many :physicians, through: :appointments
end
db
【 Physicians 】
| id | name |
|---|---|
| 1 | physician_A |
| 2 | physician_B |
【 Appointments 】
| id | physician_id | patient_id | appointment_date |
|---|---|---|---|
| 1 | 1 | 1 | 2021/01/10 10:00 |
| 2 | 1 | 2 | 2021/03/30 10:00 |
| 3 | 1 | 3 | 2021/03/20 14:30 |
| 4 | 2 | 2 | 2021/01/20 15:00 |
【 Patients 】
| id | name |
|---|---|
| 1 | patient_01 |
| 2 | patient_02 |
| 3 | patient_03 |
方法
class Physician < ApplicationRecord
has_many :appointments
has_many :patients, through: :appointments
has_many :future_appointments, -> { where(appointment_date: Date.today.. }, class_name: 'Appointment'
has_many :future_patients, through: :future_appointments, source: :patient
Physician.preload(:future_patients)
使用オプション等
| 意味 | 参考URL | |
|---|---|---|
| -> { } | スコープ | Railsガイド |
| .. | 範囲式 | リファレンスマニュアル |
| class_name | 別名使用時のモデル名指定 | Railsガイド |
| source | 別名使用時のモデル名指定 | Railsガイド |
解説
取得したいデータは、
- 全ての
医師: physicianのデータ - 今日以降の
診察予約: appointmentがある、患者: patientのデータ
です。
失敗例
初め、単純にwhereを繋げて絞り込みをしようとしましたが、失敗でした。
Physician.eager_load(:patients).where(appointments: { appointment_date: Date.today..Float::INFINITY})
上記の解説をすると、
まず、eager_loadで関連テーブルのデータも一緒に取得します。
今回は絞り込みも行いたいため、preloadではなくeager_loadを使用しています。includesでも結果は同じです。
次にwhereで絞り込みをします。
where(<テーブル名>: { <カラム名>: <条件> })で、関連テーブルでの絞り込みができます。Railsガイド
条件として、Date.todayで今日の日付を、..で範囲指定します。
Float::INFINITYは正の数の無限大です。リファレンスマニュアル
Float::INFINITYは省略することもできます。{ appointment_date: Date.today.. }
しかし、これで発行されるクエリは、
SELECT `physicians`.`id` AS t0_r0,
`physicians`.`name` AS t0_r1,
`physicians`.`created_at` AS t0_r2,
`physicians`.`updated_at` AS t0_r3,
`patients`.`id` AS t1_r0,
`patients`.`name` AS t1_r1,
`patients`.`created_at` AS t1_r2,
`patients`.`updated_at` AS t1_r3
FROM `physicians`
LEFT OUTER JOIN `appointments` ON `appointments`.`physician_id` = `physicians`.`id`
LEFT OUTER JOIN `patients` ON `patients`.`id` = `appointments`.`patient_id`
WHERE `appointments`.`appointment_date` >= '2021-02-28'
SELECTで取得するカラムを指定。ASはカラムに別名をつけています。
FROMは取得するテーブル名。
LEFT OUTER JOINで同時に取得するテーブルを指定。ONで条件付けています。
そして、WHEREで今日以降のデータに絞り込んでいます。
となり、今日以降の診察予約がある、医師と患者のデータのみを取得し、全ての医師のデータを取得できません。
physician = Physician.eager_load(:patients).where(appointments: { appointment_date: Date.today..Float::INFINITY})
physician
=> [<id: 1, name: "physician_A">]
physician[0].patients
=> [<id: 2, name: "patient_02">,
<id: 3, name: "patient_03">]
成功
スコープ
色々と調べた結果、スコープを使用することでうまくいきました。
class <モデル名> < ApplicationRecord
has_many :<関連モデル名>, -> { <クエリメソッド> }
end
スコープを使用することで、モデルの関連モデルとして呼び出したとき、クエリメソッドが実行されます。
例えば、著者: authorと書籍: bookが1対多の関係であったとき、
class Author < ApplicationRecord
has_many :books, -> { order(:publication_date) }
end
とすることで、
author.books
としたとき、書籍: bookを出版日: publication_dateの順番で取得することができます。
これを利用して多対多のアソシエーションの中間テーブルでの絞り込みを行います。
今回は中間テーブルである診察予約: appointmentの日付でクエリメソッドwhereを使用したいため、
class Physician < ApplicationRecord
has_many :appointments, -> { where(appointments_date: Date.today..) }
has_many :patients, through: :appointments
end
となります。
Date.todayで今日の日付を取得し、..で範囲指定。末端を指定しないことで、以降全てが範囲となります。
(..の末端未指定はRuby 2.6.0以降でのみ使用可能です。それ以前のバージョンの場合は、Float::INFINITYを指定してください。リファレンスマニュアル)
データの取得は、
Physician.preload(:patients)
と記述します。
今回は、eager_loadではなくpreloadを使用しています。
whereによる絞り込みをappointmentsテーブル単体で行い、joinsなどで他テーブルを参照する必要がないためです。
これはincludesでも同じ結果となります。
このとき発行されるクエリは、
Physician Load (0.8ms) SELECT `physicians`.* FROM `physicians`
Appointment Load (0.8ms) SELECT `appointments`.* FROM `appointments` WHERE `appointments`.`appointment_date` >= '2021-02-28' AND `appointments`.`physician_id` IN (1, 2)
Patient Load (0.5ms) SELECT `patients`.* FROM `patients` WHERE `patients`.`id` IN (2, 3)
となります。
1行目で医師: physicianのデータを取得。SELECTでphysiciansの全てのカラム*を取得しています。FROMはテーブルの指定です。
2行目は診察予約: appointmentのデータを取得しています。これも同じくSELECTで全てのカラム*を取得していますが、WHEREで絞り込みを行っています。
`appointments`.appointment_date` >= '2021-02-28'は、app/models/physician.rbに記載したスコープのクエリメソッドになります。
さらに、ANDで`appointments`.`physician_id` IN (1, 2)とすることで、1行目で取得した医師: physicianのidに関連するデータのみを取得しています。
最後に、3行目で患者: patientのデータ取得です。WHEREで`patients`.`id` IN (2, 3)と条件付けすることにより、patientsのidが、2行目で取得した診察予約: appointmentに関連するデータのみを取得しています。
これで、全ての医師: physicianと、本日以降の診察予約: appointmentがある患者: patientのデータを取得することができます。
physician = Physician.preload(:patients)
physician
=> [<id: 1, name: "physician_A">,
<id: 2, name: "physician_B">]
physician[0].patients
=> [<id: 2, name: "patient_02">,
<id: 3, name: "patient_03">]
physician[1].patients
=> []
しかし、今の状態では、アソシエーションで呼び出す全ての場合にクエリメソッドが効いてしまいます。
つまり、今日(2021/02/28)以前のデータをアソシエーションで呼び出すことができない状態です。
そのため、モデルの別名を作成し、絞り込みの際はそちらを使用することにします。
class_name
class_nameは、モデルを別名で使用する際、どのモデルのかを指定することができます。
class モデル名 < ApplicationRecord
has_many :関連モデルの別名, class_name: '関連モデル名'
end
例えば、筆者: authorと1対多である書籍: bookがあり、
書籍: booksテーブルのcommicカラムがtrueのとき、漫画であるとすると、
class Author < ApplicationRecord
has_many :comics, -> { where(commic: true) }, class_name: 'Book'
end
と、モデルに記述することで、
author.commics
のように、モデルの別名を使用して、書籍: booksテーブルの中の漫画のみをアソシエーションで取得することができます。
class_nameのモデル名は文字列指定・頭大文字・単数形であることに注意してください。
しかし、今回の場合は多対多のアソシエーションを使用しています。
中間テーブルにはclass_nameのオプションでいいのですが、アソシエーション先でthroughを使用しているとうまくいきません。
source
アソシエーションでthroughを使用している場合、別名をつける際はclass_nameの代わりに、sourceを使用します。
class モデル名 < ApplicationRecord
has_many :中間テーブルのモデルの別名, class_name: '中間テーブルのモデル名'
has_many :関連モデルの別名, through: :中間テーブルのモデルの別名, source: :関連モデル名
end
今回のように、別名の中間テーブルのモデルを使用する場合、throughの名前も別名を指定しなければなりません。
また、class_nameのときとは違い、sourceでは、シンボル指定・頭小文字・単数形で指定しています。単数形であることは変わりません。
よって、
class Physician < ApplicationRecord
has_many :appointments
has_many :patients, through: :appointments
has_many :future_appointments, -> { where(appointment_date: Date.today..) }, class_name: 'Appointment'
has_many :future_patients, through: :future_appointments, source: :patient
end
と記述し、
# 全て
physician.preload(:patients)
# 今日以降
physician.preload(:future_patients)
のように、使い分けることができます。
注意点: 引数は使用できません。
色々と調べたのですが、引数の使用はできないようでした。
Date.todayやtrue falseなど、値が決まっていたらhas_manyのスコープが使えるのですが、whereで日付を指定するなどはできませんでした。
# これはできません。
class Author < ApplicationRecord
has_many :books, ->(date) { where(created_at: date) }
end
引数?として使用できるのはそのモデルのデータだけのようです。
GitHub rails/…/associations.rb
最後に
言い回しや解釈の間違っているところ、さらに良い方法がございましたら、ご教示いただけますと幸です。
ご覧いただき、ありがとうございました。