ic-python-db 0.7.9__tar.gz → 0.8.2__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 (25) hide show
  1. {ic_python_db-0.7.9/ic_python_db.egg-info → ic_python_db-0.8.2}/PKG-INFO +90 -2
  2. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/README.md +89 -1
  3. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/__init__.py +6 -1
  4. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/db_engine.py +44 -0
  5. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/entity.py +22 -1
  6. ic_python_db-0.8.2/ic_python_db/schema.py +475 -0
  7. {ic_python_db-0.7.9 → ic_python_db-0.8.2/ic_python_db.egg-info}/PKG-INFO +90 -2
  8. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db.egg-info/SOURCES.txt +1 -0
  9. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/pyproject.toml +1 -1
  10. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/setup.py +1 -1
  11. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/LICENSE +0 -0
  12. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/MANIFEST.in +0 -0
  13. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/_cdk.py +0 -0
  14. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/constants.py +0 -0
  15. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/context.py +0 -0
  16. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/hooks.py +0 -0
  17. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/mixins.py +0 -0
  18. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/properties.py +0 -0
  19. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/py.typed +0 -0
  20. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/storage.py +0 -0
  21. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db/system_time.py +0 -0
  22. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db.egg-info/dependency_links.txt +0 -0
  23. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/ic_python_db.egg-info/top_level.txt +0 -0
  24. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/requirements-dev.txt +0 -0
  25. {ic_python_db-0.7.9 → ic_python_db-0.8.2}/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.8.2
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.8.2"
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,50 @@ 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
+
325
369
  def get_audit(
326
370
  self, id_from: Optional[int] = None, id_to: Optional[int] = None
327
371
  ) -> Dict[str, str]:
@@ -334,6 +334,25 @@ class Entity:
334
334
  return cls.get_full_type_name() + "_alias"
335
335
  return f"{cls.get_full_type_name()}_{field_name}_alias"
336
336
 
337
+ @classmethod
338
+ def _auto_migrate_defaults(cls, data: dict) -> dict:
339
+ """Inject default values for new Property fields not present in stored data.
340
+
341
+ Called before migrate() during version-mismatch loads so that developers
342
+ don't need to write migrate() for the trivial case of adding a field
343
+ with a default value.
344
+ """
345
+ from .properties import Property
346
+
347
+ for klass in reversed(cls.__mro__):
348
+ for attr_name, attr_value in klass.__dict__.items():
349
+ if attr_name.startswith("_"):
350
+ continue
351
+ if isinstance(attr_value, Property) and attr_name not in data:
352
+ if attr_value.default is not None:
353
+ data[attr_name] = attr_value.default
354
+ return data
355
+
337
356
  @classmethod
338
357
  def migrate(cls, obj: dict, from_version: int, to_version: int) -> dict:
