ic-python-db 0.8.2__py3-none-any.whl → 0.9.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.
- ic_python_db/__init__.py +1 -1
- ic_python_db/db_engine.py +60 -0
- ic_python_db/entity.py +140 -140
- ic_python_db/properties.py +288 -326
- {ic_python_db-0.8.2.dist-info → ic_python_db-0.9.0.dist-info}/METADATA +1 -1
- {ic_python_db-0.8.2.dist-info → ic_python_db-0.9.0.dist-info}/RECORD +9 -9
- {ic_python_db-0.8.2.dist-info → ic_python_db-0.9.0.dist-info}/WHEEL +0 -0
- {ic_python_db-0.8.2.dist-info → ic_python_db-0.9.0.dist-info}/licenses/LICENSE +0 -0
- {ic_python_db-0.8.2.dist-info → ic_python_db-0.9.0.dist-info}/top_level.txt +0 -0
ic_python_db/__init__.py
CHANGED
|
@@ -20,7 +20,7 @@ from .schema import SchemaIncompatibleError, build_schema, diff_schemas, schema_
|
|
|
20
20
|
from .storage import MemoryStorage, Storage
|
|
21
21
|
from .system_time import SystemTime
|
|
22
22
|
|
|
23
|
-
__version__ = "0.
|
|
23
|
+
__version__ = "0.9.0"
|
|
24
24
|
__all__ = [
|
|
25
25
|
"Database",
|
|
26
26
|
"Entity",
|
ic_python_db/db_engine.py
CHANGED
|
@@ -366,6 +366,66 @@ class Database:
|
|
|
366
366
|
|
|
367
367
|
return check_upgrade_compatibility(self, raise_on_error=raise_on_error)
|
|
368
368
|
|
|
369
|
+
# ── Reverse Index API ──────────────────────────────────────────────────────
|
|
370
|
+
#
|
|
371
|
+
# Reverse indexes allow OneToMany / ManyToMany / inverse-OneToOne relations
|
|
372
|
+
# to be resolved without scanning all child entities.
|
|
373
|
+
#
|
|
374
|
+
# Key format: _ri:{ParentType}:{parent_id}:{relation_name}
|
|
375
|
+
# Value: JSON array of child entity IDs, e.g. ["1", "2", "3"]
|
|
376
|
+
|
|
377
|
+
def _ri_key(self, parent_type: str, parent_id: str, relation_name: str) -> str:
|
|
378
|
+
return f"_ri:{parent_type}:{parent_id}:{relation_name}"
|
|
379
|
+
|
|
380
|
+
def reverse_index_get(
|
|
381
|
+
self, parent_type: str, parent_id: str, relation_name: str
|
|
382
|
+
) -> List[str]:
|
|
383
|
+
"""Read the reverse index for a parent entity's relation.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
List of child entity IDs, or empty list if no index exists.
|
|
387
|
+
"""
|
|
388
|
+
key = self._ri_key(parent_type, parent_id, relation_name)
|
|
389
|
+
raw = self._db_storage.get(key)
|
|
390
|
+
if raw:
|
|
391
|
+
return json.loads(raw)
|
|
392
|
+
return []
|
|
393
|
+
|
|
394
|
+
def reverse_index_add(
|
|
395
|
+
self, parent_type: str, parent_id: str, relation_name: str, child_id: str
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Add a child ID to a parent's reverse index."""
|
|
398
|
+
key = self._ri_key(parent_type, parent_id, relation_name)
|
|
399
|
+
raw = self._db_storage.get(key)
|
|
400
|
+
ids = json.loads(raw) if raw else []
|
|
401
|
+
if child_id not in ids:
|
|
402
|
+
ids.append(child_id)
|
|
403
|
+
self._db_storage.insert(key, json.dumps(ids))
|
|
404
|
+
|
|
405
|
+
def reverse_index_remove(
|
|
406
|
+
self, parent_type: str, parent_id: str, relation_name: str, child_id: str
|
|
407
|
+
) -> None:
|
|
408
|
+
"""Remove a child ID from a parent's reverse index."""
|
|
409
|
+
key = self._ri_key(parent_type, parent_id, relation_name)
|
|
410
|
+
raw = self._db_storage.get(key)
|
|
411
|
+
if not raw:
|
|
412
|
+
return
|
|
413
|
+
ids = json.loads(raw)
|
|
414
|
+
if child_id in ids:
|
|
415
|
+
ids.remove(child_id)
|
|
416
|
+
if ids:
|
|
417
|
+
self._db_storage.insert(key, json.dumps(ids))
|
|
418
|
+
else:
|
|
419
|
+
self._db_storage.remove(key)
|
|
420
|
+
|
|
421
|
+
def reverse_index_delete(
|
|
422
|
+
self, parent_type: str, parent_id: str, relation_name: str
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Delete an entire reverse index entry (used when parent is deleted)."""
|
|
425
|
+
key = self._ri_key(parent_type, parent_id, relation_name)
|
|
426
|
+
if self._db_storage.get(key) is not None:
|
|
427
|
+
self._db_storage.remove(key)
|
|
428
|
+
|
|
369
429
|
def get_audit(
|
|
370
430
|
self, id_from: Optional[int] = None, id_to: Optional[int] = None
|
|
371
431
|
) -> Dict[str, str]:
|
ic_python_db/entity.py
CHANGED
|
@@ -71,7 +71,6 @@ class Entity:
|
|
|
71
71
|
_id (str): Unique sequential identifier ("1", "2", "3", ...)
|
|
72
72
|
_loaded (bool): True if loaded from DB, False if newly created
|
|
73
73
|
_counted (bool): True if entity has been counted (prevents double-counting)
|
|
74
|
-
_relations (dict): Dictionary mapping relation names to related entities
|
|
75
74
|
_do_not_save (bool): Temporary flag to prevent saving during initialization
|
|
76
75
|
|
|
77
76
|
Class-level attributes:
|
|
@@ -161,8 +160,6 @@ class Entity:
|
|
|
161
160
|
self._loaded = False if kwargs.get("_loaded") is None else kwargs["_loaded"]
|
|
162
161
|
self._counted = False # Track if this entity has been counted
|
|
163
162
|
|
|
164
|
-
self._relations = {}
|
|
165
|
-
|
|
166
163
|
# Add to context
|
|
167
164
|
self.__class__._context.add(self)
|
|
168
165
|
|
|
@@ -439,62 +436,62 @@ class Entity:
|
|
|
439
436
|
if stored_version != current_version:
|
|
440
437
|
entity._save()
|
|
441
438
|
|
|
442
|
-
# Restore legacy "relations" block if present in serialized data.
|
|
443
|
-
# Otherwise keep the _relations that __init__ already populated
|
|
444
|
-
# via relationship descriptors (OneToMany, ManyToOne, etc.).
|
|
445
|
-
if "relations" in data:
|
|
446
|
-
relations_data = data.pop("relations")
|
|
447
|
-
relations = {}
|
|
448
|
-
for rel_name, rel_refs in relations_data.items():
|
|
449
|
-
relations[rel_name] = []
|
|
450
|
-
for ref in rel_refs:
|
|
451
|
-
related = (
|
|
452
|
-
Entity.db()
|
|
453
|
-
._entity_types[ref["_type"]]
|
|
454
|
-
.load(ref["_id"], level=level - 1)
|
|
455
|
-
)
|
|
456
|
-
if related:
|
|
457
|
-
relations[rel_name].append(related)
|
|
458
|
-
entity._relations = relations
|
|
459
|
-
|
|
460
439
|
return entity
|
|
461
440
|
|
|
462
441
|
@classmethod
|
|
463
442
|
def find(cls: Type[T], d) -> List[T]:
|
|
464
|
-
|
|
465
|
-
|
|
443
|
+
"""Find entities matching a dictionary filter.
|
|
444
|
+
|
|
445
|
+
Uses load_some over the full ID range. Prefer indexed lookups where possible.
|
|
446
|
+
"""
|
|
447
|
+
max_id = cls.max_id()
|
|
448
|
+
if max_id == 0:
|
|
449
|
+
return []
|
|
450
|
+
all_entities = cls.load_some(1, max_id)
|
|
466
451
|
return [
|
|
467
|
-
|
|
452
|
+
e
|
|
453
|
+
for e in all_entities
|
|
454
|
+
if all(getattr(e, k, None) == v for k, v in d.items())
|
|
468
455
|
]
|
|
469
456
|
|
|
470
457
|
@classmethod
|
|
471
458
|
def instances(cls: Type[T]) -> List[T]:
|
|
472
459
|
"""Get all instances of this entity type, including subclass instances.
|
|
473
460
|
|
|
474
|
-
|
|
461
|
+
.. deprecated::
|
|
462
|
+
This method loads ALL entities and is O(max_id). Prefer load_some()
|
|
463
|
+
with pagination or use relationship accessors.
|
|
475
464
|
|
|
476
465
|
Returns:
|
|
477
466
|
List of entities
|
|
478
467
|
"""
|
|
468
|
+
try:
|
|
469
|
+
import warnings
|
|
470
|
+
|
|
471
|
+
warnings.warn(
|
|
472
|
+
"Entity.instances() is deprecated and will be removed. "
|
|
473
|
+
"Use load_some() with pagination instead.",
|
|
474
|
+
DeprecationWarning,
|
|
475
|
+
stacklevel=2,
|
|
476
|
+
)
|
|
477
|
+
except (ImportError, AttributeError):
|
|
478
|
+
pass
|
|
479
479
|
db = Database.get_instance()
|
|
480
480
|
full_type_name = cls.get_full_type_name()
|
|
481
481
|
db.register_entity_type(cls, full_type_name)
|
|
482
482
|
|
|
483
|
-
# Use load_some for O(max_id) instead of O(total_keys)
|
|
484
483
|
max_id = cls.max_id()
|
|
485
484
|
if max_id == 0:
|
|
486
485
|
instances = []
|
|
487
486
|
else:
|
|
488
487
|
instances = cls.load_some(1, max_id)
|
|
489
488
|
|
|
490
|
-
# Also check for subclass instances
|
|
491
|
-
# Track processed classes to avoid duplicates when types are registered under multiple names
|
|
492
489
|
processed_classes = {cls}
|
|
493
490
|
for type_name, type_cls in db._entity_types.items():
|
|
494
491
|
if type_cls in processed_classes:
|
|
495
|
-
continue
|
|
492
|
+
continue
|
|
496
493
|
if type_name == full_type_name or type_name == cls.__name__:
|
|
497
|
-
continue
|
|
494
|
+
continue
|
|
498
495
|
if db.is_subclass(type_name, cls):
|
|
499
496
|
processed_classes.add(type_cls)
|
|
500
497
|
subclass_max_id = type_cls.max_id()
|
|
@@ -572,10 +569,11 @@ class Entity:
|
|
|
572
569
|
return ret
|
|
573
570
|
|
|
574
571
|
def delete(self) -> None:
|
|
572
|
+
"""Delete this entity from the database, cleaning up all reverse indexes."""
|
|
575
573
|
logger.debug(f"Deleting entity {self._type}@{self._id}")
|
|
576
|
-
"""Delete this entity from the database."""
|
|
577
574
|
from .constants import ACTION_DELETE
|
|
578
575
|
from .hooks import call_entity_hook
|
|
576
|
+
from .properties import ManyToMany, ManyToOne, OneToMany, OneToOne
|
|
579
577
|
|
|
580
578
|
# Call hook before deletion
|
|
581
579
|
allow, _ = call_entity_hook(self, None, self, None, ACTION_DELETE)
|
|
@@ -583,17 +581,74 @@ class Entity:
|
|
|
583
581
|
if not allow:
|
|
584
582
|
raise PermissionError("Hook rejected entity deletion")
|
|
585
583
|
|
|
586
|
-
self.db()
|
|
584
|
+
db = self.db()
|
|
585
|
+
|
|
586
|
+
# Clean up reverse indexes for all relation properties
|
|
587
|
+
for cls in reversed(self.__class__.__mro__):
|
|
588
|
+
for attr_name, attr_value in cls.__dict__.items():
|
|
589
|
+
if attr_name.startswith("_"):
|
|
590
|
+
continue
|
|
591
|
+
if isinstance(attr_value, ManyToOne):
|
|
592
|
+
# Remove self from parent's reverse index
|
|
593
|
+
parent_ref = self.__dict__.get(f"_rel_{attr_name}")
|
|
594
|
+
if parent_ref is not None:
|
|
595
|
+
for type_name in attr_value._get_allowed_types():
|
|
596
|
+
entity_class = db._entity_types.get(type_name)
|
|
597
|
+
if entity_class and db.load(type_name, parent_ref):
|
|
598
|
+
db.reverse_index_remove(
|
|
599
|
+
type_name,
|
|
600
|
+
parent_ref,
|
|
601
|
+
attr_value.reverse_name,
|
|
602
|
+
self._id,
|
|
603
|
+
)
|
|
604
|
+
break
|
|
605
|
+
elif isinstance(attr_value, OneToOne):
|
|
606
|
+
# Remove self from target's reverse index
|
|
607
|
+
target_ref = self.__dict__.get(f"_rel_{attr_name}")
|
|
608
|
+
if target_ref is not None:
|
|
609
|
+
for type_name in attr_value._get_allowed_types():
|
|
610
|
+
entity_class = db._entity_types.get(type_name)
|
|
611
|
+
if entity_class and db.load(type_name, target_ref):
|
|
612
|
+
db.reverse_index_remove(
|
|
613
|
+
type_name,
|
|
614
|
+
target_ref,
|
|
615
|
+
attr_value.reverse_name,
|
|
616
|
+
self._id,
|
|
617
|
+
)
|
|
618
|
+
break
|
|
619
|
+
# Also delete any reverse index where self is parent (inverse side)
|
|
620
|
+
db.reverse_index_delete(self._type, self._id, attr_name)
|
|
621
|
+
elif isinstance(attr_value, OneToMany):
|
|
622
|
+
# Delete the reverse index entry for this parent
|
|
623
|
+
db.reverse_index_delete(self._type, self._id, attr_name)
|
|
624
|
+
elif isinstance(attr_value, ManyToMany):
|
|
625
|
+
# Remove self from each related entity's reverse index
|
|
626
|
+
related_ids = db.reverse_index_get(self._type, self._id, attr_name)
|
|
627
|
+
for related_id in related_ids:
|
|
628
|
+
for type_name in attr_value._get_allowed_types():
|
|
629
|
+
entity_class = db._entity_types.get(type_name)
|
|
630
|
+
if entity_class and db.load(type_name, related_id):
|
|
631
|
+
db.reverse_index_remove(
|
|
632
|
+
type_name,
|
|
633
|
+
related_id,
|
|
634
|
+
attr_value.reverse_name,
|
|
635
|
+
self._id,
|
|
636
|
+
)
|
|
637
|
+
break
|
|
638
|
+
# Delete own index
|
|
639
|
+
db.reverse_index_delete(self._type, self._id, attr_name)
|
|
640
|
+
|
|
641
|
+
db.delete(self._type, self._id)
|
|
587
642
|
|
|
588
643
|
# Remove from entity registry
|
|
589
|
-
|
|
644
|
+
db.unregister_entity(self._type, self._id)
|
|
590
645
|
|
|
591
646
|
# Decrement the count when an entity is deleted
|
|
592
647
|
type_name = self.__class__.get_full_type_name()
|
|
593
648
|
count_key = f"{type_name}_count"
|
|
594
|
-
current_count = int(
|
|
649
|
+
current_count = int(db.load("_system", count_key) or 0)
|
|
595
650
|
if current_count > 0:
|
|
596
|
-
|
|
651
|
+
db.save("_system", count_key, str(current_count - 1))
|
|
597
652
|
else:
|
|
598
653
|
raise ValueError(
|
|
599
654
|
f"Entity count for {type_name} is already zero; cannot decrement further."
|
|
@@ -605,7 +660,7 @@ class Entity:
|
|
|
605
660
|
if hasattr(self, alias_field):
|
|
606
661
|
alias_value = getattr(self, alias_field)
|
|
607
662
|
if alias_value is not None:
|
|
608
|
-
|
|
663
|
+
db.delete(self._alias_key(), alias_value)
|
|
609
664
|
|
|
610
665
|
logger.debug(f"Deleted entity {self._type}@{self._id}")
|
|
611
666
|
|
|
@@ -651,61 +706,73 @@ class Entity:
|
|
|
651
706
|
return entity._id
|
|
652
707
|
|
|
653
708
|
def _serialize_full(self) -> Dict[str, Any]:
|
|
654
|
-
"""Full serialization including
|
|
709
|
+
"""Full serialization including relation FK references. Used by _save()."""
|
|
655
710
|
data = self._serialize_base()
|
|
656
711
|
|
|
657
|
-
from ic_python_db.properties import
|
|
658
|
-
|
|
659
|
-
for rel_name, rel_entities in self._relations.items():
|
|
660
|
-
if rel_entities:
|
|
661
|
-
rel_prop = getattr(self.__class__, rel_name, None)
|
|
662
|
-
is_to_many = isinstance(rel_prop, (OneToMany, ManyToMany))
|
|
712
|
+
from ic_python_db.properties import ManyToOne, OneToOne
|
|
663
713
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
714
|
+
for cls in reversed(self.__class__.__mro__):
|
|
715
|
+
for attr_name, attr_value in cls.__dict__.items():
|
|
716
|
+
if attr_name.startswith("_"):
|
|
717
|
+
continue
|
|
718
|
+
if isinstance(attr_value, (ManyToOne, OneToOne)):
|
|
719
|
+
ref = self.__dict__.get(f"_rel_{attr_name}")
|
|
720
|
+
if ref is not None:
|
|
721
|
+
data[attr_name] = ref
|
|
670
722
|
|
|
671
723
|
return data
|
|
672
724
|
|
|
725
|
+
def _resolve_ref_with_alias(self, entity_id: str, allowed_types: list) -> str:
|
|
726
|
+
"""Resolve an entity ID to its best reference (alias value or ID)."""
|
|
727
|
+
db = self.db()
|
|
728
|
+
for type_name in allowed_types:
|
|
729
|
+
entity_class = db._entity_types.get(type_name)
|
|
730
|
+
if entity_class:
|
|
731
|
+
entity = entity_class.load(entity_id)
|
|
732
|
+
if entity:
|
|
733
|
+
return self._get_entity_reference(entity)
|
|
734
|
+
return entity_id
|
|
735
|
+
|
|
673
736
|
def serialize(self) -> Dict[str, Any]:
|
|
674
737
|
"""Convert the entity to a portable serializable dictionary.
|
|
675
738
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
type serializes the reference, avoiding circular dependencies.
|
|
739
|
+
Includes: ManyToOne FK, owning-side OneToOne FK, ManyToMany entries.
|
|
740
|
+
Excludes: OneToMany (reconstructed from ManyToOne during import).
|
|
679
741
|
|
|
680
742
|
Returns:
|
|
681
743
|
Dict containing the entity's serializable data
|
|
682
744
|
"""
|
|
683
745
|
data = self._serialize_base()
|
|
684
746
|
|
|
685
|
-
from ic_python_db.properties import ManyToMany,
|
|
747
|
+
from ic_python_db.properties import ManyToMany, ManyToOne, OneToOne
|
|
686
748
|
|
|
687
|
-
|
|
688
|
-
if rel_entities:
|
|
689
|
-
rel_prop = getattr(self.__class__, rel_name, None)
|
|
749
|
+
db = self.db()
|
|
690
750
|
|
|
691
|
-
|
|
692
|
-
|
|
751
|
+
for cls in reversed(self.__class__.__mro__):
|
|
752
|
+
for attr_name, attr_value in cls.__dict__.items():
|
|
753
|
+
if attr_name.startswith("_"):
|
|
693
754
|
continue
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
755
|
+
if isinstance(attr_value, ManyToOne):
|
|
756
|
+
ref = self.__dict__.get(f"_rel_{attr_name}")
|
|
757
|
+
if ref is not None:
|
|
758
|
+
data[attr_name] = self._resolve_ref_with_alias(
|
|
759
|
+
ref, attr_value._get_allowed_types()
|
|
760
|
+
)
|
|
761
|
+
elif isinstance(attr_value, OneToOne):
|
|
762
|
+
ref = self.__dict__.get(f"_rel_{attr_name}")
|
|
763
|
+
if ref is not None:
|
|
764
|
+
data[attr_name] = self._resolve_ref_with_alias(
|
|
765
|
+
ref, attr_value._get_allowed_types()
|
|
766
|
+
)
|
|
767
|
+
elif isinstance(attr_value, ManyToMany):
|
|
768
|
+
related_ids = db.reverse_index_get(self._type, self._id, attr_name)
|
|
769
|
+
if related_ids:
|
|
770
|
+
data[attr_name] = [
|
|
771
|
+
self._resolve_ref_with_alias(
|
|
772
|
+
rid, attr_value._get_allowed_types()
|
|
773
|
+
)
|
|
774
|
+
for rid in related_ids
|
|
775
|
+
]
|
|
709
776
|
|
|
710
777
|
return data
|
|
711
778
|
|
|
@@ -970,70 +1037,3 @@ class Entity:
|
|
|
970
1037
|
Hash value
|
|
971
1038
|
"""
|
|
972
1039
|
return hash((self._type, self._id))
|
|
973
|
-
|
|
974
|
-
def add_relation(self, from_rel: str, to_rel: str, other: "Entity") -> None:
|
|
975
|
-
"""Add a bidirectional relationship with another entity.
|
|
976
|
-
|
|
977
|
-
Args:
|
|
978
|
-
from_rel: Name of relation from this entity to other
|
|
979
|
-
to_rel: Name of relation from other entity to this
|
|
980
|
-
other: Entity to create relationship with
|
|
981
|
-
"""
|
|
982
|
-
# Add forward relation
|
|
983
|
-
if from_rel not in self._relations:
|
|
984
|
-
self._relations[from_rel] = []
|
|
985
|
-
if other not in self._relations[from_rel]:
|
|
986
|
-
self._relations[from_rel].append(other)
|
|
987
|
-
|
|
988
|
-
# Add reverse relation
|
|
989
|
-
if to_rel not in other._relations:
|
|
990
|
-
other._relations[to_rel] = []
|
|
991
|
-
if self not in other._relations[to_rel]:
|
|
992
|
-
other._relations[to_rel].append(self)
|
|
993
|
-
|
|
994
|
-
# Save both entities
|
|
995
|
-
self._save()
|
|
996
|
-
other._save()
|
|
997
|
-
|
|
998
|
-
def get_relations(
|
|
999
|
-
self, relation_name: str, entity_type: str = None
|
|
1000
|
-
) -> List["Entity"]:
|
|
1001
|
-
"""Get all related entities for a relation, optionally filtered by type.
|
|
1002
|
-
|
|
1003
|
-
Args:
|
|
1004
|
-
relation_name: Name of the relation to follow
|
|
1005
|
-
entity_type: Optional type name to filter entities by
|
|
1006
|
-
|
|
1007
|
-
Returns:
|
|
1008
|
-
List of related entities
|
|
1009
|
-
"""
|
|
1010
|
-
if relation_name not in self._relations:
|
|
1011
|
-
return []
|
|
1012
|
-
|
|
1013
|
-
entities = self._relations[relation_name]
|
|
1014
|
-
if entity_type:
|
|
1015
|
-
entities = [e for e in entities if e._type == entity_type]
|
|
1016
|
-
|
|
1017
|
-
return entities
|
|
1018
|
-
|
|
1019
|
-
def remove_relation(self, from_rel: str, to_rel: str, other: "Entity") -> None:
|
|
1020
|
-
"""Remove a bidirectional relationship with another entity.
|
|
1021
|
-
|
|
1022
|
-
Args:
|
|
1023
|
-
from_rel: Name of relation from this entity to other
|
|
1024
|
-
to_rel: Name of relation from other entity to this
|
|
1025
|
-
other: Entity to remove relationship with
|
|
1026
|
-
"""
|
|
1027
|
-
# Remove forward relation
|
|
1028
|
-
if from_rel in self._relations:
|
|
1029
|
-
if other in self._relations[from_rel]:
|
|
1030
|
-
self._relations[from_rel].remove(other)
|
|
1031
|
-
|
|
1032
|
-
# Remove reverse relation
|
|
1033
|
-
if to_rel in other._relations:
|
|
1034
|
-
if self in other._relations[to_rel]:
|
|
1035
|
-
other._relations[to_rel].remove(self)
|
|
1036
|
-
|
|
1037
|
-
# Save both entities
|
|
1038
|
-
self._save()
|
|
1039
|
-
other._save()
|
ic_python_db/properties.py
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
"""Property definitions for Entity classes.
|
|
1
|
+
"""Property definitions for Entity classes.
|
|
2
|
+
|
|
3
|
+
Relation properties (ManyToOne, OneToMany, OneToOne, ManyToMany) use persisted
|
|
4
|
+
reverse indexes in stable storage so that relationships can be resolved without
|
|
5
|
+
scanning all entities.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from typing import (
|
|
4
9
|
TYPE_CHECKING,
|
|
5
10
|
Any,
|
|
6
11
|
Callable,
|
|
7
12
|
Generic,
|
|
8
|
-
Iterator,
|
|
9
13
|
List,
|
|
10
14
|
Optional,
|
|
11
15
|
Type,
|
|
12
16
|
TypeVar,
|
|
13
17
|
Union,
|
|
14
|
-
overload,
|
|
15
18
|
)
|
|
16
19
|
|
|
17
20
|
if TYPE_CHECKING:
|
|
18
21
|
from .entity import Entity
|
|
19
22
|
|
|
20
|
-
# TypeVar for property value types
|
|
21
23
|
T = TypeVar("T")
|
|
22
|
-
# TypeVar for entity types in relations
|
|
23
24
|
E = TypeVar("E", bound="Entity")
|
|
24
25
|
|
|
25
|
-
# Prefix used for storing property values in entity __dict__
|
|
26
26
|
PROPERTY_STORAGE_PREFIX = "prop"
|
|
27
27
|
|
|
28
28
|
|
|
@@ -30,13 +30,6 @@ class Property(Generic[T]):
|
|
|
30
30
|
"""Definition of an entity property.
|
|
31
31
|
|
|
32
32
|
A generic descriptor class that provides type-safe property access.
|
|
33
|
-
The type parameter T indicates the type of value this property holds.
|
|
34
|
-
|
|
35
|
-
Attributes:
|
|
36
|
-
name: Name of the property
|
|
37
|
-
type: Type of the property (e.g. str, int)
|
|
38
|
-
default: Default value if not set
|
|
39
|
-
validator: Optional function to validate values
|
|
40
33
|
"""
|
|
41
34
|
|
|
42
35
|
name: str
|
|
@@ -57,29 +50,19 @@ class Property(Generic[T]):
|
|
|
57
50
|
self.validator = validator
|
|
58
51
|
|
|
59
52
|
def __set_name__(self, owner: type, name: str) -> None:
|
|
60
|
-
"""Set the property name when class is created."""
|
|
61
53
|
self.name = name
|
|
62
54
|
|
|
63
|
-
@overload
|
|
64
|
-
def __get__(self, obj: None, objtype: Optional[type]) -> "Property[T]": ...
|
|
65
|
-
|
|
66
|
-
@overload
|
|
67
|
-
def __get__(self, obj: object, objtype: Optional[type]) -> Optional[T]: ...
|
|
68
|
-
|
|
69
55
|
def __get__(
|
|
70
56
|
self, obj: object, objtype: Optional[type] = None
|
|
71
57
|
) -> Union["Property[T]", Optional[T]]:
|
|
72
|
-
"""Get the property value."""
|
|
73
58
|
if obj is None:
|
|
74
|
-
return self
|
|
59
|
+
return self # type: ignore[return-value]
|
|
75
60
|
return obj.__dict__.get(f"_{PROPERTY_STORAGE_PREFIX}_{self.name}", self.default)
|
|
76
61
|
|
|
77
62
|
def __set__(self, obj, value):
|
|
78
|
-
"""Set the property value with type checking and validation."""
|
|
79
63
|
from .constants import ACTION_CREATE, ACTION_MODIFY
|
|
80
64
|
from .hooks import call_entity_hook
|
|
81
65
|
|
|
82
|
-
# Get old value and determine action
|
|
83
66
|
old_value = obj.__dict__.get(
|
|
84
67
|
f"_{PROPERTY_STORAGE_PREFIX}_{self.name}", self.default
|
|
85
68
|
)
|
|
@@ -89,7 +72,6 @@ class Property(Generic[T]):
|
|
|
89
72
|
else ACTION_MODIFY
|
|
90
73
|
)
|
|
91
74
|
|
|
92
|
-
# Call hook before setting
|
|
93
75
|
allow, modified_value = call_entity_hook(
|
|
94
76
|
obj, self.name, old_value, value, action
|
|
95
77
|
)
|
|
@@ -185,113 +167,47 @@ class Boolean(Property[bool]):
|
|
|
185
167
|
super().__init__(name="", type=bool, default=default)
|
|
186
168
|
|
|
187
169
|
|
|
188
|
-
|
|
189
|
-
"""Base property for defining and accessing relations.
|
|
170
|
+
# ── Relation Properties ───────────────────────────────────────────────────────
|
|
190
171
|
|
|
191
|
-
This is the base class for all relation properties. It provides common functionality
|
|
192
|
-
for managing relationships between entities.
|
|
193
172
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
returns a list of entities.
|
|
173
|
+
class Relation(Generic[E]):
|
|
174
|
+
"""Base class for relation properties.
|
|
197
175
|
|
|
198
|
-
|
|
176
|
+
Provides shared utilities for resolving and validating related entities.
|
|
177
|
+
Subclasses implement specific relationship semantics using persisted
|
|
178
|
+
reverse indexes.
|
|
199
179
|
"""
|
|
200
180
|
|
|
201
181
|
name: Optional[str]
|
|
202
182
|
entity_types: Union[str, List[str]]
|
|
203
183
|
reverse_name: Optional[str]
|
|
204
|
-
many: bool
|
|
205
184
|
|
|
206
185
|
def __init__(
|
|
207
186
|
self,
|
|
208
187
|
entity_types: Union[str, List[str]],
|
|
209
188
|
reverse_name: Optional[str] = None,
|
|
210
|
-
many: bool = False,
|
|
211
189
|
):
|
|
212
|
-
"""Initialize relation property.
|
|
213
|
-
|
|
214
|
-
Args:
|
|
215
|
-
entity_types: Type names of related entities
|
|
216
|
-
reverse_name: Optional name for reverse relation
|
|
217
|
-
many: Whether this property can hold multiple entities (default: False)
|
|
218
|
-
"""
|
|
219
190
|
self.entity_types = entity_types
|
|
220
191
|
self.name = None
|
|
221
192
|
self.reverse_name = reverse_name
|
|
222
|
-
self.many = many
|
|
223
193
|
|
|
224
194
|
def __set_name__(self, owner: type, name: str) -> None:
|
|
225
|
-
"""Set the property name when class is created."""
|
|
226
195
|
self.name = name
|
|
227
196
|
if self.reverse_name is None:
|
|
228
197
|
self.reverse_name = name
|
|
229
198
|
|
|
230
|
-
@
|
|
231
|
-
def
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def __get__(
|
|
235
|
-
self, obj: object, objtype: Optional[type]
|
|
236
|
-
) -> Union[Optional[E], List[E]]: ...
|
|
237
|
-
|
|
238
|
-
def __get__(
|
|
239
|
-
self, obj: object, objtype: Optional[type] = None
|
|
240
|
-
) -> Union["Relation[E]", Optional[E], List[E]]:
|
|
241
|
-
"""Get related entities."""
|
|
242
|
-
if obj is None:
|
|
243
|
-
return self
|
|
244
|
-
relations = obj.get_relations(self.name)
|
|
245
|
-
if not self.many:
|
|
246
|
-
# For single relationships, return the first entity or None
|
|
247
|
-
return relations[0] if relations else None
|
|
248
|
-
return relations
|
|
199
|
+
@property
|
|
200
|
+
def many(self) -> bool:
|
|
201
|
+
"""Whether this relation holds multiple entities."""
|
|
202
|
+
return isinstance(self, (OneToMany, ManyToMany))
|
|
249
203
|
|
|
250
|
-
def
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
value: For many=False: a single Entity instance
|
|
255
|
-
For many=True: a list/tuple of Entity instances
|
|
256
|
-
"""
|
|
257
|
-
if self.many:
|
|
258
|
-
if not isinstance(value, (list, tuple)):
|
|
259
|
-
raise TypeError(f"{self.name} requires a list or tuple of entities")
|
|
260
|
-
values_list = value
|
|
261
|
-
else:
|
|
262
|
-
values_list = [value]
|
|
263
|
-
|
|
264
|
-
# Get existing and new relations as sets
|
|
265
|
-
existing = set(obj.get_relations(self.name))
|
|
266
|
-
new = set(values_list)
|
|
267
|
-
|
|
268
|
-
# For one-to-many, check if entities have existing relations
|
|
269
|
-
if isinstance(self, OneToMany):
|
|
270
|
-
for entity in new:
|
|
271
|
-
existing_relations = entity.get_relations(self.reverse_name)
|
|
272
|
-
if existing_relations:
|
|
273
|
-
old_relation = existing_relations[0]
|
|
274
|
-
if old_relation != obj:
|
|
275
|
-
# Remove the entity from the old relation's list
|
|
276
|
-
old_relation._relations[self.name].remove(entity)
|
|
277
|
-
# Remove the old relation from the entity's list
|
|
278
|
-
entity._relations[self.reverse_name].remove(old_relation)
|
|
279
|
-
|
|
280
|
-
# Remove relations that are not in new set
|
|
281
|
-
to_remove = existing - new
|
|
282
|
-
for entity in to_remove:
|
|
283
|
-
obj.remove_relation(self.name, self.reverse_name, entity)
|
|
284
|
-
|
|
285
|
-
# Add relations that are not in existing set
|
|
286
|
-
to_add = new - existing
|
|
287
|
-
for entity in to_add:
|
|
288
|
-
obj.add_relation(self.name, self.reverse_name, entity)
|
|
204
|
+
def _get_allowed_types(self) -> List[str]:
|
|
205
|
+
if isinstance(self.entity_types, str):
|
|
206
|
+
return [self.entity_types]
|
|
207
|
+
return list(self.entity_types)
|
|
289
208
|
|
|
290
209
|
def validate_entity(self, entity: Any) -> bool:
|
|
291
|
-
"""Validate that an entity is of the correct type.
|
|
292
|
-
|
|
293
|
-
Handles both namespaced (e.g., "app::User") and non-namespaced (e.g., "User") types.
|
|
294
|
-
"""
|
|
210
|
+
"""Validate that an entity is of the correct type."""
|
|
295
211
|
from .entity import Entity
|
|
296
212
|
|
|
297
213
|
if entity is None:
|
|
@@ -299,17 +215,8 @@ class Relation(Generic[E]):
|
|
|
299
215
|
if not isinstance(entity, Entity):
|
|
300
216
|
raise TypeError(f"{self.name} must be set to Entity instances")
|
|
301
217
|
|
|
302
|
-
|
|
303
|
-
allowed_types = (
|
|
304
|
-
[self.entity_types]
|
|
305
|
-
if isinstance(self.entity_types, str)
|
|
306
|
-
else self.entity_types
|
|
307
|
-
)
|
|
308
|
-
|
|
309
|
-
# Check if entity type matches any allowed type
|
|
310
|
-
# This handles both exact matches and namespace variations
|
|
218
|
+
allowed_types = self._get_allowed_types()
|
|
311
219
|
if entity._type not in allowed_types:
|
|
312
|
-
# Also check if the class name matches (for backward compatibility)
|
|
313
220
|
entity_class_name = (
|
|
314
221
|
entity._type.split("::")[-1] if "::" in entity._type else entity._type
|
|
315
222
|
)
|
|
@@ -319,22 +226,13 @@ class Relation(Generic[E]):
|
|
|
319
226
|
)
|
|
320
227
|
if not type_matches:
|
|
321
228
|
raise TypeError(
|
|
322
|
-
f"{self.name} must be
|
|
323
|
-
f"but got
|
|
229
|
+
f"{self.name} must be an Entity of type {self.entity_types}, "
|
|
230
|
+
f"but got '{entity._type}'"
|
|
324
231
|
)
|
|
325
|
-
|
|
326
232
|
return True
|
|
327
233
|
|
|
328
234
|
def resolve_entity(self, obj: Any, value: Any) -> Optional[E]:
|
|
329
|
-
"""Resolve a value to an Entity instance.
|
|
330
|
-
|
|
331
|
-
Args:
|
|
332
|
-
obj: The entity object that owns this relation
|
|
333
|
-
value: Can be an Entity instance, string ID, or string name/alias
|
|
334
|
-
|
|
335
|
-
Returns:
|
|
336
|
-
Entity instance or None
|
|
337
|
-
"""
|
|
235
|
+
"""Resolve a value (Entity, ID string, or alias) to an Entity instance."""
|
|
338
236
|
from .entity import Entity
|
|
339
237
|
|
|
340
238
|
if value is None:
|
|
@@ -344,23 +242,14 @@ class Relation(Generic[E]):
|
|
|
344
242
|
return value # type: ignore[return-value]
|
|
345
243
|
|
|
346
244
|
if isinstance(value, (str, int)):
|
|
347
|
-
|
|
348
|
-
entity_types = (
|
|
349
|
-
[self.entity_types]
|
|
350
|
-
if isinstance(self.entity_types, str)
|
|
351
|
-
else self.entity_types
|
|
352
|
-
)
|
|
353
|
-
for entity_type_name in entity_types:
|
|
354
|
-
# Get the entity class from the database registry
|
|
245
|
+
for entity_type_name in self._get_allowed_types():
|
|
355
246
|
entity_class = obj.db()._entity_types.get(entity_type_name)
|
|
356
|
-
|
|
357
247
|
if entity_class:
|
|
358
248
|
found_entity = entity_class[value]
|
|
359
249
|
if found_entity:
|
|
360
250
|
return found_entity
|
|
361
|
-
|
|
362
251
|
raise ValueError(
|
|
363
|
-
f"No entity of
|
|
252
|
+
f"No entity of type {self.entity_types} found with ID or name '{value}'"
|
|
364
253
|
)
|
|
365
254
|
|
|
366
255
|
raise TypeError(
|
|
@@ -368,114 +257,94 @@ class Relation(Generic[E]):
|
|
|
368
257
|
)
|
|
369
258
|
|
|
370
259
|
|
|
371
|
-
class
|
|
372
|
-
"""
|
|
373
|
-
|
|
374
|
-
def __init__(self, obj: Any, prop: "Relation[E]"):
|
|
375
|
-
self.obj = obj
|
|
376
|
-
self.prop = prop
|
|
377
|
-
|
|
378
|
-
def add(self, entity: Union[E, str, int]) -> None:
|
|
379
|
-
"""Add a new relation."""
|
|
380
|
-
# Resolve entity (supports string ID/name)
|
|
381
|
-
resolved = self.prop.resolve_entity(self.obj, entity)
|
|
382
|
-
|
|
383
|
-
# Validate entity type using the base validate_entity method
|
|
384
|
-
self.prop.validate_entity(resolved)
|
|
385
|
-
|
|
386
|
-
# For one-to-many, check if entity already has a relation
|
|
387
|
-
if isinstance(self.prop, OneToMany):
|
|
388
|
-
existing_relations = resolved.get_relations(self.prop.reverse_name)
|
|
389
|
-
if existing_relations:
|
|
390
|
-
# Remove existing relation since it's one-to-many
|
|
391
|
-
old_relation = existing_relations[0]
|
|
392
|
-
# Remove the entity from the old relation's list
|
|
393
|
-
if old_relation != self.obj:
|
|
394
|
-
old_relation._relations[self.prop.name].remove(resolved)
|
|
395
|
-
# Remove the old relation from the entity's list
|
|
396
|
-
resolved._relations[self.prop.reverse_name].remove(old_relation)
|
|
397
|
-
|
|
398
|
-
self.obj.add_relation(self.prop.name, self.prop.reverse_name, resolved)
|
|
399
|
-
|
|
400
|
-
def remove(self, entity: Union[E, str, int]) -> None:
|
|
401
|
-
"""Remove a relation."""
|
|
402
|
-
self.obj.remove_relation(self.prop.name, self.prop.reverse_name, entity)
|
|
403
|
-
|
|
404
|
-
def __iter__(self) -> "Iterator[E]":
|
|
405
|
-
return iter(self.obj.get_relations(self.prop.name))
|
|
406
|
-
|
|
407
|
-
def __len__(self) -> int:
|
|
408
|
-
return len(self.obj.get_relations(self.prop.name))
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
class OneToOne(Relation[E]):
|
|
412
|
-
"""Property for defining one-to-one relationships.
|
|
260
|
+
class ManyToOne(Relation[E]):
|
|
261
|
+
"""Many-to-one relationship (child stores FK to parent).
|
|
413
262
|
|
|
414
|
-
This
|
|
415
|
-
|
|
263
|
+
This is the "owning" side. Setting this property:
|
|
264
|
+
- Stores the parent's ID on the child entity
|
|
265
|
+
- Updates the parent's reverse index so it can find this child
|
|
416
266
|
|
|
417
267
|
Example:
|
|
418
|
-
class
|
|
419
|
-
|
|
268
|
+
class Employee(Entity):
|
|
269
|
+
department = ManyToOne('Department', 'employees')
|
|
420
270
|
|
|
421
|
-
class
|
|
422
|
-
|
|
271
|
+
class Department(Entity):
|
|
272
|
+
employees = OneToMany('Employee', 'department')
|
|
423
273
|
"""
|
|
424
274
|
|
|
425
275
|
def __init__(
|
|
426
|
-
self,
|
|
427
|
-
entity_types: Union[str, List[str]],
|
|
428
|
-
reverse_name: Optional[str] = None,
|
|
276
|
+
self, entity_types: Union[str, List[str]], reverse_name: Optional[str] = None
|
|
429
277
|
):
|
|
430
|
-
super().__init__(entity_types, reverse_name
|
|
278
|
+
super().__init__(entity_types, reverse_name)
|
|
279
|
+
|
|
280
|
+
def __get__(
|
|
281
|
+
self, obj: object, objtype: Optional[type] = None
|
|
282
|
+
) -> Union["ManyToOne[E]", Optional[E]]:
|
|
283
|
+
if obj is None:
|
|
284
|
+
return self # type: ignore[return-value]
|
|
285
|
+
parent_ref = obj.__dict__.get(f"_rel_{self.name}")
|
|
286
|
+
if parent_ref is None:
|
|
287
|
+
return None
|
|
288
|
+
for type_name in self._get_allowed_types():
|
|
289
|
+
entity_class = obj.db()._entity_types.get(type_name)
|
|
290
|
+
if entity_class:
|
|
291
|
+
entity = entity_class.load(parent_ref)
|
|
292
|
+
if entity:
|
|
293
|
+
return entity # type: ignore[return-value]
|
|
294
|
+
return None
|
|
431
295
|
|
|
432
296
|
def __set__(self, obj, value):
|
|
433
|
-
|
|
297
|
+
from .db_engine import Database
|
|
298
|
+
from .entity import Entity
|
|
299
|
+
|
|
300
|
+
# Fast path: loading from DB — just store the raw reference, indexes are intact
|
|
301
|
+
if getattr(obj, "_do_not_save", False) and getattr(obj, "_loaded", False):
|
|
302
|
+
if value is None:
|
|
303
|
+
obj.__dict__[f"_rel_{self.name}"] = None
|
|
304
|
+
elif isinstance(value, Entity):
|
|
305
|
+
obj.__dict__[f"_rel_{self.name}"] = value._id
|
|
306
|
+
else:
|
|
307
|
+
obj.__dict__[f"_rel_{self.name}"] = str(value)
|
|
308
|
+
return
|
|
309
|
+
|
|
434
310
|
if value is not None:
|
|
435
|
-
# Check if trying to set multiple values
|
|
436
311
|
if isinstance(value, (list, tuple)):
|
|
437
312
|
raise ValueError(
|
|
438
|
-
f"{self.name} cannot be set to multiple values (
|
|
313
|
+
f"{self.name} cannot be set to multiple values (many-to-one)"
|
|
439
314
|
)
|
|
440
|
-
|
|
441
|
-
# Validate entity type
|
|
442
315
|
value = self.resolve_entity(obj, value)
|
|
316
|
+
self.validate_entity(value)
|
|
443
317
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if not isinstance(reverse_prop, OneToOne):
|
|
447
|
-
raise ValueError(
|
|
448
|
-
f"Reverse property '{self.reverse_name}' must be OneToOne"
|
|
449
|
-
)
|
|
318
|
+
db = Database.get_instance()
|
|
319
|
+
old_ref = obj.__dict__.get(f"_rel_{self.name}")
|
|
450
320
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
321
|
+
# Remove from old parent's reverse index
|
|
322
|
+
if old_ref is not None and obj._id is not None:
|
|
323
|
+
for type_name in self._get_allowed_types():
|
|
324
|
+
entity_class = db._entity_types.get(type_name)
|
|
325
|
+
if entity_class and db.load(type_name, old_ref):
|
|
326
|
+
db.reverse_index_remove(
|
|
327
|
+
type_name, old_ref, self.reverse_name, obj._id
|
|
328
|
+
)
|
|
329
|
+
break
|
|
458
330
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
existing_entity.remove_relation(self.name, self.reverse_name, value)
|
|
467
|
-
value.remove_relation(self.reverse_name, self.name, existing_entity)
|
|
331
|
+
# Set new reference
|
|
332
|
+
if value is not None:
|
|
333
|
+
obj.__dict__[f"_rel_{self.name}"] = value._id
|
|
334
|
+
if obj._id is not None:
|
|
335
|
+
db.reverse_index_add(value._type, value._id, self.reverse_name, obj._id)
|
|
336
|
+
else:
|
|
337
|
+
obj.__dict__[f"_rel_{self.name}"] = None
|
|
468
338
|
|
|
469
|
-
|
|
470
|
-
|
|
339
|
+
if not obj._do_not_save:
|
|
340
|
+
obj._save()
|
|
471
341
|
|
|
472
342
|
|
|
473
343
|
class OneToMany(Relation[E]):
|
|
474
|
-
"""
|
|
344
|
+
"""One-to-many relationship (parent side, resolved via reverse index).
|
|
475
345
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
only one owner.
|
|
346
|
+
Accessing this property reads the persisted reverse index to find child
|
|
347
|
+
IDs, then loads each child. No scanning required.
|
|
479
348
|
|
|
480
349
|
Example:
|
|
481
350
|
class Department(Entity):
|
|
@@ -486,102 +355,181 @@ class OneToMany(Relation[E]):
|
|
|
486
355
|
"""
|
|
487
356
|
|
|
488
357
|
def __init__(self, entity_types: Union[str, List[str]], reverse_name: str):
|
|
489
|
-
super().__init__(entity_types, reverse_name
|
|
358
|
+
super().__init__(entity_types, reverse_name)
|
|
490
359
|
|
|
491
|
-
def
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
resolved_values = []
|
|
497
|
-
for value in values:
|
|
498
|
-
# Resolve value to Entity instance
|
|
499
|
-
resolved_value = self.resolve_entity(obj, value)
|
|
500
|
-
|
|
501
|
-
# Validate entity type using the base validate_entity method
|
|
502
|
-
self.validate_entity(resolved_value)
|
|
503
|
-
resolved_values.append(resolved_value)
|
|
504
|
-
|
|
505
|
-
# Check that the reverse property is ManyToOne
|
|
506
|
-
reverse_prop = resolved_value.__class__.__dict__.get(self.reverse_name)
|
|
507
|
-
if not isinstance(reverse_prop, ManyToOne):
|
|
508
|
-
raise ValueError(
|
|
509
|
-
f"Reverse property '{self.reverse_name}' must be ManyToOne"
|
|
510
|
-
)
|
|
360
|
+
def __get__(
|
|
361
|
+
self, obj: object, objtype: Optional[type] = None
|
|
362
|
+
) -> Union["OneToMany[E]", List[E]]:
|
|
363
|
+
if obj is None:
|
|
364
|
+
return self # type: ignore[return-value]
|
|
511
365
|
|
|
512
|
-
|
|
513
|
-
super().__set__(obj, resolved_values)
|
|
366
|
+
from .db_engine import Database
|
|
514
367
|
|
|
515
|
-
|
|
516
|
-
|
|
368
|
+
db = Database.get_instance()
|
|
369
|
+
child_ids = db.reverse_index_get(obj._type, obj._id, self.name)
|
|
517
370
|
|
|
518
|
-
|
|
519
|
-
|
|
371
|
+
children = []
|
|
372
|
+
for child_id in child_ids:
|
|
373
|
+
for type_name in self._get_allowed_types():
|
|
374
|
+
entity_class = db._entity_types.get(type_name)
|
|
375
|
+
if entity_class:
|
|
376
|
+
child = entity_class.load(child_id)
|
|
377
|
+
if child:
|
|
378
|
+
children.append(child)
|
|
379
|
+
break
|
|
380
|
+
return children # type: ignore[return-value]
|
|
520
381
|
|
|
521
|
-
def
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
return self
|
|
527
|
-
return RelationList(obj, self)
|
|
382
|
+
def __set__(self, obj, values):
|
|
383
|
+
raise AttributeError(
|
|
384
|
+
f"Cannot set OneToMany '{self.name}' directly. "
|
|
385
|
+
f"Set the ManyToOne '{self.reverse_name}' on each child instead."
|
|
386
|
+
)
|
|
528
387
|
|
|
529
388
|
|
|
530
|
-
class
|
|
531
|
-
"""
|
|
389
|
+
class OneToOne(Relation[E]):
|
|
390
|
+
"""One-to-one relationship.
|
|
532
391
|
|
|
533
|
-
|
|
534
|
-
|
|
392
|
+
The "owning" side stores the FK and updates the reverse index.
|
|
393
|
+
The "inverse" side resolves via the reverse index.
|
|
535
394
|
|
|
536
395
|
Example:
|
|
537
|
-
class
|
|
538
|
-
|
|
396
|
+
class Person(Entity):
|
|
397
|
+
profile = OneToOne('Profile', 'person')
|
|
539
398
|
|
|
540
|
-
class
|
|
541
|
-
|
|
399
|
+
class Profile(Entity):
|
|
400
|
+
person = OneToOne('Person', 'profile')
|
|
542
401
|
"""
|
|
543
402
|
|
|
544
403
|
def __init__(
|
|
545
|
-
self,
|
|
404
|
+
self,
|
|
405
|
+
entity_types: Union[str, List[str]],
|
|
406
|
+
reverse_name: Optional[str] = None,
|
|
546
407
|
):
|
|
547
|
-
super().__init__(entity_types, reverse_name
|
|
408
|
+
super().__init__(entity_types, reverse_name)
|
|
409
|
+
|
|
410
|
+
def __get__(
|
|
411
|
+
self, obj: object, objtype: Optional[type] = None
|
|
412
|
+
) -> Union["OneToOne[E]", Optional[E]]:
|
|
413
|
+
if obj is None:
|
|
414
|
+
return self # type: ignore[return-value]
|
|
415
|
+
|
|
416
|
+
from .db_engine import Database
|
|
417
|
+
|
|
418
|
+
# Check if this side stores the FK (owning side)
|
|
419
|
+
local_ref = obj.__dict__.get(f"_rel_{self.name}")
|
|
420
|
+
if local_ref is not None:
|
|
421
|
+
for type_name in self._get_allowed_types():
|
|
422
|
+
entity_class = obj.db()._entity_types.get(type_name)
|
|
423
|
+
if entity_class:
|
|
424
|
+
entity = entity_class.load(local_ref)
|
|
425
|
+
if entity:
|
|
426
|
+
return entity # type: ignore[return-value]
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
# Inverse side: check reverse index
|
|
430
|
+
db = Database.get_instance()
|
|
431
|
+
child_ids = db.reverse_index_get(obj._type, obj._id, self.name)
|
|
432
|
+
if child_ids:
|
|
433
|
+
for type_name in self._get_allowed_types():
|
|
434
|
+
entity_class = db._entity_types.get(type_name)
|
|
435
|
+
if entity_class:
|
|
436
|
+
entity = entity_class.load(child_ids[0])
|
|
437
|
+
if entity:
|
|
438
|
+
return entity # type: ignore[return-value]
|
|
439
|
+
return None
|
|
548
440
|
|
|
549
441
|
def __set__(self, obj, value):
|
|
550
|
-
|
|
442
|
+
from .db_engine import Database
|
|
443
|
+
from .entity import Entity
|
|
444
|
+
|
|
445
|
+
# Fast path: loading from DB — just store the raw reference, indexes are intact
|
|
446
|
+
if getattr(obj, "_do_not_save", False) and getattr(obj, "_loaded", False):
|
|
447
|
+
if value is None:
|
|
448
|
+
obj.__dict__[f"_rel_{self.name}"] = None
|
|
449
|
+
elif isinstance(value, Entity):
|
|
450
|
+
obj.__dict__[f"_rel_{self.name}"] = value._id
|
|
451
|
+
else:
|
|
452
|
+
obj.__dict__[f"_rel_{self.name}"] = str(value)
|
|
453
|
+
return
|
|
454
|
+
|
|
551
455
|
if value is not None:
|
|
552
|
-
# Check if trying to set multiple values
|
|
553
456
|
if isinstance(value, (list, tuple)):
|
|
554
457
|
raise ValueError(
|
|
555
|
-
f"{self.name} cannot be set to multiple values (
|
|
458
|
+
f"{self.name} cannot be set to multiple values (one-to-one)"
|
|
556
459
|
)
|
|
557
|
-
|
|
558
|
-
# Resolve value to Entity instance
|
|
559
460
|
value = self.resolve_entity(obj, value)
|
|
560
|
-
|
|
561
|
-
# Validate entity type using the base validate_entity method
|
|
562
461
|
self.validate_entity(value)
|
|
563
462
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
reverse_prop = getattr(value.__class__, self.reverse_name, None)
|
|
567
|
-
if not reverse_prop:
|
|
568
|
-
raise ValueError(
|
|
569
|
-
f"Reverse property '{self.reverse_name}' not found in {value.__class__.__name__} entity"
|
|
570
|
-
)
|
|
463
|
+
db = Database.get_instance()
|
|
464
|
+
old_ref = obj.__dict__.get(f"_rel_{self.name}")
|
|
571
465
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
466
|
+
# Remove from old target's reverse index
|
|
467
|
+
if old_ref is not None and obj._id is not None:
|
|
468
|
+
for type_name in self._get_allowed_types():
|
|
469
|
+
entity_class = db._entity_types.get(type_name)
|
|
470
|
+
if entity_class and db.load(type_name, old_ref):
|
|
471
|
+
db.reverse_index_remove(
|
|
472
|
+
type_name, old_ref, self.reverse_name, obj._id
|
|
473
|
+
)
|
|
474
|
+
break
|
|
475
|
+
|
|
476
|
+
# Enforce OneToOne exclusivity: if the target entity already has a direct
|
|
477
|
+
# FK on the reverse relation pointing to a different entity, clear it.
|
|
478
|
+
if value is not None:
|
|
479
|
+
existing_on_target = value.__dict__.get(f"_rel_{self.reverse_name}")
|
|
480
|
+
if existing_on_target is not None and existing_on_target != obj._id:
|
|
481
|
+
# The target already links to someone else via reverse relation.
|
|
482
|
+
# Clear that link and its reverse index entry.
|
|
483
|
+
db.reverse_index_remove(
|
|
484
|
+
obj._type, existing_on_target, self.name, value._id
|
|
575
485
|
)
|
|
486
|
+
value.__dict__[f"_rel_{self.reverse_name}"] = None
|
|
487
|
+
value._save()
|
|
488
|
+
|
|
489
|
+
# Also check if obj was previously linked via reverse index (inverse side)
|
|
490
|
+
# e.g., if someone else set "other.rel = obj" previously
|
|
491
|
+
existing_in_ri = db.reverse_index_get(obj._type, obj._id, self.name)
|
|
492
|
+
for old_other_id in existing_in_ri:
|
|
493
|
+
if old_other_id != value._id:
|
|
494
|
+
db.reverse_index_remove(obj._type, obj._id, self.name, old_other_id)
|
|
495
|
+
# Clear the other entity's direct FK
|
|
496
|
+
for tn in self._get_allowed_types():
|
|
497
|
+
ec = db._entity_types.get(tn)
|
|
498
|
+
if ec:
|
|
499
|
+
other_entity = ec.load(old_other_id)
|
|
500
|
+
if (
|
|
501
|
+
other_entity
|
|
502
|
+
and other_entity.__dict__.get(
|
|
503
|
+
f"_rel_{self.reverse_name}"
|
|
504
|
+
)
|
|
505
|
+
== obj._id
|
|
506
|
+
):
|
|
507
|
+
other_entity.__dict__[f"_rel_{self.reverse_name}"] = (
|
|
508
|
+
None
|
|
509
|
+
)
|
|
510
|
+
other_entity._save()
|
|
511
|
+
break
|
|
512
|
+
|
|
513
|
+
# Set new reference
|
|
514
|
+
if value is not None:
|
|
515
|
+
obj.__dict__[f"_rel_{self.name}"] = value._id
|
|
516
|
+
if obj._id is not None:
|
|
517
|
+
db.reverse_index_add(value._type, value._id, self.reverse_name, obj._id)
|
|
518
|
+
else:
|
|
519
|
+
obj.__dict__[f"_rel_{self.name}"] = None
|
|
520
|
+
# Also clear any reverse index entries pointing at obj for this relation
|
|
521
|
+
existing_in_ri = db.reverse_index_get(obj._type, obj._id, self.name)
|
|
522
|
+
for old_other_id in existing_in_ri:
|
|
523
|
+
db.reverse_index_remove(obj._type, obj._id, self.name, old_other_id)
|
|
576
524
|
|
|
577
|
-
|
|
525
|
+
if not obj._do_not_save:
|
|
526
|
+
obj._save()
|
|
578
527
|
|
|
579
528
|
|
|
580
529
|
class ManyToMany(Relation[E]):
|
|
581
|
-
"""
|
|
530
|
+
"""Many-to-many relationship with bidirectional reverse indexes.
|
|
582
531
|
|
|
583
|
-
|
|
584
|
-
on both sides can be related to multiple entities on the other side.
|
|
532
|
+
Both sides maintain a reverse index. Setting on either side updates both.
|
|
585
533
|
|
|
586
534
|
Example:
|
|
587
535
|
class Student(Entity):
|
|
@@ -592,53 +540,67 @@ class ManyToMany(Relation[E]):
|
|
|
592
540
|
"""
|
|
593
541
|
|
|
594
542
|
def __init__(self, entity_types: Union[str, List[str]], reverse_name: str):
|
|
595
|
-
super().__init__(entity_types, reverse_name
|
|
543
|
+
super().__init__(entity_types, reverse_name)
|
|
544
|
+
|
|
545
|
+
def __get__(
|
|
546
|
+
self, obj: object, objtype: Optional[type] = None
|
|
547
|
+
) -> Union["ManyToMany[E]", List[E]]:
|
|
548
|
+
if obj is None:
|
|
549
|
+
return self # type: ignore[return-value]
|
|
550
|
+
|
|
551
|
+
from .db_engine import Database
|
|
552
|
+
|
|
553
|
+
db = Database.get_instance()
|
|
554
|
+
related_ids = db.reverse_index_get(obj._type, obj._id, self.name)
|
|
555
|
+
|
|
556
|
+
entities = []
|
|
557
|
+
for related_id in related_ids:
|
|
558
|
+
for type_name in self._get_allowed_types():
|
|
559
|
+
entity_class = db._entity_types.get(type_name)
|
|
560
|
+
if entity_class:
|
|
561
|
+
entity = entity_class.load(related_id)
|
|
562
|
+
if entity:
|
|
563
|
+
entities.append(entity)
|
|
564
|
+
break
|
|
565
|
+
return entities # type: ignore[return-value]
|
|
596
566
|
|
|
597
567
|
def __set__(self, obj, values):
|
|
598
|
-
|
|
568
|
+
from .db_engine import Database
|
|
599
569
|
from .entity import Entity
|
|
600
570
|
|
|
601
|
-
|
|
571
|
+
if values is None:
|
|
572
|
+
values = []
|
|
602
573
|
if isinstance(values, Entity):
|
|
603
574
|
values = [values]
|
|
604
575
|
elif isinstance(values, (str, int)):
|
|
605
|
-
# Handle single string ID/name
|
|
606
576
|
values = [values]
|
|
607
|
-
elif
|
|
577
|
+
elif not isinstance(values, (list, tuple)):
|
|
608
578
|
raise TypeError(f"{self.name} must be set to an entity or list of entities")
|
|
609
579
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
580
|
+
resolved = []
|
|
581
|
+
for v in values:
|
|
582
|
+
entity = self.resolve_entity(obj, v)
|
|
583
|
+
self.validate_entity(entity)
|
|
584
|
+
resolved.append(entity)
|
|
585
|
+
|
|
586
|
+
db = Database.get_instance()
|
|
587
|
+
|
|
588
|
+
# Remove old relations from both sides' indexes
|
|
589
|
+
old_ids = db.reverse_index_get(obj._type, obj._id, self.name)
|
|
590
|
+
for old_id in old_ids:
|
|
591
|
+
db.reverse_index_remove(obj._type, obj._id, self.name, old_id)
|
|
592
|
+
for type_name in self._get_allowed_types():
|
|
593
|
+
entity_class = db._entity_types.get(type_name)
|
|
594
|
+
if entity_class and db.load(type_name, old_id):
|
|
595
|
+
db.reverse_index_remove(
|
|
596
|
+
type_name, old_id, self.reverse_name, obj._id
|
|
625
597
|
)
|
|
598
|
+
break
|
|
626
599
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
@overload
|
|
633
|
-
def __get__(self, obj: None, objtype: Optional[type]) -> "ManyToMany[E]": ...
|
|
634
|
-
|
|
635
|
-
@overload
|
|
636
|
-
def __get__(self, obj: object, objtype: Optional[type]) -> "RelationList[E]": ...
|
|
600
|
+
# Add new relations to both sides' indexes
|
|
601
|
+
for entity in resolved:
|
|
602
|
+
db.reverse_index_add(obj._type, obj._id, self.name, entity._id)
|
|
603
|
+
db.reverse_index_add(entity._type, entity._id, self.reverse_name, obj._id)
|
|
637
604
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
) -> Union["ManyToMany[E]", "RelationList[E]"]:
|
|
641
|
-
"""Get related entities as a RelationList."""
|
|
642
|
-
if obj is None:
|
|
643
|
-
return self
|
|
644
|
-
return RelationList(obj, self)
|
|
605
|
+
if not obj._do_not_save:
|
|
606
|
+
obj._save()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ic-python-db
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: A lightweight key-value database written in Python, intended for use on the Internet Computer (IC)
|
|
5
5
|
Home-page: https://github.com/smart-social-contracts/ic-python-db
|
|
6
6
|
Author: Smart Social Contracts
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
ic_python_db/__init__.py,sha256=
|
|
1
|
+
ic_python_db/__init__.py,sha256=qHr8h3FfgYJ1S5P7R7ZQBbEG94J3IDsrGPJj7Z-2-lg,989
|
|
2
2
|
ic_python_db/_cdk.py,sha256=zTj8HzXUcPz8R73nh5bo6JMkCorp-KSW1hDutzi5LZs,387
|
|
3
3
|
ic_python_db/constants.py,sha256=oLvKJev-65gXFd284DdYutWBX95sTF5Xkikc6IC4mhM,118
|
|
4
4
|
ic_python_db/context.py,sha256=97F4_EAJm3adOnUfh0SaDisX3nc7F2vFIZwZUsUAy58,1080
|
|
5
|
-
ic_python_db/db_engine.py,sha256=
|
|
6
|
-
ic_python_db/entity.py,sha256=
|
|
5
|
+
ic_python_db/db_engine.py,sha256=jPJsqBPKT5omk43fODzXsR58ZY9BVTuZKo-4qW-tbdk,16022
|
|
6
|
+
ic_python_db/entity.py,sha256=jzQczj0jMqOYwDkPx5sd4Z85vdd9i8GS9UnT6_HfYxc,40997
|
|
7
7
|
ic_python_db/hooks.py,sha256=B1wV18dAMH-PootSripmMpG9Cr9M3W3lcNEF7QvPrL4,1620
|
|
8
8
|
ic_python_db/mixins.py,sha256=qZmb3ssXYf2NSjHJtgmdsOlkIJdouDjPHdQ9tZmrZi8,1937
|
|
9
|
-
ic_python_db/properties.py,sha256=
|
|
9
|
+
ic_python_db/properties.py,sha256=U_NpybwIIi_tJf9n1Aayl7dXfyKqPCMn1j2ujj_RcK8,21497
|
|
10
10
|
ic_python_db/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
11
|
ic_python_db/schema.py,sha256=zCfrlYQlSvfhBuUfDLup9uPW_OyqJKZs5qMg1RzaINM,16142
|
|
12
12
|
ic_python_db/storage.py,sha256=2F6m_01g2jwoF40s4BrdO2SffJNSO3gPf0QU3Co7T5Y,1873
|
|
13
13
|
ic_python_db/system_time.py,sha256=xrx8NfjLXCGsac3hUpuvsNwCH91tQ9jfH4N-XFo8iJk,2614
|
|
14
|
-
ic_python_db-0.
|
|
15
|
-
ic_python_db-0.
|
|
16
|
-
ic_python_db-0.
|
|
17
|
-
ic_python_db-0.
|
|
18
|
-
ic_python_db-0.
|
|
14
|
+
ic_python_db-0.9.0.dist-info/licenses/LICENSE,sha256=6q6XYNOGnJcVSus2bAezFn7bU_2Y5T6W4aGQHBb8X-c,1079
|
|
15
|
+
ic_python_db-0.9.0.dist-info/METADATA,sha256=xzdjbQH3GiCH2BzIDDYFPXG5R1nZCAV48OVlnqlpv-M,15130
|
|
16
|
+
ic_python_db-0.9.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
17
|
+
ic_python_db-0.9.0.dist-info/top_level.txt,sha256=ZaOTqhWKtJQEuXmqR920AqbDBAqv1mvsKj3TClWimDk,13
|
|
18
|
+
ic_python_db-0.9.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|