ic-python-db 0.12.0__tar.gz → 0.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {ic_python_db-0.12.0/ic_python_db.egg-info → ic_python_db-0.14.0}/PKG-INFO +55 -7
  2. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/README.md +54 -6
  3. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/__init__.py +5 -1
  4. ic_python_db-0.14.0/ic_python_db/constants.py +11 -0
  5. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/db_engine.py +417 -94
  6. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/entity.py +352 -38
  7. ic_python_db-0.14.0/ic_python_db/errors.py +10 -0
  8. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/properties.py +178 -61
  9. {ic_python_db-0.12.0 → ic_python_db-0.14.0/ic_python_db.egg-info}/PKG-INFO +55 -7
  10. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db.egg-info/SOURCES.txt +1 -0
  11. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/pyproject.toml +1 -1
  12. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/setup.py +1 -1
  13. ic_python_db-0.12.0/ic_python_db/constants.py +0 -6
  14. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/LICENSE +0 -0
  15. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/MANIFEST.in +0 -0
  16. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/_cdk.py +0 -0
  17. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/context.py +0 -0
  18. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/hooks.py +0 -0
  19. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/mixins.py +0 -0
  20. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/py.typed +0 -0
  21. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/schema.py +0 -0
  22. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/storage.py +0 -0
  23. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db/system_time.py +0 -0
  24. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
  25. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/ic_python_db.egg-info/top_level.txt +0 -0
  26. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/requirements-dev.txt +0 -0
  27. {ic_python_db-0.12.0 → ic_python_db-0.14.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ic-python-db
3
- Version: 0.12.0
3
+ Version: 0.14.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,7 +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
+ - **Range-based paging**: `page`, `load_some` 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. `instances()` and `find()` load a whole type: they are deprecated and raise `TooLargeError` past 1,000 entities (use `count()`, `find_by()` or `page()`).
64
64
  - **Entity-Relational Database**: Create, read and write entities with OneToOne, OneToMany, ManyToOne, and ManyToMany relationships.
65
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.
66
66
  - **Entity Hooks**: Intercept and control entity lifecycle events (create, modify, delete) with `on_event` hooks.
@@ -163,11 +163,12 @@ Then use the defined entities to store objects:
163
163
 
164
164
  assert Person.count() == 3
165
165
  assert Person.max_id() == 4
166
- assert Person.instances() == [john, alice, eva]
167
166
 
168
- # Cursor-based pagination
169
- assert Person.load_some(0, 2) == [john, alice]
170
- assert Person.load_some(2, 2) == [eva]
167
+ # Cursor-based pagination: (page, next_from_id), None on the last page
168
+ page, cursor = Person.page(1, 2)
169
+ assert page == [john, alice]
170
+ page, cursor = Person.page(cursor, 2)
171
+ assert page == [eva] and cursor is None
171
172
 
172
173
  # Retrieve database contents in JSON format
173
174
  print(Database.get_instance().dump_json(pretty=True))
@@ -229,7 +230,54 @@ while cursor is not None:
229
230
 
230
231
  The rebuild is idempotent. The `indexed` flag is not part of the schema descriptor, so toggling it never triggers upgrade-compatibility errors.
231
232
 
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
+ Each matching id is its own storage entry, so a value can match any number of entities without reaching the backing store's `max_value_size`.
234
+
235
+ ## Sorted Fields
236
+
237
+ Declare `sorted=True` on an `Integer` or `Float` property to read entities in its order, highest or lowest first, without loading and sorting them all. `__sorted_indexes__` adds indexes over several fields, where each field breaks ties in the one before:
238
+
239
+ ```python
240
+ class Listing(Entity):
241
+ name = String()
242
+ installs = Integer(sorted=True)
243
+ likes = Integer(sorted=True)
244
+ updated_at = Float(sorted=True)
245
+ __sorted_indexes__ = [("installs", "likes")] # installs, then likes
246
+
247
+ top, cursor = Listing.top_by("installs", count=20) # most installed
248
+ top, cursor = Listing.top_by(("installs", "likes"), count=20) # ties broken by likes
249
+ recent, cursor = Listing.top_by("updated_at", count=20)
250
+ oldest, cursor = Listing.top_by("updated_at", count=20, descending=False)
251
+ more, cursor = Listing.top_by("installs", count=20, cursor=cursor) # next page; None at the end
252
+ ```
253
+
254
+ The storage map keeps keys in order, so each entity has one index entry per sorted index whose key starts with its values. Writing a sorted field moves that entry (a few storage operations), and a page is one range read plus `count` loads, however many entities exist. Ties go to the higher id; entities with None in a field are left out of indexes on it. Integers must fit in 64 bits.
255
+
256
+ `where=` drops entities that fail a test. At most `scan_limit` entries (default 200) are read per call, so with a selective filter a page can come back short with a cursor: call again.
257
+
258
+ Fields made sorted after rows exist, or whose type changed, need a resumable rebuild (`Listing.rebuild_sorted_index("installs", from_id=cursor)`, until it returns None). A `migrate()` that changes a sorted or indexed value moves the entity's entries when it is loaded.
259
+
260
+ ## Large Relations
261
+
262
+ `OneToMany` and `ManyToMany` getters return a `RelationView`, not a list. Questions about the relation and single pages never load every member, so they stay cheap however large it grows:
263
+
264
+ ```python
265
+ if section.stands: ... # one read
266
+ len(section.stands) # one read
267
+ stand in section.stands # two reads
268
+ stands, cursor = section.stands.page(1, 50) # one range read + 50 loads
269
+ group.members.add(user) # index writes only
270
+ ```
271
+
272
+ Iterating, indexing and comparing still work, but they load every member, so past `constants.MAX_UNPAGED` (1,000) they raise `TooLargeError` instead of running into the IC instruction limit. Walk a large relation with `page()`, across calls if needed:
273
+
274
+ ```python
275
+ cursor = 1
276
+ while cursor is not None:
277
+ stands, cursor = section.stands.page(cursor, 50)
278
+ ```
279
+
280
+ `list(view)` gives a plain list. Deleting a parent is O(1): its index entries are purged in bounded steps by later writes, or by `Database.get_instance().purge_step()` from a timer.
233
281
 
234
282
  ## Namespaces
235
283
 
@@ -11,7 +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
+ - **Range-based paging**: `page`, `load_some` 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. `instances()` and `find()` load a whole type: they are deprecated and raise `TooLargeError` past 1,000 entities (use `count()`, `find_by()` or `page()`).
15
15
  - **Entity-Relational Database**: Create, read and write entities with OneToOne, OneToMany, ManyToOne, and ManyToMany relationships.
16
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.
17
17
  - **Entity Hooks**: Intercept and control entity lifecycle events (create, modify, delete) with `on_event` hooks.
@@ -114,11 +114,12 @@ Then use the defined entities to store objects:
114
114
 
115
115
  assert Person.count() == 3
116
116
  assert Person.max_id() == 4
117
- assert Person.instances() == [john, alice, eva]
118
117
 
119
- # Cursor-based pagination
120
- assert Person.load_some(0, 2) == [john, alice]
121
- assert Person.load_some(2, 2) == [eva]
118
+ # Cursor-based pagination: (page, next_from_id), None on the last page
119
+ page, cursor = Person.page(1, 2)
120
+ assert page == [john, alice]
121
+ page, cursor = Person.page(cursor, 2)
122
+ assert page == [eva] and cursor is None
122
123
 
123
124
  # Retrieve database contents in JSON format
124
125
  print(Database.get_instance().dump_json(pretty=True))
@@ -180,7 +181,54 @@ while cursor is not None:
180
181
 
181
182
  The rebuild is idempotent. The `indexed` flag is not part of the schema descriptor, so toggling it never triggers upgrade-compatibility errors.
182
183
 
183
- 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.
184
+ Each matching id is its own storage entry, so a value can match any number of entities without reaching the backing store's `max_value_size`.
185
+
186
+ ## Sorted Fields
187
+
188
+ Declare `sorted=True` on an `Integer` or `Float` property to read entities in its order, highest or lowest first, without loading and sorting them all. `__sorted_indexes__` adds indexes over several fields, where each field breaks ties in the one before:
189
+
190
+ ```python
191
+ class Listing(Entity):
192
+ name = String()
193
+ installs = Integer(sorted=True)
194
+ likes = Integer(sorted=True)
195
+ updated_at = Float(sorted=True)
196
+ __sorted_indexes__ = [("installs", "likes")] # installs, then likes
197
+
198
+ top, cursor = Listing.top_by("installs", count=20) # most installed
199
+ top, cursor = Listing.top_by(("installs", "likes"), count=20) # ties broken by likes
200
+ recent, cursor = Listing.top_by("updated_at", count=20)
201
+ oldest, cursor = Listing.top_by("updated_at", count=20, descending=False)
202
+ more, cursor = Listing.top_by("installs", count=20, cursor=cursor) # next page; None at the end
203
+ ```
204
+
205
+ The storage map keeps keys in order, so each entity has one index entry per sorted index whose key starts with its values. Writing a sorted field moves that entry (a few storage operations), and a page is one range read plus `count` loads, however many entities exist. Ties go to the higher id; entities with None in a field are left out of indexes on it. Integers must fit in 64 bits.
206
+
207
+ `where=` drops entities that fail a test. At most `scan_limit` entries (default 200) are read per call, so with a selective filter a page can come back short with a cursor: call again.
208
+
209
+ Fields made sorted after rows exist, or whose type changed, need a resumable rebuild (`Listing.rebuild_sorted_index("installs", from_id=cursor)`, until it returns None). A `migrate()` that changes a sorted or indexed value moves the entity's entries when it is loaded.
210
+
211
+ ## Large Relations
212
+
213
+ `OneToMany` and `ManyToMany` getters return a `RelationView`, not a list. Questions about the relation and single pages never load every member, so they stay cheap however large it grows:
214
+
215
+ ```python
216
+ if section.stands: ... # one read
217
+ len(section.stands) # one read
218
+ stand in section.stands # two reads
219
+ stands, cursor = section.stands.page(1, 50) # one range read + 50 loads
220
+ group.members.add(user) # index writes only
221
+ ```
222
+
223
+ Iterating, indexing and comparing still work, but they load every member, so past `constants.MAX_UNPAGED` (1,000) they raise `TooLargeError` instead of running into the IC instruction limit. Walk a large relation with `page()`, across calls if needed:
224
+
225
+ ```python
226
+ cursor = 1
227
+ while cursor is not None:
228
+ stands, cursor = section.stands.page(cursor, 50)
229
+ ```
230
+
231
+ `list(view)` gives a plain list. Deleting a parent is O(1): its index entries are purged in bounded steps by later writes, or by `Database.get_instance().purge_step()` from a timer.
184
232
 
185
233
  ## Namespaces
186
234
 
@@ -5,6 +5,7 @@ IC Python DB - A lightweight key-value database with entity relationships and au
5
5
  from .constants import ACTION_CREATE, ACTION_DELETE, ACTION_MODIFY
6
6
  from .db_engine import Database
7
7
  from .entity import Entity
8
+ from .errors import TooLargeError
8
9
  from .mixins import TimestampedMixin
9
10
  from .properties import (
10
11
  Boolean,
@@ -14,13 +15,14 @@ from .properties import (
14
15
  ManyToOne,
15
16
  OneToMany,
16
17
  OneToOne,
18
+ RelationView,
17
19
  String,
18
20
  )
19
21
  from .schema import SchemaIncompatibleError, build_schema, diff_schemas, schema_hash
20
22
  from .storage import MemoryStorage, Storage
21
23
  from .system_time import SystemTime
22
24
 
23
- __version__ = "0.12.0"
25
+ __version__ = "0.14.0"
24
26
  __all__ = [
25
27
  "Database",
26
28
  "Entity",
@@ -40,6 +42,8 @@ __all__ = [
40
42
  "ACTION_MODIFY",
41
43
  "ACTION_DELETE",
42
44
  "SchemaIncompatibleError",
45
+ "TooLargeError",
46
+ "RelationView",
43
47
  "build_schema",
44
48
  "diff_schemas",
45
49
  "schema_hash",
@@ -0,0 +1,11 @@
1
+ LEVEL_MAX_DEFAULT = 3
2
+
3
+ # Hook action types
4
+ ACTION_CREATE = "create"
5
+ ACTION_MODIFY = "modify"
6
+ ACTION_DELETE = "delete"
7
+
8
+ # Largest collection an unpaged operation (iterating a relation, instances())
9
+ # loads in one call; past it they raise TooLargeError. At about 1M
10
+ # instructions per load this stays well inside a query's 5B budget.
11
+ MAX_UNPAGED = 1000