Browse Source

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

Sigurd Ljødal 7 years ago
parent
commit
3e09b37f80

+ 4 - 0
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)

+ 2 - 0
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)

+ 2 - 0
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)
 

+ 4 - 0
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)
 

+ 6 - 5
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

+ 12 - 0
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'

+ 19 - 6
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

+ 20 - 0
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``

+ 5 - 0
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
 ~~~~~~~~~~~~~~~~~~~~~~
 

+ 87 - 35
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)