Fixed #28650 -- Added TruncWeek database function.

This commit is contained in:
Sigurd Ljødal 2017-09-28 22:39:03 +02:00 committed by Tim Graham
parent f229049d6c
commit a455e732a0
12 changed files with 116 additions and 16 deletions

View File

@ -53,6 +53,10 @@ class DatabaseOperations(BaseDatabaseOperations):
return "MAKEDATE(YEAR(%s), 1) + INTERVAL QUARTER(%s) QUARTER - INTERVAL 1 QUARTER" % ( return "MAKEDATE(YEAR(%s), 1) + INTERVAL QUARTER(%s) QUARTER - INTERVAL 1 QUARTER" % (
field_name, field_name field_name, field_name
) )
elif lookup_type == 'week':
return "DATE_SUB(%s, INTERVAL WEEKDAY(%s) DAY)" % (
field_name, field_name
)
else: else:
return "DATE(%s)" % (field_name) return "DATE(%s)" % (field_name)
@ -84,6 +88,12 @@ class DatabaseOperations(BaseDatabaseOperations):
"INTERVAL QUARTER({field_name}) QUARTER - " + "INTERVAL QUARTER({field_name}) QUARTER - " +
"INTERVAL 1 QUARTER, '%%Y-%%m-01 00:00:00') AS DATETIME)" "INTERVAL 1 QUARTER, '%%Y-%%m-01 00:00:00') AS DATETIME)"
).format(field_name=field_name) ).format(field_name=field_name)
if lookup_type == 'week':
return (
"CAST(DATE_FORMAT(DATE_SUB({field_name}, "
"INTERVAL WEEKDAY({field_name}) DAY), "
"'%%Y-%%m-%%d 00:00:00') AS DATETIME)"
).format(field_name=field_name)
try: try:
i = fields.index(lookup_type) + 1 i = fields.index(lookup_type) + 1
except ValueError: except ValueError:

View File

@ -78,6 +78,8 @@ END;
return "TRUNC(%s, '%s')" % (field_name, lookup_type.upper()) return "TRUNC(%s, '%s')" % (field_name, lookup_type.upper())
elif lookup_type == 'quarter': elif lookup_type == 'quarter':
return "TRUNC(%s, 'Q')" % field_name return "TRUNC(%s, 'Q')" % field_name
elif lookup_type == 'week':
return "TRUNC(%s, 'IW')" % field_name
else: else:
return "TRUNC(%s)" % field_name return "TRUNC(%s)" % field_name
@ -116,6 +118,8 @@ END;
sql = "TRUNC(%s, '%s')" % (field_name, lookup_type.upper()) sql = "TRUNC(%s, '%s')" % (field_name, lookup_type.upper())
elif lookup_type == 'quarter': elif lookup_type == 'quarter':
sql = "TRUNC(%s, 'Q')" % field_name sql = "TRUNC(%s, 'Q')" % field_name
elif lookup_type == 'week':
sql = "TRUNC(%s, 'IW')" % field_name
elif lookup_type == 'day': elif lookup_type == 'day':
sql = "TRUNC(%s)" % field_name sql = "TRUNC(%s)" % field_name
elif lookup_type == 'hour': elif lookup_type == 'hour':

View File

@ -335,6 +335,9 @@ def _sqlite_date_trunc(lookup_type, dt):
return '%i-%02i-01' % (dt.year, month_in_quarter) return '%i-%02i-01' % (dt.year, month_in_quarter)
elif lookup_type == 'month': elif lookup_type == 'month':
return "%i-%02i-01" % (dt.year, dt.month) return "%i-%02i-01" % (dt.year, dt.month)
elif lookup_type == 'week':
dt = dt - datetime.timedelta(days=dt.weekday())
return "%i-%02i-%02i" % (dt.year, dt.month, dt.day)
elif lookup_type == 'day': elif lookup_type == 'day':
return "%i-%02i-%02i" % (dt.year, dt.month, dt.day) return "%i-%02i-%02i" % (dt.year, dt.month, dt.day)
@ -403,6 +406,9 @@ def _sqlite_datetime_trunc(lookup_type, dt, tzname):
return '%i-%02i-01 00:00:00' % (dt.year, month_in_quarter) return '%i-%02i-01 00:00:00' % (dt.year, month_in_quarter)
elif lookup_type == 'month': elif lookup_type == 'month':
return "%i-%02i-01 00:00:00" % (dt.year, dt.month) return "%i-%02i-01 00:00:00" % (dt.year, dt.month)
elif lookup_type == 'week':
dt = dt - datetime.timedelta(days=dt.weekday())
return "%i-%02i-%02i 00:00:00" % (dt.year, dt.month, dt.day)
elif lookup_type == 'day': elif lookup_type == 'day':
return "%i-%02i-%02i 00:00:00" % (dt.year, dt.month, dt.day) return "%i-%02i-%02i 00:00:00" % (dt.year, dt.month, dt.day)
elif lookup_type == 'hour': elif lookup_type == 'hour':

View File

@ -3,7 +3,7 @@ from .datetime import (
Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth,
ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear, ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear,
Now, Trunc, TruncDate, TruncDay, TruncHour, TruncMinute, TruncMonth, Now, Trunc, TruncDate, TruncDay, TruncHour, TruncMinute, TruncMonth,
TruncQuarter, TruncSecond, TruncTime, TruncYear, TruncQuarter, TruncSecond, TruncTime, TruncWeek, TruncYear,
) )
from .text import ( from .text import (
Concat, ConcatPair, Length, Lower, Replace, StrIndex, Substr, Upper, Concat, ConcatPair, Length, Lower, Replace, StrIndex, Substr, Upper,
@ -21,7 +21,7 @@ __all__ = [
'ExtractQuarter', 'ExtractSecond', 'ExtractWeek', 'ExtractWeekDay', 'ExtractQuarter', 'ExtractSecond', 'ExtractWeek', 'ExtractWeekDay',
'ExtractYear', 'Now', 'Trunc', 'TruncDate', 'TruncDay', 'TruncHour', 'ExtractYear', 'Now', 'Trunc', 'TruncDate', 'TruncDay', 'TruncHour',
'TruncMinute', 'TruncMonth', 'TruncQuarter', 'TruncSecond', 'TruncTime', 'TruncMinute', 'TruncMonth', 'TruncQuarter', 'TruncSecond', 'TruncTime',
'TruncYear', 'TruncWeek', 'TruncYear',
# text # text
'Concat', 'ConcatPair', 'Length', 'Lower', 'Replace', 'StrIndex', 'Substr', 'Concat', 'ConcatPair', 'Length', 'Lower', 'Replace', 'StrIndex', 'Substr',
'Upper', 'Upper',

View File

@ -199,7 +199,8 @@ class TruncBase(TimezoneMixin, Transform):
field.name, output_field.__class__.__name__ if has_explicit_output_field else 'DateTimeField' field.name, output_field.__class__.__name__ if has_explicit_output_field else 'DateTimeField'
)) ))
elif isinstance(field, TimeField) and ( elif isinstance(field, TimeField) and (
isinstance(output_field, DateTimeField) or copy.kind in ('year', 'quarter', 'month', 'day', 'date')): isinstance(output_field, DateTimeField) or
copy.kind in ('year', 'quarter', 'month', 'week', 'day', 'date')):
raise ValueError("Cannot truncate TimeField '%s' to %s. " % ( raise ValueError("Cannot truncate TimeField '%s' to %s. " % (
field.name, output_field.__class__.__name__ if has_explicit_output_field else 'DateTimeField' field.name, output_field.__class__.__name__ if has_explicit_output_field else 'DateTimeField'
)) ))
@ -242,6 +243,11 @@ class TruncMonth(TruncBase):
kind = 'month' kind = 'month'
class TruncWeek(TruncBase):
"""Truncate to midnight on the Monday of the week."""
kind = 'week'
class TruncDay(TruncBase): class TruncDay(TruncBase):
kind = 'day' kind = 'day'

