Skip to content

Commit bd9aeba

Browse files
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` Since `tarantool.Interval` could contain `month` and `year` fields and such operations could be ambiguous, you can use `adjust` field to tune the logic. - `tarantool.IntervalAdjust.NONE` -- only truncation toward the end of month performed (default mode). ``` >>> dt = tarantool.Datetime(year=2022, month=3, day=31) datetime: Timestamp('2022-03-31 00:00:00'), tz: "" >>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.NONE) >>> dt + di datetime: Timestamp('2022-04-30 00:00:00'), tz: "" ``` - `tarantool.IntervalAdjust.EXCESS` -- overflow mode, without any snap or truncation to the end of month, straight addition of days in month, stopping over month boundaries if there is less number of days. ``` >>> dt = tarantool.Datetime(year=2022, month=1, day=31) datetime: Timestamp('2022-01-31 00:00:00'), tz: "" >>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.EXCESS) >>> dt + di datetime: Timestamp('2022-03-02 00:00:00'), tz: "" ``` - `tarantool.IntervalAdjust.LAST` -- mode when day snaps to the end of month, if happens. ``` >>> dt = tarantool.Datetime(year=2022, month=2, day=28) datetime: Timestamp('2022-02-28 00:00:00'), tz: "" >>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.LAST) >>> dt + di datetime: Timestamp('2022-03-31 00:00:00'), tz: "" ``` Tarantool does not yet correctly support subtraction of datetime objects with different timezones [2] and addition of intervals to datetimes with non-fixed offset timezones [3]. tarantool-python implementation support them, but it could be reworked later if core team change another solution. 1. https://github.com/tarantool/tarantool/wiki/Datetime-Internals#interval-arithmetic 2. tarantool/tarantool#7698 3. tarantool/tarantool#7700 Closes #229
1 parent c1176b4 commit bd9aeba

File tree

5 files changed

+505
-1
lines changed

5 files changed

+505
-1
lines changed

Diff for: CHANGELOG.md

+46
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8585
Its attributes (same as in init API) are exposed, so you can
8686
use them if needed.
8787

88+
- Datetime interval arithmetic support (#229).
89+
90+
Valid operations:
91+
- `tarantool.Datetime` + `tarantool.Interval` = `tarantool.Datetime`
92+
- `tarantool.Datetime` - `tarantool.Datetime` = `tarantool.Interval`
93+
- `tarantool.Interval` + `tarantool.Interval` = `tarantool.Interval`
94+
- `tarantool.Interval` - `tarantool.Interval` = `tarantool.Interval`
95+
96+
Since `tarantool.Interval` could contain `month` and `year` fields
97+
and such operations could be ambiguous, you can use `adjust` field
98+
to tune the logic.
99+
100+
- `tarantool.IntervalAdjust.NONE` -- only truncation toward the end of
101+
month performed (default mode).
102+
103+
```python
104+
>>> dt = tarantool.Datetime(year=2022, month=3, day=31)
105+
datetime: Timestamp('2022-03-31 00:00:00'), tz: ""
106+
>>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.NONE)
107+
>>> dt + di
108+
datetime: Timestamp('2022-04-30 00:00:00'), tz: ""
109+
```
110+
111+
- `tarantool.IntervalAdjust.EXCESS` -- overflow mode, without any snap
112+
or truncation to the end of month, straight addition of days in month,
113+
stopping over month boundaries if there is less number of days.
114+
115+
```python
116+
>>> dt = tarantool.Datetime(year=2022, month=1, day=31)
117+
datetime: Timestamp('2022-01-31 00:00:00'), tz: ""
118+
>>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.EXCESS)
119+
>>> dt + di
120+
datetime: Timestamp('2022-03-02 00:00:00'), tz: ""
121+
```
122+
123+
- `tarantool.IntervalAdjust.LAST` -- mode when day snaps to the end of month,
124+
if happens.
125+
126+
```python
127+
>>> dt = tarantool.Datetime(year=2022, month=2, day=28)
128+
datetime: Timestamp('2022-02-28 00:00:00'), tz: ""
129+
>>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.LAST)
130+
>>> dt + di
131+
datetime: Timestamp('2022-03-31 00:00:00'), tz: ""
132+
```
133+
88134
### Changed
89135
- Bump msgpack requirement to 1.0.4 (PR #223).
90136
The only reason of this bump is various vulnerability fixes,

Diff for: tarantool/msgpack_ext/types/datetime.py

+75
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import tarantool.msgpack_ext.types.timezones as tt_timezones
77
from tarantool.error import MsgpackError
88

9+
from tarantool.msgpack_ext.types.interval import Interval, Adjust
10+
911
# https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-datetime-type
1012
#
1113
# The datetime MessagePack representation looks like this:
@@ -47,6 +49,7 @@
4749
NSEC_IN_SEC = 1000000000
4850
NSEC_IN_MKSEC = 1000
4951
SEC_IN_MIN = 60
52+
MONTH_IN_YEAR = 12
5053

5154
def get_bytes_as_int(data, cursor, size):
5255
part = data[cursor:cursor + size]
@@ -168,6 +171,78 @@ def __init__(self, data=None, *, timestamp=None, year=None, month=None,
168171
self._datetime = datetime
169172
self._tz = ''
170173

174+
def __add__(self, other):
175+
if not isinstance(other, Interval):
176+
raise TypeError(f"unsupported operand type(s) for +: '{type(self)}' and '{type(other)}'")
177+
178+
self_dt = self._datetime
179+
180+
# https://github.com/tarantool/tarantool/wiki/Datetime-Internals#date-adjustions-and-leap-years
181+
months = other.year * MONTH_IN_YEAR + other.month
182+
183+
res = self_dt + pandas.DateOffset(months = months)
184+
185+
# pandas.DateOffset works exactly like Adjust.NONE
186+
if other.adjust == Adjust.EXCESS:
187+
if self_dt.day > res.day:
188+
res = res + pandas.DateOffset(days = self_dt.day - res.day)
189+
elif other.adjust == Adjust.LAST:
190+
if self_dt.is_month_end:
191+
# day replaces days
192+
res = res.replace(day = res.days_in_month)
193+
194+
res = res + pandas.Timedelta(weeks = other.week,
195+
days = other.day,
196+
hours = other.hour,
197+
minutes = other.minute,
198+
seconds = other.sec,
199+
microseconds = other.nsec // NSEC_IN_MKSEC,
200+
nanoseconds = other.nsec % NSEC_IN_MKSEC)
201+
202+
if res.tzinfo is not None:
203+
tzoffset = compute_offset(res)
204+
else:
205+
tzoffset = 0
206+
return Datetime(year=res.year, month=res.month, day=res.day,
207+
hour=res.hour, minute=res.minute, sec=res.second,
208+
nsec=res.nanosecond + res.microsecond * NSEC_IN_MKSEC,
209+
tzoffset=tzoffset, tz=self.tz)
210+
211+
def __sub__(self, other):
212+
if not isinstance(other, Datetime):
213+
raise TypeError(f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'")
214+
215+
self_dt = self._datetime
216+
other_dt = other._datetime
217+
218+
# Tarantool datetime subtraction ignores timezone info, but it is a bug:
219+
#
220+
# Tarantool 2.10.1-0-g482d91c66
221+
#
222+
# tarantool> datetime.new{tz='MSK'} - datetime.new{tz='UTC'}
223+
# ---
224+
# - +0 seconds
225+
# ...
226+
#
227+
# Refer to https://github.com/tarantool/tarantool/issues/7698
228+
# for possible updates.
229+
230+
if self_dt.tzinfo != other_dt.tzinfo:
231+
other_dt = other_dt.tz_convert(self_dt.tzinfo)
232+
233+
self_nsec = self_dt.microsecond * NSEC_IN_MKSEC + self_dt.nanosecond
234+
other_nsec = other_dt.microsecond * NSEC_IN_MKSEC + other_dt.nanosecond
235+
236+
return Interval(
237+
year = self_dt.year - other_dt.year,
238+
month = self_dt.month - other_dt.month,
239+
day = self_dt.day - other_dt.day,
240+
hour = self_dt.hour - other_dt.hour,
241+
minute = self_dt.minute - other_dt.minute,
242+
sec = self_dt.second - other_dt.second,
243+
nsec = self_nsec - other_nsec,
244+
)
245+
171246
def __eq__(self, other):
172247
if isinstance(other, Datetime):
173248
return self._datetime == other._datetime

Diff for: tarantool/msgpack_ext/types/interval.py

+64
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,70 @@ def __init__(self, data=None, *, year=0, month=0, week=0,
102102
self.nsec = nsec
103103
self.adjust = adjust
104104

105+
def __add__(self, other):
106+
if not isinstance(other, Interval):
107+
raise TypeError(f"unsupported operand type(s) for +: '{type(self)}' and '{type(other)}'")
108+
109+
# Tarantool saves adjust of the first argument
110+
#
111+
# Tarantool 2.10.1-0-g482d91c66
112+
#
113+
# tarantool> dt1 = datetime.interval.new{year = 2, adjust='last'}
114+
# ---
115+
# ...
116+
#
117+
# tarantool> dt2 = datetime.interval.new{year = 1, adjust='excess'}
118+
# ---
119+
# ...
120+
#
121+
# tarantool> (dt1 + dt2).adjust
122+
# ---
123+
# - 'cdata<enum 112>: 2'
124+
# ...
125+
126+
return Interval(
127+
year = self.year + other.year,
128+
month = self.month + other.month,
129+
day = self.day + other.day,
130+
hour = self.hour + other.hour,
131+
minute = self.minute + other.minute,
132+
sec = self.sec + other.sec,
133+
nsec = self.nsec + other.nsec,
134+
adjust = self.adjust,
135+
)
136+
137+
def __sub__(self, other):
138+
if not isinstance(other, Interval):
139+
raise TypeError(f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'")
140+
141+
# Tarantool saves adjust of the first argument
142+
#
143+
# Tarantool 2.10.1-0-g482d91c66
144+
#
145+
# tarantool> dt1 = datetime.interval.new{year = 2, adjust='last'}
146+
# ---
147+
# ...
148+
#
149+
# tarantool> dt2 = datetime.interval.new{year = 1, adjust='excess'}
150+
# ---
151+
# ...
152+
#
153+
# tarantool> (dt1 - dt2).adjust
154+
# ---
155+
# - 'cdata<enum 112>: 2'
156+
# ...
157+
158+
return Interval(
159+
year = self.year - other.year,
160+
month = self.month - other.month,
161+
day = self.day - other.day,
162+
hour = self.hour - other.hour,
163+
minute = self.minute - other.minute,
164+
sec = self.sec - other.sec,
165+
nsec = self.nsec - other.nsec,
166+
adjust = self.adjust,
167+
)
168+
105169
def __eq__(self, other):
106170
if not isinstance(other, Interval):
107171
return False

Diff for: test/suites/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@
1919
from .test_uuid import TestSuite_UUID
2020
from .test_datetime import TestSuite_Datetime
2121
from .test_interval import TestSuite_Interval
22+
from .test_datetime_arithmetic import TestSuite_DatetimeArithmetic
2223

2324
test_cases = (TestSuite_Schema_UnicodeConnection,
2425
TestSuite_Schema_BinaryConnection,
2526
TestSuite_Request, TestSuite_Protocol, TestSuite_Reconnect,
2627
TestSuite_Mesh, TestSuite_Execute, TestSuite_DBAPI,
2728
TestSuite_Encoding, TestSuite_Pool, TestSuite_Ssl,
2829
TestSuite_Decimal, TestSuite_UUID, TestSuite_Datetime,
29-
TestSuite_Interval)
30+
TestSuite_Interval, TestSuite_DatetimeArithmetic)
31+
3032

3133
def load_tests(loader, tests, pattern):
3234
suite = unittest.TestSuite()

0 commit comments

Comments
 (0)