From c6369337d75a00dc2e279cc15033dac5745e9ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20M=C3=BCller?= Date: Tue, 21 Aug 2018 14:15:40 +0200 Subject: [PATCH] Add Time#shift with calendrical arguments --- spec/std/time/time_spec.cr | 79 +++++++++++++++++------ src/time.cr | 124 ++++++++++++++++++++++++++++--------- 2 files changed, 157 insertions(+), 46 deletions(-) diff --git a/spec/std/time/time_spec.cr b/spec/std/time/time_spec.cr index cce64d334e21..54c9552102f9 100644 --- a/spec/std/time/time_spec.cr +++ b/spec/std/time/time_spec.cr @@ -270,6 +270,33 @@ describe Time do time.shift(0, 0).should eq time end + describe "irregular calendrical unit ratios" do + it "shifts by a week if one day is left out" do + # The week from 2011-12-25 to 2012-01-01 for example lasted only 6 days in Samoa, + # because it skipped 2011-12-28 due to changing time zone from -11:00 to +13:00. + with_zoneinfo do + samoa = Time::Location.load("Pacific/Apia") + start = Time.new(2011, 12, 25, 0, 0, 0, location: samoa) + + plus_one_week = start.shift days: 7 + plus_one_week.should eq start + 6.days + + plus_one_year = start.shift years: 1 + plus_one_year.should eq start + 365.days # 2012 is a leap year so it should've been 366 days, but 2011-12-28 was skipped + end + end + + it "shifts by conceptual hour even if elapsed time is less" do + # Venezuela switched from -4:30 to -4:00 on 2016-05-01, the hour between 2:00 and 3:00 lasted only 30 minutes + with_zoneinfo do + venezuela = Time::Location.load("America/Caracas") + start = Time.new(2016, 5, 1, 2, 0, 0, location: venezuela) + plus_one_hour = start.shift hours: 1 + plus_one_hour.should eq start + 30.minutes + end + end + end + describe "adds days" do it "simple" do time = Time.utc(2002, 2, 25, 15, 25, 13) @@ -284,13 +311,13 @@ describe Time do time.should eq Time.utc(2002, 3, 2, 17, 49, 13) end - pending "over dst" do + it "over dst" do with_zoneinfo do location = Time::Location.load("Europe/Berlin") reference = Time.new(2017, 10, 28, 13, 37, location: location) - next_day = Time.new(2017, 10, 29, 13, 37, location: location) + next_day = reference.shift days: 1 - (reference + 1.day).should eq next_day + next_day.should eq reference + 25.hours end end @@ -301,44 +328,59 @@ describe Time do end end + pending "out of range max (shift days)" do + # this will be fixed with raise on overflow + time = Time.utc(2002, 2, 25, 15, 25, 13) + expect_raises ArgumentError do + time.shift days: 10000000 + end + end + it "out of range min" do time = Time.utc(2002, 2, 25, 15, 25, 13) expect_raises ArgumentError do time - 10000000.days end end + + pending "out of range min (shift days)" do + # this will be fixed with raise on overflow + time = Time.utc(2002, 2, 25, 15, 25, 13) + expect_raises ArgumentError do + time.shift days: -10000000 + end + end end it "adds months" do t = Time.utc 2014, 10, 30, 21, 18, 13 - t2 = t + 1.month + t2 = t.shift months: 1 t2.should eq Time.utc(2014, 11, 30, 21, 18, 13) - t2 = t + 1.months + t2 = t.shift months: 1 t2.should eq Time.utc(2014, 11, 30, 21, 18, 13) t = Time.utc 2014, 10, 31, 21, 18, 13 - t2 = t + 1.month + t2 = t.shift months: 1 t2.should eq Time.utc(2014, 11, 30, 21, 18, 13) t = Time.utc 2014, 10, 31, 21, 18, 13 - t2 = t - 1.month + t2 = t.shift months: -1 t2.should eq Time.utc(2014, 9, 30, 21, 18, 13) t = Time.utc 2014, 10, 31, 21, 18, 13 - t2 = t + 6.month + t2 = t.shift months: 6 t2.should eq Time.utc(2015, 4, 30, 21, 18, 13) end it "adds years" do t = Time.utc 2014, 10, 30, 21, 18, 13 - - t2 = t + 1.year + t2 = t.shift years: 1 t2.should eq Time.utc(2015, 10, 30, 21, 18, 13) t = Time.utc 2014, 10, 30, 21, 18, 13 - t2 = t - 2.years + t2 = t.shift years: -2 t2.should eq Time.utc(2012, 10, 30, 21, 18, 13) end @@ -356,15 +398,16 @@ describe Time do end it "adds nanoseconds" do - time = Time.utc(2002, 2, 25, 15, 25, 13) - time = time + 1e16.nanoseconds - time.should eq Time.utc(2002, 6, 21, 9, 11, 53) + t1 = Time.utc(2002, 2, 25, 15, 25, 13) + t1 = t1.shift nanoseconds: 10_000_000_000_000_000 + + t1.should eq Time.utc(2002, 6, 21, 9, 11, 53) - time = time - 19e16.nanoseconds - time.should eq Time.utc(1996, 6, 13, 7, 25, 13) + t1 = t1.shift nanoseconds: -190_000_000_000_000_000 + t1.should eq Time.utc(1996, 6, 13, 7, 25, 13) - time = time + 15_623_487.nanoseconds - time.should eq Time.utc(1996, 6, 13, 7, 25, 13, nanosecond: 15_623_487) + t1 = t1.shift nanoseconds: 15_623_000 + t1.should eq Time.utc(1996, 6, 13, 7, 25, 13, nanosecond: 15_623_000) end it "preserves location when adding" do diff --git a/src/time.cr b/src/time.cr index 378f12f4f0fd..2736a317d8a7 100644 --- a/src/time.cr +++ b/src/time.cr @@ -585,7 +585,7 @@ struct Time # If the resulting date-time is ambiguous due to time zone transitions, # a correct time will be returned, but it does not guarantee which. def +(span : Time::MonthSpan) : Time - add_months span.value + shift months: span.value.to_i end # Returns a copy of this `Time` with *span* subtracted. @@ -603,37 +603,15 @@ struct Time # If the resulting date-time is ambiguous due to time zone transitions, # a correct time will be returned, but it does not guarantee which. def -(span : Time::MonthSpan) : Time - add_months -span.value + shift months: -span.value.to_i end - private def add_months(months) - day = self.day - month = self.month + months.remainder(12) - year = self.year + months.tdiv(12) - - if month < 1 - month = 12 + month - year -= 1 - elsif month > 12 - month = month - 12 - year += 1 - end - - maxday = Time.days_in_month(year, month) - if day > maxday - day = maxday - end - - temp = Time.new(year, month, day, location: location) - temp + time_of_day - end - - # Returns a copy of this `Time` with the number of *seconds* and - # *nanoseconds* added. + # Returns a copy of this `Time` shifted by the number of *seconds* and + # *nanoseconds*. # # Positive values result in a later time, negative values in an earlier time. # - # This operates on the instant time-line, such that adding the eqivalent of + # This operates on the instant time-line, such that adding the equivalent of # one hour will always be a duration of one hour later. # The local date-time representation may change by a different amount, # depending on time zone transitions. @@ -664,6 +642,96 @@ struct Time Time.new(seconds: seconds, nanoseconds: nanoseconds.to_i, location: location) end + # Returns a copy of this `Time` shifted by the amount of calendrical units + # provided as arguments. + # + # Positive values result in a later time, negative values in an earlier time. + # + # This operates on the local time-line, such that the local date-time + # represenation of the result will be apart by the specified amounts, but the + # elapsed time between both instances might not equal to the combined default + # durations + # This is the case for example when adding a day over a daylight-savings time + # change: + # + # ``` + # start = Time.new(2017, 10, 28, 13, 37, location: Time::Location.load("Europe/Berlin")) + # one_day_later = start.shift days: 1 + # + # one_day_later - start # => 25.hours + # ``` + # + # *years* is equivalent to `12` months and *weeks* is equivalent to `7` days. + # + # If the day-of-month resulting from shifting by *years* and *months* would be + # invalid, the date is adjusted to the last valid day of the month. + # For example, adding one month to `2018-07-31` would result in the invalid + # date `2018-08-31` which will be adjusted to `2018-08-30`: + # ``` + # Time.utc(2018, 7, 31).shift(months: 1) # => Time.utc(2018, 8, 30) + # ``` + # + # Overflow in smaller units is transferred to the next larger unit. + # + # Changes are applied in the same order as the arguments, sorted by increasing + # granularity. This is relevant because the order of operations can change the result: + # + # ``` + # Time.utc(2018, 7, 31).shift(months: 1, days: -1) # => Time.utc(2018, 8, 29) + # Time.utc(2018, 7, 31).shift(months: 1).shift(days: -1) # => Time.utc(2018, 8, 29) + # Time.utc(2018, 7, 31).shift(days: -1).shift(months: 1) # => Time.utc(2018, 8, 30) + # ``` + # + # There is no explicit limit on the input values but the shift must result + # in a valid time between `0001-01-01 00:00:00.0` and + # `9999-12-31 23:59:59.999_999_999`. Otherwise `ArgumentError` is raised. + # + # If the resulting date-time is ambiguous due to time zone transitions, + # a correct time will be returned, but it does not guarantee which. + def shift(*, years : Int = 0, months : Int = 0, weeks : Int = 0, days : Int = 0, + hours : Int = 0, minutes : Int = 0, seconds : Int = 0, nanoseconds : Int = 0) + seconds = seconds.to_i64 + + # Skip the entire month-based calculations if year and month are zero + if years.zero? && months.zero? + # Using offset_seconds with applied zone offset so that calculations + # are applied to the equivalent UTC representation of this local time. + seconds += offset_seconds + else + year, month, day, _ = to_utc.year_month_day_day_year + + year += years + + months += month + year += months.tdiv(12) + month = months.remainder(12) + + if month < 1 + month = 12 + month + year -= 1 + end + + maxday = Time.days_in_month(year, month) + if day > maxday + day = maxday + end + + seconds += Time.absolute_days(year, month, day).to_i64 * SECONDS_PER_DAY + seconds += offset_seconds % SECONDS_PER_DAY + end + + # FIXME: These operations currently don't have overflow checks applied. + # This should be fixed when operators by default raise on overflow. + seconds += weeks * SECONDS_PER_WEEK + seconds += days * SECONDS_PER_DAY + seconds += hours * SECONDS_PER_HOUR + seconds += minutes * SECONDS_PER_MINUTE + + # Apply the nanosecond shift (including overflow handling) and transform to + # local time zone in `location`: + Time.utc(seconds: seconds, nanoseconds: self.nanosecond).shift(0, nanoseconds).to_local_in(location) + end + # Returns a `Time::Span` amounting to the duration between *other* and `self`. # # The time span is negative if `self` is before *other*. @@ -1366,7 +1434,7 @@ struct Time @seconds + offset end - private def year_month_day_day_year + protected def year_month_day_day_year m = 1 days = DAYS_MONTH