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

netzkolchose / django-computedfields / 16205302055

10 Jul 2025 08:27PM UTC coverage: 94.867% (-0.4%) from 95.221%
16205302055

Pull #183

github

web-flow
Merge 025d1eaf7 into 1558d3012
Pull Request #183: reduce transaction pressure

470 of 509 branches covered (92.34%)

Branch coverage included in aggregate %.

12 of 12 new or added lines in 1 file covered. (100.0%)

4 existing lines in 1 file now uncovered.

1175 of 1225 relevant lines covered (95.92%)

11.51 hits per line

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

92.66
/computedfields/resolver.py
1
"""
2
Contains the resolver logic for automated computed field updates.
3
"""
4
from .thread_locals import get_not_computed_context, set_not_computed_context
12✔
5
import operator
12✔
6
from collections import OrderedDict
12✔
7
from functools import reduce
12✔
8

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

12
from .settings import settings
12✔
13
from .graph import ComputedModelsGraph, ComputedFieldsException, Graph, ModelGraph, IM2mMap
12✔
14
from .helpers import proxy_to_base_model, slice_iterator, subquery_pk, are_same
12✔
15
from . import __version__
12✔
16
from .signals import resolver_start, resolver_exit, resolver_update
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 django.db.models import Field, Model
12✔
24
from .graph import IComputedField, IDepends, IFkMap, ILocalMroMap, ILookupMap, _ST, _GT, F
12✔
25

26

27
MALFORMED_DEPENDS = """
12✔
28
Your depends keyword argument is malformed.
29

30
The depends keyword should either be None, an empty listing or
31
a listing of rules as depends=[rule1, rule2, .. ruleN].
32

33
A rule is formed as ('relation.path', ['list', 'of', 'fieldnames']) tuple.
34
The relation path either contains 'self' for fieldnames on the same model,
35
or a string as 'a.b.c', where 'a' is a relation on the current model
36
descending over 'b' to 'c' to pull fieldnames from 'c'. The denoted fieldnames
37
must be concrete fields on the rightmost model of the relation path.
38

39
Example:
40
depends=[
41
    ('self', ['name', 'status']),
42
    ('parent.color', ['value'])
43
]
44
This has 2 path rules - one for fields 'name' and 'status' on the same model,
45
and one to a field 'value' on a foreign model, which is accessible from
46
the current model through self -> parent -> color relation.
47
"""
48

49

50
class ResolverException(ComputedFieldsException):
12✔
51
    """
52
    Exception raised during model and field registration or dependency resolving.
53
    """
54

55

56
class Resolver:
12✔
57
    """
58
    Holds the needed data for graph calculations and runtime dependency resolving.
59

60
    Basic workflow:
61

62
        - On django startup a resolver gets instantiated early to track all project-wide
63
          model registrations and computed field decorations (collector phase).
64
        - On `app.ready` the computed fields are associated with their models to build
65
          a resolver-wide map of models with computed fields (``computed_models``).
66
        - After that the resolver maps are created (see `graph.ComputedModelsGraph`).
67
    """
68

69
    def __init__(self):
12✔
70
        # collector phase data
71
        #: Models from `class_prepared` signal hook during collector phase.
72
        self.models: Set[Type[Model]] = set()
12✔
73
        #: Computed fields found during collector phase.
74
        self.computedfields: Set[IComputedField] = set()
12✔
75

76
        # resolving phase data and final maps
77
        self._graph: Optional[ComputedModelsGraph] = None
12✔
78
        self._computed_models: Dict[Type[Model], Dict[str, IComputedField]] = {}
12✔
79
        self._map: ILookupMap = {}
12✔
80
        self._fk_map: IFkMap = {}
12✔
81
        self._local_mro: ILocalMroMap = {}
12✔
82
        self._m2m: IM2mMap = {}
12✔
83
        self._proxymodels: Dict[Type[Model], Type[Model]] = {}
12✔
84
        self.use_fastupdate: bool = settings.COMPUTEDFIELDS_FASTUPDATE
12✔
85
        self._batchsize: int = (settings.COMPUTEDFIELDS_BATCHSIZE_FAST
12✔
86
            if self.use_fastupdate else settings.COMPUTEDFIELDS_BATCHSIZE_BULK)
87

88
        # some internal states
89
        self._sealed: bool = False        # initial boot phase
12✔
90
        self._initialized: bool = False   # initialized (computed_models populated)?
12✔
91
        self._map_loaded: bool = False    # final stage with fully loaded maps
12✔
92

93
    def add_model(self, sender: Type[Model], **kwargs) -> None:
12✔
94
        """
95
        `class_prepared` signal hook to collect models during ORM registration.
96
        """
97
        if self._sealed:
12✔
98
            raise ResolverException('cannot add models on sealed resolver')
12✔
99
        self.models.add(sender)
12✔
100

101
    def add_field(self, field: IComputedField) -> None:
12✔
102
        """
103
        Collects fields from decoration stage of @computed.
104
        """
105
        if self._sealed:
12✔
106
            raise ResolverException('cannot add computed fields on sealed resolver')
12✔
107
        self.computedfields.add(field)
12✔
108

109
    def seal(self) -> None:
12✔
110
        """
111
        Seal the resolver, so no new models or computed fields can be added anymore.
112

113
        This marks the end of the collector phase and is a basic security measure
114
        to catch runtime model creations with computed fields.
115

116
        (Currently runtime creation of models with computed fields is not supported,
117
        trying to do so will raise an exception. This might change in future versions.)
118
        """
119
        self._sealed = True
12✔
120

121
    @property
12✔
122
    def models_with_computedfields(self) -> Generator[Tuple[Type[Model], Set[IComputedField]], None, None]:
12✔
123
        """
124
        Generator of tracked models with their computed fields.
125

126
        This cannot be accessed during the collector phase.
127
        """
128
        if not self._sealed:
12✔
129
            raise ResolverException('resolver must be sealed before accessing models or fields')
12✔
130

131
        field_ids: List[int] = [f.creation_counter for f in self.computedfields]
12✔
132
        for model in self.models:
12✔
133
            fields = set()
12✔
134
            for field in model._meta.fields:
12✔
135
                # for some reason the in ... check does not work for Django >= 3.2 anymore
136
                # workaround: check for _computed and the field creation_counter
137
                if hasattr(field, '_computed') and field.creation_counter in field_ids:
12✔
138
                    fields.add(field)
12✔
139
            if fields:
12✔
140
                yield (model, fields)
12✔
141

142
    @property
12✔
143
    def computedfields_with_models(self) -> Generator[Tuple[IComputedField, Set[Type[Model]]], None, None]:
12✔
144
        """
145
        Generator of tracked computed fields and their models.
146

147
        This cannot be accessed during the collector phase.
148
        """
149
        if not self._sealed:
12✔
150
            raise ResolverException('resolver must be sealed before accessing models or fields')
12✔
151

152
        for field in self.computedfields:
12✔
153
            models = set()
12✔
154
            for model in self.models:
12✔
155
                for f in model._meta.fields:
12✔
156
                    if hasattr(field, '_computed') and f.creation_counter == field.creation_counter:
12✔
157
                        models.add(model)
12✔
158
            yield (field, models)
12✔
159

160
    @property
12✔
161
    def computed_models(self) -> Dict[Type[Model], Dict[str, IComputedField]]:
12✔
162
        """
163
        Mapping of `ComputedFieldModel` models and their computed fields.
164

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

179
    def extract_computed_models(self) -> Dict[Type[Model], Dict[str, IComputedField]]:
12✔
180
        """
181
        Creates `computed_models` mapping from models and computed fields
182
        found in collector phase.
183
        """
184
        computed_models: Dict[Type[Model], Dict[str, IComputedField]] = {}
12✔
185
        for model, computedfields in self.models_with_computedfields:
12✔
186
            if not issubclass(model, _ComputedFieldsModelBase):
12✔
187
                raise ResolverException(f'{model} is not a subclass of ComputedFieldsModel')
12✔
188
            computed_models[model] = {}
12✔
189
            for field in computedfields:
12✔
190
                computed_models[model][field.name] = field
12✔
191

192
        return computed_models
12✔
193

194
    def initialize(self, models_only: bool = False) -> None:
12✔
195
        """
196
        Entrypoint for ``app.ready`` to seal the resolver and trigger
197
        the resolver map creation.
198

199
        Upon instantiation the resolver is in the collector phase, where it tracks
200
        model registrations and computed field decorations.
201

202
        After calling ``initialize`` no more models or fields can be registered
203
        to the resolver, and ``computed_models`` and the resolver maps get loaded.
204
        """
205
        # resolver must be sealed before doing any map calculations
206
        self.seal()
12✔
207
        self._computed_models = self.extract_computed_models()
12✔
208
        self._initialized = True
12✔
209
        if not models_only:
12✔
210
            self.load_maps()
12✔
211

212
    def load_maps(self, _force_recreation: bool = False) -> None:
12✔
213
        """
214
        Load all needed resolver maps. The steps are:
215

216
            - create intermodel graph of the dependencies
217
            - remove redundant paths with cycling check
218
            - create modelgraphs for local MRO
219
            - merge graphs to uniongraph with cycling check
220
            - create final resolver maps
221

222
                - `lookup_map`: intermodel dependencies as queryset access strings
223
                - `fk_map`: models with their contributing fk fields
224
                - `local_mro`: MRO of local computed fields per model
225
        """
226
        self._graph = ComputedModelsGraph(self.computed_models)
12✔
227
        if not getattr(settings, 'COMPUTEDFIELDS_ALLOW_RECURSION', False):
12✔
228
            self._graph.get_edgepaths()
12✔
229
            self._graph.get_uniongraph().get_edgepaths()
12✔
230
        self._map, self._fk_map = self._graph.generate_maps()
12✔
231
        self._local_mro = self._graph.generate_local_mro_map()
12✔
232
        self._m2m = self._graph._m2m
12✔
233
        self._patch_proxy_models()
12✔
234
        self._map_loaded = True
12✔
235

236
    def _patch_proxy_models(self) -> None:
12✔
237
        """
238
        Patch proxy models into the resolver maps.
239
        """
240
        for model in self.models:
12✔
241
            if model._meta.proxy:
12✔
242
                basemodel = proxy_to_base_model(model)
12✔
243
                if basemodel in self._map:
12✔
244
                    self._map[model] = self._map[basemodel]
12✔
245
                if basemodel in self._fk_map:
12✔
246
                    self._fk_map[model] = self._fk_map[basemodel]
12✔
247
                if basemodel in self._local_mro:
12✔
248
                    self._local_mro[model] = self._local_mro[basemodel]
12✔
249
                if basemodel in self._m2m:
12!
250
                    self._m2m[model] = self._m2m[basemodel]
×
251
                self._proxymodels[model] = basemodel or model
12✔
252

253
    def get_local_mro(
12✔
254
        self,
255
        model: Type[Model],
256
        update_fields: Optional[Iterable[str]] = None
257
    ) -> List[str]:
258
        """
