Fixed #28649 -- Added ExtractIsoYear database function and iso_year lookup.

This commit is contained in:
Sigurd Ljødal 2017-09-28 22:28:48 +02:00 committed by Tim Graham
parent c832885a3e
commit 3e09b37f80
10 changed files with 161 additions and 46 deletions

View File

@ -42,6 +42,10 @@ class DatabaseOperations(BaseDatabaseOperations):
# other database backends.
# Mode 3: Monday, 1-53, with 4 or more days this year.
return "WEEK(%s, 3)" % field_name
elif lookup_type == 'iso_year':
# Get the year part from the YEARWEEK function, which returns a
# number as year * 100 + week.
return "TRUNCATE(YEARWEEK(%s, 3), -2) / 100" % field_name
else:
# EXTRACT returns 1-53 based on ISO-8601 for the week number.
return "EXTRACT(%s FROM %s)" % (lookup_type.upper(), field_name)

View File

@ -69,6 +69,8 @@ END;
return "TO_CHAR(%s, 'IW')" % field_name
elif lookup_type == 'quarter':
return "TO_CHAR(%s, 'Q')" % field_name
elif lookup_type == 'iso_year':
return "TO_CHAR(%s, 'IYYY')" % field_name
else:
# https://docs.oracle.com/database/121/SQLRF/functions067.htm#SQLRF00639
return "EXTRACT(%s FROM %s)" % (lookup_type.upper(), field_name)

View File

@ -31,6 +31,8 @@ class DatabaseOperations(BaseDatabaseOperations):
if lookup_type == 'week_day':
# For consistency across backends, we return Sunday=1, Saturday=7.
return "EXTRACT('dow' FROM %s) + 1" % field_name
elif lookup_type == 'iso_year':
return "EXTRACT('isoyear' FROM %s)" % field_name
else:
return "EXTRACT('%s' FROM %s)" % (lookup_type, field_name)

View File

@ -338,6 +338,8 @@ def _sqlite_date_extract(lookup_type, dt):
return dt.isocalendar()[1]
elif lookup_type == 'quarter':
return math.ceil(dt.month / 3)
elif lookup_type == 'iso_year':
return dt.isocalendar()[0]
else:
return getattr(dt, lookup_type)
@ -410,6 +412,8 @@ def _sqlite_datetime_extract(lookup_type, dt, tzname):
return dt.isocalendar()[1]
elif lookup_type == 'quarter':
return math.ceil(dt.month / 3)
elif lookup_type == 'iso_year':
return dt.isocalendar()[0]
else:
return getattr(dt, lookup_type)

View File

