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と呼ぶことにする)ActiveSupport と ActiveRecord のファイル群をコピーする。
% 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 配列に代入エラーになった属性に関する情報を入れている。こうしておけば、検証エラーとして、その属性を画面に表示するできるようになるわけだ。