yucatio@システムエンジニア

趣味で作ったものいろいろ

Railsでvalidation中にDBアクセスする場合は、will_save_change_to_#{attr_name}?を使用する

Webアプリでvalidationを書いていると、DBに問い合わせをしなければいけない時があるのですが、その時に、値が変更されているかチェックしてからDB問い合わせした方がよいよというお話です。値が変更されているかは、Railsではwill_save_change_to_#{attr_name}?というメソッドが用意されています。#{attr_name}の部分はModelの属性(カラム名)で置き換えます。

値が変更されているか調べてからvaldiateする

前回に引き続き、会議室予約アプリを例にして、具体的な使い方をみてみます。

会議室(meeting_room)は複数の会議予約(meeting)をもちます。会議予約は、開始時刻(start_time)と終了時刻(end_time)をもちます。

f:id:yucatio:20180809214216p:plain

★前回の記事

yucatio.hatenablog.com

yucatio.hatenablog.com

仕様

  • 会議予約(前回実装済み)
    • subjectは32文字以下で必須項目
    • start_timeは必須項目
    • end_timeは必須項目
    • end_timeはstart_timeより後であること
    • 新規登録時、start_timeは現在時刻より後であること
    • start_timeが変更された時、start_timeは現在時刻より後であること
    • 保存済みのstart_timeが現在より前の場合は、start_timeを変更できない
    • 保存済みのstart_timeが現在より前の場合は、end_timeを変更できない

これに、

  • 会議予約
    • 同一の会議室に属するmeetingの期間は、重複してはならない。つまり、ある時刻に1つの会議室で開催されている会議は最大で1つである。

というvalidationを追加します。

コード

仕様を満たすコード
class Meeting < ApplicationRecord
  # 略

  validate :meeting_time_should_not_overlap

  private
  # 略

  def meeting_time_should_not_overlap
    return unless start_time && end_time

    if Meeting
           .where(meeting_room_id: meeting_room.id)
           .where('start_time < ?', end_time)
           .where('end_time > ?', start_time)
           .where.not(id: id).exists?
      errors.add(:base, '他の会議と重複しています。')
    end
  end
end

Meeting.where(meeting_room_id: meeting_room.id).where('start_time < ?', end_time).where('end_time > ?', start_time)で登録しようとする会議予約と同じ会議室の中で、開始終了時間と重複するレコードを検索します。直感的にわかりづらい条件ですが、これで検索できます。

yucatio.hatenablog.com

続くwhere.not(id: id)で、更新対象のレコードを除外しています。除外しないと対象レコードも検索に引っかかってきてしまうためです。最後にexsists?をつけて、存在するかどうかだけを確認しています。

生成されるSQLは以下のようになります。

SELECT  1 AS one 
FROM "meetings" 
WHERE "meetings"."meeting_room_id" = ?
    AND (start_time < '2018-08-10 12:00:00')
    AND (end_time > '2018-08-10 11:00:00')
    AND "meetings"."id" != ? 
LIMIT ?
[["meeting_room_id", 1], ["id", 3], ["LIMIT", 1]]

このコードで問題なく動くのですが、ひとつ気になるのは、このSQLは、validationのたびに実行されるということです。例えば、subjectしか更新しないようなリクエストでも毎回重複チェックのためのSQLが実行されます。

一般的にSQLの実行はコストが高いため、必要なとき以外は実行しないようにコードを書き換えます。

値が変更された場合のみDBアクセスをするコード

validationが必要なのは、登録時と開始時間または終了時間が変更されたときです。

class Meeting < ApplicationRecord
  # 略

  validate :meeting_time_should_not_overlap, if: :new_or_start_or_end_time_changed

  private
  # 略

  def new_or_start_or_end_time_changed
    new_record? || will_save_change_to_start_time? || will_save_change_to_end_time?
  end

  def meeting_time_should_not_overlap
    return unless start_time && end_time

    if Meeting
           .where(meeting_room_id: meeting_room.id)
           .where('start_time < ?', end_time)
           .where('end_time > ?', start_time)
           .where.not(id: id).exists?
      errors.add(:base, '他の会議と重複しています。')
    end
  end
end

new_record?(登録時)、will_save_change_to_start_time?(start_timeが変更されているとき)、そしてwill_save_change_to_end_time?(end_timeが変更されているとき)にのみSQLを実行するように変更できました。

実行結果

現在の予約状況

f:id:yucatio:20180811230448p:plain

他の会議室101の会議時間を重複する会議は登録できない↓

f:id:yucatio:20180811231217p:plain

他の会議室101の会議と重複しない会議は登録できる↓

f:id:yucatio:20180811231627p:plain

他の会議室101の会議時間を重複する日時に変更できない↓

f:id:yucatio:20180811232313p:plain

他の会議室101の会議と重複しない会議は登録できる↓

f:id:yucatio:20180811233203p:plain

日時が変更されない(subjectのみ変更)場合は、重複チェックSQLが実行されない↓

f:id:yucatio:20180811233935p:plain

環境

参考

Rails4とRails5ではDBの値をとるメソッドや変更されたかを取得するメソッドの名前が変更されています。rail5の方がわかりやすいメソッド名ですね。#{attr_name}_in_databasewill_save_change_to_#{attr_name}? 以外のメソッドもこちらにまとまっています。

qiita.com