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.