diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py index 2125fa2efec..36feb83f495 100644 --- a/django/db/backends/base/operations.py +++ b/django/db/backends/base/operations.py @@ -122,6 +122,13 @@ class BaseDatabaseOperations(object): """ raise NotImplementedError('subclasses of BaseDatabaseOperations may require a datetime_trunk_sql() method') + def time_extract_sql(self, lookup_type, field_name): + """ + Given a lookup_type of 'hour', 'minute' or 'second', returns the SQL + that extracts a value from the given time field field_name. + """ + return self.date_extract_sql(lookup_type, field_name) + def deferrable_sql(self): """ Returns the SQL necessary to make a constraint "initially deferred" diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 4bd9609e9d6..ce77103231b 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_datetime_cast_date", 2, _sqlite_datetime_cast_date) 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) conn.create_function("regexp", 2, _sqlite_regexp) conn.create_function("django_format_dtdelta", 3, _sqlite_format_dtdelta) conn.create_function("django_power", 2, _sqlite_power) @@ -402,6 +403,16 @@ def _sqlite_datetime_trunc(lookup_type, dt, tzname): return "%i-%02i-%02i %02i:%02i:%02i" % (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) +def _sqlite_time_extract(lookup_type, dt): + if dt is None: + return None + try: + dt = backend_utils.typecast_time(dt) + except (ValueError, TypeError): + return None + return getattr(dt, lookup_type) + + def _sqlite_format_dtdelta(conn, lhs, rhs): """ LHS and RHS can be either: diff --git a/django/db/backends/sqlite3/operations.py b/django/db/backends/sqlite3/operations.py index fe4a75fb378..f363556ced6 100644 --- a/django/db/backends/sqlite3/operations.py +++ b/django/db/backends/sqlite3/operations.py @@ -88,6 +88,13 @@ class DatabaseOperations(BaseDatabaseOperations): return "django_datetime_trunc('%s', %s, %%s)" % ( lookup_type.lower(), field_name), [tzname] + def time_extract_sql(self, lookup_type, field_name): + # sqlite doesn't support extract, so we fake it with the user-defined + # function django_time_extract that's registered in connect(). Note that + # single quotes are used because this is a string (and could otherwise + # cause a collision with a field name). + return "django_time_extract('%s', %s)" % (lookup_type.lower(), field_name) + def drop_foreignkey_sql(self): return "" diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index 3215927ea0b..b87af73994f 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -2416,9 +2416,12 @@ class DateTransform(Transform): tzname = timezone.get_current_timezone_name() if settings.USE_TZ else None sql, tz_params = connection.ops.datetime_extract_sql(self.lookup_name, sql, tzname) params.extend(tz_params) - else: - # DateField and TimeField. + elif isinstance(lhs_output_field, DateField): sql = connection.ops.date_extract_sql(self.lookup_name, sql) + elif isinstance(lhs_output_field, TimeField): + sql = connection.ops.time_extract_sql(self.lookup_name, sql) + else: + raise ValueError('DateTransform only valid on Date/Time/DateTimeFields') return sql, params @cached_property diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 0a0749d0c93..a333ce7b564 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -2606,23 +2606,30 @@ in the database `. hour ~~~~ -For datetime fields, an exact hour match. Allows chaining additional field -lookups. Takes an integer between 0 and 23. +For datetime and time fields, an exact hour match. Allows chaining additional +field lookups. Takes an integer between 0 and 23. Example:: Event.objects.filter(timestamp__hour=23) + Event.objects.filter(time__hour=5) Event.objects.filter(timestamp__hour__gte=12) SQL equivalent:: SELECT ... WHERE EXTRACT('hour' FROM timestamp) = '23'; + SELECT ... WHERE EXTRACT('hour' FROM time) = '5'; SELECT ... WHERE EXTRACT('hour' FROM timestamp) >= '12'; (The exact SQL syntax varies for each database engine.) -When :setting:`USE_TZ` is ``True``, values are converted to the current time -zone before filtering. +For datetime fields, when :setting:`USE_TZ` is ``True``, values are converted +to the current time zone before filtering. + +.. versionchanged:: 1.9 + + Added support for :class:`~django.db.models.TimeField` on SQLite (other + databases supported it as of 1.7). .. versionchanged:: 1.9 @@ -2633,23 +2640,30 @@ zone before filtering. minute ~~~~~~ -For datetime fields, an exact minute match. Allows chaining additional field -lookups. Takes an integer between 0 and 59. +For datetime and time fields, an exact minute match. Allows chaining additional +field lookups. Takes an integer between 0 and 59. Example:: Event.objects.filter(timestamp__minute=29) + Event.objects.filter(time__minute=46) Event.objects.filter(timestamp__minute__gte=29) SQL equivalent:: SELECT ... WHERE EXTRACT('minute' FROM timestamp) = '29'; + SELECT ... WHERE EXTRACT('minute' FROM time) = '46'; SELECT ... WHERE EXTRACT('minute' FROM timestamp) >= '29'; (The exact SQL syntax varies for each database engine.) -When :setting:`USE_TZ` is ``True``, values are converted to the current time -zone before filtering. +For datetime fields, When :setting:`USE_TZ` is ``True``, values are converted +to the current time zone before filtering. + +.. versionchanged:: 1.9 + + Added support for :class:`~django.db.models.TimeField` on SQLite (other + databases supported it as of 1.7). .. versionchanged:: 1.9 @@ -2660,23 +2674,30 @@ zone before filtering. second ~~~~~~ -For datetime fields, an exact second match. Allows chaining additional field -lookups. Takes an integer between 0 and 59. +For datetime and time fields, an exact second match. Allows chaining additional +field lookups. Takes an integer between 0 and 59. Example:: Event.objects.filter(timestamp__second=31) + Event.objects.filter(time__second=2) Event.objects.filter(timestamp__second__gte=31) SQL equivalent:: SELECT ... WHERE EXTRACT('second' FROM timestamp) = '31'; + SELECT ... WHERE EXTRACT('second' FROM time) = '2'; SELECT ... WHERE EXTRACT('second' FROM timestamp) >= '31'; (The exact SQL syntax varies for each database engine.) -When :setting:`USE_TZ` is ``True``, values are converted to the current time -zone before filtering. +For datetime fields, when :setting:`USE_TZ` is ``True``, values are converted +to the current time zone before filtering. + +.. versionchanged:: 1.9 + + Added support for :class:`~django.db.models.TimeField` on SQLite (other + databases supported it as of 1.7). .. versionchanged:: 1.9 diff --git a/docs/releases/1.9.txt b/docs/releases/1.9.txt index e06946aaa65..f43fe07f08e 100644 --- a/docs/releases/1.9.txt +++ b/docs/releases/1.9.txt @@ -245,6 +245,10 @@ Models (such as :lookup:`exact`, :lookup:`gt`, :lookup:`lt`, etc.). For example: ``Entry.objects.filter(pub_date__month__gt=6)``. +* Time lookups (hour, minute, second) are now supported by + :class:`~django.db.models.TimeField` for all database backends. Support for + backends other than SQLite was added but undocumented in Django 1.7. + * You can specify the ``output_field`` parameter of the :class:`~django.db.models.Avg` aggregate in order to aggregate over non-numeric columns, such as ``DurationField``. @@ -374,6 +378,12 @@ Database backend API * To use the new ``date`` lookup, third-party database backends may need to implement the ``DatabaseOperations.datetime_cast_date_sql()`` method. +* The ``DatabaseOperations.time_extract_sql()`` method was added. It calls the + existing ``date_extract_sql()`` method. This method is overridden by the + SQLite backend to add time lookups (hour, minute, second) to + :class:`~django.db.models.TimeField`, and may be needed by third-party + database backends. + Default settings that were tuples are now lists ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/lookup/models.py b/tests/lookup/models.py index 735602b3240..504bbf9d254 100644 --- a/tests/lookup/models.py +++ b/tests/lookup/models.py @@ -11,6 +11,14 @@ from django.utils import six from django.utils.encoding import python_2_unicode_compatible +class Alarm(models.Model): + desc = models.CharField(max_length=100) + time = models.TimeField() + + def __str__(self): + return '%s (%s)' % (self.time, self.desc) + + class Author(models.Model): name = models.CharField(max_length=100) diff --git a/tests/lookup/test_timefield.py b/tests/lookup/test_timefield.py new file mode 100644 index 00000000000..0a96992a32e --- /dev/null +++ b/tests/lookup/test_timefield.py @@ -0,0 +1,36 @@ +from __future__ import unicode_literals + +from django.test import TestCase + +from .models import Alarm + + +class TimeFieldLookupTests(TestCase): + + @classmethod + def setUpTestData(self): + # Create a few Alarms + self.al1 = Alarm.objects.create(desc='Early', time='05:30') + self.al2 = Alarm.objects.create(desc='Late', time='10:00') + self.al3 = Alarm.objects.create(desc='Precise', time='12:34:56') + + def test_hour_lookups(self): + self.assertQuerysetEqual( + Alarm.objects.filter(time__hour=5), + [''], + ordered=False + ) + + def test_minute_lookups(self): + self.assertQuerysetEqual( + Alarm.objects.filter(time__minute=30), + [''], + ordered=False + ) + + def test_second_lookups(self): + self.assertQuerysetEqual( + Alarm.objects.filter(time__second=56), + [''], + ordered=False + )