Multiparameter Assignment を理解する

趣旨

仕事で validates_multiparameter_assignments プラグインを使う機会があったので、Multiparameter Assignment の動作原理を理解したい。このプラグインは、2007/2/31 みたいな日付データを検証エラーにしたいという意図から作られている。

Multiparameter Assignment とは、date_select ヘルパーメソッドが作り出す

{ "entry" => {"written_on(1i)" => 2007, "written_on(2i)" => 6, "written_on(3i)" => 12}}

というリクエストパラメータのように、フォーム上で一つの属性が複数の画面要素の値として表現されているとき、これを 「written_on の 2007/6/12 という値」というように、1つの属性の値として再構成することをさす。

テスト環境の構築

テスト用のディレクトリを作り(AR_TEST_DIRと呼ぶことにする)ActiveSupportActiveRecord のファイル群をコピーする。

% mkdir $AR_TEST_DIR
% cd $AR_TEST_DIR
% cp -r /usr/local/lib/ruby/gems/1.8/gems/activesupport-1.4.2 activesupport
% cp -r /usr/local/lib/ruby/gems/1.8/gems/activerecord-1.15.3 activerecord

SQLite3 でデータベースを作っておく。

% sqlite3 active_record_test.sqlite3
SQLite version 3.2.1
Enter ".help" for instructions
sqlite> CREATE TABLE entries ("id" INTEGER PRIMARY KEY NOT NULL, "written_on" date DEFAULT NULL);


それから、次のようなテストコードを書いてみた。

# $AR_TEST_DIR/multiparameter_assignment_test.rb

require File.dirname(__FILE__) + "/activerecord/lib/active_record"
require 'test/unit'

ActiveRecord::Base.establish_connection(
    :adapter => "sqlite3",
    :database => "active_record_test.sqlite3"
)

class Entry < ActiveRecord::Base
end

class MultiAssignTest < Test::Unit::TestCase
  def setup
    ActiveRecord::Base.connection.begin_db_transaction()   
  end
  
  def teardown
    ActiveRecord::Base.connection.rollback_db_transaction()  
  end

  def test_multiparameter_assignment
    e = Entry.new("written_on(3i)"=>"12", "written_on(1i)"=>"2007", "written_on(2i)"=>"6")
    assert e.save_without_transactions
  end
end

これでテストを走らせると、

% ruby multiparameter_assignment_test.rb
Loaded suite multiparameter_assignment_test
Started
.
Finished in 0.048046 seconds.

1 tests, 1 assertions, 0 failures, 0 errors

となってテスト成功である。
*1

ActiveRecord::Base をめぐる冒険

準備はできたので、ActiveRecord の深層に潜って行きたい。$AR_TEST_DIR/activerecord/lib/base.rb を開くと、assign_multiparameter_attributes という名前のメソッドがあるから、これが関係するだろうと当たりをつけて、いきなり raise してみる。

# $AR_TEST_DIR/activerecord/lib/base.rb
def assign_multiparameter_attributes(pairs)
  raise; execute_callstack_for_multiparameter_attributes(
    extract_callstack_for_multiparameter_attributes(pairs)
  )
end

ここで、前節のテストコードを再実行する。

% ruby multiparameter_assignment_test.rb
Loaded suite multiparameter_assignment_test
Started
E
Finished in 0.103404 seconds.

  1) Error:
test_multiparameter_assignment(MultiAssignTest):
RuntimeError:
    ./activerecord/lib/active_record/base.rb:2073:in `assign_multiparameter_attributes'
    ./activerecord/lib/active_record/base.rb:1675:in `attributes='
    ./activerecord/lib/active_record/base.rb:1505:in `initialize_without_callbacks'
    ./activerecord/lib/active_record/callbacks.rb:225:in `initialize'
    multiparameter_assignment_test.rb:22:in `new'
    multiparameter_assignment_test.rb:22:in `test_multiparameter_assignment'

1 tests, 0 assertions, 0 failures, 1 errors

という感じで、予想通り例外を投げてくれる。raise の代わりに、

# $AR_TEST_DIR/activerecord/lib/base.rb
def assign_multiparameter_attributes(pairs)
  puts "### pairs = " + pairs.inspect
  execute_callstack_for_multiparameter_attributes(
    extract_callstack_for_multiparameter_attributes(pairs)
  )
end

と pairs の中身を表示させると、結果は、

### pairs = [["written_on(3i)", "12"], ["written_on(1i)", "2007"], ["written_on(2i)", "6"]]

となった。このデータを assign_multiparameter_attributes はどう料理するのか。extract_callstack_for_multiparameter_attributes が用意したデータに対して、execute_callstack_for_multiparameter_attributes がなんらかの処理をしている。順番に見ていこう。

# $AR_TEST_DIR/activerecord/lib/base.rb
def extract_callstack_for_multiparameter_attributes(pairs)
  attributes = { }

  for pair in pairs
    multiparameter_name, value = pair
    attribute_name = multiparameter_name.split("(").first
    attributes[attribute_name] = [] unless attributes.include?(attribute_name)

    unless value.empty?
      attributes[attribute_name] <<
        [ find_parameter_position(multiparameter_name), type_cast_attribute_value(multiparameter_name, value) ]
    end
  end

  attributes.each { |name, values| attributes[name] = values.sort_by{ |v| v.first }.collect { |v| v.last } }
end

ごちゃごちゃいろいろやっているが、要するに、

[["written_on(3i)", "12"], ["written_on(1i)", "2007"], ["written_on(2i)", "6"]]

という配列を, データ型や並び順を配慮しながら、

{"written_on" => [2007, 6, 12]}

というハッシュに変換している。

次に、execute_callstack_for_multiparameter_attributes を見てみる。いま入力として、callstack に {"written_on" => [2007, 12, 6]} が入ったと想像して次のコードを読んでほしい。

# $AR_TEST_DIR/activerecord/lib/base.rb
def execute_callstack_for_multiparameter_attributes(callstack)
  errors = []
  callstack.each do |name, values|
    klass = (self.class.reflect_on_aggregation(name.to_sym) || column_for_attribute(name)).klass # (A)
    if values.empty?
      send(name + "=", nil)
    else
      begin
        send(name + "=", Time == klass ? (@@default_timezone == :utc ? klass.utc(*values) : klass.local(*values)) : klass.new(*values)) # (B)
      rescue => ex
        errors << AttributeAssignmentError.new("error on assignment #{values.inspect} to #{name}", ex, name)
      end
    end
  end
  unless errors.empty?
    raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes" # (C)
  end
end

(A) はいささかわかりずらい。右辺の前半部は、self.class.reflect_on_aggregation(name.to_sym) はリフレクションがらみの処理なので、ここでは関係ない。後半がここでは重要である。column_for_attribute(name) は、"written_on" に対応するカラムを返す。これは ActiveRecord::ConnectionAdapters::Column のインスタンスである。*2 Column#klass は、このカラムに対応する Ruby のクラスを意味する。"written_on" の場合、

Column#type(SQL型) Column#klass(Rubyクラス)
date Date

となる。というわけで、(A) で klass には、われわれの例では Date が入ることになる。(B) で Date.new(2007, 6, 12) が実行されて、めでたく "2007/6/12" という Date オブジェクトが手に入る。ここで、2007/2/31 などというデータを与えたとすると、Date.new(2007, 2, 31) は例外を送出し、それが (C) で MultiparameterAssignmentErrors 例外に変換されて、再送出される。

validates_multiparameter_assignments プラグインのしていること

ここまで理解してしまえば、後は簡単だ。validates_multiparameter_assignments プラグインの全ソースをここに掲げる。

module ActiveRecord
  module Validations
    module ClassMethods
      def validates_multiparameter_assignments(options = {})
        configuration = { :message => ActiveRecord::Errors.default_error_messages[:invalid] }.update(options)
        
        alias_method :assign_multiparameter_attributes_without_rescuing, :assign_multiparameter_attributes
        attr_accessor :assignment_error_attrs
        
        define_method(:assign_multiparameter_attributes) do |pairs| # (A)
          self.assignment_error_attrs = []
          begin
            assign_multiparameter_attributes_without_rescuing(pairs)
          rescue ActiveRecord::MultiparameterAssignmentErrors
            $!.errors.each do |error|
              self.assignment_error_attrs << error.attribute
            end
          end
        end
        private :assign_multiparameter_attributes
        
        validate do |record|
          record.assignment_error_attrs && record.assignment_error_attrs.each do |attr|
            record.errors.add(attr, configuration[:message])
          end
        end
      end
    end
  end
end

validates_multiparameter_assignments というクラスメソッドを1つ定義しているだけである。このクラスメソッドは、ActiveRecord の初期化時に ActiveRecord::Base クラスにクラスメソッドとして mix-in される。重要なのは、中盤 (A) の部分である。ここで、前節で説明した assign_multiparameter_attributes を上書きしている。MultiparameterAssignmentErrors 例外送出の代わりに、assignment_error_attrs に代入エラーとなった属性を溜め込んでおく。そして、validation 時に、ActiveRecord::Base のサブクラスのインスタンスの errors 配列に代入エラーになった属性に関する情報を入れている。こうしておけば、検証エラーとして、その属性を画面に表示するできるようになるわけだ。

つぶやき

validates_multiparameter_assignments プラグインで、

self.assignment_error_attrs << error.attribute

の代わりに、

write_inheritable_array(:assign_multiparameter_attributes, error.attribute)

とかするとより Rails ぽかったかも、とか妄想してみる。

*1:ここで、save_without_transactions を使うのは、単に setup / teardown で一つのトランザクションにして、最後にロールバックしたいからである。いまいちスマートではないが。通常の単体テストだと、teardown のところでロールバックされるわけだが、save のまま普通にテストケースの中で使える。どう実装されているのかな??

*2:正確には、Column を実装するアダプターごとの具象サブクラス(たとえば SQLiteColumn) のインスタンス