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

netzkolchose / django-computedfields / 16201585930

10 Jul 2025 05:14PM UTC coverage: 95.143% (+0.06%) from 95.086%
16201585930

Pull #181

github

web-flow
Merge c2a44af25 into ae4feb31e
Pull Request #181: signals

464 of 501 branches covered (92.61%)

Branch coverage included in aggregate %.

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

15 existing lines in 1 file now uncovered.

1162 of 1208 relevant lines covered (96.19%)

11.54 hits per line

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

93.61
/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
        if not _is_recursive:
12✔
467
            resolver_start.send(sender=self)
12✔
468

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

478
        updates = self._querysets_for_update(_model, instance, _update_fields).values()
12✔
479
        if updates:
12✔
480
            with transaction.atomic():  # FIXME: place transaction only once in tree descent
12✔
481
                pks_updated: Dict[Type[Model], Set[Any]] = {}
12✔
482
                for queryset, fields in updates:
12✔
483
                    _pks = self.bulk_updater(queryset, fields, return_pks=True, querysize=querysize)
12✔
484
                    if _pks:
12✔
485
                        pks_updated[queryset.model] = _pks
12✔
486
                if old:
12✔
487
                    for model2, data in old.items():
12✔
488
                        pks, fields = data
12✔
489
                        queryset = model2.objects.filter(pk__in=pks-pks_updated.get(model2, set()))
12✔
490
                        self.bulk_updater(queryset, fields, querysize=querysize)
12✔
491

492
        if not _is_recursive:
12✔
493
            resolver_exit.send(sender=self)
12✔
494

495
    def bulk_updater(
12✔
496
        self,
497
        queryset: QuerySet,
498
        update_fields: Optional[Set[str]] = None,
499
        return_pks: bool = False,
500
        local_only: bool = False,
501
        querysize: Optional[int] = None
502
    ) -> Optional[Set[Any]]:
503
        """
504
        Update local computed fields and descent in the dependency tree by calling
505
        ``update_dependent`` for dependent models.
506

507
        This method does the local field updates on `queryset`:
508

509
            - eval local `MRO` of computed fields
510
            - expand `update_fields`
511
            - apply optional `select_related` and `prefetch_related` rules to `queryset`
512
            - walk all records and recalculate fields in `update_fields`
513
            - aggregate changeset and save as batched `bulk_update` to the database
514

515
        By default this method triggers the update of dependent models by calling
516
        ``update_dependent`` with `update_fields` (next level of tree traversal).
517
        This can be suppressed by setting `local_only=True`.
518

519
        If `return_pks` is set, the method returns a set of altered pks of `queryset`.
520
        """
521
        model: Type[Model] = queryset.model
12✔
522

523
        # distinct issue workaround
524
        # the workaround is needed for already sliced/distinct querysets coming from outside
525
        # TODO: distinct is a major query perf smell, and is in fact only needed on back relations
526
        #       may need some rework in _querysets_for_update
527
        #       ideally we find a way to avoid it for forward relations
528
        #       also see #101
529
        if queryset.query.can_filter() and not queryset.query.distinct_fields:
12!
530
            if queryset.query.combinator != "union":
12✔
531
                queryset = queryset.distinct()
12✔
532
        else:
UNCOV
533
            queryset = model._base_manager.filter(pk__in=subquery_pk(queryset, queryset.db))
×
534

535
        # correct update_fields by local mro
536
        mro = self.get_local_mro(model, update_fields)
12✔
537
        fields: Any = set(mro)  # FIXME: narrow type once issue in django-stubs is resolved
12✔
538
        if update_fields:
12✔
539
            update_fields.update(fields)
12✔
540

541
        select = self.get_select_related(model, fields)
12✔
542
        prefetch = self.get_prefetch_related(model, fields)
12✔
543
        if select:
12✔
544
            queryset = queryset.select_related(*select)
12✔
545
        # fix #167: skip prefetch if union was used
546
        if prefetch and queryset.query.combinator != "union":
12✔
547
            queryset = queryset.prefetch_related(*prefetch)
12✔
548

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

571
            if pks:
12✔
572
                resolver_update.send(sender=self, model=model, fields=fields, pks=pks)
12✔
573

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

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

608
    def compute(self, instance: Model, fieldname: str) -> Any:
12✔
609
        """
610
        Returns the computed field value for ``fieldname``. This method allows
611
        to inspect the new calculated value, that would be written to the database
612
        by a following ``save()``.
613

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

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

664
    def get_prefetch_related(
12✔
665
        self,
666
        model: Type[Model],
667
        fields: Optional[Iterable[str]] = None
668
    ) -> List:
669
        """
670
        Get defined prefetch_related rules for `fields` (all if none given).
671
        """
672
        if fields is None:
12!
UNCOV
673
            fields = self._computed_models[model].keys()
×
674
        prefetch: List[Any] = []
12✔
675
        for field in fields:
12✔
676
            prefetch.extend(self._computed_models[model][field]._computed['prefetch_related'])
12✔
677
        return prefetch
12✔
678

679
    def get_querysize(
12✔
680
        self,
681
        model: Type[Model],
682
        fields: Optional[Iterable[str]] = None,
683
        override: Optional[int] = None
684
    ) -> int:
685
        base = settings.COMPUTEDFIELDS_QUERYSIZE if override is None else override
12✔
686
        if fields is None:
12✔
687
            fields = self._computed_models[model].keys()
12✔
688
        return min(self._computed_models[model][f]._computed['querysize'] or base for f in fields)
12✔
689

690
    def get_contributing_fks(self) -> IFkMap:
12✔
691
        """
692
        Get a mapping of models and their local foreign key fields,
693
        that are part of a computed fields dependency chain.
694

695
        Whenever a bulk action changes one of the fields listed here, you have to create
696
        a listing of the associated  records with ``preupdate_dependent`` before doing
697
        the bulk change. After the bulk change feed the listing back to ``update_dependent``
698
        with the `old` argument.
699

700
        With ``COMPUTEDFIELDS_ADMIN = True`` in `settings.py` this mapping can also be
701
        inspected as admin view. 
702
        """
703
        if not self._map_loaded:  # pragma: no cover
704
            raise ResolverException('resolver has no maps loaded yet')
705
        return self._fk_map
12✔
706

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

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

731
    def computedfield_factory(
12✔
732
        self,
733
        field: 'Field[_ST, _GT]',
734
        compute: Callable[..., _ST],
735
        depends: Optional[IDepends] = None,
736
        select_related: Optional[Sequence[str]] = None,
737
        prefetch_related: Optional[Sequence[Any]] = None,
738
        querysize: Optional[int] = None,
739
        default_on_create: Optional[bool] = False
740
    ) -> 'Field[_ST, _GT]':
741
        """
742
        Factory for computed fields.
743

744
        The method gets exposed as ``ComputedField`` to allow a more declarative
745
        code style with better separation of field declarations and function
746
        implementations. It is also used internally for the ``computed`` decorator.
747
        Similar to the decorator, the ``compute`` function expects a single argument
748
        as model instance of the model it got applied to.
749

750
        Usage example:
751

752
        .. code-block:: python
753

754
            from computedfields.models import ComputedField
755

756
            def calc_mul(inst):
757
                return inst.a * inst.b
758

759
            class MyModel(ComputedFieldsModel):
760
                a = models.IntegerField()
761
                b = models.IntegerField()
762
                sum = ComputedField(
763
                    models.IntegerField(),
764
                    depends=[('self', ['a', 'b'])],
765
                    compute=lambda inst: inst.a + inst.b
766
                )
767
                mul = ComputedField(
768
                    models.IntegerField(),
769
                    depends=[('self', ['a', 'b'])],
770
                    compute=calc_mul
771
                )
772
        """
773
        self._sanity_check(field, depends or [])
12✔
774
        cf = cast('IComputedField[_ST, _GT]', field)
12✔
775
        cf._computed = {
12✔
776
            'func': compute,
777
            'depends': depends or [],
778
            'select_related': select_related or [],
779
            'prefetch_related': prefetch_related or [],
780
            'querysize': querysize,
781
            'default_on_create': default_on_create
782
        }
783
        cf.editable = False
12✔
784
        self.add_field(cf)
12✔
785
        return field
12✔
786

787
    def computed(
12✔
788
        self,
789
        field: 'Field[_ST, _GT]',
790
        depends: Optional[IDepends] = None,
791
        select_related: Optional[Sequence[str]] = None,
792
        prefetch_related: Optional[Sequence[Any]] = None,
793
        querysize: Optional[int] = None,
794
        default_on_create: Optional[bool] = False
795
    ) -> Callable[[Callable[..., _ST]], 'Field[_ST, _GT]']:
796
        """
797
        Decorator to create computed fields.
798

799
        `field` should be a model concrete field instance suitable to hold the result
800
        of the decorated method. The decorator expects a keyword argument `depends`
801
        to indicate dependencies to model fields (local or related).
802
        Listed dependencies will automatically update the computed field.
803

804
        Examples:
805

806
            - create a char field with no further dependencies (not very useful)
807

808
            .. code-block:: python
809

810
                @computed(models.CharField(max_length=32))
811
                def ...
812

813
            - create a char field with a dependency to the field ``name`` on a
814
              foreign key relation ``fk``
815

816
            .. code-block:: python
817

818
                @computed(models.CharField(max_length=32), depends=[('fk', ['name'])])
819
                def ...
820

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

827
        .. NOTE::
828

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

831
        With `select_related` and `prefetch_related` you can instruct the dependency resolver
832
        to apply certain optimizations on the update queryset.
833

834
        .. NOTE::
835

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

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

847
        With `default_on_create` set to ``True`` the function calculation will be skipped
848
        for newly created or copy-cloned instances, instead the value will be set from the
849
        inner field's `default` argument.
850

851
        .. CAUTION::
852

853
            With the dependency resolver you can easily create recursive dependencies
854
            by accident. Imagine the following:
855

856
            .. code-block:: python
857

858
                class A(ComputedFieldsModel):
859
                    @computed(models.CharField(max_length=32), depends=[('b_set', ['comp'])])
860
                    def comp(self):
861
                        return ''.join(b.comp for b in self.b_set.all())
862

863
                class B(ComputedFieldsModel):
864
                    a = models.ForeignKey(A)
865

866
                    @computed(models.CharField(max_length=32), depends=[('a', ['comp'])])
867
                    def comp(self):
868
                        return a.comp
869

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

876
            If you experience this in your project try to get in-depth cycle
877
            information, either by using the ``rendergraph`` management command or
878
            by directly accessing the graph objects:
879

880
            - intermodel dependency graph: ``active_resolver._graph``
881
            - model local dependency graphs: ``active_resolver._graph.modelgraphs[your_model]``
882
            - union graph: ``active_resolver._graph.get_uniongraph()``
883

884
            Also see the graph documentation :ref:`here<graph>`.
885
        """
886
        def wrap(func: Callable[..., _ST]) -> 'Field[_ST, _GT]':
12✔
887
            return self.computedfield_factory(
12✔
888
                field,
889
                compute=func,
890
                depends=depends,
891
                select_related=select_related,
892
                prefetch_related=prefetch_related,
893
                querysize=querysize,
894
                default_on_create=default_on_create
895
            )
896
        return wrap
12✔
897

898
    @overload
12✔
899
    def precomputed(self, f: F) -> F:
12✔
UNCOV
900
        ...
×
901
    @overload
12✔
902
    def precomputed(self, skip_after: bool) -> Callable[[F], F]:
12✔
UNCOV
903
        ...
×
904
    def precomputed(self, *dargs, **dkwargs) -> Union[F, Callable[[F], F]]:
12✔
905
        """
906
        Decorator for custom ``save`` methods, that expect local computed fields
907
        to contain already updated values on enter.
908

909
        By default local computed field values are only calculated once by the
910
        ``ComputedFieldModel.save`` method after your own save method.
911

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

940
    def update_computedfields(
12✔
941
        self,
942
        instance: Model,
943
        update_fields: Optional[Iterable[str]] = None
944
        ) -> Optional[Iterable[str]]:
