diff --git a/django/db/backends/__init__.py b/django/db/backends/__init__.py index 131a73715d..74046a0d9b 100644 --- a/django/db/backends/__init__.py +++ b/django/db/backends/__init__.py @@ -1,7 +1,7 @@ import datetime import time -from django.db.utils import DatabaseError +from django.db.utils import DatabaseError, ProgrammingError try: from django.utils.six.moves import _thread as thread @@ -664,6 +664,9 @@ class BaseDatabaseFeatures(object): # Does the backend require a connection reset after each material schema change? connection_persists_old_columns = False + # What kind of error does the backend throw when accessing closed cursor? + closed_cursor_error_class = ProgrammingError + def __init__(self, connection): self.connection = connection diff --git a/django/db/backends/postgresql_psycopg2/base.py b/django/db/backends/postgresql_psycopg2/base.py index 71ff2811e2..b22c653bd7 100644 --- a/django/db/backends/postgresql_psycopg2/base.py +++ b/django/db/backends/postgresql_psycopg2/base.py @@ -15,6 +15,7 @@ from django.db.backends.postgresql_psycopg2.creation import DatabaseCreation from django.db.backends.postgresql_psycopg2.version import get_version from django.db.backends.postgresql_psycopg2.introspection import DatabaseIntrospection from django.db.backends.postgresql_psycopg2.schema import DatabaseSchemaEditor +from django.db.utils import InterfaceError from django.utils.encoding import force_str from django.utils.functional import cached_property from django.utils.safestring import SafeText, SafeBytes @@ -60,6 +61,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): can_rollback_ddl = True supports_combined_alters = True nulls_order_largest = True + closed_cursor_error_class = InterfaceError class DatabaseWrapper(BaseDatabaseWrapper): diff --git a/django/db/backends/utils.py b/django/db/backends/utils.py index 0e2aa4503e..ccda77f658 100644 --- a/django/db/backends/utils.py +++ b/django/db/backends/utils.py @@ -36,6 +36,14 @@ class CursorWrapper(object): def __iter__(self): return iter(self.cursor) + def __enter__(self): + return self + + def __exit__(self, type, value, traceback): + # Ticket #17671 - Close instead of passing thru to avoid backend + # specific behavior. + self.close() + class CursorDebugWrapper(CursorWrapper): diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt index 6a29635d18..8dc17761b9 100644 --- a/docs/releases/1.7.txt +++ b/docs/releases/1.7.txt @@ -111,6 +111,25 @@ In addition, the widgets now display a help message when the browser and server time zone are different, to clarify how the value inserted in the field will be interpreted. +Using database cursors as context managers +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Prior to Python 2.7, database cursors could be used as a context manager. The +specific backend's cursor defined the behavior of the context manager. The +behavior of magic method lookups was changed with Python 2.7 and cursors were +no longer usable as context managers. + +Django 1.7 allows a cursor to be used as a context manager that is a shortcut +for the following, instead of backend specific behavior. + +.. code-block:: python + + c = connection.cursor() + try: + c.execute(...) + finally: + c.close() + Minor features ~~~~~~~~~~~~~~ diff --git a/docs/topics/db/sql.txt b/docs/topics/db/sql.txt index 7437d51d28..1369ed84f5 100644 --- a/docs/topics/db/sql.txt +++ b/docs/topics/db/sql.txt @@ -297,3 +297,30 @@ database library will automatically escape your parameters as necessary. Also note that Django expects the ``"%s"`` placeholder, *not* the ``"?"`` placeholder, which is used by the SQLite Python bindings. This is for the sake of consistency and sanity. + +.. versionchanged:: 1.7 + +:pep:`249` does not state whether a cursor should be usable as a context +manager. Prior to Python 2.7, a cursor was usable as a context manager due +an unexpected behavior in magic method lookups (`Python ticket #9220`_). +Django 1.7 explicitly added support to allow using a cursor as context +manager. + +.. _`Python ticket #9220`: http://bugs.python.org/issue9220 + +Using a cursor as a context manager: + +.. code-block:: python + + with connection.cursor() as c: + c.execute(...) + +is equivalent to: + +.. code-block:: python + + c = connection.cursor() + try: + c.execute(...) + finally: + c.close() \ No newline at end of file diff --git a/tests/backends/tests.py b/tests/backends/tests.py index a6badac9a9..73b17b0b81 100644 --- a/tests/backends/tests.py +++ b/tests/backends/tests.py @@ -613,6 +613,31 @@ class BackendTestCase(TestCase): with self.assertRaises(DatabaseError): cursor.execute(query) + def test_cursor_contextmanager(self): + """ + Test that cursors can be used as a context manager + """ + with connection.cursor() as cursor: + from django.db.backends.util import CursorWrapper + self.assertTrue(isinstance(cursor, CursorWrapper)) + # Both InterfaceError and ProgrammingError seem to be used when + # accessing closed cursor (psycopg2 has InterfaceError, rest seem + # to use ProgrammingError). + with self.assertRaises(connection.features.closed_cursor_error_class): + # cursor should be closed, so no queries should be possible. + cursor.execute("select 1") + + @unittest.skipUnless(connection.vendor == 'postgresql', + "Psycopg2 specific cursor.closed attribute needed") + def test_cursor_contextmanager_closing(self): + # There isn't a generic way to test that cursors are closed, but + # psycopg2 offers us a way to check that by closed attribute. + # So, run only on psycopg2 for that reason. + with connection.cursor() as cursor: + from django.db.backends.util import CursorWrapper + self.assertTrue(isinstance(cursor, CursorWrapper)) + self.assertTrue(cursor.closed) + # We don't make these tests conditional because that means we would need to # check and differentiate between: