• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

netzkolchose / django-computedfields / 16128397183

07 Jul 2025 09:34PM UTC coverage: 94.983% (+0.06%) from 94.927%
16128397183

Pull #179

github

web-flow
Merge a57518d46 into 6f802d065
Pull Request #179: no_computed context

479 of 520 branches covered (92.12%)

Branch coverage included in aggregate %.

17 of 17 new or added lines in 3 files covered. (100.0%)

16 existing lines in 1 file now uncovered.

1168 of 1214 relevant lines covered (96.21%)

11.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.76
/computedfields/resolver.py
1
"""
2
Contains the resolver logic for automated computed field updates.
3
"""
4
import operator
12✔
5
from collections import OrderedDict
12✔
6
from functools import reduce
12✔
7
from itertools import zip_longest
12✔
8

9
from django.db import transaction
12✔
10
from django.db.models import QuerySet
12✔
11
from django.core.exceptions import FieldDoesNotExist
12✔
12

13
from .settings import settings
12✔
14
from .graph import ComputedModelsGraph, ComputedFieldsException, Graph, ModelGraph
12✔
15
from .helpers import proxy_to_base_model, slice_iterator, subquery_pk, are_same, modelname
12✔
16
from . import __version__
12✔
17

18
from fast_update.fast import fast_update
12✔
19

20
# typing imports
21
from typing import (Any, Callable, Dict, Generator, Iterable, List, Optional, Sequence, Set,
12✔
22
                    Tuple, Type, Union, cast, overload)
23
from typing_extensions import TypedDict
12✔
24
from django.db.models import Field, Model
12✔
25
from .graph import IComputedField, IDepends, IFkMap, ILocalMroMap, ILookupMap, _ST, _GT, F
12✔
26

27

28
class IM2mData(TypedDict):
12✔
29
    left: str
12✔
30
    right: str
12✔
31
IM2mMap = Dict[Type[Model], IM2mData]
12✔
32

33

34
MALFORMED_DEPENDS = """
12✔
35
Your depends keyword argument is malformed.
36

37
The depends keyword should either be None, an empty listing or
38
a listing of rules as depends=[rule1, rule2, .. ruleN].
39

40
A rule is formed as ('relation.path', ['list', 'of', 'fieldnames']) tuple.
41
The relation path either contains 'self' for fieldnames on the same model,
42
or a string as 'a.b.c', where 'a' is a relation on the current model
43
descending over 'b' to 'c' to pull fieldnames from 'c'. The denoted fieldnames
44
must be concrete fields on the rightmost model of the relation path.
45

46
Example:
47
depends=[
48
    ('self', ['name', 'status']),
49
    ('parent.color', ['value'])
50
]
51
This has 2 path rules - one for fields 'name' and 'status' on the same model,
52
and one to a field 'value' on a foreign model, which is accessible from
53
the current model through self -> parent -> color relation.
54
"""
55

56

57
class ResolverException(ComputedFieldsException):
12✔
58
    """
59
    Exception raised during model and field registration or dependency resolving.
60
    """
61

62

63
class Resolver:
12✔
64
    """
65
    Holds the needed data for graph calculations and runtime dependency resolving.
66

67
    Basic workflow:
68

69
        - On django startup a resolver gets instantiated early to track all project-wide
70
          model registrations and computed field decorations (collector phase).
71
        - On `app.ready` the computed fields are associated with their models to build
72
          a resolver-wide map of models with computed fields (``computed_models``).
73
        - After that the resolver maps are created (see `graph.ComputedModelsGraph`).
74
    """
75

76
    def __init__(self):
12✔
77
        # collector phase data
78
        #: Models from `class_prepared` signal hook during collector phase.
79
        self.models: Set[Type[Model]] = set()
12✔
80
        #: Computed fields found during collector phase.
81
        self.computedfields: Set[IComputedField] = set()
12✔
82

83
        # resolving phase data and final maps
84
        self._graph: Optional[ComputedModelsGraph] = None
12✔
85
        self._computed_models: Dict[Type[Model], Dict[str, IComputedField]] = {}
12✔
86
        self._map: ILookupMap = {}
12✔
87
        self._fk_map: IFkMap = {}
12✔
88
        self._local_mro: ILocalMroMap = {}
12✔
89
        self._m2m: IM2mMap = {}
12✔
90
        self._proxymodels: Dict[Type[Model], Type[Model]] = {}
12✔
91
        self.use_fastupdate: bool = settings.COMPUTEDFIELDS_FASTUPDATE
12✔
92
        self._batchsize: int = (settings.COMPUTEDFIELDS_BATCHSIZE_FAST
12✔
93
            if self.use_fastupdate else settings.COMPUTEDFIELDS_BATCHSIZE_BULK)
94

95
        # some internal states
96
        self._sealed: bool = False        # initial boot phase
12✔
97
        self._initialized: bool = False   # initialized (computed_models populated)?
12✔
98
        self._map_loaded: bool = False    # final stage with fully loaded maps
12✔
99

100
        #: Disabled state, if `True` no resolver updates will happen.
101
        self.disabled: bool = False
12✔
102

103
    def add_model(self, sender: Type[Model], **kwargs) -> None:
12✔
104
        """
105
        `class_prepared` signal hook to collect models during ORM registration.
106
        """
107
        if self._sealed:
12✔
108
            raise ResolverException('cannot add models on sealed resolver')
12✔
109
        self.models.add(sender)
12✔
110

111
    def add_field(self, field: IComputedField) -> None:
12✔
112
        """
113
        Collects fields from decoration stage of @computed.
114
        """
115
        if self._sealed:
12✔
116
            raise ResolverException('cannot add computed fields on sealed resolver')
12✔
117
        self.computedfields.add(field)
12✔
118

119
    def seal(self) -> None:
12✔
120
        """
121
        Seal the resolver, so no new models or computed fields can be added anymore.
122

123
        This marks the end of the collector phase and is a basic security measure
124
        to catch runtime model creations with computed fields.
125

126
        (Currently runtime creation of models with computed fields is not supported,
127
        trying to do so will raise an exception. This might change in future versions.)
128
        """
