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

netzkolchose / django-computedfields / 16199849878

10 Jul 2025 03:50PM UTC coverage: 94.986% (+0.06%) from 94.927%
16199849878

Pull #181

github

web-flow
Merge 477f7216f into 90ab39f94
Pull Request #181: signals

481 of 522 branches covered (92.15%)

Branch coverage included in aggregate %.

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

14 existing lines in 1 file now uncovered.

1167 of 1213 relevant lines covered (96.21%)

11.54 hits per line

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

93.71
/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
from .signals import resolver_start, resolver_exit, resolver_update
12✔
18

19
from fast_update.fast import fast_update
12✔
20

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

28

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

34

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

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

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

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

57

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

63

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

68
    Basic workflow:
69

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

200
        return computed_models
12✔
201

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

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

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

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

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

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

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

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

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

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

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

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

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

359
        # generate narrowed down querysets for all cf dependencies
360
        for model, data in model_updates.items():
12✔
361
            fields, paths = data
12✔
362

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

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

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

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

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

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

448
        To do that, simply call this function after the update with the queryset
449
        containing the changed objects:
450

451
            >>> Entry.objects.filter(pub_date__year=2010).update(comments_on=False)
452
            >>> update_dependent(Entry.objects.filter(pub_date__year=2010))
453

454
        This can also be used with ``bulk_create``. Since ``bulk_create``
455
        returns the objects in a python container, you have to create the queryset
456
        yourself, e.g. with pks:
457

458
            >>> objs = Entry.objects.bulk_create([
459
            ...     Entry(headline='This is a test'),
460
            ...     Entry(headline='This is only a test'),
461
            ... ])
462
            >>> pks = set(obj.pk for obj in objs)
463
            >>> update_dependent(Entry.objects.filter(pk__in=pks))
464

465
        .. NOTE::
466

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

470
                >>> MyComputedModel.objects.bulk_create([
471
                ...     MyComputedModel(comp='SENTINEL'), # here or as default field value
472
                ...     MyComputedModel(comp='SENTINEL'),
473
                ... ])
474
                >>> update_dependent(MyComputedModel.objects.filter(comp='SENTINEL'))
475

476
            If the sentinel is beyond reach of the method result, this even ensures to update
477
            only the newly added records.
478

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

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

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

490
                >>> # given: some computed fields model depends somehow on Entry.fk_field
491
                >>> old_relations = preupdate_dependent(Entry.objects.filter(pub_date__year=2010))
492
                >>> Entry.objects.filter(pub_date__year=2010).update(fk_field=new_related_obj)
493
                >>> update_dependent(Entry.objects.filter(pub_date__year=2010), old=old_relations)
494

495
        `update_local=False` disables model local computed field updates of the entry node. 
496
        (used as optimization during tree traversal). You should not disable it yourself.
497
        """
498
        _model = model or self._get_model(instance)
12✔
499

500
        # bulk_updater might change fields, ensure we have set/None
501
        _update_fields = None if update_fields is None else set(update_fields)
12✔
502

503
        if not _is_recursive:
12✔
504
            resolver_start.send(sender=self)
12✔
505

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

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

529
        if not _is_recursive:
12✔
530
            resolver_exit.send(sender=self)
12✔
531

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

544
        This method does the local field updates on `queryset`:
545

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

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

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

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

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

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

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

608
            if pks:
12✔
609
                resolver_update.send(sender=self, model=model, fields=fields, pks=pks)
12✔
610

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

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

645
    def compute(self, instance: Model, fieldname: str) -> Any:
12✔
646
        """
647
        Returns the computed field value for ``fieldname``. This method allows
648
        to inspect the new calculated value, that would be written to the database
649
        by a following ``save()``.
650

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

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

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

714
    def get_querysize(
12✔
715
        self,
716
        model: Type[Model],
717
        fields: Optional[Iterable[str]] = None,
718
        override: Optional[int] = None
719
    ) -> int:
720
        base = settings.COMPUTEDFIELDS_QUERYSIZE if override is None else override
