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

netzkolchose / django-computedfields / 16098276187

06 Jul 2025 10:56AM UTC coverage: 94.883% (-0.02%) from 94.903%
16098276187

Pull #177

github

web-flow
Merge be35e8b90 into 39d912552
Pull Request #177: through expansion on m2m fields

431 of 467 branches covered (92.29%)

Branch coverage included in aggregate %.

44 of 45 new or added lines in 3 files covered. (97.78%)

1 existing line in 1 file now uncovered.

1108 of 1155 relevant lines covered (95.93%)

11.51 hits per line

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

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

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

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

16
from fast_update.fast import fast_update
12✔
17

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

24

25
MALFORMED_DEPENDS = """
12✔
26
Your depends keyword argument is malformed.
27

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

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

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

47

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

53

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

58
    Basic workflow:
59

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

190
        return computed_models
12✔
191

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

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

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

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

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

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

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

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

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

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

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

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

321
        # generate narrowed down querysets for all cf dependencies
322
        for model, data in model_updates.items():
12✔
323
            fields, paths = data
12✔
324

325
            # queryset construction
326
            if m2m and self._proxymodels.get(type(m2m), type(m2m)) == model:
12!
327
                # M2M optimization: got called through an M2M signal
328
                # narrow updates to the single signal instance
NEW
UNCOV
329
                queryset = model._base_manager.filter(pk=m2m.pk)
×
330
            else:
331
                queryset: Any = model._base_manager.none()
12✔
332
                query_pipe_method = self._choose_optimal_query_pipe_method(paths)
12✔
333
                queryset = reduce(
12✔
334
                    query_pipe_method,
335
                    (model._base_manager.filter(**{path+subquery: instance}) for path in paths),
336
                    queryset
337
                )
338
            if pk_list:
12✔
339
                # need pks for post_delete since the real queryset will be empty
340
                # after deleting the instance in question
341
                # since we need to interact with the db anyways
342
                # we can already drop empty results here
343
                queryset = set(queryset.values_list('pk', flat=True).iterator())
12✔
344
                if not queryset:
12✔
345
                    continue
12✔
346
            # FIXME: change to tuple or dict for narrower type
347
            final[model] = [queryset, fields]
12✔
348
        return final
12✔
349
    
350
    def _get_model(self, instance: Union[Model, QuerySet]) -> Type[Model]:
12✔
351
        return instance.model if isinstance(instance, QuerySet) else type(instance)
12✔
352

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

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

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

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

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

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

412
            >>> Entry.objects.filter(pub_date__year=2010).update(comments_on=False)
413
            >>> update_dependent(Entry.objects.filter(pub_date__year=2010))
414

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

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

426
        .. NOTE::
427

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

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

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

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

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

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

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

456
        `update_local=False` disables model local computed field updates of the entry node. 
457
        (used as optimization during tree traversal). You should not disable it yourself.
458
        """
459
        _model = model or self._get_model(instance)
12✔
460

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

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

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

487
    def bulk_updater(
12✔
488
        self,
489
        queryset: QuerySet,
490
        update_fields: Optional[Set[str]] = None,
491
        return_pks: bool = False,
492
        local_only: bool = False,
493
        querysize: Optional[int] = None
494
    ) -> Optional[Set[Any]]:
495
        """
496
        Update local computed fields and descent in the dependency tree by calling
497
        ``update_dependent`` for dependent models.
498

499
        This method does the local field updates on `queryset`:
500

501
            - eval local `MRO` of computed fields
502
            - expand `update_fields`
503
            - apply optional `select_related` and `prefetch_related` rules to `queryset`
504
            - walk all records and recalculate fields in `update_fields`
505
            - aggregate changeset and save as batched `bulk_update` to the database
506

507
        By default this method triggers the update of dependent models by calling
508
        ``update_dependent`` with `update_fields` (next level of tree traversal).
509
        This can be suppressed by setting `local_only=True`.
510

511
        If `return_pks` is set, the method returns a set of altered pks of `queryset`.
512
        """
513
        model: Type[Model] = queryset.model
12✔
514

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

527
        # correct update_fields by local mro
528
        mro = self.get_local_mro(model, update_fields)
12✔
529
        fields: Any = set(mro)  # FIXME: narrow type once issue in django-stubs is resolved
12✔
530
        if update_fields:
12✔
531
            update_fields.update(fields)
12✔
532

533
        select = self.get_select_related(model, fields)
12✔
534
        prefetch = self.get_prefetch_related(model, fields)
12✔
535
        if select:
12✔
536
            queryset = queryset.select_related(*select)
12✔
537
        # fix #167: skip prefetch if union was used
538
        if prefetch and queryset.query.combinator != "union":
12✔
539
            queryset = queryset.prefetch_related(*prefetch)
12✔
540

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

563
        # trigger dependent comp field updates from changed records
564
        # other than before we exit the update tree early, if we have no changes at all
565
        # also cuts the update tree for recursive deps (tree-like)
566
        if not local_only and pks:
12✔
567
            self.update_dependent(model._base_manager.filter(pk__in=pks), model, fields, update_local=False)
12✔
568
        return set(pks) if return_pks else None
12✔
569
    
570
    def _update(self, queryset: QuerySet, change: Sequence[Any], fields: Sequence[str]) -> Union[int, None]:
12✔
571
        # we can skip batch_size here, as it already was batched in bulk_updater
572
        if self.use_fastupdate:
12!
573
            return fast_update(queryset, change, fields, None)
12✔
574
        return queryset.model._base_manager.bulk_update(change, fields)
×
575

576
    def _compute(self, instance: Model, model: Type[Model], fieldname: str) -> Any:
12✔
577
        """
578
        Returns the computed field value for ``fieldname``.
579
        Note that this is just a shorthand method for calling the underlying computed
580
        field method and does not deal with local MRO, thus should only be used,
581
        if the MRO is respected by other means.
582
        For quick inspection of a single computed field value, that gonna be written
583
        to the database, always use ``compute(fieldname)`` instead.
584
        """
585
        field = self._computed_models[model][fieldname]
12✔
586
        return field._computed['func'](instance)
12✔
587

588
    def compute(self, instance: Model, fieldname: str) -> Any:
12✔
589
        """
590
        Returns the computed field value for ``fieldname``. This method allows
591
        to inspect the new calculated value, that would be written to the database
592
        by a following ``save()``.
593

594
        Other than calling ``update_computedfields`` on an model instance this call
595
        is not destructive for old computed field values.
596
        """
597
        # Getting a single computed value prehand is quite complicated,
598
        # as we have to:
599
        # - resolve local MRO backwards (stored MRO data is optimized for forward deps)
600
        # - calc all local cfs, that the requested one depends on
601
        # - stack and rewind interim values, as we dont want to introduce side effects here
602
        #   (in fact the save/bulker logic might try to save db calls based on changes)
603
        mro = self.get_local_mro(type(instance), None)
12✔
604
        if not fieldname in mro:
12✔
605
            return getattr(instance, fieldname)
12✔
606
        entries = self._local_mro[type(instance)]['fields']
12✔
607
        pos = 1 << mro.index(fieldname)
12✔
608
        stack: List[Tuple[str, Any]] = []
12✔
609
        model = type(instance)
12✔
610
        for field in mro:
12!
611
            if field == fieldname:
12✔
612
                ret = self._compute(instance, model, fieldname)
12✔
613
                for field2, old in stack:
12✔
614
                    # reapply old stack values
615
                    setattr(instance, field2, old)
12✔
616
                return ret
12✔
617
            f_mro = entries.get(field, 0)
12✔
618
            if f_mro & pos:
12✔
619
                # append old value to stack for later rewinding
620
                # calc and set new value for field, if the requested one depends on it
621
                stack.append((field, getattr(instance, field)))
12✔
622
                setattr(instance, field, self._compute(instance, model, field))
12✔
623

624
    # TODO: the following 3 lookups are very expensive at runtime adding ~2s for 1M calls
625
    #       --> all need pregenerated lookup maps
626
    # Note: the same goes for get_local_mro and _queryset_for_update...
627
    def get_select_related(
12✔
628
        self,
629
        model: Type[Model],
630
        fields: Optional[Iterable[str]] = None
631
    ) -> Set[str]:
632
        """
633
        Get defined select_related rules for `fields` (all if none given).
634
        """
635
        if fields is None:
12!
636
            fields = self._computed_models[model].keys()
×
637
        select: Set[str] = set()
12✔
638
        for field in fields:
12✔
639
            select.update(self._computed_models[model][field]._computed['select_related'])
12✔
640
        return select
12✔
641

642
    def get_prefetch_related(
12✔
643
        self,
644
        model: Type[Model],
645
        fields: Optional[Iterable[str]] = None
646
    ) -> List:
647
        """
648
        Get defined prefetch_related rules for `fields` (all if none given).
649
        """
650
        if fields is None:
12!
651
            fields = self._computed_models[model].keys()
×
652
        prefetch: List[Any] = []
12✔
653
        for field in fields:
12✔
654
            prefetch.extend(self._computed_models[model][field]._computed['prefetch_related'])
12✔
655
        return prefetch
12✔
656

657
    def get_querysize(
12✔
658
        self,
659
        model: Type[Model],
660
        fields: Optional[Iterable[str]] = None,
661
        override: Optional[int] = None
662
    ) -> int:
663
        base = settings.COMPUTEDFIELDS_QUERYSIZE if override is None else override
12✔
664
        if fields is None:
12✔
665
            fields = self._computed_models[model].keys()
12✔
666
        return min(self._computed_models[model][f]._computed['querysize'] or base for f in fields)
12✔
667

668
    def get_contributing_fks(self) -> IFkMap:
12✔
669
        """
670
        Get a mapping of models and their local foreign key fields,
671
        that are part of a computed fields dependency chain.
672

673
        Whenever a bulk action changes one of the fields listed here, you have to create
674
        a listing of the associated  records with ``preupdate_dependent`` before doing
675
        the bulk change. After the bulk change feed the listing back to ``update_dependent``
676
        with the `old` argument.
677

678
        With ``COMPUTEDFIELDS_ADMIN = True`` in `settings.py` this mapping can also be
679
        inspected as admin view. 
680
        """
681
        if not self._map_loaded:  # pragma: no cover
682
            raise ResolverException('resolver has no maps loaded yet')
683
        return self._fk_map
12✔
684

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

693
        There is another class of misconfigured computed fields we currently cannot
694
        find by any safety measures - if `depends` provides valid paths and fields,
695
        but the function operates on different dependencies. Currently it is the devs'
696
        responsibility to perfectly align `depends` entries with dependencies
697
        used by the function to avoid faulty update behavior.
698
        """
699
        if not isinstance(field, Field):
12!
700
                raise ResolverException('field argument is not a Field instance')
×
701
        for rule in depends:
12✔
702
            try:
12✔
703
                path, fieldnames = rule
12✔
704
            except ValueError:
×
705
                raise ResolverException(MALFORMED_DEPENDS)
×
706
            if not isinstance(path, str) or not all(isinstance(f, str) for f in fieldnames):
12!
707
                raise ResolverException(MALFORMED_DEPENDS)
×
708

709
    def computedfield_factory(
12✔
710
        self,
711
        field: 'Field[_ST, _GT]',
712
        compute: Callable[..., _ST],
713
        depends: Optional[IDepends] = None,
714
        select_related: Optional[Sequence[str]] = None,
715
        prefetch_related: Optional[Sequence[Any]] = None,
716
        querysize: Optional[int] = None
717
    ) -> 'Field[_ST, _GT]':
718
        """
719
        Factory for computed fields.
720

721
        The method gets exposed as ``ComputedField`` to allow a more declarative
722
        code style with better separation of field declarations and function
723
        implementations. It is also used internally for the ``computed`` decorator.
724
        Similar to the decorator, the ``compute`` function expects a single argument
725
        as model instance of the model it got applied to.
726

727
        Usage example:
728

729
        .. code-block:: python
730

731
            from computedfields.models import ComputedField
732

733
            def calc_mul(inst):
734
                return inst.a * inst.b
735

736
            class MyModel(ComputedFieldsModel):
737
                a = models.IntegerField()
738
                b = models.IntegerField()
739
                sum = ComputedField(
740
                    models.IntegerField(),
741
                    depends=[('self', ['a', 'b'])],
742
                    compute=lambda inst: inst.a + inst.b
743
                )
744
                mul = ComputedField(
745
                    models.IntegerField(),
746
                    depends=[('self', ['a', 'b'])],
747
                    compute=calc_mul
748
                )
749
        """
750
        self._sanity_check(field, depends or [])
12✔
751
        cf = cast('IComputedField[_ST, _GT]', field)
12✔
752
        cf._computed = {
12✔
753
            'func': compute,
754
            'depends': depends or [],
755
            'select_related': select_related or [],
756
            'prefetch_related': prefetch_related or [],
757
            'querysize': querysize
758
        }
759
        cf.editable = False
12✔
760
        self.add_field(cf)
12✔
761
        return field
12✔
762

763
    def computed(
12✔
764
        self,
765
        field: 'Field[_ST, _GT]',
766
        depends: Optional[IDepends] = None,
767
        select_related: Optional[Sequence[str]] = None,
768
        prefetch_related: Optional[Sequence[Any]] = None,
769
        querysize: Optional[int] = None
770
    ) -> Callable[[Callable[..., _ST]], 'Field[_ST, _GT]']:
771
        """
772
        Decorator to create computed fields.
773

774
        `field` should be a model concrete field instance suitable to hold the result
775
        of the decorated method. The decorator expects a keyword argument `depends`
776
        to indicate dependencies to model fields (local or related).
777
        Listed dependencies will automatically update the computed field.
778

779
        Examples:
780

781
            - create a char field with no further dependencies (not very useful)
782

783
            .. code-block:: python
784

785
                @computed(models.CharField(max_length=32))
786
                def ...
787

788
            - create a char field with a dependency to the field ``name`` on a
789
              foreign key relation ``fk``
790

791
            .. code-block:: python
792

793
                @computed(models.CharField(max_length=32), depends=[('fk', ['name'])])
794
                def ...
795

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

802
        .. NOTE::
803

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

806
        With `select_related` and `prefetch_related` you can instruct the dependency resolver
807
        to apply certain optimizations on the update queryset.
808

809
        .. NOTE::
810

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

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

822
        .. CAUTION::
823

824
            With the dependency resolver you can easily create recursive dependencies
825
            by accident. Imagine the following:
826

827
            .. code-block:: python
828

829
                class A(ComputedFieldsModel):
830
                    @computed(models.CharField(max_length=32), depends=[('b_set', ['comp'])])
831
                    def comp(self):
832
                        return ''.join(b.comp for b in self.b_set.all())
833

834
                class B(ComputedFieldsModel):
835
                    a = models.ForeignKey(A)
836

837
                    @computed(models.CharField(max_length=32), depends=[('a', ['comp'])])
838
                    def comp(self):
839
                        return a.comp
840

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

847
            If you experience this in your project try to get in-depth cycle
848
            information, either by using the ``rendergraph`` management command or
849
            by directly accessing the graph objects:
850

851
            - intermodel dependency graph: ``active_resolver._graph``
852
            - mode local dependency graphs: ``active_resolver._graph.modelgraphs[your_model]``
853
            - union graph: ``active_resolver._graph.get_uniongraph()``
854

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

859
                >>> from computedfields.graph import ComputedModelsGraph
860
                >>> from computedfields.resolver import active_resolver
861
                >>> graph = ComputedModelsGraph(active_resolver.computed_models)
862

863
            Also see the graph documentation :ref:`here<graph>`.
864
        """
865
        def wrap(func: Callable[..., _ST]) -> 'Field[_ST, _GT]':
12✔
866
            return self.computedfield_factory(
12✔
867
                field,
868
                compute=func,
869
                depends=depends,
870
                select_related=select_related,
871
                prefetch_related=prefetch_related,
872
                querysize=querysize
873
            )
874
        return wrap
12✔
875

876
    @overload
12✔
877
    def precomputed(self, f: F) -> F:
12✔
878
        ...
×
879
    @overload
12✔
880
    def precomputed(self, skip_after: bool) -> Callable[[F], F]:
12✔
881
        ...
×
882
    def precomputed(self, *dargs, **dkwargs) -> Union[F, Callable[[F], F]]:
12✔
883
        """
884
        Decorator for custom ``save`` methods, that expect local computed fields
885
        to contain already updated values on enter.
886

887
        By default local computed field values are only calculated once by the
888
        ``ComputedFieldModel.save`` method after your own save method.
889

890
        By placing this decorator on your save method, the values will be updated
891
        before entering your method as well. Note that this comes for the price of
892
        doubled local computed field calculations (before and after your save method).
893
        
894
        To avoid a second recalculation, the decorator can be called with `skip_after=True`.
895
        Note that this might lead to desychronized computed field values, if you do late
896
        field changes in your save method without another resync afterwards.
897
        """
898
        skip: bool = False
12✔
899
        func: Optional[F] = None
12✔
900
        if dargs:
12✔
901
            if len(dargs) > 1 or not callable(dargs[0]) or dkwargs:
12!
902
                raise ResolverException('error in @precomputed declaration')
×
903
            func = dargs[0]
12✔
904
        else:
905
            skip = dkwargs.get('skip_after', False)
12✔
906
        
907
        def wrap(func: F) -> F:
12✔
908
            def _save(instance, *args, **kwargs):
12✔
909
                new_fields = self.update_computedfields(instance, kwargs.get('update_fields'))
12✔
910
                if new_fields:
12!
911
                    kwargs['update_fields'] = new_fields
×
912
                kwargs['skip_computedfields'] = skip
12✔
913
                return func(instance, *args, **kwargs)
12✔
914
            return cast(F, _save)
12✔
915
        
916
        return wrap(func) if func else wrap
12✔
917

918
    def update_computedfields(
12✔
919
        self,
920
        instance: Model,
921
        update_fields: Optional[Iterable[str]] = None
922
        ) -> Optional[Iterable[str]]:
923
        """
924
        Update values of local computed fields of `instance`.
925

926
        Other than calling ``compute`` on an instance, this call overwrites
927
        computed field values on the instance (destructive).
928

929
        Returns ``None`` or an updated set of field names for `update_fields`.
930
        The returned fields might contained additional computed fields, that also
931
        changed based on the input fields, thus should extend `update_fields`
932
        on a save call.
933
        """
934
        model = type(instance)
12✔
935
        if not self.has_computedfields(model):
12✔
936
            return update_fields
12✔
937
        cf_mro = self.get_local_mro(model, update_fields)
12✔
938
        if update_fields:
12✔
939
            update_fields = set(update_fields)
12✔
940
            update_fields.update(set(cf_mro))
12✔
941
        for fieldname in cf_mro:
12✔
942
            setattr(instance, fieldname, self._compute(instance, model, fieldname))
12✔
943
        if update_fields:
12✔
944
            return update_fields
12✔
945
        return None
12✔
946

947
    def has_computedfields(self, model: Type[Model]) -> bool:
12✔
948
        """
949
        Indicate whether `model` has computed fields.
950
        """
951
        return model in self._computed_models
12✔
952

953
    def get_computedfields(self, model: Type[Model]) -> Iterable[str]:
12✔
954
        """
955
        Get all computed fields on `model`.
956
        """
957
        return self._computed_models.get(model, {}).keys()
12✔
958

959
    def is_computedfield(self, model: Type[Model], fieldname: str) -> bool:
12✔
960
        """
961
        Indicate whether `fieldname` on `model` is a computed field.
962
        """
963
        return fieldname in self.get_computedfields(model)
12✔
964

965
    def get_graphs(self) -> Tuple[Graph, Dict[Type[Model], ModelGraph], Graph]:
12✔
966
        """
967
        Return a tuple of all graphs as
968
        ``(intermodel_graph, {model: modelgraph, ...}, union_graph)``.
969
        """
970
        graph = self._graph
×
971
        if not graph:
×
972
            graph = ComputedModelsGraph(active_resolver.computed_models)
×
973
            graph.get_edgepaths()
×
974
            graph.get_uniongraph()
×
975
        return (graph, graph.modelgraphs, graph.get_uniongraph())
×
976

977

978
# active_resolver is currently treated as global singleton (used in imports)
979
#: Currently active resolver.
980
active_resolver = Resolver()
12✔
981

982
# BOOT_RESOLVER: resolver that holds all startup declarations and resolve maps
983
# gets deactivated after startup, thus it is currently not possible to define
984
# new computed fields and add their resolve rules at runtime
985
# TODO: investigate on custom resolvers at runtime to be bootstrapped from BOOT_RESOLVER
986
#: Resolver used during django bootstrapping.
987
#: This is currently the same as `active_resolver` (treated as global singleton).
988
BOOT_RESOLVER = active_resolver
12✔
989

990

991
# placeholder class to test for correct model inheritance
992
# during initial field resolving
993
class _ComputedFieldsModelBase:
12✔
994
    pass
12✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc