1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails】has_one関連は自動で一緒に読み込まれない

1
Posted at

has_one関連を勘違いしていたRails初心者が書いた記事です。

has_many関連は1対多の関係である以上毎回すべて取得するのは大変そうなので、関連先参照時に適宜取得クエリを実行するのは理解できるのですが、has_one関連って「1つ」しかないのだから、自動的に読み込まれる(キャッシュされる)のだと思い込んでいました。

目次

要約

AとBが1対1の関係であり、A has Bの関連の場合、

  • has_oneを書いてるからって本体(A)を取得した際にhas_one関連先(B)を自動で読み込むことはない。関連先(B)を読み込む際に追加でクエリを実行する。
  • eager loadingを明示することで関連データを取得することができる。

経緯

Rails学習のために自作の勤怠管理アプリを制作していた時の話です。
アプリ内で月次の勤怠一覧を表示する際に問題が発生しました。
勤怠の打刻時間が並んで表示された画像

勤怠アプリのクラス図

(簡略化のため抜粋、改変しています)
従業員は複数の勤怠データを持ち、

月次の勤怠データを取得する際、N+1クエリ問題を覚えたての私は月の日数分クエリを実行しないようwhere文を用いてデータを一括取得しました。

target_attendances = @employee
                      .attendances
                      .where(worked_on: @target_dates)
                      .index_by(&:worked_on)

その後、勤怠データに関連する修正データが存在していれば、勤怠データの時刻の代わりに勤怠修正データの修正後時刻を表示するというif分岐をしていました。

(start_date..end_date).each do |date|
  # 省略
  modify_data = target_attendances[date]&.attendance_modify_data
  if modify_data.present?
    modify_dataを表示()
  end
  # 省略
end

このコードでRailsのサーバーを開いて勤怠一覧ページを開いたところ、ターミナルのログにて以下のクエリが実行されました。

Employee Load 1回
Attendance Load 1回
AttendanceModifyData Load 31回

問題が起きているのは明らかで、日数分だけクエリを繰り返し実行してしまっています。
出力された問題のログが以下の通りです。

AttendanceModifyData Load (0.3ms) SELECT (省略)
application='KintaiWorktime',controller='attendances'*/app/presenters/monthly_schedule_presenter.rb:25:in 'block in MonthlySchedulePresenter#rows'

この中の「monthly_schedule_presenter.rb:25」がまさに前述した修正データを取得するコードでした。

modify_data = target_attendances[date]&.attendance_modify_data

エラーは出ていないのでどこを直せばいいのか最初分からず、また「has_one関連なのでデータは取得できてるよな...(できてない)」という思い込みが相まって原因を特定するのに時間がかかりました。

原因判明

試行錯誤の中で「関連ってそもそもなんだ...?」と思いRailsガイドで調べた結果、has_oneを記述することでモデル間の関係を示し、関連先のオブジェクトを取得するメソッドを追加することはできるが、親オブジェクトを取得した時点で関連先のオブジェクトを自動的に読み込む機能なんてないことが判明しました。
Railsガイド_Active Recordの関連付け
また、取得するオブジェクトが持つhas_one関連先を事前に読み込むeager loadingという機能があることが分かり、それを利用することでN+1問題も解決できることが分かりました。

解決

先ほどの月次の勤怠データを取得するクエリにeager loadingを利用するよう明示的に修正しました。

修正前

target_attendances = @employee
                      .attendances
                      .where(worked_on: @target_dates)
                      .index_by(&:worked_on)

修正後

target_attendances = @employee
                      .attendances
                      .includes(:attendance_modify_data) # 追加
                      .where(worked_on: @target_dates)
                      .index_by(&:worked_on)

この1文を追加することによって、クエリ実行を大幅に減らすことができました。

Employee Load 1回
Attendance Load 1回
AttendanceModifyData Load 1回

なぜクエリが31回→1回になったかの説明
1.勤怠データを一括して取得
2.取得した勤怠データのIDを用いて勤怠修正データを一括取得
することで、関連先のデータ取得を実現しているみたいです。

(Attendance取得クエリ)
Attendance Load (0.5ms) SELECT "attendances".* FROM "attendances" WHERE "attendances"."employee_id" = 2 AND "attendances"."worked_on" BETWEEN '2026-05-01' AND '2026-05-31' /action='index',application='KintaiWorktime',controller='attendances'/app/presenters/monthly_schedule_presenter.rb:10:in 'MonthlySchedulePresenter#rows'

(AttendanceModifyData取得クエリ)
AttendanceModifyData Load (0.6ms) SELECT "attendance_modify_data".* FROM "attendance_modify_data" WHERE "attendance_modify_data"."attendance_id" IN (32, 37, 41, 33, 38, 34, 44, 46, 35, 39, 42, 36, 40, 45, 43, 48, 47, 49, 50, 51, 52, 53, 54, 55, 56, 57, 59, 60, 61, 62, 281) /action='index',application='KintaiWorktime',controller='attendances'/

RailsはSQLを意識せずにデータベースを扱える一方で、仕組みを理解していないとパフォーマンスが悪化しやすい。という事例でした。今後も開発環境で表示されるクエリに注視して開発を心掛けたい。

参考

Railsガイド_Active Recordの関連付け_関連付けをeager loadingする

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?