ic-python-db 0.11.0__tar.gz → 0.12.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.11.0/ic_python_db.egg-info → ic_python_db-0.12.0}/PKG-INFO +2 -1
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/README.md +1 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/__init__.py +1 -1
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/db_engine.py +128 -1
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/entity.py +50 -30
- ic_python_db-0.12.0/ic_python_db/storage.py +118 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0/ic_python_db.egg-info}/PKG-INFO +2 -1
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/pyproject.toml +1 -1
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/setup.py +1 -1
- ic_python_db-0.11.0/ic_python_db/storage.py +0 -69
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/LICENSE +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/MANIFEST.in +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/_cdk.py +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/constants.py +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/context.py +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/hooks.py +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/mixins.py +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/properties.py +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/py.typed +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/schema.py +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db/system_time.py +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db.egg-info/SOURCES.txt +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/ic_python_db.egg-info/top_level.txt +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.0}/requirements-dev.txt +0 -0
- {ic_python_db-0.11.0 → ic_python_db-0.12.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.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.
|
|
@@ -11,6 +11,7 @@ A lightweight key-value database with entity relationships and audit logging cap
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
13
|
- **Persistent Storage**: Works with StableBTreeMap stable structure for persistent storage on your canister's stable memory so your data persists automatically across canister upgrades.
|
|
14
|
+
- **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.
|
|
14
15
|
- **Entity-Relational Database**: Create, read and write entities with OneToOne, OneToMany, ManyToOne, and ManyToMany relationships.
|
|
15
16
|
- **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.
|
|
16
17
|
- **Entity Hooks**: Intercept and control entity lifecycle events (create, modify, delete) with `on_event` hooks.
|
|
@@ -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.12.0"
|
|
24
24
|
__all__ = [
|
|
25
25
|
"Database",
|
|
26
26
|
"Entity",
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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")
|
|
@@ -418,6 +421,16 @@ class Entity:
|
|
|
418
421
|
if not data:
|
|
419
422
|
return None
|
|
420
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()
|
|
421
434
|
stored_version = data.get("__version__", 1)
|
|
422
435
|
current_version = cls.__version__
|
|
423
436
|
|
|
@@ -582,19 +595,17 @@ class Entity:
|
|
|
582
595
|
|
|
583
596
|
db = cls.db()
|
|
584
597
|
type_name = cls.get_full_type_name()
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
entity = cls.load(str(entity_id))
|
|
589
|
-
except (ValueError, AttributeError):
|
|
590
|
-
continue
|
|
591
|
-
if entity is None:
|
|
592
|
-
continue
|
|
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:
|
|
593
601
|
value = getattr(entity, field, None)
|
|
594
602
|
if value is not None:
|
|
595
603
|
db.field_index_add(type_name, field, str(value), entity._id)
|
|
596
604
|
|
|
597
|
-
|
|
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
|
|
598
609
|
|
|
599
610
|
@classmethod
|
|
600
611
|
def instances(cls: Type[T]) -> List[T]:
|
|
@@ -676,11 +687,7 @@ class Entity:
|
|
|
676
687
|
Returns:
|
|
677
688
|
int: Maximum entity ID
|
|
678
689
|
"""
|
|
679
|
-
|
|
680
|
-
db = cls.db()
|
|
681
|
-
max_id_key = f"{type_name}_id"
|
|
682
|
-
max_id = db.load("_system", max_id_key)
|
|
683
|
-
return int(max_id) if max_id else 0
|
|
690
|
+
return cls.db().max_id(cls.get_full_type_name())
|
|
684
691
|
|
|
685
692
|
@classmethod
|
|
686
693
|
def load_some(
|
|
@@ -698,29 +705,42 @@ class Entity:
|
|
|
698
705
|
List[T]: List of entities for the requested page
|
|
699
706
|
|
|
700
707
|
Raises:
|
|
701
|
-
ValueError: If
|
|
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.
|
|
702
713
|
"""
|
|
703
|
-
logger.
|
|
714
|
+
logger.debug(f"Loading up to {count} entities from id {from_id}")
|
|
704
715
|
|
|
705
716
|
if from_id < 1:
|
|
706
717
|
raise ValueError("from_id must be at least 1")
|
|
707
718
|
if count < 1:
|
|
708
719
|
raise ValueError("count must be at least 1")
|
|
709
720
|
|
|
710
|
-
|
|
711
|
-
|
|
721
|
+
db = cls.db()
|
|
722
|
+
type_name = cls.get_full_type_name()
|
|
712
723
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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)
|
|
718
735
|
ret.append(entity)
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
|
724
744
|
|
|
725
745
|
return ret
|
|
726
746
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Storage backends for IC Python DB
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
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))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Storage(ABC):
|
|
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
|
+
"""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def insert(self, key: str, value: str) -> None:
|
|
42
|
+
"""Insert a key-value pair into storage"""
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def get(self, key: str) -> Optional[str]:
|
|
47
|
+
"""Retrieve a value by key"""
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def remove(self, key: str) -> None:
|
|
52
|
+
"""Remove a key-value pair from storage"""
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def items(self) -> Iterator[Tuple[str, str]]:
|
|
57
|
+
"""Return all items in storage"""
|
|
58
|
+
raise NotImplementedError
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def __contains__(self, key: str) -> bool:
|
|
62
|
+
"""Check if key exists in storage"""
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def keys(self) -> Iterator[str]:
|
|
67
|
+
"""Return all keys in storage"""
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MemoryStorage(Storage):
|
|
72
|
+
"""In-memory storage implementation using Python dictionary"""
|
|
73
|
+
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self._data: Dict[str, str] = {}
|
|
76
|
+
|
|
77
|
+
def insert(self, key: str, value: str) -> None:
|
|
78
|
+
self._data[key] = value
|
|
79
|
+
|
|
80
|
+
def get(self, key: str) -> Optional[str]:
|
|
81
|
+
return self._data.get(key)
|
|
82
|
+
|
|
83
|
+
def remove(self, key: str) -> None:
|
|
84
|
+
if key in self._data:
|
|
85
|
+
del self._data[key]
|
|
86
|
+
else:
|
|
87
|
+
raise KeyError(f"Key '{key}' not found in storage")
|
|
88
|
+
|
|
89
|
+
def items(self) -> Iterator[Tuple[str, str]]:
|
|
90
|
+
return iter(self._data.items())
|
|
91
|
+
|
|
92
|
+
def __contains__(self, key: str) -> bool:
|
|
93
|
+
return key in self._data
|
|
94
|
+
|
|
95
|
+
def keys(self) -> Iterator[str]:
|
|
96
|
+
"""Return all keys in storage"""
|
|
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.
|
|
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.
|
|
@@ -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.12.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.12.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",
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Storage backends for IC Python DB
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from abc import ABC, abstractmethod
|
|
6
|
-
from typing import Dict, Iterator, Optional, Tuple
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class Storage(ABC):
|
|
10
|
-
"""Abstract base class for storage backends"""
|
|
11
|
-
|
|
12
|
-
@abstractmethod
|
|
13
|
-
def insert(self, key: str, value: str) -> None:
|
|
14
|
-
"""Insert a key-value pair into storage"""
|
|
15
|
-
raise NotImplementedError
|
|
16
|
-
|
|
17
|
-
@abstractmethod
|
|
18
|
-
def get(self, key: str) -> Optional[str]:
|
|
19
|
-
"""Retrieve a value by key"""
|
|
20
|
-
raise NotImplementedError
|
|
21
|
-
|
|
22
|
-
@abstractmethod
|
|
23
|
-
def remove(self, key: str) -> None:
|
|
24
|
-
"""Remove a key-value pair from storage"""
|
|
25
|
-
raise NotImplementedError
|
|
26
|
-
|
|
27
|
-
@abstractmethod
|
|
28
|
-
def items(self) -> Iterator[Tuple[str, str]]:
|
|
29
|
-
"""Return all items in storage"""
|
|
30
|
-
raise NotImplementedError
|
|
31
|
-
|
|
32
|
-
@abstractmethod
|
|
33
|
-
def __contains__(self, key: str) -> bool:
|
|
34
|
-
"""Check if key exists in storage"""
|
|
35
|
-
raise NotImplementedError
|
|
36
|
-
|
|
37
|
-
@abstractmethod
|
|
38
|
-
def keys(self) -> Iterator[str]:
|
|
39
|
-
"""Return all keys in storage"""
|
|
40
|
-
raise NotImplementedError
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class MemoryStorage(Storage):
|
|
44
|
-
"""In-memory storage implementation using Python dictionary"""
|
|
45
|
-
|
|
46
|
-
def __init__(self):
|
|
47
|
-
self._data: Dict[str, str] = {}
|
|
48
|
-
|
|
49
|
-
def insert(self, key: str, value: str) -> None:
|
|
50
|
-
self._data[key] = value
|
|
51
|
-
|
|
52
|
-
def get(self, key: str) -> Optional[str]:
|
|
53
|
-
return self._data.get(key)
|
|
54
|
-
|
|
55
|
-
def remove(self, key: str) -> None:
|
|
56
|
-
if key in self._data:
|
|
57
|
-
del self._data[key]
|
|
58
|
-
else:
|
|
59
|
-
raise KeyError(f"Key '{key}' not found in storage")
|
|
60
|
-
|
|
61
|
-
def items(self) -> Iterator[Tuple[str, str]]:
|
|
62
|
-
return iter(self._data.items())
|
|
63
|
-
|
|
64
|
-
def __contains__(self, key: str) -> bool:
|
|
65
|
-
return key in self._data
|
|
66
|
-
|
|
67
|
-
def keys(self) -> Iterator[str]:
|
|
68
|
-
"""Return all keys in storage"""
|
|
69
|
-
return iter(self._data.keys())
|
|
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
|