Last Updated: December 01, 2017
·
1.988K
· 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 ·