Skip to content

Commit 63843a7

Browse files
datetime: add new behavior for setting timezones
This patch adds a fix making `datetime` handle `tz` and `tzoffset` parameters properly. Previously, changing a timezone altered the timestamp value but preserved the time of day. Now you may enable new behavior making specifying a timezone preserve the timestamp and update the represented time of day. This behavior could be achieved by setting a new compat option `datetime_apply_timezone_action` to `'new'`. The option is going to be enabled by default in 4.x. Enabling new behavior involves the following. 1. Creating a datetime object with non-empty `tz`/`tzoffset` and `timestamp` values results with a time of day corresponding to the timestamp value plus the timezone offset. E.g if we create a datetime object with a timestamp corresponding to 9:12 o'clock in `+0000` timezone and set `tzoffset = -60` the resulting timestamp will represent 8:12 o'clock. 2. Setting a new `tz`/`tzoffset` affects the time of day represented by the datetime. E.g. if you update a datetime object of 11:12 o'clock having `tzoffset = 180` with `tzoffset = 120` the resulting datetime object will represent 10:12. Closes tarantool#10363 NO_DOC=will be in tarantool/doc#4720
1 parent 5ff96aa commit 63843a7

File tree

6 files changed

+234
-1
lines changed

6 files changed

+234
-1
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
## bugfix/datetime
2+
3+
* Proper behavior of applying timezones to datetime objects can
4+
now be enabled by setting configuration option
5+
`compat.datetime_apply_timezone_action` to `'new'`. It makes
6+
changing timezones of `datetime` objects preserve the represented
7+
timestamp and affect the represented time of day (gh-10363).

src/box/lua/config/descriptions.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,15 @@ I['compat.console_session_scope_vars'] = format_text([[
338338
from the console are written to globals
339339
]])
340340

341+
I['compat.datetime_apply_timezone_action'] = format_text([[
342+
Controls the behavior of applying timezone to datetime objects:
343+
344+
- `new` (4.x default): applying a timezone doesn't affect the timestamp but
345+
changes the represented time of day
346+
- `old` (3.x default): applying a timezone alters the represented timestamp
347+
but preserves the time of day
348+
]])
349+
341350
I['compat.fiber_channel_close_mode'] = format_text([[
342351
Define the behavior of fiber channels after closing:
343352

src/box/lua/config/instance_config.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2156,6 +2156,12 @@ return schema.new('instance_config', schema.record({
21562156
}, {
21572157
default = 'old',
21582158
}),
2159+
datetime_apply_timezone_action = schema.enum({
2160+
'old',
2161+
'new',
2162+
}, {
2163+
default = 'old',
2164+
}),
21592165
box_consider_system_spaces_synchronous = schema.enum({
21602166
'old',
21612167
'new',

src/lua/datetime.lua

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,42 @@
1+
local compat = require('compat')
12
local ffi = require('ffi')
23
local buffer = require('buffer')
34
local tz = require('timezones')
45

6+
-- This variable controls the behavior of applying timezone to
7+
-- datetime objects.
8+
--
9+
-- If set, setting a timezone for a datetime object doesn't
10+
-- affect the timestamp value but changes the represented time of
11+
-- day.
12+
-- Otherwise, the old behavior is used. Setting a timezone changes
13+
-- the timestamp value in that way to preserve the represented
14+
-- time of day.
15+
--
16+
-- This behavior will become default in 4.x.
17+
local applying_tz_preserves_timestamp
18+
19+
local DATETIME_APPLY_TIMEZONE_ACTION_BRIEF = [[
20+
Whether applying timezone alters the represented time of day or the timestamp.
21+
The new behavior makes setting a timezone for a datetime object change the time
22+
of day and preserve the timestamp. The old behavior makes providing a timezone
23+
for a datetime object affect the timestamp but preserve the represented time of
24+
day.
25+
26+
https://tarantool.io/compat/datetime_apply_timezone_action
27+
]]
28+
29+
compat.add_option({
30+
name = 'datetime_apply_timezone_action',
31+
default = 'old',
32+
obsolete = nil,
33+
brief = DATETIME_APPLY_TIMEZONE_ACTION_BRIEF,
34+
action = function(is_new)
35+
applying_tz_preserves_timestamp = is_new
36+
end,
37+
run_action_now = true,
38+
})
39+
540
--[[
641
`c-dt` library functions handles properly both positive and negative `dt`
742
values, where `dt` is a number of dates since Rata Die date (0001-01-01).
@@ -621,6 +656,11 @@ local function datetime_new(obj)
621656
s = s - 1
622657
fraction = fraction + 1
623658
end
659+
660+
if applying_tz_preserves_timestamp then
661+
s = s + (offset or 0) * 60
662+
end
663+
624664
-- if there are separate nsec, usec, or msec provided then
625665
-- timestamp should be integer
626666
if count_usec == 0 then
@@ -940,6 +980,13 @@ local function datetime_parse_from(str, obj)
940980
-- string.
941981
if date.tz == '' and date.tzoffset == 0 then
942982
datetime_set(date, { tzoffset = tzoffset, tz = tzname })
983+
984+
-- If working in a "preserve timestamp" mode the set
985+
-- call changes the represented time of day and it should
986+
-- be adjusted (gh-10363).
987+
if applying_tz_preserves_timestamp then
988+
time_localize(date, date.tzoffset)
989+
end
943990
end
944991

945992
return date, len
@@ -1152,13 +1199,27 @@ function datetime_set(self, obj)
11521199
if tzname ~= nil then
11531200
offset, self.tzindex = parse_tzname(sec_int, tzname)
11541201
end
1155-
self.epoch = utc_secs(sec_int, offset)
1202+
if applying_tz_preserves_timestamp then
1203+
self.epoch = sec_int
1204+
else
1205+
self.epoch = utc_secs(sec_int, offset)
1206+
end
11561207
self.nsec = nsec
11571208
self.tzoffset = offset
11581209

11591210
return self
11601211
end
11611212

1213+
-- Only timezone is changed.
1214+
if not hms and not ymd and applying_tz_preserves_timestamp then
1215+
if tzname ~= nil then
1216+
offset, self.tzindex = parse_tzname(self.epoch, tzname)
1217+
end
1218+
1219+
self.tzoffset = offset
1220+
return self
1221+
end
1222+
11621223
-- normalize time to UTC from current timezone
11631224
time_delocalize(self)
11641225

test/app-luatest/datetime_test.lua

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
local t = require('luatest')
2+
local compat = require('compat')
23
local dt = require('datetime')
34

45
local SUPPORTED_DATETIME_FORMATS = {
@@ -2098,3 +2099,151 @@ for supported_by, standard_cases in pairs(UNSUPPORTED_DATETIME_FORMATS) do
20982099
end
20992100
end
21002101
end
2102+
2103+
local TIMEZONES = {
2104+
{
2105+
tzname = 'Europe/Moscow',
2106+
tzoffset = 180,
2107+
},
2108+
{
2109+
tzname = 'Africa/Abidjan',
2110+
tzoffset = 0,
2111+
},
2112+
{
2113+
tzname = 'America/Argentina/Buenos_Aires',
2114+
tzoffset = -180,
2115+
},
2116+
{
2117+
tzname = 'Asia/Krasnoyarsk',
2118+
tzoffset = 420,
2119+
},
2120+
{
2121+
tzname = 'Pacific/Fiji',
2122+
tzoffset = 720,
2123+
}
2124+
}
2125+
2126+
local function tz_behavior_test_case(behavior, create_dt, expected)
2127+
return function()
2128+
t.assert_equals(compat.datetime_apply_timezone_action.default, 'old')
2129+
compat.datetime_apply_timezone_action = behavior
2130+
2131+
local dt_obj = create_dt()
2132+
2133+
t.assert_equals(dt_obj.hour, expected.hour)
2134+
t.assert_equals(dt_obj.timestamp, expected.timestamp)
2135+
end
2136+
end
2137+
2138+
-- Test new/old behavior of applying timezone to datetime objects
2139+
-- (gh-10363).
2140+
for _, case in ipairs(TIMEZONES) do
2141+
local function add_test_case(method, behavior, ...)
2142+
local testcase_name = ('test_tz_behavior_%s_%s_%s')
2143+
:format(method, case.tzname:gsub('/', '_'), behavior)
2144+
pg[testcase_name] = tz_behavior_test_case(behavior, ...)
2145+
end
2146+
2147+
local now = dt.now()
2148+
local create_dt = function()
2149+
return dt.new{timestamp = now.timestamp, tzoffset = case.tzoffset}
2150+
end
2151+
2152+
-- Note the following when using old behavior.
2153+
--
2154+
-- `datetime.new{timestamp = now.timestamp,
2155+
-- tzoffset = now.tzoffset}`
2156+
-- creates a datetime object representing time of day in GMT+0
2157+
-- corresponding to `now` time of day within the provided
2158+
-- timezone instead of creating a complete `now` copy.
2159+
add_test_case('create_tzoffset', 'old', create_dt,
2160+
{hour = now.hour - now.tzoffset / 60,
2161+
timestamp = now.timestamp - case.tzoffset * 60})
2162+
add_test_case('create_tzoffset', 'new', create_dt,
2163+
{hour = (now.hour + (case.tzoffset - now.tzoffset) / 60) % 24,
2164+
timestamp = now.timestamp})
2165+
2166+
create_dt = function()
2167+
return dt.new{timestamp = now.timestamp, tz = case.tzname}
2168+
end
2169+
2170+
add_test_case('create_tzname', 'old', create_dt,
2171+
{hour = now.hour - now.tzoffset / 60,
2172+
timestamp = now.timestamp - case.tzoffset * 60})
2173+
add_test_case('create_tzname', 'new', create_dt,
2174+
{hour = (now.hour + (case.tzoffset - now.tzoffset) / 60) % 24,
2175+
timestamp = now.timestamp})
2176+
2177+
create_dt = function()
2178+
return dt.new{timestamp = now.timestamp}:set{tzoffset = case.tzoffset}
2179+
end
2180+
2181+
add_test_case('set_tzoffset', 'old', create_dt,
2182+
{hour = now.hour - now.tzoffset / 60,
2183+
timestamp = now.timestamp - case.tzoffset * 60})
2184+
add_test_case('set_tzoffset', 'new', create_dt,
2185+
{hour = (now.hour + (case.tzoffset - now.tzoffset) / 60) % 24,
2186+
timestamp = now.timestamp})
2187+
2188+
create_dt = function()
2189+
return dt.new{timestamp = now.timestamp}:set{tz = case.tzname}
2190+
end
2191+
2192+
add_test_case('set_tzname', 'old', create_dt,
2193+
{hour = now.hour - now.tzoffset / 60,
2194+
timestamp = now.timestamp - case.tzoffset * 60})
2195+
add_test_case('set_tzname', 'new', create_dt,
2196+
{hour = (now.hour + (case.tzoffset - now.tzoffset) / 60) % 24,
2197+
timestamp = now.timestamp})
2198+
end
2199+
2200+
-- Test new/old behavior of applying timezone when parsing
2201+
-- datetime objects.
2202+
-- Adapted from app-tap/datetime.test.lua (gh-10363).
2203+
pg.test_new_tz_behavior_parse = function()
2204+
t.assert_equals(compat.datetime_apply_timezone_action.default, 'old')
2205+
compat.datetime_apply_timezone_action = 'new'
2206+
2207+
t.assert(dt.parse("1970-01-01T01:00:00Z") ==
2208+
dt.new{year=1970, mon=1, day=1, hour=1, min=0, sec=0})
2209+
t.assert(dt.parse("1970-01-01T01:00:00Z", {format = 'iso8601'}) ==
2210+
dt.new{year=1970, mon=1, day=1, hour=1, min=0, sec=0})
2211+
t.assert(dt.parse("1970-01-01T01:00:00Z", {format = 'rfc3339'}) ==
2212+
dt.new{year=1970, mon=1, day=1, hour=1, min=0, sec=0})
2213+
t.assert(dt.parse("2020-01-01T01:00:00+00:00", {format = 'rfc3339'}) ==
2214+
dt.parse("2020-01-01T01:00:00+00:00", {format = 'iso8601'}))
2215+
t.assert(dt.parse("1970-01-01T02:00:00+02:00") ==
2216+
dt.new{year=1970, mon=1, day=1, hour=2, min=0, sec=0, tzoffset=120})
2217+
2218+
t.assert(dt.parse("1970-01-01T01:00:00", {tzoffset = 120}) ==
2219+
dt.new{year=1970, mon=1, day=1, hour=1, min=0, sec=0, tzoffset=120})
2220+
t.assert(dt.parse("1970-01-01T01:00:00", {tzoffset = '+0200'}) ==
2221+
dt.new{year=1970, mon=1, day=1, hour=1, min=0, sec=0, tzoffset=120})
2222+
t.assert(dt.parse("1970-01-01T01:00:00", {tzoffset = '+02:00'}) ==
2223+
dt.new{year=1970, mon=1, day=1, hour=1, min=0, sec=0, tzoffset=120})
2224+
t.assert(dt.parse("1970-01-01T01:00:00Z", {tzoffset = '+02:00'}) ==
2225+
dt.new{year=1970, mon=1, day=1, hour=1, min=0, sec=0, tzoffset=0})
2226+
t.assert(dt.parse("1970-01-01T01:00:00+01:00", {tzoffset = '+02:00'}) ==
2227+
dt.new{year=1970, mon=1, day=1, hour=1, min=0, sec=0, tzoffset=60})
2228+
t.assert(dt.parse('1998-11-25', { format = '%Y-%m-%d', tzoffset = 180 }) ==
2229+
dt.new({ year = 1998, month = 11, day = 25, tzoffset = 180 }))
2230+
t.assert(dt.parse('1998', { format = '%Y', tzoffset = '+03:00' }) ==
2231+
dt.new({ year = 1998, tzoffset = 180 }))
2232+
2233+
-- Testcases with override timezone by setting tz.
2234+
-- Timezone is not specified in a parsed string.
2235+
t.assert(dt.parse("1970-01-01T01:00:00", {tz = "MSK"}) ==
2236+
dt.new{year = 1970, mon = 1, day = 1,
2237+
hour = 1, min = 0, sec = 0, tzoffset=180})
2238+
-- Timezone is specified in a parsed string as a military timezone.
2239+
t.assert(dt.parse("1970-01-01T01:00:00Z", {tz = "Europe/Moscow"}) ==
2240+
dt.new{year = 1970, mon = 1, day = 1,
2241+
hour = 1, min = 0, sec = 0, tzoffset = 0})
2242+
-- Timezone is specified in a parsed string as an offset.
2243+
t.assert(dt.parse("1970-01-01T01:00:00+01:00", {tz = "Asia/Omsk"}) ==
2244+
dt.new{year = 1970, mon = 1, day = 1,
2245+
hour = 1, min = 0, sec = 0, tzoffset = 60})
2246+
-- Timezone is not specified in a parsed string and format is passed.
2247+
t.assert(dt.parse("1998-11-25", { format = "%Y-%m-%d", tz = "MSK" }) ==
2248+
dt.new{year = 1998, month = 11, day = 25, tzoffset = 180})
2249+
end

test/config-luatest/cluster_config_schema_test.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ g.test_defaults = function()
439439
box_error_unpack_type_and_code = 'old',
440440
console_session_scope_vars = 'old',
441441
wal_cleanup_delay_deprecation = 'old',
442+
datetime_apply_timezone_action = 'old',
442443
},
443444
isolated = false,
444445
}

0 commit comments

Comments
 (0)