Railsでカスタムバリデータとvalidates_withで2つの日時の前後、最小間隔、最大間隔のチェック
開始時間と終了時間を入力するアプリで、入力の前後関係と最小間隔、最大間隔をチェックしたい場合があります。 チェックを実装するには、Railsではカスタムバリデータとカスタムメソッドの方法があります。開始時間、終了時間をもつアプリは多いので、今回は汎用的に使えるカスタムバリデータを実装します。
こちらの記事で作成した会議室予約アプリを使います。
入力画面はこんな感じです。
仕様
- 開始時間は終了時間より後
- 開始時間と終了時間の間は15分以上
- 開始時間と終了時間の間は6時間以下
実行イメージ
開始時間が終了時間より後
間隔が狭すぎる
正常範囲内
間隔が広すぎる
TimeRangeValidatorの仕様
TimeRangeValidatorは、今回作成するカスタムValidatorで、2つの日付の前後、最小間隔、最大間隔のチェックをします。
エラーの表示で"15分以上にしてください"や"6時間以下にしてください"と、数字と単位を表示したいので、数字と単位を別々に受け取ります。
- 開始時間、終了時間の属性名をシンボルで受け取る
- 最小の間隔(整数)と時間単位(
:second :minute :hour :day
のうちのいずれか)を受け取る - 最大の間隔(整数)と時間単位(
:second :minute :hour :day
のうちのいずれか)を受け取る - 開始時間より終了時間が前ならばエラー。開始時間と終了時間が同じ場合もエラー
- 開始時間と終了時間の間が、最小間隔より小さければ、エラー
- 開始時間と終了時間の間が、最大間隔より大きければ、エラー
エラーメッセージファイルと言語ファイル
先にエラーメッセージを記述します。
config/locales/app_errors_ja.yml
ja: errors: messages: end_time_before_start_time: は%{end_attribute}よりも前に設定してください too_long_time: と%{end_attribute}の間は%{duration}以下にしてください too_short_time: と%{end_attribute}の間は%{duration}以上にしてください
%{end_attribute}
には終了時間の属性名、%{duration}
には"1日"や"5時間"、"10分" などが入るようにします。
"x日" "x時間" "x分" "x秒"はja.ymlに記載されています。 ここからダウンロードしました。
rails-i18n/ja.yml at master · svenfuchs/rails-i18n · GitHub
x_hours
が記載されていなかったので追加します。
config/locales/ja.yml
ja: # 略 datetime: distance_in_words: # 略 x_days: one: 1日 other: "%{count}日" x_hours: one: 1時間 other: "%{count}時間" x_minutes: one: 1分 other: "%{count}分" x_months: one: 1ヶ月 other: "%{count}ヶ月" x_seconds: one: 1秒 other: "%{count}秒" # 後略
Validatorの実装
app
ディレクトリの下に、validators
ディレクトリを作成し、time_range_validator.rb
ファイルを作成します。
class TimeRangeValidator < ActiveModel::Validator ACCEPTABLE_TIME_UNIT = %i[second minute hour day] def validate(record) start_time = record.send(options[:start_time]) end_time = record.send(options[:end_time]) min_number = options[:min_number] min_time_unit = ACCEPTABLE_TIME_UNIT.include?(options[:min_time_unit]) ? options[:min_time_unit] : :day max_number = options[:max_number] max_time_unit = ACCEPTABLE_TIME_UNIT.include?(options[:max_time_unit]) ? options[:max_time_unit] : :day return unless start_time.present? && end_time.present? if start_time >= end_time record.errors.add( options[:start_time], :end_time_before_start_time, end_attribute: record.class.human_attribute_name(options[:end_time])) elsif min_number && start_time + min_number.send(min_time_unit) > end_time record.errors.add( options[:start_time], :too_short_time, end_attribute: record.class.human_attribute_name(options[:end_time]), count: min_number, duration: I18n.t("datetime.distance_in_words.x_#{min_time_unit}s", count: min_number) ) elsif max_number && start_time + max_number.send(max_time_unit) < end_time record.errors.add( options[:start_time], :too_long_time, end_attribute: record.class.human_attribute_name(options[:end_time]), duration: I18n.t("datetime.distance_in_words.x_#{max_time_unit}s", count: max_number) ) end end end
最初に、ACCEPTABLE_TIME_UNIT = %i[second minute hour day]
で指定可能な時間単位の配列を宣言しています。%i[]
でシンボルの配列を作成できます。
バリデータクラスに渡された引数は、options[ハッシュのキー]
で取得することができます。
全体的にsend()
メソッドを多用しています。send()メソッドは、レシーバに対して、引数に与えられたメソッドを呼び出すメソッドです。min_number=5, min_time_unit=:day
のとき、min_number.send(min_time_unit)
は、5.day
を呼び出したのと同じになります。
send()
メソッドは、メソッド名が変数で与えられた時に有用です。
エラーの追加は、record.errors.add()
ですることができます。
start_time + min_number.send(min_time_unit) > end_time
という条件は、開始時間と終了時間の間隔が最小間隔より短いか判別しています。end_time - start_time < min_number.send(min_time_unit)
と書いた方がわかりやすい気がしますが、:month
への時間単位の拡張を考え、このような表現にしています。(monthを考慮に入れると、2つの条件式は同じではありません)
呼び出し側の実装
app/models/meeting.rb
class Meeting < ApplicationRecord belongs_to :meeting_room # 略 validates_with TimeRangeValidator, start_time: :start_time, end_time: :end_time, min_number: 15, min_time_unit: :minute, max_number: 6, max_time_unit: :hour # 略 end
完成
以上で2つの日付の前後、最小間隔、最大間隔のチェックするカスタムvalidatorの完成です。
"15分"や"6時間"など、時間単位を動的に表示したかったので、数字部分と単位部分を分けて渡すことにより実現しました。Duration を使用して、min: 15.minutes, max: 6.hour
のように書いて、"15分"や"6時間"と表示できればよかったのですが、力及ばず。
time_ago_in_words (ActionView::Helpers::DateHelper) は期待する挙動と違いますし。
また、引数に:month
を取れればよかったのですが、日数が月によって違うことによる影響を調査しきれなかったので今回は実装しませんでした。
その他色々つっこみどころはあると思いますが、とりあえず自分が必要な分は実装できたので、よしとします。