multiparameter assignment を応用したフォームの入力

たとえば次のようなフォームは時々見かける。

あなたのご興味(複数選択可)
コンピュータ
旅行

...

これを Rails でうまく扱う方法を考えてみる。

User モデルに interests という文字型のカラムを設けることにしよう。たとえば車と旅行に興味があれば、"|" を区切り文字として、interests カラムに "車|旅行"のように格納することにする。

View には次のように記述する。

<input type="checkbox" name="user[interests(1s)]" value="コンピュータ"/>コンピュータ
<input type="checkbox" name="user[interests(2s)]" value="旅行" />旅行
<input type="checkbox" name="user[interests(3s)]" value="車" />

name 属性の user[interests(1s)] を見てみる。(1s) が multiparameter assignment 固有である。1 は順序を、s は文字型(String) を表している。(他には i(=整数型)や f(=浮動小数型)などがある)

User モデルには次のように記述する。ActiveRecord::Base#execute_callstack_for_multiparameter_attributes を上書きする。

class User < ActiveRecord::Base
  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
      
      if values.empty?
        send(name + "=", nil)
      else
        begin
          value = if name == 'interests'
            values.join("|")
          else  
            Time == klass ? (@@default_timezone == :utc ? klass.utc(*values) : klass.local(*values)) : klass.new(*values)  
          end
          send(name + "=", value)
        rescue => ex
          errors << ActiveRecord::AttributeAssignmentError.new("error on assignment #{values.inspect} to #{name}", ex, name)
        end
      end
    end
    unless errors.empty?
      raise ActiveRecord::MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes"
    end
  end
end

詳しくは、Multiparameter Assignment を理解するを参照してほしい。要するに、execute_callstack_for_multiparameter_attributes では、interests(1s), interests(2s), interests(3s) という3つの別々の画面要素を一つのフィールドの値に結合しているのである。上の例では name == 'interests" のとき values == ["車", "旅行"] となっている。(A) それを "|" を区切り文字として join すれば、interests カラムの値の出来上がりである。(B)

multiparameter assignment が使われる典型例は日付の入力であるが、たとえば市外局番とそれ以外に分かれた電話番号だとか、いろいろ使えそうである。

composed_of について(追記)

トラックバックで「もう一つのmultiparameter assignment を応用したフォームの入力」という記事を紹介いただいた。composed_of を使って同様のことをしようとする大変興味深い試みだ。自分で手を動かしていろいろやってみた結果次のようなことがわかった。

class User < ActiveRecord::Base
  composed_of :interest_list, :class_name => 'Array'
  def interest_list=(list)
    interests = list.join('|')
  end
end

これで同じことができるじゃん、というご指摘だった。非常にシンプルでこちらのほうが私の ugly hack なやり方より優れていると思う。ただ一つ指摘したいのは、上のコードはそのままでは動かなかったということだ。実際にはたとえば次のようなやり方がある。

class MyArray < Array
  def initialize(*args)
    super(args)   
  end
end

class User < ActiveRecord::Base
  composed_of :interest_list, :class_name => 'MyArray'
  def interest_list=(list)
    interests = list.join('|')
  end
end

なぜこんなことをしなければならないか理解するには、上の execute_callstack_for_multiparameter_attributes() をもう一度読む必要がある。本質的な部分だけ抜き出して、大幅に簡略化すると以下のようになる。

class User
  callstack.each do |name, values|
    klass = (self.class.reflect_on_aggregation(name.to_sym) || column_for_attribute(name)).klass #(A)
    send(name + "=", klass.new(*values)) #(B)
  end
end

上の例で composed_of :interest_list は、interest_list という名の AggregateReflection オブジェクトを作る。このオブジェクトの klass は上の定義から、MyArray になっている。したがって A でローカル変数には MyArray クラスが入る。問題は (B) だ。これは結局

interests_list=(MyArray.new(*values))

となる。values には配列が入っている。残念なことに、Array.new(*value) はうまくいかない。そういうコンストラクタが存在しないからだ。そのため MyArray クラスで initialize を再定義しているのだ。

execute_callstack_for_multiparameter_attributes() の最大の問題点はここにある。オブジェクトを生成するときに、かならず new(*values) しなければならないという柔軟性の欠如が問題なのだ。本当はこれがブロック渡しとかで柔軟に処理できればいいのかも。いずれにしろ、execute_callstack_for_multiparameter_attributes() は一度どこかでハックする必要があるかもしれない。