statikk 0.1.17__tar.gz → 0.1.19__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 (43) hide show
  1. {statikk-0.1.17 → statikk-0.1.19}/PKG-INFO +1 -1
  2. {statikk-0.1.17 → statikk-0.1.19}/src/statikk/engine.py +47 -17
  3. {statikk-0.1.17 → statikk-0.1.19}/src/statikk/models.py +13 -78
  4. {statikk-0.1.17 → statikk-0.1.19}/src/statikk.egg-info/PKG-INFO +1 -1
  5. {statikk-0.1.17 → statikk-0.1.19}/tests/test_engine.py +0 -9
  6. {statikk-0.1.17 → statikk-0.1.19}/.coveragerc +0 -0
  7. {statikk-0.1.17 → statikk-0.1.19}/.gitignore +0 -0
  8. {statikk-0.1.17 → statikk-0.1.19}/.readthedocs.yml +0 -0
  9. {statikk-0.1.17 → statikk-0.1.19}/AUTHORS.rst +0 -0
  10. {statikk-0.1.17 → statikk-0.1.19}/CHANGELOG.rst +0 -0
  11. {statikk-0.1.17 → statikk-0.1.19}/CONTRIBUTING.rst +0 -0
  12. {statikk-0.1.17 → statikk-0.1.19}/LICENSE.txt +0 -0
  13. {statikk-0.1.17 → statikk-0.1.19}/README.rst +0 -0
  14. {statikk-0.1.17 → statikk-0.1.19}/assets/favicon.png +0 -0
  15. {statikk-0.1.17 → statikk-0.1.19}/assets/logo.png +0 -0
  16. {statikk-0.1.17 → statikk-0.1.19}/docs/Makefile +0 -0
  17. {statikk-0.1.17 → statikk-0.1.19}/docs/_static/.gitignore +0 -0
  18. {statikk-0.1.17 → statikk-0.1.19}/docs/authors.rst +0 -0
  19. {statikk-0.1.17 → statikk-0.1.19}/docs/changelog.rst +0 -0
  20. {statikk-0.1.17 → statikk-0.1.19}/docs/conf.py +0 -0
  21. {statikk-0.1.17 → statikk-0.1.19}/docs/contributing.rst +0 -0
  22. {statikk-0.1.17 → statikk-0.1.19}/docs/index.rst +0 -0
  23. {statikk-0.1.17 → statikk-0.1.19}/docs/license.rst +0 -0
  24. {statikk-0.1.17 → statikk-0.1.19}/docs/readme.rst +0 -0
  25. {statikk-0.1.17 → statikk-0.1.19}/docs/requirements.txt +0 -0
  26. {statikk-0.1.17 → statikk-0.1.19}/docs/usage.rst +0 -0
  27. {statikk-0.1.17 → statikk-0.1.19}/pyproject.toml +0 -0
  28. {statikk-0.1.17 → statikk-0.1.19}/setup.cfg +0 -0
  29. {statikk-0.1.17 → statikk-0.1.19}/setup.py +0 -0
  30. {statikk-0.1.17 → statikk-0.1.19}/src/statikk/__init__.py +0 -0
  31. {statikk-0.1.17 → statikk-0.1.19}/src/statikk/conditions.py +0 -0
  32. {statikk-0.1.17 → statikk-0.1.19}/src/statikk/expressions.py +0 -0
  33. {statikk-0.1.17 → statikk-0.1.19}/src/statikk/fields.py +0 -0
  34. {statikk-0.1.17 → statikk-0.1.19}/src/statikk/typing.py +0 -0
  35. {statikk-0.1.17 → statikk-0.1.19}/src/statikk.egg-info/SOURCES.txt +0 -0
  36. {statikk-0.1.17 → statikk-0.1.19}/src/statikk.egg-info/dependency_links.txt +0 -0
  37. {statikk-0.1.17 → statikk-0.1.19}/src/statikk.egg-info/not-zip-safe +0 -0
  38. {statikk-0.1.17 → statikk-0.1.19}/src/statikk.egg-info/requires.txt +0 -0
  39. {statikk-0.1.17 → statikk-0.1.19}/src/statikk.egg-info/top_level.txt +0 -0
  40. {statikk-0.1.17 → statikk-0.1.19}/tests/conftest.py +0 -0
  41. {statikk-0.1.17 → statikk-0.1.19}/tests/test_expressions.py +0 -0
  42. {statikk-0.1.17 → statikk-0.1.19}/tests/test_models.py +0 -0
  43. {statikk-0.1.17 → statikk-0.1.19}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statikk
3
- Version: 0.1.17
3
+ Version: 0.1.19
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
@@ -240,13 +240,24 @@ class Table:
240
240
  """
241
241
 
242
242
  with self.batch_write() as batch:
243
- for item in model.split_to_simple_objects():
243
+ items = model.split_to_simple_objects()
244
+ new_keys = self._create_snapshot_representation(model)
245
+ keys_to_delete = model._db_snapshot_keys - new_keys
246
+
247
+ for key in keys_to_delete:
248
+ hash_key, sort_key = key.split("#", 1)
249
+ delete_params = {self.key_schema.hash_key: hash_key}
250
+ if sort_key:
251
+ delete_params[self.key_schema.sort_key] = sort_key
252
+ batch.delete_by_key(delete_params)
253
+
254
+ for item in items:
244
255
  if item.should_delete:
245
256
  batch.delete(item)
246
257
  else:
247
258
  batch.put(item)
248
259
 
249
- model._session.reset()
260
+ model._db_snapshot_keys = new_keys
250
261
 
251
262
  def update_item(
252
263
  self,
@@ -302,20 +313,17 @@ class Table:
302
313
  return type(model)(**data)
303
314
 
304
315
  def reparent_subtree(self, subtree_root: T, new_parent: T, field_name: str) -> T:
305
- subtree_copy = deepcopy(subtree_root)
306
- subtree_root._parent_changed = True
307
-
308
- subtree_copy.set_parent_references(subtree_copy, force_override=True)
309
- subtree_copy._parent = new_parent
310
- subtree_copy._parent_field_name = field_name
316
+ subtree_root._parent = new_parent
317
+ subtree_root._parent_field_name = field_name
318
+ subtree_root.set_parent_references(subtree_root, force_override=True)
311
319
  parent = None
312
- for node in subtree_copy.dfs_traverse_hierarchy():
320
+ for node in subtree_root.dfs_traverse_hierarchy():
313
321
  self.build_model_indexes(node)
314
322
  if parent:
315
323
  setattr(node, FIELD_STATIKK_PARENT_ID, parent.id)
316
324
  parent = node
317
325
 
318
- return subtree_copy
326
+ return subtree_root
319
327
 
320
328
  def build_model_indexes(self, model: T) -> T:
321
329
  for idx in self.indexes:
@@ -432,8 +440,7 @@ class Table:
432
440
 
433
441
  reconstructed_dict.pop(FIELD_STATIKK_TYPE, None)
434
442
  model = model_class.model_validate(reconstructed_dict)
435
- for node in model.dfs_traverse_hierarchy():
436
- node._is_persisted = True
443
+ model._db_snapshot_keys = self._create_snapshot_representation(model)
437
444
  return model
438
445
 
439
446
  def scan(
@@ -502,7 +509,7 @@ class Table:
502
509
  for idx in indexes:
503
510
  index_fields = self._compose_index_values(item, idx)
504
511
  for key, value in index_fields.items():
505
- if hasattr(item, key) and (getattr(item, key) is not None and not item._parent_changed):
512
+ if hasattr(item, key) and (getattr(item, key) is not None):
506
513
  continue
507
514
  if value is not None:
508
515
  setattr(item, key, value)
@@ -612,7 +619,9 @@ class Table:
612
619
  idx.sort_key.name: self._get_sort_key_value(model, idx),
613
620
  }
614
621
 
615
- def _perform_batch_write(self, put_items: List[DatabaseModel], delete_items: List[DatabaseModel]):
622
+ def _perform_batch_write(
623
+ self, put_items: List[DatabaseModel], delete_items: List[DatabaseModel], delete_keys: list[dict[str, Any]]
624
+ ):
616
625
  if len(put_items) == 0 and len(delete_items) == 0:
617
626
  return
618
627
 
@@ -625,6 +634,11 @@ class Table:
625
634
  data = self._serialize_item(enriched_item)
626
635
  batch.delete_item(Key=data)
627
636
 
637
+ if len(delete_keys) > 0:
638
+ with dynamodb_table.batch_writer() as batch:
639
+ for key in delete_keys:
640
+ batch.delete_item(Key=key)
641
+
628
642
  if len(put_items) > 0:
629
643
  with dynamodb_table.batch_writer() as batch:
630
644
  for item in put_items:
@@ -633,10 +647,19 @@ class Table:
633
647
  continue
634
648
  if not enriched_item.should_write_to_database():
635
649
  continue
636
- enriched_item._is_persisted = True
637
650
  data = self._serialize_item(enriched_item)
638
651
  batch.put_item(Item=data)
639
652
 
653
+ def _create_snapshot_representation(self, model: DatabaseModel) -> set:
654
+ db_snasphot = set()
655
+ for node in model.dfs_traverse_hierarchy():
656
+ model_key = {self.key_schema.hash_key: getattr(node, self.key_schema.hash_key)}
657
+ if self.key_schema.sort_key:
658
+ model_key[self.key_schema.sort_key] = getattr(node, self.key_schema.sort_key)
659
+ key_string = f"{model_key.get(self.key_schema.hash_key)}#{model_key.get(self.key_schema.sort_key, '')}"
660
+ db_snasphot.add(key_string)
661
+ return db_snasphot
662
+
640
663
  def reconstruct_hierarchy(self, items: list[dict]) -> Optional[dict]:
641
664
  """
