statikk 0.0.12__py3-none-any.whl → 0.1.0__py3-none-any.whl

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/conditions.py CHANGED
@@ -10,6 +10,7 @@ Example usage of this module:
10
10
  from statikk.conditions import Equals, BeginsWith
11
11
  app.query(range_key=Equals("123"), hash_key=BeginsWith("abc"))
12
12
  """
13
+
13
14
  from abc import ABC, abstractmethod
14
15
  from boto3.dynamodb.conditions import Key, ComparisonCondition
15
16
  from typing import Any
@@ -37,8 +38,8 @@ class BeginsWith(Condition):
37
38
  return Key(key).begins_with(self.value)
38
39
 
39
40
  def enrich(self, model_class, **kwargs):
40
- if not self.value.startswith(model_class.model_type()) and model_class.include_type_in_sort_key():
41
- self.value = f"{model_class.model_type()}|{self.value}"
41
+ if not self.value.startswith(model_class.type()):
42
+ self.value = f"{model_class.type()}|{self.value}"
42
43
 
43
44
 
44
45
  class LessThan(Condition):
statikk/engine.py CHANGED
@@ -13,17 +13,15 @@ from statikk.expressions import UpdateExpressionBuilder
13
13
  from statikk.models import (
14
14
  DatabaseModel,
15
15
  GSI,
16
- Index,
17
- IndexPrimaryKeyField,
18
- IndexSecondaryKeyField,
19
16
  KeySchema,
20
17
  )
18
+ from statikk.fields import FIELD_STATIKK_TYPE, FIELD_STATIKK_PARENT_ID
21
19
 
22
- from aws_xray_sdk.core import xray_recorder
23
20
  from aws_xray_sdk.core import patch_all
24
21
 
25
22
  patch_all()
26
23
 
24
+
27
25
  class InvalidIndexNameError(Exception):
28
26
  pass
29
27
 
@@ -32,6 +30,10 @@ class IncorrectSortKeyError(Exception):
32
30
  pass
33
31
 
34
32
 
33
+ class IncorrectHashKeyError(Exception):
34
+ pass
35
+
36
+
35
37
  class ItemNotFoundError(Exception):
36
38
  pass
37
39
 
@@ -56,8 +58,6 @@ class Table:
56
58
  for model in self.models:
57
59
  self._set_index_fields(model, idx)
58
60
  model.set_table_ref(self)
59
- if "type" not in model.model_fields:
60
- model.model_fields["type"] = FieldInfo(annotation=str, default=model.model_type(), required=False)
61
61
  self._client = None
62
62
  self._dynamodb_table = None
63
63
 
@@ -160,6 +160,9 @@ class Table:
160
160
  BillingMode=self.billing_mode,
161
161
  )
162
162
 
163
+ def _get_model_type_by_statikk_type(self, statikk_type: str) -> Type[DatabaseModel]:
164
+ return [model_type for model_type in self.models if model_type.type() == statikk_type][0]
165
+
163
166
  def delete(self):
164
167
  """Deletes the DynamoDB table."""
165
168
  self._dynamodb_client().delete_table(TableName=self.name)
@@ -185,18 +188,21 @@ class Table:
185
188
  raise ItemNotFoundError(f"{model_class} with id '{id}' not found.")
186
189
  data = raw_data["Item"]
187
190
  for key, value in data.items():
191
+ if key == FIELD_STATIKK_TYPE:
192
+ continue
188
193
  data[key] = self._deserialize_value(value, model_class.model_fields[key])
189
194
  return model_class(**data)
190
195
 
191
- def delete_item(self, id: str):
196
+ def delete_item(self, model: DatabaseModel):
192
197
  """
193
198
  Deletes an item from the database by id, using the partition key of the table.
194
199
  :param id: The id of the item to delete.
195
200
  """
196
- key = {self.key_schema.hash_key: id}
197
- self._get_dynamodb_table().delete_item(Key=key)
201
+ with self.batch_write() as batch:
202
+ for item in model.split_to_simple_objects():
203
+ batch.delete(item)
198
204
 
199
- def put_item(self, model: DatabaseModel) -> DatabaseModel:
205
+ def put_item(self, model: DatabaseModel):
200
206
  """
201
207
  Puts an item into the database.
202
208
 
@@ -207,7 +213,7 @@ class Table:
207
213
  always prefixed with the type of the model to avoid collisions between different model types.
208
214
 
209
215
  Example:
210
- class Card(DatabaseModel):
216
+ class Card(DatabaseModelV2):
211
217
  id: str
212
218
  player_id: IndexPrimaryKeyField
213
219
  type: IndexSecondaryKeyField
@@ -225,11 +231,9 @@ class Table:
225
231
 
226
232
  Returns the enriched database model instance.
227
233
  """
228
- data = self._serialize_item(model)
229
- self._get_dynamodb_table().put_item(Item=data)
230
- for key, value in data.items():
231
- data[key] = self._deserialize_value(value, model.model_fields[key])
232
- return type(model)(**data)
234
+ with self.batch_write() as batch:
235
+ for item in model.split_to_simple_objects():
236
+ batch.put(item)
233
237
 
234
238
  def update_item(
235
239
  self,
@@ -252,11 +256,11 @@ class Table:
252
256
  for prefixed_attribute, value in expression_attribute_values.items():
253
257
  expression_attribute_values[prefixed_attribute] = self._serialize_value(value)
254
258
  attribute = prefixed_attribute.replace(":", "")
255
- if model.model_fields[attribute].annotation is IndexSecondaryKeyField:
256
- idx_field = getattr(model, attribute)
257
- for idx in idx_field.index_names:
258
- idx_field.value = value
259
- changed_index_values.add(idx)
259
+ for index_name, index_fields in model.index_definitions().items():
260
+ if attribute in index_fields.sk_fields:
261
+ setattr(model, attribute, value)
262
+ changed_index_values.add(index_name)
263
+
260
264
  return changed_index_values
261
265
 
262
266
  changed_index_values = _find_changed_indexes()
@@ -279,6 +283,8 @@ class Table:
279
283
  response = self._get_dynamodb_table().update_item(**request)
280
284
  data = response["Attributes"]
281
285
  for key, value in data.items():
286
+ if key == FIELD_STATIKK_TYPE:
287
+ continue
282
288
  data[key] = self._deserialize_value(value, model.model_fields[key])
283
289
  return type(model)(**data)
284
290
 
@@ -289,7 +295,7 @@ class Table:
289
295
  """
