ic-python-db 0.9.0__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.
- {ic_python_db-0.9.0/ic_python_db.egg-info → ic_python_db-0.10.0}/PKG-INFO +1 -1
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/__init__.py +1 -1
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/db_engine.py +62 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/entity.py +60 -6
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/properties.py +136 -7
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/schema.py +2 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0/ic_python_db.egg-info}/PKG-INFO +1 -1
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/pyproject.toml +1 -1
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/setup.py +1 -1
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/LICENSE +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/MANIFEST.in +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/README.md +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/_cdk.py +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/constants.py +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/context.py +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/hooks.py +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/mixins.py +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/py.typed +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/storage.py +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db/system_time.py +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db.egg-info/SOURCES.txt +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/ic_python_db.egg-info/top_level.txt +0 -0
- {ic_python_db-0.9.0 → ic_python_db-0.10.0}/requirements-dev.txt +0 -0
- {ic_python_db-0.9.0 → 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.
|
|
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.
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
|
@@ -526,6 +526,100 @@ class OneToOne(Relation[E]):
|
|
|
526
526
|
obj._save()
|
|
527
527
|
|
|
528
528
|
|
|
529
|
+
class ManyToManyList(list):
|
|
530
|
+
"""List subclass returned by ManyToMany.__get__ that supports .add() and .remove()."""
|
|
531
|
+
|
|
532
|
+
def __init__(self, items, owner, prop):
|
|
533
|
+
super().__init__(items)
|
|
534
|
+
self._owner = owner
|
|
535
|
+
self._prop = prop
|
|
536
|
+
|
|
537
|
+
def add(self, entity):
|
|
538
|
+
"""Add an entity to this many-to-many relationship."""
|
|
539
|
+
from .db_engine import Database
|
|
540
|
+
from .entity import Entity
|
|
541
|
+
|
|
542
|
+
if isinstance(entity, (str, int)):
|
|
543
|
+
resolved = self._prop.resolve_entity(self._owner, entity)
|
|
544
|
+
elif isinstance(entity, Entity):
|
|
545
|
+
resolved = entity
|
|
546
|
+
else:
|
|
547
|
+
raise TypeError(f"Cannot add {type(entity)} to ManyToMany relation")
|
|
548
|
+
|
|
549
|
+
self._prop.validate_entity(resolved)
|
|
550
|
+
db = Database.get_instance()
|
|
551
|
+
|
|
552
|
+
existing = db.reverse_index_get(
|
|
553
|
+
self._owner._type, self._owner._id, self._prop.name
|
|
554
|
+
)
|
|
555
|
+
if resolved._id not in existing:
|
|
556
|
+
db.reverse_index_add(
|
|
557
|
+
self._owner._type, self._owner._id, self._prop.name, resolved._id
|
|
558
|
+
)
|
|
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
|
+
)
|
|
570
|
+
if not self._owner._do_not_save:
|
|
571
|
+
self._owner._save()
|
|
572
|
+
self.append(resolved)
|
|
573
|
+
|
|
574
|
+
def discard(self, entity):
|
|
575
|
+
"""Remove an entity from this many-to-many relationship (no error if absent)."""
|
|
576
|
+
from .db_engine import Database
|
|
577
|
+
from .entity import Entity
|
|
578
|
+
|
|
579
|
+
if isinstance(entity, Entity):
|
|
580
|
+
entity_id = entity._id
|
|
581
|
+
entity_type = entity._type
|
|
582
|
+
else:
|
|
583
|
+
entity_id = str(entity)
|
|
584
|
+
entity_type = None
|
|
585
|
+
|
|
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
|
+
)
|
|
590
|
+
db.reverse_index_remove(
|
|
591
|
+
self._owner._type, self._owner._id, self._prop.name, entity_id
|
|
592
|
+
)
|
|
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:
|
|
605
|
+
db.reverse_index_remove(
|
|
606
|
+
entity_type, entity_id, self._prop.reverse_name, self._owner._id
|
|
607
|
+
)
|
|
608
|
+
else:
|
|
609
|
+
for type_name in self._prop._get_allowed_types():
|
|
610
|
+
db.reverse_index_remove(
|
|
611
|
+
type_name, entity_id, self._prop.reverse_name, self._owner._id
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
self[:] = [e for e in self if getattr(e, "_id", None) != entity_id]
|
|
615
|
+
if not self._owner._do_not_save:
|
|
616
|
+
self._owner._save()
|
|
617
|
+
|
|
618
|
+
def remove(self, entity):
|
|
619
|
+
"""Remove an entity from this many-to-many relationship."""
|
|
620
|
+
self.discard(entity)
|
|
621
|
+
|
|
622
|
+
|
|
529
623
|
class ManyToMany(Relation[E]):
|
|
530
624
|
"""Many-to-many relationship with bidirectional reverse indexes.
|
|
531
625
|
|
|
@@ -537,10 +631,32 @@ class ManyToMany(Relation[E]):
|
|
|
537
631
|
|
|
538
632
|
class Course(Entity):
|
|
539
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.
|
|
540
650
|
"""
|
|
541
651
|
|
|
542
|
-
def __init__(
|
|
652
|
+
def __init__(
|
|
653
|
+
self,
|
|
654
|
+
entity_types: Union[str, List[str]],
|
|
655
|
+
reverse_name: str,
|
|
656
|
+
unidirectional: bool = False,
|
|
657
|
+
):
|
|
543
658
|
super().__init__(entity_types, reverse_name)
|
|
659
|
+
self.unidirectional = unidirectional
|
|
544
660
|
|
|
545
661
|
def __get__(
|
|
546
662
|
self, obj: object, objtype: Optional[type] = None
|
|
@@ -562,7 +678,7 @@ class ManyToMany(Relation[E]):
|
|
|
562
678
|
if entity:
|
|
563
679
|
entities.append(entity)
|
|
564
680
|
break
|
|
565
|
-
return entities # type: ignore[return-value]
|
|
681
|
+
return ManyToManyList(entities, obj, self) # type: ignore[return-value]
|
|
566
682
|
|
|
567
683
|
def __set__(self, obj, values):
|
|
568
684
|
from .db_engine import Database
|
|
@@ -592,15 +708,28 @@ class ManyToMany(Relation[E]):
|
|
|
592
708
|
for type_name in self._get_allowed_types():
|
|
593
709
|
entity_class = db._entity_types.get(type_name)
|
|
594
710
|
if entity_class and db.load(type_name, old_id):
|
|
595
|
-
|
|
596
|
-
type_name, old_id, self.reverse_name,
|
|
597
|
-
|
|
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
|
+
)
|
|
598
717
|
break
|
|
599
718
|
|
|
600
|
-
# 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()
|
|
601
722
|
for entity in resolved:
|
|
723
|
+
if entity._id in seen_ids:
|
|
724
|
+
continue
|
|
725
|
+
seen_ids.add(entity._id)
|
|
602
726
|
db.reverse_index_add(obj._type, obj._id, self.name, entity._id)
|
|
603
|
-
|
|
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
|
+
)
|
|
604
733
|
|
|
605
734
|
if not obj._do_not_save:
|
|
606
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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|