Skip to content

Commit e625664

Browse files
greenrobotdan-obx
authored andcommitted
Add date_value_to_int() to parse date types, unmarshall as UTC #29
When using float as a date type, always interpret values as seconds.
1 parent 054d380 commit e625664

5 files changed

Lines changed: 120 additions & 46 deletions

File tree

objectbox/model/entity.py

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@
1818
from typing import Generic
1919
import numpy as np
2020
from math import floor
21-
from datetime import datetime
21+
from datetime import datetime, timezone
2222
from objectbox.c import *
2323
from objectbox.model.properties import Property
2424
import threading
2525

26+
2627
# _Entity class holds model information as well as conversions between python objects and FlatBuffers (ObjectBox data)
2728
class _Entity(object):
2829
def __init__(self, cls, id: int, uid: int):
@@ -47,7 +48,7 @@ def __init__(self, cls, id: int, uid: int):
4748
self.id_property = None
4849
self.fill_properties()
4950
self._tl = threading.local()
50-
51+
5152
def __call__(self, **properties):
5253
""" The constructor of the user Entity class. """
5354
object_ = self.cls()
@@ -122,9 +123,9 @@ def get_value(self, object, prop: Property):
122123
if (val == np.array(prop)).all():
123124
return np.array([])
124125
elif val == prop:
125-
if prop._py_type == datetime:
126-
return datetime.fromtimestamp(0)
127-
if prop._ob_type == OBXPropertyType_Flex:
126+
if prop._ob_type == OBXPropertyType_Date or prop._ob_type == OBXPropertyType_DateNano:
127+
return 0.0 # For marshalling, prefer float over datetime
128+
elif prop._ob_type == OBXPropertyType_Flex:
128129
return None
129130
else:
130131
return prop._py_type() # default (empty) value for the given type
@@ -136,6 +137,19 @@ def get_object_id(self, object) -> int:
136137
def set_object_id(self, object, id: int):
137138
setattr(object, self.id_property._name, id)
138139

140+
@staticmethod
141+
def date_value_to_int(value, multiplier: int) -> int:
142+
if isinstance(value, datetime):
143+
return round(value.timestamp() * multiplier) # timestamp returns seconds
144+
elif isinstance(value, float):
145+
return round(value * multiplier) # floats typically represent seconds
146+
elif isinstance(value, int): # Interpret ints as-is (without the multiplier); e.g. milliseconds or nanoseconds
147+
return value
148+
else:
149+
raise TypeError(
150+
f"Unsupported Python datetime type: {type(value)}. Please use datetime, float (seconds based) or "
151+
f"int (milliseconds for Date, nanoseconds for DateNano).")
152+
139153
def marshal(self, object, id: int) -> bytearray:
140154
if not hasattr(self._tl, "builder"):
141155
self._tl.builder = flatbuffers.Builder(256)
@@ -186,13 +200,9 @@ def marshal(self, object, id: int) -> bytearray:
186200
else:
187201
val = id if prop == self.id_property else self.get_value(object, prop)
188202
if prop._ob_type == OBXPropertyType_Date:
189-
if prop._py_type == datetime:
190-
val = val.timestamp() * 1000 # timestamp returns seconds, convert to milliseconds
191-
val = floor(val) # use floor to allow for float types
203+
val = self.date_value_to_int(val, 1000) # convert to milliseconds
192204
elif prop._ob_type == OBXPropertyType_DateNano:
193-
if prop._py_type == datetime:
194-
val = val.timestamp() * 1000000000 # convert to nanoseconds
195-
val = floor(val) # use floor to allow for float types
205+
val = self.date_value_to_int(val, 1000000000) # convert to nanoseconds
196206
builder.Prepend(prop._fb_type, val)
197207

