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

netzkolchose / django-computedfields / 16100435448

06 Jul 2025 03:17PM UTC coverage: 94.939% (+0.04%) from 94.903%
16100435448

Pull #177

github

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

442 of 479 branches covered (92.28%)

Branch coverage included in aggregate %.

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

21 existing lines in 2 files now uncovered.

1115 of 1161 relevant lines covered (96.04%)

11.52 hits per line

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

98.52
/computedfields/graph.py
1
"""
2
Module containing the graph logic for the dependency resolver.
3

4
Upon application initialization a dependency graph of all project wide
5
computed fields is created. The graph does a basic cycle check and
6
removes redundant dependencies. Finally the dependencies are translated
7
to the resolver map to be used later by ``update_dependent`` and in
8
the signal handlers.
9
"""
10
from collections import OrderedDict
12✔
11
from os import PathLike
12✔
12
from django.core.exceptions import FieldDoesNotExist
12✔
13
from django.db.models import ForeignKey
12✔
14
from computedfields.helpers import pairwise, modelname, parent_to_inherited_path, skip_equal_segments
12✔
15

16
# typing imports
17
from typing import (Callable, Dict, FrozenSet, Generic, Hashable, Any, List, Optional, Sequence,
12✔
18
                    Set, Tuple, TypeVar, Type, Union)
19
from typing_extensions import TypedDict
12✔
20
from django.db.models import Model, Field
12✔
21

22

23
_ST = TypeVar("_ST", contravariant=True)
12✔
24
_GT = TypeVar("_GT", covariant=True)
12✔
25
F = TypeVar('F', bound=Callable[..., Any])
12✔
26

27

28
# depends interface
29
IDepends = Sequence[Tuple[str, Sequence[str]]]
12✔
30
IDependsAppend = List[Tuple[str, Sequence[str]]]
12✔
31

32
# _computed container
33
class IComputedData(TypedDict):
12✔
34
    depends: IDepends
12✔
35
    func: Callable[[Model], Any]
12✔
36
    select_related: Sequence[str]
12✔
37
    prefetch_related: Sequence[Any]
12✔
38
    querysize: Optional[int]
12✔
39

40

41
# django Field type extended by our _computed data attribute
42
class IComputedField(Field, Generic[_ST, _GT]):
12✔
43
    _computed: IComputedData
12✔
44
    creation_counter: int
12✔
45

46

47
class ICycleData(TypedDict):
12✔
48
    entries: Set['Edge']
12✔
49
    path: List['Edge']
12✔
50

51

52
class IDependsData(TypedDict):
12✔
53
    path: str
12✔
54
    depends: str
12✔
55

56

57
class ILocalMroData(TypedDict):
12✔
58
    base: List[str]
12✔
59
    fields: Dict[str, int]
12✔
60

61

62
class IM2mData(TypedDict):
12✔
63
    left: str
12✔
64
    right: str
12✔
65
IM2mMap = Dict[Type[Model], IM2mData]
12✔
66

67

68
# global deps: {cfModel: {cfname: {srcModel: {'path': lookup_path, 'depends': src_fieldname}}}}
69
IGlobalDeps = Dict[Type[Model], Dict[str, Dict[Type[Model], List[IDependsData]]]]
12✔
70
# local deps: {Model: {'cfname': {'depends', 'on', 'these', 'local', 'fieldnames'}}}
71
ILocalDeps = Dict[Type[Model], Dict[str, Set[str]]]
12✔
72
# cleaned: {(cfmodel, cfname): {(srcmodel, fname), ...}}
73
IGlobalDepsCleaned = Dict[Tuple[str, str], Set[Tuple[str, str]]]
12✔
74
IInterimTable = Dict[Type[Model], Dict[str, Dict[Type[Model], Dict[str, List[IDependsData]]]]]
12✔
75

76
# maps exported to resolver
77
# LookupMap: {srcModel: {srcfield: {cfModel, ({querystrings}, {cfields})}}}
78
ILookupMap = Dict[Type[Model], Dict[str, Dict[Type[Model], Tuple[Set[str], Set[str]]]]]
12✔
79
# fk map: {Model: {fkname, ...}}
80
IFkMap = Dict[Type[Model], Set[str]]
12✔
81
# local MRO: {Model: {'base': [mro of all fields], 'fields': {fname: bitarray into base}}}
82
ILocalMroMap = Dict[Type[Model], ILocalMroData]
12✔
83

84

85
class IResolvedDeps(TypedDict):
12✔
86
    globalDeps: IGlobalDeps
12✔
87
    localDeps: ILocalDeps
12✔
88

89

90
class ComputedFieldsException(Exception):
12✔
91
    """
92
    Base exception raised from computed fields.
93
    """
94

95

96
class CycleException(ComputedFieldsException):
12✔
97
    """
98
    Exception raised during path linearization, if a cycle was found.
99
    Contains the found cycle either as list of edges or nodes in
100
    ``args``.
101
    """
102

103

104
class CycleEdgeException(CycleException):
12✔
105
    """
106
    Exception raised during path linearization, if a cycle was found.
107
    Contains the found cycle as list of edges in ``args``.
108
    """
109

110

111
class CycleNodeException(CycleException):
12✔
112
    """
113
    Exception raised during path linearization, if a cycle was found.
114
    Contains the found cycle as list of nodes in ``args``.
115
    """
116

117

118
class Edge:
12✔
119
    """
120
    Class for representing an edge in ``Graph``.
121
    The instances are created as singletons,
122
    calling ``Edge('A', 'B')`` multiple times
123
    will always point to the same object.
124
    """
125
    instances: Dict[Hashable, 'Edge'] = {}
12✔
126

127
    def __new__(cls, *args):
12✔
128
        key: Tuple['Node', 'Node'] = (args[0], args[1])
12✔
129
        if key in cls.instances:
12✔
130
            return cls.instances[key]
12✔
131
        instance = super(Edge, cls).__new__(cls)
12✔
132
        cls.instances[key] = instance
12✔
133
        return instance
12✔
134

135
    def __init__(self, left: 'Node', right: 'Node', data: Optional[Any] = None):
12✔
136
        self.left = left
12✔
137
        self.right = right
12✔
138
        self.data = data
12✔
139

140
    def __str__(self) -> str:
12✔
141
        return f'Edge {self.left}-{self.right}'
12✔
142

143
    def __repr__(self) -> str:
12✔
144
        return str(self)
12✔
145

146
    def __eq__(self, other) -> bool:
12✔
147
        return self is other
12✔
148

149
    def __ne__(self, other) -> bool:
12✔
150
        return self is not other
12✔
151

152
    def __hash__(self) -> int:
12✔
153
        return id(self)
12✔
154

155

156
class Node:
12✔
157
    """
158
    Class for representing a node in ``Graph``.
159
    The instances are created as singletons,
160
    calling ``Node('A')`` multiple times will
161
    always point to the same object.
162
    """
163
    instances: Dict[Hashable, 'Node'] = {}
12✔
164

165
    def __new__(cls, *args):
12✔
166
        if args[0] in cls.instances:
12✔
167
            return cls.instances[args[0]]
12✔
168
        instance = super(Node, cls).__new__(cls)
12✔
169
        cls.instances[args[0]] = instance
12✔
170
        return instance
12✔
171

172
    def __init__(self, data: Any):
12✔
173
        self.data = data
12✔
174

175
    def __str__(self) -> str:
12✔
176
        return self.data if isinstance(self.data, str) else '.'.join(self.data)
12✔
177

178
    def __repr__(self) -> str:
12✔
179
        return str(self)
12✔
180

181
    def __eq__(self, other) -> bool:
12✔
182
        return self is other
12✔
183

184
    def __ne__(self, other) -> bool:
12✔
185
        return self is not other
×
186

187
    def __hash__(self) -> int:
12✔
188
        return id(self)
12✔
189

190

191
class Graph:
12✔
192
    """
193
    .. _graph:
194

195
    Simple directed graph implementation.
196
    """
197

198
    def __init__(self):
12✔
199
        self.nodes: Set[Node] = set()
12✔
200
        self.edges: Set[Edge] = set()
12✔
201
        self._removed: Set[Edge] = set()
12✔
202

203
    def add_node(self, node: Node) -> None:
12✔
204
        """Add a node to the graph."""
205
        self.nodes.add(node)
×
206

207
    def remove_node(self, node: Node) -> None:
12✔
208
        """
209
        Remove a node from the graph.
210

211
        .. WARNING::
212
            Removing edges containing the removed node
213
            is not implemented.
214
        """
215
        self.nodes.remove(node)
12✔
216

217
    def add_edge(self, edge: Edge) -> None:
12✔
218
        """
219
        Add an edge to the graph.
220
        Automatically inserts the associated nodes.
221
        """
222
        self.edges.add(edge)
12✔
223
        self.nodes.add(edge.left)
12✔
224
        self.nodes.add(edge.right)
12✔
225

226
    def remove_edge(self, edge: Edge) -> None:
12✔
227
        """
228
        Removes an edge from the graph.
229

230
        .. WARNING::
231
            Does not remove leftover contained nodes.
232
        """
233
        self.edges.remove(edge)
12✔
234

235
    def get_dot(
12✔
236
            self,
237
            format: str = 'pdf',
238
            mark_edges: Optional[Dict[Edge, Dict[str, Any]]] = None,
239
            mark_nodes: Optional[Dict[Node, Dict[str, Any]]] = None
240
    ):
241
        """
242
        Returns the graphviz object of the graph
243
        (needs the :mod:`graphviz` package).
244
        """
245
        from graphviz import Digraph
12✔
246
        if not mark_edges:
12✔
247
            mark_edges = {}
12✔
248
        if not mark_nodes:
12!
249
            mark_nodes = {}
12✔
250
        dot = Digraph(format=format)
12✔
251
        for node in self.nodes:
12✔
252
            dot.node(str(node), str(node), **mark_nodes.get(node, {}))
12✔
253
        for edge in self.edges:
12✔
254
            dot.edge(str(edge.left), str(edge.right), **mark_edges.get(edge, {}))
12✔
255
        return dot
12✔
256

257
    def render(
12✔
258
            self,
259
            filename: Optional[Union[PathLike, str]] = None,
260
            format: str = 'pdf',
261
            mark_edges: Optional[Dict[Edge, Dict[str, Any]]] = None,
262
            mark_nodes: Optional[Dict[Node, Dict[str, Any]]] = None
263
    ) -> None:
264
        """
265
        Renders the graph to file (needs the :mod:`graphviz` package).
266
        """
267
        self.get_dot(format, mark_edges, mark_nodes).render(
12✔
268
            filename=filename, cleanup=True)
269

270
    def view(
12✔
271
            self,
272
            format: str = 'pdf',
273
            mark_edges: Optional[Dict[Edge, Dict[str, Any]]] = None,
274
            mark_nodes: Optional[Dict[Node, Dict[str, Any]]] = None
275
    ) -> None:
276
        """
277
        Directly opens the graph in the associated desktop viewer
278
        (needs the :mod:`graphviz` package).
279
        """
280
        self.get_dot(format, mark_edges, mark_nodes).view(cleanup=True)
×
281

282
    @staticmethod
12✔
283
    def edgepath_to_nodepath(path: Sequence[Edge]) -> List[Node]:
12✔
284
        """
285
        Converts a list of edges to a list of nodes.
286
        """
287
        return [edge.left for edge in path] + [path[-1].right]
12✔
288

289
    @staticmethod
12✔
290
    def nodepath_to_edgepath(path: Sequence[Node]) -> List[Edge]:
12✔
291
        """
292
        Converts a list of nodes to a list of edges.
293
        """
294
        return [Edge(*pair) for pair in pairwise(path)]
12✔
295

296
    def _get_edge_paths(
12✔
297
            self,
298
            edge: Edge,
299
            left_edges: Dict[Node, List[Edge]],
300
            paths: List[List[Edge]],
301
            seen: Optional[List[Edge]] = None
302
    ) -> None:
303
        """
304
        Walks the graph recursively to get all possible paths.
305
        Might raise a ``CycleEdgeException``.
306
        """
307
        if not seen:
12✔
308
            seen = []
12✔
309
        if edge in seen:
12✔
310
            raise CycleEdgeException(seen[seen.index(edge):])
12✔
311
        seen.append(edge)
12✔
312
        if edge.right in left_edges:
12✔
313
            for new_edge in left_edges[edge.right]:
12✔
314
                self._get_edge_paths(new_edge, left_edges, paths, seen[:])
12✔
315
        paths.append(seen)
12✔
316

317
    def get_edgepaths(self) -> List[List[Edge]]:
12✔
318
        """
319
        Returns a list of all edge paths.
320
        An edge path is represented as list of edges.
321

322
        Might raise a ``CycleEdgeException``. For in-depth cycle detection
323
        use ``edge_cycles``, `node_cycles`` or ``get_cycles()``.
324
        """
325
        left_edges: Dict[Node, List[Edge]] = OrderedDict()
12✔
326
        paths: List[List[Edge]] = []
12✔
327
        for edge in self.edges:
12✔
328
            left_edges.setdefault(edge.left, []).append(edge)
12✔
329
        for edge in self.edges:
12✔
330
            self._get_edge_paths(edge, left_edges, paths)
12✔
331
        return paths
12✔
332

333
    def get_nodepaths(self) -> List[List[Node]]:
12✔
334
        """
335
        Returns a list of all node paths.
336
        A node path is represented as list of nodes.
337

338
        Might raise a ``CycleNodeException``. For in-depth cycle detection
339
        use ``edge_cycles``, ``node_cycles`` or ``get_cycles()``.
340
        """
341
        try:
12✔
342
            paths: List[List[Edge]] = self.get_edgepaths()
12✔
343
        except CycleEdgeException as exc:
12✔
344
            raise CycleNodeException(self.edgepath_to_nodepath(exc.args[0]))
12✔
345
        node_paths: List[List[Node]] = []
12✔
346
        for path in paths:
12✔
347
            node_paths.append(self.edgepath_to_nodepath(path))
12✔
348
        return node_paths
12✔
349

350
    def _get_cycles(
12✔
351
            self,
352
            edge: Edge,
353
            left_edges: Dict[Node, List[Edge]],
354
            cycles: Dict[FrozenSet[Edge], ICycleData],
355
            seen: Optional[List[Edge]] = None
356
    ) -> None:
357
        """
358
        Walks the graph to collect all cycles.
359
        """
360
        if not seen:
12✔
361
            seen = []
12✔
362
        if edge in seen:
12✔
363
            cycle: FrozenSet[Edge] = frozenset(seen[seen.index(edge):])
12✔
364
            data: ICycleData = cycles.setdefault(cycle, {'entries': set(), 'path': []})
12✔
365
            if seen:
12!
366
                data['entries'].add(seen[0])
12✔
367
            data['path'] = seen[seen.index(edge):]
12✔
368
            return
12✔
369
        seen.append(edge)
12✔
370
        if edge.right in left_edges:
12✔
371
            for new_edge in left_edges[edge.right]:
12✔
372
                self._get_cycles(new_edge, left_edges, cycles, seen[:])
12✔
373

374
    def get_cycles(self) -> Dict[FrozenSet[Edge], ICycleData]:
12✔
375
        """
376
        Gets all cycles in graph.
377

378
        This is not optimised by any means, it simply walks the whole graph
379
        recursively and aborts as soon a seen edge gets entered again.
380
        Therefore use this and all dependent properties
381
        (``edge_cycles`` and ``node_cycles``) for in-depth cycle inspection
382
        only.
383

384
        As a start node any node on the left side of an edge will be tested.
385

386
        Returns a mapping of
387

388
        .. code:: python
389

390
            {frozenset(<cycle edges>): {
391
                'entries': set(edges leading to the cycle),
392
                'path': list(cycle edges in last seen order)
393
            }}
394

395
        An edge in ``entries`` is not necessarily part of the cycle itself,
396
        but once entered it will lead to the cycle.
397
        """
398
        left_edges: Dict[Node, List[Edge]] = OrderedDict()
12✔
399
        cycles: Dict[FrozenSet[Edge], ICycleData] = {}
12✔
400
        for edge in self.edges:
12✔
401
            left_edges.setdefault(edge.left, []).append(edge)
12✔
402
        for edge in self.edges:
12✔
403
            self._get_cycles(edge, left_edges, cycles)
12✔
404
        return cycles
12✔
405

406
    @property
12✔
407
    def edge_cycles(self) -> List[List[Edge]]:
12✔
408
        """
409
        Returns all cycles as list of edge lists.
410
        Use this only for in-depth cycle inspection.
411
        """
412
        return [cycle['path'] for cycle in self.get_cycles().values()]
12✔
413

414
    @property
12✔
415
    def node_cycles(self) -> List[List[Node]]:
12✔
416
        """
417
        Returns all cycles as list of node lists.
418
        Use this only for in-depth cycle inspection.
419
        """
420
        return [self.edgepath_to_nodepath(cycle['path'])
12✔
421
                for cycle in self.get_cycles().values()]
422

423
    @property
12✔
424
    def is_cyclefree(self) -> bool:
12✔
425
        """
426
        True if the graph contains no cycles.
427

428
        For faster calculation this property relies on
429
        path linearization instead of the more expensive
430
        full cycle detection. For in-depth cycle inspection
431
        use ``edge_cycles`` or ``node_cycles`` instead.
432
        """
433
        try:
12✔
434
            self.get_edgepaths()
12✔
435
            return True
12✔
436
        except CycleEdgeException:
12✔
437
            return False
12✔
438

439

440
class ComputedModelsGraph(Graph):
12✔
441
    """
442
    Class to convert the computed fields dependency strings into
443
    a graph and generate the final resolver functions.
444

445
    Steps taken:
446

447
    - ``resolve_dependencies`` resolves the depends field strings
448
      to real model fields.
449
    - The dependencies are rearranged to adjacency lists for
450
      the underlying graph.
451
    - The graph does a cycle check and removes redundant edges
452
      to lower the database penalty.
453
    - In ``generate_lookup_map`` the path segments of remaining edges
454
      are collected into the final lookup map.
455
    """
456

457
    def __init__(self, computed_models: Dict[Type[Model], Dict[str, IComputedField]]):
12✔
458
        """
459
        ``computed_models`` is ``Resolver.computed_models``.
460
        """
461
        super(ComputedModelsGraph, self).__init__()
12✔
462
        self._m2m: IM2mMap = {}
12✔
463
        self._computed_models: Dict[Type[Model], Dict[str, IComputedField]] = computed_models
12✔
464
        self.models: Dict[str, Type[Model]] = {}
12✔
465
        self.resolved: IResolvedDeps = self.resolve_dependencies(computed_models)
12✔
466
        self.cleaned_data: IGlobalDepsCleaned = self._clean_data(self.resolved['globalDeps'])
12✔
467
        self._insert_data(self.cleaned_data)
12✔
468
        self.modelgraphs: Dict[Type[Model], ModelGraph] = {}
12✔
469
        self.union: Optional[Graph] = None
12✔
470

471
    def _right_constrain(self, model: Type[Model], fieldname: str) -> None:
12✔
472
        """
473
        Sanity check for right side field types.
474

475
        On the right side of a `depends` rule only real database fields should occur,
476
        that are fieldnames that can safely be used with `update_fields` in ``save``.
477
        This includes any concrete fields (also computed fields itself), but not m2m fields.
478
        """
479
        f = model._meta.get_field(fieldname)
12✔
480
        if not f.concrete or f.many_to_many:
12!
481
            raise ComputedFieldsException(f"{model} has no concrete field named '{fieldname}'")
×
482
    
483
    def _expand_m2m(self, model: Type[Model], path: str) -> str:
12✔
484
        """
485
        Expand M2M dependencies into through model.
486
        """
487
        cls: Type[Model] = model
12✔
488
        symbols: list[str] = []
12✔
489
        for symbol in path.split('.'):
12✔
490
            try:
12✔
491
                rel: Any = cls._meta.get_field(symbol)
12✔
492
            except FieldDoesNotExist:
12✔
493
                descriptor = getattr(cls, symbol)
12✔
494
                rel = getattr(descriptor, 'rel', None) or getattr(descriptor, 'related')
12✔
495
            if rel.many_to_many:
12✔
496
                if hasattr(rel, 'through'):
12✔
497
                    through = rel.through
12✔
498
                    m2m_field_name = rel.remote_field.m2m_field_name()
12✔
499
                    m2m_reverse_field_name = rel.remote_field.m2m_reverse_field_name()
12✔
500
                    symbols.append(through._meta.get_field(m2m_reverse_field_name).related_query_name())
