ic-python-db 0.10.0__py3-none-any.whl → 0.12.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -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.12.0"
24
24
  __all__ = [
25
25
  "Database",
26
26
  "Entity",
ic_python_db/db_engine.py CHANGED
@@ -9,10 +9,41 @@ from typing import Any, Dict, List, Optional, Tuple
9
9
 
10
10
  from ic_python_logging import get_logger
11
11
 
12
- from .storage import MemoryStorage, Storage
12
+ from .storage import MemoryStorage, Storage, supports_range
13
13
 
14
14
  logger = get_logger(__name__)
15
15
 
16
+ _DIGITS = frozenset("0123456789")
17
+
18
+
19
+ def _is_sequential_id(id_str: str) -> bool:
20
+ """True for canonical sequential ids: ASCII digits, no leading zero."""
21
+ return bool(id_str) and id_str[0] != "0" and all(c in _DIGITS for c in id_str)
22
+
23
+
24
+ def _next_sequential_id(id_str: str, digits: int) -> Optional[int]:
25
+ """Smallest ``digits``-digit sequential id that sorts after ``id_str``.
26
+
27
+ ``id_str`` is a key suffix returned by a range over the ``digits``-length
28
+ bucket, so it starts with a digit but may be a custom id such as
29
+ ``"1x"`` or ``"1-a"``. Returns None when no such id exists in the bucket.
30
+ """
31
+ i = 0
32
+ while i < len(id_str) and id_str[i] in _DIGITS:
33
+ i += 1
34
+ numeric_prefix, rest = id_str[:i], id_str[i:]
35
+ if not rest:
36
+ candidate = str(int(numeric_prefix) + 1)
37
+ elif rest[0] > "9":
38
+ # "1x" > every "1<digit>": jump to the next prefix, zero-filled
39
+ candidate = str(int(numeric_prefix) + 1) + "0" * len(rest)
40
+ else:
41
+ # "1-a" < "10": the zero-filled prefix is the next candidate
42
+ candidate = numeric_prefix + "0" * len(rest)
43
+ if len(candidate) > digits:
44
+ return None
45
+ return max(int(candidate), 10 ** (digits - 1))
46
+
16
47
 
17
48
  class Database:
18
49
  """Main database class providing high-level operations"""
@@ -181,6 +212,102 @@ class Database:
181
212
  return json.loads(data)
182
213
  return None
183
214
 
215
+ def max_id(self, type_name: str) -> int:
216
+ """Highest sequential id ever issued for ``type_name`` (0 if none)."""
217
+ raw = self.load("_system", f"{type_name}_id")
218
+ return int(raw) if raw else 0
219
+
220
+ def load_page(
221
+ self,
222
+ type_name: str,
223
+ from_id: int,
224
+ count: int,
225
+ max_id: Optional[int] = None,
226
+ ) -> List[Tuple[str, dict]]:
227
+ """Persisted entities of ``type_name`` with sequential id >= ``from_id``.
228
+
229
+ Returns up to ``count`` ``(id, data)`` pairs in ascending numeric id
230
+ order, skipping ids that were deleted. Only canonical sequential ids
231
+ (``"1"``, ``"42"``; no leading zeros, no custom string ids) are
232
+ returned, matching what probing ``1..max_id`` would find.
233
+
234
+ When the storage backend supports ``range`` (Basilisk's
235
+ ``StableBTreeMap``, ``MemoryStorage``) a page costs one range call per
236
+ digit-count bucket touched (usually one), independent of how many ids
237
+ were deleted. Otherwise it falls back to one ``get`` per id.
238
+ """
239
+ if from_id < 1:
240
+ raise ValueError("from_id must be at least 1")
241
+ if count < 1:
242
+ raise ValueError("count must be at least 1")
243
+ if max_id is None:
244
+ max_id = self.max_id(type_name)
245
+ if max_id < from_id:
246
+ return []
247
+ if supports_range(self._db_storage):
248
+ return self._load_page_range(type_name, from_id, count, max_id)
249
+ return self._load_page_probe(type_name, from_id, count, max_id)
250
+
251
+ def _load_page_probe(
252
+ self, type_name: str, from_id: int, count: int, max_id: int
253
+ ) -> List[Tuple[str, dict]]:
254
+ results: List[Tuple[str, dict]] = []
255
+ entity_id = from_id
256
+ while len(results) < count and entity_id <= max_id:
257
+ raw = self._db_storage.get(f"{type_name}@{entity_id}")
258
+ if raw:
259
+ results.append((str(entity_id), json.loads(raw)))
260
+ entity_id += 1
261
+ return results
262
+
263
+ def _load_page_range(
264
+ self, type_name: str, from_id: int, count: int, max_id: int
265
+ ) -> List[Tuple[str, dict]]:
266
+ # Keys are "Type@<id>" with unpadded decimal ids. The backend orders
267
+ # str keys by byte length first, then bytewise (see storage_sort_key),
268
+ # so all ids with the same digit count form one contiguous, numerically
269
+ # ordered run. Walk one run ("bucket") per digit count, from the digit
270
+ # count of from_id up to that of max_id.
271
+ prefix = f"{type_name}@"
272
+ results: List[Tuple[str, dict]] = []
273
+ digits = len(str(from_id))
274
+ max_digits = len(str(max_id))
275
+ while digits <= max_digits and len(results) < count:
276
+ lo_id = max(from_id, 10 ** (digits - 1))
277
+ if digits == max_digits:
278
+ # Exclusive upper bound right after max_id (same digit count),
279
+ # or the whole bucket if max_id is the largest d-digit number.
280
+ hi_id = max_id + 1
281
+ end = (
282
+ f"{prefix}{hi_id}"
283
+ if len(str(hi_id)) == digits
284
+ else f"{prefix}{':' * digits}"
285
+ )
286
+ else:
287
+ # ':' is the character after '9': every same-length key of
288
+ # the form "Type@<digits>" sorts before "Type@:::".
289
+ end = f"{prefix}{':' * digits}"
290
+
291
+ start = f"{prefix}{lo_id}"
292
+ plen = len(prefix)
293
+ while len(results) < count:
294
+ need = count - len(results)
295
+ page = self._db_storage.range(start, end, need)
296
+ for key, raw in page:
297
+ id_str = key[plen:]
298
+ if _is_sequential_id(id_str):
299
+ results.append((id_str, json.loads(raw)))
300
+ if len(page) < need:
301
+ break # bucket exhausted
302
+ # Page was full but may have contained skipped custom ids:
303
+ # continue from the next possible sequential id in this bucket.
304
+ next_id = _next_sequential_id(page[-1][0][plen:], digits)
305
+ if next_id is None:
306
+ break
307
+ start = f"{prefix}{next_id}"
308
+ digits += 1
309
+ return results
310
+
184
311
  def delete(self, type_name: str, entity_id: str) -> None:
185
312
  """Delete the data associated with the key
186
313
 
@@ -426,6 +553,56 @@ class Database:
426
553
  if self._db_storage.get(key) is not None:
427
554
  self._db_storage.remove(key)
428
555
 
556
+ # ── Field Index API (issue #11) ────────────────────────────────────────────
557
+ #
558
+ # Secondary indexes for scalar properties declared with indexed=True.
559
+ # Allow equality lookups (Entity.find_by) without scanning all entities.
560
+ #
561
+ # Key format: _fi:{Type}:{field}:{value}
562
+ # Value: JSON array of entity IDs, e.g. ["1", "2", "3"]
563
+
564
+ def _fi_key(self, type_name: str, field_name: str, value: str) -> str:
565
+ return f"_fi:{type_name}:{field_name}:{value}"
566
+
567
+ def field_index_get(self, type_name: str, field_name: str, value: str) -> List[str]:
568
+ """Read the ID list for entities whose ``field_name`` equals ``value``.
569
+
570
+ Returns:
571
+ List of entity IDs, or empty list if no index entry exists.
572
+ """
573
+ key = self._fi_key(type_name, field_name, value)
574
+ raw = self._db_storage.get(key)
575
+ if raw:
576
+ return json.loads(raw)
577
+ return []
578
+
579
+ def field_index_add(
580
+ self, type_name: str, field_name: str, value: str, entity_id: str
581
+ ) -> None:
582
+ """Add an entity ID to the index entry for ``value``."""
583
+ key = self._fi_key(type_name, field_name, value)
584
+ raw = self._db_storage.get(key)
585
+ ids = json.loads(raw) if raw else []
586
+ if entity_id not in ids:
587
+ ids.append(entity_id)
588
+ self._db_storage.insert(key, json.dumps(ids))
589
+
590
+ def field_index_remove(
591
+ self, type_name: str, field_name: str, value: str, entity_id: str
592
+ ) -> None:
593
+ """Remove an entity ID from the index entry for ``value``."""
594
+ key = self._fi_key(type_name, field_name, value)
595
+ raw = self._db_storage.get(key)
596
+ if not raw:
597
+ return
598
+ ids = json.loads(raw)
599
+ if entity_id in ids:
600
+ ids.remove(entity_id)
601
+ if ids:
602
+ self._db_storage.insert(key, json.dumps(ids))
603
+ else:
604
+ self._db_storage.remove(key)
605
+
429
606
  # Reverse counters (unidirectional relations)
430
607
  # Key format: "_rc:{parent_type}:{parent_id}:{relation_name}"
431
608
  # Value: integer count of related entities.
ic_python_db/entity.py CHANGED
@@ -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
 
@@ -176,8 +176,11 @@ class Entity:
176
176
  next_id = str(int(current_id) + 1)
177
177
  self._id = next_id
178
178
  db.save("_system", f"{type_name}_id", self._id)
179
- else:
180
- # Update max_id if custom ID is higher than current max
179
+ elif not self._loaded:
180
+ # Update max_id if custom ID is higher than current max.
181
+ # Skipped when hydrating persisted state: that id already went
182
+ # through this check when it was first created, so re-reading the
183
+ # counter would only add one storage read per loaded entity.
181
184
  db = self.db()
182
185
  type_name = self._type
183
186
  current_max_id = db.load("_system", f"{type_name}_id")
@@ -198,11 +201,17 @@ class Entity:
198
201
  self.db().register_entity(self)
199
202
 
200
203
  self._do_not_save = True
204
+ # True only while reconstructing persisted state in this constructor:
205
+ # property descriptors skip hooks, validation, and index maintenance
206
+ # (indexes are already intact). Explicit updates later — including
207
+ # deserialize() upserts — run the full descriptor path.
208
+ self._hydrating = self._loaded
201
209
  # Set additional attributes
202
210
  for k, v in kwargs.items():
203
211
  if not k.startswith("_"):
204
212
  setattr(self, k, v)
205
213
  self._do_not_save = False
214
+ self._hydrating = False
206
215
 
207
216
  if not self._loaded:
208
217
  self._save()
@@ -412,6 +421,16 @@ class Entity:
412
421
  if not data:
413
422
  return None
414
423
 
424
+ return cls._hydrate(entity_id, data)
425
+
426
+ @classmethod
427
+ def _hydrate(cls: Type[T], entity_id: str, data: dict) -> T:
428
+ """Build an instance from its persisted dict, migrating if needed.
429
+
430
+ Shared by ``load`` (single ``get``) and ``load_some`` (range page).
431
+ Callers must have checked the identity registry first.
432
+ """
433
+ type_name = cls.get_full_type_name()
415
434
  stored_version = data.get("__version__", 1)
416
435
  current_version = cls.__version__
417
436
 
@@ -454,6 +473,140 @@ class Entity:
454
473
  if all(getattr(e, k, None) == v for k, v in d.items())
455
474
  ]
456
475
 
476
+ @classmethod
477
+ def _indexed_properties(cls) -> Dict[str, Any]:
478
+ """Return {name: Property} for all properties declared with indexed=True."""
479
+ from .properties import Property
480
+
481
+ result: Dict[str, Any] = {}
482
+ for klass in reversed(cls.__mro__):
483
+ for attr_name, attr_value in klass.__dict__.items():
484
+ if attr_name.startswith("_"):
485
+ continue
486
+ if isinstance(attr_value, Property) and attr_value.indexed:
487
+ result[attr_name] = attr_value
488
+ return result
489
+
490
+ @classmethod
491
+ def _require_indexed_field(cls, field: str) -> None:
492
+ if field not in cls._indexed_properties():
493
+ raise ValueError(
494
+ f"{cls.__name__}.{field} is not an indexed property "
495
+ f"(declare it with indexed=True)"
496
+ )
497
+
498
+ @classmethod
499
+ def find_by(
500
+ cls: Type[T],
501
+ field: str,
502
+ value: Any,
503
+ from_id: int = 1,
504
+ count: int = 50,
505
+ ) -> Tuple[List[T], Optional[int]]:
506
+ """Equality lookup on an indexed field with cursor pagination (issue #11).
507
+
508
+ IDs come from the persistent field index, so cost is proportional to
509
+ the number of matches, not to max_id. Results are ordered by ascending
510
+ entity ID, compatible with load_some-style cursors.
511
+
512
+ Args:
513
+ field: Name of a property declared with indexed=True
514
+ value: Value to match (compared via its string form, like the index)
515
+ from_id: Only return entities with _id >= from_id
516
+ count: Maximum number of entities to return
517
+
518
+ Returns:
519
+ (entities, next_from_id) — next_from_id is None when no more
520
+ matches remain, otherwise pass it back as from_id for the next page.
521
+
522
+ Raises:
523
+ ValueError: If the field is not declared with indexed=True
524
+ """
525
+ cls._require_indexed_field(field)
526
+ if from_id < 1:
527
+ raise ValueError("from_id must be at least 1")
528
+ if count < 1:
529
+ raise ValueError("count must be at least 1")
530
+
531
+ ids = cls.db().field_index_get(cls.get_full_type_name(), field, str(value))
532
+ matching = sorted((int(i) for i in ids if i.isdigit() and int(i) >= from_id))
533
+
534
+ entities: List[T] = []
535
+ last_id = None
536
+ for entity_id in matching:
537
+ if len(entities) >= count:
538
+ break
539
+ entity = cls.load(str(entity_id))
540
+ if entity is not None:
541
+ entities.append(entity)
542
+ last_id = entity_id
543
+
544
+ has_more = last_id is not None and any(i > last_id for i in matching)
545
+ next_from_id = (last_id + 1) if has_more else None
546
+ return entities, next_from_id
547
+
548
+ @classmethod
549
+ def count_by(cls: Type[T], field: str, value: Any) -> int:
550
+ """Number of entities whose indexed ``field`` equals ``value``.
551
+
552
+ Raises:
553
+ ValueError: If the field is not declared with indexed=True
554
+ """
555
+ cls._require_indexed_field(field)
556
+ return len(
557
+ cls.db().field_index_get(cls.get_full_type_name(), field, str(value))
558
+ )
559
+
560
+ @classmethod
561
+ def rebuild_field_index(
562
+ cls: Type[T],
563
+ field: str,
564
+ from_id: int = 1,
565
+ batch: int = 200,
566
+ ) -> Optional[int]:
567
+ """Index one batch of pre-existing entities; resumable (issue #11).
568
+
569
+ Entities created before a field was declared indexed=True are missing
570
+ from its index. This walks the ID range in bounded batches so callers
571
+ on the IC can spread the work across multiple update calls or timer
572
+ ticks without hitting per-message instruction limits:
573
+
574
+ cursor = 1
575
+ while cursor is not None:
576
+ cursor = Proposal.rebuild_field_index("org_scope", from_id=cursor)
577
+
578
+ Idempotent — re-indexing an already-indexed entity is a no-op.
579
+
580
+ Returns:
581
+ The from_id for the next batch, or None when the rebuild is done.
582
+
583
+ Raises:
584
+ ValueError: If the field is not declared with indexed=True
585
+ """
586
+ cls._require_indexed_field(field)
587
+ if from_id < 1:
588
+ raise ValueError("from_id must be at least 1")
589
+ if batch < 1:
590
+ raise ValueError("batch must be at least 1")
591
+
592
+ max_id = cls.max_id()
593
+ if max_id == 0 or from_id > max_id:
594
+ return None
595
+
596
+ db = cls.db()
597
+ type_name = cls.get_full_type_name()
598
+ # One range read per batch of *live* entities (deleted ids cost nothing)
599
+ entities = cls.load_some(from_id, batch)
600
+ for entity in entities:
601
+ value = getattr(entity, field, None)
602
+ if value is not None:
603
+ db.field_index_add(type_name, field, str(value), entity._id)
604
+
605
+ if len(entities) < batch:
606
+ return None
607
+ next_from_id = int(entities[-1]._id) + 1
608
+ return next_from_id if next_from_id <= max_id else None
609
+
457
610
  @classmethod
458
611
  def instances(cls: Type[T]) -> List[T]:
459
612
  """Get all instances of this entity type, including subclass instances.
@@ -534,11 +687,7 @@ class Entity:
534
687
  Returns:
535
688
  int: Maximum entity ID
536
689
  """
537
- type_name = cls.get_full_type_name()
538
- db = cls.db()
539
- max_id_key = f"{type_name}_id"
540
- max_id = db.load("_system", max_id_key)
541
- return int(max_id) if max_id else 0
690
+ return cls.db().max_id(cls.get_full_type_name())
542
691
 
543
692
  @classmethod
544
693
  def load_some(
@@ -556,29 +705,42 @@ class Entity:
556
705
  List[T]: List of entities for the requested page
557
706
 
558
707
  Raises:
559
- ValueError: If page or page_size is less than 1
708
+ ValueError: If from_id or count is less than 1
709
+
710
+ Cost: with a range-capable storage backend (Basilisk's StableBTreeMap,
711
+ MemoryStorage) this is one ordered range read per page regardless of
712
+ how many ids in between were deleted; otherwise one read per id.
560
713
  """
561
- logger.info(f"Loading entities from {from_id} to {from_id + count}")
714
+ logger.debug(f"Loading up to {count} entities from id {from_id}")
562
715
 
563
716
  if from_id < 1:
564
717
  raise ValueError("from_id must be at least 1")
565
718
  if count < 1:
566
719
  raise ValueError("count must be at least 1")
567
720
 
568
- # Return the slice of entities for the requested page
569
- ret = []
721
+ db = cls.db()
722
+ type_name = cls.get_full_type_name()
570
723
 
571
- while len(ret) < count and from_id <= cls.max_id():
572
- logger.info(f"Loading entity {from_id}")
573
- try:
574
- entity = cls.load(str(from_id))
575
- if entity:
724
+ ret: List[T] = []
725
+ max_id = db.max_id(type_name)
726
+ while len(ret) < count:
727
+ need = count - len(ret)
728
+ page = db.load_page(type_name, from_id, need, max_id=max_id)
729
+ for entity_id, data in page:
730
+ try:
731
+ # Identity map first, so callers keep getting the same instance
732
+ entity = db.get_entity(type_name, entity_id)
733
+ if entity is None:
734
+ entity = cls._hydrate(entity_id, data)
576
735
  ret.append(entity)
577
- except (ValueError, AttributeError) as e:
578
- # Skip entities with broken/dangling relation references
579
- # (full fix: issue #4 — lazy relation resolution)
580
- logger.warning(f"Skipping {cls.__name__}@{from_id}: {e}")
581
- from_id += 1
736
+ except (ValueError, AttributeError) as e:
737
+ # Skip entities with broken/dangling relation references
738
+ # (full fix: issue #4 — lazy relation resolution)
739
+ logger.warning(f"Skipping {cls.__name__}@{entity_id}: {e}")
740
+ if len(page) < need:
741
+ break # no more persisted entities of this type
742
+ # Page was full but something was skipped: continue after it
743
+ from_id = int(page[-1][0]) + 1
582
744
 
583
745
  return ret
584
746
 
@@ -674,10 +836,7 @@ class Entity:
674
836
  for attr_name, attr_value in cls.__dict__.items():
675
837
  if attr_name.startswith("_"):
676
838
  continue
677
- if (
678
- isinstance(attr_value, ManyToMany)
679
- and attr_value.unidirectional
680
- ):
839
+ if isinstance(attr_value, ManyToMany) and attr_value.unidirectional:
681
840
  my_short = (
682
841
  self._type.split("::")[-1]
683
842
  if "::" in self._type
@@ -692,6 +851,12 @@ class Entity:
692
851
  self._type, self._id, attr_value.reverse_name
693
852
  )
694
853
 
854
+ # Remove from field indexes (issue #11)
855
+ for field_name in self._indexed_properties():
856
+ value = getattr(self, field_name, None)
857
+ if value is not None:
858
+ db.field_index_remove(self._type, field_name, str(value), self._id)
859
+
695
860
  db.delete(self._type, self._id)
696
861
 
697
862
  # 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 ───────────────────────────────────────────────────────
ic_python_db/storage.py CHANGED
@@ -3,11 +3,39 @@ Storage backends for IC Python DB
3
3
  """
4
4
 
5
5
  from abc import ABC, abstractmethod
6
- from typing import Dict, Iterator, Optional, Tuple
6
+ from typing import Dict, Iterator, List, Optional, Tuple
7
+
8
+
9
+ def storage_sort_key(key: str) -> Tuple[int, bytes]:
10
+ """Sort key reproducing the on-chain order of ``str`` keys.
11
+
12
+ Basilisk's ``StableBTreeMap`` stores a ``str`` key as
13
+ ``tag + 4-byte big-endian length + utf-8``, so the B-tree orders keys by
14
+ byte length first and bytewise second: ``"T@9" < "T@10" < "T@2x"``.
15
+ ``MemoryStorage.range`` uses this so local tests see exactly the order a
16
+ canister will.
17
+ """
18
+ raw = key.encode("utf-8")
19
+ return (len(raw), raw)
20
+
21
+
22
+ def supports_range(storage) -> bool:
23
+ """True if ``storage`` offers ``range(start, end, limit)``.
24
+
25
+ Basilisk's ``StableBTreeMap`` (>= the version that added ``range``) and
26
+ ``MemoryStorage`` do; older CDK builds and simple dict-backed test
27
+ doubles don't, and callers fall back to key probing.
28
+ """
29
+ return callable(getattr(storage, "range", None))
7
30
 
8
31
 
9
32
  class Storage(ABC):
10
- """Abstract base class for storage backends"""
33
+ """Abstract base class for storage backends
34
+
35
+ ``range`` is optional: backends that can walk keys in order should
36
+ implement it (see ``storage_sort_key`` for the order that is expected);
37
+ callers must check ``supports_range`` and fall back to ``get`` probing.
38
+ """
11
39
 
12
40
  @abstractmethod
13
41
  def insert(self, key: str, value: str) -> None:
@@ -67,3 +95,24 @@ class MemoryStorage(Storage):
67
95
  def keys(self) -> Iterator[str]:
68
96
  """Return all keys in storage"""
69
97
  return iter(self._data.keys())
98
+
99
+ def range(
100
+ self, start: str, end: Optional[str] = None, limit: int = 1000
101
+ ) -> List[Tuple[str, str]]:
102
+ """Ordered page of ``(key, value)`` with ``start <= key < end``.
103
+
104
+ Same contract as Basilisk's ``StableBTreeMap.range``: half-open,
105
+ ``end=None`` means unbounded, keys ordered by ``storage_sort_key``.
106
+ O(n log n) here, which is fine for an in-memory test backend.
107
+ """
108
+ if limit < 0:
109
+ raise ValueError("limit must be >= 0")
110
+ lo = storage_sort_key(start)
111
+ hi = None if end is None else storage_sort_key(end)
112
+ selected = [
113
+ k
114
+ for k in self._data
115
+ if lo <= storage_sort_key(k) and (hi is None or storage_sort_key(k) < hi)
116
+ ]
117
+ selected.sort(key=storage_sort_key)
118
+ return [(k, self._data[k]) for k in selected[:limit]]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ic-python-db
3
- Version: 0.10.0
3
+ Version: 0.12.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
@@ -60,6 +60,7 @@ A lightweight key-value database with entity relationships and audit logging cap
60
60
  ## Features
61
61
 
62
62
  - **Persistent Storage**: Works with StableBTreeMap stable structure for persistent storage on your canister's stable memory so your data persists automatically across canister upgrades.
63
+ - **Range-based paging**: `load_some`, `find`, `instances` and `rebuild_field_index` read a page of entities with a single ordered `range` call on the stable map (Basilisk `StableBTreeMap.range`), so cost tracks the rows returned rather than the highest id ever issued, and deleted ids are free. Backends without `range` (older CDK builds, plain dict test doubles) transparently fall back to one read per id.
63
64
  - **Entity-Relational Database**: Create, read and write entities with OneToOne, OneToMany, ManyToOne, and ManyToMany relationships.
64
65
  - **Schema Versioning & Upgrade Safety**: Automatic schema introspection, compatibility checking, and auto-migration for safe changes. Breaking changes without a `migrate()` method are rejected, preventing data corruption on canister upgrades.
65
66
  - **Entity Hooks**: Intercept and control entity lifecycle events (create, modify, delete) with `on_event` hooks.
@@ -194,6 +195,42 @@ Then use the defined entities to store objects:
194
195
 
195
196
  For more usage examples, see the [tests](tests/src/tests).
196
197
 
198
+ ## Indexed Fields
199
+
200
+ Declare `indexed=True` on a scalar property to maintain a persistent secondary index, enabling equality lookups without scanning all entities:
201
+
202
+ ```python
203
+ class Proposal(Entity):
204
+ title = String()
205
+ status = String(max_length=32, indexed=True)
206
+ org_scope = String(max_length=128, indexed=True)
207
+
208
+ Proposal(title="A", status="voting", org_scope="Justice")
209
+ Proposal(title="B", status="executed", org_scope="Justice")
210
+
211
+ # Paginated equality lookup — cost proportional to matches, not max_id
212
+ entities, next_from_id = Proposal.find_by("status", "voting", from_id=1, count=50)
213
+
214
+ # Count without loading entities
215
+ Proposal.count_by("org_scope", "Justice") # 2
216
+ ```
217
+
218
+ 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`.
219
+
220
+ ### Backfilling existing data
221
+
222
+ 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):
223
+
224
+ ```python
225
+ cursor = 1
226
+ while cursor is not None:
227
+ cursor = Proposal.rebuild_field_index("status", from_id=cursor, batch=200)
228
+ ```
229
+
230
+ The rebuild is idempotent. The `indexed` flag is not part of the schema descriptor, so toggling it never triggers upgrade-compatibility errors.
231
+
232
+ 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.
233
+
197
234
  ## Namespaces
198
235
 
199
236
  Organize entities with the `__namespace__` attribute to avoid type conflicts when you have the same class name in different modules:
@@ -0,0 +1,18 @@
1
+ ic_python_db/__init__.py,sha256=8F0p76Ez1Hhcb9iHKzJjrCWp4aZGgvAGdP9-b9Ka-CU,990
2
+ ic_python_db/_cdk.py,sha256=zTj8HzXUcPz8R73nh5bo6JMkCorp-KSW1hDutzi5LZs,387
3
+ ic_python_db/constants.py,sha256=oLvKJev-65gXFd284DdYutWBX95sTF5Xkikc6IC4mhM,118
4
+ ic_python_db/context.py,sha256=97F4_EAJm3adOnUfh0SaDisX3nc7F2vFIZwZUsUAy58,1080
5
+ ic_python_db/db_engine.py,sha256=wK6mJshJMjrmVa7LPjA5ZVz1LXSxTtRjht6MU2mDM6k,26133
6
+ ic_python_db/entity.py,sha256=zU_7zoYUs125DToM4Htywc9fhz5NxZhbdvYGMei5RJs,50437
7
+ ic_python_db/hooks.py,sha256=B1wV18dAMH-PootSripmMpG9Cr9M3W3lcNEF7QvPrL4,1620
8
+ ic_python_db/mixins.py,sha256=qZmb3ssXYf2NSjHJtgmdsOlkIJdouDjPHdQ9tZmrZi8,1937
9
+ ic_python_db/properties.py,sha256=l7q3Iv8R-57XMlYwz2gcJV9mObYV_LfR6mw2jZgGwns,27556
10
+ ic_python_db/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ ic_python_db/schema.py,sha256=h1RAbapExo8pMD5IQvK8ZTVkgyonbPV9qSqliSP0Y14,16249
12
+ ic_python_db/storage.py,sha256=899qyuA3alpAFIPM23A_07vqquUB5ZG5GjjpzlHW1Os,3828
13
+ ic_python_db/system_time.py,sha256=xrx8NfjLXCGsac3hUpuvsNwCH91tQ9jfH4N-XFo8iJk,2614
14
+ ic_python_db-0.12.0.dist-info/licenses/LICENSE,sha256=6q6XYNOGnJcVSus2bAezFn7bU_2Y5T6W4aGQHBb8X-c,1079
15
+ ic_python_db-0.12.0.dist-info/METADATA,sha256=9AjuP2o844gKy9w2Ee-vSZNrqBrEglF6GvsE3v-JHcI,17115
16
+ ic_python_db-0.12.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
17
+ ic_python_db-0.12.0.dist-info/top_level.txt,sha256=ZaOTqhWKtJQEuXmqR920AqbDBAqv1mvsKj3TClWimDk,13
18
+ ic_python_db-0.12.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,18 +0,0 @@
1
- ic_python_db/__init__.py,sha256=LH2RcKA378wYJc5qK0lTwMZrNQMUi90Ho2hvcri5gLM,990
2
- ic_python_db/_cdk.py,sha256=zTj8HzXUcPz8R73nh5bo6JMkCorp-KSW1hDutzi5LZs,387
3
- ic_python_db/constants.py,sha256=oLvKJev-65gXFd284DdYutWBX95sTF5Xkikc6IC4mhM,118
4
- ic_python_db/context.py,sha256=97F4_EAJm3adOnUfh0SaDisX3nc7F2vFIZwZUsUAy58,1080
5
- ic_python_db/db_engine.py,sha256=sA_RkFvgHjaBtvp_lOQ3rr7yuzPJQawBeqwQtn-ZXzQ,18622
6
- ic_python_db/entity.py,sha256=5ZuPYnR92_vDUfG_jnUPLTz06_bjqtwZQtt9q0Vm5Nc,43648
7
- ic_python_db/hooks.py,sha256=B1wV18dAMH-PootSripmMpG9Cr9M3W3lcNEF7QvPrL4,1620
8
- ic_python_db/mixins.py,sha256=qZmb3ssXYf2NSjHJtgmdsOlkIJdouDjPHdQ9tZmrZi8,1937
9
- ic_python_db/properties.py,sha256=BmW4dWBUn4sGnenlvusp1snER7zvstSDu9drKHM3rns,26413
10
- ic_python_db/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- ic_python_db/schema.py,sha256=h1RAbapExo8pMD5IQvK8ZTVkgyonbPV9qSqliSP0Y14,16249
12
- ic_python_db/storage.py,sha256=2F6m_01g2jwoF40s4BrdO2SffJNSO3gPf0QU3Co7T5Y,1873
13
- ic_python_db/system_time.py,sha256=xrx8NfjLXCGsac3hUpuvsNwCH91tQ9jfH4N-XFo8iJk,2614
14
- ic_python_db-0.10.0.dist-info/licenses/LICENSE,sha256=6q6XYNOGnJcVSus2bAezFn7bU_2Y5T6W4aGQHBb8X-c,1079
15
- ic_python_db-0.10.0.dist-info/METADATA,sha256=dlFpBN0E5WDXrdK9y4Xz1d-5pIZtcK3x464c-Y5HXpo,15131
16
- ic_python_db-0.10.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
17
- ic_python_db-0.10.0.dist-info/top_level.txt,sha256=ZaOTqhWKtJQEuXmqR920AqbDBAqv1mvsKj3TClWimDk,13
18
- ic_python_db-0.10.0.dist-info/RECORD,,