Skip to content

Commit

Permalink
Manage Entity State and provide helpful indicators (#85)
Browse files Browse the repository at this point in the history
  • Loading branch information
Subhash Bhushan authored Feb 27, 2019
1 parent a5a8317 commit df0f9bf
Show file tree
Hide file tree
Showing 3 changed files with 217 additions and 37 deletions.
175 changes: 138 additions & 37 deletions src/protean/core/entity.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Entity Functionality and Classes"""
import copy
import logging
from typing import Any, Union

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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 #
################
Expand All @@ -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 #
######################
Expand Down Expand Up @@ -606,40 +666,68 @@ 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.
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.
Expand All @@ -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 """
Expand Down Expand Up @@ -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
Expand All @@ -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
3 changes: 3 additions & 0 deletions src/protean/core/field/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
76 changes: 76 additions & 0 deletions tests/core/test_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down

0 comments on commit df0f9bf

Please sign in to comment.