Skip to content

Commit

Permalink
Merge pull request #137 from netzkolchose/field_factory
Browse files Browse the repository at this point in the history
ComputedField factory method
  • Loading branch information
jerch committed Oct 31, 2023
2 parents e9985a6 + 6f5b861 commit be8a683
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 30 deletions.
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
[![Coverage Status](https://coveralls.io/repos/github/netzkolchose/django-computedfields/badge.svg?branch=master)](https://coveralls.io/github/netzkolchose/django-computedfields?branch=master)


### django-computedfields ###
### django-computedfields

django-computedfields provides autoupdated database fields
for model methods.

Tested with Django 3.2 and 4.2 (Python 3.7 to 3.10).


#### Example ####
#### Example

Just derive your model from `ComputedFieldsModel` and place
the `@computed` decorator at a method:
Expand All @@ -33,7 +33,7 @@ During saving the associated method gets called and it’s result
written to the database.


#### How to recalculate without saving the model record ####
#### How to recalculate without saving the model record

If you need to recalculate the computed field but without saving it, use
`from computedfields.models import compute`
Expand Down Expand Up @@ -75,12 +75,34 @@ class MyModel(ComputedFieldsModel):
Now changes to `self.name`, `fk` or `fk.fieldname` will update `computed_field`.


#### Documentation ####
#### Alternative Syntax

Instead of using the `@computed` decorator with inline field definitions,
you can also use a more declarative syntax with `ComputedField`, example from above rewritten:

```python
from django.db import models
from computedfields.models import ComputedFieldsModel, ComputedField

def get_upper_string(inst):
return inst.name.upper()

class MyModel(ComputedFieldsModel):
name = models.CharField(max_length=32)
computed_field = ComputedField(
models.CharField(max_length=32),
depends=[('self', ['name'])],
compute=get_upper_string
)
```


#### Documentation

The documentation can be found [here](https://django-computedfields.readthedocs.io/en/latest/index.html).


#### Changelog ####
#### Changelog

- 0.2.3
- performance improvement: use UNION for multi dependency query construction
Expand Down
5 changes: 5 additions & 0 deletions computedfields/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

__all__ = [
'ComputedFieldsModel',
'ComputedField',
'computed',
'precomputed',
'compute',
Expand Down Expand Up @@ -66,6 +67,10 @@ def save(
#: Convenient access to the decorator :meth:`@precomputed<.resolver.Resolver.precomputed>`.
precomputed = active_resolver.precomputed

# ComputedField factory
#: Convenient access to :meth:`computedfield_factory<.resolver.Resolver.computedfield_factory>`.
ComputedField = active_resolver.computedfield_factory

# computed field updates
#: Convenient access to :meth:`compute<.resolver.Resolver.compute>`.
compute = active_resolver.compute
Expand Down
109 changes: 86 additions & 23 deletions computedfields/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,84 @@ def get_contributing_fks(self) -> IFkMap:
raise ResolverException('resolver has no maps loaded yet')
return self._fk_map

def _sanity_check(self, field: Field, depends: IDepends) -> None:
"""
Basic type check for computed field arguments `field` and `depends`.
This only checks for proper type alignment (most crude source of errors) to give
devs an early startup error for misconfigured computed fields.
More subtle errors like non-existing paths or fields are caught
by the resolver during graph reduction yielding somewhat crytic error messages.
There is another class of misconfigured computed fields we currently cannot
find by any safety measures - if `depends` provides valid paths and fields,
but the function operates on different dependencies. Currently it is the devs'
responsibility to perfectly align `depends` entries with dependencies
used by the function to avoid faulty update behavior.
"""
if not isinstance(field, Field):
raise ResolverException('field argument is not a Field instance')
for rule in depends:
try:
path, fieldnames = rule
except ValueError:
raise ResolverException(MALFORMED_DEPENDS)
if not isinstance(path, str) or not all(isinstance(f, str) for f in fieldnames):
raise ResolverException(MALFORMED_DEPENDS)

def computedfield_factory(
self,
field: 'Field[_ST, _GT]',
compute: Callable[..., _ST],
depends: Optional[IDepends] = None,
select_related: Optional[Sequence[str]] = None,
prefetch_related: Optional[Sequence[Any]] = None,
querysize: Optional[int] = None
) -> 'Field[_ST, _GT]':
"""
Factory for computed fields.
The method gets exposed as ``ComputedField`` to allow a more declarative
code style with better separation of field declarations and function
implementations. It is also used internally for the ``computed`` decorator.
Similar to the decorator, the ``compute`` function expects a single argument
as model instance of the model it got applied to.
Usage example:
.. code-block:: python
from computedfields.models import ComputedField
def calc_mul(inst):
return inst.a * inst.b
class MyModel(ComputedFieldsModel):
a = models.IntegerField()
b = models.IntegerField()
sum = ComputedField(
models.IntegerField(),
depends=[('self', ['a', 'b'])],
compute=lambda inst: inst.a + inst.b
)
mul = ComputedField(
models.IntegerField(),
depends=[('self', ['a', 'b'])],
compute=calc_mul
)
"""
self._sanity_check(field, depends or [])
cf = cast('IComputedField[_ST, _GT]', field)
cf._computed = {
'func': compute,
'depends': depends or [],
'select_related': select_related or [],
'prefetch_related': prefetch_related or [],
'querysize': querysize
}
cf.editable = False
self.add_field(cf)
return field

def computed(
self,
field: 'Field[_ST, _GT]',
Expand Down Expand Up @@ -781,31 +859,16 @@ def comp(self):
Also see the graph documentation :ref:`here<graph>`.
"""
def wrap(func: Callable[..., _ST]) -> 'Field[_ST, _GT]':
self._sanity_check(field, depends or [])
cf = cast('IComputedField[_ST, _GT]', field)
cf._computed = {
'func': func,
'depends': depends or [],
'select_related': select_related or [],
'prefetch_related': prefetch_related or [],
'querysize': querysize
}
cf.editable = False
self.add_field(cf)
return field
return self.computedfield_factory(
field,
compute=func,
depends=depends,
select_related=select_related,
prefetch_related=prefetch_related,
querysize=querysize
)
return wrap

def _sanity_check(self, field: Field, depends: IDepends) -> None:
if not isinstance(field, Field):
raise ResolverException('field argument is not a Field instance')
for rule in depends:
try:
path, fieldnames = rule
except ValueError:
raise ResolverException(MALFORMED_DEPENDS)
if not isinstance(path, str) or not all(isinstance(f, str) for f in fieldnames):
raise ResolverException(MALFORMED_DEPENDS)

@overload
def precomputed(self, f: F) -> F:
...
Expand Down
30 changes: 29 additions & 1 deletion docs/manual.rst
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,38 @@ The ``@computed`` decorator expects a model field instance as first argument to
result of the decorated method.


Alternative Syntax
------------------

For a more declarative code style you can use the ``ComputedField`` factory method instead
(since version 2.4.0):

.. code-block:: python
from django.db import models
from computedfields.models import ComputedFieldsModel, ComputedField
class Person(ComputedFieldsModel):
forename = models.CharField(max_length=32)
surname = models.CharField(max_length=32)
combined = ComputedField(
models.CharField(max_length=32),
depends=[('self', ['surname', 'forename'])],
compute=lambda inst: f'{inst.surname}, {inst.forename}'
)
which yields the same behavior as the decorator. ``ComputedField`` expects
the same arguments as the decorator, plus the compute function as ``compute``.
The compute function should expect a model instance as single argument.

While the code examples of this guide use only the decorator syntax,
they also apply to the declarative syntax with ``ComputedField``.


Automatic Updates
-----------------

The `depends` keyword argument of the ``@computed`` decorator can be used with any relation
The `depends` keyword argument can be used with any relation
to indicate dependencies to fields on other models as well.

The example above extended by a model ``Address``:
Expand Down
13 changes: 12 additions & 1 deletion example/test_full/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.db import models
import sys
from computedfields.models import ComputedFieldsModel, computed, precomputed
from computedfields.models import ComputedFieldsModel, computed, precomputed, ComputedField


def model_factory(name, keys):
Expand Down Expand Up @@ -1072,3 +1072,14 @@ def c_10_1(self):
@computed(models.CharField(max_length=32), depends=[('self', ['name'])])
def default(self):
return self.name


# ComputedField factory: direct usage test
def calc_d(inst):
return inst.a * inst.b

class FactorySimple(ComputedFieldsModel):
a = models.IntegerField()
b = models.IntegerField()
c = ComputedField(models.IntegerField(), compute=lambda inst: inst.a + inst.b)
d = ComputedField(models.IntegerField(), compute=calc_d)
34 changes: 34 additions & 0 deletions example/test_full/tests/test_computedfield.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.test import TestCase
from .. import models


class ComputedField(TestCase):
def setUp(self):
self.obj1 = models.FactorySimple.objects.create(a=10, b=20)
self.obj2 = models.FactorySimple.objects.create(a=100, b=200)

def test_create(self):
self.assertEqual(self.obj1.c, 30)
self.assertEqual(self.obj1.c, self.obj1.a + self.obj1.b)
self.assertEqual(self.obj1.d, 200)
self.assertEqual(self.obj1.d, self.obj1.a * self.obj1.b)
self.obj2.refresh_from_db()
self.assertEqual(self.obj2.c, 300)
self.assertEqual(self.obj2.c, self.obj2.a + self.obj2.b)
self.assertEqual(self.obj2.d, 20000)
self.assertEqual(self.obj2.d, self.obj2.a * self.obj2.b)

def test_alter(self):
self.obj1.b = 30
self.obj1.save()
self.assertEqual(self.obj1.c, 40)
self.assertEqual(self.obj1.c, self.obj1.a + self.obj1.b)
self.assertEqual(self.obj1.d, 300)
self.assertEqual(self.obj1.d, self.obj1.a * self.obj1.b)
self.obj2.b = 300
self.obj2.save()
self.obj2.refresh_from_db()
self.assertEqual(self.obj2.c, 400)
self.assertEqual(self.obj2.c, self.obj2.a + self.obj2.b)
self.assertEqual(self.obj2.d, 30000)
self.assertEqual(self.obj2.d, self.obj2.a * self.obj2.b)

0 comments on commit be8a683

Please sign in to comment.