Jens
Jens

Reputation: 1398

Rails Upgrade makes default Time format change. How to revert?

I am upgrading a Rails app from

in various steps. Since the Rails upgrade requires the Postgres upgrade I can't separate the upgrades in a sensible way.

Currently I am struggling with the way "Time" objects are handled in Rails 5.2. A "time" column in an AR object is now returned as an ActiveSupport::TimeWithZone, even if the database column has no time zone. Previously it was a plain Time object which had a different default JSON representation.

This makes a lot of API tests fail which were previously all returning UTC times.

Example for Rails 4.2, Ruby 2.2, PG 9.1 for a PhoneNumber object:

2.2.6 :002 > p.time_weekdays_from
  => 2000-01-01 07:00:00 UTC 
2.2.6 :003 > p.time_weekdays_from.class
  => Time 

Example for Rails 5.2, Ruby 2.5, PG 10:

irb(main):016:0> p.time_weekdays_from
  => Sat, 01 Jan 2000 11:15:00 CET +01:00
irb(main):018:0> p.time_weekdays_from.class
  => ActiveSupport::TimeWithZone

I have added an initializer to override this for the time being and this seems to work fine, but I'd nevertheless like to understnand why this change has been made and why even 'time without time zone' DB columns are being treated by Rails as if they had a timezone.

# This works, but why is it necessary?
module ActiveSupport
  class TimeWithZone
    def as_json(options = nil)
      self.utc.iso8601
    end
  end
end

PS: I don't always want UTC, I just want it for the API because that's what our API clients expect.

Upvotes: 4

Views: 1272

Answers (1)

Schwern
Schwern

Reputation: 164829

Currently I am struggling with the way "Time" objects are handled in Rails 5.2. A "time" column in an AR object is now returned as an ActiveSupport::TimeWithZone, even if the database column has no time zone. Previously it was a plain Time object which had a different default JSON representation.

I'd nevertheless like to understnand why this change has been made and why even 'time without time zone' DB columns are being treated by Rails as if they had a timezone.

This change was made because Ruby's default Time has no understanding of time zones. ActiveSupport::TimeWithZone can. This solves a lot of problems when working with times, time zones, and databases.

For example, let's say your application's time zone is America/Chicago. Previously you had to decide whether you're going to store your times with or without time zones. If you opt for without a time zone, do you store it as UTC or as America/Chicago? If you store it as UTC, do you convert it to America/New York on load or on display? Conversion means adding and subtracting hours from the Time. When you save Time objects you have to be careful to remember what time zone the Time was converted to and to convert it back to the database's time zone. Coordinating all this leads to many bugs.

Rails 5 introduces ActiveSupport::TimeWithZone. This stores the time as UTC and the desired time zone to represent it in. Now handling time is simpler: store it as UTC (ie. timestamp) and add the application's time zone on load. No conversion is necessary. Rails handles this for you.

The change is now timestamp columns, by default, will be formatted in the application's time zone. This takes some getting used to, but ultimately will make your handling of times and time zones more robust.

> Time.zone.tzinfo.name
=> "America/Chicago"

> Time.zone.utc_offset
=> -21600

# Displayed in the application time zone
> Foo.last.created_at
=> Tue, 31 Dec 2019 17:16:14 CST -06:00

# Stored as UTC
> Foo.last.created_at.utc
=> 2019-12-31 23:16:14 UTC

If you have code which manually does time zone conversions, get rid of it. Work in UTC. Time zones are now just formatting.

As much as possible...

  • Work with objects, not strings.
  • Work in UTC, time zones are for formatting.
  • If you need to turn a time into a string, make the formatting explicit.
    def get_api_time
      '2000-01-01 07:00:00 UTC'
    end

    # bad: downgrading to strings, implicit formatting
    expected_time = Time.utc(2000, 1, 1, 7)
    expect( get_api_time ).to eq expected_time

    # good: upgrading to objects, format is irrelevant
    expected_time = Time.zone.parse('2000-01-01 07:00:00 UTC')
    expect(
      Time.zone.parse(get_api_time)
    ).to eq expected_time

    # better: refactor method to return ActiveSupport::TimeWithZone
    def get_api_time
      Time.zone.parse('2000-01-01 07:00:00 UTC')
    end

    expected_time = Time.zone.parse('2000-01-01 07:00:00 UTC')
    expect( get_api_time ).to eq expected_time

I recommend reading these articles, they clear things up.

Upvotes: 2

Related Questions