198208
builder.Slot(prop._fb_slot)
@@ -211,42 +221,49 @@ def unmarshal(self, data: bytes):
211221
for prop in self.properties:
212222
o = table.Offset(prop._fb_v_offset)
213223
val = None
224+
ob_type = prop._ob_type
214225
if not o:
215226
val = prop._py_type() # use default (empty) value if not present in the object
216-
elif prop._ob_type == OBXPropertyType_String:
227+
elif ob_type == OBXPropertyType_String:
217228
val = table.String(o + table.Pos).decode('utf-8')
218-
elif prop._ob_type == OBXPropertyType_BoolVector:
229+
elif ob_type == OBXPropertyType_BoolVector:
219230
val = table.GetVectorAsNumpy(flatbuffers.number_types.BoolFlags, o)
220-
elif prop._ob_type == OBXPropertyType_ByteVector:
231+
elif ob_type == OBXPropertyType_ByteVector:
221232
# access the FB byte vector information
222233
start = table.Vector(o)
223234
size = table.VectorLen(o)
224235
# slice the vector as a requested type
225-
val = prop._py_type(table.Bytes[start:start+size])
226-
elif prop._ob_type == OBXPropertyType_ShortVector:
236+
val = prop._py_type(table.Bytes[start:start + size])
237+
elif ob_type == OBXPropertyType_ShortVector:
227238
val = table.GetVectorAsNumpy(flatbuffers.number_types.Int16Flags, o)
228-
elif prop._ob_type == OBXPropertyType_CharVector:
239+
elif ob_type == OBXPropertyType_CharVector:
229240
val = table.GetVectorAsNumpy(flatbuffers.number_types.Int16Flags, o)
230-
elif prop._ob_type == OBXPropertyType_Date and prop._py_type == datetime:
231-
table_val = table.Get(prop._fb_type, o + table.Pos)
232-
val = datetime.fromtimestamp(table_val/1000) if table_val != 0 else datetime.fromtimestamp(0) # default timestamp
233-
elif prop._ob_type == OBXPropertyType_DateNano and prop._py_type == datetime:
234-
table_val = table.Get(prop._fb_type, o + table.Pos)
235-
val = datetime.fromtimestamp(table_val/1000000000) if table_val != 0 else datetime.fromtimestamp(0) # default timestamp
236-
elif prop._ob_type == OBXPropertyType_IntVector:
241+
elif ob_type == OBXPropertyType_Date:
242+
val = table.Get(prop._fb_type, o + table.Pos) # int
243+
if prop._py_type == datetime:
244+
val = datetime.fromtimestamp(val / 1000.0, tz=timezone.utc)
245+
elif prop._py_type == float:
246+
val = val / 1000.0
247+
elif ob_type == OBXPropertyType_DateNano and prop._py_type == datetime:
248+
val = table.Get(prop._fb_type, o + table.Pos) # int
249+
if prop._py_type == datetime:
250+
val = datetime.fromtimestamp(val / 1000000000.0, tz=timezone.utc)
251+
elif prop._py_type == float:
252+
val = val / 1000000000.0
253+
elif ob_type == OBXPropertyType_IntVector:
237254
val = table.GetVectorAsNumpy(flatbuffers.number_types.Int32Flags, o)
238-
elif prop._ob_type == OBXPropertyType_LongVector:
255+
elif ob_type == OBXPropertyType_LongVector:
239256
val = table.GetVectorAsNumpy(flatbuffers.number_types.Int64Flags, o)
240-
elif prop._ob_type == OBXPropertyType_FloatVector:
257+
elif ob_type == OBXPropertyType_FloatVector:
241258
val = table.GetVectorAsNumpy(flatbuffers.number_types.Float32Flags, o)
242-
elif prop._ob_type == OBXPropertyType_DoubleVector:
259+
elif ob_type == OBXPropertyType_DoubleVector:
243260
val = table.GetVectorAsNumpy(flatbuffers.number_types.Float64Flags, o)
244-
elif prop._ob_type == OBXPropertyType_Flex:
261+
elif ob_type == OBXPropertyType_Flex:
245262
# access the FB byte vector information
246263
start = table.Vector(o)
247264
size = table.VectorLen(o)
248265
# slice the vector as bytes
249-
buf = table.Bytes[start:start+size]
266+
buf = table.Bytes[start:start + size]
250267
val = flatbuffers.flexbuffers.Loads(buf)
251268
else:
252269
val = table.Get(prop._fb_type, o + table.Pos)
@@ -258,6 +275,8 @@ def unmarshal(self, data: bytes):
258275

259276
def Entity(id: int = 0, uid: int = 0) -> Callable[[Type], _Entity]:
260277
""" Entity decorator that wraps _Entity to allow @Entity(id=, uid=); i.e. no class arguments. """
278+
261279
def wrapper(class_):
262280
return _Entity(class_, id, uid)
281+
263282
return wrapper

tests/common.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import timezone
2+
13
import objectbox
24
import os
35
from os import path
@@ -9,17 +11,20 @@
911

1012
test_dir = 'testdata'
1113

14+
1215
def create_default_model() -> objectbox.Model:
1316
model = objectbox.Model()
1417
model.entity(TestEntity, last_property_id=IdUid(27, 1027))
1518
model.last_entity_id = IdUid(2, 2)
1619
model.last_index_id = IdUid(2, 10002)
1720
return model
1821

