Add compatibility for Ruby 2.4 `to_time` changes

In Ruby 2.4 the `to_time` method for both `DateTime` and `Time` will
preserve the timezone of the receiver when converting to an instance
of `Time`. Since Rails 5.0 will support Ruby 2.2, 2.3 and later we
need to introduce a compatibility layer so that apps that upgrade do
not break. New apps will have a config initializer file that defaults
to match the new Ruby 2.4 behavior going forward.

For information about the changes to Ruby see:
https://bugs.ruby-lang.org/issues/12189
https://bugs.ruby-lang.org/issues/12271

Fixes #24617.
This commit is contained in:
Andrew White 2016-04-23 14:46:50 +01:00
parent 1ffa1a852e
commit c9c5788a52
16 changed files with 248 additions and 7 deletions

View File

@ -1,3 +1,23 @@
* Add `ActiveSupport.to_time_preserves_timezone` config option to control
how `to_time` handles timezones. In Ruby 2.4+ the behavior will change
from converting to the local system timezone to preserving the timezone
of the receiver. This config option defaults to false so that apps made
with earlier versions of Rails are not affected when upgrading, e.g:
>> ENV['TZ'] = 'US/Eastern'
>> "2016-04-23T10:23:12.000Z".to_time
=> "2016-04-23T06:23:12.000-04:00"
>> ActiveSupport.to_time_preserves_timezone = true
>> "2016-04-23T10:23:12.000Z".to_time
=> "2016-04-23T10:23:12.000Z"
Fixes #24617.
*Andrew White*
* `ActiveSupport::TimeZone.country_zones(country_code)` looks up the
country's time zones by its two-letter ISO3166 country code, e.g.

View File

@ -26,6 +26,7 @@ require "active_support/dependencies/autoload"
require "active_support/version"
require "active_support/logger"
require "active_support/lazy_load_hooks"
require "active_support/core_ext/date_and_time/compatibility"
module ActiveSupport
extend ActiveSupport::Autoload
@ -85,6 +86,14 @@ module ActiveSupport
def self.halt_callback_chains_on_return_false=(value)
Callbacks.halt_and_display_warning_on_return_false = value
end
def self.to_time_preserves_timezone
DateAndTime::Compatibility.preserve_timezone
end
def self.to_time_preserves_timezone=(value)
DateAndTime::Compatibility.preserve_timezone = value
end
end
autoload :I18n, "active_support/i18n"

View File

@ -0,0 +1,16 @@
module DateAndTime
module Compatibility
# If true, +to_time+ preserves the the timezone offset.
#
# NOTE: With Ruby 2.4+ the default for +to_time+ changed from
# converting to the local system time to preserving the offset
# of the receiver. For backwards compatibility we're overriding
# this behavior but new apps will have an initializer that sets
# this to true because the new behavior is preferred.
mattr_accessor(:preserve_timezone, instance_writer: false) { false }
def to_time
preserve_timezone ? getlocal(utc_offset) : getlocal
end
end
end

View File

@ -1,4 +1,5 @@
require 'active_support/core_ext/date_time/acts_like'
require 'active_support/core_ext/date_time/blank'
require 'active_support/core_ext/date_time/calculations'
require 'active_support/core_ext/date_time/compatibility'
require 'active_support/core_ext/date_time/conversions'

View File

@ -0,0 +1,16 @@
require 'active_support/core_ext/date_and_time/compatibility'
class DateTime
prepend DateAndTime::Compatibility
# Returns a <tt>Time.local()</tt> instance of the simultaneous time in your
# system's <tt>ENV['TZ']</tt> zone.
def getlocal(utc_offset = nil)
utc = getutc
Time.utc(
utc.year, utc.month, utc.day,
utc.hour, utc.min, utc.sec + utc.sec_fraction
).getlocal(utc_offset)
end
end

View File

@ -32,7 +32,7 @@ class String
parts.fetch(:offset, form == :utc ? 0 : nil)
)
form == :utc ? time.utc : time.getlocal
form == :utc ? time.utc : time.to_time
end
# Converts a string to a Date value.

View File

@ -1,4 +1,5 @@
require 'active_support/core_ext/time/acts_like'
require 'active_support/core_ext/time/calculations'
require 'active_support/core_ext/time/compatibility'
require 'active_support/core_ext/time/conversions'
require 'active_support/core_ext/time/zones'

View File

@ -0,0 +1,5 @@
require 'active_support/core_ext/date_and_time/compatibility'
class Time
prepend DateAndTime::Compatibility
end

View File

@ -1,6 +1,7 @@
require 'active_support/duration'
require 'active_support/values/time_zone'
require 'active_support/core_ext/object/acts_like'
require 'active_support/core_ext/date_and_time/compatibility'
module ActiveSupport
# A Time-like class that can represent a time in any time zone. Necessary
@ -44,7 +45,7 @@ module ActiveSupport
PRECISIONS = Hash.new { |h, n| h[n] = "%FT%T.%#{n}N".freeze }
PRECISIONS[0] = '%FT%T'.freeze
include Comparable
include Comparable, DateAndTime::Compatibility
attr_reader :time_zone
def initialize(utc_time, time_zone, local_time = nil, period = nil)
@ -401,11 +402,6 @@ module ActiveSupport
utc.to_r
end
# Returns an instance of Time in the system timezone.
def to_time
utc.to_time
end
# Returns an instance of DateTime with the timezone's UTC offset
#
# Time.zone.now.to_datetime # => Tue, 18 Aug 2015 02:32:20 +0000

View File

@ -0,0 +1,110 @@
require 'abstract_unit'
require 'active_support/time'
require 'time_zone_test_helpers'
class DateAndTimeCompatibilityTest < ActiveSupport::TestCase
include TimeZoneTestHelpers
def setup
@utc_time = Time.utc(2016, 4, 23, 14, 11, 12)
@utc_offset = 3600
@system_offset = -14400
@zone = ActiveSupport::TimeZone['London']
end
def test_time_to_time_preserves_timezone
with_preserve_timezone(true) do
with_env_tz 'US/Eastern' do
time = Time.new(2016, 4, 23, 15, 11, 12, 3600).to_time
assert_instance_of Time, time
assert_equal @utc_time, time.getutc
assert_equal @utc_offset, time.utc_offset
end
end
end
def test_time_to_time_does_not_preserve_time_zone
with_preserve_timezone(false) do
with_env_tz 'US/Eastern' do
time = Time.new(2016, 4, 23, 15, 11, 12, 3600).to_time
assert_instance_of Time, time
assert_equal @utc_time, time.getutc
assert_equal @system_offset, time.utc_offset
end
end
end
def test_datetime_to_time_preserves_timezone
with_preserve_timezone(true) do
with_env_tz 'US/Eastern' do
time = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1,24)).to_time
assert_instance_of Time, time
assert_equal @utc_time, time.getutc
assert_equal @utc_offset, time.utc_offset
end
end
end
def test_datetime_to_time_does_not_preserve_time_zone
with_preserve_timezone(false) do
with_env_tz 'US/Eastern' do
time = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1,24)).to_time
assert_instance_of Time, time
assert_equal @utc_time, time.getutc
assert_equal @system_offset, time.utc_offset
end
end
end
def test_twz_to_time_preserves_timezone
with_preserve_timezone(true) do
with_env_tz 'US/Eastern' do
time = ActiveSupport::TimeWithZone.new(@utc_time, @zone).to_time
assert_instance_of Time, time
assert_equal @utc_time, time.getutc
assert_equal @utc_offset, time.utc_offset
end
end
end
def test_twz_to_time_does_not_preserve_time_zone
with_preserve_timezone(false) do
with_env_tz 'US/Eastern' do
time = ActiveSupport::TimeWithZone.new(@utc_time, @zone).to_time
assert_instance_of Time, time
assert_equal @utc_time, time.getutc
assert_equal @system_offset, time.utc_offset
end
end
end
def test_string_to_time_preserves_timezone
with_preserve_timezone(true) do
with_env_tz 'US/Eastern' do
time = "2016-04-23T15:11:12+01:00".to_time
assert_instance_of Time, time
assert_equal @utc_time, time.getutc
assert_equal @utc_offset, time.utc_offset
end
end
end
def test_string_to_time_does_not_preserve_time_zone
with_preserve_timezone(false) do
with_env_tz 'US/Eastern' do
time = "2016-04-23T15:11:12+01:00".to_time
assert_instance_of Time, time
assert_equal @utc_time, time.getutc
assert_equal @system_offset, time.utc_offset
end
end
end
end

View File

