ic-python-db 0.8.2__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {ic_python_db-0.8.2/ic_python_db.egg-info → ic_python_db-0.9.0}/PKG-INFO +1 -1
  2. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/__init__.py +1 -1
  3. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/db_engine.py +60 -0
  4. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/entity.py +140 -140
  5. ic_python_db-0.9.0/ic_python_db/properties.py +606 -0
  6. {ic_python_db-0.8.2 → ic_python_db-0.9.0/ic_python_db.egg-info}/PKG-INFO +1 -1
  7. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/pyproject.toml +1 -1
  8. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/setup.py +1 -1
  9. ic_python_db-0.8.2/ic_python_db/properties.py +0 -644
  10. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/LICENSE +0 -0
  11. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/MANIFEST.in +0 -0
  12. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/README.md +0 -0
  13. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/_cdk.py +0 -0
  14. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/constants.py +0 -0
  15. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/context.py +0 -0
  16. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/hooks.py +0 -0
  17. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/mixins.py +0 -0
  18. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/py.typed +0 -0
  19. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/schema.py +0 -0
  20. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/storage.py +0 -0
  21. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db/system_time.py +0 -0
  22. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db.egg-info/SOURCES.txt +0 -0
  23. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
  24. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/ic_python_db.egg-info/top_level.txt +0 -0
  25. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/requirements-dev.txt +0 -0
  26. {ic_python_db-0.8.2 → ic_python_db-0.9.0}/setup.cfg +0 -0
@@ -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
@@ -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",
@@ -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]:
@@ -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()