diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py index 63081ba113..bf9af0149a 100644 --- a/django/db/backends/base/operations.py +++ b/django/db/backends/base/operations.py @@ -96,6 +96,12 @@ class BaseDatabaseOperations(object): """ raise NotImplementedError('subclasses of BaseDatabaseOperations may require a datetime_cast_date() method') + def datetime_cast_time_sql(self, field_name, tzname): + """ + Returns the SQL necessary to cast a datetime value to time value. + """ + raise NotImplementedError('subclasses of BaseDatabaseOperations may require a datetime_cast_time_sql() method') + def datetime_extract_sql(self, lookup_type, field_name, tzname): """ Given a lookup_type of 'year', 'month', 'day', 'hour', 'minute' or diff --git a/django/db/backends/mysql/operations.py b/django/db/backends/mysql/operations.py index 5ced46f970..f0fd8314ae 100644 --- a/django/db/backends/mysql/operations.py +++ b/django/db/backends/mysql/operations.py @@ -51,6 +51,11 @@ class DatabaseOperations(BaseDatabaseOperations): sql = "DATE(%s)" % field_name return sql, params + def datetime_cast_time_sql(self, field_name, tzname): + field_name, params = self._convert_field_to_tz(field_name, tzname) + sql = "TIME(%s)" % field_name + return sql, params + def datetime_extract_sql(self, lookup_type, field_name, tzname): field_name, params = self._convert_field_to_tz(field_name, tzname) sql = self.date_extract_sql(lookup_type, field_name) diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py index 0a6a239956..3761ed0df1 100644 --- a/django/db/backends/oracle/operations.py +++ b/django/db/backends/oracle/operations.py @@ -128,6 +128,12 @@ WHEN (new.%(col_name)s IS NULL) sql = 'TRUNC(%s)' % field_name return sql, [] + def datetime_cast_time_sql(self, field_name, tzname): + # Since `TimeField` values are stored as TIMESTAMP where only the date + # part is ignored, convert the field to the specified timezone. + field_name = self._convert_field_to_tz(field_name, tzname) + return field_name, [] + def datetime_extract_sql(self, lookup_type, field_name, tzname): field_name = self._convert_field_to_tz(field_name, tzname) sql = self.date_extract_sql(lookup_type, field_name) diff --git a/django/db/backends/postgresql/operations.py b/django/db/backends/postgresql/operations.py index 2130571a05..c32ca040e7 100644 --- a/django/db/backends/postgresql/operations.py +++ b/django/db/backends/postgresql/operations.py @@ -45,6 +45,11 @@ class DatabaseOperations(BaseDatabaseOperations): sql = '(%s)::date' % field_name return sql, params + def datetime_cast_time_sql(self, field_name, tzname): + field_name, params = self._convert_field_to_tz(field_name, tzname) + sql = '(%s)::time' % field_name + return sql, params + def datetime_extract_sql(self, lookup_type, field_name, tzname): field_name, params = self._convert_field_to_tz(field_name, tzname) sql = self.date_extract_sql(lookup_type, field_name) diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 70d511f108..870dc10353 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -210,6 +210,7 @@ class DatabaseWrapper(BaseDatabaseWrapper): conn.create_function("django_date_extract", 2, _sqlite_date_extract) conn.create_function("django_date_trunc", 2, _sqlite_date_trunc) conn.create_function("django_datetime_cast_date", 2, _sqlite_datetime_cast_date) + conn.create_function("django_datetime_cast_time", 2, _sqlite_datetime_cast_time) conn.create_function("django_datetime_extract", 3, _sqlite_datetime_extract) conn.create_function("django_datetime_trunc", 3, _sqlite_datetime_trunc) conn.create_function("django_time_extract", 2, _sqlite_time_extract) @@ -403,6 +404,13 @@ def _sqlite_datetime_cast_date(dt, tzname): return dt.date().isoformat() +def _sqlite_datetime_cast_time(dt, tzname): + dt = _sqlite_datetime_parse(dt, tzname) + if dt is None: + return None + return dt.time().isoformat() + + def _sqlite_datetime_extract(lookup_type, dt, tzname): dt = _sqlite_datetime_parse(dt, tzname) if dt is None: diff --git a/django/db/backends/sqlite3/operations.py b/django/db/backends/sqlite3/operations.py index 4b7fc091db..bf3002b79b 100644 --- a/django/db/backends/sqlite3/operations.py +++ b/django/db/backends/sqlite3/operations.py @@ -85,6 +85,10 @@ class DatabaseOperations(BaseDatabaseOperations): self._require_pytz() return "django_datetime_cast_date(%s, %%s)" % field_name, [tzname] + def datetime_cast_time_sql(self, field_name, tzname): + self._require_pytz() + return "django_datetime_cast_time(%s, %%s)" % field_name, [tzname] + def datetime_extract_sql(self, lookup_type, field_name, tzname): # Same comment as in date_extract_sql. self._require_pytz() diff --git a/django/db/models/functions/__init__.py b/django/db/models/functions/__init__.py index 14f1f69d01..dd45d7bb4c 100644 --- a/django/db/models/functions/__init__.py +++ b/django/db/models/functions/__init__.py @@ -5,7 +5,7 @@ from .base import ( from .datetime import ( Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, ExtractSecond, ExtractWeekDay, ExtractYear, Trunc, TruncDate, TruncDay, - TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncYear, + TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime, TruncYear, ) __all__ = [ @@ -16,5 +16,5 @@ __all__ = [ 'Extract', 'ExtractDay', 'ExtractHour', 'ExtractMinute', 'ExtractMonth', 'ExtractSecond', 'ExtractWeekDay', 'ExtractYear', 'Trunc', 'TruncDate', 'TruncDay', 'TruncHour', 'TruncMinute', 'TruncMonth', - 'TruncSecond', 'TruncYear', + 'TruncSecond', 'TruncTime', 'TruncYear', ] diff --git a/django/db/models/functions/datetime.py b/django/db/models/functions/datetime.py index 85a398a50b..9cbdcb0231 100644 --- a/django/db/models/functions/datetime.py +++ b/django/db/models/functions/datetime.py @@ -239,6 +239,23 @@ class TruncDate(TruncBase): return sql, lhs_params +class TruncTime(TruncBase): + kind = 'time' + lookup_name = 'time' + + @cached_property + def output_field(self): + return TimeField() + + def as_sql(self, compiler, connection): + # Cast to date rather than truncate to date. + lhs, lhs_params = compiler.compile(self.lhs) + tzname = timezone.get_current_timezone_name() if settings.USE_TZ else None + sql, tz_params = connection.ops.datetime_cast_time_sql(lhs, tzname) + lhs_params.extend(tz_params) + return sql, lhs_params + + class TruncHour(TruncBase): kind = 'hour' @@ -252,3 +269,4 @@ class TruncSecond(TruncBase): DateTimeField.register_lookup(TruncDate) +DateTimeField.register_lookup(TruncTime) diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index 20ceadde91..74198ed749 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -686,6 +686,17 @@ that deal with time-parts can be used with ``TimeField``:: truncate function. It's also registered as a transform on ``DateTimeField`` as ``__date``. +.. class:: TruncTime(expression, **extra) + +.. versionadded:: 1.11 + + .. attribute:: lookup_name = 'time' + .. attribute:: output_field = TimeField() + +``TruncTime`` casts ``expression`` to a time rather than using the built-in SQL +truncate function. It's also registered as a transform on ``DateTimeField`` as +``__time``. + .. class:: TruncDay(expression, output_field=None, tzinfo=None, **extra) .. attribute:: kind = 'day' diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 6985da5ca6..36d791db0e 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -2674,6 +2674,27 @@ When :setting:`USE_TZ` is ``True``, datetime fields are converted to the current time zone before filtering. This requires :ref:`time zone definitions in the database `. +.. fieldlookup:: time + +``time`` +~~~~~~~~ + +.. versionadded:: 1.11 + +For datetime fields, casts the value as time. Allows chaining additional field +lookups. Takes a :class:`datetime.time` value. + +Example:: + + Entry.objects.filter(pub_date__time=datetime.time(14, 30)) + Entry.objects.filter(pub_date__time__between=(datetime.time(8), datetime.time(17))) + +(No equivalent SQL code fragment is included for this lookup because +implementation of the relevant query varies among different database engines.) + +When :setting:`USE_TZ` is ``True``, fields are converted to the current time +zone before filtering. + .. fieldlookup:: hour ``hour`` diff --git a/docs/releases/1.11.txt b/docs/releases/1.11.txt index 392ded70b8..0ed83a1da8 100644 --- a/docs/releases/1.11.txt +++ b/docs/releases/1.11.txt @@ -201,6 +201,10 @@ Models * Added support for time truncation to :class:`~django.db.models.functions.datetime.Trunc` functions. +* Added the :class:`~django.db.models.functions.datetime.TruncTime` function + to truncate :class:`~django.db.models.DateTimeField` to its time component + and exposed it through the :lookup:`time` lookup. + Requests and Responses ~~~~~~~~~~~~~~~~~~~~~~ @@ -273,6 +277,10 @@ Database backend API ``lookup_type`` argument can be either ``'hour'``, ``'minute'``, or ``'second'``. +* The ``DatabaseOperations.datetime_cast_time_sql()`` method is added to + support the :lookup:`time` lookup. It accepts a ``field_name`` and ``tzname`` + arguments and returns the SQL necessary to cast a datetime value to time value. + Dropped support for PostgreSQL 9.2 and PostGIS 2.0 -------------------------------------------------- diff --git a/tests/db_functions/test_datetime.py b/tests/db_functions/test_datetime.py index e727ea5b7d..1b44c9a658 100644 --- a/tests/db_functions/test_datetime.py +++ b/tests/db_functions/test_datetime.py @@ -9,7 +9,7 @@ from django.db.models import DateField, DateTimeField, IntegerField, TimeField from django.db.models.functions import ( Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, ExtractSecond, ExtractWeekDay, ExtractYear, Trunc, TruncDate, TruncDay, - TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncYear, + TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime, TruncYear, ) from django.test import TestCase, override_settings from django.utils import timezone @@ -512,6 +512,30 @@ class DateFunctionTests(TestCase): with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateField"): list(DTModel.objects.annotate(truncated=TruncDate('start_time', output_field=TimeField()))) + def test_trunc_time_func(self): + start_datetime = microsecond_support(datetime(2015, 6, 15, 14, 30, 50, 321)) + end_datetime = microsecond_support(datetime(2016, 6, 15, 14, 10, 50, 123)) + if settings.USE_TZ: + start_datetime = timezone.make_aware(start_datetime, is_dst=False) + end_datetime = timezone.make_aware(end_datetime, is_dst=False) + self.create_model(start_datetime, end_datetime) + self.create_model(end_datetime, start_datetime) + self.assertQuerysetEqual( + DTModel.objects.annotate(extracted=TruncTime('start_datetime')).order_by('start_datetime'), + [ + (start_datetime, start_datetime.time()), + (end_datetime, end_datetime.time()), + ], + lambda m: (m.start_datetime, m.extracted) + ) + self.assertEqual(DTModel.objects.filter(start_datetime__time=TruncTime('start_datetime')).count(), 2) + + with self.assertRaisesMessage(ValueError, "Cannot truncate DateField 'start_date' to TimeField"): + list(DTModel.objects.annotate(truncated=TruncTime('start_date'))) + + with self.assertRaisesMessage(ValueError, "Cannot truncate DateField 'start_date' to TimeField"): + list(DTModel.objects.annotate(truncated=TruncTime('start_date', output_field=DateField()))) + def test_trunc_day_func(self): start_datetime = microsecond_support(datetime(2015, 6, 15, 14, 30, 50, 321)) end_datetime = truncate_to(microsecond_support(datetime(2016, 6, 15, 14, 10, 50, 123)), 'day')