From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from [87.239.111.99] (localhost [127.0.0.1]) by dev.tarantool.org (Postfix) with ESMTP id 910456EC55; Wed, 28 Jul 2021 13:39:30 +0300 (MSK) DKIM-Filter: OpenDKIM Filter v2.11.0 dev.tarantool.org 910456EC55 DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=tarantool.org; s=dev; t=1627468770; bh=h42Ppn9izzcUKeBcJ1fVSjKQ4tBuepCJU2ChopjmPhM=; h=To:Date:In-Reply-To:References:Subject:List-Id:List-Unsubscribe: List-Archive:List-Post:List-Help:List-Subscribe:From:Reply-To:Cc: From; b=AeuCnNlhqWuPxyXkGp6iYEtLSIyN6Q0KlcLtNEk+29+ynrWdutqZTajeA+XDDvTWP jDk0meMBQA2aWgH7YOVZwY4x0ioiOjCwuk+kYnL3YnDfnKhbKvC+J5RLCoe8wDY2Nj 4fWe+ZmKZ1B/Ziuj0tVgQ2le7I/If+4ZEXcBAY6k= Received: from smtp57.i.mail.ru (smtp57.i.mail.ru [217.69.128.37]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by dev.tarantool.org (Postfix) with ESMTPS id 80DD06F3C3 for ; Wed, 28 Jul 2021 13:34:38 +0300 (MSK) DKIM-Filter: OpenDKIM Filter v2.11.0 dev.tarantool.org 80DD06F3C3 Received: by smtp57.i.mail.ru with esmtpa (envelope-from ) id 1m8gtW-0008Bz-Ev; Wed, 28 Jul 2021 13:34:35 +0300 To: v.shpilevoy@tarantool.org Date: Wed, 28 Jul 2021 13:34:11 +0300 Message-Id: <072bcee03a63600d918b18cd2863b7c36f666072.1627468002.git.tsafin@tarantool.org> X-Mailer: git-send-email 2.29.2 In-Reply-To: References: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-4EC0790: 10 X-7564579A: 646B95376F6C166E X-77F55803: 4F1203BC0FB41BD941C43E597735A9C3104FC76DFAAAAF7DA068FE323FAC4379182A05F53808504035F362B4599A14C33697FC7C9E7E3F6CAA295ACD0F0533D3DA7404836C41A079 X-7FA49CB5: FF5795518A3D127A4AD6D5ED66289B5278DA827A17800CE79145AB6E9E75F07EEA1F7E6F0F101C67BD4B6F7A4D31EC0BCC500DACC3FED6E28638F802B75D45FF8AA50765F7900637B100969577675F2D8638F802B75D45FF36EB9D2243A4F8B5A6FCA7DBDB1FC311F39EFFDF887939037866D6147AF826D8C26EBEEE47D5813D751D290DEBD80F6A117882F4460429724CE54428C33FAD305F5C1EE8F4F765FCAA867293B0326636D2E47CDBA5A96583BD4B6F7A4D31EC0BC014FD901B82EE079FA2833FD35BB23D27C277FBC8AE2E8BAA867293B0326636D2E47CDBA5A96583BA9C0B312567BB2376E601842F6C81A19E625A9149C048EE41BF15D38FB6CB3AA5FD2DD08EB4836CD8FC6C240DEA7642DBF02ECDB25306B2B78CF848AE20165D0A6AB1C7CE11FEE386D40F53BA19229503F1AB874ED89028C4224003CC836476EA7A3FFF5B025636E2021AF6380DFAD1A18204E546F3947CB11811A4A51E3B096D1867E19FE1407959CC434672EE6371089D37D7C0E48F6C8AA50765F7900637B8F435DEDE9E76EBEFF80C71ABB335746BA297DBC24807EABDAD6C7F3747799A X-C1DE0DAB: C20DE7B7AB408E4181F030C43753B8186998911F362727C4C7A0BC55FA0FE5FC091E91936EBDB81086472AF9111C0C93C12E633DD06164ACB1881A6453793CE9C32612AADDFBE061C61BE10805914D3804EBA3D8E7E5B87ABF8C51168CD8EBDBD6672DD12D5A8206DC48ACC2A39D04F89CDFB48F4795C241BDAD6C7F3747799A X-C8649E89: 4E36BF7865823D7055A7F0CF078B5EC49A30900B95165D34E420FF71F2F0FE036858643C926EDF0D376E308303C75DD4AF0808BC0EF57CCBB19C49FFF56C24481D7E09C32AA3244C258CCDAC48C3098BC3E3BBD8996A7D6660759606DA2E136A83B48618A63566E0 X-D57D3AED: 3ZO7eAau8CL7WIMRKs4sN3D3tLDjz0dLbV79QFUyzQ2Ujvy7cMT6pYYqY16iZVKkSc3dCLJ7zSJH7+u4VD18S7Vl4ZUrpaVfd2+vE6kuoey4m4VkSEu530nj6fImhcD4MUrOEAnl0W826KZ9Q+tr5ycPtXkTV4k65bRjmOUUP8cvGozZ33TWg5HZplvhhXbhDGzqmQDTd6OAevLeAnq3Ra9uf7zvY2zzsIhlcp/Y7m53TZgf2aB4JOg4gkr2biojiF1u9eOpfTTsn+8i5dpKbA== X-Mailru-Sender: B5B6A6EBBD94DAD8C47A93838048902D54E8C5C2A129CF3782C42B72F00A3798307A24EBD9CB6D631EC9E4A2C82A33BC8C24925A86E657CE0C70AEE3C9A96FBAB3D7EE8ED63280BE112434F685709FCF0DA7A0AF5A3A8387 X-Mras: Ok Subject: [Tarantool-patches] [PATCH resend v2 09/11] lua, datetime: time intervals support X-BeenThere: tarantool-patches@dev.tarantool.org X-Mailman-Version: 2.1.34 Precedence: list List-Id: Tarantool development patches List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , From: Timur Safin via Tarantool-patches Reply-To: Timur Safin Cc: tarantool-patches@dev.tarantool.org Errors-To: tarantool-patches-bounces@dev.tarantool.org Sender: "Tarantool-patches" * created few entry points (months(N), years(N), days(N), etc.) for easier datetime arithmetic; * additions/subtractions of years/months use `dt_add_years()` and `dt_add_months()` from 3rd party c-dt library; * also there are `:add{}` and `:sub{}` methods in datetime object to add or substract more complex intervals; * introduced `is_datetime()` and `is_interval()` helpers for checking of validity of passed arguments; * human-readable stringization implemented for interval objects. Note, that additions/subtractions completed for all _reasonable_ combinations of values of date and interval types; Time + Interval => Time Interval + Time => Time Time - Time => Interval Time - Interval => Time Interval + Interval => Interval Interval - Interval => Interval Part of #5941 --- src/exports.h | 3 + src/lua/datetime.lua | 556 +++++++++++++++++++++++++++------ test/app-tap/datetime.test.lua | 163 +++++++++- 3 files changed, 631 insertions(+), 91 deletions(-) diff --git a/src/exports.h b/src/exports.h index 3a1e8854c..6e7fe206d 100644 --- a/src/exports.h +++ b/src/exports.h @@ -535,6 +535,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 7a208cef9..1466b923f 100644 --- a/src/lua/datetime.lua +++ b/src/lua/datetime.lua @@ -37,6 +37,17 @@ ffi.cdef [[ int dt_rdn (dt_t dt); dt_dow_t dt_dow (dt_t dt); + // dt_arithmetic.h + typedef enum { + DT_EXCESS, + DT_LIMIT, + DT_SNAP + } dt_adjust_t; + + dt_t dt_add_years (dt_t dt, int delta, dt_adjust_t adjust); + dt_t dt_add_quarters (dt_t dt, int delta, dt_adjust_t adjust); + dt_t dt_add_months (dt_t dt, int delta, dt_adjust_t adjust); + // dt_parse_iso.h size_t dt_parse_iso_date (const char *str, size_t len, dt_t *dt); @@ -158,58 +169,146 @@ local DT_EPOCH_1970_OFFSET = 719163LL local datetime_t = ffi.typeof('struct datetime_t') local interval_t = ffi.typeof('struct datetime_interval_t') +ffi.cdef [[ + struct t_interval_months { + int m; + }; + + struct t_interval_years { + int y; + }; +]] +local interval_months_t = ffi.typeof('struct t_interval_months') +local interval_years_t = ffi.typeof('struct t_interval_years') + +local function is_interval(o) + return ffi.istype(interval_t, o) or + ffi.istype(interval_months_t, o) or + ffi.istype(interval_years_t, o) +end + +local function is_datetime(o) + return ffi.istype(o, datetime_t) +end + local function interval_new() local interval = ffi.new(interval_t) return interval end -local function adjusted_secs(dt) - return dt.secs - dt.offset * 60 +local function check_number(n, message, lvl) + if lvl == nil then + lvl = 2 + end + if type(n) ~= 'number' then + return error(('Usage: %s'):format(message), lvl) + end end -local function datetime_sub(lhs, rhs) - local s1 = adjusted_secs(lhs) - local s2 = adjusted_secs(rhs) - local d = interval_new() - d.secs = s2 - s1 - d.nsec = rhs.nsec - lhs.nsec - if d.nsec < 0 then - d.secs = d.secs - 1 - d.nsec = d.nsec + NANOS_PER_SEC +local function check_date(o, message, lvl) + if lvl == nil then + lvl = 2 + end + if not is_datetime(o) then + return error(('Usage: %s'):format(message), lvl) end - return d end -local function datetime_add(lhs, rhs) - local s1 = adjusted_secs(lhs) - local s2 = adjusted_secs(rhs) - local d = interval_new() - d.secs = s2 + s1 - d.nsec = rhs.nsec + lhs.nsec - if d.nsec >= NANOS_PER_SEC then - d.secs = d.secs + 1 - d.nsec = d.nsec - NANOS_PER_SEC +local function check_date_interval(o, message, lvl) + if lvl == nil then + lvl = 2 + end + if not (is_datetime(o) or is_interval(o)) then + return error(('Usage: %s'):format(message), lvl) end - return d 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 - if rhs == nil then - return false +local function check_interval(o, message, lvl) + if lvl == nil then + lvl = 2 + end + if not is_interval(o) then + return error(('Usage: %s'):format(message), lvl) end +end + +local function check_str(o, message, lvl) + if lvl == nil then + lvl = 2 + end + if not type(o) == 'string' then + return error(('Usage: %s'):format(message), lvl) + end +end + +local function interval_years_new(y) + check_number(y, "years(number)") + local o = ffi.new(interval_years_t) + o.y = y + return o +end + +local function interval_months_new(m) + check_number(m, "months(number)") + local o = ffi.new(interval_months_t) + o.m = m + return o +end + +local function interval_weeks_new(w) + check_number(w, "weeks(number)") + local o = ffi.new(interval_t) + o.secs = w * SECS_PER_DAY * 7 + return o +end + +local function interval_days_new(d) + check_number(d, "days(number)") + local o = ffi.new(interval_t) + o.secs = d * SECS_PER_DAY + return o +end + +local function interval_hours_new(h) + check_number(h, "hours(number)") + local o = ffi.new(interval_t) + o.secs = h * 60 * 60 + return o +end + +local function interval_minutes_new(m) + check_number(m, "minutes(number)") + local o = ffi.new(interval_t) + o.secs = m * 60 + return o +end + +local function interval_seconds_new(s) + check_number(s, "seconds(number)") + local o = ffi.new(interval_t) + o.nsec = s % 1 * 1e9 + o.secs = s - (s % 1) + return o +end + +local function datetime_eq(lhs, rhs) + check_date_interval(lhs, "datetime:__eq(date or interval)") + check_date_interval(rhs, "datetime:__eq(date or interval)") return (lhs.secs == rhs.secs) and (lhs.nsec == rhs.nsec) end local function datetime_lt(lhs, rhs) + check_date_interval(lhs, "datetime:__lt(date or interval)") + check_date_interval(rhs, "datetime:__lt(date or interval)") return (lhs.secs < rhs.secs) or (lhs.secs == rhs.secs and lhs.nsec < rhs.nsec) end local function datetime_le(lhs, rhs) + check_date_interval(lhs, "datetime:__le(date or interval)") + check_date_interval(rhs, "datetime:__le(date or interval)") return (lhs.secs <= rhs.secs) or (lhs.secs == rhs.secs and lhs.nsec <= rhs.nsec) end @@ -224,19 +323,123 @@ local function interval_serialize(self) return { secs = self.secs, nsec = self.nsec } end +local function local_rd(o) + return math.floor(tonumber(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 _normalize_nsec(secs, nsec) + if nsec < 0 then + secs = secs - 1 + nsec = nsec + NANOS_PER_SEC + elseif nsec >= NANOS_PER_SEC then + secs = secs + 1 + nsec = nsec - NANOS_PER_SEC + end + return secs, nsec +end + +-- addition or subtraction from date/time of a given interval +-- described via table direction should be +1 or -1 +local function interval_increment(self, o, direction) + assert(direction == -1 or direction == 1) + check_date(self, "interval_increment(date, object, -+1)") + 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 + + secs, nsec = _normalize_nsec(secs, nsec) + + -- .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 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 @@ -250,32 +453,20 @@ local datetime_index = function(self, key) days = function(self) return (tonumber(self.secs) + self.nsec / 1e9) / (60 * 60) / 24 end, + add = function(self) + return function(self, o) + return interval_increment(self, o, 1) + end + end, + sub = function(self) + return function(self, o) + return interval_increment(self, o, -1) + end + end, } return attributes[key] ~= nil and attributes[key](self) or nil end -local datetime_mt = { - -- __tostring = datetime_tostring, - __serialize = datetime_serialize, - __eq = datetime_eq, - __lt = datetime_lt, - __le = datetime_le, - __sub = datetime_sub, - __add = datetime_add, - __index = datetime_index, -} - -local interval_mt = { - -- __tostring = interval_tostring, - __serialize = interval_serialize, - __eq = datetime_eq, - __lt = datetime_lt, - __le = datetime_le, - __sub = datetime_sub, - __add = datetime_add, - __index = datetime_index, -} - local function datetime_new_raw(secs, nsec, offset) local dt_obj = ffi.new(datetime_t) dt_obj.secs = secs @@ -284,14 +475,6 @@ local function datetime_new_raw(secs, nsec, offset) return dt_obj end -local function local_rd(o) - return math.floor(tonumber(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 +550,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, @@ -402,6 +586,153 @@ local function datetime_new(o) return mk_timestamp(dt, secs, frac, offset) end +local function datetime_tostring(o) + 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) == interval_years_t then + return ('%+d years'):format(o.y) + elseif ffi.typeof(o) == interval_months_t then + return ('%+d months'):format(o.m) + elseif ffi.typeof(o) == interval_t then + local ts = o.timestamp + local sign = '+' + + if ts < 0 then + ts = -ts + sign = '-' + end + + 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 date_first(lhs, rhs) + if is_datetime(lhs) then + return lhs, rhs + else + return rhs, lhs + end +end + +--[[ +Matrix of subtraction operands eligibility and their result type + +| | datetime | interval | interval_months | interval_years | ++-----------------+-----------+----------+-----------------+----------------+ +| datetime | interval | datetime | datetime | datetime | +| interval | | interval | | | +| interval_months | | | interval_months | | +| interval_years | | | | interval_years | +]] +local function datetime_sub(lhs, rhs) + check_date_interval(lhs, "datetime:__sub(date or interval)") + local d, s = lhs, rhs + local left_t = ffi.typeof(d) + local right_t = ffi.typeof(s) + local o + + if left_t == datetime_t then + -- 1. left is date, right is date or generic interval + if (right_t == datetime_t or right_t == interval_t) then + o = right_t == datetime_t and interval_new() or datetime_new() + o.secs, o.nsec = _normalize_nsec(lhs.secs - rhs.secs, + lhs.nsec - rhs.nsec) + return o + -- 2. left is date, right is interval in months + elseif right_t == interval_months_t then + local dt = cdt.dt_add_months(local_dt(lhs), -rhs.m, cdt.DT_LIMIT) + return mk_timestamp(dt, lhs.secs % SECS_PER_DAY, + lhs.nsec, lhs.offset) + + -- 3. left is date, right is interval in years + elseif right_t == interval_years_t then + local dt = cdt.dt_add_years(local_dt(lhs), -rhs.y, cdt.DT_LIMIT) + return mk_timestamp(dt, lhs.secs % SECS_PER_DAY, + lhs.nsec, lhs.offset) + else + error("datetime:__sub(date or interval) - incompatible type of arguments", 2) + end + -- 4. both left and right are generic intervals + elseif left_t == interval_t and right_t == interval_t then + o = interval_new() + o.secs, o.nsec = _normalize_nsec(lhs.secs - rhs.secs, + lhs.nsec - rhs.nsec) + return o + -- 5. both left and right are intervals in months + elseif left_t == interval_months_t and right_t == interval_months_t then + return interval_months_new(lhs.m - rhs.m) + -- 5. both left and right are intervals in years + elseif left_t == interval_years_t and right_t == interval_years_t then + return interval_years_new(lhs.y - rhs.y) + else + error("datetime:__sub(date or interval) - incompatible type of arguments", 2) + end +end + +--[[ +Matrix of addition operands eligibility and their result type + +| | datetime | interval | interval_months | interval_years | ++-----------------+-----------+----------+-----------------+----------------+ +| datetime | datetime | datetime | datetime | datetime | +| interval | datetime | interval | | | +| interval_months | datetime | | interval_months | | +| interval_years | datetime | | | interval_years | +]] +local function datetime_add(lhs, rhs) + local d, s = date_first(lhs, rhs) + + check_date_interval(d, "datetime:__add(interval)") + check_interval(s, "datetime:__add(interval)") + local left_t = ffi.typeof(d) + local right_t = ffi.typeof(s) + local o + + -- 1. left is date, right is date or interval + if left_t == datetime_t and right_t == interval_t then + o = datetime_new() + o.secs, o.nsec = _normalize_nsec(d.secs + s.secs, d.nsec + s.nsec) + return o + -- 2. left is date, right is interval in months + elseif left_t == datetime_t and right_t == interval_months_t then + 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) + + -- 3. left is date, right is interval in years + elseif left_t == datetime_t and right_t == interval_years_t then + 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) + -- 4. both left and right are generic intervals + elseif left_t == interval_t and right_t == interval_t then + o = interval_new() + o.secs, o.nsec = _normalize_nsec(d.secs + s.secs, d.nsec + s.nsec) + return o + -- 5. both left and right are intervals in months + elseif left_t == interval_months_t and right_t == interval_months_t then + return interval_months_new(d.m + s.m) + -- 6. both left and right are intervals in years + elseif left_t == interval_years_t and right_t == interval_years_t then + return interval_years_new(d.y + s.y) + else + error("datetime:__add(date or interval) - incompatible type of arguments", 2) + end +end -- simple parse functions: -- parse_date/parse_time/parse_zone @@ -415,6 +746,7 @@ end ]] local function parse_date(str) + check_str("datetime.parse_date(string)") local dt = ffi.new('dt_t[1]') local len = cdt.dt_parse_iso_date(str, #str, dt) return len > 0 and mk_timestamp(dt[0]) or nil, tonumber(len) @@ -431,6 +763,7 @@ end The time designator [T] may be omitted. ]] local function parse_time(str) + check_str("datetime.parse_time(string)") local sp = ffi.new('int[1]') local fp = ffi.new('int[1]') local len = cdt.dt_parse_iso_time(str, #str, sp, fp) @@ -444,6 +777,7 @@ end ±hhmm ±hh:mm ]] local function parse_zone(str) + check_str("datetime.parse_zone(string)") local offset = ffi.new('int[1]') local len = cdt.dt_parse_iso_zone_lenient(str, #str, offset) return len > 0 and mk_timestamp(nil, nil, nil, offset[0]) or nil, tonumber(len) @@ -457,7 +791,8 @@ end date [T] time [ ] time_zone ]] -local function parse_str(str) +local function parse(str) + check_str("datetime.parse(string)") local dt = ffi.new('dt_t[1]') local len = #str local n = cdt.dt_parse_iso_date(str, len, dt) @@ -508,7 +843,7 @@ local function datetime_from(o) if o == nil or type(o) == 'table' then return datetime_new(o) elseif type(o) == 'string' then - return parse_str(o) + return parse(o) end end @@ -531,8 +866,8 @@ local function local_now() end local function datetime_to_tm_ptr(o) + assert(is_datetime(o)) local p_tm = ffi.new 'struct tm[1]' - assert(ffi.typeof(o) == datetime_t) -- dt_to_struct_tm() fills only date data cdt.dt_to_struct_tm(local_dt(o), p_tm) @@ -551,20 +886,21 @@ local function datetime_to_tm_ptr(o) end local function asctime(o) - assert(ffi.typeof(o) == datetime_t) + check_date(o, "datetime:asctime()") + 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, "datetime:ctime()") 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, "datetime.strftime(fmt, date)") local sz = 50 local buff = ffi.new('char[?]', sz) local p_tm = datetime_to_tm_ptr(o) @@ -572,36 +908,76 @@ local function strftime(fmt, o) return ffi.string(buff) end -local function datetime_tostring(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 +local datetime_mt = { + __tostring = datetime_tostring, + __serialize = datetime_serialize, + __eq = datetime_eq, + __lt = datetime_lt, + __le = datetime_le, + __sub = datetime_sub, + __add = datetime_add, + __index = datetime_index, + add = function(self, o) + self = interval_increment(self, o, 1) + return self + end, + sub = function(self, o) + self = interval_increment(self, o, -1) + return self + end +} + +local interval_mt = { + __tostring = datetime_tostring, + __serialize = interval_serialize, + __eq = datetime_eq, + __lt = datetime_lt, + __le = datetime_le, + __sub = datetime_sub, + __add = datetime_add, + __index = datetime_index, +} + +local interval_tiny_mt = { + __tostring = datetime_tostring, + __serialize = interval_serialize, + __sub = datetime_sub, + __add = datetime_add, + __index = datetime_index, +} ffi.metatype(interval_t, interval_mt) ffi.metatype(datetime_t, datetime_mt) +ffi.metatype(interval_years_t, interval_tiny_mt) +ffi.metatype(interval_months_t, interval_tiny_mt) return setmetatable( { - datetime = datetime_new, - interval = interval_new, - - parse = parse_str, - parse_date = parse_date, - parse_time = parse_time, - parse_zone = parse_zone, - - tostring = datetime_tostring, - - now = local_now, - -- strptime = strptime; - strftime = strftime, - asctime = asctime, - ctime = ctime, + new = datetime_new, + years = interval_years_new, + months = interval_months_new, + weeks = interval_weeks_new, + days = interval_days_new, + hours = interval_hours_new, + minutes = interval_minutes_new, + seconds = interval_seconds_new, + interval = interval_new, + + parse = parse, + parse_date = parse_date, + parse_time = parse_time, + parse_zone = parse_zone, + + tostring = datetime_tostring, + + 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/test/app-tap/datetime.test.lua b/test/app-tap/datetime.test.lua index 407d89556..21d13b3a6 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(10) test:test("Simple tests for parser", function(test) test:plan(2) @@ -203,4 +203,165 @@ test:test("Parse tiny date into seconds and other parts", function(test) test:ok(tiny.hours == 0.00848, "hours") end) +test:test("Stringization of dates and intervals", 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 interval 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) + +local function catchadd(A, B) + return pcall(function() return A + B end) +end + +--[[ +Matrix of addition operands eligibility and their result type + +| | datetime | interval | interval_months | interval_years | ++-----------------+-----------+----------+-----------------+----------------+ +| datetime | datetime | datetime | datetime | datetime | +| interval | datetime | interval | | | +| interval_months | datetime | | interval_months | | +| interval_years | datetime | | | interval_years | +]] + +test:test("Matrix of allowed time and interval additions", function(test) + test:plan(20) + + -- check arithmetic with leap dates + local T1970 = date('1970-01-01') + local T2000 = date('2000-01-01') + local I1 = date.days(1) + local M2 = date.months(2) + local M10 = date.months(10) + local Y1 = date.years(1) + local Y5 = date.years(5) + + test:ok(catchadd(T1970, I1) == true, "status: T + I") + test:ok(catchadd(T1970, M2) == true, "status: T + M") + test:ok(catchadd(T1970, Y1) == true, "status: T + Y") + test:ok(catchadd(T1970, T2000) == false, "status: T + T") + test:ok(catchadd(I1, T1970) == true, "status: I + T") + test:ok(catchadd(M2, T1970) == true, "status: M + T") + test:ok(catchadd(Y1, T1970) == true, "status: Y + T") + test:ok(catchadd(I1, Y1) == false, "status: I + Y") + test:ok(catchadd(M2, Y1) == false, "status: M + Y") + test:ok(catchadd(I1, Y1) == false, "status: I + Y") + test:ok(catchadd(Y5, M10) == false, "status: Y + M") + test:ok(catchadd(Y5, I1) == false, "status: Y + I") + test:ok(catchadd(Y5, Y1) == true, "status: Y + Y") + + test:ok(tostring(T1970 + I1) == "1970-01-02T00:00Z", "value: T + I") + test:ok(tostring(T1970 + M2) == "1970-03-01T00:00Z", "value: T + M") + test:ok(tostring(T1970 + Y1) == "1971-01-01T00:00Z", "value: T + Y") + test:ok(tostring(I1 + T1970) == "1970-01-02T00:00Z", "value: I + T") + test:ok(tostring(M2 + T1970) == "1970-03-01T00:00Z", "value: M + T") + test:ok(tostring(Y1 + T1970) == "1971-01-01T00:00Z", "value: Y + T") + test:ok(tostring(Y5 + Y1) == "+6 years", "Y + Y") + +end) + +local function catchsub_status(A, B) + return pcall(function() return A - B end) +end + +--[[ +Matrix of subtraction operands eligibility and their result type + +| | datetime | interval | interval_months | interval_years | ++-----------------+-----------+----------+-----------------+----------------+ +| datetime | interval | datetime | datetime | datetime | +| interval | | interval | | | +| interval_months | | | interval_months | | +| interval_years | | | | interval_years | +]] +test:test("Matrix of allowed time and interval subtractions", function(test) + test:plan(18) + + -- check arithmetic with leap dates + local T1970 = date('1970-01-01') + local T2000 = date('2000-01-01') + local I1 = date.days(1) + local M2 = date.months(2) + local M10 = date.months(10) + local Y1 = date.years(1) + local Y5 = date.years(5) + + test:ok(catchsub_status(T1970, I1) == true, "status: T - I") + test:ok(catchsub_status(T1970, M2) == true, "status: T - M") + test:ok(catchsub_status(T1970, Y1) == true, "status: T - Y") + test:ok(catchsub_status(T1970, T2000) == true, "status: T - T") + test:ok(catchsub_status(I1, T1970) == false, "status: I + T") + test:ok(catchsub_status(M2, T1970) == false, "status: M + T") + test:ok(catchsub_status(Y1, T1970) == false, "status: Y + T") + test:ok(catchsub_status(I1, Y1) == false, "status: I - Y") + test:ok(catchsub_status(M2, Y1) == false, "status: M - Y") + test:ok(catchsub_status(I1, Y1) == false, "status: I - Y") + test:ok(catchsub_status(Y5, M10) == false, "status: Y - M") + test:ok(catchsub_status(Y5, I1) == false, "status: Y - I") + test:ok(catchsub_status(Y5, Y1) == true, "status: Y - Y") + + test:ok(tostring(T1970 - I1) == "1969-12-31T00:00Z", "value: T - I") + test:ok(tostring(T1970 - M2) == "1969-11-01T00:00Z", "value: T - M") + test:ok(tostring(T1970 - Y1) == "1969-01-01T00:00Z", "value: T - Y") + test:ok(tostring(T1970 - T2000) == "-10957 days, 0 hours, 0 minutes, 0 seconds", + "value: T - T") + test:ok(tostring(Y5 - Y1) == "+4 years", "value: Y - Y") + + +end) + os.exit(test:check() and 0 or 1) -- 2.29.2