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.
Written by Gus Bonfant
Related protips
2 Responses
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
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