129
        self._sealed = True
12✔
130

131
    @property
12✔
132
    def models_with_computedfields(self) -> Generator[Tuple[Type[Model], Set[IComputedField]], None, None]:
12✔
133
        """
134
        Generator of tracked models with their computed fields.
135

136
        This cannot be accessed during the collector phase.
137
        """
138
        if not self._sealed:
12✔
139
            raise ResolverException('resolver must be sealed before accessing models or fields')
12✔
140

141
        field_ids: List[int] = [f.creation_counter for f in self.computedfields]
12✔
142
        for model in self.models:
12✔
143
            fields = set()
12✔
144
            for field in model._meta.fields:
12✔
145
                # for some reason the in ... check does not work for Django >= 3.2 anymore
146
                # workaround: check for _computed and the field creation_counter
147
                if hasattr(field, '_computed') and field.creation_counter in field_ids:
12✔
148
                    fields.add(field)
12✔
149
            if fields:
12✔
150
                yield (model, fields)
12✔
151

152
    @property
12✔
153
    def computedfields_with_models(self) -> Generator[Tuple[IComputedField, Set[Type[Model]]], None, None]:
12✔
154
        """
155
        Generator of tracked computed fields and their models.
156

157
        This cannot be accessed during the collector phase.
158
        """
159
        if not self._sealed:
12✔
160
            raise ResolverException('resolver must be sealed before accessing models or fields')
12✔
161

162
        for field in self.computedfields:
12✔
163
            models = set()
12✔
164
            for model in self.models:
12✔
165
                for f in model._meta.fields:
12✔
166
                    if hasattr(field, '_computed') and f.creation_counter == field.creation_counter:
12✔
167
                        models.add(model)
12✔
168
            yield (field, models)
12✔
169

170
    @property
12✔
171
    def computed_models(self) -> Dict[Type[Model], Dict[str, IComputedField]]:
12✔
172
        """
173
        Mapping of `ComputedFieldModel` models and their computed fields.
174

175
        The data is the single source of truth for the graph reduction and
176
        map creations. Thus it can be used to decide at runtime whether
177
        the active resolver a certain as a model with computed fields.
178
        
179
        .. NOTE::
180
        
181
            The resolver will only list models here, that actually have
182
            a computed field defined. A model derived from `ComputedFieldsModel`
183
            without a computed field will not be listed.
184
        """
185
        if self._initialized:
12✔
186
            return self._computed_models
12✔
187
        raise ResolverException('resolver is not properly initialized')
12✔
188

189
    def extract_computed_models(self) -> Dict[Type[Model], Dict[str, IComputedField]]:
12✔
190
        """
191
        Creates `computed_models` mapping from models and computed fields
192
        found in collector phase.
193
        """
194
        computed_models: Dict[Type[Model], Dict[str, IComputedField]] = {}
12✔
195
        for model, computedfields in self.models_with_computedfields:
12✔
196
            if not issubclass(model, _ComputedFieldsModelBase):
12✔
197
                raise ResolverException(f'{model} is not a subclass of ComputedFieldsModel')
12✔
198
            computed_models[model] = {}
12✔
199
            for field in computedfields:
12✔
200
                computed_models[model][field.name] = field
12✔
201

202
        return computed_models
12✔
203

204
    def initialize(self, models_only: bool = False) -> None:
12✔
205
        """
206
        Entrypoint for ``app.ready`` to seal the resolver and trigger
207
        the resolver map creation.
208

209
        Upon instantiation the resolver is in the collector phase, where it tracks
210
        model registrations and computed field decorations.
211

212
        After calling ``initialize`` no more models or fields can be registered
213
        to the resolver, and ``computed_models`` and the resolver maps get loaded.
214
        """
215
        # resolver must be sealed before doing any map calculations
216
        self.seal()
12✔
217
        self._computed_models = self.extract_computed_models()
12✔
218
        self._initialized = True
12✔
219
        if not models_only:
12✔
220
            self.load_maps()
12✔
221

222
    def load_maps(self, _force_recreation: bool = False) -> None:
12✔
223
        """
224
        Load all needed resolver maps. The steps are:
225

226
            - create intermodel graph of the dependencies
227
            - remove redundant paths with cycling check
228
            - create modelgraphs for local MRO
229
            - merge graphs to uniongraph with cycling check
230
            - create final resolver maps
231

232
                - `lookup_map`: intermodel dependencies as queryset access strings
233
                - `fk_map`: models with their contributing fk fields
234
                - `local_mro`: MRO of local computed fields per model
235
        """
236
        self._graph = ComputedModelsGraph(self.computed_models)
12✔
237
        if not getattr(settings, 'COMPUTEDFIELDS_ALLOW_RECURSION', False):
12✔
238
            self._graph.get_edgepaths()
12✔
239
            self._graph.get_uniongraph().get_edgepaths()
12✔
240
        self._map, self._fk_map = self._graph.generate_maps()
12✔
241
        self._local_mro = self._graph.generate_local_mro_map()
12✔
242
        self._extract_m2m_through()
12✔
243
        self._patch_proxy_models()
12✔
244
        self._map_loaded = True
12✔
245

246
    def _extract_m2m_through(self) -> None:
12✔
247
        """
248
        Creates M2M through model mappings with left/right field names.
249
        The map is used by the m2m_changed handler for faster name lookups.
250
        This cannot be pickled, thus is built for every resolver bootstrapping.
251
        """
252
        for model, fields in self.computed_models.items():
12✔
253
            for _, real_field in fields.items():
12✔
254
                depends = real_field._computed['depends']
12✔
255
                for path, _ in depends:
12✔
256
                    if path == 'self':
12✔
257
                        continue
12✔
258
                    cls: Type[Model] = model
12✔
259
                    for symbol in path.split('.'):
12✔
260
                        try:
12✔
261
                            rel: Any = cls._meta.get_field(symbol)
12✔
262
                        except FieldDoesNotExist:
12✔
263
                            descriptor = getattr(cls, symbol)
12✔
264
                            rel = getattr(descriptor, 'rel', None) or getattr(descriptor, 'related')
