diff --git a/src/protean/core/entity.py b/src/protean/core/entity.py index 6457a989..5af8640d 100644 --- a/src/protean/core/entity.py +++ b/src/protean/core/entity.py @@ -5,7 +5,7 @@ from protean.core.exceptions import ObjectNotFoundError from protean.core.exceptions import ValidationError from protean.core.field import Auto -from protean.core.field import Field +from protean.core.field import Field, Reference from protean.utils.generic import classproperty from protean.utils.query import Q @@ -47,6 +47,9 @@ def __new__(mcs, name, bases, attrs, **kwargs): # Load declared fields from Base class, in case this Entity is subclassing another new_class._load_base_class_fields(bases, attrs) + # Set up Relation Fields + new_class._set_up_reference_fields() + # Lookup an already defined ID field or create an `Auto` field new_class._set_id_field() @@ -71,12 +74,26 @@ def _load_base_class_fields(new_class, bases, attrs): new_class._load_fields(base_class_fields) def _load_fields(new_class, attrs): - """Load field items into Metaclass""" + """Load field items into Class. + + This method sets up the primary attribute of an association. + If Child class has defined an attribute so `parent = field.Reference(Parent)`, then `parent` + is set up in this method, while `parent_id` is set up in `_set_up_reference_fields()`. + """ for attr_name, attr_obj in attrs.items(): - if isinstance(attr_obj, Field): + if isinstance(attr_obj, (Field, Reference)): setattr(new_class, attr_name, attr_obj) new_class._meta.declared_fields[attr_name] = attr_obj + def _set_up_reference_fields(new_class): + """Walk through relation fields and setup shadow attributes""" + if new_class._meta.declared_fields: + for _, field in new_class._meta.declared_fields.items(): + if isinstance(field, Reference): + shadow_field_name, shadow_field = field.get_shadow_field() + setattr(new_class, shadow_field_name, shadow_field) + shadow_field.__set_name__(new_class, shadow_field_name) + def _set_id_field(new_class): """Lookup the id field for this entity and assign""" # FIXME What does it mean when there are no declared fields? @@ -356,6 +373,25 @@ def has_prev(self): return self.all().has_prev +class EntityStateFieldsCacheDescriptor: + def __get__(self, instance, cls=None): + if instance is None: + return self + res = instance.fields_cache = {} + return res + + +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 + fields_cache = EntityStateFieldsCacheDescriptor() + + class Entity(metaclass=EntityBase): """The Base class for Protean-Compliant Domain Entities. @@ -398,6 +434,9 @@ def __init__(self, *template, **kwargs): self.errors = {} + # Set up the storage for instance state + self._state = EntityState() + # Load the attributes based on the template loaded_fields = [] for dictionary in template: @@ -420,7 +459,10 @@ def __init__(self, *template, **kwargs): # for required fields for field_name, field_obj in self._meta.declared_fields.items(): if field_name not in loaded_fields: - setattr(self, field_name, None) + # Check that the field is not set already, which would happen if we are + # dealing with reference fields + if getattr(self, field_name, None) is None: + setattr(self, field_name, None) # Raise any errors found during load if self.errors: diff --git a/src/protean/core/exceptions.py b/src/protean/core/exceptions.py index e38f4ff9..0f5ccd95 100644 --- a/src/protean/core/exceptions.py +++ b/src/protean/core/exceptions.py @@ -11,6 +11,10 @@ class ObjectNotFoundError(Exception): """Object was not found, can raise 404""" +class ValueError(Exception): + """Object of incorrect type, or with invalid state was assigned""" + + class ValidationError(Exception): """Raised when validation fails on a field. Validators and custom fields should raise this exception. diff --git a/src/protean/core/field/__init__.py b/src/protean/core/field/__init__.py index 7aea86ba..35fb5750 100644 --- a/src/protean/core/field/__init__.py +++ b/src/protean/core/field/__init__.py @@ -1,5 +1,6 @@ """Package for defining Field type and its implementations""" +from .association import Reference from .base import Field from .basic import Auto from .basic import Boolean @@ -17,4 +18,4 @@ __all__ = ('Field', 'String', 'Boolean', 'Integer', 'Float', 'List', 'Dict', 'Auto', 'Date', 'DateTime', 'Text', 'StringShort', 'StringMedium', - 'StringLong') + 'StringLong', 'Reference') diff --git a/src/protean/core/field/association.py b/src/protean/core/field/association.py new file mode 100644 index 00000000..f0d5d00e --- /dev/null +++ b/src/protean/core/field/association.py @@ -0,0 +1,113 @@ +from .base import Field +from .mixins import FieldCacheMixin + +from protean.core import exceptions + + +class ReferenceField(Field): + """Shadow Attribute Field to back References""" + + def __init__(self, reference, **kwargs): + """Accept reference field as a an attribute, otherwise is a straightforward field""" + self.reference = reference + super().__init__(**kwargs) + + def __set__(self, instance, value): + """Override `__set__` to update relation field""" + value = self._load(value) + + if value: + instance.__dict__[self.field_name] = value + + # Fetch target object and refresh the reference field value + reference_obj = self.reference.to_cls.find_by( + **{self.reference.linked_attribute: value}) + if reference_obj: + self.reference.value = reference_obj + instance.__dict__[self.reference.field_name] = reference_obj + else: + # Object was not found in the database + raise exceptions.ValueError( + "Target Object not found", + self.reference.field_name) + else: + self._reset_values(instance) + + def __delete__(self, instance): + self._reset_values(instance) + + def _cast_to_type(self, value): + """Verify type of value assigned to the shadow field""" + # FIXME Verify that the value being assigned is compatible with the remote field + return value + + def _reset_values(self, instance): + """Reset all associated values and clean up dictionary items""" + instance.__dict__.pop(self.field_name) + instance.__dict__.pop(self.reference.field_name) + self.reference.value = None + self.value = None + + +class Reference(FieldCacheMixin, Field): + """ + Provide a many-to-one relation by adding a column to the local entity + to hold the remote value. + + By default ForeignKey will target the pk of the remote model but this + behavior can be changed by using the ``via`` argument. + """ + + def __init__(self, to_cls, via=None, **kwargs): + super().__init__(**kwargs) + self.to_cls = to_cls + self.via = via + + # Choose the Linkage attribute between `via` and `id` + self.linked_attribute = self.via or 'id' + + self.relation = ReferenceField(self) + + def get_attribute_name(self): + """Return attribute name suffixed with via if defined, or `_id`""" + return '{}_{}'.format(self.field_name, self.linked_attribute) + + def get_shadow_field(self): + """Return shadow field + Primarily used during Entity initialization to register shadow field""" + return (self.attribute_name, self.relation) + + def __set__(self, instance, value): + """Override `__set__` to coordinate between relation field and its shadow attribute""" + value = self._load(value) + + if value: + # Check if the reference object has been saved. Otherwise, throw ValueError + if value.id is None: # FIXME not a comprehensive check. Should refer to state + raise exceptions.ValueError( + "Target Object must be saved before being referenced", + self.field_name) + else: + self.relation.value = value.id + instance.__dict__[self.field_name] = value + instance.__dict__[self.attribute_name] = getattr(value, self.linked_attribute) + else: + self._reset_values(instance) + + def __delete__(self, instance): + self._reset_values(instance) + + def _reset_values(self, instance): + """Reset all associated values and clean up dictionary items""" + self.value = None + self.relation.value = None + instance.__dict__.pop(self.field_name, None) + instance.__dict__.pop(self.attribute_name, None) + + def _cast_to_type(self, value): + if not isinstance(value, self.to_cls): + self.fail('invalid', value=value) + return value + + def get_cache_name(self): + return self.name diff --git a/src/protean/core/field/base.py b/src/protean/core/field/base.py index 9375569d..c91b8bef 100644 --- a/src/protean/core/field/base.py +++ b/src/protean/core/field/base.py @@ -77,7 +77,6 @@ def __init__(self, identifier: bool = False, default: Any = None, self._value = value # These are set up when the owner (Entity class) adds the field to itself - self.name = None self.field_name = None self.attribute_name = None @@ -89,7 +88,6 @@ def __init__(self, identifier: bool = False, default: Any = None, self.error_messages = messages def __set_name__(self, entity_cls, name): - self.name = name + "__raw" self.field_name = name self.attribute_name = self.get_attribute_name() @@ -98,14 +96,14 @@ def __set_name__(self, entity_cls, name): self.label = self.field_name.replace('_', ' ').capitalize() def __get__(self, instance, owner): - return getattr(instance, self.name, self.value) + return instance.__dict__.get(self.field_name, self.value) def __set__(self, instance, value): value = self._load(value) - setattr(instance, self.name, value) + instance.__dict__[self.field_name] = value def __delete__(self, instance): - raise AttributeError("Can't delete attribute") + instance.__dict__.pop(self.field_name, None) @property def value(self): @@ -113,7 +111,7 @@ def value(self): @value.setter def value(self, value): - self._value = value if value else self.type() + self._value = value if value else None def get_attribute_name(self): """Return Attribute name for the attribute. diff --git a/src/protean/core/field/mixins.py b/src/protean/core/field/mixins.py new file mode 100644 index 00000000..c6dbc4ec --- /dev/null +++ b/src/protean/core/field/mixins.py @@ -0,0 +1,26 @@ +NOT_PROVIDED = object() + + +class FieldCacheMixin: + """Provide an API for working with the model's fields value cache.""" + + def get_cache_name(self): + raise NotImplementedError + + def get_cached_value(self, instance, default=NOT_PROVIDED): + cache_name = self.get_cache_name() + try: + return instance._state.fields_cache[cache_name] + except KeyError: + if default is NOT_PROVIDED: + raise + return default + + def is_cached(self, instance): + return self.get_cache_name() in instance._state.fields_cache + + def set_cached_value(self, instance, value): + instance._state.fields_cache[self.get_cache_name()] = value + + def delete_cached_value(self, instance): + del instance._state.fields_cache[self.get_cache_name()] diff --git a/tests/conftest.py b/tests/conftest.py index 53a70d98..14fbb80e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,11 +10,18 @@ def run_around_tests(): """Initialize DogModel with Dict Repo""" from protean.core.repository import repo_factory - from tests.support.dog import DogModel + from tests.support.dog import DogModel, RelatedDogModel, DogRelatedByEmailModel + from tests.support.human import HumanModel repo_factory.register(DogModel) + repo_factory.register(RelatedDogModel) + repo_factory.register(DogRelatedByEmailModel) + repo_factory.register(HumanModel) # A test function will be run at this point yield repo_factory.Dog.delete_all() + repo_factory.RelatedDog.delete_all() + repo_factory.DogRelatedByEmail.delete_all() + repo_factory.Human.delete_all() diff --git a/tests/core/test_entity.py b/tests/core/test_entity.py index 112d3225..e29db319 100644 --- a/tests/core/test_entity.py +++ b/tests/core/test_entity.py @@ -1,12 +1,13 @@ """Tests for Entity Functionality and Base Classes""" import pytest -from tests.support.dog import Dog +from tests.support.dog import Dog, RelatedDog, DogRelatedByEmail +from tests.support.human import Human from protean.core import field from protean.core.entity import Entity, QuerySet from protean.core.exceptions import ObjectNotFoundError -from protean.core.exceptions import ValidationError +from protean.core.exceptions import ValidationError, ValueError from protean.utils.query import Q @@ -1182,3 +1183,147 @@ def test_empty_resultset(self): q1 = Dog.query.filter(owner='XYZ', age=100) assert q1.total == 0 + + +class TestAssociations: + """Class that holds tests cases for Entity Associations""" + + class TestReference: + """Class to test References (Foreign Key) Association""" + + def test_init(self): + """Test successful RelatedDog initialization""" + human = Human.create(first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10, owner=human) + assert dog.owner == human + + def test_save(self): + """Test successful RelatedDog save""" + human = Human.create(first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10, owner=human) + dog.save() + assert dog.id is not None + + def test_unsaved_entity_init(self): + """Test that initialization fails when an unsaved entity is assigned to a relation""" + with pytest.raises(ValueError): + human = Human(first_name='Jeff', last_name='Kennedy', email='jeff.kennedy@presidents.com') + RelatedDog(id=1, name='John Doe', age=10, owner=human) + + def test_unsaved_entity_assign(self): + """Test that assignment fails when an unsaved entity is assigned to a relation""" + with pytest.raises(ValueError): + human = Human(first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10) + dog.owner = human + + def test_invalid_entity_type(self): + """Test that assignment fails when an invalid entity type is assigned to a relation""" + with pytest.raises(ValidationError): + dog = Dog.create(name='Johnny', owner='John') + related_dog = RelatedDog(id=1, name='John Doe', age=10) + related_dog.owner = dog + + def test_shadow_attribute(self): + """Test identifier backing the association""" + human = Human.create(first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10, owner=human) + assert human.id is not None + assert dog.owner_id == human.id + + def test_save_after_assign(self): + """Test identifier backing the association""" + human = Human.create(id=101, first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10) + dog.owner = human + assert dog.owner_id == human.id + + def test_shadow_attribute_init(self): + """Test identifier backing the association""" + human = Human.create(id=101, first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10, owner_id=human.id) + dog.save() + assert dog.owner_id == human.id + assert dog.owner.id == human.id + + def test_shadow_attribute_assign(self): + """Test identifier backing the association""" + human = Human.create(id=101, first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10) + dog.owner_id = human.id + dog.save() + assert dog.owner_id == human.id + assert dog.owner.id == human.id + + def test_reference_reset_association_to_None(self): + """Test that the reference field and shadow attribute are reset together""" + human = Human.create(id=101, first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10, owner=human) + assert dog.owner_id == human.id + assert dog.owner.id == human.id + + dog.owner = None + assert dog.owner is None + assert dog.owner_id is None + + def test_reference_reset_shadow_field_to_None(self): + """Test that the reference field and shadow attribute are reset together""" + human = Human.create(id=101, first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10, owner=human) + assert dog.owner_id == human.id + assert dog.owner.id == human.id + + dog.owner_id = None + assert dog.owner is None + assert dog.owner_id is None + + def test_reference_reset_association_by_del(self): + """Test that the reference field and shadow attribute are reset together""" + human = Human.create(id=101, first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10, owner=human) + assert dog.owner_id == human.id + assert dog.owner.id == human.id + + del dog.owner + assert dog.owner is None + assert dog.owner_id is None + + def test_reference_reset_shadow_field_by_del(self): + """Test that the reference field and shadow attribute are reset together""" + human = Human.create(id=101, first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = RelatedDog(id=1, name='John Doe', age=10, owner=human) + assert dog.owner_id == human.id + assert dog.owner.id == human.id + + del dog.owner_id + assert dog.owner is None + assert dog.owner_id is None + + def test_via(self): + """Test successful save with an entity linked by via""" + human = Human.create(first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = DogRelatedByEmail.create(id=1, name='John Doe', age=10, owner=human) + assert hasattr(dog, 'owner_email') + assert dog.owner_email == human.email + + def test_via_with_shadow_attribute_assign(self): + """Test successful save with an entity linked by via""" + human = Human.create(first_name='Jeff', last_name='Kennedy', + email='jeff.kennedy@presidents.com') + dog = DogRelatedByEmail(id=1, name='John Doe', age=10) + dog.owner_email = human.email + dog.save() + assert hasattr(dog, 'owner_email') + assert dog.owner_email == human.email diff --git a/tests/support/dog.py b/tests/support/dog.py index c6fb70f9..d5c9bc0a 100644 --- a/tests/support/dog.py +++ b/tests/support/dog.py @@ -1,5 +1,7 @@ """Support Classes for Test Cases""" +from tests.support.human import Human + from protean.core import field from protean.core.entity import Entity from protean.impl.repository.dict_repo import DictModel @@ -19,3 +21,35 @@ class Meta: """ Meta class for model options""" entity = Dog model_name = 'dogs' + + +class RelatedDog(Entity): + """This is a dummy Dog Entity class with an association""" + name = field.String(required=True, unique=True, max_length=50) + age = field.Integer(default=5) + owner = field.Reference(Human) + + +class RelatedDogModel(DictModel): + """ Model for the RelatedDog Entity""" + + class Meta: + """ Meta class for model options""" + entity = RelatedDog + model_name = 'related_dogs' + + +class DogRelatedByEmail(Entity): + """This is a dummy Dog Entity class with an association""" + name = field.String(required=True, unique=True, max_length=50) + age = field.Integer(default=5) + owner = field.Reference(Human, via='email') + + +class DogRelatedByEmailModel(DictModel): + """ Model for the DogRelatedByEmail Entity""" + + class Meta: + """ Meta class for model options""" + entity = DogRelatedByEmail + model_name = 'related_dogs_by_email' diff --git a/tests/support/human.py b/tests/support/human.py new file mode 100644 index 00000000..463d114c --- /dev/null +++ b/tests/support/human.py @@ -0,0 +1,21 @@ +"""Human Support Class for Test Cases""" + +from protean.core import field +from protean.core.entity import Entity +from protean.impl.repository.dict_repo import DictModel + + +class Human(Entity): + """This is a dummy Human Entity class""" + first_name = field.String(required=True, unique=True, max_length=50) + last_name = field.String(required=True, unique=True, max_length=50) + email = field.String(required=True, unique=True, max_length=50) + + +class HumanModel(DictModel): + """ Model for the Human Entity""" + + class Meta: + """ Meta class for model options""" + entity = Human + model_name = 'humans'