statikk 0.1.7__py3-none-any.whl → 0.1.9__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
@@ -1,6 +1,6 @@
1
1
  import os
2
2
  from datetime import datetime
3
- from typing import Any, Dict, Type, Optional, List, Union
3
+ from typing import Any, Dict, Type, Optional, List, Union, get_type_hints, get_origin, get_args
4
4
 
5
5
  import boto3
6
6
  from botocore.config import Config
@@ -17,7 +17,7 @@ from statikk.models import (
17
17
  KeySchema,
18
18
  )
19
19
  from statikk.fields import FIELD_STATIKK_TYPE, FIELD_STATIKK_PARENT_ID
20
-
20
+ from copy import deepcopy
21
21
  from aws_xray_sdk.core import patch_all
22
22
 
23
23
  patch_all()
@@ -58,6 +58,7 @@ class Table:
58
58
  for idx in self.indexes:
59
59
  for model in self.models:
60
60
  self._set_index_fields(model, idx)
61
+ model.model_rebuild(force=True)
61
62
  model.set_table_ref(self)
62
63
  self._client = None
63
64
  self._dynamodb_table = None
@@ -162,7 +163,12 @@ class Table:
162
163
  )
163
164
 
164
165
  def _get_model_type_by_statikk_type(self, statikk_type: str) -> Type[DatabaseModel]:
165
- return [model_type for model_type in self.models if model_type.type() == statikk_type][0]
166
+ model_type_filter = [model_type for model_type in self.models if model_type.type() == statikk_type]
167
+ if not model_type_filter:
168
+ raise InvalidModelTypeError(
169
+ f"Model type '{statikk_type}' not found. Make sure to register it through the models list."
170
+ )
171
+ return model_type_filter[0]
166
172
 
167
173
  def delete(self):
168
174
  """Deletes the DynamoDB table."""
@@ -235,7 +241,7 @@ class Table:
235
241
 
236
242
  with self.batch_write() as batch:
237
243
  for item in model.split_to_simple_objects():
238
- if item._should_delete:
244
+ if item.should_delete:
239
245
  batch.delete(item)
240
246
  else:
241
247
  batch.put(item)
@@ -288,11 +294,28 @@ class Table:
288
294
  response = self._get_dynamodb_table().update_item(**request)
289
295
  data = response["Attributes"]
290
296
  for key, value in data.items():
291
- if key == FIELD_STATIKK_TYPE:
297
+ if key in [FIELD_STATIKK_TYPE, FIELD_STATIKK_PARENT_ID]:
292
298
  continue
293
299
  data[key] = self._deserialize_value(value, model.model_fields[key])
294
300
  return type(model)(**data)
295
301
 
302
+ def reparent_subtree(self, subtree_root: T, new_parent: T) -> T:
303
+ subtree_copy = deepcopy(subtree_root)
304
+ subtree_root._parent_changed = True
305
+
306
+ subtree_copy.set_parent_references(subtree_copy, force_override=True)
307
+ subtree_copy._parent = new_parent
308
+ for node in subtree_copy.dfs_traverse_hierarchy():
309
+ for idx in self.indexes:
310
+ new_index_values = self._compose_index_values(node, idx)
311
+ new_sort_key_value = new_index_values[idx.sort_key.name]
312
+ setattr(node, idx.sort_key.name, new_sort_key_value)
313
+ new_hash_key_value = new_index_values[idx.hash_key.name]
314
+ setattr(node, idx.hash_key.name, new_hash_key_value)
315
+ setattr(node, FIELD_STATIKK_PARENT_ID, new_parent.id)
316
+
317
+ return subtree_copy
318
+
296
319
  def batch_write(self):
