Fixed #25240 -- Added ExtractWeek and exposed it through the __week lookup.

Thanks to Mariusz Felisiak and Tim Graham for review.
This commit is contained in:
Mads Jensen 2016-11-11 14:01:40 +01:00 committed by Tim Graham
parent 2dc07da497
commit 1446902be4
9 changed files with 126 additions and 5 deletions

View File

@ -24,7 +24,13 @@ class DatabaseOperations(BaseDatabaseOperations):
# DAYOFWEEK() returns an integer, 1-7, Sunday=1. # DAYOFWEEK() returns an integer, 1-7, Sunday=1.
# Note: WEEKDAY() returns 0-6, Monday=0. # Note: WEEKDAY() returns 0-6, Monday=0.
return "DAYOFWEEK(%s)" % field_name return "DAYOFWEEK(%s)" % field_name
elif lookup_type == 'week':
# Override the value of default_week_format for consistency with
# other database backends.
# Mode 3: Monday, 1-53, with 4 or more days this year.
return "WEEK(%s, 3)" % field_name
else: else:
# EXTRACT returns 1-53 based on ISO-8601 for the week number.
return "EXTRACT(%s FROM %s)" % (lookup_type.upper(), field_name) return "EXTRACT(%s FROM %s)" % (lookup_type.upper(), field_name)
def date_trunc_sql(self, lookup_type, field_name): def date_trunc_sql(self, lookup_type, field_name):

View File

@ -84,6 +84,9 @@ WHEN (new.%(col_name)s IS NULL)
if lookup_type == 'week_day': if lookup_type == 'week_day':
# TO_CHAR(field, 'D') returns an integer from 1-7, where 1=Sunday. # TO_CHAR(field, 'D') returns an integer from 1-7, where 1=Sunday.
return "TO_CHAR(%s, 'D')" % field_name return "TO_CHAR(%s, 'D')" % field_name
elif lookup_type == 'week':
# IW = ISO week number
return "TO_CHAR(%s, 'IW')" % field_name
else: else:
# http://docs.oracle.com/cd/B19306_01/server.102/b14200/functions050.htm # http://docs.oracle.com/cd/B19306_01/server.102/b14200/functions050.htm
return "EXTRACT(%s FROM %s)" % (lookup_type.upper(), field_name) return "EXTRACT(%s FROM %s)" % (lookup_type.upper(), field_name)

View File

@ -344,6 +344,8 @@ def _sqlite_date_extract(lookup_type, dt):
return None return None
if lookup_type == 'week_day': if lookup_type == 'week_day':
return (dt.isoweekday() % 7) + 1 return (dt.isoweekday() % 7) + 1
elif lookup_type == 'week':
return dt.isocalendar()[1]
else: else:
return getattr(dt, lookup_type) return getattr(dt, lookup_type)
@ -406,6 +408,8 @@ def _sqlite_datetime_extract(lookup_type, dt, tzname):
return None return None
if lookup_type == 'week_day': if lookup_type == 'week_day':
return (dt.isoweekday() % 7) + 1 return (dt.isoweekday() % 7) + 1
elif lookup_type == 'week':
return dt.isocalendar()[1]
else: else:
return getattr(dt, lookup_type) return getattr(dt, lookup_type)

View File

