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 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.8.2"
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
- D = d
465
- L = [_.serialize() for _ in cls.instances()]
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
- cls.load(d["_id"]) for d in L if all(d.get(k) == v for k, v in D.items())
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
- Uses load_some() for O(max_id) performance instead of scanning all keys.
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 # Skip already processed classes
492
+ continue
496
493
  if type_name == full_type_name or type_name == cls.__name__:
497
- continue # Skip self
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().delete(self._type, self._id)
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
- self.db().unregister_entity(self._type, self._id)
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(self.db().load("_system", count_key) or 0)
649
+ current_count = int(db.load("_system", count_key) or 0)
595
650
  if current_count > 0:
596
- self.db().save("_system", count_key, str(current_count - 1))
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
- self.db().delete(self._alias_key(), alias_value)
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 all relations. Used by _save() for persistence."""
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 ManyToMany, OneToMany
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
- if len(rel_entities) == 1 and not is_to_many:
665
- data[rel_name] = self._get_entity_reference(rel_entities[0])
666
- else:
667
- data[rel_name] = [
668
- self._get_entity_reference(e) for e in rel_entities
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
- OneToMany relations are skipped (reconstructed from reverse ManyToOne).
677
- For bilateral OneToOne relations, only the alphabetically-earlier entity
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, OneToMany, OneToOne
747
+ from ic_python_db.properties import ManyToMany, ManyToOne, OneToOne
686
748
 
687
- for rel_name, rel_entities in self._relations.items():
688
- if rel_entities:
689
- rel_prop = getattr(self.__class__, rel_name, None)
749
+ db = self.db()
690
750
 
691
- # Skip OneToMany — always reconstructed from reverse ManyToOne
692
- if isinstance(rel_prop, OneToMany):
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
- # For OneToOne bilateral, only serialize on one deterministic side:
695
- # the entity whose type name is alphabetically <= the target type.
696
- if isinstance(rel_prop, OneToOne):
697
- target_type = rel_entities[0]._type if rel_entities else None
698
- if target_type and self._type > target_type:
699
- continue
700
-
701
- is_to_many = isinstance(rel_prop, (OneToMany, ManyToMany))
702
-
703
- if len(rel_entities) == 1 and not is_to_many:
704
- data[rel_name] = self._get_entity_reference(rel_entities[0])
705
- else:
706
- data[rel_name] = [
707
- self._get_entity_reference(e) for e in rel_entities
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()
@@ -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
- class Relation(Generic[E]):
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
- For one-to-one relationships (default), this property returns a single entity and
195
- enforces single cardinality. For one-to-many or many-to-many relationships, it
196
- returns a list of entities.
173
+ class Relation(Generic[E]):
174
+ """Base class for relation properties.
197
175
 
198
- The type parameter E indicates the type of related entity.
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
- @overload
231
- def __get__(self, obj: None, objtype: Optional[type]) -> "Relation[E]": ...
232
-
233
- @overload
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 __set__(self, obj, value):
251
- """Set related entities.
252
-
253
- Args:
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
- # Convert entity_types to list for uniform handling
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 set an Entity instance of any of the following types: {self.entity_types}, "
323
- f"but got type '{entity._type}'"
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
- # Try to find entity by ID or name (alias) using each allowed entity type
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 types {self.entity_types} found with ID or name '{value}'"
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 RelationList(Generic[E]):
372
- """Helper class for managing lists of related entities."""
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 property type represents a one-to-one relationship where each entity
415
- can be related to exactly one entity on the other side.
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 Person(Entity):
419
- profile = OneToOne('Profile', 'person')
268
+ class Employee(Entity):
269
+ department = ManyToOne('Department', 'employees')
420
270
 
421
- class Profile(Entity):
422
- person = OneToOne('Person', 'profile')
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, many=False)
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
- """Set the related entity with one-to-one constraints."""
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 (one-to-one relationship)"
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
- # Check that the reverse property is OneToOne
445
- reverse_prop = value.__class__.__dict__.get(self.reverse_name)
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
- # Get current value if any
452
- current = self.__get__(obj)
453
- if current is value:
454
- return
455
- if current is not None:
456
- # Remove existing relation
457
- obj.remove_relation(self.name, self.reverse_name, current)
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
- # Check if value is already related to another entity and remove that relation
460
- existing = value.get_relations(self.reverse_name)
461
- if existing:
462
- existing_entity = existing[0]
463
- if existing_entity is obj:
464
- return
465
- # Remove the existing relation from both sides
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
- # Set the new relation
470
- super().__set__(obj, value)
339
+ if not obj._do_not_save:
340
+ obj._save()
471
341
 
472
342
 
473
343
  class OneToMany(Relation[E]):
474
- """Property for defining one-to-many relationships.
344
+ """One-to-many relationship (parent side, resolved via reverse index).
475
345
 
476
- This property type represents a one-to-many relationship where the 'one' side
477
- owns multiple instances of the 'many' side, but each 'many' instance belongs to
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, many=True)
358
+ super().__init__(entity_types, reverse_name)
490
359
 
491
- def __set__(self, obj, values):
492
- """Set related entities with one-to-many constraints."""
493
- if not isinstance(values, (list, tuple)):
494
- raise TypeError(f"{self.name} must be set to a list of entities")
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
- # Replace original values with resolved entities
513
- super().__set__(obj, resolved_values)
366
+ from .db_engine import Database
514
367
 
515
- @overload
516
- def __get__(self, obj: None, objtype: Optional[type]) -> "OneToMany[E]": ...
368
+ db = Database.get_instance()
369
+ child_ids = db.reverse_index_get(obj._type, obj._id, self.name)
517
370
 
518
- @overload
519
- def __get__(self, obj: object, objtype: Optional[type]) -> "RelationList[E]": ...
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 __get__(
522
- self, obj: object, objtype: Optional[type] = None
523
- ) -> Union["OneToMany[E]", "RelationList[E]"]:
524
- """Get related entities as a RelationList."""
525
- if obj is None:
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 ManyToOne(Relation[E]):
531
- """Property for defining many-to-one relationships.
389
+ class OneToOne(Relation[E]):
390
+ """One-to-one relationship.
532
391
 
533
- This property type represents a many-to-one relationship where multiple entities
534
- can belong to a single owner entity.
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 Employee(Entity):
538
- department = ManyToOne('Department', 'employees')
396
+ class Person(Entity):
397
+ profile = OneToOne('Profile', 'person')
539
398
 
540
- class Department(Entity):
541
- employees = OneToMany('Employee', 'department')
399
+ class Profile(Entity):
400
+ person = OneToOne('Person', 'profile')
542
401
  """
543
402
 
544
403
  def __init__(
545
- self, entity_types: Union[str, List[str]], reverse_name: Optional[str] = None
404
+ self,
405
+ entity_types: Union[str, List[str]],
406
+ reverse_name: Optional[str] = None,
546
407
  ):
547
- super().__init__(entity_types, reverse_name, many=False)
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
- """Set the related entity with many-to-one constraints."""
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 (many-to-one relationship)"
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
- # Check that the reverse property is OneToMany
565
- # Use getattr to walk MRO so inherited relations are found
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
- if not isinstance(reverse_prop, OneToMany):
573
- raise ValueError(
574
- f"Reverse property '{self.reverse_name}' must be OneToMany and it is '{reverse_prop.__class__.__name__}'"
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
- super().__set__(obj, value)
525
+ if not obj._do_not_save:
526
+ obj._save()
578
527
 
579
528
 
580
529
  class ManyToMany(Relation[E]):
581
- """Property for defining many-to-many relationships.
530
+ """Many-to-many relationship with bidirectional reverse indexes.
582
531
 
583
- This property type represents a many-to-many relationship where entities
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, many=True)
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
- """Set related entities with many-to-many constraints."""
568
+ from .db_engine import Database
599
569
  from .entity import Entity
600
570
 
601
- # Convert single entity to list for convenience
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 values is not None and not isinstance(values, (list, tuple)):
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
- if values is not None:
611
- resolved_values = []
612
- for value in values:
613
- # Resolve value to Entity instance
614
- resolved_value = self.resolve_entity(obj, value)
615
-
616
- # Validate entity type using the base validate_entity method
617
- self.validate_entity(resolved_value)
618
- resolved_values.append(resolved_value)
619
-
620
- # Check that the reverse property is ManyToMany
621
- reverse_prop = resolved_value.__class__.__dict__.get(self.reverse_name)
622
- if not isinstance(reverse_prop, ManyToMany):
623
- raise ValueError(
624
- f"Reverse property '{self.reverse_name}' must be ManyToMany"
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
- # Replace original values with resolved entities
628
- values = resolved_values
629
-
630
- super().__set__(obj, values)
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
- def __get__(
639
- self, obj: object, objtype: Optional[type] = None
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.8.2
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=ImQQTAjHTT7UEqYwhySPAijIU0jJHEjn53QH7W54Ddk,989
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=YeekQT7gSVSKlXkVS7cnwEpFibPMuzgIOGLxk2la734,13560
6
- ic_python_db/entity.py,sha256=LSlFYDvQoLnn1DQWpsgzlJ6MK5cPLGCEHiV5UsRge-4,40054
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=f2_ugxToz5QIXRL6EukneQmXZBu-wbIrio0zlVqdme0,22632
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.8.2.dist-info/licenses/LICENSE,sha256=6q6XYNOGnJcVSus2bAezFn7bU_2Y5T6W4aGQHBb8X-c,1079
15
- ic_python_db-0.8.2.dist-info/METADATA,sha256=mos6F5JJwuVBnHHGINkP-ru95mRZWIknQjKMsI0Zzd0,15130
16
- ic_python_db-0.8.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
17
- ic_python_db-0.8.2.dist-info/top_level.txt,sha256=ZaOTqhWKtJQEuXmqR920AqbDBAqv1mvsKj3TClWimDk,13
18
- ic_python_db-0.8.2.dist-info/RECORD,,
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,,