ic-python-db 0.7.9__py3-none-any.whl → 0.9.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -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
  ]
ic_python_db/db_engine.py CHANGED
@@ -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]:
ic_python_db/entity.py CHANGED
@@ -71,7 +71,6 @@ class Entity:
71
71
  _id (str): Unique sequential identifier ("1", "2", "3", ...)
72
72
  _loaded (bool): True if loaded from DB, False if newly created
73
73
  _counted (bool): True if entity has been counted (prevents double-counting)
74
- _relations (dict): Dictionary mapping relation names to related entities
75
74
  _do_not_save (bool): Temporary flag to prevent saving during initialization
76
75
 
77
76
  Class-level attributes:
@@ -161,8 +160,6 @@ class Entity:
161
160
  self._loaded = False if kwargs.get("_loaded") is None else kwargs["_loaded"]
162
161
  self._counted = False # Track if this entity has been counted
163
162
 
164
- self._relations = {}
165
-
166
163
  # Add to context
167
164
  self.__class__._context.add(self)
168
165
 
@@ -334,6 +331,25 @@ class Entity:
334
331
  return cls.get_full_type_name() + "_alias"
335
332
  return f"{cls.get_full_type_name()}_{field_name}_alias"
336
333
 
334
+ @classmethod
335
+ def _auto_migrate_defaults(cls, data: dict) -> dict:
336
+ """Inject default values for new Property fields not present in stored data.
337
+
338
+ Called before migrate() during version-mismatch loads so that developers
339
+ don't need to write migrate() for the trivial case of adding a field
340
+ with a default value.
341
+ """
342
+ from .properties import Property
343
+
344
+ for klass in reversed(cls.__mro__):
345
+ for attr_name, attr_value in klass.__dict__.items():
346
+ if attr_name.startswith("_"):
347
+ continue
348
+ if isinstance(attr_value, Property) and attr_name not in data:
349
+ if attr_value.default is not None:
350
+ data[attr_name] = attr_value.default
351
+ return data
352
+
337
353
  @classmethod
338
354
  def migrate(cls, obj: dict, from_version: int, to_version: int) -> dict:
339
355
  """Migrate entity data from one version to another.
@@ -404,8 +420,10 @@ class Entity:
404
420
  f"Version mismatch for {type_name}@{entity_id}: "
405
421
  f"stored={stored_version}, current={current_version}"
406
422
  )
407
- # Apply migration
423
+ # Apply custom migration first (developer logic takes priority)
408
424
  data = cls.migrate(data, stored_version, current_version)
425
+ # Auto-inject defaults for new fields not handled by migrate()
426
+ data = cls._auto_migrate_defaults(data)
409
427
  data["__version__"] = current_version
410
428
  logger.debug(
411
429
  f"Migrated {type_name}@{entity_id} to version {current_version}"
@@ -418,62 +436,62 @@ class Entity:
418
436
  if stored_version != current_version:
419
437
  entity._save()
420
438
 
421
- # Restore legacy "relations" block if present in serialized data.
422
- # Otherwise keep the _relations that __init__ already populated
423
- # via relationship descriptors (OneToMany, ManyToOne, etc.).
424
- if "relations" in data:
425
- relations_data = data.pop("relations")
426
- relations = {}
427
- for rel_name, rel_refs in relations_data.items():
428
- relations[rel_name] = []
429
- for ref in rel_refs:
430
- related = (
431
- Entity.db()
432
- ._entity_types[ref["_type"]]
433
- .load(ref["_id"], level=level - 1)
434
- )
435
- if related:
436
- relations[rel_name].append(related)
437
- entity._relations = relations
438
-
439
439
  return entity
440
440
 
441
441
  @classmethod
442
442
  def find(cls: Type[T], d) -> List[T]:
443
- D = d
444
- L = [_.serialize() for _ in cls.instances()]
443
+ """Find entities matching a dictionary filter.
444
+
445
+ Uses load_some over the full ID range. Prefer indexed lookups where possible.
446
+ """
447
+ max_id = cls.max_id()
448
+ if max_id == 0:
449
+ return []
450
+ all_entities = cls.load_some(1, max_id)
445
451
  return [
446
- cls.load(d["_id"]) for d in L if all(d.get(k) == v for k, v in D.items())
452
+ e
453
+ for e in all_entities
454
+ if all(getattr(e, k, None) == v for k, v in d.items())
447
455
  ]
448
456
 
449
457
  @classmethod
450
458
  def instances(cls: Type[T]) -> List[T]:
451
459
  """Get all instances of this entity type, including subclass instances.
452
460
 
453
- Uses load_some() for O(max_id) performance instead of scanning all keys.
461
+ .. deprecated::
462
+ This method loads ALL entities and is O(max_id). Prefer load_some()
463
+ with pagination or use relationship accessors.
454
464
 
455
465
  Returns:
456
466
  List of entities
457
467
  """
468
+ try:
469
+ import warnings
470
+
471
+ warnings.warn(
472
+ "Entity.instances() is deprecated and will be removed. "
473
+ "Use load_some() with pagination instead.",
474
+ DeprecationWarning,
475
+ stacklevel=2,
476
+ )
477
+ except (ImportError, AttributeError):
478
+ pass
458
479
  db = Database.get_instance()
459
480
  full_type_name = cls.get_full_type_name()
460
481
  db.register_entity_type(cls, full_type_name)
461
482
 
462
- # Use load_some for O(max_id) instead of O(total_keys)
463
483
  max_id = cls.max_id()
464
484
  if max_id == 0:
465
485
  instances = []
466
486
  else:
467
487
  instances = cls.load_some(1, max_id)
468
488
 
469
- # Also check for subclass instances
470
- # Track processed classes to avoid duplicates when types are registered under multiple names
471
489
  processed_classes = {cls}
472
490
  for type_name, type_cls in db._entity_types.items():
473
491
  if type_cls in processed_classes:
474
- continue # Skip already processed classes
492
+ continue
475
493
  if type_name == full_type_name or type_name == cls.__name__:
476
- continue # Skip self
494
+ continue
477
495
  if db.is_subclass(type_name, cls):
478
496
  processed_classes.add(type_cls)
479
497
  subclass_max_id = type_cls.max_id()
@@ -551,10 +569,11 @@ class Entity:
551
569
  return ret
552
570
 
553
571
  def delete(self) -> None:
572
+ """Delete this entity from the database, cleaning up all reverse indexes."""
554
573
  logger.debug(f"Deleting entity {self._type}@{self._id}")
555
- """Delete this entity from the database."""
556
574
  from .constants import ACTION_DELETE
557
575
  from .hooks import call_entity_hook
576
+ from .properties import ManyToMany, ManyToOne, OneToMany, OneToOne
558
577
 
559
578
  # Call hook before deletion
560
579
  allow, _ = call_entity_hook(self, None, self, None, ACTION_DELETE)
@@ -562,17 +581,74 @@ class Entity:
562
581
  if not allow:
563
582
  raise PermissionError("Hook rejected entity deletion")
564
583
 
565
- self.db().delete(self._type, self._id)
584
+ db = self.db()
585
+
586
+ # Clean up reverse indexes for all relation properties
587
+ for cls in reversed(self.__class__.__mro__):
588
+ for attr_name, attr_value in cls.__dict__.items():
589
+ if attr_name.startswith("_"):
590
+ continue
591
+ if isinstance(attr_value, ManyToOne):
592
+ # Remove self from parent's reverse index
593
+ parent_ref = self.__dict__.get(f"_rel_{attr_name}")
594
+ if parent_ref is not None:
595
+ for type_name in attr_value._get_allowed_types():
596
+ entity_class = db._entity_types.get(type_name)
597
+ if entity_class and db.load(type_name, parent_ref):
598
+ db.reverse_index_remove(
599
+ type_name,
600
+ parent_ref,
601
+ attr_value.reverse_name,
602
+ self._id,
603
+ )
604
+ break
605
+ elif isinstance(attr_value, OneToOne):
606
+ # Remove self from target's reverse index
607
+ target_ref = self.__dict__.get(f"_rel_{attr_name}")
608
+ if target_ref is not None:
609
+ for type_name in attr_value._get_allowed_types():
610
+ entity_class = db._entity_types.get(type_name)
611
+ if entity_class and db.load(type_name, target_ref):
612
+ db.reverse_index_remove(
613
+ type_name,
614
+ target_ref,
615
+ attr_value.reverse_name,
616
+ self._id,
617
+ )
618
+ break
619
+ # Also delete any reverse index where self is parent (inverse side)
620
+ db.reverse_index_delete(self._type, self._id, attr_name)
621
+ elif isinstance(attr_value, OneToMany):
622
+ # Delete the reverse index entry for this parent
623
+ db.reverse_index_delete(self._type, self._id, attr_name)
624
+ elif isinstance(attr_value, ManyToMany):
625
+ # Remove self from each related entity's reverse index
626
+ related_ids = db.reverse_index_get(self._type, self._id, attr_name)
627
+ for related_id in related_ids:
628
+ for type_name in attr_value._get_allowed_types():
629
+ entity_class = db._entity_types.get(type_name)
630
+ if entity_class and db.load(type_name, related_id):
631
+ db.reverse_index_remove(
632
+ type_name,
633
+ related_id,
634
+ attr_value.reverse_name,
635
+ self._id,
636
+ )
637
+ break
638
+ # Delete own index
639
+ db.reverse_index_delete(self._type, self._id, attr_name)
640
+
641
+ db.delete(self._type, self._id)
566
642
 
567
643
  # Remove from entity registry
568
- self.db().unregister_entity(self._type, self._id)
644
+ db.unregister_entity(self._type, self._id)
569
645
 
570
646
  # Decrement the count when an entity is deleted
571
647
  type_name = self.__class__.get_full_type_name()
572
648
  count_key = f"{type_name}_count"
573
- current_count = int(self.db().load("_system", count_key) or 0)
649
+ current_count = int(db.load("_system", count_key) or 0)
574
650
  if current_count > 0:
575
- self.db().save("_system", count_key, str(current_count - 1))
651
+ db.save("_system", count_key, str(current_count - 1))
576
652
  else:
577
653
  raise ValueError(
578
654
  f"Entity count for {type_name} is already zero; cannot decrement further."
@@ -584,7 +660,7 @@ class Entity:
584
660
  if hasattr(self, alias_field):
585
661
  alias_value = getattr(self, alias_field)
586
662
  if alias_value is not None:
587
- self.db().delete(self._alias_key(), alias_value)
663
+ db.delete(self._alias_key(), alias_value)
588
664
 
589
665
  logger.debug(f"Deleted entity {self._type}@{self._id}")
590
666
 
@@ -630,61 +706,73 @@ class Entity:
630
706
  return entity._id
631
707
 
632
708
  def _serialize_full(self) -> Dict[str, Any]:
633
- """Full serialization including all relations. Used by _save() for persistence."""
709
+ """Full serialization including relation FK references. Used by _save()."""
634
710
  data = self._serialize_base()
635
711
 
636
- from ic_python_db.properties import ManyToMany, OneToMany
712
+ from ic_python_db.properties import ManyToOne, OneToOne
637
713
 
638
- for rel_name, rel_entities in self._relations.items():
639
- if rel_entities:
640
- rel_prop = getattr(self.__class__, rel_name, None)
641
- is_to_many = isinstance(rel_prop, (OneToMany, ManyToMany))
642
-
643
- if len(rel_entities) == 1 and not is_to_many:
644
- data[rel_name] = self._get_entity_reference(rel_entities[0])
645
- else:
646
- data[rel_name] = [
647
- self._get_entity_reference(e) for e in rel_entities
648
- ]
714
+ for cls in reversed(self.__class__.__mro__):
715
+ for attr_name, attr_value in cls.__dict__.items():
716
+ if attr_name.startswith("_"):
717
+ continue
718
+ if isinstance(attr_value, (ManyToOne, OneToOne)):
719
+ ref = self.__dict__.get(f"_rel_{attr_name}")
720
+ if ref is not None:
721
+ data[attr_name] = ref
649
722
 
650
723
  return data
651
724
 
725
+ def _resolve_ref_with_alias(self, entity_id: str, allowed_types: list) -> str:
726
+ """Resolve an entity ID to its best reference (alias value or ID)."""
727
+ db = self.db()
728
+ for type_name in allowed_types:
729
+ entity_class = db._entity_types.get(type_name)
730
+ if entity_class:
731
+ entity = entity_class.load(entity_id)
732
+ if entity:
733
+ return self._get_entity_reference(entity)
734
+ return entity_id
735
+
652
736
  def serialize(self) -> Dict[str, Any]:
653
737
  """Convert the entity to a portable serializable dictionary.
654
738
 
655
- OneToMany relations are skipped (reconstructed from reverse ManyToOne).
656
- For bilateral OneToOne relations, only the alphabetically-earlier entity
657
- type serializes the reference, avoiding circular dependencies.
739
+ Includes: ManyToOne FK, owning-side OneToOne FK, ManyToMany entries.
740
+ Excludes: OneToMany (reconstructed from ManyToOne during import).
658
741
 
659
742
  Returns:
660
743
  Dict containing the entity's serializable data
661
744
  """
662
745
  data = self._serialize_base()
663
746
 
664
- from ic_python_db.properties import ManyToMany, OneToMany, OneToOne
747
+ from ic_python_db.properties import ManyToMany, ManyToOne, OneToOne
665
748
 
666
- for rel_name, rel_entities in self._relations.items():
667
- if rel_entities:
668
- rel_prop = getattr(self.__class__, rel_name, None)
749
+ db = self.db()
669
750
 
670
- # Skip OneToMany — always reconstructed from reverse ManyToOne
671
- if isinstance(rel_prop, OneToMany):
751
+ for cls in reversed(self.__class__.__mro__):
752
+ for attr_name, attr_value in cls.__dict__.items():
753
+ if attr_name.startswith("_"):
672
754
  continue
673
- # For OneToOne bilateral, only serialize on one deterministic side:
674
- # the entity whose type name is alphabetically <= the target type.
675
- if isinstance(rel_prop, OneToOne):
676
- target_type = rel_entities[0]._type if rel_entities else None
677
- if target_type and self._type > target_type:
678
- continue
679
-
680
- is_to_many = isinstance(rel_prop, (OneToMany, ManyToMany))
681
-
682
- if len(rel_entities) == 1 and not is_to_many:
683
- data[rel_name] = self._get_entity_reference(rel_entities[0])
684
- else:
685
- data[rel_name] = [
686
- self._get_entity_reference(e) for e in rel_entities
687
- ]
755
+ if isinstance(attr_value, ManyToOne):
756
+ ref = self.__dict__.get(f"_rel_{attr_name}")
757
+ if ref is not None:
758
+ data[attr_name] = self._resolve_ref_with_alias(
759
+ ref, attr_value._get_allowed_types()
760
+ )
761
+ elif isinstance(attr_value, OneToOne):
762
+ ref = self.__dict__.get(f"_rel_{attr_name}")
763
+ if ref is not None:
764
+ data[attr_name] = self._resolve_ref_with_alias(
765
+ ref, attr_value._get_allowed_types()
766
+ )
767
+ elif isinstance(attr_value, ManyToMany):
768
+ related_ids = db.reverse_index_get(self._type, self._id, attr_name)
769
+ if related_ids:
770
+ data[attr_name] = [
771
+ self._resolve_ref_with_alias(
772
+ rid, attr_value._get_allowed_types()
773
+ )
774
+ for rid in related_ids
775
+ ]
688
776
 
689
777
  return data
690
778
 
@@ -949,70 +1037,3 @@ class Entity:
949
1037
  Hash value
950
1038
  """
