From 6983201cfb172c2895ed6eea1e0f0eb3804d4c00 Mon Sep 17 00:00:00 2001 From: Shai Berger Date: Wed, 12 Mar 2014 23:43:45 +0200 Subject: [PATCH] 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. --- django/db/backends/oracle/base.py | 61 +++++++++++++++++++++++-------- tests/model_fields/models.py | 6 +++ tests/model_fields/tests.py | 13 ++++++- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/django/db/backends/oracle/base.py b/django/db/backends/oracle/base.py index d6aaf35ab0f..0c799055fbc 100644 --- a/django/db/backends/oracle/base.py +++ b/django/db/backends/oracle/base.py @@ -78,6 +78,19 @@ else: 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): empty_fetchmany_value = () needs_datetime_string_cast = False @@ -421,18 +434,36 @@ WHEN (new.%(col_name)s IS NULL) else: 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): + """ + 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: return None - # Oracle doesn't support tz-aware datetimes + # cx_Oracle doesn't support tz-aware datetimes if timezone.is_aware(value): if settings.USE_TZ: value = value.astimezone(timezone.utc).replace(tzinfo=None) else: 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): if value is None: @@ -445,24 +476,21 @@ WHEN (new.%(col_name)s IS NULL) if timezone.is_aware(value): raise ValueError("Oracle backend does not support timezone-aware times.") - return datetime.datetime(1900, 1, 1, value.hour, value.minute, - value.second, value.microsecond) + return Oracle_datetime(1900, 1, 1, value.hour, value.minute, + value.second, value.microsecond) def year_lookup_bounds_for_date_field(self, value): - first = '%s-01-01' - second = '%s-12-31' - return [first % value, second % 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): - # The default implementation uses datetime objects for the bounds. - # 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. + # 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).replace(tzinfo=None) for b in bounds] - return [b.isoformat(b' ') for b in bounds] + 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): "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): # With raw SQL queries, datetimes can reach this function # 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): warnings.warn("Oracle received a naive datetime (%s)" " while time zone support is active." % param, RuntimeWarning) default_timezone = timezone.get_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. # The conversion done below works both in 2 and 3. diff --git a/tests/model_fields/models.py b/tests/model_fields/models.py index c9e65555255..c4e99c949a0 100644 --- a/tests/model_fields/models.py +++ b/tests/model_fields/models.py @@ -76,6 +76,12 @@ class BooleanModel(models.Model): 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): string = models.CharField(max_length=10, primary_key=True) diff --git a/tests/model_fields/tests.py b/tests/model_fields/tests.py index 8cd58b8a442..c8b46d8c2f2 100644 --- a/tests/model_fields/tests.py +++ b/tests/model_fields/tests.py @@ -23,7 +23,7 @@ from django.utils.functional import lazy from .models import ( Foo, Bar, Whiz, BigD, BigS, BigInt, Post, NullBooleanModel, BooleanModel, PrimaryKeyCharModel, DataModel, Document, RenamedField, - VerboseNameField, FksToBooleans, FkToChar, FloatModel) + DateTimeModel, VerboseNameField, FksToBooleans, FkToChar, FloatModel) class BasicFieldTests(test.TestCase): @@ -197,6 +197,17 @@ class DateTimeFieldTests(unittest.TestCase): self.assertEqual(f.to_python('01:02:03.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): def _test_get_db_prep_lookup(self, f):