File: tree.py

package info (click to toggle)
django-cacheops 7.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 404 kB
  • sloc: python: 3,189; sh: 7; makefile: 4
file content (172 lines) | stat: -rw-r--r-- 6,672 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
from itertools import product
from funcy import group_by, join_with, lcat, lmap, cat

from django.db.models import Subquery
from django.db.models.query import QuerySet
from django.db.models.sql import OR
from django.db.models.sql.datastructures import Join
from django.db.models.sql.query import Query, ExtraWhere
from django.db.models.sql.where import NothingNode
from django.db.models.lookups import Lookup, Exact, In, IsNull
from django.db.models.expressions import BaseExpression, Exists

from .conf import settings
from .invalidation import serializable_fields

# This existed prior to Django 5.2
try:
    from django.db.models.sql.where import SubqueryConstraint
except ImportError:
    class SubqueryConstraint(object):
        pass


def dnfs(qs):
    """
    Converts query condition tree into a DNF of eq conds.
    Separately for each alias.

    Any negations, conditions with lookups other than __exact or __in,
    conditions on joined models and subrequests are ignored.
    __in is converted into = or = or = ...
    """
    SOME = Some()
    SOME_TREE = {frozenset({(None, None, SOME, True)})}

    def negate(term):
        return (term[0], term[1], term[2], not term[3])

    def _dnf(where):
        """
        Constructs DNF of where tree consisting of terms in form:
            (alias, attribute, value, negation)
        meaning `alias.attribute = value`
         or `not alias.attribute = value` if negation is False

        Any conditions other then eq are dropped.
        """
        if isinstance(where, Lookup):
            # If where.lhs don't refer to a field then don't bother
            if not hasattr(where.lhs, 'target'):
                return SOME_TREE
            # Don't bother with complex right hand side either
            if isinstance(where.rhs, (QuerySet, Query, BaseExpression)):
                return SOME_TREE
            # Skip conditions on non-serialized fields
            if where.lhs.target not in serializable_fields(where.lhs.target.model):
                return SOME_TREE

            attname = where.lhs.target.attname
            if isinstance(where, Exact):
                return {frozenset({(where.lhs.alias, attname, where.rhs, True)})}
            elif isinstance(where, IsNull):
                return {frozenset({(where.lhs.alias, attname, None, where.rhs)})}
            elif isinstance(where, In) and len(where.rhs) < settings.CACHEOPS_LONG_DISJUNCTION:
                return {frozenset({(where.lhs.alias, attname, v, True)}) for v in where.rhs}
            else:
                return SOME_TREE
        elif isinstance(where, NothingNode):
            return set()
        elif isinstance(where, (ExtraWhere, SubqueryConstraint, Exists)):
            return SOME_TREE
        elif len(where) == 0:
            return {frozenset()}
        else:
            children_dnfs = lmap(_dnf, where.children)

            if len(children_dnfs) == 0:
                return {frozenset()}
            elif len(children_dnfs) == 1:
                result = children_dnfs[0]
            else:
                # Just unite children joined with OR
                if where.connector == OR:
                    result = set(cat(children_dnfs))
                # Use Cartesian product to AND children
                else:
                    result = {frozenset(cat(conjs)) for conjs in product(*children_dnfs)}

            # Negating and expanding brackets
            if where.negated:
                result = {frozenset(map(negate, conjs)) for conjs in product(*result)}

            return result

    def clean_conj(conj, for_alias):
        conds = {}
        for alias, attname, value, negation in conj:
            # "SOME" conds, negated conds and conds for other aliases should be stripped
            if value is not SOME and negation and alias == for_alias:
                # Conjs with fields eq 2 different values will never cause invalidation
                if attname in conds and conds[attname] != value:
                    return None
                conds[attname] = value
        return conds

    def clean_dnf(tree, aliases):
        cleaned = [clean_conj(conj, alias) for conj in tree for alias in aliases]
        # Remove deleted conjunctions
        cleaned = [conj for conj in cleaned if conj is not None]
        # Any empty conjunction eats up the rest
        # NOTE: a more elaborate DNF reduction is not really needed,
        #       just keep your querysets sane.
        if not all(cleaned):
            return [{}]
        return cleaned

    def add_join_conds(dnf, query):
        from collections import defaultdict

        # A cond on parent (alias, col) means the same cond applies to target and vice a versa
        join_exts = defaultdict(list)
        for alias, join in query.alias_map.items():
            if query.alias_refcount[alias] and isinstance(join, Join):
                for parent_col, target_col in join.join_cols:
                    join_exts[join.parent_alias, parent_col].append((join.table_alias, target_col))
                    join_exts[join.table_alias, target_col].append((join.parent_alias, parent_col))

        if not join_exts:
            return dnf

        return {
            conj | {
                (join_alias, join_col, v, negation)
                for alias, col, v, negation in conj
                for join_alias, join_col in join_exts[alias, col]
            }
            for conj in dnf
        }

    def query_dnf(query):
        def table_for(alias):
            return alias if alias == main_alias else query.alias_map[alias].table_name

        dnf = _dnf(query.where)
        dnf = add_join_conds(dnf, query)

        # NOTE: we exclude content_type as it never changes and will hold dead invalidation info
        main_alias = query.model._meta.db_table
        aliases = {alias for alias, join in query.alias_map.items()
                   if query.alias_refcount[alias]} \
                | {main_alias} - {'django_content_type'}
        tables = group_by(table_for, aliases)
        return {table: clean_dnf(dnf, table_aliases) for table, table_aliases in tables.items()}

    if qs.query.combined_queries:
        dnfs_ = join_with(lcat, (query_dnf(q) for q in qs.query.combined_queries))
    else:
        dnfs_ = query_dnf(qs.query)

    # Add any subqueries used for annotation
    if qs.query.annotations:
        subqueries = (query_dnf(getattr(q, 'query', None))
                      for q in qs.query.annotations.values() if isinstance(q, Subquery))
        dnfs_.update(join_with(lcat, subqueries))

    return dnfs_


class Some:
    def __str__(self):
        return 'SOME'
    __repr__ = __str__