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

netzkolchose / django-computedfields / 16734946149

04 Aug 2025 09:45PM UTC coverage: 94.927% (-0.8%) from 95.75%
16734946149

Pull #197

github

web-flow
Merge d7cbc908e into 1259b59f9
Pull Request #197: performance optimizations

550 of 594 branches covered (92.59%)

Branch coverage included in aggregate %.

96 of 114 new or added lines in 3 files covered. (84.21%)

1340 of 1397 relevant lines covered (95.92%)

14.3 hits per line

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

92.15
/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
15✔
5
import operator
15✔
6
from functools import reduce
15✔
7
from collections import defaultdict
15✔
8

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

12
from .settings import settings
15✔
13
from .graph import ComputedModelsGraph, ComputedFieldsException, Graph, ModelGraph, IM2mMap
15✔
14
from .helpers import proxy_to_base_model, slice_iterator, subquery_pk, are_same, frozenset_none
15✔
15
from . import __version__
15✔
16
from .signals import resolver_start, resolver_exit, resolver_update
15✔
17

18
from .backends import UPDATE_IMPLEMENTATIONS
15✔
19

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

27

28
MALFORMED_DEPENDS = """
15✔
29
Your depends keyword argument is malformed.
30

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

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

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

50

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

56

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

61
    Basic workflow:
62

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

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

77
        # resolving phase data and final maps
78
        self._graph: Optional[ComputedModelsGraph] = None
15✔
79
        self._computed_models: Dict[Type[Model], Dict[str, IComputedField]] = {}
15✔
80
        self._map: ILookupMap = {}
15✔
81
        self._fk_map: IFkMap = {}
15✔
82
        self._local_mro: ILocalMroMap = {}
15✔
83
        self._m2m: IM2mMap = {}
15✔
84
        self._proxymodels: Dict[Type[Model], Type[Model]] = {}
15✔
85
        self._batchsize: int = settings.COMPUTEDFIELDS_BATCHSIZE
15✔
86
        self._update_backend: str = settings.COMPUTEDFIELDS_UPDATE_BACKEND
15✔
87
        try:
15✔
88
            self._update = UPDATE_IMPLEMENTATIONS[self._update_backend]
15✔
NEW
89
        except KeyError:
×
NEW
90
            raise ResolverException(
×
91
                f'\nCOMPUTEDFIELDS_UPDATE_BACKEND must be one of '
92
                f'{list(UPDATE_IMPLEMENTATIONS.keys())}'
93
            )
94

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

100
        # runtime caches
101
        self._cached_updates: IModelUpdateCache = defaultdict(dict)
15✔
102
        self._cached_mro = defaultdict(dict)
15✔
103
        self._cached_select_related = defaultdict(dict)
15✔
104
        self._cached_prefetch_related = defaultdict(dict)
15✔
105
        self._cached_querysize = defaultdict(lambda: defaultdict(dict))
15✔
106

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

115
    def add_field(self, field: IComputedField) -> None:
15✔
116
        """
117
        Collects fields from decoration stage of @computed.
118
        """
119
        if self._sealed:
15✔
120
            raise ResolverException('cannot add computed fields on sealed resolver')
15✔
121
        self.computedfields.add(field)
15✔
122

123
    def seal(self) -> None:
15✔
124
        """
125
        Seal the resolver, so no new models or computed fields can be added anymore.
126

127
        This marks the end of the collector phase and is a basic security measure
128
        to catch runtime model creations with computed fields.
129

130
        (Currently runtime creation of models with computed fields is not supported,
131
        trying to do so will raise an exception. This might change in future versions.)
132
        """
133
        self._sealed = True
15✔
134

135
    @property
15✔
136
    def models_with_computedfields(self) -> Generator[Tuple[Type[Model], Set[IComputedField]], None, None]:
15✔
137
        """
138
        Generator of tracked models with their computed fields.
139

140
        This cannot be accessed during the collector phase.
141
        """
142
        if not self._sealed:
15✔
143
            raise ResolverException('resolver must be sealed before accessing models or fields')
15✔
144

145
        field_ids: List[int] = [f.creation_counter for f in self.computedfields]
15✔
146
        for model in self.models:
15✔
147
            fields = set()
15✔
148
            for field in model._meta.fields:
15✔
149
                # for some reason the in ... check does not work for Django >= 3.2 anymore
150
                # workaround: check for _computed and the field creation_counter
151
                if hasattr(field, '_computed') and field.creation_counter in field_ids:
15✔
152
                    fields.add(field)
15✔
153
            if fields:
15✔
154
                yield (model, cast(Set[IComputedField], fields))
15✔
155

156
    @property
15✔
157
    def computedfields_with_models(self) -> Generator[Tuple[IComputedField, Set[Type[Model]]], None, None]:
15✔
158
        """
159
        Generator of tracked computed fields and their models.
160

161
        This cannot be accessed during the collector phase.
162
        """
163
        if not self._sealed:
15✔
164
            raise ResolverException('resolver must be sealed before accessing models or fields')
15✔
165

166
        for field in self.computedfields:
15✔
167
            models = set()
15✔
168
            for model in self.models:
15✔
169
                for f in model._meta.fields:
15✔
170
                    if hasattr(field, '_computed') and f.creation_counter == field.creation_counter:
15✔
171
                        models.add(model)
15✔
172
            yield (field, models)
15✔
173

174
    @property
15✔
175
    def computed_models(self) -> Dict[Type[Model], Dict[str, IComputedField]]:
15✔
176
        """
177
        Mapping of `ComputedFieldModel` models and their computed fields.
178

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

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

206
        return computed_models
15✔
207

208
    def initialize(self, models_only: bool = False) -> None:
15✔
209
        """
210
        Entrypoint for ``app.ready`` to seal the resolver and trigger
211
        the resolver map creation.
212

213
        Upon instantiation the resolver is in the collector phase, where it tracks
214
        model registrations and computed field decorations.
215

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

226
    def load_maps(self, _force_recreation: bool = False) -> None:
15✔
227
        """
228
        Load all needed resolver maps. The steps are:
229

230
            - create intermodel graph of the dependencies
231
            - remove redundant paths with cycling check
232
            - create modelgraphs for local MRO
233
            - merge graphs to uniongraph with cycling check
234
            - create final resolver maps
235

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

251
    def _clear_runtime_caches(self):
15✔
252
        """
253
        Clear all runtime caches.
254
        """
255
        self._cached_updates.clear()
15✔
256
        self._cached_mro.clear()
15✔
257
        self._cached_select_related.clear()
15✔
258
        self._cached_prefetch_related.clear()
15✔
259
        self._cached_querysize.clear()
15✔
260

261
    def _patch_proxy_models(self) -> None:
15✔
262
        """
263
        Patch proxy models into the resolver maps.
264
        """
265
        for model in self.models:
15✔
266
            if model._meta.proxy:
15✔
267
                basemodel = proxy_to_base_model(model)
15✔
268
                if basemodel in self._map:
15✔
269
                    self._map[model] = self._map[basemodel]
15✔
270
                if basemodel in self._fk_map:
15✔
271
                    self._fk_map[model] = self._fk_map[basemodel]
15✔
272
                if basemodel in self._local_mro:
15✔
273
                    self._local_mro[model] = self._local_mro[basemodel]
15✔
274
                if basemodel in self._m2m:
15!
275
                    self._m2m[model] = self._m2m[basemodel]
×
276
                self._proxymodels[model] = basemodel or model
15✔
277

278
    def get_local_mro(
15✔
279
        self,
280
        model: Type[Model],
281
        update_fields: Optional[FrozenSet[str]] = None
282
    ) -> List[str]:
283
        """
284
        Return `MRO` for local computed field methods for a given set of `update_fields`.
285
        The returned list of fieldnames must be calculated in order to correctly update
286
        dependent computed field values in one pass.
287

288
        Returns computed fields as self dependent to simplify local field dependency calculation.
289
        """
290
        try:
15✔
291
            return self._cached_mro[model][update_fields]
15✔
292
        except KeyError:
15✔
293
            pass
15✔
294
        entry = self._local_mro.get(model)
15✔
295
        if not entry:
15✔
296
            self._cached_mro[model][update_fields] = []
15✔
297
            return []
15✔
298
        if update_fields is None:
15✔
299
            self._cached_mro[model][update_fields] = entry['base']
15✔
300
            return entry['base']
15✔
301
        base = entry['base']
15✔
302
        fields = entry['fields']
15✔
303
        mro = 0
15✔
304
        for field in update_fields:
15✔
305
            mro |= fields.get(field, 0)
15✔
306
        result = [name for pos, name in enumerate(base) if mro & (1 << pos)]
15✔
307
        self._cached_mro[model][update_fields] = result
15✔
308
        return result
15✔
309

310
    def get_model_updates(
15✔
311
        self,
312
        model: Type[Model],
313
        update_fields: Optional[FrozenSet[str]] = None
314
    ) -> IModelUpdate:
315
        """
316
        For a given model and updated fields this method
317
        returns a dictionary with dependent models (keys) and a tuple
318
        with dependent fields and the queryset accessor string (value).
319
        """
320
        try:
15✔
321
            return self._cached_updates[model][update_fields]
15✔
322
        except KeyError:
15✔
323
            pass
15✔
324
        modeldata = self._map.get(model)
15✔
325
        if not modeldata:
15✔
326
            self._cached_updates[model][update_fields] = {}
15✔
327
            return {}
15✔
328
        if not update_fields:
15✔
329
            updates: Set[str] = set(modeldata.keys())
15✔
330
        else:
331
            updates = set()
15✔
332
            for fieldname in update_fields:
15✔
333
                if fieldname in modeldata:
15✔
334
                    updates.add(fieldname)
15✔
335
        model_updates: IModelUpdate = defaultdict(lambda: (set(), set()))
15✔
336
        for update in updates:
15✔
337
            # aggregate fields and paths to cover
338
            # multiple comp field dependencies
339
            for m, r in modeldata[update].items():
15✔
340
                fields, paths = r
15✔
341
                m_fields, m_paths = model_updates[m]
15✔
342
                m_fields.update(fields)
15✔
343
                m_paths.update(paths)
15✔
344
        self._cached_updates[model][update_fields] = model_updates
15✔
345
        return model_updates
15✔
346

347
    def _querysets_for_update(
15✔
348
        self,
349
        model: Type[Model],
350
        instance: Union[Model, QuerySet],
351
        update_fields: Optional[Iterable[str]] = None,
352
        pk_list: bool = False,
353
    ) -> Dict[Type[Model], List[Any]]:
354
        """
355
        Returns a mapping of all dependent models, dependent fields and a
356
        queryset containing all dependent objects.
357
        """
358
        final: Dict[Type[Model], List[Any]] = {}
15✔
359
        model_updates = self.get_model_updates(model, frozenset_none(update_fields))
15✔
360
        if not model_updates:
15✔
361
            return final
15✔
362

363
        subquery = '__in' if isinstance(instance, QuerySet) else ''
15✔
364
        # fix #100
365
        # mysql does not support 'LIMIT & IN/ALL/ANY/SOME subquery'
366
        # thus we extract pks explicitly instead
367
        real_inst: Union[Model, QuerySet, Set[Any]] = instance
15✔
368
        if isinstance(instance, QuerySet):
15✔
369
            from django.db import connections
15✔
370
            if not instance.query.can_filter() and connections[instance.db].vendor == 'mysql':
15!
371
                real_inst = set(instance.values_list('pk', flat=True).iterator())
×
372

373
        # generate narrowed down querysets for all cf dependencies
374
        for m, data in model_updates.items():
15✔
375
            fields, paths = data
15✔
376
            queryset: Union[QuerySet, Set[Any]] = m._base_manager.none()
15✔
377
            query_pipe_method = self._choose_optimal_query_pipe_method(paths)
15✔
378
            queryset = reduce(
15✔
379
                query_pipe_method,
380
                (m._base_manager.filter(**{path+subquery: real_inst}) for path in paths),
381
                queryset
382
            )
383
            if pk_list:
15✔
384
                # need pks for post_delete since the real queryset will be empty
385
                # after deleting the instance in question
386
                # since we need to interact with the db anyways
387
                # we can already drop empty results here
388
                queryset = set(queryset.values_list('pk', flat=True).iterator())
15✔
389
                if not queryset:
15✔
390
                    continue
15✔
391
            # FIXME: change to tuple or dict for narrower type
392
            final[m] = [queryset, fields]
15✔
393
        return final
15✔
394
    
395
    def _get_model(self, instance: Union[Model, QuerySet]) -> Type[Model]:
15✔
396
        return instance.model if isinstance(instance, QuerySet) else type(instance)
15✔
397

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

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

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

436
        # exit empty, if we are in not_computed context
437
        if ctx := get_not_computed_context():
15✔
438
            if result and ctx.recover:
15✔
439
                ctx.record_querysets(result)
15✔
440
            return {}
15✔
441
        return result
15✔
442

443
    def update_dependent(
15✔
444
        self,
445
        instance: Union[QuerySet, Model],
446
        model: Optional[Type[Model]] = None,
447
        update_fields: Optional[Iterable[str]] = None,
448
        old: Optional[Dict[Type[Model], List[Any]]] = None,
449
        update_local: bool = True,
450
        querysize: Optional[int] = None,
451
        _is_recursive: bool = False
452
    ) -> None:
453
        """
454
        Updates all dependent computed fields on related models traversing
455
        the dependency tree as shown in the graphs.
456

457
        This is the main entry hook of the resolver to do updates on dependent
458
        computed fields during runtime. While this is done automatically for
459
        model instance actions from signal handlers, you have to call it yourself
460
        after changes done by bulk actions.
461

462
        To do that, simply call this function after the update with the queryset
463
        containing the changed objects:
464

465
            >>> Entry.objects.filter(pub_date__year=2010).update(comments_on=False)
466
            >>> update_dependent(Entry.objects.filter(pub_date__year=2010))
467

468
        This can also be used with ``bulk_create``. Since ``bulk_create``
469
        returns the objects in a python container, you have to create the queryset
470
        yourself, e.g. with pks:
471

472
            >>> objs = Entry.objects.bulk_create([
473
            ...     Entry(headline='This is a test'),
474
            ...     Entry(headline='This is only a test'),
475
            ... ])
476
            >>> pks = set(obj.pk for obj in objs)
477
            >>> update_dependent(Entry.objects.filter(pk__in=pks))
478

479
        .. NOTE::
480

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

484
                >>> MyComputedModel.objects.bulk_create([
485
                ...     MyComputedModel(comp='SENTINEL'), # here or as default field value
486
                ...     MyComputedModel(comp='SENTINEL'),
487
                ... ])
488
                >>> update_dependent(MyComputedModel.objects.filter(comp='SENTINEL'))
489

490
            If the sentinel is beyond reach of the method result, this even ensures to update
491
            only the newly added records.
492

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

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

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

504
                >>> # given: some computed fields model depends somehow on Entry.fk_field
505
                >>> old_relations = preupdate_dependent(Entry.objects.filter(pub_date__year=2010))
506
                >>> Entry.objects.filter(pub_date__year=2010).update(fk_field=new_related_obj)
507
                >>> update_dependent(Entry.objects.filter(pub_date__year=2010), old=old_relations)
508

509
        `update_local=False` disables model local computed field updates of the entry node. 
510
        (used as optimization during tree traversal). You should not disable it yourself.
511
        """
512
        _model = model or self._get_model(instance)
15✔
513

514
        # bulk_updater might change fields, ensure we have set/None
515
        _update_fields = None if update_fields is None else set(update_fields)
15✔
516

517
        # exit early if we are in not_computed context
518
        if ctx := get_not_computed_context():
15✔
519
            if ctx.recover:
15✔
520
                ctx.record_update(instance, _model, _update_fields)
15✔
521
            return
15✔
522

523
        # Note: update_local is always off for updates triggered from the resolver
524
        # but True by default to avoid accidentally skipping updates called by user
525
        if update_local and self.has_computedfields(_model):
15✔
526
            # We skip a transaction here in the same sense,
527
            # as local cf updates are not guarded either.
528
            # FIXME: signals are broken here...
529
            if isinstance(instance, QuerySet):
15!
530
                self.bulk_updater(instance, _update_fields, local_only=True, querysize=querysize)
15✔
531
            else:
NEW
532
                self.single_updater(_model, instance, _update_fields)
×
533

534
        updates = self._querysets_for_update(_model, instance, _update_fields).values()
15✔
535
        if updates:
15✔
536
            if not _is_recursive:
15✔
537
                resolver_start.send(sender=self)
15✔
538
                with transaction.atomic():
15✔
539
                    pks_updated: Dict[Type[Model], Set[Any]] = {}
15✔
540
                    for queryset, fields in updates:
15✔
541
                        _pks = self.bulk_updater(queryset, fields, return_pks=True, querysize=querysize)
15✔
542
                        if _pks:
15✔
543
                            pks_updated[queryset.model] = _pks
15✔
544
                    if old:
15✔
545
                        for model2, data in old.items():
15✔
546
                            pks, fields = data
15✔
547
                            queryset = model2.objects.filter(pk__in=pks-pks_updated.get(model2, set()))
15✔
548
                            self.bulk_updater(queryset, fields, querysize=querysize)
15✔
549
            else:
550
                for queryset, fields in updates:
15✔
551
                    self.bulk_updater(queryset, fields, return_pks=False, querysize=querysize)
15✔
552
            if not _is_recursive:
15✔
553
                resolver_exit.send(sender=self)
15✔
554

555
    def single_updater(
15✔
556
        self,
557
        model,
558
        instance,
559
        update_fields
560
    ):
561
        # TODO: needs a couple of tests, proper typing and doc
NEW
562
        cf_mro = self.get_local_mro(model, frozenset_none(update_fields))
×
NEW
563
        if update_fields:
×
NEW
564
            update_fields.update(cf_mro)
×
NEW
565
        changed = []
×
NEW
566
        for fieldname in cf_mro:
×
NEW
567
            old_value = getattr(instance, fieldname)
×
NEW
568
            new_value = self._compute(instance, model, fieldname)
×
NEW
569
            if new_value != old_value:
×
NEW
570
                changed.append(fieldname)
×
NEW
571
                setattr(instance, fieldname, new_value)
×
NEW
572
        if changed:
×
NEW
573
            self._update(model.objects.all(), [instance], changed)
×
NEW
574
            resolver_update.send(sender=self, model=model, fields=changed, pks=[instance.pk])
×
575

576
    def bulk_updater(
15✔
577
        self,
578
        queryset: QuerySet,
579
        update_fields: Optional[Set[str]] = None,
580
        return_pks: bool = False,
581
        local_only: bool = False,
582
        querysize: Optional[int] = None
583
    ) -> Optional[Set[Any]]:
584
        """
585
        Update local computed fields and descent in the dependency tree by calling
586
        ``update_dependent`` for dependent models.
587

588
        This method does the local field updates on `queryset`:
589

590
            - eval local `MRO` of computed fields
591
            - expand `update_fields`
592
            - apply optional `select_related` and `prefetch_related` rules to `queryset`
593
            - walk all records and recalculate fields in `update_fields`
594
            - aggregate changeset and save as batched `bulk_update` to the database
595

596
        By default this method triggers the update of dependent models by calling
597
        ``update_dependent`` with `update_fields` (next level of tree traversal).
598
        This can be suppressed by setting `local_only=True`.
599

600
        If `return_pks` is set, the method returns a set of altered pks of `queryset`.
601
        """
602
        model: Type[Model] = queryset.model
15✔
603

604
        # distinct issue workaround
605
        # the workaround is needed for already sliced/distinct querysets coming from outside
606
        # TODO: distinct is a major query perf smell, and is in fact only needed on back relations
607
        #       may need some rework in _querysets_for_update
608
        #       ideally we find a way to avoid it for forward relations
609
        #       also see #101
610
        if queryset.query.can_filter() and not queryset.query.distinct_fields:
15!
611
            if queryset.query.combinator != "union":
15✔
612
                queryset = queryset.distinct()
15✔
613
        else:
614
            queryset = model._base_manager.filter(pk__in=subquery_pk(queryset, queryset.db))
×
615

616
        # correct update_fields by local mro
617
        mro: List[str] = self.get_local_mro(model, frozenset_none(update_fields))
15✔
618
        fields = frozenset(mro)
15✔
619
        if update_fields:
15✔
620
            update_fields.update(fields)
15✔
621

622
        # fix #167: skip prefetch/select if union was used
623
        # fix #193: if select or prefetch is set, extract pks on UNIONed queryset
624
        select = self.get_select_related(model, fields)
15✔
625
        prefetch = self.get_prefetch_related(model, fields)
15✔
626
        if (select or prefetch) and queryset.query.combinator == "union":
15✔
627
            queryset = model._base_manager.filter(pk__in=subquery_pk(queryset, queryset.db))
15✔
628
        if select:
15✔
629
            queryset = queryset.select_related(*select)
15✔
630
        if prefetch:
15✔
631
            queryset = queryset.prefetch_related(*prefetch)
15✔
632

633
        pks = []
15✔
634
        if fields:
15✔
635
            q_size = self.get_querysize(model, fields, querysize)
15✔
636
            changed_objs: List[Model] = []
15✔
637
            for elem in slice_iterator(queryset, q_size):
15✔
638
                # note on the loop: while it is technically not needed to batch things here,
639
                # we still prebatch to not cause memory issues for very big querysets
640
                has_changed = False
15✔
641
                for comp_field in mro:
15✔
642
                    new_value = self._compute(elem, model, comp_field)
15✔
643
                    if new_value != getattr(elem, comp_field):
15✔
644
                        has_changed = True
15✔
645
                        setattr(elem, comp_field, new_value)
15✔
646
                if has_changed:
15✔
647
                    changed_objs.append(elem)
15✔
648
                    pks.append(elem.pk)
15✔
649
                if len(changed_objs) >= self._batchsize:
15✔
650
                    self._update(model._base_manager.all(), changed_objs, fields)
15✔
651
                    changed_objs = []
15✔
652
            if changed_objs:
15✔
653
                self._update(model._base_manager.all(), changed_objs, fields)
15✔
654

655
            if pks:
15✔
656
                resolver_update.send(sender=self, model=model, fields=fields, pks=pks)
15✔
657

658
        # trigger dependent comp field updates from changed records
659
        # other than before we exit the update tree early, if we have no changes at all
660
        # also cuts the update tree for recursive deps (tree-like)
661
        if not local_only and pks:
15✔
662
            self.update_dependent(
15✔
663
                instance=model._base_manager.filter(pk__in=pks),
664
                model=model,
665
                update_fields=fields,
666
                update_local=False,
667
                _is_recursive=True
668
            )
669
        return set(pks) if return_pks else None
15✔
670

671
    def _compute(self, instance: Model, model: Type[Model], fieldname: str) -> Any:
15✔
672
        """
673
        Returns the computed field value for ``fieldname``.
674
        Note that this is just a shorthand method for calling the underlying computed
675
        field method and does not deal with local MRO, thus should only be used,
676
        if the MRO is respected by other means.
677
        For quick inspection of a single computed field value, that gonna be written
678
        to the database, always use ``compute(fieldname)`` instead.
679
        """
680
        field = self._computed_models[model][fieldname]
15✔
681
        if instance._state.adding or not instance.pk:
15✔
682
            if field._computed['default_on_create']:
15✔
683
                return field.get_default()
15✔
684
        return field._computed['func'](instance)
15✔
685

686
    def compute(self, instance: Model, fieldname: str) -> Any:
15✔
687
        """
688
        Returns the computed field value for ``fieldname``. This method allows
689
        to inspect the new calculated value, that would be written to the database
690
        by a following ``save()``.
691

692
        Other than calling ``update_computedfields`` on an model instance this call
693
        is not destructive for old computed field values.
694
        """
695
        # Getting a single computed value prehand is quite complicated,
696
        # as we have to:
697
        # - resolve local MRO backwards (stored MRO data is optimized for forward deps)
698
        # - calc all local cfs, that the requested one depends on
699
        # - stack and rewind interim values, as we dont want to introduce side effects here
700
        #   (in fact the save/bulker logic might try to save db calls based on changes)
701
        if get_not_computed_context():
15✔
702
            return getattr(instance, fieldname)
15✔
703
        mro = self.get_local_mro(type(instance), None)
15✔
704
        if not fieldname in mro:
15✔
705
            return getattr(instance, fieldname)
15✔
706
        entries = self._local_mro[type(instance)]['fields']
15✔
707
        pos = 1 << mro.index(fieldname)
15✔
708
        stack: List[Tuple[str, Any]] = []
15✔
709
        model = type(instance)
15✔
710
        for field in mro:
15!
711
            if field == fieldname:
15✔
712
                ret = self._compute(instance, model, fieldname)
15✔
713
                for field2, old in stack:
15✔
714
                    # reapply old stack values
715
                    setattr(instance, field2, old)
15✔
716
                return ret
15✔
717
            f_mro = entries.get(field, 0)
15✔
718
            if f_mro & pos:
15✔
719
                # append old value to stack for later rewinding
720
                # calc and set new value for field, if the requested one depends on it
721
                stack.append((field, getattr(instance, field)))
15✔
722
                setattr(instance, field, self._compute(instance, model, field))
15✔
723

724
    def get_select_related(
15✔
725
        self,
726
        model: Type[Model],
727
        fields: Optional[FrozenSet[str]] = None
728
    ) -> Set[str]:
729
        """
730
        Get defined select_related rules for `fields` (all if none given).
731
        """
732
        try:
15✔
733
            return self._cached_select_related[model][fields]
15✔
734
        except KeyError:
15✔
735
            pass
15✔
736
        select: Set[str] = set()
15✔
737
        ff = fields
15✔
738
        if ff is None:
15!
NEW
739
            ff = frozenset(self._computed_models[model].keys())
×
740
        for field in ff:
15✔
741
            select.update(self._computed_models[model][field]._computed['select_related'])
15✔
742
        self._cached_select_related[model][fields] = select
15✔
743
        return select
15✔
744

745
    def get_prefetch_related(
15✔
746
        self,
747
        model: Type[Model],
748
        fields: Optional[FrozenSet[str]] = None
749
    ) -> List:
750
        """
751
        Get defined prefetch_related rules for `fields` (all if none given).
752
        """
753
        try:
15✔
754
            return self._cached_prefetch_related[model][fields]
15✔
755
        except KeyError:
15✔
756
            pass
15✔
757
        prefetch: List[Any] = []
15✔
758
        ff = fields
15✔
759
        if ff is None:
15!
NEW
760
            ff = frozenset(self._computed_models[model].keys())
×
761
        for field in ff:
15✔
762
            prefetch.extend(self._computed_models[model][field]._computed['prefetch_related'])
15✔
763
        self._cached_prefetch_related[model][fields] = prefetch
15✔
764
        return prefetch
15✔
765

766
    def get_querysize(
15✔
767
        self,
768
        model: Type[Model],
769
        fields: Optional[FrozenSet[str]] = None,
770
        override: Optional[int] = None
771
    ) -> int:
772
        try:
15✔
773
            return self._cached_querysize[model][fields][override]
15✔
774
        except KeyError:
15✔
775
            pass
15✔
776
        ff = fields
15✔
777
        if ff is None:
15✔
778
            ff = frozenset(self._computed_models[model].keys())
15✔
779
        base = settings.COMPUTEDFIELDS_QUERYSIZE if override is None else override
15✔
780
        result = min(self._computed_models[model][f]._computed['querysize'] or base for f in ff)
15✔
781
        self._cached_querysize[model][fields][override] = result
15✔
782
        return result
15✔
783

784
    def get_contributing_fks(self) -> IFkMap:
15✔
785
        """
786
        Get a mapping of models and their local foreign key fields,
787
        that are part of a computed fields dependency chain.
788

789
        Whenever a bulk action changes one of the fields listed here, you have to create
790
        a listing of the associated  records with ``preupdate_dependent`` before doing
791
        the bulk change. After the bulk change feed the listing back to ``update_dependent``
792
        with the `old` argument.
793

794
        With ``COMPUTEDFIELDS_ADMIN = True`` in `settings.py` this mapping can also be
795
        inspected as admin view. 
796
        """
797
        if not self._map_loaded:  # pragma: no cover
798
            raise ResolverException('resolver has no maps loaded yet')
799
        return self._fk_map
15✔
800

801
    def _sanity_check(self, field: Field, depends: IDepends) -> None:
15✔
802
        """
803
        Basic type check for computed field arguments `field` and `depends`.
804
        This only checks for proper type alignment (most crude source of errors) to give
805
        devs an early startup error for misconfigured computed fields.
806
        More subtle errors like non-existing paths or fields are caught
807
        by the resolver during graph reduction yielding somewhat crytic error messages.
808

809
        There is another class of misconfigured computed fields we currently cannot
810
        find by any safety measures - if `depends` provides valid paths and fields,
811
        but the function operates on different dependencies. Currently it is the devs'
812
        responsibility to perfectly align `depends` entries with dependencies
813
        used by the function to avoid faulty update behavior.
814
        """
815
        if not isinstance(field, Field):
15!
816
                raise ResolverException('field argument is not a Field instance')
×
817
        for rule in depends:
15✔
818
            try:
15✔
819
                path, fieldnames = rule
15✔
820
            except ValueError:
×
821
                raise ResolverException(MALFORMED_DEPENDS)
×
822
            if not isinstance(path, str) or not all(isinstance(f, str) for f in fieldnames):
15!
823
                raise ResolverException(MALFORMED_DEPENDS)
×
824

825
    def computedfield_factory(
15✔
826
        self,
827
        field: 'Field[_ST, _GT]',
828
        compute: Callable[..., _ST],
829
        depends: Optional[IDepends] = None,
830
        select_related: Optional[Sequence[str]] = None,
831
        prefetch_related: Optional[Sequence[Any]] = None,
832
        querysize: Optional[int] = None,
833
        default_on_create: Optional[bool] = False
834
    ) -> 'Field[_ST, _GT]':
835
        """
836
        Factory for computed fields.
837

838
        The method gets exposed as ``ComputedField`` to allow a more declarative
839
        code style with better separation of field declarations and function
840
        implementations. It is also used internally for the ``computed`` decorator.
841
        Similar to the decorator, the ``compute`` function expects a single argument
842
        as model instance of the model it got applied to.
843

844
        Usage example:
845

846
        .. code-block:: python
847

848
            from computedfields.models import ComputedField
849

850
            def calc_mul(inst):
851
                return inst.a * inst.b
852

853
            class MyModel(ComputedFieldsModel):
854
                a = models.IntegerField()
855
                b = models.IntegerField()
856
                sum = ComputedField(
857
                    models.IntegerField(),
858
                    depends=[('self', ['a', 'b'])],
859
                    compute=lambda inst: inst.a + inst.b
860
                )
861
                mul = ComputedField(
862
                    models.IntegerField(),
863
                    depends=[('self', ['a', 'b'])],
864
                    compute=calc_mul
865
                )
866
        """
867
        self._sanity_check(field, depends or [])
15✔
868
        cf = cast('IComputedField[_ST, _GT]', field)
15✔
869
        cf._computed = {
15✔
870
            'func': compute,
871
            'depends': depends or [],
872
            'select_related': select_related or [],
873
            'prefetch_related': prefetch_related or [],
874
            'querysize': querysize,
875
            'default_on_create': default_on_create
876
        }
877
        cf.editable = False
15✔
878
        self.add_field(cf)
15✔
879
        return field
15✔
880

881
    def computed(
15✔
882
        self,
883
        field: 'Field[_ST, _GT]',
884
        depends: Optional[IDepends] = None,
885
        select_related: Optional[Sequence[str]] = None,
886
        prefetch_related: Optional[Sequence[Any]] = None,
887
        querysize: Optional[int] = None,
888
        default_on_create: Optional[bool] = False
889
    ) -> Callable[[Callable[..., _ST]], 'Field[_ST, _GT]']:
890
        """
891
        Decorator to create computed fields.
892

893
        `field` should be a model concrete field instance suitable to hold the result
894
        of the decorated method. The decorator expects a keyword argument `depends`
895
        to indicate dependencies to model fields (local or related).
896
        Listed dependencies will automatically update the computed field.
897

898
        Examples:
899

900
            - create a char field with no further dependencies (not very useful)
901

902
            .. code-block:: python
903

904
                @computed(models.CharField(max_length=32))
905
                def ...
906

907
            - create a char field with a dependency to the field ``name`` on a
908
              foreign key relation ``fk``
909

910
            .. code-block:: python
911

912
                @computed(models.CharField(max_length=32), depends=[('fk', ['name'])])
913
                def ...
914

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

921
        .. NOTE::
922

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

925
        With `select_related` and `prefetch_related` you can instruct the dependency resolver
926
        to apply certain optimizations on the update queryset.
927

928
        .. NOTE::
929

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

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

941
        With `default_on_create` set to ``True`` the function calculation will be skipped
942
        for newly created or copy-cloned instances, instead the value will be set from the
943
        inner field's `default` argument.
944

945
        .. CAUTION::
946

947
            With the dependency resolver you can easily create recursive dependencies
948
            by accident. Imagine the following:
949

950
            .. code-block:: python
951

952
                class A(ComputedFieldsModel):
953
                    @computed(models.CharField(max_length=32), depends=[('b_set', ['comp'])])
954
                    def comp(self):
955
                        return ''.join(b.comp for b in self.b_set.all())
956

957
                class B(ComputedFieldsModel):
958
                    a = models.ForeignKey(A)
959

960
                    @computed(models.CharField(max_length=32), depends=[('a', ['comp'])])
961
                    def comp(self):
962
                        return a.comp
963

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

970
            If you experience this in your project try to get in-depth cycle
971
            information, either by using the ``rendergraph`` management command or
972
            by directly accessing the graph objects:
973

974
            - intermodel dependency graph: ``active_resolver._graph``
975
            - model local dependency graphs: ``active_resolver._graph.modelgraphs[your_model]``
976
            - union graph: ``active_resolver._graph.get_uniongraph()``
977

978
            Also see the graph documentation :ref:`here<graph>`.
979
        """
980
        def wrap(func: Callable[..., _ST]) -> 'Field[_ST, _GT]':
15✔
981
            return self.computedfield_factory(
15✔
982
                field,
983
                compute=func,
984
                depends=depends,
985
                select_related=select_related,
986
                prefetch_related=prefetch_related,
987
                querysize=querysize,
988
                default_on_create=default_on_create
989
            )
990
        return wrap
15✔
991

992
    @overload
15✔
993
    def precomputed(self, f: F) -> F:
15✔
994
        ...
×
995
    @overload
15✔
996
    def precomputed(self, skip_after: bool) -> Callable[[F], F]:
15✔
997
        ...
×
998
    def precomputed(self, *dargs, **dkwargs) -> Union[F, Callable[[F], F]]:
15✔
999
        """
1000
        Decorator for custom ``save`` methods, that expect local computed fields
1001
        to contain already updated values on enter.
1002

1003
        By default local computed field values are only calculated once by the
1004
        ``ComputedFieldModel.save`` method after your own save method.
1005

1006
        By placing this decorator on your save method, the values will be updated
1007
        before entering your method as well. Note that this comes for the price of
1008
        doubled local computed field calculations (before and after your save method).
1009
        
1010
        To avoid a second recalculation, the decorator can be called with `skip_after=True`.
1011
        Note that this might lead to desychronized computed field values, if you do late
1012
        field changes in your save method without another resync afterwards.
1013
        """
1014
        skip: bool = False
15✔
1015
        func: Optional[F] = None
15✔
1016
        if dargs:
15✔
1017
            if len(dargs) > 1 or not callable(dargs[0]) or dkwargs:
15!
1018
                raise ResolverException('error in @precomputed declaration')
×
1019
            func = dargs[0]
15✔
1020
        else:
1021
            skip = dkwargs.get('skip_after', False)
15✔
1022
        
1023
        def wrap(func: F) -> F:
15✔
1024
            def _save(instance, *args, **kwargs):
15✔
1025
                new_fields = self.update_computedfields(instance, kwargs.get('update_fields'))
15✔
1026
                if new_fields:
15!
1027
                    kwargs['update_fields'] = new_fields
×
1028
                kwargs['skip_computedfields'] = skip
15✔
1029
                return func(instance, *args, **kwargs)
15✔
1030
            return cast(F, _save)
15✔
1031
        
1032
        return wrap(func) if func else wrap
15✔
1033

1034
    def update_computedfields(
15✔
1035
        self,
1036
        instance: Model,
1037
        update_fields: Optional[Iterable[str]] = None
1038
        ) -> Optional[Iterable[str]]:
1039
        """
1040
        Update values of local computed fields of `instance`.
1041

1042
        Other than calling ``compute`` on an instance, this call overwrites
1043
        computed field values on the instance (destructive).
1044

1045
        Returns ``None`` or an updated set of field names for `update_fields`.
1046
        The returned fields might contained additional computed fields, that also
1047
        changed based on the input fields, thus should extend `update_fields`
1048
        on a save call.
1049
        """
1050
        if get_not_computed_context():
15✔
1051
            return update_fields
15✔
1052
        model = type(instance)
15✔
1053
        if not self.has_computedfields(model):
15✔
1054
            return update_fields
15✔
1055
        cf_mro = self.get_local_mro(model, frozenset_none(update_fields))
15✔
1056
        if update_fields:
15✔
1057
            update_fields = set(update_fields)
15✔
1058
            update_fields.update(set(cf_mro))
15✔
1059
        for fieldname in cf_mro:
15✔
1060
            setattr(instance, fieldname, self._compute(instance, model, fieldname))
15✔
1061
        if update_fields:
15✔
1062
            return update_fields
15✔
1063
        return None
15✔
1064

1065
    def has_computedfields(self, model: Type[Model]) -> bool:
15✔
1066
        """
1067
        Indicate whether `model` has computed fields.
1068
        """
1069
        return model in self._computed_models
15✔
1070

1071
    def get_computedfields(self, model: Type[Model]) -> Iterable[str]:
15✔
1072
        """
1073
        Get all computed fields on `model`.
1074
        """
1075
        return self._computed_models.get(model, {}).keys()
15✔
1076

1077
    def is_computedfield(self, model: Type[Model], fieldname: str) -> bool:
15✔
1078
        """
1079
        Indicate whether `fieldname` on `model` is a computed field.
1080
        """
1081
        return fieldname in self.get_computedfields(model)
15✔
1082

1083
    def get_graphs(self) -> Tuple[Graph, Dict[Type[Model], ModelGraph], Graph]:
15✔
1084
        """
1085
        Return a tuple of all graphs as
1086
        ``(intermodel_graph, {model: modelgraph, ...}, union_graph)``.
1087
        """
1088
        graph = self._graph
×
1089
        if not graph:
×
1090
            graph = ComputedModelsGraph(active_resolver.computed_models)
×
1091
            graph.get_edgepaths()
×
1092
            graph.get_uniongraph()
×
1093
        return (graph, graph.modelgraphs, graph.get_uniongraph())
×
1094

1095

1096
# active_resolver is currently treated as global singleton (used in imports)
1097
#: Currently active resolver.
1098
active_resolver = Resolver()
15✔
1099

1100
# BOOT_RESOLVER: resolver that holds all startup declarations and resolve maps
1101
# gets deactivated after startup, thus it is currently not possible to define
1102
# new computed fields and add their resolve rules at runtime
1103
# TODO: investigate on custom resolvers at runtime to be bootstrapped from BOOT_RESOLVER
1104
#: Resolver used during django bootstrapping.
1105
#: This is currently the same as `active_resolver` (treated as global singleton).
1106
BOOT_RESOLVER = active_resolver
15✔
1107

1108

1109
# placeholder class to test for correct model inheritance
1110
# during initial field resolving
1111
class _ComputedFieldsModelBase:
15✔
1112
    pass
15✔
1113

1114

1115
class NotComputed:
15✔
1116
    """
1117
    Context to disable all computed field calculations and resolver updates temporarily.
1118

1119
    With *recover=True* the context will track all database relevant actions and update
1120
    affected computed fields on exit of the context.
1121
    """
1122
    def __init__(self, recover=False):
15✔
1123
        self.remove_ctx = True
15✔
1124
        self.recover = recover
15✔
1125
        self.qs: IRecordedStrict = defaultdict(lambda: {'pks': set(), 'fields': set()})
15✔
1126
        self.up: IRecorded = defaultdict(lambda: {'pks': set(), 'fields': set()})
15✔
1127

1128
    def __enter__(self):
15✔
1129
        ctx = get_not_computed_context()
15✔
1130
        if ctx:
15✔
1131
            self.remove_ctx = False
15✔
1132
            return ctx
15✔
1133
        set_not_computed_context(self)
15✔
1134
        return self
15✔
1135

1136
    def __exit__(self, exc_type, exc_value, traceback):
15✔
1137
        if self.remove_ctx:
15✔
1138
            set_not_computed_context(None)
15✔
1139
            if self.recover:
15✔
1140
                self._resync()
15✔
1141
        return False
15✔
1142
    
1143
    def record_querysets(
15✔
1144
        self,
1145
        data: Dict[Type[Model], List[Any]]
1146
    ):
1147
        """
1148
        Records the results of a previous _queryset_for_updates call
1149
        (must be called with argument *pk_list=True*).
1150
        """
1151
        if not self.recover:
15!
1152
            return
×
1153
        for model, mdata in data.items():
15✔
1154
            pks, fields = mdata
15✔
1155
            entry = self.qs[model]
15✔
1156
            entry['pks'] |= pks
15✔
1157
            # expand fields (might show a negative perf impact)
1158
            entry['fields'] |= fields
15✔
1159

1160
    def record_update(
15✔
1161
        self,
1162
        instance: Union[QuerySet, Model],
1163
        model: Type[Model],
1164
        fields: Optional[Set[str]] = None
1165
    ):
1166
        """
1167
        Records any update as typically given to update_dependent.
1168
        """
1169
        if not self.recover:
15!
1170
            return
×
1171
        entry = self.up[model]
15✔
1172
        if isinstance(instance, QuerySet):
15✔
1173
            entry['pks'].update(instance.values_list('pk', flat=True))
15✔
1174
        else:
1175
            entry['pks'].add(instance.pk)
15✔
1176
        # expand fields (might show a negative perf impact)
1177
        # special None handling in fields here is needed to preserve
1178
        # "all" rule from update_dependent on local CF model updates
1179
        if fields is None:
15✔
1180
            entry['fields'] = None
15✔
1181
        else:
1182
            if not entry['fields'] is None:
15✔
1183
                entry['fields'] |= fields
15✔
1184

1185
    def _resync(self):
15✔
1186
        """
1187
        This method tries to recover from the desync state by replaying the updates
1188
        of the recorded db actions.
1189

1190
        The resync does a flattening on the first update tree level:
1191
        - determine all follow-up changesets as pk lists (next tree level)
1192
        - merge *local_only* CF models with follow-up changesets (limited flattening)
1193
        - update remaining *local_only* CF models
1194
        - update remaining changesets with full descent
1195

1196
        The method currently favours field- and changeset merges over isolated updates.
1197
        The final updates are done the same way as during normal operation (DFS).
1198
        """
1199
        if not self.qs and not self.up:
15✔
1200
            return
15✔
1201

1202
        # first collect querysets from record_update for later bulk_update
1203
        # this additional pk extraction introduces a timy perf penalty,
1204
        # but pays off by pk merging
1205
        for model, local_data in self.up.items():
15✔
1206

1207
            # for CF models expand the local MRO before getting the querysets
1208
            # FIXME: untangle the side effect update of fields in update_dependent <-- bulk_updater
1209
            fields = local_data['fields']
15✔
1210
            if fields and active_resolver.has_computedfields(model):
15✔
1211
                fields = set(active_resolver.get_local_mro(model, frozenset(fields)))
15✔
1212

1213
            mdata = active_resolver._querysets_for_update(
15✔
1214
                model,
1215
                model._base_manager.filter(pk__in=local_data['pks']),
1216
                update_fields=fields,
1217
                pk_list=True
1218
            )
1219
            for m, mdata in mdata.items():
15✔
1220
                pks, fields = mdata
15✔
1221
                entry = self.qs[m]
15✔
1222
                entry['pks'] |= pks
15✔
1223
                entry['fields'] |= fields
15✔
1224
    
1225
        # move CF model local_only updates to final changesets, if already there
1226
        for model, mdata in self.up.items():
15✔
1227
            # patch for proxy models (resolver works internally with basemodels only)
1228
            basemodel = proxy_to_base_model(model) if model._meta.proxy else model
15✔
1229
            if active_resolver.has_computedfields(model) and basemodel in self.qs:
15✔
1230
                local_entry = self.up[model]
15✔
1231
                final_entry = self.qs[basemodel]
15✔
1232
                if local_entry['fields'] is None:
15✔
1233
                    final_entry['fields'] = set(active_resolver.get_local_mro(model))
15✔
1234
                else:
1235
                    final_entry['fields'] |= final_entry['fields']
15✔
1236
                final_entry['pks'] |= local_entry['pks']
15✔
1237
                local_entry['pks'].clear()
15✔
1238

1239
        # finally update all remaining changesets:
1240
        # 1. local_only update for CF models in up
1241
        # 2. all remaining changesets in qs
1242
        resolver_start.send(sender=active_resolver)
15✔
1243
        with transaction.atomic():
15✔
1244
            for model, local_data in self.up.items():
15✔
1245
                if local_data['pks'] and active_resolver.has_computedfields(model):
15✔
1246
                    # postponed local_only upd for CFs models
1247
                    # IMPORTANT: must happen before final updates
1248
                    active_resolver.bulk_updater(
15✔
1249
                        model._base_manager.filter(pk__in=local_data['pks']),
1250
                        local_data['fields'],
1251
                        local_only=True,
1252
                        querysize=settings.COMPUTEDFIELDS_QUERYSIZE
1253
                    )
1254
            for model, mdata in self.qs.items():
15✔
1255
                if mdata['pks']:
15!
1256
                    active_resolver.bulk_updater(
15✔
1257
                        model._base_manager.filter(pk__in=mdata['pks']),
1258
                        mdata['fields'],
1259
                        querysize=settings.COMPUTEDFIELDS_QUERYSIZE
1260
                    )
1261
        resolver_exit.send(sender=active_resolver)
15✔
1262

1263

1264
#class NotComputed:
1265
#    """
1266
#    Context to disable all computed field calculations and resolver updates temporarily.
1267
#
1268
#    With *recover=True* the context will track all database relevant actions and update
1269
#    affected computed fields on exit of the context.
1270
#    """
1271
#    def __init__(self, recover=False):
1272
#        self.remove_ctx = True
1273
#        self.recover = recover
1274
#        self.recorded_qs = defaultdict(lambda: defaultdict(lambda: set()))
1275
#        self.recorded_up = defaultdict(lambda: defaultdict(lambda: set()))
1276
#
1277
#    def __enter__(self):
1278
#        ctx = get_not_computed_context()
1279
#        if ctx:
1280
#            self.remove_ctx = False
1281
#            return ctx
1282
#        set_not_computed_context(self)
1283
#        return self
1284
#
1285
#    def __exit__(self, exc_type, exc_value, traceback):
1286
#        if self.remove_ctx:
1287
#            set_not_computed_context(None)
1288
#            if self.recover:
1289
#                self._resync()
1290
#        return False
1291
#
1292
#    def record_querysets(
1293
#        self,
1294
#        data: Dict[Type[Model], List[Any]]
1295
#    ):
1296
#        for model, mdata in data.items():
1297
#            pks, fields = mdata
1298
#            self.recorded_qs[model][frozenset(fields)] |= pks
1299
#
1300
#    def record_update(
1301
#        self,
1302
#        instance: Union[QuerySet, Model],
1303
#        model: Type[Model],
1304
#        fields: Optional[Set[str]] = None
1305
#    ):
1306
#        ff = None if fields is None else frozenset(fields)
1307
#        if isinstance(instance, QuerySet):
1308
#            self.recorded_up[model][ff].update(instance.values_list('pk', flat=True))
1309
#        else:
1310
#            self.recorded_up[model][ff].add(instance.pk)
1311
#
1312
#    def _resync(self):
1313
#        if not self.recorded_qs and not self.recorded_up:
1314
#            return
1315
#
1316
#        # working way: move pks to recorded_qs, if model:fields is alread there
1317
#        for model, data in self.recorded_up.items():
1318
#            for fields, pks in data.items():
1319
#                if fields and active_resolver.has_computedfields(model):
1320
#                    fields = set(active_resolver.get_local_mro(model, fields))
1321
#                mdata = active_resolver._querysets_for_update(
1322
#                    model,
1323
#                    model._base_manager.filter(pk__in=pks),
1324
#                    update_fields=fields,
1325
#                    pk_list=True
1326
#                )
1327
#                for qs_model, qs_data in mdata.items():
1328
#                    qs_pks, qs_fields = qs_data
1329
#                    self.recorded_qs[qs_model][frozenset(qs_fields)] |= qs_pks
1330
#
1331
#        resolver_start.send(sender=active_resolver)
1332
#        with transaction.atomic():
1333
#            for model, data in self.recorded_up.items():
1334
#                for fields, pks in data.items():
1335
#                    if active_resolver.has_computedfields(model):
1336
#                        basemodel = proxy_to_base_model(model) if model._meta.proxy else model
1337
#                        ff = frozenset(active_resolver.get_local_mro(model) if fields is None else fields)
1338
#                        if basemodel in self.recorded_qs and ff in self.recorded_qs[basemodel]:
1339
#                            self.recorded_qs[basemodel][ff] |= pks
1340
#                        else:
1341
#                            ff = None if fields is None else set(fields)
1342
#                            active_resolver.bulk_updater(
1343
#                                model._base_manager.filter(pk__in=pks),
1344
#                                ff,
1345
#                                local_only=True,
1346
#                                querysize=settings.COMPUTEDFIELDS_QUERYSIZE,
1347
#                            )
1348
#
1349
#            # attempt with merging into same recorded_qs run
1350
#            # here we would benefit from a topsorted list ;)
1351
#            # FIXME: loop needs a recursion abort
1352
#            recorded_qs = self.recorded_qs
1353
#            while recorded_qs:
1354
#                recorded_up = defaultdict(lambda: defaultdict(lambda: set()))
1355
#                done = defaultdict(lambda: set())
1356
#                for model, data in recorded_qs.items():
1357
#                    for fields, pks in data.items():
1358
#                        pks = active_resolver.bulk_updater(
1359
#                            model._base_manager.filter(pk__in=pks),
1360
#                            None if fields is None else set(fields),
1361
#                            local_only=True,
1362
#                            querysize=settings.COMPUTEDFIELDS_QUERYSIZE,
1363
#                            return_pks=True
1364
#                        )
1365
#                        done[model].add(frozenset(fields))
1366
#                        if pks:
1367
#                            fields = set(active_resolver.get_local_mro(model, fields))
1368
#                            mdata = active_resolver._querysets_for_update(
1369
#                                model,
1370
#                                model._base_manager.filter(pk__in=pks),
1371
#                                update_fields=fields,
1372
#                                pk_list=True
1373
#                            )
1374
#                            for qs_model, qs_data in mdata.items():
1375
#                                qs_pks, qs_fields = qs_data
1376
#                                ff = frozenset(qs_fields)
1377
#                                if (
1378
#                                    qs_model in recorded_qs
1379
#                                    and ff in recorded_qs[qs_model]
1380
#                                    and ff not in done[qs_model]
1381
#                                ):
1382
#                                    recorded_qs[qs_model][ff] |= qs_pks
1383
#                                else:
1384
#                                    recorded_up[qs_model][ff] |= qs_pks
1385
#                                #recorded_up[qs_model][frozenset(qs_fields)] |= qs_pks
1386
#                recorded_qs = recorded_up
1387
#        resolver_exit.send(sender=active_resolver)
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