From 172886818c0f614ba52e72e13359ca8ea7f3682a Mon Sep 17 00:00:00 2001 From: Georgy Moiseev Date: Thu, 8 Sep 2022 16:45:33 +0300 Subject: [PATCH] msgpack: support datetime interval arithmetic Support datetime and interval arithmetic with the same rules as in Tarantool [1]. Valid operations: - tarantool.Datetime + tarantool.Interval = tarantool.Datetime - tarantool.Datetime - tarantool.Datetime = tarantool.Interval - tarantool.Interval + tarantool.Interval = tarantool.Interval - tarantool.Interval - tarantool.Interval = tarantool.Interval 1. https://github.com/tarantool/tarantool/wiki/Datetime-Internals#interval-arithmetic Closes #229 --- CHANGELOG.md | 1 + tarantool/msgpack_ext/types/datetime.py | 56 +++++ tarantool/msgpack_ext/types/interval.py | 64 ++++++ test/suites/__init__.py | 4 +- test/suites/test_datetime_arithmetic.py | 278 ++++++++++++++++++++++++ 5 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 test/suites/test_datetime_arithmetic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c3484d3..bde637ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 is raised in this case. - Datetime interval type support and tarantool.Interval type (#229). +- Datetime interval arithmetic support (#229). ### Changed - Bump msgpack requirement to 1.0.4 (PR #223). diff --git a/tarantool/msgpack_ext/types/datetime.py b/tarantool/msgpack_ext/types/datetime.py index 8f86364e..da9b2e34 100644 --- a/tarantool/msgpack_ext/types/datetime.py +++ b/tarantool/msgpack_ext/types/datetime.py @@ -6,6 +6,8 @@ import tarantool.msgpack_ext.types.timezones as tt_timezones from tarantool.error import MsgpackError, MsgpackWarning, warn +from tarantool.msgpack_ext.types.interval import Interval, Adjust + # https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-datetime-type # # The datetime MessagePack representation looks like this: @@ -45,8 +47,10 @@ BYTEORDER = 'little' NSEC_IN_SEC = 1000000000 +NSEC_IN_MKSEC = 1000 SEC_IN_MIN = 60 MIN_IN_DAY = 60 * 24 +MONTH_IN_YEAR = 12 def get_bytes_as_int(data, cursor, size): @@ -209,6 +213,58 @@ def __init__(self, *args, tarantool_tzindex=None, **kwargs): self._tzoffset = tzoffset self._tzindex = tzindex + def __add__(self, other): + if not isinstance(other, Interval): + raise TypeError(f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'") + + self_ts = self._timestamp + + # https://github.com/tarantool/tarantool/wiki/Datetime-Internals#date-adjustions-and-leap-years + months = other.year * MONTH_IN_YEAR + other.month + + res = self_ts + pandas.DateOffset(months = months) + + # pandas.DateOffset works like Adjust.Limit + if other.adjust == Adjust.Excess: + if self_ts.day > res.day: + res = res + pandas.DateOffset(days = self_ts.day - res.day) + elif other.adjust == Adjust.Last: + if self_ts.is_month_end: + # day replaces days + res = res.replace(day = res.days_in_month) + + res = res + pandas.Timedelta(weeks = other.week, + days = other.day, + hours = other.hour, + minutes = other.minute, + seconds = other.second, + nanoseconds = other.nanosecond) + + if self._tzindex != 0: + return Datetime(res, tarantool_tzindex=self._tzindex) + else: + return Datetime(res) + + def __sub__(self, other): + if not isinstance(other, Datetime): + raise TypeError(f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'") + + self_ts = self._timestamp + other_ts = other._timestamp + + self_nsec = self_ts.microsecond * NSEC_IN_MKSEC + self_ts.nanosecond + other_nsec = other_ts.microsecond * NSEC_IN_MKSEC + other_ts.nanosecond + + return Interval( + year = self_ts.year - other_ts.year, + month = self_ts.month - other_ts.month, + day = self_ts.day - other_ts.day, + hour = self_ts.hour - other_ts.hour, + minute = self_ts.minute - other_ts.minute, + second = self_ts.second - other_ts.second, + nanosecond = self_nsec - other_nsec, + ) + def __eq__(self, other): if isinstance(other, Datetime): return self._timestamp == other._timestamp diff --git a/tarantool/msgpack_ext/types/interval.py b/tarantool/msgpack_ext/types/interval.py index c2c8fe7c..1a932cd7 100644 --- a/tarantool/msgpack_ext/types/interval.py +++ b/tarantool/msgpack_ext/types/interval.py @@ -102,6 +102,70 @@ def __init__(self, data=None, *, year=0, month=0, week=0, self.nanosecond = nanosecond self.adjust = adjust + def __add__(self, other): + if not isinstance(other, Interval): + raise TypeError(f"unsupported operand type(s) for +: '{type(self)}' and '{type(other)}'") + + # Tarantool saves adjust of the first argument + # + # Tarantool 2.10.1-0-g482d91c66 + # + # tarantool> dt1 = datetime.interval.new{year = 2, adjust='last'} + # --- + # ... + # + # tarantool> dt2 = datetime.interval.new{year = 1, adjust='excess'} + # --- + # ... + # + # tarantool> (dt1 + dt2).adjust + # --- + # - 'cdata: 2' + # ... + + return Interval( + year = self.year + other.year, + month = self.month + other.month, + day = self.day + other.day, + hour = self.hour + other.hour, + minute = self.minute + other.minute, + second = self.second + other.second, + nanosecond = self.nanosecond + other.nanosecond, + adjust = self.adjust, + ) + + def __sub__(self, other): + if not isinstance(other, Interval): + raise TypeError(f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'") + + # Tarantool saves adjust of the first argument + # + # Tarantool 2.10.1-0-g482d91c66 + # + # tarantool> dt1 = datetime.interval.new{year = 2, adjust='last'} + # --- + # ... + # + # tarantool> dt2 = datetime.interval.new{year = 1, adjust='excess'} + # --- + # ... + # + # tarantool> (dt1 - dt2).adjust + # --- + # - 'cdata: 2' + # ... + + return Interval( + year = self.year - other.year, + month = self.month - other.month, + day = self.day - other.day, + hour = self.hour - other.hour, + minute = self.minute - other.minute, + second = self.second - other.second, + nanosecond = self.nanosecond - other.nanosecond, + adjust = self.adjust, + ) + def __eq__(self, other): if not isinstance(other, Interval): return False diff --git a/test/suites/__init__.py b/test/suites/__init__.py index 7096cad9..903826fa 100644 --- a/test/suites/__init__.py +++ b/test/suites/__init__.py @@ -19,6 +19,7 @@ from .test_uuid import TestSuite_UUID from .test_datetime import TestSuite_Datetime from .test_interval import TestSuite_Interval +from .test_datetime_arithmetic import TestSuite_DatetimeArithmetic test_cases = (TestSuite_Schema_UnicodeConnection, TestSuite_Schema_BinaryConnection, @@ -26,7 +27,8 @@ TestSuite_Mesh, TestSuite_Execute, TestSuite_DBAPI, TestSuite_Encoding, TestSuite_Pool, TestSuite_Ssl, TestSuite_Decimal, TestSuite_UUID, TestSuite_Datetime, - TestSuite_Interval) + TestSuite_Interval, TestSuite_DatetimeArithmetic) + def load_tests(loader, tests, pattern): suite = unittest.TestSuite() diff --git a/test/suites/test_datetime_arithmetic.py b/test/suites/test_datetime_arithmetic.py new file mode 100644 index 00000000..0c862b75 --- /dev/null +++ b/test/suites/test_datetime_arithmetic.py @@ -0,0 +1,278 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function + +import sys +import unittest +import msgpack +import warnings +import tarantool +import pandas +import pytz + +from tarantool.msgpack_ext.packer import default as packer_default +from tarantool.msgpack_ext.unpacker import ext_hook as unpacker_ext_hook + +from .lib.tarantool_server import TarantoolServer +from .lib.skip import skip_or_run_datetime_test +from tarantool.error import MsgpackError + +class TestSuite_DatetimeArithmetic(unittest.TestCase): + @classmethod + def setUpClass(self): + print(' DATETIME ARITHMETIC TYPE '.center(70, '='), file=sys.stderr) + print('-' * 70, file=sys.stderr) + self.srv = TarantoolServer() + self.srv.script = 'test/suites/box.lua' + self.srv.start() + + self.adm = self.srv.admin + self.adm(r""" + _, datetime = pcall(require, 'datetime') + + box.schema.space.create('test') + box.space['test']:create_index('primary', { + type = 'tree', + parts = {1, 'string'}, + unique = true}) + + box.schema.user.create('test', {password = 'test', if_not_exists = true}) + box.schema.user.grant('test', 'read,write,execute', 'universe') + + local function add(arg1, arg2) + return arg1 + arg2 + end + rawset(_G, 'add', add) + + local function sub(arg1, arg2) + return arg1 - arg2 + end + rawset(_G, 'sub', sub) + """) + + self.con = tarantool.Connection(self.srv.host, self.srv.args['primary'], + user='test', password='test') + + def setUp(self): + # prevent a remote tarantool from clean our session + if self.srv.is_started(): + self.srv.touch_lock() + + self.adm("box.space['test']:truncate()") + + + interval_cases = { + 'year': { + 'arg_1': tarantool.Interval(year=2), + 'arg_2': tarantool.Interval(year=1), + 'res_add': tarantool.Interval(year=3), + 'res_sub': tarantool.Interval(year=1), + }, + 'date': { + 'arg_1': tarantool.Interval(year=1, month=2, day=3), + 'arg_2': tarantool.Interval(year=3, month=2, day=1), + 'res_add': tarantool.Interval(year=4, month=4, day=4), + 'res_sub': tarantool.Interval(year=-2, month=0, day=2), + }, + 'time': { + 'arg_1': tarantool.Interval(hour=10, minute=20, second=30), + 'arg_2': tarantool.Interval(hour=2, minute=15, second=50), + 'res_add': tarantool.Interval(hour=12, minute=35, second=80), + 'res_sub': tarantool.Interval(hour=8, minute=5, second=-20), + }, + 'datetime': { + 'arg_1': tarantool.Interval(year=1, month=2, day=3, hour=1, minute=2, second=3000), + 'arg_2': tarantool.Interval(year=2, month=1, day=31, hour=-3, minute=0, second=-2000), + 'res_add': tarantool.Interval(year=3, month=3, day=34, hour=-2, minute=2, second=1000), + 'res_sub': tarantool.Interval(year=-1, month=1, day=-28, hour=4, minute=2, second=5000), + }, + 'datetime_with_nanoseconds': { + 'arg_1': tarantool.Interval(year=1, month=2, day=3, hour=1, minute=2, + second=3000, nanosecond=10000000), + 'arg_2': tarantool.Interval(year=2, month=1, day=31, hour=-3, minute=0, + second=1000, nanosecond=9876543), + 'res_add': tarantool.Interval(year=3, month=3, day=34, hour=-2, minute=2, + second=4000, nanosecond=19876543), + 'res_sub': tarantool.Interval(year=-1, month=1, day=-28, hour=4, minute=2, + second=2000, nanosecond=123457), + }, + 'heterogenous': { + 'arg_1': tarantool.Interval(year=1, month=2, day=3), + 'arg_2': tarantool.Interval(second=3000, nanosecond=9876543), + 'res_add': tarantool.Interval(year=1, month=2, day=3, + second=3000, nanosecond=9876543), + 'res_sub': tarantool.Interval(year=1, month=2, day=3, + second=-3000, nanosecond=-9876543), + }, + 'same_adjust': { + 'arg_1': tarantool.Interval(year=2, adjust=tarantool.IntervalAdjust.Last), + 'arg_2': tarantool.Interval(year=1, adjust=tarantool.IntervalAdjust.Last), + 'res_add': tarantool.Interval(year=3, adjust=tarantool.IntervalAdjust.Last), + 'res_sub': tarantool.Interval(year=1, adjust=tarantool.IntervalAdjust.Last), + }, + 'different_adjust': { + 'arg_1': tarantool.Interval(year=2, adjust=tarantool.IntervalAdjust.Last), + 'arg_2': tarantool.Interval(year=1, adjust=tarantool.IntervalAdjust.Excess), + 'res_add': tarantool.Interval(year=3, adjust=tarantool.IntervalAdjust.Last), + 'res_sub': tarantool.Interval(year=1, adjust=tarantool.IntervalAdjust.Last), + }, + } + + def test_python_interval_add(self): + for name in self.interval_cases.keys(): + with self.subTest(msg=name): + case = self.interval_cases[name] + + self.assertEqual(case['arg_1'] + case['arg_2'], case['res_add']) + + def test_python_interval_sub(self): + for name in self.interval_cases.keys(): + with self.subTest(msg=name): + case = self.interval_cases[name] + + self.assertEqual(case['arg_1'] - case['arg_2'], case['res_sub']) + + @skip_or_run_datetime_test + def test_tarantool_interval_add(self): + for name in self.interval_cases.keys(): + with self.subTest(msg=name): + case = self.interval_cases[name] + + self.assertSequenceEqual(self.con.call('add', case['arg_1'], case['arg_2']), + [case['res_add']]) + + @skip_or_run_datetime_test + def test_tarantool_interval_sub(self): + for name in self.interval_cases.keys(): + with self.subTest(msg=name): + case = self.interval_cases[name] + + self.assertSequenceEqual(self.con.call('sub', case['arg_1'], case['arg_2']), + [case['res_sub']]) + + + datetime_sub_cases = { + 'date': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Datetime(year=2010, month=2, day=1), + 'res': tarantool.Interval(year=-2, month=0, day=2), + }, + 'datetime': { + 'arg_1': tarantool.Datetime(year=2001, month=2, day=3, hour=1, minute=2, second=30), + 'arg_2': tarantool.Datetime(year=2002, month=1, day=31, hour=3, minute=0, second=20), + 'res': tarantool.Interval(year=-1, month=1, day=-28, hour=-2, minute=2, second=10), + }, + 'datetime_with_nanoseconds': { + 'arg_1': tarantool.Datetime(year=2001, month=2, day=3, hour=1, minute=2, + second=30, nanosecond=10000000), + 'arg_2': tarantool.Datetime(year=2002, month=1, day=31, hour=3, minute=0, + second=10, nanosecond=9876543), + 'res': tarantool.Interval(year=-1, month=1, day=-28, hour=-2, minute=2, + second=20, nanosecond=123457), + }, + 'heterogenous': { + 'arg_1': tarantool.Datetime(year=2001, month=2, day=3, hour=1, minute=2), + 'arg_2': tarantool.Datetime(year=2001, month=2, day=3, second=30, + microsecond=9876, nanosecond=543), + 'res': tarantool.Interval(hour=1, minute=2, second=-30, nanosecond=-9876543), + }, + } + + def test_python_datetime_sub(self): + for name in self.datetime_sub_cases.keys(): + with self.subTest(msg=name): + case = self.datetime_sub_cases[name] + + self.assertEqual(case['arg_1'] - case['arg_2'], case['res']) + + @skip_or_run_datetime_test + def test_tarantool_datetime_sub(self): + for name in self.datetime_sub_cases.keys(): + with self.subTest(msg=name): + case = self.datetime_sub_cases[name] + + self.assertSequenceEqual(self.con.call('sub', case['arg_1'], case['arg_2']), + [case['res']]) + + + datetime_add_cases = { + 'year': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Interval(year=1), + 'res': tarantool.Datetime(year=2009, month=2, day=3), + }, + 'date': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Interval(year=1, month=2, day=3), + 'res': tarantool.Datetime(year=2009, month=4, day=6), + }, + 'date_days_overflow': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Interval(year=1, month=2, day=30), + 'res': tarantool.Datetime(year=2009, month=5, day=3), + }, + 'time': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Interval(hour=1, minute=2, second=3), + 'res': tarantool.Datetime(year=2008, month=2, day=3, hour=1, minute=2, second=3), + }, + 'time_seconds_overflow': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Interval(hour=1, minute=2, second=13003), + 'res': tarantool.Datetime(year=2008, month=2, day=3, hour=4, minute=38, second=43), + }, + 'nanoseconds': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3, hour=3, minute=36, second=43), + 'arg_2': tarantool.Interval(nanosecond=10000023), + 'res': tarantool.Datetime(year=2008, month=2, day=3, hour=3, minute=36, second=43, + microsecond=10000, nanosecond=23), + }, + 'zero': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3, hour=3, minute=36, second=43), + 'arg_2': tarantool.Interval(), + 'res': tarantool.Datetime(year=2008, month=2, day=3, hour=3, minute=36, second=43), + }, + 'month_non_last_day_limit_adjust': { + 'arg_1': tarantool.Datetime(year=2009, month=1, day=30), + 'arg_2': tarantool.Interval(month=13, adjust=tarantool.IntervalAdjust.Limit), + 'res': tarantool.Datetime(year=2010, month=2, day=28), + }, + 'month_non_last_day_excess_adjust': { + 'arg_1': tarantool.Datetime(year=2009, month=1, day=30), + 'arg_2': tarantool.Interval(month=13, adjust=tarantool.IntervalAdjust.Excess), + 'res': tarantool.Datetime(year=2010, month=3, day=2), + }, + 'month_non_last_day_last_adjust': { + 'arg_1': tarantool.Datetime(year=2009, month=3, day=30), + 'arg_2': tarantool.Interval(month=2, adjust=tarantool.IntervalAdjust.Last), + 'res': tarantool.Datetime(year=2009, month=5, day=30), + }, + 'month_overflow_last_day_last_adjust': { + 'arg_1': tarantool.Datetime(year=2009, month=2, day=28), + 'arg_2': tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.Last), + 'res': tarantool.Datetime(year=2009, month=3, day=31), + }, + } + + def test_python_datetime_add(self): + for name in self.datetime_add_cases.keys(): + with self.subTest(msg=name): + case = self.datetime_add_cases[name] + + self.assertEqual(case['arg_1'] + case['arg_2'], case['res']) + + @skip_or_run_datetime_test + def test_tarantool_datetime_add(self): + for name in self.datetime_add_cases.keys(): + with self.subTest(msg=name): + case = self.datetime_add_cases[name] + + self.assertSequenceEqual(self.con.call('add', case['arg_1'], case['arg_2']), + [case['res']]) + + + @classmethod + def tearDownClass(self): + self.con.close() + self.srv.stop() + self.srv.clean()