Skip to content

Commit b8ae851

Browse files
committed
add support for datetime database functions
1 parent 6b62e6d commit b8ae851

File tree

5 files changed

+123
-24
lines changed

5 files changed

+123
-24
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

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ DATABASES = {
6262

6363
- `DateTimeField` doesn't support microsecond precision.
6464

65+
- The following database functions aren't supported:
66+
- `ExtractQuarter`
67+
- `Now`
68+
- `Sign`
69+
- `TruncDate`
70+
- `TruncTime`
71+
72+
- The `tzinfo` parameter of the `Trunc` database functions doesn't work
73+
properly because MongoDB converts the result back to UTC.
74+
6575
## Troubleshooting
6676

6777
TODO

django_mongodb/features.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
1919
# Database defaults not supported: bson.errors.InvalidDocument:
2020
# cannot encode object: <django.db.models.expressions.DatabaseDefault
2121
"basic.tests.ModelInstanceCreationTests.test_save_primary_with_db_default",
22-
# Date lookups aren't implemented: https://github.com/mongodb-labs/django-mongodb/issues/9
23-
# (e.g. ExtractWeekDay is not supported.)
24-
"basic.tests.ModelLookupTest.test_does_not_exist",
25-
"basic.tests.ModelLookupTest.test_equal_lookup",
26-
"basic.tests.ModelLookupTest.test_rich_lookup",
22+
# Query for chained lookups not generated correctly.
2723
"lookup.tests.LookupTests.test_chain_date_time_lookups",
28-
"lookup.test_timefield.TimeFieldLookupTests.test_hour_lookups",
29-
"lookup.test_timefield.TimeFieldLookupTests.test_minute_lookups",
30-
"lookup.test_timefield.TimeFieldLookupTests.test_second_lookups",
31-
"timezones.tests.LegacyDatabaseTests.test_query_datetime_lookups",
32-
"timezones.tests.NewDatabaseTests.test_query_convert_timezones",
33-
"timezones.tests.NewDatabaseTests.test_query_datetime_lookups",
34-
"timezones.tests.NewDatabaseTests.test_query_datetime_lookups_in_other_timezone",
3524
# 'NulledTransform' object has no attribute 'as_mql'.
3625
"lookup.tests.LookupTests.test_exact_none_transform",
3726
# "Save with update_fields did not affect any rows."
@@ -62,6 +51,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
6251
"db_functions.math.test_log.LogTests.test_decimal",
6352
# MongoDB gives ROUND(365, -1)=360 instead of 370 like other databases.
6453
"db_functions.math.test_round.RoundTests.test_integer_with_negative_precision",
54+
# Truncating in another timezone doesn't work becauase MongoDB converts
55+
# the result back to UTC.
56+
"db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_func_with_timezone",
57+
"db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_timezone_applied_before_truncation",
6558
}
6659

6760
django_test_skips = {
@@ -171,6 +164,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
171164
"lookup.tests.LookupQueryingTests.test_filter_lookup_lhs",
172165
# Subquery not supported.
173166
"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",
174169
"lookup.tests.LookupQueryingTests.test_filter_subquery_lhs",
175170
# ExpressionWrapper not supported.
176171
"annotations.tests.NonAggregateAnnotationTestCase.test_combined_expression_annotation_with_aggregation",
@@ -206,6 +201,11 @@ class DatabaseFeatures(BaseDatabaseFeatures):
206201
"annotations.tests.AliasTests.test_order_by_alias",
207202
"annotations.tests.NonAggregateAnnotationTestCase.test_order_by_aggregate",
208203
"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",
209209
},
210210
"Count doesn't work in QuerySet.annotate()": {
211211
"annotations.tests.AliasTests.test_alias_annotate_with_aggregation",
@@ -312,9 +312,25 @@ class DatabaseFeatures(BaseDatabaseFeatures):
312312
"db_functions.math.test_sqrt.SqrtTests.test_transform",
313313
"db_functions.math.test_tan.TanTests.test_transform",
314314
},
315-
"MongoDB does not support Sign.": {
315+
"MongoDB does not support this database function.": {
316+
"db_functions.datetime.test_now.NowTests",
316317
"db_functions.math.test_sign.SignTests",
317318
},
319+
"ExtractQuarter database function not supported.": {
320+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_quarter_func",
321+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_quarter_func_boundaries",
322+
},
323+
"TruncDate database function not supported.": {
324+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_date_func",
325+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_date_none",
326+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_lookup_name_sql_injection",
327+
"timezones.tests.NewDatabaseTests.test_query_convert_timezones",
328+
},
329+
"TruncTime database function not supported.": {
330+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_time_comparison",
331+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_time_func",
332+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_time_none",
333+
},
318334
"MongoDB can't annotate ($project) a function like PI().": {
319335
"db_functions.math.test_pi.PiTests.test",
320336
},

django_mongodb/functions.py

Lines changed: 41 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

@@ -14,6 +27,18 @@
1427
Random: "rand",
1528
Upper: "toUpper",
1629
}
30+
EXTRACT_OPERATORS = {
31+
ExtractDay.lookup_name: "dayOfMonth",
32+
ExtractHour.lookup_name: "hour",
33+
ExtractIsoWeekDay.lookup_name: "isoDayOfWeek",
34+
ExtractIsoYear.lookup_name: "isoWeekYear",
35+
ExtractMinute.lookup_name: "minute",
36+
ExtractMonth.lookup_name: "month",
37+
ExtractSecond.lookup_name: "second",
38+
ExtractWeek.lookup_name: "isoWeek",
39+
ExtractWeekDay.lookup_name: "dayOfWeek",
40+
ExtractYear.lookup_name: "year",
41+
}
1742

1843

1944
def cot(self, compiler, connection):
@@ -23,15 +48,12 @@ def cot(self, compiler, connection):
2348

2449
def extract(self, compiler, connection):
2550
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:
51+
operator = EXTRACT_OPERATORS.get(self.lookup_name)
52+
if operator is None:
3353
raise NotSupportedError("%s is not supported." % self.__class__.__name__)
34-
return {operator: lhs_mql}
54+
if timezone := self.get_tzname():
55+
lhs_mql = {"date": lhs_mql, "timezone": timezone}
56+
return {f"${operator}": lhs_mql}
3557

3658

3759
def func(self, compiler, connection):
@@ -53,9 +75,18 @@ def round_(self, compiler, connection):
5375
return {"$round": [expr.as_mql(compiler, connection) for expr in self.get_source_expressions()]}
5476

5577

78+
def trunc(self, compiler, connection):
79+
lhs_mql = process_lhs(self, compiler, connection)
80+
lhs_mql = {"date": lhs_mql, "unit": self.kind, "startOfWeek": "mon"}
81+
if timezone := self.get_tzname():
82+
lhs_mql["timezone"] = timezone
83+
return {"$dateTrunc": lhs_mql}
84+
85+
5686
def register_functions():
5787
Cot.as_mql_agg = cot
58-
Extract.as_mql = extract
88+
Extract.as_mql = Extract.as_mql_agg = extract
5989
Func.as_mql_agg = func
6090
Log.as_mql_agg = log
6191
Round.as_mql_agg = round_
92+
TruncBase.as_mql = TruncBase.as_mql_agg = trunc

django_mongodb/operations.py

Lines changed: 41 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):
@@ -133,3 +134,43 @@ def _prep_lookup_value(self, value, field, field_kind, lookup):
133134
if field_kind == "DecimalField":
134135
value = self.adapt_decimalfield_value(value, field.max_digits, field.decimal_places)
135136
return value
137+
138+
"""Django uses these methods to generate SQL queries before it generates MQL queries."""
139+
140+
# EXTRACT format cannot be passed in parameters.
141+
_extract_format_re = _lazy_re_compile(r"[A-Z_]+")
142+
143+
def date_extract_sql(self, lookup_type, sql, params):
144+
if lookup_type == "week_day":
145+
# For consistency across backends, we return Sunday=1, Saturday=7.
146+
return f"EXTRACT(DOW FROM {sql}) + 1", params
147+
if lookup_type == "iso_week_day":
148+
return f"EXTRACT(ISODOW FROM {sql})", params
149+
if lookup_type == "iso_year":
150+
return f"EXTRACT(ISOYEAR FROM {sql})", params
151+
152+
lookup_type = lookup_type.upper()
153+
if not self._extract_format_re.fullmatch(lookup_type):
154+
raise ValueError(f"Invalid lookup type: {lookup_type!r}")
155+
return f"EXTRACT({lookup_type} FROM {sql})", params
156+
157+
def datetime_extract_sql(self, lookup_type, sql, params, tzname):
158+
if lookup_type == "second":
159+
# Truncate fractional seconds.
160+
return f"EXTRACT(SECOND FROM DATE_TRUNC(%s, {sql}))", ("second", *params)
161+
return self.date_extract_sql(lookup_type, sql, params)
162+
163+
def datetime_trunc_sql(self, lookup_type, sql, params, tzname):
164+
return f"DATE_TRUNC(%s, {sql})", (lookup_type, *params)
165+
166+
def date_trunc_sql(self, lookup_type, sql, params, tzname=None):
167+
return f"DATE_TRUNC(%s, {sql})", (lookup_type, *params)
168+
169+
def datetime_cast_date_sql(self, sql, params, tzname):
170+
return f"({sql})::date", params
171+
172+
def datetime_cast_time_sql(self, sql, params, tzname):
173+
return f"({sql})::time", params
174+
175+
def time_trunc_sql(self, lookup_type, sql, params, tzname=None):
176+
return f"DATE_TRUNC(%s, {sql})::time", (lookup_type, *params)

0 commit comments

Comments
 (0)