6
3

More than 3 years have passed since last update.

【Rails】関連先のデータを、中間テーブルで絞り込む。

Posted at

目的

多対多のアソシエーションを組んでいて、関連テーブルのデータもincludeなどで取得するとき、
関連テーブル先のデータを中間テーブルのデータで絞り込む方法です。

前提

患者: patient医師: physicianとの診察予約: appointmentをとります。
診察予約が中間テーブルになります。
Railsガイドを参考にしました。

migrate

db/migrate/xxxxxxxxxxxxxx_create_physicians.rb
# 医師
class CreatePhysicians < ActiveRecord::Migration[6.1]
  def change
    create_table :physicians do |t|
      t.string :name

      t.timestamps
    end
  end
end
db/migrate/xxxxxxxxxxxxxx_create_appointments.rb
# 診察予約(中間テーブル)
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
db/migrate/xxxxxxxxxxxxxx_create_patients.rb
# 患者
class CreatePatients < ActiveRecord::Migration[6.1]
  def change
    create_table :patients do |t|
      t.string :name

      t.timestamps
    end
  end
end

belongs_toreferencesのエイリアスです。GitHub rails/…/schema_definitions.rb

models

app/models/physician.rb
class Physician < ApplicationRecord
  has_many :appointments
  has_many :patients, through: :appointments
end
app/models/appointment.rb
class Appointment < ApplicationRecord
  belongs_to :physician
  belongs_to :patient
end
app/models/patient.rb
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

方法

app/models/physician.rb
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対多の関係であったとき、

models/author.rb
class Author < ApplicationRecord
  has_many :books, -> { order(:publication_date) }
end

とすることで、

author.books

としたとき、書籍: book出版日: publication_dateの順番で取得することができます。


これを利用して多対多のアソシエーションの中間テーブルでの絞り込みを行います。
今回は中間テーブルである診察予約: appointmentの日付でクエリメソッドwhereを使用したいため、

app/models/physician.rb
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のデータを取得。SELECTphysiciansの全てのカラム*を取得しています。FROMはテーブルの指定です。

2行目は診察予約: appointmentのデータを取得しています。これも同じくSELECTで全てのカラム*を取得していますが、WHEREで絞り込みを行っています。
`appointments`.appointment_date` >= '2021-02-28'は、app/models/physician.rbに記載したスコープのクエリメソッドになります。
さらに、AND`appointments`.`physician_id` IN (1, 2)とすることで、1行目で取得した医師: physicianidに関連するデータのみを取得しています。

最後に、3行目で患者: patientのデータ取得です。WHERE`patients`.`id` IN (2, 3)と条件付けすることにより、patientsidが、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のとき、漫画であるとすると、

app/models/author.rb
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では、シンボル指定・頭小文字・単数形で指定しています。単数形であることは変わりません。

よって、

app/models/physician.rb
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.todaytrue falseなど、値が決まっていたらhas_manyのスコープが使えるのですが、whereで日付を指定するなどはできませんでした。

# これはできません。
class Author < ApplicationRecord
  has_many :books, ->(date) { where(created_at: date) }
end


引数?として使用できるのはそのモデルのデータだけのようです。
GitHub rails/…/associations.rb

最後に

言い回しや解釈の間違っているところ、さらに良い方法がございましたら、ご教示いただけますと幸です。
ご覧いただき、ありがとうございました。

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3