@ -40,6 +40,14 @@ class DateTimeExtCalculationsTest < ActiveSupport::TestCase
Time::DATE_FORMATS.delete(:custom)
end
def test_getlocal
with_env_tz 'US/Eastern' do
assert_equal Time.local(2016, 3, 11, 10, 11, 12), DateTime.new(2016, 3, 11, 15, 11, 12, 0).getlocal
assert_equal Time.local(2016, 3, 21, 11, 11, 12), DateTime.new(2016, 3, 21, 15, 11, 12, 0).getlocal
assert_equal Time.local(2016, 4, 1, 11, 11, 12), DateTime.new(2016, 4, 1, 16, 11, 12, Rational(1,24)).getlocal
end
end
def test_to_date
assert_equal Date.new(2005, 2, 21), DateTime.new(2005, 2, 21, 14, 30, 0).to_date
end

View File

@ -13,4 +13,12 @@ module TimeZoneTestHelpers
ensure
old_tz ? ENV['TZ'] = old_tz : ENV.delete('TZ')
end
def with_preserve_timezone(value)
old_preserve_tz = ActiveSupport.to_time_preserves_timezone
ActiveSupport.to_time_preserves_timezone = value
yield
ensure
ActiveSupport.to_time_preserves_timezone = old_preserve_tz
end
end

View File

@ -1,3 +1,11 @@
* Add `config/initializers/to_time_preserves_timezone.rb`, which tells
Active Support to preserve the receiver's timezone when calling `to_time`.
This matches the new behavior that will be part of Ruby 2.4.
Fixes #24617.
*Andrew White*
* Make `rails restart` command work with Puma by passing the restart command
which Puma can use to restart rails server.

View File

@ -92,6 +92,7 @@ module Rails
cookie_serializer_config_exist = File.exist?('config/initializers/cookies_serializer.rb')
callback_terminator_config_exist = File.exist?('config/initializers/callback_terminator.rb')
active_record_belongs_to_required_by_default_config_exist = File.exist?('config/initializers/active_record_belongs_to_required_by_default.rb')
to_time_preserves_timezone_config_exist = File.exist?('config/initializers/to_time_preserves_timezone.rb')
action_cable_config_exist = File.exist?('config/cable.yml')
ssl_options_exist = File.exist?('config/initializers/ssl_options.rb')
rack_cors_config_exist = File.exist?('config/initializers/cors.rb')
@ -112,6 +113,10 @@ module Rails
remove_file 'config/initializers/active_record_belongs_to_required_by_default.rb'
end
unless to_time_preserves_timezone_config_exist
remove_file 'config/initializers/to_time_preserves_timezone.rb'
end
unless action_cable_config_exist
template 'config/cable.yml'
end

View File

@ -0,0 +1,10 @@
# Be sure to restart your server when you modify this file.
# Preserve the timezone of the receiver when calling to `to_time`.
# Ruby 2.4 will change the behavior of `to_time` to preserve the timezone
# when converting to an instance of `Time` instead of the previous behavior
# of converting to the local system timezone.
#
# Rails 5.0 introduced this config option so that apps made with earlier
# versions of Rails are not affected when upgrading.
ActiveSupport.to_time_preserves_timezone = true

View File

@ -257,6 +257,34 @@ class AppGeneratorTest < Rails::Generators::TestCase
end
end
def test_rails_update_does_not_create_to_time_preserves_timezone
app_root = File.join(destination_root, 'myapp')
run_generator [app_root]
FileUtils.rm("#{app_root}/config/initializers/to_time_preserves_timezone.rb")
stub_rails_application(app_root) do
generator = Rails::Generators::AppGenerator.new ["rails"], [], destination_root: app_root, shell: @shell
generator.send(:app_const)
quietly { generator.send(:update_config_files) }
assert_no_file "#{app_root}/config/initializers/to_time_preserves_timezone.rb"
end
end
def test_rails_update_does_not_remove_to_time_preserves_timezone_if_already_present
app_root = File.join(destination_root, 'myapp')
run_generator [app_root]
FileUtils.touch("#{app_root}/config/initializers/to_time_preserves_timezone.rb")
stub_rails_application(app_root) do
generator = Rails::Generators::AppGenerator.new ["rails"], [], destination_root: app_root, shell: @shell
generator.send(:app_const)
quietly { generator.send(:update_config_files) }
assert_file "#{app_root}/config/initializers/to_time_preserves_timezone.rb"
end
end
def test_rails_update_does_not_create_ssl_options_by_default
app_root = File.join(destination_root, 'myapp')
run_generator [app_root]