12✔
265
                        if rel.many_to_many:
12✔
266
                            if hasattr(rel, 'through'):
12✔
267
                                self._m2m[rel.through] = {
12✔
268
                                    'left': rel.remote_field.name, 'right': rel.name}
269
                            else:
270
                                self._m2m[rel.remote_field.through] = {
12✔
271
                                    'left': rel.name, 'right': rel.remote_field.name}
272
                        cls = rel.related_model
12✔
273

274
    def _patch_proxy_models(self) -> None:
12✔
275
        """
276
        Patch proxy models into the resolver maps.
277
        """
278
        for model in self.models:
12✔
279
            if model._meta.proxy:
12✔
280
                basemodel = proxy_to_base_model(model)
12✔
281
                if basemodel in self._map:
12✔
282
                    self._map[model] = self._map[basemodel]
12✔
283
                if basemodel in self._fk_map:
12✔
284
                    self._fk_map[model] = self._fk_map[basemodel]
12✔
285
                if basemodel in self._local_mro:
12✔
286
                    self._local_mro[model] = self._local_mro[basemodel]
12✔
287
                if basemodel in self._m2m:
12!
UNCOV
288
                    self._m2m[model] = self._m2m[basemodel]
×
289
                self._proxymodels[model] = basemodel or model
12✔
290

291
    def get_local_mro(
12✔
292
        self,
293
        model: Type[Model],
294
        update_fields: Optional[Iterable[str]] = None
295
    ) -> List[str]:
296
        """
297
        Return `MRO` for local computed field methods for a given set of `update_fields`.
298
        The returned list of fieldnames must be calculated in order to correctly update
299
        dependent computed field values in one pass.
300

301
        Returns computed fields as self dependent to simplify local field dependency calculation.
302
        """
303
        # TODO: investigate - memoization of update_fields result? (runs ~4 times faster)
304
        entry = self._local_mro.get(model)
12✔
305
        if not entry:
12✔
306
            return []
12✔
307
        if update_fields is None:
12✔
308
            return entry['base']
12✔
309
        update_fields = frozenset(update_fields)
12✔
310
        base = entry['base']
12✔
311
        fields = entry['fields']
12✔
312
        mro = 0
12✔
313
        for field in update_fields:
12✔
314
            mro |= fields.get(field, 0)
12✔
315
        return [name for pos, name in enumerate(base) if mro & (1 << pos)]
12✔
316

317
    def _querysets_for_update(
12✔
318
        self,
319
        model: Type[Model],
320
        instance: Union[Model, QuerySet],
321
        update_fields: Optional[Iterable[str]] = None,
322
        pk_list: bool = False,
323
        m2m: Optional[Model] = None
324
    ) -> Dict[Type[Model], List[Any]]:
325
        """
326
        Returns a mapping of all dependent models, dependent fields and a
327
        queryset containing all dependent objects.
328
        """
329
        final: Dict[Type[Model], List[Any]] = OrderedDict()
12✔
330
        if self.disabled:
12✔
331
            # TODO: track instance/queryset for context re-plays
332
            return final
12✔
333
        modeldata = self._map.get(model)
12✔
334
        if not modeldata:
12✔
335
            return final
12✔
336
        if not update_fields:
12✔
337
            updates: Set[str] = set(modeldata.keys())
12✔
338
        else:
339
            updates = set()
12✔
340
            for fieldname in update_fields:
12✔
341
                if fieldname in modeldata:
12✔
342
                    updates.add(fieldname)
12✔
343
        subquery = '__in' if isinstance(instance, QuerySet) else ''
12✔
344

345
        # fix #100
346
        # mysql does not support 'LIMIT & IN/ALL/ANY/SOME subquery'
347
        # thus we extract pks explicitly instead
348
        # TODO: cleanup type mess here including this workaround
349
        if isinstance(instance, QuerySet):
12✔
350
            from django.db import connections
12✔
351
            if not instance.query.can_filter() and connections[instance.db].vendor == 'mysql':
12!
UNCOV
352
                instance = set(instance.values_list('pk', flat=True).iterator())
×
353

354
        model_updates: Dict[Type[Model], Tuple[Set[str], Set[str]]] = OrderedDict()
12✔
355
        for update in updates:
12✔
356
            # first aggregate fields and paths to cover
357
            # multiple comp field dependencies
358
            for model, resolver in modeldata[update].items():
12✔
359
                fields, paths = resolver
12✔
360
                m_fields, m_paths = model_updates.setdefault(model, (set(), set()))
12✔
361
                m_fields.update(fields)
12✔
362
                m_paths.update(paths)
12✔
363

364
        # generate narrowed down querysets for all cf dependencies
365
        for model, data in model_updates.items():
12✔
366
            fields, paths = data
12✔
367

368
            # queryset construction
369
            if m2m and self._proxymodels.get(type(m2m), type(m2m)) == model:
12✔
370
                # M2M optimization: got called through an M2M signal
371
                # narrow updates to the single signal instance
372
                queryset = model._base_manager.filter(pk=m2m.pk)
12✔
373
            else:
374
                queryset: Any = model._base_manager.none()
12✔
375
                query_pipe_method = self._choose_optimal_query_pipe_method(paths)
12✔
376
                queryset = reduce(
12✔
377
                    query_pipe_method,
378
                    (model._base_manager.filter(**{path+subquery: instance}) for path in paths),
379
                    queryset
380
                )
381
            if pk_list:
12✔
382
                # need pks for post_delete since the real queryset will be empty
383
                # after deleting the instance in question
384
                # since we need to interact with the db anyways
385
                # we can already drop empty results here
386
                queryset = set(queryset.values_list('pk', flat=True).iterator())
12✔
387
                if not queryset:
12✔
388
                    continue
12✔
389
            # FIXME: change to tuple or dict for narrower type
390
            final[model] = [queryset, fields]
12✔
391
        return final
12✔
392
    
393
    def _get_model(self, instance: Union[Model, QuerySet]) -> Type[Model]:
12✔
394
        return instance.model if isinstance(instance, QuerySet) else type(instance)
12✔
395

396
    def _choose_optimal_query_pipe_method(self, paths: Set[str]) -> Callable:
12✔
397
        """
398
            Choose optimal pipe method, to combine querystes.
399
            Returns `|` if there are only one element or the difference is only the fields name, on the same path.
400
            Otherwise, return union.
401
        """
402
        if len(paths) == 1:
12✔
403
            return operator.or_
12✔
404
        else:
405
            paths_by_parts = tuple(path.split("__") for path in paths)
12✔
406
            if are_same(*(len(path_in_parts) for path_in_parts in paths_by_parts)):
12✔
407
                max_depth = len(paths_by_parts[0]) - 1
12✔
408
                for depth, paths_parts in enumerate(zip(*paths_by_parts)):
12!
409
                    if are_same(*paths_parts):
12✔
410
                        pass
12✔
411
                    else:
412
                        if depth == max_depth:
12✔
413
                            return operator.or_
12✔
414
                        else:
415
                            break
12✔
416
        return lambda x, y: x.union(y)
12✔
417

418
    def preupdate_dependent(
12✔
419
        self,
420
        instance: Union[QuerySet, Model],
421
        model: Optional[Type[Model]] = None,
422
        update_fields: Optional[Iterable[str]] = None,
423
    ) -> Dict[Type[Model], List[Any]]:
424
        """
425
        Create a mapping of currently associated computed field records,
426
        that might turn dirty by a follow-up bulk action.
427

428
        Feed the mapping back to ``update_dependent`` as `old` argument
429
        after your bulk action to update de-associated computed field records as well.
430
        """
431
        return self._querysets_for_update(
12✔
432
            model or self._get_model(instance), instance, update_fields, pk_list=True)
433

434
    def update_dependent(
12✔
435
        self,
436
        instance: Union[QuerySet, Model],
437
        model: Optional[Type[Model]] = None,
438
        update_fields: Optional[Iterable[str]] = None,
439
        old: Optional[Dict[Type[Model], List[Any]]] = None,
440
        update_local: bool = True,
441
        querysize: Optional[int] = None
442
    ) -> None:
443
        """
444
        Updates all dependent computed fields on related models traversing
445
        the dependency tree as shown in the graphs.
446

447
        This is the main entry hook of the resolver to do updates on dependent
448
        computed fields during runtime. While this is done automatically for
449
        model instance actions from signal handlers, you have to call it yourself
450
        after changes done by bulk actions.
451

452
        To do that, simply call this function after the update with the queryset
453
        containing the changed objects:
454

455
            >>> Entry.objects.filter(pub_date__year=2010).update(comments_on=False)
456
            >>> update_dependent(Entry.objects.filter(pub_date__year=2010))
457

458
        This can also be used with ``bulk_create``. Since ``bulk_create``
459
        returns the objects in a python container, you have to create the queryset
460
        yourself, e.g. with pks:
461

462
            >>> objs = Entry.objects.bulk_create([
463
            ...     Entry(headline='This is a test'),
464
            ...     Entry(headline='This is only a test'),
465
            ... ])
466
            >>> pks = set(obj.pk for obj in objs)
467
            >>> update_dependent(Entry.objects.filter(pk__in=pks))
468

469
        .. NOTE::
470

471
            Getting pks from ``bulk_create`` is not supported by all database adapters.
472
            With a local computed field you can "cheat" here by providing a sentinel:
473

474
                >>> MyComputedModel.objects.bulk_create([
475
                ...     MyComputedModel(comp='SENTINEL'), # here or as default field value
476
                ...     MyComputedModel(comp='SENTINEL'),
477
                ... ])
478
                >>> update_dependent(MyComputedModel.objects.filter(comp='SENTINEL'))
479

480
            If the sentinel is beyond reach of the method result, this even ensures to update
481
            only the newly added records.
482

483
        `instance` can also be a single model instance. Since calling ``save`` on a model instance
484
        will trigger this function by the `post_save` signal already it should not be called
485
        for single instances, if they get saved anyway.
486

487
        `update_fields` can be used to indicate, that only certain fields on the queryset changed,
488
        which helps to further narrow down the records to be updated.
489

490
        Special care is needed, if a bulk action contains foreign key changes,
491
        that are part of a computed field dependency chain. To correctly handle that case,
492
        provide the result of ``preupdate_dependent`` as `old` argument like this:
493

494
                >>> # given: some computed fields model depends somehow on Entry.fk_field
495
                >>> old_relations = preupdate_dependent(Entry.objects.filter(pub_date__year=2010))
496
                >>> Entry.objects.filter(pub_date__year=2010).update(fk_field=new_related_obj)
497
                >>> update_dependent(Entry.objects.filter(pub_date__year=2010), old=old_relations)
498

499
        `update_local=False` disables model local computed field updates of the entry node. 
500
        (used as optimization during tree traversal). You should not disable it yourself.
501
        """
502
        if self.disabled:
12✔
503
            # TODO: track instance/queryset for context re-plays
504
            return
12✔
505

506
        _model = model or self._get_model(instance)
12✔
507

508
        # bulk_updater might change fields, ensure we have set/None
509
        _update_fields = None if update_fields is None else set(update_fields)
12✔
510

511
        # Note: update_local is always off for updates triggered from the resolver
512
        # but True by default to avoid accidentally skipping updates called by user
513
        if update_local and self.has_computedfields(_model):
12✔
514
            # We skip a transaction here in the same sense,
515
            # as local cf updates are not guarded either.
516
            queryset = instance if isinstance(instance, QuerySet) \
12✔
517
                else _model._base_manager.filter(pk__in=[instance.pk])
518
            self.bulk_updater(queryset, _update_fields, local_only=True, querysize=querysize)
12✔
519

520
        updates = self._querysets_for_update(_model, instance, _update_fields).values()
12✔
521
        if updates:
12✔
522
            with transaction.atomic():  # FIXME: place transaction only once in tree descent
12✔
523
                pks_updated: Dict[Type[Model], Set[Any]] = {}
12✔
524
                for queryset, fields in updates:
12✔
525
                    _pks = self.bulk_updater(queryset, fields, return_pks=True, querysize=querysize)
12✔
526
                    if _pks:
12✔
527
                        pks_updated[queryset.model] = _pks
12✔
528
                if old:
12✔
529
                    for model2, data in old.items():
12✔
530
                        pks, fields = data
12✔
531
                        queryset = model2.objects.filter(pk__in=pks-pks_updated.get(model2, set()))
12✔
532
                        self.bulk_updater(queryset, fields, querysize=querysize)
12✔
533

534
    def bulk_updater(
12✔
535
        self,
536
        queryset: QuerySet,
537
        update_fields: Optional[Set[str]] = None,
538
        return_pks: bool = False,
539
        local_only: bool = False,
540
        querysize: Optional[int] = None
541
    ) -> Optional[Set[Any]]:
542
        """
543
        Update local computed fields and descent in the dependency tree by calling
544
        ``update_dependent`` for dependent models.
545

546
        This method does the local field updates on `queryset`:
547

548
            - eval local `MRO` of computed fields
549
            - expand `update_fields`
550
            - apply optional `select_related` and `prefetch_related` rules to `queryset`
551
            - walk all records and recalculate fields in `update_fields`
552
            - aggregate changeset and save as batched `bulk_update` to the database
553

554
        By default this method triggers the update of dependent models by calling
555
        ``update_dependent`` with `update_fields` (next level of tree traversal).
556
        This can be suppressed by setting `local_only=True`.
557

558
        If `return_pks` is set, the method returns a set of altered pks of `queryset`.
559
        """
560
        model: Type[Model] = queryset.model
12✔
561

562
        # distinct issue workaround
563
        # the workaround is needed for already sliced/distinct querysets coming from outside
564
        # TODO: distinct is a major query perf smell, and is in fact only needed on back relations
565
        #       may need some rework in _querysets_for_update
566
        #       ideally we find a way to avoid it for forward relations
567
        #       also see #101
568
        if queryset.query.can_filter() and not queryset.query.distinct_fields:
12!
569
            if queryset.query.combinator != "union":
12✔
570
                queryset = queryset.distinct()
12✔
571
        else:
UNCOV
572
            queryset = model._base_manager.filter(pk__in=subquery_pk(queryset, queryset.db))
×
573

574
        # correct update_fields by local mro
575
        mro = self.get_local_mro(model, update_fields)
12✔
576
        fields: Any = set(mro)  # FIXME: narrow type once issue in django-stubs is resolved
12✔
577
        if update_fields:
12✔
578
            update_fields.update(fields)
12✔
579

580
        select = self.get_select_related(model, fields)
12✔
581
        prefetch = self.get_prefetch_related(model, fields)
12✔
582
        if select:
12✔
583
            queryset = queryset.select_related(*select)
12✔
584
        # fix #167: skip prefetch if union was used
585
        if prefetch and queryset.query.combinator != "union":
12✔
586
            queryset = queryset.prefetch_related(*prefetch)
12✔
587

588
        pks = []
12✔
589
        if fields:
12!
590
            q_size = self.get_querysize(model, fields, querysize)
12✔
591
            change: List[Model] = []
12✔
592
            for elem in slice_iterator(queryset, q_size):
12✔
593
                # note on the loop: while it is technically not needed to batch things here,
594
                # we still prebatch to not cause memory issues for very big querysets
595
                has_changed = False
12✔
596
                for comp_field in mro:
12✔
597
                    new_value = self._compute(elem, model, comp_field)
12✔
598
                    if new_value != getattr(elem, comp_field):
12✔
599
                        has_changed = True
12✔
600
                        setattr(elem, comp_field, new_value)
12✔
601
                if has_changed:
12✔
602
                    change.append(elem)
12✔
603
                    pks.append(elem.pk)
12✔
604
                if len(change) >= self._batchsize:
12!
UNCOV
605
                    self._update(model._base_manager.all(), change, fields)
×
UNCOV
606
                    change = []
×
607
            if change:
12✔
608
                self._update(model._base_manager.all(), change, fields)
12✔
609

610
        # trigger dependent comp field updates from changed records
611
        # other than before we exit the update tree early, if we have no changes at all
612
        # also cuts the update tree for recursive deps (tree-like)
613
        if not local_only and pks:
12✔
614
            self.update_dependent(model._base_manager.filter(pk__in=pks), model, fields, update_local=False)
12✔
615
        return set(pks) if return_pks else None
12✔
616
    
617
    def _update(self, queryset: QuerySet, change: Sequence[Any], fields: Sequence[str]) -> Union[int, None]:
12✔
618
        # we can skip batch_size here, as it already was batched in bulk_updater
619
        if self.use_fastupdate:
12!
620
            return fast_update(queryset, change, fields, None)
12✔
UNCOV
621
        return queryset.model._base_manager.bulk_update(change, fields)
×
622

623
    def _compute(self, instance: Model, model: Type[Model], fieldname: str) -> Any:
12✔
624
        """
625
        Returns the computed field value for ``fieldname``.
626
        Note that this is just a shorthand method for calling the underlying computed
627
        field method and does not deal with local MRO, thus should only be used,
628
        if the MRO is respected by other means.
629
        For quick inspection of a single computed field value, that gonna be written
630
        to the database, always use ``compute(fieldname)`` instead.
631
        """
632
        field = self._computed_models[model][fieldname]
12✔
633
        if instance._state.adding or not instance.pk:
12✔
634
            if field._computed['default_on_create']:
12✔
635
                return field.get_default()
12✔
636
        return field._computed['func'](instance)
12✔
637

638
    def compute(self, instance: Model, fieldname: str) -> Any:
12✔
639
        """
640
        Returns the computed field value for ``fieldname``. This method allows
641
        to inspect the new calculated value, that would be written to the database
642
        by a following ``save()``.
643

644
        Other than calling ``update_computedfields`` on an model instance this call
645
        is not destructive for old computed field values.
646
        """
647
        # Getting a single computed value prehand is quite complicated,
648
        # as we have to:
649
        # - resolve local MRO backwards (stored MRO data is optimized for forward deps)
650
        # - calc all local cfs, that the requested one depends on
651
        # - stack and rewind interim values, as we dont want to introduce side effects here
652
        #   (in fact the save/bulker logic might try to save db calls based on changes)
653
        mro = self.get_local_mro(type(instance), None)
12✔
654
        if not fieldname in mro:
12✔
655
            return getattr(instance, fieldname)
12✔
656
        entries = self._local_mro[type(instance)]['fields']
12✔
657
        pos = 1 << mro.index(fieldname)
12✔
658
        stack: List[Tuple[str, Any]] = []
12✔
659
        model = type(instance)
12✔
660
        for field in mro:
12!
661
            if field == fieldname:
12✔
662
                ret = self._compute(instance, model, fieldname)
12✔
663
                for field2, old in stack:
12✔
664
                    # reapply old stack values
665
                    setattr(instance, field2, old)
12✔
666
                return ret
12✔
667
            f_mro = entries.get(field, 0)
12✔
668
            if f_mro & pos:
12✔
669
                # append old value to stack for later rewinding
670
                # calc and set new value for field, if the requested one depends on it
671
                stack.append((field, getattr(instance, field)))
12✔
672
                setattr(instance, field, self._compute(instance, model, field))
12✔
673

674
    # TODO: the following 3 lookups are very expensive at runtime adding ~2s for 1M calls
675
    #       --> all need pregenerated lookup maps
676
    # Note: the same goes for get_local_mro and _queryset_for_update...
677
    def get_select_related(
12✔
678
        self,
679
        model: Type[Model],
680
        fields: Optional[Iterable[str]] = None
681
    ) -> Set[str]:
682
        """
683
        Get defined select_related rules for `fields` (all if none given).
684
        """
685
        if fields is None:
12!
UNCOV
686
            fields = self._computed_models[model].keys()
×
687
        select: Set[str] = set()
12✔
688
        for field in fields:
12✔
689
            select.update(self._computed_models[model][field]._computed['select_related'])
12✔
690
        return select
12✔
691

692
    def get_prefetch_related(
12✔
693
        self,
694
        model: Type[Model],
695
        fields: Optional[Iterable[str]] = None
696
    ) -> List:
697
        """
698
        Get defined prefetch_related rules for `fields` (all if none given).
699
        """
700
        if fields is None:
12!
UNCOV
701
            fields = self._computed_models[model].keys()
×
702
        prefetch: List[Any] = []
12✔
703
        for field in fields:
12✔
704
            prefetch.extend(self._computed_models[model][field]._computed['prefetch_related'])
12✔
705
        return prefetch
12✔
706

707
    def get_querysize(
12✔
708
        self,
709
        model: Type[Model],
710
        fields: Optional[Iterable[str]] = None,
711
        override: Optional[int] = None
712
    ) -> int:
713
        base = settings.COMPUTEDFIELDS_QUERYSIZE if override is None else override
12✔
714
        if fields is None:
12✔
715
            fields = self._computed_models[model].keys()
12✔
716
        return min(self._computed_models[model][f]._computed['querysize'] or base for f in fields)
12✔
717

718
    def get_contributing_fks(self) -> IFkMap:
12✔
719
        """
720
        Get a mapping of models and their local foreign key fields,
721
        that are part of a computed fields dependency chain.
722

723
        Whenever a bulk action changes one of the fields listed here, you have to create
724
        a listing of the associated  records with ``preupdate_dependent`` before doing
725
        the bulk change. After the bulk change feed the listing back to ``update_dependent``
726
        with the `old` argument.
727

728
        With ``COMPUTEDFIELDS_ADMIN = True`` in `settings.py` this mapping can also be
729
        inspected as admin view. 
730
        """
731
        if not self._map_loaded:  # pragma: no cover
732
            raise ResolverException('resolver has no maps loaded yet')
733
        return self._fk_map
12✔
734

735
    def _sanity_check(self, field: Field, depends: IDepends) -> None:
12✔
736
        """
737
        Basic type check for computed field arguments `field` and `depends`.
738
        This only checks for proper type alignment (most crude source of errors) to give
739
        devs an early startup error for misconfigured computed fields.
740
        More subtle errors like non-existing paths or fields are caught
741
        by the resolver during graph reduction yielding somewhat crytic error messages.
742

743
        There is another class of misconfigured computed fields we currently cannot
744
        find by any safety measures - if `depends` provides valid paths and fields,
745
        but the function operates on different dependencies. Currently it is the devs'
746
        responsibility to perfectly align `depends` entries with dependencies
747
        used by the function to avoid faulty update behavior.
748
        """
749
        if not isinstance(field, Field):
12!
UNCOV
750
                raise ResolverException('field argument is not a Field instance')
×
751
        for rule in depends:
12✔
752
            try:
12✔
753
                path, fieldnames = rule
12✔
754
            except ValueError:
×
UNCOV
755
                raise ResolverException(MALFORMED_DEPENDS)
×
756
            if not isinstance(path, str) or not all(isinstance(f, str) for f in fieldnames):
12!
UNCOV
757
                raise ResolverException(MALFORMED_DEPENDS)
×
758

759
    def computedfield_factory(
12✔
760
        self,
761
        field: 'Field[_ST, _GT]',
762
        compute: Callable[..., _ST],
763
        depends: Optional[IDepends] = None,
764
        select_related: Optional[Sequence[str]] = None,
765
        prefetch_related: Optional[Sequence[Any]] = None,
766
        querysize: Optional[int] = None,
767
        default_on_create: Optional[bool] = False
768
    ) -> 'Field[_ST, _GT]':