339
358
  """Migrate entity data from one version to another.
@@ -404,8 +423,10 @@ class Entity:
404
423
  f"Version mismatch for {type_name}@{entity_id}: "
405
424
  f"stored={stored_version}, current={current_version}"
406
425
  )
407
- # Apply migration
426
+ # Apply custom migration first (developer logic takes priority)
408
427
  data = cls.migrate(data, stored_version, current_version)
428
+ # Auto-inject defaults for new fields not handled by migrate()
429
+ data = cls._auto_migrate_defaults(data)
409
430
  data["__version__"] = current_version
410
431
  logger.debug(
411
432
  f"Migrated {type_name}@{entity_id} to version {current_version}"
@@ -0,0 +1,475 @@
1
+ """Schema introspection, diffing, and upgrade compatibility checking.
2
+
3
+ Provides tools to:
4
+ - Build a JSON-serializable schema descriptor from Entity subclasses
5
+ - Compare old and new schemas to classify changes as safe or breaking
6
+ - Enforce upgrade compatibility (reject breaking changes without migrate())
7
+ """
8
+
9
+ import hashlib
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type
14
+
15
+ if TYPE_CHECKING:
16
+ from .db_engine import Database
17
+
18
+
19
+ class ChangeType(Enum):
20
+ ADDED = "added"
21
+ REMOVED = "removed"
22
+ TYPE_CHANGED = "type_changed"
23
+ DEFAULT_CHANGED = "default_changed"
24
+ CONSTRAINTS_CHANGED = "constraints_changed"
25
+ RELATIONSHIP_CHANGED = "relationship_changed"
26
+ ENTITY_ADDED = "entity_added"
27
+ ENTITY_REMOVED = "entity_removed"
28
+ VERSION_CHANGED = "version_changed"
29
+
30
+
31
+ @dataclass
32
+ class SchemaChange:
33
+ """Describes a single change between two schema versions."""
34
+
35
+ entity_type: str
36
+ field: Optional[str]
37
+ change_type: ChangeType
38
+ old_value: Any = None
39
+ new_value: Any = None
40
+ safe: bool = False
41
+ reason: str = ""
42
+
43
+ def __repr__(self):
44
+ if self.field:
45
+ return f"SchemaChange({self.entity_type}.{self.field}: {self.change_type.value}, safe={self.safe})"
46
+ return f"SchemaChange({self.entity_type}: {self.change_type.value}, safe={self.safe})"
47
+
48
+
49
+ def build_field_descriptor(prop) -> Dict[str, Any]:
50
+ """Build a descriptor dict for a single Property or Relation."""
51
+ from .properties import (
52
+ Boolean,
53
+ Float,
54
+ Integer,
55
+ ManyToMany,
56
+ ManyToOne,
57
+ OneToMany,
58
+ OneToOne,
59
+ Relation,
60
+ String,
61
+ )
62
+
63
+ desc: Dict[str, Any] = {}
64
+
65
+ if isinstance(prop, Relation):
66
+ desc["kind"] = "relationship"
67
+ desc["type"] = type(prop).__name__
68
+ entity_types = prop.entity_types
69
+ if isinstance(entity_types, list):
70
+ desc["target"] = entity_types
71
+ else:
72
+ desc["target"] = entity_types
73
+ if prop.reverse_name:
74
+ desc["inverse"] = prop.reverse_name
75
+ desc["many"] = prop.many
76
+ return desc
77
+
78
+ desc["kind"] = "property"
79
+ desc["type"] = type(prop).__name__
80
+
81
+ if prop.default is not None:
82
+ desc["default"] = prop.default
83
+ elif hasattr(prop, "default"):
84
+ desc["has_default"] = prop.default is not None
85
+
86
+ if isinstance(prop, String):
87
+ constraints = {}
88
+ if prop.validator:
89
+ # Extract min/max length from closure
90
+ closure_vars = _extract_closure_vars(prop.validator)
91
+ if closure_vars.get("min_length") is not None:
92
+ constraints["min_length"] = closure_vars["min_length"]
93
+ if closure_vars.get("max_length") is not None:
94
+ constraints["max_length"] = closure_vars["max_length"]
95
+ if constraints:
96
+ desc["constraints"] = constraints
97
+
98
+ elif isinstance(prop, (Integer, Float)):
99
+ constraints = {}
100
+ if prop.validator:
101
+ closure_vars = _extract_closure_vars(prop.validator)
102
+ if closure_vars.get("min_value") is not None:
103
+ constraints["min_value"] = closure_vars["min_value"]
104
+ if closure_vars.get("max_value") is not None:
105
+ constraints["max_value"] = closure_vars["max_value"]
106
+ if constraints:
107
+ desc["constraints"] = constraints
108
+
109
+ return desc
110
+
111
+
112
+ def _extract_closure_vars(func) -> Dict[str, Any]:
113
+ """Extract closure variables from a validator function."""
114
+ result = {}
115
+ if hasattr(func, "__code__") and hasattr(func, "__closure__"):
116
+ if func.__closure__:
117
+ free_vars = func.__code__.co_freevars
118
+ for name, cell in zip(free_vars, func.__closure__):
119
+ try:
120
+ result[name] = cell.cell_contents
121
+ except ValueError:
122
+ pass
123
+ return result
124
+
125
+
126
+ def build_schema(entity_types: Dict[str, Type]) -> Dict[str, Any]:
127
+ """Build a complete schema descriptor from registered entity types.
128
+
129
+ Args:
130
+ entity_types: Dict mapping type names to Entity classes,
131
+ typically from Database._entity_types
132
+
133
+ Returns:
134
+ Schema descriptor dict suitable for JSON serialization and comparison.
135
+ """
136
+ from .entity import Entity
137
+ from .properties import Property, Relation
138
+
139
+ schema: Dict[str, Any] = {}
140
+ seen_classes = set()
141
+
142
+ for type_name, entity_cls in entity_types.items():
143
+ if entity_cls in seen_classes:
144
+ continue
145
+ if not isinstance(entity_cls, type) or not issubclass(entity_cls, Entity):
146
+ continue
147
+ if entity_cls is Entity:
148
+ continue
149
+
150
+ seen_classes.add(entity_cls)
151
+ full_name = entity_cls.get_full_type_name()
152
+
153
+ entity_desc: Dict[str, Any] = {
154
+ "version": entity_cls.__version__,
155
+ "fields": {},
156
+ "relationships": {},
157
+ }
158
+
159
+ has_custom_migrate = _has_custom_migrate(entity_cls)
160
+ if has_custom_migrate:
161
+ entity_desc["has_migrate"] = True
162
+
163
+ for cls in reversed(entity_cls.__mro__):
164
+ for attr_name, attr_value in cls.__dict__.items():
165
+ if attr_name.startswith("_"):
166
+ continue
167
+
168
+ if isinstance(attr_value, Property):
169
+ entity_desc["fields"][attr_name] = build_field_descriptor(
170
+ attr_value
171
+ )
172
+ elif isinstance(attr_value, Relation):
173
+ entity_desc["relationships"][attr_name] = build_field_descriptor(
174
+ attr_value
175
+ )
176
+
177
+ schema[full_name] = entity_desc
178
+
179
+ return schema
180
+
181
+
182
+ def _has_custom_migrate(entity_cls: Type) -> bool:
183
+ """Check if an Entity class has overridden the default migrate() method."""
184
+ from .entity import Entity
185
+
186
+ if "migrate" not in entity_cls.__dict__:
187
+ return False
188
+ return entity_cls.migrate is not Entity.migrate
189
+
190
+
191
+ def schema_hash(schema: Dict[str, Any]) -> str:
192
+ """Compute a deterministic hash of a schema descriptor."""
193
+ canonical = json.dumps(schema, sort_keys=True, separators=(",", ":"))
194
+ return hashlib.sha256(canonical.encode()).hexdigest()
195
+
196
+
197
+ def diff_schemas(
198
+ old_schema: Dict[str, Any], new_schema: Dict[str, Any]
199
+ ) -> List[SchemaChange]:
200
+ """Compare two schema descriptors and return a list of changes.
201
+
202
+ Changes are classified as safe (auto-migratable) or breaking (requires migrate()).
203
+ """
204
+ changes: List[SchemaChange] = []
205
+
206
+ all_entity_types = set(old_schema.keys()) | set(new_schema.keys())
207
+
208
+ for entity_type in sorted(all_entity_types):
209
+ old_entity = old_schema.get(entity_type)
210
+ new_entity = new_schema.get(entity_type)
211
+
212
+ if old_entity is None and new_entity is not None:
213
+ changes.append(
214
+ SchemaChange(
215
+ entity_type=entity_type,
216
+ field=None,
217
+ change_type=ChangeType.ENTITY_ADDED,
218
+ new_value=new_entity,
219
+ safe=True,
220
+ reason="New entity type — no existing data to migrate",
221
+ )
222
+ )
223
+ continue
224
+
225
+ if old_entity is not None and new_entity is None:
226
+ changes.append(
227
+ SchemaChange(
228
+ entity_type=entity_type,
229
+ field=None,
230
+ change_type=ChangeType.ENTITY_REMOVED,
231
+ old_value=old_entity,
232
+ safe=True,
233
+ reason="Removed entity type — existing data will be orphaned",
234
+ )
235
+ )
236
+ continue
237
+
238
+ if old_entity["version"] != new_entity["version"]:
239
+ changes.append(
240
+ SchemaChange(
241
+ entity_type=entity_type,
242
+ field=None,
243
+ change_type=ChangeType.VERSION_CHANGED,
244
+ old_value=old_entity["version"],
245
+ new_value=new_entity["version"],
246
+ safe=True,
247
+ reason=f"Version {old_entity['version']} → {new_entity['version']}",
248
+ )
249
+ )
250
+
251
+ _diff_fields(
252
+ entity_type,
253
+ old_entity.get("fields", {}),
254
+ new_entity.get("fields", {}),
255
+ changes,
256
+ )
257
+ _diff_relationships(
258
+ entity_type,
259
+ old_entity.get("relationships", {}),
260
+ new_entity.get("relationships", {}),
261
+ changes,
262
+ )
263
+
264
+ return changes
265
+
266
+
267
+ def _diff_fields(
268
+ entity_type: str,
269
+ old_fields: Dict[str, Any],
270
+ new_fields: Dict[str, Any],
271
+ changes: List[SchemaChange],
272
+ ) -> None:
273
+ """Compare fields between old and new entity schemas."""
274
+ all_field_names = set(old_fields.keys()) | set(new_fields.keys())
275
+
276
+ for field_name in sorted(all_field_names):
277
+ old_field = old_fields.get(field_name)
278
+ new_field = new_fields.get(field_name)
279
+
280
+ if old_field is None and new_field is not None:
281
+ has_default = new_field.get("default") is not None or new_field.get(
282
+ "has_default", False
283
+ )
284
+ changes.append(
285
+ SchemaChange(
286
+ entity_type=entity_type,
287
+ field=field_name,
288
+ change_type=ChangeType.ADDED,
289
+ new_value=new_field,
290
+ safe=has_default,
291
+ reason=(
292
+ f"New field with default={new_field.get('default')!r} — auto-migratable"
293
+ if has_default
294
+ else "New field without default — requires migrate() to provide initial values"
295
+ ),
296
+ )
297
+ )
298
+ continue
299
+
300
+ if old_field is not None and new_field is None:
301
+ changes.append(
302
+ SchemaChange(
303
+ entity_type=entity_type,
304
+ field=field_name,
305
+ change_type=ChangeType.REMOVED,
306
+ old_value=old_field,
307
+ safe=True,
308
+ reason="Removed field — old data will be ignored on load",
309
+ )
310
+ )
311
+ continue
312
+
313
+ if old_field.get("type") != new_field.get("type"):
314
+ changes.append(
315
+ SchemaChange(
316
+ entity_type=entity_type,
317
+ field=field_name,
318
+ change_type=ChangeType.TYPE_CHANGED,
319
+ old_value=old_field.get("type"),
320
+ new_value=new_field.get("type"),
321
+ safe=False,
322
+ reason=f"Type changed {old_field.get('type')} → {new_field.get('type')} — requires migrate()",
323
+ )
324
+ )
325
+
326
+ old_constraints = old_field.get("constraints", {})
327
+ new_constraints = new_field.get("constraints", {})
328
+ if old_constraints != new_constraints:
329
+ changes.append(
330
+ SchemaChange(
331
+ entity_type=entity_type,
332
+ field=field_name,
333
+ change_type=ChangeType.CONSTRAINTS_CHANGED,
334
+ old_value=old_constraints,
335
+ new_value=new_constraints,
336
+ safe=True,
337
+ reason="Constraints changed — existing data may need validation",
338
+ )
339
+ )
340
+
341
+
342
+ def _diff_relationships(
343
+ entity_type: str,
344
+ old_rels: Dict[str, Any],
345
+ new_rels: Dict[str, Any],
346
+ changes: List[SchemaChange],
347
+ ) -> None:
348
+ """Compare relationships between old and new entity schemas."""
349
+ all_rel_names = set(old_rels.keys()) | set(new_rels.keys())
350
+
351
+ for rel_name in sorted(all_rel_names):
352
+ old_rel = old_rels.get(rel_name)
353
+ new_rel = new_rels.get(rel_name)
354
+
355
+ if old_rel is None and new_rel is not None:
356
+ changes.append(
357
+ SchemaChange(
358
+ entity_type=entity_type,
359
+ field=rel_name,
360
+ change_type=ChangeType.ADDED,
361
+ new_value=new_rel,
362
+ safe=True,
363
+ reason="New relationship — no existing data affected",
364
+ )
365
+ )
366
+ continue
367
+
368
+ if old_rel is not None and new_rel is None:
369
+ changes.append(
370
+ SchemaChange(
371
+ entity_type=entity_type,
372
+ field=rel_name,
373
+ change_type=ChangeType.REMOVED,
374
+ old_value=old_rel,
375
+ safe=True,
376
+ reason="Removed relationship — old references will be orphaned",
377
+ )
378
+ )
379
+ continue
380
+
381
+ if old_rel.get("type") != new_rel.get("type"):
382
+ changes.append(
383
+ SchemaChange(
384
+ entity_type=entity_type,
385
+ field=rel_name,
386
+ change_type=ChangeType.RELATIONSHIP_CHANGED,
387
+ old_value=old_rel,
388
+ new_value=new_rel,
389
+ safe=False,
390
+ reason=(
391
+ f"Relationship type changed {old_rel.get('type')} → {new_rel.get('type')} "
392
+ f"— requires migrate()"
393
+ ),
394
+ )
395
+ )
396
+ elif old_rel.get("target") != new_rel.get("target"):
397
+ changes.append(
398
+ SchemaChange(
399
+ entity_type=entity_type,
400
+ field=rel_name,
401
+ change_type=ChangeType.RELATIONSHIP_CHANGED,
402
+ old_value=old_rel,
403
+ new_value=new_rel,
404
+ safe=False,
405
+ reason=(
406
+ f"Relationship target changed {old_rel.get('target')} → {new_rel.get('target')} "
407
+ f"— requires migrate()"
408
+ ),
409
+ )
410
+ )
411
+
412
+
413
+ def check_upgrade_compatibility(
414
+ db: "Database",
415
+ raise_on_error: bool = True,
416
+ ) -> List[SchemaChange]:
417
+ """Check that the current Entity definitions are compatible with stored schema.
418
+
419
+ Loads the previously stored schema from _system/_schema, builds the current
420
+ schema from registered Entity classes, diffs them, and verifies that every
421
+ breaking change has a corresponding migrate() override.
422
+
423
+ After successful validation, saves the new schema.
424
+
425
+ Args:
426
+ db: Database instance
427
+ raise_on_error: If True (default), raise on incompatible changes.
428
+ If False, return the changes list without raising.
429
+
430
+ Returns:
431
+ List of SchemaChange objects describing all detected changes.
432
+
433
+ Raises:
434
+ SchemaIncompatibleError: If breaking changes are found without migrate().
435
+ """
436
+ old_schema_data = db.load("_system", "_schema")
437
+
438
+ current_schema = build_schema(db._entity_types)
439
+
440
+ if old_schema_data is None:
441
+ db.save("_system", "_schema", current_schema)
442
+ db.save("_system", "_schema_hash", schema_hash(current_schema))
443
+ return []
444
+
445
+ changes = diff_schemas(old_schema_data, current_schema)
446
+
447
+ breaking_without_migrate = []
448
+ for change in changes:
449
+ if change.safe:
450
+ continue
451
+ entity_cls = db._entity_types.get(change.entity_type)
452
+ if entity_cls and _has_custom_migrate(entity_cls):
453
+ continue
454
+ breaking_without_migrate.append(change)
455
+
456
+ if breaking_without_migrate and raise_on_error:
457
+ details = "\n".join(
458
+ f" - {c.entity_type}.{c.field}: {c.reason}"
459
+ for c in breaking_without_migrate
460
+ )
461
+ raise SchemaIncompatibleError(
462
+ f"Upgrade rejected: {len(breaking_without_migrate)} breaking change(s) "
463
+ f"without migrate() method:\n{details}"
464
+ )
465
+
466
+ db.save("_system", "_schema", current_schema)
467
+ db.save("_system", "_schema_hash", schema_hash(current_schema))
468
+
469
+ return changes
470
+
471
+
472
+ class SchemaIncompatibleError(Exception):
473
+ """Raised when an upgrade contains breaking schema changes without a migrate() method."""
474
+
475
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ic-python-db
3
- Version: 0.7.9
3
+ Version: 0.8.2
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
 
@@ -15,6 +15,7 @@ ic_python_db/hooks.py
15
15
  ic_python_db/mixins.py
16
16
  ic_python_db/properties.py
17
17
  ic_python_db/py.typed
18
+ ic_python_db/schema.py
18
19
  ic_python_db/storage.py
19
20
  ic_python_db/system_time.py
20
21
  ic_python_db.egg-info/PKG-INFO
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ic-python-db"
7
- version = "0.7.9"
7
+ version = "0.8.2"
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.7.9",
8
+ version="0.8.2",
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