View File

@ -777,8 +777,8 @@ class QuerySet:
Return a list of date objects representing all available dates for Return a list of date objects representing all available dates for
the given field_name, scoped to 'kind'. the given field_name, scoped to 'kind'.
""" """
assert kind in ("year", "month", "day"), \ assert kind in ('year', 'month', 'week', 'day'), \
"'kind' must be one of 'year', 'month' or 'day'." "'kind' must be one of 'year', 'month', 'week', or 'day'."
assert order in ('ASC', 'DESC'), \ assert order in ('ASC', 'DESC'), \
"'order' must be either 'ASC' or 'DESC'." "'order' must be either 'ASC' or 'DESC'."
return self.annotate( return self.annotate(
@ -793,8 +793,8 @@ class QuerySet:
Return a list of datetime objects representing all available Return a list of datetime objects representing all available
datetimes for the given field_name, scoped to 'kind'. datetimes for the given field_name, scoped to 'kind'.
""" """
assert kind in ("year", "month", "day", "hour", "minute", "second"), \ assert kind in ('year', 'month', 'week', 'day', 'hour', 'minute', 'second'), \
"'kind' must be one of 'year', 'month', 'day', 'hour', 'minute' or 'second'." "'kind' must be one of 'year', 'month', 'week', 'day', 'hour', 'minute', or 'second'."
assert order in ('ASC', 'DESC'), \ assert order in ('ASC', 'DESC'), \
"'order' must be either 'ASC' or 'DESC'." "'order' must be either 'ASC' or 'DESC'."
if settings.USE_TZ: if settings.USE_TZ:

View File

@ -439,6 +439,7 @@ return:
* "year": 2015-01-01 00:00:00+00:00 * "year": 2015-01-01 00:00:00+00:00
* "quarter": 2015-04-01 00:00:00+00:00 * "quarter": 2015-04-01 00:00:00+00:00
* "month": 2015-06-01 00:00:00+00:00 * "month": 2015-06-01 00:00:00+00:00
* "week": 2015-06-15 00:00:00+00:00
* "day": 2015-06-15 00:00:00+00:00 * "day": 2015-06-15 00:00:00+00:00
* "hour": 2015-06-15 14:00:00+00:00 * "hour": 2015-06-15 14:00:00+00:00
* "minute": 2015-06-15 14:30:00+00:00 * "minute": 2015-06-15 14:30:00+00:00
@ -452,6 +453,7 @@ values returned when this timezone is active will be:
* "year": 2015-01-01 00:00:00+11:00 * "year": 2015-01-01 00:00:00+11:00
* "quarter": 2015-04-01 00:00:00+10:00 * "quarter": 2015-04-01 00:00:00+10:00
* "month": 2015-06-01 00:00:00+10:00 * "month": 2015-06-01 00:00:00+10:00
* "week": 2015-06-16 00:00:00+10:00
* "day": 2015-06-16 00:00:00+10:00 * "day": 2015-06-16 00:00:00+10:00
* "hour": 2015-06-16 00:00:00+10:00 * "hour": 2015-06-16 00:00:00+10:00
* "minute": 2015-06-16 00:30:00+10:00 * "minute": 2015-06-16 00:30:00+10:00
@ -504,6 +506,14 @@ Usage example::
.. attribute:: kind = 'month' .. attribute:: kind = 'month'
.. class:: TruncWeek(expression, output_field=None, tzinfo=None, **extra)
.. versionadded:: 2.1
Truncates to midnight on the Monday of the week.
.. attribute:: kind = 'week'
.. class:: TruncQuarter(expression, output_field=None, tzinfo=None, **extra) .. class:: TruncQuarter(expression, output_field=None, tzinfo=None, **extra)
.. versionadded:: 2.0 .. versionadded:: 2.0

View File

@ -694,13 +694,15 @@ objects representing all available dates of a particular kind within the
contents of the ``QuerySet``. contents of the ``QuerySet``.
``field`` should be the name of a ``DateField`` of your model. ``field`` should be the name of a ``DateField`` of your model.
``kind`` should be either ``"year"``, ``"month"`` or ``"day"``. Each ``kind`` should be either ``"year"``, ``"month"``, ``"week"``, or ``"day"``.
``datetime.date`` object in the result list is "truncated" to the given Each :class:`datetime.date` object in the result list is "truncated" to the
``type``. given ``type``.
* ``"year"`` returns a list of all distinct year values for the field. * ``"year"`` returns a list of all distinct year values for the field.
* ``"month"`` returns a list of all distinct year/month values for the * ``"month"`` returns a list of all distinct year/month values for the
field. field.
* ``"week"`` returns a list of all distinct year/week values for the field. All
dates will be a Monday.
* ``"day"`` returns a list of all distinct year/month/day values for the * ``"day"`` returns a list of all distinct year/month/day values for the
field. field.
@ -713,6 +715,8 @@ Examples::
[datetime.date(2005, 1, 1)] [datetime.date(2005, 1, 1)]
>>> Entry.objects.dates('pub_date', 'month') >>> Entry.objects.dates('pub_date', 'month')
[datetime.date(2005, 2, 1), datetime.date(2005, 3, 1)] [datetime.date(2005, 2, 1), datetime.date(2005, 3, 1)]
>>> Entry.objects.dates('pub_date', 'week')
[datetime.date(2005, 2, 14), datetime.date(2005, 3, 14)]
>>> Entry.objects.dates('pub_date', 'day') >>> Entry.objects.dates('pub_date', 'day')
[datetime.date(2005, 2, 20), datetime.date(2005, 3, 20)] [datetime.date(2005, 2, 20), datetime.date(2005, 3, 20)]
>>> Entry.objects.dates('pub_date', 'day', order='DESC') >>> Entry.objects.dates('pub_date', 'day', order='DESC')
@ -720,6 +724,10 @@ Examples::
>>> Entry.objects.filter(headline__contains='Lennon').dates('pub_date', 'day') >>> Entry.objects.filter(headline__contains='Lennon').dates('pub_date', 'day')
[datetime.date(2005, 3, 20)] [datetime.date(2005, 3, 20)]
.. versionchanged:: 2.1
"week" support was added.
``datetimes()`` ``datetimes()``
~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~
@ -731,9 +739,9 @@ contents of the ``QuerySet``.
``field_name`` should be the name of a ``DateTimeField`` of your model. ``field_name`` should be the name of a ``DateTimeField`` of your model.
``kind`` should be either ``"year"``, ``"month"``, ``"day"``, ``"hour"``, ``kind`` should be either ``"year"``, ``"month"``, ``"week"``, ``"day"``,
``"minute"`` or ``"second"``. Each ``datetime.datetime`` object in the result ``"hour"``, ``"minute"``, or ``"second"``. Each :class:`datetime.datetime`
list is "truncated" to the given ``type``. object in the result list is "truncated" to the given ``type``.
``order``, which defaults to ``'ASC'``, should be either ``'ASC'`` or ``order``, which defaults to ``'ASC'``, should be either ``'ASC'`` or
``'DESC'``. This specifies how to order the results. ``'DESC'``. This specifies how to order the results.
@ -745,6 +753,10 @@ object. If it's ``None``, Django uses the :ref:`current time zone
<default-current-time-zone>`. It has no effect when :setting:`USE_TZ` is <default-current-time-zone>`. It has no effect when :setting:`USE_TZ` is
``False``. ``False``.
.. versionchanged:: 2.1
"week" support was added.
.. _database-time-zone-definitions: .. _database-time-zone-definitions:
.. note:: .. note::

View File

@ -173,6 +173,10 @@ Models
* The new :class:`~django.db.models.functions.Replace` database function * The new :class:`~django.db.models.functions.Replace` database function
replaces strings in an expression. replaces strings in an expression.
* The new :class:`~django.db.models.functions.TruncWeek` function truncates
:class:`~django.db.models.DateField` and
:class:`~django.db.models.DateTimeField` to the Monday of a week.
Requests and Responses Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~

View File

@ -55,6 +55,12 @@ class DatesTests(TestCase):
datetime.date(2010, 7, 1), datetime.date(2010, 7, 1),
], ],
) )
self.assertSequenceEqual(
Comment.objects.dates("article__pub_date", "week"), [
datetime.date(2005, 7, 25),
datetime.date(2010, 7, 26),
],
)
self.assertSequenceEqual( self.assertSequenceEqual(
Comment.objects.dates("article__pub_date", "day"), [ Comment.objects.dates("article__pub_date", "day"), [
datetime.date(2005, 7, 28), datetime.date(2005, 7, 28),
@ -93,7 +99,8 @@ class DatesTests(TestCase):
) )
def test_dates_fails_when_given_invalid_kind_argument(self): def test_dates_fails_when_given_invalid_kind_argument(self):
with self.assertRaisesMessage(AssertionError, "'kind' must be one of 'year', 'month' or 'day'."): msg = "'kind' must be one of 'year', 'month', 'week', or 'day'."
with self.assertRaisesMessage(AssertionError, msg):
Article.objects.dates("pub_date", "bad_kind") Article.objects.dates("pub_date", "bad_kind")
def test_dates_fails_when_given_invalid_order_argument(self): def test_dates_fails_when_given_invalid_order_argument(self):

View File

@ -53,6 +53,12 @@ class DateTimesTests(TestCase):
datetime.datetime(2010, 7, 1), datetime.datetime(2010, 7, 1),
], ],
) )
self.assertSequenceEqual(
Comment.objects.datetimes("article__pub_date", "week"), [
datetime.datetime(2005, 7, 25),
datetime.datetime(2010, 7, 26),
],
)
self.assertSequenceEqual( self.assertSequenceEqual(
Comment.objects.datetimes("article__pub_date", "day"), [ Comment.objects.datetimes("article__pub_date", "day"), [
datetime.datetime(2005, 7, 28), datetime.datetime(2005, 7, 28),
@ -98,6 +104,9 @@ class DateTimesTests(TestCase):
self.assertQuerysetEqual( self.assertQuerysetEqual(
Article.objects.datetimes('pub_date', 'month'), Article.objects.datetimes('pub_date', 'month'),
["datetime.datetime(2005, 7, 1, 0, 0)"]) ["datetime.datetime(2005, 7, 1, 0, 0)"])
self.assertQuerysetEqual(
Article.objects.datetimes('pub_date', 'week'),
["datetime.datetime(2005, 7, 25, 0, 0)"])
self.assertQuerysetEqual( self.assertQuerysetEqual(
Article.objects.datetimes('pub_date', 'day'), Article.objects.datetimes('pub_date', 'day'),
["datetime.datetime(2005, 7, 28, 0, 0)", ["datetime.datetime(2005, 7, 28, 0, 0)",

View File

@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta
import pytz import pytz
@ -8,7 +8,7 @@ from django.db.models.functions import (
Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth,
ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear, ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear,
Trunc, TruncDate, TruncDay, TruncHour, TruncMinute, TruncMonth, Trunc, TruncDate, TruncDay, TruncHour, TruncMinute, TruncMonth,
TruncQuarter, TruncSecond, TruncTime, TruncYear, TruncQuarter, TruncSecond, TruncTime, TruncWeek, TruncYear,
) )
from django.test import ( from django.test import (
TestCase, override_settings, skipIfDBFeature, skipUnlessDBFeature, TestCase, override_settings, skipIfDBFeature, skipUnlessDBFeature,
@ -34,6 +34,10 @@ def truncate_to(value, kind, tzinfo=None):
if isinstance(value, datetime): if isinstance(value, datetime):
return value.replace(hour=0, minute=0, second=0, microsecond=0) return value.replace(hour=0, minute=0, second=0, microsecond=0)
return value return value
if kind == 'week':
if isinstance(value, datetime):
return (value - timedelta(days=value.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
return value - timedelta(days=value.weekday())
if kind == 'month': if kind == 'month':
if isinstance(value, datetime): if isinstance(value, datetime):
return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0) return value.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
@ -536,6 +540,7 @@ class DateFunctionTests(TestCase):
test_date_kind('year') test_date_kind('year')
test_date_kind('quarter') test_date_kind('quarter')
test_date_kind('month') test_date_kind('month')
test_date_kind('week')
test_date_kind('day') test_date_kind('day')
test_time_kind('hour') test_time_kind('hour')
test_time_kind('minute') test_time_kind('minute')
@ -543,6 +548,7 @@ class DateFunctionTests(TestCase):
test_datetime_kind('year') test_datetime_kind('year')
test_datetime_kind('quarter') test_datetime_kind('quarter')
test_datetime_kind('month') test_datetime_kind('month')
test_datetime_kind('week')
test_datetime_kind('day') test_datetime_kind('day')
test_datetime_kind('hour') test_datetime_kind('hour')
test_datetime_kind('minute') test_datetime_kind('minute')
@ -656,6 +662,30 @@ class DateFunctionTests(TestCase):
with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"): with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"):
list(DTModel.objects.annotate(truncated=TruncMonth('start_time', output_field=TimeField()))) list(DTModel.objects.annotate(truncated=TruncMonth('start_time', output_field=TimeField())))
def test_trunc_week_func(self):
start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321)
end_datetime = truncate_to(datetime(2016, 6, 15, 14, 10, 50, 123), 'week')
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=TruncWeek('start_datetime')).order_by('start_datetime'),
[
(start_datetime, truncate_to(start_datetime, 'week')),
(end_datetime, truncate_to(end_datetime, 'week')),
],
lambda m: (m.start_datetime, m.extracted)
)
self.assertEqual(DTModel.objects.filter(start_datetime=TruncWeek('start_datetime')).count(), 1)
with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"):
list(DTModel.objects.annotate(truncated=TruncWeek('start_time')))
with self.assertRaisesMessage(ValueError, "Cannot truncate TimeField 'start_time' to DateTimeField"):
list(DTModel.objects.annotate(truncated=TruncWeek('start_time', output_field=TimeField())))
def test_trunc_date_func(self): def test_trunc_date_func(self):
start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321) start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321)
end_datetime = datetime(2016, 6, 15, 14, 10, 50, 123) end_datetime = datetime(2016, 6, 15, 14, 10, 50, 123)
@ -960,6 +990,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
test_date_kind('year') test_date_kind('year')
test_date_kind('quarter') test_date_kind('quarter')
test_date_kind('month') test_date_kind('month')
test_date_kind('week')
test_date_kind('day') test_date_kind('day')
test_time_kind('hour') test_time_kind('hour')
test_time_kind('minute') test_time_kind('minute')
@ -967,6 +998,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
test_datetime_kind('year') test_datetime_kind('year')
test_datetime_kind('quarter') test_datetime_kind('quarter')
test_datetime_kind('month') test_datetime_kind('month')
test_datetime_kind('week')
test_datetime_kind('day') test_datetime_kind('day')
test_datetime_kind('hour') test_datetime_kind('hour')
test_datetime_kind('minute') test_datetime_kind('minute')