951
1039
  return hash((self._type, self._id))
952
-
953
- def add_relation(self, from_rel: str, to_rel: str, other: "Entity") -> None:
954
- """Add a bidirectional relationship with another entity.
955
-
956
- Args:
957
- from_rel: Name of relation from this entity to other
958
- to_rel: Name of relation from other entity to this
959
- other: Entity to create relationship with
960
- """
961
- # Add forward relation
962
- if from_rel not in self._relations:
963
- self._relations[from_rel] = []
964
- if other not in self._relations[from_rel]:
965
- self._relations[from_rel].append(other)
966
-
967
- # Add reverse relation
968
- if to_rel not in other._relations:
969
- other._relations[to_rel] = []
970
- if self not in other._relations[to_rel]:
971
- other._relations[to_rel].append(self)
972
-
973
- # Save both entities
974
- self._save()
975
- other._save()
976
-
977
- def get_relations(
978
- self, relation_name: str, entity_type: str = None
979
- ) -> List["Entity"]:
980
- """Get all related entities for a relation, optionally filtered by type.
981
-
982
- Args:
983
- relation_name: Name of the relation to follow
984
- entity_type: Optional type name to filter entities by
985
-
986
- Returns:
987
- List of related entities
988
- """
989
- if relation_name not in self._relations:
990
- return []
991
-
992
- entities = self._relations[relation_name]
993
- if entity_type:
994
- entities = [e for e in entities if e._type == entity_type]
995
-
996
- return entities
997
-
998
- def remove_relation(self, from_rel: str, to_rel: str, other: "Entity") -> None:
999
- """Remove a bidirectional relationship with another entity.
1000
-
1001
- Args:
1002
- from_rel: Name of relation from this entity to other
1003
- to_rel: Name of relation from other entity to this
1004
- other: Entity to remove relationship with
1005
- """
1006
- # Remove forward relation
1007
- if from_rel in self._relations:
1008
- if other in self._relations[from_rel]:
1009
- self._relations[from_rel].remove(other)
1010
-
1011
- # Remove reverse relation
1012
- if to_rel in other._relations:
1013
- if self in other._relations[to_rel]:
1014
- other._relations[to_rel].remove(self)
1015
-
1016
- # Save both entities
1017
- self._save()
1018
- other._save()