Digging into the code that makes date_select and its brethen work

One of my first experiences with Rails’ “automagicality” was building my first form with a time_select input field, and being impressed that Rails could take six selects, tie up their input and give you back a datetime without you having to do anything. Even with the introduction of strong_parameters in version 4, Rails was still able handle these weird multi-selects without a problem.

Recently, my curiosity got the better of me and I decided to find out how it all worked under-the-hood.

Note: the following covers the latest stable version of Rails – v4.2.6 at the time of writing – so older/newer versions may differ

Inspect element

Our first step is see what exactly Rails spits out with its datetime input helpers – for example:

date_select :user, :birthday

gives us (<option> elements elided for clarity):

<select id="user_birthday_1i" name="user[birthday(1i)]"><!-- year options --></select>
<select id="user_birthday_2i" name="user[birthday(2i)]"><!-- month options --></select>
<select id="user_birthday_3i" name="user[birthday(3i)]"><!-- day options --></select>

Interestingly, there’s nothing about years/months/days in there, just a numbered suffix in brackets ((1i), (2i) etc.).

Looking through the code for Rails’ date field helpers, we can see that the numbers correspond to the ordered parts of an ISO date:

# actionview/lib/action_view/helpers/date_helper.rb
class ActionView::Helpers::DateHelper::DateTimeSelector
  POSITION = { # These are the ordered parts of the date
    :year => 1, :month => 2, :day => 3, :hour => 4, :minute => 5, :second => 6
  }.freeze

  # ...

  # Returns the name attribute for the input tag.
  #  => post[written_on(1i)]
  def input_name_from_type(type)
    # ... elided

    field_name = @options[:field_name] || type
    if @options[:include_position]
      field_name += "(#{ActionView::Helpers::DateTimeSelector::POSITION[type]}i)" # This is the part that addeds the numbered suffix
    end

    # ... elided
  end

  # ...
end

Attribute assignment

The date field helpers goes some way to explaining how the inputs are built, but says nothing about how they’re processed once submitted to a controller. This feature isn’t documented at all – and pretty hard to grep for – but by following through the code we find the ActiveRecord::AttributeAssignment module and what it calls “multiparameter” attributes.

Such attributes are detected by the presence of an open bracket in the attribute name (which is why we never have to pre-declare them, and is surprisingly not done by reflection on the column type to find date/time types):

# activerecord/lib/active_record/attribute_assignment.rb
module ActiveRecord::AttributeAssignment
  # ...

  def assign_attributes(new_attributes)
    # elided

    attributes                  = new_attributes.stringify_keys
    multi_parameter_attributes  = []
    nested_parameter_attributes = []

    attributes = sanitize_for_mass_assignment(attributes)

    attributes.each do |k, v|
      if k.include?("(") # Here's where multiparameter attributes are detected
        multi_parameter_attributes << [ k, v ]
      elsif v.is_a?(Hash) # For things like `accepts_nested_attributes_for`
        nested_parameter_attributes << [ k, v ]
      else
        _assign_attribute(k, v)
      end
    end

    assign_nested_parameter_attributes(nested_parameter_attributes) unless nested_parameter_attributes.empty?
    assign_multiparameter_attributes(multi_parameter_attributes) unless multi_parameter_attributes.empty?
  end

  # ...
end

We can also see in this module why numeric indexes are used – they correspond to the positional arguments for the Date/Time constructor:

module ActiveRecord::AttributeAssignment
  # ...

  def read_date
    # ... elided
    set_values = values.values_at(1,2,3) # Here's where the params are transformed into the positional arguments
    begin
      Date.new(*set_values)
    rescue ArgumentError
      # ... elided
    end
  end

  # ...
end

A helpful comment also explains that the i after the number (e.g. user[birthday(1i)]) is used for type coercion – i corresponds to Fixnum (integer) and f to Float:

module ActiveRecord::AttributeAssignment
  # ...

  # Instantiates objects for all attribute classes that needs more than one constructor parameter. This is done
  # by calling new on the column type or aggregation type (through composed_of) object with these parameters.
  # So having the pairs written_on(1) = "2004", written_on(2) = "6", written_on(3) = "24", will instantiate
  # written_on (a date type) with Date.new("2004", "6", "24"). You can also specify a typecast character in the
  # parentheses to have the parameters typecasted before they're used in the constructor. Use i for Fixnum and
  # f for Float. If all the values for a given attribute are empty, the attribute will be set to +nil+.
  def assign_multiparameter_attributes(pairs)
    # ... elided
  end

  # ...
end

Custom inputs

With the knowledge that attribute assignment isn’t tied to date/time columns (although they are handled specially for parsing), we can now create our own multi-part inputs and attributes. This will be covered in part 2.

Image by Dafne Cholet on Flickr, used under CC BY 2.0 license.