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 +4 -0
- django_cte/_deprecated.py +138 -0
- django_cte/cte.py +231 -0
- django_cte/jitmixin.py +28 -0
- django_cte/join.py +91 -0
- django_cte/meta.py +112 -0
- django_cte/query.py +168 -0
- django_cte/raw.py +38 -0
- django_cte-2.0.1.dev20251120124810.dist-info/METADATA +84 -0
- django_cte-2.0.1.dev20251120124810.dist-info/RECORD +12 -0
- django_cte-2.0.1.dev20251120124810.dist-info/WHEEL +4 -0
- django_cte-2.0.1.dev20251120124810.dist-info/licenses/LICENSE +24 -0
django_cte/__init__.py
ADDED
|
@@ -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
|
+
[](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,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,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.
|