297
320
  """
298
321
  Returns a context manager for batch writing items to the database. This method handles all the buffering of the
@@ -462,12 +485,11 @@ class Table:
462
485
  self,
463
486
  item: DatabaseModel,
464
487
  indexes: List[GSI],
465
- force_override_index_fields: bool = False,
466
488
  ) -> DatabaseModel:
467
489
  for idx in indexes:
468
490
  index_fields = self._compose_index_values(item, idx)
469
491
  for key, value in index_fields.items():
470
- if hasattr(item, key) and (getattr(item, key) is not None and not force_override_index_fields):
492
+ if hasattr(item, key) and (getattr(item, key) is not None and not item._parent_changed):
471
493
  continue
472
494
  if value is not None:
473
495
  setattr(item, key, value)
@@ -558,8 +580,11 @@ class Table:
558
580
  return self.delimiter.join(sort_key_values)
559
581
 
560
582
  def _compose_index_values(self, model: DatabaseModel, idx: GSI) -> Dict[str, Any]:
561
- hash_key_fields = model.index_definitions().get(idx.name, []).pk_fields
583
+ hash_key_fields = model.index_definitions().get(idx.name, None)
584
+ if hash_key_fields is None:
585
+ raise IncorrectHashKeyError(f"Model {model.__class__} does not have a hash key defined.")
562
586
 
587
+ hash_key_fields = hash_key_fields.pk_fields
563
588
  if len(hash_key_fields) == 0 and not model.is_nested():
564
589
  raise IncorrectHashKeyError(f"Model {model.__class__} does not have a hash key defined.")
565
590
 
@@ -598,6 +623,26 @@ class Table:
598
623
  data = self._serialize_item(enriched_item)
599
624
  batch.delete_item(Key=data)
600
625
 
626
+ def inspect_optional_field(self, model_class, field_name):
627
+ field_type = model_class.model_fields[field_name].annotation
628
+
629
+ is_optional = False
630
+ inner_type = field_type
631
+
632
+ if get_origin(field_type) is Union:
633
+ args = get_args(field_type)
634
+ if len(args) == 2 and args[1] is type(None):
635
+ is_optional = True
636
+ inner_type = args[0]
637
+
638
+ elif hasattr(field_type, "__origin__") and field_type.__origin__ is Union:
639
+ args = getattr(field_type, "__args__", [])
640
+ if len(args) == 2 and args[1] is type(None):
641
+ is_optional = True
642
+ inner_type = args[0]
643
+
644
+ return (is_optional, inner_type)
645
+
601
646
  def reconstruct_hierarchy(self, items: list[dict]) -> Optional[dict]:
602
647
  """
603
648
  Reconstructs a hierarchical dictionary structure from a flat list of dictionaries
@@ -679,7 +724,9 @@ class Table:
679
724
  if field_name.startswith("_"):
680
725
  continue
681
726
 
682
- field_type = field_info.annotation
727
+ is_optional, inner_type = self.inspect_optional_field(parent_model_class, field_name)
728
+
729
+ field_type = inner_type if is_optional else field_info.annotation
683
730
 
684
731
  if field_type == child_model_class:
685
732
  matching_fields.append((field_name, "single"))
statikk/expressions.py CHANGED
@@ -597,7 +597,7 @@ class UpdateExpressionBuilder:
597
597
 
598
598
  def _safe_name(self, key):
599
599
  """Replace reserved words with a safe placeholder."""
600
- if key.upper() in RESERVED_WORDS:
600
+ if key.upper() in RESERVED_WORDS or key.startswith("_"):
601
601
  safe_key = self.safe_name(key)
602
602
  self.expression_attribute_names[safe_key] = key
603
603
  return safe_key
statikk/models.py CHANGED
@@ -163,6 +163,20 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
163
163
  _parent: Optional[DatabaseModel] = None
164
164
  _model_types_in_hierarchy: dict[str, Type[DatabaseModel]] = {}
165
165
  _should_delete: bool = False
166
+ _parent_changed: bool = False
167
+
168
+ def is_parent_changed(self):
169
+ """
170
+ Recursively check if this node or any of its ancestors has been marked as having changed parents.
171
+ Returns True if the node or any ancestor has _parent_changed=True, otherwise False.
172
+ """
173
+ if self._parent_changed:
174
+ return True
175
+
176
+ if self._parent is not None:
177
+ return self._parent.is_parent_changed()
178
+
179
+ return False
166
180
 
167
181
  @classmethod
168
182
  def type(cls) -> str:
@@ -188,6 +202,21 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
188
202
  def is_simple_object(self) -> bool:
189
203
  return len(self._model_types_in_hierarchy) == 1
190
204
 
205
+ @property
206
+ def should_delete(self) -> bool:
207
+ if self._is_any_parent_marked_for_deletion():
208
+ return True
209
+
210
+ return self._should_delete or self.is_parent_changed()
211
+
212
+ def _is_any_parent_marked_for_deletion(self) -> bool:
213
+ current = self._parent
214
+ while current is not None:
215
+ if current._should_delete:
216
+ return True
217
+ current = current._parent
218
+ return False
219
+
191
220
  @classmethod
192
221
  def query(
193
222
  cls: Type[T],
@@ -260,6 +289,9 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
260
289
  def mark_for_delete(self):
261
290
  self._should_delete = True
262
291
 
292
+ def change_parent_to(self, new_parent: DatabaseModel) -> T:
293
+ return self._table.reparent_subtree(self, new_parent)
294
+
263
295
  @classmethod
264
296
  def scan(
265
297
  cls,
@@ -280,7 +312,7 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
280
312
  def initialize_tracking(self):
281
313
  self._model_types_in_hierarchy[self.type()] = type(self)
282
314
  if not self.is_nested():
283
- self._set_parent_references(self)
315
+ self.set_parent_references(self)
284
316
  self.init_tracking()
285
317
 
286
318
  return self
@@ -370,27 +402,58 @@ class DatabaseModel(BaseModel, TrackingMixin, extra=Extra.allow):
370
402
  def get_type_from_hierarchy_by_name(self, name: str) -> Optional[Type[DatabaseModel]]:
371
403
  return self._model_types_in_hierarchy.get(name)
372
404
 
373
- def _set_parent_to_field(self, field: DatabaseModel, parent: DatabaseModel, root: DatabaseModel):
374
- if field._parent:
405
+ def _set_parent_to_field(
406
+ self, field: DatabaseModel, parent: DatabaseModel, root: DatabaseModel, force_override: bool = False
407
+ ):
408
+ if field._parent and not force_override:
375
409
  return # Already set
376
410
  field._parent = parent
377
411
  root._model_types_in_hierarchy[field.type()] = type(field)
378
- field._set_parent_references(root)
412
+ field.set_parent_references(root, force_override)
379
413
  field.init_tracking()
380
414
 
381
- def _set_parent_references(self, root: DatabaseModel):
415
+ def set_parent_references(self, root: DatabaseModel, force_override: bool = False):
416
+ """
417
+ Sets parent references for all DatabaseModel objects in the hierarchy.
418
+ """
419
+ for parent, field_name, model in self.traverse_hierarchy():
420
+ self._set_parent_to_field(model, parent, root, force_override)
421
+
422
+ def traverse_hierarchy(self):
423
+ """
424
+ Traverses the object and yields tuples of (parent, field_name, field_value) for each DatabaseModel found.
425
+ """
382
426
  for field_name, field_value in self:
383
427
  if isinstance(field_value, DatabaseModel):
384
- self._set_parent_to_field(field_value, self, root)
385
- elif isinstance(field_value, list):
428
+ yield self, field_name, field_value
429
+ elif isinstance(field_value, (list, set)):
386
430
  for item in field_value:
387
431
  if isinstance(item, DatabaseModel):
388
- self._set_parent_to_field(item, self, root)
389
- elif isinstance(field_value, set):
432
+ yield self, field_name, item
433
+ elif isinstance(field_value, dict):
434
+ for key, value in field_value.items():
435
+ if isinstance(value, DatabaseModel):
436
+ yield self, key, value
437
+
438
+ def dfs_traverse_hierarchy(self):
439
+ """
440
+ Performs a depth-first traversal of the entire object hierarchy,
441
+ yielding each DatabaseModel in order from root to leaves.
442
+ """
443
+ yield self
444
+
445
+ fields = []
446
+ for field_name, field_value in self:
447
+ fields.append((field_name, field_value))
448
+
449
+ for field_name, field_value in fields:
450
+ if isinstance(field_value, DatabaseModel):
451
+ yield from field_value.dfs_traverse_hierarchy()
452
+ elif isinstance(field_value, (list, set)):
390
453
  for item in field_value:
391
454
  if isinstance(item, DatabaseModel):
392
- self._set_parent_to_field(item, self, root)
455
+ yield from item.dfs_traverse_hierarchy()
393
456
  elif isinstance(field_value, dict):
394
457
  for key, value in field_value.items():
395
458
  if isinstance(value, DatabaseModel):
396
- self._set_parent_to_field(value, self, root)
459
+ yield from value.dfs_traverse_hierarchy()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: statikk
3
- Version: 0.1.7
3
+ Version: 0.1.9
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=0jF5pUZJtr_lBZMEZXd7PGDerr26CLZUh91gy15Leac,33252
4
+ statikk/expressions.py,sha256=boAeGxZj2cDsXxoiX3IIEzfX9voSMQngi4-rE_jYeuE,12233
5
+ statikk/fields.py,sha256=LkMP5NnX7WS0HSLxI3Q-dMOrfaJ0SD7SayZxJU5Acgg,86
6
+ statikk/models.py,sha256=gzy_MV3LVsVlorqHencXyS7HCVV2QQ698aWK5bQIdFk,16115
7
+ statikk/typing.py,sha256=qfpegORcdODuILK3gvuD4SdcZA1a7Myn0yvscOLPHOM,68
8
+ statikk-0.1.9.dist-info/LICENSE.txt,sha256=uSH_2Hpb2Bigy5_HhBliN2fZbBU64G3ERM5zzhKPUEE,1078
9
+ statikk-0.1.9.dist-info/METADATA,sha256=ZvTT-xMgID_v5WgHYz13E_JLHtqflVz9naDu9Qh-9rA,3160
10
+ statikk-0.1.9.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
11
+ statikk-0.1.9.dist-info/top_level.txt,sha256=etKmBbjzIlLpSefXoiOfhWGEgvqUEALaFwCjFDBD9YI,8
12
+ statikk-0.1.9.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=_iUJYyKNypqQeg6f4MC45XWhOrwsYRsI8FZe0Hs3fDA,31067
4
- statikk/expressions.py,sha256=mF6Hmj3Kmj6KKXTymeTHSepVA7rhiSINpFgSAPeBTRY,12210
5
- statikk/fields.py,sha256=LkMP5NnX7WS0HSLxI3Q-dMOrfaJ0SD7SayZxJU5Acgg,86
6
- statikk/models.py,sha256=uGCfpmoagz98BlTWiyj8NFvd7PTcsj1XV2edTumxAqY,13862
7
- statikk/typing.py,sha256=qfpegORcdODuILK3gvuD4SdcZA1a7Myn0yvscOLPHOM,68
8
- statikk-0.1.7.dist-info/LICENSE.txt,sha256=uSH_2Hpb2Bigy5_HhBliN2fZbBU64G3ERM5zzhKPUEE,1078
9
- statikk-0.1.7.dist-info/METADATA,sha256=YiDH3MeMZuJypLtkjGb-prE-Aqe15kbKBDT9cAyBRmM,3160
10
- statikk-0.1.7.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
11
- statikk-0.1.7.dist-info/top_level.txt,sha256=etKmBbjzIlLpSefXoiOfhWGEgvqUEALaFwCjFDBD9YI,8
12
- statikk-0.1.7.dist-info/RECORD,,