不正な日付のバリデーション

たとえばブログ記事のモデルEntryがあって、はてなダイアリーみたいに投稿者が投稿日を指定できるとする。日時や日付を入力したい場合、投稿画面では以下のように datetime_select や date_select を使うと凄く楽だ(posted_time は datetime カラム、posted_date は date カラムだとする)。

<%- form_for @entry do |f| -%>
時刻も指定:<%= f.datetime_select :posted_time %><br />
日付のみ:<%= f.date_select :posted_date %><br />
<% # 以下略 %>

この場合、日付選択部分で「2月31日」のような不正な日付を選択しても、ActiveRecord::Base が勝手に「3月2日」のような(余計な日数分だけ後ろにずらした)日付に修正してセットしてくれるため、選択できる範囲で何を選んでもエラーにはならない(Rails 2.1.x系の場合)。
これは便利ではあるんだけど、要件によっては、不正な日付が入力されたらエラーにして注意を喚起したい場合もあると思う。どうすればいいか。


とりあえず、ぼくのかんがえたれいるずかくちょうとしては、以下のようなコードを読み込ませてエラーが発生するようにしてしまえば良さそうな気がする。

module ActiveRecord
  class Base
    def instantiate_time_object_with_date_validation(name, values)
      Date.new(*(values[0..2])) # 不正な日付だったら例外
      instantiate_time_object_without_date_validation(name, values)
    end
    alias_method_chain :instantiate_time_object, :date_validation
  end
end

ここで発生した例外は最終的には ActiveRecord::MultiparameterAssignmentErrors になるので、validates_multiparameter_assignments プラグイン*1で捕まえてメッセージを出すなりしてあげれば良い。
まあ、もっとスタンダードな方法があるかもしれませんが初心者なので知りません。誰か教えてください。


以下、ここを拡張する理由の補足。

まず、どこでdatetime_selectやdate_selectの入力値をモデルの属性値としてセットしているかを探してみると、ActiveRecord::Base の最後のほうの execute_callstack_for_multiparameter_attributes というメソッドで、以下のように行っていることがわかる(name には属性名、value には datetime_select などで選択された [年,月,日,時,分,秒] の配列が入る)。

  • 対象の属性のクラスが Time (datetime カラム)だったら、以下の instantiate_time_object を呼び出し、結果をセット
  • 対象の属性のクラスが Date (date カラム)だったら、まず Date.new(*value) を呼び出し、結果をセットする。もしここでArgumentError が発生したら、同じ value を使って、上記と同じように instantiate_time_object を呼び出し、結果をDateオブジェクトに変換してセットする*2

ここで使われる instantiate_time_object の定義は

def instantiate_time_object(name, values)
  if self.class.time_zone_aware_attributes && !self.class.skip_time_zone_conversion_for_attributes.include?(name.to_sym)
    Time.zone.local(*values)
  else
    Time.time_with_datetime_fallback(@@default_timezone, *values)
  end
end

となっていて、タイムゾーンの設定に従って、画面入力(datetime_select なら [年,月,日,時,分,秒] の配列)を Time に変換しているらしい。Time クラスは、引数に不正な日付を渡してもなんとかしてくれる(java.util.Calendar のデフォルト lenient モードのように)ので、何を選択してもエラーにならないという結果になる。なので、このメソッドを拡張して、不正な日付を渡すと ArgumentError が発生する Date.new を呼び出して強引に日付のチェックを行えば良いということになる。

Rails 2.0.xあたりまでは、属性がDateだった場合のArgumentErrorをキャッチしていなかったため、datetime の場合は何を入れてもエラーにならず、dateの場合だけ MultiparameterAssignmentError になるという中途半端な挙動だったようだけど、2.1.xからはこういうふうになったらしい。ソース見る限り。

*1:ちなみにダウンロード先が未だによくわからない。GitHub に上がっている奴でいいんだろうか?作者が違うような気がするんだけど…

*2:つまり、上の _with_date_validation は再度同じことをやっているわけで無駄な気はする。ただこの execute_callstack_for_multiparameter_attributes は大きめのメソッドなのであまり拡張したくない…