From 851b7f866e13518d900407c78dcd6eb477afad06 Mon Sep 17 00:00:00 2001
From: Andrew White <andrew.white@unboxed.co>
Date: Fri, 14 Apr 2017 18:04:13 +0100
Subject: Add additional options to time `change` methods

Support `:offset` in `Time#change` and `:zone` or `:offset`
in `ActiveSupport::TimeWithZone#change`.

Fixes #28723.
---
 activesupport/CHANGELOG.md                         | 10 ++++++
 .../active_support/core_ext/time/calculations.rb   | 36 +++++++++++++---------
 activesupport/lib/active_support/time_with_zone.rb | 36 ++++++++++++++++++++++
 activesupport/test/core_ext/date_time_ext_test.rb  |  3 ++
 activesupport/test/core_ext/time_ext_test.rb       |  7 +++++
 activesupport/test/core_ext/time_with_zone_test.rb |  6 ++++
 6 files changed, 83 insertions(+), 15 deletions(-)

(limited to 'activesupport')

diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md
index 1adc473e1d..6146dd989f 100644
--- a/activesupport/CHANGELOG.md
+++ b/activesupport/CHANGELOG.md
@@ -1,3 +1,13 @@
+*   Add support for `:offset` and `:zone` to `ActiveSupport::TimeWithZone#change`
+
+    *Andrew White*
+
+*   Add support for `:offset` to `Time#change`
+
+    Fixes #28723.
+
+    *Andrew White*
+
 *   Add `fetch_values` for `HashWithIndifferentAccess`
 
     The method was originally added to `Hash` in Ruby 2.3.0.
diff --git a/activesupport/lib/active_support/core_ext/time/calculations.rb b/activesupport/lib/active_support/core_ext/time/calculations.rb
index 7b7aeef25a..d3f23f4663 100644
--- a/activesupport/lib/active_support/core_ext/time/calculations.rb
+++ b/activesupport/lib/active_support/core_ext/time/calculations.rb
@@ -107,21 +107,22 @@ class Time
   # to the +options+ parameter. The time options (<tt>:hour</tt>, <tt>:min</tt>,
   # <tt>:sec</tt>, <tt>:usec</tt>, <tt>:nsec</tt>) reset cascadingly, so if only
   # the hour is passed, then minute, sec, usec and nsec is set to 0. If the hour
-  # and minute is passed, then sec, usec and nsec is set to 0. The +options+
-  # parameter takes a hash with any of these keys: <tt>:year</tt>, <tt>:month</tt>,
-  # <tt>:day</tt>, <tt>:hour</tt>, <tt>:min</tt>, <tt>:sec</tt>, <tt>:usec</tt>
-  # <tt>:nsec</tt>. Pass either <tt>:usec</tt> or <tt>:nsec</tt>, not both.
+  # and minute is passed, then sec, usec and nsec is set to 0. The +options+ parameter
+  # takes a hash with any of these keys: <tt>:year</tt>, <tt>:month</tt>, <tt>:day</tt>,
+  # <tt>:hour</tt>, <tt>:min</tt>, <tt>:sec</tt>, <tt>:usec</tt>, <tt>:nsec</tt>,
+  # <tt>:offset</tt>. Pass either <tt>:usec</tt> or <tt>:nsec</tt>, not both.
   #
   #   Time.new(2012, 8, 29, 22, 35, 0).change(day: 1)              # => Time.new(2012, 8, 1, 22, 35, 0)
   #   Time.new(2012, 8, 29, 22, 35, 0).change(year: 1981, day: 1)  # => Time.new(1981, 8, 1, 22, 35, 0)
   #   Time.new(2012, 8, 29, 22, 35, 0).change(year: 1981, hour: 0) # => Time.new(1981, 8, 29, 0, 0, 0)
   def change(options)
-    new_year  = options.fetch(:year, year)
-    new_month = options.fetch(:month, month)
-    new_day   = options.fetch(:day, day)
-    new_hour  = options.fetch(:hour, hour)
-    new_min   = options.fetch(:min, options[:hour] ? 0 : min)
-    new_sec   = options.fetch(:sec, (options[:hour] || options[:min]) ? 0 : sec)
+    new_year   = options.fetch(:year, year)
+    new_month  = options.fetch(:month, month)
+    new_day    = options.fetch(:day, day)
+    new_hour   = options.fetch(:hour, hour)
+    new_min    = options.fetch(:min, options[:hour] ? 0 : min)
+    new_sec    = options.fetch(:sec, (options[:hour] || options[:min]) ? 0 : sec)
+    new_offset = options.fetch(:offset, nil)
 
     if new_nsec = options[:nsec]
       raise ArgumentError, "Can't change both :nsec and :usec at the same time: #{options.inspect}" if options[:usec]
