[Tarantool-patches] [RFC PATCH 13/13] lua: complete time duration support

Timur Safin tsafin at tarantool.org
Thu Jul 15 11:18:19 MSK 2021


* reworked code which adds/subs years and months to
  use `dt_add_years()` and `dt_add_months()` instead
  of manual plumbing as before
* introduced `:add{}` and `:sub{}` methods to datetime
  object to add or substract complex periods of times
* implemented stringization of period objects
---
 src/exports.h                  |   3 +
 src/lua/datetime.lua           | 276 ++++++++++++++++++++++++---------
 test/app-tap/datetime.test.lua |  80 ++++++++--
 3 files changed, 280 insertions(+), 79 deletions(-)

diff --git a/src/exports.h b/src/exports.h
index 7397010e0..3388fb685 100644
--- a/src/exports.h
+++ b/src/exports.h
@@ -533,6 +533,9 @@ EXPORT(uuid_nil)
 EXPORT(uuid_unpack)
 EXPORT(datetime_unpack)
 EXPORT(datetime_pack)
+EXPORT(dt_add_months)
+EXPORT(dt_add_years)
+EXPORT(dt_add_quarters)
 EXPORT(dt_from_rdn)
 EXPORT(dt_from_yd)
 EXPORT(dt_from_ymd)
diff --git a/src/lua/datetime.lua b/src/lua/datetime.lua
index 698c55ebd..d28f931f6 100644
--- a/src/lua/datetime.lua
+++ b/src/lua/datetime.lua
@@ -216,6 +216,13 @@ local function duration_minutes_new(m)
     return o
 end
 
+local function duration_seconds_new(s)
+    local o = ffi.new(duration_t)
+    o.nsec = s % 1 * 1e9
+    o.secs = s - (s % 1)
+    return o
+end
+
 local function datetime_eq(lhs, rhs)
     -- we usually don't need to check nullness
     -- but older tarantool console will call us checking for equality to nil
@@ -246,31 +253,141 @@ local function duration_serialize(self)
     return { secs = self.secs, nsec = self.nsec }
 end
 
