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

netzkolchose / django-computedfields / 16098233627

06 Jul 2025 10:50AM UTC coverage: 94.772% (-0.1%) from 94.903%
16098233627

Pull #177

github

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

432 of 469 branches covered (92.11%)

Branch coverage included in aggregate %.

39 of 40 new or added lines in 3 files covered. (97.5%)

1 existing line in 1 file now uncovered.

1109 of 1157 relevant lines covered (95.85%)

11.5 hits per line

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

98.2
/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!
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
                            if rel.many_to_many:
12!
NEW
596
                                assert 1 == 0
×
597
                                #'m2m_column_name', 
598
                                #'m2m_db_table', 
599
                                #'m2m_field_name', 
600
                                #'m2m_reverse_field_name', 
601
                                #'m2m_reverse_name', 
602
                                #'m2m_reverse_target_field_name', 
603
                                #'m2m_target_field_name',
604
                                #if hasattr(rel, 'through'):
605
                                #    reverse = True
606
                                #    through_model = rel.through
607
                                #    m2m_field_name = rel.remote_field.m2m_field_name()
608
                                #    m2m_reverse_field_name = rel.remote_field.m2m_reverse_field_name()
609
                                #    related_name = through_model._meta.get_field(m2m_reverse_field_name).related_query_name()
610
                                #    pathname = f'{related_name}.{m2m_field_name}'
611
                                #else:
612
                                #    reverse = False
613
                                #    through_model = getattr(rel.model, symbol).through
614
                                #    m2m_field_name = rel.m2m_field_name()
615
                                #    m2m_reverse_field_name = rel.m2m_reverse_field_name()
616
                                #    related_name = through_model._meta.get_field(m2m_field_name).related_query_name()
617
                                #    pathname = f'{related_name}.{m2m_reverse_field_name}'
618
                                #d = {
619
                                #    'reltype': type(rel),
620
                                #    'through': through_model,
621
                                #    'reverse': reverse,
622
                                #    'm2m_field_name': m2m_field_name,
623
                                #    'm2m_reverse_field_name': m2m_reverse_field_name,
624
                                #    'related_name': related_name,
625
                                #    'pathname': pathname,
626
                                #    'symbol': symbol
627
                                #}
628
                                #from pprint import pprint
629
                                #pprint(d)
630

631

632
                                #if hasattr(rel, 'through'):
633
                                #    print('#####through', rel.through)
634
                                #else:
635
                                #    print('#####auto through', getattr(rel.model, symbol).through)
636
                                #print('rel:', type(rel))
637
                                #print(
638
                                #    rel.related_model,
639
                                #    symbol,
640
                                #    getattr(rel, 'm2m_field_name', lambda: False)(),
641
                                #    getattr(rel, 'm2m_reverse_field_name', lambda: False)(),
642
                                #    getattr(rel, 'm2m_reverse_name', lambda: False)(),
643
                                #    getattr(rel, 'm2m_reverse_target_field_name', lambda: False)(),
644
                                #    getattr(rel, 'm2m_target_field_name', lambda: False)(),
645
                                #    rel.is_relation
646
                                #)
647
                                #through = rel.through if hasattr(rel, 'through') else getattr(rel.model, symbol).through
648
                                #try:
649
                                #    qn = through._meta.get_field(getattr(rel, 'm2m_reverse_field_name', lambda: False)()).related_query_name()
650
                                #except Exception:
651
                                #    pass
652
                                #print('#########', dir(rel))
653
                                # add dependency to m2m relation fields
654
                                #path_segments.append(symbol)
655
                                #fieldentry.setdefault(rel.related_model, []).append(
656
                                #    {'path': '__'.join(path_segments), 'depends': rel.remote_field.name})
657
                                #path_segments.pop()
658
                        except FieldDoesNotExist:
12✔
659
                            # handle reverse relation (not a concrete field)
660
                            descriptor = getattr(cls, symbol)
12✔
661
                            rel = getattr(descriptor, 'rel', None) or getattr(descriptor, 'related')
12✔
662
                            symbol = rel.field.related_query_name()
12✔
663
                            # add dependency to reverse relation field as well
664
                            # this needs to be added in opposite direction on related model
665
                            path_segments.append(symbol)
12✔
666
                            fieldentry.setdefault(rel.related_model, []).append(
12✔
667
                                {'path': '__'.join(path_segments), 'depends': rel.field.name})
668
                            path_segments.pop()
12✔
669
                        # add path segment to self deps if we have an fk field on a CFM
670
                        # this is needed to correctly propagate direct fk changes
671
                        # in local cf mro later on
672
                        if isinstance(rel, ForeignKey) and cls in computed_models:
12✔
673
                            self._right_constrain(cls, symbol)
12✔
674
                            local_deps.setdefault(cls, {}).setdefault(field, set()).add(symbol)
12✔
675
                        if path_segments:
12✔
676
                            # add segment to intermodel graph deps
677
                            fieldentry.setdefault(cls, []).append(
12✔
678
                                {'path': '__'.join(path_segments), 'depends': symbol})
679
                        path_segments.append(symbol)
12✔
680
                        cls = rel.related_model
12✔
681
                    for target_field in fieldnames:
12✔
682
                        self._right_constrain(cls, target_field)
12✔
683
                        fieldentry.setdefault(cls, []).append(
12✔
684
                            {'path': '__'.join(path_segments), 'depends': target_field})
685
        return {'globalDeps': global_deps, 'localDeps': local_deps}
12✔
686

687
    def _clean_data(self, data: IGlobalDeps) -> IGlobalDepsCleaned:
12✔
688
        """
689
        Converts the global dependency data into an adjacency list tree
690
        to be used with the underlying graph.
691
        """
692
        cleaned: IGlobalDepsCleaned = OrderedDict()
12✔
693
        for model, fielddata in data.items():
12✔
694
            self.models[modelname(model)] = model
12✔
695
            for field, modeldata in fielddata.items():
12✔
696
                for depmodel, relations in modeldata.items():
12✔
697
                    self.models[modelname(depmodel)] = depmodel
12✔
698
                    for dep in relations:
12✔
699
                        key = (modelname(depmodel), dep['depends'])
12✔
700
                        value = (modelname(model), field)
12✔
701
                        cleaned.setdefault(key, set()).add(value)
12✔
702
        return cleaned
12✔
703

704
    def _insert_data(self, data: IGlobalDepsCleaned) -> None:
12✔
705
        """
706
        Adds edges in ``data`` to the graph.
707
        Data must be an adjacency mapping like
708
        ``{left: set(right neighbours)}``.
709
        """
710
        for left, value in data.items():
12✔
711
            for right in value:
12✔
712
                edge = Edge(Node(left), Node(right))
12✔
713
                self.add_edge(edge)
12✔
714

715
    def _get_fk_fields(self, model: Type[Model], paths: Set[str]) -> Set[str]:
12✔
716
        """
717
        Reduce field name dependencies in paths to reverse real local fk fields.
718
        """
719
        candidates = set(el.split('__')[-1] for el in paths)
12✔
720
        result: Set[str] = set()
12✔
721
        for field in model._meta.get_fields():
12✔
722
            if isinstance(field, ForeignKey) and field.related_query_name() in candidates:
12✔
723
                result.add(field.name)
12✔
724
        return result
12✔
725

726
    def _resolve(self, data: Dict[str, List[IDependsData]]) -> Tuple[Set[str], Set[str]]:
12✔
727
        """
728
        Helper to merge querystring paths for lookup map.
729
        """
730
        fields: Set[str] = set()
12✔
731
        strings: Set[str] = set()
12✔
732
        for field, dependencies in data.items():
12✔
733
            fields.add(field)
12✔
734
            for dep in dependencies:
12✔
735
                strings.add(dep['path'])
12✔
736
        return fields, strings
12✔
737

738
    def generate_maps(self) -> Tuple[ILookupMap, IFkMap]:
12✔
739
        """
740
        Generates the final lookup map and the fk map.
741

742
        Schematically the lookup map is a reversed adjacency list of every source model
743
        with its fields mapping to the target models with computed fields it would
744
        update through a certain filter string::
745

746
            src_model:[src_field, ...] --> target_model:[(cf_field, filter_string), ...]
747

748
        During runtime ``update_dependent`` will use the the information to create
749
        select querysets on the target_models (roughly):
750

751
        .. code:: python
752

753
            qs = target_model._base_manager.filter(filter_string=src_model.instance)
754
            qs |= target_model._base_manager.filter(filter_string2=src_model.instance)
755
            ...
756
            bulk_updater(qs, cf_fields)
757

758
        The fk map list all models with fk fieldnames, that contribute to computed fields.
759

760
        Returns a tuple of (lookup_map, fk_map).
761
        """
762
        # apply full node information to graph edges
763
        table: IInterimTable = {}
12✔
764
        for edge in self.edges:
12✔
765
            lmodel, lfield = edge.left.data
12✔
766
            lmodel = self.models[lmodel]
12✔
767
            rmodel, rfield = edge.right.data
12✔
768
            rmodel = self.models[rmodel]
12✔
769
            table.setdefault(lmodel, {}) \
12✔
770
                .setdefault(lfield, {}) \
771
                .setdefault(rmodel, {}) \
772
                .setdefault(rfield, []) \
773
                .extend(self.resolved['globalDeps'][rmodel][rfield][lmodel])
774

775
        # build lookup and path map
776
        lookup_map: ILookupMap = {}
12✔
777
        path_map: Dict[Type[Model], Set[str]] = {}
12✔
778
        for lmodel, data in table.items():
12✔
779
            for lfield, ldata in data.items():
12✔
780
                for rmodel, rdata in ldata.items():
12✔
781
                    fields, strings = self._resolve(rdata)
12✔
782
                    lookup_map.setdefault(lmodel, {}) \
12✔
783
                        .setdefault(lfield, {})[rmodel] = (fields, strings)
784
                    path_map.setdefault(lmodel, set()).update(strings)
12✔
785

786
        # translate paths to model local fields and filter for fk fields
787
        fk_map: IFkMap = {}
12✔
788
        for model, paths in path_map.items():
12✔
789
            value = self._get_fk_fields(model, paths)
12✔
790
            if value:
12✔
791
                fk_map[model] = value
12✔
792

793
        return lookup_map, fk_map
12✔
794

795
    def prepare_modelgraphs(self) -> None:
12✔
796
        """
797
        Helper to initialize model local subgraphs.
798
        """
799
        if self.modelgraphs:
12✔
800
            return
12✔
801
        data = self.resolved['localDeps']
12✔
802
        for model, local_deps in data.items():
12✔
803
            model_graph = ModelGraph(model, local_deps, self._computed_models[model])
12✔
804
            model_graph.transitive_reduction()  # modelgraph always must be cyclefree
12✔
805
            self.modelgraphs[model] = model_graph
12✔
806

807
    def generate_local_mro_map(self) -> ILocalMroMap:
12✔
808
        """
809
        Generate model local computed fields mro maps.
810
        Returns a mapping of models with local computed fields dependencies and their
811
        `mro`, example:
812

813
        .. code:: python
814

815
            {
816
                modelX: {
817
                    'base': ['c1', 'c2', 'c3'],
818
                    'fields': {
819
                        'name': ['c2', 'c3'],
820
                        'c2': ['c2', 'c3']
821
                    }
822
                }
823
            }
824

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

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

832
        .. NOTE::
833
            Note that the actual values in `fields` are bitarrays to index positions of `base`,
834
            which allows quick field update merges at runtime by doing binary OR on the bitarrays.
835
        """
836
        self.prepare_modelgraphs()
12✔
837
        return dict(
12✔
838
            (model, g.generate_local_mapping(g.generate_field_paths(g.get_topological_paths())))
839
            for model, g in self.modelgraphs.items()
840
        )
841

842
    def get_uniongraph(self) -> Graph:
12✔
843
        """
844
        Build a union graph of intermodel dependencies and model local dependencies.
845
        This graph represents the final update cascades triggered by certain field updates.
846
        The union graph is needed to spot cycles introduced by model local dependencies,
847
        that otherwise might went unnoticed, example:
848

849
        - global dep graph (acyclic):  ``A.comp --> B.comp, B.comp2 --> A.comp``
850
        - modelgraph of B  (acyclic):  ``B.comp --> B.comp2``
851

852
        Here the resulting union graph is not a DAG anymore, since both subgraphs short-circuit
853
        to a cycle of ``A.comp --> B.comp --> B.comp2 --> A.comp``.
854
        """
855
        if not self.union:
12✔
856
            graph = Graph()
12✔
857
            # copy intermodel edges
858
            for edge in self.edges:
12✔
859
                graph.add_edge(edge)
12✔
860
            # copy modelgraph edges
861
            self.prepare_modelgraphs()
12✔
862
            for model, modelgraph in self.modelgraphs.items():
12✔
863
                name = modelname(model)
12✔
864
                for edge in modelgraph.edges:
12✔
865
                    graph.add_edge(Edge(
12✔
866
                        Node((name, edge.left.data)),
867
                        Node((name, edge.right.data))
868
                    ))
869
            self.union = graph
12✔
870
        return self.union
12✔
871

872

873
class ModelGraph(Graph):
12✔
874
    """
875
    Graph to resolve model local computed field dependencies in right calculation order.
876
    """
877

878
    def __init__(
12✔
879
            self,
880
            model: Type[Model],
881
            local_dependencies: Dict[str, Set[str]],
882
            computed_fields: Dict[str, IComputedField]
883
    ):
884
        super(ModelGraph, self).__init__()
12✔
885
        self.model = model
12✔
886

887
        # add all edges from extracted local deps
888
        for right, deps in local_dependencies.items():
12✔
889
            for left in deps:
12✔
890
                self.add_edge(Edge(Node(left), Node(right)))
12✔
891

892
        # add ## node as update_fields=None placeholder with edges to all computed fields
893
        # --> None explicitly updates all computed fields
894
        # Note: this has to be on all cfs to not skip a non local dependent one by accident
895
        left_all = Node('##')
12✔
896
        for computed in computed_fields:
12✔
897
            self.add_edge(Edge(left_all, Node(computed)))
12✔
898

899
    def transitive_reduction(self) -> None:
12✔
900
        """
901
        Remove redundant single edges. Also checks for cycles.
902
        *Note:* Other than intermodel dependencies local dependencies must always be cyclefree.
903
        """
904
        paths: List[List[Edge]] = self.get_edgepaths()
12✔
905
        remove: Set[Edge] = set()
12✔
906
        for path1 in paths:
12✔
907
            # we only cut single edge paths
908
            if len(path1) > 1:
12✔
909
                continue
12✔
910
            left: Node = path1[0].left
12✔
911
            right: Node = path1[-1].right
12✔
912
            for path2 in paths:
12✔
913
                if path2 == path1:
12✔
914
                    continue
12✔
915
                if right == path2[-1].right and left == path2[0].left:
12✔
916
                    remove.add(path1[0])
12✔
917
        for edge in remove:
12✔
918
            self.remove_edge(edge)
12✔
919

920
    def _tsort(
12✔
921
            self,
922
            graph: Dict[Node, List[Node]],
923
            start: Node,
924
            paths: Dict[Node, List[Node]],
925
            path: List[Node]
926
    ):
927
        """
928
        Recursive deep first search variant of topsort.
929
        Also accumulates any revealed subpaths.
930
        """
931
        for node in graph.get(start, []):
12✔
932
            if not node in paths:
12✔
933
                # accumulate revealed topological subpaths
934
                paths[node] = self._tsort(graph, node, paths, [])
12✔
935
            for snode in paths[node]:
12✔
936
                # append node if its not yet part of the path
937
                if not snode in path:
12✔
938
                    path += [snode]
12✔
939
        path += [start]
12✔
940
        return path
12✔
941

942
    def get_topological_paths(self) -> Dict[Node, List[Node]]:
12✔
943
        """
944
        Creates a map of all possible entry nodes and their topological update path
945
        (computed fields mro).
946
        """
947
        # create simplified parent-child relation graph
948
        graph: Dict[Node, List[Node]] = {}
12✔
949
        for edge in self.edges:
12✔
950
            graph.setdefault(edge.left, []).append(edge.right)
12✔
951
        topological_paths: Dict[Node, List[Node]] = {}
12✔
952

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

957
        # we still need to reveal concrete field deps
958
        # other than for cfs we also strip last entry (the field itself)
959
        for node in graph:
12✔
960
            if node in topological_paths:
12✔
961
                continue
12✔
962
            topological_paths[node] = self._tsort(graph, node, topological_paths, [])[:-1]
12✔
963

964
        # reverse all tpaths
965
        for entry, path in topological_paths.items():
12✔
966
            topological_paths[entry] = path[::-1]
12✔
967

968
        return topological_paths
12✔
969

970
    def generate_field_paths(self, tpaths: Dict[Node, List[Node]]) -> Dict[str, List[str]]:
12✔
971
        """
972
        Convert topological path node mapping into a mapping containing the fieldnames.
973
        """
974
        field_paths: Dict[str, List[str]] = {}
12✔
975
        for node, path in tpaths.items():
12✔
976
            field_paths[node.data] = [el.data for el in path]
12✔
977
        return field_paths
12✔
978

979
    def generate_local_mapping(self, field_paths: Dict[str, List[str]]) -> ILocalMroData:
12✔
980
        """
981
        Generates the final model local update table to be used during ``ComputedFieldsModel.save``.
982
        Output is a mapping of local fields, that also update local computed fields, to a bitarray
983
        containing the computed fields mro, and the base topologcial order for a full update.
984
        """
985
        # transfer final data to bitarray
986
        # bitarray: bit index is realisation of one valid full topsort order held in '##'
987
        # since this order was build from full graph it always reflects the correct mro
988
        # even for a subset of fields in update_fields later on
989
        # properties:
990
        #   - length is number of computed fields on model
991
        #   - True indicates execution of associated function at position in topological order
992
        #   - bitarray is int, field update rules can be added by bitwise OR at save time
993
        # example:
994
        #   - edges: [name, c_a], [c_a, c_b], [c_c, c_b], [z, c_c]
995
        #   - topological order of cfs: {1: c_a, 2: c_c, 4: c_b}
996
        #   - field deps:   name  : c_a, c_b    --> 0b101
997
        #                   c_a   : c_a, c_b    --> 0b101
998
        #                   c_c   : c_c, c_b    --> 0b110
999
        #                   z     : c_c, c_b    --> 0b110
1000
        #   - update mro:   [name, z]   --> 0b101 | 0b110 --> [c_a, c_c, c_b]
1001
        #                   [c_a, c_b]  --> 0b101 | 0b110 --> [c_a, c_c, c_b]
1002
        binary: Dict[str, int] = {}
12✔
1003
        base = field_paths['##']
12✔
1004
        for field, deps in field_paths.items():
12✔
1005
            if field == '##':
12✔
1006
                continue
12✔
1007
            binary[field] = 0
12✔
1008
            for pos, name in enumerate(base):
12✔
1009
                binary[field] |= (1 if name in deps else 0) << pos
12✔
1010
        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