Merge branch 'efficient_attributes' into attribute_access
authorStephen Burrows <stephen.r.burrows@gmail.com>
Tue, 10 May 2011 13:58:51 +0000 (09:58 -0400)
committerStephen Burrows <stephen.r.burrows@gmail.com>
Tue, 10 May 2011 13:58:51 +0000 (09:58 -0400)
Conflicts:
philo/models/base.py

1  2 
philo/models/base.py

diff --combined philo/models/base.py
@@@ -1,31 -1,25 +1,31 @@@
 +from UserDict import DictMixin
 +
  from django import forms
 -from django.db import models
  from django.contrib.contenttypes.models import ContentType
  from django.contrib.contenttypes import generic
  from django.core.exceptions import ObjectDoesNotExist
  from django.core.validators import RegexValidator
 +from django.db import models
  from django.utils import simplejson as json
  from django.utils.encoding import force_unicode
 +from mptt.models import MPTTModel, MPTTModelBase, MPTTOptions
 +
  from philo.exceptions import AncestorDoesNotExist
  from philo.models.fields import JSONField
 -from philo.utils import ContentTypeRegistryLimiter, ContentTypeSubclassLimiter
  from philo.signals import entity_class_prepared
 +from philo.utils import ContentTypeRegistryLimiter, ContentTypeSubclassLimiter
  from philo.validators import json_validator
 -from UserDict import DictMixin
 -from mptt.models import MPTTModel, MPTTModelBase, MPTTOptions
  
  
  class Tag(models.Model):
 +      """A simple, generic model for tagging."""
 +      #: A CharField (max length 255) which contains the name of the tag.
        name = models.CharField(max_length=255)
 +      #: A CharField (max length 255) which contains the tag's unique slug.
        slug = models.SlugField(max_length=255, unique=True)
        
        def __unicode__(self):
 +              """Returns the value of the :attr:`name` field"""
                return self.name
        
        class Meta:
@@@ -44,12 -38,10 +44,12 @@@ class Titled(models.Model)
                abstract = True
  
  
 +#: An instance of :class:`ContentTypeRegistryLimiter` which is used to track the content types which can be related to by :class:`ForeignKeyValue`\ s and :class:`ManyToManyValue`\ s.
  value_content_type_limiter = ContentTypeRegistryLimiter()
  
  
  def register_value_model(model):
 +      """Registers a model as a valid content type for a :class:`ForeignKeyValue` or :class:`ManyToManyValue` through the :data:`value_content_type_limiter`."""
        value_content_type_limiter.register_class(model)
  
  
