Skip to content

Commit adc7e93

Browse files
authored
Merge pull request #18 from RSS-Engineering/support_ttl
Support ttl field proper datetime format
2 parents 2016b4c + 8d0cc26 commit adc7e93

File tree

4 files changed

+46
-13
lines changed

4 files changed

+46
-13
lines changed

README.md

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

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

93+
`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.
94+
9395
### SQLite (Python 3.7+)
9496

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

pydanticrud/backends/dynamodb.py

Lines changed: 23 additions & 10 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
@@ -45,7 +46,7 @@ def expression_to_condition(expr, keys: set):
4546
if isinstance(expr, ast.NullExpression):
4647
return None, set()
4748
if isinstance(expr, ast.DatetimeExpression):
48-
return _to_epoch_float(expr.value), set()
49+
return _to_epoch_decimal(expr.value), set()
4950
if isinstance(expr, ast.StringExpression):
5051
return expr.value, set()
5152
if isinstance(expr, ast.FloatExpression):
@@ -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,
@@ -96,7 +102,7 @@ def _to_epoch_float(dt):
96102

97103
def chunk_list(lst, size):
98104
for i in range(0, len(lst), size):
99-
yield lst[i:i + size]
105+
yield lst[i : i + size]
100106

101107

102108
def index_definition(index_name, keys, gsi=False):
@@ -115,9 +121,10 @@ def index_definition(index_name, keys, gsi=False):
115121

116122

117123
class DynamoSerializer:
118-
def __init__(self, schema):
124+
def __init__(self, schema, ttl_field=None):
119125
self.properties = schema.get("properties")
120126
self.definitions = schema.get("definitions")
127+
self.ttl_field = ttl_field
121128

122129
def _get_type_possibilities(self, field_name) -> Set[tuple]:
123130
field_properties = self.properties.get(field_name)
@@ -142,7 +149,11 @@ def type_from_definition(definition_signature: Union[str, dict]) -> dict:
142149
return set([(t["type"], t.get("format", "")) for t in type_dicts])
143150

144151
def _serialize_field(self, field_name, value):
145-
field_types = self._get_type_possibilities(field_name)
152+
if field_name == self.ttl_field:
153+
field_types = {("string", "ttl")}
154+
else:
155+
field_types = self._get_type_possibilities(field_name)
156+
146157
if value is not None:
147158
for t in field_types:
148159
try:
@@ -205,9 +216,9 @@ def __init__(self, cls):
205216
cfg = cls.Config
206217
self.cls = cls
207218
self.schema = cls.schema()
208-
self.serializer = DynamoSerializer(self.schema)
209219
self.hash_key = cfg.hash_key
210220
self.range_key = getattr(cfg, "range_key", None)
221+
self.serializer = DynamoSerializer(self.schema, ttl_field=getattr(cfg, "ttl", None))
211222
self.table_name = cls.get_table_name()
212223

213224
self.local_indexes = getattr(cfg, "local_indexes", {})
@@ -427,7 +438,9 @@ def batch_save(self, items: list) -> dict:
427438
# chunk list for size limit of 25 items to write using this batch_write operation refer below.
428439
# https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb/client/batch_write_item.html#:~:text=The%20BatchWriteItem%20operation,Data%20Types.
429440
for chunk in chunk_list(items, 25):
430-
serialized_items = [self.serializer.serialize_record(item.dict(by_alias=True)) for item in chunk]
441+
serialized_items = [
442+
self.serializer.serialize_record(item.dict(by_alias=True)) for item in chunk
443+
]
431444
for serialized_item in serialized_items:
432445
request_items[self.table_name].append({"PutRequest": {"Item": serialized_item}})
433446
try:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "pydanticrud"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
description = "Supercharge your Pydantic models with CRUD methods and a pluggable backend"
55
authors = ["Timothy Farrell <[email protected]>"]
66
license = "MIT"

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

@@ -27,6 +27,7 @@ class SimpleKeyModel(BaseModel):
2727
name: str
2828
total: float
2929
timestamp: datetime
30+
expires: datetime
3031
sigfig: Decimal
3132
enabled: bool
3233
data: Dict[int, int] = None
@@ -36,6 +37,7 @@ class SimpleKeyModel(BaseModel):
3637
class Config:
3738
title = "ModelTitle123"
3839
hash_key = "name"
40+
ttl = "expires"
3941
backend = DynamoDbBackend
4042
endpoint = "http://localhost:18002"
4143
global_indexes = {"by-id": ("id",)}
@@ -125,6 +127,7 @@ def simple_model_data_generator(**kwargs):
125127
name=random_unique_name(),
126128
total=round(random.random(), 9),
127129
timestamp=random_datetime(),
130+
expires=(datetime.utcnow() + timedelta(seconds=random.randint(0, 10))).replace(tzinfo=timezone.utc),
128131
sigfig=Decimal(str(random.random())[:8]),
129132
enabled=random.choice((True, False)),
130133
data={random.randint(0, 1000): random.randint(0, 1000)},
@@ -309,6 +312,21 @@ def test_save_get_delete_simple(dynamo, simple_table):
309312
SimpleKeyModel.get(data["name"])
310313

311314

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

0 commit comments

Comments
 (0)