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/__init__.py +41 -0
- ic_python_db/_cdk.py +14 -0
- ic_python_db/constants.py +6 -0
- ic_python_db/context.py +37 -0
- ic_python_db/db_engine.py +335 -0
- ic_python_db/entity.py +941 -0
- ic_python_db/hooks.py +51 -0
- ic_python_db/mixins.py +65 -0
- ic_python_db/properties.py +639 -0
- ic_python_db/py.typed +0 -0
- ic_python_db/storage.py +69 -0
- ic_python_db/system_time.py +88 -0
- ic_python_db-0.7.1.dist-info/METADATA +356 -0
- ic_python_db-0.7.1.dist-info/RECORD +17 -0
- ic_python_db-0.7.1.dist-info/WHEEL +5 -0
- ic_python_db-0.7.1.dist-info/licenses/LICENSE +21 -0
- ic_python_db-0.7.1.dist-info/top_level.txt +1 -0
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()
|