validation エラーの表示を validation 順に揃える
たとえば title:string, body:text という2つの属性を持つモデル Entry があったとする。そこで次のような validation を掛けたとしよう。
class Entry < ActiveRecord::Base validates_presence_of :title validates_presence_of :body end
これに対する入力フォームのビューはこんな感じだ。
<%= error_messages_for 'entry' %> <!--[form:entry]--> <p><label for="entry_title">Title</label><br/> <%= text_field 'entry', 'title' %></p> <p><label for="entry_body">Body</label><br/> <%= text_area 'entry', 'body' %></p> <!--[eoform:entry]-->
入力フォームで title と body を入力しないまま保存しようとすると、次のようなエラーメッセージが表示されるはずだ。
2 errors prohibited this entry from being saved There were problems with the following fields: * Title can't be blank * Body can't be blank
ここでは、たまたま Title → Body の順でエラーメッセージが表示されているが、実はこの順序は保証されていないことは、Rails アプリを少しでも作りこんだ人ならよくご存知のはずだ。
根本的な原因は、エラーメッセージが、「属性名→エラーメッセージ」というハッシュに格納されていることにある。ハッシュでは、要素間の順序は保存されない。各要素に順番にアクセスするとき、先に挿入した要素が先に現れるとは限らないのだ。
この問題を解決するために、次のようなモンキーパッチを作った。これを config/environment.rb の末尾に追加して Rails アプリを再起動すれば OK。モデル・ビュー・コントローラのコードに変更は必要ない。
module ActiveSupport class OrderedHash def each_key each { |key, value| yield key } end end end module ActiveRecord class Errors def initialize(base) # :nodoc: @base, @errors = base, ActiveSupport::OrderedHash.new end def clear @errors = ActiveSupport::OrderedHash.new end end module Validations module ClassMethods def write_inheritable_set(key, methods) existing_methods = read_inheritable_attribute(key) || [] write_inheritable_attribute(key, existing_methods | methods) end end end end
ActiveRecord::Errors の @errors はオリジナルでは Hash であった。それを ActiveSupport::OrderedHash に変更することで、エラーメッセージの順序を保存している。
やや不思議に思うのは write_inheritable_set() の実装である。オリジナルでは、こうなっていた。
write_inheritable_attribute(key, methods | existing_methods)
つまり、validation 用のブロックがリストの末尾ではなく、先頭に追加されていくのである。結果として、validation はモデルで現れる順番の逆順で実行されていた。上のモデルの例でいえば、まず :body の存在性検証、次いで :title の存在性検証、という順序で validation が走っていたのである。これには、何か特別な意味があるのだろうか?しばらく考えたが思いつかなかった。とりあえずこのコードで正しく validation は行われているようである。
GetText との共存(追記 2007/8/23)
上のやり方でうまく行くと思ったのだが、その後、GetText でフォームの検証メッセージの日本語化がうまく行かないことがわかった。GetText が当てている ActiveRecord::Errors へのパッチと衝突が起きていた。ActiveRecord::Errors#initialize を次のように変更する。他には修正点はない。
module ActiveSupport class OrderedHash def each_key each { |key, value| yield key } end end end module ActiveRecord class Errors # ただの上書きにしなかったのは、GetText の ActiveRecord パッチとの衝突を避けるため def initialize_with_validation_order(base) # :nodoc: initialize_without_validation_order(base) @errors = ActiveSupport::OrderedHash.new end alias_method_chain :initialize, :validation_order def clear @errors = ActiveSupport::OrderedHash.new end end module Validations module ClassMethods def write_inheritable_set(key, methods) existing_methods = read_inheritable_attribute(key) || [] write_inheritable_attribute(key, existing_methods | methods) end end end end