22+
1923
def load_empty_test_default_store(db_name: str = test_dir) -> objectbox.Store:
2024
model = create_default_model()
2125
return objectbox.Store(model=model, directory=db_name)
2226

27+
2328
def load_empty_test_datetime_store(name: str = "") -> objectbox.Store:
2429
model = objectbox.Model()
2530
model.entity(TestEntityDatetime, last_property_id=IdUid(4, 2004))
@@ -61,8 +66,10 @@ def create_test_store(db_name: Optional[str] = None, clear_db: bool = True) -> o
6166

6267

6368
def assert_equal_prop(actual, expected, default):
64-
assert actual == expected or (isinstance(
65-
expected, objectbox.model.Property) and actual == default)
69+
if isinstance(expected, objectbox.model.Property):
70+
assert (actual == default)
71+
else:
72+
assert (actual == expected)
6673

6774

6875
def assert_equal_prop_vector(actual, expected, default):
@@ -72,8 +79,10 @@ def assert_equal_prop_vector(actual, expected, default):
7279

7380
# compare approx values
7481
def assert_equal_prop_approx(actual, expected, default):
75-
assert pytest.approx(actual) == expected or (isinstance(
76-
expected, objectbox.model.Property) and actual == default)
82+
if isinstance(expected, objectbox.model.Property):
83+
assert (actual == default)
84+
else:
85+
assert (pytest.approx(actual) == expected)
7786

7887

7988
def assert_equal(actual: TestEntity, expected: TestEntity):
@@ -100,6 +109,6 @@ def assert_equal(actual: TestEntity, expected: TestEntity):
100109
assert_equal_prop_approx(actual.longs_list, expected.longs_list, [])
101110
assert_equal_prop_approx(actual.floats_list, expected.floats_list, [])
102111
assert_equal_prop_approx(actual.doubles_list, expected.doubles_list, [])
103-
assert_equal_prop_approx(actual.date, expected.date, 0)
112+
assert_equal_prop_approx(actual.date, expected.date, datetime.fromtimestamp(0, timezone.utc))
104113
assert_equal_prop(actual.date_nano, expected.date_nano, 0)
105114
assert_equal_prop(actual.flex, expected.flex, None)

tests/model.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,16 @@ class TestEntity:
3131
longs_list = Int64List(id=22, uid=1022)
3232
floats_list = Float32List(id=23, uid=1023)
3333
doubles_list = Float64List(id=24, uid=1024)
34-
date = Date(py_type=int, id=25, uid=1025)
35-
date_nano = DateNano(py_type=int, id=26, uid=1026)
34+
date = Date(id=25, uid=1025)
35+
date_nano = DateNano(int, id=26, uid=1026)
3636
flex = Flex(id=27, uid=1027)
3737
transient = "" # not "Property" so it's not stored
3838

3939

4040
@Entity(id=2, uid=2)
4141
class TestEntityDatetime:
4242
id = Id(id=1, uid=2001)
43-
date = Date(id=2, uid=2002)
43+
date = Date(float, id=2, uid=2002)
4444
date_nano = DateNano(id=3, uid=2003)
4545

4646

tests/test_box.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from tests.model import TestEntity, TestEntityDatetime, TestEntityFlex
44
from tests.common import *
55
import numpy as np
6-
from datetime import datetime
6+
from datetime import datetime, timezone
77
import time
88
from math import floor
99

@@ -47,8 +47,8 @@ def test_box_basics():
4747
object.longs_list = [4568, 8714, 1234, 5678, 9012240941]
4848
object.floats_list = [0.11, 1.22, 2.33, 3.44, 4.5595]
4949
object.doubles_list = [99.1999, 88.2888, 77.3777, 66.4666, 55.6597555]
50-
object.date = time.time() * 1000 # milliseconds since UNIX epoch
51-
object.date_nano = time.time_ns() # nanoseconds since UNIX epoch
50+
object.date = time.time() # seconds since UNIX epoch (float)
51+
object.date_nano = time.time_ns() # nanoseconds since UNIX epoch (int)
5252
object.flex = dict(a=1, b=2, c=3)
5353
object.transient = "abcd"
5454

@@ -60,20 +60,23 @@ def test_box_basics():
6060
assert box.count() == 2
6161

6262
# read
63+
# wrap date so it can be compared (is read as datetime)
64+
object.date = datetime.fromtimestamp(round(object.date * 1000) / 1000, tz=timezone.utc)
6365
read = box.get(object.id)
6466
assert_equal(read, object)
6567
assert read.transient != object.transient # !=
6668

6769
# update
6870
object.str = "bar"
69-
object.date = floor(time.time_ns() / 1000000) # check that date can also be int
70-
object.date_nano = float(time.time() * 1000000000) # check that date_nano can also be float
71+
object.date = floor(time.time_ns() / 1000000) # check that date can also be an int
72+
object.date_nano = time.time() # check that date_nano can also be a float
7173
id = box.put(object)
7274
assert id == 5
7375

7476
# read again
7577
read = box.get(object.id)
76-
assert_equal(read, object)
78+
assert (floor(read.date.timestamp() * 1000) == object.date)
79+
assert (read.date_nano == floor(object.date_nano * 1000000000))
7780

7881
# remove
7982
box.remove(object)
@@ -107,7 +110,8 @@ def test_box_bulk():
107110
assert objects[2].id == 4
108111
assert objects[3].id == 1
109112

110-
assert_equal(box.get(objects[0].id), objects[0])
113+
read = box.get(objects[0].id)
114+
assert_equal(read, objects[0])
111115
assert_equal(box.get(objects[1].id), objects[1])
112116
assert_equal(box.get(objects[2].id), objects[2])
113117
assert_equal(box.get(objects[3].id), objects[3])
@@ -155,7 +159,9 @@ def test_datetime():
155159

156160
# read
157161
read = box.get(object.id)
158-
assert pytest.approx(read.date.timestamp()) == object.date.timestamp()
162+
assert type(read.date) == float
163+
assert type(read.date_nano) == datetime
164+
assert pytest.approx(read.date) == object.date.timestamp()
159165

160166
# update
161167
object.str = "bar"
@@ -166,7 +172,7 @@ def test_datetime():
166172

167173
# read again
168174
read = box.get(object.id)
169-
assert pytest.approx(read.date.timestamp()) == object.date.timestamp()
175+
assert pytest.approx(read.date) == object.date.timestamp()
170176

171177
# remove
172178
success = box.remove(object)

tests/test_utils.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,48 @@
1+
from datetime import timezone, datetime, timedelta
2+
13
import pytest
24

5+
from objectbox.model.entity import _Entity
36
from objectbox.utils import *
47

58

9+
def test_date_value_to_int__basics():
10+
assert _Entity.date_value_to_int(1234, 1000) == 1234
11+
assert _Entity.date_value_to_int(1234, 1000000000) == 1234
12+
assert _Entity.date_value_to_int(1234.0, 1000) == 1234000 # milliseconds
13+
assert _Entity.date_value_to_int(1234.0, 1000000000) == 1234000000000 # nanoseconds
14+
assert _Entity.date_value_to_int(datetime.fromtimestamp(1234), 1000) == 1234000 # milliseconds
15+
16+
17+
def test_date_value_to_int__timezone():
18+
# create datetime object for may 1st, 2000
19+
dt_utc = datetime(year=2000, month=5, day=1, hour=12, minute=30, second=45, microsecond=123456, tzinfo=timezone.utc)
20+
dt_plus2 = datetime(year=2000, month=5, day=1, hour=14, minute=30, second=45, microsecond=123456,
21+
tzinfo=timezone(offset=timedelta(hours=2)))
22+
23+
# Demonstrate Python's semantic
24+
assert dt_utc == dt_plus2
25+
assert dt_utc.timestamp() == dt_plus2.timestamp()
26+
27+
# Actual test
28+
expected: int = 957184245123
29+
assert _Entity.date_value_to_int(dt_utc, 1000) == expected
30+
assert _Entity.date_value_to_int(dt_plus2, 1000) == expected
31+
32+
33+
def test_date_value_to_int__naive():
34+
dt_naive = datetime(year=2000, month=5, day=1, hour=12, minute=30, second=45, microsecond=123456)
35+
local_tz = datetime.now().astimezone().tzinfo
36+
dt_local = datetime(year=2000, month=5, day=1, hour=12, minute=30, second=45, microsecond=123456, tzinfo=local_tz)
37+
38+
# Demonstrate Python's semantic
39+
assert dt_naive.astimezone(timezone.utc) == dt_local # naive lacks the TZ, so we can't compare directly
40+
assert dt_naive.timestamp() == dt_local.timestamp()
41+
42+
# Actual test
43+
assert _Entity.date_value_to_int(dt_naive, 1000) == _Entity.date_value_to_int(dt_local, 1000)
44+
45+
646
def test_vector_distance_f32():
747
""" Tests distance values between two vectors. """
848

0 commit comments

Comments
 (0)