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

netzkolchose / django-computedfields / 16503881831

24 Jul 2025 05:38PM UTC coverage: 91.04% (-4.2%) from 95.192%
16503881831

Pull #189

github

web-flow
Merge e7187a562 into f018eaa24
Pull Request #189: autorecover for not_computed context

549 of 634 branches covered (86.59%)

Branch coverage included in aggregate %.

162 of 213 new or added lines in 4 files covered. (76.06%)

7 existing lines in 1 file now uncovered.

1290 of 1386 relevant lines covered (93.07%)

11.17 hits per line

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

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

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

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

18
from fast_update.fast import fast_update
12✔
19

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

27

28
MALFORMED_DEPENDS = """
12✔
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):
12✔
52
    """
53
    Exception raised during model and field registration or dependency resolving.
54
    """
55

56

57
class Resolver:
12✔
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):
12✔
71
        # collector phase data
72
        #: Models from `class_prepared` signal hook during collector phase.
73
        self.models: Set[Type[Model]] = set()
12✔
74
        #: Computed fields found during collector phase.
75
        self.computedfields: Set[IComputedField] = set()
12✔
76

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

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

94
        # model update cache
95
        self._updates_cache: IModelUpdateCache = defaultdict(dict)
12✔
96

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

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

113
    def seal(self) -> None:
12✔
114
        """
115
        Seal the resolver, so no new models or computed fields can be added anymore.
116

117
        This marks the end of the collector phase and is a basic security measure
118
        to catch runtime model creations with computed fields.
119

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

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

130
        This cannot be accessed during the collector phase.
131
        """
132
        if not self._sealed:
12✔
133
            raise ResolverException('resolver must be sealed before accessing models or fields')
12✔
134

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

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

151
        This cannot be accessed during the collector phase.
152
        """
153
        if not self._sealed:
12✔
154
            raise ResolverException('resolver must be sealed before accessing models or fields')
12✔
155

156
        for field in self.computedfields:
12✔
157
            models = set()
12✔
158
            for model in self.models:
12✔
159
                for f in model._meta.fields:
12✔
160
                    if hasattr(field, '_computed') and f.creation_counter == field.creation_counter:
12✔
161
                        models.add(model)
12✔
162
            yield (field, models)
12✔
163

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

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

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

196
        return computed_models
12✔
197

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

203
        Upon instantiation the resolver is in the collector phase, where it tracks
204
        model registrations and computed field decorations.
205

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

216
    def load_maps(self, _force_recreation: bool = False) -> None:
12✔
217
        """
218
        Load all needed resolver maps. The steps are:
219

220
            - create intermodel graph of the dependencies
221
            - remove redundant paths with cycling check
222
            - create modelgraphs for local MRO
223
            - merge graphs to uniongraph with cycling check
224
            - create final resolver maps
225

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

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

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

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

284
    def get_model_updates(
12✔
285
        self,
286
        model: Type[Model],
287
        update_fields: Optional[Iterable[str]] = None
288
    ) -> IModelUpdate:
289
        """
290
        For a given model and updated fields this method
291
        returns a dictionary with dependent models (keys) and a tuple
292
        with dependent fields and the queryset accessor string (value).
293
        """
294
        modeldata = self._map.get(model)
12✔
295
        if not modeldata:
12✔
296
            return {}
12✔
297
        if not update_fields is None:
12✔
298
            update_fields = frozenset(update_fields)
12✔
299
        try:
12✔
300
            return self._updates_cache[model][update_fields]
12✔
301
        except KeyError:
12✔
302
            pass
12✔
303
        if not update_fields:
12✔
304
            updates: Set[str] = set(modeldata.keys())
12✔
305
        else:
306
            updates = set()
12✔
307
            for fieldname in update_fields:
12✔
308
                if fieldname in modeldata:
12✔
309
                    updates.add(fieldname)
12✔
310
        model_updates: IModelUpdate = defaultdict(lambda: (set(), set()))
12✔
311
        for update in updates:
12✔
312
            # aggregate fields and paths to cover
313
            # multiple comp field dependencies
314
            for m, r in modeldata[update].items():
12✔
315
                fields, paths = r
12✔
316
                m_fields, m_paths = model_updates[m]
12✔
317
                m_fields.update(fields)
12✔
318
                m_paths.update(paths)
12✔
319
        self._updates_cache[model][update_fields] = model_updates
12✔
320
        return model_updates
12✔
321

322
    def _querysets_for_update(
12✔
323
        self,
324
        model: Type[Model],
325
        instance: Union[Model, QuerySet],
326
        update_fields: Optional[Iterable[str]] = None,
327
        pk_list: bool = False,
328
    ) -> Dict[Type[Model], List[Any]]:
329
        """
330
        Returns a mapping of all dependent models, dependent fields and a
331
        queryset containing all dependent objects.
332
        """
333
        final: Dict[Type[Model], List[Any]] = {}
12✔
334
        model_updates = self.get_model_updates(model, update_fields)
12✔
335
        if not model_updates:
12✔
336
            return final
12✔
337

338
        subquery = '__in' if isinstance(instance, QuerySet) else ''
12✔
339
        # fix #100
340
        # mysql does not support 'LIMIT & IN/ALL/ANY/SOME subquery'
341
        # thus we extract pks explicitly instead
342
        real_inst: Union[Model, QuerySet, Set[Any]] = instance
12✔
343
        if isinstance(instance, QuerySet):
12✔
344
            from django.db import connections
12✔
345
            if not instance.query.can_filter() and connections[instance.db].vendor == 'mysql':
12!
NEW
346
                real_inst = set(instance.values_list('pk', flat=True).iterator())
×
347

348
        # generate narrowed down querysets for all cf dependencies
349
        for m, data in model_updates.items():
12✔
350
            fields, paths = data
12✔
351
            queryset: Union[QuerySet, Set[Any]] = m._base_manager.none()
12✔
352
            query_pipe_method = self._choose_optimal_query_pipe_method(paths)
12✔
353
            queryset = reduce(
12✔
354
                query_pipe_method,
355
                (m._base_manager.filter(**{path+subquery: real_inst}) for path in paths),
356
                queryset
357
            )
358
            if pk_list:
12✔
359
                # need pks for post_delete since the real queryset will be empty
360
                # after deleting the instance in question
361
                # since we need to interact with the db anyways
362
                # we can already drop empty results here
363
                queryset = set(queryset.values_list('pk', flat=True).iterator())
12✔
364
                if not queryset:
12✔
365
                    continue
12✔
366
            # FIXME: change to tuple or dict for narrower type
367
            final[m] = [queryset, fields]
12✔
368
        return final
12✔
369
    
370
    def _get_model(self, instance: Union[Model, QuerySet]) -> Type[Model]:
12✔
371
        return instance.model if isinstance(instance, QuerySet) else type(instance)
12✔
372

373
    def _choose_optimal_query_pipe_method(self, paths: Set[str]) -> Callable:
12✔
374
        """
375
            Choose optimal pipe method, to combine querystes.
376
            Returns `|` if there are only one element or the difference is only the fields name, on the same path.
377
            Otherwise, return union.
378
        """
379
        if len(paths) == 1:
12✔
380
            return operator.or_
12✔
381
        else:
382
            paths_by_parts = tuple(path.split("__") for path in paths)
12✔
383
            if are_same(*(len(path_in_parts) for path_in_parts in paths_by_parts)):
12✔
384
                max_depth = len(paths_by_parts[0]) - 1
12✔
385
                for depth, paths_parts in enumerate(zip(*paths_by_parts)):
12!
386
                    if are_same(*paths_parts):
12✔
387
                        pass
12✔
388
                    else:
389
                        if depth == max_depth:
12✔
390
                            return operator.or_
12✔
391
                        else:
392
                            break
12✔
393
        return lambda x, y: x.union(y)
12✔
394

395
    def preupdate_dependent(
12✔
396
        self,
397
        instance: Union[QuerySet, Model],
398
        model: Optional[Type[Model]] = None,
399
        update_fields: Optional[Iterable[str]] = None,
400
    ) -> Dict[Type[Model], List[Any]]:
401
        """
402
        Create a mapping of currently associated computed field records,
403
        that might turn dirty by a follow-up bulk action.
404

405
        Feed the mapping back to ``update_dependent`` as `old` argument
406
        after your bulk action to update de-associated computed field records as well.
