diff --git a/django/db/backends/mysql/features.py b/django/db/backends/mysql/features.py index 419b2ba6f05..eb80b2e543c 100644 --- a/django/db/backends/mysql/features.py +++ b/django/db/backends/mysql/features.py @@ -68,6 +68,10 @@ class DatabaseFeatures(BaseDatabaseFeatures): "returns JSON": { 'schema.tests.SchemaTests.test_func_index_json_key_transform', }, + "MySQL supports multiplying and dividing DurationFields by a " + "scalar value but it's not implemented (#25287).": { + 'expressions.tests.FTimeDeltaTests.test_durationfield_multiply_divide', + }, } if 'ONLY_FULL_GROUP_BY' in self.connection.sql_mode: skips.update({ diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 35466189e6c..a64190f0d07 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -563,6 +563,7 @@ def _sqlite_format_dtdelta(conn, lhs, rhs): LHS and RHS can be either: - An integer number of microseconds - A string representing a datetime + - A scalar value, e.g. float """ conn = conn.strip() try: @@ -574,8 +575,12 @@ def _sqlite_format_dtdelta(conn, lhs, rhs): # typecast_timestamp returns a date or a datetime without timezone. # It will be formatted as "%Y-%m-%d" or "%Y-%m-%d %H:%M:%S[.%f]" out = str(real_lhs + real_rhs) - else: + elif conn == '-': out = str(real_lhs - real_rhs) + elif conn == '*': + out = real_lhs * real_rhs + else: + out = real_lhs / real_rhs return out diff --git a/django/db/backends/sqlite3/operations.py b/django/db/backends/sqlite3/operations.py index 95484931cf7..90a42418039 100644 --- a/django/db/backends/sqlite3/operations.py +++ b/django/db/backends/sqlite3/operations.py @@ -351,7 +351,7 @@ class DatabaseOperations(BaseDatabaseOperations): return super().combine_expression(connector, sub_expressions) def combine_duration_expression(self, connector, sub_expressions): - if connector not in ['+', '-']: + if connector not in ['+', '-', '*', '/']: raise DatabaseError('Invalid connector for timedelta: %s.' % connector) fn_params = ["'%s'" % connector] + sub_expressions if len(fn_params) > 3: diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py index 4ecae5f02d8..528d988e854 100644 --- a/django/db/models/expressions.py +++ b/django/db/models/expressions.py @@ -6,7 +6,7 @@ from decimal import Decimal from uuid import UUID from django.core.exceptions import EmptyResultSet, FieldError -from django.db import NotSupportedError, connection +from django.db import DatabaseError, NotSupportedError, connection from django.db.models import fields from django.db.models.constants import LOOKUP_SEP from django.db.models.query_utils import Q @@ -546,6 +546,24 @@ class DurationExpression(CombinedExpression): sql = connection.ops.combine_duration_expression(self.connector, expressions) return expression_wrapper % sql, expression_params + def as_sqlite(self, compiler, connection, **extra_context): + sql, params = self.as_sql(compiler, connection, **extra_context) + if self.connector in {Combinable.MUL, Combinable.DIV}: + try: + lhs_type = self.lhs.output_field.get_internal_type() + rhs_type = self.rhs.output_field.get_internal_type() + except (AttributeError, FieldError): + pass + else: + allowed_fields = { + 'DecimalField', 'DurationField', 'FloatField', 'IntegerField', + } + if lhs_type not in allowed_fields or rhs_type not in allowed_fields: + raise DatabaseError( + f'Invalid arguments for operator {self.connector}.' + ) + return sql, params + class TemporalSubtraction(CombinedExpression): output_field = fields.DurationField() diff --git a/docs/releases/4.0.txt b/docs/releases/4.0.txt index 3792c4b716f..faf3d0fa1ee 100644 --- a/docs/releases/4.0.txt +++ b/docs/releases/4.0.txt @@ -233,6 +233,9 @@ Models * :meth:`.QuerySet.bulk_create` now sets the primary key on objects when using SQLite 3.35+. +* :class:`~django.db.models.DurationField` now supports multiplying and + dividing by scalar values on SQLite. + Requests and Responses ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/expressions/models.py b/tests/expressions/models.py index 02836e653ea..938e623d60d 100644 --- a/tests/expressions/models.py +++ b/tests/expressions/models.py @@ -60,6 +60,7 @@ class Experiment(models.Model): estimated_time = models.DurationField() start = models.DateTimeField() end = models.DateTimeField() + scalar = models.IntegerField(null=True) class Meta: db_table = 'expressions_ExPeRiMeNt' diff --git a/tests/expressions/tests.py b/tests/expressions/tests.py index 9b88c94d7b8..0585805a8b6 100644 --- a/tests/expressions/tests.py +++ b/tests/expressions/tests.py @@ -1530,6 +1530,36 @@ class FTimeDeltaTests(TestCase): )) self.assertIsNone(queryset.first().shifted) + def test_durationfield_multiply_divide(self): + Experiment.objects.update(scalar=2) + tests = [ + (Decimal('2'), 2), + (F('scalar'), 2), + (2, 2), + (3.2, 3.2), + ] + for expr, scalar in tests: + with self.subTest(expr=expr): + qs = Experiment.objects.annotate( + multiplied=ExpressionWrapper( + expr * F('estimated_time'), + output_field=DurationField(), + ), + divided=ExpressionWrapper( + F('estimated_time') / expr, + output_field=DurationField(), + ), + ) + for experiment in qs: + self.assertEqual( + experiment.multiplied, + experiment.estimated_time * scalar, + ) + self.assertEqual( + experiment.divided, + experiment.estimated_time / scalar, + ) + def test_duration_expressions(self): for delta in self.deltas: qs = Experiment.objects.annotate(duration=F('estimated_time') + delta)