259
        Return `MRO` for local computed field methods for a given set of `update_fields`.
260
        The returned list of fieldnames must be calculated in order to correctly update
261
        dependent computed field values in one pass.
262

263
        Returns computed fields as self dependent to simplify local field dependency calculation.
264
        """
265
        # TODO: investigate - memoization of update_fields result? (runs ~4 times faster)
266
        entry = self._local_mro.get(model)
12✔
267
        if not entry:
12✔
268
            return []
12✔
269
        if update_fields is None:
12✔
270
            return entry['base']
12✔
271
        update_fields = frozenset(update_fields)
12✔
272
        base = entry['base']
12✔
273
        fields = entry['fields']
12✔
274
        mro = 0
12✔
275
        for field in update_fields:
12✔
276
            mro |= fields.get(field, 0)
12✔
277
        return [name for pos, name in enumerate(base) if mro & (1 << pos)]
12✔
278

279
    def _querysets_for_update(
12✔
280
        self,
281
        model: Type[Model],
282
        instance: Union[Model, QuerySet],
283
        update_fields: Optional[Iterable[str]] = None,
284
        pk_list: bool = False,
285
    ) -> Dict[Type[Model], List[Any]]:
286
        """
287
        Returns a mapping of all dependent models, dependent fields and a
288
        queryset containing all dependent objects.
289
        """
290
        final: Dict[Type[Model], List[Any]] = OrderedDict()
12✔
291
        if get_not_computed_context():
12✔
292
            # TODO: track instance/queryset for context re-plays
293
            return final
12✔
294
        modeldata = self._map.get(model)
12✔
295
        if not modeldata:
12✔
296
            return final
12✔
297
        if not update_fields:
12✔
298
            updates: Set[str] = set(modeldata.keys())
12✔
299
        else:
300
            updates = set()
12✔
301
            for fieldname in update_fields:
12✔
302
                if fieldname in modeldata:
12✔
303
                    updates.add(fieldname)
12✔
304
        subquery = '__in' if isinstance(instance, QuerySet) else ''
12✔
305

306
        # fix #100
307
        # mysql does not support 'LIMIT & IN/ALL/ANY/SOME subquery'
308
        # thus we extract pks explicitly instead
309
        # TODO: cleanup type mess here including this workaround
310
        if isinstance(instance, QuerySet):
12✔
311
            from django.db import connections
12✔
312
            if not instance.query.can_filter() and connections[instance.db].vendor == 'mysql':
12!
313
                instance = set(instance.values_list('pk', flat=True).iterator())
×
314

315
        model_updates: Dict[Type[Model], Tuple[Set[str], Set[str]]] = OrderedDict()
12✔
316
        for update in updates:
12✔
317
            # first aggregate fields and paths to cover
318
            # multiple comp field dependencies
319
            for model, resolver in modeldata[update].items():
12✔
320
                fields, paths = resolver
12✔
321
                m_fields, m_paths = model_updates.setdefault(model, (set(), set()))
12✔
322
                m_fields.update(fields)
12✔
323
                m_paths.update(paths)
12✔
324

325
        # generate narrowed down querysets for all cf dependencies
326
        for model, data in model_updates.items():
12✔
327
            fields, paths = data
12✔
328
            queryset: Any = model._base_manager.none()
12✔
329
            query_pipe_method = self._choose_optimal_query_pipe_method(paths)
12✔
330
            queryset = reduce(
12✔
331
                query_pipe_method,
332
                (model._base_manager.filter(**{path+subquery: instance}) for path in paths),
333
                queryset
334
            )
335
            if pk_list:
12✔
336
                # need pks for post_delete since the real queryset will be empty
337
                # after deleting the instance in question
338
                # since we need to interact with the db anyways
339
                # we can already drop empty results here
340
                queryset = set(queryset.values_list('pk', flat=True).iterator())
12✔
341
                if not queryset:
12✔
342
                    continue
12✔
343
            # FIXME: change to tuple or dict for narrower type
344
            final[model] = [queryset, fields]
12✔
345
        return final
12✔
346
    
347
    def _get_model(self, instance: Union[Model, QuerySet]) -> Type[Model]:
12✔
348
        return instance.model if isinstance(instance, QuerySet) else type(instance)
12✔
349

350
    def _choose_optimal_query_pipe_method(self, paths: Set[str]) -> Callable:
12✔
351
        """
352
            Choose optimal pipe method, to combine querystes.
353
            Returns `|` if there are only one element or the difference is only the fields name, on the same path.
354
            Otherwise, return union.
355
        """
356
        if len(paths) == 1:
12✔
357
            return operator.or_
12✔
358
        else:
359
            paths_by_parts = tuple(path.split("__") for path in paths)
12✔
360
            if are_same(*(len(path_in_parts) for path_in_parts in paths_by_parts)):
12✔
361
                max_depth = len(paths_by_parts[0]) - 1
12✔
362
                for depth, paths_parts in enumerate(zip(*paths_by_parts)):
12!
363
                    if are_same(*paths_parts):
12✔
364
                        pass
12✔
365
                    else:
366
                        if depth == max_depth:
12✔
367
                            return operator.or_
12✔
368
                        else:
369
                            break
12✔
370
        return lambda x, y: x.union(y)
12✔
371

372
    def preupdate_dependent(
12✔
373
        self,
374
        instance: Union[QuerySet, Model],
375
        model: Optional[Type[Model]] = None,
376
        update_fields: Optional[Iterable[str]] = None,
377
    ) -> Dict[Type[Model], List[Any]]:
378
        """
379
        Create a mapping of currently associated computed field records,
380
        that might turn dirty by a follow-up bulk action.
381

382
        Feed the mapping back to ``update_dependent`` as `old` argument
383
        after your bulk action to update de-associated computed field records as well.
384
        """
385
        return self._querysets_for_update(
12✔
386
            model or self._get_model(instance), instance, update_fields, pk_list=True)
387

388
    def update_dependent(
12✔
389
        self,
390
        instance: Union[QuerySet, Model],
391
        model: Optional[Type[Model]] = None,
392
        update_fields: Optional[Iterable[str]] = None,
393
        old: Optional[Dict[Type[Model], List[Any]]] = None,
394
        update_local: bool = True,
395
        querysize: Optional[int] = None,
396
        _is_recursive: bool = False
397
    ) -> None:
398
        """
399
        Updates all dependent computed fields on related models traversing
400
        the dependency tree as shown in the graphs.
401

402
        This is the main entry hook of the resolver to do updates on dependent
403
        computed fields during runtime. While this is done automatically for
404
        model instance actions from signal handlers, you have to call it yourself
405
        after changes done by bulk actions.
406

407
        To do that, simply call this function after the update with the queryset
408
        containing the changed objects:
409

410
            >>> Entry.objects.filter(pub_date__year=2010).update(comments_on=False)
411
            >>> update_dependent(Entry.objects.filter(pub_date__year=2010))
412

413
        This can also be used with ``bulk_create``. Since ``bulk_create``
414
        returns the objects in a python container, you have to create the queryset
415
        yourself, e.g. with pks:
416

417
            >>> objs = Entry.objects.bulk_create([
418
            ...     Entry(headline='This is a test'),
419
            ...     Entry(headline='This is only a test'),
420
            ... ])
421
            >>> pks = set(obj.pk for obj in objs)
422
            >>> update_dependent(Entry.objects.filter(pk__in=pks))
423

424
        .. NOTE::
425

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

429
                >>> MyComputedModel.objects.bulk_create([
430
                ...     MyComputedModel(comp='SENTINEL'), # here or as default field value
431
                ...     MyComputedModel(comp='SENTINEL'),
432
                ... ])
433
                >>> update_dependent(MyComputedModel.objects.filter(comp='SENTINEL'))
434

435
            If the sentinel is beyond reach of the method result, this even ensures to update
436
            only the newly added records.
437

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

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

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

449
                >>> # given: some computed fields model depends somehow on Entry.fk_field
450
                >>> old_relations = preupdate_dependent(Entry.objects.filter(pub_date__year=2010))
451
                >>> Entry.objects.filter(pub_date__year=2010).update(fk_field=new_related_obj)
452
                >>> update_dependent(Entry.objects.filter(pub_date__year=2010), old=old_relations)
453

454
        `update_local=False` disables model local computed field updates of the entry node. 
455
        (used as optimization during tree traversal). You should not disable it yourself.
456
        """
457
        if get_not_computed_context():
12✔
458
            # TODO: track instance/queryset for context re-plays
459
            return
12✔
460

461
        _model = model or self._get_model(instance)
12✔
462

463
        # bulk_updater might change fields, ensure we have set/None
464
        _update_fields = None if update_fields is None else set(update_fields)
12✔
465

466
        # Note: update_local is always off for updates triggered from the resolver
467
        # but True by default to avoid accidentally skipping updates called by user
468
        if update_local and self.has_computedfields(_model):
12✔
469
            # We skip a transaction here in the same sense,
470
            # as local cf updates are not guarded either.
471
            queryset = instance if isinstance(instance, QuerySet) \
12✔
472
                else _model._base_manager.filter(pk__in=[instance.pk])
473
            self.bulk_updater(queryset, _update_fields, local_only=True, querysize=querysize)
12✔
474

475
        updates = self._querysets_for_update(_model, instance, _update_fields).values()
12✔
476
        if updates:
12✔
477
            if not _is_recursive:
12✔
478
                resolver_start.send(sender=self)
12✔
479
                with transaction.atomic():
12✔
480
                    pks_updated: Dict[Type[Model], Set[Any]] = {}
12✔
481
                    for queryset, fields in updates:
12✔
482
                        _pks = self.bulk_updater(queryset, fields, return_pks=True, querysize=querysize)
12✔
483
                        if _pks:
12✔
484
                            pks_updated[queryset.model] = _pks
12✔
485
                    if old:
12✔
486
                        for model2, data in old.items():
12✔
487
                            pks, fields = data
12✔
488
                            queryset = model2.objects.filter(pk__in=pks-pks_updated.get(model2, set()))
12✔
489
                            self.bulk_updater(queryset, fields, querysize=querysize)
12✔
490
            else:
491
                pks_updated: Dict[Type[Model], Set[Any]] = {}
12✔
492
                for queryset, fields in updates:
12✔
493
                    _pks = self.bulk_updater(queryset, fields, return_pks=True, querysize=querysize)
12✔
494
                    if _pks:
12✔
495
                        pks_updated[queryset.model] = _pks
12✔
496
                if old:
12!
UNCOV
497
                    for model2, data in old.items():
×
UNCOV
498
                        pks, fields = data
×
UNCOV
499
                        queryset = model2.objects.filter(pk__in=pks-pks_updated.get(model2, set()))
×
UNCOV
500
                        self.bulk_updater(queryset, fields, querysize=querysize)
×
501
            if not _is_recursive:
12✔
502
                resolver_exit.send(sender=self)
12✔
503

504
    def bulk_updater(
12✔
505
        self,
506
        queryset: QuerySet,
507
        update_fields: Optional[Set[str]] = None,
508
        return_pks: bool = False,
509
        local_only: bool = False,
510
        querysize: Optional[int] = None
511
    ) -> Optional[Set[Any]]:
512
        """
513
        Update local computed fields and descent in the dependency tree by calling
514
        ``update_dependent`` for dependent models.
515

516
        This method does the local field updates on `queryset`:
517

518
            - eval local `MRO` of computed fields
519
            - expand `update_fields`
520
            - apply optional `select_related` and `prefetch_related` rules to `queryset`
521
            - walk all records and recalculate fields in `update_fields`
522
            - aggregate changeset and save as batched `bulk_update` to the database
523

524
        By default this method triggers the update of dependent models by calling
525
        ``update_dependent`` with `update_fields` (next level of tree traversal).
526
        This can be suppressed by setting `local_only=True`.
527

528
        If `return_pks` is set, the method returns a set of altered pks of `queryset`.
529
        """
530
        model: Type[Model] = queryset.model
12✔
531

532
        # distinct issue workaround
533
        # the workaround is needed for already sliced/distinct querysets coming from outside
534
        # TODO: distinct is a major query perf smell, and is in fact only needed on back relations
535
        #       may need some rework in _querysets_for_update
536
        #       ideally we find a way to avoid it for forward relations
537
        #       also see #101
538
        if queryset.query.can_filter() and not queryset.query.distinct_fields:
12!
539
            if queryset.query.combinator != "union":
12✔
540
                queryset = queryset.distinct()
12✔
541
        else:
542
            queryset = model._base_manager.filter(pk__in=subquery_pk(queryset, queryset.db))
×
543

544
        # correct update_fields by local mro
545
        mro = self.get_local_mro(model, update_fields)
12✔
546
        fields: Any = set(mro)  # FIXME: narrow type once issue in django-stubs is resolved
12✔
547
        if update_fields:
12✔
548
            update_fields.update(fields)
12✔
549

550
        select = self.get_select_related(model, fields)
12✔
551
        prefetch = self.get_prefetch_related(model, fields)
12✔
552
        if select:
12✔
553
            queryset = queryset.select_related(*select)
12✔
554
        # fix #167: skip prefetch if union was used
555
        if prefetch and queryset.query.combinator != "union":
12✔
556
            queryset = queryset.prefetch_related(*prefetch)
12✔
557

558
        pks = []
12✔
559
        if fields:
12!
560
            q_size = self.get_querysize(model, fields, querysize)
12✔
561
            change: List[Model] = []
12✔
562
            for elem in slice_iterator(queryset, q_size):
12✔
563
                # note on the loop: while it is technically not needed to batch things here,
564
                # we still prebatch to not cause memory issues for very big querysets
565
                has_changed = False
12✔
566
                for comp_field in mro:
12✔
567
                    new_value = self._compute(elem, model, comp_field)
12✔
568
                    if new_value != getattr(elem, comp_field):
12✔
569
                        has_changed = True
12✔
570
                        setattr(elem, comp_field, new_value)
12✔
571
                if has_changed:
12✔
572
                    change.append(elem)
12✔
573
                    pks.append(elem.pk)
12✔
574
                if len(change) >= self._batchsize:
12!
575
                    self._update(model._base_manager.all(), change, fields)
×
576
                    change = []
×
577
            if change:
12✔
578
                self._update(model._base_manager.all(), change, fields)
12✔
579

580
            if pks:
12✔
581
                resolver_update.send(sender=self, model=model, fields=fields, pks=pks)
12✔
582

583
        # trigger dependent comp field updates from changed records
584
        # other than before we exit the update tree early, if we have no changes at all
585
        # also cuts the update tree for recursive deps (tree-like)
586
        if not local_only and pks:
12✔
587
            self.update_dependent(
12✔
588
                instance=model._base_manager.filter(pk__in=pks),
589
                model=model,
590
                update_fields=fields,
591
                update_local=False,
592
                _is_recursive=True
593
            )
594
        return set(pks) if return_pks else None
12✔
595
    
596
    def _update(self, queryset: QuerySet, change: Sequence[Any], fields: Sequence[str]) -> Union[int, None]:
12✔
597
        # we can skip batch_size here, as it already was batched in bulk_updater
598
        if self.use_fastupdate:
12!
599
            return fast_update(queryset, change, fields, None)
12✔
600
        return queryset.model._base_manager.bulk_update(change, fields)
×
601

602
    def _compute(self, instance: Model, model: Type[Model], fieldname: str) -> Any:
12✔
603
        """
604
        Returns the computed field value for ``fieldname``.
605
        Note that this is just a shorthand method for calling the underlying computed
606
        field method and does not deal with local MRO, thus should only be used,
607
        if the MRO is respected by other means.
608
        For quick inspection of a single computed field value, that gonna be written
609
        to the database, always use ``compute(fieldname)`` instead.
610
        """
611
        field = self._computed_models[model][fieldname]
12✔
612
        if instance._state.adding or not instance.pk:
12✔
613
            if field._computed['default_on_create']:
12✔
614
                return field.get_default()
12✔
615
        return field._computed['func'](instance)
12✔
616

617
    def compute(self, instance: Model, fieldname: str) -> Any:
12✔
618
        """
619
        Returns the computed field value for ``fieldname``. This method allows
620
        to inspect the new calculated value, that would be written to the database
621
        by a following ``save()``.
622

623
        Other than calling ``update_computedfields`` on an model instance this call
624
        is not destructive for old computed field values.
625
        """
626
        # Getting a single computed value prehand is quite complicated,
627
        # as we have to:
628
        # - resolve local MRO backwards (stored MRO data is optimized for forward deps)
629
        # - calc all local cfs, that the requested one depends on
630
        # - stack and rewind interim values, as we dont want to introduce side effects here
631
        #   (in fact the save/bulker logic might try to save db calls based on changes)
632
        if get_not_computed_context():
12✔
633
            return getattr(instance, fieldname)
12✔
634
        mro = self.get_local_mro(type(instance), None)
12✔
635
        if not fieldname in mro:
12✔
636
            return getattr(instance, fieldname)
12✔
637
        entries = self._local_mro[type(instance)]['fields']
12✔
638
        pos = 1 << mro.index(fieldname)
12✔
639
        stack: List[Tuple[str, Any]] = []
12✔
640
        model = type(instance)
12✔
641
        for field in mro:
12!
642
            if field == fieldname:
12✔
643
                ret = self._compute(instance, model, fieldname)
12✔
644
                for field2, old in stack:
12✔
645
                    # reapply old stack values
646
                    setattr(instance, field2, old)
12✔
647
                return ret
12✔
648
            f_mro = entries.get(field, 0)
12✔
649
            if f_mro & pos:
12✔
650
                # append old value to stack for later rewinding
651
                # calc and set new value for field, if the requested one depends on it
652
                stack.append((field, getattr(instance, field)))
12✔
653
                setattr(instance, field, self._compute(instance, model, field))
12✔
654

655
    # TODO: the following 3 lookups are very expensive at runtime adding ~2s for 1M calls
656
    #       --> all need pregenerated lookup maps
657
    # Note: the same goes for get_local_mro and _queryset_for_update...
658
    def get_select_related(
12✔
659
        self,
660
        model: Type[Model],
661
        fields: Optional[Iterable[str]] = None
662
    ) -> Set[str]:
663
        """
664
        Get defined select_related rules for `fields` (all if none given).
665
        """
666
        if fields is None:
12!
667
            fields = self._computed_models[model].keys()
×
668
        select: Set[str] = set()
12✔
669
        for field in fields:
12✔
670
            select.update(self._computed_models[model][field]._computed['select_related'])
12✔
671
        return select
12✔
672

673
    def get_prefetch_related(
12✔
674
        self,
675
        model: Type[Model],
676
        fields: Optional[Iterable[str]] = None
677
    ) -> List:
678
        """
679
        Get defined prefetch_related rules for `fields` (all if none given).
680
        """
681
        if fields is None:
12!
682
            fields = self._computed_models[model].keys()
×
683
        prefetch: List[Any] = []
12✔
684
        for field in fields:
12✔
685
            prefetch.extend(self._computed_models[model][field]._computed['prefetch_related'])
12✔
686
        return prefetch
12✔
687

688
    def get_querysize(
12✔
689
        self,
690
        model: Type[Model],
691
        fields: Optional[Iterable[str]] = None,
692
        override: Optional[int] = None
693
    ) -> int:
694
        base = settings.COMPUTEDFIELDS_QUERYSIZE if override is None else override
12✔
695
        if fields is None:
12✔
696
            fields = self._computed_models[model].keys()
12✔
697
        return min(self._computed_models[model][f]._computed['querysize'] or base for f in fields)
12✔
698

699
    def get_contributing_fks(self) -> IFkMap:
12✔
700
        """
701
        Get a mapping of models and their local foreign key fields,
702
        that are part of a computed fields dependency chain.
703

704
        Whenever a bulk action changes one of the fields listed here, you have to create
705
        a listing of the associated  records with ``preupdate_dependent`` before doing
706
        the bulk change. After the bulk change feed the listing back to ``update_dependent``
707
        with the `old` argument.
708

709
        With ``COMPUTEDFIELDS_ADMIN = True`` in `settings.py` this mapping can also be
710
        inspected as admin view. 
711
        """
712
        if not self._map_loaded:  # pragma: no cover
713
            raise ResolverException('resolver has no maps loaded yet')
714
        return self._fk_map
12✔
715

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

724
        There is another class of misconfigured computed fields we currently cannot
725
        find by any safety measures - if `depends` provides valid paths and fields,
726
        but the function operates on different dependencies. Currently it is the devs'
727
        responsibility to perfectly align `depends` entries with dependencies
728
        used by the function to avoid faulty update behavior.
729
        """
730
        if not isinstance(field, Field):
12!
731
                raise ResolverException('field argument is not a Field instance')
×
732
        for rule in depends:
12✔
733
            try:
12✔
734
                path, fieldnames = rule
12✔
735
            except ValueError:
×
736
                raise ResolverException(MALFORMED_DEPENDS)
×
737
            if not isinstance(path, str) or not all(isinstance(f, str) for f in fieldnames):
12!
738
                raise ResolverException(MALFORMED_DEPENDS)
×
739

740
    def computedfield_factory(
12✔
741
        self,
742
        field: 'Field[_ST, _GT]',
743
        compute: Callable[..., _ST],
744
        depends: Optional[IDepends] = None,
745
        select_related: Optional[Sequence[str]] = None,
746
        prefetch_related: Optional[Sequence[Any]] = None,
747
        querysize: Optional[int] = None,
748
        default_on_create: Optional[bool] = False
749
    ) -> 'Field[_ST, _GT]':
750
        """
751
        Factory for computed fields.
752

753
        The method gets exposed as ``ComputedField`` to allow a more declarative
754
        code style with better separation of field declarations and function
755
        implementations. It is also used internally for the ``computed`` decorator.
756
        Similar to the decorator, the ``compute`` function expects a single argument
757
        as model instance of the model it got applied to.
758

759
        Usage example:
760

761
        .. code-block:: python
762

763
            from computedfields.models import ComputedField
764

765
            def calc_mul(inst):
766
                return inst.a * inst.b
767

768
            class MyModel(ComputedFieldsModel):
769
                a = models.IntegerField()
770
                b = models.IntegerField()
771
                sum = ComputedField(
772
                    models.IntegerField(),
773
                    depends=[('self', ['a', 'b'])],
774
                    compute=lambda inst: inst.a + inst.b
775
                )
776
                mul = ComputedField(
777
                    models.IntegerField(),
778
                    depends=[('self', ['a', 'b'])],
779
                    compute=calc_mul
780
                )
781
        """
782
        self._sanity_check(field, depends or [])
12✔
783
        cf = cast('IComputedField[_ST, _GT]', field)
12✔
784
        cf._computed = {
12✔
785
            'func': compute,
786
            'depends': depends or [],
787
            'select_related': select_related or [],
788
            'prefetch_related': prefetch_related or [],
789
            'querysize': querysize,
790
            'default_on_create': default_on_create
791
        }
792
        cf.editable = False
12✔
793
        self.add_field(cf)
12✔
794
        return field
12✔
795

796
    def computed(
12✔
797
        self,
798
        field: 'Field[_ST, _GT]',
799
        depends: Optional[IDepends] = None,
800
        select_related: Optional[Sequence[str]] = None,
801
        prefetch_related: Optional[Sequence[Any]] = None,
802
        querysize: Optional[int] = None,
803
        default_on_create: Optional[bool] = False
804
    ) -> Callable[[Callable[..., _ST]], 'Field[_ST, _GT]']:
805
        """
806
        Decorator to create computed fields.
807

808
        `field` should be a model concrete field instance suitable to hold the result
809
        of the decorated method. The decorator expects a keyword argument `depends`
810
        to indicate dependencies to model fields (local or related).
811
        Listed dependencies will automatically update the computed field.
812

813
        Examples:
814

815
            - create a char field with no further dependencies (not very useful)
816

817
            .. code-block:: python
818

819
                @computed(models.CharField(max_length=32))
820
                def ...
821

822
            - create a char field with a dependency to the field ``name`` on a
823
              foreign key relation ``fk``
824

825
            .. code-block:: python
826

827
                @computed(models.CharField(max_length=32), depends=[('fk', ['name'])])
828
                def ...
829

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

836
        .. NOTE::
837

838
            Dependencies to model local fields should be listed with ``'self'`` as relation name.
839

840
        With `select_related` and `prefetch_related` you can instruct the dependency resolver
841
        to apply certain optimizations on the update queryset.
842

843
        .. NOTE::
844

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

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

856
        With `default_on_create` set to ``True`` the function calculation will be skipped
857
        for newly created or copy-cloned instances, instead the value will be set from the
858
        inner field's `default` argument.
859

860
        .. CAUTION::
861

862
            With the dependency resolver you can easily create recursive dependencies
863
            by accident. Imagine the following:
864

865
            .. code-block:: python
866

867
                class A(ComputedFieldsModel):
868
                    @computed(models.CharField(max_length=32), depends=[('b_set', ['comp'])])
869
                    def comp(self):
870
                        return ''.join(b.comp for b in self.b_set.all())
871

872
                class B(ComputedFieldsModel):
873
                    a = models.ForeignKey(A)
874

875
                    @computed(models.CharField(max_length=32), depends=[('a', ['comp'])])
876
                    def comp(self):
877
                        return a.comp
878

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

885
            If you experience this in your project try to get in-depth cycle
886
            information, either by using the ``rendergraph`` management command or
887
            by directly accessing the graph objects:
888

889
            - intermodel dependency graph: ``active_resolver._graph``
890
            - model local dependency graphs: ``active_resolver._graph.modelgraphs[your_model]``
891
            - union graph: ``active_resolver._graph.get_uniongraph()``
892

893
            Also see the graph documentation :ref:`here<graph>`.
894
        """
895
        def wrap(func: Callable[..., _ST]) -> 'Field[_ST, _GT]':
12✔
896
            return self.computedfield_factory(
12✔
897
                field,
898
                compute=func,
899
                depends=depends,
900
                select_related=select_related,
901
                prefetch_related=prefetch_related,
902
                querysize=querysize,
903
                default_on_create=default_on_create
904
            )
905
        return wrap
12✔
906

907
    @overload
12✔
908
    def precomputed(self, f: F) -> F:
12✔
909
        ...
×
910
    @overload
12✔
911
    def precomputed(self, skip_after: bool) -> Callable[[F], F]:
12✔
912
        ...
×
913
    def precomputed(self, *dargs, **dkwargs) -> Union[F, Callable[[F], F]]:
12✔
914
        """
915
        Decorator for custom ``save`` methods, that expect local computed fields
916
        to contain already updated values on enter.
917

918
        By default local computed field values are only calculated once by the
919
        ``ComputedFieldModel.save`` method after your own save method.
920

921
        By placing this decorator on your save method, the values will be updated
922
        before entering your method as well. Note that this comes for the price of
923
        doubled local computed field calculations (before and after your save method).
924
        
925
        To avoid a second recalculation, the decorator can be called with `skip_after=True`.
926
        Note that this might lead to desychronized computed field values, if you do late
927
        field changes in your save method without another resync afterwards.
928
        """
