Consistent Date Formatting in Ruby on Rails 5+

If you’ve ever dealt with dates in Rails, particularly accepting dates from user input and then displaying them, you’re familiar with two clunky experiences:

  1. The awful default month, day, year, hour, minute, second dropdowns
  2. The complete mess of a date that you get when you force that field to be represented as a text input in a form.

In a hurry? Scroll on down to the bottom for the copy-paste-ready code snippet to make everything better like magic.

Part 1: Getting Date to format itself as desired #

There are a bunch of core extensions to Date in ActiveSupport, but none of them create a new date class. If you’re working with Date, which is what Rails returns when you have a date column in your database, the method converting that date into a string is Date#to_formatted_s. The ActiveSupport extensions add this method, and they alias the to_s method to it, so when you call Date#to_s (probably implicitly), you get Date.to_formatted_s.

There are two ways to change the format used by to_formatted_s:

  1. Pass the name of the format you would like used (this arg will be used to lookup the format in Date::DATE_FORMATS).
  2. Change the format that :default maps to. Since to_formatted_s sets a default value of :default, when you call Date#to_s without passing an argument you get format specified by Date::DATE_FORMATS[:default]. You can override the default on an application-wide basis like this:

    Date::DATE_FORMATS[:default] = "%m/%d/%Y"
    

I have this set in an initializer like config/initializers/date_formats.rb. It’s crude and doesn’t support changing with your localizations, but gets Date#to_s to return the desired format without any monkey patching or explicitly converting your dates to strings and passing in an argument.

Part 2: Getting user-inputted dates converted to Date objects #

By default, your ActiveRecord instance will cast date columns using good ol’ ISO 8601 that looks like this: yyyy-mm-dd.

If it doesn’t match that format exactly (e.g., it’s slash delimited), it falls back to using Ruby’s Date._parse, which is a little more lenient, but still expects to see days, months, then years: dd/mm/yyyy.

To get Rails to parse the dates in the format you’re collecting them, you just need to replace the cast_value method with one of your own. You can monkeypatch ActiveRecord::Types::Date, but it’s not much harder to roll your own type inheriting from it.

I like using Chronic to parse date strings from user input because it puts up with more shit, like “Tomorrow”, or “Jan 1 99”, or even bonkers input like “5/6-99” (May 6, 1999). Since Chronic returns an instance of Time and we’re overriding the supplied cast_value method, we need to call to_date on it.

class EasyDate < ActiveRecord::Type::Date
  def cast_value(value)
    default = super
    parsed = Chronic.parse(value)&.to_date if value.is_a?(String)
    parsed || default
  end
end

Now we just need to tell ActiveRecord to use EasyDate instead of ActiveRecord::Type::Date:

class Pony < ApplicationRecord
  attribute :birth_date, EasyDate.new
end

That works, but if you’re like me, you want to take on a bunch more work right now so you can save 3 seconds in the future. What if we could tell ActiveRecord to always use EasyDate for columns that are just dates? We can.

Part 3: Make Rails handle this automatically #

ActiveRecord gives you all sorts of information about your model that it uses to handle all of the “magic” behind the scenes. One of the methods it gives you us attribute_types. If you run that in your console, you get back vomit — it’s kind of scary, and you just go “oh nevermind”. If you dig into that vomit like some sort of weirdo, though, it’s just a hash that looks like this:

{ column_name: instance_of_a_type_object, ... }

Not scary stuff, but since we’re starting to poke at internals, the inspected output of these instance_of_a_type_object can end up looking obtuse and “closed”. Like this:

ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Money

Thankfully, we really only care about one: ActiveRecord::Types::Date, so we can narrow it down:

date_attributes = 
  attribute_types.select { |_, type| ActiveRecord::Type::Date === type }

That gives us a list of the attributes that we want to upcycle to use EasyDate. Now we just need to tell ActiveRecord to use EasyDate on those attributes like we did above:

date_attributes.each do |attr_name, _type|
  attribute attr_name, EasyDate.new
end

And that basically does it, but we need to glue it all together. If you’re using ApplicationRecord you can drop this right into it and be off to the races:

def inherited(klass)
  super
  klass.attribute_types
    .select { |_, type| ActiveRecord::Type::Date === type }
    .each do |name, _type|
      klass.attribute name, EasyDate.new
    end
end

If you don’t want to “pollute” ApplicationRecord, you can do like I did and create a module and extend ApplicationRecord with it. If you’re not using ApplicationRecord, you’ll need to create the module and extend ActiveRecord::Base.

module FixDateFormatting
  # the above `inherited` method here
end

# Either this...
class ApplicationRecord < ActiveRecord::Base
  extend FixDateFormatting
end

# ...or this:
ActiveRecord::Base.extend(FixDateFormatting)

TL;DR — What to copy paste #

If you aren’t concerned with the how and just want this to work, you can:

  1. Add gem "chronic" to your Gemfile and bundle install
  2. Make sure your models inherit from ApplicationRecord
  3. Copy the code below a file at config/initializers/date_formatting.rb
  4. Restart
  5. Everything should now Just Work™

Here’s the code to copy:

Date::DATE_FORMATS[:default] = "%m/%d/%Y"

module FixDateFormatting
  class EasyDate < ActiveRecord::Type::Date
    def cast_value(value)
      default = super
      parsed = Chronic.parse(value)&.to_date if value.is_a?(String)
      parsed || default
    end
  end

  def inherited(subclass)
    super
    date_attributes = subclass.attribute_types.select { |_, type| ActiveRecord::Type::Date === type }
    date_attributes.each do |name, _type|
      subclass.attribute name, EasyDate.new
    end
  end
end

Rails.application.config.to_prepare do
  ApplicationRecord.extend(FixDateFormatting)
end
 
38
Kudos
 
38
Kudos

Now read this

Co-dependent Models in Rails

Sometimes you have a one-to-many relationship in your Rails models and you want to allow nested attributes at the time of creation. So you do this: # Seems like it would work, but does NOT: class User < ApplicationModel has_many :tags... Continue →