12✔
501
                    symbols.append(m2m_field_name)
12✔
502
                else:
503
                    through = rel.remote_field.through
12✔
504
                    m2m_field_name = rel.m2m_field_name()
12✔
505
                    m2m_reverse_field_name = rel.m2m_reverse_field_name()
12✔
506
                    symbols.append(through._meta.get_field(m2m_field_name).related_query_name())
12✔
507
                    symbols.append(m2m_reverse_field_name)
12✔
508
                self._m2m[through] = {
12✔
509
                    'left': m2m_field_name,
510
                    'right': m2m_reverse_field_name
511
                }
512
            else:
513
                symbols.append(symbol)
12✔
514
            cls = rel.related_model
12✔
515
        return '.'.join(symbols)
12✔
516

517
    def resolve_dependencies(
12✔
518
        self,
519
        computed_models: Dict[Type[Model], Dict[str, IComputedField]]
520
    ) -> IResolvedDeps:
521
        """
522
        Converts `depends` rules into ORM lookups and checks the source fields' existance.
523

524
        Basic `depends` rules:
525
        - left side may contain any relation path accessible from an instance as ``'a.b.c'``
526
        - right side may contain real database source fields (never relations)
527

528
        Deeper nested relations get automatically added to the resolver map:
529

530
        - fk relations are added on the model holding the fk field
531
        - reverse fk relations are added on related model holding the fk field
532
        - m2m fields and backrelations are added on the model directly, but
533
          only used for inter-model resolving, never for field lookups during ``save``
534
        """
535
        global_deps: IGlobalDeps = OrderedDict()
12✔
536
        local_deps: ILocalDeps = {}
12✔
537
        for model, fields in computed_models.items():
12✔
538
            # skip proxy models for graph handling,
539
            # deps get patched at runtime from resolved real models
540
            if model._meta.proxy:
12✔
541
                continue
12✔
542
            local_deps.setdefault(model, {})  # always add to local to get a result for MRO
12✔
543
            for field, real_field in fields.items():
12✔
544
                fieldentry = global_deps.setdefault(model, {}).setdefault(field, {})
12✔
545
                local_deps.setdefault(model, {}).setdefault(field, set())
12✔
546

547
                depends: IDepends = real_field._computed['depends']
12✔
548

549
                # fields contributed from multi table model inheritance need patched depends rules,
550
                # so the relation paths match the changed model entrypoint
551
                if real_field.model != model and not real_field.model._meta.abstract:
12✔
552
                    # path from original model to current inherited
553
                    # these path segments have to be removed from depends
554
                    remove_segments = parent_to_inherited_path(real_field.model, model)
12✔
555
                    if not remove_segments:
12!
UNCOV
556
                        raise ComputedFieldsException(f'field {real_field} cannot be mapped on model {model}')
×
557

558
                    # paths starting with these segments belong to other derived models
559
                    # and get skipped for the dep tree creation on the current model
560
                    # allows depending on fields of derived models in a computed field on the parent model
561
                    # ("up-pulling", make sure your method handles the attribute access correctly)
562
                    skip_paths: List[str] = []
12✔
563
                    for rel1 in real_field.model._meta.related_objects:
12✔
564
                        if rel1.name != remove_segments[0]:
12✔
565
                            skip_paths.append(rel1.name)
12✔
566

567
                    # do a full rewrite of depends entry
568
                    depends_overwrite: IDependsAppend = []
12✔
569
                    for path, fieldnames in depends:
12✔
570
                        ps = path.split('.')
12✔
571
                        if ps[0] in skip_paths:
12✔
572
                            continue
12✔
573
                        path = '.'.join(skip_equal_segments(ps, remove_segments)) or 'self'
12✔
574
                        depends_overwrite.append((path, fieldnames[:]))
12✔
575
                    depends = depends_overwrite
12✔
576

577
                for path, fieldnames in depends:
12✔
578
                    if path == 'self':
12✔
579
                        # skip selfdeps in global graph handling
580
                        # we handle it instead on model graph level
581
                        # do at least an existance check here to provide an early error
582
                        for fieldname in fieldnames:
12✔
583
                            self._right_constrain(model, fieldname)
12✔
584
                        local_deps.setdefault(model, {}).setdefault(field, set()).update(fieldnames)
12✔
585
                        continue
12✔
586

587
                    # expand m2m into through model
588
                    path = self._expand_m2m(model, path)
12✔
589

590
                    path_segments: List[str] = []
12✔
591
                    cls: Type[Model] = model
12✔
592
                    for symbol in path.split('.'):
12✔
593
                        try:
12✔
594
                            rel: Any = cls._meta.get_field(symbol)
12✔
595
                        except FieldDoesNotExist:
12✔
596
                            # handle reverse relation (not a concrete field)
597
                            descriptor = getattr(cls, symbol)
12✔
598
                            rel = getattr(descriptor, 'rel', None) or getattr(descriptor, 'related')
12✔
599
                            symbol = rel.field.related_query_name()
12✔
600
                            # add dependency to reverse relation field as well
601
                            # this needs to be added in opposite direction on related model
602
                            path_segments.append(symbol)
12✔
603
                            fieldentry.setdefault(rel.related_model, []).append(
12✔
604
                                {'path': '__'.join(path_segments), 'depends': rel.field.name})