@ -1,9 +1,9 @@
from .comparison import Cast, Coalesce, Greatest, Least
from .datetime import (
Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth,
ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear,
Now, Trunc, TruncDate, TruncDay, TruncHour, TruncMinute, TruncMonth,
TruncQuarter, TruncSecond, TruncTime, TruncWeek, TruncYear,
Extract, ExtractDay, ExtractHour, ExtractIsoYear, ExtractMinute,
ExtractMonth, ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay,
ExtractYear, Now, Trunc, TruncDate, TruncDay, TruncHour, TruncMinute,
TruncMonth, TruncQuarter, TruncSecond, TruncTime, TruncWeek, TruncYear,
)
from .math import (
Abs, ACos, ASin, ATan, ATan2, Ceil, Cos, Cot, Degrees, Exp, Floor, Ln, Log,
@ -24,7 +24,8 @@ __all__ = [
# datetime
'Extract', 'ExtractDay', 'ExtractHour', 'ExtractMinute', 'ExtractMonth',
'ExtractQuarter', 'ExtractSecond', 'ExtractWeek', 'ExtractWeekDay',
'ExtractYear', 'Now', 'Trunc', 'TruncDate', 'TruncDay', 'TruncHour',
'ExtractIsoYear', 'ExtractYear', 'Now', 'Trunc', 'TruncDate', 'TruncDay',
'TruncHour', 'TruncMinute', 'TruncMonth', 'TruncQuarter', 'TruncSecond',
'TruncMinute', 'TruncMonth', 'TruncQuarter', 'TruncSecond', 'TruncTime',
'TruncWeek', 'TruncYear',
# math

View File

@ -80,6 +80,11 @@ class ExtractYear(Extract):
lookup_name = 'year'
class ExtractIsoYear(Extract):
"""Return the ISO-8601 week-numbering year."""
lookup_name = 'iso_year'
class ExtractMonth(Extract):
lookup_name = 'month'
@ -126,6 +131,7 @@ DateField.register_lookup(ExtractMonth)
DateField.register_lookup(ExtractDay)
DateField.register_lookup(ExtractWeekDay)
DateField.register_lookup(ExtractWeek)
DateField.register_lookup(ExtractIsoYear)
DateField.register_lookup(ExtractQuarter)
TimeField.register_lookup(ExtractHour)
@ -142,6 +148,12 @@ ExtractYear.register_lookup(YearGte)
ExtractYear.register_lookup(YearLt)
ExtractYear.register_lookup(YearLte)
ExtractIsoYear.register_lookup(YearExact)
ExtractIsoYear.register_lookup(YearGt)
ExtractIsoYear.register_lookup(YearGte)
ExtractIsoYear.register_lookup(YearLt)
ExtractIsoYear.register_lookup(YearLte)
class Now(Func):
template = 'CURRENT_TIMESTAMP'

View File

@ -182,6 +182,7 @@ Given the datetime ``2015-06-15 23:30:01.000321+00:00``, the built-in
``lookup_name``\s return:
* "year": 2015
* "iso_year": 2015
* "quarter": 2
* "month": 6
* "day": 15
@ -252,6 +253,14 @@ Usage example::
.. attribute:: lookup_name = 'year'
.. class:: ExtractIsoYear(expression, tzinfo=None, **extra)
.. versionadded:: 2.2
Returns the ISO-8601 week-numbering year.
.. attribute:: lookup_name = 'iso_year'
.. class:: ExtractMonth(expression, tzinfo=None, **extra)
.. attribute:: lookup_name = 'month'
@ -283,7 +292,7 @@ that deal with date-parts can be used with ``DateField``::
>>> from django.utils import timezone
>>> from django.db.models.functions import (
... ExtractDay, ExtractMonth, ExtractQuarter, ExtractWeek,
... ExtractWeekDay, ExtractYear,
... ExtractWeekDay, ExtractIsoYear, ExtractYear,
... )
>>> start_2015 = datetime(2015, 6, 15, 23, 30, 1, tzinfo=timezone.utc)
>>> end_2015 = datetime(2015, 6, 16, 13, 11, 27, tzinfo=timezone.utc)
@ -292,15 +301,17 @@ that deal with date-parts can be used with ``DateField``::
... end_datetime=end_2015, end_date=end_2015.date())
>>> Experiment.objects.annotate(
... year=ExtractYear('start_date'),
... isoyear=ExtractIsoYear('start_date'),
... quarter=ExtractQuarter('start_date'),
... month=ExtractMonth('start_date'),
... week=ExtractWeek('start_date'),
... day=ExtractDay('start_date'),
... weekday=ExtractWeekDay('start_date'),
... ).values('year', 'quarter', 'month', 'week', 'day', 'weekday').get(
... ).values('year', 'isoyear', 'quarter', 'month', 'week', 'day', 'weekday').get(
... end_date__year=ExtractYear('start_date'),
... )
{'year': 2015, 'quarter': 2, 'month': 6, 'week': 25, 'day': 15, 'weekday': 2}
{'year': 2015, 'isoyear': 2015, 'quarter': 2, 'month': 6, 'week': 25,
'day': 15, 'weekday': 2}
``DateTimeField`` extracts
~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -340,6 +351,7 @@ Each class is also a ``Transform`` registered on ``DateTimeField`` as
... end_datetime=end_2015, end_date=end_2015.date())
>>> Experiment.objects.annotate(
... year=ExtractYear('start_datetime'),
... isoyear=ExtractIsoYear('start_datetime'),
... quarter=ExtractQuarter('start_datetime'),
... month=ExtractMonth('start_datetime'),
... week=ExtractWeek('start_datetime'),
@ -349,10 +361,11 @@ Each class is also a ``Transform`` registered on ``DateTimeField`` as
... minute=ExtractMinute('start_datetime'),
... second=ExtractSecond('start_datetime'),
... ).values(
... 'year', 'month', 'week', 'day', 'weekday', 'hour', 'minute', 'second',
... 'year', 'isoyear', 'month', 'week', 'day',
... 'weekday', 'hour', 'minute', 'second',
... ).get(end_datetime__year=ExtractYear('start_datetime'))
{'year': 2015, 'quarter': 2, 'month': 6, 'week': 25, 'day': 15, 'weekday': 2,
'hour': 23, 'minute': 30, 'second': 1}
{'year': 2015, 'isoyear': 2015, 'quarter': 2, 'month': 6, 'week': 25,
'day': 15, 'weekday': 2, 'hour': 23, 'minute': 30, 'second': 1}
When :setting:`USE_TZ` is ``True`` then datetimes are stored in the database
in UTC. If a different timezone is active in Django, the datetime is converted

View File

