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 +6 -1
- ic_python_db/db_engine.py +104 -0
- ic_python_db/entity.py +162 -141
- ic_python_db/properties.py +288 -326
- ic_python_db/schema.py +475 -0
- {ic_python_db-0.7.9.dist-info → ic_python_db-0.9.0.dist-info}/METADATA +90 -2
- ic_python_db-0.9.0.dist-info/RECORD +18 -0
- ic_python_db-0.7.9.dist-info/RECORD +0 -17
- {ic_python_db-0.7.9.dist-info → ic_python_db-0.9.0.dist-info}/WHEEL +0 -0
- {ic_python_db-0.7.9.dist-info → ic_python_db-0.9.0.dist-info}/licenses/LICENSE +0 -0
- {ic_python_db-0.7.9.dist-info → ic_python_db-0.9.0.dist-info}/top_level.txt +0 -0
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.
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
492
|
+
continue
|
|
475
493
|
if type_name == full_type_name or type_name == cls.__name__:
|
|
476
|
-
continue
|
|
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()
|
|
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
|
-
|
|
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(
|
|
649
|
+
current_count = int(db.load("_system", count_key) or 0)
|
|
574
650
|
if current_count > 0:
|
|
575
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
712
|
+
from ic_python_db.properties import ManyToOne, OneToOne
|
|
637
713
|
|
|
638
|
-
for
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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,
|
|
747
|
+
from ic_python_db.properties import ManyToMany, ManyToOne, OneToOne
|
|
665
748
|
|
|
666
|
-
|
|
667
|
-
if rel_entities:
|
|
668
|
-
rel_prop = getattr(self.__class__, rel_name, None)
|
|
749
|
+
db = self.db()
|
|
669
750
|
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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()
|