+local function local_rd(o)
+    return math.floor(o.secs / SECS_PER_DAY) + DT_EPOCH_1970_OFFSET
+end
+
+local function local_dt(o)
+    return cdt.dt_from_rdn(local_rd(o))
+end
+
+-- addition or subtraction from date/time of a given period described via table
+-- direction should be +1 or -1
+local function duration_increment(self, o, direction)
+    assert(direction == -1 or direction == 1)
+    assert(ffi.typeof(self) == datetime_t)
+    assert(type(o) == 'table')
+
+    local ym_updated = false
+    local dhms_updated = false
+
+    local dt = local_dt(self)
+    local secs, nsec
+    secs, nsec = self.secs, self.nsec
+
+    for key, value in pairs(o) do
+        local handlers = {
+            years = function(v)
+                assert(v > 0 and v < 10000)
+                dt = cdt.dt_add_years(dt, direction * v, cdt.DT_LIMIT)
+                ym_updated = true
+            end,
+
+            months = function(v)
+                assert(v > 0 and v < 13 )
+                dt = cdt.dt_add_months(dt, direction * v, cdt.DT_LIMIT)
+                ym_updated = true
+            end,
+
+            weeks = function(v)
+                assert(v > 0 and v < 32)
+                secs = secs + direction * 7 * v * SECS_PER_DAY
+                dhms_updated = true
+            end,
+
+            days = function(v)
+                assert(v > 0 and v < 32)
+                secs = secs + direction * v * SECS_PER_DAY
+                dhms_updated = true
+            end,
+
+            hours = function(v)
+                assert(v >= 0 and v < 24)
+                secs = secs + direction * 60 * 60 * v
+                dhms_updated = true
+            end,
+
+            minutes = function(v)
+                assert(v >= 0 and v < 60)
+                secs = secs + direction * 60 * v
+            end,
+
+            seconds = function(v)
+                assert(v >= 0 and v < 61)
+                local s, frac
+                frac = v % 1
+                if frac > 0 then
+                    s = v - (v % 1)
+                else
+                    s = v
+                end
+                secs = secs + direction * s
+                nsec = nsec + direction * frac * 1e9 -- convert fraction to nanoseconds
+                dhms_updated = true
+            end,
+        }
+        handlers[key](value)
+    end
+
+    -- normalize values
+    if nsec < 0 then
+        nsec = nsec + NANOS_PER_SEC
+        secs = secs - 1
+    elseif nsec >= NANOS_PER_SEC then
+        nsec = nsec - NANOS_PER_SEC
+        secs = secs + 1
+    end
+
+    -- .days, .hours, .minutes, .seconds
+    if dhms_updated then
+        self.secs = secs
+        self.nsec = nsec
+    end
+
+    -- .years, .months updated
+    if ym_updated then
+        self.secs = (cdt.dt_rdn(dt) - DT_EPOCH_1970_OFFSET) * SECS_PER_DAY +
+                    secs % SECS_PER_DAY
+    end
+
+    return self
+end
+
 local datetime_index = function(self, key)
     local attributes = {
         timestamp = function(self)
-            return tonumber(self.secs + self.nsec / 1e9)
+            return self.secs + self.nsec / 1e9
         end,
         nanoseconds = function(self)
-            return tonumber(self.secs * 1e9 + self.nsec)
+            return self.secs * 1e9 + self.nsec
         end,
         microseconds = function(self)
-            return tonumber(self.secs * 1e6 + self.nsec / 1e3)
+            return self.secs * 1e6 + self.nsec / 1e3
         end,
         milliseconds = function(self)
-            return tonumber(self.secs * 1e3 + self.nsec / 1e6)
+            return self.secs * 1e3 + self.nsec / 1e6
         end,
         seconds = function(self)
-            return tonumber(self.secs + self.nsec / 1e9)
+            return self.secs + self.nsec / 1e9
         end,
         minutes = function(self)
-            return tonumber((self.secs + self.nsec / 1e9) / 60 % 60)
+            return (self.secs + self.nsec / 1e9) / 60
         end,
         hours = function(self)
-            return tonumber((self.secs + self.nsec / 1e9) / (60 * 60))
+            return (self.secs + self.nsec / 1e9) / (60 * 60)
         end,
         days = function(self)
-            return tonumber((self.secs + self.nsec / 1e9) / (60 * 60)) / 24
+            return (self.secs + self.nsec / 1e9) / (60 * 60) / 24
+        end,
+        add = function(self)
+            return function(self, o)
+                return duration_increment(self, o, 1)
+            end
+        end,
+        sub = function(self)
+            return function(self, o)
+                return duration_increment(self, o, -1)
+            end
         end,
     }
     return attributes[key] ~= nil and attributes[key](self) or nil
@@ -284,14 +401,6 @@ local function datetime_new_raw(secs, nsec, offset)
     return dt_obj
 end
 
-local function local_rd(o)
-    return math.floor(o.secs / SECS_PER_DAY) + DT_EPOCH_1970_OFFSET
-end
-
-local function local_dt(o)
-    return cdt.dt_from_rdn(local_rd(o))
-end
-
 local function mk_timestamp(dt, sp, fp, offset)
     local epochV = dt ~= nil and (cdt.dt_rdn(dt) - DT_EPOCH_1970_OFFSET) * SECS_PER_DAY or 0
     local spV = sp ~= nil and sp or 0
@@ -367,11 +476,12 @@ local function datetime_new(o)
             second = function(v)
                 assert(v >= 0 and v < 61)
                 frac = v % 1
-                if frac then
+                if frac > 0 then
                     s = v - (v % 1)
                 else
                     s = v
                 end
+                frac = frac * 1e9 -- convert fraction to nanoseconds
                 hms = true
             end,
 
@@ -403,22 +513,37 @@ local function datetime_new(o)
 end
 
 local function datetime_tostring(o)
-    print(ffi.typeof(o))
-    assert(ffi.typeof(o) == datetime_t)
-    local sz = 48
-    local buff = ffi.new('char[?]', sz)
-    local len = native.datetime_to_string(o, buff, sz)
-    assert(len < sz)
-    return ffi.string(buff)
-end
+    if ffi.typeof(o) == datetime_t then
+        local sz = 48
+        local buff = ffi.new('char[?]', sz)
+        local len = native.datetime_to_string(o, buff, sz)
+        assert(len < sz)
+        return ffi.string(buff)
+    elseif ffi.typeof(o) == duration_years_t then
+        return ('%+d years'):format(o.y)
+    elseif ffi.typeof(o) == duration_months_t then
+        return ('%+d months'):format(o.m)
+    elseif ffi.typeof(o) == duration_t then
+        local ts = o.timestamp
+        local sign = '+'
+
+        if ts < 0 then
+            ts = -ts
+            sign = '-'
+        end
 
-local function dt_to_ymd(dt)
-    local y, m, d
-    y = ffi.new('int[1]')
-    m = ffi.new('int[1]')
-    d = ffi.new('int[1]')
-    cdt.dt_to_ymd(dt, y, m, d)
-    return y[0], m[0], d[0]
+        if ts < 60 then
+            return ('%s%s secs'):format(sign, ts)
+        elseif ts < 60 * 60 then
+            return ('%+d minutes, %s seconds'):format(o.minutes, ts % 60)
+        elseif ts < 24 * 60 * 60 then
+            return ('%+d hours, %d minutes, %s seconds'):format(
+                    o.hours, o.minutes % 60, ts % 60)
+        else
+            return ('%+d days, %d hours, %d minutes, %s seconds'):format(
+                    o.days, o.hours % 24, o.minutes % 60, ts % 60)
+        end
+    end
 end
 
 local function check_date(o)
@@ -433,40 +558,36 @@ local function date_first(lhs, rhs)
     end
 end
 
-local function shift_months(y, M, deltaM)
-    M = M + deltaM
-    local newM = (M - 1) % 12 + 1
-    local newY = y + math.floor((M - 1)/12)
-    assert(newM >= 1 and newM <= 12, "month value is outside of range")
-    return newY, newM
-end
-
 local function datetime_sub(lhs, rhs)
     check_date(lhs) -- make sure left is date
     local d, s = lhs, rhs
 
     -- 1. left is date, right is date or delta
     if (ffi.typeof(s) == datetime_t) or (ffi.typeof(s) == duration_t) then
-        d.secs = d.secs - s.secs
-        d.nsec = s.nsec - s.nsec
-        if d.nsec < 0 then
-            d.secs = d.secs - 1
-            d.nsec = d.nsec + NANOS_PER_SEC
+        local o
+        -- if they are both dates then result is delta
+        if ffi.typeof(s) == datetime_t then
+            o = duration_new()
+        else
+            o = datetime_new()
+        end
+        o.secs = d.secs - s.secs
+        o.nsec = d.nsec - s.nsec
+        if o.nsec < 0 then
+            o.secs = d.secs - 1
+            o.nsec = d.nsec + NANOS_PER_SEC
         end
+        return o
 
     -- 2. left is date, right is duration in months
     elseif ffi.typeof(s) == duration_months_t then
-        local y, M, D = dt_to_ymd(local_dt(d))
-        y, M = shift_months(y, M, -s.m)
-        local dt = cdt.dt_from_ymd(y, M, D)
+        local dt = cdt.dt_add_months(local_dt(d), -s.m, cdt.DT_LIMIT)
         local secs = d.secs % SECS_PER_DAY
         return mk_timestamp(dt, secs, d.nsec, d.offset or 0)
 
     -- 2. left is date, right is duration in years
     elseif ffi.typeof(s) == duration_years_t then
-        local y, M, D = dt_to_ymd(local_dt(d))
-        y = y - s.y
-        local dt = cdt.dt_from_ymd(y, M, D)
+        local dt = cdt.dt_add_years(local_dt(d), -s.y, cdt.DT_LIMIT)
         local secs = d.secs % SECS_PER_DAY
         return mk_timestamp(dt, secs, d.nsec, d.offset or 0)
     else
@@ -479,27 +600,25 @@ local function datetime_add(lhs, rhs)
 
     -- 1. left is date, right is date or delta
     if (ffi.typeof(s) == datetime_t) or (ffi.typeof(s) == duration_t) then
-        d.secs = d.secs + s.secs
-        d.nsec = d.nsec + s.nsec
-        if d.nsec >= NANOS_PER_SEC then
-            d.secs = d.secs + 1
-            d.nsec = d.nsec - NANOS_PER_SEC
+        local o = datetime_new()
+
+        o.secs = d.secs + s.secs
+        o.nsec = d.nsec + s.nsec
+        if o.nsec >= NANOS_PER_SEC then
+            o.secs = d.secs + 1
+            o.nsec = d.nsec - NANOS_PER_SEC
         end
