Skip to content

Commit

Permalink
Merge pull request #84 from proteanhq/75-referenced-objects-take-4
Browse files Browse the repository at this point in the history
75 Support for Foreign Key relationships - Reference Class
  • Loading branch information
abhishek-ram authored Feb 26, 2019
2 parents 9100287 + dccf4a7 commit a5a8317
Show file tree
Hide file tree
Showing 10 changed files with 405 additions and 14 deletions.
50 changes: 46 additions & 4 deletions src/protean/core/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand All @@ -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?
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/protean/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/protean/core/field/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,4 +18,4 @@

__all__ = ('Field', 'String', 'Boolean', 'Integer', 'Float', 'List', 'Dict',
'Auto', 'Date', 'DateTime', 'Text', 'StringShort', 'StringMedium',
'StringLong')
'StringLong', 'Reference')
113 changes: 113 additions & 0 deletions src/protean/core/field/association.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 4 additions & 6 deletions src/protean/core/field/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

Expand All @@ -98,22 +96,22 @@ 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):
return self._value

@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.
Expand Down
26 changes: 26 additions & 0 deletions src/protean/core/field/mixins.py
Original file line number Diff line number Diff line change
@@ -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()]
9 changes: 8 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading

0 comments on commit a5a8317

Please sign in to comment.