django-cte 2.0.1.dev20251120124810__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
django_cte/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cte import CTE, with_cte, CTEManager, CTEQuerySet, With # noqa
2
+
3
+ __version__ = "2.0.1.dev20251120124810"
4
+ __all__ = ["CTE", "with_cte"]
@@ -0,0 +1,138 @@
1
+ try:
2
+ from warnings import deprecated
3
+ except ImportError:
4
+ from warnings import warn
5
+
6
+ # Copied from Python 3.13, lightly modified for Python 3.9 compatibility.
7
+ # Can be removed when the oldest supported Python version is 3.13.
8
+ class deprecated:
9
+ """Indicate that a class, function or overload is deprecated.
10
+
11
+ When this decorator is applied to an object, the type checker
12
+ will generate a diagnostic on usage of the deprecated object.
13
+
14
+ Usage:
15
+
16
+ @deprecated("Use B instead")
17
+ class A:
18
+ pass
19
+
20
+ @deprecated("Use g instead")
21
+ def f():
22
+ pass
23
+
24
+ @overload
25
+ @deprecated("int support is deprecated")
26
+ def g(x: int) -> int: ...
27
+ @overload
28
+ def g(x: str) -> int: ...
29
+
30
+ The warning specified by *category* will be emitted at runtime
31
+ on use of deprecated objects. For functions, that happens on calls;
32
+ for classes, on instantiation and on creation of subclasses.
33
+ If the *category* is ``None``, no warning is emitted at runtime.
34
+ The *stacklevel* determines where the
35
+ warning is emitted. If it is ``1`` (the default), the warning
36
+ is emitted at the direct caller of the deprecated object; if it
37
+ is higher, it is emitted further up the stack.
38
+ Static type checker behavior is not affected by the *category*
39
+ and *stacklevel* arguments.
40
+
41
+ The deprecation message passed to the decorator is saved in the
42
+ ``__deprecated__`` attribute on the decorated object.
43
+ If applied to an overload, the decorator
44
+ must be after the ``@overload`` decorator for the attribute to
45
+ exist on the overload as returned by ``get_overloads()``.
46
+
47
+ See PEP 702 for details.
48
+
49
+ """
50
+ def __init__(
51
+ self,
52
+ message: str,
53
+ /,
54
+ *,
55
+ category=DeprecationWarning,
56
+ stacklevel=1,
57
+ ):
58
+ if not isinstance(message, str):
59
+ raise TypeError(
60
+ f"Expected an object of type str for 'message', not {type(message).__name__!r}"
61
+ )
62
+ self.message = message
63
+ self.category = category
64
+ self.stacklevel = stacklevel
65
+
66
+ def __call__(self, arg, /):
67
+ # Make sure the inner functions created below don't
68
+ # retain a reference to self.
69
+ msg = self.message
70
+ category = self.category
71
+ stacklevel = self.stacklevel
72
+ if category is None:
73
+ arg.__deprecated__ = msg
74
+ return arg
75
+ elif isinstance(arg, type):
76
+ import functools
77
+ from types import MethodType
78
+
79
+ original_new = arg.__new__
80
+
81
+ @functools.wraps(original_new)
82
+ def __new__(cls, /, *args, **kwargs):
83
+ if cls is arg:
84
+ warn(msg, category=category, stacklevel=stacklevel + 1)
85
+ if original_new is not object.__new__:
86
+ return original_new(cls, *args, **kwargs)
87
+ # Mirrors a similar check in object.__new__.
88
+ elif cls.__init__ is object.__init__ and (args or kwargs):
89
+ raise TypeError(f"{cls.__name__}() takes no arguments")
90
+ else:
91
+ return original_new(cls)
92
+
93
+ arg.__new__ = staticmethod(__new__)
94
+
95
+ original_init_subclass = arg.__init_subclass__
96
+ # We need slightly different behavior if __init_subclass__
97
+ # is a bound method (likely if it was implemented in Python)
98
+ if isinstance(original_init_subclass, MethodType):
99
+ original_init_subclass = original_init_subclass.__func__
100
+
101
+ @functools.wraps(original_init_subclass)
102
+ def __init_subclass__(*args, **kwargs):
103
+ warn(msg, category=category, stacklevel=stacklevel + 1)
104
+ return original_init_subclass(*args, **kwargs)
105
+
106
+ arg.__init_subclass__ = classmethod(__init_subclass__)
107
+ # Or otherwise, which likely means it's a builtin such as
108
+ # object's implementation of __init_subclass__.
109
+ else:
110
+ @functools.wraps(original_init_subclass)
111
+ def __init_subclass__(*args, **kwargs):
112
+ warn(msg, category=category, stacklevel=stacklevel + 1)
113
+ return original_init_subclass(*args, **kwargs)
114
+
115
+ arg.__init_subclass__ = __init_subclass__
116
+
117
+ arg.__deprecated__ = __new__.__deprecated__ = msg
118
+ __init_subclass__.__deprecated__ = msg
119
+ return arg
120
+ elif callable(arg):
121
+ import functools
122
+ import inspect
123
+
124
+ @functools.wraps(arg)
125
+ def wrapper(*args, **kwargs):
126
+ warn(msg, category=category, stacklevel=stacklevel + 1)
127
+ return arg(*args, **kwargs)
128
+
129
+ if inspect.iscoroutinefunction(arg):
130
+ wrapper = inspect.markcoroutinefunction(wrapper)
131
+
132
+ arg.__deprecated__ = wrapper.__deprecated__ = msg
133
+ return wrapper
134
+ else:
135
+ raise TypeError(
136
+ "@deprecated decorator with non-None category must be applied to "
137
+ f"a class or callable, not {arg!r}"
138
+ )
django_cte/cte.py ADDED
@@ -0,0 +1,231 @@
1
+ from copy import copy
2
+
3
+ import django
4
+ from django.db.models import Manager, sql
5
+ from django.db.models.expressions import Ref
6
+ from django.db.models.query import Q, QuerySet, ValuesIterable
7
+ from django.db.models.sql.datastructures import BaseTable
8
+
9
+ from .jitmixin import jit_mixin
10
+ from .join import QJoin, INNER
11
+ from .meta import CTEColumnRef, CTEColumns
12
+ from .query import CTEQuery
13
+ from ._deprecated import deprecated
14
+
15
+ __all__ = ["CTE", "with_cte"]
16
+
17
+
18
+ def with_cte(*ctes, select):
19
+ """Add Common Table Expression(s) (CTEs) to a model or queryset
20
+
21
+ :param *ctes: One or more CTE objects.
22
+ :param select: A model class, queryset, or CTE to use as the base
23
+ query to which CTEs are attached.
24
+ :returns: A queryset with the given CTE added to it.
25
+ """
26
+ if isinstance(select, CTE):
27
+ select = select.queryset()
28
+ elif not isinstance(select, QuerySet):
29
+ select = select._default_manager.all()
30
+ jit_mixin(select.query, CTEQuery)
31
+ select.query._with_ctes += ctes
32
+ return select
33
+
34
+
35
+ class CTE:
36
+ """Common Table Expression
37
+
38
+ :param queryset: A queryset to use as the body of the CTE.
39
+ :param name: Optional name parameter for the CTE (default: "cte").
40
+ This must be a unique name that does not conflict with other
41
+ entities (tables, views, functions, other CTE(s), etc.) referenced
42
+ in the given query as well any query to which this CTE will
43
+ eventually be added.
44
+ :param materialized: Optional parameter (default: False) which enforce
45
+ using of MATERIALIZED statement for supporting databases.
46
+ """
47
+
48
+ def __init__(self, queryset, name="cte", materialized=False):
49
+ self._set_queryset(queryset)
50
+ self.name = name
51
+ self.col = CTEColumns(self)
52
+ self.materialized = materialized
53
+
54
+ def __getstate__(self):
55
+ return (self.query, self.name, self.materialized, self._iterable_class)
56
+
57
+ def __setstate__(self, state):
58
+ if len(state) == 3:
59
+ # Keep compatibility with the previous serialization method
60
+ self.query, self.name, self.materialized = state
61
+ self._iterable_class = ValuesIterable
62
+ else:
63
+ self.query, self.name, self.materialized, self._iterable_class = state
64
+ self.col = CTEColumns(self)
65
+
66
+ def __repr__(self):
67
+ return f"<{type(self).__name__} {self.name}>"
68
+
69
+ def _set_queryset(self, queryset):
70
+ self.query = None if queryset is None else queryset.query
71
+ self._iterable_class = getattr(queryset, "_iterable_class", ValuesIterable)
72
+
73
+ @classmethod
74
+ def recursive(cls, make_cte_queryset, name="cte", materialized=False):
75
+ """Recursive Common Table Expression
76
+
77
+ :param make_cte_queryset: Function taking a single argument (a
78
+ not-yet-fully-constructed cte object) and returning a `QuerySet`
79
+ object. The returned `QuerySet` normally consists of an initial
80
+ statement unioned with a recursive statement.
81
+ :param name: See `name` parameter of `__init__`.
82
+ :param materialized: See `materialized` parameter of `__init__`.
83
+ :returns: The fully constructed recursive cte object.
84
+ """
85
+ cte = cls(None, name, materialized)
86
+ cte._set_queryset(make_cte_queryset(cte))
87
+ return cte
88
+
89
+ def join(self, model_or_queryset, *filter_q, **filter_kw):
90
+ """Join this CTE to the given model or queryset
91
+
92
+ This CTE will be referenced by the returned queryset, but the
93
+ corresponding `WITH ...` statement will not be prepended to the
94
+ queryset's SQL output; use `with_cte(cte, select=cte.join(...))`
95
+ to achieve that outcome.
96
+
97
+ :param model_or_queryset: Model class or queryset to which the
98
+ CTE should be joined.
99
+ :param *filter_q: Join condition Q expressions (optional).
100
+ :param **filter_kw: Join conditions. All LHS fields (kwarg keys)
101
+ are assumed to reference `model_or_queryset` fields. Use
102
+ `cte.col.name` on the RHS to recursively reference CTE query
103
+ columns. For example: `cte.join(Book, id=cte.col.id)`
104
+ :returns: A queryset with the given model or queryset joined to
105
+ this CTE.
106
+ """
107
+ if isinstance(model_or_queryset, QuerySet):
108
+ queryset = model_or_queryset.all()
109
+ else:
110
+ queryset = model_or_queryset._default_manager.all()
111
+ join_type = filter_kw.pop("_join_type", INNER)
112
+ query = queryset.query
113
+
114
+ # based on Query.add_q: add necessary joins to query, but no filter
115
+ q_object = Q(*filter_q, **filter_kw)
116
+ map = query.alias_map
117
+ existing_inner = set(a for a in map if map[a].join_type == INNER)
118
+ if django.VERSION >= (5, 2):
119
+ on_clause, _ = query._add_q(
120
+ q_object, query.used_aliases, update_join_types=(join_type == INNER)
121
+ )
122
+ else:
123
+ on_clause, _ = query._add_q(q_object, query.used_aliases)
124
+ query.demote_joins(existing_inner)
125
+
126
+ parent = query.get_initial_alias()
127
+ query.join(QJoin(parent, self.name, self.name, on_clause, join_type))
128
+ return queryset
129
+
130
+ def queryset(self):
131
+ """Get a queryset selecting from this CTE
132
+
133
+ This CTE will be referenced by the returned queryset, but the
134
+ corresponding `WITH ...` statement will not be prepended to the
135
+ queryset's SQL output; use `with_cte(cte, select=cte)` to do
136
+ that.
137
+
138
+ :returns: A queryset.
139
+ """
140
+ cte_query = self.query
141
+ qs = cte_query.model._default_manager.get_queryset()
142
+ qs._iterable_class = self._iterable_class
143
+ qs._fields = () # Allow any field names to be used in further annotations
144
+
145
+ query = jit_mixin(sql.Query(cte_query.model), CTEQuery)
146
+ query.join(BaseTable(self.name, None))
147
+ query.default_cols = cte_query.default_cols
148
+ query.deferred_loading = cte_query.deferred_loading
149
+
150
+ if django.VERSION < (5, 2) and cte_query.values_select:
151
+ query.set_values(cte_query.values_select)
152
+
153
+ if cte_query.annotations:
154
+ for alias, value in cte_query.annotations.items():
155
+ col = CTEColumnRef(alias, self.name, value.output_field)
156
+ query.add_annotation(col, alias)
157
+ query.annotation_select_mask = cte_query.annotation_select_mask
158
+
159
+ if selected := getattr(cte_query, "selected", None):
160
+ for alias in selected:
161
+ if alias not in cte_query.annotations:
162
+ output_field = cte_query.resolve_ref(alias).output_field
163
+ col = CTEColumnRef(alias, self.name, output_field)
164
+ query.add_annotation(col, alias)
165
+ query.selected = {alias: alias for alias in selected}
166
+
167
+ qs.query = query
168
+ return qs
169
+
170
+ def _resolve_ref(self, name):
171
+ selected = getattr(self.query, "selected", None)
172
+ if selected and name in selected and name not in self.query.annotations:
173
+ return Ref(name, self.query.resolve_ref(name))
174
+ return self.query.resolve_ref(name)
175
+
176
+ def resolve_expression(self, *args, **kw):
177
+ if self.query is None:
178
+ raise ValueError("Cannot resolve recursive CTE without a query.")
179
+ clone = copy(self)
180
+ clone.query = clone.query.resolve_expression(*args, **kw)
181
+ return clone
182
+
183
+
184
+ @deprecated("Use `django_cte.CTE` instead.")
185
+ class With(CTE):
186
+
187
+ @staticmethod
188
+ @deprecated("Use `django_cte.CTE.recursive` instead.")
189
+ def recursive(*args, **kw):
190
+ return CTE.recursive(*args, **kw)
191
+
192
+
193
+ @deprecated("CTEQuerySet is deprecated. "
194
+ "CTEs can now be applied to any queryset using `with_cte()`")
195
+ class CTEQuerySet(QuerySet):
196
+ """QuerySet with support for Common Table Expressions"""
197
+
198
+ def __init__(self, model=None, query=None, using=None, hints=None):
199
+ # Only create an instance of a Query if this is the first invocation in
200
+ # a query chain.
201
+ super(CTEQuerySet, self).__init__(model, query, using, hints)
202
+ jit_mixin(self.query, CTEQuery)
203
+
204
+ @deprecated("Use `django_cte.with_cte(cte, select=...)` instead.")
205
+ def with_cte(self, cte):
206
+ qs = self._clone()
207
+ qs.query._with_ctes += cte,
208
+ return qs
209
+
210
+ def as_manager(cls):
211
+ # Address the circular dependency between
212
+ # `CTEQuerySet` and `CTEManager`.
213
+ manager = CTEManager.from_queryset(cls)()
214
+ manager._built_with_as_manager = True
215
+ return manager
216
+ as_manager.queryset_only = True
217
+ as_manager = classmethod(as_manager)
218
+
219
+
220
+ @deprecated("CTEMAnager is deprecated. "
221
+ "CTEs can now be applied to any queryset using `with_cte()`")
222
+ class CTEManager(Manager.from_queryset(CTEQuerySet)):
223
+ """Manager for models that perform CTE queries"""
224
+
225
+ @classmethod
226
+ def from_queryset(cls, queryset_class, class_name=None):
227
+ if not issubclass(queryset_class, CTEQuerySet):
228
+ raise TypeError(
229
+ "models with CTE support need to use a CTEQuerySet")
230
+ return super(CTEManager, cls).from_queryset(
231
+ queryset_class, class_name=class_name)
django_cte/jitmixin.py ADDED
@@ -0,0 +1,28 @@
1
+ def jit_mixin(obj, mixin):
2
+ """Apply mixin to object and return the object"""
3
+ if not isinstance(obj, mixin):
4
+ obj.__class__ = jit_mixin_type(obj.__class__, mixin)
5
+ return obj
6
+
7
+
8
+ def jit_mixin_type(base, *mixins):
9
+ assert not issubclass(base, mixins), (base, mixins)
10
+ mixed = _mixin_cache.get((base, mixins))
11
+ if mixed is None:
12
+ prefix = "".join(m._jit_mixin_prefix for m in mixins)
13
+ name = f"{prefix}{base.__name__}"
14
+ mixed = _mixin_cache[(base, mixins)] = type(name, (*mixins, base), {
15
+ "_jit_mixin_base": getattr(base, "_jit_mixin_base", base),
16
+ "_jit_mixins": mixins + getattr(base, "_jit_mixins", ()),
17
+ })
18
+ return mixed
19
+
20
+
21
+ _mixin_cache = {}
22
+
23
+
24
+ class JITMixin:
25
+
26
+ def __reduce__(self):
27
+ # make JITMixin classes pickleable
28
+ return (jit_mixin_type, (self._jit_mixin_base, *self._jit_mixins))
django_cte/join.py ADDED
@@ -0,0 +1,91 @@
1
+ from django.db.models.sql.constants import INNER
2
+
3
+
4
+ class QJoin:
5
+ """Join clause with join condition from Q object clause
6
+
7
+ :param parent_alias: Alias of parent table.
8
+ :param table_name: Name of joined table.
9
+ :param table_alias: Alias of joined table.
10
+ :param on_clause: Query `where_class` instance represenging the ON clause.
11
+ :param join_type: Join type (INNER or LOUTER).
12
+ """
13
+
14
+ filtered_relation = None
15
+
16
+ def __init__(self, parent_alias, table_name, table_alias,
17
+ on_clause, join_type=INNER, nullable=None):
18
+ self.parent_alias = parent_alias
19
+ self.table_name = table_name
20
+ self.table_alias = table_alias
21
+ self.on_clause = on_clause
22
+ self.join_type = join_type # LOUTER or INNER
23
+ self.nullable = join_type != INNER if nullable is None else nullable
24
+
25
+ @property
26
+ def identity(self):
27
+ return (
28
+ self.__class__,
29
+ self.table_name,
30
+ self.parent_alias,
31
+ self.join_type,
32
+ self.on_clause,
33
+ )
34
+
35
+ def __hash__(self):
36
+ return hash(self.identity)
37
+
38
+ def __eq__(self, other):
39
+ if not isinstance(other, QJoin):
40
+ return NotImplemented
41
+ return self.identity == other.identity
42
+
43
+ def equals(self, other):
44
+ return self.identity == other.identity
45
+
46
+ def as_sql(self, compiler, connection):
47
+ """Generate join clause SQL"""
48
+ on_clause_sql, params = self.on_clause.as_sql(compiler, connection)
49
+ if self.table_alias == self.table_name:
50
+ alias = ''
51
+ else:
52
+ alias = ' %s' % self.table_alias
53
+ qn = compiler.quote_name_unless_alias
54
+ sql = '%s %s%s ON %s' % (
55
+ self.join_type,
56
+ qn(self.table_name),
57
+ alias,
58
+ on_clause_sql
59
+ )
60
+ return sql, params
61
+
62
+ def relabeled_clone(self, change_map):
63
+ return self.__class__(
64
+ parent_alias=change_map.get(self.parent_alias, self.parent_alias),
65
+ table_name=self.table_name,
66
+ table_alias=change_map.get(self.table_alias, self.table_alias),
67
+ on_clause=self.on_clause.relabeled_clone(change_map),
68
+ join_type=self.join_type,
69
+ nullable=self.nullable,
70
+ )
71
+
72
+ class join_field:
73
+ # `Join.join_field` is used internally by `Join` as well as in
74
+ # `QuerySet.resolve_expression()`:
75
+ #
76
+ # isinstance(table, Join)
77
+ # and table.join_field.related_model._meta.db_table != alias
78
+ #
79
+ # Currently that does not apply here since `QJoin` is not an
80
+ # instance of `Join`, although maybe it should? Maybe this
81
+ # should have `related_model._meta.db_table` return
82
+ # `<QJoin>.table_name` or `<QJoin>.table_alias`?
83
+ #
84
+ # `PathInfo.join_field` is another similarly named attribute in
85
+ # Django that has a much more complicated interface, but luckily
86
+ # seems unrelated to `Join.join_field`.
87
+
88
+ class related_model:
89
+ class _meta:
90
+ # for QuerySet.set_group_by(allow_aliases=True)
91
+ local_concrete_fields = ()
django_cte/meta.py ADDED
@@ -0,0 +1,112 @@
1
+ import weakref
2
+
3
+ from django.db.models.expressions import Col, Expression
4
+
5
+
6
+ class CTEColumns:
7
+
8
+ def __init__(self, cte):
9
+ self._cte = weakref.ref(cte)
10
+
11
+ def __getattr__(self, name):
12
+ return CTEColumn(self._cte(), name)
13
+
14
+
15
+ class CTEColumn(Expression):
16
+
17
+ def __init__(self, cte, name, output_field=None):
18
+ self._cte = cte
19
+ self.table_alias = cte.name
20
+ self.name = self.alias = name
21
+ self._output_field = output_field
22
+
23
+ def __repr__(self):
24
+ return "<{} {}.{}>".format(
25
+ self.__class__.__name__,
26
+ self._cte.name,
27
+ self.name,
28
+ )
29
+
30
+ @property
31
+ def _ref(self):
32
+ if self._cte.query is None:
33
+ raise ValueError(
34
+ "cannot resolve '{cte}.{name}' in recursive CTE setup. "
35
+ "Hint: use ExpressionWrapper({cte}.col.{name}, "
36
+ "output_field=...)".format(cte=self._cte.name, name=self.name)
37
+ )
38
+ ref = self._cte._resolve_ref(self.name)
39
+ if ref is self or self in ref.get_source_expressions():
40
+ raise ValueError("Circular reference: {} = {}".format(self, ref))
41
+ return ref
42
+
43
+ @property
44
+ def target(self):
45
+ return self._ref.target
46
+
47
+ @property
48
+ def output_field(self):
49
+ # required to fix error caused by django commit
50
+ # 9d519d3dc4e5bd1d9ff3806b44624c3e487d61c1
51
+ if self._cte.query is None:
52
+ raise AttributeError
53
+
54
+ if self._output_field is not None:
55
+ return self._output_field
56
+ return self._ref.output_field
57
+
58
+ def as_sql(self, compiler, connection):
59
+ qn = compiler.quote_name_unless_alias
60
+ ref = self._ref
61
+ if isinstance(ref, Col) and self.name == "pk":
62
+ column = ref.target.column
63
+ else:
64
+ column = self.name
65
+ return "%s.%s" % (qn(self.table_alias), qn(column)), []
66
+
67
+ def relabeled_clone(self, relabels):
68
+ if self.table_alias is not None and self.table_alias in relabels:
69
+ clone = self.copy()
70
+ clone.table_alias = relabels[self.table_alias]
71
+ return clone
72
+ return self
73
+
74
+
75
+ class CTEColumnRef(Expression):
76
+
77
+ def __init__(self, name, cte_name, output_field):
78
+ self.name = name
79
+ self.cte_name = cte_name
80
+ self.output_field = output_field
81
+ self._alias = None
82
+
83
+ def resolve_expression(self, query=None, allow_joins=True, reuse=None,
84
+ summarize=False, for_save=False):
85
+ if query:
86
+ clone = self.copy()
87
+ clone._alias = self._alias or query.table_map.get(
88
+ self.cte_name, [self.cte_name])[0]
89
+ return clone
90
+ return super().resolve_expression(
91
+ query, allow_joins, reuse, summarize, for_save)
92
+
93
+ def relabeled_clone(self, change_map):
94
+ if (
95
+ self.cte_name not in change_map
96
+ and self._alias not in change_map
97
+ ):
98
+ return super().relabeled_clone(change_map)
99
+
100
+ clone = self.copy()
101
+ if self.cte_name in change_map:
102
+ clone._alias = change_map[self.cte_name]
103
+
104
+ if self._alias in change_map:
105
+ clone._alias = change_map[self._alias]
106
+ return clone
107
+
108
+ def as_sql(self, compiler, connection):
109
+ qn = compiler.quote_name_unless_alias
110
+ table = self._alias or compiler.query.table_map.get(
111
+ self.cte_name, [self.cte_name])[0]
112
+ return "%s.%s" % (qn(table), qn(self.name)), []
django_cte/query.py ADDED
@@ -0,0 +1,168 @@
1
+ import django
2
+ from django.core.exceptions import EmptyResultSet
3
+ from django.db.models.sql.constants import LOUTER
4
+
5
+ from .jitmixin import JITMixin, jit_mixin
6
+ from .join import QJoin
7
+
8
+ # NOTE: it is currently not possible to execute delete queries that
9
+ # reference CTEs without patching `QuerySet.delete` (Django method)
10
+ # to call `self.query.chain(sql.DeleteQuery)` instead of
11
+ # `sql.DeleteQuery(self.model)`
12
+
13
+
14
+ class CTEQuery(JITMixin):
15
+ """A Query mixin that processes SQL compilation through a CTE compiler"""
16
+ _jit_mixin_prefix = "CTE"
17
+ _with_ctes = ()
18
+
19
+ @property
20
+ def combined_queries(self):
21
+ return self.__dict__.get("combined_queries", ())
22
+
23
+ @combined_queries.setter
24
+ def combined_queries(self, queries):
25
+ ctes = []
26
+ seen = {cte.name: cte for cte in self._with_ctes}
27
+ for query in queries:
28
+ for cte in getattr(query, "_with_ctes", ()):
29
+ if seen.get(cte.name) is cte:
30
+ continue
31
+ if cte.name in seen:
32
+ raise ValueError(
33
+ f"Found two or more CTEs named '{cte.name}'. "
34
+ "Hint: assign a unique name to each CTE."
35
+ )
36
+ ctes.append(cte)
37
+ seen[cte.name] = cte
38
+
39
+ if seen:
40
+ def without_ctes(query):
41
+ if getattr(query, "_with_ctes", None):
42
+ query = query.clone()
43
+ del query._with_ctes
44
+ return query
45
+
46
+ self._with_ctes += tuple(ctes)
47
+ queries = tuple(without_ctes(q) for q in queries)
48
+ self.__dict__["combined_queries"] = queries
49
+
50
+ def resolve_expression(self, *args, **kwargs):
51
+ clone = super().resolve_expression(*args, **kwargs)
52
+ clone._with_ctes = tuple(
53
+ cte.resolve_expression(*args, **kwargs)
54
+ for cte in clone._with_ctes
55
+ )
56
+ return clone
57
+
58
+ def get_compiler(self, *args, **kwargs):
59
+ return jit_mixin(super().get_compiler(*args, **kwargs), CTECompiler)
60
+
61
+ def chain(self, klass=None):
62
+ clone = jit_mixin(super().chain(klass), CTEQuery)
63
+ clone._with_ctes = self._with_ctes
64
+ return clone
65
+
66
+
67
+ def generate_cte_sql(connection, query, as_sql):
68
+ if not query._with_ctes:
69
+ return as_sql()
70
+
71
+ ctes = []
72
+ params = []
73
+ for cte in query._with_ctes:
74
+ if django.VERSION > (4, 2):
75
+ _ignore_with_col_aliases(cte.query)
76
+
77
+ alias = query.alias_map.get(cte.name)
78
+ should_elide_empty = (
79
+ not isinstance(alias, QJoin) or alias.join_type != LOUTER
80
+ )
81
+
82
+ compiler = cte.query.get_compiler(
83
+ connection=connection, elide_empty=should_elide_empty
84
+ )
85
+
86
+ qn = compiler.quote_name_unless_alias
87
+ try:
88
+ cte_sql, cte_params = compiler.as_sql()
89
+ except EmptyResultSet:
90
+ # If the CTE raises an EmptyResultSet the SqlCompiler still
91
+ # needs to know the information about this base compiler
92
+ # like, col_count and klass_info.
93
+ as_sql()
94
+ raise
95
+ template = get_cte_query_template(cte)
96
+ ctes.append(template.format(name=qn(cte.name), query=cte_sql))
97
+ params.extend(cte_params)
98
+
99
+ explain_attribute = "explain_info"
100
+ explain_info = getattr(query, explain_attribute, None)
101
+ explain_format = getattr(explain_info, "format", None)
102
+ explain_options = getattr(explain_info, "options", {})
103
+
104
+ explain_query_or_info = getattr(query, explain_attribute, None)
105
+ sql = []
106
+ if explain_query_or_info:
107
+ sql.append(
108
+ connection.ops.explain_query_prefix(
109
+ explain_format,
110
+ **explain_options
111
+ )
112
+ )
113
+ # this needs to get set to None so that the base as_sql() doesn't
114
+ # insert the EXPLAIN statement where it would end up between the
115
+ # WITH ... clause and the final SELECT
116
+ setattr(query, explain_attribute, None)
117
+
118
+ if ctes:
119
+ # Always use WITH RECURSIVE
120
+ # https://www.postgresql.org/message-id/13122.1339829536%40sss.pgh.pa.us
121
+ sql.extend(["WITH RECURSIVE", ", ".join(ctes)])
122
+ base_sql, base_params = as_sql()
123
+
124
+ if explain_query_or_info:
125
+ setattr(query, explain_attribute, explain_query_or_info)
126
+
127
+ sql.append(base_sql)
128
+ params.extend(base_params)
129
+ return " ".join(sql), tuple(params)
130
+
131
+
132
+ def get_cte_query_template(cte):
133
+ if cte.materialized:
134
+ return "{name} AS MATERIALIZED ({query})"
135
+ return "{name} AS ({query})"
136
+
137
+
138
+ def _ignore_with_col_aliases(cte_query):
139
+ if getattr(cte_query, "combined_queries", None):
140
+ cte_query.combined_queries = tuple(
141
+ jit_mixin(q, NoAliasQuery) for q in cte_query.combined_queries
142
+ )
143
+
144
+
145
+ class CTECompiler(JITMixin):
146
+ """Mixin for django.db.models.sql.compiler.SQLCompiler"""
147
+ _jit_mixin_prefix = "CTE"
148
+
149
+ def as_sql(self, *args, **kwargs):
150
+ def _as_sql():
151
+ return super(CTECompiler, self).as_sql(*args, **kwargs)
152
+ return generate_cte_sql(self.connection, self.query, _as_sql)
153
+
154
+
155
+ class NoAliasQuery(JITMixin):
156
+ """Mixin for django.db.models.sql.compiler.Query"""
157
+ _jit_mixin_prefix = "NoAlias"
158
+
159
+ def get_compiler(self, *args, **kwargs):
160
+ return jit_mixin(super().get_compiler(*args, **kwargs), NoAliasCompiler)
161
+
162
+
163
+ class NoAliasCompiler(JITMixin):
164
+ """Mixin for django.db.models.sql.compiler.SQLCompiler"""
165
+ _jit_mixin_prefix = "NoAlias"
166
+
167
+ def get_select(self, *, with_col_aliases=False, **kw):
168
+ return super().get_select(**kw)
django_cte/raw.py ADDED
@@ -0,0 +1,38 @@
1
+ def raw_cte_sql(sql, params, refs):
2
+ """Raw CTE SQL
3
+
4
+ :param sql: SQL query (string).
5
+ :param params: List of bind parameters.
6
+ :param refs: Dict of output fields: `{"name": <Field instance>}`.
7
+ :returns: Object that can be passed to `With`.
8
+ """
9
+
10
+ class raw_cte_ref:
11
+ def __init__(self, output_field):
12
+ self.output_field = output_field
13
+
14
+ def get_source_expressions(self):
15
+ return []
16
+
17
+ class raw_cte_compiler:
18
+
19
+ def __init__(self, connection):
20
+ self.connection = connection
21
+
22
+ def as_sql(self):
23
+ return sql, params
24
+
25
+ def quote_name_unless_alias(self, name):
26
+ return self.connection.ops.quote_name(name)
27
+
28
+ class raw_cte_queryset:
29
+ class query:
30
+ @staticmethod
31
+ def get_compiler(connection, *, elide_empty=None):
32
+ return raw_cte_compiler(connection)
33
+
34
+ @staticmethod
35
+ def resolve_ref(name):
36
+ return raw_cte_ref(refs[name])
37
+
38
+ return raw_cte_queryset
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-cte
3
+ Version: 2.0.1.dev20251120124810
4
+ Summary: Common Table Expressions (CTE) for Django
5
+ Author-email: Daniel Miller <millerdev@gmail.com>
6
+ Requires-Python: >= 3.9
7
+ Description-Content-Type: text/markdown
8
+ License-Expression: BSD-3-Clause
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Framework :: Django
22
+ Classifier: Framework :: Django :: 4
23
+ Classifier: Framework :: Django :: 4.2
24
+ Classifier: Framework :: Django :: 5
25
+ Classifier: Framework :: Django :: 5.1
26
+ Classifier: Framework :: Django :: 5.2
27
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
+ License-File: LICENSE
29
+ Requires-Dist: django
30
+ Project-URL: Home, https://github.com/dimagi/django-cte
31
+
32
+ # Common Table Expressions with Django
33
+
34
+ [![Build Status](https://github.com/dimagi/django-cte/actions/workflows/tests.yml/badge.svg)](https://github.com/dimagi/django-cte/actions/workflows/tests.yml)
35
+ [![PyPI version](https://badge.fury.io/py/django-cte.svg)](https://badge.fury.io/py/django-cte)
36
+
37
+ ## Installation
38
+ ```
39
+ pip install django-cte
40
+ ```
41
+
42
+
43
+ ## Documentation
44
+
45
+ The [django-cte documentation](https://dimagi.github.io/django-cte/) shows how
46
+ to use Common Table Expressions with the Django ORM.
47
+
48
+
49
+ ## Running tests
50
+
51
+ ```
52
+ cd django-cte
53
+ uv sync
54
+
55
+ pytest
56
+ ruff check
57
+
58
+ # To run tests against postgres
59
+ psql -U username -h localhost -p 5432 -c 'create database django_cte;'
60
+ export PG_DB_SETTINGS='{
61
+ "ENGINE":"django.db.backends.postgresql_psycopg2",
62
+ "NAME":"django_cte",
63
+ "USER":"username",
64
+ "PASSWORD":"password",
65
+ "HOST":"localhost",
66
+ "PORT":"5432"}'
67
+
68
+ # WARNING pytest will delete the test_django_cte database if it exists!
69
+ DB_SETTINGS="$PG_DB_SETTINGS" pytest
70
+ ```
71
+
72
+ All feature and bug contributions are expected to be covered by tests.
73
+
74
+
75
+ ## Publishing a new verison to PyPI
76
+
77
+ Push a new tag to Github using the format vX.Y.Z where X.Y.Z matches the version
78
+ in [`__init__.py`](django_cte/__init__.py).
79
+
80
+ A new version is published to https://test.pypi.org/p/django-cte on every
81
+ push to the *main* branch.
82
+
83
+ Publishing is automated with [Github Actions](.github/workflows/pypi.yml).
84
+
@@ -0,0 +1,12 @@
1
+ django_cte/__init__.py,sha256=yZ7JoQz-Vxue4HW_uP6-knUYwqPe75Qx__CEvIvUJNM,141
2
+ django_cte/_deprecated.py,sha256=OAEmWVqnoi-27m2nyrfTIwVwPE1J8VLrfspgRHW-EZI,5623
3
+ django_cte/cte.py,sha256=E3wLUzxDecoNS2vza764hsQvnloFWK2Fan1lK6xfK8Q,9087
4
+ django_cte/jitmixin.py,sha256=JRTRlbBhD8XD0GHwxg8wKdJSbcTv_zUrSyqtOSfgM5M,885
5
+ django_cte/join.py,sha256=2b1iBKFFiqBqYBmX4W9q2RfX17xXjZbbzClpUdbnUtA,3156
6
+ django_cte/meta.py,sha256=gNBtegkvg5CXcEPyt1nZMIF2UlsPMTUPStVJSsRni3I,3491
7
+ django_cte/query.py,sha256=djY7Bknl_NQSd1g6zy8N3fU3h2r8OfpyeJY_Zt9a7fg,5582
8
+ django_cte/raw.py,sha256=6_YDCKBsgXx1oQku4EcyK4qJmnmiyzEeZRxiYdkg8Y8,1045
9
+ django_cte-2.0.1.dev20251120124810.dist-info/licenses/LICENSE,sha256=mkLNw_QhpZ40jBEbuAosqH4ciA3KMrwb8aSYbTmy5gc,1508
10
+ django_cte-2.0.1.dev20251120124810.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
11
+ django_cte-2.0.1.dev20251120124810.dist-info/METADATA,sha256=dW2kH1gGOkV9kM7T7TgtR06QR1o_RKvM9tJxtF_kWzM,2646
12
+ django_cte-2.0.1.dev20251120124810.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,24 @@
1
+ Copyright (c) 2018, Dimagi Inc., and individual contributors.
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+ * Redistributions of source code must retain the above copyright
7
+ notice, this list of conditions and the following disclaimer.
8
+ * Redistributions in binary form must reproduce the above copyright
9
+ notice, this list of conditions and the following disclaimer in the
10
+ documentation and/or other materials provided with the distribution.
11
+ * Neither the name Dimagi, nor the names of its contributors, may be used
12
+ to endorse or promote products derived from this software without
13
+ specific prior written permission.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
16
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
17
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL DIMAGI INC. BE LIABLE FOR ANY
19
+ DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
20
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
21
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
22
+ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
23
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
24
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.