How to use dates in Rails when your database stores a string

May 21, 2009

When working with Rails there's a lot of magic that happens behind the scenes to make it easy to do complex things. Most of the time you don't need to know how that "magic" works but there are times when things don't work as expected and its helpful to dig in and understand what Rails is doing under the covers so you can change how it works. Did I just say "change how Rails works"?!? I did! Rails is opinionated software that seeks to lead you down the golden path but there are legitimate times when you have to veer off that path and Rails lets you do so. I find this most often happens to me when I'm dealing with an existing legacy database which is not setup as Rails would like.

Today I'm going to go through an example that happened to me recently when I had an existing database that stored some dates in a text column but I needed to treat them as dates in my UI. I couldn't change the type of that column as there was another legacy application that expected it to be text.

Using Dates the Rails Way

First let's look at how easy it is to work with dates when you can follow the Rails Way. Let's create a new project and add a scaffolded Person object with a date attribute called birthday.

rails date_select_example
cd date_select_example
script/generate scaffold person name:string birthday:date
rake db:migrate



Now if we hit the site and try to create a new person we see a screen like this



And when you click "Create"



Exactly what you want with almost no code in the view and none in the model. I did make a minor edit to the view so we would get 1960 in the year select by adding :start_year=>1900 (a full documentation of date_select options are available here)

#app/views/people/new.html.erb
<h1>New person
<% form_for(@person) do |f| %>
  <%= f.error_messages %>
  <p>
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </p>
  <p>
    <%= f.label :birthday %><br />
    <%= f.date_select :birthday, :start_year=>1900 %>
  </p>
  <p>
    <%= f.submit 'Create' %>
  </p>
<% end %>

<%= link_to 'Back', people_path %>



and

#app/models/person.rb
class Person < ActiveRecord::Base
end



Errors when storing as text in the database

Now what happens when you run into a case where the date is stored as a string in the database. Let's say we have an "anniversary" attribute stored as a string that we want to treat the same way as we did birthday. We create the migration.

script/generate migration AddAnniversaryToPerson anniversary:string
rake db:migrate



Then add the anniversary to our view.

#app/views/people/edit.html.erb
<h1>Editing person
<% form_for(@person) do |f| %>
  <%= f.error_messages %>
  <p>
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </p>
  <p>
    <%= f.label :birthday %><br />
    <%= f.date_select :birthday, :start_year=>1900  %>
  </p>
  <p>
    <%= f.label :anniversary %><br />
    <%= f.date_select :anniversary, :start_year=>1900  %>
  </p>
  <p>
    <%= f.submit 'Update' %>
  </p>
<% end %>

<%= link_to 'Show', @person %> |
<%= link_to 'Back', people_path %>





It looks like we're done so we click "Update" and .. Oops. It doesn't work! We get the error 1 error(s) on assignment of multiparameter attributes. Now we need to figure out what multiparameter attributes are and whey they're not working for us.

ActiveRecord::MultiparameterAssignmentErrors in PeopleController#update

1 error(s) on assignment of multiparameter attributes
RAILS_ROOT: /Users/alexrothenberg/date_select_example

Application Trace | Framework Trace | Full Trace
/Library/Ruby/Gems/1.8/gems/activerecord-2.3.2/lib/active_record/base.rb:3061:in `execute_callstack_for_multiparameter_attributes'
/Library/Ruby/Gems/1.8/gems/activerecord-2.3.2/lib/active_record/base.rb:3022:in `assign_multiparameter_attributes'
/Library/Ruby/Gems/1.8/gems/activerecord-2.3.2/lib/active_record/base.rb:2749:in `attributes='
/Library/Ruby/Gems/1.8/gems/activerecord-2.3.2/lib/active_record/base.rb:2627:in `update_attributes'
/Users/alexrothenberg/date_select_example/app/controllers/people_controller.rb:63:in `update'
...more stack trace...

Request Parameters:

{"commit"=>"Update",
 "_method"=>"put",
 "authenticity_token"=>"qezkVq+MNzFuXxFBJ/GaSoh2BNdxM6oF3H7JP5beFFE=",
 "id"=>"1",
 "person"=>{"name"=>"Barack Obama",
 "birthday(2i)"=>"8",
 "birthday(3i)"=>"4",
 "anniversary(1i)"=>"2009",
 "anniversary(2i)"=>"5",
 "anniversary(3i)"=>"22",
 "birthday(1i)"=>"1960"}}



What happened? There are two keys to figuring out what's going on


  1. The date_select helper actually sends 3 http parameters to our application anniversary(1i), anniversary(2i) and anniversary(3i). ActiveRecord must combine those into a single Date before updating the row in the database.
  2. Looking at assign_multiparameter_attributes in active_record/base.rb we see this this comment that talks about combining 3 http parameters into a date type by calling new on the column type
    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, f for Float, s for String, and a for Array. If all the values for a given attribute are empty, the attribute will be set to nil.



Now we know we're close to the right place and can check the column's return type using script/console

  $ script/console
  Loading development environment (Rails 2.3.2)
  >> Person.columns_hash['birthday']
  => #
  >> Person.columns_hash['birthday'].klass
  => Date
  >> Person.columns_hash['anniversary']
  => #
  >> Person.columns_hash['anniversary'].klass
  => String



This makes sense. ActiveRecord is treating the anniversary column as a string because that's what it is in the database so is not combining the 3 multiparameter attributes into a Date. What we need to do is override the default ActiveRecord logic and tell it to treat this column as a date. We can write a failing spec

require File.dirname(__FILE__) + '/../spec_helper'

describe Person do
  it "should treat anniversary as a Date column" do
    Person.columns_hash['anniversary'].klass.should == Date
  end
end

# 'Person should treat anniversary as a Date column' FAILED
# expected: Date,
#      got: String (using ==)



Then take advantage of a trick of Ruby that allows you to extend an object without affecting other instances of its class (see PickAxe book's explanation of this technique). If we lookup the definition of klass for a column It is actually very simple to implement our fix with the 5 lines below.

class Person < ActiveRecord::Base
  class << columns_hash['anniversary']
    def type
      :date
    end
  end
end



Now our tests pass and when we go back to our site and click the Update button and it works.