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)