Skip to content

Commit b784710

Browse files
committed
implement date lookups
1 parent 78fd337 commit b784710

File tree

4 files changed

+111
-31
lines changed

4 files changed

+111
-31
lines changed

.github/workflows/test-python.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ jobs:
6363
- name: Start MongoDB
6464
uses: supercharge/[email protected]
6565
with:
66-
mongodb-version: 4.4
66+
mongodb-version: 5.0
6767
- name: Run tests
6868
run: >
6969
python3 django_repo/tests/runtests.py --settings mongodb_settings -v 2
@@ -74,6 +74,7 @@ jobs:
7474
bulk_create
7575
dates
7676
datetimes
77+
db_functions.datetime
7778
db_functions.math
7879
empty
7980
defer

django_mongodb/features.py

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
77
supports_ignore_conflicts = False
88
# Not implemented: https://github.com/mongodb-labs/django-mongodb/issues/8
99
supports_json_field = False
10+
# BSON Date type doesn't support microsecond precision
11+
supports_microsecond_precision = False
1012
# MongoDB stores datetimes in UTC.
1113
supports_timezones = False
1214
# Not implemented: https://github.com/mongodb-labs/django-mongodb/issues/7
@@ -17,19 +19,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
1719
# Database defaults not supported: bson.errors.InvalidDocument:
1820
# cannot encode object: <django.db.models.expressions.DatabaseDefault
1921
"basic.tests.ModelInstanceCreationTests.test_save_primary_with_db_default",
20-
# Date lookups aren't implemented: https://github.com/mongodb-labs/django-mongodb/issues/9
21-
# (e.g. ExtractWeekDay is not supported.)
22-
"basic.tests.ModelLookupTest.test_does_not_exist",
23-
"basic.tests.ModelLookupTest.test_equal_lookup",
24-
"basic.tests.ModelLookupTest.test_rich_lookup",
25-
"lookup.tests.LookupTests.test_chain_date_time_lookups",
26-
"lookup.test_timefield.TimeFieldLookupTests.test_hour_lookups",
27-
"lookup.test_timefield.TimeFieldLookupTests.test_minute_lookups",
28-
"lookup.test_timefield.TimeFieldLookupTests.test_second_lookups",
29-
"timezones.tests.LegacyDatabaseTests.test_query_datetime_lookups",
22+
# TruncDate isn't implemented.
3023
"timezones.tests.NewDatabaseTests.test_query_convert_timezones",
31-
"timezones.tests.NewDatabaseTests.test_query_datetime_lookups",
32-
"timezones.tests.NewDatabaseTests.test_query_datetime_lookups_in_other_timezone",
24+
# Query for chained looksup not generated correctly.
25+
"lookup.tests.LookupTests.test_chain_date_time_lookups",
3326
# 'NulledTransform' object has no attribute 'as_mql'.
3427
"lookup.tests.LookupTests.test_exact_none_transform",
3528
# "Save with update_fields did not affect any rows."
@@ -60,6 +53,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
6053
"db_functions.math.test_log.LogTests.test_decimal",
6154
# MongoDB gives ROUND(365, -1)=360 instead of 370 like other databases.
6255
"db_functions.math.test_round.RoundTests.test_integer_with_negative_precision",
56+
# Wrong result truncating in other time zone.
57+
"db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_timezone_applied_before_truncation",
6358
}
6459

6560
django_test_skips = {
@@ -169,6 +164,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
169164
"lookup.tests.LookupQueryingTests.test_filter_lookup_lhs",
170165
# Subquery not supported.
171166
"annotations.tests.NonAggregateAnnotationTestCase.test_empty_queryset_annotation",
167+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_outerref",
168+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_subquery_with_parameters",
172169
"lookup.tests.LookupQueryingTests.test_filter_subquery_lhs",
173170
# ExpressionWrapper not supported.
174171
"annotations.tests.NonAggregateAnnotationTestCase.test_combined_expression_annotation_with_aggregation",
@@ -204,6 +201,11 @@ class DatabaseFeatures(BaseDatabaseFeatures):
204201
"annotations.tests.AliasTests.test_order_by_alias",
205202
"annotations.tests.NonAggregateAnnotationTestCase.test_order_by_aggregate",
206203
"annotations.tests.NonAggregateAnnotationTestCase.test_order_by_annotation",
204+
# annotate().filter().count() gives incorrect results.
205+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_year_exact_lookup",
206+
# Year lookup + lt/gt crashes: 'dict' object has no attribute 'startswith'
207+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_year_greaterthan_lookup",
208+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_year_lessthan_lookup",
207209
},
208210
"Count doesn't work in QuerySet.annotate()": {
209211
"annotations.tests.AliasTests.test_alias_annotate_with_aggregation",
@@ -292,14 +294,22 @@ class DatabaseFeatures(BaseDatabaseFeatures):
292294
"timezones.tests.NewDatabaseTests.test_cursor_explicit_time_zone",
293295
"timezones.tests.NewDatabaseTests.test_raw_sql",
294296
},
295-
"BSON Date type doesn't support microsecond precision.": {
296-
"basic.tests.ModelRefreshTests.test_refresh_unsaved",
297-
"basic.tests.ModelTest.test_microsecond_precision",
298-
"timezones.tests.LegacyDatabaseTests.test_auto_now_and_auto_now_add",
299-
"timezones.tests.LegacyDatabaseTests.test_aware_datetime_in_local_timezone_with_microsecond",
300-
"timezones.tests.LegacyDatabaseTests.test_naive_datetime_with_microsecond",
301-
"timezones.tests.NewDatabaseTests.test_aware_datetime_in_local_timezone_with_microsecond",
302-
"timezones.tests.NewDatabaseTests.test_naive_datetime_with_microsecond",
297+
"ExtractQuarter not supported.": {
298+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_quarter_func",
299+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_quarter_func_boundaries",
300+
},
301+
"TruncDate (datetime to date) not supported.": {
302+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_date_func",
303+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_date_none",
304+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_lookup_name_sql_injection",
305+
},
306+
"TruncTime (datetime to time) not supported.": {
307+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_time_comparison",
308+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_time_func",
309+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_time_none",
310+
},
311+
"No Now() database function in MongoDB.": {
312+
"db_functions.datetime.test_now.NowTests",
303313
},
304314
"Transform not supported.": {
305315
"db_functions.math.test_abs.AbsTests.test_transform",

django_mongodb/functions.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
from django.db import NotSupportedError
22
from django.db.models.expressions import Func
3-
from django.db.models.functions.datetime import Extract
3+
from django.db.models.functions.datetime import (
4+
Extract,
5+
ExtractDay,
6+
ExtractHour,
7+
ExtractIsoWeekDay,
8+
ExtractIsoYear,
9+
ExtractMinute,
10+
ExtractMonth,
11+
ExtractSecond,
12+
ExtractWeek,
13+
ExtractWeekDay,
14+
ExtractYear,
15+
TruncBase,
16+
)
417
from django.db.models.functions.math import Ceil, Cot, Degrees, Log, Power, Radians, Random, Round
518
from django.db.models.functions.text import Upper
619

@@ -9,6 +22,16 @@
922
MONGO_OPERATORS = {
1023
Ceil: "ceil",
1124
Degrees: "radiansToDegrees",
25+
ExtractDay.lookup_name: "dayOfMonth",
26+
ExtractHour.lookup_name: "hour",
27+
ExtractIsoWeekDay.lookup_name: "isoDayOfWeek",
28+
ExtractIsoYear.lookup_name: "isoWeekYear",
29+
ExtractMinute.lookup_name: "minute",
30+
ExtractMonth.lookup_name: "month",
31+
ExtractSecond.lookup_name: "second",
32+
ExtractWeek.lookup_name: "isoWeek",
33+
ExtractWeekDay.lookup_name: "dayOfWeek",
34+
ExtractYear.lookup_name: "year",
1235
Power: "pow",
1336
Radians: "degreesToRadians",
1437
Random: "rand",
@@ -23,14 +46,12 @@ def cot(self, compiler, connection):
2346

2447
def extract(self, compiler, connection):
2548
lhs_mql = process_lhs(self, compiler, connection)
26-
if self.lookup_name == "week":
27-
operator = "$week"
28-
elif self.lookup_name == "month":
29-
operator = "$month"
30-
elif self.lookup_name == "year":
31-
operator = "$year"
32-
else:
33-
raise NotSupportedError("%s is not supported." % self.__class__.__name__)
49+
try:
50+
operator = f"${MONGO_OPERATORS[self.lookup_name]}"
51+
except KeyError:
52+
raise NotSupportedError("%s is not supported." % self.__class__.__name__) from None
53+
if timezone := self.get_tzname():
54+
lhs_mql = {"date": lhs_mql, "timezone": timezone}
3455
return {operator: lhs_mql}
3556

3657

@@ -53,9 +74,18 @@ def round_(self, compiler, connection):
5374
return {"$round": [expr.as_mql(compiler, connection) for expr in self.get_source_expressions()]}
5475

5576

77+
def trunc(self, compiler, connection):
78+
lhs_mql = process_lhs(self, compiler, connection)
79+
lhs_mql = {"date": lhs_mql, "unit": self.kind, "startOfWeek": "mon"}
80+
if timezone := self.get_tzname():
81+
lhs_mql["timezone"] = timezone
82+
return {"$dateTrunc": lhs_mql}
83+
84+
5685
def register_functions():
5786
Cot.as_mql_agg = cot
58-
Extract.as_mql = extract
87+
Extract.as_mql = Extract.as_mql_agg = extract
5988
Func.as_mql_agg = func
6089
Log.as_mql_agg = log
6190
Round.as_mql_agg = round_
91+
TruncBase.as_mql = TruncBase.as_mql_agg = trunc

django_mongodb/operations.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.conf import settings
66
from django.db.backends.base.operations import BaseDatabaseOperations
77
from django.utils import timezone
8+
from django.utils.regex_helper import _lazy_re_compile
89

910

1011
class DatabaseOperations(BaseDatabaseOperations):
@@ -43,6 +44,44 @@ def adapt_timefield_value(self, value):
4344
raise ValueError("MongoDB backend does not support timezone-aware times.")
4445
return datetime.datetime.combine(datetime.datetime.min.date(), value)
4546

47+
# EXTRACT format cannot be passed in parameters.
48+
_extract_format_re = _lazy_re_compile(r"[A-Z_]+")
49+
50+
def date_extract_sql(self, lookup_type, sql, params):
51+
if lookup_type == "week_day":
52+
# For consistency across backends, we return Sunday=1, Saturday=7.
53+
return f"EXTRACT(DOW FROM {sql}) + 1", params
54+
if lookup_type == "iso_week_day":
55+
return f"EXTRACT(ISODOW FROM {sql})", params
56+
if lookup_type == "iso_year":
57+
return f"EXTRACT(ISOYEAR FROM {sql})", params
58+
59+
lookup_type = lookup_type.upper()
60+
if not self._extract_format_re.fullmatch(lookup_type):
61+
raise ValueError(f"Invalid lookup type: {lookup_type!r}")
62+
return f"EXTRACT({lookup_type} FROM {sql})", params
63+
64+
def datetime_extract_sql(self, lookup_type, sql, params, tzname):
65+
if lookup_type == "second":
66+
# Truncate fractional seconds.
67+
return f"EXTRACT(SECOND FROM DATE_TRUNC(%s, {sql}))", ("second", *params)
68+
return self.date_extract_sql(lookup_type, sql, params)
69+
70+
def datetime_trunc_sql(self, lookup_type, sql, params, tzname):
71+
return f"DATE_TRUNC(%s, {sql})", (lookup_type, *params)
72+
73+
def date_trunc_sql(self, lookup_type, sql, params, tzname=None):
74+
return f"DATE_TRUNC(%s, {sql})", (lookup_type, *params)
75+
76+
def datetime_cast_date_sql(self, sql, params, tzname):
77+
return f"({sql})::date", params
78+
79+
def datetime_cast_time_sql(self, sql, params, tzname):
80+
return f"({sql})::time", params
81+
82+
def time_trunc_sql(self, lookup_type, sql, params, tzname=None):
83+
return f"DATE_TRUNC(%s, {sql})::time", (lookup_type, *params)
84+
4685
def get_db_converters(self, expression):
4786
converters = super().get_db_converters(expression)
4887
internal_type = expression.output_field.get_internal_type()

0 commit comments

Comments
 (0)