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" % (
field_name, field_name
)
elif lookup_type == 'week':
return "DATE_SUB(%s, INTERVAL WEEKDAY(%s) DAY)" % (
field_name, field_name
)
else:
return "DATE(%s)" % (field_name)
@ -84,6 +88,12 @@ class DatabaseOperations(BaseDatabaseOperations):
"INTERVAL QUARTER({field_name}) QUARTER - " +
"INTERVAL 1 QUARTER, '%%Y-%%m-01 00:00:00') AS DATETIME)"
).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:
i = fields.index(lookup_type) + 1
except ValueError:

View File

@ -78,6 +78,8 @@ END;
return "TRUNC(%s, '%s')" % (field_name, lookup_type.upper())
elif lookup_type == 'quarter':
return "TRUNC(%s, 'Q')" % field_name
elif lookup_type == 'week':
return "TRUNC(%s, 'IW')" % field_name
else:
return "TRUNC(%s)" % field_name
@ -116,6 +118,8 @@ END;
sql = "TRUNC(%s, '%s')" % (field_name, lookup_type.upper())
elif lookup_type == 'quarter':
sql = "TRUNC(%s, 'Q')" % field_name
elif lookup_type == 'week':
sql = "TRUNC(%s, 'IW')" % field_name
elif lookup_type == 'day':
sql = "TRUNC(%s)" % field_name
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)
elif lookup_type == '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':
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)
elif lookup_type == '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':
return "%i-%02i-%02i 00:00:00" % (dt.year, dt.month, dt.day)
elif lookup_type == 'hour':

View File

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

View File

@ -199,7 +199,8 @@ class TruncBase(TimezoneMixin, Transform):
field.name, output_field.__class__.__name__ if has_explicit_output_field else 'DateTimeField'
))
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. " % (
field.name, output_field.__class__.__name__ if has_explicit_output_field else 'DateTimeField'
))
@ -242,6 +243,11 @@ class TruncMonth(TruncBase):
kind = 'month'
class TruncWeek(TruncBase):
"""Truncate to midnight on the Monday of the week."""
kind = 'week'
class TruncDay(TruncBase):
kind = 'day'

View File

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

View File

@ -439,6 +439,7 @@ return:
* "year": 2015-01-01 00:00:00+00:00
* "quarter": 2015-04-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
* "hour": 2015-06-15 14:00: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
* "quarter": 2015-04-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
* "hour": 2015-06-16 00:00:00+10:00
* "minute": 2015-06-16 00:30:00+10:00
@ -504,6 +506,14 @@ Usage example::
.. 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)
.. versionadded:: 2.0

View File

@ -694,13 +694,15 @@ objects representing all available dates of a particular kind within the
contents of the ``QuerySet``.
``field`` should be the name of a ``DateField`` of your model.
``kind`` should be either ``"year"``, ``"month"`` or ``"day"``. Each
``datetime.date`` object in the result list is "truncated" to the given
``type``.
``kind`` should be either ``"year"``, ``"month"``, ``"week"``, or ``"day"``.
Each :class:`datetime.date` object in the result list is "truncated" to the
given ``type``.
* ``"year"`` returns a list of all distinct year values for the field.
* ``"month"`` returns a list of all distinct year/month values for the
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
field.
@ -713,6 +715,8 @@ Examples::
[datetime.date(2005, 1, 1)]
>>> Entry.objects.dates('pub_date', 'month')
[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')
[datetime.date(2005, 2, 20), datetime.date(2005, 3, 20)]
>>> Entry.objects.dates('pub_date', 'day', order='DESC')
@ -720,6 +724,10 @@ Examples::
>>> Entry.objects.filter(headline__contains='Lennon').dates('pub_date', 'day')
[datetime.date(2005, 3, 20)]
.. versionchanged:: 2.1
"week" support was added.
``datetimes()``
~~~~~~~~~~~~~~~
@ -731,9 +739,9 @@ contents of the ``QuerySet``.
``field_name`` should be the name of a ``DateTimeField`` of your model.
``kind`` should be either ``"year"``, ``"month"``, ``"day"``, ``"hour"``,
``"minute"`` or ``"second"``. Each ``datetime.datetime`` object in the result
list is "truncated" to the given ``type``.
``kind`` should be either ``"year"``, ``"month"``, ``"week"``, ``"day"``,
``"hour"``, ``"minute"``, or ``"second"``. Each :class:`datetime.datetime`
object in the result list is "truncated" to the given ``type``.
``order``, which defaults to ``'ASC'``, should be either ``'ASC'`` or
``'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
``False``.
.. versionchanged:: 2.1
"week" support was added.
.. _database-time-zone-definitions:
.. note::

View File

@ -173,6 +173,10 @@ Models
* The new :class:`~django.db.models.functions.Replace` database function
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
~~~~~~~~~~~~~~~~~~~~~~

View File

@ -55,6 +55,12 @@ class DatesTests(TestCase):
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(
Comment.objects.dates("article__pub_date", "day"), [
datetime.date(2005, 7, 28),
@ -93,7 +99,8 @@ class DatesTests(TestCase):
)
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")
def test_dates_fails_when_given_invalid_order_argument(self):

View File

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

View File

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