@@ -130,13 +131,18 @@ class Time
       new_usec = options.fetch(:usec, (options[:hour] || options[:min] || options[:sec]) ? 0 : Rational(nsec, 1000))
     end
 
-    if utc?
-      ::Time.utc(new_year, new_month, new_day, new_hour, new_min, new_sec, new_usec)
+    raise ArgumentError, "argument out of range" if new_usec >= 1000000
+
+    new_sec += Rational(new_usec, 1000000)
+
+    if new_offset
+      ::Time.new(new_year, new_month, new_day, new_hour, new_min, new_sec, new_offset)
+    elsif utc?
+      ::Time.utc(new_year, new_month, new_day, new_hour, new_min, new_sec)
     elsif zone
-      ::Time.local(new_year, new_month, new_day, new_hour, new_min, new_sec, new_usec)
+      ::Time.local(new_year, new_month, new_day, new_hour, new_min, new_sec)
     else
-      raise ArgumentError, "argument out of range" if new_usec >= 1000000
-      ::Time.new(new_year, new_month, new_day, new_hour, new_min, new_sec + (new_usec.to_r / 1000000), utc_offset)
+      ::Time.new(new_year, new_month, new_day, new_hour, new_min, new_sec, utc_offset)
     end
   end
 
diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb
index b0dd6b7e8c..ecb9fb6785 100644
--- a/activesupport/lib/active_support/time_with_zone.rb
+++ b/activesupport/lib/active_support/time_with_zone.rb
@@ -330,6 +330,42 @@ module ActiveSupport
       since(-other)
     end
 
+    # Returns a new +ActiveSupport::TimeWithZone+ where one or more of the elements have
+    # been changed according to the +options+ parameter. The time options (<tt>:hour</tt>,
+    # <tt>:min</tt>, <tt>:sec</tt>, <tt>:usec</tt>, <tt>:nsec</tt>) reset cascadingly,
+    # so if only the hour is passed, then minute, sec, usec and nsec is set to 0. If the
+    # hour and minute is passed, then sec, usec and nsec is set to 0. The +options+
+    # parameter takes a hash with any of these keys: <tt>:year</tt>, <tt>:month</tt>,
+    # <tt>:day</tt>, <tt>:hour</tt>, <tt>:min</tt>, <tt>:sec</tt>, <tt>:usec</tt>,
+    # <tt>:nsec</tt>, <tt>:offset</tt>, <tt>:zone</tt>. Pass either <tt>:usec</tt>
+    # or <tt>:nsec</tt>, not both. Similarly, pass either <tt>:zone</tt> or
+    # <tt>:offset</tt>, not both.
+    #
+    #   t = Time.zone.now          # => Fri, 14 Apr 2017 11:45:15 EST -05:00
+    #   t.change(year: 2020)       # => Tue, 14 Apr 2020 11:45:15 EST -05:00
+    #   t.change(hour: 12)         # => Fri, 14 Apr 2017 12:00:00 EST -05:00
+    #   t.change(min: 30)          # => Fri, 14 Apr 2017 11:30:00 EST -05:00
+    #   t.change(offset: "-10:00") # => Fri, 14 Apr 2017 11:45:15 HST -10:00
+    #   t.change(zone: "Hawaii")   # => Fri, 14 Apr 2017 11:45:15 HST -10:00
+    def change(options)
+      if options[:zone] && options[:offset]
+        raise ArgumentError, "Can't change both :offset and :zone at the same time: #{options.inspect}"
+      end
+
+      new_time = time.change(options)
+
+      if options[:zone]
+        new_zone = ::Time.find_zone(options[:zone])
+      elsif options[:offset]
+        new_zone = ::Time.find_zone(new_time.utc_offset)
+      end
+
+      new_zone ||= time_zone
+      periods = new_zone.periods_for_local(new_time)
+
+      self.class.new(nil, new_zone, new_time, periods.include?(period) ? period : nil)
+    end
+
     # Uses Date to provide precise Time calculations for years, months, and days
     # according to the proleptic Gregorian calendar. The result is returned as a
     # new TimeWithZone object.
