Removed global timezone-aware datetime adapters.

Refs #23820.

Fixed #19738.

Refs #17755. In order not to introduce a regression for raw queries,
parameters are passed through the connection.ops.value_to_db_* methods,
depending on their type.
This commit is contained in:
Aymeric Augustin 2015-05-02 15:54:17 +02:00
parent fe6b5e62b1
commit d9521f66b1
9 changed files with 141 additions and 60 deletions

View File

@ -433,6 +433,25 @@ class BaseDatabaseOperations(object):
""" """
return value return value
def value_to_db_unknown(self, value):
"""
Transforms a value to something compatible with the backend driver.
This method only depends on the type of the value. It's designed for
cases where the target type isn't known, such as .raw() SQL queries.
As a consequence it may not work perfectly in all circumstances.
"""
if isinstance(value, datetime.datetime): # must be before date
return self.value_to_db_datetime(value)
elif isinstance(value, datetime.date):
return self.value_to_db_date(value)
elif isinstance(value, datetime.time):
return self.value_to_db_time(value)
elif isinstance(value, decimal.Decimal):
return self.value_to_db_decimal(value)
else:
return value
def value_to_db_date(self, value): def value_to_db_date(self, value):
""" """
Transforms a date value to an object compatible with what is expected Transforms a date value to an object compatible with what is expected
@ -486,6 +505,8 @@ class BaseDatabaseOperations(object):
""" """
first = datetime.date(value, 1, 1) first = datetime.date(value, 1, 1)
second = datetime.date(value, 12, 31) second = datetime.date(value, 12, 31)
first = self.value_to_db_date(first)
second = self.value_to_db_date(second)
return [first, second] return [first, second]
def year_lookup_bounds_for_datetime_field(self, value): def year_lookup_bounds_for_datetime_field(self, value):
@ -502,6 +523,8 @@ class BaseDatabaseOperations(object):
tz = timezone.get_current_timezone() tz = timezone.get_current_timezone()
first = timezone.make_aware(first, tz) first = timezone.make_aware(first, tz)
second = timezone.make_aware(second, tz) second = timezone.make_aware(second, tz)
first = self.value_to_db_datetime(first)
second = self.value_to_db_datetime(second)
return [first, second] return [first, second]
def get_db_converters(self, expression): def get_db_converters(self, expression):

View File

@ -16,6 +16,7 @@ from django.db import utils
from django.db.backends import utils as backend_utils from django.db.backends import utils as backend_utils
from django.db.backends.base.base import BaseDatabaseWrapper from django.db.backends.base.base import BaseDatabaseWrapper
from django.utils import six, timezone from django.utils import six, timezone
from django.utils.deprecation import RemovedInDjango21Warning
from django.utils.encoding import force_str from django.utils.encoding import force_str
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.safestring import SafeBytes, SafeText from django.utils.safestring import SafeBytes, SafeText
@ -52,15 +53,14 @@ DatabaseError = Database.DatabaseError
IntegrityError = Database.IntegrityError IntegrityError = Database.IntegrityError
def adapt_datetime_with_timezone_support(value, conv): def adapt_datetime_warn_on_aware_datetime(value, conv):
# Equivalent to DateTimeField.get_db_prep_value. Used only by raw SQL. # Remove this function and rely on the default adapter in Django 2.1.
if settings.USE_TZ: if settings.USE_TZ and timezone.is_aware(value):
if timezone.is_naive(value): warnings.warn(
warnings.warn("MySQL received a naive datetime (%s)" "The MySQL database adapter received an aware datetime (%s), "
" while time zone support is active." % value, "probably from cursor.execute(). Update your code to pass a "
RuntimeWarning) "naive datetime in the database connection's time zone (UTC by "
default_timezone = timezone.get_default_timezone() "default).", RemovedInDjango21Warning)
value = timezone.make_aware(value, default_timezone)
value = value.astimezone(timezone.utc).replace(tzinfo=None) value = value.astimezone(timezone.utc).replace(tzinfo=None)
return Thing2Literal(value.strftime("%Y-%m-%d %H:%M:%S.%f"), conv) return Thing2Literal(value.strftime("%Y-%m-%d %H:%M:%S.%f"), conv)
@ -74,7 +74,7 @@ django_conversions.update({
FIELD_TYPE.TIME: backend_utils.typecast_time, FIELD_TYPE.TIME: backend_utils.typecast_time,
FIELD_TYPE.DECIMAL: backend_utils.typecast_decimal, FIELD_TYPE.DECIMAL: backend_utils.typecast_decimal,
FIELD_TYPE.NEWDECIMAL: backend_utils.typecast_decimal, FIELD_TYPE.NEWDECIMAL: backend_utils.typecast_decimal,
datetime.datetime: adapt_datetime_with_timezone_support, datetime.datetime: adapt_datetime_warn_on_aware_datetime,
}) })
# This should match the numerical portion of the version numbers (we can treat # This should match the numerical portion of the version numbers (we can treat

