Fixed #20292: Pass datetime objects (not formatted dates) as params to Oracle

This seems worthwhile in its own right, but also works around an Oracle
bug (in versions 10 -- 11.1) where the use of Unicode would reset the
date/time formats, causing ORA-01843 errors.

Thanks Trac users CarstenF for the report, jtiai for the initial patch,
and everyone who contributed to the discussion on the ticket.
This commit is contained in:
Shai Berger 2014-03-12 23:43:45 +02:00
parent fc79c3fb3d
commit 6983201cfb
3 changed files with 63 additions and 17 deletions

View File

@ -78,6 +78,19 @@ else:
convert_unicode = force_bytes convert_unicode = force_bytes
class Oracle_datetime(datetime.datetime):
"""
A datetime object, with an additional class attribute
to tell cx_Oracle to save the microseconds too.
"""
input_size = Database.TIMESTAMP
@classmethod
def from_datetime(cls, dt):
return Oracle_datetime(dt.year, dt.month, dt.day,
dt.hour, dt.minute, dt.second, dt.microsecond)
class DatabaseFeatures(BaseDatabaseFeatures): class DatabaseFeatures(BaseDatabaseFeatures):
empty_fetchmany_value = () empty_fetchmany_value = ()
needs_datetime_string_cast = False needs_datetime_string_cast = False
@ -421,18 +434,36 @@ WHEN (new.%(col_name)s IS NULL)
else: else:
return "TABLESPACE %s" % self.quote_name(tablespace) return "TABLESPACE %s" % self.quote_name(tablespace)
def value_to_db_date(self, value):
"""
Transform a date value to an object compatible with what is expected
by the backend driver for date columns.
The default implementation transforms the date to text, but that is not
necessary for Oracle.
"""
return value
def value_to_db_datetime(self, value): def value_to_db_datetime(self, value):
"""
Transform a datetime value to an object compatible with what is expected
by the backend driver for datetime columns.
If naive datetime is passed assumes that is in UTC. Normally Django
models.DateTimeField makes sure that if USE_TZ is True passed datetime
is timezone aware.
"""
if value is None: if value is None:
return None return None
# Oracle doesn't support tz-aware datetimes # cx_Oracle doesn't support tz-aware datetimes
if timezone.is_aware(value): if timezone.is_aware(value):
if settings.USE_TZ: if settings.USE_TZ:
value = value.astimezone(timezone.utc).replace(tzinfo=None) value = value.astimezone(timezone.utc).replace(tzinfo=None)
else: else:
raise ValueError("Oracle backend does not support timezone-aware datetimes when USE_TZ is False.") raise ValueError("Oracle backend does not support timezone-aware datetimes when USE_TZ is False.")
return six.text_type(value) return Oracle_datetime.from_datetime(value)
def value_to_db_time(self, value): def value_to_db_time(self, value):
if value is None: if value is None:
@ -445,24 +476,21 @@ WHEN (new.%(col_name)s IS NULL)
if timezone.is_aware(value): if timezone.is_aware(value):
raise ValueError("Oracle backend does not support timezone-aware times.") raise ValueError("Oracle backend does not support timezone-aware times.")
return datetime.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): def year_lookup_bounds_for_date_field(self, value):
first = '%s-01-01' # Create bounds as real date values
second = '%s-12-31' first = datetime.date(value, 1, 1)
return [first % value, second % value] last = datetime.date(value, 12, 31)
return [first, last]
def year_lookup_bounds_for_datetime_field(self, value): def year_lookup_bounds_for_datetime_field(self, value):
# The default implementation uses datetime objects for the bounds. # cx_Oracle doesn't support tz-aware datetimes
# This must be overridden here, to use a formatted date (string) as
# 'second' instead -- cx_Oracle chops the fraction-of-second part
# off of datetime objects, leaving almost an entire second out of
# the year under the default implementation.
bounds = super(DatabaseOperations, self).year_lookup_bounds_for_datetime_field(value) bounds = super(DatabaseOperations, self).year_lookup_bounds_for_datetime_field(value)
if settings.USE_TZ: if settings.USE_TZ:
bounds = [b.astimezone(timezone.utc).replace(tzinfo=None) for b in bounds] bounds = [b.astimezone(timezone.utc) for b in bounds]
return [b.isoformat(b' ') 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"
@ -695,14 +723,15 @@ class OracleParam(object):
def __init__(self, param, cursor, strings_only=False): def __init__(self, param, cursor, strings_only=False):
# With raw SQL queries, datetimes can reach this function # With raw SQL queries, datetimes can reach this function
# 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): if settings.USE_TZ and (isinstance(param, datetime.datetime) and
not isinstance(param, Oracle_datetime)):
if timezone.is_naive(param): if timezone.is_naive(param):
warnings.warn("Oracle received a naive datetime (%s)" warnings.warn("Oracle received a naive datetime (%s)"
" while time zone support is active." % param, " while time zone support is active." % param,
RuntimeWarning) RuntimeWarning)
default_timezone = timezone.get_default_timezone() default_timezone = timezone.get_default_timezone()
param = timezone.make_aware(param, default_timezone) param = timezone.make_aware(param, default_timezone)
param = param.astimezone(timezone.utc).replace(tzinfo=None) param = Oracle_datetime.from_datetime(param.astimezone(timezone.utc))
# Oracle doesn't recognize True and False correctly in Python 3. # Oracle doesn't recognize True and False correctly in Python 3.
# The conversion done below works both in 2 and 3. # The conversion done below works both in 2 and 3.

View File

@ -76,6 +76,12 @@ class BooleanModel(models.Model):
string = models.CharField(max_length=10, default='abc') string = models.CharField(max_length=10, default='abc')
class DateTimeModel(models.Model):
d = models.DateField()
dt = models.DateTimeField()
t = models.TimeField()
class PrimaryKeyCharModel(models.Model): class PrimaryKeyCharModel(models.Model):
string = models.CharField(max_length=10, primary_key=True) string = models.CharField(max_length=10, primary_key=True)

View File

@ -23,7 +23,7 @@ from django.utils.functional import lazy
from .models import ( from .models import (
Foo, Bar, Whiz, BigD, BigS, BigInt, Post, NullBooleanModel, Foo, Bar, Whiz, BigD, BigS, BigInt, Post, NullBooleanModel,
BooleanModel, PrimaryKeyCharModel, DataModel, Document, RenamedField, BooleanModel, PrimaryKeyCharModel, DataModel, Document, RenamedField,
VerboseNameField, FksToBooleans, FkToChar, FloatModel) DateTimeModel, VerboseNameField, FksToBooleans, FkToChar, FloatModel)
class BasicFieldTests(test.TestCase): class BasicFieldTests(test.TestCase):
@ -197,6 +197,17 @@ class DateTimeFieldTests(unittest.TestCase):
self.assertEqual(f.to_python('01:02:03.999999'), self.assertEqual(f.to_python('01:02:03.999999'),
datetime.time(1, 2, 3, 999999)) datetime.time(1, 2, 3, 999999))
def test_datetimes_save_completely(self):
dat = datetime.date(2014, 3, 12)
datetim = datetime.datetime(2014, 3, 12, 21, 22, 23, 240000)
tim = datetime.time(21, 22, 23, 240000)
DateTimeModel.objects.create(d=dat, dt=datetim, t=tim)
obj = DateTimeModel.objects.first()
self.assertTrue(obj)
self.assertEqual(obj.d, dat)
self.assertEqual(obj.dt, datetim)
self.assertEqual(obj.t, tim)
class BooleanFieldTests(unittest.TestCase): class BooleanFieldTests(unittest.TestCase):
def _test_get_db_prep_lookup(self, f): def _test_get_db_prep_lookup(self, f):