statikk 0.1.14__py3-none-any.whl → 0.1.15__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/engine.py CHANGED
@@ -16,7 +16,7 @@ from statikk.models import (
16
16
  GSI,
17
17
  KeySchema,
18
18
  )
19
- from statikk.fields import FIELD_STATIKK_TYPE, FIELD_STATIKK_PARENT_ID
19
+ from statikk.fields import FIELD_STATIKK_TYPE, FIELD_STATIKK_PARENT_ID, FIELD_STATIKK_PARENT_FIELD_NAME
20
20
  from copy import deepcopy
21
21
  from aws_xray_sdk.core import patch_all
22
22
 
@@ -296,20 +296,23 @@ class Table:
296
296
  response = self._get_dynamodb_table().update_item(**request)
297
297
  data = response["Attributes"]
298
298
  for key, value in data.items():
299
- if key in [FIELD_STATIKK_TYPE, FIELD_STATIKK_PARENT_ID]:
299
+ if key in [FIELD_STATIKK_TYPE, FIELD_STATIKK_PARENT_ID, FIELD_STATIKK_PARENT_FIELD_NAME]:
300
300
  continue
301
301
  data[key] = self._deserialize_value(value, model.model_fields[key])
302
302
  return type(model)(**data)
303
303
 
304
- def reparent_subtree(self, subtree_root: T, new_parent: T) -> T:
304
+ def reparent_subtree(self, subtree_root: T, new_parent: T, field_name: str) -> T:
305
305
  subtree_copy = deepcopy(subtree_root)
306
306
  subtree_root._parent_changed = True
307
307
 
308
308
  subtree_copy.set_parent_references(subtree_copy, force_override=True)
309
309
  subtree_copy._parent = new_parent
310
+ subtree_copy._parent_field_name = field_name
311
+ parent = subtree_copy._parent
310
312
  for node in subtree_copy.dfs_traverse_hierarchy():
311
313
  self.build_model_indexes(node)
312
- setattr(node, FIELD_STATIKK_PARENT_ID, new_parent.id)
314
+ setattr(node, FIELD_STATIKK_PARENT_ID, parent.id)
315
+ parent = node
313
316
 
314
317
  return subtree_copy
315
318
 
@@ -427,7 +430,9 @@ class Table:
427
430
  model_class = self._get_model_type_by_statikk_type(model_type)
428
431
 
429
432
  reconstructed_dict.pop(FIELD_STATIKK_TYPE, None)
430
- return model_class(**reconstructed_dict)
433
+ model = model_class.model_validate(reconstructed_dict)
434
+ model._is_persisted = True
435
+ return model
431
436
 