@ -2927,6 +2927,26 @@ SQL equivalent::
When :setting:`USE_TZ` is ``True``, datetime fields are converted to the
current time zone before filtering.
.. fieldlookup:: iso_year
``iso_year``
~~~~~~~~~~~~
.. versionadded:: 2.2
For date and datetime fields, an exact ISO 8601 week-numbering year match.
Allows chaining additional field lookups. Takes an integer year.
Example::
Entry.objects.filter(pub_date__iso_year=2005)
Entry.objects.filter(pub_date__iso_year__gte=2005)
(The exact SQL syntax varies for each database engine.)
When :setting:`USE_TZ` is ``True``, datetime fields are converted to the
current time zone before filtering.
.. fieldlookup:: month
``month``

View File

@ -189,6 +189,11 @@ Models
:meth:`.QuerySet.bulk_create` to ``True`` tells the database to ignore
failure to insert rows that fail uniqueness constraints or other checks.
* The new :class:`~django.db.models.functions.ExtractIsoYear` function extracts
ISO-8601 week-numbering years from :class:`~django.db.models.DateField` and
:class:`~django.db.models.DateTimeField`, and the new :lookup:`iso_year`
lookup allows querying by an ISO-8601 week-numbering year.
Requests and Responses
~~~~~~~~~~~~~~~~~~~~~~

View File

