statikk 0.0.8__tar.gz → 0.0.10__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {statikk-0.0.8 → statikk-0.0.10}/PKG-INFO +1 -1
- {statikk-0.0.8 → statikk-0.0.10}/docs/usage.rst +3 -1
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk/engine.py +106 -53
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk/expressions.py +16 -6
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk/models.py +16 -10
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/PKG-INFO +1 -1
- {statikk-0.0.8 → statikk-0.0.10}/tests/test_engine.py +121 -8
- {statikk-0.0.8 → statikk-0.0.10}/.coveragerc +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/.gitignore +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/.readthedocs.yml +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/AUTHORS.rst +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/CHANGELOG.rst +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/CONTRIBUTING.rst +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/LICENSE.txt +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/README.rst +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/assets/favicon.png +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/assets/logo.png +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/docs/Makefile +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/docs/_static/.gitignore +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/docs/authors.rst +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/docs/changelog.rst +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/docs/conf.py +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/docs/contributing.rst +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/docs/index.rst +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/docs/license.rst +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/docs/readme.rst +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/docs/requirements.txt +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/pyproject.toml +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/setup.cfg +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/setup.py +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk/__init__.py +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk/conditions.py +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/SOURCES.txt +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/dependency_links.txt +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/not-zip-safe +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/requires.txt +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/top_level.txt +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/tests/conftest.py +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/tests/test_expressions.py +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/tests/test_models.py +0 -0
- {statikk-0.0.8 → statikk-0.0.10}/tox.ini +0 -0
@@ -295,7 +295,7 @@ Let's take a look at an example:
|
|
295
295
|
|
296
296
|
card = Card(player_id=player.id, tier="EPIC", values={1, 2, 3, 4}, cost=5, name="FooFoo")
|
297
297
|
card.save()
|
298
|
-
|
298
|
+
card.update().set("tier", "LEGENDARY").delete("values", {1}).add("cost", 4).remove("name").execute()
|
299
299
|
card = Card.get(card.id)
|
300
300
|
card.model_dump()
|
301
301
|
# {
|
@@ -305,6 +305,8 @@ Let's take a look at an example:
|
|
305
305
|
# "values": {2, 3, 4},
|
306
306
|
# "cost": 9,
|
307
307
|
# "name": "Foo" (default value defined on the model)
|
308
|
+
# "gsi_pk": "<player_id>",
|
309
|
+
# "gsi_sk": "Card|LEGENDARY"
|
308
310
|
# }
|
309
311
|
|
310
312
|
Note that you need to call ``execute`` on the update expression to transmit the changes to the database.
|
@@ -6,13 +6,14 @@ import boto3
|
|
6
6
|
from botocore.config import Config
|
7
7
|
from pydantic.fields import FieldInfo
|
8
8
|
from boto3.dynamodb.conditions import ComparisonCondition, Key
|
9
|
-
from boto3.dynamodb.types import TypeDeserializer
|
9
|
+
from boto3.dynamodb.types import TypeDeserializer, Decimal
|
10
10
|
|
11
11
|
from statikk.conditions import Condition, Equals, BeginsWith
|
12
12
|
from statikk.expressions import UpdateExpressionBuilder
|
13
13
|
from statikk.models import (
|
14
14
|
DatabaseModel,
|
15
15
|
GSI,
|
16
|
+
Index,
|
16
17
|
IndexPrimaryKeyField,
|
17
18
|
IndexSecondaryKeyField,
|
18
19
|
KeySchema,
|
@@ -156,7 +157,13 @@ class Table:
|
|
156
157
|
key = {self.key_schema.hash_key: id}
|
157
158
|
self._get_dynamodb_table().delete_item(Key=key)
|
158
159
|
|
159
|
-
def get_item(
|
160
|
+
def get_item(
|
161
|
+
self,
|
162
|
+
id: str,
|
163
|
+
model_class: Type[DatabaseModel],
|
164
|
+
sort_key: Optional[Any] = None,
|
165
|
+
consistent_read: bool = False,
|
166
|
+
):
|
160
167
|
"""
|
161
168
|
Returns an item from the database by id, using the partition key of the table.
|
162
169
|
:param id: The id of the item to retrieve.
|
@@ -166,11 +173,10 @@ class Table:
|
|
166
173
|
key = {self.key_schema.hash_key: id}
|
167
174
|
if sort_key:
|
168
175
|
key[self.key_schema.sort_key] = self._serialize_value(sort_key)
|
169
|
-
raw_data = self._get_dynamodb_table().get_item(Key=key)
|
176
|
+
raw_data = self._get_dynamodb_table().get_item(Key=key, ConsistentRead=consistent_read)
|
170
177
|
if "Item" not in raw_data:
|
171
178
|
raise ItemNotFoundError(f"{model_class} with id '{id}' not found.")
|
172
179
|
data = raw_data["Item"]
|
173
|
-
del data["type"]
|
174
180
|
for key, value in data.items():
|
175
181
|
data[key] = self._deserialize_value(value, model_class.model_fields[key])
|
176
182
|
return model_class(**data)
|
@@ -206,7 +212,6 @@ class Table:
|
|
206
212
|
"""
|
207
213
|
data = self._serialize_item(model)
|
208
214
|
self._get_dynamodb_table().put_item(Item=data)
|
209
|
-
del data["type"]
|
210
215
|
for key, value in data.items():
|
211
216
|
data[key] = self._deserialize_value(value, model.model_fields[key])
|
212
217
|
return type(model)(**data)
|
@@ -215,6 +220,7 @@ class Table:
|
|
215
220
|
self,
|
216
221
|
hash_key: str,
|
217
222
|
update_builder: UpdateExpressionBuilder,
|
223
|
+
model: DatabaseModel,
|
218
224
|
range_key: Optional[str] = None,
|
219
225
|
):
|
220
226
|
(
|
@@ -226,16 +232,40 @@ class Table:
|
|
226
232
|
if range_key:
|
227
233
|
key[self.key_schema.sort_key] = range_key
|
228
234
|
|
235
|
+
def _find_changed_indexes():
|
236
|
+
changed_index_values = set()
|
237
|
+
for prefixed_attribute, value in expression_attribute_values.items():
|
238
|
+
expression_attribute_values[prefixed_attribute] = self._serialize_value(value)
|
239
|
+
attribute = prefixed_attribute.replace(":", "")
|
240
|
+
if model.model_fields[attribute].annotation is IndexSecondaryKeyField:
|
241
|
+
idx_field = getattr(model, attribute)
|
242
|
+
for idx in idx_field.index_names:
|
243
|
+
idx_field.value = value
|
244
|
+
changed_index_values.add(idx)
|
245
|
+
return changed_index_values
|
246
|
+
|
247
|
+
changed_index_values = _find_changed_indexes()
|
248
|
+
|
249
|
+
for idx_name in changed_index_values:
|
250
|
+
idx = [idx for idx in self.indexes if idx.name == idx_name][0]
|
251
|
+
index_value = self._get_sort_key_value(model, idx)
|
252
|
+
expression_attribute_values[f":{idx.sort_key.name}"] = index_value
|
253
|
+
update_expression += f" SET {idx.sort_key.name} = :{idx.sort_key.name}"
|
254
|
+
|
229
255
|
request = {
|
230
256
|
"Key": key,
|
231
257
|
"UpdateExpression": update_expression,
|
232
258
|
"ExpressionAttributeValues": expression_attribute_values,
|
259
|
+
"ReturnValues": "ALL_NEW",
|
233
260
|
}
|
234
261
|
if expression_attribute_names:
|
235
262
|
request["ExpressionAttributeNames"] = expression_attribute_names
|
236
263
|
|
237
264
|
response = self._get_dynamodb_table().update_item(**request)
|
238
|
-
|
265
|
+
data = response["Attributes"]
|
266
|
+
for key, value in data.items():
|
267
|
+
data[key] = self._deserialize_value(value, model.model_fields[key])
|
268
|
+
return type(model)(**data)
|
239
269
|
|
240
270
|
def batch_write(self):
|
241
271
|
"""
|
@@ -272,11 +302,11 @@ class Table:
|
|
272
302
|
raise InvalidIndexNameError(f"The provided index name '{index_name}' is not configured on the table.")
|
273
303
|
index = index_filter[0]
|
274
304
|
key_condition = hash_key.evaluate(index.hash_key.name)
|
275
|
-
if range_key is None:
|
305
|
+
if range_key is None and model_class.include_type_in_sort_key():
|
276
306
|
range_key = BeginsWith(model_class.model_type())
|
277
|
-
|
278
|
-
|
279
|
-
|
307
|
+
if range_key:
|
308
|
+
range_key.enrich(model_class=model_class)
|
309
|
+
key_condition = key_condition & range_key.evaluate(index.sort_key.name)
|
280
310
|
|
281
311
|
query_params = {
|
282
312
|
"IndexName": index_name,
|
@@ -288,13 +318,14 @@ class Table:
|
|
288
318
|
|
289
319
|
while last_evaluated_key:
|
290
320
|
items = self._get_dynamodb_table().query(**query_params)
|
291
|
-
yield from [
|
321
|
+
yield from [self._deserialize_item(item, model_class=model_class) for item in items["Items"]]
|
292
322
|
last_evaluated_key = items.get("LastEvaluatedKey", False)
|
293
323
|
|
294
324
|
def scan(
|
295
325
|
self,
|
296
326
|
model_class: Type[DatabaseModel],
|
297
327
|
filter_condition: Optional[ComparisonCondition] = None,
|
328
|
+
consistent_read: bool = False,
|
298
329
|
):
|
299
330
|
"""
|
300
331
|
Scans the database for items matching the provided filter condition. The method returns a list of items matching
|
@@ -303,7 +334,9 @@ class Table:
|
|
303
334
|
:param model_class: The model class to use to deserialize the items.
|
304
335
|
:param filter_condition: An optional filter condition to use for the query. See boto3.dynamodb.conditions.ComparisonCondition for more information.
|
305
336
|
"""
|
306
|
-
query_params = {
|
337
|
+
query_params = {
|
338
|
+
"ConsistentRead": consistent_read,
|
339
|
+
}
|
307
340
|
if filter_condition:
|
308
341
|
query_params["FilterExpression"] = filter_condition
|
309
342
|
last_evaluated_key = True
|
@@ -349,11 +382,16 @@ class Table:
|
|
349
382
|
|
350
383
|
return results
|
351
384
|
|
352
|
-
def _prepare_model_data(
|
353
|
-
|
385
|
+
def _prepare_model_data(
|
386
|
+
self,
|
387
|
+
item: DatabaseModel,
|
388
|
+
indexes: List[Index],
|
389
|
+
force_override_index_fields: bool = False,
|
390
|
+
) -> Dict[str, Any]:
|
391
|
+
for idx in indexes:
|
354
392
|
index_fields = self._compose_index_values(item, idx)
|
355
393
|
for key, value in index_fields.items():
|
356
|
-
if hasattr(item, key) and getattr(item, key) is not None:
|
394
|
+
if hasattr(item, key) and (getattr(item, key) is not None and not force_override_index_fields):
|
357
395
|
continue
|
358
396
|
if value is not None:
|
359
397
|
setattr(item, key, value)
|
@@ -361,20 +399,32 @@ class Table:
|
|
361
399
|
return item.model_dump()
|
362
400
|
|
363
401
|
def _serialize_item(self, item: DatabaseModel):
|
364
|
-
data = self._prepare_model_data(item)
|
402
|
+
data = self._prepare_model_data(item, self.indexes)
|
365
403
|
for key, value in data.items():
|
366
404
|
data[key] = self._serialize_value(value)
|
367
|
-
data["type"] = item.model_type()
|
368
405
|
return data
|
369
406
|
|
407
|
+
def _deserialize_item(self, item: Dict[str, Any], model_class: Type[DatabaseModel]):
|
408
|
+
for key, value in item.items():
|
409
|
+
item[key] = self._deserialize_value(value, model_class.model_fields[key])
|
410
|
+
return model_class(**item)
|
411
|
+
|
370
412
|
def _deserialize_value(self, value: Any, annotation: Any):
|
371
|
-
if annotation is datetime or "
|
413
|
+
if annotation is datetime or "datetime" in str(annotation):
|
372
414
|
return datetime.fromtimestamp(int(value))
|
415
|
+
if annotation is float:
|
416
|
+
return float(value)
|
373
417
|
return value
|
374
418
|
|
375
419
|
def _serialize_value(self, value: Any):
|
376
420
|
if isinstance(value, datetime):
|
377
421
|
return int(value.timestamp())
|
422
|
+
if isinstance(value, float):
|
423
|
+
return Decimal(value)
|
424
|
+
if isinstance(value, list):
|
425
|
+
return [self._serialize_value(item) for item in value]
|
426
|
+
if isinstance(value, dict):
|
427
|
+
return {key: self._serialize_value(item) for key, item in value.items() if item is not None}
|
378
428
|
return value
|
379
429
|
|
380
430
|
def _set_index_fields(self, model: DatabaseModel | Type[DatabaseModel], idx: GSI):
|
@@ -384,52 +434,55 @@ class Table:
|
|
384
434
|
if idx.sort_key.name not in model_fields:
|
385
435
|
model_fields[idx.sort_key.name] = FieldInfo(annotation=idx.sort_key.type, default=None, required=False)
|
386
436
|
|
387
|
-
def
|
388
|
-
model_fields = model.model_fields
|
389
|
-
hash_key_field = [
|
390
|
-
field_name
|
391
|
-
for field_name, field_info in model_fields.items()
|
392
|
-
if field_info.annotation is not None
|
393
|
-
if field_info.annotation is IndexPrimaryKeyField and idx.name in getattr(model, field_name).index_names
|
394
|
-
]
|
395
|
-
if len(hash_key_field) == 0 and model.type_is_primary_key():
|
396
|
-
hash_key_field.append(model.model_type())
|
397
|
-
hash_key_field = hash_key_field[0]
|
437
|
+
def _get_sort_key_value(self, model: DatabaseModel, idx: GSI) -> str:
|
398
438
|
sort_key_fields_unordered = [
|
399
439
|
(field_name, getattr(model, field_name).order)
|
400
|
-
for field_name, field_info in model_fields.items()
|
440
|
+
for field_name, field_info in model.model_fields.items()
|
401
441
|
if field_info.annotation is not None
|
402
442
|
if field_info.annotation is IndexSecondaryKeyField and idx.name in getattr(model, field_name).index_names
|
403
443
|
]
|
404
444
|
|
445
|
+
if len(sort_key_fields_unordered) == idx.sort_key is not None:
|
446
|
+
raise IncorrectSortKeyError(f"Model {model.__class__} does not have a sort key defined.")
|
405
447
|
if sort_key_fields_unordered[0][1] is not None:
|
406
448
|
sort_key_fields_unordered.sort(key=lambda x: x[1])
|
407
449
|
|
408
450
|
sort_key_fields = [field[0] for field in sort_key_fields_unordered]
|
409
451
|
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
452
|
+
if len(sort_key_fields) == 0 and model.include_type_in_sort_key():
|
453
|
+
return model.model_type()
|
454
|
+
if idx.sort_key.type is not str:
|
455
|
+
value = getattr(model, sort_key_fields[0]).value
|
456
|
+
if type(value) is not idx.sort_key.type:
|
457
|
+
raise IncorrectSortKeyError(
|
458
|
+
f"Incorrect sort key type. Sort key type for sort key '{idx.sort_key.name}' should be: "
|
459
|
+
+ str(idx.sort_key.type)
|
460
|
+
+ " but got: "
|
461
|
+
+ str(type(value))
|
462
|
+
)
|
463
|
+
value = value or idx.sort_key.default
|
464
|
+
return self._serialize_value(value)
|
465
|
+
|
466
|
+
sort_key_values: List[str] = []
|
467
|
+
if model.include_type_in_sort_key() and model.model_type() not in sort_key_values:
|
468
|
+
sort_key_values.append(model.model_type())
|
469
|
+
|
470
|
+
for field in sort_key_fields:
|
471
|
+
value = getattr(model, field).value
|
472
|
+
sort_key_values.append(value)
|
473
|
+
return self.delimiter.join(sort_key_values)
|
424
474
|
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
for
|
430
|
-
|
431
|
-
|
432
|
-
|
475
|
+
def _compose_index_values(self, model: DatabaseModel, idx: GSI) -> Dict[str, Any]:
|
476
|
+
model_fields = model.model_fields
|
477
|
+
hash_key_field = [
|
478
|
+
field_name
|
479
|
+
for field_name, field_info in model_fields.items()
|
480
|
+
if field_info.annotation is not None
|
481
|
+
if field_info.annotation is IndexPrimaryKeyField and idx.name in getattr(model, field_name).index_names
|
482
|
+
]
|
483
|
+
if len(hash_key_field) == 0 and model.type_is_primary_key():
|
484
|
+
hash_key_field.append(model.model_type())
|
485
|
+
hash_key_field = hash_key_field[0]
|
433
486
|
|
434
487
|
def _get_hash_key_value():
|
435
488
|
if hash_key_field == model.model_type():
|
@@ -439,7 +492,7 @@ class Table:
|
|
439
492
|
|
440
493
|
return {
|
441
494
|
idx.hash_key.name: _get_hash_key_value(),
|
442
|
-
idx.sort_key.name: _get_sort_key_value(),
|
495
|
+
idx.sort_key.name: self._get_sort_key_value(model, idx),
|
443
496
|
}
|
444
497
|
|
445
498
|
def _perform_batch_write(self, put_items: List[DatabaseModel], delete_items: List[DatabaseModel]):
|
@@ -591,10 +591,14 @@ class UpdateExpressionBuilder:
|
|
591
591
|
self.model_schema = model_cls.model_json_schema()
|
592
592
|
self.expression_attribute_names = {}
|
593
593
|
|
594
|
+
@classmethod
|
595
|
+
def safe_name(cls, key):
|
596
|
+
return f"#n_{key}"
|
597
|
+
|
594
598
|
def _safe_name(self, key):
|
595
599
|
"""Replace reserved words with a safe placeholder."""
|
596
600
|
if key.upper() in RESERVED_WORDS:
|
597
|
-
safe_key =
|
601
|
+
safe_key = self.safe_name(key)
|
598
602
|
self.expression_attribute_names[safe_key] = key
|
599
603
|
return safe_key
|
600
604
|
return key
|
@@ -669,11 +673,17 @@ class UpdateExpressionBuilder:
|
|
669
673
|
|
670
674
|
|
671
675
|
class DatabaseModelUpdateExpressionBuilder(UpdateExpressionBuilder):
|
672
|
-
def __init__(self,
|
673
|
-
self.
|
676
|
+
def __init__(self, model, sort_key: Optional[str] = None):
|
677
|
+
self.model = model
|
678
|
+
self.hash_key = model.id
|
674
679
|
self.sort_key = sort_key
|
675
|
-
self.model_cls =
|
676
|
-
super().__init__(model_cls)
|
680
|
+
self.model_cls = model.__class__
|
681
|
+
super().__init__(self.model_cls)
|
677
682
|
|
678
683
|
def execute(self):
|
679
|
-
return self.model_cls.update_item(
|
684
|
+
return self.model_cls.update_item(
|
685
|
+
self.hash_key,
|
686
|
+
range_key=self.sort_key,
|
687
|
+
update_builder=self,
|
688
|
+
model=self.model,
|
689
|
+
)
|
@@ -70,9 +70,6 @@ class DatabaseModel(BaseModel):
|
|
70
70
|
def batch_write(cls):
|
71
71
|
return cls._table.batch_write()
|
72
72
|
|
73
|
-
class Config:
|
74
|
-
include_type_field_in_sort_key = False
|
75
|
-
|
76
73
|
@classmethod
|
77
74
|
def query(
|
78
75
|
cls,
|
@@ -95,29 +92,38 @@ class DatabaseModel(BaseModel):
|
|
95
92
|
def delete(self):
|
96
93
|
return self._table.delete_item(self.id)
|
97
94
|
|
98
|
-
|
99
|
-
|
100
|
-
return DatabaseModelUpdateExpressionBuilder(cls, id, sort_key)
|
95
|
+
def update(self, sort_key: Optional[str] = None) -> DatabaseModelUpdateExpressionBuilder:
|
96
|
+
return DatabaseModelUpdateExpressionBuilder(self, sort_key)
|
101
97
|
|
102
98
|
@classmethod
|
103
99
|
def update_item(
|
104
100
|
cls,
|
105
101
|
hash_key: str,
|
106
102
|
update_builder: DatabaseModelUpdateExpressionBuilder,
|
103
|
+
model: DatabaseModel,
|
107
104
|
range_key: Optional[str] = None,
|
108
105
|
):
|
109
|
-
return cls._table.update_item(
|
106
|
+
return cls._table.update_item(
|
107
|
+
hash_key,
|
108
|
+
range_key=range_key,
|
109
|
+
update_builder=update_builder,
|
110
|
+
model=model,
|
111
|
+
)
|
110
112
|
|
111
113
|
@classmethod
|
112
|
-
def get(cls, id: str, sort_key: Optional[str] = None):
|
113
|
-
return cls._table.get_item(id=id, model_class=cls, sort_key=sort_key)
|
114
|
+
def get(cls, id: str, sort_key: Optional[str] = None, consistent_read: bool = False):
|
115
|
+
return cls._table.get_item(id=id, model_class=cls, sort_key=sort_key, consistent_read=consistent_read)
|
114
116
|
|
115
117
|
@classmethod
|
116
118
|
def batch_get(cls, ids: List[str], batch_size: int = 100):
|
117
119
|
return cls._table.batch_get_items(ids=ids, model_class=cls, batch_size=batch_size)
|
118
120
|
|
119
121
|
@classmethod
|
120
|
-
def scan(
|
122
|
+
def scan(
|
123
|
+
cls,
|
124
|
+
filter_condition: Optional[ComparisonCondition] = None,
|
125
|
+
consistent_read: bool = False,
|
126
|
+
):
|
121
127
|
return cls._table.scan(model_class=cls, filter_condition=filter_condition)
|
122
128
|
|
123
129
|
@staticmethod
|
@@ -1,6 +1,7 @@
|
|
1
|
+
from _decimal import Decimal
|
1
2
|
from datetime import datetime, timezone
|
2
3
|
from typing import List
|
3
|
-
|
4
|
+
from pydantic import BaseModel
|
4
5
|
import pytest
|
5
6
|
from boto3.dynamodb.conditions import Attr
|
6
7
|
from moto import mock_dynamodb
|
@@ -28,6 +29,7 @@ class MyAwesomeModel(DatabaseModel):
|
|
28
29
|
name: str = "Foo"
|
29
30
|
values: set = {1, 2, 3, 4}
|
30
31
|
cost: int = 4
|
32
|
+
probability: float = 0.5
|
31
33
|
|
32
34
|
|
33
35
|
class SimpleModel(DatabaseModel):
|
@@ -89,6 +91,7 @@ def test_create_my_awesome_model():
|
|
89
91
|
"values": {1, 2, 3, 4},
|
90
92
|
"cost": 4,
|
91
93
|
"type": "MyAwesomeModel",
|
94
|
+
"probability": 0.5,
|
92
95
|
}
|
93
96
|
model_2 = MyAwesomeModel(id="foo-2", player_id="123", tier="EPIC", name="FooFoo")
|
94
97
|
table.put_item(model_2)
|
@@ -102,6 +105,7 @@ def test_create_my_awesome_model():
|
|
102
105
|
"values": {1, 2, 3, 4},
|
103
106
|
"cost": 4,
|
104
107
|
"type": "MyAwesomeModel",
|
108
|
+
"probability": 0.5,
|
105
109
|
}
|
106
110
|
mock_dynamodb().stop()
|
107
111
|
|
@@ -591,11 +595,7 @@ def test_get_item_does_not_exist():
|
|
591
595
|
mock_dynamodb().stop()
|
592
596
|
|
593
597
|
|
594
|
-
def
|
595
|
-
pass
|
596
|
-
|
597
|
-
|
598
|
-
def test_update_add_attribute():
|
598
|
+
def test_update():
|
599
599
|
mock_dynamodb().start()
|
600
600
|
table = Table(
|
601
601
|
name="my-dynamodb-table",
|
@@ -612,13 +612,23 @@ def test_update_add_attribute():
|
|
612
612
|
_create_dynamodb_table(table)
|
613
613
|
model = MyAwesomeModel(id="foo", player_id="123", tier="LEGENDARY", name="FooFoo", values={1, 2, 3, 4})
|
614
614
|
model.save()
|
615
|
-
|
615
|
+
(
|
616
|
+
model.update()
|
617
|
+
.set("player_id", "456")
|
618
|
+
.set("tier", "EPIC")
|
619
|
+
.delete("values", {1})
|
620
|
+
.remove("name")
|
621
|
+
.add("cost", 1)
|
622
|
+
.execute()
|
623
|
+
)
|
616
624
|
item = table.get_item("foo", MyAwesomeModel)
|
617
625
|
assert item.player_id.value == "456"
|
618
626
|
assert item.values == {2, 3, 4}
|
619
627
|
assert item.name == "Foo" # default value
|
620
628
|
assert item.cost == 5
|
621
|
-
|
629
|
+
assert item.tier.value == "EPIC"
|
630
|
+
assert item.gsi_sk == "MyAwesomeModel|EPIC"
|
631
|
+
item.update().set("name", "FooFoo").execute()
|
622
632
|
item = table.get_item("foo", MyAwesomeModel)
|
623
633
|
assert item.name == "FooFoo"
|
624
634
|
mock_dynamodb().stop()
|
@@ -676,6 +686,41 @@ def test_query_no_range_key_provided():
|
|
676
686
|
mock_dynamodb().stop()
|
677
687
|
|
678
688
|
|
689
|
+
def test_query_no_range_is_provided_but_model_does_not_include_type_in_range_key():
|
690
|
+
class Model(DatabaseModel):
|
691
|
+
tier: IndexSecondaryKeyField
|
692
|
+
|
693
|
+
@classmethod
|
694
|
+
def type_is_primary_key(cls):
|
695
|
+
return True
|
696
|
+
|
697
|
+
@classmethod
|
698
|
+
def include_type_in_sort_key(cls):
|
699
|
+
return False
|
700
|
+
|
701
|
+
mock_dynamodb().start()
|
702
|
+
table = Table(
|
703
|
+
name="my-dynamodb-table",
|
704
|
+
key_schema=KeySchema(hash_key="id"),
|
705
|
+
indexes=[
|
706
|
+
GSI(
|
707
|
+
name="main-index",
|
708
|
+
hash_key=Key(name="gsi_pk"),
|
709
|
+
sort_key=Key(name="gsi_sk"),
|
710
|
+
)
|
711
|
+
],
|
712
|
+
models=[Model],
|
713
|
+
)
|
714
|
+
_create_dynamodb_table(table)
|
715
|
+
m = Model(tier="LEGENDARY")
|
716
|
+
m.save()
|
717
|
+
models = list(Model.query(hash_key=Equals("Model")))
|
718
|
+
assert len(models) == 1
|
719
|
+
assert models[0].tier.value == "LEGENDARY"
|
720
|
+
assert models[0].gsi_pk == "Model"
|
721
|
+
assert models[0].gsi_sk == "LEGENDARY"
|
722
|
+
|
723
|
+
|
679
724
|
def test_index_field_order_is_respected():
|
680
725
|
class ModelWithIndexOrdersDefined(DatabaseModel):
|
681
726
|
player_id: IndexPrimaryKeyField
|
@@ -703,3 +748,71 @@ def test_index_field_order_is_respected():
|
|
703
748
|
model.save()
|
704
749
|
item = table.get_item("123", ModelWithIndexOrdersDefined)
|
705
750
|
assert item.gsi_sk == "ModelWithIndexOrdersDefined|EPIC|Mage"
|
751
|
+
|
752
|
+
|
753
|
+
def test_nested_models():
|
754
|
+
class InnerInnerModel(BaseModel):
|
755
|
+
baz: str
|
756
|
+
|
757
|
+
class InnerModel(BaseModel):
|
758
|
+
foo: str
|
759
|
+
values: List[datetime] = [
|
760
|
+
datetime(2023, 9, 9, 12, 0, 0),
|
761
|
+
datetime(2023, 9, 9, 13, 0, 0),
|
762
|
+
]
|
763
|
+
cost: int = 5
|
764
|
+
inner_inner: InnerInnerModel
|
765
|
+
|
766
|
+
class NestedModel(DatabaseModel):
|
767
|
+
player_id: IndexPrimaryKeyField
|
768
|
+
unit_class: IndexSecondaryKeyField = IndexSecondaryKeyField(order=2)
|
769
|
+
tier: IndexSecondaryKeyField = IndexSecondaryKeyField(order=1)
|
770
|
+
name: str = "Foo"
|
771
|
+
values: set = {1, 2, 3, 4}
|
772
|
+
cost: int = 4
|
773
|
+
inner_model: InnerModel
|
774
|
+
|
775
|
+
mock_dynamodb().start()
|
776
|
+
table = Table(
|
777
|
+
name="my-dynamodb-table",
|
778
|
+
key_schema=KeySchema(hash_key="id"),
|
779
|
+
indexes=[
|
780
|
+
GSI(
|
781
|
+
name="main-index",
|
782
|
+
hash_key=Key(name="gsi_pk"),
|
783
|
+
sort_key=Key(name="gsi_sk"),
|
784
|
+
)
|
785
|
+
],
|
786
|
+
models=[NestedModel],
|
787
|
+
)
|
788
|
+
_create_dynamodb_table(table)
|
789
|
+
model = NestedModel(
|
790
|
+
id="123",
|
791
|
+
player_id="456",
|
792
|
+
unit_class="Mage",
|
793
|
+
tier="EPIC",
|
794
|
+
inner_model=InnerModel(foo="bar", inner_inner=InnerInnerModel(baz="baz")),
|
795
|
+
)
|
796
|
+
model.save()
|
797
|
+
item = table.get_item("123", NestedModel)
|
798
|
+
assert item.model_dump() == {
|
799
|
+
"id": "123",
|
800
|
+
"player_id": "456",
|
801
|
+
"unit_class": "Mage",
|
802
|
+
"tier": "EPIC",
|
803
|
+
"name": "Foo",
|
804
|
+
"values": {Decimal("1"), Decimal("2"), Decimal("3"), Decimal("4")},
|
805
|
+
"cost": 4,
|
806
|
+
"inner_model": {
|
807
|
+
"foo": "bar",
|
808
|
+
"values": [
|
809
|
+
datetime(2023, 9, 9, 10, 0, tzinfo=timezone.utc),
|
810
|
+
datetime(2023, 9, 9, 11, 0, tzinfo=timezone.utc),
|
811
|
+
],
|
812
|
+
"cost": 5,
|
813
|
+
"inner_inner": {"baz": "baz"},
|
814
|
+
},
|
815
|
+
"gsi_pk": "456",
|
816
|
+
"gsi_sk": "NestedModel|EPIC|Mage",
|
817
|
+
"type": "NestedModel",
|
818
|
+
}
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|