605
                            path_segments.pop()
12✔
606
                        # add path segment to self deps if we have an fk field on a CFM
607
                        # this is needed to correctly propagate direct fk changes
608
                        # in local cf mro later on
609
                        if isinstance(rel, ForeignKey) and cls in computed_models:
12✔
610
                            self._right_constrain(cls, symbol)
12✔
611
                            local_deps.setdefault(cls, {}).setdefault(field, set()).add(symbol)
12✔
612
                        if path_segments:
12✔
613
                            # add segment to intermodel graph deps
614
                            fieldentry.setdefault(cls, []).append(
12✔
615
                                {'path': '__'.join(path_segments), 'depends': symbol})
616
                        path_segments.append(symbol)
12✔
617
                        cls = rel.related_model
12✔
618
                    for target_field in fieldnames:
12✔
619
                        self._right_constrain(cls, target_field)
12✔
620
                        fieldentry.setdefault(cls, []).append(
12✔
621
                            {'path': '__'.join(path_segments), 'depends': target_field})
622
        return {'globalDeps': global_deps, 'localDeps': local_deps}
12✔
623

624
    def _clean_data(self, data: IGlobalDeps) -> IGlobalDepsCleaned:
12✔
625
        """
626
        Converts the global dependency data into an adjacency list tree
627
        to be used with the underlying graph.
628
        """
629
        cleaned: IGlobalDepsCleaned = OrderedDict()
12✔
630
        for model, fielddata in data.items():
12✔
631
            self.models[modelname(model)] = model
12✔
632
            for field, modeldata in fielddata.items():
12✔
633
                for depmodel, relations in modeldata.items():
12✔
634
                    self.models[modelname(depmodel)] = depmodel
12✔
635
                    for dep in relations:
12✔
636
                        key = (modelname(depmodel), dep['depends'])
12✔
637
                        value = (modelname(model), field)
12✔
638
                        cleaned.setdefault(key, set()).add(value)
12✔
639
        return cleaned
12✔
640

641
    def _insert_data(self, data: IGlobalDepsCleaned) -> None:
12✔
642
        """
643
        Adds edges in ``data`` to the graph.
644
        Data must be an adjacency mapping like
645
        ``{left: set(right neighbours)}``.
646
        """
647
        for left, value in data.items():
12✔
648
            for right in value:
12✔
649
                edge = Edge(Node(left), Node(right))
12✔
650
                self.add_edge(edge)
12✔
651

652
    def _get_fk_fields(self, model: Type[Model], paths: Set[str]) -> Set[str]:
12✔
653
        """
654
        Reduce field name dependencies in paths to reverse real local fk fields.
655
        """
656
        candidates = set(el.split('__')[-1] for el in paths)
12✔
657
        result: Set[str] = set()
12✔
658
        for field in model._meta.get_fields():
12✔
659
            if isinstance(field, ForeignKey) and field.related_query_name() in candidates:
12✔
660
                result.add(field.name)
12✔
661
        return result
12✔
662

663
    def _resolve(self, data: Dict[str, List[IDependsData]]) -> Tuple[Set[str], Set[str]]:
12✔
664
        """
665
        Helper to merge querystring paths for lookup map.
666
        """
667
        fields: Set[str] = set()
12✔
668
        strings: Set[str] = set()
12✔
669
        for field, dependencies in data.items():
12✔
670
            fields.add(field)
12✔
671
            for dep in dependencies:
12✔
672
                strings.add(dep['path'])
12✔
673
        return fields, strings
12✔
674

675
    def generate_maps(self) -> Tuple[ILookupMap, IFkMap]:
12✔
676
        """
677
        Generates the final lookup map and the fk map.
678

679
        Schematically the lookup map is a reversed adjacency list of every source model
680
        with its fields mapping to the target models with computed fields it would
681
        update through a certain filter string::
682

683
            src_model:[src_field, ...] --> target_model:[(cf_field, filter_string), ...]
684

685
        During runtime ``update_dependent`` will use the the information to create
686
        select querysets on the target_models (roughly):
687

688
        .. code:: python
689

690
            qs = target_model._base_manager.filter(filter_string=src_model.instance)
691
            qs |= target_model._base_manager.filter(filter_string2=src_model.instance)
692
            ...
693
            bulk_updater(qs, cf_fields)
694

695
        The fk map list all models with fk fieldnames, that contribute to computed fields.
696

697
        Returns a tuple of (lookup_map, fk_map).
698
        """
699
        # apply full node information to graph edges
700
        table: IInterimTable = {}
12✔
701
        for edge in self.edges:
12✔
702
            lmodel, lfield = edge.left.data
12✔
703
            lmodel = self.models[lmodel]
12✔
704
            rmodel, rfield = edge.right.data
12✔
705
            rmodel = self.models[rmodel]
12✔
706
            table.setdefault(lmodel, {}) \
12✔
707
                .setdefault(lfield, {}) \
708
                .setdefault(rmodel, {}) \
709
                .setdefault(rfield, []) \
710
                .extend(self.resolved['globalDeps'][rmodel][rfield][lmodel])
711

712
        # build lookup and path map
713
        lookup_map: ILookupMap = {}
12✔
714
        path_map: Dict[Type[Model], Set[str]] = {}
12✔
715
        for lmodel, data in table.items():
12✔
716
            for lfield, ldata in data.items():
12✔
717
                for rmodel, rdata in ldata.items():
12✔
718
                    fields, strings = self._resolve(rdata)
12✔
719
                    lookup_map.setdefault(lmodel, {}) \
12✔
720
                        .setdefault(lfield, {})[rmodel] = (fields, strings)
721
                    path_map.setdefault(lmodel, set()).update(strings)
12✔
722

723
        # translate paths to model local fields and filter for fk fields
724
        fk_map: IFkMap = {}
12✔
725
        for model, paths in path_map.items():
12✔
726
            value = self._get_fk_fields(model, paths)
12✔
727
            if value:
12✔
728
                fk_map[model] = value
12✔
729

730
        return lookup_map, fk_map
12✔
731

732
    def prepare_modelgraphs(self) -> None:
12✔
733
        """
734
        Helper to initialize model local subgraphs.
735
        """
736
        if self.modelgraphs:
12✔
737
            return
12✔
738
        data = self.resolved['localDeps']
12✔
739
        for model, local_deps in data.items():
12✔
740
            model_graph = ModelGraph(model, local_deps, self._computed_models[model])
12✔
741
            model_graph.transitive_reduction()  # modelgraph always must be cyclefree
12✔
742
            self.modelgraphs[model] = model_graph
12✔
743

744
    def generate_local_mro_map(self) -> ILocalMroMap:
12✔
745
        """
746
        Generate model local computed fields mro maps.
747
        Returns a mapping of models with local computed fields dependencies and their
748
        `mro`, example:
749

750
        .. code:: python
751

752
            {
753
                modelX: {
754
                    'base': ['c1', 'c2', 'c3'],
755
                    'fields': {
756
                        'name': ['c2', 'c3'],
757
                        'c2': ['c2', 'c3']
758
                    }
759
                }
760
            }
761

762
        In the example `modelX` would have 3 computed fields, where `c2` somehow depends on
763
        the field `name`. `c3` itself depends on changes to `c2`, thus a change to `name` should
764
        run `c2` and `c3` in that specific order.
765

766
        `base` lists all computed fields in topological execution order (mro).
767
        It is also used at runtime to cover a full update of an instance (``update_fields=None``).
768

769
        .. NOTE::
770
            Note that the actual values in `fields` are bitarrays to index positions of `base`,
771
            which allows quick field update merges at runtime by doing binary OR on the bitarrays.
772
        """
773
        self.prepare_modelgraphs()
12✔
774
        return dict(
12✔
775
            (model, g.generate_local_mapping(g.generate_field_paths(g.get_topological_paths())))
776
            for model, g in self.modelgraphs.items()
777
        )
778

779
    def get_uniongraph(self) -> Graph:
12✔
780
        """
781
        Build a union graph of intermodel dependencies and model local dependencies.
782
        This graph represents the final update cascades triggered by certain field updates.
783
        The union graph is needed to spot cycles introduced by model local dependencies,
784
        that otherwise might went unnoticed, example:
785

786
        - global dep graph (acyclic):  ``A.comp --> B.comp, B.comp2 --> A.comp``
787
        - modelgraph of B  (acyclic):  ``B.comp --> B.comp2``
788

789
        Here the resulting union graph is not a DAG anymore, since both subgraphs short-circuit
790
        to a cycle of ``A.comp --> B.comp --> B.comp2 --> A.comp``.
791
        """
792
        if not self.union:
12✔
793
            graph = Graph()
12✔
794
            # copy intermodel edges
795
            for edge in self.edges:
12✔
796
                graph.add_edge(edge)
12✔
797
            # copy modelgraph edges
798
            self.prepare_modelgraphs()
12✔
799
            for model, modelgraph in self.modelgraphs.items():
12✔
800
                name = modelname(model)
12✔
801
                for edge in modelgraph.edges:
12✔
802
                    graph.add_edge(Edge(
12✔
803
                        Node((name, edge.left.data)),
804
                        Node((name, edge.right.data))
805
                    ))
806
            self.union = graph
12✔
807
        return self.union
12✔
808

809

810
class ModelGraph(Graph):
12✔
811
    """
812
    Graph to resolve model local computed field dependencies in right calculation order.
813
    """
814

815
    def __init__(
12✔
816
            self,
817
            model: Type[Model],
818
            local_dependencies: Dict[str, Set[str]],
819
            computed_fields: Dict[str, IComputedField]
820
    ):
821
        super(ModelGraph, self).__init__()
12✔
822
        self.model = model
12✔
823

824
        # add all edges from extracted local deps
825
        for right, deps in local_dependencies.items():
12✔
826
            for left in deps:
12✔
827
                self.add_edge(Edge(Node(left), Node(right)))
12✔
828

829
        # add ## node as update_fields=None placeholder with edges to all computed fields
830
        # --> None explicitly updates all computed fields
831
        # Note: this has to be on all cfs to not skip a non local dependent one by accident
832
        left_all = Node('##')
12✔
833
        for computed in computed_fields:
12✔
834
            self.add_edge(Edge(left_all, Node(computed)))
12✔
835

836
    def transitive_reduction(self) -> None:
12✔
837
        """
838
        Remove redundant single edges. Also checks for cycles.
839
        *Note:* Other than intermodel dependencies local dependencies must always be cyclefree.
840
        """
841
        paths: List[List[Edge]] = self.get_edgepaths()
12✔
842
        remove: Set[Edge] = set()
12✔
843
        for path1 in paths:
12✔
844
            # we only cut single edge paths
845
            if len(path1) > 1:
12✔
846
                continue
12✔
847
            left: Node = path1[0].left
12✔
848
            right: Node = path1[-1].right
12✔
849
            for path2 in paths:
12✔
850
                if path2 == path1:
12✔
851
                    continue
12✔
852
                if right == path2[-1].right and left == path2[0].left:
12✔
853
                    remove.add(path1[0])
12✔
854
        for edge in remove:
12✔
855
            self.remove_edge(edge)
12✔
856

857
    def _tsort(
12✔
858
            self,
859
            graph: Dict[Node, List[Node]],
860
            start: Node,
861
            paths: Dict[Node, List[Node]],
862
            path: List[Node]
863
    ):
864
        """
865
        Recursive deep first search variant of topsort.
866
        Also accumulates any revealed subpaths.
867
        """
868
        for node in graph.get(start, []):
12✔
869
            if not node in paths:
12✔
870
                # accumulate revealed topological subpaths
871
                paths[node] = self._tsort(graph, node, paths, [])
12✔
872
            for snode in paths[node]:
12✔
873
                # append node if its not yet part of the path
874
                if not snode in path:
12✔
875
                    path += [snode]
12✔
876
        path += [start]
12✔
877
        return path
12✔
878

879
    def get_topological_paths(self) -> Dict[Node, List[Node]]:
12✔
880
        """
881
        Creates a map of all possible entry nodes and their topological update path
882
        (computed fields mro).
883
        """
884
        # create simplified parent-child relation graph
885
        graph: Dict[Node, List[Node]] = {}
12✔
886
        for edge in self.edges:
12✔
887
            graph.setdefault(edge.left, []).append(edge.right)
12✔
888
        topological_paths: Dict[Node, List[Node]] = {}
12✔
889

890
        # '##' has connections to all cfs thus creates the basic deps order map containing all cfs
891
        # it also creates all tpaths between cfs itself
892
        topological_paths[Node('##')] = self._tsort(graph, Node('##'), topological_paths, [])[:-1]
12✔
893

894
        # we still need to reveal concrete field deps
895
        # other than for cfs we also strip last entry (the field itself)
896
        for node in graph:
12✔
897
            if node in topological_paths:
12✔
898
                continue
12✔
899
            topological_paths[node] = self._tsort(graph, node, topological_paths, [])[:-1]
12✔
900

901
        # reverse all tpaths
902
        for entry, path in topological_paths.items():
12✔
903
            topological_paths[entry] = path[::-1]
12✔
904

905
        return topological_paths
12✔
906

907
    def generate_field_paths(self, tpaths: Dict[Node, List[Node]]) -> Dict[str, List[str]]:
12✔
908
        """
909
        Convert topological path node mapping into a mapping containing the fieldnames.
910
        """
911
        field_paths: Dict[str, List[str]] = {}
12✔
912
        for node, path in tpaths.items():
12✔
913
            field_paths[node.data] = [el.data for el in path]
12✔
914
        return field_paths
12✔
915

916
    def generate_local_mapping(self, field_paths: Dict[str, List[str]]) -> ILocalMroData:
12✔
917
        """
918
        Generates the final model local update table to be used during ``ComputedFieldsModel.save``.
919
        Output is a mapping of local fields, that also update local computed fields, to a bitarray
920
        containing the computed fields mro, and the base topologcial order for a full update.
921
        """
922
        # transfer final data to bitarray
923
        # bitarray: bit index is realisation of one valid full topsort order held in '##'
924
        # since this order was build from full graph it always reflects the correct mro
925
        # even for a subset of fields in update_fields later on
926
        # properties:
927
        #   - length is number of computed fields on model
928
        #   - True indicates execution of associated function at position in topological order
929
        #   - bitarray is int, field update rules can be added by bitwise OR at save time
930
        # example:
931
        #   - edges: [name, c_a], [c_a, c_b], [c_c, c_b], [z, c_c]
932
        #   - topological order of cfs: {1: c_a, 2: c_c, 4: c_b}
933
        #   - field deps:   name  : c_a, c_b    --> 0b101
934
        #                   c_a   : c_a, c_b    --> 0b101
935
        #                   c_c   : c_c, c_b    --> 0b110
936
        #                   z     : c_c, c_b    --> 0b110
937
        #   - update mro:   [name, z]   --> 0b101 | 0b110 --> [c_a, c_c, c_b]
938
        #                   [c_a, c_b]  --> 0b101 | 0b110 --> [c_a, c_c, c_b]
939
        binary: Dict[str, int] = {}
12✔
940
        base = field_paths['##']
12✔
941
        for field, deps in field_paths.items():
12✔
942
            if field == '##':
12✔
943
                continue
12✔
944
            binary[field] = 0
12✔
945
            for pos, name in enumerate(base):
12✔
946
                binary[field] |= (1 if name in deps else 0) << pos
12✔
947
        return {'base': base, 'fields': binary}
12✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc