Browse Source

Fixed #30821 -- Added ExtractIsoWeekYear database function and iso_week_day lookup.

Anatol Ulrich 5 years ago
parent
commit
8ed6788aa4

+ 3 - 1
django/db/backends/mysql/operations.py

@@ -36,8 +36,10 @@ class DatabaseOperations(BaseDatabaseOperations):
         # https://dev.mysql.com/doc/mysql/en/date-and-time-functions.html
         if lookup_type == 'week_day':
             # DAYOFWEEK() returns an integer, 1-7, Sunday=1.
-            # Note: WEEKDAY() returns 0-6, Monday=0.
             return "DAYOFWEEK(%s)" % field_name
+        elif lookup_type == 'iso_week_day':
+            # WEEKDAY() returns an integer, 0-6, Monday=0.
+            return "WEEKDAY(%s) + 1" % field_name
         elif lookup_type == 'week':
             # Override the value of default_week_format for consistency with
             # other database backends.

+ 2 - 0
django/db/backends/oracle/operations.py

@@ -74,6 +74,8 @@ END;
         if lookup_type == 'week_day':
             # TO_CHAR(field, 'D') returns an integer from 1-7, where 1=Sunday.
             return "TO_CHAR(%s, 'D')" % field_name
+        elif lookup_type == 'iso_week_day':
+            return "TO_CHAR(%s - 1, 'D')" % field_name
         elif lookup_type == 'week':
             # IW = ISO week number
             return "TO_CHAR(%s, 'IW')" % field_name

+ 2 - 0
django/db/backends/postgresql/operations.py

@@ -32,6 +32,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_week_day':
+            return "EXTRACT('isodow' FROM %s)" % field_name
         elif lookup_type == 'iso_year':
             return "EXTRACT('isoyear' FROM %s)" % field_name
         else:

+ 2 - 0
django/db/backends/sqlite3/base.py

@@ -478,6 +478,8 @@ def _sqlite_datetime_extract(lookup_type, dt, tzname=None, conn_tzname=None):
         return None
     if lookup_type == 'week_day':
         return (dt.isoweekday() % 7) + 1
+    elif lookup_type == 'iso_week_day':
+        return dt.isoweekday()
     elif lookup_type == 'week':
         return dt.isocalendar()[1]
     elif lookup_type == 'quarter':

+ 10 - 9
django/db/models/functions/__init__.py