View File

@ -17,6 +17,7 @@ from django.db import utils
from django.db.backends.base.base import BaseDatabaseWrapper from django.db.backends.base.base import BaseDatabaseWrapper
from django.db.backends.base.validation import BaseDatabaseValidation from django.db.backends.base.validation import BaseDatabaseValidation
from django.utils import six, timezone from django.utils import six, timezone
from django.utils.deprecation import RemovedInDjango21Warning
from django.utils.duration import duration_string from django.utils.duration import duration_string
from django.utils.encoding import force_bytes, force_text from django.utils.encoding import force_bytes, force_text
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -336,13 +337,14 @@ class OracleParam(object):
# without being converted by DateTimeField.get_db_prep_value. # without being converted by DateTimeField.get_db_prep_value.
if settings.USE_TZ and (isinstance(param, datetime.datetime) and if settings.USE_TZ and (isinstance(param, datetime.datetime) and
not isinstance(param, Oracle_datetime)): not isinstance(param, Oracle_datetime)):
if timezone.is_naive(param): if timezone.is_aware(param):
warnings.warn("Oracle received a naive datetime (%s)" warnings.warn(
" while time zone support is active." % param, "The Oracle database adapter received an aware datetime (%s), "
RuntimeWarning) "probably from cursor.execute(). Update your code to pass a "
default_timezone = timezone.get_default_timezone() "naive datetime in the database connection's time zone (UTC by "
param = timezone.make_aware(param, default_timezone) "default).", RemovedInDjango21Warning)
param = Oracle_datetime.from_datetime(param.astimezone(timezone.utc)) param = param.astimezone(timezone.utc).replace(tzinfo=None)
param = Oracle_datetime.from_datetime(param)
if isinstance(param, datetime.timedelta): if isinstance(param, datetime.timedelta):
param = duration_string(param) param = duration_string(param)

View File

@ -419,19 +419,6 @@ WHEN (new.%(col_name)s IS NULL)
return Oracle_datetime(1900, 1, 1, value.hour, value.minute, return Oracle_datetime(1900, 1, 1, value.hour, value.minute,
value.second, value.microsecond) value.second, value.microsecond)
def year_lookup_bounds_for_date_field(self, value):
# Create bounds as real date values
first = datetime.date(value, 1, 1)
last = datetime.date(value, 12, 31)
return [first, last]
def year_lookup_bounds_for_datetime_field(self, value):
# cx_Oracle doesn't support tz-aware datetimes
bounds = super(DatabaseOperations, self).year_lookup_bounds_for_datetime_field(value)
if settings.USE_TZ:
bounds = [b.astimezone(timezone.utc) for b in bounds]
return [Oracle_datetime.from_datetime(b) for b in bounds]
def combine_expression(self, connector, sub_expressions): def combine_expression(self, connector, sub_expressions):
"Oracle requires special cases for %% and & operators in query expressions" "Oracle requires special cases for %% and & operators in query expressions"
if connector == '%%': if connector == '%%':

View File

@ -20,6 +20,7 @@ from django.utils import six, timezone
from django.utils.dateparse import ( from django.utils.dateparse import (
parse_date, parse_datetime, parse_duration, parse_time, parse_date, parse_datetime, parse_duration, parse_time,
) )
from django.utils.deprecation import RemovedInDjango21Warning
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.utils.safestring import SafeBytes from django.utils.safestring import SafeBytes
@ -49,15 +50,14 @@ DatabaseError = Database.DatabaseError
IntegrityError = Database.IntegrityError IntegrityError = Database.IntegrityError
def adapt_datetime_with_timezone_support(value): def adapt_datetime_warn_on_aware_datetime(value):
# Equivalent to DateTimeField.get_db_prep_value. Used only by raw SQL. # Remove this function and rely on the default adapter in Django 2.1.
if settings.USE_TZ: if settings.USE_TZ and timezone.is_aware(value):
if timezone.is_naive(value): warnings.warn(
warnings.warn("SQLite received a naive datetime (%s)" "The SQLite database adapter received an aware datetime (%s), "
" while time zone support is active." % value, "probably from cursor.execute(). Update your code to pass a "
RuntimeWarning) "naive datetime in the database connection's time zone (UTC by "
default_timezone = timezone.get_default_timezone() "default).", RemovedInDjango21Warning)
value = timezone.make_aware(value, default_timezone)
value = value.astimezone(timezone.utc).replace(tzinfo=None) value = value.astimezone(timezone.utc).replace(tzinfo=None)
return value.isoformat(str(" ")) return value.isoformat(str(" "))
@ -77,7 +77,7 @@ Database.register_converter(str("timestamp"), decoder(parse_datetime))
Database.register_converter(str("TIMESTAMP"), decoder(parse_datetime)) Database.register_converter(str("TIMESTAMP"), decoder(parse_datetime))
Database.register_converter(str("decimal"), decoder(backend_utils.typecast_decimal)) Database.register_converter(str("decimal"), decoder(backend_utils.typecast_decimal))
Database.register_adapter(datetime.datetime, adapt_datetime_with_timezone_support) Database.register_adapter(datetime.datetime, adapt_datetime_warn_on_aware_datetime)
Database.register_adapter(decimal.Decimal, backend_utils.rev_typecast_decimal) Database.register_adapter(decimal.Decimal, backend_utils.rev_typecast_decimal)
if six.PY2: if six.PY2:
Database.register_adapter(str, lambda s: s.decode('utf-8')) Database.register_adapter(str, lambda s: s.decode('utf-8'))

View File

@ -86,13 +86,29 @@ class RawQuery(object):
def __repr__(self): def __repr__(self):
return "<RawQuery: %s>" % self return "<RawQuery: %s>" % self
@property
def params_type(self):
return dict if isinstance(self.params, Mapping) else tuple
def __str__(self): def __str__(self):
_type = dict if isinstance(self.params, Mapping) else tuple return self.sql % self.params_type(self.params)
return self.sql % _type(self.params)
def _execute_query(self): def _execute_query(self):
self.cursor = connections[self.using].cursor() connection = connections[self.using]
self.cursor.execute(self.sql, self.params)
# Adapt parameters to the database, as much as possible considering
# that the target type isn't known. See #17755.
params_type = self.params_type
adapter = connection.ops.value_to_db_unknown
if params_type is tuple:
params = tuple(adapter(val) for val in self.params)
elif params_type is dict:
params = dict((key, adapter(val)) for key, val in six.iteritems(self.params))
else:
raise RuntimeError("Unexpected params type: %s" % params_type)
self.cursor = connection.cursor()
self.cursor.execute(self.sql, params)
class Query(object): class Query(object):

View File

@ -32,6 +32,11 @@ details on these changes.
* ``django.db.models.fields.add_lazy_relation()`` will be removed. * ``django.db.models.fields.add_lazy_relation()`` will be removed.
* When time zone support is enabled, database backends that don't support time
zones won't convert aware datetimes to naive values in UTC anymore when such
values are passed as parameters to SQL queries executed outside of the ORM,
e.g. with ``cursor.execute()``.
* The ``django.contrib.auth.tests.utils.skipIfCustomUser()`` decorator will be * The ``django.contrib.auth.tests.utils.skipIfCustomUser()`` decorator will be
removed. removed.

View File