@ -4,8 +4,9 @@ from .base import (
) )
from .datetime import ( from .datetime import (
Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth,
ExtractSecond, ExtractWeekDay, ExtractYear, Trunc, TruncDate, TruncDay, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear, Trunc, TruncDate,
TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime, TruncYear, TruncDay, TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime,
TruncYear,
) )
__all__ = [ __all__ = [
@ -14,7 +15,7 @@ __all__ = [
'Lower', 'Now', 'Substr', 'Upper', 'Lower', 'Now', 'Substr', 'Upper',
# datetime # datetime
'Extract', 'ExtractDay', 'ExtractHour', 'ExtractMinute', 'ExtractMonth', 'Extract', 'ExtractDay', 'ExtractHour', 'ExtractMinute', 'ExtractMonth',
'ExtractSecond', 'ExtractWeekDay', 'ExtractYear', 'ExtractSecond', 'ExtractWeek', 'ExtractWeekDay', 'ExtractYear',
'Trunc', 'TruncDate', 'TruncDay', 'TruncHour', 'TruncMinute', 'TruncMonth', 'Trunc', 'TruncDate', 'TruncDay', 'TruncHour', 'TruncMinute', 'TruncMonth',
'TruncSecond', 'TruncTime', 'TruncYear', 'TruncSecond', 'TruncTime', 'TruncYear',
] ]

View File

@ -87,6 +87,14 @@ class ExtractDay(Extract):
lookup_name = 'day' lookup_name = 'day'
class ExtractWeek(Extract):
"""
Return 1-52 or 53, based on ISO-8601, i.e., Monday is the first of the
week.
"""
lookup_name = 'week'
class ExtractWeekDay(Extract): class ExtractWeekDay(Extract):
""" """
Return Sunday=1 through Saturday=7. Return Sunday=1 through Saturday=7.
@ -112,6 +120,7 @@ DateField.register_lookup(ExtractYear)
DateField.register_lookup(ExtractMonth) DateField.register_lookup(ExtractMonth)
DateField.register_lookup(ExtractDay) DateField.register_lookup(ExtractDay)
DateField.register_lookup(ExtractWeekDay) DateField.register_lookup(ExtractWeekDay)
DateField.register_lookup(ExtractWeek)
TimeField.register_lookup(ExtractHour) TimeField.register_lookup(ExtractHour)
TimeField.register_lookup(ExtractMinute) TimeField.register_lookup(ExtractMinute)

View File

@ -313,6 +313,7 @@ Given the datetime ``2015-06-15 23:30:01.000321+00:00``, the built-in
* "year": 2015 * "year": 2015
* "month": 6 * "month": 6
* "day": 15 * "day": 15
* "week": 25
* "week_day": 2 * "week_day": 2
* "hour": 23 * "hour": 23
* "minute": 30 * "minute": 30
@ -340,6 +341,14 @@ returned when this timezone is active will be the same as above except for:
>>> (dt.isoweekday() % 7) + 1 >>> (dt.isoweekday() % 7) + 1
2 2
.. admonition:: ``week`` values
The ``week`` ``lookup_type`` is calculated based on `ISO-8601
<https://en.wikipedia.org/wiki/ISO-8601>`_, i.e.,
a week starts on a Monday. The first week is the one with the majority
of the days, i.e., a week that starts on or before Thursday. The value
returned is in the range 1 to 52 or 53.
Each ``lookup_name`` above has a corresponding ``Extract`` subclass (listed Each ``lookup_name`` above has a corresponding ``Extract`` subclass (listed
below) that should typically be used instead of the more verbose equivalent, below) that should typically be used instead of the more verbose equivalent,
e.g. use ``ExtractYear(...)`` rather than ``Extract(..., lookup_name='year')``. e.g. use ``ExtractYear(...)`` rather than ``Extract(..., lookup_name='year')``.
@ -382,6 +391,12 @@ Usage example::
.. attribute:: lookup_name = 'week_day' .. attribute:: lookup_name = 'week_day'
.. class:: ExtractWeek(expression, tzinfo=None, **extra)
.. versionadded:: 1.11
.. attribute:: lookup_name = 'week'
These are logically equivalent to ``Extract('date_field', lookup_name)``. Each These are logically equivalent to ``Extract('date_field', lookup_name)``. Each
class is also a ``Transform`` registered on ``DateField`` and ``DateTimeField`` class is also a ``Transform`` registered on ``DateField`` and ``DateTimeField``
as ``__(lookup_name)``, e.g. ``__year``. as ``__(lookup_name)``, e.g. ``__year``.

View File

@ -2703,6 +2703,28 @@ When :setting:`USE_TZ` is ``True``, datetime fields are converted to the
current time zone before filtering. This requires :ref:`time zone definitions current time zone before filtering. This requires :ref:`time zone definitions
in the database <database-time-zone-definitions>`. in the database <database-time-zone-definitions>`.
.. fieldlookup:: week
``week``
~~~~~~~~
.. versionadded:: 1.11
For date and datetime fields, return the week number (1-52 or 53) according
to `ISO-8601 <https://en.wikipedia.org/wiki/ISO-8601>`_, i.e., weeks start
on a Monday and the first week starts on or before Thursday.
Example::
Entry.objects.filter(pub_date__week=52)
Entry.objects.filter(pub_date__week__gte=32, pub_date__week__lte=38)
(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:: week_day .. fieldlookup:: week_day
``week_day`` ``week_day``

View File

@ -306,6 +306,11 @@ Models
* Added support for time truncation to * Added support for time truncation to
:class:`~django.db.models.functions.datetime.Trunc` functions. :class:`~django.db.models.functions.datetime.Trunc` functions.
* Added the :class:`~django.db.models.functions.datetime.ExtractWeek` function
to extract the week from :class:`~django.db.models.DateField` and
:class:`~django.db.models.DateTimeField` and exposed it through the
:lookup:`week` lookup.
* Added the :class:`~django.db.models.functions.datetime.TruncTime` function * Added the :class:`~django.db.models.functions.datetime.TruncTime` function
to truncate :class:`~django.db.models.DateTimeField` to its time component to truncate :class:`~django.db.models.DateTimeField` to its time component
and exposed it through the :lookup:`time` lookup. and exposed it through the :lookup:`time` lookup.

View File

@ -9,8 +9,9 @@ from django.db import connection
from django.db.models import DateField, DateTimeField, IntegerField, TimeField from django.db.models import DateField, DateTimeField, IntegerField, TimeField
from django.db.models.functions import ( from django.db.models.functions import (
Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth,
ExtractSecond, ExtractWeekDay, ExtractYear, Trunc, TruncDate, TruncDay, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear, Trunc, TruncDate,
TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime, TruncYear, TruncDay, TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime,
TruncYear,
) )
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
@ -166,6 +167,11 @@ class DateFunctionTests(TestCase):
[(start_datetime, start_datetime.day), (end_datetime, end_datetime.day)], [(start_datetime, start_datetime.day), (end_datetime, end_datetime.day)],
lambda m: (m.start_datetime, m.extracted) lambda m: (m.start_datetime, m.extracted)
) )
self.assertQuerysetEqual(
DTModel.objects.annotate(extracted=Extract('start_datetime', 'week')).order_by('start_datetime'),
[(start_datetime, 25), (end_datetime, 24)],
lambda m: (m.start_datetime, m.extracted)
)
self.assertQuerysetEqual( self.assertQuerysetEqual(
DTModel.objects.annotate(extracted=Extract('start_datetime', 'week_day')).order_by('start_datetime'), DTModel.objects.annotate(extracted=Extract('start_datetime', 'week_day')).order_by('start_datetime'),
[ [
@ -254,6 +260,53 @@ class DateFunctionTests(TestCase):
) )
self.assertEqual(DTModel.objects.filter(start_datetime__day=ExtractDay('start_datetime')).count(), 2) self.assertEqual(DTModel.objects.filter(start_datetime__day=ExtractDay('start_datetime')).count(), 2)
def test_extract_week_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=ExtractWeek('start_datetime')).order_by('start_datetime'),
[(start_datetime, 25), (end_datetime, 24)],
lambda m: (m.start_datetime, m.extracted)
)
self.assertQuerysetEqual(
DTModel.objects.annotate(extracted=ExtractWeek('start_date')).order_by('start_datetime'),
[(start_datetime, 25), (end_datetime, 24)],
lambda m: (m.start_datetime, m.extracted)
)
# both dates are from the same week.
self.assertEqual(DTModel.objects.filter(start_datetime__week=ExtractWeek('start_datetime')).count(), 2)
def test_extract_week_func_boundaries(self):
end_datetime = microsecond_support(datetime(2016, 6, 15, 14, 10, 50, 123))
if settings.USE_TZ:
end_datetime = timezone.make_aware(end_datetime, is_dst=False)
week_52_day_2014 = microsecond_support(datetime(2014, 12, 27, 13, 0)) # Sunday
week_1_day_2014_2015 = microsecond_support(datetime(2014, 12, 31, 13, 0)) # Wednesday
week_53_day_2015 = microsecond_support(datetime(2015, 12, 31, 13, 0)) # Thursday
if settings.USE_TZ:
week_1_day_2014_2015 = timezone.make_aware(week_1_day_2014_2015, is_dst=False)
week_52_day_2014 = timezone.make_aware(week_52_day_2014, is_dst=False)
week_53_day_2015 = timezone.make_aware(week_53_day_2015, is_dst=False)
days = [week_52_day_2014, week_1_day_2014_2015, week_53_day_2015]
self.create_model(week_53_day_2015, end_datetime)
self.create_model(week_52_day_2014, end_datetime)
self.create_model(week_1_day_2014_2015, end_datetime)
qs = DTModel.objects.filter(start_datetime__in=days).annotate(
extracted=ExtractWeek('start_datetime'),
).order_by('start_datetime')
self.assertQuerysetEqual(qs, [
(week_52_day_2014, 52),
(week_1_day_2014_2015, 1),
(week_53_day_2015, 53),
], lambda m: (m.start_datetime, m.extracted))
def test_extract_weekday_func(self): def test_extract_weekday_func(self):
start_datetime = microsecond_support(datetime(2015, 6, 15, 14, 30, 50, 321)) start_datetime = microsecond_support(datetime(2015, 6, 15, 14, 30, 50, 321))
end_datetime = microsecond_support(datetime(2016, 6, 15, 14, 10, 50, 123)) end_datetime = microsecond_support(datetime(2016, 6, 15, 14, 10, 50, 123))
@ -669,6 +722,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
qs = DTModel.objects.annotate( qs = DTModel.objects.annotate(
day=Extract('start_datetime', 'day'), day=Extract('start_datetime', 'day'),
day_melb=Extract('start_datetime', 'day', tzinfo=melb), day_melb=Extract('start_datetime', 'day', tzinfo=melb),
week=Extract('start_datetime', 'week', tzinfo=melb),
weekday=ExtractWeekDay('start_datetime'), weekday=ExtractWeekDay('start_datetime'),
weekday_melb=ExtractWeekDay('start_datetime', tzinfo=melb), weekday_melb=ExtractWeekDay('start_datetime', tzinfo=melb),
hour=ExtractHour('start_datetime'), hour=ExtractHour('start_datetime'),
@ -678,6 +732,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
utc_model = qs.get() utc_model = qs.get()
self.assertEqual(utc_model.day, 15) self.assertEqual(utc_model.day, 15)
self.assertEqual(utc_model.day_melb, 16) self.assertEqual(utc_model.day_melb, 16)
self.assertEqual(utc_model.week, 25)
self.assertEqual(utc_model.weekday, 2) self.assertEqual(utc_model.weekday, 2)
self.assertEqual(utc_model.weekday_melb, 3) self.assertEqual(utc_model.weekday_melb, 3)
self.assertEqual(utc_model.hour, 23) self.assertEqual(utc_model.hour, 23)
@ -688,6 +743,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
self.assertEqual(melb_model.day, 16) self.assertEqual(melb_model.day, 16)
self.assertEqual(melb_model.day_melb, 16) self.assertEqual(melb_model.day_melb, 16)
self.assertEqual(melb_model.week, 25)
self.assertEqual(melb_model.weekday, 3) self.assertEqual(melb_model.weekday, 3)
self.assertEqual(melb_model.weekday_melb, 3) self.assertEqual(melb_model.weekday_melb, 3)
self.assertEqual(melb_model.hour, 9) self.assertEqual(melb_model.hour, 9)