From 1446902be48ebf19bfe484371897a2815dd21fca Mon Sep 17 00:00:00 2001 From: Mads Jensen Date: Fri, 11 Nov 2016 14:01:40 +0100 Subject: [PATCH] Fixed #25240 -- Added ExtractWeek and exposed it through the __week lookup. Thanks to Mariusz Felisiak and Tim Graham for review. --- django/db/backends/mysql/operations.py | 6 +++ django/db/backends/oracle/operations.py | 3 ++ django/db/backends/sqlite3/base.py | 4 ++ django/db/models/functions/__init__.py | 7 +-- django/db/models/functions/datetime.py | 9 ++++ docs/ref/models/database-functions.txt | 15 +++++++ docs/ref/models/querysets.txt | 22 +++++++++ docs/releases/1.11.txt | 5 +++ tests/db_functions/test_datetime.py | 60 ++++++++++++++++++++++++- 9 files changed, 126 insertions(+), 5 deletions(-) diff --git a/django/db/backends/mysql/operations.py b/django/db/backends/mysql/operations.py index 306a0378b2a..772ae1e37d9 100644 --- a/django/db/backends/mysql/operations.py +++ b/django/db/backends/mysql/operations.py @@ -24,7 +24,13 @@ class DatabaseOperations(BaseDatabaseOperations): # DAYOFWEEK() returns an integer, 1-7, Sunday=1. # Note: WEEKDAY() returns 0-6, Monday=0. return "DAYOFWEEK(%s)" % field_name + elif lookup_type == 'week': + # Override the value of default_week_format for consistency with + # other database backends. + # Mode 3: Monday, 1-53, with 4 or more days this year. + return "WEEK(%s, 3)" % 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) def date_trunc_sql(self, lookup_type, field_name): diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py index 8191f89dd09..82be9c52414 100644 --- a/django/db/backends/oracle/operations.py +++ b/django/db/backends/oracle/operations.py @@ -84,6 +84,9 @@ WHEN (new.%(col_name)s IS NULL) 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 == 'week': + # IW = ISO week number + return "TO_CHAR(%s, 'IW')" % field_name else: # http://docs.oracle.com/cd/B19306_01/server.102/b14200/functions050.htm return "EXTRACT(%s FROM %s)" % (lookup_type.upper(), field_name) diff --git a/django/db/backends/sqlite3/base.py b/django/db/backends/sqlite3/base.py index 66ad27842b3..aee012c7e6d 100644 --- a/django/db/backends/sqlite3/base.py +++ b/django/db/backends/sqlite3/base.py @@ -344,6 +344,8 @@ def _sqlite_date_extract(lookup_type, dt): return None if lookup_type == 'week_day': return (dt.isoweekday() % 7) + 1 + elif lookup_type == 'week': + return dt.isocalendar()[1] else: return getattr(dt, lookup_type) @@ -406,6 +408,8 @@ def _sqlite_datetime_extract(lookup_type, dt, tzname): return None if lookup_type == 'week_day': return (dt.isoweekday() % 7) + 1 + elif lookup_type == 'week': + return dt.isocalendar()[1] else: return getattr(dt, lookup_type) diff --git a/django/db/models/functions/__init__.py b/django/db/models/functions/__init__.py index dd45d7bb4c7..eeafc1dc48d 100644 --- a/django/db/models/functions/__init__.py +++ b/django/db/models/functions/__init__.py @@ -4,8 +4,9 @@ from .base import ( ) from .datetime import ( Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, - ExtractSecond, ExtractWeekDay, ExtractYear, Trunc, TruncDate, TruncDay, - TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime, TruncYear, + ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear, Trunc, TruncDate, + TruncDay, TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime, + TruncYear, ) __all__ = [ @@ -14,7 +15,7 @@ __all__ = [ 'Lower', 'Now', 'Substr', 'Upper', # datetime 'Extract', 'ExtractDay', 'ExtractHour', 'ExtractMinute', 'ExtractMonth', - 'ExtractSecond', 'ExtractWeekDay', 'ExtractYear', + 'ExtractSecond', 'ExtractWeek', 'ExtractWeekDay', 'ExtractYear', 'Trunc', 'TruncDate', 'TruncDay', 'TruncHour', 'TruncMinute', 'TruncMonth', 'TruncSecond', 'TruncTime', 'TruncYear', ] diff --git a/django/db/models/functions/datetime.py b/django/db/models/functions/datetime.py index 341c53e1620..7b137eabef4 100644 --- a/django/db/models/functions/datetime.py +++ b/django/db/models/functions/datetime.py @@ -87,6 +87,14 @@ class ExtractDay(Extract): lookup_name = 'day' +class ExtractWeek(Extract): + """ + Return 1-52 or 53, based on ISO-8601, i.e., Monday is the first of the + week. + """ + lookup_name = 'week' + + class ExtractWeekDay(Extract): """ Return Sunday=1 through Saturday=7. @@ -112,6 +120,7 @@ DateField.register_lookup(ExtractYear) DateField.register_lookup(ExtractMonth) DateField.register_lookup(ExtractDay) DateField.register_lookup(ExtractWeekDay) +DateField.register_lookup(ExtractWeek) TimeField.register_lookup(ExtractHour) TimeField.register_lookup(ExtractMinute) diff --git a/docs/ref/models/database-functions.txt b/docs/ref/models/database-functions.txt index 0d9c80fa256..ad653a710b9 100644 --- a/docs/ref/models/database-functions.txt +++ b/docs/ref/models/database-functions.txt @@ -313,6 +313,7 @@ Given the datetime ``2015-06-15 23:30:01.000321+00:00``, the built-in * "year": 2015 * "month": 6 * "day": 15 +* "week": 25 * "week_day": 2 * "hour": 23 * "minute": 30 @@ -340,6 +341,14 @@ returned when this timezone is active will be the same as above except for: >>> (dt.isoweekday() % 7) + 1 2 +.. admonition:: ``week`` values + + The ``week`` ``lookup_type`` is calculated based on `ISO-8601 + `_, i.e., + a week starts on a Monday. The first week is the one with the majority + of the days, i.e., a week that starts on or before Thursday. The value + returned is in the range 1 to 52 or 53. + Each ``lookup_name`` above has a corresponding ``Extract`` subclass (listed below) that should typically be used instead of the more verbose equivalent, e.g. use ``ExtractYear(...)`` rather than ``Extract(..., lookup_name='year')``. @@ -382,6 +391,12 @@ Usage example:: .. attribute:: lookup_name = 'week_day' +.. class:: ExtractWeek(expression, tzinfo=None, **extra) + + .. versionadded:: 1.11 + + .. attribute:: lookup_name = 'week' + These are logically equivalent to ``Extract('date_field', lookup_name)``. Each class is also a ``Transform`` registered on ``DateField`` and ``DateTimeField`` as ``__(lookup_name)``, e.g. ``__year``. diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 7bcf2fc6ed5..0d89f221b91 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -2703,6 +2703,28 @@ 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:: week + +``week`` +~~~~~~~~ + +.. versionadded:: 1.11 + +For date and datetime fields, return the week number (1-52 or 53) according +to `ISO-8601 `_, i.e., weeks start +on a Monday and the first week starts on or before Thursday. + +Example:: + + Entry.objects.filter(pub_date__week=52) + Entry.objects.filter(pub_date__week__gte=32, pub_date__week__lte=38) + +(No equivalent SQL code fragment is included for this lookup because +implementation of the relevant query varies among different database engines.) + +When :setting:`USE_TZ` is ``True``, fields are converted to the current time +zone before filtering. + .. fieldlookup:: week_day ``week_day`` diff --git a/docs/releases/1.11.txt b/docs/releases/1.11.txt index 77a1c3ea126..d26a6a0a51a 100644 --- a/docs/releases/1.11.txt +++ b/docs/releases/1.11.txt @@ -306,6 +306,11 @@ Models * Added support for time truncation to :class:`~django.db.models.functions.datetime.Trunc` functions. +* Added the :class:`~django.db.models.functions.datetime.ExtractWeek` function + to extract the week from :class:`~django.db.models.DateField` and + :class:`~django.db.models.DateTimeField` and exposed it through the + :lookup:`week` lookup. + * Added the :class:`~django.db.models.functions.datetime.TruncTime` function to truncate :class:`~django.db.models.DateTimeField` to its time component and exposed it through the :lookup:`time` lookup. diff --git a/tests/db_functions/test_datetime.py b/tests/db_functions/test_datetime.py index f9a9a54c1c3..a6c18438a68 100644 --- a/tests/db_functions/test_datetime.py +++ b/tests/db_functions/test_datetime.py @@ -9,8 +9,9 @@ from django.db import connection from django.db.models import DateField, DateTimeField, IntegerField, TimeField from django.db.models.functions import ( Extract, ExtractDay, ExtractHour, ExtractMinute, ExtractMonth, - ExtractSecond, ExtractWeekDay, ExtractYear, Trunc, TruncDate, TruncDay, - TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime, TruncYear, + ExtractSecond, ExtractWeek, ExtractWeekDay, ExtractYear, Trunc, TruncDate, + TruncDay, TruncHour, TruncMinute, TruncMonth, TruncSecond, TruncTime, + TruncYear, ) from django.test import TestCase, override_settings from django.utils import timezone @@ -166,6 +167,11 @@ class DateFunctionTests(TestCase): [(start_datetime, start_datetime.day), (end_datetime, end_datetime.day)], lambda m: (m.start_datetime, m.extracted) ) + self.assertQuerysetEqual( + DTModel.objects.annotate(extracted=Extract('start_datetime', 'week')).order_by('start_datetime'), + [(start_datetime, 25), (end_datetime, 24)], + lambda m: (m.start_datetime, m.extracted) + ) self.assertQuerysetEqual( DTModel.objects.annotate(extracted=Extract('start_datetime', 'week_day')).order_by('start_datetime'), [ @@ -254,6 +260,53 @@ class DateFunctionTests(TestCase): ) self.assertEqual(DTModel.objects.filter(start_datetime__day=ExtractDay('start_datetime')).count(), 2) + def test_extract_week_func(self): + start_datetime = microsecond_support(datetime(2015, 6, 15, 14, 30, 50, 321)) + end_datetime = microsecond_support(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=ExtractWeek('start_datetime')).order_by('start_datetime'), + [(start_datetime, 25), (end_datetime, 24)], + lambda m: (m.start_datetime, m.extracted) + ) + self.assertQuerysetEqual( + DTModel.objects.annotate(extracted=ExtractWeek('start_date')).order_by('start_datetime'), + [(start_datetime, 25), (end_datetime, 24)], + lambda m: (m.start_datetime, m.extracted) + ) + # both dates are from the same week. + self.assertEqual(DTModel.objects.filter(start_datetime__week=ExtractWeek('start_datetime')).count(), 2) + + def test_extract_week_func_boundaries(self): + end_datetime = microsecond_support(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 = microsecond_support(datetime(2014, 12, 27, 13, 0)) # Sunday + week_1_day_2014_2015 = microsecond_support(datetime(2014, 12, 31, 13, 0)) # Wednesday + week_53_day_2015 = microsecond_support(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=ExtractWeek('start_datetime'), + ).order_by('start_datetime') + self.assertQuerysetEqual(qs, [ + (week_52_day_2014, 52), + (week_1_day_2014_2015, 1), + (week_53_day_2015, 53), + ], lambda m: (m.start_datetime, m.extracted)) + def test_extract_weekday_func(self): start_datetime = microsecond_support(datetime(2015, 6, 15, 14, 30, 50, 321)) end_datetime = microsecond_support(datetime(2016, 6, 15, 14, 10, 50, 123)) @@ -669,6 +722,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests): qs = DTModel.objects.annotate( day=Extract('start_datetime', 'day'), day_melb=Extract('start_datetime', 'day', tzinfo=melb), + week=Extract('start_datetime', 'week', tzinfo=melb), weekday=ExtractWeekDay('start_datetime'), weekday_melb=ExtractWeekDay('start_datetime', tzinfo=melb), hour=ExtractHour('start_datetime'), @@ -678,6 +732,7 @@ class DateFunctionWithTimeZoneTests(DateFunctionTests): utc_model = qs.get() self.assertEqual(utc_model.day, 15) self.assertEqual(utc_model.day_melb, 16) + self.assertEqual(utc_model.week, 25) self.assertEqual(utc_model.weekday, 2) self.assertEqual(utc_model.weekday_melb, 3) self.assertEqual(utc_model.hour, 23) @@ -688,6 +743,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.weekday, 3) self.assertEqual(melb_model.weekday_melb, 3) self.assertEqual(melb_model.hour, 9)