From 8ed6788aa479034a44a8aa87da47de56dd728c95 Mon Sep 17 00:00:00 2001 From: Anatol Ulrich Date: Tue, 1 Oct 2019 00:12:19 +0200 Subject: [PATCH] Fixed #30821 -- Added ExtractIsoWeekYear database function and iso_week_day lookup. --- django/db/backends/mysql/operations.py | 4 +- django/db/backends/oracle/operations.py | 2 + django/db/backends/postgresql/operations.py | 2 + django/db/backends/sqlite3/base.py | 2 + django/db/models/functions/__init__.py | 19 +++--- django/db/models/functions/datetime.py | 8 ++- docs/ref/models/database-functions.txt | 43 ++++++++---- docs/ref/models/querysets.txt | 29 +++++++++ docs/releases/3.1.txt | 5 +- .../datetime/test_extract_trunc.py | 65 +++++++++++++++++-- tests/timezones/tests.py | 3 + 11 files changed, 152 insertions(+), 30 deletions(-) diff --git a/django/db/backends/mysql/operations.py b/django/db/backends/mysql/operations.py index e940286720..49ee2919f2 100644 --- a/django/db/backends/mysql/operations.py +++ b/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. diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py index 3226b564df..4a6c71e149 100644 --- a/django/db/backends/oracle/operations.py +++ b/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 diff --git a/django/db/backends/postgresql/operations.py b/django/db/backends/postgresql/operations.py index fe5b208c6a..56e1331f75 100644 --- a/django/db/backends/postgresql/operations.py +++ b/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: diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 7a4439ae88..f959f921f1 100644 --- a/django/db/backends/sqlite3/base.py +++ b/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': diff --git a/django/db/models/functions/__init__.py b/django/db/models/functions/__init__.py index c928873661..8cbb0e3f39 100644 --- a/django/db/models/functions/__init__.py +++ b/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', diff --git a/django/db/models/functions/datetime.py b/django/db/models/functions/datetime.py index 665e0b931d..b6594b043b 100644 --- a/django/db/models/functions/datetime.py +++ b/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) diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index 88cc0a053e..90c8ab6344 100644 --- a/docs/ref/models/database-functions.txt +++ b/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`` ------- diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 5de717d131..219245576a 100644 --- a/docs/ref/models/querysets.txt +++ b/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 `. +.. 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 `. + .. fieldlookup:: quarter ``quarter`` diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index d2078469ee..c3fbc324d1 100644 --- a/docs/releases/3.1.txt +++ b/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 ~~~~~~~~~~ diff --git a/tests/db_functions/datetime/test_extract_trunc.py b/tests/db_functions/datetime/test_extract_trunc.py index f2f75ee620..f8ebb81cb5 100644 --- a/tests/db_functions/datetime/test_extract_trunc.py +++ b/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) diff --git a/tests/timezones/tests.py b/tests/timezones/tests.py index 68c7e3a7ef..e57e12d6c4 100644 --- a/tests/timezones/tests.py +++ b/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)