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.
Files changed (41) hide show
  1. {statikk-0.0.8 → statikk-0.0.10}/PKG-INFO +1 -1
  2. {statikk-0.0.8 → statikk-0.0.10}/docs/usage.rst +3 -1
  3. {statikk-0.0.8 → statikk-0.0.10}/src/statikk/engine.py +106 -53
  4. {statikk-0.0.8 → statikk-0.0.10}/src/statikk/expressions.py +16 -6
  5. {statikk-0.0.8 → statikk-0.0.10}/src/statikk/models.py +16 -10
  6. {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/PKG-INFO +1 -1
  7. {statikk-0.0.8 → statikk-0.0.10}/tests/test_engine.py +121 -8
  8. {statikk-0.0.8 → statikk-0.0.10}/.coveragerc +0 -0
  9. {statikk-0.0.8 → statikk-0.0.10}/.gitignore +0 -0
  10. {statikk-0.0.8 → statikk-0.0.10}/.readthedocs.yml +0 -0
  11. {statikk-0.0.8 → statikk-0.0.10}/AUTHORS.rst +0 -0
  12. {statikk-0.0.8 → statikk-0.0.10}/CHANGELOG.rst +0 -0
  13. {statikk-0.0.8 → statikk-0.0.10}/CONTRIBUTING.rst +0 -0
  14. {statikk-0.0.8 → statikk-0.0.10}/LICENSE.txt +0 -0
  15. {statikk-0.0.8 → statikk-0.0.10}/README.rst +0 -0
  16. {statikk-0.0.8 → statikk-0.0.10}/assets/favicon.png +0 -0
  17. {statikk-0.0.8 → statikk-0.0.10}/assets/logo.png +0 -0
  18. {statikk-0.0.8 → statikk-0.0.10}/docs/Makefile +0 -0
  19. {statikk-0.0.8 → statikk-0.0.10}/docs/_static/.gitignore +0 -0
  20. {statikk-0.0.8 → statikk-0.0.10}/docs/authors.rst +0 -0
  21. {statikk-0.0.8 → statikk-0.0.10}/docs/changelog.rst +0 -0
  22. {statikk-0.0.8 → statikk-0.0.10}/docs/conf.py +0 -0
  23. {statikk-0.0.8 → statikk-0.0.10}/docs/contributing.rst +0 -0
  24. {statikk-0.0.8 → statikk-0.0.10}/docs/index.rst +0 -0
  25. {statikk-0.0.8 → statikk-0.0.10}/docs/license.rst +0 -0
  26. {statikk-0.0.8 → statikk-0.0.10}/docs/readme.rst +0 -0
  27. {statikk-0.0.8 → statikk-0.0.10}/docs/requirements.txt +0 -0
  28. {statikk-0.0.8 → statikk-0.0.10}/pyproject.toml +0 -0
  29. {statikk-0.0.8 → statikk-0.0.10}/setup.cfg +0 -0
  30. {statikk-0.0.8 → statikk-0.0.10}/setup.py +0 -0
  31. {statikk-0.0.8 → statikk-0.0.10}/src/statikk/__init__.py +0 -0
  32. {statikk-0.0.8 → statikk-0.0.10}/src/statikk/conditions.py +0 -0
  33. {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/SOURCES.txt +0 -0
  34. {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/dependency_links.txt +0 -0
  35. {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/not-zip-safe +0 -0
  36. {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/requires.txt +0 -0
  37. {statikk-0.0.8 → statikk-0.0.10}/src/statikk.egg-info/top_level.txt +0 -0
  38. {statikk-0.0.8 → statikk-0.0.10}/tests/conftest.py +0 -0
  39. {statikk-0.0.8 → statikk-0.0.10}/tests/test_expressions.py +0 -0
  40. {statikk-0.0.8 → statikk-0.0.10}/tests/test_models.py +0 -0
  41. {statikk-0.0.8 → statikk-0.0.10}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: statikk
3
- Version: 0.0.8
3
+ Version: 0.0.10
4
4
  Summary: statikk is a single table application (STA) library for DynamoDb.
5
5
  Home-page: https://github.com/terinia/statikk
6
6
  Author: Balint Biro
@@ -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
- Card.update().set("tier", "LEGENDARY").delete("values", {1}).add("cost", 4).remove("name").execute()
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(self, id: str, model_class: Type[DatabaseModel], sort_key: Optional[Any] = None):
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
- return response
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
- range_key.enrich(model_class=model_class)
279
- key_condition = key_condition & range_key.evaluate(index.sort_key.name)
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 [model_class(**item) for item in items["Items"]]
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(self, item: DatabaseModel) -> Dict[str, Any]:
353
- for idx in self.indexes:
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 "annotation=datetime" in str(annotation):
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 _compose_index_values(self, model: DatabaseModel, idx: GSI) -> Dict[str, Any]:
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
- def _get_sort_key_value():
411
- if len(sort_key_fields) == 0 and model.include_type_in_sort_key():
412
- return model.model_type()
413
- if idx.sort_key.type is not str:
414
- value = getattr(model, sort_key_fields[0]).value
415
- if type(value) is not idx.sort_key.type:
416
- raise IncorrectSortKeyError(
417
- f"Incorrect sort key type. Sort key type for sort key '{idx.sort_key.name}' should be: "
418
- + str(idx.sort_key.type)
419
- + " but got: "
420
- + str(type(value))
421
- )
422
- value = value or idx.sort_key.default
423
- return self._serialize_value(value)
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
- sort_key_values: List[str] = []
426
- if model.include_type_in_sort_key() and model.model_type() not in sort_key_values:
427
- sort_key_values.append(model.model_type())
428
-
429
- for field in sort_key_fields:
430
- value = getattr(model, field).value
431
- sort_key_values.append(value)
432
- return self.delimiter.join(sort_key_values)
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 = f"#n_{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, model_cls: Type[DatabaseModel], id: str, sort_key: Optional[str] = None):
673
- self.hash_key = id
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 = 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(self.hash_key, range_key=self.sort_key, update_builder=self)
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
- @classmethod
99
- def update(cls, id: str, sort_key: Optional[str] = None) -> DatabaseModelUpdateExpressionBuilder:
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(hash_key, range_key=range_key, update_builder=update_builder)
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(cls, filter_condition: Optional[ComparisonCondition] = None):
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,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: statikk
3
- Version: 0.0.8
3
+ Version: 0.0.10
4
4
  Summary: statikk is a single table application (STA) library for DynamoDb.
5
5
  Home-page: https://github.com/terinia/statikk
6
6
  Author: Balint Biro
@@ -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 test_update_set_attribute():
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
- MyAwesomeModel.update("foo").set("player_id", "456").delete("values", {1}).remove("name").add("cost", 1).execute()
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
- MyAwesomeModel.update("foo").set("name", "FooFoo").execute()
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