Skip to content

Commit 8bbfc61

Browse files
committed
add support for datetime database functions
1 parent 46320f4 commit 8bbfc61

File tree

5 files changed

+126
-30
lines changed

5 files changed

+126
-30
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: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,12 @@ 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."
3827
"basic.tests.SelectOnSaveTests.test_select_on_save_lying_update",
39-
# 'TruncDate' object has no attribute 'as_mql'.
40-
"model_fields.test_datetimefield.DateTimeFieldTests.test_lookup_date_with_use_tz",
41-
"model_fields.test_datetimefield.DateTimeFieldTests.test_lookup_date_without_use_tz",
42-
# BaseDatabaseOperations.date_extract_sql() not implemented.
43-
"annotations.tests.AliasTests.test_basic_alias_f_transform_annotation",
4428
# Slicing with QuerySet.count() doesn't work.
4529
"lookup.tests.LookupTests.test_count",
4630
# Lookup in order_by() not supported:
@@ -62,6 +46,10 @@ class DatabaseFeatures(BaseDatabaseFeatures):
6246
"db_functions.math.test_log.LogTests.test_decimal",
6347
# MongoDB gives ROUND(365, -1)=360 instead of 370 like other databases.
6448
"db_functions.math.test_round.RoundTests.test_integer_with_negative_precision",
49+
# Truncating in another timezone doesn't work becauase MongoDB converts
50+
# the result back to UTC.
51+
"db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_func_with_timezone",
52+
"db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_trunc_timezone_applied_before_truncation",
6553
}
6654

6755
django_test_skips = {
@@ -158,6 +146,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
158146
"lookup.tests.LookupTests.test_nested_outerref_lhs",
159147
"lookup.tests.LookupQueryingTests.test_filter_exists_lhs",
160148
# QuerySet.alias() doesn't work.
149+
"annotations.tests.AliasTests.test_basic_alias_f_transform_annotation",
161150
"annotations.tests.NonAggregateAnnotationTestCase.test_annotation_and_alias_filter_in_subquery",
162151
"lookup.tests.LookupQueryingTests.test_alias",
163152
# annotate() with combined expressions doesn't work:
@@ -171,6 +160,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
171160
"lookup.tests.LookupQueryingTests.test_filter_lookup_lhs",
172161
# Subquery not supported.
173162
"annotations.tests.NonAggregateAnnotationTestCase.test_empty_queryset_annotation",
163+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_outerref",
164+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_subquery_with_parameters",
174165
"lookup.tests.LookupQueryingTests.test_filter_subquery_lhs",
175166
# ExpressionWrapper not supported.
176167
"annotations.tests.NonAggregateAnnotationTestCase.test_combined_expression_annotation_with_aggregation",
@@ -198,14 +189,17 @@ class DatabaseFeatures(BaseDatabaseFeatures):
198189
# Coalesce not implemented.
199190
"annotations.tests.AliasTests.test_alias_annotation_expression",
200191
"annotations.tests.NonAggregateAnnotationTestCase.test_full_expression_wrapped_annotation",
201-
# BaseDatabaseOperations may require a datetime_extract_sql().
202-
"annotations.tests.NonAggregateAnnotationTestCase.test_joined_transformed_annotation",
203192
# BaseDatabaseOperations may require a format_for_duration_arithmetic().
204193
"annotations.tests.NonAggregateAnnotationTestCase.test_mixed_type_annotation_date_interval",
205194
# FieldDoesNotExist with ordering.
206195
"annotations.tests.AliasTests.test_order_by_alias",
207196
"annotations.tests.NonAggregateAnnotationTestCase.test_order_by_aggregate",
208197
"annotations.tests.NonAggregateAnnotationTestCase.test_order_by_annotation",
198+
# annotate().filter().count() gives incorrect results.
199+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_year_exact_lookup",
200+
# Year lookup + lt/gt crashes: 'dict' object has no attribute 'startswith'
201+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_year_greaterthan_lookup",
202+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_year_lessthan_lookup",
209203
},
210204
"Count doesn't work in QuerySet.annotate()": {
211205
"annotations.tests.AliasTests.test_alias_annotate_with_aggregation",
@@ -246,6 +240,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
246240
"annotations.tests.NonAggregateAnnotationTestCase.test_annotation_and_alias_filter_related_in_subquery",
247241
"annotations.tests.NonAggregateAnnotationTestCase.test_annotation_filter_with_subquery",
248242
"annotations.tests.NonAggregateAnnotationTestCase.test_annotation_reverse_m2m",
243+
"annotations.tests.NonAggregateAnnotationTestCase.test_joined_transformed_annotation",
249244
"annotations.tests.NonAggregateAnnotationTestCase.test_mti_annotations",
250245
"annotations.tests.NonAggregateAnnotationTestCase.test_values_with_pk_annotation",
251246
"annotations.tests.NonAggregateAnnotationTestCase.test_annotation_subquery_outerref_transform",
@@ -312,9 +307,27 @@ class DatabaseFeatures(BaseDatabaseFeatures):
312307
"db_functions.math.test_sqrt.SqrtTests.test_transform",
313308
"db_functions.math.test_tan.TanTests.test_transform",
314309
},
315-
"MongoDB does not support Sign.": {
310+
"MongoDB does not support this database function.": {
311+
"db_functions.datetime.test_now.NowTests",
316312
"db_functions.math.test_sign.SignTests",
317313
},
314+
"ExtractQuarter database function not supported.": {
315+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_quarter_func",
316+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_quarter_func_boundaries",
317+
},
318+
"TruncDate database function not supported.": {
319+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_date_func",
320+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_date_none",
321+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_lookup_name_sql_injection",
322+
"model_fields.test_datetimefield.DateTimeFieldTests.test_lookup_date_with_use_tz",
323+
"model_fields.test_datetimefield.DateTimeFieldTests.test_lookup_date_without_use_tz",
324+
"timezones.tests.NewDatabaseTests.test_query_convert_timezones",
325+
},
326+
"TruncTime database function not supported.": {
327+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_time_comparison",
328+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_time_func",
329+
"db_functions.datetime.test_extract_trunc.DateFunctionTests.test_trunc_time_none",
330+
},
318331
"MongoDB can't annotate ($project) a function like PI().": {
319332
"db_functions.math.test_pi.PiTests.test",
320333
},

django_mongodb/functions.py

Lines changed: 40 additions & 9 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 = cot
5888
Extract.as_mql = extract
5989
Func.as_mql = func
6090
Log.as_mql = log
6191
Round.as_mql = round_
92+
TruncBase.as_mql = 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)