Skip to main content

Properties that are both ORM expressions and python code.

Project description

https://img.shields.io/pypi/v/django_shared_property.svg https://img.shields.io/travis/schinckel/django-shared-property.svg Build status on Appveyor Documentation Status

Properties that are both ORM expressions and python code.

Installation:

$ pip install django_shared_property

Philosophy:

I often find that I have annotations in Django querysets that are based on one or more other fields, and are used frequently. In some cases I have even been known to ensure these annotations are always available on the model using a custom queryset/manager.

But, unlike Python properties, these annotations are not “live”. If, for example, you have the following:

class FullNameQueryset(models.query.QuerySet):
    def with_full_name(self):
        return self.annotate(
          full_name=Concat(models.F('first_name'), models.Value(' '), models.F('last_name')),
        )


class Person(models.Model):
    first_name = models.TextField()
    last_name = models.TextField()

    objects = FullNameQueryset.as_manager()

Then, you can do a person = Person.objects.with_full_name().get(pk=1), and then reference person.full_name.

But, if you modify person.first_name, you’d need to write it back to the database, and then reload it. Which may not be ideal, and at best requires two database operations.

django-shared-property allows you to have properties that automatically act as an annototation, allowing you to define the expression, and have Django use that operation within any database query. It can then be used in a filter (or further annotation), even across relationships. And finally, if any local changes are made to the object that would affect the value when stored in the database, then the property value will also update in Python.

Show me how!

Similar to a Python property, a django-shared-property requires a method that takes no arguments. It should, however, return a Django Expression. For example, following our annotation above:

class Person(models.Model):
    first_name = models.TextField()
    last_name = models.TextField()

    @shared_property
    def full_name(self):
        return Concat(models.F('first_name'), models.Value(' '), models.F('last_name'))

You then reference it just like any other field.

Person.objects.filter(full_name__contains='Bob')

What else can it do?

Shared properties can reference any number of fields on the model, and even other shared properties, just like with annotations. They can even reference fields from related models, using the familiar models.F('relation__field') lookup syntax. You can also use some Django expressions (such as Concat, Lower, Upper), where there is a clear relationship to a Python concept.

  • Case

  • When

  • F

  • Q (Specifically, within a When, but it could work elsewhere)

  • Concat

  • Value

  • Lower and Upper (but only on Python objects that have these as attributes)

  • ExpressionWrapper

  • CombinedExpresson

  • Coalesce (but see the note below)

Within the context of a Q expression, you can use __isnull and __exact lookups.

You can even refer to constants in your Python file, such as the different values of an Enum. The return value of your python object will then correctly return instances of the Enum.

If your chosen expression/function/value does not work, then it may be possible to implement it (see below).

Shared properties should be pure functions - they must not refer to self (indeed, this will cause an error), and should not refer to variables, as they will be executed at times other than when they are about to be decorated.

How does it work?

Because of the limit that the decorated function be a pure function, we are able to execute the callable, using the result as a Django expression.

The Django part is relatively straightforward. The expression returned by the method that is decorated as a shared_property is used in a context that looks a bit like an annotation - however there are a couple of things that need to be done to ensure that the expression has the correct data available to it to make sure it points at the correct tables. We indicate to Django that it should not be written back to the database by marking it as a private field.

Creating the Python property is a bit trickier. We still need the expression, but we build an Abstract Syntax Tree based on the expression. We then compile this into a callable object that we use as the property.

In a little more detail:

  • Call the decorated function, returning the Expression

  • Use Python’s introspection tools to examine the expression (and it’s “source expressions”) and a recursive descent parser to build an AST equivalent to the expression. Specifically, the AST contains a function definition.

  • Compile this AST into a code object

  • eval this code object with the correct context to pull in any constants from outside the namespace.

  • Extract the newly defined function, and use it for the callable in our property.

Advanced Use

Sometimes you want to define the callable yourself: there is an alternate syntax for that. This could be where the expression has not been defined, or it’s possible to create a more efficient callable by hand:

class MyModel(models.Model):
    # other fields

    @shared_property(Case(
        When(models.Q(x__gte=2, x__lt=5), then=models.Value('B')),
        When(models.Q(x__lt=2), then=models.Value('A')),
        default=models.Value('C'),
        output_field=models.TextField(),
    )
    def state(self):
        if 2 <= self.x < 5:
            return 'B'
        elif x < 2:
            return 'A'
        return 'C'

    @shared_property(Coalesce(
      CombinedExpression(F('expiry_date'), '<', Func(function='current_timestamp')),
      models.Value(True),
    ))
    def active(self):
        return self.expiry_date is None or self.expiry_date < timezone.now()

In this specific case, the code that is generated would be fairly similar (although it would not use the a < b < c idiom), however it shows how it is possible to to explicitly provide the python code. Please note that the onus of responsibility is on the developer to ensure that the expression and function are equivalent in this context.

The second example shows where a python comparison doesn’t quite map to the SQL code: the COALESCE(expiry_date < now(), true) relies on SQL comparisons involving NULL to also return NULL, but in Python you cannot do this.

Also note that in this case only a single expression may be used as the argument to the decorator.

Registering Expressions

It is possible to register your own expressions. The structure is quite strict, and you’ll need to reference the parser instance as well as the incoming expression. There’s sometimes quite a bit of work to turn the Expression into (a) the correct Python, and then (b) the AST that is required.

from django_shared_property.parser import register


@register
def handle_foo(parser, expression):
    # This assumes a foo() function in python that matches a foo()
    # function in SQL, neither of which takes arguments.
    return Call(
        func=Name(id='foo', **parser.file),
        args=[],
        keywords=[],
        kwonlyargs=[],
        **parser.file,
    )


class Foo(Func):
    function = 'foo'


class MyModel(models.Model):
    # ...

    @shared_property
    def the_foo(self):
        return Foo()

This is a toy example - try looking in the parser module for other examples.

Credits

Developed by Matthew Schinckel.

This package was created with Cookiecutter and the wboxx1/cookiecutter-pypackage-poetry project template.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

Django-Shared-Property-0.3.0.tar.gz (13.1 kB view details)

Uploaded Source

Built Distribution

Django_Shared_Property-0.3.0-py3-none-any.whl (10.4 kB view details)

Uploaded Python 3

File details

Details for the file Django-Shared-Property-0.3.0.tar.gz.

File metadata

  • Download URL: Django-Shared-Property-0.3.0.tar.gz
  • Upload date:
  • Size: 13.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/3.8.0 pkginfo/1.8.2 readme-renderer/34.0 requests/2.27.1 requests-toolbelt/0.9.1 urllib3/1.26.9 tqdm/4.63.0 importlib-metadata/4.11.3 keyring/23.5.0 rfc3986/2.0.0 colorama/0.4.4 CPython/3.10.2

File hashes

Hashes for Django-Shared-Property-0.3.0.tar.gz
Algorithm Hash digest
SHA256 97f56bb134635cde6189bba1a4120c3777e2debf041a3bae0129c15cd75b0500
MD5 d9ebb9dd72a8058bdddd0c12c83c4823
BLAKE2b-256 fc7f29958d21215fcf56d982823dfd9fd4ab58974ede3c7c61a1343354df7186

See more details on using hashes here.

File details

Details for the file Django_Shared_Property-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: Django_Shared_Property-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 10.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/3.8.0 pkginfo/1.8.2 readme-renderer/34.0 requests/2.27.1 requests-toolbelt/0.9.1 urllib3/1.26.9 tqdm/4.63.0 importlib-metadata/4.11.3 keyring/23.5.0 rfc3986/2.0.0 colorama/0.4.4 CPython/3.10.2

File hashes

Hashes for Django_Shared_Property-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4e8764e52089c66d6f9095b2ca0e05b2ffcf650753a4a559fcacb8c796a6b701
MD5 778e5f931f624a062fe767e41d34915c
BLAKE2b-256 b1a9d47505e4ea31ed65b24805818ae236d4d83feca3e379fcceca6de4d8146c

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page