Rails の フォームヘルパーである datetime_select は、year, month, day, hour, minute
の5つのフォームを生成し、入力したらパラメータも5つ別々に渡されます。
しかし、assign_attributes
を使用すると、一つのパラメータに成形して、model に上手いこと落とし込むのです。
このようなassign_attributes
の不思議な動作に興味を持ったので binging.pry で追ってみました。
□ 発端
schema.rb
# @list の data type はこんな感じ
...
create_table "lists"
t.string "name",
t.datetime "reserved_at"
end
...
# View で以下のように定義して Submit したら...
= form_with "~省略~"
= f.text_field :name
= f.datetime_select :reserved_at
= f.submit
# パラメータが分かれてる?
params
=> {
"name"=>"ダミー名前",
"reserved_at(1i)"=>"2020",
"reserved_at(2i)"=>"3",
"reserved_at(3i)"=>"2",
"reserved_at(4i)"=>"00",
"reserved_at(5i)"=>"00"
}
# インスタンスに取り込んだら...
@list.assign_attributes(params)
# 成形されてる...!?
@list.reserved_at
=> Sun, 02 Mar 2020 00:00:00 JST +09:00
□ 結論
- 結論として、
reserved_at
の data type である datetime に即して格納されてました。 - ActiveModel が data type に応じて良い感じにしてくれます。
- データの流れは次の通りです。
#(1) 最初はバラバラの分割パラメータ
{
"reserved_at(1i)"=>"2020",
"reserved_at(2i)"=>"3",
"reserved_at(3i)"=>"2",
"reserved_at(4i)"=>"00",
"reserved_at(5i)"=>"00"
}
#(2) 分割パラメータの共通する属性名を元にして一つにまとまる
{ "reserved_at"=>
{
1=>2020,
2=>3,
3=>2,
4=>0,
5=>0
}
}
#(3) value が配列に変換されて...
values = [2020, 3, 2, 0, 0]
#(4) よく見る形に成形!
Time.send(default_timezone, *values)
=> 2020-03-02 00:00:00 +0900
□ 動作を追ってみた
# ここが始まり
From: /attr_encrypted-3.1.0/lib/attr_encrypted/adapters/active_record.rb @ line 36 ActiveRecord::Base#assign_attributes:
35: def assign_attributes(*args)
=> 36: perform_attribute_assignment :assign_attributes_without_attr_encrypted, *args
37: end
From: /attr_encrypted-3.1.0/lib/attr_encrypted/adapters/active_record.rb @ line 28 ActiveRecord::Base#perform_attribute_assignment:
25: def perform_attribute_assignment(method, new_attributes, *args)
26: return if new_attributes.blank?
27:
=> 28: send method, new_attributes.reject { |k, _| self.class.encrypted_attributes.key?(k.to_sym) }, *args
29: send method, new_attributes.reject { |k, _| !self.class.encrypted_attributes.key?(k.to_sym) }, *args
30: end
From: /activemodel-5.2.2/lib/active_model/attribute_assignment.rb @ line 35 ActiveModel::AttributeAssignment#assign_attributes:
28: def assign_attributes(new_attributes)
29: if !new_attributes.respond_to?(:stringify_keys)
30: raise ArgumentError, "When assigning attributes, you must pass a hash as an argument."
31: end
32: return if new_attributes.empty?
33:
34: attributes = new_attributes.stringify_keys
=> 35: _assign_attributes(sanitize_for_mass_assignment(attributes))
36: end
# 分割パラメータが捕捉された!
# `multi_parameter_attributes` に格納されて処理される!
From: /activerecord-5.2.2/lib/active_record/attribute_assignment.rb @ line 26 ActiveRecord::AttributeAssignment#_assign_attributes:
12: def _assign_attributes(attributes)
13: multi_parameter_attributes = {}
14: nested_parameter_attributes = {}
15:
16: attributes.each do |k, v|
17: if k.include?("(")
18: multi_parameter_attributes[k] = attributes.delete(k)
19: elsif v.is_a?(Hash)
20: nested_parameter_attributes[k] = attributes.delete(k)
21: end
22: end
23: super(attributes)
# ↑ 通常のパラメータはここで取り込まれる
24:
25: assign_nested_parameter_attributes(nested_parameter_attributes) unless nested_parameter_attributes.empty?
=> 26: assign_multiparameter_attributes(multi_parameter_attributes) unless multi_parameter_attributes.empty?
27: end
From: /activerecord-5.2.2/lib/active_record/attribute_assignment.rb @ line 41 ActiveRecord::AttributeAssignment#assign_multiparameter_attributes:
40: def assign_multiparameter_attributes(pairs)
41: execute_callstack_for_multiparameter_attributes(
=> 42: extract_callstack_for_multiparameter_attributes(pairs)
43: )
44: end
# (2) の状態に成形される
# {"reserved_at"=>{1=>2020, 2=>3, 3=>2, 4=>0, 5=>0}}
From: /activerecord-5.2.2/lib/active_record/attribute_assignment.rb @ line 77 ActiveRecord::AttributeAssignment#extract_callstack_for_multiparameter_attributes:
66: def extract_callstack_for_multiparameter_attributes(pairs)
67: attributes = {}
68:
69: pairs.each do |(multiparameter_name, value)|
70: attribute_name = multiparameter_name.split("(").first
71: attributes[attribute_name] ||= {}
72:
73: parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value)
74: attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= parameter_value
75: end
76:
=> 77: attributes
78: end
# こんな感じ => `send("reserved_at", {1=>2020, 2=>3, 3=>2, 4=>0, 5=>0})`
From: /activerecord-5.2.2/lib/active_record/attribute_assignment.rb @ line 50 ActiveRecord::AttributeAssignment#execute_callstack_for_multiparameter_attributes:
46: def execute_callstack_for_multiparameter_attributes(callstack)
47: errors = []
48: callstack.each do |name, values_with_empty_parameters|
49: begin
50: if values_with_empty_parameters.each_value.all?(&:nil?)
51: values = nil
52: else
53: values = values_with_empty_parameters
54: end
=> 55: send("#{name}=", values)
56: rescue => ex
57: errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name)
58: end
59: end
60: unless errors.empty?
61: error_descriptions = errors.map(&:message).join(",")
62: raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes [#{error_descriptions}]"
63: end
64: end
# 途中で途切れてるかも...
From: /activerecord-5.2.2/lib/active_record/attribute_methods/write.rb @ line 22 self.__temp__4656c69667562797f52756375627675646f51647=:
17: ActiveRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name
18: sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key
19:
20: generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1
21: def __temp__#{safe_name}=(value)
22: name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name}
23: #{sync_with_transaction_state}
=> 24: _write_attribute(name, value)
25: end
26: alias_method #{(name + '=').inspect}, :__temp__#{safe_name}=
27: undef_method :__temp__#{safe_name}=
STR
# @attributes => #<ActiveModel::AttributeSet:0x00007fc3106cc7a0 ~省略: @listの情報~
From: /activerecord-5.2.2/lib/active_record/attribute_methods/write.rb @ line 51 ActiveRecord::AttributeMethods::Write#_write_attribute:
50: def _write_attribute(attr_name, value) # :nodoc:
=> 51: @attributes.write_from_user(attr_name.to_s, value)
52: value
53: end
From: /activemodel-5.2.2/lib/active_model/attribute_set.rb @ line 57 ActiveModel::AttributeSet#write_from_user:
56: def write_from_user(name, value)
=> 57: attributes[name] = self[name].with_value_from_user(value)
58: end
# ここで、data type が datetime であることを認識
From: /activemodel-5.2.2/lib/active_model/attribute.rb @ line 71 ActiveModel::Attribute#with_value_from_user:
70: def with_value_from_user(value)
=> 71: type.assert_valid_value(value)
72: self.class.from_user(name, value, type, original_attribute || self)
73: end
From: /activemodel-5.2.2/lib/active_model/type/helpers/accepts_multiparameter_time.rb @ line 17 #<ActiveModel::Type::Helpers::AcceptsMultiparameterTime:0x0000555af83e4920>#assert_valid_value:
16: define_method(:assert_valid_value) do |value|
=> 17: if value.is_a?(Hash)
18: value_from_multiparameter_assignment(value)
19: else
20: super(value)
21: end
22: end
From: /activemodel-5.2.2/lib/active_model/type/date_time.rb @ line 42 ActiveModel::Type::DateTime#value_from_multiparameter_assignment:
41: def value_from_multiparameter_assignment(values_hash)
=> 42: missing_parameter = (1..3).detect { |key| !values_hash.key?(key) }
43: if missing_parameter
44: raise ArgumentError, missing_parameter
45: end
46: super
47: end
# ここで成形されて終わり
# ::Time.send(default_timezone, *values) => 2020-03-02 00:00:00 +0900
From: /activemodel-5.2.2/lib/active_model/type/helpers/accepts_multiparameter_time.rb @ line 34 #<ActiveModel::Type::Helpers::AcceptsMultiparameterTime:0x0000555af83e4920>#value_from_multiparameter_assignment:
28: define_method(:value_from_multiparameter_assignment) do |values_hash|
29: defaults.each do |k, v|
30: values_hash[k] ||= v
31: end
32: return unless values_hash[1] && values_hash[2] && values_hash[3]
33: values = values_hash.sort.map(&:last)
=> 34: ::Time.send(default_timezone, *values)
35: end
□ あとがき( 今後の展望 )
- 属性名の取得が、
split("(").first
となってる。 - そのため、
/.+\([0-9]\S\)/
みたいな名前にして、 HTML 側のフォームでパラメータ名を設定したら、フォームヘルパーに頼らない形で datetime type の登録できる。 - 他にも
assign_nested_parameter_attributes
とあったため、独自でパラメータをネストさせたフォームを作成したら、こんなのもインスタンス変数に格納できる。
bash
params = { reserved_at: { 1=> 2020, 2=> 3, 3=> 2, 4=> 00, 5=> 00 } }
@list.assign_attributes(params)
=> nil
@list.reserved_at
=> Mon, 02 Mar 2020 00:00:00 JST +09:00
- 結論として、以下のような datetime type の分割されたパラメータは、
assign_attributes
で上手く取り込むことができる。 - 他の data type は検証中だが、string での失敗は確認済み。
{
"reserved_at(1i)"=>"2020",
"reserved_at(2i)"=>"3",
"reserved_at(3i)"=>"2",
"reserved_at(4i)"=>"00",
"reserved_at(5i)"=>"00"
}
{
reserved_at: {
1=> 2020,
2=> 3,
3=> 2,
4=> 00,
5=> 00
}
}