Common Patterns

django-queryable-properties offers some fully implemented properties for common code patterns out of the box. They are parameterizable and are supposed to help remove boilerplate for recurring types of properties while making them usable in querysets at the same time.

Checking a field for one or multiple specific values

Properties on model objects are often used to check if an attribute on a model instance contains a specific value (or one of multiple values). This is often done for fields with choices as it allows to implement the check for a certain choice value in one place instead of redefining it whenever the field should be checked for the value. However, the pattern is not limited to fields with choices.

Imagine that the ApplicationVersion example model would also contain a field that contains information about the type of release, e.g. if a certain version is an alpha, a beta, etc. It would be well-advised to use a field with choices for this value and to also define properties to check for the individual values to only define these checks once.

Without django-queryable-properties, the implementation could look similar to this:

from django.db import models
from django.utils.translation import ugettext_lazy as _


class ApplicationVersion(models.Model):
    ALPHA = 'a'
    BETA = 'b'
    STABLE = 's'
    RELEASE_TYPE_CHOICES = (
        (ALPHA, _('Alpha')),
        (BETA, _('Beta')),
        (STABLE, _('Stable')),
    )

    ...  # other fields
    release_type = models.CharField(max_length=1, choices=RELEASE_TYPE_CHOICES)
    
    @property
    def is_alpha(self):
        return self.release_type == self.ALPHA
    
    @property
    def is_beta(self):
        return self.release_type == self.BETA
    
    @property
    def is_stable(self):
        return self.release_type == self.STABLE
    
    @property
    def is_unstable(self):
        return self.release_type in (self.ALPHA, self.BETA)

Instead of defining the properties like this, the property class ValueCheckProperty of the django-queryable-properties package could be used:

from django.db import models
from django.utils.translation import ugettext_lazy as _

from queryable_properties.managers import QueryablePropertiesManager
from queryable_properties.properties import ValueCheckProperty


class ApplicationVersion(models.Model):
    ALPHA = 'a'
    BETA = 'b'
    STABLE = 's'
    RELEASE_TYPE_CHOICES = (
        (ALPHA, _('Alpha')),
        (BETA, _('Beta')),
        (STABLE, _('Stable')),
    )

    ...  # other fields
    release_type = models.CharField(max_length=1, choices=RELEASE_TYPE_CHOICES)
    
    objects = QueryablePropertiesManager()
    
    is_alpha = ValueCheckProperty('release_type', ALPHA)
    is_beta = ValueCheckProperty('release_type', BETA)
    is_stable = ValueCheckProperty('release_type', STABLE)
    is_unstable = ValueCheckProperty('release_type', ALPHA, BETA)

Instances of this property class take the path of the attribute to check as their first parameter in addition to any number of parameters that represent the values to check for - if one of them matches when the property is accessed on a model instance, the property will return True (otherwise False).

Not only does this property class allow to achieve the same functionality with less code, but it offers even more functionality due to being a queryable property. The class implements both queryset filtering as well as annotating (based on Django’s Case/When objects), so the properties can be used in querysets as well:

stable_versions = ApplicationVersion.objects.filter(is_stable=True)
non_alpha_versions = ApplicationVersion.objects.filter(is_alpha=False)
ApplicationVersion.objects.order_by('is_unstable')

For a quick overview, the ValueCheckProperty offers the following queryable property features:

Feature Supported
Getter Yes
Setter No
Filtering Yes
Annotation Yes (Django 1.8 or higher)
Updating No

Attribute paths

The attribute path specified as the first parameter can not only be a simple field name like in the example above, but also a more complex path to an attribute using dot-notation - basically the same way as for Python’s operator.attrgetter. For queryset operations, the dots are then simply replaced by the lookup separator (__), so an attribute path my.attr becomes my__attr in queries.

This is especially useful to reach fields of related model instances via foreign keys, but it also allows to be more creative since the path simply needs to make sense both on the object-level as well as in queries. For example, a DateField may be defined as date_field = models.DateField(), which would allow a ValueCheckProperty to be set up with the path date_field.year. This works because the date object has an attribute year on the object-level and Django offers a year transform for querysets (so date_field__year does in fact work). However, this specific example requires at least Django 1.9 as older versions don’t allow to combine transforms and lookups. In general, this means that the attribute path does not have to refer to an actual field, which also means that it may refer to another queryable property (which needs to support the in lookup to be able to filter correctly).

Unlike Python’s attrgetter, the property will also automatically catch some exceptions during getter access (if any of them occur, the property considers none of the configured values as matching):

  • AttributeErrors if an intermediate object is None (e.g. if a path is a.b and the a attribute already returns None, then the attribute error when accessing b will be caught). This is intended to make working with nullable fields easier. Any other kind of AttributeError will still be raised.
  • Any ObjectDoesNotExist errors raised by Django, which are raised e.g. when accessing a reverse One-To-One relation with a missing value. This is intended to make working with these kinds of relations easier.

Checking if a value is contained in a range defined by two fields

A common pattern that uses a property is having a model with two attributes that define a lower and an upper limit and a property that checks if a certain value is contained in that range. These fields may be numerical fields (IntegerField, DecimalField, etc.) or something like date fields (DateField, DateTimeField, etc.) - basically anything that allows “greater than” and “lower than” comparisons.

As an example, the ApplicationVersion example model could contain two such date fields to express the period in which a certain app version is supported, which could look similar to this:

from django.db import models
from django.utils import timezone


class ApplicationVersion(models.Model):
    ...  # other fields
    supported_from = models.DateTimeField()
    supported_until = models.DateTimeField()
    
    @property
    def is_supported(self):
        return self.supported_from <= timezone.now() <= self.supported_until

Instead of defining the properties like this, the property class RangeCheckProperty of the django-queryable-properties package could be used:

from django.db import models
from django.utils import timezone

from queryable_properties.managers import QueryablePropertiesManager
from queryable_properties.properties import RangeCheckProperty


class ApplicationVersion(models.Model):
    ...  # other fields
    supported_from = models.DateTimeField()
    supported_until = models.DateTimeField()
    
    objects = QueryablePropertiesManager()
    
    is_supported = RangeCheckProperty('supported_from', 'supported_until', timezone.now)

Instances of this property class take the paths of the attributes for the lower and upper limits as their first and second arguments. Both values may also be more complex attribute paths in dot-notation - the same behavior as for the attribute path of ValueCheckProperty objects apply (refer to chapter “Attribute Paths” above). If one of the limiting values is None or an exception is caught, the value is considered missing (see next sub- chapter). The third mandatory parameter for RangeCheckProperty objects is the value to check against, which may either be a static value or a callable that can be called without any argument and that returns the actual value (timezone.now in the example above), similar to the default option of Django’s model fields.

Not only does this property class allow to achieve the same functionality with less code, but it offers even more functionality due to being a queryable property. The class implements both queryset filtering as well as annotating (based on Django’s Case/When objects), so the properties can be used in querysets as well:

currently_supported = ApplicationVersion.objects.filter(is_supported=True)
not_supported = ApplicationVersion.objects.filter(is_supported=False)
ApplicationVersion.objects.order_by('is_supported')

For a quick overview, the RangeCheckProperty offers the following queryable property features:

Feature Supported
Getter Yes
Setter No
Filtering Yes
Annotation Yes (Django 1.8 or higher)
Updating No

Range configuration

RangeCheckProperty objects also allow further configuration to tweak the configured range via some optional parameters:

  • include_boundaries determines if a value exactly equal to one of the limits is considered a part of the range (default: True)
  • include_missing determines if a missing value for either boundary is considered part of the range (default: False)
  • in_range determines if the property should return True if the value is contained in the configured range (this is the default) or if it should return True if the value is outside of the range

It should be noted that the include_boundaries and include_missing parameters are applied first to define the range (which values are considered inside the range between the two values) and the in_range parameter is applied afterwards to potentially invert the result (in the case of in_range=False). This means that setting include_missing=True defines that missing values are part of the range and a value of in_range=False would then invert this range, meaning that missing values would not lead to a value of True since they are configured to be in the range while the property is set up to return True for values outside of the range. For a quick reference, all possible configuration combinations are listed in the following table:

include_boundaries include_missing in_range returns True for
True False True
  • Values in between boundaries (excl.)
  • The exact boundary values
True True True
  • Values in between boundaries (excl.)
  • The exact boundary values
  • Missing values
False False True
  • Values in between boundaries (excl.)
False True True
  • Values in between boundaries (excl.)
  • Missing values
True False False
  • Values outside of the boundaries (excl.)
  • Missing values
True True False
  • Values outside of the boundaries (excl.)
False False False
  • Values outside of the boundaries (excl.)
  • The exact boundary values
  • Missing values
False True False
  • Values outside of the boundaries (excl.)
  • The exact boundary values

Note

The attribute paths passed to RangeCheckProperty may also refer to other queryable properties as long as these properties allow filtering with the lt/lte and gt/gte lookups (depending on the value of include_boundaries) and potentially the isnull lookup (depending on the value of include_missing).

Simple aggregates

django-queryable-properties also comes with a property class for simple aggregates that simply takes an aggregate object and uses it for both queryset annotations as well as the getter. This allows to define such properties in only one line of code. For example, the Application model could receive a simple property that returns the number of versions like this:

from django.db.models import Count, Model
from queryable_properties.properties import AggregateProperty


class Application(Model):
    ...  # other fields/properties

    version_count = AggregateProperty(Count('versions'))

Since the getter also performs a query to retrieve the aggregated value, the AggregateProperty initializer also allows to mark the property as cached using an additional cached parameter (defaults to False). This can improve performance since the query will only be executed on the first getter access at the cost of potentially not working with an up-to-date value.

Note

Since this property deals with aggregates, the notes Regarding aggregate annotations across relations apply when using such properties across relations in querysets.

For a quick overview, the AggregateProperty offers the following queryable property features:

Feature Supported
Getter Yes
Setter No
Filtering Yes
Annotation Yes
Updating No