@@ -1,9 +1,10 @@
 from .comparison import Cast, Coalesce, Greatest, Least, NullIf
 from .datetime import (
-    Extract, ExtractDay, ExtractHour, ExtractIsoYear, ExtractMinute,
-    ExtractMonth, ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay,
-    ExtractYear, Now, Trunc, TruncDate, TruncDay, TruncHour, TruncMinute,
-    TruncMonth, TruncQuarter, TruncSecond, TruncTime, TruncWeek, TruncYear,
+    Extract, ExtractDay, ExtractHour, ExtractIsoWeekDay, 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,11 +25,11 @@ __all__ = [
     'Cast', 'Coalesce', 'Greatest', 'Least', 'NullIf',
     # datetime
     'Extract', 'ExtractDay', 'ExtractHour', 'ExtractMinute', 'ExtractMonth',
-    'ExtractQuarter', 'ExtractSecond', 'ExtractWeek', 'ExtractWeekDay',
-    'ExtractIsoYear', 'ExtractYear', 'Now', 'Trunc', 'TruncDate', 'TruncDay',
-    'TruncHour', 'TruncMinute', 'TruncMonth', 'TruncQuarter', 'TruncSecond',
-    'TruncMinute', 'TruncMonth', 'TruncQuarter', 'TruncSecond', 'TruncTime',
-    'TruncWeek', 'TruncYear',
+    'ExtractQuarter', 'ExtractSecond', 'ExtractWeek', 'ExtractIsoWeekDay',
+    'ExtractWeekDay', 'ExtractIsoYear', 'ExtractYear', 'Now', 'Trunc',
+    'TruncDate', 'TruncDay', 'TruncHour', 'TruncMinute', 'TruncMonth',
+    'TruncQuarter', 'TruncSecond', 'TruncMinute', 'TruncMonth', 'TruncQuarter',
+    'TruncSecond', 'TruncTime', 'TruncWeek', 'TruncYear',
     # math
     'Abs', 'ACos', 'ASin', 'ATan', 'ATan2', 'Ceil', 'Cos', 'Cot', 'Degrees',
     'Exp', 'Floor', 'Ln', 'Log', 'Mod', 'Pi', 'Power', 'Radians', 'Round',

+ 7 - 1
django/db/models/functions/datetime.py

@@ -75,7 +75,7 @@ class Extract(TimezoneMixin, Transform):
             )
         if (
             isinstance(field, DurationField) and
-            copy.lookup_name in ('year', 'iso_year', 'month', 'week', 'week_day', 'quarter')
+            copy.lookup_name in ('year', 'iso_year', 'month', 'week', 'week_day', 'iso_week_day', 'quarter')
         ):
             raise ValueError(
                 "Cannot extract component '%s' from DurationField '%s'."
@@ -118,6 +118,11 @@ class ExtractWeekDay(Extract):
     lookup_name = 'week_day'
 
 
+class ExtractIsoWeekDay(Extract):
+    """Return Monday=1 through Sunday=7, based on ISO-8601."""
+    lookup_name = 'iso_week_day'
+
+
 class ExtractQuarter(Extract):
     lookup_name = 'quarter'
 
@@ -138,6 +143,7 @@ DateField.register_lookup(ExtractYear)
 DateField.register_lookup(ExtractMonth)
 DateField.register_lookup(ExtractDay)
 DateField.register_lookup(ExtractWeekDay)
+DateField.register_lookup(ExtractIsoWeekDay)
 DateField.register_lookup(ExtractWeek)
 DateField.register_lookup(ExtractIsoYear)
 DateField.register_lookup(ExtractQuarter)

+ 30 - 13
docs/ref/models/database-functions.txt

@@ -205,6 +205,7 @@ Given the datetime ``2015-06-15 23:30:01.000321+00:00``, the built-in
 * "day": 15
 * "week": 25
 * "week_day": 2
+* "iso_week_day": 1
 * "hour": 23
 * "minute": 30
 * "second": 1
@@ -216,6 +217,7 @@ returned when this timezone is active will be the same as above except for:
 
 * "day": 16
 * "week_day": 3
+* "iso_week_day": 2
 * "hour": 9
 
 .. admonition:: ``week_day`` values
@@ -288,6 +290,15 @@ Usage example::
 
     .. attribute:: lookup_name = 'week_day'
 
+.. class:: ExtractIsoWeekDay(expression, tzinfo=None, **extra)
+
+    .. versionadded:: 3.1
+
+    Returns the ISO-8601 week day with day 1 being Monday and day 7 being
+    Sunday.
+
+    .. attribute:: lookup_name = 'iso_week_day'
+
 .. class:: ExtractWeek(expression, tzinfo=None, **extra)
 
     .. attribute:: lookup_name = 'week'
@@ -307,7 +318,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, ExtractIsoYear, ExtractYear,
+    ...     ExtractIsoWeekDay, 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)
@@ -322,11 +333,13 @@ that deal with date-parts can be used with ``DateField``::
     ...     week=ExtractWeek('start_date'),
     ...     day=ExtractDay('start_date'),
     ...     weekday=ExtractWeekDay('start_date'),
-    ... ).values('year', 'isoyear', 'quarter', 'month', 'week', 'day', 'weekday').get(
-    ...     end_date__year=ExtractYear('start_date'),
-    ... )
+    ...     isoweekday=ExtractIsoWeekDay('start_date'),
+    ... ).values(
+    ...     'year', 'isoyear', 'quarter', 'month', 'week', 'day', 'weekday',
+    ...     'isoweekday',
+    ... ).get(end_date__year=ExtractYear('start_date'))
     {'year': 2015, 'isoyear': 2015, 'quarter': 2, 'month': 6, 'week': 25,
-     'day': 15, 'weekday': 2}
+     'day': 15, 'weekday': 2, 'isoweekday': 1}
 
 ``DateTimeField`` extracts
 ~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -356,8 +369,8 @@ Each class is also a ``Transform`` registered on ``DateTimeField`` as
     >>> from django.utils import timezone
     >>> from django.db.models.functions import (
     ...     ExtractDay, ExtractHour, ExtractMinute, ExtractMonth,
-    ...     ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay,
-    ...     ExtractIsoYear, ExtractYear,
+    ...     ExtractQuarter, ExtractSecond, ExtractWeek, ExtractIsoWeekDay,
+    ...     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)
@@ -372,15 +385,17 @@ Each class is also a ``Transform`` registered on ``DateTimeField`` as
     ...     week=ExtractWeek('start_datetime'),
     ...     day=ExtractDay('start_datetime'),
     ...     weekday=ExtractWeekDay('start_datetime'),
+    ...     isoweekday=ExtractIsoWeekDay('start_datetime'),
     ...     hour=ExtractHour('start_datetime'),
     ...     minute=ExtractMinute('start_datetime'),
     ...     second=ExtractSecond('start_datetime'),
     ... ).values(
     ...     'year', 'isoyear', 'month', 'week', 'day',
-    ...     'weekday', 'hour', 'minute', 'second',
+    ...     'weekday', 'isoweekday', 'hour', 'minute', 'second',
     ... ).get(end_datetime__year=ExtractYear('start_datetime'))
     {'year': 2015, 'isoyear': 2015, 'quarter': 2, 'month': 6, 'week': 25,
-     'day': 15, 'weekday': 2, 'hour': 23, 'minute': 30, 'second': 1}
+     'day': 15, 'weekday': 2, 'isoweekday': 1, '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
@@ -394,11 +409,12 @@ values that are returned::
     ...    Experiment.objects.annotate(
     ...        day=ExtractDay('start_datetime'),
     ...        weekday=ExtractWeekDay('start_datetime'),
+    ...        isoweekday=ExtractIsoWeekDay('start_datetime'),
     ...        hour=ExtractHour('start_datetime'),
-    ...    ).values('day', 'weekday', 'hour').get(
+    ...    ).values('day', 'weekday', 'isoweekday', 'hour').get(
     ...        end_datetime__year=ExtractYear('start_datetime'),
     ...    )
-    {'day': 16, 'weekday': 3, 'hour': 9}
+    {'day': 16, 'weekday': 3, 'isoweekday': 2, 'hour': 9}
 
 Explicitly passing the timezone to the ``Extract`` function behaves in the same
 way, and takes priority over an active timezone::
@@ -408,11 +424,12 @@ way, and takes priority over an active timezone::
     >>> Experiment.objects.annotate(
     ...     day=ExtractDay('start_datetime', tzinfo=melb),
     ...     weekday=ExtractWeekDay('start_datetime', tzinfo=melb),
+    ...     isoweekday=ExtractIsoWeekDay('start_datetime', tzinfo=melb),
     ...     hour=ExtractHour('start_datetime', tzinfo=melb),
-    ... ).values('day', 'weekday', 'hour').get(
+    ... ).values('day', 'weekday', 'isoweekday', 'hour').get(
     ...     end_datetime__year=ExtractYear('start_datetime'),
     ... )
-    {'day': 16, 'weekday': 3, 'hour': 9}
+    {'day': 16, 'weekday': 3, 'isoweekday': 2, 'hour': 9}
 
 ``Now``
 -------

+ 29 - 0
docs/ref/models/querysets.txt

@@ -3110,6 +3110,35 @@ When :setting:`USE_TZ` is ``True``, datetime fields are converted to the
 current time zone before filtering. This requires :ref:`time zone definitions
 in the database <database-time-zone-definitions>`.
 
+.. fieldlookup:: iso_week_day
+
+``iso_week_day``
+~~~~~~~~~~~~~~~~
+
+.. versionadded:: 3.1
+
+For date and datetime fields, an exact ISO 8601 day of the week match. Allows
+chaining additional field lookups.
+
+Takes an integer value representing the day of the week from 1 (Monday) to 7
+(Sunday).
+
+Example::
+
+    Entry.objects.filter(pub_date__iso_week_day=1)
+    Entry.objects.filter(pub_date__iso_week_day__gte=1)
+
+(No equivalent SQL code fragment is included for this lookup because
+implementation of the relevant query varies among different database engines.)
+
+Note this will match any record with a ``pub_date`` that falls on a Monday (day
+1 of the week), regardless of the month or year in which it occurs. Week days
+are indexed with day 1 being Monday and day 7 being Sunday.
+
+When :setting:`USE_TZ` is ``True``, datetime fields are converted to the
+current time zone before filtering. This requires :ref:`time zone definitions
+in the database <database-time-zone-definitions>`.
+
 .. fieldlookup:: quarter
 
 ``quarter``

+ 4 - 1
docs/releases/3.1.txt

@@ -160,7 +160,10 @@ Migrations
 Models
 ~~~~~~
 
-* ...
+* The new :class:`~django.db.models.functions.ExtractIsoWeekDay` function
+  extracts ISO-8601 week days from :class:`~django.db.models.DateField` and
+  :class:`~django.db.models.DateTimeField`, and the new :lookup:`iso_week_day`
+  lookup allows querying by an ISO-8601 day of week.
 
 Pagination
 ~~~~~~~~~~

+ 60 - 5
tests/db_functions/datetime/test_extract_trunc.py

@@ -8,10 +8,11 @@ from django.db.models import (
     TimeField,
 )
 from django.db.models.functions import (
-    Extract, ExtractDay, ExtractHour, ExtractIsoYear, ExtractMinute,
-    ExtractMonth, ExtractQuarter, ExtractSecond, ExtractWeek, ExtractWeekDay,
-    ExtractYear, Trunc, TruncDate, TruncDay, TruncHour, TruncMinute,
-    TruncMonth, TruncQuarter, TruncSecond, TruncTime, TruncWeek, TruncYear,
+    Extract, ExtractDay, ExtractHour, ExtractIsoWeekDay, 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,
@@ -217,6 +218,16 @@ class DateFunctionTests(TestCase):
             ],
             lambda m: (m.start_datetime, m.extracted)
         )
+        self.assertQuerysetEqual(
+            DTModel.objects.annotate(
+                extracted=Extract('start_datetime', 'iso_week_day'),
+            ).order_by('start_datetime'),
+            [
+                (start_datetime, start_datetime.isoweekday()),
+                (end_datetime, end_datetime.isoweekday()),
+            ],
+            lambda m: (m.start_datetime, m.extracted)
+        )
         self.assertQuerysetEqual(
             DTModel.objects.annotate(extracted=Extract('start_datetime', 'hour')).order_by('start_datetime'),
             [(start_datetime, start_datetime.hour), (end_datetime, end_datetime.hour)],
@@ -275,7 +286,10 @@ class DateFunctionTests(TestCase):
 
     def test_extract_duration_unsupported_lookups(self):
         msg = "Cannot extract component '%s' from DurationField 'duration'."
-        for lookup in ('year', 'iso_year', 'month', 'week', 'week_day', 'quarter'):
+        for lookup in (
+            'year', 'iso_year', 'month', 'week', 'week_day', 'iso_week_day',
+            'quarter',
+        ):
             with self.subTest(lookup):
                 with self.assertRaisesMessage(ValueError, msg % lookup):
                     DTModel.objects.annotate(extracted=Extract('duration', lookup))
@@ -499,6 +513,41 @@ class DateFunctionTests(TestCase):
         )
         self.assertEqual(DTModel.objects.filter(start_datetime__week_day=ExtractWeekDay('start_datetime')).count(), 2)
 
+    def test_extract_iso_weekday_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=ExtractIsoWeekDay('start_datetime'),
+            ).order_by('start_datetime'),
+            [
+                (start_datetime, start_datetime.isoweekday()),
+                (end_datetime, end_datetime.isoweekday()),
+            ],
+            lambda m: (m.start_datetime, m.extracted)
+        )
+        self.assertQuerysetEqual(
+            DTModel.objects.annotate(
+                extracted=ExtractIsoWeekDay('start_date'),
+            ).order_by('start_datetime'),
+            [
+                (start_datetime, start_datetime.isoweekday()),
+                (end_datetime, end_datetime.isoweekday()),
+            ],
+            lambda m: (m.start_datetime, m.extracted)
+        )
+        self.assertEqual(
+            DTModel.objects.filter(
+                start_datetime__week_day=ExtractWeekDay('start_datetime'),
+            ).count(),
+            2,
+        )
+
     def test_extract_hour_func(self):
         start_datetime = datetime(2015, 6, 15, 14, 30, 50, 321)
         end_datetime = datetime(2016, 6, 15, 14, 10, 50, 123)
@@ -1005,6 +1054,8 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
             isoyear=ExtractIsoYear('start_datetime', tzinfo=melb),
             weekday=ExtractWeekDay('start_datetime'),
             weekday_melb=ExtractWeekDay('start_datetime', tzinfo=melb),
+            isoweekday=ExtractIsoWeekDay('start_datetime'),
+            isoweekday_melb=ExtractIsoWeekDay('start_datetime', tzinfo=melb),
             quarter=ExtractQuarter('start_datetime', tzinfo=melb),
             hour=ExtractHour('start_datetime'),
             hour_melb=ExtractHour('start_datetime', tzinfo=melb),
@@ -1020,6 +1071,8 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
         self.assertEqual(utc_model.isoyear, 2015)
         self.assertEqual(utc_model.weekday, 2)
         self.assertEqual(utc_model.weekday_melb, 3)
+        self.assertEqual(utc_model.isoweekday, 1)
+        self.assertEqual(utc_model.isoweekday_melb, 2)
         self.assertEqual(utc_model.quarter, 2)
         self.assertEqual(utc_model.hour, 23)
         self.assertEqual(utc_model.hour_melb, 9)
@@ -1035,8 +1088,10 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests):
         self.assertEqual(melb_model.week, 25)
         self.assertEqual(melb_model.isoyear, 2015)
         self.assertEqual(melb_model.weekday, 3)
+        self.assertEqual(melb_model.isoweekday, 2)
         self.assertEqual(melb_model.quarter, 2)
         self.assertEqual(melb_model.weekday_melb, 3)
+        self.assertEqual(melb_model.isoweekday_melb, 2)
         self.assertEqual(melb_model.hour, 9)
         self.assertEqual(melb_model.hour_melb, 9)
 

+ 3 - 0
tests/timezones/tests.py

@@ -153,6 +153,7 @@ class LegacyDatabaseTests(TestCase):
         self.assertEqual(Event.objects.filter(dt__month=1).count(), 2)
         self.assertEqual(Event.objects.filter(dt__day=1).count(), 2)
         self.assertEqual(Event.objects.filter(dt__week_day=7).count(), 2)
+        self.assertEqual(Event.objects.filter(dt__iso_week_day=6).count(), 2)
         self.assertEqual(Event.objects.filter(dt__hour=1).count(), 1)
         self.assertEqual(Event.objects.filter(dt__minute=30).count(), 2)
         self.assertEqual(Event.objects.filter(dt__second=0).count(), 2)
@@ -366,6 +367,7 @@ class NewDatabaseTests(TestCase):
         self.assertEqual(Event.objects.filter(dt__month=1).count(), 2)
         self.assertEqual(Event.objects.filter(dt__day=1).count(), 2)
         self.assertEqual(Event.objects.filter(dt__week_day=7).count(), 2)
+        self.assertEqual(Event.objects.filter(dt__iso_week_day=6).count(), 2)
         self.assertEqual(Event.objects.filter(dt__hour=1).count(), 1)
         self.assertEqual(Event.objects.filter(dt__minute=30).count(), 2)
         self.assertEqual(Event.objects.filter(dt__second=0).count(), 2)
@@ -381,6 +383,7 @@ class NewDatabaseTests(TestCase):
             self.assertEqual(Event.objects.filter(dt__month=1).count(), 1)
             self.assertEqual(Event.objects.filter(dt__day=1).count(), 1)
             self.assertEqual(Event.objects.filter(dt__week_day=7).count(), 1)
+            self.assertEqual(Event.objects.filter(dt__iso_week_day=6).count(), 1)
             self.assertEqual(Event.objects.filter(dt__hour=22).count(), 1)
             self.assertEqual(Event.objects.filter(dt__minute=30).count(), 2)
             self.assertEqual(Event.objects.filter(dt__second=0).count(), 2)