407
        """
408
        result = self._querysets_for_update(
12✔
409
            model or self._get_model(instance), instance, update_fields, pk_list=True)
410

411
        # exit empty, if we are in not_computed context
412
        if ctx := get_not_computed_context():
12✔
413
            if result and ctx.recover:
12✔
414
                ctx.record_querysets(result)
12✔
415
            return {}
12✔
416
        return result
12✔
417

418
    def update_dependent(
12✔
419
        self,
420
        instance: Union[QuerySet, Model],
421
        model: Optional[Type[Model]] = None,
422
        update_fields: Optional[Iterable[str]] = None,
423
        old: Optional[Dict[Type[Model], List[Any]]] = None,
424
        update_local: bool = True,
425
        querysize: Optional[int] = None,
426
        _is_recursive: bool = False
427
    ) -> None:
428
        """
429
        Updates all dependent computed fields on related models traversing
430
        the dependency tree as shown in the graphs.
431

432
        This is the main entry hook of the resolver to do updates on dependent
433
        computed fields during runtime. While this is done automatically for
434
        model instance actions from signal handlers, you have to call it yourself
435
        after changes done by bulk actions.
436

437
        To do that, simply call this function after the update with the queryset
438
        containing the changed objects:
439

440
            >>> Entry.objects.filter(pub_date__year=2010).update(comments_on=False)
441
            >>> update_dependent(Entry.objects.filter(pub_date__year=2010))
442

443
        This can also be used with ``bulk_create``. Since ``bulk_create``
444
        returns the objects in a python container, you have to create the queryset
445
        yourself, e.g. with pks:
446

447
            >>> objs = Entry.objects.bulk_create([
448
            ...     Entry(headline='This is a test'),
449
            ...     Entry(headline='This is only a test'),
450
            ... ])
451
            >>> pks = set(obj.pk for obj in objs)
452
            >>> update_dependent(Entry.objects.filter(pk__in=pks))
453

454
        .. NOTE::
455

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

459
                >>> MyComputedModel.objects.bulk_create([
460
                ...     MyComputedModel(comp='SENTINEL'), # here or as default field value
461
                ...     MyComputedModel(comp='SENTINEL'),
462
                ... ])
463
                >>> update_dependent(MyComputedModel.objects.filter(comp='SENTINEL'))
464

465
            If the sentinel is beyond reach of the method result, this even ensures to update
466
            only the newly added records.
467

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

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

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

479
                >>> # given: some computed fields model depends somehow on Entry.fk_field
480
                >>> old_relations = preupdate_dependent(Entry.objects.filter(pub_date__year=2010))
481
                >>> Entry.objects.filter(pub_date__year=2010).update(fk_field=new_related_obj)
482
                >>> update_dependent(Entry.objects.filter(pub_date__year=2010), old=old_relations)
483

484
        `update_local=False` disables model local computed field updates of the entry node. 
485
        (used as optimization during tree traversal). You should not disable it yourself.
486
        """
487
        _model = model or self._get_model(instance)
12✔
488

489
        # bulk_updater might change fields, ensure we have set/None
490
        _update_fields = None if update_fields is None else set(update_fields)
12✔
491

492
        # exit early if we are in not_computed context
493
        if ctx := get_not_computed_context():
12✔
494
            if ctx.recover:
12✔
495
                ctx.record_update(instance, _model, _update_fields)
12✔
496
            return
12✔
497

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

507
        updates = self._querysets_for_update(_model, instance, _update_fields).values()
12✔
508
        if updates:
12✔
509
            if not _is_recursive:
12✔
510
                resolver_start.send(sender=self)
12✔
511
                with transaction.atomic():
12✔
512
                    pks_updated: Dict[Type[Model], Set[Any]] = {}
12✔
513
                    for queryset, fields in updates:
12✔
514
                        _pks = self.bulk_updater(queryset, fields, return_pks=True, querysize=querysize)
12✔
515
                        if _pks:
12✔
516
                            pks_updated[queryset.model] = _pks
12✔
517
                    if old:
12✔
518
                        for model2, data in old.items():
12✔
519
                            pks, fields = data
12✔
520
                            queryset = model2.objects.filter(pk__in=pks-pks_updated.get(model2, set()))
12✔
521
                            self.bulk_updater(queryset, fields, querysize=querysize)
12✔
522
            else:
523
                for queryset, fields in updates:
12✔
524
                    self.bulk_updater(queryset, fields, return_pks=False, querysize=querysize)
12✔
525
            if not _is_recursive:
12✔
526
                resolver_exit.send(sender=self)
12✔
527

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

540
        This method does the local field updates on `queryset`:
541

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

548
        By default this method triggers the update of dependent models by calling
549
        ``update_dependent`` with `update_fields` (next level of tree traversal).
550
        This can be suppressed by setting `local_only=True`.
551

552
        If `return_pks` is set, the method returns a set of altered pks of `queryset`.
553
        """
