目的
多対多のアソシエーションを組んでいて、関連テーブルのデータも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
最後に
言い回しや解釈の間違っているところ、さらに良い方法がございましたら、ご教示いただけますと幸です。
ご覧いただき、ありがとうございました。