Skip to content

Commit c3ebfc7

Browse files
committed
fix pattern lookup matching on F expressions
1 parent 9cfe204 commit c3ebfc7

File tree

6 files changed

+49
-37
lines changed

6 files changed

+49
-37
lines changed

.github/workflows/test-python.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ jobs:
7676
datetimes
7777
db_functions
7878
empty
79+
expressions.tests.BasicExpressionsTests.test_ticket_11722_iexact_lookup
80+
expressions.tests.BasicExpressionsTests.test_ticket_16731_startswith_lookup
7981
expressions.tests.ExpressionOperatorTests
82+
expressions.tests.ExpressionsTests.test_insensitive_patterns_escape
83+
expressions.tests.ExpressionsTests.test_patterns_escape
8084
expressions.tests.FieldTransformTests.test_transform_in_values
8185
expressions.tests.NegatedExpressionTests
8286
expressions_case

django_mongodb/base.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import re
2-
31
from django.core.exceptions import ImproperlyConfigured
42
from django.db.backends.base.base import BaseDatabaseWrapper
53
from django.db.backends.signals import connection_created
@@ -82,15 +80,15 @@ class DatabaseWrapper(BaseDatabaseWrapper):
8280
"in": lambda a, b: {"$in": [a, b]},
8381
"isnull": lambda a, b: {("$eq" if b else "$ne"): [a, None]},
8482
"range": lambda a, b: {"$and": [{"$gte": [a, b[0]]}, {"$lte": [a, b[1]]}]},
85-
"iexact": lambda a, b: regex_match(a, b, "^%s$", re.IGNORECASE),
86-
"startswith": lambda a, b: regex_match(a, b, "^%s"),
87-
"istartswith": lambda a, b: regex_match(a, b, "^%s", re.IGNORECASE),
88-
"endswith": lambda a, b: regex_match(a, b, "%s$"),
89-
"iendswith": lambda a, b: regex_match(a, b, "%s$", re.IGNORECASE),
90-
"contains": lambda a, b: regex_match(a, b, "%s"),
91-
"icontains": lambda a, b: regex_match(a, b, "%s", re.IGNORECASE),
92-
"regex": lambda a, b: regex_match(a, "", f"{b}%s"),
93-
"iregex": lambda a, b: regex_match(a, "", f"{b}%s", re.IGNORECASE),
83+
"iexact": lambda a, b: regex_match(a, ("^", b, {"$literal": "$"}), insensitive=True),
84+
"startswith": lambda a, b: regex_match(a, ("^", b)),
85+
"istartswith": lambda a, b: regex_match(a, ("^", b), insensitive=True),
86+
"endswith": lambda a, b: regex_match(a, (b, {"$literal": "$"})),
87+
"iendswith": lambda a, b: regex_match(a, (b, {"$literal": "$"}), insensitive=True),
88+
"contains": lambda a, b: regex_match(a, b),
89+
"icontains": lambda a, b: regex_match(a, b, insensitive=True),
90+
"regex": lambda a, b: regex_match(a, b),
91+
"iregex": lambda a, b: regex_match(a, b, insensitive=True),
9492
}
9593

9694
display_name = "MongoDB"

django_mongodb/features.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
4343
"lookup.tests.LookupTests.test_exact_sliced_queryset_limit_one_offset",
4444
# Regex lookup doesn't work on non-string fields.
4545
"lookup.tests.LookupTests.test_regex_non_string",
46-
# Substr not implemented.
47-
"lookup.tests.LookupTests.test_pattern_lookups_with_substr",
4846
# Querying ObjectID with string doesn't work.
4947
"lookup.tests.LookupTests.test_lookup_int_as_str",
5048
# MongoDB gives the wrong result of log(number, base) when base is a

django_mongodb/lookups.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from django.db import NotSupportedError
22
from django.db.models.fields.related_lookups import In, MultiColSource, RelatedIn
3-
from django.db.models.lookups import BuiltinLookup, IsNull, UUIDTextMixin
3+
from django.db.models.lookups import BuiltinLookup, IsNull, PatternLookup, UUIDTextMixin
44

55
from .query_utils import process_lhs, process_rhs
66

@@ -24,6 +24,24 @@ def is_null(self, compiler, connection):
2424
return connection.mongo_operators["isnull"](lhs_mql, self.rhs)
2525

2626

27+
def pattern_lookup_prep_lookup_value(self, value):
28+
if hasattr(self.rhs, "as_mql"):
29+
# If value is a column reference, escape regex special characters.
30+
# Analogous to PatternLookup.get_rhs_op() / pattern_esc.
31+
for find, replacement in (("\\", r"\\"), ("%", r"\%"), ("_", r"\_")):
32+
value = {"$replaceAll": {"input": value, "find": find, "replacement": replacement}}
33+
else:
34+
# If value is a literal, remove percent signs added by
35+
# PatternLookup.process_rhs() for LIKE queries.
36+
if self.lookup_name in ("startswith", "istartswith"):
37+
value = value[:-1]
38+
elif self.lookup_name in ("endswith", "iendswith"):
39+
value = value[1:]
40+
elif self.lookup_name in ("contains", "icontains"):
41+
value = value[1:-1]
42+
return value
43+
44+
2745
def uuid_text_mixin(self, compiler, connection): # noqa: ARG001
2846
raise NotSupportedError("Pattern lookups on UUIDField are not supported.")
2947

@@ -32,4 +50,5 @@ def register_lookups():
3250
BuiltinLookup.as_mql = builtin_lookup
3351
In.as_mql = RelatedIn.as_mql = in_
3452
IsNull.as_mql = is_null
53+
PatternLookup.prep_lookup_value_mongo = pattern_lookup_prep_lookup_value
3554
UUIDTextMixin.as_mql = uuid_text_mixin

django_mongodb/operations.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import re
23
import uuid
34

45
from bson.decimal128 import Decimal128
@@ -108,8 +109,8 @@ def combine_expression(self, connector, sub_expressions):
108109
return {f"${operator}": sub_expressions}
109110

110111
def prep_for_like_query(self, x):
111-
# Override value escaping for LIKE queries.
112-
return str(x)
112+
"""Escape "x" for $regexMatch queries."""
113+
return re.escape(x)
113114

114115
def quote_name(self, name):
115116
if name.startswith('"') and name.endswith('"'):

django_mongodb/query_utils.py

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import re
2-
31
from django.core.exceptions import FullResultSet
42
from django.db.models.expressions import Value
53

@@ -27,25 +25,19 @@ def process_lhs(node, compiler, connection):
2725
def process_rhs(node, compiler, connection):
2826
rhs = node.rhs
2927
if hasattr(rhs, "as_mql"):
30-
return rhs.as_mql(compiler, connection)
31-
_, value = node.process_rhs(compiler, connection)
32-
lookup_name = node.lookup_name
33-
# Undo Lookup.get_db_prep_lookup() putting params in a list.
34-
if lookup_name not in ("in", "range"):
35-
value = value[0]
36-
# Remove percent signs added by PatternLookup.process_rhs() for LIKE
37-
# queries.
38-
if lookup_name in ("startswith", "istartswith"):
39-
value = value[:-1]
40-
elif lookup_name in ("endswith", "iendswith"):
41-
value = value[1:]
42-
elif lookup_name in ("contains", "icontains"):
43-
value = value[1:-1]
44-
28+
value = rhs.as_mql(compiler, connection)
29+
else:
30+
_, value = node.process_rhs(compiler, connection)
31+
lookup_name = node.lookup_name
32+
# Undo Lookup.get_db_prep_lookup() putting params in a list.
33+
if lookup_name not in ("in", "range"):
34+
value = value[0]
35+
if hasattr(node, "prep_lookup_value_mongo"):
36+
value = node.prep_lookup_value_mongo(value)
4537
return connection.ops.prep_lookup_value(value, node.lhs.output_field, node.lookup_name)
4638

4739

48-
def regex_match(field, value, regex, *re_args, **re_kwargs):
49-
regex = re.compile(regex % re.escape(value), *re_args, **re_kwargs)
50-
options = "i" if regex.flags & re.I else ""
51-
return {"$regexMatch": {"input": field, "regex": regex.pattern, "options": options}}
40+
def regex_match(field, regex_vals, insensitive=False):
41+
regex = {"$concat": regex_vals} if isinstance(regex_vals, tuple) else regex_vals
42+
options = "i" if insensitive else ""
43+
return {"$regexMatch": {"input": field, "regex": regex, "options": options}}

0 commit comments

Comments
 (0)