Prologue
If you're a newbie programmer like I am and just starting to build your very first Ruby on Rails application, there's a good chance you'll attempt to incorporate some dates in your data using good ole HTML5 forms.
Whether it's inputting a user's birthdate or adding some sort of calendar feature, your instinct will be to use something like date_select
, which is just one of many Action View Helpers built into Rails for us to use at our disposal. And it'll work quite nicely, allowing you to include options like start_year
, end_year
, order
, and prompt
. And in your controller, you'll just add the attribute name to your strong params, pass it along to an object and rely on mass assignment to take care of the rest. Rails will perform its standard "rails-y magic" and everything will function the way you expect it to. The world is a joyful place.
The Conflict
"We keep moving forward, opening new doors, and doing new things, because we're curious and curiosity keeps leading us down new paths."
-Walt Disney
You often discover issues when you get curious -- not only because you're new to coding and there's just so much that you don't know, but also because you're a programmer and programmers are usually... well... curious (and for good reasons too). So as Disney suggested, curiosity is what led me to test how Rails might handle the input of an invalid date like February 30 or 31. Because without using any JavaScript, plugins, gems or other external code, there's obviously no way to manipulate the DOM, which means no way to dynamically set the range or hide the few extra option
tags based on an event (like the user selecting a month, for example). The best we can hope for is for our application to spit back an error which Rails is quite equipped to deal with.
...
...
...
Fingers crossed... and Submit
!
...
...
...
...what???
No error and Disney's date of birth is now March 3rd.
The Rationale
So it appears that when Rails is given an invalid date such as February 31, it takes the additional days and just rolls it over to the next month. I couldn't figure out for the life of me why Rails would believe this is more useful than just throwing an invalid date error, which I'm sure wouldn't be too difficult to implement under the hood with a bit of sanitizing and/or validating. Perhaps once I've grown as a developer and one day decide to take a deep dive into the Rails library and source code, I'll have a better understanding and when I do I will be sure to come edit this blog. I reckon there must be circumstances where this is the desired behavior but for the time being, I'm not convinced.
The Fix
There's a number of things you could do. The way date_select
works is that you get three separate key-value pairs coming through your params.
{"user"=> {
"first_name"=>"Walt",
"last_name"=>"Disney",
"gender"=>"male",
"birthdate(1i)"=>"1901",
"birthdate(2i)"=>"2",
"birthdate(3i)"=>"31"},
"commit"=>"Submit",
"controller"=>"users",
"action"=>"create"}
But inside Rails' Active Model library is a module called Multiparameter Attribute Assignment and it's responsible for doing just that.
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 Integer and f for Float. If all the values for a given attribute are empty, the attribute will be set to nil.
Rails will take the three values (year, month, day) and reconstruct it into a Date
object before persisting it to the database. This is once again Rails at its best, making our lives convenient so that we don't have to concern ourselves with correctly assigning multiparameters to an attribute. But what this also means is that, if we wanted, we could probably monkey patch the code to prevent it from carrying the extra days over. But monkey patching is often a frowned upon practice and should be avoided when possible.
Other common solutions I came across on the web mostly involved using some type of external library (various plugins and gems) or JavaScript. All very valid and possibly the obvious choice, but that's not what this blog is about. As the title suggests, I'm going to show you how I did it, the quick-and-dirty way. No gems and no JavaScript.
The answer is a simple rescue. Here's what I did:
def create
begin
@user = User.new(user_params)
@user.valid?
Date.new(user_params["birthdate(1i)"].to_i, user_params["birthdate(2i)"].to_i, user_params["birthdate(3i)"].to_i)
if @user.save
redirect_to @user, notice: "New user created!"
else
render :new
end
rescue ArgumentError
@user.errors.add(:birthdate, "must be a valid date")
@user.birthdate = nil
render :new
end
end
I utilize the fact that trying to instantiate a Date
object with invalid dates will actually raise ArgumentError: invalid date
. And when it does, I simply rescue the exception and attach an error to :birthdate
before re-rendering the view.
You'll also notice that after I pass the params into a new User
instance, I call @user.valid?
first. This is to accumulate any other errors that the object might have after running validations on all of the other attributes like name and gender. Only then do I instantiate a Date
"dummy" object and pass in the three values to check whether the date is valid.
Final Thoughts
So while what I did might not be the most elegant pattern or the most efficient method to validate dates, if you're project isn't regularly dealing with dates and you just need a quick workaround in a few places of your code, this could be the easiest (or at least a temporary) solution to reject invalid dates without breaking your code. I hope you've found this useful!
Top comments (2)
Great that you're creative with your workaround :)!
Another way could be a model validation that adds an error to the instance instead of parsing an invalid date to a unintended one. For example, you could use a custom validation like this:
Oh you put me to shame Michael! You're right, I could definitely move this to the model and write a custom validation. Quick question though, won't
_before_type_cast
return a hash? I'm not sure if I'd be able to call#to_date
on it. But I suppose I could just pass the values into aDate
object again and rescue the error. Either way, it's a great idea to keep this logic out of the controller! Thank you!