ic-python-db 0.9.1__tar.gz → 0.10.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 (25) hide show
  1. {ic_python_db-0.9.1/ic_python_db.egg-info → ic_python_db-0.10.0}/PKG-INFO +1 -1
  2. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/__init__.py +1 -1
  3. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/db_engine.py +62 -0
  4. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/entity.py +60 -6
  5. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/properties.py +67 -10
  6. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/schema.py +2 -0
  7. {ic_python_db-0.9.1 → ic_python_db-0.10.0/ic_python_db.egg-info}/PKG-INFO +1 -1
  8. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/pyproject.toml +1 -1
  9. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/setup.py +1 -1
  10. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/LICENSE +0 -0
  11. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/MANIFEST.in +0 -0
  12. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/README.md +0 -0
  13. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/_cdk.py +0 -0
  14. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/constants.py +0 -0
  15. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/context.py +0 -0
  16. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/hooks.py +0 -0
  17. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/mixins.py +0 -0
  18. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/py.typed +0 -0
  19. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/storage.py +0 -0
  20. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db/system_time.py +0 -0
  21. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db.egg-info/SOURCES.txt +0 -0
  22. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
  23. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/ic_python_db.egg-info/top_level.txt +0 -0
  24. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/requirements-dev.txt +0 -0
  25. {ic_python_db-0.9.1 → ic_python_db-0.10.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ic-python-db
3
- Version: 0.9.1
3
+ Version: 0.10.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.9.1"
23
+ __version__ = "0.10.0"
24
24
  __all__ = [
25
25
  "Database",
26
26
  "Entity",
@@ -426,6 +426,68 @@ class Database:
426
426
  if self._db_storage.get(key) is not None:
427
427
  self._db_storage.remove(key)
428
428
 
429
+ # Reverse counters (unidirectional relations)
430
+ # Key format: "_rc:{parent_type}:{parent_id}:{relation_name}"
431
+ # Value: integer count of related entities.
432
+ #
433
+ # Used by unidirectional ManyToMany relations: the owning side keeps its
434
+ # normal (small) index while the target side maintains only an O(1)
435
+ # counter instead of an ID array that would grow with every relation
436
+ # (e.g. a UserProfile referenced by 100k users).
437
+
438
+ def _rc_key(self, parent_type: str, parent_id: str, relation_name: str) -> str:
439
+ return f"_rc:{parent_type}:{parent_id}:{relation_name}"
440
+
441
+ def reverse_count_get(
442
+ self, parent_type: str, parent_id: str, relation_name: str
443
+ ) -> int:
444
+ """Read the reverse counter for a parent entity's relation.
445
+
446
+ Falls back to the length of a legacy reverse-index array (from when
447
+ the relation was bidirectional) if no counter exists yet.
448
+ """
449
+ key = self._rc_key(parent_type, parent_id, relation_name)
450
+ raw = self._db_storage.get(key)
451
+ if raw is not None:
452
+ return int(raw)
453
+ legacy = self._db_storage.get(
454
+ self._ri_key(parent_type, parent_id, relation_name)
455
+ )
456
+ if legacy:
457
+ return len(json.loads(legacy))
458
+ return 0
459
+
460
+ def reverse_count_add(
461
+ self, parent_type: str, parent_id: str, relation_name: str, delta: int
462
+ ) -> None:
463
+ """Adjust the reverse counter by delta (never below zero).
464
+
465
+ On first write, seeds the counter from a legacy reverse-index array
466
+ (if present) and drops the array — lazy, exactly-once migration of
467
+ relations converted from bidirectional to unidirectional.
468
+ """
469
+ key = self._rc_key(parent_type, parent_id, relation_name)
470
+ raw = self._db_storage.get(key)
471
+ if raw is not None:
472
+ current = int(raw)
473
+ else:
474
+ legacy_key = self._ri_key(parent_type, parent_id, relation_name)
475
+ legacy = self._db_storage.get(legacy_key)
476
+ if legacy:
477
+ current = len(json.loads(legacy))
478
+ self._db_storage.remove(legacy_key)
479
+ else:
480
+ current = 0
481
+ self._db_storage.insert(key, str(max(0, current + delta)))
482
+
483
+ def reverse_count_delete(
484
+ self, parent_type: str, parent_id: str, relation_name: str
485
+ ) -> None:
486
+ """Delete a reverse counter entry (used when the parent is deleted)."""
487
+ key = self._rc_key(parent_type, parent_id, relation_name)
488
+ if self._db_storage.get(key) is not None:
489
+ self._db_storage.remove(key)
490
+
429
491
  def get_audit(
430
492
  self, id_from: Optional[int] = None, id_to: Optional[int] = None
431
493
  ) -> Dict[str, str]:
@@ -513,6 +513,20 @@ class Entity:
513
513
  count = db.load("_system", count_key)
514
514
  return int(count) if count else 0
515
515
 
516
+ def reverse_count(self, relation_name: str) -> int:
517
+ """Number of entities referencing this one via a unidirectional
518
+ ManyToMany relation.
519
+
520
+ ``relation_name`` is the ``reverse_name`` declared on the owning
521
+ side, e.g. with ``User.profiles = ManyToMany('UserProfile', 'users',
522
+ unidirectional=True)`` a profile's holder count is
523
+ ``profile.reverse_count('users')``.
524
+
525
+ Falls back to the legacy bidirectional index length when the
526
+ relation was converted and no write has migrated the counter yet.
527
+ """
528
+ return self.db().reverse_count_get(self._type, self._id, relation_name)
529
+
516
530
  @classmethod
517
531
  def max_id(cls: Type[T]) -> int:
518
532
  """Get the maximum ID assigned to entities of this type.
@@ -623,21 +637,61 @@ class Entity:
623
637
  db.reverse_index_delete(self._type, self._id, attr_name)
624
638
  elif isinstance(attr_value, ManyToMany):
625
639
  # Remove self from each related entity's reverse index
640
+ # (or decrement its counter for unidirectional relations)
626
641
  related_ids = db.reverse_index_get(self._type, self._id, attr_name)
627
642
  for related_id in related_ids:
628
643
  for type_name in attr_value._get_allowed_types():
629
644
  entity_class = db._entity_types.get(type_name)
630
645
  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
- )
646
+ if attr_value.unidirectional:
647
+ db.reverse_count_add(
648
+ type_name,
649
+ related_id,
650
+ attr_value.reverse_name,
651
+ -1,
652
+ )
653
+ else:
654
+ db.reverse_index_remove(
655
+ type_name,
656
+ related_id,
657
+ attr_value.reverse_name,
658
+ self._id,
659
+ )
637
660
  break
638
661
  # Delete own index
639
662
  db.reverse_index_delete(self._type, self._id, attr_name)
640
663
 
664
+ # Clean up reverse counters other entity types keep on *this* entity
665
+ # via unidirectional ManyToMany relations targeting our type. Their
666
+ # forward indexes may retain a dangling ID (skipped on load); the
667
+ # counter itself must not outlive us.
668
+ seen_classes = set()
669
+ for entity_class in db._entity_types.values():
670
+ if entity_class in seen_classes or not isinstance(entity_class, type):
671
+ continue
672
+ seen_classes.add(entity_class)
673
+ for cls in reversed(entity_class.__mro__):
674
+ for attr_name, attr_value in cls.__dict__.items():
675
+ if attr_name.startswith("_"):
676
+ continue
677
+ if (
678
+ isinstance(attr_value, ManyToMany)
679
+ and attr_value.unidirectional
680
+ ):
681
+ my_short = (
682
+ self._type.split("::")[-1]
683
+ if "::" in self._type
684
+ else self._type
685
+ )
686
+ targets = [
687
+ t.split("::")[-1] if "::" in t else t
688
+ for t in attr_value._get_allowed_types()
689
+ ]
690
+ if my_short in targets:
691
+ db.reverse_count_delete(
692
+ self._type, self._id, attr_value.reverse_name
693
+ )
694
+
641
695
  db.delete(self._type, self._id)
642
696
 
643
697
  # Remove from entity registry
@@ -556,9 +556,17 @@ class ManyToManyList(list):
556
556
  db.reverse_index_add(
557
557
  self._owner._type, self._owner._id, self._prop.name, resolved._id
558
558
  )
559
- db.reverse_index_add(
560
- resolved._type, resolved._id, self._prop.reverse_name, self._owner._id
561
- )
559
+ if self._prop.unidirectional:
560
+ db.reverse_count_add(
561
+ resolved._type, resolved._id, self._prop.reverse_name, 1
562
+ )
563
+ else:
564
+ db.reverse_index_add(
565
+ resolved._type,
566
+ resolved._id,
567
+ self._prop.reverse_name,
568
+ self._owner._id,
569
+ )
562
570
  if not self._owner._do_not_save:
563
571
  self._owner._save()
564
572
  self.append(resolved)
@@ -576,10 +584,24 @@ class ManyToManyList(list):
576
584
  entity_type = None
577
585
 
578
586
  db = Database.get_instance()
587
+ was_present = entity_id in db.reverse_index_get(
588
+ self._owner._type, self._owner._id, self._prop.name
589
+ )
579
590
  db.reverse_index_remove(
580
591
  self._owner._type, self._owner._id, self._prop.name, entity_id
581
592
  )
582
- if entity_type:
593
+ if self._prop.unidirectional:
594
+ if was_present:
595
+ if entity_type is None:
596
+ for type_name in self._prop._get_allowed_types():
597
+ if db.load(type_name, entity_id):
598
+ entity_type = type_name
599
+ break
600
+ if entity_type:
601
+ db.reverse_count_add(
602
+ entity_type, entity_id, self._prop.reverse_name, -1
603
+ )
604
+ elif entity_type:
583
605
  db.reverse_index_remove(
584
606
  entity_type, entity_id, self._prop.reverse_name, self._owner._id
585
607
  )
@@ -609,10 +631,32 @@ class ManyToMany(Relation[E]):
609
631
 
610
632
  class Course(Entity):
611
633
  students = ManyToMany('Student', 'courses')
634
+
635
+ Unidirectional mode (``unidirectional=True``) is for relations whose
636
+ reverse side would fan out to huge cardinalities (e.g. a profile shared
637
+ by 100k users). Declare it on the owning (small) side only — the target
638
+ entity declares nothing. The owning side keeps its normal index, while
639
+ the target side maintains only an O(1) counter instead of an ID array
640
+ that would be rewritten on every add/remove:
641
+
642
+ class User(Entity):
643
+ profiles = ManyToMany('UserProfile', 'users', unidirectional=True)
644
+
645
+ profile.reverse_count('users') # number of users holding it
646
+
647
+ The counter lazily migrates from a legacy bidirectional index on first
648
+ write (the old ID array is dropped). Reverse *listing* is intentionally
649
+ unsupported — scan the owning entities instead.
612
650
  """
613
651
 
614
- def __init__(self, entity_types: Union[str, List[str]], reverse_name: str):
652
+ def __init__(
653
+ self,
654
+ entity_types: Union[str, List[str]],
655
+ reverse_name: str,
656
+ unidirectional: bool = False,
657
+ ):
615
658
  super().__init__(entity_types, reverse_name)
659
+ self.unidirectional = unidirectional
616
660
 
617
661
  def __get__(
618
662
  self, obj: object, objtype: Optional[type] = None
@@ -664,15 +708,28 @@ class ManyToMany(Relation[E]):
664
708
  for type_name in self._get_allowed_types():
665
709
  entity_class = db._entity_types.get(type_name)
666
710
  if entity_class and db.load(type_name, old_id):
667
- db.reverse_index_remove(
668
- type_name, old_id, self.reverse_name, obj._id
669
- )
711
+ if self.unidirectional:
712
+ db.reverse_count_add(type_name, old_id, self.reverse_name, -1)
713
+ else:
714
+ db.reverse_index_remove(
715
+ type_name, old_id, self.reverse_name, obj._id
716
+ )
670
717
  break
671
718
 
672
- # Add new relations to both sides' indexes
719
+ # Add new relations to both sides' indexes (dedupe: the forward index
720
+ # ignores repeats, so the reverse side must too)
721
+ seen_ids = set()
673
722
  for entity in resolved:
723
+ if entity._id in seen_ids:
724
+ continue
725
+ seen_ids.add(entity._id)
674
726
  db.reverse_index_add(obj._type, obj._id, self.name, entity._id)
675
- db.reverse_index_add(entity._type, entity._id, self.reverse_name, obj._id)
727
+ if self.unidirectional:
728
+ db.reverse_count_add(entity._type, entity._id, self.reverse_name, 1)
729
+ else:
730
+ db.reverse_index_add(
731
+ entity._type, entity._id, self.reverse_name, obj._id
732
+ )
676
733
 
677
734
  if not obj._do_not_save:
678
735
  obj._save()
@@ -73,6 +73,8 @@ def build_field_descriptor(prop) -> Dict[str, Any]:
73
73
  if prop.reverse_name:
74
74
  desc["inverse"] = prop.reverse_name
75
75
  desc["many"] = prop.many
76
+ if isinstance(prop, ManyToMany) and prop.unidirectional:
77
+ desc["unidirectional"] = True
76
78
  return desc
77
79
 
78
80
  desc["kind"] = "property"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ic-python-db
3
- Version: 0.9.1
3
+ Version: 0.10.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ic-python-db"
7
- version = "0.9.1"
7
+ version = "0.10.0"
8
8
  description = "A lightweight key-value database written in Python, intended for use on the Internet Computer (IC)"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="ic-python-db",
8
- version="0.9.1",
8
+ version="0.10.0",
9
9
  author="Smart Social Contracts",
10
10
  author_email="smartsocialcontracts@gmail.com",
11
11
  description="A lightweight key-value database with entity relationships and audit logging",
File without changes
File without changes
File without changes
File without changes