642
665
  Reconstructs a hierarchical dictionary structure from a flat list of dictionaries
@@ -657,8 +680,11 @@ class Table:
657
680
  children_by_parent_id[parent_id] = []
658
681
  children_by_parent_id[parent_id].append(item)
659
682
 
660
- root_item = [item for item in items if FIELD_STATIKK_PARENT_ID not in item][0]
683
+ root_items = [item for item in items if FIELD_STATIKK_PARENT_ID not in item]
684
+ if not root_items:
685
+ return None
661
686
 
687
+ root_item = root_items[0]
662
688
  processed_root = self._process_item(root_item, items_by_id, children_by_parent_id)
663
689
  return processed_root
664
690
 
@@ -732,6 +758,7 @@ class BatchWriteContext:
732
758
  self._table = app
733
759
  self._put_items: List[DatabaseModel] = []
734
760
  self._delete_items: List[DatabaseModel] = []
761
+ self._delete_keys: List[dict[str, Any]] = []
735
762
 
736
763
  def put(self, item: DatabaseModel):
737
764
  self._put_items.append(item)
@@ -739,8 +766,11 @@ class BatchWriteContext:
739
766
  def delete(self, item: DatabaseModel):
740
767
  self._delete_items.append(item)
741
768
 
769
+ def delete_by_key(self, key: dict[str, Any]):
770
+ self._delete_keys.append(key)
771
+
742
772
  def __enter__(self):
743
773
  return self
744
774
 
745
775
  def __exit__(self, exc_type, exc_val, exc_tb):
746
- self._table._perform_batch_write(self._put_items, self._delete_items)
776
+ self._table._perform_batch_write(self._put_items, self._delete_items, self._delete_keys)
@@ -61,36 +61,6 @@ class TreeStructureChange(BaseModel):
61
61
  timestamp: datetime = Field(default_factory=datetime.now)
62
62
 
63
63
 
64
- class Session(BaseModel):
65
- _changes: list[TreeStructureChange] = []
66
-
67
- def add_change(self, new_parent: Optional[T], new_parent_field_name: Optional[str], subtree: T):
68
- self._changes.append(
69
- TreeStructureChange(new_parent=new_parent, new_parent_field_name=new_parent_field_name, subtree=subtree)
70
- )
71
-
72
- def get_subtree_changes_by_parent_id(
73
- self, parent_id: str, subtree_id: str, field_name: str
74
- ) -> Optional[TreeStructureChange]:
75
- sorted_changes = sorted(self._changes, key=lambda change: change.timestamp, reverse=True)
76
- return next(
77
- filter(
78
- lambda change: change.new_parent.id == parent_id
79
- and change.subtree.id == subtree_id
80
- and change.new_parent_field_name == field_name,
81
- sorted_changes,
82
- ),
83
- None,
84
- )
85
-
86
- def get_last_change_for(self, subtree_id):
87
- sorted_changes = sorted(self._changes, key=lambda change: change.timestamp, reverse=True)
88
- return next(filter(lambda change: change.subtree.id == subtree_id, sorted_changes), None)
89
-
90
- def reset(self):
91
- self._changes = []
92
-
93
-
94
64
  class TrackingMixin:
