[Tarantool-patches] [PATCH v4 2/7] lua: built-in module datetime

Timur Safin tsafin at tarantool.org
Fri Aug 13 01:33:41 MSK 2021


* created a new Tarantool built-in module `datetime`;
* register cdef types for this module;
* export some `dt_*` functions from `c-dt` library;

* implemented `ctime`, `asctime` and `strftime` as
  ffi wrappers in C kernel code:

We used to use heavy ffi-related Lua code, but it appears
to be quite fragile, if we run this code on different OS
targets. To make this Lua code less platfrom dependent we
have moved all platform specifics to the C level.
Specifically we want to avoid dependence on `struct tm {}`
layout and GLIBC-specific `strftime` behaviour (which
differs to BSD/Mac LIBC behaviour).

* introduced simple datetime tests

Created app-tap test for new builtin module `datetime.lua`,
where we specifically check:

- cases of simple datetime string formatting using:
  - asctime (gmt time);
  - ctime (local TZ time);
  - strftime (using given format).

- verify that we parse expected, and fail with unexpected:
  - for that, in the datetime.lua, we have extended api of `parse_date()`,
    `parse_time()`, and `parse_time_zone()` so they return
    not only parsed object, but also a length of parsed substring;
  - which allows us to parse even _partially_ valid strings like
    "20121224 Foo bar".

- check calculated attributes to date object, e.g.:
  - timestamp, seconds, microseconds, minute, or hours
  - to_utc(), and to_tz() allow to switch timezone of a
    datetime object. It's not changing much - only timezone
    but that impacts textual representation of a date.

Part of #5941
---
 cmake/BuildCDT.cmake                          |   2 +
 extra/exports                                 |  26 +
 src/CMakeLists.txt                            |   2 +
 src/lib/core/CMakeLists.txt                   |   1 +
 src/lib/core/datetime.c                       |  96 ++++
 src/lib/core/datetime.h                       |  95 ++++
 src/lua/datetime.lua                          | 500 ++++++++++++++++++
 src/lua/init.c                                |   4 +-
 src/lua/utils.c                               |  27 +
 src/lua/utils.h                               |  12 +
 test/app-tap/datetime.test.lua                | 206 ++++++++
 .../gh-5632-6050-6259-gc-buf-reuse.test.lua   |  74 ++-
 12 files changed, 1043 insertions(+), 2 deletions(-)
 create mode 100644 src/lib/core/datetime.c
 create mode 100644 src/lib/core/datetime.h
 create mode 100644 src/lua/datetime.lua
 create mode 100755 test/app-tap/datetime.test.lua

diff --git a/cmake/BuildCDT.cmake b/cmake/BuildCDT.cmake
index 343fb1b99..80b26c64a 100644
--- a/cmake/BuildCDT.cmake
+++ b/cmake/BuildCDT.cmake
@@ -5,4 +5,6 @@ macro(libccdt_build)
     file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/third_party/c-dt/build/)
     add_subdirectory(${PROJECT_SOURCE_DIR}/third_party/c-dt
                      ${CMAKE_CURRENT_BINARY_DIR}/third_party/c-dt/build/)
+    set_target_properties(cdt PROPERTIES COMPILE_FLAGS "-DDT_NAMESPACE=tnt_")
+    add_definitions("-DDT_NAMESPACE=tnt_")
 endmacro()
diff --git a/extra/exports b/extra/exports
index 9eaba1282..80eb92abd 100644
--- a/extra/exports
+++ b/extra/exports
@@ -148,8 +148,34 @@ csv_feed
 csv_iterator_create
 csv_next
 csv_setopt
+datetime_asctime
+datetime_ctime
+datetime_now
+datetime_strftime
+decimal_unpack
 decimal_from_string
 decimal_unpack
+tnt_dt_dow
+tnt_dt_from_rdn
+tnt_dt_from_struct_tm
+tnt_dt_from_yd
+tnt_dt_from_ymd
+tnt_dt_from_yqd
+tnt_dt_from_ywd
+tnt_dt_parse_iso_date
+tnt_dt_parse_iso_time_basic
+tnt_dt_parse_iso_time_extended
+tnt_dt_parse_iso_time
+tnt_dt_parse_iso_zone_basic
+tnt_dt_parse_iso_zone_extended
+tnt_dt_parse_iso_zone_lenient
+tnt_dt_parse_iso_zone
+tnt_dt_rdn
+tnt_dt_to_struct_tm
+tnt_dt_to_yd
+tnt_dt_to_ymd
+tnt_dt_to_yqd
+tnt_dt_to_ywd
 error_ref
 error_set_prev
 error_unref
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 97b0cb326..4473ff1da 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -51,6 +51,8 @@ lua_source(lua_sources ../third_party/luafun/fun.lua)
 lua_source(lua_sources lua/httpc.lua)
 lua_source(lua_sources lua/iconv.lua)
 lua_source(lua_sources lua/swim.lua)
+lua_source(lua_sources lua/datetime.lua)
+
 # LuaJIT jit.* library
 lua_source(lua_sources ${LUAJIT_SOURCE_ROOT}/src/jit/bc.lua)
 lua_source(lua_sources ${LUAJIT_SOURCE_ROOT}/src/jit/bcsave.lua)
diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt
index 2cd4d0b4f..8bc776b82 100644
--- a/src/lib/core/CMakeLists.txt
+++ b/src/lib/core/CMakeLists.txt
@@ -30,6 +30,7 @@ set(core_sources
     decimal.c
     mp_decimal.c
     cord_buf.c
+    datetime.c
 )
 
 if (TARGET_OS_NETBSD)
