Skip to content

Commit fa8988f

Browse files
committed
INTPYTHON-792 Add support for window functions
1 parent a96c8d2 commit fa8988f

10 files changed

Lines changed: 562 additions & 3 deletions

File tree

.github/workflows/runtests.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"empty_models",
4848
"expressions",
4949
"expressions_case",
50+
"expressions_window",
5051
"field_defaults",
5152
"file_storage",
5253
"file_uploads",

django_mongodb_backend/compiler.py

Lines changed: 271 additions & 3 deletions
Large diffs are not rendered by default.

django_mongodb_backend/expressions/builtins.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
Subquery,
2525
Value,
2626
When,
27+
Window,
2728
)
2829
from django.db.models.sql import Query
2930

@@ -157,6 +158,16 @@ def query(self, compiler, connection, get_wrapping_pipeline=None, as_expr=False)
157158
else:
158159
if subquery.aggregation_pipeline is None:
159160
subquery.aggregation_pipeline = []
161+
# Window stages must precede the wrapping pipeline so the $group
162+
# can reference the window function outputs.
163+
if subquery.window_pipeline:
164+
subquery.aggregation_pipeline.extend(subquery.window_pipeline)
165+
subquery.window_pipeline = None
166+
# Qualify ($match on window results) must come after window stages
167+
# but before the wrapping pipeline's $group stage.
168+
if subquery.qualify_mql:
169+
subquery.aggregation_pipeline.append({"$match": subquery.qualify_mql})
170+
subquery.qualify_mql = None
160171
subquery.aggregation_pipeline.extend(wrapping_result_pipeline)
161172
# Erase project_fields since the required value is projected above.
162173
subquery.project_fields = None
@@ -209,6 +220,10 @@ def when(self, compiler, connection):
209220
return self.condition.as_mql(compiler, connection, as_expr=True)
210221

211222

223+
def window(self, compiler, connection): # noqa: ARG001
224+
raise NotSupportedError("Window expressions must be used as annotations.")
225+
226+
212227
def value(self, compiler, connection, as_expr=False): # noqa: ARG001
213228
value = self.value
214229
if isinstance(value, (list, int, str, dict, tuple)) and as_expr:
@@ -258,3 +273,4 @@ def register_expressions():
258273
Subquery.as_mql_path = partialmethod(subquery, as_expr=False)
259274
When.as_mql_expr = when
260275
Value.as_mql = value
276+
Window.as_mql_expr = window

django_mongodb_backend/features.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ class DatabaseFeatures(GISFeatures, BaseDatabaseFeatures):
3333
supports_expression_defaults = False
3434
supports_expression_indexes = False
3535
supports_foreign_keys = False
36+
supports_frame_range_fixed_distance = True
3637
supports_ignore_conflicts = False
3738
supports_json_field_contains = False
3839
# BSON Date type doesn't support microsecond precision.
3940
supports_microsecond_precision = False
4041
supports_nulls_distinct_unique_constraints = True
42+
supports_over_clause = True
4143
supports_paramstyle_pyformat = False
4244
supports_sequence_reset = False
4345
supports_slicing_ordering_in_compound = True
@@ -527,6 +529,10 @@ def django_test_skips(self):
527529
"prefetch_related.tests.LookupOrderingTest.test_order",
528530
"prefetch_related.tests.MultiDbTests.test_using_is_honored_m2m",
529531
"prefetch_related.tests.MultiTableInheritanceTest.test_m2m_to_inheriting_model",
532+
"prefetch_related.tests.PrefetchLimitTests.test_empty_order",
533+
"prefetch_related.tests.PrefetchLimitTests.test_m2m_forward",
534+
"prefetch_related.tests.PrefetchLimitTests.test_m2m_reverse",
535+
"prefetch_related.tests.PrefetchLimitTests.test_reverse_ordering",
530536
"prefetch_related.tests.PrefetchRelatedMTICacheTests.test_add_clears_prefetched_objects_in_grandparent",
531537
"prefetch_related.tests.PrefetchRelatedMTICacheTests.test_add_clears_prefetched_objects_in_parent",
532538
"prefetch_related.tests.PrefetchRelatedMTICacheTests.test_grandparent_m2m_available_in_child",

django_mongodb_backend/functions/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .json import register_json
55
from .math import register_math
66
from .text import register_text
7+
from .window import register_window
78

89

910
def register_functions():
@@ -13,3 +14,4 @@ def register_functions():
1314
register_json()
1415
register_math()
1516
register_text()
17+
register_window()
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
from django.db.models.aggregates import Aggregate, Count, StdDev, Variance
2+
from django.db.models.functions.window import (
3+
CumeDist,
4+
DenseRank,
5+
FirstValue,
6+
Lag,
7+
LastValue,
8+
Lead,
9+
NthValue,
10+
Ntile,
11+
PercentRank,
12+
Rank,
13+
RowNumber,
14+
)
15+
16+
17+
def aggregate(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
18+
agg_mql = self.as_mql_expr(compiler, connection)
19+
return {alias: {**agg_mql, "window": default_frame()}}, {}
20+
21+
22+
def count(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
23+
# Count.as_mql_expr() returns {"$sum": lhs_mql} for non-distinct counts.
24+
# MongoDB's $count is a pipeline stage (not a window accumulator), so use
25+
# $sum instead.
26+
agg_mql = self.as_mql_expr(compiler, connection)
27+
return {alias: {**agg_mql, "window": default_frame()}}, {}
28+
29+
30+
def cume_dist(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
31+
# CumeDist = (rank + group_size - 1) / total. Use $rank for the base rank
32+
# (1-based, same value for ties), range:[0, 0] for group_size (count of all
33+
# rows with identical sort key value), and full-partition sum for total.
34+
rank_alias = f"__wtemp{next(idx)}"
35+
group_size_alias = f"__wtemp{next(idx)}"
36+
total_alias = f"__wtemp{next(idx)}"
37+
output = {
38+
rank_alias: {"$rank": {}},
39+
group_size_alias: {"$sum": 1, "window": {"range": [0, 0]}},
40+
total_alias: {"$sum": 1, "window": {"documents": ["unbounded", "unbounded"]}},
41+
}
42+
add_fields = {
43+
alias: {
44+
"$divide": [
45+
{"$subtract": [{"$add": [f"${rank_alias}", f"${group_size_alias}"]}, 1]},
46+
f"${total_alias}",
47+
]
48+
}
49+
}
50+
return output, add_fields
51+
52+
53+
def dense_rank(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
54+
return {alias: {"$denseRank": {}}}, {}
55+
56+
57+
def first_value(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
58+
expr = self.get_source_expressions()[0]
59+
mql = expr.as_mql(compiler, connection, as_expr=True)
60+
return {alias: {"$first": mql, "window": default_frame()}}, {}
61+
62+
63+
def lag(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
64+
exprs = self.get_source_expressions()
65+
expr_mql = exprs[0].as_mql(compiler, connection, as_expr=True)
66+
offset = exprs[1].value
67+
mql = {"output": expr_mql, "by": -offset}
68+
if len(exprs) > 2:
69+
mql["default"] = exprs[2].as_mql(compiler, connection, as_expr=True)
70+
return {alias: {"$shift": mql}}, {}
71+
72+
73+
def last_value(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
74+
expr = self.get_source_expressions()[0]
75+
mql = expr.as_mql(compiler, connection, as_expr=True)
76+
return {alias: {"$last": mql, "window": default_frame()}}, {}
77+
78+
79+
def lead(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
80+
exprs = self.get_source_expressions()
81+
expr_mql = exprs[0].as_mql(compiler, connection, as_expr=True)
82+
offset = exprs[1].value
83+
mql = {"output": expr_mql, "by": offset}
84+
if len(exprs) > 2:
85+
mql["default"] = exprs[2].as_mql(compiler, connection, as_expr=True)
86+
return {alias: {"$shift": mql}}, {}
87+
88+
89+
def nth_value(self, compiler, connection, alias, idx, default_frame):
90+
expr, nth_expr = self.get_source_expressions()
91+
mql = expr.as_mql(compiler, connection, as_expr=True)
92+
nth = nth_expr.value
93+
push_alias = f"__wtemp{next(idx)}"
94+
output = {push_alias: {"$push": mql, "window": default_frame()}}
95+
add_fields = {
96+
alias: {
97+
"$cond": {
98+
"if": {"$gte": [{"$size": f"${push_alias}"}, nth]},
99+
"then": {"$arrayElemAt": [f"${push_alias}", nth - 1]},
100+
"else": None,
101+
}
102+
}
103+
}
104+
return output, add_fields
105+
106+
107+
def ntile(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
108+
num_buckets = self.get_source_expressions()[0].value
109+
doc_num_alias = f"__wtemp{next(idx)}"
110+
total_alias = f"__wtemp{next(idx)}"
111+
output = {
112+
doc_num_alias: {"$documentNumber": {}},
113+
total_alias: {"$sum": 1, "window": {"documents": ["unbounded", "unbounded"]}},
114+
}
115+
# SQL NTILE: rows 1..extra*(per+1) go to the first `extra` buckets (each
116+
# gets per+1 rows); remaining rows are distributed evenly across the rest.
117+
add_fields = {
118+
alias: {
119+
"$let": {
120+
"vars": {
121+
"per": {"$floor": {"$divide": [f"${total_alias}", num_buckets]}},
122+
"xtra": {"$mod": [f"${total_alias}", num_buckets]},
123+
},
124+
"in": {
125+
"$let": {
126+
"vars": {
127+
"thr": {"$multiply": ["$$xtra", {"$add": ["$$per", 1]}]},
128+
},
129+
"in": {
130+
"$cond": {
131+
"if": {"$lte": [f"${doc_num_alias}", "$$thr"]},
132+
"then": {
133+
"$add": [
134+
{
135+
"$floor": {
136+
"$divide": [
137+
{"$subtract": [f"${doc_num_alias}", 1]},
138+
{"$add": ["$$per", 1]},
139+
]
140+
}
141+
},
142+
1,
143+
]
144+
},
145+
"else": {
146+
"$add": [
147+
"$$xtra",
148+
{
149+
"$floor": {
150+
"$divide": [
151+
{
152+
"$subtract": [
153+
f"${doc_num_alias}",
154+
{"$add": ["$$thr", 1]},
155+
]
156+
},
157+
# Guard against division by
158+
# zero when num_buckets >
159+
# total (per==0).
160+
{"$max": ["$$per", 1]},
161+
]
162+
}
163+
},
164+
1,
165+
]
166+
},
167+
}
168+
},
169+
}
170+
},
171+
}
172+
}
173+
}
174+
return output, add_fields
175+
176+
177+
def percent_rank(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
178+
# Use document-position rank ($sum:1 docs unbounded-to-current) instead of
179+
# $rank, which MongoDB limits to a single sort field.
180+
row_num_alias = f"__wtemp{next(idx)}"
181+
count_alias = f"__wtemp{next(idx)}"
182+
output = {
183+
row_num_alias: {"$sum": 1, "window": {"documents": ["unbounded", "current"]}},
184+
count_alias: {"$sum": 1, "window": {"documents": ["unbounded", "unbounded"]}},
185+
}
186+
add_fields = {
187+
alias: {
188+
"$cond": {
189+
"if": {"$lte": [f"${count_alias}", 1]},
190+
"then": {"$literal": 0.0},
191+
"else": {
192+
"$divide": [
193+
{"$subtract": [f"${row_num_alias}", 1]},
194+
{"$subtract": [f"${count_alias}", 1]},
195+
]
196+
},
197+
}
198+
}
199+
}
200+
return output, add_fields
201+
202+
203+
def rank(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
204+
return {alias: {"$rank": {}}}, {}
205+
206+
207+
def row_number(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
208+
return {alias: {"$documentNumber": {}}}, {}
209+
210+
211+
def stddev(self, compiler, connection, alias, idx, default_frame): # noqa: ARG001
212+
agg_mql = self.as_mql_expr(compiler, connection)
213+
return {alias: {**agg_mql, "window": default_frame()}}, {}
214+
215+
216+
def variance(self, compiler, connection, alias, idx, default_frame):
217+
# MongoDB has no $varPop/$varSamp window accumulator; compute as stdDev².
218+
agg_mql = self.as_mql_expr(compiler, connection)
219+
stddev_alias = f"__wtemp{next(idx)}"
220+
output = {stddev_alias: {**agg_mql, "window": default_frame()}}
221+
add_fields = {alias: {"$pow": [f"${stddev_alias}", 2]}}
222+
return output, add_fields
223+
224+
225+
def register_window():
226+
Aggregate.get_window_mql = aggregate
227+
Count.get_window_mql = count
228+
CumeDist.get_window_mql = cume_dist
229+
DenseRank.get_window_mql = dense_rank
230+
FirstValue.get_window_mql = first_value
231+
Lag.get_window_mql = lag
232+
LastValue.get_window_mql = last_value
233+
Lead.get_window_mql = lead
234+
NthValue.get_window_mql = nth_value
235+
Ntile.get_window_mql = ntile
236+
PercentRank.get_window_mql = percent_rank
237+
Rank.get_window_mql = rank
238+
RowNumber.get_window_mql = row_number
239+
StdDev.get_window_mql = stddev
240+
Variance.get_window_mql = variance

django_mongodb_backend/query.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def __init__(self, compiler):
5252
self.project_fields = None
5353
self.aggregation_pipeline = compiler.aggregation_pipeline
5454
self.search_pipeline = compiler.search_pipeline
55+
self.window_pipeline = None
56+
self.qualify_mql = None
5557
self.extra_fields = None
5658
self.combinator_pipeline = None
5759
# $lookup stage that encapsulates the pipeline for performing a nested
@@ -93,6 +95,10 @@ def get_pipeline(self):
9395
pipeline.append({"$match": self.match_mql})
9496
if self.aggregation_pipeline:
9597
pipeline.extend(self.aggregation_pipeline)
98+
if self.window_pipeline:
99+
pipeline.extend(self.window_pipeline)
100+
if self.qualify_mql:
101+
pipeline.append({"$match": self.qualify_mql})
96102
if self.needs_wrap_aggregation:
97103
if self.compiler.connection.client_encryption:
98104
# Automatic encryption doesn't support $unionWith, so use

docs/releases/6.0.x.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Django MongoDB Backend 6.0.x
1010
New features
1111
------------
1212

13+
- Added support for :ref:`window functions <django:window-functions>`.
14+
1315
- Added support for the following database functions:
1416

1517
- :class:`~django.db.models.functions.ExtractQuarter`

tests/expressions_/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.db import models
2+
3+
4+
class Number(models.Model):
5+
value = models.IntegerField()

tests/expressions_/test_window.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from django.db import NotSupportedError
2+
from django.db.models.expressions import Window
3+
from django.db.models.functions.window import Rank
4+
from django.test import TestCase
5+
6+
from .models import Number
7+
8+
9+
class WindowTests(TestCase):
10+
def test_window_order_by(self):
11+
msg = "Window expressions must be used as annotations."
12+
with self.assertRaisesMessage(NotSupportedError, msg):
13+
list(Number.objects.order_by(Window(Rank())))

0 commit comments

Comments
 (0)