432
437
  def scan(
433
438
  self,
@@ -626,6 +631,7 @@ class Table:
626
631
  continue
627
632
  if not enriched_item.should_write_to_database():
628
633
  continue
634
+ enriched_item._is_persisted = True
629
635
  data = self._serialize_item(enriched_item)
630
636
  batch.put_item(Item=data)
631
637
 
@@ -640,9 +646,6 @@ class Table:
640
646
  Returns:
641
647
  The top-level dictionary with its hierarchy fully reconstructed, or None if the list is empty
642
648
  """
643
- if not items:
644
- return None
645
-
646
649
  items_by_id = {item["id"]: item for item in items}
647
650
  children_by_parent_id = {}
648
651
  for item in items:
@@ -652,128 +655,74 @@ class Table:
652
655
  children_by_parent_id[parent_id] = []
653
656
  children_by_parent_id[parent_id].append(item)
654
657
 
655
- # Find the root item (the one with no parent ID)
656
- root_items = [item for item in items if FIELD_STATIKK_PARENT_ID not in item]
658
+ root_item = [item for item in items if FIELD_STATIKK_PARENT_ID not in item][0]
657
659
 
658
- if not root_items:
659
- return None
660
-
661
- if len(root_items) > 1:
662
- root_item = root_items[0]
663
- else:
664
- root_item = root_items[0]
660
+ processed_root = self._process_item(root_item, items_by_id, children_by_parent_id)
661
+ return processed_root
665
662
 
666
- processed_items = set()
667
- return self._reconstruct_item_with_children(root_item, items_by_id, children_by_parent_id, processed_items)
668
-
669
- def _reconstruct_item_with_children(
670
- self, item: dict, items_by_id: dict, children_by_parent_id: dict, processed_items: set
671
- ) -> dict:
663
+ def _process_item(self, item: dict, items_by_id: dict, children_by_parent_id: dict) -> dict:
672
664
  """
673
- Recursively reconstruct an item and its children using model class definitions.
665
+ Recursively processes an item and all its children to rebuild the hierarchical structure.
674
666
 
675
667
  Args:
676
- item: The item to reconstruct
677
- items_by_id: Map of all item IDs to items
678
- children_by_parent_id: Map of parent IDs to lists of child items
679
- processed_items: Set of already processed item IDs to avoid duplicates
668
+ item: The current item to process
669
+ items_by_id: Dictionary mapping item IDs to items
670
+ children_by_parent_id: Dictionary mapping parent IDs to lists of child items
680
671
 
681
672
  Returns:
682
- The reconstructed item with all child references integrated
673
+ The processed item with all its child relationships resolved
683
674
  """
684
- if item["id"] in processed_items:
685
- return item
686
- processed_items.add(item["id"])
687
- result = item.copy()
688
-
689
- if FIELD_STATIKK_PARENT_ID in result:
690
- result.pop(FIELD_STATIKK_PARENT_ID)
675
+ processed_item = deepcopy(item)
691
676
 
692
- children = children_by_parent_id.get(item["id"], [])
693
- if not children:
694
- return result
677
+ if FIELD_STATIKK_TYPE in processed_item:
678
+ model_class = self._get_model_type_by_statikk_type(processed_item[FIELD_STATIKK_TYPE])
679
+ model_fields = model_class.model_fields
680
+ else:
681
+ return processed_item
695
682
 
696
- parent_model_class = self._get_model_type_by_statikk_type(item[FIELD_STATIKK_TYPE])
683
+ # Get children of this item
684
+ children = children_by_parent_id.get(processed_item["id"], [])
697
685
 
698
- children_by_type = {}
686
+ # Group children by parent field name
687
+ children_by_field = {}
699
688
  for child in children:
700
- child_type = child[FIELD_STATIKK_TYPE]
701
- if child_type not in children_by_type:
702
- children_by_type[child_type] = []
703
- children_by_type[child_type].append(child)
704
-
705
- for child_type, child_items in children_by_type.items():
706
- child_model_class = self._get_model_type_by_statikk_type(child_type)
707
- matching_fields = []
708
-
709
- for field_name, field_info in parent_model_class.model_fields.items():
710
- if field_name.startswith("_"):
711
- continue
712
-
713
- is_optional, inner_type = inspect_optional_field(parent_model_class, field_name)
714
-
715
- field_type = inner_type if is_optional else field_info.annotation
716
-
717
- if field_type == child_model_class:
718
- matching_fields.append((field_name, "single"))
719
-
720
- elif hasattr(field_type, "__origin__") and field_type.__origin__ == list:
721
- args = getattr(field_type, "__args__", [])
722
- if args and args[0] == child_model_class:
723
- matching_fields.append((field_name, "list"))
724
-
725
- elif hasattr(field_type, "__origin__") and field_type.__origin__ == set:
726
- args = getattr(field_type, "__args__", [])
727
- if args and args[0] == child_model_class:
728
- matching_fields.append((field_name, "set"))
729
-
730
- if matching_fields:
731
- for field_name, container_type in matching_fields:
732
- if container_type == "list":
733
- if field_name not in result:
734
- result[field_name] = []
735
-
736
- existing_ids = {
737
- item.get("id") for item in result[field_name] if isinstance(item, dict) and "id" in item
738
- }
739
-
740
- for child in child_items:
741
- if child["id"] in existing_ids:
742
- continue
689
+ field_name = child.get(FIELD_STATIKK_PARENT_FIELD_NAME)
690
+ if field_name:
691
+ if field_name not in children_by_field:
692
+ children_by_field[field_name] = []
693
+ children_by_field[field_name].append(child)
694
+
695
+ for field_name, field_info in model_fields.items():
696
+ if field_name not in children_by_field:
697
+ continue
743
698
 
744
- reconstructed_child = self._reconstruct_item_with_children(
745
- child, items_by_id, children_by_parent_id, processed_items
746
- )
699
+ field_children = children_by_field[field_name]
747
700
 
748
- result[field_name].append(reconstructed_child)
749
- existing_ids.add(child["id"])
701
+ field_type = field_info.annotation
702
+ is_optional = False
703
+ inner_type = field_type
750
704
 
751
- elif container_type == "set":
752
- if field_name not in result:
753
- result[field_name] = []
705
+ if hasattr(field_type, "__origin__") and field_type.__origin__ is Union:
706
+ args = field_type.__args__
707
+ if type(None) in args:
708
+ is_optional = True
709
+ # Get the non-None type
710
+ inner_type = next(arg for arg in args if arg is not type(None))
754
711
 
755
- existing_ids = {
756
- item.get("id") for item in result[field_name] if isinstance(item, dict) and "id" in item
757
- }
712
+ if hasattr(inner_type, "__origin__") and inner_type.__origin__ == list:
713
+ child_list = []
758
714
 
759
- for child in child_items:
760
- if child["id"] in existing_ids:
761
- continue
762
- reconstructed_child = self._reconstruct_item_with_children(
763
- child, items_by_id, children_by_parent_id, processed_items
764
- )
715
+ for child in field_children:
716
+ processed_child = self._process_item(child, items_by_id, children_by_parent_id)
717
+ child_list.append(processed_child)
765
718
 
766
- result[field_name].append(reconstructed_child)
767
- existing_ids.add(child["id"])
719
+ processed_item[field_name] = child_list
768
720
 
769
- elif container_type == "single":
770
- if child_items:
771
- reconstructed_child = self._reconstruct_item_with_children(
772
- child_items[0], items_by_id, children_by_parent_id, processed_items
773
- )
774
- result[field_name] = reconstructed_child
721
+ elif len(field_children) == 1:
722
+ processed_child = self._process_item(field_children[0], items_by_id, children_by_parent_id)
723
+ processed_item[field_name] = processed_child
775
724
 
776
- return result
725
+ return processed_item
777
726
 
778
727
 
779
728
  class BatchWriteContext:
statikk/fields.py CHANGED
@@ -1,2 +1,3 @@
1
1
  FIELD_STATIKK_TYPE = "__statikk_type"
2
2
  FIELD_STATIKK_PARENT_ID = "__statikk_parent_id"
3
+ FIELD_STATIKK_PARENT_FIELD_NAME = "__statikk_parent_field_name"
statikk/models.py CHANGED
@@ -14,7 +14,7 @@ from pydantic_core._pydantic_core import PydanticUndefined
14
14
 
15
15
  from statikk.conditions import Condition
16
16
  from statikk.expressions import DatabaseModelUpdateExpressionBuilder
17
- from statikk.fields import FIELD_STATIKK_TYPE, FIELD_STATIKK_PARENT_ID
17
+ from statikk.fields import FIELD_STATIKK_TYPE, FIELD_STATIKK_PARENT_ID, FIELD_STATIKK_PARENT_FIELD_NAME
18
18
 
19
19
  if typing.TYPE_CHECKING:
20
20
  from statikk.engine import Table
@@ -196,14 +196,20 @@ class TrackingMixin:
196
196
  return {}
197
197
 
198
198
 
199
- class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
199
+ class DatabaseModel(BaseModel, TrackingMixin):
200
200
  id: str = Field(default_factory=lambda: str(uuid4()))
201
201
  _parent: Optional[DatabaseModel] = None
202
+ _parent_field_name: Optional[str] = None
202
203
  _model_types_in_hierarchy: dict[str, Type[DatabaseModel]] = {}
203
204
  _should_delete: bool = False
204
205
  _parent_changed: bool = False
206
+ _is_persisted: bool = False
205
207
  _session = Session()
206
208
 
209
+ class Config:
210
+ extra = Extra.allow
211
+ arbitrary_types_allowed = True
212
+
207
213
  def __eq__(self, other):
208
214
  return self.id == other.id
209
215
 
@@ -255,12 +261,19 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
255
261
  def is_simple_object(self) -> bool:
256
262
  return len(self._model_types_in_hierarchy) == 1
257
263
 
264
+ @property
265
+ def is_persisted(self) -> bool:
266
+ if self._parent is not None:
267
+ return self._parent.is_persisted
268
+
269
+ return self._is_persisted
270
+
258
271
  @property
259
272
  def should_delete(self) -> bool:
260
273
  if self._is_any_parent_marked_for_deletion():
261
274
  return True
262
275
 
263
- return self._should_delete or self.is_parent_changed()
276
+ return self.is_persisted and (self._should_delete or self.is_parent_changed())
264
277
 
265
278
  def _is_any_parent_marked_for_deletion(self) -> bool:
266
279
  current = self._parent
@@ -346,8 +359,8 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
346
359
  def mark_for_delete(self):
347
360
  self._should_delete = True
348
361
 
349
- def change_parent_to(self, new_parent: DatabaseModel) -> T:
350
- return self._table.reparent_subtree(self, new_parent)
362
+ def _change_parent_to(self, new_parent: DatabaseModel, field_name: str) -> T:
363
+ return self._table.reparent_subtree(self, new_parent, field_name)
351
364
 
352
365
  def _remove_from_parent(self, parent, field_name, subtree):
353
366
  is_optional, inner_type = inspect_optional_field(parent.__class__, field_name)
@@ -384,17 +397,17 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
384
397
  if hasattr(field_type, "__origin__") and field_type.__origin__ == list:
385
398
  if not isinstance(getattr(self, field_name), list):
386
399
  setattr(self, field_name, [])
387
- reparented = child_node.change_parent_to(self)
400
+ reparented = child_node._change_parent_to(self, field_name)
388
401
  getattr(self, field_name).append(reparented)
389
402
 
390
403
  elif hasattr(field_type, "__origin__") and field_type.__origin__ == set:
391
404
  if not isinstance(getattr(self, field_name), set):
392
405
  setattr(self, field_name, set())
393
- reparented = child_node.change_parent_to(self)
406
+ reparented = child_node._change_parent_to(self, field_name)
394
407
  getattr(self, field_name).add(reparented)
395
408
 
396
409
  elif issubclass(field_type, DatabaseModel):
397
- reparented = child_node.change_parent_to(self)
410
+ reparented = child_node._change_parent_to(self, field_name)
398
411
  setattr(self, field_name, reparented)
399
412
 
400
413
  if reparented:
@@ -418,6 +431,7 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
418
431
  data[FIELD_STATIKK_TYPE] = self.type()
419
432
  if self._parent:
420
433
  data[FIELD_STATIKK_PARENT_ID] = self._parent.id
434
+ data[FIELD_STATIKK_PARENT_FIELD_NAME] = self._parent_field_name
421
435
  return data
422
436
 
423
437
  @model_validator(mode="after")
@@ -465,13 +479,6 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
465
479
  items.append(item)
466
480
  item.split_to_simple_objects(items)
467
481
 
468
- elif isinstance(field_value, set):
469
- for item in field_value:
470
- if hasattr(item, "__class__") and issubclass(item.__class__, DatabaseModel):
471
- if item not in items:
472
- items.append(item)
473
- item.split_to_simple_objects(items)
474
-
475
482
  return items
476
483
 
477
484
  def get_attribute(self, attribute_name: str):
@@ -488,14 +495,6 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
488
495
  for item in field_value:
489
496
  if issubclass(item.__class__, DatabaseModel) and item.is_nested():
490
497
  nested_models.append(field_name)
491
- elif isinstance(field_value, set):
492
- for item in field_value:
493
- if issubclass(item.__class__, DatabaseModel) and item.is_nested():
494
- nested_models.append(field_name)
495
- elif isinstance(field_value, dict):
496
- for key, value in field_value.items():
497
- if issubclass(value.__class__, DatabaseModel) and value.is_nested():
498
- nested_models.append(field_name)
499
498
  return set(nested_models)
500
499
 
501
500
  def get_type_from_hierarchy_by_name(self, name: str) -> Optional[Type[DatabaseModel]]:
@@ -512,6 +511,7 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
512
511
  if field._parent and not force_override:
513
512
  return # Already set
514
513
  field._parent = parent
514
+ field._parent_field_name = field_name
515
515
  if field.should_track_session:
516
516
  field._session.add_change(parent, field_name, field)
517
517
  root._model_types_in_hierarchy[field.type()] = type(field)
@@ -532,14 +532,10 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
532
532
  for field_name, field_value in self:
533
533
  if isinstance(field_value, DatabaseModel):
534
534
  yield self, field_name, field_value
535
- elif isinstance(field_value, (list, set)):
535
+ elif isinstance(field_value, list):
536
536
  for item in field_value:
537
537
  if isinstance(item, DatabaseModel):
538
538
  yield self, field_name, item
539
- elif isinstance(field_value, dict):
540
- for key, value in field_value.items():
541
- if isinstance(value, DatabaseModel):
542
- yield self, key, value
543
539
 
544
540
  def dfs_traverse_hierarchy(self):
545
541
  """
@@ -555,11 +551,7 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
555
551
  for field_name, field_value in fields:
556
552
  if isinstance(field_value, DatabaseModel):
557
553
  yield from field_value.dfs_traverse_hierarchy()
558
- elif isinstance(field_value, (list, set)):
554
+ elif isinstance(field_value, list):
559
555
  for item in field_value:
560
556
  if isinstance(item, DatabaseModel):
561
557
  yield from item.dfs_traverse_hierarchy()
562
- elif isinstance(field_value, dict):
563
- for key, value in field_value.items():
564
- if isinstance(value, DatabaseModel):
565
- yield from value.dfs_traverse_hierarchy()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statikk
3
- Version: 0.1.14
3
+ Version: 0.1.15
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
@@ -0,0 +1,12 @@
1
+ statikk/__init__.py,sha256=pH5i4Fj1tbXLqLtTVIdoojiplZssQn0nnud8-HXodRE,577
2
+ statikk/conditions.py,sha256=63FYMR-UUaE-ZJEb_8CU721CQTwhajq39-BbokmKeMA,2166
3
+ statikk/engine.py,sha256=ccQ57Xsg1brgbDZMHewPzTuCEvz0vRkEVHCRhvX2m5Q,30502
4
+ statikk/expressions.py,sha256=boAeGxZj2cDsXxoiX3IIEzfX9voSMQngi4-rE_jYeuE,12233
5
+ statikk/fields.py,sha256=uLGD3xEnz2nqQY6EwG8Dd7pnRMMVP4PU2Z2I-uGNaTA,150
6
+ statikk/models.py,sha256=3vDyq_ox6zCGsSw72PcIbOEf4pgs3oJskLhvf--EtMQ,19740
7
+ statikk/typing.py,sha256=laOlOpWOm9_sOj4hhdZnGTUZRiq8760_B9I9B3wBhz8,750
8
+ statikk-0.1.15.dist-info/licenses/LICENSE.txt,sha256=uSH_2Hpb2Bigy5_HhBliN2fZbBU64G3ERM5zzhKPUEE,1078
9
+ statikk-0.1.15.dist-info/METADATA,sha256=sL1Y0GF7pUk3leDfgyTlhV69F5wnOiCPm5jO3WPLkcg,3183
10
+ statikk-0.1.15.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
11
+ statikk-0.1.15.dist-info/top_level.txt,sha256=etKmBbjzIlLpSefXoiOfhWGEgvqUEALaFwCjFDBD9YI,8
12
+ statikk-0.1.15.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- statikk/__init__.py,sha256=pH5i4Fj1tbXLqLtTVIdoojiplZssQn0nnud8-HXodRE,577
2
- statikk/conditions.py,sha256=63FYMR-UUaE-ZJEb_8CU721CQTwhajq39-BbokmKeMA,2166
3
- statikk/engine.py,sha256=-YmezdiYViz1sR60VL7ryDCvrhp-WWGVCtPAakKRzg8,32636
4
- statikk/expressions.py,sha256=boAeGxZj2cDsXxoiX3IIEzfX9voSMQngi4-rE_jYeuE,12233
5
- statikk/fields.py,sha256=LkMP5NnX7WS0HSLxI3Q-dMOrfaJ0SD7SayZxJU5Acgg,86
6
- statikk/models.py,sha256=zLImQUtUURbAQ3gYAiotkypxaDUqAIiCL1edNzuey3k,20447
7
- statikk/typing.py,sha256=laOlOpWOm9_sOj4hhdZnGTUZRiq8760_B9I9B3wBhz8,750
8
- statikk-0.1.14.dist-info/licenses/LICENSE.txt,sha256=uSH_2Hpb2Bigy5_HhBliN2fZbBU64G3ERM5zzhKPUEE,1078
9
- statikk-0.1.14.dist-info/METADATA,sha256=8NHWnMMS3Qy53_I9V3EDWOY6x3wTzQ_juoYSPS9QeII,3183
10
- statikk-0.1.14.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
11
- statikk-0.1.14.dist-info/top_level.txt,sha256=etKmBbjzIlLpSefXoiOfhWGEgvqUEALaFwCjFDBD9YI,8
12
- statikk-0.1.14.dist-info/RECORD,,