8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-03-02

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
  }
}
8
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?