diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index 5af8640d..09517d67 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -1,4 +1,5 @@ """Entity Functionality and Classes""" +import copy import logging from typing import Any, Union @@ -281,7 +282,9 @@ def all(self): # Convert the returned results to entity and return it entity_items = [] for item in results.items: - entity_items.append(model_cls.to_entity(item)) + entity = model_cls.to_entity(item) + entity._state.mark_retrieved() + entity_items.append(entity) results.items = entity_items # Cache results @@ -384,11 +387,37 @@ def __get__(self, instance, cls=None): class EntityState: """Store entity instance state.""" - # If true, uniqueness validation checks will consider this a new, unsaved - # object. Necessary for correct validation of new instances of objects with - # explicit (non-auto) PKs. This impacts validation only; it has no effect - # on the actual save. - adding = True + def __init__(self): + self._new = True + self._changed = False + self._destroyed = False + + def is_new(self): + return self._new + + def is_persisted(self): + return not self._new + + def is_changed(self): + return self._changed + + def is_destroyed(self): + return self._destroyed + + def mark_saved(self): + self._new = False + self._changed = False + + mark_retrieved = mark_saved # Alias as placeholder so that future change wont affect interface + + def mark_changed(self): + if not (self._new or self._destroyed): + self._changed = True + + def mark_destroyed(self): + self._destroyed = True + self._changed = False + fields_cache = EntityStateFieldsCacheDescriptor() @@ -513,6 +542,13 @@ def _retrieve_model(cls): return (model_cls, adapter) + def clone(self): + """Deepclone the entity, but reset state""" + clone_copy = copy.deepcopy(self) + clone_copy._state = EntityState() + + return clone_copy + ################ # Meta methods # ################ @@ -532,6 +568,30 @@ def id_field(cls): """Pass through method to retrieve Identifier field defined for entity""" return cls._meta.id_field + ################# + # State methods # + ################# + + @property + def is_new(self): + """Pass through method to check if Entity is not persisted""" + return self._state.is_new() + + @property + def is_persisted(self): + """Pass through method to check if Entity is persisted""" + return self._state.is_persisted() + + @property + def is_changed(self): + """Pass through method to check if Entity has changed since last persistence""" + return self._state.is_changed() + + @property + def is_destroyed(self): + """Pass through method to check if Entity has been destroyed""" + return self._state.is_destroyed() + ###################### # Life-cycle methods # ###################### @@ -606,26 +666,33 @@ def create(cls, *args, **kwargs) -> 'Entity': model_cls, adapter = cls._retrieve_model() - # Build the entity from the input arguments - # Raises validation errors, if any, at this point - entity = cls(*args, **kwargs) + try: + # Build the entity from the input arguments + # Raises validation errors, if any, at this point + entity = cls(*args, **kwargs) + + # Do unique checks, create this object and return it + entity._validate_unique() - # Do unique checks, create this object and return it - entity._validate_unique() + # Build the model object and create it + model_obj = adapter._create_object(model_cls.from_entity(entity)) - # Build the model object and create it - model_obj = adapter._create_object(model_cls.from_entity(entity)) + # Update the auto fields of the entity + for field_name, field_obj in entity._meta.declared_fields.items(): + if isinstance(field_obj, Auto): + if isinstance(model_obj, dict): + field_val = model_obj[field_name] + else: + field_val = getattr(model_obj, field_name) + setattr(entity, field_name, field_val) - # Update the auto fields of the entity - for field_name, field_obj in entity._meta.declared_fields.items(): - if isinstance(field_obj, Auto): - if isinstance(model_obj, dict): - field_val = model_obj[field_name] - else: - field_val = getattr(model_obj, field_name) - setattr(entity, field_name, field_val) + # Set Entity status to saved + entity._state.mark_saved() - return entity + return entity + except ValidationError as exc: + # FIXME Log Exception + raise def save(self): """Save a new Entity into repository. @@ -633,13 +700,34 @@ def save(self): Performs unique validations before creating the entity. """ logger.debug( - f'Creating new `{self.__class__.__name__}` object') + f'Saving `{self.__class__.__name__}` object') + + # Fetch Model class and connected-adapter from Repository Factory + model_cls, adapter = self.__class__._retrieve_model() + + try: + # Do unique checks, update the record and return the Entity + self._validate_unique(create=False) - values = {} - for item in self._meta.declared_fields.items(): - values[item[0]] = getattr(self, item[0]) + # Build the model object and create it + model_obj = adapter._create_object(model_cls.from_entity(self)) - return self.__class__.create(**values) + # Update the auto fields of the entity + for field_name, field_obj in self._meta.declared_fields.items(): + if isinstance(field_obj, Auto): + if isinstance(model_obj, dict): + field_val = model_obj[field_name] + else: + field_val = getattr(model_obj, field_name) + setattr(self, field_name, field_val) + + # Set Entity status to saved + self._state.mark_saved() + + return self + except Exception as exc: + # FIXME Log Exception + raise def update(self, *data, **kwargs) -> 'Entity': """Update a Record in the repository. @@ -660,14 +748,21 @@ def update(self, *data, **kwargs) -> 'Entity': # Fetch Model class and connected-adapter from Repository Factory model_cls, adapter = self.__class__._retrieve_model() - # Update entity's data attributes - self._update_data(*data, **kwargs) + try: + # Update entity's data attributes + self._update_data(*data, **kwargs) + + # Do unique checks, update the record and return the Entity + self._validate_unique(create=False) + adapter._update_object(model_cls.from_entity(self)) - # Do unique checks, update the record and return the Entity - self._validate_unique(create=False) - adapter._update_object(model_cls.from_entity(self)) + # Set Entity status to saved + self._state.mark_saved() - return self + return self + except Exception as exc: + # FIXME Log Exception + raise def _validate_unique(self, create=True): """ Validate the unique constraints for the entity """ @@ -701,9 +796,6 @@ def delete(self): Throws ObjectNotFoundError if the object was not found in the repository """ - # FIXME: Return True or False to indicate an object was deleted, - # rather than the count of records deleted - # FIXME: Ensure Adapter throws ObjectNotFoundError # Fetch Model class and connected-adapter from Repository Factory @@ -712,4 +804,13 @@ def delete(self): filters = { self.__class__._meta.id_field.field_name: self.id } - return adapter._delete_objects(**filters) + try: + count_deleted = adapter._delete_objects(**filters) + + # Mark as Destroyed + self._state.mark_destroyed() + + return count_deleted + except Exception as exc: + # FIXME Log Exception + raise diff --git a/src/protean/core/field/base.py b/src/protean/core/field/base.py index c91b8bef..94f1fce7 100644 --- a/src/protean/core/field/base.py +++ b/src/protean/core/field/base.py @@ -102,6 +102,9 @@ def __set__(self, instance, value): value = self._load(value) instance.__dict__[self.field_name] = value + # Mark Entity as Dirty + instance._state.mark_changed() + def __delete__(self, instance): instance.__dict__.pop(self.field_name, None) diff --git a/tests/core/test_entity.py b/tests/core/test_entity.py index e29db319..d435750c 100644 --- a/tests/core/test_entity.py +++ b/tests/core/test_entity.py @@ -496,6 +496,82 @@ def test_filter_returns_q_object(self): query = Dog.query.filter(owner='John') assert isinstance(query, QuerySet) + class TestState: + """Class that holds tests for Entity State Management""" + + def test_default_state(self): + """Test that a default state is available when the entity is instantiated""" + dog = Dog(id=1, name='John Doe', age=10, owner='Jimmy') + assert dog._state is not None + assert dog._state._new + assert dog._state.is_new() + assert dog.is_new + assert not dog.is_persisted + + def test_state_on_retrieved_objects(self): + """Test that retrieved objects are not marked as new""" + dog = Dog.create(name='John Doe', age=10, owner='Jimmy') + dog_dup = Dog.get(dog.id) + + assert not dog_dup.is_new + + def test_persisted_after_save(self): + """Test that the entity is marked as saved after successfull save""" + dog = Dog(id=1, name='John Doe', age=10, owner='Jimmy') + assert dog.is_new + dog.save() + assert dog.is_persisted + + def test_not_persisted_if_save_failed(self): + """Test that the entity still shows as new if save failed""" + dog = Dog(id=1, name='John Doe', age=10, owner='Jimmy') + try: + del dog.name + dog.save() + except ValidationError as exc: + assert dog.is_new + + def test_persisted_after_create(self): + """Test that the entity is marked as saved after successfull create""" + dog = Dog.create(id=1, name='John Doe', age=10, owner='Jimmy') + assert not dog.is_new + + def test_copy_resets_state(self): + """Test that a default state is available when the entity is instantiated""" + dog1 = Dog.create(id=1, name='John Doe', age=10, owner='Jimmy') + dog2 = dog1.clone() + + assert dog2.is_new + + def test_changed(self): + """Test that entity is marked as changed if attributes are updated""" + dog = Dog.create(id=1, name='John Doe', age=10, owner='Jimmy') + assert not dog.is_changed + dog.name = 'Jane Doe' + assert dog.is_changed + + def test_not_changed_if_still_new(self): + """Test that entity is not marked as changed upon attribute change if its still new""" + dog = Dog(id=1, name='John Doe', age=10, owner='Jimmy') + assert not dog.is_changed + dog.name = 'Jane Doe' + assert not dog.is_changed + + def test_not_changed_after_save(self): + """Test that entity is marked as not changed after save""" + dog = Dog.create(id=1, name='John Doe', age=10, owner='Jimmy') + dog.name = 'Jane Doe' + assert dog.is_changed + dog.save() + assert not dog.is_changed + + def test_destroyed(self): + """Test that a entity is marked as destroyed after delete""" + dog = Dog.create(id=1, name='John Doe', age=10, owner='Jimmy') + assert not dog.is_destroyed + dog.delete() + assert dog.is_destroyed + class TestQuerySet: """Class that holds Tests for QuerySet"""