945
        """
946
        Update values of local computed fields of `instance`.
947

948
        Other than calling ``compute`` on an instance, this call overwrites
949
        computed field values on the instance (destructive).
950

951
        Returns ``None`` or an updated set of field names for `update_fields`.
952
        The returned fields might contained additional computed fields, that also
953
        changed based on the input fields, thus should extend `update_fields`
954
        on a save call.
955
        """
956
        if get_not_computed_context():
12✔
957
            return update_fields
12✔
958
        model = type(instance)
12✔
959
        if not self.has_computedfields(model):
12✔
960
            return update_fields
12✔
961
        cf_mro = self.get_local_mro(model, update_fields)
12✔
962
        if update_fields:
12✔
963
            update_fields = set(update_fields)
12✔
964
            update_fields.update(set(cf_mro))
12✔
965
        for fieldname in cf_mro:
12✔
966
            setattr(instance, fieldname, self._compute(instance, model, fieldname))
12✔
967
        if update_fields:
12✔
968
            return update_fields
12✔
969
        return None
12✔
970

971
    def has_computedfields(self, model: Type[Model]) -> bool:
12✔
972
        """
973
        Indicate whether `model` has computed fields.
974
        """
975
        return model in self._computed_models
12✔
976

977
    def get_computedfields(self, model: Type[Model]) -> Iterable[str]:
12✔
978
        """
979
        Get all computed fields on `model`.
980
        """
981
        return self._computed_models.get(model, {}).keys()
12✔
982

983
    def is_computedfield(self, model: Type[Model], fieldname: str) -> bool:
12✔
984
        """
985
        Indicate whether `fieldname` on `model` is a computed field.
986
        """
987
        return fieldname in self.get_computedfields(model)
12✔
988

989
    def get_graphs(self) -> Tuple[Graph, Dict[Type[Model], ModelGraph], Graph]:
12✔
990
        """
991
        Return a tuple of all graphs as
992
        ``(intermodel_graph, {model: modelgraph, ...}, union_graph)``.
993
        """
994
        graph = self._graph
×
995
        if not graph:
×
996
            graph = ComputedModelsGraph(active_resolver.computed_models)
×
997
            graph.get_edgepaths()
×
UNCOV
998
            graph.get_uniongraph()
×
UNCOV
999
        return (graph, graph.modelgraphs, graph.get_uniongraph())
×
1000

1001

1002
# active_resolver is currently treated as global singleton (used in imports)
1003
#: Currently active resolver.
1004
active_resolver = Resolver()
12✔
1005

1006
# BOOT_RESOLVER: resolver that holds all startup declarations and resolve maps
1007
# gets deactivated after startup, thus it is currently not possible to define
1008
# new computed fields and add their resolve rules at runtime
1009
# TODO: investigate on custom resolvers at runtime to be bootstrapped from BOOT_RESOLVER
1010
#: Resolver used during django bootstrapping.
1011
#: This is currently the same as `active_resolver` (treated as global singleton).
1012
BOOT_RESOLVER = active_resolver
12✔
1013

1014

1015
# placeholder class to test for correct model inheritance
1016
# during initial field resolving
1017
class _ComputedFieldsModelBase:
12✔
1018
    pass
12✔
1019

1020

1021
class NotComputed:
12✔
1022
    """
1023
    Context to disable all computed field calculations and resolver updates temporarily.
1024

1025
    .. CAUTION::
1026

1027
        Currently there is no auto-recovery implemented at all,
1028
        therefore it is your responsibility to recover properly from the desync state.
1029
    """
1030
    def __init__(self):
12✔
1031
        self.remove_ctx = True
12✔
1032

1033
    def __enter__(self):
12✔
1034
        ctx = get_not_computed_context()
12✔
1035
        if ctx:
12✔
1036
            self.remove_ctx = False
12✔
1037
            return ctx
12✔
1038
        set_not_computed_context(self)
12✔
1039
        return self
12✔
1040

1041
    def __exit__(self, exc_type, exc_value, traceback):
12✔
1042
        if self.remove_ctx:
12✔
1043
            set_not_computed_context(None)
12✔
1044
            # TODO: re-play aggregated changes
1045
        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