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.
- {ic_python_db-0.9.1/ic_python_db.egg-info → ic_python_db-0.11.0}/PKG-INFO +37 -1
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/README.md +36 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/__init__.py +1 -1
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/db_engine.py +112 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/entity.py +206 -7
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/properties.py +102 -15
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/schema.py +2 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0/ic_python_db.egg-info}/PKG-INFO +37 -1
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/pyproject.toml +1 -1
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/setup.py +1 -1
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/LICENSE +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/MANIFEST.in +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/_cdk.py +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/constants.py +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/context.py +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/hooks.py +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/mixins.py +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/py.typed +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/storage.py +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db/system_time.py +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db.egg-info/SOURCES.txt +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/ic_python_db.egg-info/top_level.txt +0 -0
- {ic_python_db-0.9.1 → ic_python_db-0.11.0}/requirements-dev.txt +0 -0
- {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.
|
|
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,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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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__(
|
|
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 ───────────────────────────────────────────────────────
|
|
@@ -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
|
-
|
|
560
|
-
|
|
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
|
|
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__(
|
|
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
|
-
|
|
668
|
-
type_name, old_id, self.reverse_name,
|
|
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
|
-
|
|
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.
|
|
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
|