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.
- {ic_python_db-0.10.0/ic_python_db.egg-info → ic_python_db-0.11.0}/PKG-INFO +37 -1
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/README.md +36 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/__init__.py +1 -1
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/db_engine.py +50 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/entity.py +150 -5
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/properties.py +35 -5
- {ic_python_db-0.10.0 → ic_python_db-0.11.0/ic_python_db.egg-info}/PKG-INFO +37 -1
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/pyproject.toml +1 -1
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/setup.py +1 -1
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/LICENSE +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/MANIFEST.in +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/_cdk.py +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/constants.py +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/context.py +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/hooks.py +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/mixins.py +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/py.typed +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/schema.py +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/storage.py +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db/system_time.py +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db.egg-info/SOURCES.txt +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/ic_python_db.egg-info/top_level.txt +0 -0
- {ic_python_db-0.10.0 → ic_python_db-0.11.0}/requirements-dev.txt +0 -0
- {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.
|
|
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.
|
|
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__(
|
|
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__(
|
|
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__(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|