Skip to content

Commit a4ce01e

Browse files
Tim FarrellTim Farrell
authored andcommitted
Support ttl field proper datetime format
1 parent 3d7695b commit a4ce01e

File tree

3 files changed

+40
-9
lines changed

3 files changed

+40
-9
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ NOTE: Rule complexity is limited by the querying capabilities of the backend.
8888

8989
`global_indexes` - (optional) specify a mapping of index-name to tuple(partition_key).
9090

91+
`ttl` - (optional) the name of the datetime-typed field that dynamo should consider to be the TTL field. PydantiCRUD will save this field as a float type instead of an ISO datetime string. This field only works properly with UTC-zoned datetime instances.
92+
9193
### SQLite (Python 3.7+)
9294

9395
`database` - the filename of the database file for SQLite to use

pydanticrud/backends/dynamodb.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from typing import Optional, Set, Union, Dict, Any
22
import logging
33
import json
4-
from datetime import datetime
4+
from datetime import datetime, timezone
5+
from decimal import Decimal
56

67
import boto3
78
from boto3.dynamodb.conditions import Key, Attr
@@ -70,17 +71,22 @@ def rule_to_boto_expression(rule: Rule, keys: Optional[Set[str]] = None):
7071
"bool": "BOOL",
7172
}
7273

73-
EPOCH = datetime.utcfromtimestamp(0)
74+
EPOCH = datetime(1970, 1, 1, 0, 0)
7475

7576

76-
def _to_epoch_float(dt):
77-
return (dt - EPOCH).total_seconds * 1000
77+
def _to_epoch_decimal(dt: datetime) -> Decimal:
78+
"""TTL fields must be stored as a float but boto only supports decimals."""
79+
epock = EPOCH
80+
if dt.tzinfo:
81+
epock = epock.replace(tzinfo=timezone.utc)
82+
return Decimal((dt - epock).total_seconds())
7883

7984

8085
SERIALIZE_MAP = {
8186
"number": str, # float or decimal
8287
"string": str,
8388
"string:date-time": lambda d: d.isoformat(),
89+
"string:ttl": lambda d: _to_epoch_decimal(d),
8490
"boolean": lambda d: 1 if d else 0,
8591
"object": json.dumps,
8692
"array": json.dumps,
@@ -111,9 +117,10 @@ def index_definition(index_name, keys, gsi=False):
111117

112118

113119
class DynamoSerializer:
114-
def __init__(self, schema):
120+
def __init__(self, schema, ttl_field=None):
115121
self.properties = schema["properties"]
116122
self.definitions = schema.get("definitions")
123+
self.ttl_field = ttl_field
117124

118125
def _get_type_possibilities(self, field_name) -> Set[tuple]:
119126
field_properties = self.properties.get(field_name)
@@ -138,7 +145,11 @@ def type_from_definition(definition_signature: Union[str, dict]) -> dict:
138145
return set([(t["type"], t.get("format", "")) for t in type_dicts])
139146

140147
def _serialize_field(self, field_name, value):
141-
field_types = self._get_type_possibilities(field_name)
148+
if field_name == self.ttl_field:
149+
field_types = {("string", "ttl")}
150+
else:
151+
field_types = self._get_type_possibilities(field_name)
152+
142153
if value is not None:
143154
for t in field_types:
144155
try:
@@ -201,9 +212,9 @@ def __init__(self, cls):
201212
cfg = cls.Config
202213
self.cls = cls
203214
self.schema = cls.schema()
204-
self.serializer = DynamoSerializer(self.schema)
205215
self.hash_key = cfg.hash_key
206216
self.range_key = getattr(cfg, "range_key", None)
217+
self.serializer = DynamoSerializer(self.schema, ttl_field=getattr(cfg, "ttl", None))
207218
self.table_name = cls.get_table_name()
208219

209220
self.local_indexes = getattr(cfg, "local_indexes", {})

tests/test_dynamodb.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
from typing import Dict, List, Optional, Union
1+
from typing import Dict, List, Optional, Union, Any
22
from decimal import Decimal
3-
from datetime import datetime
3+
from datetime import datetime, timedelta, timezone
44
from uuid import uuid4, UUID
55
import random
66

@@ -26,6 +26,7 @@ class SimpleKeyModel(BaseModel):
2626
name: str
2727
total: float
2828
timestamp: datetime
29+
expires: datetime
2930
sigfig: Decimal
3031
enabled: bool
3132
data: Dict[int, int] = None
@@ -35,6 +36,7 @@ class SimpleKeyModel(BaseModel):
3536
class Config:
3637
title = "ModelTitle123"
3738
hash_key = "name"
39+
ttl = "expires"
3840
backend = DynamoDbBackend
3941
endpoint = "http://localhost:18002"
4042
global_indexes = {"by-id": ("id",)}
@@ -123,6 +125,7 @@ def simple_model_data_generator(**kwargs):
123125
name=random_unique_name(),
124126
total=round(random.random(), 9),
125127
timestamp=random_datetime(),
128+
expires=(datetime.utcnow() + timedelta(seconds=random.randint(0, 10))).replace(tzinfo=timezone.utc),
126129
sigfig=Decimal(str(random.random())[:8]),
127130
enabled=random.choice((True, False)),
128131
data={random.randint(0, 1000): random.randint(0, 1000)},
@@ -307,6 +310,21 @@ def test_save_get_delete_simple(dynamo, simple_table):
307310
SimpleKeyModel.get(data["name"])
308311

309312

313+
def test_save_ttl_field_is_float(dynamo, simple_query_data):
314+
"""DynamoDB requires ttl fields to be a float in order to be successfully processed. Boto provides the ability to
315+
set a float via a decimal (but not a float strangely)."""
316+
317+
key = simple_query_data[0]["name"]
318+
table = SimpleKeyModel.__backend__.get_table()
319+
resp = table.get_item(Key=SimpleKeyModel.__backend__._key_param_to_dict(key))
320+
expires_value = resp["Item"]["expires"]
321+
assert isinstance(expires_value, Decimal)
322+
assert datetime.utcfromtimestamp(float(expires_value)).replace(tzinfo=timezone.utc) == simple_query_data[0]["expires"]
323+
324+
instance = SimpleKeyModel.get(key)
325+
assert instance.expires == simple_query_data[0]["expires"]
326+
327+
310328
def test_query_with_hash_key_simple(dynamo, simple_query_data):
311329
res = SimpleKeyModel.query(Rule(f"name == '{simple_query_data[0]['name']}'"))
312330
res_data = {m.name: m.dict() for m in res}

0 commit comments

Comments
 (0)