290
296
  return BatchWriteContext(self)
291
297
 
292
- def query_index(
298
+ def _prepare_index_query_params(
293
299
  self,
294
300
  hash_key: Union[Condition | str],
295
301
  model_class: Type[DatabaseModel],
@@ -297,17 +303,6 @@ class Table:
297
303
  filter_condition: Optional[ComparisonCondition] = None,
298
304
  index_name: Optional[str] = None,
299
305
  ):
300
- """
301
- Queries the database using the provided hash key and range key conditions. A filter condition can also be provided
302
- using the filter_condition parameter. The method returns a list of items matching the query, deserialized into the
303
- provided model_class parameter.
304
-
305
- :param hash_key: The hash key condition to use for the query. See statikk.conditions.Condition for more information.
306
- :param range_key: The range key condition to use for the query. See statikk.conditions.Condition for more information.
307
- :param model_class: The model class to use to deserialize the items.
308
- :param filter_condition: An optional filter condition to use for the query. See boto3.dynamodb.conditions.ComparisonCondition for more information.
309
- :param index_name: The name of the index to use for the query. If not provided, the first index configured on the table is used.
310
- """
311
306
  if isinstance(hash_key, str):
312
307
  hash_key = Equals(hash_key)
313
308
  if not index_name:
@@ -317,28 +312,89 @@ class Table:
317
312
  raise InvalidIndexNameError(f"The provided index name '{index_name}' is not configured on the table.")
318
313
  index = index_filter[0]
319
314
  key_condition = hash_key.evaluate(index.hash_key.name)
320
- if range_key is None and model_class.include_type_in_sort_key():
321
- range_key = BeginsWith(model_class.model_type())
315
+ if range_key is None and FIELD_STATIKK_TYPE not in model_class.index_definitions()[index_name].pk_fields:
316
+ range_key = BeginsWith(model_class.type())
322
317
  if range_key:
323
- range_key.enrich(model_class=model_class)
318
+ if not model_class.is_nested():
319
+ range_key.enrich(model_class=model_class)
324
320
  key_condition = key_condition & range_key.evaluate(index.sort_key.name)
325
321
 
326
322
  query_params = {
327
323
  "IndexName": index_name,
328
324
  "KeyConditionExpression": key_condition,
329
325
  }
326
+
330
327
  if filter_condition:
331
328
  query_params["FilterExpression"] = filter_condition
329
+ return query_params
330
+
331
+ def query_index(
332
+ self,
333
+ hash_key: Union[Condition | str],
334
+ model_class: Type[DatabaseModel],
335
+ range_key: Optional[Condition] = None,
336
+ filter_condition: Optional[ComparisonCondition] = None,
337
+ index_name: Optional[str] = None,
338
+ ):
339
+ """
340
+ Queries the database using the provided hash key and range key conditions. A filter condition can also be provided
341
+ using the filter_condition parameter. The method returns a list of items matching the query, deserialized into the
342
+ provided model_class parameter.
343
+
344
+ :param hash_key: The hash key condition to use for the query. See statikk.conditions.Condition for more information.
345
+ :param range_key: The range key condition to use for the query. See statikk.conditions.Condition for more information.
346
+ :param model_class: The model class to use to deserialize the items.
347
+ :param filter_condition: An optional filter condition to use for the query. See boto3.dynamodb.conditions.ComparisonCondition for more information.
348
+ :param index_name: The name of the index to use for the query. If not provided, the first index configured on the table is used.
349
+ """
350
+ query_params = self._prepare_index_query_params(
351
+ hash_key=hash_key,
352
+ model_class=model_class,
353
+ range_key=range_key,
354
+ filter_condition=filter_condition,
355
+ index_name=index_name,
356
+ )
332
357
  last_evaluated_key = True
358
+ while last_evaluated_key:
359
+ items = self._get_dynamodb_table().query(**query_params)
360
+ yield from [model_class(**item) for item in items["Items"]]
361
+ last_evaluated_key = items.get("LastEvaluatedKey", False)
333
362
 
363
+ def query_hierarchy(
364
+ self,
365
+ hash_key: Union[Condition | str],
366
+ model_class: Type[DatabaseModel],
367
+ range_key: Optional[Condition] = None,
368
+ filter_condition: Optional[ComparisonCondition] = None,
369
+ index_name: Optional[str] = None,
370
+ ) -> Optional[DatabaseModel]:
371
+ query_params = self._prepare_index_query_params(
372
+ hash_key=hash_key,
373
+ model_class=model_class,
374
+ range_key=range_key,
375
+ filter_condition=filter_condition,
376
+ index_name=index_name,
377
+ )
378
+ hierarchy_items = []
379
+ last_evaluated_key = True
334
380
  while last_evaluated_key:
335
381
  items = self._get_dynamodb_table().query(**query_params)
336
- yield from [self._deserialize_item(item, model_class=model_class) for item in items["Items"]]
382
+ hierarchy_items.extend([item for item in items["Items"]])
337
383
  last_evaluated_key = items.get("LastEvaluatedKey", False)
338
384
 
385
+ reconstructed_dict = self.reconstruct_hierarchy(hierarchy_items)
386
+
387
+ if not reconstructed_dict:
388
+ return None
389
+
390
+ model_type = reconstructed_dict.get(FIELD_STATIKK_TYPE)
391
+ model_class = self._get_model_type_by_statikk_type(model_type)
392
+
393
+ reconstructed_dict.pop(FIELD_STATIKK_TYPE, None)
394
+ return model_class(**reconstructed_dict)
395
+
339
396
  def scan(
340
397
  self,
341
- model_class: Type[DatabaseModel],
342
398
  filter_condition: Optional[ComparisonCondition] = None,
343
399
  consistent_read: bool = False,
344
400
  ):
@@ -358,7 +414,7 @@ class Table:
358
414
 
359
415
  while last_evaluated_key:
360
416
  items = self._get_dynamodb_table().scan(**query_params)
361
- yield from [model_class(**item) for item in items["Items"]]
417
+ yield from [self._get_model_type_by_statikk_type(item["__statikk_type"])(**item) for item in items["Items"]]
362
418
  last_evaluated_key = items.get("LastEvaluatedKey", False)
363
419
 
364
420
  def _convert_dynamodb_to_python(self, item) -> Dict[str, Any]:
@@ -400,9 +456,9 @@ class Table:
400
456
  def _prepare_model_data(
401
457
  self,
402
458
  item: DatabaseModel,
403
- indexes: List[Index],
459
+ indexes: List[GSI],
404
460
  force_override_index_fields: bool = False,
405
- ) -> Dict[str, Any]:
461
+ ) -> DatabaseModel:
406
462
  for idx in indexes:
407
463
  index_fields = self._compose_index_values(item, idx)
408
464
  for key, value in index_fields.items():
@@ -411,24 +467,41 @@ class Table:
411
467
  if value is not None:
412
468
  setattr(item, key, value)
413
469
  item.model_rebuild(force=True)
414
- return item.model_dump()
470
+ return item
415
471
 
416
472
  def _serialize_item(self, item: DatabaseModel):
417
- data = self._prepare_model_data(item, self.indexes)
473
+ data = item.model_dump(exclude=item.get_nested_model_fields())
474
+ serialized_data = {}
418
475
  for key, value in data.items():
419
476
  data[key] = self._serialize_value(value)
420
477
  return data
421
478
 
422
479
  def _deserialize_item(self, item: Dict[str, Any], model_class: Type[DatabaseModel]):
423
480
  for key, value in item.items():
481
+ if key == FIELD_STATIKK_TYPE:
482
+ continue
424
483
  item[key] = self._deserialize_value(value, model_class.model_fields[key])
425
484
  return model_class(**item)
426
485
 
427
486
  def _deserialize_value(self, value: Any, annotation: Any):
428
- if annotation is datetime or "datetime" in str(annotation) and value is not None:
487
+ actual_annotation = annotation.annotation if hasattr(annotation, "annotation") else annotation
488
+
489
+ if actual_annotation is datetime or "datetime" in str(annotation) and value is not None:
429
490
  return datetime.fromtimestamp(int(value))
430
- if annotation is float:
491
+ if actual_annotation is float:
431
492
  return float(value)
493
+ if actual_annotation is list:
494
+ origin = getattr(actual_annotation, "__origin__", None)
495
+ args = getattr(actual_annotation, "__args__", None)
496
+ item_annotation = args[0] if args else Any
497
+ return [self._deserialize_value(item, item_annotation) for item in value]
498
+ if actual_annotation is set:
499
+ origin = getattr(actual_annotation, "__origin__", None)
500
+ args = getattr(actual_annotation, "__args__", None)
501
+ item_annotation = args[0] if args else Any
502
+ return {self._deserialize_value(item, item_annotation) for item in value}
503
+ if isinstance(value, dict):
504
+ return {key: self._deserialize_value(item, annotation) for key, item in value.items() if item is not None}
432
505
  return value
433
506
 
434
507
  def _serialize_value(self, value: Any):
@@ -438,36 +511,26 @@ class Table:
438
511
  return Decimal(value)
439
512
  if isinstance(value, list):
440
513
  return [self._serialize_value(item) for item in value]
514
+ if isinstance(value, set):
515
+ return {self._serialize_value(item) for item in value}
441
516
  if isinstance(value, dict):
442
517
  return {key: self._serialize_value(item) for key, item in value.items() if item is not None}
443
518
  return value
444
519
 
445
- def _set_index_fields(self, model: DatabaseModel | Type[DatabaseModel], idx: GSI):
520
+ def _set_index_fields(self, model: Type[DatabaseModel], idx: GSI):
446
521
  model_fields = model.model_fields
447
- if idx.hash_key.name not in model_fields:
522
+ if idx.hash_key.name not in model_fields.keys():
448
523
  model_fields[idx.hash_key.name] = FieldInfo(annotation=idx.hash_key.type, default=None, required=False)
449
- if idx.sort_key.name not in model_fields:
524
+ if idx.sort_key.name not in model_fields.keys():
450
525
  model_fields[idx.sort_key.name] = FieldInfo(annotation=idx.sort_key.type, default=None, required=False)
451
526
 
452
527
  def _get_sort_key_value(self, model: DatabaseModel, idx: GSI) -> str:
453
- sort_key_fields_unordered = [
454
- (field_name, getattr(model, field_name).order)
455
- for field_name, field_info in model.model_fields.items()
456
- if field_info.annotation is not None
457
- if field_info.annotation is IndexSecondaryKeyField and idx.name in getattr(model, field_name).index_names
458
- ]
459
-
460
- if len(sort_key_fields_unordered) == idx.sort_key is not None:
528
+ sort_key_fields = model.index_definitions().get(idx.name, []).sk_fields
529
+ if len(sort_key_fields) == 0:
461
530
  raise IncorrectSortKeyError(f"Model {model.__class__} does not have a sort key defined.")
462
- if sort_key_fields_unordered[0][1] is not None:
463
- sort_key_fields_unordered.sort(key=lambda x: x[1])
464
531
 
465
- sort_key_fields = [field[0] for field in sort_key_fields_unordered]
466
-
467
- if len(sort_key_fields) == 0 and model.include_type_in_sort_key():
468
- return model.model_type()
469
532
  if idx.sort_key.type is not str:
470
- value = getattr(model, sort_key_fields[0]).value
533
+ value = getattr(model, sort_key_fields[0])
471
534
  if type(value) is not idx.sort_key.type:
472
535
  raise IncorrectSortKeyError(
473
536
  f"Incorrect sort key type. Sort key type for sort key '{idx.sort_key.name}' should be: "
@@ -478,32 +541,28 @@ class Table:
478
541
  value = value or idx.sort_key.default
479
542
  return self._serialize_value(value)
480
543
 
481
- sort_key_values: List[str] = []
482
- if model.include_type_in_sort_key() and model.model_type() not in sort_key_values:
483
- sort_key_values.append(model.model_type())
544
+ sort_key_values = []
545
+ if model._parent:
546
+ sort_key_values.append(getattr(model._parent, idx.sort_key.name))
547
+ if FIELD_STATIKK_TYPE not in model.index_definitions()[idx.name].pk_fields:
548
+ sort_key_values.append(model.type())
549
+ for sort_key_field in sort_key_fields:
550
+ if sort_key_field in model.model_fields.keys():
551
+ sort_key_values.append(getattr(model, sort_key_field))
484
552
 
485
- for field in sort_key_fields:
486
- value = getattr(model, field).value
487
- sort_key_values.append(value)
488
553
  return self.delimiter.join(sort_key_values)
489
554
 
490
555
  def _compose_index_values(self, model: DatabaseModel, idx: GSI) -> Dict[str, Any]:
491
- model_fields = model.model_fields
492
- hash_key_field = [
493
- field_name
494
- for field_name, field_info in model_fields.items()
495
- if field_info.annotation is not None
496
- if field_info.annotation is IndexPrimaryKeyField and idx.name in getattr(model, field_name).index_names
497
- ]
498
- if len(hash_key_field) == 0 and model.type_is_primary_key():
499
- hash_key_field.append(model.model_type())
500
- hash_key_field = hash_key_field[0]
556
+ hash_key_fields = model.index_definitions().get(idx.name, []).pk_fields
557
+
558
+ if len(hash_key_fields) == 0 and not model.is_nested():
559
+ raise IncorrectHashKeyError(f"Model {model.__class__} does not have a hash key defined.")
501
560
 
502
561
  def _get_hash_key_value():
503
- if hash_key_field == model.model_type():
504
- return model.model_type()
505
- else:
506
- return getattr(model, hash_key_field).value
562
+ if model._parent:
563
+ return getattr(model._parent, idx.hash_key.name)
564
+
565
+ return self.delimiter.join([self._serialize_value(model.get_attribute(field)) for field in hash_key_fields])
507
566
 
508
567
  return {
509
568
  idx.hash_key.name: _get_hash_key_value(),
@@ -519,15 +578,165 @@ class Table:
519
578
  if len(put_items) > 0:
520
579
  with dynamodb_table.batch_writer() as batch:
521
580
  for item in put_items:
522
- data = self._serialize_item(item)
581
+ enriched_item = self._prepare_model_data(item, self.indexes)
582
+ if not enriched_item.was_modified:
583
+ continue
584
+ if not enriched_item.should_write_to_database():
585
+ continue
586
+ data = self._serialize_item(enriched_item)
523
587
  batch.put_item(Item=data)
524
588
 
525
589
  if len(delete_items) > 0:
526
590
  with dynamodb_table.batch_writer() as batch:
527
591
  for item in delete_items:
528
- data = self._serialize_item(item)
592
+ enriched_item = self._prepare_model_data(item, self.indexes)
593
+ data = self._serialize_item(enriched_item)
529
594
  batch.delete_item(Key=data)
530
595
 
596
+ def reconstruct_hierarchy(self, items: list[dict]) -> Optional[dict]:
597
+ """
598
+ Reconstructs a hierarchical dictionary structure from a flat list of dictionaries
599
+ using explicit parent-child relationships and model class definitions.
600
+
601
+ Args:
602
+ items: A flat list of dictionaries representing models with FIELD_STATIKK_PARENT_ID
603
+
604
+ Returns:
605
+ The top-level dictionary with its hierarchy fully reconstructed, or None if the list is empty
606
+ """
607
+ if not items:
608
+ return None
609
+
610
+ items_by_id = {item["id"]: item for item in items}
611
+ children_by_parent_id = {}
612
+ for item in items:
613
+ parent_id = item.get(FIELD_STATIKK_PARENT_ID)
614
+ if parent_id:
615
+ if parent_id not in children_by_parent_id:
616
+ children_by_parent_id[parent_id] = []
617
+ children_by_parent_id[parent_id].append(item)
618
+
619
+ # Find the root item (the one with no parent ID)
620
+ root_items = [item for item in items if FIELD_STATIKK_PARENT_ID not in item]
621
+
622
+ if not root_items:
623
+ return None
624
+
625
+ if len(root_items) > 1:
626
+ root_item = root_items[0]
627
+ else:
628
+ root_item = root_items[0]
629
+
630
+ processed_items = set()
631
+ return self._reconstruct_item_with_children(root_item, items_by_id, children_by_parent_id, processed_items)
632
+
633
+ def _reconstruct_item_with_children(
634
+ self, item: dict, items_by_id: dict, children_by_parent_id: dict, processed_items: set
635
+ ) -> dict:
636
+ """
637
+ Recursively reconstruct an item and its children using model class definitions.
638
+
639
+ Args:
640
+ item: The item to reconstruct
641
+ items_by_id: Map of all item IDs to items
642
+ children_by_parent_id: Map of parent IDs to lists of child items
643
+ processed_items: Set of already processed item IDs to avoid duplicates
644
+
645
+ Returns:
646
+ The reconstructed item with all child references integrated
647
+ """
648
+ if item["id"] in processed_items:
649
+ return item
650
+ processed_items.add(item["id"])
651
+ result = item.copy()
652
+
653
+ if FIELD_STATIKK_PARENT_ID in result:
654
+ result.pop(FIELD_STATIKK_PARENT_ID)
655
+
656
+ children = children_by_parent_id.get(item["id"], [])
657
+ if not children:
658
+ return result
659
+
660
+ parent_model_class = self._get_model_type_by_statikk_type(item[FIELD_STATIKK_TYPE])
661
+
662
+ children_by_type = {}
663
+ for child in children:
664
+ child_type = child[FIELD_STATIKK_TYPE]
665
+ if child_type not in children_by_type:
666
+ children_by_type[child_type] = []
667
+ children_by_type[child_type].append(child)
668
+
669
+ for child_type, child_items in children_by_type.items():
670
+ child_model_class = self._get_model_type_by_statikk_type(child_type)
671
+ matching_fields = []
672
+
673
+ for field_name, field_info in parent_model_class.model_fields.items():
674
+ if field_name.startswith("_"):
675
+ continue
676
+
677
+ field_type = field_info.annotation
678
+
679
+ if field_type == child_model_class:
680
+ matching_fields.append((field_name, "single"))
681
+
682
+ elif hasattr(field_type, "__origin__") and field_type.__origin__ == list:
683
+ args = getattr(field_type, "__args__", [])
684
+ if args and args[0] == child_model_class:
685
+ matching_fields.append((field_name, "list"))
686
+
687
+ elif hasattr(field_type, "__origin__") and field_type.__origin__ == set:
688
+ args = getattr(field_type, "__args__", [])
689
+ if args and args[0] == child_model_class:
690
+ matching_fields.append((field_name, "set"))
691
+
692
+ if matching_fields:
693
+ for field_name, container_type in matching_fields:
694
+ if container_type == "list":
695
+ if field_name not in result:
696
+ result[field_name] = []
697
+
698
+ existing_ids = {
699
+ item.get("id") for item in result[field_name] if isinstance(item, dict) and "id" in item
700
+ }
701
+
702
+ for child in child_items:
703
+ if child["id"] in existing_ids:
704
+ continue
705
+
706
+ reconstructed_child = self._reconstruct_item_with_children(
707
+ child, items_by_id, children_by_parent_id, processed_items
708
+ )
709
+
710
+ result[field_name].append(reconstructed_child)
711
+ existing_ids.add(child["id"])
712
+
713
+ elif container_type == "set":
714
+ if field_name not in result:
715
+ result[field_name] = []
716
+
717
+ existing_ids = {
718
+ item.get("id") for item in result[field_name] if isinstance(item, dict) and "id" in item
719
+ }
720
+
721
+ for child in child_items:
722
+ if child["id"] in existing_ids:
723
+ continue
724
+ reconstructed_child = self._reconstruct_item_with_children(
725
+ child, items_by_id, children_by_parent_id, processed_items
726
+ )
727
+
728
+ result[field_name].append(reconstructed_child)
729
+ existing_ids.add(child["id"])
730
+
731
+ elif container_type == "single":
732
+ if child_items:
733
+ reconstructed_child = self._reconstruct_item_with_children(
734
+ child_items[0], items_by_id, children_by_parent_id, processed_items
735
+ )
736
+ result[field_name] = reconstructed_child
737
+
738
+ return result
739
+
531
740
 
532
741
  class BatchWriteContext:
533
742
  def __init__(self, app: Table):
statikk/fields.py ADDED
@@ -0,0 +1,2 @@
1
+ FIELD_STATIKK_TYPE = "__statikk_type"
2
+ FIELD_STATIKK_PARENT_ID = "__statikk_parent_id"
statikk/models.py CHANGED
@@ -1,15 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
- import uuid
3
+ import typing
4
+ import logging
5
+ from uuid import uuid4
4
6
  from typing import Optional, List, Any, Set, Type
5
7
 
6
8
  from boto3.dynamodb.conditions import ComparisonCondition
7
- from pydantic import BaseModel, model_serializer, model_validator
8
- from pydantic.fields import FieldInfo
9
+ from pydantic import BaseModel, model_serializer, model_validator, Field, Extra
10
+ from pydantic.fields import FieldInfo, Field
9
11
  from pydantic_core._pydantic_core import PydanticUndefined
10
12
 
11
13
  from statikk.conditions import Condition
12
14
  from statikk.expressions import DatabaseModelUpdateExpressionBuilder
15
+ from statikk.fields import FIELD_STATIKK_TYPE, FIELD_STATIKK_PARENT_ID
16
+
17
+ if typing.TYPE_CHECKING:
18
+ from statikk.engine import Table
19
+
20
+ logger = logging.getLogger(__name__)
13
21
 
14
22
 
15
23
  class Key(BaseModel):
@@ -39,37 +47,114 @@ class Index(BaseModel):
39
47
  return self.value
40
48
 
41
49
 
42
- class IndexPrimaryKeyField(Index):
43
- pass
50
+ class IndexFieldConfig(BaseModel):
51
+ pk_fields: list[str] = []
52
+ sk_fields: list[str] = []
44
53
 
45
54
 
46
- class IndexSecondaryKeyField(Index):
47
- order: Optional[int] = None
55
+ class TrackingMixin:
56
+ _original_hash: int = Field(exclude=True)
48
57
 
58
+ def __init__(self):
59
+ self._original_hash = self._recursive_hash()
49
60
 
50
- class DatabaseModel(BaseModel):
51
- id: str
61
+ def _recursive_hash(self) -> int:
62
+ """
63
+ Compute a hash value for the model, ignoring nested DatabaseModel instances.
52
64
 
53
- @classmethod
54
- def model_type(cls):
55
- return cls.__name__
65
+ This ensures that changes to child models don't affect the parent's hash.
66
+
67
+ Returns:
68
+ A hash value representing the model's non-model fields.
69
+ """
70
+ values = []
71
+ for field_name in self.model_fields:
72
+ if not hasattr(self, field_name):
73
+ continue
74
+
75
+ if field_name.startswith("_"):
76
+ continue
77
+
78
+ value = getattr(self, field_name)
79
+
80
+ if hasattr(value, "__class__") and issubclass(value.__class__, DatabaseModel):
81
+ continue
82
+
83
+ if isinstance(value, list) or isinstance(value, set):
84
+ contains_model = False
85
+ for item in value:
86
+ if hasattr(item, "__class__") and issubclass(item.__class__, DatabaseModel):
87
+ contains_model = True
88
+ break
89
+ if contains_model:
90
+ continue
91
+
92
+ if isinstance(value, dict):
93
+ contains_model = False
94
+ if not contains_model:
95
+ for val in value.values():
96
+ if hasattr(val, "__class__") and issubclass(val.__class__, DatabaseModel):
97
+ contains_model = True
98
+ break
99
+ if contains_model:
100
+ continue
101
+
102
+ values.append(self._make_hashable(value))
103
+
104
+ return hash(tuple(values))
105
+
106
+ def _make_hashable(self, value: Any) -> Any:
107
+ if isinstance(value, (str, int, float, bool, type(None))):
108
+ return value
109
+ elif isinstance(value, list) or isinstance(value, set):
110
+ return tuple(self._make_hashable(item) for item in value)
111
+ elif isinstance(value, dict):
112
+ return tuple((self._make_hashable(k), self._make_hashable(v)) for k, v in sorted(value.items()))
113
+ elif isinstance(value, BaseModel):
114
+ return value._recursive_hash() if hasattr(value, "_recursive_hash") else hash(value)
115
+ else:
116
+ try:
117
+ return hash(value)
118
+ except Exception:
119
+ logger.warning(
120
+ f"{type(value)} is unhashable, tracking will not work. Consider implementing the TrackingMixin for this type."
121
+ )
122
+ return value
123
+
124
+ @property
125
+ def was_modified(self) -> bool:
126
+ return self._recursive_hash() != self._original_hash
127
+
128
+
129
+ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
130
+ id: str = Field(default_factory=lambda: str(uuid4()))
131
+ _parent: Optional[DatabaseModel] = None
132
+ _model_types_in_hierarchy: dict[str, Type[DatabaseModel]] = {}
56
133
 
57
134
  @classmethod
58
- def include_type_in_sort_key(cls):
59
- return True
135
+ def type(cls) -> str:
136
+ return cls.__name__
60
137
 
61
138
  @classmethod
62
- def type_is_primary_key(cls):
63
- return False
139
+ def index_definitions(cls) -> dict[str, IndexFieldConfig]:
140
+ return {"main_index": IndexFieldConfig(pk_fields=[], sk_fields=[])}
64
141
 
65
142
  @classmethod
66
- def set_table_ref(cls, table):
143
+ def set_table_ref(cls, table: "Table"):
67
144
  cls._table = table
68
145
 
69
146
  @classmethod
70
147
  def batch_write(cls):
71
148
  return cls._table.batch_write()
72
149
 
150
+ @classmethod
151
+ def is_nested(cls) -> bool:
152
+ return False
153
+
154
+ @property
155
+ def is_simple_object(self) -> bool:
156
+ return len(self._model_types_in_hierarchy) == 1
157
+
73
158
  @classmethod
74
159
  def query(
75
160
  cls,
@@ -86,11 +171,27 @@ class DatabaseModel(BaseModel):
86
171
  index_name=index_name,
87
172
  )
88
173
 
174
+ @classmethod
175
+ def query_hierarchy(
176
+ cls,
177
+ hash_key: Union[Condition | str],
178
+ range_key: Optional[Condition] = None,
179
+ filter_condition: Optional[ComparisonCondition] = None,
180
+ index_name: Optional[str] = None,
181
+ ) -> DatabaseModel:
182
+ return cls._table.query_hierarchy(
183
+ hash_key=hash_key,
184
+ model_class=cls,
185
+ range_key=range_key,
186
+ filter_condition=filter_condition,
187
+ index_name=index_name,
188
+ )
189
+
89
190
  def save(self):
90
191
  return self._table.put_item(self)
91
192
 
92
193
  def delete(self):
93
- return self._table.delete_item(self.id)
194
+ return self._table.delete_item(self)
94
195
 
95
196
  def update(self, sort_key: Optional[str] = None) -> DatabaseModelUpdateExpressionBuilder:
96
197
  return DatabaseModelUpdateExpressionBuilder(self, sort_key)
@@ -118,73 +219,141 @@ class DatabaseModel(BaseModel):
118
219
  def batch_get(cls, ids: List[str], batch_size: int = 100):
119
220
  return cls._table.batch_get_items(ids=ids, model_class=cls, batch_size=batch_size)
120
221
 
222
+ def should_write_to_database(self) -> bool:
223
+ if self.is_nested():
224
+ return self._parent.should_write_to_database()
225
+ return True
226
+
121
227
  @classmethod
122
228
  def scan(
123
229
  cls,
124
230
  filter_condition: Optional[ComparisonCondition] = None,
125
231
  consistent_read: bool = False,
126
232
  ):
127
- return cls._table.scan(model_class=cls, filter_condition=filter_condition)
233
+ return cls._table.scan(filter_condition=filter_condition)
234
+
235
+ @model_serializer(mode="wrap")
236
+ def serialize_model(self, handler):
237
+ data = handler(self)
238
+ data[FIELD_STATIKK_TYPE] = self.type()
239
+ if self._parent:
240
+ data[FIELD_STATIKK_PARENT_ID] = self._parent.id
241
+ return data
128
242
 
129
- @staticmethod
130
- def _index_types() -> Set[Type[Index]]:
131
- return {Index, IndexPrimaryKeyField, IndexSecondaryKeyField}
243
+ @model_validator(mode="after")
244
+ def initialize_tracking(self):
245
+ self._original_hash = self._recursive_hash()
246
+ self._model_types_in_hierarchy[self.type()] = type(self)
247
+ if not self.is_nested():
248
+ self._set_parent_references(self)
132
249
 
133
- @staticmethod
134
- def _is_index_field(field: FieldInfo) -> bool:
135
- index_types = {Index, IndexPrimaryKeyField, IndexSecondaryKeyField}
136
- return field.annotation in index_types
250
+ return self
137
251
 
138
- @classmethod
139
- def _create_index_field_from_shorthand(cls, field: FieldInfo, value: str) -> FieldInfo:
140
- annotation = field.annotation
141
- extra_fields = dict()
142
- if field.default is not PydanticUndefined:
143
- extra_fields["index_names"] = field.default.index_names
144
- if field.annotation is IndexSecondaryKeyField:
145
- extra_fields["order"] = field.default.order
146
- return annotation(value=value, **extra_fields)
147
-
148
- @model_validator(mode="before")
149
- @classmethod
150
- def check_boxed_indexes(cls, data: Any) -> Any:
252
+ def split_to_simple_objects(self, items: Optional[list[DatabaseModel]] = None) -> list[DatabaseModel]:
151
253
  """
152
- This method allows for a cleaner interface when working with model indexes.
153
- Instead of having to manually box the index value, this method will do it for you.
154
- For example:
155
- my_model = MyModel(id="123", my_index="abc")
156
- Is equivalent to:
157
- my_model = MyModel(id="123", my_index=IndexPrimaryKeyField(value="abc"))
158
- """
159
- if isinstance(data, dict):
160
- if "id" not in data:
161
- data["id"] = str(uuid.uuid4())
162
-
163
- for key, value in data.items():
164
- field = cls.model_fields[key]
165
- if cls._is_index_field(field) and not isinstance(value, dict):
166
- if isinstance(value, tuple(cls._index_types())):
167
- continue
168
- else:
169
- data[key] = cls._create_index_field_from_shorthand(field, value)
170
- return data
254
+ Split a complex nested DatabaseModel into a list of individual DatabaseModel instances.
171
255
 
172
- @model_validator(mode="after")
173
- def validate_index_fields(self):
174
- sort_key_field_orders = [
175
- getattr(self, field_name).order
176
- for field_name, field_info in self.model_fields.items()
177
- if field_info.annotation is not None
178
- if field_info.annotation is IndexSecondaryKeyField
179
- ]
180
- have_orders_defined = any([order for order in sort_key_field_orders])
181
- all_orders_defined = all([order for order in sort_key_field_orders])
182
- use_default_ordering = all([order is None for order in sort_key_field_orders])
183
- if have_orders_defined and not all_orders_defined and not use_default_ordering:
184
- raise ValueError(
185
- f"`order` is not defined on at least one of the Index keys on model {type(self)}. Please set the order for all sort key fields."
186
- )
187
- if all_orders_defined and len(set(sort_key_field_orders)) != len(sort_key_field_orders):
188
- raise ValueError(
189
- f"Duplicate `order` values found on model {type(self)}. Please ensure that all `order` values are unique."
190
- )
256
+ This method recursively traverses the model and all its nested DatabaseModel instances,
257
+ collecting them into a flat list for simpler processing or storage.
258
+
259
+ Args:
260
+ items: An optional existing list to add items to. If None, a new list is created.
261
+
262
+ Returns:
263
+ A list containing this model and all nested DatabaseModel instances.
264
+ """
265
+ if items is None:
266
+ items = [self]
267
+ else:
268
+ if self not in items:
269
+ items.append(self)
270
+
271
+ # Iterate through all fields of the model
272
+ for field_name, field_value in self:
273
+ # Skip fields that start with underscore (private fields)
274
+ if field_name.startswith("_"):
275
+ continue
276
+
277
+ # Handle direct DatabaseModel instances
278
+ if hasattr(field_value, "__class__") and issubclass(field_value.__class__, DatabaseModel):
279
+ if field_value not in items:
280
+ items.append(field_value)
281
+ field_value.split_to_simple_objects(items)
282
+
283
+ # Handle lists containing DatabaseModel instances
284
+ elif isinstance(field_value, list):
285
+ for item in field_value:
286
+ if hasattr(item, "__class__") and issubclass(item.__class__, DatabaseModel):
287
+ if item not in items:
288
+ items.append(item)
289
+ item.split_to_simple_objects(items)
290
+
291
+ # Handle sets containing DatabaseModel instances
292
+ elif isinstance(field_value, set):
293
+ for item in field_value:
294
+ if hasattr(item, "__class__") and issubclass(item.__class__, DatabaseModel):
295
+ if item not in items:
296
+ items.append(item)
297
+ item.split_to_simple_objects(items)
298
+
299
+ # Handle dictionaries that may contain DatabaseModel instances
300
+ elif isinstance(field_value, dict):
301
+ # Check dictionary values
302
+ for value in field_value.values():
303
+ if hasattr(value, "__class__") and issubclass(value.__class__, DatabaseModel):
304
+ if value not in items:
305
+ items.append(value)
306
+ value.split_to_simple_objects(items)
307
+
308
+ return items
309
+
310
+ def get_attribute(self, attribute_name: str):
311
+ if attribute_name == FIELD_STATIKK_TYPE:
312
+ return self.type()
313
+ return getattr(self, attribute_name)
314
+
315
+ def get_nested_model_fields(self) -> set[DatabaseModel]:
316
+ nested_models = []
317
+ for field_name, field_value in self:
318
+ if issubclass(field_value.__class__, DatabaseModel) and field_value.is_nested():
319
+ nested_models.append(field_name)
320
+ elif isinstance(field_value, list):
321
+ for item in field_value:
322
+ if issubclass(item.__class__, DatabaseModel) and item.is_nested():
323
+ nested_models.append(field_name)
324
+ elif isinstance(field_value, set):
325
+ for item in field_value:
326
+ if issubclass(item.__class__, DatabaseModel) and item.is_nested():
327
+ nested_models.append(field_name)
328
+ elif isinstance(field_value, dict):
329
+ for key, value in field_value.items():
330
+ if issubclass(value.__class__, DatabaseModel) and value.is_nested():
331
+ nested_models.append(field_name)
332
+ return set(nested_models)
333
+
334
+ def get_type_from_hierarchy_by_name(self, name: str) -> Optional[Type[DatabaseModel]]:
335
+ return self._model_types_in_hierarchy.get(name)
336
+
337
+ def _set_parent_to_field(self, field: DatabaseModel, parent: DatabaseModel, root: DatabaseModel):
338
+ if field._parent:
339
+ return # Already set
340
+ field._parent = parent
341
+ root._model_types_in_hierarchy[field.type()] = type(field)
342
+ field._set_parent_references(root)
343
+
344
+ def _set_parent_references(self, root: DatabaseModel):
345
+ for field_name, field_value in self:
346
+ if isinstance(field_value, DatabaseModel):
347
+ self._set_parent_to_field(field_value, self, root)
348
+ elif isinstance(field_value, list):
349
+ for item in field_value:
350
+ if isinstance(item, DatabaseModel):
351
+ self._set_parent_to_field(item, self, root)
352
+ elif isinstance(field_value, set):
353
+ for item in field_value:
354
+ if isinstance(item, DatabaseModel):
355
+ self._set_parent_to_field(item, self, root)
356
+ elif isinstance(field_value, dict):
357
+ for key, value in field_value.items():
358
+ if isinstance(value, DatabaseModel):
359
+ self._set_parent_to_field(value, self, root)
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: statikk
3
- Version: 0.0.12
3
+ Version: 0.1.0
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
@@ -13,25 +13,23 @@ Classifier: Programming Language :: Python
13
13
  Requires-Python: >=3.8
14
14
  Description-Content-Type: text/x-rst; charset=UTF-8
15
15
  License-File: LICENSE.txt
16
- Requires-Dist: pydantic ==2.7.4
17
- Requires-Dist: boto3 ==1.28.43
18
- Requires-Dist: aws-xray-sdk ==2.14.0
19
- Requires-Dist: importlib-metadata ; python_version < "3.8"
16
+ Requires-Dist: importlib-metadata; python_version < "3.8"
17
+ Requires-Dist: pydantic<3.0,>=2.7.4
18
+ Requires-Dist: boto3<2.0,>=1.28.43
19
+ Requires-Dist: aws-xray-sdk<3.0,>=2.14.0
20
20
  Provides-Extra: testing
21
- Requires-Dist: setuptools ; extra == 'testing'
22
- Requires-Dist: pytest ; extra == 'testing'
23
- Requires-Dist: pytest-cov ; extra == 'testing'
24
- Requires-Dist: moto[dynamodb] ==4.2.14 ; extra == 'testing'
21
+ Requires-Dist: setuptools; extra == "testing"
22
+ Requires-Dist: pytest; extra == "testing"
23
+ Requires-Dist: pytest-cov; extra == "testing"
24
+ Requires-Dist: moto[dynamodb]==4.2.14; extra == "testing"
25
25
 
26
26
  .. image:: ./assets/logo.png
27
27
  :alt: Statikk
28
28
  :align: center
29
29
 
30
- We built this library because we got fed up with all the boilerplate we needed to write to use DynamoDB in a Single Table Application architecture.
31
- We were originally using `PynamoDB <https://github.com/pynamodb/PynamoDB>`_ , but we found it to be too verbose for our liking.
30
+ An ORM-like Single Table Application (STA) architecture library for Python and DynamoDb. Makes it really eason to set up and work with STAs.
32
31
 
33
- This library is very much in alpha phase and is not yet recommended for production use. We are actively working on it and using it as a core library
34
- for our upcoming game, Conquests of Ethoas. Drastic API changes can still happen.
32
+ The library is in alpha. Constant work is being done on it as I'm developing my game.
35
33
 
36
34
  =================
37
35
  Requirements
@@ -55,15 +53,19 @@ Basic Usage
55
53
 
56
54
  .. code-block:: python
57
55
 
58
- from statikk.models import DatabaseModel, Table, GlobalSecondaryIndex, KeySchema, IndexPrimaryKeyField, IndexSecondaryKeyField
56
+ from statikk.models import DatabaseModel, Table, GlobalSecondaryIndex, KeySchema
59
57
 
60
58
  class MyAwesomeModel(DatabaseModel):
61
- player_id: IndexPrimaryKeyField
62
- tier: IndexSecondaryKeyField
59
+ player_id: str
60
+ tier: str
63
61
  name: str = "Foo"
64
62
  values: set = {1, 2, 3, 4}
65
63
  cost: int = 4
66
64
 
65
+ @classmethod
66
+ def index_definitions(cls) -> dict[str, IndexFieldConfig]:
67
+ return {"main-index": IndexFieldConfig(pk_fields=["player_id"], sk_fields=["tier"])}
68
+
67
69
  table = Table(
68
70
  name="my-dynamodb-table",
69
71
  key_schema=KeySchema(hash_key="id"),
@@ -0,0 +1,11 @@
1
+ statikk/__init__.py,sha256=pH5i4Fj1tbXLqLtTVIdoojiplZssQn0nnud8-HXodRE,577
2
+ statikk/conditions.py,sha256=63FYMR-UUaE-ZJEb_8CU721CQTwhajq39-BbokmKeMA,2166
3
+ statikk/engine.py,sha256=GCcqowgJa041Oo22sarSqDUQRWS4GbwHSNz_WGmcpNM,30855
4
+ statikk/expressions.py,sha256=mF6Hmj3Kmj6KKXTymeTHSepVA7rhiSINpFgSAPeBTRY,12210
5
+ statikk/fields.py,sha256=LkMP5NnX7WS0HSLxI3Q-dMOrfaJ0SD7SayZxJU5Acgg,86
6
+ statikk/models.py,sha256=S5KdjNH0ufGn7iW4ZTVMXv1e177U95C28mbUlwDax_k,12831
7
+ statikk-0.1.0.dist-info/LICENSE.txt,sha256=uSH_2Hpb2Bigy5_HhBliN2fZbBU64G3ERM5zzhKPUEE,1078
8
+ statikk-0.1.0.dist-info/METADATA,sha256=Aqf0Od-2YmGbkQRqX36IhwPPzx-r5Wjr2ipe5dBcj7o,3160
9
+ statikk-0.1.0.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
10
+ statikk-0.1.0.dist-info/top_level.txt,sha256=etKmBbjzIlLpSefXoiOfhWGEgvqUEALaFwCjFDBD9YI,8
11
+ statikk-0.1.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (70.2.0)
2
+ Generator: setuptools (75.8.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,10 +0,0 @@
1
- statikk/__init__.py,sha256=pH5i4Fj1tbXLqLtTVIdoojiplZssQn0nnud8-HXodRE,577
2
- statikk/conditions.py,sha256=66kJ-2YWqlV-dzEkiAe6c985dMQ-lpaoZ8aaoWhSs_M,2220
3
- statikk/engine.py,sha256=aYIVTwUxlSLnoB8DOUpX4udf0PcIoiTzgSfJ3YCtPaU,22193
4
- statikk/expressions.py,sha256=mF6Hmj3Kmj6KKXTymeTHSepVA7rhiSINpFgSAPeBTRY,12210
5
- statikk/models.py,sha256=q-wY2bQRgaH0NTnR4NGnQ22eO3rUshK-aAykt7NVFO4,6172
6
- statikk-0.0.12.dist-info/LICENSE.txt,sha256=uSH_2Hpb2Bigy5_HhBliN2fZbBU64G3ERM5zzhKPUEE,1078
7
- statikk-0.0.12.dist-info/METADATA,sha256=KEEKDEMhWuuw_uSGW16PptvjXQIyMxi96Hh9Zg8Z4yI,3339
8
- statikk-0.0.12.dist-info/WHEEL,sha256=y4mX-SOX4fYIkonsAGA5N0Oy-8_gI4FXw5HNI1xqvWg,91
9
- statikk-0.0.12.dist-info/top_level.txt,sha256=etKmBbjzIlLpSefXoiOfhWGEgvqUEALaFwCjFDBD9YI,8
10
- statikk-0.0.12.dist-info/RECORD,,