ic-python-db 0.7.9__tar.gz → 0.9.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 (26) hide show
  1. {ic_python_db-0.7.9/ic_python_db.egg-info → ic_python_db-0.9.0}/PKG-INFO +90 -2
  2. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/README.md +89 -1
  3. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/__init__.py +6 -1
  4. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/db_engine.py +104 -0
  5. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/entity.py +162 -141
  6. ic_python_db-0.9.0/ic_python_db/properties.py +606 -0
  7. ic_python_db-0.9.0/ic_python_db/schema.py +475 -0
  8. {ic_python_db-0.7.9 → ic_python_db-0.9.0/ic_python_db.egg-info}/PKG-INFO +90 -2
  9. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db.egg-info/SOURCES.txt +1 -0
  10. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/pyproject.toml +1 -1
  11. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/setup.py +1 -1
  12. ic_python_db-0.7.9/ic_python_db/properties.py +0 -644
  13. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/LICENSE +0 -0
  14. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/MANIFEST.in +0 -0
  15. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/_cdk.py +0 -0
  16. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/constants.py +0 -0
  17. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/context.py +0 -0
  18. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/hooks.py +0 -0
  19. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/mixins.py +0 -0
  20. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/py.typed +0 -0
  21. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/storage.py +0 -0
  22. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db/system_time.py +0 -0
  23. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db.egg-info/dependency_links.txt +0 -0
  24. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/ic_python_db.egg-info/top_level.txt +0 -0
  25. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/requirements-dev.txt +0 -0
  26. {ic_python_db-0.7.9 → ic_python_db-0.9.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ic-python-db
3
- Version: 0.7.9
3
+ Version: 0.9.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
@@ -61,6 +61,7 @@ A lightweight key-value database with entity relationships and audit logging cap
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
63
  - **Entity-Relational Database**: Create, read and write entities with OneToOne, OneToMany, ManyToOne, and ManyToMany relationships.
64
+ - **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.
64
65
  - **Entity Hooks**: Intercept and control entity lifecycle events (create, modify, delete) with `on_event` hooks.
65
66
  - **Access Control**: Thread-safe context management for user identity tracking and ownership-based permissions.
66
67
  - **Namespaces**: Organize entities into namespaces to avoid type conflicts when you have multiple entities with the same class name.
@@ -256,7 +257,7 @@ See [docs/HOOKS.md](docs/HOOKS.md) for more patterns.
256
257
 
257
258
  ## Access Control
258
259
 
259
- Thread-safe user context management with `as_user()`:
260
+ User context management with `as_user()`:
260
261
 
261
262
  ```python
262
263
  from ic_python_db import Database, Entity, String, ACTION_MODIFY, ACTION_DELETE
@@ -316,6 +317,92 @@ class User(Entity):
316
317
  profile: Optional["Profile"] = OneToOne("Profile", "user")
317
318
  ```
318
319
 
320
+ ## Schema Versioning & Upgrade Safety
321
+
322
+ ic-python-db includes built-in upgrade compatibility checking. The system introspects your Entity class definitions to detect schema changes and ensure safe upgrades.
323
+
324
+ ### Auto-migration for safe changes
325
+
326
+ Adding a field with a default value requires no migration code — the system handles it automatically:
327
+
328
+ ```python
329
+ # v1
330
+ class Product(Entity):
331
+ __version__ = 1
332
+ name = String()
333
+
334
+ # v2 — just add the field with a default, no migrate() needed
335
+ class Product(Entity):
336
+ __version__ = 2
337
+ name = String()
338
+ price = Float(default=0.0) # auto-injected for existing entities
339
+ active = Boolean(default=True) # auto-injected for existing entities
340
+ ```
341
+
342
+ ### Custom migration for breaking changes
343
+
344
+ For type changes, field renames, or data transformations, override `migrate()`:
345
+
346
+ ```python
347
+ class Product(Entity):
348
+ __version__ = 2
349
+ name = String()
350
+ price_dollars = Float() # was price_cents: Integer in v1
351
+
352
+ @classmethod
353
+ def migrate(cls, obj, from_version, to_version):
354
+ if from_version == 1:
355
+ obj["price_dollars"] = obj.pop("price_cents") / 100.0
356
+ return obj
357
+ ```
358
+
359
+ ### Upgrade compatibility enforcement
360
+
361
+ Call `check_upgrade_compatibility()` from your canister's `post_upgrade` to reject incompatible upgrades before they corrupt data:
362
+
363
+ ```python
364
+ from basilisk import post_upgrade
365
+ from ic_python_db import Database
366
+
367
+ @post_upgrade
368
+ def on_post_upgrade():
369
+ db = Database.get_instance()
370
+ db.check_upgrade_compatibility()
371
+ # If a breaking change lacks migrate(), this raises SchemaIncompatibleError
372
+ # which traps post_upgrade, causing the IC to roll back the upgrade
373
+ ```
374
+
375
+ The system classifies schema changes as:
376
+
377
+ | Change | Safety | Action |
378
+ |--------|--------|--------|
379
+ | Add field with `default=` | Safe | Auto-migrated |
380
+ | Remove field | Safe | Old data ignored |
381
+ | Add new Entity type | Safe | No migration needed |
382
+ | Change field type | Breaking | Requires `migrate()` |
383
+ | Add field without default | Breaking | Requires `migrate()` |
384
+ | Change relationship type | Breaking | Requires `migrate()` |
385
+
386
+ ### Schema introspection
387
+
388
+ You can inspect and compare schemas programmatically:
389
+
390
+ ```python
391
+ from ic_python_db import Database, build_schema, diff_schemas
392
+
393
+ db = Database.get_instance()
394
+
395
+ # Build current schema from Entity definitions
396
+ schema = db.build_schema_from_entities()
397
+
398
+ # Compare two schemas
399
+ changes = diff_schemas(old_schema, new_schema)
400
+ for change in changes:
401
+ print(f"{change.entity_type}.{change.field}: {change.reason}")
402
+ ```
403
+
404
+ See [docs/SCHEMA_VERSIONING.md](docs/SCHEMA_VERSIONING.md) for the full reference.
405
+
319
406
  ## API Reference
320
407
 
321
408
  - **Core**: `Database`, `Entity`
@@ -324,6 +411,7 @@ class User(Entity):
324
411
  - **Mixins**: `TimestampedMixin` (timestamps and ownership tracking)
325
412
  - **Hooks**: `ACTION_CREATE`, `ACTION_MODIFY`, `ACTION_DELETE`
326
413
  - **Context**: `get_caller_id()`, `set_caller_id()`, `Database.as_user()`
414
+ - **Schema**: `build_schema`, `diff_schemas`, `schema_hash`, `SchemaIncompatibleError`
327
415
 
328
416
  ## Development
329
417
 
@@ -12,6 +12,7 @@ A lightweight key-value database with entity relationships and audit logging cap
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
14
  - **Entity-Relational Database**: Create, read and write entities with OneToOne, OneToMany, ManyToOne, and ManyToMany relationships.
15
+ - **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.
15
16
  - **Entity Hooks**: Intercept and control entity lifecycle events (create, modify, delete) with `on_event` hooks.
16
17
  - **Access Control**: Thread-safe context management for user identity tracking and ownership-based permissions.
17
18
  - **Namespaces**: Organize entities into namespaces to avoid type conflicts when you have multiple entities with the same class name.
@@ -207,7 +208,7 @@ See [docs/HOOKS.md](docs/HOOKS.md) for more patterns.
207
208
 
208
209
  ## Access Control
209
210
 
210
- Thread-safe user context management with `as_user()`:
211
+ User context management with `as_user()`:
211
212
 
212
213
  ```python
213
214
  from ic_python_db import Database, Entity, String, ACTION_MODIFY, ACTION_DELETE
@@ -267,6 +268,92 @@ class User(Entity):
267
268
  profile: Optional["Profile"] = OneToOne("Profile", "user")
268
269
  ```
269
270
 
271
+ ## Schema Versioning & Upgrade Safety
272
+
273
+ ic-python-db includes built-in upgrade compatibility checking. The system introspects your Entity class definitions to detect schema changes and ensure safe upgrades.
274
+
275
+ ### Auto-migration for safe changes
276
+
277
+ Adding a field with a default value requires no migration code — the system handles it automatically:
278
+
279
+ ```python
280
+ # v1
281
+ class Product(Entity):
282
+ __version__ = 1
283
+ name = String()
284
+
285
+ # v2 — just add the field with a default, no migrate() needed
286
+ class Product(Entity):
287
+ __version__ = 2
288
+ name = String()
289
+ price = Float(default=0.0) # auto-injected for existing entities
290
+ active = Boolean(default=True) # auto-injected for existing entities
291
+ ```
292
+
293
+ ### Custom migration for breaking changes
294
+
295
+ For type changes, field renames, or data transformations, override `migrate()`:
296
+
297
+ ```python
298
+ class Product(Entity):
299
+ __version__ = 2
300
+ name = String()
301
+ price_dollars = Float() # was price_cents: Integer in v1
302
+
303
+ @classmethod
304
+ def migrate(cls, obj, from_version, to_version):
305
+ if from_version == 1:
306
+ obj["price_dollars"] = obj.pop("price_cents") / 100.0
307
+ return obj
308
+ ```
309
+
310
+ ### Upgrade compatibility enforcement
311
+
312
+ Call `check_upgrade_compatibility()` from your canister's `post_upgrade` to reject incompatible upgrades before they corrupt data:
313
+
314
+ ```python
315
+ from basilisk import post_upgrade
316
+ from ic_python_db import Database
317
+
318
+ @post_upgrade
319
+ def on_post_upgrade():
320
+ db = Database.get_instance()
321
+ db.check_upgrade_compatibility()
322
+ # If a breaking change lacks migrate(), this raises SchemaIncompatibleError
323
+ # which traps post_upgrade, causing the IC to roll back the upgrade
324
+ ```
325
+
326
+ The system classifies schema changes as:
327
+
328
+ | Change | Safety | Action |
329
+ |--------|--------|--------|
330
+ | Add field with `default=` | Safe | Auto-migrated |
331
+ | Remove field | Safe | Old data ignored |
332
+ | Add new Entity type | Safe | No migration needed |
333
+ | Change field type | Breaking | Requires `migrate()` |
334
+ | Add field without default | Breaking | Requires `migrate()` |
335
+ | Change relationship type | Breaking | Requires `migrate()` |
336
+
337
+ ### Schema introspection
338
+
339
+ You can inspect and compare schemas programmatically:
340
+
341
+ ```python
342
+ from ic_python_db import Database, build_schema, diff_schemas
343
+
344
+ db = Database.get_instance()
345
+
346
+ # Build current schema from Entity definitions
347
+ schema = db.build_schema_from_entities()
348
+
349
+ # Compare two schemas
350
+ changes = diff_schemas(old_schema, new_schema)
351
+ for change in changes:
352
+ print(f"{change.entity_type}.{change.field}: {change.reason}")
353
+ ```
354
+
355
+ See [docs/SCHEMA_VERSIONING.md](docs/SCHEMA_VERSIONING.md) for the full reference.
356
+
270
357
  ## API Reference
271
358
 
272
359
  - **Core**: `Database`, `Entity`
@@ -275,6 +362,7 @@ class User(Entity):
275
362
  - **Mixins**: `TimestampedMixin` (timestamps and ownership tracking)
276
363
  - **Hooks**: `ACTION_CREATE`, `ACTION_MODIFY`, `ACTION_DELETE`
277
364
  - **Context**: `get_caller_id()`, `set_caller_id()`, `Database.as_user()`
365
+ - **Schema**: `build_schema`, `diff_schemas`, `schema_hash`, `SchemaIncompatibleError`
278
366
 
279
367
  ## Development
280
368
 
@@ -16,10 +16,11 @@ from .properties import (
16
16
  OneToOne,
17
17
  String,
18
18
  )
19
+ from .schema import SchemaIncompatibleError, build_schema, diff_schemas, schema_hash
19
20
  from .storage import MemoryStorage, Storage
20
21
  from .system_time import SystemTime
21
22
 
22
- __version__ = "0.7.9"
23
+ __version__ = "0.9.0"
23
24
  __all__ = [
24
25
  "Database",
25
26
  "Entity",
@@ -38,4 +39,8 @@ __all__ = [
38
39
  "ACTION_CREATE",
39
40
  "ACTION_MODIFY",
40
41
  "ACTION_DELETE",
42
+ "SchemaIncompatibleError",
43
+ "build_schema",
44
+ "diff_schemas",
45
+ "schema_hash",
41
46
  ]
@@ -322,6 +322,110 @@ class Database:
322
322
  return json.dumps(result, indent=2)
323
323
  return json.dumps(result)
324
324
 
325
+ def build_schema_from_entities(self) -> Dict[str, Any]:
326
+ """Build a schema descriptor from all registered entity types.
327
+
328
+ Returns:
329
+ Dict describing every entity type's fields, types, defaults,
330
+ constraints, and relationships.
331
+ """
332
+ from .schema import build_schema
333
+
334
+ return build_schema(self._entity_types)
335
+
336
+ def get_schema_hash(self) -> Optional[str]:
337
+ """Return the stored schema hash, or None if no schema has been saved."""
338
+ return self.load("_system", "_schema_hash")
339
+
340
+ def save_schema(self) -> None:
341
+ """Persist the current schema descriptor and its hash."""
342
+ from .schema import schema_hash
343
+
344
+ current = self.build_schema_from_entities()
345
+ self.save("_system", "_schema", current)
346
+ self.save("_system", "_schema_hash", schema_hash(current))
347
+
348
+ def check_upgrade_compatibility(self, raise_on_error: bool = True):
349
+ """Check that current Entity definitions are compatible with stored schema.
350
+
351
+ Compares the previously stored schema against the live class definitions.
352
+ Breaking changes (type changes, new fields without defaults, relationship
353
+ cardinality changes) require the Entity to define a migrate() override.
354
+
355
+ On success, saves the new schema. On failure (with raise_on_error=True),
356
+ raises SchemaIncompatibleError — designed to be called from post_upgrade
357
+ so the IC rolls back the upgrade.
358
+
359
+ Returns:
360
+ List of SchemaChange objects.
361
+
362
+ Raises:
363
+ SchemaIncompatibleError: if incompatible and raise_on_error is True.
364
+ """
365
+ from .schema import check_upgrade_compatibility
366
+
367
+ return check_upgrade_compatibility(self, raise_on_error=raise_on_error)
368
+
369
+ # ── Reverse Index API ──────────────────────────────────────────────────────
370
+ #
371
+ # Reverse indexes allow OneToMany / ManyToMany / inverse-OneToOne relations
372
+ # to be resolved without scanning all child entities.
373
+ #
374
+ # Key format: _ri:{ParentType}:{parent_id}:{relation_name}
375
+ # Value: JSON array of child entity IDs, e.g. ["1", "2", "3"]
376
+
377
+ def _ri_key(self, parent_type: str, parent_id: str, relation_name: str) -> str:
378
+ return f"_ri:{parent_type}:{parent_id}:{relation_name}"
379
+
380
+ def reverse_index_get(
381
+ self, parent_type: str, parent_id: str, relation_name: str
382
+ ) -> List[str]:
383
+ """Read the reverse index for a parent entity's relation.
384
+
385
+ Returns:
386
+ List of child entity IDs, or empty list if no index exists.
387
+ """
388
+ key = self._ri_key(parent_type, parent_id, relation_name)
389
+ raw = self._db_storage.get(key)
390
+ if raw:
391
+ return json.loads(raw)
392
+ return []
393
+
394
+ def reverse_index_add(
395
+ self, parent_type: str, parent_id: str, relation_name: str, child_id: str
396
+ ) -> None:
397
+ """Add a child ID to a parent's reverse index."""
398
+ key = self._ri_key(parent_type, parent_id, relation_name)
399
+ raw = self._db_storage.get(key)
400
+ ids = json.loads(raw) if raw else []
401
+ if child_id not in ids:
402
+ ids.append(child_id)
403
+ self._db_storage.insert(key, json.dumps(ids))
404
+
405
+ def reverse_index_remove(
406
+ self, parent_type: str, parent_id: str, relation_name: str, child_id: str
407
+ ) -> None:
408
+ """Remove a child ID from a parent's reverse index."""
409
+ key = self._ri_key(parent_type, parent_id, relation_name)
410
+ raw = self._db_storage.get(key)
411
+ if not raw:
412
+ return
413
+ ids = json.loads(raw)
414
+ if child_id in ids:
415
+ ids.remove(child_id)
416
+ if ids:
417
+ self._db_storage.insert(key, json.dumps(ids))
418
+ else:
419
+ self._db_storage.remove(key)
420
+
421
+ def reverse_index_delete(
422
+ self, parent_type: str, parent_id: str, relation_name: str
423
+ ) -> None:
424
+ """Delete an entire reverse index entry (used when parent is deleted)."""
425
+ key = self._ri_key(parent_type, parent_id, relation_name)
426
+ if self._db_storage.get(key) is not None:
427
+ self._db_storage.remove(key)
428
+
325
429
  def get_audit(
326
430
  self, id_from: Optional[int] = None, id_to: Optional[int] = None
327
431
  ) -> Dict[str, str]: