ic-python-db 0.9.1__tar.gz → 0.11.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.11.0}/PKG-INFO +37 -1
  2. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/README.md +36 -0
  3. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/__init__.py +1 -1
  4. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/db_engine.py +112 -0
  5. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/entity.py +206 -7
  6. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/properties.py +102 -15
  7. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/schema.py +2 -0
  8. {ic_python_db-0.9.1 → ic_python_db-0.11.0/ic_python_db.egg-info}/PKG-INFO +37 -1
  9. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/pyproject.toml +1 -1
  10. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/setup.py +1 -1
  11. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/LICENSE +0 -0
  12. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/MANIFEST.in +0 -0
  13. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/_cdk.py +0 -0
  14. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/constants.py +0 -0
  15. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/context.py +0 -0
  16. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/hooks.py +0 -0
  17. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/mixins.py +0 -0
  18. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/py.typed +0 -0
  19. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/storage.py +0 -0
  20. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/system_time.py +0 -0
  21. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db.egg-info/SOURCES.txt +0 -0
  22. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
  23. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db.egg-info/top_level.txt +0 -0
  24. {ic_python_db-0.9.1 → ic_python_db-0.11.0}/requirements-dev.txt +0 -0
  25. {ic_python_db-0.9.1 → ic_python_db-0.11.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.11.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
@@ -194,6 +194,42 @@ Then use the defined entities to store objects:
194
194
 
195
195
  For more usage examples, see the [tests](tests/src/tests).
196
196
 
197
+ ## Indexed Fields
198
+
199
+ Declare `indexed=True` on a scalar property to maintain a persistent secondary index, enabling equality lookups without scanning all entities:
200
+
201
+ ```python
202
+ class Proposal(Entity):
203
+ title = String()
204
+ status = String(max_length=32, indexed=True)
205
+ org_scope = String(max_length=128, indexed=True)
206
+
207
+ Proposal(title="A", status="voting", org_scope="Justice")
208
+ Proposal(title="B", status="executed", org_scope="Justice")
209
+
210
+ # Paginated equality lookup — cost proportional to matches, not max_id
211
+ entities, next_from_id = Proposal.find_by("status", "voting", from_id=1, count=50)
212
+
213
+ # Count without loading entities
214
+ Proposal.count_by("org_scope", "Justice") # 2
215
+ ```
216
+
217
+ The index is maintained automatically on create, update, and delete. `None` values are not indexed. Querying a field that is not declared `indexed=True` raises `ValueError`.
218
+
219
+ ### Backfilling existing data
220
+
221
+ Entities created before a field became indexed are missing from its index. Rebuild in bounded batches (resumable, safe for IC instruction limits — spread calls across multiple update calls or timers):
222
+
223
+ ```python
224
+ cursor = 1
225
+ while cursor is not None:
226
+ cursor = Proposal.rebuild_field_index("status", from_id=cursor, batch=200)
227
+ ```
228
+
229
+ The rebuild is idempotent. The `indexed` flag is not part of the schema descriptor, so toggling it never triggers upgrade-compatibility errors.
230
+
231
+ Note: each distinct value's ID list is stored in a single storage entry, so extremely hot values are bounded by the backing store's `max_value_size` — the same constraint as relationship reverse indexes.
232
+
197
233
  ## Namespaces
198
234
 
199
235
  Organize entities with the `__namespace__` attribute to avoid type conflicts when you have the same class name in different modules:
@@ -145,6 +145,42 @@ Then use the defined entities to store objects:
145
145
 
146
146
  For more usage examples, see the [tests](tests/src/tests).
147
147
 
148
+ ## Indexed Fields
149
+
150
+ Declare `indexed=True` on a scalar property to maintain a persistent secondary index, enabling equality lookups without scanning all entities:
151
+
152
+ ```python
153
+ class Proposal(Entity):
154
+ title = String()
155
+ status = String(max_length=32, indexed=True)
156
+ org_scope = String(max_length=128, indexed=True)
157
+
158
+ Proposal(title="A", status="voting", org_scope="Justice")
159
+ Proposal(title="B", status="executed", org_scope="Justice")
160
+
161
+ # Paginated equality lookup — cost proportional to matches, not max_id
162
+ entities, next_from_id = Proposal.find_by("status", "voting", from_id=1, count=50)
163
+
164
+ # Count without loading entities
165
+ Proposal.count_by("org_scope", "Justice") # 2
166
+ ```
167
+
168
+ The index is maintained automatically on create, update, and delete. `None` values are not indexed. Querying a field that is not declared `indexed=True` raises `ValueError`.
169
+
170
+ ### Backfilling existing data
171
+
172
+ Entities created before a field became indexed are missing from its index. Rebuild in bounded batches (resumable, safe for IC instruction limits — spread calls across multiple update calls or timers):
173
+
174
+ ```python
175
+ cursor = 1
176
+ while cursor is not None:
177
+ cursor = Proposal.rebuild_field_index("status", from_id=cursor, batch=200)
178
+ ```
179
+
180
+ The rebuild is idempotent. The `indexed` flag is not part of the schema descriptor, so toggling it never triggers upgrade-compatibility errors.
181
+
182
+ Note: each distinct value's ID list is stored in a single storage entry, so extremely hot values are bounded by the backing store's `max_value_size` — the same constraint as relationship reverse indexes.
183
+
148
184
  ## Namespaces
149
185
 
150
186
  Organize entities with the `__namespace__` attribute to avoid type conflicts when you have the same class name in different modules:
@@ -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.11.0"
24
24
  __all__ = [
25
25
  "Database",
26
26
  "Entity",
@@ -426,6 +426,118 @@ class Database:
426
426
  if self._db_storage.get(key) is not None:
427
427
  self._db_storage.remove(key)
428
428
 
429
+ # ── Field Index API (issue #11) ────────────────────────────────────────────
430
+ #
431
+ # Secondary indexes for scalar properties declared with indexed=True.
432
+ # Allow equality lookups (Entity.find_by) without scanning all entities.
433
+ #
434
+ # Key format: _fi:{Type}:{field}:{value}
435
+ # Value: JSON array of entity IDs, e.g. ["1", "2", "3"]
436
+
437
+ def _fi_key(self, type_name: str, field_name: str, value: str) -> str:
438
+ return f"_fi:{type_name}:{field_name}:{value}"
439
+
440
+ def field_index_get(self, type_name: str, field_name: str, value: str) -> List[str]:
441
+ """Read the ID list for entities whose ``field_name`` equals ``value``.
442
+
443
+ Returns:
444
+ List of entity IDs, or empty list if no index entry exists.
445
+ """
446
+ key = self._fi_key(type_name, field_name, value)
447
+ raw = self._db_storage.get(key)
448
+ if raw:
449
+ return json.loads(raw)
450
+ return []
451
+
452
+ def field_index_add(
453
+ self, type_name: str, field_name: str, value: str, entity_id: str
454
+ ) -> None:
455
+ """Add an entity ID to the index entry for ``value``."""
456
+ key = self._fi_key(type_name, field_name, value)
457
+ raw = self._db_storage.get(key)
458
+ ids = json.loads(raw) if raw else []
459
+ if entity_id not in ids:
460
+ ids.append(entity_id)
461
+ self._db_storage.insert(key, json.dumps(ids))
462
+
463
+ def field_index_remove(
464
+ self, type_name: str, field_name: str, value: str, entity_id: str
465
+ ) -> None:
466
+ """Remove an entity ID from the index entry for ``value``."""
467
+ key = self._fi_key(type_name, field_name, value)
468
+ raw = self._db_storage.get(key)
469
+ if not raw:
470
+ return
471
+ ids = json.loads(raw)
472
+ if entity_id in ids:
473
+ ids.remove(entity_id)
474
+ if ids:
475
+ self._db_storage.insert(key, json.dumps(ids))
476
+ else:
477
+ self._db_storage.remove(key)
478
+
479
+ # Reverse counters (unidirectional relations)
480
+ # Key format: "_rc:{parent_type}:{parent_id}:{relation_name}"
481
+ # Value: integer count of related entities.
482
+ #
483
+ # Used by unidirectional ManyToMany relations: the owning side keeps its
484
+ # normal (small) index while the target side maintains only an O(1)
485
+ # counter instead of an ID array that would grow with every relation
486
+ # (e.g. a UserProfile referenced by 100k users).
487
+
488
+ def _rc_key(self, parent_type: str, parent_id: str, relation_name: str) -> str:
489
+ return f"_rc:{parent_type}:{parent_id}:{relation_name}"
490
+
491
+ def reverse_count_get(
492
+ self, parent_type: str, parent_id: str, relation_name: str
493
+ ) -> int:
494
+ """Read the reverse counter for a parent entity's relation.
495
+
496
+ Falls back to the length of a legacy reverse-index array (from when
497
+ the relation was bidirectional) if no counter exists yet.
498
+ """
499
+ key = self._rc_key(parent_type, parent_id, relation_name)
500
+ raw = self._db_storage.get(key)
501
+ if raw is not None:
502
+ return int(raw)
503
+ legacy = self._db_storage.get(
504
+ self._ri_key(parent_type, parent_id, relation_name)
505
+ )
506
+ if legacy:
507
+ return len(json.loads(legacy))
508
+ return 0
509
+
510
+ def reverse_count_add(
511
+ self, parent_type: str, parent_id: str, relation_name: str, delta: int
512
+ ) -> None:
513
+ """Adjust the reverse counter by delta (never below zero).
514
+
515
+ On first write, seeds the counter from a legacy reverse-index array
516
+ (if present) and drops the array — lazy, exactly-once migration of
517
+ relations converted from bidirectional to unidirectional.
518
+ """
519
+ key = self._rc_key(parent_type, parent_id, relation_name)
520
+ raw = self._db_storage.get(key)
521
+ if raw is not None:
522
+ current = int(raw)
523
+ else:
524
+ legacy_key = self._ri_key(parent_type, parent_id, relation_name)
525
+ legacy = self._db_storage.get(legacy_key)
526
+ if legacy:
527
+ current = len(json.loads(legacy))
528
+ self._db_storage.remove(legacy_key)
529
+ else:
530
+ current = 0
531
+ self._db_storage.insert(key, str(max(0, current + delta)))
532
+
533
+ def reverse_count_delete(
534
+ self, parent_type: str, parent_id: str, relation_name: str
535
+ ) -> None:
536
+ """Delete a reverse counter entry (used when the parent is deleted)."""
537
+ key = self._rc_key(parent_type, parent_id, relation_name)
538
+ if self._db_storage.get(key) is not None:
539
+ self._db_storage.remove(key)
540
+
429
541
  def get_audit(
430
542
  self, id_from: Optional[int] = None, id_to: Optional[int] = None
431
543
  ) -> Dict[str, str]:
@@ -1,6 +1,6 @@
1
1
  """Enhanced entity implementation with support for mixins and entity types."""
2
2
 
3
- from typing import Any, Dict, List, Optional, Set, Type, TypeVar
3
+ from typing import Any, Dict, List, Optional, Set, Tuple, Type, TypeVar
4
4
 
5
5
  from ic_python_logging import get_logger
6
6
 
@@ -198,11 +198,17 @@ class Entity:
198
198
  self.db().register_entity(self)
199
199
 
200
200
  self._do_not_save = True
201
+ # True only while reconstructing persisted state in this constructor:
202
+ # property descriptors skip hooks, validation, and index maintenance
203
+ # (indexes are already intact). Explicit updates later — including
204
+ # deserialize() upserts — run the full descriptor path.
205
+ self._hydrating = self._loaded
201
206
  # Set additional attributes
202
207
  for k, v in kwargs.items():
203
208
  if not k.startswith("_"):
204
209
  setattr(self, k, v)
205
210
  self._do_not_save = False
211
+ self._hydrating = False
206
212
 
207
213
  if not self._loaded:
208
214
  self._save()
@@ -454,6 +460,142 @@ class Entity:
454
460
  if all(getattr(e, k, None) == v for k, v in d.items())
455
461
  ]
456
462
 
463
+ @classmethod
464
+ def _indexed_properties(cls) -> Dict[str, Any]:
465
+ """Return {name: Property} for all properties declared with indexed=True."""
466
+ from .properties import Property
467
+
468
+ result: Dict[str, Any] = {}
469
+ for klass in reversed(cls.__mro__):
470
+ for attr_name, attr_value in klass.__dict__.items():
471
+ if attr_name.startswith("_"):
472
+ continue
473
+ if isinstance(attr_value, Property) and attr_value.indexed:
474
+ result[attr_name] = attr_value
475
+ return result
476
+
477
+ @classmethod
478
+ def _require_indexed_field(cls, field: str) -> None:
479
+ if field not in cls._indexed_properties():
480
+ raise ValueError(
481
+ f"{cls.__name__}.{field} is not an indexed property "
482
+ f"(declare it with indexed=True)"
483
+ )
484
+
485
+ @classmethod
486
+ def find_by(
487
+ cls: Type[T],
488
+ field: str,
489
+ value: Any,
490
+ from_id: int = 1,
491
+ count: int = 50,
492
+ ) -> Tuple[List[T], Optional[int]]:
493
+ """Equality lookup on an indexed field with cursor pagination (issue #11).
494
+
495
+ IDs come from the persistent field index, so cost is proportional to
496
+ the number of matches, not to max_id. Results are ordered by ascending
497
+ entity ID, compatible with load_some-style cursors.
498
+
499
+ Args:
500
+ field: Name of a property declared with indexed=True
501
+ value: Value to match (compared via its string form, like the index)
502
+ from_id: Only return entities with _id >= from_id
503
+ count: Maximum number of entities to return
504
+
505
+ Returns:
506
+ (entities, next_from_id) — next_from_id is None when no more
507
+ matches remain, otherwise pass it back as from_id for the next page.
508
+
509
+ Raises:
510
+ ValueError: If the field is not declared with indexed=True
511
+ """
512
+ cls._require_indexed_field(field)
513
+ if from_id < 1:
514
+ raise ValueError("from_id must be at least 1")
515
+ if count < 1:
516
+ raise ValueError("count must be at least 1")
517
+
518
+ ids = cls.db().field_index_get(cls.get_full_type_name(), field, str(value))
519
+ matching = sorted((int(i) for i in ids if i.isdigit() and int(i) >= from_id))
520
+
521
+ entities: List[T] = []
522
+ last_id = None
523
+ for entity_id in matching:
524
+ if len(entities) >= count:
525
+ break
526
+ entity = cls.load(str(entity_id))
527
+ if entity is not None:
528
+ entities.append(entity)
529
+ last_id = entity_id
530
+
531
+ has_more = last_id is not None and any(i > last_id for i in matching)
532
+ next_from_id = (last_id + 1) if has_more else None
533
+ return entities, next_from_id
534
+
535
+ @classmethod
536
+ def count_by(cls: Type[T], field: str, value: Any) -> int:
537
+ """Number of entities whose indexed ``field`` equals ``value``.
538
+
539
+ Raises:
540
+ ValueError: If the field is not declared with indexed=True
541
+ """
542
+ cls._require_indexed_field(field)
543
+ return len(
544
+ cls.db().field_index_get(cls.get_full_type_name(), field, str(value))
545
+ )
546
+
547
+ @classmethod
548
+ def rebuild_field_index(
549
+ cls: Type[T],
550
+ field: str,
551
+ from_id: int = 1,
552
+ batch: int = 200,
553
+ ) -> Optional[int]:
554
+ """Index one batch of pre-existing entities; resumable (issue #11).
555
+
556
+ Entities created before a field was declared indexed=True are missing
557
+ from its index. This walks the ID range in bounded batches so callers
558
+ on the IC can spread the work across multiple update calls or timer
559
+ ticks without hitting per-message instruction limits:
560
+
561
+ cursor = 1
562
+ while cursor is not None:
563
+ cursor = Proposal.rebuild_field_index("org_scope", from_id=cursor)
564
+
565
+ Idempotent — re-indexing an already-indexed entity is a no-op.
566
+
567
+ Returns:
568
+ The from_id for the next batch, or None when the rebuild is done.
569
+
570
+ Raises:
571
+ ValueError: If the field is not declared with indexed=True
572
+ """
573
+ cls._require_indexed_field(field)
574
+ if from_id < 1:
575
+ raise ValueError("from_id must be at least 1")
576
+ if batch < 1:
577
+ raise ValueError("batch must be at least 1")
578
+
579
+ max_id = cls.max_id()
580
+ if max_id == 0 or from_id > max_id:
581
+ return None
582
+
583
+ db = cls.db()
584
+ type_name = cls.get_full_type_name()
585
+ end = min(from_id + batch - 1, max_id)
586
+ for entity_id in range(from_id, end + 1):
587
+ try:
588
+ entity = cls.load(str(entity_id))
589
+ except (ValueError, AttributeError):
590
+ continue
591
+ if entity is None:
592
+ continue
593
+ value = getattr(entity, field, None)
594
+ if value is not None:
595
+ db.field_index_add(type_name, field, str(value), entity._id)
596
+
597
+ return end + 1 if end < max_id else None
598
+
457
599
  @classmethod
458
600
  def instances(cls: Type[T]) -> List[T]:
459
601
  """Get all instances of this entity type, including subclass instances.
@@ -513,6 +655,20 @@ class Entity:
513
655
  count = db.load("_system", count_key)
514
656
  return int(count) if count else 0
515
657
 
658
+ def reverse_count(self, relation_name: str) -> int:
659
+ """Number of entities referencing this one via a unidirectional
660
+ ManyToMany relation.
661
+
662
+ ``relation_name`` is the ``reverse_name`` declared on the owning
663
+ side, e.g. with ``User.profiles = ManyToMany('UserProfile', 'users',
664
+ unidirectional=True)`` a profile's holder count is
665
+ ``profile.reverse_count('users')``.
666
+
667
+ Falls back to the legacy bidirectional index length when the
668
+ relation was converted and no write has migrated the counter yet.
669
+ """
670
+ return self.db().reverse_count_get(self._type, self._id, relation_name)
671
+
516
672
  @classmethod
517
673
  def max_id(cls: Type[T]) -> int:
518
674
  """Get the maximum ID assigned to entities of this type.
@@ -623,21 +779,64 @@ class Entity:
623
779
  db.reverse_index_delete(self._type, self._id, attr_name)
624
780
  elif isinstance(attr_value, ManyToMany):
625
781
  # Remove self from each related entity's reverse index
782
+ # (or decrement its counter for unidirectional relations)
626
783
  related_ids = db.reverse_index_get(self._type, self._id, attr_name)
627
784
  for related_id in related_ids:
628
785
  for type_name in attr_value._get_allowed_types():
629
786
  entity_class = db._entity_types.get(type_name)
630
787
  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
- )
788
+ if attr_value.unidirectional:
789
+ db.reverse_count_add(
790
+ type_name,
791
+ related_id,
792
+ attr_value.reverse_name,
793
+ -1,
794
+ )
795
+ else:
796
+ db.reverse_index_remove(
797
+ type_name,
798
+ related_id,
799
+ attr_value.reverse_name,
800
+ self._id,
801
+ )
637
802
  break
638
803
  # Delete own index
639
804
  db.reverse_index_delete(self._type, self._id, attr_name)
640
805
 
806
+ # Clean up reverse counters other entity types keep on *this* entity
807
+ # via unidirectional ManyToMany relations targeting our type. Their
808
+ # forward indexes may retain a dangling ID (skipped on load); the
809
+ # counter itself must not outlive us.
810
+ seen_classes = set()
811
+ for entity_class in db._entity_types.values():
812
+ if entity_class in seen_classes or not isinstance(entity_class, type):
813
+ continue
814
+ seen_classes.add(entity_class)
815
+ for cls in reversed(entity_class.__mro__):
816
+ for attr_name, attr_value in cls.__dict__.items():
817
+ if attr_name.startswith("_"):
818
+ continue
819
+ if isinstance(attr_value, ManyToMany) and attr_value.unidirectional:
820
+ my_short = (
821
+ self._type.split("::")[-1]
822
+ if "::" in self._type
823
+ else self._type
824
+ )
825
+ targets = [
826
+ t.split("::")[-1] if "::" in t else t
827
+ for t in attr_value._get_allowed_types()
828
+ ]
829
+ if my_short in targets:
830
+ db.reverse_count_delete(
831
+ self._type, self._id, attr_value.reverse_name
832
+ )
833
+
834
+ # Remove from field indexes (issue #11)
835
+ for field_name in self._indexed_properties():
836
+ value = getattr(self, field_name, None)
837
+ if value is not None:
838
+ db.field_index_remove(self._type, field_name, str(value), self._id)
839
+
641
840
  db.delete(self._type, self._id)
642
841
 
643
842
  # Remove from entity registry
@@ -30,12 +30,18 @@ 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
+
34
+ With ``indexed=True`` a persistent secondary index (issue #11) maps each
35
+ distinct value to the list of entity IDs holding it, enabling equality
36
+ lookups via ``Entity.find_by`` without scanning all entities. ``None``
37
+ values are not indexed.
33
38
  """
34
39
 
35
40
  name: str
36
41
  type: Type[T]
37
42
  default: Optional[T]
38
43
  validator: Optional[Callable[[T], bool]]
44
+ indexed: bool
39
45
 
40
46
  def __init__(
41
47
  self,
@@ -43,11 +49,13 @@ class Property(Generic[T]):
43
49
  type: Type[T] = type(None), # type: ignore[assignment]
44
50
  default: Optional[T] = None,
45
51
  validator: Optional[Callable[[T], bool]] = None,
52
+ indexed: bool = False,
46
53
  ):
47
54
  self.name = name
48
55
  self.type = type
49
56
  self.default = default
50
57
  self.validator = validator
58
+ self.indexed = indexed
51
59
 
52
60
  def __set_name__(self, owner: type, name: str) -> None:
53
61
  self.name = name
@@ -63,6 +71,11 @@ class Property(Generic[T]):
63
71
  from .constants import ACTION_CREATE, ACTION_MODIFY
64
72
  from .hooks import call_entity_hook
65
73
 
74
+ # Fast path: hydrating from DB — store raw value, indexes are intact.
75
+ if getattr(obj, "_hydrating", False):
76
+ obj.__dict__[f"_{PROPERTY_STORAGE_PREFIX}_{self.name}"] = value
77
+ return
78
+
66
79
  old_value = obj.__dict__.get(
67
80
  f"_{PROPERTY_STORAGE_PREFIX}_{self.name}", self.default
68
81
  )
@@ -100,6 +113,14 @@ class Property(Generic[T]):
100
113
  raise ValueError(f"Invalid value for {self.name}: {value}")
101
114
 
102
115
  obj.__dict__[f"_{PROPERTY_STORAGE_PREFIX}_{self.name}"] = value
116
+
117
+ if self.indexed and old_value != value and obj._id is not None:
118
+ db = obj.db()
119
+ if old_value is not None:
120
+ db.field_index_remove(obj._type, self.name, str(old_value), obj._id)
121
+ if value is not None:
122
+ db.field_index_add(obj._type, self.name, str(value), obj._id)
123
+
103
124
  obj._save()
104
125
 
105
126
 
@@ -111,6 +132,7 @@ class String(Property[str]):
111
132
  min_length: Optional[int] = None,
112
133
  max_length: Optional[int] = None,
113
134
  default: Optional[str] = None,
135
+ indexed: bool = False,
114
136
  ):
115
137
  def validator(value: str) -> bool:
116
138
  if min_length is not None and len(value) < min_length:
@@ -119,7 +141,9 @@ class String(Property[str]):
119
141
  return False
120
142
  return True
121
143
 
122
- super().__init__(name="", type=str, default=default, validator=validator)
144
+ super().__init__(
145
+ name="", type=str, default=default, validator=validator, indexed=indexed
146
+ )
123
147
 
124
148
 
125
149
  class Integer(Property[int]):
@@ -130,6 +154,7 @@ class Integer(Property[int]):
130
154
  min_value: Optional[int] = None,
131
155
  max_value: Optional[int] = None,
132
156
  default: Optional[int] = None,
157
+ indexed: bool = False,
133
158
  ):
134
159
  def validator(value: int) -> bool:
135
160
  if min_value is not None and value < min_value:
@@ -138,7 +163,9 @@ class Integer(Property[int]):
138
163
  return False
139
164
  return True
140
165
 
141
- super().__init__(name="", type=int, default=default, validator=validator)
166
+ super().__init__(
167
+ name="", type=int, default=default, validator=validator, indexed=indexed
168
+ )
142
169
 
143
170
 
144
171
  class Float(Property[float]):
@@ -149,6 +176,7 @@ class Float(Property[float]):
149
176
  min_value: Optional[float] = None,
150
177
  max_value: Optional[float] = None,
151
178
  default: Optional[float] = None,
179
+ indexed: bool = False,
152
180
  ):
153
181
  def validator(value: float) -> bool:
154
182
  if min_value is not None and value < min_value:
@@ -157,14 +185,16 @@ class Float(Property[float]):
157
185
  return False
158
186
  return True
159
187
 
160
- super().__init__(name="", type=float, default=default, validator=validator)
188
+ super().__init__(
189
+ name="", type=float, default=default, validator=validator, indexed=indexed
190
+ )
161
191
 
162
192
 
163
193
  class Boolean(Property[bool]):
164
194
  """Boolean property."""
165
195
 
166
- def __init__(self, default: Optional[bool] = None):
167
- super().__init__(name="", type=bool, default=default)
196
+ def __init__(self, default: Optional[bool] = None, indexed: bool = False):
197
+ super().__init__(name="", type=bool, default=default, indexed=indexed)
168
198
 
169
199
 
170
200
  # ── Relation Properties ───────────────────────────────────────────────────────
@@ -556,9 +586,17 @@ class ManyToManyList(list):
556
586
  db.reverse_index_add(
557
587
  self._owner._type, self._owner._id, self._prop.name, resolved._id
558
588
  )
559
- db.reverse_index_add(
560
- resolved._type, resolved._id, self._prop.reverse_name, self._owner._id
561
- )
589
+ if self._prop.unidirectional:
590
+ db.reverse_count_add(
591
+ resolved._type, resolved._id, self._prop.reverse_name, 1
592
+ )
593
+ else:
594
+ db.reverse_index_add(
595
+ resolved._type,
596
+ resolved._id,
597
+ self._prop.reverse_name,
598
+ self._owner._id,
599
+ )
562
600
  if not self._owner._do_not_save:
563
601
  self._owner._save()
564
602
  self.append(resolved)
@@ -576,10 +614,24 @@ class ManyToManyList(list):
576
614
  entity_type = None
577
615
 
578
616
  db = Database.get_instance()
617
+ was_present = entity_id in db.reverse_index_get(
618
+ self._owner._type, self._owner._id, self._prop.name
619
+ )
579
620
  db.reverse_index_remove(
580
621
  self._owner._type, self._owner._id, self._prop.name, entity_id
581
622
  )
582
- if entity_type:
623
+ if self._prop.unidirectional:
624
+ if was_present:
625
+ if entity_type is None:
626
+ for type_name in self._prop._get_allowed_types():
627
+ if db.load(type_name, entity_id):
628
+ entity_type = type_name
629
+ break
630
+ if entity_type:
631
+ db.reverse_count_add(
632
+ entity_type, entity_id, self._prop.reverse_name, -1
633
+ )
634
+ elif entity_type:
583
635
  db.reverse_index_remove(
584
636
  entity_type, entity_id, self._prop.reverse_name, self._owner._id
585
637
  )
@@ -609,10 +661,32 @@ class ManyToMany(Relation[E]):
609
661
 
610
662
  class Course(Entity):
611
663
  students = ManyToMany('Student', 'courses')
664
+
665
+ Unidirectional mode (``unidirectional=True``) is for relations whose
666
+ reverse side would fan out to huge cardinalities (e.g. a profile shared
667
+ by 100k users). Declare it on the owning (small) side only — the target
668
+ entity declares nothing. The owning side keeps its normal index, while
669
+ the target side maintains only an O(1) counter instead of an ID array
670
+ that would be rewritten on every add/remove:
671
+
672
+ class User(Entity):
673
+ profiles = ManyToMany('UserProfile', 'users', unidirectional=True)
674
+
675
+ profile.reverse_count('users') # number of users holding it
676
+
677
+ The counter lazily migrates from a legacy bidirectional index on first
678
+ write (the old ID array is dropped). Reverse *listing* is intentionally
679
+ unsupported — scan the owning entities instead.
612
680
  """
613
681
 
614
- def __init__(self, entity_types: Union[str, List[str]], reverse_name: str):
682
+ def __init__(
683
+ self,
684
+ entity_types: Union[str, List[str]],
685
+ reverse_name: str,
686
+ unidirectional: bool = False,
687
+ ):
615
688
  super().__init__(entity_types, reverse_name)
689
+ self.unidirectional = unidirectional
616
690
 
617
691
  def __get__(
618
692
  self, obj: object, objtype: Optional[type] = None
@@ -664,15 +738,28 @@ class ManyToMany(Relation[E]):
664
738
  for type_name in self._get_allowed_types():
665
739
  entity_class = db._entity_types.get(type_name)
666
740
  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
- )
741
+ if self.unidirectional:
742
+ db.reverse_count_add(type_name, old_id, self.reverse_name, -1)
743
+ else:
744
+ db.reverse_index_remove(
745
+ type_name, old_id, self.reverse_name, obj._id
746
+ )
670
747
  break
671
748
 
672
- # Add new relations to both sides' indexes
749
+ # Add new relations to both sides' indexes (dedupe: the forward index
750
+ # ignores repeats, so the reverse side must too)
751
+ seen_ids = set()
673
752
  for entity in resolved:
753
+ if entity._id in seen_ids:
754
+ continue
755
+ seen_ids.add(entity._id)
674
756
  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)
757
+ if self.unidirectional:
758
+ db.reverse_count_add(entity._type, entity._id, self.reverse_name, 1)
759
+ else:
760
+ db.reverse_index_add(
761
+ entity._type, entity._id, self.reverse_name, obj._id
762
+ )
676
763
 
677
764
  if not obj._do_not_save:
678
765
  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.11.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
@@ -194,6 +194,42 @@ Then use the defined entities to store objects:
194
194
 
195
195
  For more usage examples, see the [tests](tests/src/tests).
196
196
 
197
+ ## Indexed Fields
198
+
199
+ Declare `indexed=True` on a scalar property to maintain a persistent secondary index, enabling equality lookups without scanning all entities:
200
+
201
+ ```python
202
+ class Proposal(Entity):
203
+ title = String()
204
+ status = String(max_length=32, indexed=True)
205
+ org_scope = String(max_length=128, indexed=True)
206
+
207
+ Proposal(title="A", status="voting", org_scope="Justice")
208
+ Proposal(title="B", status="executed", org_scope="Justice")
209
+
210
+ # Paginated equality lookup — cost proportional to matches, not max_id
211
+ entities, next_from_id = Proposal.find_by("status", "voting", from_id=1, count=50)
212
+
213
+ # Count without loading entities
214
+ Proposal.count_by("org_scope", "Justice") # 2
215
+ ```
216
+
217
+ The index is maintained automatically on create, update, and delete. `None` values are not indexed. Querying a field that is not declared `indexed=True` raises `ValueError`.
218
+
219
+ ### Backfilling existing data
220
+
221
+ Entities created before a field became indexed are missing from its index. Rebuild in bounded batches (resumable, safe for IC instruction limits — spread calls across multiple update calls or timers):
222
+
223
+ ```python
224
+ cursor = 1
225
+ while cursor is not None:
226
+ cursor = Proposal.rebuild_field_index("status", from_id=cursor, batch=200)
227
+ ```
228
+
229
+ The rebuild is idempotent. The `indexed` flag is not part of the schema descriptor, so toggling it never triggers upgrade-compatibility errors.
230
+
231
+ Note: each distinct value's ID list is stored in a single storage entry, so extremely hot values are bounded by the backing store's `max_value_size` — the same constraint as relationship reverse indexes.
232
+
197
233
  ## Namespaces
198
234
 
199
235
  Organize entities with the `__namespace__` attribute to avoid type conflicts when you have the same class name in different modules:
@@ -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.11.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.11.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