554
        model: Type[Model] = queryset.model
12✔
555

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

568
        # correct update_fields by local mro
569
        mro: List[str] = self.get_local_mro(model, update_fields)
12✔
570
        fields = set(mro)
12✔
571
        if update_fields:
12✔
572
            update_fields.update(fields)
12✔
573

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

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

604
            if pks:
12✔
605
                resolver_update.send(sender=self, model=model, fields=fields, pks=pks)
12✔
606

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

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

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

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

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

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

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

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

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

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

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

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

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

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

783
        Usage example:
784

785
        .. code-block:: python
786

787
            from computedfields.models import ComputedField
788

789
            def calc_mul(inst):
790
                return inst.a * inst.b
791

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

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

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

837
        Examples:
838

839
            - create a char field with no further dependencies (not very useful)
840

841
            .. code-block:: python
842

843
                @computed(models.CharField(max_length=32))
844
                def ...
845

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

849
            .. code-block:: python
850

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

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

860
        .. NOTE::
861

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

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

867
        .. NOTE::
868

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

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

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

884
        .. CAUTION::
885

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

889
            .. code-block:: python
890

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

896
                class B(ComputedFieldsModel):
897
                    a = models.ForeignKey(A)
898

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

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

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

913
            - intermodel dependency graph: ``active_resolver._graph``
914
            - model local dependency graphs: ``active_resolver._graph.modelgraphs[your_model]``
915
            - union graph: ``active_resolver._graph.get_uniongraph()``
916

917
            Also see the graph documentation :ref:`here<graph>`.
918
        """
919
        def wrap(func: Callable[..., _ST]) -> 'Field[_ST, _GT]':
12✔
920
            return self.computedfield_factory(
12✔
921
                field,
922
                compute=func,
923
                depends=depends,
924
                select_related=select_related,
925
                prefetch_related=prefetch_related,
926
                querysize=querysize,
927
                default_on_create=default_on_create
928
            )
929
        return wrap
12✔
930

931
    @overload
12✔
932
    def precomputed(self, f: F) -> F:
12✔
933
        ...
×
934
    @overload
12✔
935
    def precomputed(self, skip_after: bool) -> Callable[[F], F]:
12✔
936
        ...
×
937
    def precomputed(self, *dargs, **dkwargs) -> Union[F, Callable[[F], F]]:
12✔
938
        """
939
        Decorator for custom ``save`` methods, that expect local computed fields
940
        to contain already updated values on enter.
941

942
        By default local computed field values are only calculated once by the
943
        ``ComputedFieldModel.save`` method after your own save method.
944

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

973
    def update_computedfields(
12✔
974
        self,
975
        instance: Model,
976
        update_fields: Optional[Iterable[str]] = None
977
        ) -> Optional[Iterable[str]]:
978
        """
979
        Update values of local computed fields of `instance`.
980

981
        Other than calling ``compute`` on an instance, this call overwrites
982
        computed field values on the instance (destructive).
983

984
        Returns ``None`` or an updated set of field names for `update_fields`.
985
        The returned fields might contained additional computed fields, that also
986
        changed based on the input fields, thus should extend `update_fields`
987
        on a save call.
988
        """
989
        if get_not_computed_context():
12✔
990
            return update_fields
12✔
991
        model = type(instance)
12✔
992
        if not self.has_computedfields(model):
12✔
993
            return update_fields
12✔
994
        cf_mro = self.get_local_mro(model, update_fields)
12✔
995
        if update_fields:
12✔
996
            update_fields = set(update_fields)
12✔
997
            update_fields.update(set(cf_mro))
12✔
998
        for fieldname in cf_mro:
12✔
999
            setattr(instance, fieldname, self._compute(instance, model, fieldname))
12✔
1000
        if update_fields:
12✔
1001
            return update_fields
12✔
1002
        return None
12✔
1003

1004
    def has_computedfields(self, model: Type[Model]) -> bool:
12✔
1005
        """
1006
        Indicate whether `model` has computed fields.
1007
        """
1008
        return model in self._computed_models
12✔
1009

1010
    def get_computedfields(self, model: Type[Model]) -> Iterable[str]:
12✔
1011
        """
1012
        Get all computed fields on `model`.
1013
        """
1014
        return self._computed_models.get(model, {}).keys()
12✔
1015

1016
    def is_computedfield(self, model: Type[Model], fieldname: str) -> bool:
12✔
1017
        """
1018
        Indicate whether `fieldname` on `model` is a computed field.
1019
        """
1020
        return fieldname in self.get_computedfields(model)
12✔
1021

1022
    def get_graphs(self) -> Tuple[Graph, Dict[Type[Model], ModelGraph], Graph]:
12✔
1023
        """
1024
        Return a tuple of all graphs as
1025
        ``(intermodel_graph, {model: modelgraph, ...}, union_graph)``.
1026
        """
1027
        graph = self._graph
×
1028
        if not graph:
×
1029
            graph = ComputedModelsGraph(active_resolver.computed_models)
×
1030
            graph.get_edgepaths()
×
1031
            graph.get_uniongraph()
×
1032
        return (graph, graph.modelgraphs, graph.get_uniongraph())
×
1033

1034

1035
# active_resolver is currently treated as global singleton (used in imports)
1036
#: Currently active resolver.
1037
active_resolver = Resolver()
12✔
1038

1039
# BOOT_RESOLVER: resolver that holds all startup declarations and resolve maps
1040
# gets deactivated after startup, thus it is currently not possible to define
1041
# new computed fields and add their resolve rules at runtime
1042
# TODO: investigate on custom resolvers at runtime to be bootstrapped from BOOT_RESOLVER
1043
#: Resolver used during django bootstrapping.
1044
#: This is currently the same as `active_resolver` (treated as global singleton).
1045
BOOT_RESOLVER = active_resolver
12✔
1046

1047

1048
# placeholder class to test for correct model inheritance
1049
# during initial field resolving
1050
class _ComputedFieldsModelBase:
12✔
1051
    pass
12✔
1052

1053

1054
class NotComputed:
12✔
1055
    """
1056
    Context to disable all computed field calculations and resolver updates temporarily.
1057

1058
    .. CAUTION::
1059

1060
        Currently there is no auto-recovery implemented at all,
1061
        therefore it is your responsibility to recover properly from the desync state.
1062
    """
1063
    def __init__(self, recover=False, new_method=False, debug=False):
12✔
1064
        self.remove_ctx = True
12✔
1065
        self.recover = recover
12✔
1066
        self.qs: IRecordedStrict = defaultdict(lambda: {'pks': set(), 'fields': set()})
12✔
1067
        self.up: IRecorded = defaultdict(lambda: {'pks': set(), 'fields': set()})
12✔
1068

1069
        # better with preserving fields
1070
        self.recorded_qs = defaultdict(lambda: defaultdict(lambda: set()))
12!
1071
        self.recorded_up = defaultdict(lambda: defaultdict(lambda: set()))
12!
1072

1073
        self.record_querysets = self.record_querysets_new if new_method else self.record_querysets_old
12✔
1074
        self.record_update = self.record_update_new if new_method else self.record_update_old
12✔
1075
        self.resync = self.resync_new if new_method else self.resync_old
12✔
1076
        self.debug = debug
12✔
1077

1078
    def __enter__(self):
12✔
1079
        ctx = get_not_computed_context()
12✔
1080
        if ctx:
12✔
1081
            self.remove_ctx = False
12✔
1082
            return ctx
12✔
1083
        set_not_computed_context(self)
12✔
1084
        return self
12✔
1085

1086
    def __exit__(self, exc_type, exc_value, traceback):
12✔
1087
        if self.remove_ctx:
12✔
1088
            set_not_computed_context(None)
12✔
1089
            if self.recover:
12✔
1090
                self.resync()
12✔
1091
        return False
12✔
1092
    
1093
    def record_querysets_old(
12✔
1094
        self,
1095
        data: Dict[Type[Model], List[Any]]
1096
    ):
1097
        """
1098
        Records the results of a previous _queryset_for_updates call
1099
        (must be called with argument *pk_list=True*).
1100
        """
1101
        if not self.recover:
12!
NEW
1102
            return
×
1103
        if self.debug:
12!
NEW
1104
            print('\nPRE:', data)
×
1105
        for model, mdata in data.items():
12✔
1106
            pks, fields = mdata
12✔
1107
            entry = self.qs[model]
12✔
1108
            entry['pks'] |= pks
12✔
1109
            # expand fields (might show a negative perf impact)
1110
            entry['fields'] |= fields
12✔
1111

1112
    def record_update_old(
12✔
1113
        self,
1114
        instance: Union[QuerySet, Model],
1115
        model: Type[Model],
1116
        fields: Optional[Set[str]] = None
1117
    ):
1118
        """
1119
        Records any update as typically given to update_dependent.
1120
        """
1121
        if not self.recover:
12!
NEW
1122
            return
×
1123
        if self.debug:
12!
NEW
1124
            print('\nUPD:', instance, model, fields)
×
1125
        entry = self.up[model]
12✔
1126
        if isinstance(instance, QuerySet):
12✔
1127
            entry['pks'].update(instance.values_list('pk', flat=True))
12✔
1128
        else:
1129
            entry['pks'].add(instance.pk)
12✔
1130
        # expand fields (might show a negative perf impact)
1131
        # special None handling in fields here is needed to preserve
1132
        # "all" rule from update_dependent on local CF model updates
1133
        if fields is None:
12✔
1134
            entry['fields'] = None
12✔
1135
        else:
1136
            if not entry['fields'] is None:
12✔
1137
                entry['fields'] |= fields
12✔
1138

1139
    def resync_old(self):
12✔
1140
        """
1141
        This method tries to recover from the desync state by replaying the updates
1142
        of the recorded db actions.
1143

1144
        The resync does a flattening on the first update tree level:
1145
        - determine all follow-up changesets as pk lists (next tree level)
1146
        - update all CF models with *local_only*, subtract their pks from changeset
1147
        - execute remaining changesets with full descent
1148

1149
        The method currently favours field- and changeset merges over isolated updates.
1150
        The final updates are done the same way as during normal operation (DFS).
1151

1152
        In theory it would be possible to further optimize the execution order
1153
        of the final updates by topsorting their update trees into one big update tree.
1154
        We currently dont do this, as it is somewhat hard to achieve:
1155
        - performance impact is unclear, we simply don't know, whether
1156
          multiple isolated updates on smaller change- and fieldsets
1157
          would be faster than merged updates with field expansion (no proper metric)
1158
        - performance is altered by the question, whether a field's relation is close
1159
          and its compute function workload
1160
        - topsort across related models has a 2D dependency to models and fields
1161
        - involves a partial DFS to BFS transformation
1162
        """
1163
        if not self.qs and not self.up:
12✔
1164
            return
12✔
1165

1166
        # first collect querysets from record_update for later bulk_update
1167
        # this additional pk extraction introduces a timy perf penalty,
1168
        # but pays off by pk merging
1169
        for model, local_data in self.up.items():
12✔
1170

1171
            # for CF models expand the local MRO before getting the querysets
1172
            fields = local_data['fields']
12✔
1173
            if fields and active_resolver.has_computedfields(model):
12✔
1174
                fields = set(active_resolver.get_local_mro(model, local_data['fields']))
12✔
1175

1176
            mdata = active_resolver._querysets_for_update(
12✔
1177
                model,
1178
                model._base_manager.filter(pk__in=local_data['pks']),
1179
                update_fields=fields,
1180
                pk_list=True
1181
            )
1182
            if self.debug:
12!
NEW
1183
                print('\nCF-UPDATE:', model, local_data)
×
1184
            for m, mdata in mdata.items():
12✔
1185
                if self.debug:
12!
NEW
1186
                    print('\nCF-COLLECTED:', m, mdata)
×
1187
                pks, fields = mdata
12✔
1188
                entry = self.qs[m]
12✔
1189
                entry['pks'] |= pks
12✔
1190
                entry['fields'] |= fields
12✔
1191
    
1192
        # move CF model local_only updates to final changesets, if already there
1193
        for model, mdata in self.up.items():
12✔
1194
            # patch for proxy models (resolver works internally with basemodels only)
1195
            basemodel = proxy_to_base_model(model) if model._meta.proxy else model
12✔
1196
            if active_resolver.has_computedfields(model) and basemodel in self.qs:
12✔
1197
                local_entry = self.up[model]
12✔
1198
                final_entry = self.qs[basemodel]
12✔
1199
                if local_entry['fields'] is None:
12✔
1200
                    final_entry['fields'] = set(active_resolver.get_local_mro(model))
12✔
1201
                else:
1202
                    final_entry['fields'] |= final_entry['fields']
12✔
1203
                final_entry['pks'] |= local_entry['pks']
12✔
1204
                local_entry['pks'].clear()
12✔
1205

1206
        # finally update all remaining changesets:
1207
        # 1. local_only update for CF models in up
1208
        # 2. all remaining changesets in qs
1209
        with transaction.atomic():
12✔
1210
            for model, local_data in self.up.items():
12✔
1211
                if local_data['pks'] and active_resolver.has_computedfields(model):
12✔
1212
                    if self.debug:
12!
NEW
1213
                        print('\nCF-LOCAL:', model, local_data)
×
1214
                    # postponed local_only upd for CFs models
1215
                    # IMPORTANT: must happen before final updates
1216
                    active_resolver.bulk_updater(
12✔
1217
                        model._base_manager.filter(pk__in=local_data['pks']),
1218
                        local_data['fields'],
1219
                        local_only=True,
1220
                        querysize=settings.COMPUTEDFIELDS_QUERYSIZE
1221
                    )
1222
            for model, mdata in self.qs.items():
12✔
1223
                if mdata['pks']:
12!
1224
                    if self.debug:
12!
NEW
1225
                        print('\nFINAL:', model, mdata)
×
1226
                    active_resolver.bulk_updater(
12✔
1227
                        model._base_manager.filter(pk__in=mdata['pks']),
1228
                        mdata['fields'],
1229
                        querysize=settings.COMPUTEDFIELDS_QUERYSIZE
1230
                    )
1231

1232
    #################### new impl, slightly worse, but with proper fieldset separation and loop-based
1233
    def record_querysets_new(
12✔
1234
        self,
1235
        data: Dict[Type[Model], List[Any]]
1236
    ):
NEW
1237
        for model, mdata in data.items():
×
NEW
1238
            pks, fields = mdata
×
NEW
1239
            self.recorded_qs[model][frozenset(fields)] |= pks
×
1240

1241
    def record_update_new(
12✔
1242
        self,
1243
        instance: Union[QuerySet, Model],
1244
        model: Type[Model],
1245
        fields: Optional[Set[str]] = None
1246
    ):
NEW
1247
        if isinstance(instance, QuerySet):
×
NEW
1248
            self.recorded_up[model][None if fields is None else frozenset(fields)].update(instance.values_list('pk', flat=True))
×
1249
        else:
NEW
1250
            self.recorded_up[model][None if fields is None else frozenset(fields)].add(instance.pk)
×
1251

1252
    @transaction.atomic
12✔
1253
    def resync_new(self):
12✔
NEW
1254
        if not self.recorded_qs and not self.recorded_up:
×
NEW
1255
            return
×
1256
        #for model, data in self.recorded_up.items():
1257
        #    for fields, pks in data.items():
1258
        #        # CODESMELL: var with side-effect
1259
        #        ff = None if fields is None else set(fields)
1260
        #        if active_resolver.has_computedfields(model):
1261
        #            active_resolver.bulk_updater(
1262
        #                model._base_manager.filter(pk__in=pks),
1263
        #                ff,
1264
        #                local_only=True,
1265
        #                querysize=settings.COMPUTEDFIELDS_QUERYSIZE,
1266
        #            )
1267
        #        mdata = active_resolver._querysets_for_update(
1268
        #            model,
