ic-python-db 0.7.1__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/entity.py ADDED
@@ -0,0 +1,941 @@
1
+ """Enhanced entity implementation with support for mixins and entity types."""
2
+
3
+ from typing import Any, Dict, List, Optional, Set, Type, TypeVar
4
+
5
+ from ic_python_logging import get_logger
6
+
7
+ from .constants import LEVEL_MAX_DEFAULT
8
+ from .db_engine import Database
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ T = TypeVar("T", bound="Entity")
14
+
15
+
16
+ class Entity:
17
+ """Base class for database entities with enhanced features.
18
+
19
+ This is the core class for all database entities. It provides automatic ID generation,
20
+ property storage, relationship management, and alias-based lookups.
21
+
22
+ Usage Examples:
23
+ # Basic entity with auto-generated ID
24
+ class User(Entity):
25
+ name = String(min_length=2, max_length=50)
26
+ age = Integer(min_value=0)
27
+
28
+ user = User(name="John", age=30) # Creates entity with _id="1"
29
+
30
+ # Entity with alias for lookups
31
+ class Person(Entity):
32
+ __alias__ = "name" # Enables Person["John"] lookup
33
+ name = String()
34
+
35
+ person = Person(name="Jane")
36
+ found = Person["Jane"] # Lookup by alias
37
+
38
+ # Entity with versioning and migration
39
+ class Product(Entity):
40
+ __version__ = 2 # Current schema version
41
+ name = String()
42
+ price = Float()
43
+
44
+ @classmethod
45
+ def migrate(cls, obj, from_version, to_version):
46
+ if from_version == 1 and to_version >= 2:
47
+ if 'price' not in obj:
48
+ obj['price'] = 0.0
49
+ return obj
50
+
51
+ # Entity with namespace
52
+ class AppUser(Entity):
53
+ __namespace__ = "app" # Stores as "app::AppUser"
54
+ name = String()
55
+
56
+ class AdminUser(Entity):
57
+ __namespace__ = "admin" # Stores as "admin::AdminUser"
58
+ name = String()
59
+
60
+ Key Features:
61
+ - Auto-generated sequential IDs (_id: "1", "2", "3", ...)
62
+ - Property validation and type checking via descriptors
63
+ - Relationship management (OneToOne, OneToMany, ManyToMany)
64
+ - Alias-based entity lookup (Entity["alias_value"])
65
+ - Automatic persistence to database on creation/update
66
+ - Entity counting and pagination support
67
+ - Schema versioning and automatic migration on load
68
+
69
+ Internally managed attributes:
70
+ _type (str): Entity class name (e.g., "User", "Person")
71
+ _id (str): Unique sequential identifier ("1", "2", "3", ...)
72
+ _loaded (bool): True if loaded from DB, False if newly created
73
+ _counted (bool): True if entity has been counted (prevents double-counting)
74
+ _relations (dict): Dictionary mapping relation names to related entities
75
+ _do_not_save (bool): Temporary flag to prevent saving during initialization
76
+
77
+ Class-level attributes:
78
+ __alias__ (str): Optional field name for alias-based lookups
79
+ __version__ (int): Schema version for migration support (default: 1)
80
+ __namespace__ (str): Optional namespace for entity type (e.g., "app", "admin")
81
+ Entities with namespaces are stored as "namespace::ClassName"
82
+ Allows multiple entities with same class name in different namespaces
83
+ _entity_type (str): Optional entity type for subclasses
84
+ _context (Set[Entity]): Set of all entities in current context
85
+
86
+ Property Storage:
87
+ User-defined properties (String, Integer, etc.) are stored as _prop_{name}
88
+ in the entity's __dict__ to avoid conflicts with internal attributes.
89
+ Example: name = String() stores value in _prop_name
90
+ """
91
+
92
+ _entity_type = None # To be defined in subclasses
93
+ _context: Set["Entity"] = set() # Set of entities in current context
94
+ _do_not_save = False
95
+ __version__ = 1 # Default schema version
96
+ __namespace__: Optional[str] = None # Optional namespace for entity type
97
+
98
+ def __init__(self, **kwargs):
99
+ """Initialize a new entity.
100
+
101
+ Creates a new entity instance with auto-generated ID and sets up all internal
102
+ attributes. User-provided properties are validated and stored using the
103
+ _prop_{name} pattern to avoid conflicts with internal attributes.
104
+
105
+ The entity is automatically:
106
+ - Assigned a sequential ID (_id)
107
+ - Registered with the database
108
+ - Added to the class context
109
+ - Persisted to storage via _save()
110
+
111
+ Args:
112
+ **kwargs: User-defined attributes to set on the entity.
113
+ These correspond to properties defined on the class
114
+ (e.g., name="John" for a String() property named 'name').
115
+
116
+ Special internal kwargs (used internally by the system):
117
+ - _id: Custom ID string (bypasses auto-generation)
118
+
119
+ Example:
120
+ class User(Entity):
121
+ name = String(min_length=2)
122
+ age = Integer(min_value=0)
123
+
124
+ # Creates new entity with _id="1", validates and stores properties
125
+ user = User(name="Alice", age=25)
126
+ """
127
+ # Initialize any mixins
128
+ super().__init__() if hasattr(super(), "__init__") else None
129
+
130
+ # Store the type for this entity - use namespace::class_name if namespace is set
131
+ self._type = self.__class__.get_full_type_name()
132
+ # Get next sequential ID from storage
133
+ self._id = None if kwargs.get("_id") is None else kwargs["_id"]
134
+ self._loaded = False if kwargs.get("_loaded") is None else kwargs["_loaded"]
135
+ self._counted = False # Track if this entity has been counted
136
+
137
+ self._relations = {}
138
+
139
+ # Add to context
140
+ self.__class__._context.add(self)
141
+
142
+ # Register this type with the database (using full type name)
143
+ self.db().register_entity_type(self.__class__, self._type)
144
+
145
+ # Generate ID if not provided, or update max_id if custom ID is higher
146
+ if self._id is None:
147
+ db = self.db()
148
+ type_name = self._type
149
+ current_id = db.load("_system", f"{type_name}_id")
150
+ if current_id is None:
151
+ current_id = "0"
152
+ next_id = str(int(current_id) + 1)
153
+ self._id = next_id
154
+ db.save("_system", f"{type_name}_id", self._id)
155
+ else:
156
+ # Update max_id if custom ID is higher than current max
157
+ db = self.db()
158
+ type_name = self._type
159
+ current_max_id = db.load("_system", f"{type_name}_id")
160
+ if current_max_id is None:
161
+ current_max_id = "0"
162
+
163
+ # Only update if the custom ID is numeric and higher than current max
164
+ try:
165
+ custom_id_int = int(self._id)
166
+ current_max_int = int(current_max_id)
167
+ if custom_id_int > current_max_int:
168
+ db.save("_system", f"{type_name}_id", self._id)
169
+ except ValueError:
170
+ # If custom ID is not numeric, don't update max_id counter
171
+ pass
172
+
173
+ # Register this instance in the entity registry
174
+ self.db().register_entity(self)
175
+
176
+ self._do_not_save = True
177
+ # Set additional attributes
178
+ for k, v in kwargs.items():
179
+ if not k.startswith("_"):
180
+ setattr(self, k, v)
181
+ self._do_not_save = False
182
+
183
+ self._save()
184
+
185
+ @classmethod
186
+ def new(cls, **kwargs):
187
+ return cls(**kwargs)
188
+
189
+ @classmethod
190
+ def db(cls) -> Database:
191
+ """Get the database instance.
192
+
193
+ Returns:
194
+ Database: The database instance
195
+ """
196
+ return Database.get_instance()
197
+
198
+ @classmethod
199
+ def get_full_type_name(cls) -> str:
200
+ """Get the full type name including namespace.
201
+
202
+ Returns:
203
+ str: Full type name in format 'namespace::ClassName' or 'ClassName'
204
+ """
205
+ namespace = getattr(cls, "__namespace__", None)
206
+ if namespace:
207
+ return f"{namespace}::{cls.__name__}"
208
+ return cls.__name__
209
+
210
+ def _save(
211
+ self,
212
+ ) -> "Entity":
213
+ """Save the entity to the database.
214
+
215
+ Returns:
216
+ Entity: self for chaining
217
+
218
+ Raises:
219
+ PermissionError: If TimestampedMixin is used and caller is not the owner
220
+ """
221
+
222
+ # Use full type name (including namespace) for system counters and storage
223
+ type_name = self._type
224
+ db = self.__class__.db()
225
+
226
+ if self._id is None:
227
+ # Increment the ID when a new entity is created (never reuse or decrement)
228
+ self._id = str(int(db.load("_system", f"{type_name}_id") or 0) + 1)
229
+ db.save("_system", f"{type_name}_id", self._id)
230
+ # Increment the count when a new entity is created and decrement when deleted
231
+ if not self._counted:
232
+ count_key = f"{type_name}_count"
233
+ current_count = int(db.load("_system", count_key) or 0)
234
+ db.save("_system", count_key, str(current_count + 1))
235
+ self._counted = True
236
+ else:
237
+ if not self._loaded:
238
+ if db.load(type_name, self._id) is not None:
239
+ raise ValueError(f"Entity {self._type}@{self._id} already exists")
240
+ else:
241
+ # Increment count for new entities with custom IDs
242
+ if not self._counted:
243
+ count_key = f"{type_name}_count"
244
+ current_count = int(db.load("_system", count_key) or 0)
245
+ db.save("_system", count_key, str(current_count + 1))
246
+ self._counted = True
247
+
248
+ logger.debug(f"Saving entity {self._type}@{self._id}")
249
+
250
+ # Update timestamps if mixin is present
251
+ if hasattr(self, "_update_timestamps"):
252
+ from .context import get_caller_id
253
+
254
+ caller_id = get_caller_id()
255
+ if (
256
+ hasattr(self, "check_ownership")
257
+ and hasattr(self, "_timestamp_created")
258
+ and self._timestamp_created
259
+ ):
260
+ if not self.check_ownership(caller_id):
261
+ raise PermissionError(
262
+ f"Only the owner can update this entity. Current owner: {self._owner}"
263
+ )
264
+ self._update_timestamps(caller_id)
265
+
266
+ # Save to database
267
+ data = self.serialize()
268
+
269
+ if not self._do_not_save:
270
+ logger.debug(f"Saving entity {self._type}@{self._id} to database")
271
+ db = self.db()
272
+ persisted_data = {**data, "__version__": self.__class__.__version__}
273
+ db.save(self._type, self._id, persisted_data)
274
+ if hasattr(self.__class__, "__alias__") and self.__class__.__alias__:
275
+ alias_field = self.__class__.__alias__
276
+ if hasattr(self, alias_field):
277
+ alias_value = getattr(self, alias_field)
278
+ if alias_value is not None:
279
+ db.save(self.__class__._alias_key(), alias_value, self._id)
280
+ self._loaded = True
281
+
282
+ return self
283
+
284
+ @classmethod
285
+ def _alias_key(cls: Type[T], field_name: str = None) -> str:
286
+ """Get the alias key for this entity type and field, including namespace if set.
287
+
288
+ Args:
289
+ field_name: Optional field name for the alias key.
290
+ - If provided, it is used in the key.
291
+ - If not provided and cls.__alias__ is defined, cls.__alias__ is used.
292
+ - If neither is provided, the key is generated without a field name.
293
+
294
+ Returns:
295
+ Alias key string in one of the following formats:
296
+ - "{type_name}_{field_name}_alias" (if field_name or cls.__alias__ is used)
297
+ - "{type_name}_alias" (if neither is provided)
298
+ """
299
+ if field_name is not None and not isinstance(field_name, str):
300
+ raise TypeError(
301
+ f"field_name must be a string, got {type(field_name).__name__}"
302
+ )
303
+ if field_name is None:
304
+ field_name = getattr(cls, "__alias__", None)
305
+ if field_name is None:
306
+ return cls.get_full_type_name() + "_alias"
307
+ return f"{cls.get_full_type_name()}_{field_name}_alias"
308
+
309
+ @classmethod
310
+ def migrate(cls, obj: dict, from_version: int, to_version: int) -> dict:
311
+ """Migrate entity data from one version to another.
312
+
313
+ This is the default implementation that does nothing. Subclasses should
314
+ override this method to implement custom migration logic.
315
+
316
+ Args:
317
+ obj: Dictionary containing the entity data to migrate
318
+ from_version: The version of the data being migrated from
319
+ to_version: The target version to migrate to
320
+
321
+ Returns:
322
+ Dictionary containing the migrated entity data
323
+
324
+ Example:
325
+ @classmethod
326
+ def migrate(cls, obj, from_version, to_version):
327
+ if from_version == 1 and to_version >= 2:
328
+ if 'price' not in obj:
329
+ obj['price'] = 0.0
330
+ if from_version <= 2 and to_version >= 3:
331
+ if 'old_name' in obj:
332
+ obj['new_name'] = obj.pop('old_name')
333
+ return obj
334
+ """
335
+ return obj
336
+
337
+ @classmethod
338
+ def load(
339
+ cls: Type[T], entity_id: str = None, level: int = LEVEL_MAX_DEFAULT
340
+ ) -> Optional[T]:
341
+ """Load an entity from the database.
342
+
343
+ Args:
344
+ id: ID of entity to load
345
+
346
+ Returns:
347
+ Entity if found, None otherwise
348
+ """
349
+ logger.debug(f"Loading entity {entity_id} (level={level})")
350
+ if level == 0:
351
+ return None
352
+
353
+ if not entity_id:
354
+ return None
355
+
356
+ # Use full type name (including namespace if set)
357
+ type_name = cls.get_full_type_name()
358
+
359
+ # Check entity registry first
360
+ db = cls.db()
361
+ existing_entity = db.get_entity(type_name, entity_id)
362
+ if existing_entity is not None:
363
+ logger.debug(f"Found entity {type_name}@{entity_id} in registry")
364
+ return existing_entity
365
+
366
+ logger.debug(f"Loading entity {type_name}@{entity_id} from database")
367
+ data = db.load(type_name, entity_id)
368
+ if not data:
369
+ return None
370
+
371
+ stored_version = data.get("__version__", 1)
372
+ current_version = cls.__version__
373
+
374
+ if stored_version != current_version:
375
+ logger.debug(
376
+ f"Version mismatch for {type_name}@{entity_id}: "
377
+ f"stored={stored_version}, current={current_version}"
378
+ )
379
+ # Apply migration
380
+ data = cls.migrate(data, stored_version, current_version)
381
+ data["__version__"] = current_version
382
+ logger.debug(
383
+ f"Migrated {type_name}@{entity_id} to version {current_version}"
384
+ )
385
+
386
+ # Create instance first
387
+ entity = cls(**data, _loaded=True)
388
+
389
+ # Extract relations
390
+ relations = {}
391
+ if "relations" in data:
392
+ relations_data = data.pop("relations")
393
+ for rel_name, rel_refs in relations_data.items():
394
+ relations[rel_name] = []
395
+ for ref in rel_refs:
396
+ related = (
397
+ Entity.db()
398
+ ._entity_types[ref["_type"]]
399
+ .load(ref["_id"], level=level - 1)
400
+ )
401
+ if related:
402
+ relations[rel_name].append(related)
403
+
404
+ # Set relations after loading
405
+ entity._relations = relations
406
+
407
+ return entity
408
+
409
+ @classmethod
410
+ def find(cls: Type[T], d) -> List[T]:
411
+ D = d
412
+ L = [_.serialize() for _ in cls.instances()]
413
+ return [
414
+ cls.load(d["_id"]) for d in L if all(d.get(k) == v for k, v in D.items())
415
+ ]
416
+
417
+ @classmethod
418
+ def instances(cls: Type[T]) -> List[T]:
419
+ """Get all instances of this entity type, including subclass instances.
420
+
421
+ Uses load_some() for O(max_id) performance instead of scanning all keys.
422
+
423
+ Returns:
424
+ List of entities
425
+ """
426
+ db = Database.get_instance()
427
+ full_type_name = cls.get_full_type_name()
428
+ db.register_entity_type(cls, full_type_name)
429
+
430
+ # Use load_some for O(max_id) instead of O(total_keys)
431
+ max_id = cls.max_id()
432
+ if max_id == 0:
433
+ instances = []
434
+ else:
435
+ instances = cls.load_some(1, max_id)
436
+
437
+ # Also check for subclass instances
438
+ # Track processed classes to avoid duplicates when types are registered under multiple names
439
+ processed_classes = {cls}
440
+ for type_name, type_cls in db._entity_types.items():
441
+ if type_cls in processed_classes:
442
+ continue # Skip already processed classes
443
+ if type_name == full_type_name or type_name == cls.__name__:
444
+ continue # Skip self
445
+ if db.is_subclass(type_name, cls):
446
+ processed_classes.add(type_cls)
447
+ subclass_max_id = type_cls.max_id()
448
+ if subclass_max_id > 0:
449
+ instances.extend(type_cls.load_some(1, subclass_max_id))
450
+
451
+ return instances
452
+
453
+ @classmethod
454
+ def count(cls: Type[T]) -> int:
455
+ """Get the total count of entities of this type.
456
+
457
+ Returns:
458
+ int: Total number of entities
459
+ """
460
+ type_name = cls.get_full_type_name()
461
+ db = cls.db()
462
+ count_key = f"{type_name}_count"
463
+ count = db.load("_system", count_key)
464
+ return int(count) if count else 0
465
+
466
+ @classmethod
467
+ def max_id(cls: Type[T]) -> int:
468
+ """Get the maximum ID assigned to entities of this type.
469
+
470
+ Returns:
471
+ int: Maximum entity ID
472
+ """
473
+ type_name = cls.get_full_type_name()
474
+ db = cls.db()
475
+ max_id_key = f"{type_name}_id"
476
+ max_id = db.load("_system", max_id_key)
477
+ return int(max_id) if max_id else 0
478
+
479
+ @classmethod
480
+ def load_some(
481
+ cls: Type[T],
482
+ from_id: int,
483
+ count: int = 10,
484
+ ) -> List[T]:
485
+ """Load some entities.
486
+
487
+ Args:
488
+ from_id (int): ID of the first entity to load
489
+ count (int): Number of entities to load
490
+
491
+ Returns:
492
+ List[T]: List of entities for the requested page
493
+
494
+ Raises:
495
+ ValueError: If page or page_size is less than 1
496
+ """
497
+ logger.info(f"Loading entities from {from_id} to {from_id + count}")
498
+
499
+ if from_id < 1:
500
+ raise ValueError("from_id must be at least 1")
501
+ if count < 1:
502
+ raise ValueError("count must be at least 1")
503
+
504
+ # Return the slice of entities for the requested page
505
+ ret = []
506
+
507
+ while len(ret) < count and from_id <= cls.max_id():
508
+ logger.info(f"Loading entity {from_id}")
509
+ entity = cls.load(str(from_id))
510
+ if entity:
511
+ ret.append(entity)
512
+ from_id += 1
513
+
514
+ return ret
515
+
516
+ def delete(self) -> None:
517
+ logger.debug(f"Deleting entity {self._type}@{self._id}")
518
+ """Delete this entity from the database."""
519
+ from .constants import ACTION_DELETE
520
+ from .hooks import call_entity_hook
521
+
522
+ # Call hook before deletion
523
+ allow, _ = call_entity_hook(self, None, self, None, ACTION_DELETE)
524
+
525
+ if not allow:
526
+ raise PermissionError("Hook rejected entity deletion")
527
+
528
+ self.db().delete(self._type, self._id)
529
+
530
+ # Remove from entity registry
531
+ self.db().unregister_entity(self._type, self._id)
532
+
533
+ # Decrement the count when an entity is deleted
534
+ type_name = self.__class__.get_full_type_name()
535
+ count_key = f"{type_name}_count"
536
+ current_count = int(self.db().load("_system", count_key) or 0)
537
+ if current_count > 0:
538
+ self.db().save("_system", count_key, str(current_count - 1))
539
+ else:
540
+ raise ValueError(
541
+ f"Entity count for {type_name} is already zero; cannot decrement further."
542
+ )
543
+
544
+ # Remove from alias mappings when deleted
545
+ if hasattr(self.__class__, "__alias__") and self.__class__.__alias__:
546
+ alias_field = self.__class__.__alias__
547
+ if hasattr(self, alias_field):
548
+ alias_value = getattr(self, alias_field)
549
+ if alias_value is not None:
550
+ self.db().delete(self._alias_key(), alias_value)
551
+
552
+ logger.debug(f"Deleted entity {self._type}@{self._id}")
553
+
554
+ # Remove from context
555
+ self.__class__._context.discard(self)
556
+
557
+ def serialize(self) -> Dict[str, Any]:
558
+ """Convert the entity to a serializable dictionary.
559
+
560
+ Returns:
561
+ Dict containing the entity's serializable data
562
+ """
563
+ # Get mixin data first if available
564
+ data = super().serialize() if hasattr(super(), "serialize") else {}
565
+
566
+ # Add core entity data
567
+ data.update(
568
+ {
569
+ "_type": self._type, # Use the entity type
570
+ "_id": self._id,
571
+ }
572
+ )
573
+
574
+ # Add all property descriptors from class hierarchy
575
+ from ic_python_db.properties import Property
576
+
577
+ for cls in reversed(self.__class__.__mro__):
578
+ for k, v in cls.__dict__.items():
579
+ if not k.startswith("_") and isinstance(v, Property):
580
+ data[k] = getattr(self, k)
581
+
582
+ # Add instance attributes
583
+ for k, v in self.__dict__.items():
584
+ if not k.startswith("_"):
585
+ data[k] = v
586
+
587
+ # Add relations as references (prefer alias over _id if available)
588
+ def get_entity_reference(entity):
589
+ """Get the best reference for an entity: alias value if available, otherwise _id."""
590
+ if hasattr(entity.__class__, "__alias__") and entity.__class__.__alias__:
591
+ alias_field = entity.__class__.__alias__
592
+ alias_value = getattr(entity, alias_field, None)
593
+ if alias_value is not None:
594
+ return alias_value
595
+ return entity._id
596
+
597
+ for rel_name, rel_entities in self._relations.items():
598
+ if rel_entities:
599
+ # Check if this is a *ToMany relation that should always be a list
600
+ rel_prop = getattr(self.__class__, rel_name, None)
601
+ from ic_python_db.properties import ManyToMany, OneToMany
602
+
603
+ is_to_many = isinstance(rel_prop, (OneToMany, ManyToMany))
604
+
605
+ if len(rel_entities) == 1 and not is_to_many:
606
+ # Single relation for OneToOne/ManyToOne - store as single reference
607
+ data[rel_name] = get_entity_reference(rel_entities[0])
608
+ else:
609
+ # Multiple relations or *ToMany relations - store as list of references
610
+ data[rel_name] = [get_entity_reference(e) for e in rel_entities]
611
+
612
+ return data
613
+
614
+ @classmethod
615
+ def deserialize(cls, data: dict, level: int = 1):
616
+ """Deserialize entity from dictionary data with upsert functionality.
617
+
618
+ This method will:
619
+ - Update an existing entity if found by _id or alias
620
+ - Create a new entity if not found
621
+ - Handle proper ID generation and counting for new entities
622
+ - Update alias mappings when alias fields change
623
+
624
+ Args:
625
+ data: Dictionary containing serialized entity data
626
+ level: Relationship loading depth for the upsert existence check.
627
+ Defaults to 1 (no deep relationship loading) since deserialize
628
+ resolves relationships separately. Use higher values only if
629
+ you need the returned entity to have pre-loaded relationships.
630
+
631
+ Returns:
632
+ Entity instance (either updated existing or newly created)
633
+
634
+ Raises:
635
+ ValueError: If data is invalid or entity type not found
636
+ """
637
+ if not isinstance(data, dict):
638
+ raise ValueError("Data must be a dictionary")
639
+
640
+ # Validate entity type
641
+ if "_type" not in data:
642
+ raise ValueError("Serialized data must contain '_type' field")
643
+
644
+ entity_type = data["_type"]
645
+
646
+ # If called on base Entity class, look up the specific entity class
647
+ if cls.__name__ == "Entity":
648
+ db = cls.db()
649
+ target_class = db._entity_types.get(entity_type)
650
+ # If not found and entity_type has namespace, try without namespace
651
+ if not target_class:
652
+ class_name = db._extract_class_name(entity_type)
653
+ target_class = db._entity_types.get(class_name)
654
+ if not target_class:
655
+ raise ValueError(f"Unknown entity type: {entity_type}")
656
+ # Delegate to the specific entity class
657
+ return target_class.deserialize(data, level=level)
658
+
659
+ # If called on specific entity class, validate type matches (check both full type name and class name)
660
+ full_type_name = cls.get_full_type_name()
661
+ if entity_type != full_type_name and entity_type != cls.__name__:
662
+ raise ValueError(
663
+ f"Entity type mismatch: expected {full_type_name} or {cls.__name__}, got {entity_type}"
664
+ )
665
+
666
+ stored_version = data.get("__version__", 1)
667
+ current_version = cls.__version__
668
+
669
+ if stored_version != current_version:
670
+ logger.debug(
671
+ f"Version mismatch during deserialize for {entity_type}: "
672
+ f"stored={stored_version}, current={current_version}"
673
+ )
674
+ data = cls.migrate(data, stored_version, current_version)
675
+ data["__version__"] = current_version
676
+ logger.debug(f"Migrated {entity_type} to version {current_version}")
677
+
678
+ # Try to find existing entity
679
+ existing_entity = None
680
+
681
+ # First try by _id if provided
682
+ entity_id = data.get("_id")
683
+ if entity_id:
684
+ existing_entity = cls.load(str(entity_id), level=level)
685
+
686
+ # If not found by ID and class has alias, try by alias
687
+ if not existing_entity and hasattr(cls, "__alias__") and cls.__alias__:
688
+ alias_field = cls.__alias__
689
+ if alias_field in data:
690
+ alias_value = data[alias_field]
691
+ if alias_value is not None:
692
+ # Use the same lookup logic as __class_getitem__
693
+ alias_key = cls._alias_key()
694
+ actual_id = cls.db().load(alias_key, str(alias_value))
695
+ if actual_id:
696
+ existing_entity = cls.load(actual_id, level=level)
697
+
698
+ if existing_entity:
699
+ # UPDATE existing entity
700
+ from ic_python_db.properties import Property, Relation
701
+
702
+ # Store old alias value for cleanup if it changes
703
+ old_alias_value = None
704
+ if hasattr(cls, "__alias__") and cls.__alias__:
705
+ alias_field = cls.__alias__
706
+ if hasattr(existing_entity, alias_field):
707
+ old_alias_value = getattr(existing_entity, alias_field)
708
+
709
+ # Update properties (merge mode - only update provided fields)
710
+ existing_entity._do_not_save = True
711
+ for key, value in data.items():
712
+ if key.startswith("_"):
713
+ continue # Skip internal fields
714
+
715
+ # Check if this is a relation property
716
+ prop = getattr(cls, key, None)
717
+ if isinstance(prop, Relation):
718
+ continue # Skip relations for now, handle them after
719
+
720
+ # Update the property
721
+ setattr(existing_entity, key, value)
722
+
723
+ # Handle alias update if alias field changed
724
+ if hasattr(cls, "__alias__") and cls.__alias__:
725
+ alias_field = cls.__alias__
726
+ if alias_field in data:
727
+ new_alias_value = data[alias_field]
728
+ if old_alias_value != new_alias_value:
729
+ # Remove old alias mapping
730
+ if old_alias_value is not None:
731
+ cls.db().delete(cls._alias_key(), str(old_alias_value))
732
+ # New alias mapping will be created when entity is saved
733
+
734
+ existing_entity._do_not_save = False
735
+
736
+ # Attempt to resolve relations (silently skip if related entity doesn't exist)
737
+ for key, value in data.items():
738
+ if key.startswith("_"):
739
+ continue
740
+
741
+ prop = getattr(cls, key, None)
742
+ if isinstance(prop, Relation) and value is not None:
743
+ try:
744
+ # Set relation - the descriptor will resolve IDs to entities
745
+ setattr(existing_entity, key, value)
746
+ except ValueError:
747
+ # Skip if related entity doesn't exist
748
+ pass
749
+
750
+ # Save to persist changes and update alias mappings
751
+ existing_entity._save()
752
+ return existing_entity
753
+
754
+ else:
755
+ # CREATE new entity
756
+ from ic_python_db.properties import Property, Relation
757
+
758
+ # Prepare kwargs for entity creation (exclude relations)
759
+ kwargs = {}
760
+
761
+ # Include _id if provided (for proper deserialization)
762
+ if entity_id:
763
+ kwargs["_id"] = entity_id
764
+
765
+ # Add properties and instance attributes (excluding relations and other internal fields)
766
+ for key, value in data.items():
767
+ if key.startswith("_") and key != "_id":
768
+ continue # Skip internal fields except _id
769
+
770
+ # Check if this is a relation property
771
+ prop = getattr(cls, key, None)
772
+ if isinstance(prop, Relation):
773
+ continue # Skip relations for now, handle them after entity creation
774
+
775
+ kwargs[key] = value
776
+
777
+ # Create the entity instance
778
+ entity = cls(**kwargs)
779
+
780
+ # Attempt to resolve relations (silently skip if related entity doesn't exist)
781
+ for key, value in data.items():
782
+ if key.startswith("_"):
783
+ continue
784
+
785
+ prop = getattr(cls, key, None)
786
+ if isinstance(prop, Relation) and value is not None:
787
+ try:
788
+ # Set relation - the descriptor will resolve IDs to entities
789
+ setattr(entity, key, value)
790
+ except ValueError:
791
+ # Skip if related entity doesn't exist
792
+ pass
793
+
794
+ return entity
795
+
796
+ @classmethod
797
+ def __class_getitem__(cls: Type[T], key: Any) -> Optional[T]:
798
+ """Allow using class[id] syntax to load entities.
799
+
800
+ Args:
801
+ key: ID of entity to load, value of aliased field, or tuple of (field_name, value)
802
+ for specific field lookup.
803
+
804
+ Returns:
805
+ Entity if found, None otherwise
806
+
807
+ Examples:
808
+ Entity[1] # Lookup by ID
809
+ Entity["john"] # Lookup by ID, then by __alias__ field
810
+ Entity["name", "john"] # Lookup by specific field "name" only
811
+ """
812
+ logger.debug(f"Loading entity with key {key}")
813
+
814
+ # Handle tuple for specific field lookup: Entity["field_name", "value"]
815
+ if isinstance(key, tuple) and len(key) == 2:
816
+ field_name, value = key
817
+ if not isinstance(field_name, str) or not field_name:
818
+ raise TypeError(
819
+ f"Field name must be a non-empty string, got {type(field_name).__name__}"
820
+ )
821
+ alias_key = cls._alias_key(field_name)
822
+ logger.debug(f"Specific field lookup: alias_key={alias_key}, value={value}")
823
+ actual_id = cls.db().load(alias_key, str(value))
824
+ if actual_id:
825
+ return cls.load(actual_id)
826
+ return None
827
+
828
+ # First try as direct ID lookup (convert to string if numeric)
829
+ str_key = str(key) if isinstance(key, (int, float)) else key
830
+ entity = cls.load(str_key)
831
+ if entity:
832
+ return entity
833
+
834
+ logger.debug(f"Entity not found by ID {str_key}")
835
+
836
+ # If entity not found by ID and class has __alias__ defined, try by alias
837
+ if hasattr(cls, "__alias__") and cls.__alias__:
838
+ alias_key = cls._alias_key()
839
+ logger.debug(
840
+ f"Trying to find entity by alias key {alias_key} with value {str_key}"
841
+ )
842
+ actual_key = cls.db().load(alias_key, str_key)
843
+ if actual_key:
844
+ logger.debug(
845
+ f"Found entity by alias key {alias_key} with value {str_key}"
846
+ )
847
+ return cls.load(actual_key)
848
+ else:
849
+ logger.debug(
850
+ f"Entity not found by alias key {alias_key} with value {str_key}"
851
+ )
852
+
853
+ return None
854
+
855
+ def __eq__(self, other: object) -> bool:
856
+ """Compare entities based on type and ID.
857
+
858
+ Args:
859
+ other: Object to compare with
860
+
861
+ Returns:
862
+ True if entities are equal, False otherwise
863
+ """
864
+ if not isinstance(other, Entity):
865
+ return NotImplemented
866
+ return self._type == other._type and self._id == other._id
867
+
868
+ def __hash__(self) -> int:
869
+ """Hash entity based on type and ID.
870
+
871
+ Returns:
872
+ Hash value
873
+ """
874
+ return hash((self._type, self._id))
875
+
876
+ def add_relation(self, from_rel: str, to_rel: str, other: "Entity") -> None:
877
+ """Add a bidirectional relationship with another entity.
878
+
879
+ Args:
880
+ from_rel: Name of relation from this entity to other
881
+ to_rel: Name of relation from other entity to this
882
+ other: Entity to create relationship with
883
+ """
884
+ # Add forward relation
885
+ if from_rel not in self._relations:
886
+ self._relations[from_rel] = []
887
+ if other not in self._relations[from_rel]:
888
+ self._relations[from_rel].append(other)
889
+
890
+ # Add reverse relation
891
+ if to_rel not in other._relations:
892
+ other._relations[to_rel] = []
893
+ if self not in other._relations[to_rel]:
894
+ other._relations[to_rel].append(self)
895
+
896
+ # Save both entities
897
+ self._save()
898
+ other._save()
899
+
900
+ def get_relations(
901
+ self, relation_name: str, entity_type: str = None
902
+ ) -> List["Entity"]:
903
+ """Get all related entities for a relation, optionally filtered by type.
904
+
905
+ Args:
906
+ relation_name: Name of the relation to follow
907
+ entity_type: Optional type name to filter entities by
908
+
909
+ Returns:
910
+ List of related entities
911
+ """
912
+ if relation_name not in self._relations:
913
+ return []
914
+
915
+ entities = self._relations[relation_name]
916
+ if entity_type:
917
+ entities = [e for e in entities if e._type == entity_type]
918
+
919
+ return entities
920
+
921
+ def remove_relation(self, from_rel: str, to_rel: str, other: "Entity") -> None:
922
+ """Remove a bidirectional relationship with another entity.
923
+
924
+ Args:
925
+ from_rel: Name of relation from this entity to other
926
+ to_rel: Name of relation from other entity to this
927
+ other: Entity to remove relationship with
928
+ """
929
+ # Remove forward relation
930
+ if from_rel in self._relations:
931
+ if other in self._relations[from_rel]:
932
+ self._relations[from_rel].remove(other)
933
+
934
+ # Remove reverse relation
935
+ if to_rel in other._relations:
936
+ if self in other._relations[to_rel]:
937
+ other._relations[to_rel].remove(self)
938
+
939
+ # Save both entities
940
+ self._save()
941
+ other._save()