summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSigurd Ljødal <sigurd@ljodal.no>2017-09-28 22:28:48 +0200
committerTim Graham <timograham@gmail.com>2018-08-18 13:09:15 -0400
commit3e09b37f80ab34cf57e245e1fcdabb3d4ff92a38 (patch)
tree7b56c5caeb4d6ebe6b5075c711c36a0572419c6f
parentc832885a3e8659d4a704bf103d523b610c24e4ec (diff)
downloaddjango-3e09b37f80ab34cf57e245e1fcdabb3d4ff92a38.tar.gz
Fixed #28649 -- Added ExtractIsoYear database function and iso_year lookup.
-rw-r--r--django/db/backends/mysql/operations.py4
-rw-r--r--django/db/backends/oracle/operations.py2
-rw-r--r--django/db/backends/postgresql/operations.py2
-rw-r--r--django/db/backends/sqlite3/base.py4
-rw-r--r--django/db/models/functions/__init__.py11
-rw-r--r--django/db/models/functions/datetime.py12
-rw-r--r--docs/ref/models/database-functions.txt25
-rw-r--r--docs/ref/models/querysets.txt20
-rw-r--r--docs/releases/2.2.txt5
-rw-r--r--tests/db_functions/datetime/test_extract_trunc.py122
10 files changed, 161 insertions, 46 deletions
diff --git a/django/db/backends/mysql/operations.py b/django/db/backends/mysql/operations.py
index babf522e45..877c32b6a7 100644
--- a/django/db/backends/mysql/operations.py
+++ b/django/db/backends/mysql/operations.py
@@ -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)
diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py
index 62830476bf..7018123cfa 100644
--- a/django/db/backends/oracle/operations.py
+++ b/django/db/backends/oracle/operations.py
@@ -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)
diff --git a/django/db/backends/postgresql/operations.py b/django/db/backends/postgresql/operations.py
index f4e9571f81..b1b83861c1 100644
--- a/django/db/backends/postgresql/operations.py
+++ b/django/db/backends/postgresql/operations.py
@@ -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)
diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py
index 20905c83b8..d124fcd47b 100644
--- a/django/db/backends/sqlite3/base.py
+++ b/django/db/backends/sqlite3/base.py
@@ -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)
diff --git a/django/db/models/functions/__init__.py b/django/db/models/functions/__init__.py
index 5f5a8bd1dc..f005546eb0 100644
--- a/django/db/models/functions/__init__.py
+++ b/django/db/models/functions/__init__.py
@@ -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
diff --git a/django/db/models/functions/datetime.py b/django/db/models/functions/datetime.py
index 1876aa7d5c..4d24d2a694 100644
--- a/django/db/models/functions/datetime.py
+++ b/django/db/models/functions/datetime.py
@@ -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'
diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt
index 77e6a1beb4..f1affb2b6f 100644
--- a/docs/ref/models/database-functions.txt
+++ b/docs/ref/models/database-functions.txt
@@ -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
diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt
index c171d5074f..d2e20261a7 100644
--- a/docs/ref/models/querysets.txt
+++ b/docs/ref/models/querysets.txt
@@ -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``
diff --git a/docs/releases/2.2.txt b/docs/releases/2.2.txt
index 4d5b763edf..307a2b6a06 100644
--- a/docs/releases/2.2.txt
+++ b/docs/releases/2.2.txt
@@ -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
~~~~~~~~~~~~~~~~~~~~~~
diff --git a/tests/db_functions/datetime/test_extract_trunc.py b/tests/db_functions/datetime/test_extract_trunc.py
index 077690630b..99d33b252c 100644
--- a/tests/db_functions/datetime/test_extract_trunc.py
+++ b/tests/db_functions/datetime/test_extract_trunc.py
@@ -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)