Our backend application is running on Rails 4.2 (Ruby 2.4.2), and we’ve been eager to upgrade to Rails 5 for ages. We do weekly retrospectives here at Liefery, and someone mentions almost every week that they’d love for us to do the upgrade. Unfortunately, it wasn’t as easy as just changing the version in our Gemfile and calling it a day. We were blocked for a while because of incompatible gems, so we kept our eyes open and upgraded these when possible.
Getting all our gems in order took some time, but once they were Rails 5 compatible we could finally start! After some back and forth with our Gemfile, we had Rails pinned to version 5.0.6 and we pushed our code up to GitHub to see what Jenkins had to say about it.
Jenkins was not happy (for many reasons), but one particularly scary reason jumped out in the form of a deprecation warning.
DEPRECATION WARNING: Time columns will become time zone aware in Rails 5.1. This still causes `String`s to be parsed as if they were in `Time.zone`, and `Time`s to be converted to `Time.zone`.
To keep the old behavior, you must add the following to your initializer:
config.active_record.time_zone_aware_types = [:datetime]
To silence this deprecation warning, add the following:
config.active_record.time_zone_aware_types = [:datetime, :time]
I’m not sure about you, but it doesn’t matter how many times I read this deprecation warning, I can’t understand what it’s trying to tell me. So let’s just put it aside it for now and try to figure out what’s happening on our own with an example. Since we’ll need to compare Rails 4 with Rails 5 behaviour, let’s start with something we’re already familiar with, Rails 4.
Behaviour in Rails 4
At the time of writing, I am in Berlin, and we are still on summer time (CEST), which means we are 2 hours ahead of UTC.
For this example, let’s pretend we have a store model (I’m going to call it “CornStore”, it sells corn) and that store wants to have a sale that will start on a certain date at a certain time. For that we’ll need a datetime
column. The store also opens every day at 9 am, the date doesn’t matter here, so for that we’ll use a time
column. Since we use Postgres (version 9.6) at Liefery, our store’s database will be the same. Let’s do a migration and see what that looks like:
rails generate migration AddTimesToCornStore sale_start_at:datetime opening_time:time
That creates the following migration:
class AddTimesToCornStore < ActiveRecord::Migration
def change
add_column :corn_stores, :sale_start_at, :datetime
add_column :corn_stores, :opening_time, :time
end
end
After running rake db:migrate
, if we take a look at our structure.sql
, it looks like this:
CREATE TABLE corn_stores (
id SERIAL PRIMARY KEY,
sale_starts_at timestamp without time zone,
opening_time time without time zone
);
Let’s add some data in there. My current time is October 19th, 11:00 am CEST.
corn_store = CornStore.create!(
sale_start_at: Time.current + 1.day,
opening_time: Time.zone.parse("09:00"))
corn_store.reload
corn_store.sale_start_at
# => Fri, 20 Oct 2017 11:00:00 CEST +02:00
corn_store.sale_start_at.class
# => ActiveSupport::TimeWithZone
corn_store.opening_time
# => "2000-01-01T07:00:00.000Z"
corn_store.opening_time.class
# => Time
This looks weird. Why does the opening_time
have such an strange date? Why is its return value so different from sale_start_at
? Let’s double check Postgres before we make any assumptions:
SELECT sale_start_at FROM corn_stores WHERE id = 1;
# sale_start_at
# --------------------------
# 2017-10-20 09:00:00.513386
SELECT opening_time FROM corn_stores WHERE id = 1;
# opening_time
# -------------
# 07:00:00
There are a few interesting things going on here.
- Our
sale_start_at
value is stored in Postgres in UTC. This is a bit confusing though, because it doesn’t actually say “UTC” anywhere. - When we’re in the rails console,
ActiveRecord
returns thesale_started_at
value as anActiveSupport::TimeWithZone
object. Our application knows our time zone because we haveconfig.time_zone = "Berlin"
in ourapplication.rb
. If I were to change that to “London”, we would get “Fri, 20 Oct 2017 10:00:00 BST +01:00” instead of “Fri, 20 Oct 2017 11:00:00 CEST +02:00”. - Remember our
structure.sql
? It created two columns, both of which were “without time zone”. As far as Postgres is concerned, these values have no time zone, but Rails applies time zone logic to them. Thedatetime
column forsale_started_at
is time zone aware. It knows what time zone the application is in and gives us our value based on that. - Our
opening_time
value in Postgres is just a time (no date!). It’s also in UTC, which again, is confusing. - When we’re in the rails console,
ActiveRecord
returns theopening_time
value as aTime
object, which… confusingly… now has a date attached to it and that date is January 1st, 2000 (this just seems to be a dummy value which originates from the early days of Rails). It’s also still in UTC. Rails didn’t translate the value to Berlin time for us. This means ourtime
column is not time zone aware.
If we now look back at our deprecation warning, things are starting to make a bit more sense. In Rails 4, only datetime
columns were time zone aware. So to keep this behaviour in Rails 5, we can add config.active_record.time_zone_aware_types = [:datetime]
to the application.rb
(this will silence the deprecation warning). If you were to repeat all the exercises in a Rails 5 project with the above configuration, you’d get the exact same results as in Rails 4.
Behaviour in Rails 5
Let’s try out the new - and Rails 5.1 default - behaviour. In a Rails 5 project, let’s add config.active_record.time_zone_aware_types = [:datetime, :time]
to our application.rb
and exit out of and then into the console again (just to make sure everything’s reloaded properly). Let’s revisit that old corn store and see what’s different. Remember, my original time was October 19th, 11:00 am CEST. My sale starts tomorrow (October 20th), and my store opens every day at 9 am.
corn_store = CornStore.last
corn_store.sale_start_at
# => Fri, 20 Oct 2017 11:00:00 CEST +02:00
corn_store.sale_start_at.class
# => ActiveSupport::TimeWithZone
corn_store.opening_time
# => Sat, 01 Jan 2000 08:00:00 CET +01:00
corn_store.opening_time.class
# => ActiveSupport::TimeWithZone
Something’s changed! Here you can see that instead of getting a Time
object back for the opening_time
, we’ve gotten a ActiveSupport::TimeWithZone
object. Like the deprecation warning suggested, it has become time zone aware. Rails has applied time zone logic to this value. We set the value as 9 am CEST, it was saved to Postgres as 7 am UTC and it was returned as 8 am CET (winter time)! This is unfortunate, because it’s not even a little bit the time that we wanted.
How you deal with this change is up to you. We decided that using the new configuration would be quite problematic for us as it would throw our times off by an hour - not a good idea for a delivery company! Our current course of action is just to use the config.active_record.time_zone_aware_types = [:datetime]
configuration for now and discuss a possible future refactoring.
Conclusion
Let’s look at the deprecation warning again. What is it trying to tell us?
DEPRECATION WARNING: Time columns will become time zone aware in Rails 5.1. This still causes `String`s to be parsed as if they were in `Time.zone`, and `Time`s to be converted to `Time.zone`.
To keep the old behavior, you must add the following to your initializer:
config.active_record.time_zone_aware_types = [:datetime]
To silence this deprecation warning, add the following:
config.active_record.time_zone_aware_types = [:datetime, :time]
It makes more sense now, but the explanation for the suggested configuration options is unclear. If you want to keep the old behaviour, you have to add the first configuration option to your Rails 5 project. If you want to use the entirely new behaviour, you can instead add the second configuration option. You have to add one of these two configuration options to silence the deprecation warning.
Here’s a good way of thinking about it: which columns in your application should return an ActiveSupport::TimeWithZone
object?
- To keep the old behaviour and only have
datetime
columns return anActiveSupport::TimeWithZone
object, use[:datetime]
. - To use the new behaviour and have
time
columns also return anActiveSupport::TimeWithZone
object, use[:datetime, :time]
.
And hey, it’s open source! If you see something that doesn’t quite make sense or you think might be wrong, it’s totally fixable. :)