12✔
721
        if fields is None:
12✔
722
            fields = self._computed_models[model].keys()
12✔
723
        return min(self._computed_models[model][f]._computed['querysize'] or base for f in fields)
12✔
724

725
    def get_contributing_fks(self) -> IFkMap:
12✔
726
        """
727
        Get a mapping of models and their local foreign key fields,
728
        that are part of a computed fields dependency chain.
729

730
        Whenever a bulk action changes one of the fields listed here, you have to create
731
        a listing of the associated  records with ``preupdate_dependent`` before doing
732
        the bulk change. After the bulk change feed the listing back to ``update_dependent``
733
        with the `old` argument.
734

735
        With ``COMPUTEDFIELDS_ADMIN = True`` in `settings.py` this mapping can also be
736
        inspected as admin view. 
737
        """
738
        if not self._map_loaded:  # pragma: no cover
739
            raise ResolverException('resolver has no maps loaded yet')
740
        return self._fk_map
12✔
741

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

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

766
    def computedfield_factory(
12✔
767
        self,
768
        field: 'Field[_ST, _GT]',
769
        compute: Callable[..., _ST],
770
        depends: Optional[IDepends] = None,
771
        select_related: Optional[Sequence[str]] = None,
772
        prefetch_related: Optional[Sequence[Any]] = None,
773
        querysize: Optional[int] = None,
774
        default_on_create: Optional[bool] = False
775
    ) -> 'Field[_ST, _GT]':
776
        """
777
        Factory for computed fields.
778

779
        The method gets exposed as ``ComputedField`` to allow a more declarative
780
        code style with better separation of field declarations and function
781
        implementations. It is also used internally for the ``computed`` decorator.
782
        Similar to the decorator, the ``compute`` function expects a single argument
783
        as model instance of the model it got applied to.
784

785
        Usage example:
786

787
        .. code-block:: python
788

789
            from computedfields.models import ComputedField
790

791
            def calc_mul(inst):
792
                return inst.a * inst.b
793

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

822
    def computed(
12✔
823
        self,
824
        field: 'Field[_ST, _GT]',
825
        depends: Optional[IDepends] = None,
826
        select_related: Optional[Sequence[str]] = None,
827
        prefetch_related: Optional[Sequence[Any]] = None,
828
        querysize: Optional[int] = None,
829
        default_on_create: Optional[bool] = False
830
    ) -> Callable[[Callable[..., _ST]], 'Field[_ST, _GT]']:
831
        """
832
        Decorator to create computed fields.
833

834
        `field` should be a model concrete field instance suitable to hold the result
835
        of the decorated method. The decorator expects a keyword argument `depends`
836
        to indicate dependencies to model fields (local or related).
837
        Listed dependencies will automatically update the computed field.
838

839
        Examples:
840

841
            - create a char field with no further dependencies (not very useful)
842

843
            .. code-block:: python
844

845
                @computed(models.CharField(max_length=32))
846
                def ...
847

848
            - create a char field with a dependency to the field ``name`` on a
849
              foreign key relation ``fk``
850

851
            .. code-block:: python
852

853
                @computed(models.CharField(max_length=32), depends=[('fk', ['name'])])
854
                def ...
855

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

862
        .. NOTE::
863

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

866
        With `select_related` and `prefetch_related` you can instruct the dependency resolver
867
        to apply certain optimizations on the update queryset.
868

869
        .. NOTE::
870

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

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

882
        With `default_on_create` set to ``True`` the function calculation will be skipped
883
        for newly created or copy-cloned instances, instead the value will be set from the
884
        inner field's `default` argument.
885

886
        .. CAUTION::
887

888
            With the dependency resolver you can easily create recursive dependencies
889
            by accident. Imagine the following:
890

891
            .. code-block:: python
892

893
                class A(ComputedFieldsModel):
894
                    @computed(models.CharField(max_length=32), depends=[('b_set', ['comp'])])
895
                    def comp(self):
896
                        return ''.join(b.comp for b in self.b_set.all())
897

898
                class B(ComputedFieldsModel):