95
65
  _original_hash: int = Field(exclude=True)
96
66
 
@@ -202,9 +172,7 @@ class DatabaseModel(BaseModel, TrackingMixin):
202
172
  _parent_field_name: Optional[str] = None
203
173
  _model_types_in_hierarchy: dict[str, Type[DatabaseModel]] = {}
204
174
  _should_delete: bool = False
205
- _parent_changed: bool = False
206
- _is_persisted: bool = False
207
- _session = Session()
175
+ _db_snapshot_keys = set()
208
176
 
209
177
  class Config:
210
178
  extra = Extra.allow
@@ -215,34 +183,10 @@ class DatabaseModel(BaseModel, TrackingMixin):
215
183
  return False
216
184
  return self.id == other.id
217
185
 
218
- def is_parent_changed(self):
219
- """
220
- Recursively check if this node or any of its ancestors has been marked as having changed parents.
221
- Returns True if the node or any ancestor has _parent_changed=True, otherwise False.
222
- """
223
- if self._parent_changed:
224
- return True
225
-
226
- if self._parent is not None:
227
- return self._parent.is_parent_changed()
228
-
229
- return False
230
-
231
186
  @classmethod
232
187
  def type(cls) -> str:
233
188
  return cls.__name__
234
189
 
235
- @property
236
- def should_track_session(self) -> bool:
237
- """
238
- If set to True, subtree movements across the database model will be tracked in a session.
239
- Use this if you need to move a subtree across multiple parent-child relationships within a single session.
240
- Session is reset after each save.
241
- """
242
- if self._parent is not None:
243
- return self._parent.should_track_session
244
- return False
245
-
246
190
  @classmethod
247
191
  def index_definitions(cls) -> dict[str, IndexFieldConfig]:
248
192
  return {"main_index": IndexFieldConfig(pk_fields=[], sk_fields=[])}
@@ -263,16 +207,11 @@ class DatabaseModel(BaseModel, TrackingMixin):
263
207
  def is_simple_object(self) -> bool:
264
208
  return len(self._model_types_in_hierarchy) == 1
265
209
 
266
- @property
267
- def is_persisted(self) -> bool:
268
- return self._is_persisted
269
-
270
210
  @property
271
211
  def should_delete(self) -> bool:
272
212
  if self._is_any_parent_marked_for_deletion():
273
213
  return True
274
-
275
- return self.is_persisted and (self._should_delete or self.is_parent_changed())
214
+ return self._should_delete
276
215
 
277
216
  def _is_any_parent_marked_for_deletion(self) -> bool:
278
217
  current = self._parent
@@ -361,22 +300,24 @@ class DatabaseModel(BaseModel, TrackingMixin):
361
300
  def _change_parent_to(self, new_parent: DatabaseModel, field_name: str) -> T:
362
301
  return self._table.reparent_subtree(self, new_parent, field_name)
363
302
 
364
- def _remove_from_parent(self, parent, field_name, subtree):
303
+ def _remove_from_parent(self, parent: DatabaseModel, field_name: str):
365
304
  is_optional, inner_type = inspect_optional_field(parent.__class__, field_name)
366
305
  field_type = inner_type if is_optional else parent.model_fields[field_name].annotation
367
- field = getattr(self, field_name)
306
+ field = getattr(parent, field_name)
368
307
  if hasattr(field_type, "__origin__") and field_type.__origin__ == list:
369
308
  if not isinstance(field, list):
370
- setattr(self, field_name, [])
371
- field.remove(next(filter(lambda item: item.id == subtree.id, getattr(parent, field_name)), None))
309
+ setattr(parent, field_name, [])
310
+ field.remove(next(filter(lambda item: item.id == self.id, getattr(parent, field_name)), None))
372
311
 
373
312
  elif hasattr(field_type, "__origin__") and field_type.__origin__ == set:
374
313
  if not isinstance(field, set):
375
- setattr(self, field_name, set())
376
- field.remove(next(filter(lambda item: item.id == subtree.id, getattr(parent, field_name)), None))
314
+ setattr(parent, field_name, set())
315
+ field.remove(next(filter(lambda item: item.id == self.id, getattr(parent, field_name)), None))
377
316
 
378
317
  elif issubclass(field_type, DatabaseModel):
379
- setattr(parent, field_name, None)
318
+ current_value = getattr(parent, field_name)
319
+ if current_value and current_value.id == self.id:
320
+ setattr(parent, field_name, None)
380
321
 
381
322
  def add_child_node(self, field_name: str, child_node: DatabaseModel):
382
323
  if not child_node.is_nested():
@@ -385,10 +326,8 @@ class DatabaseModel(BaseModel, TrackingMixin):
385
326
  if not hasattr(self, field_name):
386
327
  raise ValueError(f"Field {field_name} does not exist on {self.__class__.__name__}")
387
328
 
388
- if self.should_track_session:
389
- previous_change = self._session.get_subtree_changes_by_parent_id(self.id, child_node.id, field_name)
390
- if previous_change:
391
- self._remove_from_parent(previous_change.new_parent, previous_change.new_parent_field_name, child_node)
329
+ if child_node._parent:
330
+ child_node._remove_from_parent(child_node._parent, child_node._parent_field_name)
392
331
 
393
332
  is_optional, inner_type = inspect_optional_field(self.__class__, field_name)
394
333
  field_type = inner_type if is_optional else self.model_fields[field_name].annotation
@@ -410,8 +349,6 @@ class DatabaseModel(BaseModel, TrackingMixin):
410
349
  setattr(self, field_name, reparented)
411
350
 
412
351
  if reparented:
413
- if self.should_track_session:
414
- self._session.add_change(self, field_name, reparented)
415
352
  return reparented
416
353
 
417
354
  raise ValueError(f"Unsupported field type: {field_type}")
@@ -511,8 +448,6 @@ class DatabaseModel(BaseModel, TrackingMixin):
511
448
  return # Already set
512
449
  field._parent = parent
513
450
  field._parent_field_name = field_name
514
- if field.should_track_session:
515
- field._session.add_change(parent, field_name, field)
516
451
  root._model_types_in_hierarchy[field.type()] = type(field)
517
452
  field.set_parent_references(root, force_override)
518
453
  field.init_tracking()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statikk
3
- Version: 0.1.17
3
+ Version: 0.1.19
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
@@ -982,27 +982,18 @@ def test_add_child_node():
982
982
  assert my_database_model.nested.other_nested._parent == my_database_model.nested
983
983
  assert my_database_model.nested.list_nested[0]._parent == my_database_model.nested
984
984
  my_database_model.nested.add_child_node("other_list_nested", my_database_model.nested.list_nested[0])
985
- assert my_database_model.nested.list_nested[0]._parent_changed is True
986
985
  other_list_nested_new = my_database_model.nested.other_list_nested[1]
987
986
  assert len(my_database_model.nested.other_list_nested) == 2
988
- assert other_list_nested_new._parent_changed is False
989
987
  assert other_list_nested_new.gsi_sk == "MyDatabaseModel|MyNestedDatabaseModel|bar|MyOtherNestedDatabaseModel|bazz"
990
988
  my_database_model.nested.add_child_node("list_nested", other_list_nested_new)
991
- assert my_database_model.nested.list_nested[0]._parent_changed is False
992
989
  assert my_database_model.nested.list_nested[0]._parent == my_database_model.nested
993
990
  assert my_database_model.nested.list_nested[0].gsi_pk == "foo"
994
991
  assert (
995
992
  my_database_model.nested.list_nested[0].gsi_sk
996
993
  == "MyDatabaseModel|MyNestedDatabaseModel|bar|MyOtherNestedDatabaseModel|bazz"
997
994
  )
998
- assert other_list_nested_new._parent_changed is True
999
995
  my_database_model.save()
1000
996
  my_database_model = MyDatabaseModel.query_hierarchy(hash_key=Equals("foo"))
1001
997
  assert len(my_database_model.nested.list_nested) == 1
1002
998
  assert len(my_database_model.nested.other_list_nested) == 1
1003
999
  hierarchy = MyDatabaseModel.query_hierarchy(hash_key=Equals("foo"))
1004
- assert hierarchy.is_persisted is True
1005
- assert hierarchy.nested.is_persisted is True
1006
- assert hierarchy.nested.list_nested[0].is_persisted is True
1007
- assert hierarchy.nested.other_list_nested[0].is_persisted is True
1008
- assert hierarchy.nested.other_nested.is_persisted is True
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