1269
        #            model._base_manager.filter(pk__in=pks),
1270
        #            update_fields=ff,
1271
        #            pk_list=True
1272
        #        )
1273
        #        for qs_model, qs_data in mdata.items():
1274
        #            qs_pks, qs_fields = qs_data
1275
        #            self.recorded_qs[qs_model][frozenset(qs_fields)] |= qs_pks
1276

1277
        # working way: move pks to recorded_qs, if model:fields is alread there
NEW
1278
        for model, data in self.recorded_up.items():
×
NEW
1279
            for fields, pks in data.items():
×
NEW
1280
                if fields and active_resolver.has_computedfields(model):
×
NEW
1281
                    fields = set(active_resolver.get_local_mro(model, fields))
×
NEW
1282
                mdata = active_resolver._querysets_for_update(
×
1283
                    model,
1284
                    model._base_manager.filter(pk__in=pks),
1285
                    update_fields=fields,
1286
                    pk_list=True
1287
                )
NEW
1288
                for qs_model, qs_data in mdata.items():
×
NEW
1289
                    qs_pks, qs_fields = qs_data
×
NEW
1290
                    self.recorded_qs[qs_model][frozenset(qs_fields)] |= qs_pks
×
NEW
1291
        for model, data in self.recorded_up.items():
×
NEW
1292
            for fields, pks in data.items():
×
NEW
1293
                if active_resolver.has_computedfields(model):
×
NEW
1294
                    ff = frozenset(active_resolver.get_local_mro(model) if fields is None else fields)
×
NEW
1295
                    if model in self.recorded_qs and ff in self.recorded_qs[model]:
×
NEW
1296
                        self.recorded_qs[model][ff] |= pks
×
1297
                    else:
NEW
1298
                        ff = None if fields is None else set(fields)
×
NEW
1299
                        active_resolver.bulk_updater(
×
1300
                            model._base_manager.filter(pk__in=pks),
1301
                            ff,
1302
                            local_only=True,
1303
                            querysize=settings.COMPUTEDFIELDS_QUERYSIZE,
1304
                        )
1305

1306
        # attempt with merging into same recorded_qs run
1307
        # here we would benefit from a topsorted list ;)
NEW
1308
        recorded_qs = self.recorded_qs
×
NEW
1309
        while recorded_qs:
×
NEW
1310
            recorded_up = defaultdict(lambda: defaultdict(lambda: set()))
×
NEW
1311
            done = defaultdict(lambda: set())
×
NEW
1312
            for model, data in recorded_qs.items():
×
NEW
1313
                for fields, pks in data.items():
×
NEW
1314
                    pks = active_resolver.bulk_updater(
×
1315
                        model._base_manager.filter(pk__in=pks),
1316
                        None if fields is None else set(fields),
1317
                        local_only=True,
1318
                        querysize=settings.COMPUTEDFIELDS_QUERYSIZE,
1319
                        return_pks=True
1320
                    )
NEW
1321
                    done[model].add(frozenset(fields))
×
NEW
1322
                    if pks:
×
NEW
1323
                        fields = set(active_resolver.get_local_mro(model, fields))
×
NEW
1324
                        mdata = active_resolver._querysets_for_update(
×
1325
                            model,
1326
                            model._base_manager.filter(pk__in=pks),
1327
                            update_fields=fields,
1328
                            pk_list=True
1329
                        )
NEW
UNCOV
1330
                        for qs_model, qs_data in mdata.items():
×
NEW
UNCOV
1331
                            qs_pks, qs_fields = qs_data
×
NEW
UNCOV
1332
                            ff = frozenset(qs_fields)
×
NEW
UNCOV
1333
                            if (
×
1334
                                qs_model in recorded_qs
1335
                                and ff in recorded_qs[qs_model]
1336
                                and ff not in done[qs_model]
1337
                            ):
NEW
UNCOV
1338
                                recorded_qs[qs_model][ff] |= qs_pks
×
1339
                            else:
NEW
UNCOV
1340
                                recorded_up[qs_model][ff] |= qs_pks
×
1341
                            #recorded_up[qs_model][frozenset(qs_fields)] |= qs_pks
NEW
UNCOV
1342
            recorded_qs = recorded_up
×
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