sugimotoです。
Ruby on Rails にはいろいろ便利な機能がありますが、前から便利だけど微妙。。と思っているのが、date_select や datetime_select です。どちらも ActionView::Helpers::DateHelper のmethod で日付入力を簡単に作れますが、年月日のプルダウン入力はちょっと。。。という感じです。
ただ、この入力、Modelのdate,datetime なカラムにそのまま突っ込んで保存してしまえるのがとっても便利です。Post.published_at というカラムに
params = {
:post => {
:published_at(1i) => "2010",
:published_at(2i) => "09",
:published_at(3i) => "28",
:published_at(4i) => "15",
:published_at(5i) => "20"
}
}
みたいな感じでcontrollerにパラメータが渡されますが、
post = Post.new(params[:post])
とするだけで、post.published_at = ‘2010-09-28 15:20’ なレコードができてしまいます。
前置きが長くなりましたが、この仕組を調べてみました。 なお、今回例示したソースは公開時のtrunk を参照しています。
仕組みを調べてみる
まずはレコードを作成する場所、つまり、 ActiveRecord::Base::initialize を見てみます。
def initialize(attributes = nil)
@attributes = attributes_from_column_definition
@attributes_cache = {}
@new_record = true
@readonly = false
@destroyed = false
@marked_for_destruction = false
@previously_changed = {}
@changed_attributes = {}
ensure_proper_type
if scope = self.class.send(:current_scoped_methods)
create_with = scope.scope_for_create
create_with.each { |att,value| self.send("#{att}=", value) } if create_with
end
self.attributes = attributes unless attributes.nil?
result = yield self if block_given?
_run_initialize_callbacks
result
end
どうやら、受渡されたattributes を self.attributes=(attributes) でいれているだけですね。 では self.attributes=(v) では何をしているんでしょうか。
def attributes=(new_attributes, guard_protected_attributes = true)
return unless new_attributes.is_a?(Hash)
attributes = new_attributes.stringify_keys
multi_parameter_attributes = []
attributes = sanitize_for_mass_assignment(attributes) if guard_protected_attributes
attributes.each do |k, v|
if k.include?("(")
multi_parameter_attributes < < [ k, v ]
else
respond_to?(:"#{k}=") ? send(:"#{k}=", v) : raise(UnknownAttributeError, "unknown attribute: #{k}")
end
end
assign_multiparameter_attributes(multi_parameter_attributes)
end
"(" が付いた attribute を複数パラメータの属性として次のような配列を作成し、 assign_multiparameter_attributes に渡しています。
[
["published_at(1i)", "2010"],
["published_at(2i)", "09"],
["published_at(3i)", "28"],
["updated_at(1i)", "2010"],
["updated_at(2i)", "09"],
["updated_at(3i)", "28"],
["updated_at(4i)", "15"],
["updated_at(5i)", "10"],
["published_at(4i)", "15"],
["published_at(5i)", "10"],
]
では、次にassign_multiparameter_attributes を見てみます。
def assign_multiparameter_attributes(pairs)
execute_callstack_for_multiparameter_attributes(
extract_callstack_for_multiparameter_attributes(pairs)
)
end
method名から察するに、一度パラメータを展開して、それを処理しているようです。展開のmethodはどんな処理でしょう。
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)
parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value)
attributes[attribute_name] < < [ find_parameter_position(multiparameter_name), parameter_value ]
end
attributes.each { |name, values| attributes[name] = values.sort_by{ |v| v.first }.collect { |v| v.last } }
end
published_at(2i) のような名前から attributes["published_at"] という配列を作成して、(1i), (2i), (3i) などの値とセットで配列に積んでいっています。 さらに、名前でhashになった attributes を名前ごとにソートしています。 次のような hash が出来上がります。
ソート前 - 1つ目の要素が文字列のまま渡されているので、xxxx(11i) みたいなのがあると並び替えを間違える気がしますが、どうなんでしょうね。。。
attributes = {
"publised_at" => [["1", 2007], ["3", 28], ["4", 15], ["5", 10], ["2", 9], ],
"updated_at" => [["1", 2007], ["3", 28], ["4", 15], ["5", 10], ["2", 9], ],
}
ソート後 (1つ目の要素でソートされ、2つ目の要素で collect された)
attributes = {
"publised_at" => [2007, 9, 28, 15, 10 ],
"updated_at" => [2007, 9, 28, 15, 10 ],
}
type_cast_attribute_value, find_parameter_position の2つのメソッドは名前から何をしているかなんとなく想像がつきますが、一応見てみます。
def type_cast_attribute_value(multiparameter_name, value)
multiparameter_name =~ /\([0-9]*([if])\)/ ? value.send("to_" + $1) : value
end
xxxx(1i) のような名前のパラメータを to_i でtype cast した値を返します。どうやら、xxxx(1f) のような名前にすると Float にcastするようです。
def find_parameter_position(multiparameter_name)
multiparameter_name.scan(/\(([0-9]*).*\)/).first.first
end
xxxx(1i) のようなパラメータ名から数字を取り出し、パラメータの順序を返しています。
そろそろ先が見えてきた感じがします。(1i) に従って配列にされたパラメータの処理をする execute_callstack_for_multiparameter_attributes を見てみます。
def execute_callstack_for_multiparameter_attributes(callstack)
errors = []
callstack.each do |name, values_with_empty_parameters|
begin
klass = (self.class.reflect_on_aggregation(name.to_sym) || column_for_attribute(name)).klass
# in order to allow a date to be set without a year, we must keep the empty values.
# Otherwise, we wouldn't be able to distinguish it from a date with an empty day.
values = values_with_empty_parameters.reject { |v| v.nil? }
if values.empty?
send(name + "=", nil)
else
value = if Time == klass
instantiate_time_object(name, values)
elsif Date == klass
begin
values = values_with_empty_parameters.collect do |v| v.nil? ? 1 : v end
Date.new(*values)
rescue ArgumentError => ex # if Date.new raises an exception on an invalid date
instantiate_time_object(name, values).to_date # we instantiate Time object and convert it back to a date thus using Time's logic in handling invalid dates
end
else
klass.new(*values)
end
send(name + "=", value)
end
rescue => ex
errors < < AttributeAssignmentError.new("error on assignment #{values.inspect} to #{name}", ex, name)
end
end
unless errors.empty?
raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes"
end
end
なかなか長いです。。前半の klass = (....).klass でカラムの型を取ってきて、Time であれば、attributes の配列を instantiate_time_object で Date であれば、Date.new でインスタンス化して、最後に send(name + "=", value) でインスタンス化した値をカラムにセットしています。 Date でも Timeでもなかった場合は、klass.new(*values) で強引にインスタンス化しようとしますね。強気です。
最後に
なぜ、datetime_select を調べたかというと、datetime なカラムの入力フォームを作成するときに日付と時間を別にして日付部分をdatepicker にできないかな、と考えていたからでした。
xxxxx(1i) 的なパラメータ名を設定して、上記処理を通ったあとに、Date.new できる配列になれば良いってことで、やっぱり、datetime_select が生成するようなhashを作らないといけないんだな。。って感じです。