diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py index 84f391ad79..7626595741 100644 --- a/django/db/backends/base/features.py +++ b/django/db/backends/base/features.py @@ -240,6 +240,9 @@ class BaseDatabaseFeatures: create_test_procedure_without_params_sql = None create_test_procedure_with_int_param_sql = None + # Does the backend support keyword parameters for cursor.callproc()? + supports_callproc_kwargs = False + def __init__(self, connection): self.connection = connection diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py index 47e186af06..5cb012659c 100644 --- a/django/db/backends/oracle/features.py +++ b/django/db/backends/oracle/features.py @@ -54,3 +54,4 @@ class DatabaseFeatures(BaseDatabaseFeatures): V_I := P_I; END; """ + supports_callproc_kwargs = True diff --git a/django/db/backends/utils.py b/django/db/backends/utils.py index 8d3a8ab4b3..9634807a87 100644 --- a/django/db/backends/utils.py +++ b/django/db/backends/utils.py @@ -6,6 +6,7 @@ import re from time import time from django.conf import settings +from django.db.utils import NotSupportedError from django.utils.encoding import force_bytes from django.utils.timezone import utc @@ -45,13 +46,23 @@ class CursorWrapper: # The following methods cannot be implemented in __getattr__, because the # code must run when the method is invoked, not just when it is accessed. - def callproc(self, procname, params=None): + 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). + if kparams is not None and not self.db.features.supports_callproc_kwargs: + raise NotSupportedError( + 'Keyword parameters for callproc are not supported on this ' + 'database backend.' + ) self.db.validate_no_broken_transaction() with self.db.wrap_database_errors: - if params is None: + if params is None and kparams is None: return self.cursor.callproc(procname) - else: + elif kparams is None: return self.cursor.callproc(procname, params) + else: + params = params or () + return self.cursor.callproc(procname, params, kparams) def execute(self, sql, params=None): self.db.validate_no_broken_transaction() diff --git a/docs/releases/2.0.txt b/docs/releases/2.0.txt index c4f5a8147c..1584b87293 100644 --- a/docs/releases/2.0.txt +++ b/docs/releases/2.0.txt @@ -269,6 +269,10 @@ Models * The new ``field_name`` parameter of :meth:`.QuerySet.in_bulk` allows fetching results based on any unique model field. +* :meth:`.CursorWrapper.callproc()` now takes an optional dictionary of keyword + parameters, if the backend supports this feature. Of Django's built-in + backends, only Oracle supports it. + Requests and Responses ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/db/sql.txt b/docs/topics/db/sql.txt index 94e08b8bef..969026e56e 100644 --- a/docs/topics/db/sql.txt +++ b/docs/topics/db/sql.txt @@ -350,10 +350,12 @@ is equivalent to:: Calling stored procedures ~~~~~~~~~~~~~~~~~~~~~~~~~ -.. method:: CursorWrapper.callproc(procname, params=None) +.. method:: CursorWrapper.callproc(procname, params=None, kparams=None) - Calls a database stored procedure with the given name and optional sequence - of input parameters. + Calls a database stored procedure with the given name. A sequence + (``params``) or dictionary (``kparams``) of input parameters may be + provided. Most databases don't support ``kparams``. Of Django's built-in + backends, only Oracle supports it. For example, given this stored procedure in an Oracle database: @@ -372,3 +374,7 @@ Calling stored procedures with connection.cursor() as cursor: cursor.callproc('test_procedure', [1, 'test']) + + .. versionchanged:: 2.0 + + The ``kparams`` argument was added. diff --git a/tests/backends/test_utils.py b/tests/backends/test_utils.py index 77f95fdcc5..be9aeaf698 100644 --- a/tests/backends/test_utils.py +++ b/tests/backends/test_utils.py @@ -3,8 +3,9 @@ from decimal import Decimal, Rounded from django.db import connection from django.db.backends.utils import format_number, truncate_name +from django.db.utils import NotSupportedError from django.test import ( - SimpleTestCase, TransactionTestCase, skipUnlessDBFeature, + SimpleTestCase, TransactionTestCase, skipIfDBFeature, skipUnlessDBFeature, ) @@ -53,13 +54,13 @@ class TestUtils(SimpleTestCase): class CursorWrapperTests(TransactionTestCase): available_apps = [] - def _test_procedure(self, procedure_sql, params, param_types): + def _test_procedure(self, procedure_sql, params, param_types, kparams=None): with connection.cursor() as cursor: cursor.execute(procedure_sql) # Use a new cursor because in MySQL a procedure can't be used in the # same cursor in which it was created. with connection.cursor() as cursor: - cursor.callproc('test_procedure', params) + cursor.callproc('test_procedure', params, kparams) with connection.schema_editor() as editor: editor.remove_procedure('test_procedure', param_types) @@ -70,3 +71,14 @@ class CursorWrapperTests(TransactionTestCase): @skipUnlessDBFeature('create_test_procedure_with_int_param_sql') def test_callproc_with_int_params(self): self._test_procedure(connection.features.create_test_procedure_with_int_param_sql, [1], ['INTEGER']) + + @skipUnlessDBFeature('create_test_procedure_with_int_param_sql', 'supports_callproc_kwargs') + def test_callproc_kparams(self): + self._test_procedure(connection.features.create_test_procedure_with_int_param_sql, [], ['INTEGER'], {'P_I': 1}) + + @skipIfDBFeature('supports_callproc_kwargs') + def test_unsupported_callproc_kparams_raises_error(self): + msg = 'Keyword parameters for callproc are not supported on this database backend.' + with self.assertRaisesMessage(NotSupportedError, msg): + with connection.cursor() as cursor: + cursor.callproc('test_procedure', [], {'P_I': 1})