The Monthly Bug

I stumbled upon an odd Ruby on Rails bug this week. It was difficult to track down because it was a bug that only affected some users some of the time. Out of every 100 users: 50 would have no problems, 42 would have problems 2% of the time, 8 would have problems 8% of the time. Making it more difficult to pinpoint—during 92% of the year none of the users experienced any problems at all! Let me demonstrate the problem and explain how to solve it.
Let’s say we have a form for a user to fill out. On that form, we want the user to provide a date, but we don’t care about the day of the month; we just want the month and year. We might be asking for their birth month and year or how long they’ve been using our product. The most common example would be asking for a credit card expiration date.
Something like this:
1 2 3 4 5 6 7 | <% form_tag(:action => "place_order") do -%> ...form body... <%= date_select(:payment, :card_expire, :order => [:month,:year], :use_month_numbers => true, :discard_day => true, :start_year => 2007, :end_year => 2012) %> <%= submit_tag("Place Order") %> <% end -%> |
I’m using the date_select helper method to output the date form field. It’s handy because it draws directly on a @payment object and outputs all the necessary pull-down menus with the values of @payment.card_expire already selected. I’ve passed it a few options so that it will display a month select that uses numbers, skip the day select, and display a year select from 2007-2012.
Understanding the date_select Helper
Let’s look at how this form helper works… In order to automatically group and match up the date parameters when the form is submitted, Rails uses a special technique. We already know that the entire form is submitted as an array (payment[]) and each of the fields are named inside that array (payment[:card_expire]). Like many Rails forms, that allows us access the form parameters using params[:payment] and params[:payment][:card_expire]. But the date parameters are further divided and their order will be important in rebuilding the date. Therefore, Rails names our date select fields by appending a number and a single-letter (donoting the type, ‘i’ is for ‘integer’) inside parentheses. So the month select gets the name payment[card_expire(2i)] and the year select gets the name payment[card_expire(1i)]. If we had a day field, it would be named with “3i”.
When the form is submitted, if we mass assign the parameters while instantiating an object, like this:
1 2 3 | def place_order @payment = Payment.new(params[:payment]) end |
…then Rails will notice that some parameters have parentheses in their name and will try to reassemble them into a date before assigning the value. The parameter “reassembling” isn’t just for dates though. That’s a good thing—it is code that can reassemble lots of “multi-parameter assignments” for us. But that means when it tries to build a date, it’s going to need to know what parameter we want to use for the day of the month. It doesn’t have a default, we have to pass a value in. If we don’t, we’ll get a “MultiparameterAssignmentErrors” because the date it tries to build will be invalid. (And it won’t help when debugging that that error type doesn’t make it very clear what went wrong.)
Because Rails is always looking out for us, it saves us from having to remember to add the date value in by hand. If you look at the HTML source for the form above, you’ll see this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <form action="/store/place_order" method="post"> ...form body... <input type="hidden" id="payment_card_expire_3i" name="payment[card_expire(3i)]" value="15" /> <select id="payment_card_expire_2i" name="payment[card_expire(2i)]"> <option value="1">1</option> <option value="2">2</option> <option value="3">3</option> <option value="4">4</option> <option value="5">5</option> <option value="6">6</option> <option value="7" selected="selected">7</option> <option value="8">8</option> <option value="9">9</option> <option value="10">10</option> <option value="11">11</option> <option value="12">12</option> </select> <select id="payment_card_expire_1i" name="payment[card_expire(1i)]"> <option value="2007" selected="selected">2007</option> <option value="2008">2008</option> <option value="2009">2009</option> <option value="2010">2010</option> <option value="2011">2011</option> <option value="2012">2012</option> </select> <input name="commit" type="submit" value="Place Order" /> </form> |
Notice that even though the date was supposed to be “discarded”, Rails “helpfully” includes it as a hidden field, payment[card_expire(3i)]. You can’t even suppress the output of that hidden field because Rails “knows” that it’s likely going to need it once the form is submitted. In fact, it will create hidden fields for any field in the date that you don’t output but that it thinks it will need later. At first, that seems helpful enough.
The Rub
Here’s the problem: it includes today’s date for the value of the hidden field. 92% of the year, that works perfectly. But what if today’s date is July 31, 2007 and our user sets the expiration date selections to be “09/2007″. The hidden field will have a value of “31″, MultiparameterAssignment will try to build a date out of “09/31/2007″, the user will get a show-stopping error and we’ll get a cryptic error in our log file. Of course if today’s date is July 31, 2007 and the customer enters and expiration of “10/2007″ then they won’t get an error. The worst case is when a customer has a credit card that expires in February, then 29 days each year they will have problems. It’s a bug that’s hard to track down, because if you spot the error in your log file on the following day, you may not be able to repeat the error!
The Solution
This bug exists in Rails up to the current release, version 1.2.3. This bug has already been fixed in Edge Rails and will be available to everyone in the next release. It still uses a hidden field but, instead of using today’s date, it will default to 1. A much better solution!
Until you are using a patched version of Rails, you can just override the value of params[:payment]['card_expire(3i)'] with your own.
Like this:
1 2 3 4 5 | def place_order adjusted_params = params[:payment].dup adjusted_params['card_expire(3i)'] = 1.to_s @payment = Payment.new(adjusted_params) end |
Or like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | def place_order adjusted_params = adjust_cc_expire(params[:payment]) @payment = Payment.new(adjusted_params) end def adjust_cc_expire( sent_params ) new_params = sent_params.dup case new_params['card_expire(2i)'].to_i when 2: new_params['card_expire(3i)'] = 28.to_s when 4, 6, 9, 11: new_params['card_expire(3i)'] = 30.to_s when 1, 3, 5, 7, 8, 10, 12: new_params['card_expire(3i)'] = 31.to_s end return new_params end |

August 2nd, 2007 at 7:26 pm
That was wicked mad!!!
February 4th, 2008 at 7:37 pm
Great article.
One question, how would you handle to display the correct date back to the form if now you don’t have the date? I keep getting the wrong month selected because I save the date without the day (eg 1208) for December 2008.
Thank you,
Peter.
February 4th, 2008 at 8:02 pm
A date is not really a date if you don’t have a month, day and year. You can default to the first day of the month and then ignore it whenever you don’t need it, but it needs to have a day.