From c9c5788a527b70d7f983e2b4b47e3afd863d9f48 Mon Sep 17 00:00:00 2001 From: Andrew White Date: Sat, 23 Apr 2016 14:46:50 +0100 Subject: [PATCH] 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. --- activesupport/CHANGELOG.md | 20 ++++ activesupport/lib/active_support.rb | 9 ++ .../core_ext/date_and_time/compatibility.rb | 16 +++ .../lib/active_support/core_ext/date_time.rb | 1 + .../core_ext/date_time/compatibility.rb | 16 +++ .../core_ext/string/conversions.rb | 2 +- .../lib/active_support/core_ext/time.rb | 1 + .../core_ext/time/compatibility.rb | 5 + .../lib/active_support/time_with_zone.rb | 8 +- .../date_and_time_compatibility_test.rb | 110 ++++++++++++++++++ .../test/core_ext/date_time_ext_test.rb | 8 ++ activesupport/test/time_zone_test_helpers.rb | 8 ++ railties/CHANGELOG.md | 8 ++ .../generators/rails/app/app_generator.rb | 5 + .../to_time_preserves_timezone.rb | 10 ++ .../test/generators/app_generator_test.rb | 28 +++++ 16 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb create mode 100644 activesupport/lib/active_support/core_ext/date_time/compatibility.rb create mode 100644 activesupport/lib/active_support/core_ext/time/compatibility.rb create mode 100644 activesupport/test/core_ext/date_and_time_compatibility_test.rb create mode 100644 railties/lib/rails/generators/rails/app/templates/config/initializers/to_time_preserves_timezone.rb diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index f8423c3ef87..23ca8246e26 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -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. diff --git a/activesupport/lib/active_support.rb b/activesupport/lib/active_support.rb index 72777baecd0..11569add37d 100644 --- a/activesupport/lib/active_support.rb +++ b/activesupport/lib/active_support.rb @@ -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" diff --git a/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb b/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb new file mode 100644 index 00000000000..b8eb587390d --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb @@ -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 diff --git a/activesupport/lib/active_support/core_ext/date_time.rb b/activesupport/lib/active_support/core_ext/date_time.rb index 5450533935a..86177488c0a 100644 --- a/activesupport/lib/active_support/core_ext/date_time.rb +++ b/activesupport/lib/active_support/core_ext/date_time.rb @@ -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' diff --git a/activesupport/lib/active_support/core_ext/date_time/compatibility.rb b/activesupport/lib/active_support/core_ext/date_time/compatibility.rb new file mode 100644 index 00000000000..63ac4c2f3a9 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/date_time/compatibility.rb @@ -0,0 +1,16 @@ +require 'active_support/core_ext/date_and_time/compatibility' + +class DateTime + prepend DateAndTime::Compatibility + + # Returns a Time.local() instance of the simultaneous time in your + # system's ENV['TZ'] 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 diff --git a/activesupport/lib/active_support/core_ext/string/conversions.rb b/activesupport/lib/active_support/core_ext/string/conversions.rb index 71612e09fa6..946976c5e96 100644 --- a/activesupport/lib/active_support/core_ext/string/conversions.rb +++ b/activesupport/lib/active_support/core_ext/string/conversions.rb @@ -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. diff --git a/activesupport/lib/active_support/core_ext/time.rb b/activesupport/lib/active_support/core_ext/time.rb index 72c3234630d..0bce6322227 100644 --- a/activesupport/lib/active_support/core_ext/time.rb +++ b/activesupport/lib/active_support/core_ext/time.rb @@ -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' diff --git a/activesupport/lib/active_support/core_ext/time/compatibility.rb b/activesupport/lib/active_support/core_ext/time/compatibility.rb new file mode 100644 index 00000000000..945319461b4 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/time/compatibility.rb @@ -0,0 +1,5 @@ +require 'active_support/core_ext/date_and_time/compatibility' + +class Time + prepend DateAndTime::Compatibility +end diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb index 79cc748cf5f..a44041af82f 100644 --- a/activesupport/lib/active_support/time_with_zone.rb +++ b/activesupport/lib/active_support/time_with_zone.rb @@ -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 diff --git a/activesupport/test/core_ext/date_and_time_compatibility_test.rb b/activesupport/test/core_ext/date_and_time_compatibility_test.rb new file mode 100644 index 00000000000..7cc2fae5bee --- /dev/null +++ b/activesupport/test/core_ext/date_and_time_compatibility_test.rb @@ -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 diff --git a/activesupport/test/core_ext/date_time_ext_test.rb b/activesupport/test/core_ext/date_time_ext_test.rb index 16efeeadd50..d57e71df3ba 100644 --- a/activesupport/test/core_ext/date_time_ext_test.rb +++ b/activesupport/test/core_ext/date_time_ext_test.rb @@ -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 diff --git a/activesupport/test/time_zone_test_helpers.rb b/activesupport/test/time_zone_test_helpers.rb index 9632b89d092..eb6f7d0f85b 100644 --- a/activesupport/test/time_zone_test_helpers.rb +++ b/activesupport/test/time_zone_test_helpers.rb @@ -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 diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 32bfdf272b9..3a4a77724f8 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -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. diff --git a/railties/lib/rails/generators/rails/app/app_generator.rb b/railties/lib/rails/generators/rails/app/app_generator.rb index f58e6ba6531..4d5bb364b2c 100644 --- a/railties/lib/rails/generators/rails/app/app_generator.rb +++ b/railties/lib/rails/generators/rails/app/app_generator.rb @@ -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 diff --git a/railties/lib/rails/generators/rails/app/templates/config/initializers/to_time_preserves_timezone.rb b/railties/lib/rails/generators/rails/app/templates/config/initializers/to_time_preserves_timezone.rb new file mode 100644 index 00000000000..8674be3227e --- /dev/null +++ b/railties/lib/rails/generators/rails/app/templates/config/initializers/to_time_preserves_timezone.rb @@ -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 diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index 2d9867fa9d3..25a8635e7d6 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -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]