From 9946f0b0d9356b55e819f861b31615fa5b548f99 Mon Sep 17 00:00:00 2001 From: Jingbei Li Date: Tue, 12 Jul 2022 14:22:40 +0800 Subject: [PATCH] Fixed #33817 -- Added support for python-oracledb and deprecated cx_Oracle. --- .../contrib/gis/db/backends/oracle/adapter.py | 5 ++- .../gis/db/backends/oracle/introspection.py | 5 ++- django/db/backends/oracle/base.py | 15 ++++----- django/db/backends/oracle/features.py | 2 +- django/db/backends/oracle/introspection.py | 33 +++++++++---------- django/db/backends/oracle/operations.py | 8 ++--- django/db/backends/oracle/oracledb_any.py | 21 ++++++++++++ django/db/backends/oracle/utils.py | 2 +- django/db/backends/utils.py | 2 +- django/db/models/expressions.py | 2 +- docs/internals/deprecation.txt | 2 ++ docs/ref/databases.txt | 10 ++++-- docs/releases/5.0.txt | 9 +++++ docs/topics/install.txt | 8 ++--- tests/dbshell/test_oracle.py | 2 +- tests/expressions/tests.py | 2 +- tests/requirements/oracle.txt | 2 +- 17 files changed, 80 insertions(+), 50 deletions(-) create mode 100644 django/db/backends/oracle/oracledb_any.py diff --git a/django/contrib/gis/db/backends/oracle/adapter.py b/django/contrib/gis/db/backends/oracle/adapter.py index 7556b121b8..0bf11648d9 100644 --- a/django/contrib/gis/db/backends/oracle/adapter.py +++ b/django/contrib/gis/db/backends/oracle/adapter.py @@ -1,11 +1,10 @@ -from cx_Oracle import CLOB - from django.contrib.gis.db.backends.base.adapter import WKTAdapter from django.contrib.gis.geos import GeometryCollection, Polygon +from django.db.backends.oracle.oracledb_any import oracledb class OracleSpatialAdapter(WKTAdapter): - input_size = CLOB + input_size = oracledb.CLOB def __init__(self, geom): """ diff --git a/django/contrib/gis/db/backends/oracle/introspection.py b/django/contrib/gis/db/backends/oracle/introspection.py index 096fee54e1..bf299b12ff 100644 --- a/django/contrib/gis/db/backends/oracle/introspection.py +++ b/django/contrib/gis/db/backends/oracle/introspection.py @@ -1,6 +1,5 @@ -import cx_Oracle - from django.db.backends.oracle.introspection import DatabaseIntrospection +from django.db.backends.oracle.oracledb_any import oracledb from django.utils.functional import cached_property @@ -12,7 +11,7 @@ class OracleIntrospection(DatabaseIntrospection): def data_types_reverse(self): return { **super().data_types_reverse, - cx_Oracle.OBJECT: "GeometryField", + oracledb.DB_TYPE_OBJECT: "GeometryField", } def get_geometry_type(self, table_name, description): diff --git a/django/db/backends/oracle/base.py b/django/db/backends/oracle/base.py index 2bd706463a..803c3a5baf 100644 --- a/django/db/backends/oracle/base.py +++ b/django/db/backends/oracle/base.py @@ -1,7 +1,7 @@ """ Oracle database backend for Django. -Requires cx_Oracle: https://oracle.github.io/python-cx_Oracle/ +Requires oracledb: https://oracle.github.io/python-oracledb/ """ import datetime import decimal @@ -13,6 +13,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db import IntegrityError from django.db.backends.base.base import BaseDatabaseWrapper +from django.db.backends.oracle.oracledb_any import oracledb as Database from django.db.backends.utils import debug_transaction from django.utils.asyncio import async_unsafe from django.utils.encoding import force_bytes, force_str @@ -49,12 +50,8 @@ _setup_environment( ) -try: - import cx_Oracle as Database -except ImportError as e: - raise ImproperlyConfigured("Error loading cx_Oracle module: %s" % e) - -# Some of these import cx_Oracle, so import them after checking if it's installed. +# Some of these import oracledb, so import them after checking if it's +# installed. from .client import DatabaseClient # NOQA from .creation import DatabaseCreation # NOQA from .features import DatabaseFeatures # NOQA @@ -70,7 +67,7 @@ def wrap_oracle_errors(): try: yield except Database.DatabaseError as e: - # cx_Oracle raises a cx_Oracle.DatabaseError exception with the + # oracledb raises a oracledb.DatabaseError exception with the # following attributes and values: # code = 2091 # message = 'ORA-02091: transaction rolled back @@ -514,7 +511,7 @@ class FormatStylePlaceholderCursor: return [p.force_bytes for p in params] def _fix_for_params(self, query, params, unify_by_values=False): - # cx_Oracle wants no trailing ';' for SQL statements. For PL/SQL, it + # oracledb wants no trailing ';' for SQL statements. For PL/SQL, it # it does want a trailing ';' but not a trailing '/'. However, these # characters must be included in the original query in case the query # is being passed to SQL*Plus. diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py index cea3b12863..3d4dbb0bf9 100644 --- a/django/db/backends/oracle/features.py +++ b/django/db/backends/oracle/features.py @@ -125,7 +125,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): }, } django_test_expected_failures = { - # A bug in Django/cx_Oracle with respect to string handling (#23843). + # A bug in Django/oracledb with respect to string handling (#23843). "annotations.tests.NonAggregateAnnotationTestCase.test_custom_functions", "annotations.tests.NonAggregateAnnotationTestCase." "test_custom_functions_can_ref_other_functions", diff --git a/django/db/backends/oracle/introspection.py b/django/db/backends/oracle/introspection.py index b76c0b7601..07c2d9bded 100644 --- a/django/db/backends/oracle/introspection.py +++ b/django/db/backends/oracle/introspection.py @@ -1,11 +1,10 @@ from collections import namedtuple -import cx_Oracle - from django.db import models from django.db.backends.base.introspection import BaseDatabaseIntrospection from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo from django.db.backends.base.introspection import TableInfo as BaseTableInfo +from django.db.backends.oracle.oracledb_any import oracledb FieldInfo = namedtuple( "FieldInfo", BaseFieldInfo._fields + ("is_autofield", "is_json", "comment") @@ -18,22 +17,22 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): # Maps type objects to Django Field types. data_types_reverse = { - cx_Oracle.DB_TYPE_DATE: "DateField", - cx_Oracle.DB_TYPE_BINARY_DOUBLE: "FloatField", - cx_Oracle.DB_TYPE_BLOB: "BinaryField", - cx_Oracle.DB_TYPE_CHAR: "CharField", - cx_Oracle.DB_TYPE_CLOB: "TextField", - cx_Oracle.DB_TYPE_INTERVAL_DS: "DurationField", - cx_Oracle.DB_TYPE_NCHAR: "CharField", - cx_Oracle.DB_TYPE_NCLOB: "TextField", - cx_Oracle.DB_TYPE_NVARCHAR: "CharField", - cx_Oracle.DB_TYPE_NUMBER: "DecimalField", - cx_Oracle.DB_TYPE_TIMESTAMP: "DateTimeField", - cx_Oracle.DB_TYPE_VARCHAR: "CharField", + oracledb.DB_TYPE_DATE: "DateField", + oracledb.DB_TYPE_BINARY_DOUBLE: "FloatField", + oracledb.DB_TYPE_BLOB: "BinaryField", + oracledb.DB_TYPE_CHAR: "CharField", + oracledb.DB_TYPE_CLOB: "TextField", + oracledb.DB_TYPE_INTERVAL_DS: "DurationField", + oracledb.DB_TYPE_NCHAR: "CharField", + oracledb.DB_TYPE_NCLOB: "TextField", + oracledb.DB_TYPE_NVARCHAR: "CharField", + oracledb.DB_TYPE_NUMBER: "DecimalField", + oracledb.DB_TYPE_TIMESTAMP: "DateTimeField", + oracledb.DB_TYPE_VARCHAR: "CharField", } def get_field_type(self, data_type, description): - if data_type == cx_Oracle.NUMBER: + if data_type == oracledb.NUMBER: precision, scale = description[4:6] if scale == 0: if precision > 11: @@ -52,7 +51,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): return "IntegerField" elif scale == -127: return "FloatField" - elif data_type == cx_Oracle.NCLOB and description.is_json: + elif data_type == oracledb.NCLOB and description.is_json: return "JSONField" return super().get_field_type(data_type, description) @@ -193,7 +192,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): is_json, comment, ) = field_map[name] - name %= {} # cx_Oracle, for some reason, doubles percent signs. + name %= {} # oracledb, for some reason, doubles percent signs. description.append( FieldInfo( self.identifier_converter(name), diff --git a/django/db/backends/oracle/operations.py b/django/db/backends/oracle/operations.py index 64b1f82071..11043a9b51 100644 --- a/django/db/backends/oracle/operations.py +++ b/django/db/backends/oracle/operations.py @@ -247,7 +247,7 @@ END; value = bool(value) return value - # cx_Oracle always returns datetime.datetime objects for + # oracledb always returns datetime.datetime objects for # DATE and TIMESTAMP columns, but Django wants to see a # python datetime.date, .time, or .datetime. @@ -311,10 +311,10 @@ END; ) def last_executed_query(self, cursor, sql, params): - # https://cx-oracle.readthedocs.io/en/latest/api_manual/cursor.html#Cursor.statement + # https://python-oracledb.readthedocs.io/en/latest/api_manual/cursor.html#Cursor.statement # The DB API definition does not define this attribute. statement = cursor.statement - # Unlike Psycopg's `query` and MySQLdb`'s `_executed`, cx_Oracle's + # Unlike Psycopg's `query` and MySQLdb`'s `_executed`, oracledb's # `statement` doesn't contain the query parameters. Substitute # parameters manually. if params: @@ -592,7 +592,7 @@ END; if hasattr(value, "resolve_expression"): return value - # cx_Oracle doesn't support tz-aware datetimes + # oracledb doesn't support tz-aware datetimes if timezone.is_aware(value): if settings.USE_TZ: value = timezone.make_naive(value, self.connection.timezone) diff --git a/django/db/backends/oracle/oracledb_any.py b/django/db/backends/oracle/oracledb_any.py new file mode 100644 index 0000000000..65d9f1d67d --- /dev/null +++ b/django/db/backends/oracle/oracledb_any.py @@ -0,0 +1,21 @@ +import warnings + +from django.core.exceptions import ImproperlyConfigured +from django.utils.deprecation import RemovedInDjango60Warning + +try: + import oracledb + + is_oracledb = True +except ImportError as e: + try: + import cx_Oracle as oracledb # NOQA + + warnings.warn( + "cx_Oracle is deprecated. Use oracledb instead.", + RemovedInDjango60Warning, + stacklevel=2, + ) + is_oracledb = False + except ImportError: + raise ImproperlyConfigured(f"Error loading oracledb module: {e}") diff --git a/django/db/backends/oracle/utils.py b/django/db/backends/oracle/utils.py index 8941a85967..318a265645 100644 --- a/django/db/backends/oracle/utils.py +++ b/django/db/backends/oracle/utils.py @@ -42,7 +42,7 @@ class InsertVar: class Oracle_datetime(datetime.datetime): """ A datetime object, with an additional class attribute - to tell cx_Oracle to save the microseconds too. + to tell oracledb to save the microseconds too. """ input_size = Database.TIMESTAMP diff --git a/django/db/backends/utils.py b/django/db/backends/utils.py index f1acf98a8b..4c1e62bcbf 100644 --- a/django/db/backends/utils.py +++ b/django/db/backends/utils.py @@ -47,7 +47,7 @@ class CursorWrapper: def callproc(self, procname, params=None, kparams=None): # Keyword parameters for callproc aren't supported in PEP 249, but the - # database driver may support them (e.g. cx_Oracle). + # database driver may support them (e.g. oracledb). if kparams is not None and not self.db.features.supports_callproc_kwargs: raise NotSupportedError( "Keyword parameters for callproc are not supported on this " diff --git a/django/db/models/expressions.py b/django/db/models/expressions.py index a3d08d4734..ac3ddfbc9d 100644 --- a/django/db/models/expressions.py +++ b/django/db/models/expressions.py @@ -1036,7 +1036,7 @@ class Value(SQLiteNumericMixin, Expression): if hasattr(output_field, "get_placeholder"): return output_field.get_placeholder(val, compiler, connection), [val] if val is None: - # cx_Oracle does not always convert None to the appropriate + # oracledb does not always convert None to the appropriate # NULL type (like in case expressions using numbers), so we # use a literal SQL NULL return "NULL", [] diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index fa2a1c0a0c..c9a8b05637 100644 --- a/docs/internals/deprecation.txt +++ b/docs/internals/deprecation.txt @@ -38,6 +38,8 @@ details on these changes. * Support for calling ``format_html()`` without passing args or kwargs will be removed. +* Support for ``cx_Oracle`` will be removed. + .. _deprecation-removed-in-5.1: 5.1 diff --git a/docs/ref/databases.txt b/docs/ref/databases.txt index d32ff7fd1b..f859f51377 100644 --- a/docs/ref/databases.txt +++ b/docs/ref/databases.txt @@ -919,11 +919,15 @@ To enable the JSON1 extension you can follow the instruction on Oracle notes ============ -Django supports `Oracle Database Server`_ versions 19c and higher. Version 8.3 -or higher of the `cx_Oracle`_ Python driver is required. +Django supports `Oracle Database Server`_ versions 19c and higher. Version +1.3.2 or higher of the `oracledb`_ Python driver is required. + +.. deprecated:: 5.0 + + Support for ``cx_Oracle`` is deprecated. .. _`Oracle Database Server`: https://www.oracle.com/ -.. _`cx_Oracle`: https://oracle.github.io/python-cx_Oracle/ +.. _`oracledb`: https://oracle.github.io/python-oracledb/ In order for the ``python manage.py migrate`` command to work, your Oracle database user must have privileges to run the following commands: diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index 3e747f01de..de76d9e4c4 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -386,6 +386,10 @@ Models ``CHAR(32)`` column. See the migration guide above for more details on :ref:`migrating-uuidfield`. +* Django now supports `oracledb`_ version 1.3.2 or higher. Support for + ``cx_Oracle`` is deprecated as of this release and will be removed in Django + 6.0. + Pagination ~~~~~~~~~~ @@ -606,6 +610,11 @@ Miscellaneous * Support for calling ``format_html()`` without passing args or kwargs will be removed. +* Support for ``cx_Oracle`` is deprecated in favor of `oracledb`_ 1.3.2+ Python + driver. + +.. _`oracledb`: https://oracle.github.io/python-oracledb/ + Features removed in 5.0 ======================= diff --git a/docs/topics/install.txt b/docs/topics/install.txt index bbc74bd4e3..e93a6e0d54 100644 --- a/docs/topics/install.txt +++ b/docs/topics/install.txt @@ -90,9 +90,9 @@ database bindings are installed. * If you're using SQLite you might want to read the :ref:`SQLite backend notes `. -* If you're using Oracle, you'll need a copy of cx_Oracle_, but please - read the :ref:`notes for the Oracle backend ` for details - regarding supported versions of both Oracle and ``cx_Oracle``. +* If you're using Oracle, you'll need to install oracledb_, but please read the + :ref:`notes for the Oracle backend ` for details regarding + supported versions of both Oracle and ``oracledb``. * If you're using an unofficial 3rd party backend, please consult the documentation provided for any additional requirements. @@ -115,7 +115,7 @@ database queries, Django will need permission to create a test database. .. _psycopg: https://www.psycopg.org/psycopg3/ .. _psycopg2: https://www.psycopg.org/ .. _SQLite: https://www.sqlite.org/ -.. _cx_Oracle: https://oracle.github.io/python-cx_Oracle/ +.. _oracledb: https://oracle.github.io/python-oracledb/ .. _Oracle: https://www.oracle.com/ .. _install-django-code: diff --git a/tests/dbshell/test_oracle.py b/tests/dbshell/test_oracle.py index b9a4592ad5..805e344377 100644 --- a/tests/dbshell/test_oracle.py +++ b/tests/dbshell/test_oracle.py @@ -5,7 +5,7 @@ from django.db.backends.oracle.client import DatabaseClient from django.test import SimpleTestCase -@skipUnless(connection.vendor == "oracle", "Requires cx_Oracle to be installed") +@skipUnless(connection.vendor == "oracle", "Requires oracledb to be installed") class OracleDbshellTests(SimpleTestCase): def settings_to_cmd_args_env(self, settings_dict, parameters=None, rlwrap=False): if parameters is None: diff --git a/tests/expressions/tests.py b/tests/expressions/tests.py index 8a3b5d45f5..9ba354f9f4 100644 --- a/tests/expressions/tests.py +++ b/tests/expressions/tests.py @@ -536,7 +536,7 @@ class BasicExpressionsTests(TestCase): results = list(qs) # Could use Coalesce(subq, Value('')) instead except for the bug in - # cx_Oracle mentioned in #23843. + # oracledb mentioned in #23843. bob = results[0] if ( bob["largest_company"] == "" diff --git a/tests/requirements/oracle.txt b/tests/requirements/oracle.txt index 9182a318c4..5f9058f822 100644 --- a/tests/requirements/oracle.txt +++ b/tests/requirements/oracle.txt @@ -1 +1 @@ -cx_oracle >= 8.3 +oracledb >= 1.3.2