I spent quite a while trying to track down a problem in the Rails validates_date_time plugin. In the end the fix was simple, but since I'm new to Rails and Ruby I assumed I was doing something wrong. I finally decided to dig into the code and tests for validates_date_time to see if I could find a bug.
The Problem: I have a model called Person. The schema looks like so:
ActiveRecord::Schema.define(:version => 1) do
create_table "people", :force => true do |t|
t.column "name", :string
t.column "date_of_birth", :datetime
end
end
I set ActiveRecord::Validations::DateTime.us_date_format = true.
I can create a new Person just fine like so:
ruby script/console
>> require 'pp'
=> true
>> p = Person.create(:name => "Test", :date_of_birth => Date.new(1972, 12, 31))
>> p.valid?
=> true
Great. But when I try to load the person from the database there is a problem.
>> p = Person.find_by_name("Test")
=> #"Test", "id"=>"2", "date_of_birth"=>"
1972-12-31"}>
>> p.valid?
=> false
>> p.errors.on(:date_of_birth)
=> "is an invalid date"
What happens if I set ActiveRecord::Validations::DateTime.us_date_format = false? Let's see.
>> ActiveRecord::Validations::DateTime.us_date_format = false
=> false
>> p = Person.find_by_name("Test")
=> #"Test", "id"=>"2", "date_of_birth"=>"
1972-12-31"}>
>> p.valid?
=> true
The problem seems to be related to the use of us_date_format. So what is going on?
The validate_date_time plugin looks at each attribute that is passed to validates_date and looks at the *_before_type_cast version of the attribute. In our case it is looking at p.date_of_birth_before_type_cast. Here's what it sees:
>> ActiveRecord::Validations::DateTime.us_date_format = true
=> true
>> p = Person.find_by_name("Test")
=> #"Test", "id"=>"2", "date_of_birth"=>"
1972-12-31"}>
>> p.date_of_birth_before_type_cast
=> "1972-12-31"
So our raw value of our date_of_birth is "1972-12-31". That looks perfectly reasonable since it is an ISO date and ISO dates are the best way to represent dates since they are easily parsed. So why is that date considered invalid?
The validate_date_time plugin uses a parse_date method to parse date values. Here's what it looks like:
def parse_date(value)
raise if value.blank?
return value if value.is_a?(Date)
return value.to_date if value.is_a?(Time)
raise unless value.is_a?(String)
year, month, day = case value.strip
# 22/1/06 or 22\1\06
when /^(\d{1,2})[\\\/\.:-](\d{1,2})[\\\/\.:-](\d{2}|\d{4})$/ then [$3, $2, $1]
# 22 Feb 06 or 1 jun 2001
when /^(\d{1,2}) (\w{3,9}) (\d{2}|\d{4})$/ then [$3, $2, $1]
# July 1 2005
when /^(\w{3,9} (\d{1,2}) (\d{2}|\d{4}))$/ then [$3, $1, $2]
# 2006-01-01
when /^(\d{4})-(\d{2})-(\d{2})$/ then [$1, $2, $3]
# Not a valid date string
else raise
end
month, day = day, month if ActiveRecord::Validations::DateTime.us_date_format
Date.new(unambiguous_year(year), month_index(month), day.to_i)
rescue
raise DateParseError
end
The last when statement is where our ISO date of "1972-12-31" matches. At that point year = "1972", month = "12", and day = "31". But then if us_date_format is true, the value of month and day get swapped. Now year = "1972", month = "31", and day = "12".
But that can't be right. The unit tests for validates_date_time all run clean and the date_test.rb has test cases for us_date_format. So what gives?
Here are the tests to prove it:
def test_us_date_format
with_us_date_format do
{'1/31/06' => '2006-01-31', '2\28\01' => '2001-02-28',
'10/10/80' => '1980-10-10', '7\4\1960' => '1960-07-04'}.each do |value, result|
assert_update_and_equal result, :date_of_birth => value
end
end
end
Running the Plugin tests (after configuring a database for the validates_date_time plugin to use) results in this:
c:> rake test:plugins
Started
.....................
Finished in 0.703 seconds.
21 tests, 121 assertions, 0 failures, 0 errors
The problem is that the test_us_date_format is not testing a date like ours. Let's change it to look like this:
def test_us_date_format
with_us_date_format do
{'1/31/06' => '2006-01-31', '2\28\01' => '2001-02-28',
'10/10/80' => '1980-10-10', '7\4\1960' => '1960-07-04',
'1972-12-31' => '1972-12-31'}.each do |value, result|
assert_update_and_equal result, :date_of_birth => value
end
end
end
That says, when use_date_format is true I expect to receive the same value when I update a date_of_birth field with an ISO formatted date of '1972-12-31'. What happens when we run the tests now:
c:> rake test:plugins
Started
........F..F.........
Finished in 0.75 seconds.
1) Failure:
test_us_date_format(DateTest)
[./vendor/plugins/validates_date_time/test/abstract_unit.rb:44:in `assert_up
date_and_equal'
./vendor/plugins/validates_date_time/test/date_test.rb:70:in `test_us_date_
format'
./vendor/plugins/validates_date_time/test/date_test.rb:69:in `test_us_date_
format'
./vendor/plugins/validates_date_time/test/abstract_unit.rb:65:in `with_us_d
ate_format'
./vendor/plugins/validates_date_time/test/date_test.rb:66:in `test_us_date_
format']:
{:date_of_birth=>"1972-12-31"} should be valid.
is not true.
2) Failure:
test_various_formats(DateTimeTest)
[./vendor/plugins/validates_date_time/test/abstract_unit.rb:50:in `assert_up
date_and_match'
./vendor/plugins/validates_date_time/test/date_time_test.rb:12:in `test_var
ious_formats'
./vendor/plugins/validates_date_time/test/date_time_test.rb:11:in `test_var
ious_formats']:
<"Tue Jan 03 19:00:00 Central Standard Time 2006"> expected to be =~
.
21 tests, 114 assertions, 2 failures, 0 errors
rake aborted!
Command failed with status (1): [c:/ruby/bin/ruby -Ilib;test "c:/ruby/lib/r...]
(See full trace by running task with --trace)
The first failure is the one we're interested in. It demonstrates the problem we're seeing. Let's see if we can fix it.
Replace the parse_date method in validates_date_time.rb with this:
def parse_date(value)
raise if value.blank?
return value if value.is_a?(Date)
return value.to_date if value.is_a?(Time)
raise unless value.is_a?(String)
year, month, day, is_iso = case value.strip
# 22/1/06 or 22\1\06
when /^(\d{1,2})[\\\/\.:-](\d{1,2})[\\\/\.:-](\d{2}|\d{4})$/ then [$3, $2, $1]
# 22 Feb 06 or 1 jun 2001
when /^(\d{1,2}) (\w{3,9}) (\d{2}|\d{4})$/ then [$3, $2, $1]
# July 1 2005
when /^(\w{3,9} (\d{1,2}) (\d{2}|\d{4}))$/ then [$3, $1, $2]
# 2006-01-01
when /^(\d{4})-(\d{2})-(\d{2})$/ then [$1, $2, $3, true]
# Not a valid date string
else raise
end
month, day = day, month if !is_iso && ActiveRecord::Validations::DateTime.us_date_format
Date.new(unambiguous_year(year), month_index(month), day.to_i)
rescue
raise DateParseError
end
Now we're setting a local variable is_iso to true when our date matches the ISO formatted when statement. Now when is_iso is true we can skip the swapping of the month and day values. Let's run some tests and see if this is working:
c:> rake test:plugins
Started
.....................
Finished in 0.688 seconds.
21 tests, 123 assertions, 0 failures, 0 errors
Perfect. Now everything should be working in the console too. Let's check:
>> ActiveRecord::Validations::DateTime.us_date_format = true
=> true
>> p = Person.find_by_name("Test")
=> #"Test", "id"=>"2", "date_of_birth"=>"
1972-12-31"}>
>> p.valid?
=> true
Looks good. I think that should do it. I wonder if this explains why the validates_date_time plugin is only rated 3 out of 5 stars here. It is a great plugin, it just looks like the us_date_format might be a little rough around the edges.