899
                    a = models.ForeignKey(A)
900

901
                    @computed(models.CharField(max_length=32), depends=[('a', ['comp'])])
902
                    def comp(self):
903
                        return a.comp
904

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

911
            If you experience this in your project try to get in-depth cycle
912
            information, either by using the ``rendergraph`` management command or
913
            by directly accessing the graph objects:
914

915
            - intermodel dependency graph: ``active_resolver._graph``
916
            - mode local dependency graphs: ``active_resolver._graph.modelgraphs[your_model]``
917
            - union graph: ``active_resolver._graph.get_uniongraph()``
918

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

923
                >>> from computedfields.graph import ComputedModelsGraph
924
                >>> from computedfields.resolver import active_resolver
925
                >>> graph = ComputedModelsGraph(active_resolver.computed_models)
926

927
            Also see the graph documentation :ref:`here<graph>`.
928
        """
929
        def wrap(func: Callable[..., _ST]) -> 'Field[_ST, _GT]':
12✔
930
            return self.computedfield_factory(
12✔
931
                field,
932
                compute=func,
933
                depends=depends,
934
                select_related=select_related,
935
                prefetch_related=prefetch_related,
936
                querysize=querysize,
937
                default_on_create=default_on_create
938
            )
939
        return wrap
12✔
940

941
    @overload
12✔
942
    def precomputed(self, f: F) -> F:
12✔
UNCOV
943
        ...
×
944
    @overload
12✔
945
    def precomputed(self, skip_after: bool) -> Callable[[F], F]:
12✔
UNCOV
946
        ...
×
947
    def precomputed(self, *dargs, **dkwargs) -> Union[F, Callable[[F], F]]:
12✔
948
        """
949
        Decorator for custom ``save`` methods, that expect local computed fields
950
        to contain already updated values on enter.
951

952
        By default local computed field values are only calculated once by the
953
        ``ComputedFieldModel.save`` method after your own save method.
954

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

983
    def update_computedfields(
12✔
984
        self,
985
        instance: Model,
986
        update_fields: Optional[Iterable[str]] = None
987
        ) -> Optional[Iterable[str]]:
988
        """
989
        Update values of local computed fields of `instance`.
990

991
        Other than calling ``compute`` on an instance, this call overwrites
992
        computed field values on the instance (destructive).
993

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

1012
    def has_computedfields(self, model: Type[Model]) -> bool:
12✔
1013
        """
1014
        Indicate whether `model` has computed fields.
1015
        """
1016
        return model in self._computed_models
12✔
1017

1018
    def get_computedfields(self, model: Type[Model]) -> Iterable[str]:
12✔
1019
        """
1020
        Get all computed fields on `model`.
1021
        """
1022
        return self._computed_models.get(model, {}).keys()
12✔
1023

1024
    def is_computedfield(self, model: Type[Model], fieldname: str) -> bool:
12✔
1025
        """
1026
        Indicate whether `fieldname` on `model` is a computed field.
1027
        """
1028
        return fieldname in self.get_computedfields(model)
12✔
1029

1030
    def get_graphs(self) -> Tuple[Graph, Dict[Type[Model], ModelGraph], Graph]:
12✔
1031
        """
1032
        Return a tuple of all graphs as
1033
        ``(intermodel_graph, {model: modelgraph, ...}, union_graph)``.
1034
        """
1035
        graph = self._graph
×
1036
        if not graph:
×
1037
            graph = ComputedModelsGraph(active_resolver.computed_models)
×
1038
            graph.get_edgepaths()
×
UNCOV
1039
            graph.get_uniongraph()
×
UNCOV
1040
        return (graph, graph.modelgraphs, graph.get_uniongraph())
×
1041

1042

1043
# active_resolver is currently treated as global singleton (used in imports)
1044
#: Currently active resolver.
1045
active_resolver = Resolver()
12✔
1046

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

1055

1056
# placeholder class to test for correct model inheritance
1057
# during initial field resolving
1058
class _ComputedFieldsModelBase:
12✔
1059
    pass
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