Skip to content

Commit

Permalink
Simply and re-organize the process of making field descriptors sealable.
Browse files Browse the repository at this point in the history
This avoids the usage of metaclasses and remove a ton of boilerplate.
  • Loading branch information
charettes committed Jan 14, 2018
1 parent 6b2ab88 commit be0686e
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 132 deletions.
110 changes: 48 additions & 62 deletions seal/related.py → seal/descriptors.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from __future__ import unicode_literals

from django.contrib.contenttypes.fields import GenericForeignKey
from django.db.models.fields import DeferredAttribute
from django.db.models.fields.related import (
ForwardManyToOneDescriptor, ForwardOneToOneDescriptor,
ManyToManyDescriptor, ReverseManyToOneDescriptor,
Expand All @@ -24,25 +28,15 @@ def get_queryset(self):
return SealableRelatedManager


class SealableReverseManyToOneDescriptor(ReverseManyToOneDescriptor):
@cached_property
def related_manager_cls(self):
related_manager_cls = super(SealableReverseManyToOneDescriptor, self).related_manager_cls
return create_sealable_related_manager(related_manager_cls, self.rel.name)


class SealableForwardManyToOneDescriptor(ForwardManyToOneDescriptor):
def get_object(self, instance):
if getattr(instance._state, 'sealed', False):
raise SealedObject('Cannot fetch related field %s on a sealed object.' % self.field.name)
return super(SealableForwardManyToOneDescriptor, self).get_object(instance)


class SealableReverseOneToOneDescriptor(ReverseOneToOneDescriptor):
def get_queryset(self, instance, **hints):
if getattr(instance._state, 'sealed', False):
raise SealedObject('Cannot fetch related field %s on a sealed object.' % self.related.name)
return super(SealableReverseOneToOneDescriptor, self).get_queryset(instance=instance, **hints)
class SealableDeferredAttribute(DeferredAttribute):
def __get__(self, instance, cls=None):
if instance is None:
return self
if (getattr(instance._state, 'sealed', False) and
instance.__dict__.get(self.field_name, self) is self and
self._check_parent_chain(instance, self.field_name) is None):
raise SealedObject('Cannot fetch deferred field %s on a sealed object.' % self.field_name)
return super(SealableDeferredAttribute, self).__get__(instance, cls)


class SealableForwardOneToOneDescriptor(ForwardOneToOneDescriptor):
Expand All @@ -64,60 +58,52 @@ def get_object(self, instance):
return super(SealableForwardOneToOneDescriptor, self).get_object(instance)


class SealableManyToManyDescriptor(ManyToManyDescriptor):
@cached_property
def related_manager_cls(self):
related_manager_cls = super(SealableManyToManyDescriptor, self).related_manager_cls
field_name = self.rel.name if self.reverse else self.field.name
return create_sealable_related_manager(related_manager_cls, field_name)


def create_sealable_m2m_contribute_to_class(m2m):
contribute_to_class = m2m.contribute_to_class

def sealable_contribute_to_class(cls, *args, **kwargs):
contribute_to_class(cls, *args, **kwargs)
setattr(cls, m2m.name, SealableManyToManyDescriptor(m2m.remote_field, reverse=False))
return sealable_contribute_to_class


def create_sealable_m2m_contribute_to_related_class(m2m):
contribute_to_related_class = m2m.contribute_to_related_class
class SealableReverseOneToOneDescriptor(ReverseOneToOneDescriptor):
def get_queryset(self, instance, **hints):
if getattr(instance._state, 'sealed', False):
raise SealedObject('Cannot fetch related field %s on a sealed object.' % self.related.name)
return super(SealableReverseOneToOneDescriptor, self).get_queryset(instance=instance, **hints)

def sealable_contribute_to_related_class(cls, related, *args, **kwargs):
contribute_to_related_class(cls, related, *args, **kwargs)
if not m2m.remote_field.is_hidden() and not related.related_model._meta.swapped:
setattr(cls, related.get_accessor_name(), SealableManyToManyDescriptor(m2m.remote_field, reverse=True))
return sealable_contribute_to_related_class

class SealableForwardManyToOneDescriptor(ForwardManyToOneDescriptor):
def get_object(self, instance):
if getattr(instance._state, 'sealed', False):
raise SealedObject('Cannot fetch related field %s on a sealed object.' % self.field.name)
return super(SealableForwardManyToOneDescriptor, self).get_object(instance)

sealable_accessor_classes = {
ReverseManyToOneDescriptor: SealableReverseManyToOneDescriptor,
ForwardManyToOneDescriptor: SealableForwardManyToOneDescriptor,
ReverseOneToOneDescriptor: SealableReverseOneToOneDescriptor,
ForwardOneToOneDescriptor: SealableForwardOneToOneDescriptor,
}

class SealableReverseManyToOneDescriptor(ReverseManyToOneDescriptor):
@cached_property
def related_manager_cls(self):
related_manager_cls = super(SealableReverseManyToOneDescriptor, self).related_manager_cls
return create_sealable_related_manager(related_manager_cls, self.rel.name)

class SealableGenericForeignKeyProxy(object):
def __init__(self, field):
self.__field = field

def __getattr__(self, name):
return getattr(self.__field, name)
class SealableManyToManyDescriptor(ManyToManyDescriptor):
@cached_property
def related_manager_cls(self):
related_manager_cls = super(SealableManyToManyDescriptor, self).related_manager_cls
field_name = self.rel.name if self.reverse else self.field.name
return create_sealable_related_manager(related_manager_cls, field_name)

def contribute_to_class(self, cls, name, **kwargs):
self.__field.contribute_to_class(cls, name, **kwargs)
setattr(cls, name, self)

class SealableGenericForeignKey(GenericForeignKey):
def __get__(self, instance, cls=None):
if instance is None:
return self

if getattr(instance._state, 'sealed', False) and not self.__field.is_cached(instance):
raise SealedObject('Cannot fetch related field %s on a sealed object.' % self.__field.name)
if getattr(instance._state, 'sealed', False) and not self.is_cached(instance):
raise SealedObject('Cannot fetch related field %s on a sealed object.' % self.name)

return self.__field.__get__(instance, cls)
return super(SealableGenericForeignKey, self).__get__(instance, cls=cls)

def __set__(self, instance, value):
return self.__field.__set__(instance, value)

sealable_descriptor_classes = {
DeferredAttribute: SealableDeferredAttribute,
ForwardOneToOneDescriptor: SealableForwardOneToOneDescriptor,
ReverseOneToOneDescriptor: SealableReverseOneToOneDescriptor,
ForwardManyToOneDescriptor: SealableForwardManyToOneDescriptor,
ReverseManyToOneDescriptor: SealableReverseManyToOneDescriptor,
ManyToManyDescriptor: SealableManyToManyDescriptor,
GenericForeignKey: SealableGenericForeignKey,
}
125 changes: 61 additions & 64 deletions seal/models.py
Original file line number Diff line number Diff line change
@@ -1,76 +1,73 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from __future__ import unicode_literals

from django.db import models
from django.utils.six import with_metaclass
from django.db.models.fields.related import lazy_related_operation
from django.dispatch import receiver

from .exceptions import SealedObject
from .descriptors import sealable_descriptor_classes
from .managers import SealableQuerySet
from .related import (
SealableGenericForeignKeyProxy, create_sealable_m2m_contribute_to_class,
create_sealable_m2m_contribute_to_related_class, sealable_accessor_classes,
)


class SealaleModelBase(models.base.ModelBase):
def __new__(cls, name, bases, attrs):
meta = attrs.get('Meta')
abstract = meta and getattr(meta, 'abstract', False)
proxy = meta and getattr(meta, 'proxy', False)
if not (abstract or proxy):
# Turn implicit one-to-one parent link into explicit ones in order
# to replace their related accessors below.
concrete_bases = (
base for base in bases
if isinstance(base, models.base.ModelBase) and not base._meta.abstract
)
parent_links = {
models.utils.make_model_tuple(value.remote_field.model) for value in attrs.values()
if isinstance(value, models.OneToOneField) and value.remote_field.parent_link
}
for base in concrete_bases:
if models.utils.make_model_tuple(base) in parent_links:
continue
parent_link_name = '%s_ptr' % base._meta.model_name
attrs[parent_link_name] = models.OneToOneField(
base,
on_delete=models.CASCADE,
name=parent_link_name,
auto_created=True,
parent_link=True,
)
for attr, value in attrs.items():
if isinstance(value, models.ForeignObject):
sealable_accessor_class = sealable_accessor_classes.get(value.related_accessor_class)
if sealable_accessor_class:
value.related_accessor_class = sealable_accessor_class
sealable_forward_accessor_class = sealable_accessor_classes.get(value.forward_related_accessor_class)
if sealable_forward_accessor_class:
value.forward_related_accessor_class = sealable_forward_accessor_class
elif isinstance(value, models.ManyToManyField):
# ManyToManyField doesn't declare a class level attribute for
# its forward and reverse accessor class. We must provide
# override contribute_to_class/contribute_to_related_class to
# work around it.
value.contribute_to_class = create_sealable_m2m_contribute_to_class(value)
value.contribute_to_related_class = create_sealable_m2m_contribute_to_related_class(value)
elif isinstance(value, GenericForeignKey):
attrs[attr] = SealableGenericForeignKeyProxy(value)
return super(SealaleModelBase, cls).__new__(cls, name, bases, attrs)

class SealableModel(models.Model):
"""
Abstract model class that turns deferred and related fields accesses that
would incur a database query into exceptions once sealed.
"""

class SealableModel(with_metaclass(SealaleModelBase, models.Model)):
objects = SealableQuerySet.as_manager()

class Meta:
abstract = True

def seal(self):
"""
Seal the instance to turn deferred and related fields access that would
required fetching from the database into exceptions.
"""
self._state.sealed = True

class Meta:
abstract = True

def refresh_from_db(self, using=None, fields=None):
sealed = getattr(self._state, 'sealed', False)
if sealed and fields is not None:
fields = set(fields)
deferred_fields = self.get_deferred_fields()
if fields.intersection(deferred_fields):
raise SealedObject('Cannot fetch deferred fields %s on a sealed object.' % ','.join(sorted(fields)))
super(SealableModel, self).refresh_from_db(using=using, fields=fields)
def make_descriptor_sealable(model, attname):
"""
Make a descriptor sealable if a sealable class is defined.
"""
try:
descriptor = getattr(model, attname)
except AttributeError:
# Handle hidden reverse accessor case. e.g. related_name='+'
return
sealable_descriptor_class = sealable_descriptor_classes.get(descriptor.__class__)
if sealable_descriptor_class:
descriptor.__class__ = sealable_descriptor_class


def make_remote_field_descriptor_sealable(model, related_model, remote_field):
"""
Make a remote field descriptor sealable if a sealable class is defined.
"""
if not issubclass(related_model, SealableModel):
return
accessor_name = remote_field.get_accessor_name()
make_descriptor_sealable(related_model, accessor_name)


@receiver(models.signals.class_prepared)
def make_field_descriptors_sealable(sender, **kwargs):
"""
Replace SealableModel subclasses forward and reverse fields descriptors
by sealable ones.
"""
if not issubclass(sender, SealableModel):
return
opts = sender._meta
if opts.abstract or opts.proxy:
return
for field in (opts.local_fields + opts.local_many_to_many + opts.private_fields):
make_descriptor_sealable(sender, field.name)
remote_field = field.remote_field
if remote_field:
# Use lazy_related_operation because lazy relationships might not
# be resolved yet.
lazy_related_operation(
make_remote_field_descriptor_sealable, sender, remote_field.model, remote_field=remote_field
)
4 changes: 0 additions & 4 deletions tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,6 @@ class GreatSeaLion(SeaLion):
pass


class GreatSeaLionExplicitParentLink(SeaLion):
sealion_ptr = models.OneToOneField('tests.SeaLion', models.CASCADE, parent_link=True, primary_key=True)


class Koala(models.Model):
height = models.PositiveIntegerField()
weight = models.PositiveIntegerField()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_state_sealed_assigned(self):

def test_sealed_deferred_field(self):
instance = SeaLion.objects.seal().defer('weight').get()
with self.assertRaisesMessage(SealedObject, 'Cannot fetch deferred fields weight on a sealed object.'):
with self.assertRaisesMessage(SealedObject, 'Cannot fetch deferred field weight on a sealed object.'):
instance.weight

def test_not_sealed_deferred_field(self):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class SealableModelTests(SimpleTestCase):
def test_sealed_instance_deferred_attribute_access(self):
instance = SeaLion.from_db('default', ['id'], [1])
instance.seal()
message = "Cannot fetch deferred fields weight on a sealed object."
message = "Cannot fetch deferred field weight on a sealed object."
with self.assertRaisesMessage(SealedObject, message):
instance.weight

Expand Down

0 comments on commit be0686e

Please sign in to comment.