diff --git a/src/lib/core/datetime.c b/src/lib/core/datetime.c
new file mode 100644
index 000000000..ab26c3445
--- /dev/null
+++ b/src/lib/core/datetime.c
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2021, Tarantool AUTHORS, please see AUTHORS file.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above
+ *    copyright notice, this list of conditions and the
+ *    following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above
+ *    copyright notice, this list of conditions and the following
+ *    disclaimer in the documentation and/or other materials
+ *    provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY <COPYRIGHT HOLDER> ``AS IS'' AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
+ * <COPYRIGHT HOLDER> OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+ * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+ * THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ * SUCH DAMAGE.
+ */
+
+#include <string.h>
+#include <time.h>
+
+#include "trivia/util.h"
+#include "datetime.h"
+
+static int
+local_dt(int64_t secs)
+{
+	return dt_from_rdn((int)(secs / SECS_PER_DAY) + DT_EPOCH_1970_OFFSET);
+}
+
+static struct tm *
+datetime_to_tm(const struct datetime *date)
+{
+	static struct tm tm;
+
+	memset(&tm, 0, sizeof(tm));
+	int64_t secs = date->secs;
+	dt_to_struct_tm(local_dt(secs), &tm);
+
+	int seconds_of_day = date->secs % SECS_PER_DAY;
+	tm.tm_hour = (seconds_of_day / 3600) % 24;
+	tm.tm_min = (seconds_of_day / 60) % 60;
+	tm.tm_sec = seconds_of_day % 60;
+
+	return &tm;
+}
+
+void
+datetime_now(struct datetime *now)
+{
+	struct timeval tv;
+	gettimeofday(&tv, NULL);
+	now->secs = tv.tv_sec;
+	now->nsec = tv.tv_usec * 1000;
+
+	time_t now_seconds;
+	time(&now_seconds);
+	struct tm tm;
+	localtime_r(&now_seconds, &tm);
+	now->offset = tm.tm_gmtoff / 60;
+}
+
+char *
+datetime_asctime(const struct datetime *date, char *buf)
+{
+	struct tm *p_tm = datetime_to_tm(date);
+	return asctime_r(p_tm, buf);
+}
+
+char *
+datetime_ctime(const struct datetime *date, char *buf)
+{
+	time_t time = date->secs;
+	return ctime_r(&time, buf);
+}
+
+size_t
+datetime_strftime(const struct datetime *date, const char *fmt, char *buf,
+		  uint32_t len)
+{
+	struct tm *p_tm = datetime_to_tm(date);
+	return strftime(buf, len, fmt, p_tm);
+}
diff --git a/src/lib/core/datetime.h b/src/lib/core/datetime.h
new file mode 100644
index 000000000..4ec459860
--- /dev/null
+++ b/src/lib/core/datetime.h
@@ -0,0 +1,95 @@
+#pragma once
+/*
+ * Copyright 2021, Tarantool AUTHORS, please see AUTHORS file.
+ *
+ * Redistribution and use in source and binary forms, with or
+ * without modification, are permitted provided that the following
+ * conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above
+ *    copyright notice, this list of conditions and the
+ *    following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above
+ *    copyright notice, this list of conditions and the following
+ *    disclaimer in the documentation and/or other materials
+ *    provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY <COPYRIGHT HOLDER> ``AS IS'' AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
+ * <COPYRIGHT HOLDER> OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+ * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+ * BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
+ * THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+ * SUCH DAMAGE.
+ */
+
+#include <stdint.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include "c-dt/dt.h"
+
+#if defined(__cplusplus)
+extern "C"
+{
+#endif /* defined(__cplusplus) */
+
+#ifndef SECS_PER_DAY
+#define SECS_PER_DAY          86400
+#define DT_EPOCH_1970_OFFSET  719163
+#endif
+
+/**
+ * Full datetime structure representing moments
+ * since Unix Epoch (1970-01-01).
+ * Time is kept normalized to UTC, time-zone offset
+ * is informative only.
+ */
+struct datetime {
+	/** seconds since epoch */
+	int64_t secs;
+	/** nanoseconds if any */
+	int32_t nsec;
+	/** offset in minutes from UTC */
+	int32_t offset;
+};
+
+/**
+ * Date/time interval structure
+ */
+struct datetime_interval {
+	/** relative seconds delta */
+	int64_t secs;
+	/** nanoseconds delta */
+	int32_t nsec;
+};
+
+/**
+ * Convert datetime to string using default asctime format
+ * "Sun Sep 16 01:03:52 1973\n\0"
+ * Wrapper around reenterable asctime_r() version of POSIX function
+ * @param date source datetime value
+ * @sa datetime_ctime
+ */
+char *
+datetime_asctime(const struct datetime *date, char *buf);
+
+char *
+datetime_ctime(const struct datetime *date, char *buf);
+
+size_t
+datetime_strftime(const struct datetime *date, const char *fmt, char *buf,
+		  uint32_t len);
+
+void
+datetime_now(struct datetime * now);
+
+#if defined(__cplusplus)
+} /* extern "C" */
+#endif /* defined(__cplusplus) */
diff --git a/src/lua/datetime.lua b/src/lua/datetime.lua
new file mode 100644
index 000000000..7ef5a8d56
--- /dev/null
+++ b/src/lua/datetime.lua
@@ -0,0 +1,500 @@
+local ffi = require('ffi')
+
+ffi.cdef [[
+
+    /*
+    `c-dt` library functions handles properly both positive and negative `dt`
+    values, where `dt` is a number of dates since Rata Die date (0001-01-01).
+
+    For better compactness of our typical data in MessagePack stream we shift
+    root of our time to the Unix Epoch date (1970-01-01), thus our 0 is
+    actually dt = 719163.
+
+    So here is a simple formula how convert our epoch-based seconds to dt values
+        dt = (secs / 86400) + 719163
+    Where 719163 is an offset of Unix Epoch (1970-01-01) since Rata Die
+    (0001-01-01) in dates.
+
+    */
+    typedef int dt_t;
+
+    // dt_core.h
+    dt_t     tnt_dt_from_rdn     (int n);
+    dt_t     tnt_dt_from_ymd     (int y, int m, int d);
+
+    int      tnt_dt_rdn          (dt_t dt);
+
+    // dt_parse_iso.h
+    size_t tnt_dt_parse_iso_date          (const char *str, size_t len, dt_t *dt);
+    size_t tnt_dt_parse_iso_time          (const char *str, size_t len, int *sod, int *nsec);
+    size_t tnt_dt_parse_iso_zone_lenient  (const char *str, size_t len, int *offset);
+
+    // datetime.c
+    int
+    datetime_to_string(const struct datetime * date, char *buf, uint32_t len);
+
+    char *
+    datetime_asctime(const struct datetime *date, char *buf);
+
+    char *
+    datetime_ctime(const struct datetime *date, char *buf);
+
+    size_t
+    datetime_strftime(const struct datetime *date, const char *fmt, char *buf,
+                      uint32_t len);
+
+    void
+    datetime_now(struct datetime * now);
+
+]]
+
+local builtin = ffi.C
+local math_modf = math.modf
+
+local SECS_PER_DAY     = 86400
+
+-- c-dt/dt_config.h
+
+-- Unix, January 1, 1970, Thursday
+local DT_EPOCH_1970_OFFSET = 719163LL
+
+
+local datetime_t = ffi.typeof('struct datetime')
+local interval_t = ffi.typeof('struct datetime_interval')
+
+local function is_interval(o)
+    return type(o) == 'cdata' and ffi.istype(interval_t, o)
+end
+
+local function is_datetime(o)
+    return type(o) == 'cdata' and ffi.istype(datetime_t, o)
+end
+
+local function is_date_interval(o)
+    return type(o) == 'cdata' and
+           (ffi.istype(interval_t, o) or ffi.istype(datetime_t, o))
+end
+
+local function interval_new()
+    local interval = ffi.new(interval_t)
+    return interval
+end
+
+local function check_date(o, message)
+    if not is_datetime(o) then
+        return error(("%s: expected datetime, but received %s"):
+                     format(message, o), 2)
+    end
+end
+
+local function check_str(s, message)
+    if not type(s) == 'string' then
+        return error(("%s: expected string, but received %s"):
+                     format(message, s), 2)
+    end
+end
+
+local function check_range(v, range, txt)
+    assert(#range == 2)
+    if v < range[1] or v > range[2] then
+        error(('value %d of %s is out of allowed range [%d, %d]'):
+              format(v, txt, range[1], range[2]), 4)
+    end
+end
+
+local function datetime_cmp(lhs, rhs)
+    if not is_date_interval(lhs) or
+       not is_date_interval(rhs) then
+       return nil
+    end
+    local sdiff = lhs.secs - rhs.secs
+    return sdiff ~= 0 and sdiff or (lhs.nsec - rhs.nsec)
+end
+
+local function datetime_eq(lhs, rhs)
+    local rc = datetime_cmp(lhs, rhs)
+    return rc ~= nil and rc == 0
+end
+
+local function datetime_lt(lhs, rhs)
+    local rc = datetime_cmp(lhs, rhs)
+    return rc == nil and error('incompatible types for comparison', 2) or
+           rc < 0
+end
+
+local function datetime_le(lhs, rhs)
+    local rc = datetime_cmp(lhs, rhs)
+    return rc == nil and error('incompatible types for comparison', 2) or
+           rc <= 0
+end
+
+local function datetime_serialize(self)
+    return { secs = self.secs, nsec = self.nsec, offset = self.offset }
+end
+
+local function interval_serialize(self)
+    return { secs = self.secs, nsec = self.nsec }
+end
+
+local function datetime_new_raw(secs, nsec, offset)
+    local dt_obj = ffi.new(datetime_t)
+    dt_obj.secs = secs
+    dt_obj.nsec = nsec
+    dt_obj.offset = offset
+    return dt_obj
+end
+
+local function datetime_new_dt(dt, secs, frac, offset)
+    local epochV = dt ~= nil and (builtin.tnt_dt_rdn(dt) - DT_EPOCH_1970_OFFSET) *
+                   SECS_PER_DAY or 0
+    local secsV = secs ~= nil and secs or 0
+    local fracV = frac ~= nil and frac or 0
+    local ofsV = offset ~= nil and offset or 0
+    return datetime_new_raw(epochV + secsV - ofsV * 60, fracV, ofsV)
+end
+
+-- create datetime given attribute values from obj
+-- in the "easy mode", providing builder with
+-- .secs, .nsec, .offset
+local function datetime_new_obj(obj, ...)
+    if obj == nil or type(obj) ~= 'table' then
+        return datetime_new_raw(obj, ...)
+    end
+    local secs = 0
+    local nsec = 0
+    local offset = 0
+
+    for key, value in pairs(obj) do
+        if key == 'secs' then
+            secs = value
+        elseif key == 'nsec' then
+            nsec = value
+        elseif key == 'offset' then
+            offset = value
+        else
+            error(('unknown attribute %s'):format(key), 2)
+        end
+    end
+
+    return datetime_new_raw(secs, nsec, offset)
+end
+
+-- create datetime given attribute values from obj
+local function datetime_new(obj)
+    if obj == nil or type(obj) ~= 'table' then
+        return datetime_new_raw(0, 0, 0)
+    end
+    local y = 0
+    local M = 0
+    local d = 0
+    local ymd = false
+
+    local h = 0
+    local m = 0
+    local s = 0
+    local frac = 0
+    local hms = false
+    local offset = 0
+
+    local dt = 0
+
+    for key, value in pairs(obj) do
+        if key == 'year' then
+            check_range(value, {1, 9999}, key)
+            y = value
+            ymd = true
+        elseif key == 'month' then
+            check_range(value, {1, 12}, key)
+            M = value
+            ymd = true
+        elseif key == 'day' then
+            check_range(value, {1, 31}, key)
+            d = value
+            ymd = true
+        elseif key == 'hour' then
+            check_range(value, {0, 23}, key)
+            h = value
+            hms = true
+        elseif key == 'min' or key == 'minute' then
+            check_range(value, {0, 59}, key)
+            m = value
+            hms = true
+        elseif key == 'sec' or key == 'second' then
+            check_range(value, {0, 60}, key)
+            s, frac = math_modf(value)
+            frac = frac * 1e9 -- convert fraction to nanoseconds
+            hms = true
+        elseif key == 'tz' then
+        -- tz offset in minutes
+            check_range(value, {0, 720}, key)
+            offset = value
+        elseif key == 'isdst' or key == 'wday' or key =='yday' then -- luacheck: ignore 542
+            -- ignore unused os.date attributes
+        else
+            error(('unknown attribute %s'):format(key), 2)
+        end
+    end
+
+    -- .year, .month, .day
+    if ymd then
+        dt = builtin.tnt_dt_from_ymd(y, M, d)
+    end
+
+    -- .hour, .minute, .second
+    local secs = 0
+    if hms then
+        secs = h * 3600 + m * 60 + s
+    end
+
+    return datetime_new_dt(dt, secs, frac, offset)
+end
+
+--[[
+    Basic      Extended
+    20121224   2012-12-24   Calendar date   (ISO 8601)
+    2012359    2012-359     Ordinal date    (ISO 8601)
+    2012W521   2012-W52-1   Week date       (ISO 8601)
+    2012Q485   2012-Q4-85   Quarter date
+]]
+
+local function parse_date(str)
+    check_str("datetime.parse_date()")
+    local dt = ffi.new('dt_t[1]')
+    local len = builtin.tnt_dt_parse_iso_date(str, #str, dt)
+    return len > 0 and datetime_new_dt(dt[0]) or nil, tonumber(len)
+end
+
+--[[
+    Basic               Extended
+    T12                 N/A
+    T1230               T12:30
+    T123045             T12:30:45
+    T123045.123456789   T12:30:45.123456789
+    T123045,123456789   T12:30:45,123456789
+
+    The time designator [T] may be omitted.
+]]
+local function parse_time(str)
+    check_str("datetime.parse_time()")
+    local sp = ffi.new('int[1]')
+    local fp = ffi.new('int[1]')
+    local len = builtin.tnt_dt_parse_iso_time(str, #str, sp, fp)
+    return len > 0 and datetime_new_dt(nil, sp[0], fp[0]) or nil,
+           tonumber(len)
+end
+
+--[[
+    Basic    Extended
+    Z        N/A
+    +hh      N/A
+    -hh      N/A
+    +hhmm    +hh:mm
+    -hhmm    -hh:mm
+]]
+local function parse_zone(str)
+    check_str("datetime.parse_zone()")
+    local offset = ffi.new('int[1]')
+    local len = builtin.tnt_dt_parse_iso_zone_lenient(str, #str, offset)
+    return len > 0 and datetime_new_dt(nil, nil, nil, offset[0]) or nil,
+           tonumber(len)
+end
+
+
+--[[
+    aggregated parse functions
+    assumes to deal with date T time time_zone
+    at once
+
+    date [T] time [ ] time_zone
+]]
+local function parse(str)
+    check_str("datetime.parse()")
+    local dt = ffi.new('dt_t[1]')
+    local len = #str
+    local n = builtin.tnt_dt_parse_iso_date(str, len, dt)
+    local dt_ = dt[0]
+    if n == 0 or len == n then
+        return datetime_new_dt(dt_)
+    end
+
+    str = str:sub(tonumber(n) + 1)
+
+    local ch = str:sub(1,1)
+    if ch:match('[Tt ]') == nil then
+        return datetime_new_dt(dt_)
+    end
+
+    str = str:sub(2)
+    len = #str
+
+    local sp = ffi.new('int[1]')
+    local fp = ffi.new('int[1]')
+    local n = builtin.tnt_dt_parse_iso_time(str, len, sp, fp)
+    if n == 0 then
+        return datetime_new_dt(dt_)
+    end
+    local sp_ = sp[0]
+    local fp_ = fp[0]
+    if len == n then
+        return datetime_new_dt(dt_, sp_, fp_)
+    end
+
+    str = str:sub(tonumber(n) + 1)
+
+    if str:sub(1,1) == ' ' then
+        str = str:sub(2)
+    end
+
+    len = #str
+
+    local offset = ffi.new('int[1]')
+    n = builtin.tnt_dt_parse_iso_zone_lenient(str, len, offset)
+    if n == 0 then
+        return datetime_new_dt(dt_, sp_, fp_)
+    end
+    return datetime_new_dt(dt_, sp_, fp_, offset[0])
+end
+
+local function datetime_from(o)
+    if o == nil or type(o) == 'table' then
+        return datetime_new(o)
+    elseif type(o) == 'string' then
+        return parse(o)
+    end
+end
+
+local function local_now()
+    local d = datetime_new_raw(0, 0, 0)
+    builtin.datetime_now(d)
+    return d
+end
+
+-- Change the time-zone to the provided target_offset
+-- Time `.secs`/`.nsec` are always UTC normalized, we need only to
+-- reattribute object with different `.offset`
+local function datetime_to_tz(self, tgt_ofs)
+    if self.offset == tgt_ofs then
+        return self
+    end
+    if type(tgt_ofs) == 'string' then
+        local obj = parse_zone(tgt_ofs)
+        if obj == nil then
+            error(('%s: invalid time-zone format %s'):format(self, tgt_ofs), 2)
+        else
+            tgt_ofs = obj.offset
+        end
+    end
+    return datetime_new_raw(self.secs, self.nsec, tgt_ofs)
+end
+
+local function datetime_index(self, key)
+    if key == 'epoch' or key == 'unixtime' then
+        return self.secs
+    elseif key == 'ts' or key == 'timestamp' then
+        return tonumber(self.secs) + self.nsec / 1e9
+    elseif key == 'ns' or key == 'nanoseconds' then
+        return self.secs * 1e9 + self.nsec
+    elseif key == 'us' or key == 'microseconds' then
+        return self.secs * 1e6 + self.nsec / 1e3
+    elseif key == 'ms' or key == 'milliseconds' then
+        return self.secs * 1e3 + self.nsec / 1e6
+    elseif key == 's' or key == 'seconds' then
+        return tonumber(self.secs) + self.nsec / 1e9
+    elseif key == 'm' or key == 'min' or key == 'minutes' then
+        return (tonumber(self.secs) + self.nsec / 1e9) / 60
+    elseif key == 'hr' or key == 'hours' then
+        return (tonumber(self.secs) + self.nsec / 1e9) / (60 * 60)
+    elseif key == 'd' or key == 'days' then
+        return (tonumber(self.secs) + self.nsec / 1e9) / (24 * 60 * 60)
+    elseif key == 'to_utc' then
+        return function(self)
+            return datetime_to_tz(self, 0)
+        end
+    elseif key == 'to_tz' then
+        return function(self, offset)
+            return datetime_to_tz(self, offset)
+        end
+    else
+        error(('unknown attribute %s'):format(key), 2)
+    end
+end
+
+local function datetime_newindex(self, key, value)
+    if key == 'epoch' or key == 'unixtime' then
+        self.secs = value
+        self.nsec, self.offset = 0, 0
+    elseif key == 'ts' or key == 'timestamp' then
+        local secs, frac = math_modf(value)
+        self.secs = secs
+        self.nsec = frac * 1e9
+        self.offset = 0
+    else
+        error(('assigning to unknown attribute %s'):format(key), 2)
+    end
+end
+
+-- sizeof("Wed Jun 30 21:49:08 1993\n")
+local buf_len = 26
+
+local function asctime(o)
+    check_date(o, "datetime:asctime()")
+    local buf = ffi.new('char[?]', buf_len)
+    return ffi.string(builtin.datetime_asctime(o, buf))
+end
+
+local function ctime(o)
+    check_date(o, "datetime:ctime()")
+    local buf = ffi.new('char[?]', buf_len)
+    return ffi.string(builtin.datetime_ctime(o, buf))
+end
+
+local function strftime(fmt, o)
+    check_date(o, "datetime.strftime()")
+    local sz = 128
+    local buff = ffi.new('char[?]', sz)
+    builtin.datetime_strftime(o, fmt, buff, sz)
+    return ffi.string(buff)
+end
+
+local datetime_mt = {
+    __serialize = datetime_serialize,
+    __eq = datetime_eq,
+    __lt = datetime_lt,
+    __le = datetime_le,
+    __index = datetime_index,
+    __newindex = datetime_newindex,
+}
+
+local interval_mt = {
+    __serialize = interval_serialize,
+    __eq = datetime_eq,
+    __lt = datetime_lt,
+    __le = datetime_le,
+    __index = datetime_index,
+}
+
+ffi.metatype(interval_t, interval_mt)
+ffi.metatype(datetime_t, datetime_mt)
+
+return setmetatable(
+    {
+        new         = datetime_new,
+        new_raw     = datetime_new_obj,
+        interval    = interval_new,
+
+        parse       = parse,
+        parse_date  = parse_date,
+        parse_time  = parse_time,
+        parse_zone  = parse_zone,
+
+        now         = local_now,
+        strftime    = strftime,
+        asctime     = asctime,
+        ctime       = ctime,
+
+        is_datetime = is_datetime,
+        is_interval = is_interval,
+    }, {
+        __call = function(self, ...) return datetime_from(...) end
+    }
+)
diff --git a/src/lua/init.c b/src/lua/init.c
index f9738025d..127e935d7 100644
--- a/src/lua/init.c
+++ b/src/lua/init.c
@@ -129,7 +129,8 @@ extern char strict_lua[],
 	parse_lua[],
 	process_lua[],
 	humanize_lua[],
-	memprof_lua[]
+	memprof_lua[],
+	datetime_lua[]
 ;
 
 static const char *lua_modules[] = {
@@ -184,6 +185,7 @@ static const char *lua_modules[] = {
 	"memprof.process", process_lua,
 	"memprof.humanize", humanize_lua,
 	"memprof", memprof_lua,
+	"datetime", datetime_lua,
 	NULL
 };
 
diff --git a/src/lua/utils.c b/src/lua/utils.c
index c71cd4857..9130b60b5 100644
--- a/src/lua/utils.c
+++ b/src/lua/utils.c
@@ -48,6 +48,9 @@ static uint32_t CTID_STRUCT_IBUF_PTR;
 uint32_t CTID_CHAR_PTR;
 uint32_t CTID_CONST_CHAR_PTR;
 uint32_t CTID_UUID;
+uint32_t CTID_DATETIME = 0;
+uint32_t CTID_INTERVAL = 0;
+
 
 void *
 luaL_pushcdata(struct lua_State *L, uint32_t ctypeid)
@@ -120,6 +123,12 @@ luaL_pushuuidstr(struct lua_State *L, const struct tt_uuid *uuid)
 	lua_pushlstring(L, str, UUID_STR_LEN);
 }
 
+struct datetime *
+luaL_pushdatetime(struct lua_State *L)
+{
+	return luaL_pushcdata(L, CTID_DATETIME);
+}
+
 int
 luaL_iscdata(struct lua_State *L, int idx)
 {
@@ -725,6 +734,24 @@ tarantool_lua_utils_init(struct lua_State *L)
 	CTID_UUID = luaL_ctypeid(L, "struct tt_uuid");
 	assert(CTID_UUID != 0);
 
+	rc = luaL_cdef(L, "struct datetime {"
+			  "int64_t secs;"
+			  "int32_t nsec;"
+			  "int32_t offset;"
+			  "};");
+	assert(rc == 0);
+	(void) rc;
+	CTID_DATETIME = luaL_ctypeid(L, "struct datetime");
+	assert(CTID_DATETIME != 0);
+	rc = luaL_cdef(L, "struct datetime_interval {"
+			  "int64_t secs;"
+			  "int32_t nsec;"
+			  "};");
+	assert(rc == 0);
+	(void) rc;
+	CTID_INTERVAL = luaL_ctypeid(L, "struct datetime_interval");
+	assert(CTID_INTERVAL != 0);
+
 	lua_pushcfunction(L, luaT_newthread_wrapper);
 	luaT_newthread_ref = luaL_ref(L, LUA_REGISTRYINDEX);
 	return 0;
diff --git a/src/lua/utils.h b/src/lua/utils.h
index 45070b778..73495b607 100644
--- a/src/lua/utils.h
+++ b/src/lua/utils.h
@@ -59,6 +59,7 @@ struct lua_State;
 struct ibuf;
 typedef struct ibuf box_ibuf_t;
 struct tt_uuid;
+struct datetime;
 
 /**
  * Single global lua_State shared by core and modules.
@@ -71,6 +72,8 @@ extern struct lua_State *tarantool_L;
 extern uint32_t CTID_CHAR_PTR;
 extern uint32_t CTID_CONST_CHAR_PTR;
 extern uint32_t CTID_UUID;
+extern uint32_t CTID_DATETIME;
+extern uint32_t CTID_INTERVAL;
 
 struct tt_uuid *
 luaL_pushuuid(struct lua_State *L);
@@ -78,6 +81,15 @@ luaL_pushuuid(struct lua_State *L);
 void
 luaL_pushuuidstr(struct lua_State *L, const struct tt_uuid *uuid);
 
+/**
+ * @brief Push cdata of a datetime type onto the stack.
+ * @param L Lua State
+ * @sa luaL_pushcdata
+ * @return memory associated with this datetime data
+ */
+struct datetime *
+luaL_pushdatetime(struct lua_State *L);
+
 /** \cond public */
 
 /**
diff --git a/test/app-tap/datetime.test.lua b/test/app-tap/datetime.test.lua
new file mode 100755
index 000000000..464d4bd49
--- /dev/null
+++ b/test/app-tap/datetime.test.lua
@@ -0,0 +1,206 @@
+#!/usr/bin/env tarantool
+
+local tap = require('tap')
+local test = tap.test("errno")
+local date = require('datetime')
+local ffi = require('ffi')
+
+
+test:plan(6)
+
+test:test("Simple tests for parser", function(test)
+    test:plan(2)
+    test:ok(date("1970-01-01T01:00:00Z") ==
+            date {year=1970, month=1, day=1, hour=1, minute=0, second=0})
+    test:ok(date("1970-01-01T02:00:00+02:00") ==
+            date {year=1970, month=1, day=1, hour=2, minute=0, second=0, tz=120})
+end)
+
+test:test("Multiple tests for parser (with nanoseconds)", function(test)
+    test:plan(168)
+    -- borrowed from p5-time-moments/t/180_from_string.t
+    local tests =
+    {
+        { '1970-01-01T00:00:00Z',                       0,           0,    0 },
+        { '1970-01-01T02:00:00+02:00',                  0,           0,  120 },
+        { '1970-01-01T01:30:00+01:30',                  0,           0,   90 },
+        { '1970-01-01T01:00:00+01:00',                  0,           0,   60 },
+        { '1970-01-01T00:01:00+00:01',                  0,           0,    1 },
+        { '1970-01-01T00:00:00+00:00',                  0,           0,    0 },
+        { '1969-12-31T23:59:00-00:01',                  0,           0,   -1 },
+        { '1969-12-31T23:00:00-01:00',                  0,           0,  -60 },
+        { '1969-12-31T22:30:00-01:30',                  0,           0,  -90 },
+        { '1969-12-31T22:00:00-02:00',                  0,           0, -120 },
+        { '1970-01-01T00:00:00.123456789Z',             0,   123456789,    0 },
+        { '1970-01-01T00:00:00.12345678Z',              0,   123456780,    0 },
+        { '1970-01-01T00:00:00.1234567Z',               0,   123456700,    0 },
+        { '1970-01-01T00:00:00.123456Z',                0,   123456000,    0 },
+        { '1970-01-01T00:00:00.12345Z',                 0,   123450000,    0 },
+        { '1970-01-01T00:00:00.1234Z',                  0,   123400000,    0 },
+        { '1970-01-01T00:00:00.123Z',                   0,   123000000,    0 },
+        { '1970-01-01T00:00:00.12Z',                    0,   120000000,    0 },
+        { '1970-01-01T00:00:00.1Z',                     0,   100000000,    0 },
+        { '1970-01-01T00:00:00.01Z',                    0,    10000000,    0 },
+        { '1970-01-01T00:00:00.001Z',                   0,     1000000,    0 },
+        { '1970-01-01T00:00:00.0001Z',                  0,      100000,    0 },
+        { '1970-01-01T00:00:00.00001Z',                 0,       10000,    0 },
+        { '1970-01-01T00:00:00.000001Z',                0,        1000,    0 },
+        { '1970-01-01T00:00:00.0000001Z',               0,         100,    0 },
+        { '1970-01-01T00:00:00.00000001Z',              0,          10,    0 },
+        { '1970-01-01T00:00:00.000000001Z',             0,           1,    0 },
+        { '1970-01-01T00:00:00.000000009Z',             0,           9,    0 },
+        { '1970-01-01T00:00:00.00000009Z',              0,          90,    0 },
+        { '1970-01-01T00:00:00.0000009Z',               0,         900,    0 },
+        { '1970-01-01T00:00:00.000009Z',                0,        9000,    0 },
+        { '1970-01-01T00:00:00.00009Z',                 0,       90000,    0 },
+        { '1970-01-01T00:00:00.0009Z',                  0,      900000,    0 },
+        { '1970-01-01T00:00:00.009Z',                   0,     9000000,    0 },
+        { '1970-01-01T00:00:00.09Z',                    0,    90000000,    0 },
+        { '1970-01-01T00:00:00.9Z',                     0,   900000000,    0 },
+        { '1970-01-01T00:00:00.99Z',                    0,   990000000,    0 },
+        { '1970-01-01T00:00:00.999Z',                   0,   999000000,    0 },
+        { '1970-01-01T00:00:00.9999Z',                  0,   999900000,    0 },
+        { '1970-01-01T00:00:00.99999Z',                 0,   999990000,    0 },
+        { '1970-01-01T00:00:00.999999Z',                0,   999999000,    0 },
+        { '1970-01-01T00:00:00.9999999Z',               0,   999999900,    0 },
+        { '1970-01-01T00:00:00.99999999Z',              0,   999999990,    0 },
+        { '1970-01-01T00:00:00.999999999Z',             0,   999999999,    0 },
+        { '1970-01-01T00:00:00.0Z',                     0,           0,    0 },
+        { '1970-01-01T00:00:00.00Z',                    0,           0,    0 },
+        { '1970-01-01T00:00:00.000Z',                   0,           0,    0 },
+        { '1970-01-01T00:00:00.0000Z',                  0,           0,    0 },
+        { '1970-01-01T00:00:00.00000Z',                 0,           0,    0 },
+        { '1970-01-01T00:00:00.000000Z',                0,           0,    0 },
+        { '1970-01-01T00:00:00.0000000Z',               0,           0,    0 },
+        { '1970-01-01T00:00:00.00000000Z',              0,           0,    0 },
+        { '1970-01-01T00:00:00.000000000Z',             0,           0,    0 },
+        { '1973-11-29T21:33:09Z',               123456789,           0,    0 },
+        { '2013-10-28T17:51:56Z',              1382982716,           0,    0 },
+        { '9999-12-31T23:59:59Z',            253402300799,           0,    0 },
+    }
+    for _, value in ipairs(tests) do
+        local str, epoch, nsec, offset
+        str, epoch, nsec, offset = unpack(value)
+        local dt = date(str)
+        test:ok(dt.secs == epoch, ('%s: dt.secs == %d'):format(str, epoch))
+        test:ok(dt.nsec == nsec, ('%s: dt.nsec == %d'):format(str, nsec))
+        test:ok(dt.offset == offset, ('%s: dt.offset == %d'):format(str, offset))
+    end
+end)
+
+ffi.cdef [[
+    void tzset(void);
+]]
+
+test:test("Datetime string formatting", function(test)
+    test:plan(7)
+    local str = "1970-01-01"
+    local t = date(str)
+    test:ok(t.secs == 0, ('%s: t.secs == %d'):format(str, tonumber(t.secs)))
+    test:ok(t.nsec == 0, ('%s: t.nsec == %d'):format(str, t.nsec))
+    test:ok(t.offset == 0, ('%s: t.offset == %d'):format(str, t.offset))
+    test:ok(date.asctime(t) == 'Thu Jan  1 00:00:00 1970\n', ('%s: asctime'):format(str))
+    -- ctime() is local timezone dependent. To make sure that
+    -- test is deterministic we enforce timezone via TZ environment
+    -- manipulations and calling tzset()
+
+    -- redefine timezone to be always GMT-2
+    os.setenv('TZ', 'GMT-2')
+    ffi.C.tzset()
+    test:ok(date.ctime(t) == 'Thu Jan  1 02:00:00 1970\n', ('%s: ctime with timezone'):format(str))
+    test:ok(date.strftime('%d/%m/%Y', t) == '01/01/1970', ('%s: strftime #1'):format(str))
+    test:ok(date.strftime('%A %d. %B %Y', t) == 'Thursday 01. January 1970', ('%s: strftime #2'):format(str))
+end)
+
+test:test("Parse iso date - valid strings", function(test)
+    test:plan(32)
+    local good = {
+        {2012, 12, 24, "20121224",                   8 },
+        {2012, 12, 24, "20121224  Foo bar",          8 },
+        {2012, 12, 24, "2012-12-24",                10 },
+        {2012, 12, 24, "2012-12-24 23:59:59",       10 },
+        {2012, 12, 24, "2012-12-24T00:00:00+00:00", 10 },
+        {2012, 12, 24, "2012359",                    7 },
+        {2012, 12, 24, "2012359T235959+0130",        7 },
+        {2012, 12, 24, "2012-359",                   8 },
+        {2012, 12, 24, "2012W521",                   8 },
+        {2012, 12, 24, "2012-W52-1",                10 },
+        {2012, 12, 24, "2012Q485",                   8 },
+        {2012, 12, 24, "2012-Q4-85",                10 },
+        {   1,  1,  1, "0001-Q1-01",                10 },
+        {   1,  1,  1, "0001-W01-1",                10 },
+        {   1,  1,  1, "0001-01-01",                10 },
+        {   1,  1,  1, "0001-001",                   8 },
+    }
+
+    for _, value in ipairs(good) do
+        local year, month, day, str, date_part_len
+        year, month, day, str, date_part_len = unpack(value)
+        local expected_date = date{year = year, month = month, day = day}
+        local date_part, len
+        date_part, len = date.parse_date(str)
+        test:ok(len == date_part_len, ('%s: length check %d'):format(str, len))
+        test:ok(expected_date == date_part, ('%s: expected date'):format(str))
+    end
+end)
+
+test:test("Parse iso date - invalid strings", function(test)
+    test:plan(62)
+    local bad = {
+        "20121232"   , -- Invalid day of month
+        "2012-12-310", -- Invalid day of month
+        "2012-13-24" , -- Invalid month
+        "2012367"    , -- Invalid day of year
+        "2012-000"   , -- Invalid day of year
+        "2012W533"   , -- Invalid week of year
+        "2012-W52-8" , -- Invalid day of week
+        "2012Q495"   , -- Invalid day of quarter
+        "2012-Q5-85" , -- Invalid quarter
+        "20123670"   , -- Trailing digit
+        "201212320"  , -- Trailing digit
+        "2012-12"    , -- Reduced accuracy
+        "2012-Q4"    , -- Reduced accuracy
+        "2012-Q42"   , -- Invalid
+        "2012-Q1-1"  , -- Invalid day of quarter
+        "2012Q--420" , -- Invalid
+        "2012-Q-420" , -- Invalid
+        "2012Q11"    , -- Incomplete
+        "2012Q1234"  , -- Trailing digit
+        "2012W12"    , -- Incomplete
+        "2012W1234"  , -- Trailing digit
+        "2012W-123"  , -- Invalid
+        "2012-W12"   , -- Incomplete
+        "2012-W12-12", -- Trailing digit
+        "2012U1234"  , -- Invalid
+        "2012-1234"  , -- Invalid
+        "2012-X1234" , -- Invalid
+        "0000-Q1-01" , -- Year less than 0001
+        "0000-W01-1" , -- Year less than 0001
+        "0000-01-01" , -- Year less than 0001
+        "0000-001"   , -- Year less than 0001
+    }
+
+    for _, str in ipairs(bad) do
+        local date_part, len
+        date_part, len = date.parse_date(str)
+        test:ok(len == 0, ('%s: length check %d'):format(str, len))
+        test:ok(date_part == nil, ('%s: empty date check %s'):format(str, date_part))
+    end
+end)
+
+test:test("Parse tiny date into seconds and other parts", function(test)
+    test:plan(9)
+    local str = '19700101 00:00:30.528'
+    local tiny = date(str)
+    test:ok(tiny.secs == 30, ("secs of '%s'"):format(str))
+    test:ok(tiny.nsec == 528000000, ("nsec of '%s'"):format(str))
+    test:ok(tiny.nanoseconds == 30528000000, "nanoseconds")
+    test:ok(tiny.microseconds == 30528000, "microseconds")
+    test:ok(tiny.milliseconds == 30528, "milliseconds")
+    test:ok(tiny.seconds == 30.528, "seconds")
+    test:ok(tiny.timestamp == 30.528, "timestamp")
+    test:ok(tiny.minutes == 0.5088, "minutes")
+    test:ok(tiny.hours == 0.00848, "hours")
+end)
+
+os.exit(test:check() and 0 or 1)
diff --git a/test/app-tap/gh-5632-6050-6259-gc-buf-reuse.test.lua b/test/app-tap/gh-5632-6050-6259-gc-buf-reuse.test.lua
index 84c2944e5..7eb05f9e8 100755
--- a/test/app-tap/gh-5632-6050-6259-gc-buf-reuse.test.lua
+++ b/test/app-tap/gh-5632-6050-6259-gc-buf-reuse.test.lua
@@ -14,6 +14,7 @@ local uri = require('uri')
 local json = require('json')
 local msgpackffi = require('msgpackffi')
 local decimal = require('decimal')
+local datetime = require('datetime')
 
 local function test_uuid(test)
     test:plan(1)
@@ -256,15 +257,86 @@ local function test_decimal(test)
     test:ok(is_success, 'decimal str in gc')
 end
 
+local function test_datetime_asctime(test)
+    test:plan(1)
+
+    local gc_count = 100
+    local iter_count = 1000
+    local is_success = true
+
+    local T1 = datetime('1970-01-01')
+    local T2 = datetime('2000-01-01')
+
+    local function datetime_asctime()
+        local str1 = datetime.asctime(T1)
+        local str2 = datetime.asctime(T2)
+        local str3 = datetime.asctime(T1)
+        local str4 = datetime.asctime(T2)
+        if str1 ~= str3 or str2 ~= str4 then
+            is_success = false
+        end
+    end
+
+    local function create_gc()
+        for _ = 1, gc_count do
+            ffi.gc(ffi.new('char[1]'), function() datetime_asctime() end)
+        end
+    end
+
+    for _ = 1, iter_count do
+        create_gc()
+        datetime_asctime()
+    end
+
+    test:ok(is_success, 'info datetime in gc')
+end
+
+local function test_datetime_ctime(test)
+    test:plan(1)
+
+    local gc_count = 100
+    local iter_count = 1000
+    local is_success = true
+
+    local T1 = datetime('1970-01-01')
+    local T2 = datetime('2000-01-01')
+
+    local function datetime_ctime()
+        local str1 = datetime.ctime(T1)
+        local str2 = datetime.ctime(T2)
+        local str3 = datetime.ctime(T1)
+        local str4 = datetime.ctime(T2)
+        if str1 ~= str3 or str2 ~= str4 then
+            is_success = false
+        end
+    end
+
+    local function create_gc()
+        for _ = 1, gc_count do
+            ffi.gc(ffi.new('char[1]'), function() datetime_ctime() end)
+        end
+    end
+
+    for _ = 1, iter_count do
+        create_gc()
+        datetime_ctime()
+    end
+
+    test:ok(is_success, 'info datetime in gc')
+end
+
+
 box.cfg{}
 
 local test = tap.test('gh-5632-6050-6259-gc-buf-reuse')
-test:plan(6)
+test:plan(8)
 test:test('uuid in __gc', test_uuid)
 test:test('uri in __gc', test_uri)
 test:test('msgpackffi in __gc', test_msgpackffi)
 test:test('json in __gc', test_json)
 test:test('info uuid in __gc', test_info_uuid)
 test:test('decimal str in __gc', test_decimal)
+test:test('datetime.asctime in __gc', test_datetime_asctime)
+test:test('datetime.ctime in __gc', test_datetime_ctime)
 
 os.exit(test:check() and 0 or 1)
-- 
2.29.2



More information about the Tarantool-patches mailing list