-        return d
+        return o
 
     -- 2. left is date, right is duration in months
     elseif ffi.typeof(s) == duration_months_t then
-        local y, M, D = dt_to_ymd(local_dt(d))
-        y, M = shift_months(y, M, s.m)
-        local dt = cdt.dt_from_ymd(y, M, D)
+        local dt = cdt.dt_add_months(local_dt(d), s.m, cdt.DT_LIMIT)
         local secs = d.secs % SECS_PER_DAY
         return mk_timestamp(dt, secs, d.nsec, d.offset or 0)
 
     -- 2. left is date, right is duration in years
     elseif ffi.typeof(s) == duration_years_t then
-        local y, M, D = dt_to_ymd(local_dt(d))
-        y = y + s.y
-        local dt = cdt.dt_from_ymd(y, M, D)
+        local dt = cdt.dt_add_years(local_dt(d), s.y, cdt.DT_LIMIT)
         local secs = d.secs % SECS_PER_DAY
         return mk_timestamp(dt, secs, d.nsec, d.offset or 0)
     else
@@ -636,7 +755,7 @@ end
 
 local function datetime_to_tm_ptr(o)
     local p_tm = ffi.new 'struct tm[1]'
-    assert(ffi.typeof(o) == datetime_t)
+    check_date(o)
     -- dt_to_struct_tm() fills only date data
     cdt.dt_to_struct_tm(local_dt(o), p_tm)
 
@@ -655,20 +774,20 @@ local function datetime_to_tm_ptr(o)
 end
 
 local function asctime(o)
-    assert(ffi.typeof(o) == datetime_t)
+    check_date(o)
     local p_tm = datetime_to_tm_ptr(o)
     return ffi.string(native.asctime(p_tm))
 end
 
 local function ctime(o)
-    assert(ffi.typeof(o) == datetime_t)
+    check_date(o)
     local p_time = ffi.new 'time_t[1]'
     p_time[0] = o.secs
     return ffi.string(native.ctime(p_time))
 end
 
 local function strftime(fmt, o)
-    assert(ffi.typeof(o) == datetime_t)
+    check_date(o)
     local sz = 50
     local buff = ffi.new('char[?]', sz)
     local p_tm = datetime_to_tm_ptr(o)
@@ -678,7 +797,7 @@ end
 
 
 local datetime_mt = {
-    -- __tostring = datetime_tostring,
+    __tostring = datetime_tostring,
     __serialize = datetime_serialize,
     __eq = datetime_eq,
     __lt = datetime_lt,
@@ -686,10 +805,18 @@ local datetime_mt = {
     __sub = datetime_sub,
     __add = datetime_add,
     __index = datetime_index,
+    add = function(self, o)
+        self = duration_increment(self, o, 1)
+        return self
+    end,
+    sub = function(self, o)
+        self = duration_increment(self, o, -1)
+        return self
+    end
 }
 
 local duration_mt = {
-    -- __tostring = duration_tostring,
+    __tostring = datetime_tostring,
     __serialize = duration_serialize,
     __eq = datetime_eq,
     __lt = datetime_lt,
@@ -699,8 +826,18 @@ local duration_mt = {
     __index = datetime_index,
 }
 
+local duration_tiny_mt = {
+    __tostring = datetime_tostring,
+    __serialize = duration_serialize,
+    __sub = datetime_sub,
+    __add = datetime_add,
+    __index = datetime_index,
+}
+
 ffi.metatype(duration_t, duration_mt)
 ffi.metatype(datetime_t, datetime_mt)
+ffi.metatype(duration_years_t, duration_tiny_mt)
+ffi.metatype(duration_months_t, duration_tiny_mt)
 
 return setmetatable(
     {
@@ -710,6 +847,7 @@ return setmetatable(
         days = duration_days_new,
         hours = duration_hours_new,
         minutes = duration_minutes_new,
+        seconds = duration_seconds_new,
         delta = duration_new,
 
         parse = parse_str,
diff --git a/test/app-tap/datetime.test.lua b/test/app-tap/datetime.test.lua
index 06720b241..f911412bf 100755
--- a/test/app-tap/datetime.test.lua
+++ b/test/app-tap/datetime.test.lua
@@ -4,7 +4,7 @@ local tap = require('tap')
 local test = tap.test("errno")
 local date = require('datetime')
 
-test:plan(6)
+test:plan(8)
 
 test:test("Simple tests for parser", function(test)
     test:plan(2)
@@ -192,15 +192,75 @@ 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, "minuts")
-    test:ok(tiny.hours,  0.00848, "hours")
+    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, "minuts")
+    test:ok(tiny.hours == 0.00848, "hours")
+end)
+
+test:test("Stringization of dates and periods", function(test)
+    test:plan(13)
+    local str = '19700101Z'
+    local dt = date(str)
+    test:ok(tostring(dt) == '1970-01-01T00:00Z', ('tostring(%s)'):format(str))
+    test:ok(tostring(date.seconds(12)) == '+12 secs', '+12 seconds')
+    test:ok(tostring(date.seconds(-12)) == '-12 secs', '-12 seconds')
+    test:ok(tostring(date.minutes(12)) == '+12 minutes, 0 seconds', '+12 minutes')
+    test:ok(tostring(date.minutes(-12)) == '-12 minutes, 0 seconds', '-12 minutes')
+    test:ok(tostring(date.hours(12)) == '+12 hours, 0 minutes, 0 seconds',
+            '+12 hours')
+    test:ok(tostring(date.hours(-12)) == '-12 hours, 0 minutes, 0 seconds',
+            '-12 hours')
+    test:ok(tostring(date.days(12)) == '+12 days, 0 hours, 0 minutes, 0 seconds',
+            '+12 days')
+    test:ok(tostring(date.days(-12)) == '-12 days, 0 hours, 0 minutes, 0 seconds',
+            '-12 days')
+    test:ok(tostring(date.months(5)) == '+5 months', '+5 months')
+    test:ok(tostring(date.months(-5)) == '-5 months', '-5 months')
+    test:ok(tostring(date.years(4)) == '+4 years', '+4 years')
+    test:ok(tostring(date.years(-4)) == '-4 years', '-4 years')
+end)
+
+test:test("Time duration operations", function(test)
+    test:plan(12)
+
+    -- check arithmetic with leap dates
+    local T = date('1972-02-29')
+    local M = date.months(2)
+    local Y = date.years(1)
+    test:ok(tostring(T + M) == '1972-04-29T00:00Z', ('T(%s) + M(%s'):format(T, M))
+    test:ok(tostring(T + Y) == '1973-03-01T00:00Z', ('T(%s) + Y(%s'):format(T, Y))
+    test:ok(tostring(T + M + Y) == '1973-04-30T00:00Z',
+            ('T(%s) + M(%s) + Y(%s'):format(T, M, Y))
+    test:ok(tostring(T + Y + M) == '1973-05-01T00:00Z',
+            ('T(%s) + M(%s) + Y(%s'):format(T, M, Y))
+    test:ok(tostring(T:add{years = 1, months = 2}) == '1973-04-30T00:00Z',
+            ('T:add{years=1,months=2}(%s)'):format(T))
+
+    -- check average, not leap dates
+    T = date('1970-01-08')
+    test:ok(tostring(T + M) == '1970-03-08T00:00Z', ('T(%s) + M(%s'):format(T, M))
+    test:ok(tostring(T + Y) == '1971-01-08T00:00Z', ('T(%s) + Y(%s'):format(T, Y))
+    test:ok(tostring(T + M + Y) == '1971-03-08T00:00Z',
+            ('T(%s) + M(%s) + Y(%s'):format(T, M, Y))
+    test:ok(tostring(T + Y + M) == '1971-03-08T00:00Z',
+            ('T(%s) + Y(%s) + M(%s'):format(T, Y, M))
+    test:ok(tostring(T:add{years = 1, months = 2}) == '1971-03-08T00:00Z',
+            ('T:add{years=1,months=2}(%s)'):format(T))
+
+
+    -- subtraction of 2 dates
+    local T2 = date('19700103')
+    local T1 = date('1970-01-01')
+    test:ok(tostring(T2 - T1) == '+2 days, 0 hours, 0 minutes, 0 seconds',
+            ('T2(%s) - T1(%s'):format(T2, T1))
+    test:ok(tostring(T1 - T2) == '-2 days, 0 hours, 0 minutes, 0 seconds',
+            ('T2(%s) - T1(%s'):format(T2, T1))
 end)
 
 os.exit(test:check() and 0 or 1)
-- 
2.29.2



More information about the Tarantool-patches mailing list