Skip to content

Commit fcd197b

Browse files
Add schema versioning, upgrade compatibility checking, and auto-migration
Introduces a schema introspection and diff engine that detects breaking changes between entity versions and rejects upgrades that lack a migrate() method. Safe changes (new fields with defaults) are auto-migrated without developer intervention. Closes #8 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5a0ef9e commit fcd197b

7 files changed

Lines changed: 1192 additions & 2 deletions

File tree

README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ A lightweight key-value database with entity relationships and audit logging cap
1212

1313
- **Persistent Storage**: Works with StableBTreeMap stable structure for persistent storage on your canister's stable memory so your data persists automatically across canister upgrades.
1414
- **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.
1516
- **Entity Hooks**: Intercept and control entity lifecycle events (create, modify, delete) with `on_event` hooks.
1617
- **Access Control**: Thread-safe context management for user identity tracking and ownership-based permissions.
1718
- **Namespaces**: Organize entities into namespaces to avoid type conflicts when you have multiple entities with the same class name.
@@ -267,6 +268,90 @@ class User(Entity):
267268
profile: Optional["Profile"] = OneToOne("Profile", "user")
268269
```
269270

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+
270355
## API Reference
271356

272357
- **Core**: `Database`, `Entity`
@@ -275,6 +360,7 @@ class User(Entity):
275360
- **Mixins**: `TimestampedMixin` (timestamps and ownership tracking)
276361
- **Hooks**: `ACTION_CREATE`, `ACTION_MODIFY`, `ACTION_DELETE`
277362
- **Context**: `get_caller_id()`, `set_caller_id()`, `Database.as_user()`
363+
- **Schema**: `build_schema`, `diff_schemas`, `schema_hash`, `SchemaIncompatibleError`
278364

279365
## Development
280366

ic_python_db/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
OneToOne,
1717
String,
1818
)
19+
from .schema import SchemaIncompatibleError, build_schema, diff_schemas, schema_hash
1920
from .storage import MemoryStorage, Storage
2021
from .system_time import SystemTime
2122

@@ -38,4 +39,8 @@
3839
"ACTION_CREATE",
3940
"ACTION_MODIFY",
4041
"ACTION_DELETE",
42+
"SchemaIncompatibleError",
43+
"build_schema",
44+
"diff_schemas",
45+
"schema_hash",
4146
]

ic_python_db/db_engine.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,50 @@ def raw_dump_json(self, pretty: bool = False) -> str:
322322
return json.dumps(result, indent=2)
323323
return json.dumps(result)
324324

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+
325369
def get_audit(
326370
self, id_from: Optional[int] = None, id_to: Optional[int] = None
327371
) -> Dict[str, str]:

ic_python_db/entity.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,25 @@ def _alias_key(cls: Type[T], field_name: str = None) -> str:
334334
return cls.get_full_type_name() + "_alias"
335335
return f"{cls.get_full_type_name()}_{field_name}_alias"
336336

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+
337356
@classmethod
338357
def migrate(cls, obj: dict, from_version: int, to_version: int) -> dict:
339358
"""Migrate entity data from one version to another.
@@ -404,8 +423,10 @@ def load(
404423
f"Version mismatch for {type_name}@{entity_id}: "
405424
f"stored={stored_version}, current={current_version}"
406425
)
407-
# Apply migration
426+
# Apply custom migration first (developer logic takes priority)
408427
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)
409430
data["__version__"] = current_version
410431
logger.debug(
411432
f"Migrated {type_name}@{entity_id} to version {current_version}"

0 commit comments

Comments
 (0)