Skip to content

Commit a84bf11

Browse files
WaVEVtimgraham
authored andcommitted
add support for ordering by expressions
1 parent 6f409db commit a84bf11

File tree

4 files changed

+52
-126
lines changed

4 files changed

+52
-126
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,6 @@ Migrations for 'admin':
123123
supported.
124124

125125
* Ordering a `QuerySet` by `nulls_first` or `nulls_last` isn't supported.
126-
Neither is randomized ordering.
127126

128127
- `DateTimeField` doesn't support microsecond precision, and correspondingly,
129128
`DurationField` stores milliseconds rather than microseconds.

django_mongodb/compiler.py

Lines changed: 47 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import itertools
22
from collections import defaultdict
33

4+
from bson import SON
45
from django.core.exceptions import EmptyResultSet, FullResultSet
56
from django.db import DatabaseError, IntegrityError, NotSupportedError
67
from django.db.models import Count, Expression
78
from django.db.models.aggregates import Aggregate, Variance
8-
from django.db.models.expressions import Col, OrderBy, Value
9+
from django.db.models.expressions import Col, Ref, Value
910
from django.db.models.functions.comparison import Coalesce
1011
from django.db.models.functions.math import Power
1112
from django.db.models.sql import compiler
12-
from django.db.models.sql.constants import GET_ITERATOR_CHUNK_SIZE, MULTI, ORDER_DIR, SINGLE
13+
from django.db.models.sql.constants import GET_ITERATOR_CHUNK_SIZE, MULTI, SINGLE
1314
from django.utils.functional import cached_property
15+
from pymongo import ASCENDING, DESCENDING
1416

1517
from .base import Cursor
1618
from .query import MongoQuery, wrap_database_errors
@@ -25,6 +27,7 @@ class SQLCompiler(compiler.SQLCompiler):
2527
def __init__(self, *args, **kwargs):
2628
super().__init__(*args, **kwargs)
2729
self.aggregation_pipeline = None
30+
self._order_by = None
2831

2932
def _get_group_alias_column(self, expr, annotation_group_idx):
3033
"""Generate a dummy field for use in the ids fields in $group."""
@@ -98,7 +101,7 @@ def _prepare_expressions_for_pipeline(self, expression, target, annotation_group
98101
replacements[sub_expr] = replacing_expr
99102
return replacements, group
100103

101-
def _prepare_annotations_for_aggregation_pipeline(self):
104+
def _prepare_annotations_for_aggregation_pipeline(self, order_by):
102105
"""Prepare annotations for the aggregation pipeline."""
103106
replacements = {}
104107
group = {}
@@ -110,6 +113,13 @@ def _prepare_annotations_for_aggregation_pipeline(self):
110113
)
111114
replacements.update(new_replacements)
112115
group.update(expr_group)
116+
for expr, _ in order_by:
117+
if expr.contains_aggregate:
118+
new_replacements, expr_group = self._prepare_expressions_for_pipeline(
119+
expr, None, annotation_group_idx
120+
)
121+
replacements.update(new_replacements)
122+
group.update(expr_group)
113123
having_replacements, having_group = self._prepare_expressions_for_pipeline(
114124
self.having, None, annotation_group_idx
115125
)
@@ -121,9 +131,10 @@ def _get_group_id_expressions(self, order_by):
121131
"""Generate group ID expressions for the aggregation pipeline."""
122132
group_expressions = set()
123133
replacements = {}
124-
for expr, (_, _, is_ref) in order_by:
125-
if not is_ref:
126-
group_expressions |= set(expr.get_group_by_cols())
134+
if not self._meta_ordering:
135+
for expr, (_, _, is_ref) in order_by:
136+
if not is_ref:
137+
group_expressions |= set(expr.get_group_by_cols())
127138
for expr, *_ in self.select:
128139
group_expressions |= set(expr.get_group_by_cols())
129140
having_group_by = self.having.get_group_by_cols() if self.having else ()
@@ -187,7 +198,7 @@ def _build_aggregation_pipeline(self, ids, group):
187198

188199
def pre_sql_setup(self, with_col_aliases=False):
189200
extra_select, order_by, group_by = super().pre_sql_setup(with_col_aliases=with_col_aliases)
190-
group, all_replacements = self._prepare_annotations_for_aggregation_pipeline()
201+
group, all_replacements = self._prepare_annotations_for_aggregation_pipeline(order_by)
191202
# query.group_by is either:
192203
# - None: no GROUP BY
193204
# - True: group by select fields
@@ -207,6 +218,7 @@ def pre_sql_setup(self, with_col_aliases=False):
207218
}
208219
)
209220
self.aggregation_pipeline = pipeline
221+
self._order_by = [expr.replace_expressions(all_replacements) for expr, _ in order_by]
210222
self.annotations = {
211223
target: expr.replace_expressions(all_replacements)
212224
for target, expr in self.query.annotation_select.items()
@@ -333,8 +345,13 @@ def build_query(self, columns=None):
333345
self.check_query()
334346
query = self.query_class(self)
335347
query.lookup_pipeline = self.get_lookup_pipeline()
336-
query.order_by(self._get_ordering())
337-
query.project_fields = self.get_project_fields(columns, ordering=query.ordering)
348+
ordering_fields, order, need_extra_fields = self.preprocess_orderby()
349+
query.project_fields = self.get_project_fields(columns, ordering_fields)
350+
query.ordering = order
351+
if need_extra_fields and columns is None:
352+
query.extra_fields = self.get_project_fields(
353+
((name, field) for name, field in ordering_fields if name.startswith("__order"))
354+
)
338355
where = self.get_where()
339356
try:
340357
expr = where.as_mql(self, self.connection) if where else {}
@@ -380,52 +397,6 @@ def project_field(column):
380397
+ tuple(map(project_field, related_columns))
381398
)
382399

383-
def _get_ordering(self):
384-
"""
385-
Return a list of (field, ascending) tuples that the query results
386-
should be ordered by. If there is no field ordering defined, return
387-
the standard_ordering (a boolean, needed for MongoDB "$natural"
388-
ordering).
389-
"""
390-
opts = self.query.get_meta()
391-
ordering = (
392-
self.query.order_by or opts.ordering
393-
if self.query.default_ordering
394-
else self.query.order_by
395-
)
396-
if not ordering:
397-
return self.query.standard_ordering
398-
default_order, _ = ORDER_DIR["ASC" if self.query.standard_ordering else "DESC"]
399-
column_ordering = []
400-
columns_seen = set()
401-
for order in ordering:
402-
if order == "?":
403-
raise NotSupportedError("Randomized ordering isn't supported by MongoDB.")
404-
if hasattr(order, "resolve_expression"):
405-
# order is an expression like OrderBy, F, or database function.
406-
orderby = order if isinstance(order, OrderBy) else order.asc()
407-
orderby = orderby.resolve_expression(self.query, allow_joins=True, reuse=None)
408-
ascending = not orderby.descending
409-
# If the query is reversed, ascending and descending are inverted.
410-
if not self.query.standard_ordering:
411-
ascending = not ascending
412-
else:
413-
# order is a string like "field" or "field__other_field".
414-
orderby, _ = self.find_ordering_name(
415-
order, self.query.get_meta(), default_order=default_order
416-
)[0]
417-
ascending = not orderby.descending
418-
column = orderby.expression.as_mql(self, self.connection)
419-
if isinstance(column, dict):
420-
raise NotSupportedError("order_by() expression not supported.")
421-
# $sort references must not include the dollar sign.
422-
column = column.removeprefix("$")
423-
# Don't add the same column twice.
424-
if column not in columns_seen:
425-
columns_seen.add(column)
426-
column_ordering.append((column, ascending))
427-
return column_ordering
428-
429400
@cached_property
430401
def collection_name(self):
431402
return self.query.get_meta().db_table
@@ -473,12 +444,29 @@ def get_project_fields(self, columns=None, ordering=None):
473444
if self.query.alias_refcount[alias] and self.collection_name != alias:
474445
fields[alias] = 1
475446
# Add order_by() fields.
476-
for column, _ in ordering or []:
477-
foreign_table = column.split(".", 1)[0] if "." in column else None
478-
if column not in fields and foreign_table not in fields:
479-
fields[column] = 1
447+
for alias, expression in ordering or []:
448+
nested_entity = alias.split(".", 1)[0] if "." in alias else None
449+
if alias not in fields and nested_entity not in fields:
450+
fields[alias] = expression.as_mql(self, self.connection)
480451
return fields
481452

453+
def preprocess_orderby(self):
454+
fields = {}
455+
result = SON()
456+
need_extra_fields = False
457+
idx = itertools.count(start=1)
458+
for order in self._order_by or []:
459+
if isinstance(order.expression, Ref):
460+
fieldname = order.expression.refs
461+
elif isinstance(order.expression, Col):
462+
fieldname = order.expression.as_mql(self, self.connection).removeprefix("$")
463+
else:
464+
fieldname = f"__order{next(idx)}"
465+
need_extra_fields = True
466+
fields[fieldname] = order.expression
467+
result[fieldname] = DESCENDING if order.descending else ASCENDING
468+
return tuple(fields.items()), result, need_extra_fields
469+
482470
def get_where(self):
483471
return self.where
484472

django_mongodb/features.py

Lines changed: 2 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -32,36 +32,9 @@ class DatabaseFeatures(BaseDatabaseFeatures):
3232
"lookup.tests.LookupTests.test_exact_none_transform",
3333
# "Save with update_fields did not affect any rows."
3434
"basic.tests.SelectOnSaveTests.test_select_on_save_lying_update",
35-
# Order by constant not supported:
36-
# AttributeError: 'Field' object has no attribute 'model'
37-
"aggregation.tests.AggregateTestCase.test_annotate_values_list",
38-
"aggregation.tests.AggregateTestCase.test_grouped_annotation_in_group_by",
39-
"aggregation.tests.AggregateTestCase.test_non_grouped_annotation_not_in_group_by",
40-
"aggregation.tests.AggregateTestCase.test_values_annotation_with_expression",
41-
"annotations.tests.NonAggregateAnnotationTestCase.test_order_by_aggregate",
42-
"model_fields.test_jsonfield.TestQuerying.test_ordering_grouping_by_count",
43-
"ordering.tests.OrderingTests.test_default_ordering_does_not_affect_group_by",
44-
"ordering.tests.OrderingTests.test_order_by_constant_value",
45-
"expressions.tests.NegatedExpressionTests.test_filter",
46-
"expressions_case.tests.CaseExpressionTests.test_order_by_conditional_implicit",
4735
# BaseExpression.convert_value() crashes with Decimal128.
4836
"aggregation.tests.AggregateTestCase.test_combine_different_types",
4937
"annotations.tests.NonAggregateAnnotationTestCase.test_combined_f_expression_annotation_with_aggregation",
50-
# NotSupportedError: order_by() expression not supported.
51-
"aggregation.tests.AggregateTestCase.test_aggregation_order_by_not_selected_annotation_values",
52-
"db_functions.comparison.test_coalesce.CoalesceTests.test_ordering",
53-
"db_functions.tests.FunctionTests.test_nested_function_ordering",
54-
"db_functions.text.test_length.LengthTests.test_ordering",
55-
"db_functions.text.test_strindex.StrIndexTests.test_order_by",
56-
"expressions_case.tests.CaseExpressionTests.test_order_by_conditional_explicit",
57-
"lookup.tests.LookupQueryingTests.test_lookup_in_order_by",
58-
"ordering.tests.OrderingTests.test_order_by_expr_query_reuse",
59-
"ordering.tests.OrderingTests.test_order_by_expression_ref",
60-
"ordering.tests.OrderingTests.test_ordering_select_related_collision",
61-
"queries.tests.Queries1Tests.test_order_by_related_field_transform",
62-
"update.tests.AdvancedTests.test_update_ordered_by_inline_m2m_annotation",
63-
"update.tests.AdvancedTests.test_update_ordered_by_m2m_annotation",
64-
"update.tests.AdvancedTests.test_update_ordered_by_m2m_annotation_desc",
6538
# Pattern lookups that use regexMatch don't work on JSONField:
6639
# Unsupported conversion from array to string in $convert
6740
"model_fields.test_jsonfield.TestQuerying.test_icontains",
@@ -76,11 +49,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
7649
"db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_timezone_applied_before_truncation",
7750
# Length of null considered zero rather than null.
7851
"db_functions.text.test_length.LengthTests.test_basic",
79-
# Key transforms are incorrectly treated as joins:
80-
# Ordering can't span tables on MongoDB (value_custom__a).
81-
"model_fields.test_jsonfield.TestQuerying.test_order_grouping_custom_decoder",
82-
"model_fields.test_jsonfield.TestQuerying.test_ordering_by_transform",
83-
"model_fields.test_jsonfield.TestQuerying.test_ordering_grouping_by_key_transform",
8452
# Wrong results in queries with multiple tables.
8553
"annotations.tests.NonAggregateAnnotationTestCase.test_annotation_reverse_m2m",
8654
"annotations.tests.NonAggregateAnnotationTestCase.test_annotation_with_m2m",
@@ -89,17 +57,9 @@ class DatabaseFeatures(BaseDatabaseFeatures):
8957
"expressions.test_queryset_values.ValuesExpressionsTests.test_values_list_expression",
9058
"expressions.test_queryset_values.ValuesExpressionsTests.test_values_list_expression_flat",
9159
"expressions.tests.IterableLookupInnerExpressionsTests.test_expressions_in_lookups_join_choice",
92-
"ordering.tests.OrderingTests.test_order_by_grandparent_fk_with_expression_in_default_ordering",
93-
"ordering.tests.OrderingTests.test_order_by_parent_fk_with_expression_in_default_ordering",
94-
"ordering.tests.OrderingTests.test_order_by_ptr_field_with_default_ordering_by_expression",
9560
"queries.tests.Queries1Tests.test_order_by_tables",
9661
"queries.tests.TestTicket24605.test_ticket_24605",
9762
"queries.tests.TestInvalidValuesRelation.test_invalid_values",
98-
# alias().order_by() doesn't work.
99-
"annotations.tests.AliasTests.test_order_by_alias",
100-
"annotations.tests.AliasTests.test_order_by_alias_aggregate",
101-
# annotate() + values_list() + order_by() loses annotated value.
102-
"expressions_case.tests.CaseExpressionTests.test_annotate_values_not_in_order_by",
10363
# QuerySet.explain() not implemented:
10464
# https://github.com/mongodb-labs/django-mongodb/issues/28
10565
"queries.test_explain.ExplainUnsupportedTests.test_message",
@@ -108,9 +68,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
10868
"aggregation.tests.AggregateTestCase.test_aggregation_default_passed_another_aggregate",
10969
"aggregation.tests.AggregateTestCase.test_annotation_expressions",
11070
"aggregation.tests.AggregateTestCase.test_reverse_fkey_annotate",
111-
# Incorrect order: pipeline does not order by the correct fields.
112-
"aggregation.tests.AggregateTestCase.test_annotate_ordering",
113-
"aggregation.tests.AggregateTestCase.test_even_more_aggregate",
11471
}
11572
# $bitAnd, #bitOr, and $bitXor are new in MongoDB 6.3.
11673
_django_test_expected_failures_bitwise = {
@@ -395,6 +352,8 @@ def django_test_expected_failures(self):
395352
"Cannot use QuerySet.update() when querying across multiple collections on MongoDB.": {
396353
"queries.tests.Queries4Tests.test_ticket7095",
397354
"queries.tests.Queries5Tests.test_ticket9848",
355+
"update.tests.AdvancedTests.test_update_ordered_by_m2m_annotation",
356+
"update.tests.AdvancedTests.test_update_ordered_by_m2m_annotation_desc",
398357
},
399358
"QuerySet.dates() is not supported on MongoDB.": {
400359
"aggregation.tests.AggregateTestCase.test_dates_with_aggregation",
@@ -577,10 +536,6 @@ def django_test_expected_failures(self):
577536
"model_fields.test_jsonfield.TestQuerying.test_none_key",
578537
"model_fields.test_jsonfield.TestQuerying.test_none_key_exclude",
579538
},
580-
"Randomized ordering isn't supported by MongoDB.": {
581-
"aggregation.tests.AggregateTestCase.test_aggregation_random_ordering",
582-
"ordering.tests.OrderingTests.test_random_ordering",
583-
},
584539
"Queries without a collection aren't supported on MongoDB.": {
585540
"queries.test_q.QCheckTests",
586541
"queries.test_query.TestQueryNoModel",

django_mongodb/query.py

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from django.db.models.sql.constants import INNER
1010
from django.db.models.sql.datastructures import Join
1111
from django.db.models.sql.where import AND, OR, XOR, NothingNode, WhereNode
12-
from pymongo import ASCENDING, DESCENDING
1312
from pymongo.errors import DuplicateKeyError, PyMongoError
1413

1514

@@ -51,28 +50,11 @@ def __init__(self, compiler):
5150
self.lookup_pipeline = None
5251
self.project_fields = None
5352
self.aggregation_pipeline = compiler.aggregation_pipeline
53+
self.extra_fields = None
5454

5555
def __repr__(self):
5656
return f"<MongoQuery: {self.mongo_query!r} ORDER {self.ordering!r}>"
5757

58-
def order_by(self, ordering):
59-
"""
60-
Reorder query results or execution order. Called by compiler during
61-
query building.
62-
63-
`ordering` is a list with (column, ascending) tuples or a boolean --
64-
use natural ordering, if any, when the argument is True and its reverse
65-
otherwise.
66-
"""
67-
if isinstance(ordering, bool):
68-
# No need to add {$natural: ASCENDING} as it's the default.
69-
if not ordering:
70-
self.ordering.append(("$natural", DESCENDING))
71-
else:
72-
for column, ascending in ordering:
73-
direction = ASCENDING if ascending else DESCENDING
74-
self.ordering.append((column, direction))
75-
7658
@wrap_database_errors
7759
def delete(self):
7860
"""Execute a delete query."""
@@ -97,6 +79,8 @@ def get_pipeline(self):
9779
pipeline.extend(self.aggregation_pipeline)
9880
if self.project_fields:
9981
pipeline.append({"$project": self.project_fields})
82+
if self.extra_fields:
83+
pipeline.append({"$addFields": self.extra_fields})
10084
if self.ordering:
10185
pipeline.append({"$sort": dict(self.ordering)})
10286
if self.query.low_mark > 0:

0 commit comments

Comments
 (0)