Last Updated: December 01, 2017
·
2.067K
· gus

Assigning date attributes without ActiveRecord

ActiveRecord sometimes looks like a black box of magic and unicorns, we may hate it but we cannot denny that it does a lot of stuff for us. One of those things is magically instantiating and assigning date objects to our model date attributes, but what happens when we deviate from its magical path?

If you are building a Rails application without AR when submitting forms with dates you may have stumbled upon this kind of parameter

{ "name" => "James Bond", "birth_date(3i)"=>"11", "birth_date(2i)"=>"11", "birth_date(1i)"=>"1920" }

Let's say that you have a simple model with a birth_date and name attribute, for the sake of simplicity we'll assume BaseModel adds all the boilerplate needed for making our class behave like an ActiveRecord model

class Person < BaseModel
 attr_accessor :birth_date, :name

 def initialize(attributes = nil)
 super
 end
end

Now when submitting our form name will be assigned but birth_date won't.

The reason is simple: birthdate(3i), birthdate(2i) nor birthdate(1i) are attributes of the class, but AR's ```assignattribute``` does all the magic needed to assign dates and other multiparameter attributes.

As stated previously, we can base our implementation off of ActiveRecord's assign_attribute and iterate through the passed attributes, select those that are of the type multiparameter, extract their values and assign them to a new hash to be sent to the parent class.

class Person < BaseModel
 attr_accessor :birth_date, :name

 def initialize(attributes = nil)
 super

 assign_dates(attributes) if attributes
 end


 protected

 def assign_dates(attributes)
 new_attributes = attributes.stringify_keys
 multiparameter_attributes = extract_multiparameter_attributes(new_attributes)

 multiparameter_attributes.each do |multiparameter_attribute, values_hash|
 set_values = (1..3).collect{ |position| values_hash[position].to_i }

 self.send("#{multiparameter_attribute}=", Date.new(*set_values))
 end
 end

 def extract_multiparameter_attributes(new_attributes)
 multiparameter_attributes = []

 new_attributes.each do |k, v|
 if k.include?('(')
 multiparameter_attributes << [k, v]
 end
 end

 extract_attributes(multiparameter_attributes)
 end

 def extract_attributes(pairs)
 attributes = {}

 pairs.each do |pair|
 multiparameter_name, value = pair
 attribute_name = multiparameter_name.split('(').first
 attributes[attribute_name] = {} unless attributes.include?(attribute_name)

 attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= value
 end

 attributes
 end

 def find_parameter_position(multiparameter_name)
 multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i
 end 
end 

Diving into ActiveRecord's source is a rewarding exercise that greatly helps to understand the seemingly magic capabilities of this black box.

2 Responses
Add your response

FYI, looks like this will get extracted to ActiveModel for use by non-AR models in Rails 4.2, see https://github.com/rails/rails/pull/8189

over 1 year ago ·

For anyone else searching for this, the PR has been moved to rails 5. My hack is to include ActiveRecord::AttributeAssignment

# works on my AR v4.2.4
class MyObj
 include ActiveModel::Model
 include ActiveRecord::AttributeAssignment

 attr_accessor :my_date

 def initialize(*params)
 assign_attributes(*params)
 end

 # AttributeAssignment needs to know the class
 def type_for_attribute(name)
 case name
 when 'my_date'
 klass = Date
 end
 OpenStruct.new(klass: klass)
 end
end

obj = MyObj.new('my_date(1i)' => '2015', 'my_date(2i)' => '1', 'my_date(3i)' => '31')
obj.my_date # Sat, 31 Jan 2015
over 1 year ago ·