@ -313,11 +313,15 @@ Database backend API
doesn't implement this. You may want to review the implementation on the doesn't implement this. You may want to review the implementation on the
backends that Django includes for reference (:ticket:`24245`). backends that Django includes for reference (:ticket:`24245`).
* The recommended way to add time zone information to datetimes fetched from * Registering a global adapter or converter at the level of the DB-API module
databases that don't support time zones is to register a converter for to handle time zone information of :class:`~datetime.datetime` values passed
``DateTimeField``. Do this in ``DatabaseOperations.get_db_converters()``. as query parameters or returned as query results on databases that don't
Registering a global converter at the level of the DB-API module is support time zones is discouraged. It can conflict with other libraries.
discouraged because it can conflict with other libraries.
The recommended way to add a time zone to :class:`~datetime.datetime` values
fetched from the database is to register a converter for ``DateTimeField``
in ``DatabaseOperations.get_db_converters()``.
Default settings that were tuples are now lists Default settings that were tuples are now lists
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -418,20 +422,40 @@ console, for example.
If you are overriding Django's default logging, you should check to see how If you are overriding Django's default logging, you should check to see how
your configuration merges with the new defaults. your configuration merges with the new defaults.
Removal of time zone aware global converters for datetimes Removal of time zone aware global adapters and converters for datetimes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Django no longer registers global converters for returning time zone aware Django no longer registers global adapters and converters for managing time
datetimes in database query results when :setting:`USE_TZ` is ``True``. zone information on :class:`~datetime.datetime` values sent to the database as
Instead the ORM adds suitable time zone information. query parameters or read from the database in query results. This change
affects projects that meet all the following conditions:
As a consequence, SQL queries executed outside of the ORM, for instance with * The :setting:`USE_TZ` setting is ``True``.
``cursor.execute(query, params)``, now return naive datetimes instead of aware * The database is SQLite, MySQL, Oracle, or a third-party database that
datetimes on databases that do not support time zones: SQLite, MySQL, and doesn't support time zones. In doubt, you can check the value of
Oracle. Since these datetimes are in UTC, you can make them aware as follows:: ``connection.features.supports_timezones``.
* The code queries the database outside of the ORM, typically with
``cursor.execute(sql, params)``.
If you're passing aware :class:`~datetime.datetime` parameters to such
queries, you should turn them into naive datetimes in UTC::
from django.utils import timezone from django.utils import timezone
value = value.replace(tzinfo=timezone.utc) param = timezone.make_naive(param, timezone.utc)
If you fail to do so, Django 1.9 and 2.0 will perform the conversion like
earlier versions but emit a deprecation warning. Django 2.1 won't perform any
conversion, which may result in data corruption.
If you're reading :class:`~datetime.datetime` values from the results, they
will be naive instead of aware. You can compensate as follows::
from django.utils import timezone
value = timezone.make_aware(value, timezone.utc)
You don't need any of this if you're querying the database through the ORM,
even if you're using :meth:`raw() <django.db.models.query.QuerySet.raw>`
queries. The ORM takes care of managing time zone information.
Miscellaneous Miscellaneous
~~~~~~~~~~~~~ ~~~~~~~~~~~~~

View File

@ -266,6 +266,13 @@ class LegacyDatabaseTests(TestCase):
[event], [event],
transform=lambda d: d) transform=lambda d: d)
def test_cursor_execute_accepts_naive_datetime(self):
dt = datetime.datetime(2011, 9, 1, 13, 20, 30)
with connection.cursor() as cursor:
cursor.execute('INSERT INTO timezones_event (dt) VALUES (%s)', [dt])
event = Event.objects.get()
self.assertEqual(event.dt, dt)
def test_cursor_execute_returns_naive_datetime(self): def test_cursor_execute_returns_naive_datetime(self):
dt = datetime.datetime(2011, 9, 1, 13, 20, 30) dt = datetime.datetime(2011, 9, 1, 13, 20, 30)
Event.objects.create(dt=dt) Event.objects.create(dt=dt)
@ -564,6 +571,23 @@ class NewDatabaseTests(TestCase):
[event], [event],
transform=lambda d: d) transform=lambda d: d)
@skipUnlessDBFeature('supports_timezones')
def test_cursor_execute_accepts_aware_datetime(self):
dt = datetime.datetime(2011, 9, 1, 13, 20, 30, tzinfo=EAT)
with connection.cursor() as cursor:
cursor.execute('INSERT INTO timezones_event (dt) VALUES (%s)', [dt])
event = Event.objects.get()
self.assertEqual(event.dt, dt)
@skipIfDBFeature('supports_timezones')
def test_cursor_execute_accepts_naive_datetime(self):
dt = datetime.datetime(2011, 9, 1, 13, 20, 30, tzinfo=EAT)
utc_naive_dt = timezone.make_naive(dt, timezone.utc)
with connection.cursor() as cursor:
cursor.execute('INSERT INTO timezones_event (dt) VALUES (%s)', [utc_naive_dt])
event = Event.objects.get()
self.assertEqual(event.dt, dt)
@skipUnlessDBFeature('supports_timezones') @skipUnlessDBFeature('supports_timezones')
def test_cursor_execute_returns_aware_datetime(self): def test_cursor_execute_returns_aware_datetime(self):
dt = datetime.datetime(2011, 9, 1, 13, 20, 30, tzinfo=EAT) dt = datetime.datetime(2011, 9, 1, 13, 20, 30, tzinfo=EAT)
@ -578,7 +602,7 @@ class NewDatabaseTests(TestCase):
utc_naive_dt = timezone.make_naive(dt, timezone.utc) utc_naive_dt = timezone.make_naive(dt, timezone.utc)
Event.objects.create(dt=dt) Event.objects.create(dt=dt)
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute('SELECT dt FROM timezones_event WHERE dt = %s', [dt]) cursor.execute('SELECT dt FROM timezones_event WHERE dt = %s', [utc_naive_dt])
self.assertEqual(cursor.fetchall()[0][0], utc_naive_dt) self.assertEqual(cursor.fetchall()[0][0], utc_naive_dt)
@requires_tz_support @requires_tz_support