Skip to content

Commit 1988672

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

File tree

4 files changed

+62
-127
lines changed

4 files changed

+62
-127
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: 56 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,8 @@ class SQLCompiler(compiler.SQLCompiler):
2527
def __init__(self, *args, **kwargs):
2628
super().__init__(*args, **kwargs)
2729
self.aggregation_pipeline = None
30+
# A list of OrderBy objects for this query.
31+
self.order_by_expressions = None
2832

2933
def _get_group_alias_column(self, expr, annotation_group_idx):
3034
"""Generate a dummy field for use in the ids fields in $group."""
@@ -98,7 +102,7 @@ def _prepare_expressions_for_pipeline(self, expression, target, annotation_group
98102
replacements[sub_expr] = replacing_expr
99103
return replacements, group
100104

101-
def _prepare_annotations_for_aggregation_pipeline(self):
105+
def _prepare_annotations_for_aggregation_pipeline(self, order_by):
102106
"""Prepare annotations for the aggregation pipeline."""
103107
replacements = {}
104108
group = {}
@@ -110,6 +114,13 @@ def _prepare_annotations_for_aggregation_pipeline(self):
110114
)
111115
replacements.update(new_replacements)
112116
group.update(expr_group)
117+
for expr, _ in order_by:
118+
if expr.contains_aggregate:
119+
new_replacements, expr_group = self._prepare_expressions_for_pipeline(
120+
expr, None, annotation_group_idx
121+
)
122+
replacements.update(new_replacements)
123+
group.update(expr_group)
113124
having_replacements, having_group = self._prepare_expressions_for_pipeline(
114125
self.having, None, annotation_group_idx
115126
)
@@ -121,9 +132,10 @@ def _get_group_id_expressions(self, order_by):
121132
"""Generate group ID expressions for the aggregation pipeline."""
122133
group_expressions = set()
123134
replacements = {}
124-
for expr, (_, _, is_ref) in order_by:
125-
if not is_ref:
126-
group_expressions |= set(expr.get_group_by_cols())
135+
if not self._meta_ordering:
136+
for expr, (_, _, is_ref) in order_by:
137+
if not is_ref:
138+
group_expressions |= set(expr.get_group_by_cols())
127139
for expr, *_ in self.select:
128140
group_expressions |= set(expr.get_group_by_cols())
129141
having_group_by = self.having.get_group_by_cols() if self.having else ()
@@ -187,7 +199,7 @@ def _build_aggregation_pipeline(self, ids, group):
187199

188200
def pre_sql_setup(self, with_col_aliases=False):
189201
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()
202+
group, all_replacements = self._prepare_annotations_for_aggregation_pipeline(order_by)
191203
# query.group_by is either:
192204
# - None: no GROUP BY
193205
# - True: group by select fields
@@ -211,6 +223,9 @@ def pre_sql_setup(self, with_col_aliases=False):
211223
target: expr.replace_expressions(all_replacements)
212224
for target, expr in self.query.annotation_select.items()
213225
}
226+
self.order_by_expressions = [
227+
expr.replace_expressions(all_replacements) for expr, _ in order_by
228+
]
214229
return extra_select, order_by, group_by
215230

216231
def execute_sql(
@@ -333,8 +348,11 @@ def build_query(self, columns=None):
333348
self.check_query()
334349
query = self.query_class(self)
335350
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)
351+
ordering_fields, sort_ordering, extra_fields = self._get_ordering()
352+
query.project_fields = self.get_project_fields(columns, ordering_fields)
353+
query.ordering = sort_ordering
354+
if extra_fields and columns is None:
355+
query.extra_fields = self.get_project_fields(extra_fields)
338356
where = self.get_where()
339357
try:
340358
expr = where.as_mql(self, self.connection) if where else {}
@@ -380,52 +398,6 @@ def project_field(column):
380398
+ tuple(map(project_field, related_columns))
381399
)
382400

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-
429401
@cached_property
430402
def collection_name(self):
431403
return self.query.get_meta().db_table
@@ -473,12 +445,37 @@ def get_project_fields(self, columns=None, ordering=None):
473445
if self.query.alias_refcount[alias] and self.collection_name != alias:
474446
fields[alias] = 1
475447
# 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
448+
for alias, expression in ordering or []:
449+
nested_entity = alias.split(".", 1)[0] if "." in alias else None
450+
if alias not in fields and nested_entity not in fields:
451+
fields[alias] = expression.as_mql(self, self.connection)
480452
return fields
481453

454+
def _get_ordering(self):
455+
"""
456+
Process the query's OrderBy objects and return:
457+
- A tuple of ('field_name': Col/Expression, ...)
458+
- A bson.SON mapping to pass to $sort.
459+
- A tuple of ('field_name': Expression, ...) for expressions that need
460+
to be added to extra_fields.
461+
"""
462+
fields = {}
463+
sort_ordering = SON()
464+
extra_fields = {}
465+
idx = itertools.count(start=1)
466+
for order in self.order_by_expressions or []:
467+
if isinstance(order.expression, Ref):
468+
field_name = order.expression.refs
469+
elif isinstance(order.expression, Col):
470+
field_name = order.expression.as_mql(self, self.connection).removeprefix("$")
471+
else:
472+
# The expression must be added to extra_fields with an alias.
473+
field_name = f"__order{next(idx)}"
474+
extra_fields[field_name] = order.expression
475+
fields[field_name] = order.expression
476+
sort_ordering[field_name] = DESCENDING if order.descending else ASCENDING
477+
return tuple(fields.items()), sort_ordering, tuple(extra_fields.items())
478+
482479
def get_where(self):
483480
return self.where
484481

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: 4 additions & 20 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,8 +79,10 @@ 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:
101-
pipeline.append({"$sort": dict(self.ordering)})
85+
pipeline.append({"$sort": self.ordering})
10286
if self.query.low_mark > 0:
10387
pipeline.append({"$skip": self.query.low_mark})
10488
if self.query.high_mark is not None:

0 commit comments

Comments
 (0)