Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion django/db/backends/mysql/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ def date_extract_sql(self, lookup_type, field_name):
# 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.
Expand Down
2 changes: 2 additions & 0 deletions django/db/backends/oracle/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def date_extract_sql(self, lookup_type, field_name):
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
Expand Down
2 changes: 2 additions & 0 deletions django/db/backends/postgresql/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ def date_extract_sql(self, lookup_type, field_name):
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:
Expand Down
2 changes: 2 additions & 0 deletions django/db/backends/sqlite3/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
19 changes: 10 additions & 9 deletions django/db/models/functions/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -24,11 +25,11 @@
'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',
Expand Down
8 changes: 7 additions & 1 deletion django/db/models/functions/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def resolve_expression(self, query=None, allow_joins=True, reuse=None, summarize
)
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'."
Expand Down Expand Up @@ -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'

Expand All @@ -138,6 +143,7 @@ class ExtractSecond(Extract):
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)
Expand Down
43 changes: 30 additions & 13 deletions docs/ref/models/database-functions.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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)
Expand All @@ -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
~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -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,
... 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)
Expand All @@ -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
Expand All @@ -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::
Expand All @@ -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``
-------
Expand Down
29 changes: 29 additions & 0 deletions docs/ref/models/querysets.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down
5 changes: 4 additions & 1 deletion docs/releases/3.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~
Expand Down
65 changes: 60 additions & 5 deletions tests/db_functions/datetime/test_extract_trunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
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,
Expand Down Expand Up @@ -217,6 +218,16 @@ def test_extract_func(self):
],
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)],
Expand Down Expand Up @@ -275,7 +286,10 @@ def test_extract_duration_without_native_duration_field(self):

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))
Expand Down Expand Up @@ -499,6 +513,41 @@ def test_extract_weekday_func(self):
)
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)
Expand Down Expand Up @@ -1005,6 +1054,8 @@ def test_extract_func_with_timezone(self):
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),
Expand All @@ -1020,6 +1071,8 @@ def test_extract_func_with_timezone(self):
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)
Expand All @@ -1035,8 +1088,10 @@ def test_extract_func_with_timezone(self):
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)

Expand Down
Loading