769
        """
770
        Factory for computed fields.
771

772
        The method gets exposed as ``ComputedField`` to allow a more declarative
773
        code style with better separation of field declarations and function
774
        implementations. It is also used internally for the ``computed`` decorator.
775
        Similar to the decorator, the ``compute`` function expects a single argument
776
        as model instance of the model it got applied to.
777

778
        Usage example:
779

780
        .. code-block:: python
781

782
            from computedfields.models import ComputedField
783

784
            def calc_mul(inst):
785
                return inst.a * inst.b
786

787
            class MyModel(ComputedFieldsModel):
788
                a = models.IntegerField()
789
                b = models.IntegerField()
790
                sum = ComputedField(
791
                    models.IntegerField(),
792
                    depends=[('self', ['a', 'b'])],
793
                    compute=lambda inst: inst.a + inst.b
794
                )
795
                mul = ComputedField(
796
                    models.IntegerField(),
797
                    depends=[('self', ['a', 'b'])],
798
                    compute=calc_mul
799
                )
800
        """
801
        self._sanity_check(field, depends or [])
12✔
802
        cf = cast('IComputedField[_ST, _GT]', field)
12✔
803
        cf._computed = {
12✔
804
            'func': compute,
805
            'depends': depends or [],
806
            'select_related': select_related or [],
807
            'prefetch_related': prefetch_related or [],
808
            'querysize': querysize,
809
            'default_on_create': default_on_create
810
        }
811
        cf.editable = False
12✔
812
        self.add_field(cf)
12✔
813
        return field
12✔
814

815
    def computed(
12✔
816
        self,
817
        field: 'Field[_ST, _GT]',
818
        depends: Optional[IDepends] = None,
819
        select_related: Optional[Sequence[str]] = None,
820
        prefetch_related: Optional[Sequence[Any]] = None,
821
        querysize: Optional[int] = None,
822
        default_on_create: Optional[bool] = False
823
    ) -> Callable[[Callable[..., _ST]], 'Field[_ST, _GT]']:
824
        """
825
        Decorator to create computed fields.
826

827
        `field` should be a model concrete field instance suitable to hold the result
828
        of the decorated method. The decorator expects a keyword argument `depends`
829
        to indicate dependencies to model fields (local or related).
830
        Listed dependencies will automatically update the computed field.
831

832
        Examples:
833

834
            - create a char field with no further dependencies (not very useful)
835

836
            .. code-block:: python
837

838
                @computed(models.CharField(max_length=32))
839
                def ...
840

841
            - create a char field with a dependency to the field ``name`` on a
842
              foreign key relation ``fk``
843

844
            .. code-block:: python
845

846
                @computed(models.CharField(max_length=32), depends=[('fk', ['name'])])
847
                def ...
848

849
        Dependencies should be listed as ``['relation_name', concrete_fieldnames]``.
850
        The relation can span serveral models, simply name the relation
851
        in python style with a dot (e.g. ``'a.b.c'``). A relation can be any of
852
        foreign key, m2m, o2o and their back relations. The fieldnames must point to
853
        concrete fields on the foreign model.
854

855
        .. NOTE::
856

857
            Dependencies to model local fields should be list with ``'self'`` as relation name.
858

859
        With `select_related` and `prefetch_related` you can instruct the dependency resolver
860
        to apply certain optimizations on the update queryset.
861

862
        .. NOTE::
863

864
            `select_related` and `prefetch_related` are stacked over computed fields
865
            of the same model during updates, that are marked for update.
866
            If your optimizations contain custom attributes (as with `to_attr` of a
867
            `Prefetch` object), these attributes will only be available on instances
868
            during updates from the resolver, never on newly constructed instances or
869
            model instances pulled by other means, unless you applied the same lookups manually.
870

871
            To keep the computed field methods working under any circumstances,
872
            it is a good idea not to rely on lookups with custom attributes,
873
            or to test explicitly for them in the method with an appropriate plan B.
874

875
        With `default_on_create` set to ``True`` the function calculation will be skipped
876
        for newly created or copy-cloned instances, instead the value will be set from the
877
        inner field's `default` argument.
878

879
        .. CAUTION::
880

881
            With the dependency resolver you can easily create recursive dependencies
882
            by accident. Imagine the following:
883

884
            .. code-block:: python
885

886
                class A(ComputedFieldsModel):
887
                    @computed(models.CharField(max_length=32), depends=[('b_set', ['comp'])])
888
                    def comp(self):
889
                        return ''.join(b.comp for b in self.b_set.all())
890

891
                class B(ComputedFieldsModel):
892
                    a = models.ForeignKey(A)
893

894
                    @computed(models.CharField(max_length=32), depends=[('a', ['comp'])])
895
                    def comp(self):
896
                        return a.comp
897

898
            Neither an object of `A` or `B` can be saved, since the ``comp`` fields depend on
899
            each other. While it is quite easy to spot for this simple case it might get tricky
900
            for more complicated dependencies. Therefore the dependency resolver tries
901
            to detect cyclic dependencies and might raise a ``CycleNodeException`` during
902
            startup.
903

904
            If you experience this in your project try to get in-depth cycle
905
            information, either by using the ``rendergraph`` management command or
906
            by directly accessing the graph objects:
907

908
            - intermodel dependency graph: ``active_resolver._graph``
909
            - mode local dependency graphs: ``active_resolver._graph.modelgraphs[your_model]``
910
            - union graph: ``active_resolver._graph.get_uniongraph()``
911

912
            Note that there is not graph object, when running with ``COMPUTEDFIELDS_MAP = True``.
913
            In that case either comment out that line `settings.py` and restart the server
914
            or build the graph at runtime with:
915

916
                >>> from computedfields.graph import ComputedModelsGraph
917
                >>> from computedfields.resolver import active_resolver
918
                >>> graph = ComputedModelsGraph(active_resolver.computed_models)
919

920
            Also see the graph documentation :ref:`here<graph>`.
921
        """
922
        def wrap(func: Callable[..., _ST]) -> 'Field[_ST, _GT]':
12✔
923
            return self.computedfield_factory(
12✔
924
                field,
925
                compute=func,
926
                depends=depends,
927
                select_related=select_related,
928
                prefetch_related=prefetch_related,
929
                querysize=querysize,
930
                default_on_create=default_on_create
931
            )
932
        return wrap
12✔
933

934
    @overload
12✔
935
    def precomputed(self, f: F) -> F:
12✔
UNCOV
936
        ...
×
937
    @overload