diff --git a/activesupport/test/core_ext/date_time_ext_test.rb b/activesupport/test/core_ext/date_time_ext_test.rb
index 36f0ee22b8..be7c14e9b4 100644
--- a/activesupport/test/core_ext/date_time_ext_test.rb
+++ b/activesupport/test/core_ext/date_time_ext_test.rb
@@ -166,6 +166,9 @@ class DateTimeExtCalculationsTest < ActiveSupport::TestCase
     assert_equal DateTime.civil(2005, 2, 22, 16, 45),     DateTime.civil(2005, 2, 22, 15, 15, 10).change(hour: 16, min: 45)
     assert_equal DateTime.civil(2005, 2, 22, 15, 45),     DateTime.civil(2005, 2, 22, 15, 15, 10).change(min: 45)
 
+    # datetime with non-zero offset
+    assert_equal DateTime.civil(2005, 2, 22, 15, 15, 10, Rational(-5, 24)), DateTime.civil(2005, 2, 22, 15, 15, 10, 0).change(offset: Rational(-5, 24))
+
     # datetime with fractions of a second
     assert_equal DateTime.civil(2005, 2, 1, 15, 15, 10.7), DateTime.civil(2005, 2, 22, 15, 15, 10.7).change(day: 1)
     assert_equal DateTime.civil(2005, 1, 2, 11, 22, Rational(33000008, 1000000)), DateTime.civil(2005, 1, 2, 11, 22, 33).change(usec: 8)
diff --git a/activesupport/test/core_ext/time_ext_test.rb b/activesupport/test/core_ext/time_ext_test.rb
index bd644c8457..625a5bffb8 100644
--- a/activesupport/test/core_ext/time_ext_test.rb
+++ b/activesupport/test/core_ext/time_ext_test.rb
@@ -433,6 +433,13 @@ class TimeExtCalculationsTest < ActiveSupport::TestCase
     assert_raise(ArgumentError) { Time.new(2005, 2, 22, 15, 15, 45, "-08:00").change(nsec: 1000000000) }
   end
 
+  def test_change_offset
+    assert_equal Time.new(2006, 2, 22, 15, 15, 10, "-08:00"), Time.new(2006, 2, 22, 15, 15, 10, "+01:00").change(offset: "-08:00")
+    assert_equal Time.new(2006, 2, 22, 15, 15, 10, -28800), Time.new(2006, 2, 22, 15, 15, 10, 3600).change(offset: -28800)
+    assert_raise(ArgumentError) { Time.new(2005, 2, 22, 15, 15, 45, "+01:00").change(usec: 1000000, offset: "-08:00") }
+    assert_raise(ArgumentError) { Time.new(2005, 2, 22, 15, 15, 45, "+01:00").change(nsec: 1000000000, offset: -28800) }
+  end
+
   def test_advance
     assert_equal Time.local(2006, 2, 28, 15, 15, 10), Time.local(2005, 2, 28, 15, 15, 10).advance(years: 1)
     assert_equal Time.local(2005, 6, 28, 15, 15, 10), Time.local(2005, 2, 28, 15, 15, 10).advance(months: 4)
diff --git a/activesupport/test/core_ext/time_with_zone_test.rb b/activesupport/test/core_ext/time_with_zone_test.rb
index c3afe68378..70ae793cda 100644
--- a/activesupport/test/core_ext/time_with_zone_test.rb
+++ b/activesupport/test/core_ext/time_with_zone_test.rb
@@ -625,6 +625,12 @@ class TimeWithZoneTest < ActiveSupport::TestCase
     assert_equal "Fri, 31 Dec 1999 06:00:00 EST -05:00", @twz.change(hour: 6).inspect
     assert_equal "Fri, 31 Dec 1999 19:15:00 EST -05:00", @twz.change(min: 15).inspect
     assert_equal "Fri, 31 Dec 1999 19:00:30 EST -05:00", @twz.change(sec: 30).inspect
+    assert_equal "Fri, 31 Dec 1999 19:00:00 HST -10:00", @twz.change(offset: "-10:00").inspect
+    assert_equal "Fri, 31 Dec 1999 19:00:00 HST -10:00", @twz.change(offset: -36000).inspect
+    assert_equal "Fri, 31 Dec 1999 19:00:00 HST -10:00", @twz.change(zone: "Hawaii").inspect
+    assert_equal "Fri, 31 Dec 1999 19:00:00 HST -10:00", @twz.change(zone: -10).inspect
+    assert_equal "Fri, 31 Dec 1999 19:00:00 HST -10:00", @twz.change(zone: -36000).inspect
+    assert_equal "Fri, 31 Dec 1999 19:00:00 HST -10:00", @twz.change(zone: "Pacific/Honolulu").inspect
   end
 
   def test_change_at_dst_boundary
-- 
cgit v1.2.3