yucatio@システムエンジニア

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

Railsでカスタムバリデータとvalidates_withで2つの日時の前後、最小間隔、最大間隔のチェック

開始時間と終了時間を入力するアプリで、入力の前後関係と最小間隔、最大間隔をチェックしたい場合があります。 チェックを実装するには、Railsではカスタムバリデータとカスタムメソッドの方法があります。開始時間、終了時間をもつアプリは多いので、今回は汎用的に使えるカスタムバリデータを実装します。

こちらの記事で作成した会議室予約アプリを使います。

yucatio.hatenablog.com

入力画面はこんな感じです。

f:id:yucatio:20180826224710p:plain

仕様

  • 開始時間は終了時間より後
  • 開始時間と終了時間の間は15分以上
  • 開始時間と終了時間の間は6時間以下

実行イメージ

開始時間が終了時間より後

f:id:yucatio:20180826224921p:plain

間隔が狭すぎる

f:id:yucatio:20180826225451p:plain

正常範囲内

f:id:yucatio:20180826230416p:plain

間隔が広すぎる

f:id:yucatio:20180826225804p:plain

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を取れればよかったのですが、日数が月によって違うことによる影響を調査しきれなかったので今回は実装しませんでした。

その他色々つっこみどころはあると思いますが、とりあえず自分が必要な分は実装できたので、よしとします。

環境