django-cte 1.3.3.dev20250526204410__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 +3 -0
- django_cte/cte.py +166 -0
- django_cte/expressions.py +50 -0
- django_cte/join.py +91 -0
- django_cte/meta.py +112 -0
- django_cte/query.py +222 -0
- django_cte/raw.py +38 -0
- django_cte-1.3.3.dev20250526204410.dist-info/METADATA +84 -0
- django_cte-1.3.3.dev20250526204410.dist-info/RECORD +11 -0
- django_cte-1.3.3.dev20250526204410.dist-info/WHEEL +4 -0
- django_cte-1.3.3.dev20250526204410.dist-info/licenses/LICENSE +24 -0
django_cte/__init__.py
ADDED
django_cte/cte.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from django.db.models import Manager
|
|
2
|
+
from django.db.models.query import Q, QuerySet, ValuesIterable
|
|
3
|
+
from django.db.models.sql.datastructures import BaseTable
|
|
4
|
+
|
|
5
|
+
from .join import QJoin, INNER
|
|
6
|
+
from .meta import CTEColumnRef, CTEColumns
|
|
7
|
+
from .query import CTEQuery
|
|
8
|
+
|
|
9
|
+
__all__ = ["With", "CTEManager", "CTEQuerySet"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class With(object):
|
|
13
|
+
"""Common Table Expression query object: `WITH ...`
|
|
14
|
+
|
|
15
|
+
:param queryset: A queryset to use as the body of the CTE.
|
|
16
|
+
:param name: Optional name parameter for the CTE (default: "cte").
|
|
17
|
+
This must be a unique name that does not conflict with other
|
|
18
|
+
entities (tables, views, functions, other CTE(s), etc.) referenced
|
|
19
|
+
in the given query as well any query to which this CTE will
|
|
20
|
+
eventually be added.
|
|
21
|
+
:param materialized: Optional parameter (default: False) which enforce
|
|
22
|
+
using of MATERIALIZED statement for supporting databases.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, queryset, name="cte", materialized=False):
|
|
26
|
+
self.query = None if queryset is None else queryset.query
|
|
27
|
+
self.name = name
|
|
28
|
+
self.col = CTEColumns(self)
|
|
29
|
+
self.materialized = materialized
|
|
30
|
+
|
|
31
|
+
def __getstate__(self):
|
|
32
|
+
return (self.query, self.name, self.materialized)
|
|
33
|
+
|
|
34
|
+
def __setstate__(self, state):
|
|
35
|
+
self.query, self.name, self.materialized = state
|
|
36
|
+
self.col = CTEColumns(self)
|
|
37
|
+
|
|
38
|
+
def __repr__(self):
|
|
39
|
+
return "<With {}>".format(self.name)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def recursive(cls, make_cte_queryset, name="cte", materialized=False):
|
|
43
|
+
"""Recursive Common Table Expression: `WITH RECURSIVE ...`
|
|
44
|
+
|
|
45
|
+
:param make_cte_queryset: Function taking a single argument (a
|
|
46
|
+
not-yet-fully-constructed cte object) and returning a `QuerySet`
|
|
47
|
+
object. The returned `QuerySet` normally consists of an initial
|
|
48
|
+
statement unioned with a recursive statement.
|
|
49
|
+
:param name: See `name` parameter of `__init__`.
|
|
50
|
+
:param materialized: See `materialized` parameter of `__init__`.
|
|
51
|
+
:returns: The fully constructed recursive cte object.
|
|
52
|
+
"""
|
|
53
|
+
cte = cls(None, name, materialized)
|
|
54
|
+
cte.query = make_cte_queryset(cte).query
|
|
55
|
+
return cte
|
|
56
|
+
|
|
57
|
+
def join(self, model_or_queryset, *filter_q, **filter_kw):
|
|
58
|
+
"""Join this CTE to the given model or queryset
|
|
59
|
+
|
|
60
|
+
This CTE will be refernced by the returned queryset, but the
|
|
61
|
+
corresponding `WITH ...` statement will not be prepended to the
|
|
62
|
+
queryset's SQL output; use `<CTEQuerySet>.with_cte(cte)` to
|
|
63
|
+
achieve that outcome.
|
|
64
|
+
|
|
65
|
+
:param model_or_queryset: Model class or queryset to which the
|
|
66
|
+
CTE should be joined.
|
|
67
|
+
:param *filter_q: Join condition Q expressions (optional).
|
|
68
|
+
:param **filter_kw: Join conditions. All LHS fields (kwarg keys)
|
|
69
|
+
are assumed to reference `model_or_queryset` fields. Use
|
|
70
|
+
`cte.col.name` on the RHS to recursively reference CTE query
|
|
71
|
+
columns. For example: `cte.join(Book, id=cte.col.id)`
|
|
72
|
+
:returns: A queryset with the given model or queryset joined to
|
|
73
|
+
this CTE.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(model_or_queryset, QuerySet):
|
|
76
|
+
queryset = model_or_queryset.all()
|
|
77
|
+
else:
|
|
78
|
+
queryset = model_or_queryset._default_manager.all()
|
|
79
|
+
join_type = filter_kw.pop("_join_type", INNER)
|
|
80
|
+
query = queryset.query
|
|
81
|
+
|
|
82
|
+
# based on Query.add_q: add necessary joins to query, but no filter
|
|
83
|
+
q_object = Q(*filter_q, **filter_kw)
|
|
84
|
+
map = query.alias_map
|
|
85
|
+
existing_inner = set(a for a in map if map[a].join_type == INNER)
|
|
86
|
+
on_clause, _ = query._add_q(q_object, query.used_aliases)
|
|
87
|
+
query.demote_joins(existing_inner)
|
|
88
|
+
|
|
89
|
+
parent = query.get_initial_alias()
|
|
90
|
+
query.join(QJoin(parent, self.name, self.name, on_clause, join_type))
|
|
91
|
+
return queryset
|
|
92
|
+
|
|
93
|
+
def queryset(self):
|
|
94
|
+
"""Get a queryset selecting from this CTE
|
|
95
|
+
|
|
96
|
+
This CTE will be referenced by the returned queryset, but the
|
|
97
|
+
corresponding `WITH ...` statement will not be prepended to the
|
|
98
|
+
queryset's SQL output; use `<CTEQuerySet>.with_cte(cte)` to
|
|
99
|
+
achieve that outcome.
|
|
100
|
+
|
|
101
|
+
:returns: A queryset.
|
|
102
|
+
"""
|
|
103
|
+
cte_query = self.query
|
|
104
|
+
qs = cte_query.model._default_manager.get_queryset()
|
|
105
|
+
|
|
106
|
+
query = CTEQuery(cte_query.model)
|
|
107
|
+
query.join(BaseTable(self.name, None))
|
|
108
|
+
query.default_cols = cte_query.default_cols
|
|
109
|
+
query.deferred_loading = cte_query.deferred_loading
|
|
110
|
+
if cte_query.annotations:
|
|
111
|
+
for alias, value in cte_query.annotations.items():
|
|
112
|
+
col = CTEColumnRef(alias, self.name, value.output_field)
|
|
113
|
+
query.add_annotation(col, alias)
|
|
114
|
+
if cte_query.values_select:
|
|
115
|
+
query.set_values(cte_query.values_select)
|
|
116
|
+
qs._iterable_class = ValuesIterable
|
|
117
|
+
query.annotation_select_mask = cte_query.annotation_select_mask
|
|
118
|
+
|
|
119
|
+
qs.query = query
|
|
120
|
+
return qs
|
|
121
|
+
|
|
122
|
+
def _resolve_ref(self, name):
|
|
123
|
+
return self.query.resolve_ref(name)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class CTEQuerySet(QuerySet):
|
|
127
|
+
"""QuerySet with support for Common Table Expressions"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, model=None, query=None, using=None, hints=None):
|
|
130
|
+
# Only create an instance of a Query if this is the first invocation in
|
|
131
|
+
# a query chain.
|
|
132
|
+
if query is None:
|
|
133
|
+
query = CTEQuery(model)
|
|
134
|
+
super(CTEQuerySet, self).__init__(model, query, using, hints)
|
|
135
|
+
|
|
136
|
+
def with_cte(self, cte):
|
|
137
|
+
"""Add a Common Table Expression to this queryset
|
|
138
|
+
|
|
139
|
+
The CTE `WITH ...` clause will be added to the queryset's SQL
|
|
140
|
+
output (after other CTEs that have already been added) so it
|
|
141
|
+
can be referenced in annotations, filters, etc.
|
|
142
|
+
"""
|
|
143
|
+
qs = self._clone()
|
|
144
|
+
qs.query._with_ctes.append(cte)
|
|
145
|
+
return qs
|
|
146
|
+
|
|
147
|
+
def as_manager(cls):
|
|
148
|
+
# Address the circular dependency between
|
|
149
|
+
# `CTEQuerySet` and `CTEManager`.
|
|
150
|
+
manager = CTEManager.from_queryset(cls)()
|
|
151
|
+
manager._built_with_as_manager = True
|
|
152
|
+
return manager
|
|
153
|
+
as_manager.queryset_only = True
|
|
154
|
+
as_manager = classmethod(as_manager)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class CTEManager(Manager.from_queryset(CTEQuerySet)):
|
|
158
|
+
"""Manager for models that perform CTE queries"""
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_queryset(cls, queryset_class, class_name=None):
|
|
162
|
+
if not issubclass(queryset_class, CTEQuerySet):
|
|
163
|
+
raise TypeError(
|
|
164
|
+
"models with CTE support need to use a CTEQuerySet")
|
|
165
|
+
return super(CTEManager, cls).from_queryset(
|
|
166
|
+
queryset_class, class_name=class_name)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import django
|
|
2
|
+
from django.db.models import Subquery
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CTESubqueryResolver(object):
|
|
6
|
+
|
|
7
|
+
def __init__(self, annotation):
|
|
8
|
+
self.annotation = annotation
|
|
9
|
+
|
|
10
|
+
def resolve_expression(self, *args, **kw):
|
|
11
|
+
# source: django.db.models.expressions.Subquery.resolve_expression
|
|
12
|
+
# --- begin copied code (lightly adapted) --- #
|
|
13
|
+
|
|
14
|
+
# Need to recursively resolve these.
|
|
15
|
+
def resolve_all(child):
|
|
16
|
+
if hasattr(child, 'children'):
|
|
17
|
+
[resolve_all(_child) for _child in child.children]
|
|
18
|
+
if hasattr(child, 'rhs'):
|
|
19
|
+
child.rhs = resolve(child.rhs)
|
|
20
|
+
|
|
21
|
+
def resolve(child):
|
|
22
|
+
if hasattr(child, 'resolve_expression'):
|
|
23
|
+
resolved = child.resolve_expression(*args, **kw)
|
|
24
|
+
# Add table alias to the parent query's aliases to prevent
|
|
25
|
+
# quoting.
|
|
26
|
+
if hasattr(resolved, 'alias') and \
|
|
27
|
+
resolved.alias != resolved.target.model._meta.db_table:
|
|
28
|
+
get_query(clone).external_aliases.add(resolved.alias)
|
|
29
|
+
return resolved
|
|
30
|
+
return child
|
|
31
|
+
|
|
32
|
+
# --- end copied code --- #
|
|
33
|
+
|
|
34
|
+
if django.VERSION < (3, 0):
|
|
35
|
+
def get_query(clone):
|
|
36
|
+
return clone.queryset.query
|
|
37
|
+
else:
|
|
38
|
+
def get_query(clone):
|
|
39
|
+
return clone.query
|
|
40
|
+
|
|
41
|
+
# NOTE this uses the old (pre-Django 3) way of resolving.
|
|
42
|
+
# Should a different technique should be used on Django 3+?
|
|
43
|
+
clone = self.annotation.resolve_expression(*args, **kw)
|
|
44
|
+
if isinstance(self.annotation, Subquery):
|
|
45
|
+
for cte in getattr(get_query(clone), '_with_ctes', []):
|
|
46
|
+
resolve_all(cte.query.where)
|
|
47
|
+
for key, value in cte.query.annotations.items():
|
|
48
|
+
if isinstance(value, Subquery):
|
|
49
|
+
cte.query.annotations[key] = resolve(value)
|
|
50
|
+
return clone
|
django_cte/join.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from django.db.models.sql.constants import INNER
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class QJoin(object):
|
|
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(object):
|
|
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(CTEColumnRef, self).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(CTEColumnRef, self).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,222 @@
|
|
|
1
|
+
import django
|
|
2
|
+
from django.core.exceptions import EmptyResultSet
|
|
3
|
+
from django.db import connections
|
|
4
|
+
from django.db.models.sql import DeleteQuery, Query, UpdateQuery
|
|
5
|
+
from django.db.models.sql.compiler import (
|
|
6
|
+
SQLCompiler,
|
|
7
|
+
SQLDeleteCompiler,
|
|
8
|
+
SQLUpdateCompiler,
|
|
9
|
+
)
|
|
10
|
+
from django.db.models.sql.constants import LOUTER
|
|
11
|
+
from django.db.models.sql.where import ExtraWhere, WhereNode
|
|
12
|
+
|
|
13
|
+
from .expressions import CTESubqueryResolver
|
|
14
|
+
from .join import QJoin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CTEQuery(Query):
|
|
18
|
+
"""A Query which processes SQL compilation through the CTE compiler"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *args, **kwargs):
|
|
21
|
+
super(CTEQuery, self).__init__(*args, **kwargs)
|
|
22
|
+
self._with_ctes = []
|
|
23
|
+
|
|
24
|
+
def combine(self, other, connector):
|
|
25
|
+
if other._with_ctes:
|
|
26
|
+
if self._with_ctes:
|
|
27
|
+
raise TypeError("cannot merge queries with CTEs on both sides")
|
|
28
|
+
self._with_ctes = other._with_ctes[:]
|
|
29
|
+
return super(CTEQuery, self).combine(other, connector)
|
|
30
|
+
|
|
31
|
+
def get_compiler(self, using=None, connection=None, *args, **kwargs):
|
|
32
|
+
""" Overrides the Query method get_compiler in order to return
|
|
33
|
+
a CTECompiler.
|
|
34
|
+
"""
|
|
35
|
+
# Copy the body of this method from Django except the final
|
|
36
|
+
# return statement. We will ignore code coverage for this.
|
|
37
|
+
if using is None and connection is None: # pragma: no cover
|
|
38
|
+
raise ValueError("Need either using or connection")
|
|
39
|
+
if using:
|
|
40
|
+
connection = connections[using]
|
|
41
|
+
# Check that the compiler will be able to execute the query
|
|
42
|
+
for alias, aggregate in self.annotation_select.items():
|
|
43
|
+
connection.ops.check_expression_support(aggregate)
|
|
44
|
+
# Instantiate the custom compiler.
|
|
45
|
+
klass = COMPILER_TYPES.get(self.__class__, CTEQueryCompiler)
|
|
46
|
+
return klass(self, connection, using, *args, **kwargs)
|
|
47
|
+
|
|
48
|
+
def add_annotation(self, annotation, *args, **kw):
|
|
49
|
+
annotation = CTESubqueryResolver(annotation)
|
|
50
|
+
super(CTEQuery, self).add_annotation(annotation, *args, **kw)
|
|
51
|
+
|
|
52
|
+
def __chain(self, _name, klass=None, *args, **kwargs):
|
|
53
|
+
klass = QUERY_TYPES.get(klass, self.__class__)
|
|
54
|
+
clone = getattr(super(CTEQuery, self), _name)(klass, *args, **kwargs)
|
|
55
|
+
clone._with_ctes = self._with_ctes[:]
|
|
56
|
+
return clone
|
|
57
|
+
|
|
58
|
+
if django.VERSION < (2, 0):
|
|
59
|
+
def clone(self, klass=None, *args, **kwargs):
|
|
60
|
+
return self.__chain("clone", klass, *args, **kwargs)
|
|
61
|
+
|
|
62
|
+
else:
|
|
63
|
+
def chain(self, klass=None):
|
|
64
|
+
return self.__chain("chain", klass)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CTECompiler(object):
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def generate_sql(cls, connection, query, as_sql):
|
|
71
|
+
if not query._with_ctes:
|
|
72
|
+
return as_sql()
|
|
73
|
+
|
|
74
|
+
ctes = []
|
|
75
|
+
params = []
|
|
76
|
+
for cte in query._with_ctes:
|
|
77
|
+
if django.VERSION > (4, 2):
|
|
78
|
+
_ignore_with_col_aliases(cte.query)
|
|
79
|
+
|
|
80
|
+
alias = query.alias_map.get(cte.name)
|
|
81
|
+
should_elide_empty = (
|
|
82
|
+
not isinstance(alias, QJoin) or alias.join_type != LOUTER
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if django.VERSION >= (4, 0):
|
|
86
|
+
compiler = cte.query.get_compiler(
|
|
87
|
+
connection=connection, elide_empty=should_elide_empty
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
compiler = cte.query.get_compiler(connection=connection)
|
|
91
|
+
|
|
92
|
+
qn = compiler.quote_name_unless_alias
|
|
93
|
+
try:
|
|
94
|
+
cte_sql, cte_params = compiler.as_sql()
|
|
95
|
+
except EmptyResultSet:
|
|
96
|
+
if django.VERSION < (4, 0) and not should_elide_empty:
|
|
97
|
+
# elide_empty is not available prior to Django 4.0. The
|
|
98
|
+
# below behavior emulates the logic of it, rebuilding
|
|
99
|
+
# the CTE query with a WHERE clause that is always false
|
|
100
|
+
# but that the SqlCompiler cannot optimize away. This is
|
|
101
|
+
# only required for left outer joins, as standard inner
|
|
102
|
+
# joins should be optimized and raise the EmptyResultSet
|
|
103
|
+
query = cte.query.copy()
|
|
104
|
+
query.where = WhereNode([ExtraWhere(["1 = 0"], [])])
|
|
105
|
+
compiler = query.get_compiler(connection=connection)
|
|
106
|
+
cte_sql, cte_params = compiler.as_sql()
|
|
107
|
+
else:
|
|
108
|
+
# If the CTE raises an EmptyResultSet the SqlCompiler still
|
|
109
|
+
# needs to know the information about this base compiler
|
|
110
|
+
# like, col_count and klass_info.
|
|
111
|
+
as_sql()
|
|
112
|
+
raise
|
|
113
|
+
template = cls.get_cte_query_template(cte)
|
|
114
|
+
ctes.append(template.format(name=qn(cte.name), query=cte_sql))
|
|
115
|
+
params.extend(cte_params)
|
|
116
|
+
|
|
117
|
+
# Required due to breaking change in django commit
|
|
118
|
+
# fc91ea1e50e5ef207f0f291b3f6c1942b10db7c7
|
|
119
|
+
if django.VERSION >= (4, 0):
|
|
120
|
+
explain_attribute = "explain_info"
|
|
121
|
+
explain_info = getattr(query, explain_attribute, None)
|
|
122
|
+
explain_format = getattr(explain_info, "format", None)
|
|
123
|
+
explain_options = getattr(explain_info, "options", {})
|
|
124
|
+
else:
|
|
125
|
+
explain_attribute = "explain_query"
|
|
126
|
+
explain_format = getattr(query, "explain_format", None)
|
|
127
|
+
explain_options = getattr(query, "explain_options", {})
|
|
128
|
+
|
|
129
|
+
explain_query_or_info = getattr(query, explain_attribute, None)
|
|
130
|
+
sql = []
|
|
131
|
+
if explain_query_or_info:
|
|
132
|
+
sql.append(
|
|
133
|
+
connection.ops.explain_query_prefix(
|
|
134
|
+
explain_format,
|
|
135
|
+
**explain_options
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
# this needs to get set to None so that the base as_sql() doesn't
|
|
139
|
+
# insert the EXPLAIN statement where it would end up between the
|
|
140
|
+
# WITH ... clause and the final SELECT
|
|
141
|
+
setattr(query, explain_attribute, None)
|
|
142
|
+
|
|
143
|
+
if ctes:
|
|
144
|
+
# Always use WITH RECURSIVE
|
|
145
|
+
# https://www.postgresql.org/message-id/13122.1339829536%40sss.pgh.pa.us
|
|
146
|
+
sql.extend(["WITH RECURSIVE", ", ".join(ctes)])
|
|
147
|
+
base_sql, base_params = as_sql()
|
|
148
|
+
|
|
149
|
+
if explain_query_or_info:
|
|
150
|
+
setattr(query, explain_attribute, explain_query_or_info)
|
|
151
|
+
|
|
152
|
+
sql.append(base_sql)
|
|
153
|
+
params.extend(base_params)
|
|
154
|
+
return " ".join(sql), tuple(params)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def get_cte_query_template(cls, cte):
|
|
158
|
+
if cte.materialized:
|
|
159
|
+
return "{name} AS MATERIALIZED ({query})"
|
|
160
|
+
return "{name} AS ({query})"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class CTEUpdateQuery(UpdateQuery, CTEQuery):
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class CTEDeleteQuery(DeleteQuery, CTEQuery):
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
QUERY_TYPES = {
|
|
172
|
+
Query: CTEQuery,
|
|
173
|
+
UpdateQuery: CTEUpdateQuery,
|
|
174
|
+
DeleteQuery: CTEDeleteQuery,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _ignore_with_col_aliases(cte_query):
|
|
179
|
+
if getattr(cte_query, "combined_queries", None):
|
|
180
|
+
for query in cte_query.combined_queries:
|
|
181
|
+
query.ignore_with_col_aliases = True
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class CTEQueryCompiler(SQLCompiler):
|
|
185
|
+
|
|
186
|
+
def as_sql(self, *args, **kwargs):
|
|
187
|
+
def _as_sql():
|
|
188
|
+
return super(CTEQueryCompiler, self).as_sql(*args, **kwargs)
|
|
189
|
+
return CTECompiler.generate_sql(self.connection, self.query, _as_sql)
|
|
190
|
+
|
|
191
|
+
def get_select(self, **kw):
|
|
192
|
+
if kw.get("with_col_aliases") \
|
|
193
|
+
and getattr(self.query, "ignore_with_col_aliases", False):
|
|
194
|
+
kw.pop("with_col_aliases")
|
|
195
|
+
return super().get_select(**kw)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class CTEUpdateQueryCompiler(SQLUpdateCompiler):
|
|
199
|
+
|
|
200
|
+
def as_sql(self, *args, **kwargs):
|
|
201
|
+
def _as_sql():
|
|
202
|
+
return super(CTEUpdateQueryCompiler, self).as_sql(*args, **kwargs)
|
|
203
|
+
return CTECompiler.generate_sql(self.connection, self.query, _as_sql)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class CTEDeleteQueryCompiler(SQLDeleteCompiler):
|
|
207
|
+
|
|
208
|
+
# NOTE: it is currently not possible to execute delete queries that
|
|
209
|
+
# reference CTEs without patching `QuerySet.delete` (Django method)
|
|
210
|
+
# to call `self.query.chain(sql.DeleteQuery)` instead of
|
|
211
|
+
# `sql.DeleteQuery(self.model)`
|
|
212
|
+
|
|
213
|
+
def as_sql(self, *args, **kwargs):
|
|
214
|
+
def _as_sql():
|
|
215
|
+
return super(CTEDeleteQueryCompiler, self).as_sql(*args, **kwargs)
|
|
216
|
+
return CTECompiler.generate_sql(self.connection, self.query, _as_sql)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
COMPILER_TYPES = {
|
|
220
|
+
CTEUpdateQuery: CTEUpdateQueryCompiler,
|
|
221
|
+
CTEDeleteQuery: CTEDeleteQueryCompiler,
|
|
222
|
+
}
|
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(object):
|
|
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(object):
|
|
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(object):
|
|
29
|
+
class query(object):
|
|
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: 1.3.3.dev20250526204410
|
|
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
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Environment :: Web Environment
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
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.0
|
|
26
|
+
Classifier: Framework :: Django :: 5.1
|
|
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
|
+
[](https://github.com/dimagi/django-cte/actions/workflows/tests.yml)
|
|
35
|
+
[](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,11 @@
|
|
|
1
|
+
django_cte/__init__.py,sha256=VFBvWKXfBtqdPTJcYi67U8mUDTxntsQxpHrjrGdCRhE,96
|
|
2
|
+
django_cte/cte.py,sha256=Xw2Ll-O3fujrQF-dsvXdQy4FzG8fQdsLYJk6GCXRI3M,6554
|
|
3
|
+
django_cte/expressions.py,sha256=taT2trCz4z0YmOGVXzR9XXquPR43OH6xghNWAplc3lM,1930
|
|
4
|
+
django_cte/join.py,sha256=HrO31WKx3j5RiEWf7dDOXbBfeeUPFEbNLo3gzzt3qbo,3164
|
|
5
|
+
django_cte/meta.py,sha256=PIu7iW_tu3gBCpiBRfVHvuJKSSND2IsaNhoT01WEEJM,3535
|
|
6
|
+
django_cte/query.py,sha256=A16buWFqWJZ2RA1WUby7tdC_uxmn9XNLuCewjJAg1mk,8314
|
|
7
|
+
django_cte/raw.py,sha256=nROVPEVRc3_zKAFcED6KR8Rr6FTTn8jQDFQWfi-X3-A,1077
|
|
8
|
+
django_cte-1.3.3.dev20250526204410.dist-info/licenses/LICENSE,sha256=mkLNw_QhpZ40jBEbuAosqH4ciA3KMrwb8aSYbTmy5gc,1508
|
|
9
|
+
django_cte-1.3.3.dev20250526204410.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
10
|
+
django_cte-1.3.3.dev20250526204410.dist-info/METADATA,sha256=BoO5aEF98J-66izORnA7QYxj_OB7cF80136D0NyoikM,2645
|
|
11
|
+
django_cte-1.3.3.dev20250526204410.dist-info/RECORD,,
|
|
@@ -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.
|