ic-python-db 0.10.0__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.10.0/ic_python_db.egg-info → ic_python_db-0.11.0}/PKG-INFO +37 -1
  2. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/README.md +36 -0
  3. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/__init__.py +1 -1
  4. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/db_engine.py +50 -0
  5. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/entity.py +150 -5
  6. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/properties.py +35 -5
  7. {ic_python_db-0.10.0 → ic_python_db-0.11.0/ic_python_db.egg-info}/PKG-INFO +37 -1
  8. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/pyproject.toml +1 -1
  9. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/setup.py +1 -1
  10. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/LICENSE +0 -0
  11. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/MANIFEST.in +0 -0
  12. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/_cdk.py +0 -0
  13. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/constants.py +0 -0
  14. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/context.py +0 -0
  15. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/hooks.py +0 -0
  16. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/mixins.py +0 -0
  17. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/py.typed +0 -0
  18. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/schema.py +0 -0
  19. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/storage.py +0 -0
  20. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/system_time.py +0 -0
  21. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db.egg-info/SOURCES.txt +0 -0
  22. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
  23. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db.egg-info/top_level.txt +0 -0
  24. {ic_python_db-0.10.0 → ic_python_db-0.11.0}/requirements-dev.txt +0 -0
  25. {ic_python_db-0.10.0 → 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.10.0
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.10.0"
23
+ __version__ = "0.11.0"
24
24
  __all__ = [
25
25
  "Database",
26
26
  "Entity",
@@ -426,6 +426,56 @@ 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
+
429
479
  # Reverse counters (unidirectional relations)
430
480
  # Key format: "_rc:{parent_type}:{parent_id}:{relation_name}"
431
481
  # Value: integer count of related entities.
@@ -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.
@@ -674,10 +816,7 @@ class Entity:
674
816
  for attr_name, attr_value in cls.__dict__.items():
675
817
  if attr_name.startswith("_"):
676
818
  continue
677
- if (
678
- isinstance(attr_value, ManyToMany)
679
- and attr_value.unidirectional
680
- ):
819
+ if isinstance(attr_value, ManyToMany) and attr_value.unidirectional:
681
820
  my_short = (
682
821
  self._type.split("::")[-1]
683
822
  if "::" in self._type
@@ -692,6 +831,12 @@ class Entity:
692
831
  self._type, self._id, attr_value.reverse_name
693
832
  )
694
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
+
695
840
  db.delete(self._type, self._id)
696
841
 
697
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 ───────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ic-python-db
3
- Version: 0.10.0
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.10.0"
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.10.0",
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