@@@ -57,37 -49,21 +57,37 @@@ register_value_model(Tag
  
  
  def unregister_value_model(model):
 +      """Registers a model as a valid content type for a :class:`ForeignKeyValue` or :class:`ManyToManyValue` through the :data:`value_content_type_limiter`."""
        value_content_type_limiter.unregister_class(model)
  
  
  class AttributeValue(models.Model):
 +      """
 +      This is an abstract base class for models that can be used as values for :class:`Attribute`\ s.
 +      
 +      AttributeValue subclasses are expected to supply access to a clean version of their value through an attribute called "value".
 +      
 +      """
 +      
 +      #: :class:`GenericRelation` to :class:`Attribute`
        attribute_set = generic.GenericRelation('Attribute', content_type_field='value_content_type', object_id_field='value_object_id')
        
        def set_value(self, value):
 +              """Given a ``value``, sets the appropriate fields so that it can be correctly stored in the database."""
                raise NotImplementedError
        
        def value_formfields(self, **kwargs):
 -              """Define any formfields that would be used to construct an instance of this value."""
 +              """
 +              Returns any formfields that would be used to construct an instance of this value.
 +              
 +              :returns: A dictionary mapping field names to formfields.
 +              
 +              """
 +              
                raise NotImplementedError
        
        def construct_instance(self, **kwargs):
 -              """Apply cleaned data from the formfields generated by valid_formfields to oneself."""
 +              """Applies cleaned data from the formfields generated by valid_formfields to oneself."""
                raise NotImplementedError
        
        def __unicode__(self):
                abstract = True
  
  
 +#: An instance of :class:`ContentTypeSubclassLimiter` which is used to track the content types which are considered valid value models for an :class:`Attribute`.
  attribute_value_limiter = ContentTypeSubclassLimiter(AttributeValue)
  
  
  class JSONValue(AttributeValue):
 +      """Stores a python object as a json string."""
        value = JSONField(verbose_name='Value (JSON)', help_text='This value must be valid JSON.', default='null', db_index=True)
        
        def __unicode__(self):
  
  
  class ForeignKeyValue(AttributeValue):
 +      """Stores a generic relationship to an instance of any value content type (as defined by the :data:`value_content_type_limiter`)."""
        content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
        object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
        value = generic.GenericForeignKey()
  
  
  class ManyToManyValue(AttributeValue):
 +      """Stores a generic relationship to many instances of any value content type (as defined by the :data:`value_content_type_limiter`)."""
        content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
        values = models.ManyToManyField(ForeignKeyValue, blank=True, null=True)
        
  
  
  class Attribute(models.Model):
 +      """Represents an arbitrary key/value pair on an arbitrary :class:`Model` where the key consists of word characters and the value is a subclass of :class:`AttributeValue`."""
        entity_content_type = models.ForeignKey(ContentType, related_name='attribute_entity_set', verbose_name='Entity type')
        entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID', db_index=True)
 +      
 +      #: :class:`GenericForeignKey` to anything (generally an instance of an Entity subclass).
        entity = generic.GenericForeignKey('entity_content_type', 'entity_object_id')
        
        value_content_type = models.ForeignKey(ContentType, related_name='attribute_value_set', limit_choices_to=attribute_value_limiter, verbose_name='Value type', null=True, blank=True)
        value_object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
 +      
 +      #: :class:`GenericForeignKey` to an instance of a subclass of :class:`AttributeValue` as determined by the :data:`attribute_value_limiter`.
        value = generic.GenericForeignKey('value_content_type', 'value_object_id')
        
 +      #: :class:`CharField` containing a key (up to 255 characters) consisting of alphanumeric characters and underscores.
        key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
        
        def __unicode__(self):
@@@ -312,28 -278,56 +312,69 @@@ class EntityBase(models.base.ModelBase)
                return new
  
  
+ class EntityAttributeMapper(object, DictMixin):
+       def __init__(self, entity):
+               self.entity = entity
+       
+       def get_attributes(self):
+               return self.entity.attribute_set.all()
+       
+       def make_cache(self):
+               attributes = self.get_attributes()
+               value_lookups = {}
+               
+               for a in attributes:
+                       value_lookups.setdefault(a.value_content_type, []).append(a.value_object_id)
+               
+               values_bulk = {}
+               
+               for ct, pks in value_lookups.items():
+                       values_bulk[ct] = ct.model_class().objects.in_bulk(pks)
+               
+               self._cache = dict([(a.key, getattr(values_bulk[a.value_content_type].get(a.value_object_id), 'value', None)) for a in attributes])
+       
+       def __getitem__(self, key):
+               if not hasattr(self, '_cache'):
+                       self.make_cache()
+               return self._cache[key]
+       
+       def keys(self):
+               if not hasattr(self, '_cache'):
+                       self.make_cache()
+               return self._cache.keys()
+       
+       def items(self):
+               if not hasattr(self, '_cache'):
+                       self.make_cache()
+               return self._cache.items()
+       
+       def values(self):
+               if not hasattr(self, '_cache'):
+                       self.make_cache()
+               return self._cache.values()
  class Entity(models.Model):
 +      """An abstract class that simplifies access to related attributes. Most models provided by Philo subclass Entity."""
        __metaclass__ = EntityBase
        
        attribute_set = generic.GenericRelation(Attribute, content_type_field='entity_content_type', object_id_field='entity_object_id')
        
        @property
        def attributes(self):
-               
-               return QuerySetMapper(self.attribute_set.all())
 +              """
 +              Property that returns a dictionary-like object which can be used to retrieve related :class:`Attribute`\ s' values directly.
 +
 +              Example::
 +
 +                      >>> attr = entity.attribute_set.get(key='spam')
 +                      >>> attr.value.value
 +                      u'eggs'
 +                      >>> entity.attributes['spam']
 +                      u'eggs'
 +              
 +              """
+               return EntityAttributeMapper(self)
        
        class Meta:
                abstract = True
@@@ -344,21 -338,19 +385,21 @@@ class TreeManager(models.Manager)
        
        def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='slug'):
                """
 -              Returns the object with the path, unless absolute_result is set to False, in which
 -              case it returns a tuple containing the deepest object found along the path, and the
 -              remainder of the path after that object as a string (or None if there is no remaining
 -              path). Raises a DoesNotExist exception if no object is found with the given path.
 -              
 -              If the path you're searching for is known to exist, it is always faster to use
 -              absolute_result=True - unless the path depth is over ~40, in which case the high cost
 -              of the absolute query makes a binary search (i.e. non-absolute) faster.
 +              If ``absolute_result`` is ``True``, returns the object at ``path`` (starting at ``root``) or raises a :class:`DoesNotExist` exception. Otherwise, returns a tuple containing the deepest object found along ``path`` (or ``root`` if no deeper object is found) and the remainder of the path after that object as a string (or None if there is no remaining path).
 +              
 +              .. note:: If you are looking for something with an exact path, it is faster to use absolute_result=True, unless the path depth is over ~40, in which case the high cost of the absolute query may make a binary search (i.e. non-absolute) faster.
 +              
 +              .. note:: SQLite allows max of 64 tables in one join. That means the binary search will only work on paths with a max depth of 127 and the absolute fetch will only work to a max depth of (surprise!) 63. Larger depths could be handled, but since the common use case will not have a tree structure that deep, they are not.
 +              
 +              :param path: The path of the object
 +              :param root: The object which will be considered the root of the search
 +              :param absolute_result: Whether to return an absolute result or do a binary search
 +              :param pathsep: The path separator used in ``path``
 +              :param field: The field on the model which should be queried for ``path`` segment matching.
 +              :returns: An instance if absolute_result is True or (instance, remaining_path) otherwise.
 +              
                """
 -              # Note: SQLite allows max of 64 tables in one join. That means the binary search will
 -              # only work on paths with a max depth of 127 and the absolute fetch will only work
 -              # to a max depth of (surprise!) 63. Although this could be handled, chances are your
 -              # tree structure won't be that deep.
 +              
                segments = path.split(pathsep)
                
                # Clean out blank segments. Handles multiple consecutive pathseps.
@@@ -457,14 -449,6 +498,14 @@@ class TreeModel(MPTTModel)
        slug = models.SlugField(max_length=255)
        
        def get_path(self, root=None, pathsep='/', field='slug'):
 +              """
 +              :param root: Only return the path since this object.
 +              :param pathsep: The path separator to use when constructing an instance's path
 +              :param field: The field to pull path information from for each ancestor.
 +              :returns: A string representation of an object's path.
 +              
 +              """
 +              
                if root == self:
                        return ''
                
@@@ -495,30 -479,20 +536,37 @@@ class TreeEntityBase(MPTTModelBase, Ent
                return meta.register(cls)
  
  
+ class TreeEntityAttributeMapper(EntityAttributeMapper):
+       def get_attributes(self):
+               ancestors = dict(self.entity.get_ancestors(include_self=True).values_list('pk', 'level'))
+               ct = ContentType.objects.get_for_model(self.entity)
+               return sorted(Attribute.objects.filter(entity_content_type=ct, entity_object_id__in=ancestors.keys()), key=lambda x: ancestors[x.entity_object_id])
  class TreeEntity(Entity, TreeModel):
 +      """An abstract subclass of Entity which represents a tree relationship."""
 +      
        __metaclass__ = TreeEntityBase
        
        @property
        def attributes(self):
 +              """
 +              Property that returns a dictionary-like object which can be used to retrieve related :class:`Attribute`\ s' values directly. If an attribute with a given key is not related to the :class:`Entity`, then the object will check the parent's attributes.
 +
 +              Example::
 +
 +                      >>> attr = entity.attribute_set.get(key='spam')
 +                      DoesNotExist: Attribute matching query does not exist.
 +                      >>> attr = entity.parent.attribute_set.get(key='spam')
 +                      >>> attr.value.value
 +                      u'eggs'
 +                      >>> entity.attributes['spam']
 +                      u'eggs'
 +              
 +              """
 +              
                if self.parent:
-                       return QuerySetMapper(self.attribute_set.all(), passthrough=self.parent.attributes)
+                       return TreeEntityAttributeMapper(self)
                return super(TreeEntity, self).attributes
        
        class Meta: