Skip to content

Commit 6bb94c1

Browse files
committed
WIP.
1 parent 2787e0d commit 6bb94c1

File tree

8 files changed

+104
-34
lines changed

8 files changed

+104
-34
lines changed

django_mongodb_backend/base.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from .features import DatabaseFeatures
2121
from .introspection import DatabaseIntrospection
2222
from .operations import DatabaseOperations
23-
from .query_utils import regex_match
23+
from .query_utils import regex_expr, regex_match
2424
from .schema import DatabaseSchemaEditor
2525
from .utils import OperationDebugWrapper
2626
from .validation import DatabaseValidation
@@ -108,7 +108,12 @@ def _isnull_operator(a, b):
108108
}
109109
return is_null if b else {"$not": is_null}
110110

111-
mongo_operators = {
111+
def _isnull_operator_match(a, b):
112+
if b:
113+
return {"$or": [{a: {"$exists": False}}, {a: None}]}
114+
return {"$and": [{a: {"$exists": True}}, {a: {"$ne": None}}]}
115+
116+
mongo_operators_expr = {
112117
"exact": lambda a, b: {"$eq": [a, b]},
113118
"gt": lambda a, b: {"$gt": [a, b]},
114119
"gte": lambda a, b: {"$gte": [a, b]},
@@ -126,6 +131,37 @@ def _isnull_operator(a, b):
126131
{"$or": [DatabaseWrapper._isnull_operator(b[1], True), {"$lte": [a, b[1]]}]},
127132
]
128133
},
134+
"iexact": lambda a, b: regex_expr(a, ("^", b, {"$literal": "$"}), insensitive=True),
135+
"startswith": lambda a, b: regex_expr(a, ("^", b)),
136+
"istartswith": lambda a, b: regex_expr(a, ("^", b), insensitive=True),
137+
"endswith": lambda a, b: regex_expr(a, (b, {"$literal": "$"})),
138+
"iendswith": lambda a, b: regex_expr(a, (b, {"$literal": "$"}), insensitive=True),
139+
"contains": lambda a, b: regex_expr(a, b),
140+
"icontains": lambda a, b: regex_expr(a, b, insensitive=True),
141+
"regex": lambda a, b: regex_expr(a, b),
142+
"iregex": lambda a, b: regex_expr(a, b, insensitive=True),
143+
}
144+
145+
mongo_operators_match = {
146+
"exact": lambda a, b: {a: b},
147+
"gt": lambda a, b: {a: {"$gt": b}},
148+
"gte": lambda a, b: {a: {"$gte": b}},
149+
# MongoDB considers null less than zero. Exclude null values to match
150+
# SQL behavior.
151+
"lt": lambda a, b: {
152+
"$and": [{a: {"$lt": b}}, DatabaseWrapper._isnull_operator_match(a, False)]
153+
},
154+
"lte": lambda a, b: {
155+
"$and": [{a: {"$lte": b}}, DatabaseWrapper._isnull_operator_match(a, False)]
156+
},
157+
"in": lambda a, b: {a: {"$in": list(b)}},
158+
"isnull": _isnull_operator_match,
159+
"range": lambda a, b: {
160+
"$and": [
161+
{"$or": [DatabaseWrapper._isnull_operator_match(b[0], True), {a: {"$gte": b[0]}}]},
162+
{"$or": [DatabaseWrapper._isnull_operator_match(b[1], True), {a: {"$lte": b[1]}}]},
163+
]
164+
},
129165
"iexact": lambda a, b: regex_match(a, ("^", b, {"$literal": "$"}), insensitive=True),
130166
"startswith": lambda a, b: regex_match(a, ("^", b)),
131167
"istartswith": lambda a, b: regex_match(a, ("^", b), insensitive=True),

django_mongodb_backend/compiler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ def build_query(self, columns=None):
485485
except FullResultSet:
486486
query.match_mql = {}
487487
else:
488-
query.match_mql = {"$expr": expr}
488+
query.match_mql = expr
489489
if extra_fields:
490490
query.extra_fields = self.get_project_fields(extra_fields, force_expression=True)
491491
query.subqueries = self.subqueries

django_mongodb_backend/expressions/builtins.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def case(self, compiler, connection):
3333
for case in self.cases:
3434
case_mql = {}
3535
try:
36-
case_mql["case"] = case.as_mql(compiler, connection)
36+
case_mql["case"] = case.as_mql(compiler, connection, as_expr=True)
3737
except EmptyResultSet:
3838
continue
3939
except FullResultSet:
@@ -152,7 +152,7 @@ def raw_sql(self, compiler, connection): # noqa: ARG001
152152
raise NotSupportedError("RawSQL is not supported on MongoDB.")
153153

154154

155-
def ref(self, compiler, connection): # noqa: ARG001
155+
def ref(self, compiler, connection, as_path=False): # noqa: ARG001
156156
prefix = (
157157
f"{self.source.alias}."
158158
if isinstance(self.source, Col) and self.source.alias != compiler.collection_name
@@ -162,7 +162,9 @@ def ref(self, compiler, connection): # noqa: ARG001
162162
refs, _ = compiler.columns[self.ordinal - 1]
163163
else:
164164
refs = self.refs
165-
return f"${prefix}{refs}"
165+
if not as_path:
166+
prefix = f"${prefix}"
167+
return f"{prefix}{refs}"
166168

167169

168170
def star(self, compiler, connection): # noqa: ARG001
@@ -181,8 +183,8 @@ def exists(self, compiler, connection, get_wrapping_pipeline=None):
181183
return connection.mongo_operators["isnull"](lhs_mql, False)
182184

183185

184-
def when(self, compiler, connection):
185-
return self.condition.as_mql(compiler, connection)
186+
def when(self, compiler, connection, **extra):
187+
return self.condition.as_mql(compiler, connection, **extra)
186188

187189

188190
def value(self, compiler, connection): # noqa: ARG001

django_mongodb_backend/fields/json.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from itertools import chain
2+
13
from django.db import NotSupportedError
24
from django.db.models.fields.json import (
35
ContainedBy,
@@ -13,12 +15,14 @@
1315
KeyTransformNumericLookupMixin,
1416
)
1517

16-
from ..lookups import builtin_lookup
18+
from ..lookups import builtin_lookup, is_constant_value
1719
from ..query_utils import process_lhs, process_rhs
1820

1921

20-
def build_json_mql_path(lhs, key_transforms):
22+
def build_json_mql_path(lhs, key_transforms, as_path=False):
2123
# Build the MQL path using the collected key transforms.
24+
if as_path:
25+
return ".".join(chain([lhs], key_transforms))
2226
result = lhs
2327
for key in key_transforms:
2428
get_field = {"$getField": {"input": result, "field": key}}
@@ -45,8 +49,12 @@ def data_contains(self, compiler, connection): # noqa: ARG001
4549
raise NotSupportedError("contains lookup is not supported on this database backend.")
4650

4751

48-
def _has_key_predicate(path, root_column, negated=False):
52+
def _has_key_predicate(path, root_column, negated=False, as_path=False):
4953
"""Return MQL to check for the existence of `path`."""
54+
if as_path:
55+
if not negated:
56+
return {"$and": [{path: {"$exists": True}}, {path: {"$ne": None}}]}
57+
return {"$or": [{path: {"$exists": False}}, {path: None}]}
5058
result = {
5159
"$and": [
5260
# The path must exist (i.e. not be "missing").
@@ -64,18 +72,20 @@ def _has_key_predicate(path, root_column, negated=False):
6472
def has_key_lookup(self, compiler, connection):
6573
"""Return MQL to check for the existence of a key."""
6674
rhs = self.rhs
75+
as_path = is_constant_value(rhs)
6776
lhs = process_lhs(self, compiler, connection)
6877
if not isinstance(rhs, (list, tuple)):
6978
rhs = [rhs]
7079
paths = []
7180
# Transform any "raw" keys into KeyTransforms to allow consistent handling
7281
# in the code that follows.
82+
7383
for key in rhs:
7484
rhs_json_path = key if isinstance(key, KeyTransform) else KeyTransform(key, self.lhs)
75-
paths.append(rhs_json_path.as_mql(compiler, connection))
85+
paths.append(rhs_json_path.as_mql(compiler, connection, as_path=as_path))
7686
keys = []
7787
for path in paths:
78-
keys.append(_has_key_predicate(path, lhs))
88+
keys.append(_has_key_predicate(path, lhs, as_path=as_path))
7989
if self.mongo_operator is None:
8090
return keys[0]
8191
return {self.mongo_operator: keys}
@@ -93,7 +103,7 @@ def json_exact_process_rhs(self, compiler, connection):
93103
)
94104

95105

96-
def key_transform(self, compiler, connection):
106+
def key_transform(self, compiler, connection, **extra):
97107
"""
98108
Return MQL for this KeyTransform (JSON path).
99109
@@ -108,8 +118,8 @@ def key_transform(self, compiler, connection):
108118
while isinstance(previous, KeyTransform):
109119
key_transforms.insert(0, previous.key_name)
110120
previous = previous.lhs
111-
lhs_mql = previous.as_mql(compiler, connection)
112-
return build_json_mql_path(lhs_mql, key_transforms)
121+
lhs_mql = previous.as_mql(compiler, connection, **extra)
122+
return build_json_mql_path(lhs_mql, key_transforms, **extra)
113123

114124

115125
def key_transform_in(self, compiler, connection):

django_mongodb_backend/functions.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,12 @@ def preserve_null(operator):
146146
def wrapped(self, compiler, connection):
147147
lhs_mql = process_lhs(self, compiler, connection)
148148
return {
149-
"$cond": {
150-
"if": connection.mongo_operators["isnull"](lhs_mql, True),
151-
"then": None,
152-
"else": {f"${operator}": lhs_mql},
149+
"$expr": {
150+
"$cond": {
151+
"if": connection.mongo_operators_expr["isnull"](lhs_mql, True),
152+
"then": None,
153+
"else": {f"${operator}": lhs_mql},
154+
}
153155
}
154156
}
155157

django_mongodb_backend/lookups.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.db import NotSupportedError
2+
from django.db.models.expressions import Value
23
from django.db.models.fields.related_lookups import In, RelatedIn
34
from django.db.models.lookups import (
45
BuiltinLookup,
@@ -8,13 +9,21 @@
89
UUIDTextMixin,
910
)
1011

11-
from .query_utils import process_lhs, process_rhs
12+
from .query_utils import is_direct_value, process_lhs, process_rhs
1213

1314

14-
def builtin_lookup(self, compiler, connection):
15-
lhs_mql = process_lhs(self, compiler, connection)
15+
def is_constant_value(value):
16+
return is_direct_value(value) or isinstance(value, Value)
17+
18+
19+
def builtin_lookup(self, compiler, connection, as_expr=False):
1620
value = process_rhs(self, compiler, connection)
17-
return connection.mongo_operators[self.lookup_name](lhs_mql, value)
21+
if is_constant_value(self.rhs) and not as_expr:
22+
lhs_mql = process_lhs(self, compiler, connection, as_path=True)
23+
return connection.mongo_operators_match[self.lookup_name](lhs_mql, value)
24+
25+
lhs_mql = process_lhs(self, compiler, connection)
26+
return {"$expr": connection.mongo_operators_expr[self.lookup_name](lhs_mql, value)}
1827

1928

2029
_field_resolve_expression_parameter = FieldGetDbPrepValueIterableMixin.resolve_expression_parameter
@@ -75,11 +84,14 @@ def get_subquery_wrapping_pipeline(self, compiler, connection, field_name, expr)
7584
]
7685

7786

78-
def is_null(self, compiler, connection):
87+
def is_null(self, compiler, connection, as_expr=False):
7988
if not isinstance(self.rhs, bool):
8089
raise ValueError("The QuerySet value for an isnull lookup must be True or False.")
90+
if is_constant_value(self.rhs) and not as_expr:
91+
lhs_mql = process_lhs(self, compiler, connection, as_path=True)
92+
return connection.mongo_operators_match["isnull"](lhs_mql, self.rhs)
8193
lhs_mql = process_lhs(self, compiler, connection)
82-
return connection.mongo_operators["isnull"](lhs_mql, self.rhs)
94+
return {"$expr": connection.mongo_operators_expr["isnull"](lhs_mql, self.rhs)}
8395

8496

8597
# from https://www.pcre.org/current/doc/html/pcre2pattern.html#SEC4

django_mongodb_backend/query.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ def _get_reroot_replacements(expression):
211211
compiler, connection
212212
)
213213
)
214+
extra_conditions = {"$and": extra_conditions} if extra_conditions else {}
214215
lookup_pipeline = [
215216
{
216217
"$lookup": {
@@ -236,8 +237,8 @@ def _get_reroot_replacements(expression):
236237
{"$eq": [f"$${parent_template}{i}", field]}
237238
for i, field in enumerate(rhs_fields)
238239
]
239-
+ extra_conditions
240-
}
240+
},
241+
**extra_conditions,
241242
}
242243
}
243244
],
@@ -331,7 +332,7 @@ def where_node(self, compiler, connection):
331332
raise FullResultSet
332333

333334
if self.negated and mql:
334-
mql = {"$not": mql}
335+
mql = {"$nor": mql}
335336

336337
return mql
337338

django_mongodb_backend/query_utils.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,24 @@ def is_direct_value(node):
77
return not hasattr(node, "as_sql")
88

99

10-
def process_lhs(node, compiler, connection):
10+
def process_lhs(node, compiler, connection, **extra):
1111
if not hasattr(node, "lhs"):
1212
# node is a Func or Expression, possibly with multiple source expressions.
1313
result = []
1414
for expr in node.get_source_expressions():
1515
if expr is None:
1616
continue
1717
try:
18-
result.append(expr.as_mql(compiler, connection))
18+
result.append(expr.as_mql(compiler, connection, **extra))
1919
except FullResultSet:
20-
result.append(Value(True).as_mql(compiler, connection))
20+
result.append(Value(True).as_mql(compiler, connection, **extra))
2121
if isinstance(node, Aggregate):
2222
return result[0]
2323
return result
2424
# node is a Transform with just one source expression, aliased as "lhs".
2525
if is_direct_value(node.lhs):
2626
return node
27-
return node.lhs.as_mql(compiler, connection)
27+
return node.lhs.as_mql(compiler, connection, **extra)
2828

2929

3030
def process_rhs(node, compiler, connection):
@@ -47,7 +47,14 @@ def process_rhs(node, compiler, connection):
4747
return value
4848

4949

50-
def regex_match(field, regex_vals, insensitive=False):
50+
def regex_expr(field, regex_vals, insensitive=False):
5151
regex = {"$concat": regex_vals} if isinstance(regex_vals, tuple) else regex_vals
5252
options = "i" if insensitive else ""
5353
return {"$regexMatch": {"input": field, "regex": regex, "options": options}}
54+
55+
56+
def regex_match(field, regex_vals, insensitive=False):
57+
regex = {"$concat": regex_vals} if isinstance(regex_vals, tuple) else regex_vals
58+
options = "i" if insensitive else ""
59+
# return {"$regexMatch": {"input": field, "regex": regex, "options": options}}
60+
return {field: {"$regex": regex, "$options": options}}

0 commit comments

Comments
 (0)