12✔
938
    def precomputed(self, skip_after: bool) -> Callable[[F], F]:
12✔
UNCOV
939
        ...
×
940
    def precomputed(self, *dargs, **dkwargs) -> Union[F, Callable[[F], F]]:
12✔
941
        """
942
        Decorator for custom ``save`` methods, that expect local computed fields
943
        to contain already updated values on enter.
944

945
        By default local computed field values are only calculated once by the
946
        ``ComputedFieldModel.save`` method after your own save method.
947

948
        By placing this decorator on your save method, the values will be updated
949
        before entering your method as well. Note that this comes for the price of
950
        doubled local computed field calculations (before and after your save method).
951
        
952
        To avoid a second recalculation, the decorator can be called with `skip_after=True`.
953
        Note that this might lead to desychronized computed field values, if you do late
954
        field changes in your save method without another resync afterwards.
955
        """
956
        skip: bool = False
12✔
957
        func: Optional[F] = None
12✔
958
        if dargs:
12✔
959
            if len(dargs) > 1 or not callable(dargs[0]) or dkwargs:
12!
UNCOV
960
                raise ResolverException('error in @precomputed declaration')
×
961
            func = dargs[0]
12✔
962
        else:
963
            skip = dkwargs.get('skip_after', False)
12✔
964
        
965
        def wrap(func: F) -> F:
12✔
966
            def _save(instance, *args, **kwargs):
12✔
967
                new_fields = self.update_computedfields(instance, kwargs.get('update_fields'))
12✔
968
                if new_fields:
12!
UNCOV
969
                    kwargs['update_fields'] = new_fields
×
970
                kwargs['skip_computedfields'] = skip
12✔
971
                return func(instance, *args, **kwargs)
12✔
972
            return cast(F, _save)
12✔
973
        
974
        return wrap(func) if func else wrap
12✔
975

976
    def update_computedfields(
12✔
977
        self,
978
        instance: Model,
979
        update_fields: Optional[Iterable[str]] = None
980
        ) -> Optional[Iterable[str]]:
981
        """
982
        Update values of local computed fields of `instance`.
983

984
        Other than calling ``compute`` on an instance, this call overwrites
985
        computed field values on the instance (destructive).
986

987
        Returns ``None`` or an updated set of field names for `update_fields`.
988
        The returned fields might contained additional computed fields, that also
989
        changed based on the input fields, thus should extend `update_fields`
990
        on a save call.
991
        """
992
        model = type(instance)
12✔
993
        if not self.has_computedfields(model):
12✔
994
            return update_fields
12✔
995
        cf_mro = self.get_local_mro(model, update_fields)
12✔
996
        if update_fields:
12✔
997
            update_fields = set(update_fields)
12✔
998
            update_fields.update(set(cf_mro))
12✔
999
        for fieldname in cf_mro:
12✔
1000
            setattr(instance, fieldname, self._compute(instance, model, fieldname))
12✔
1001
        if update_fields:
12✔
1002
            return update_fields
12✔
1003
        return None
12✔
1004

1005
    def has_computedfields(self, model: Type[Model]) -> bool:
12✔
1006
        """
1007
        Indicate whether `model` has computed fields.
1008
        """
1009
        return model in self._computed_models
12✔
1010

1011
    def get_computedfields(self, model: Type[Model]) -> Iterable[str]:
12✔
1012
        """
1013
        Get all computed fields on `model`.
1014
        """
1015
        return self._computed_models.get(model, {}).keys()
12✔
1016

1017
    def is_computedfield(self, model: Type[Model], fieldname: str) -> bool:
12✔
1018
        """
1019
        Indicate whether `fieldname` on `model` is a computed field.
1020
        """
1021
        return fieldname in self.get_computedfields(model)
12✔
1022

1023
    def get_graphs(self) -> Tuple[Graph, Dict[Type[Model], ModelGraph], Graph]:
12✔
1024
        """
1025
        Return a tuple of all graphs as
1026
        ``(intermodel_graph, {model: modelgraph, ...}, union_graph)``.
1027
        """
UNCOV
1028
        graph = self._graph
×
1029
        if not graph:
×
1030
            graph = ComputedModelsGraph(active_resolver.computed_models)
×
1031
            graph.get_edgepaths()
×
1032
            graph.get_uniongraph()
×
1033
        return (graph, graph.modelgraphs, graph.get_uniongraph())
×
1034

1035

1036
# active_resolver is currently treated as global singleton (used in imports)
1037
#: Currently active resolver.
1038
active_resolver = Resolver()
12✔
1039

1040
# BOOT_RESOLVER: resolver that holds all startup declarations and resolve maps
1041
# gets deactivated after startup, thus it is currently not possible to define
1042
# new computed fields and add their resolve rules at runtime
1043
# TODO: investigate on custom resolvers at runtime to be bootstrapped from BOOT_RESOLVER
1044
#: Resolver used during django bootstrapping.
1045
#: This is currently the same as `active_resolver` (treated as global singleton).
1046
BOOT_RESOLVER = active_resolver
12✔
1047

1048

1049
# placeholder class to test for correct model inheritance
1050
# during initial field resolving
1051
class _ComputedFieldsModelBase:
12✔
1052
    pass
12✔
1053

1054

1055
class NoComputedContextManager:
12✔
1056
    """
1057
    Context to disable all computed fields resolver updates temporarily.
1058

1059
    Note. that local computed fields will still be calculated during save calls,
1060
    use the `skip_computedfields=True` argument on save to also disable those.
1061

1062
    .. CAUTION::
1063

1064
        Currently there is no auto-recovery implemented at all,
1065
        therefore it is your responsibility to recover properly from the desync state.
1066
    """
1067
    def __init__(self, resolver=None):
12✔
1068
        self.resolver = resolver or active_resolver
12✔
1069

1070
    def __enter__(self):
12✔
1071
        self.resolver.disabled = True
12✔
1072
        return self
12✔
1073

1074
    def __exit__(self, exc_type, exc_value, traceback):
12✔
1075
        self.resolver.disabled = False
12✔
1076
        # TODO: re-play aggragated changes
1077
        return False
12✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc