From d56840d73a5c4696e1058a81ef45529a26afa8e5 Mon Sep 17 00:00:00 2001 From: Timur Safin <tsafin@tarantool.org> Date: Tue, 1 Feb 2022 01:10:06 +0300 Subject: [PATCH] datetime: make date.new{} and date:set{} equivalent Constructor date.new() and modifier date:set() should always produce same result for all attributes combinations. Fixed the problem for `timestamp` with `tzoffset`. Fixes #6793 @TarantoolBot document Title: datetime :set{} with tzoffset Constructor `date.new()` and modifier `date:set()` should always produce same result for all attributes combinations. Fixed the problem for `timestamp` with `tzoffset`. ``` tarantool> date.new{tzoffset = '+0800', timestamp = 1630359071} --- - 2021-08-30T21:31:11+0800 ... tarantool> date.new():set{tzoffset = '+0800', timestamp = 1630359071} --- - 2021-08-30T21:31:11+0800 ... ``` --- .../gh-6793-datetime-set-with-tzoffset.md | 5 ++ src/lua/datetime.lua | 62 ++++++++++++------- test/app-tap/datetime.test.lua | 52 +++++++++++++--- 3 files changed, 86 insertions(+), 33 deletions(-) create mode 100644 changelogs/unreleased/gh-6793-datetime-set-with-tzoffset.md diff --git a/changelogs/unreleased/gh-6793-datetime-set-with-tzoffset.md b/changelogs/unreleased/gh-6793-datetime-set-with-tzoffset.md new file mode 100644 index 0000000000..cda5cfaf41 --- /dev/null +++ b/changelogs/unreleased/gh-6793-datetime-set-with-tzoffset.md @@ -0,0 +1,5 @@ +## bugfix/datetime + + * Fixed a bug in datetime module when `date:set{tzoffset=XXX}` was not + producing the same result with `date.new{tzoffset=XXX}` for the same + set of attributes passed (gh-6793). diff --git a/src/lua/datetime.lua b/src/lua/datetime.lua index 12e1ebab24..ae10b56cf6 100644 --- a/src/lua/datetime.lua +++ b/src/lua/datetime.lua @@ -376,6 +376,16 @@ local function utc_secs(epoch, tzoffset) return epoch - tzoffset * 60 end +local function time_delocalize(self) + self.epoch = local_secs(self) + self.tzoffset = 0 +end + +local function time_localize(self, offset) + self.epoch = utc_secs(self.epoch, offset) + self.tzoffset = offset +end + -- get epoch seconds, shift to the local timezone -- adjust from 1970-related to 0000-related time -- then return dt in those coordinates (number of days @@ -942,14 +952,13 @@ local function datetime_totable(self) } end -local function datetime_update_dt(self, dt, new_offset) - local epoch = local_secs(self) +local function datetime_update_dt(self, dt) + local epoch = self.epoch local secs_day = epoch % SECS_PER_DAY - epoch = (dt - DAYS_EPOCH_OFFSET) * SECS_PER_DAY + secs_day - self.epoch = utc_secs(epoch, new_offset) + self.epoch = (dt - DAYS_EPOCH_OFFSET) * SECS_PER_DAY + secs_day end -local function datetime_ymd_update(self, y, M, d, new_offset) +local function datetime_ymd_update(self, y, M, d) if d < 0 then d = builtin.tnt_dt_days_in_month(y, M) elseif d > 28 then @@ -960,13 +969,13 @@ local function datetime_ymd_update(self, y, M, d, new_offset) end end local dt = dt_from_ymd_checked(y, M, d) - datetime_update_dt(self, dt, new_offset) + datetime_update_dt(self, dt) end -local function datetime_hms_update(self, h, m, s, new_offset) - local epoch = local_secs(self) +local function datetime_hms_update(self, h, m, s) + local epoch = self.epoch local secs_day = epoch - (epoch % SECS_PER_DAY) - self.epoch = utc_secs(secs_day + h * 3600 + m * 60 + s, new_offset) + self.epoch = secs_day + h * 3600 + m * 60 + s end local function datetime_set(self, obj) @@ -1040,6 +1049,18 @@ local function datetime_set(self, obj) end end + local offset = obj.tzoffset + if offset ~= nil then + offset = get_timezone(offset, 'tzoffset') + check_range(offset, -720, 840, 'tzoffset') + end + offset = offset or self.tzoffset + + local tzname = obj.tz + if tzname ~= nil then + offset, self.tzindex = parse_tzname(tzname) + end + local ts = obj.timestamp if ts ~= nil then if ymd then @@ -1060,38 +1081,31 @@ local function datetime_set(self, obj) 'if nsec, usec, or msecs provided', 2) end - self.epoch = sec_int + self.epoch = utc_secs(sec_int, offset) self.nsec = nsec + self.tzoffset = offset return self end - local offset = obj.tzoffset - if offset ~= nil then - offset = get_timezone(offset, 'tzoffset') - check_range(offset, -720, 840, 'tzoffset') - end - offset = offset or self.tzoffset - - local tzname = obj.tz - if tzname ~= nil then - offset, self.tzindex = parse_tzname(tzname) - end + -- normalize time to UTC from current timezone + time_delocalize(self) -- .year, .month, .day if ymd then y = y or y0 M = M or M0 d = d or d0 - datetime_ymd_update(self, y, M, d, offset) + datetime_ymd_update(self, y, M, d) end -- .hour, .minute, .second if hms then - datetime_hms_update(self, h or h0, m or m0, sec or sec0, offset) + datetime_hms_update(self, h or h0, m or m0, sec or sec0) end - self.tzoffset = offset + -- denormalize back to local timezone + time_localize(self, offset) return self end diff --git a/test/app-tap/datetime.test.lua b/test/app-tap/datetime.test.lua index 398b2848fd..48e4e8f7dd 100755 --- a/test/app-tap/datetime.test.lua +++ b/test/app-tap/datetime.test.lua @@ -11,7 +11,7 @@ local ffi = require('ffi') --]] if jit.arch == 'arm64' then jit.off() end -test:plan(36) +test:plan(37) -- minimum supported date - -5879610-06-22 local MIN_DATE_YEAR = -5879610 @@ -804,7 +804,7 @@ local strftime_formats = { test:test("Datetime string formatting detailed", function(test) test:plan(77) local T = date.new{ timestamp = 0.125 } - T:set{ tzoffset = 180 } + T:set{ hour = 3, tzoffset = 180 } test:is(tostring(T), '1970-01-01T03:00:00.125+0300', 'tostring()') for _, row in pairs(strftime_formats) do @@ -817,7 +817,7 @@ end) test:test("Datetime string parsing by format (detailed)", function(test) test:plan(68) local T = date.new{ timestamp = 0.125 } - T:set{ tzoffset = 180 } + T:set{ hour = 3, tzoffset = 180 } test:is(tostring(T), '1970-01-01T03:00:00.125+0300', 'tostring()') for _, row in pairs(strftime_formats) do @@ -1667,20 +1667,54 @@ test:test("Time :set{} operations", function(test) 'hour 6') test:is(tostring(ts:set{ min = 12, sec = 23 }), '2020-11-09T04:12:23+0300', 'min 12, sec 23') - test:is(tostring(ts:set{ tzoffset = -8*60 }), '2020-11-08T17:12:23-0800', + test:is(tostring(ts:set{ tzoffset = -8*60 }), '2020-11-09T04:12:23-0800', 'offset -0800' ) - test:is(tostring(ts:set{ tzoffset = '+0800' }), '2020-11-09T09:12:23+0800', + test:is(tostring(ts:set{ tzoffset = '+0800' }), '2020-11-09T04:12:23+0800', 'offset +0800' ) + -- timestamp 1630359071.125 is 2021-08-30T21:31:11.125Z test:is(tostring(ts:set{ timestamp = 1630359071.125 }), - '2021-08-31T05:31:11.125+0800', 'timestamp 1630359071.125' ) - test:is(tostring(ts:set{ msec = 123}), '2021-08-31T05:31:11.123+0800', + '2021-08-30T21:31:11.125+0800', 'timestamp 1630359071.125' ) + test:is(tostring(ts:set{ msec = 123}), '2021-08-30T21:31:11.123+0800', 'msec = 123') - test:is(tostring(ts:set{ usec = 123}), '2021-08-31T05:31:11.000123+0800', + test:is(tostring(ts:set{ usec = 123}), '2021-08-30T21:31:11.000123+0800', 'usec = 123') - test:is(tostring(ts:set{ nsec = 123}), '2021-08-31T05:31:11.000000123+0800', + test:is(tostring(ts:set{ nsec = 123}), '2021-08-30T21:31:11.000000123+0800', 'nsec = 123') end) +test:test("Check :set{} and .new{} equal for all attributes", function(test) + test:plan(11) + local ts, ts2 + local obj = {} + local attribs = { + {'year', 2000}, + {'month', 11}, + {'day', 30}, + {'hour', 6}, + {'min', 12}, + {'sec', 23}, + {'tzoffset', -8*60}, + {'tzoffset', '+0800'}, + {'tz', 'MSK'}, + {'nsec', 560000}, + } + for _, row in pairs(attribs) do + local key, value = unpack(row) + obj[key] = value + ts = date.new(obj) + ts2 = date.new():set(obj) + test:is(ts, ts2, ('[%s] = %s (%s = %s)'): + format(key, tostring(value), tostring(ts), tostring(ts2))) + end + + obj = {timestamp = 1630359071.125, tzoffset = '+0800'} + ts = date.new(obj) + ts2 = date.new():set(obj) + test:is(ts, ts2, ('timestamp+tzoffset (%s = %s)'): + format(tostring(ts), tostring(ts2))) +end) + + test:test("Time invalid :set{} operations", function(test) test:plan(84) -- GitLab