@ -7,10 +7,10 @@ from django.db.models import (
DateField, DateTimeField, IntegerField, Max, OuterRef, Subquery, TimeField,
)
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, TruncWeek, TruncYear,
Extract, ExtractDay, ExtractHour, ExtractIsoYear, ExtractMinute,
ExtractMonth, ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay,
ExtractYear, Trunc, TruncDate, TruncDay, TruncHour, TruncMinute,
TruncMonth, TruncQuarter, TruncSecond, TruncTime, TruncWeek, TruncYear,
)
from django.test import (
TestCase, override_settings, skipIfDBFeature, skipUnlessDBFeature,
@ -86,25 +86,25 @@ class DateFunctionTests(TestCase):
self.create_model(start_datetime, end_datetime)
self.create_model(end_datetime, start_datetime)
qs = DTModel.objects.filter(start_datetime__year__exact=2015)
self.assertEqual(qs.count(), 1)
query_string = str(qs.query).lower()
self.assertEqual(query_string.count(' between '), 1)
self.assertEqual(query_string.count('extract'), 0)
# exact is implied and should be the same
qs = DTModel.objects.filter(start_datetime__year=2015)
self.assertEqual(qs.count(), 1)
query_string = str(qs.query).lower()
self.assertEqual(query_string.count(' between '), 1)
self.assertEqual(query_string.count('extract'), 0)
# date and datetime fields should behave the same
qs = DTModel.objects.filter(start_date__year=2015)
self.assertEqual(qs.count(), 1)
query_string = str(qs.query).lower()
self.assertEqual(query_string.count(' between '), 1)
self.assertEqual(query_string.count('extract'), 0)
for lookup in ('year', 'iso_year'):
with self.subTest(lookup):
qs = DTModel.objects.filter(**{'start_datetime__%s__exact' % lookup: 2015})
self.assertEqual(qs.count(), 1)
query_string = str(qs.query).lower()
self.assertEqual(query_string.count(' between '), 1)
self.assertEqual(query_string.count('extract'), 0)
# exact is implied and should be the same
qs = DTModel.objects.filter(**{'start_datetime__%s' % lookup: 2015})
self.assertEqual(qs.count(), 1)
query_string = str(qs.query).lower()
self.assertEqual(query_string.count(' between '), 1)
self.assertEqual(query_string.count('extract'), 0)
# date and datetime fields should behave the same
qs = DTModel.objects.filter(**{'start_date__%s' % lookup: 2015})
self.assertEqual(qs.count(), 1)
query_string = str(qs.query).lower()
self.assertEqual(query_string.count(' between '), 1)
self.assertEqual(query_string.count('extract'), 0)
def test_extract_year_greaterthan_lookup(self):
start_datetime = datetime(2015, 6, 15, 14, 10)
@ -115,12 +115,14 @@ class DateFunctionTests(TestCase):
self.create_model(start_datetime, end_datetime)
self.create_model(end_datetime, start_datetime)
qs = DTModel.objects.filter(start_datetime__year__gt=2015)
self.assertEqual(qs.count(), 1)
self.assertEqual(str(qs.query).lower().count('extract'), 0)
qs = DTModel.objects.filter(start_datetime__year__gte=2015)
self.assertEqual(qs.count(), 2)
self.assertEqual(str(qs.query).lower().count('extract'), 0)
for lookup in ('year', 'iso_year'):
with self.subTest(lookup):
qs = DTModel.objects.filter(**{'start_datetime__%s__gt' % lookup: 2015})
self.assertEqual(qs.count(), 1)
self.assertEqual(str(qs.query).lower().count('extract'), 0)
qs = DTModel.objects.filter(**{'start_datetime__%s__gte' % lookup: 2015})
self.assertEqual(qs.count(), 2)
self.assertEqual(str(qs.query).lower().count('extract'), 0)
def test_extract_year_lessthan_lookup(self):
start_datetime = datetime(2015, 6, 15, 14, 10)
@ -131,12 +133,14 @@ class DateFunctionTests(TestCase):
self.create_model(start_datetime, end_datetime)
self.create_model(end_datetime, start_datetime)
qs = DTModel.objects.filter(start_datetime__year__lt=2016)
self.assertEqual(qs.count(), 1)
self.assertEqual(str(qs.query).count('extract'), 0)
qs = DTModel.objects.filter(start_datetime__year__lte=2016)
self.assertEqual(qs.count(), 2)
self.assertEqual(str(qs.query).count('extract'), 0)
for lookup in ('year', 'iso_year'):
with self.subTest(lookup):
qs = DTModel.objects.filter(**{'start_datetime__%s__lt' % lookup: 2016})
self.assertEqual(qs.count(), 1)
self.assertEqual(str(qs.query).count('extract'), 0)
qs = DTModel.objects.filter(**{'start_datetime__%s__lte' % lookup: 2016})
self.assertEqual(qs.count(), 2)
self.assertEqual(str(qs.query).count('extract'), 0)
def test_extract_func(self):
start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321)
@ -261,6 +265,51 @@ class DateFunctionTests(TestCase):
)
self.assertEqual(DTModel.objects.filter(start_datetime__year=ExtractYear('start_datetime')).count(), 2)
def test_extract_iso_year_func(self):
start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321)
end_datetime = 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=ExtractIsoYear('start_datetime')).order_by('start_datetime'),
[(start_datetime, start_datetime.year), (end_datetime, end_datetime.year)],
lambda m: (m.start_datetime, m.extracted)
)
self.assertQuerysetEqual(
DTModel.objects.annotate(extracted=ExtractIsoYear('start_date')).order_by('start_datetime'),
[(start_datetime, start_datetime.year), (end_datetime, end_datetime.year)],
lambda m: (m.start_datetime, m.extracted)
)
# Both dates are from the same week year.
self.assertEqual(DTModel.objects.filter(start_datetime__iso_year=ExtractIsoYear('start_datetime')).count(), 2)
def test_extract_iso_year_func_boundaries(self):
end_datetime = 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 = datetime(2014, 12, 27, 13, 0) # Sunday
week_1_day_2014_2015 = datetime(2014, 12, 31, 13, 0) # Wednesday
week_53_day_2015 = 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=ExtractIsoYear('start_datetime'),
).order_by('start_datetime')
self.assertQuerysetEqual(qs, [
(week_52_day_2014, 2014),
(week_1_day_2014_2015, 2015),
(week_53_day_2015, 2015),
], lambda m: (m.start_datetime, m.extracted))
def test_extract_month_func(self):
start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321)
end_datetime = datetime(2016, 6, 15, 14, 10, 50, 123)
@ -902,6 +951,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
day=Extract('start_datetime', 'day'),
day_melb=Extract('start_datetime', 'day', tzinfo=melb),
week=Extract('start_datetime', 'week', tzinfo=melb),
isoyear=ExtractIsoYear('start_datetime', tzinfo=melb),
weekday=ExtractWeekDay('start_datetime'),
weekday_melb=ExtractWeekDay('start_datetime', tzinfo=melb),
quarter=ExtractQuarter('start_datetime', tzinfo=melb),
@ -913,6 +963,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
self.assertEqual(utc_model.day, 15)
self.assertEqual(utc_model.day_melb, 16)
self.assertEqual(utc_model.week, 25)
self.assertEqual(utc_model.isoyear, 2015)
self.assertEqual(utc_model.weekday, 2)
self.assertEqual(utc_model.weekday_melb, 3)
self.assertEqual(utc_model.quarter, 2)
@ -925,6 +976,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
self.assertEqual(melb_model.day, 16)
self.assertEqual(melb_model.day_melb, 16)
self.assertEqual(melb_model.week, 25)
self.assertEqual(melb_model.isoyear, 2015)
self.assertEqual(melb_model.weekday, 3)
self.assertEqual(melb_model.quarter, 2)
self.assertEqual(melb_model.weekday_melb, 3)