【Tips】Rails の assign_attributes は分割されたパラメータを飲み込む

Rails の フォームヘルパーである datetime_select は、`year, month, day, hour, minute`の５つのフォームを生成し、入力したらパラメータも５つ別々に渡されます。
しかし、`assign_attributes`を使用すると、一つのパラメータに成形して、model に上手いこと落とし込むのです。

このような`assign_attributes`の不思議な動作に興味を持ったので binging.pry で追ってみました。

## □ 発端

```ruby:schema.rb
# @list の data type はこんな感じ
...
  create_table "lists"
    t.string "name",
    t.datetime "reserved_at"
  end
...
```

```ruby:
# 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 に応じて良い感じにしてくれます。
- データの流れは次の通りです。

```ruby:
#(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
```

## □ 動作を追ってみた


```ruby
# ここが始まり
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`とあったため、独自でパラメータをネストさせたフォームを作成したら、こんなのもインスタンス変数に格納できる。

```ruby: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 での失敗は確認済み。

```ruby:

{
  "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
  }
}
```