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