929
        skip: bool = False
12✔
930
        func: Optional[F] = None
12✔
931
        if dargs:
12✔
932
            if len(dargs) > 1 or not callable(dargs[0]) or dkwargs:
12!
933
                raise ResolverException('error in @precomputed declaration')
×
934
            func = dargs[0]
12✔
935
        else:
936
            skip = dkwargs.get('skip_after', False)
12✔
937
        
938
        def wrap(func: F) -> F:
12✔
939
            def _save(instance, *args, **kwargs):
12✔
940
                new_fields = self.update_computedfields(instance, kwargs.get('update_fields'))
12✔
941
                if new_fields:
12!
942
                    kwargs['update_fields'] = new_fields
×
943
                kwargs['skip_computedfields'] = skip
12✔
944
                return func(instance, *args, **kwargs)
12✔
945
            return cast(F, _save)
12✔
946
        
947
        return wrap(func) if func else wrap
12✔
948

949
    def update_computedfields(
12✔
950
        self,
951
        instance: Model,
952
        update_fields: Optional[Iterable[str]] = None
953
        ) -> Optional[Iterable[str]]:
954
        """
955
        Update values of local computed fields of `instance`.
956

957
        Other than calling ``compute`` on an instance, this call overwrites
958
        computed field values on the instance (destructive).
959

960
        Returns ``None`` or an updated set of field names for `update_fields`.
961
        The returned fields might contained additional computed fields, that also
962
        changed based on the input fields, thus should extend `update_fields`
963
        on a save call.
964
        """
965
        if get_not_computed_context():
12✔
966
            return update_fields
12✔
967
        model = type(instance)
12✔
968
        if not self.has_computedfields(model):
12✔
969
            return update_fields
12✔
970
        cf_mro = self.get_local_mro(model, update_fields)
12✔
971
        if update_fields:
12✔
972
            update_fields = set(update_fields)
12✔
973
            update_fields.update(set(cf_mro))
12✔
974
        for fieldname in cf_mro:
12✔
975
            setattr(instance, fieldname, self._compute(instance, model, fieldname))
12✔
976
        if update_fields:
12✔
977
            return update_fields
12✔
978
        return None
12✔
979

980
    def has_computedfields(self, model: Type[Model]) -> bool:
12✔
981
        """
982
        Indicate whether `model` has computed fields.
983
        """
984
        return model in self._computed_models
12✔
985

986
    def get_computedfields(self, model: Type[Model]) -> Iterable[str]:
12✔
987
        """
988
        Get all computed fields on `model`.
989
        """
990
        return self._computed_models.get(model, {}).keys()
12✔
991

992
    def is_computedfield(self, model: Type[Model], fieldname: str) -> bool:
12✔
993
        """
994
        Indicate whether `fieldname` on `model` is a computed field.
995
        """
996
        return fieldname in self.get_computedfields(model)
12✔
997

998
    def get_graphs(self) -> Tuple[Graph, Dict[Type[Model], ModelGraph], Graph]:
12✔
999
        """
1000
        Return a tuple of all graphs as
1001
        ``(intermodel_graph, {model: modelgraph, ...}, union_graph)``.
1002
        """
1003
        graph = self._graph
×
1004
        if not graph:
×
1005
            graph = ComputedModelsGraph(active_resolver.computed_models)
×
1006
            graph.get_edgepaths()
×
1007
            graph.get_uniongraph()
×
1008
        return (graph, graph.modelgraphs, graph.get_uniongraph())
×
1009

1010

1011
# active_resolver is currently treated as global singleton (used in imports)
1012
#: Currently active resolver.
1013
active_resolver = Resolver()
12✔
1014

1015
# BOOT_RESOLVER: resolver that holds all startup declarations and resolve maps
1016
# gets deactivated after startup, thus it is currently not possible to define
1017
# new computed fields and add their resolve rules at runtime
1018
# TODO: investigate on custom resolvers at runtime to be bootstrapped from BOOT_RESOLVER
1019
#: Resolver used during django bootstrapping.
1020
#: This is currently the same as `active_resolver` (treated as global singleton).
1021
BOOT_RESOLVER = active_resolver
12✔
1022

1023

1024
# placeholder class to test for correct model inheritance
1025
# during initial field resolving
1026
class _ComputedFieldsModelBase:
12✔
1027
    pass
12✔
1028

1029

1030
class NotComputed:
12✔
1031
    """
1032
    Context to disable all computed field calculations and resolver updates temporarily.
1033

1034
    .. CAUTION::
1035

1036
        Currently there is no auto-recovery implemented at all,
1037
        therefore it is your responsibility to recover properly from the desync state.
1038
    """
1039
    def __init__(self):
12✔
1040
        self.remove_ctx = True
12✔
1041

1042
    def __enter__(self):
12✔
1043
        ctx = get_not_computed_context()
12✔
1044
        if ctx:
12✔
1045
            self.remove_ctx = False
12✔
1046
            return ctx
12✔
1047
        set_not_computed_context(self)
12✔
1048
        return self
12✔
1049

1050
    def __exit__(self, exc_type, exc_value, traceback):
12✔
1051
        if self.remove_ctx:
12✔
1052
            set_not_computed_context(None)
12✔
1053
            # TODO: re-play aggregated changes
1054
        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