В результате общения продуктовой команды появился итоговый взгляд на datetime.
Имена полей в базовой структуре должны быть недвусмысленными:
epoch
(int64) - количество секунд от начала 1970.nsec
(uint32) - количество наносекунд от начала последней секунды
tzoffset
(int16) - количество минут - сдвиг таймзоны в минутах от UTC
tzindex
(uint16) - индекс таймзоны, если время определено с его помощью
Notes:
- Для хранения
tzoffset
иtzindex
посмотреть наint16
. Пробенчмаркать. Если всё ок, использовать их. Если нет, тоint
Все исключения бросаются на 1 уровень выше, чтобы точно показывать место, где ошибка
в передаваемых параметрах (см. error(msg, level)
)
Для хранения идентификаторов таймзоны используется следующий подход:
zone.tab
, каждой известной зоне присваиваем номер.datetime.TZ
datetime.TZ['Europe/Moscow'] = N
datetime.TZ[N] = 'Europe/Moscow'
Таблица должна быть доступна для записи и расширения из Lua. Таблица может быть ffi-маппингом к низкоуровневой структуре.
Содержимое tzdata читается из системно-установленного tzdata.
Рассматриваемые сценарии:
datetime.TZ
, что тривиальноЕсли рассмотреть ситуацию появления новой таймзоны, то проблема для пользователя возникает в случае, если пользователю понадобится работать с этой таймзоной и он не будет знать какой tzindex ей нужно присвоить вручную. Для этой цели можно поддерживать список значений на странице документации модуля. В случае появления новой таймзоны мы расширяем индекс, добавляем поддержку в новую версию модуля, а для старых предлагаем сниппет формата:
local datetime = require "datetime"
datetime.TZ['Xxx/Yyy'] = 543
datetime.TZ[543] = 'Xxx/Yyy'
Зачем нужно хранить таймзону? С точки зрения конкретного момента времени разницы с tzoffset нет. Разница появляется, когда с этой датой мы захотим выполнять арифметику. Рассмотрим простой пример: один и тот-же момент времени, но в разных временных зонах. Сохраняем их в базе и впоследствии пытаемся вычислить "событие на 1 год позже"
2013-10-26 21:00:00 Europe/Moscow (GMT+4) = 1382806800
2013-10-26 21:00:00 Asia/Dubai (GMT+4) = 1382806800
добавляем 1 год:
2014-10-26 21:00:00 Europe/Moscow (GMT+3) = 1414346400
2014-10-26 21:00:00 Asia/Dubai (GMT+4) = 1414342800
С точки зрения хранения в индексе можно рассмотреть datetime
как тапл из 4х полей: {epoch, nsec, tzoffset, tz}
Эпоха абсолютна для всего мира. Видимое пользователем время определяется при помощи tzoffset/tz, но с точки зрения течения времени мы всегда используем единое координированное время (UTC)
Второе поле nsec позволяет достичь максимальной точности при упорядочивании дат, в т.ч. позволяет в будущем сделать поддержку "високосной секунды" при помощи диапазона 1e9..2e9
При этом для абсолютно одинаковых моментов универсального времени мы даём упорядочивание по смещению. Для одинакового смещения мы упорядочиваем таймзону (по нашему индексу)
Сравнение дат выполняется по тем же правилам.
Договорились:
Mons: Остановились пока на том, что команда TeamC посмотрит и предложит варианты
Храним всегда в msgpack extention (код type
разработчик выберет самостоятельно).
Это расширение хранит всегда (строго в таком порядке и типах):
Не храним:
Таким образом время с нулевым таймстемп будет храниться как 7 байт (заголовок extention и четыре нуля) - Если считать до сокращения длины.
Mons: Кажется, что данный формат упаковки не соответствует msgpack spec: https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type
байт 0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
Код ext | длина | type | epoch | tzoffset | tz | nsec |
0xC7 | 4 | 'd' | 2 | 0 | 0 | 1 |
0xC7 | 0 | 'd' | - | - | - | - |
0xC7 | xxx | 'd' | 123456 | 180 | - | - |
Возврат __serialize
обычно предполагает сериализацию для 3х представлений: json
, yaml
, msgpack
.
msgpack
в нашем случае рассматривается отдельно. json
не обладает поддержкой типа datetime/timestamp
. Для yaml формат сериализации описан (https://yaml.org/type/timestamp.html), но к сожалению ISO8601 не поддерживает указание timezone (https://stackoverflow.com/questions/42194571/identifying-time-zones-in-iso-8601).
На текущий момент разрабатывается черновик расширения RFC3339, который предусматривает поддержку IANA Timezones, поэтому предлагается использовать его:
2011-12-03T10:15:30.123Z
2011-12-03T10:15:30.123+01:00
2011-12-03T10:15:30.123+01:00[Europe/Paris]
Cтандартом не определены минимальные/максимальные значения даты и времени
и оставлено на детали реализации, мы принимаем следующие рассуждения за базис
Для представления дат в виде epoch используется timestamp разрядностью 52 бита (точное представление double), что даёт нам (грубо) диапазон от ±142 млн лет (2LL^52 / 864 / 36525 = 142710460
). Данная величина достаточно большая, чтобы представлять любые разумные вычисления дат. Для работы с миллиардами лет не требуется работа с таймзонами или точными значениями наносекунд.
Точные значения:
Для представления максимально больших/максимально малых дат можно использовать ±infinity из спеки double
#!/usr/bin/env perl
use strict;
use 5.016;
use DateTime;
use DateTime::TimeZone;
my $mindate = DateTime->new(
year => -142710460,
month => 01,
day => 01,
hour => 00,
minute => 00,
second => 00,
nanosecond => 00,
time_zone => 'UTC',
);
say $mindate->strftime("%F %T %Z");
say $mindate->epoch;
my $maxdate = DateTime->new(
year => 142710460,
month => 12,
day => 31,
hour => 23,
minute => 59,
second => 59,
nanosecond => 00,
time_zone => 'UTC',
);
say $maxdate->strftime("%F %T %Z");
say $maxdate->epoch;
Для представления дат в виде epoch используется timestamp разрядностью 64 бита, что даёт
нам (грубо) диапазон от -292 млрд лет до +292 млрд лет
(2LL^63 / 864 / 36525 = -292271023045
, ( 2LL^63-1 ) / 864 / 36525 = 292271023045
)
Но при этом для хранения значения year часто (напр. в структуре struct tm
) используется int
,
что даёт диапазон лет от -2147483648 до 2147483647
Немного прикинем:
Такким образом, если только мы не планируем хранить и обрабатывать в нашем datetime
объекты вселенско-космических масштабов, мы можем смело ограничить значение year
размерностью int32 (-2147483648..2147483647). Это даст приемлемый диапазон эпохи
приблизительно в диапазоне -2^55 .. 2^55
Точные значения:
С точки зрения пользования API есть 2 варианта взаимодействия: вызов метода на объекте и арифметические операции.
При применении метода на объекте подразумевается мутация объекта с возвратом этого же объекта.
При применении арифметических операций объекты остаются неизменными и возвращается новый сконструированный объект
:add
/:sub
vs intervals vs mt.__add
/mt.__sub
.format()
/.parse()
"%F %T %Z"
strftime
/strptime
— убратьcdata<int64_t>
Реализуем базовый объект datetime
с конструктором, аксессорами для получения
отдельных значений полей, функциями для изменения значения даты
и базовым форматтером
Принимает таблицу значений. Там где применимо,
заимствуем названия полей у os.date. Значения полей имеют натуральные
и привычные для человека интервалы.
local dt = datetime.new {
nsec = 123456789, -- наносекунд от последней секунды
usec = 123456, -- или микросекунд от последней секунды
msec = 123, -- или миллисекунд от последней секунды
sec = 19, -- секунд в часе 0..59 (60?)
min = 29, -- минут 0..59
hour = 18, -- часов 0..23
day = 20, -- число 1..31
month = 8, -- месяц 1..12
year = 2021, -- год *
timestamp = 1629476485.123, -- эпоха. целое или дробное
tzoffset = 180, -- смещение времени от UTC в минутах -1080 .. 1080. 180 = GMT+3
tz = "Europe/Moscow", -- таймзона по Olson
}
isdst
, wday
, yday
для возможности передачи в параметрыos.date('*t')
или datetime.totable
day
, month
по умолчанию равны 1-1
year
по умолчанию равен 1970error
).timestamp
, наличие полей кроме tzoffset
/tz
и вызывает исключениеtimestamp
передаётся отдельно дробная часть (nsec
/usec
/msec
), то значение timestamp
округляется до целого и в качестве дробной части используется переданное отдельно поле.tz
временно может бросать исключение Not yet implemented
Доступ к атрибутам делается через __index
. Все атрибуты readonly. Реализуются следующие атрибуты:
dt.nsec
dt.usec
dt.msec
dt.sec -- как в os.date
dt.min -- как в os.date
dt.hour -- как в os.date
dt.day -- как в os.date
dt.month -- как в os.date
dt.year -- как в os.date
dt.wday -- как в os.date. week day
dt.yday -- как в os.date. year day
dt.isdst -- как в os.date.
dt.tzoffset
dt.tz -- Like "Europe/Moscow". Доступно после этапа 3
dt.timestamp -- double
Для сравнения дат реализуются метаметоды сравнения (__gt
/__lt
), которые сравнивают значения epoch и nsec.
Для проверки на равенство реализуется __eq
, который сравнивает не только epoch и nsec, но и tz/tzoffset. Т.е. 2 даты будут считаться равными только в том случае, если они равны с точностью до таймзоны.
Для конвертации даты в простую таблицу (возможно для конструирования копии даты)
используется метод :totable
. Метод возвращает поля, аналогичные таковым в вызове
os.date('*t')
плюс наносекунды (nsec
)
local t = dt:totable()
--[[
t.nsec
t.sec
t.min
t.hour
t.day
t.month
t.year
t.isdst
t.wday
t.yday
--]]
local dt2 = datetime.new(dt:totable())
Для форматирования даты применяется реализация POSIX strftime с расширением на наносекуды (%?f
)
В качестве имени метода используем :format
. Формат по умолчанию — '%F %T %Z'
(обсудить. не добавить ли доли секунд?)
dt:format('%Y-%m-%dT%H:%M:%S.%3f') -- 2021-08-21T14:53:34.032
В метатаблице определяется __tostring
, который вызывает :format
по умолчанию
Объекты даты-времени мутабельны.
Для модификации предусмотрен метод прямого изменения значений :set
. Явная установка в атрибуты
посредством __newindex
запрещена. Устанавливаемые значения проверяются на приемлемый диапазон аналогично конструктору. Применяются правила взаимного исклчения аргументов, как в .new
Возвращаемое значение: объект self
. Это позволяет связывать модифицирующие операции в цепочки.
В случае, если результирующее значение даты получается некорректным, выбрасывается исключение.
dt:set {
nsec = 123456789,
usec = 123456,
msec = 123,
sec = 19,
min = 29,
hour = 18,
day = 20,
month = 8,
year = 2021,
tzoffset = 180,
-- or
tz = "Europe/Moscow",
}
dt:set {
timestamp = 1629476485.124,
tzoffset = 180,
-- or
tz = "Europe/Moscow",
}
-- создать новую дату из существующей с изменением одного поля:
local dt2 = datetime.new(dt:totable()):set{ day = 1 }
local dt2 = datetime.new(dt:totable()):set{ day = -1 } -- установить последний день месяца
Этап 1 должен заканчиваться PR со следующим результатом:
tz
UNera: На втором этапе делаем
- Объекты интервалов
- функции :add, :sub. Эти функции принимают на вход как таблицу, так и интервал. Чтобы потом их не рефакторить Монс принял решение вынести функции :add и :sub из первого этапа и соединить их с реализацией интервалов.
Ещё раз:
- функция :add{таблица},
- функция :add(interval)
- функция :sub{таблица}
- функция :sub(interval)
Для создания интервала используется datetime.interval
(может быть datetime.interval.new
? interval.new
?)
UNera: используем
datetime.intevral.new
для единообразия сdatetime.new
.
Принимает таблицу значений. Там где применимо, заимствуем названия полей у os.date.
local iv = datetime.interval {
nsec = 123456789, -- наносекунд
usec = 123456, -- или микросекунд
msec = 123, -- или миллисекунд
sec = 19, -- секунд
min = 29, -- минут
hour = 18, -- часов
day = 20, -- дней
week = 54, -- недель
month = 8, -- месяцев
year = 2021, -- лет
}
Доступ к атрибутам делается через __index
. Все атрибуты readonly. Реализуются все атрибуты конструктора.
TODO: арифметика с double: считать число днями или секундами?
UNera: считать число секундами
Для модификации существующего объекта даты предусмотрена арифметика дат на интервалах с двумя методами: :add
и :sub
. Методы принимают объект interval
или эквивалентную ему таблицу.
Для получения нового объекта даты из существующей даты и интервала применяется арифметическая операция +
(__add
) или -
(__sub
)
При арифметике значения вычисляются последовательно от годов к наносекундам:
year
- годыmonth
- месяцыweek
- неделиday
- дниhour
- часыmin
- минутыsec
- секундыmsec
- миллисекундыusec
- микросекундыnsec
- наносекундыВ случае, если результирующее значение даты получается некорректным, выбрасывается исключение
Возвращаемое значение методов - объект self
. Позволяет собирать модифицирующие методы в цепочки.
-- add 9000 years, 82 months, 5 weeks, 201 days, 183 hours, 292 minutes
-- and 191.001239234 seconds to given date
dt:add{
year = 9000,
month = 82,
week = 5,
day = 201,
sec = 191,
min = 292,
hour = 183,
nsec = 1239234,
}
dt:sub{
year = 9000,
month = 82,
week = 5,
day = 201,
sec = 191,
min = 292,
hour = 183,
nsec = 1239234,
}
-- создать новую дату из существующей с добавлением одного дня
local dt2 = datetime.new(dt:totable()):add{ day = 1 }
28 февраля невисокосного года + 1 год = 28 февраля високосного года
29 февраля високосного года + 1 год = 28 февраля невисокосного года
31 января + 1 месяц = 28 или 29 февраля
30 января + 1 месяц = 28 или 29 февраля
29 февраля + 1 месяц = 29 марта
31 марта + 1 месяц = 30 апреля
31 января + 1 месяц = 28 или 29 февраля
29 февраля + 1 месяц = 31 марта
31 марта + 1 месяц = 30 апреля
30 апреля + 1 месяц = 31 мая
28 февраля 2001 + 1 месяц = 28 марта 2001
28 февраля 2004 + 1 месяц = 28 марта 2004
Upd: "бухгалтерская" логика с "последним днём месяца" убирается под опцию. Таким образом убираем лишнее исключение из расчёта и неочевидное поведение, когда мы "случайно" цепляем 28 февраля.
Для получения логики "последний день месяца" в функцию:add
необходимо передавать дополнительный параметр. Альтернативный вариант — цепочка с установкой явно последнего дня::add{ month=1 }:set{ day=-1 }
adjust
Если требуется альтернативная арифметика "последнего дня", то в функции :add
/:sub
или конструктор интервала можно передавать параметр adjust
.
Поддерживаемые значения:
none
: последний день не вычисляется. при арифметике выполняется только округление вниз, если номер дня превышает допустимый. используется по умолчанию. (impl details: DT_LIMIT)
-- 29.02.* -> 29.03.*
dt:add( {month = 1, adjust = "none" } )
last
: последний день вычисляется. если в данной дате день является последним, то при прибавлении/вычитании месяцев в результирующей дате день должен быть последним. (impl details: DT_SNAP)
-- 28.02.2001 -> 31.03.2001 -- last day in Feb 2001
-- 28.02.2004 -> 28.03.2004 -- not a last day in Feb => no abjustments
-- 29.02.2004 -> 31.03.2001 -- last day in Feb 2004
dt:add( {month = 1, adjust = "last" } )
excess
: выполняется переполнение. можно не делать. (impl details: DT_EXCESS)
Возврат __serialize
должен вернуть таблицу, аналогичную передаваемой в конструктор.
__tostring
должен возвращать строковое представление. Формат представления можно позаимствовать у PostgreSQL.
На этом этапе даём возможность пользователям парсить различные форматы данных. Для парсинга дат реализуем
метод :parse
с предустановленным, но расширяемым набором форматов. В качестве форматной строки используется POSIX strptime с расширенным форматом %?f
.
Поддерживаемые форматы:
local dt = datetime.parse("20050809T183142", { format = "iso8601" })
local dt = datetime.parse("20050809T183142", { format = "iso8601", tz = "Europe/Moscow" })
local dt = datetime.parse("1937-01-01T12:00:27.87+00:20", { format = "rfc3339" })
local dt = datetime.parse("1937-01-01T12:00:27.87", { format = "rfc3339", tzoffset = 20 })
local dt = datetime.parse("2011-12-03T10:15:30.123+01:00[Europe/Paris]", { format = "rfc3339" })
Для парсинга даты применяем метод .parse
. В качестве формата используем strptime
(см. man strptime
) с расширением для наносекунд (%f
).
local dt = datetime.parse('2020-01-11 22:21:20.351', { format = '%F %T.%f' })
Реализуем возможность преобразования timezone строк вроде "Europe/Moscow" в tzindex
.
tzindex
- это индекс в Olson БД: tzdata.
После этого этапа должны заработать все возможности с атрибутом tz
Логика работы конструктора tz
.
tzoffset
. в tzindex
сохраняем -1При вычислении :add/:sub и установленном tzindex вычисление производится с учётом tzdata.
Этап завершается PR, включающим работу с полями tz
в конструкторе и tzindex
в С-структуре. С покрытием тестами.