]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Update connection docs for migrating off of nesting
authorMike Bayer <mike_mp@zzzcomputing.com>
Sat, 29 Aug 2020 18:32:20 +0000 (14:32 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 29 Aug 2020 22:55:02 +0000 (18:55 -0400)
Change-Id: I3a81140f00a4a9945121bfb8ec4c0e3953b4085f

doc/build/core/connections.rst
doc/build/glossary.rst
doc/build/orm/session_transaction.rst
lib/sqlalchemy/orm/session.py
test/orm/test_transaction.py

index b9605bb498e461b19245642685008cdbd3f59141..989b721a04fc4f9f0da3f2c05a79354da16f983e 100644 (file)
@@ -131,9 +131,11 @@ as a best practice.
 Nesting of Transaction Blocks
 -----------------------------
 
-.. note:: The "transaction nesting" feature of SQLAlchemy is a legacy feature
-   that will be deprecated in an upcoming release.  New usage paradigms will
-   eliminate the need for it to be present.
+.. deprecated:: 1.4 The "transaction nesting" feature of SQLAlchemy is a legacy feature
+   that is deprecated in the 1.4 release and will be removed in SQLAlchemy 2.0.
+   The pattern has proven to be a little too awkward and complicated, unless an
+   application makes more of a first-class framework around the behavior.  See
+   the following subsection :ref:`connections_avoid_nesting`.
 
 The :class:`.Transaction` object also handles "nested" behavior by keeping
 track of the outermost begin/commit pair. In this example, two functions both
@@ -168,8 +170,144 @@ which "guarantee" that a transaction will be used if one was not already
 available, but will automatically participate in an enclosing transaction if
 one exists.
 
-.. index::
-   single: thread safety; transactions
+.. _connections_avoid_nesting:
+
+Arbitrary Transaction Nesting as an Antipattern
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+With many years of experience, the above "nesting" pattern has not proven to
+be very popular, and where it has been observed in large projects such
+as Openstack, it tends to be complicated.
+
+The most ideal way to organize an application would have a single, or at
+least very few, points at which the "beginning" and "commit" of all
+database transactions is demarcated.   This is also the general
+idea discussed in terms of the ORM at :ref:`session_faq_whentocreate`.  To
+adapt the example from the previous section to this practice looks like::
+
+
+    # method_a calls method_b
+    def method_a(connection):
+        method_b(connection)
+
+    # method_b uses the connection and assumes the transaction
+    # is external
+    def method_b(connection):
+        connection.execute(text("insert into mytable values ('bat', 'lala')"))
+        connection.execute(mytable.insert(), {"col1": "bat", "col2": "lala"})
+
+    # open a Connection inside of a transaction and call method_a
+    with engine.begin() as conn:
+        method_a(conn)
+
+That is, ``method_a()`` and ``method_b()`` do not deal with the details
+of the transaction at all; the transactional scope of the connection is
+defined **externally** to the functions that have a SQL dialogue with the
+connection.
+
+It may be observed that the above code has fewer lines, and less indentation
+which tends to correlate with lower :term:`cyclomatic complexity`.   The
+above code is organized such that ``method_a()`` and ``method_b()`` are always
+invoked from a point at which a transaction is begun.  The previous
+version of the example features a ``method_a()`` and a ``method_b()`` that are
+trying to be agnostic of this fact, which suggests they are prepared for
+at least twice as many potential codepaths through them.
+
+.. _connections_subtransactions:
+
+Migrating from the "nesting" pattern
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+As SQLAlchemy's intrinsic-nested pattern is considered legacy, an application
+that for either legacy or novel reasons still seeks to have a context that
+automatically frames transactions should seek to maintain this functionality
+through the use of a custom Python context manager.  A similar example is also
+provided in terms of the ORM in the "seealso" section below.
+
+To provide backwards compatibility for applications that make use of this
+pattern, the following context manager or a similar implementation based on
+a decorator may be used::
+
+    import contextlib
+
+    @contextlib.contextmanager
+    def transaction(connection):
+        if not connection.in_transaction():
+            with connection.begin():
+                yield connection
+        else:
+            yield connection
+
+The above contextmanager would be used as::
+
+    # method_a starts a transaction and calls method_b
+    def method_a(connection):
+        with transaction(connection):  # open a transaction
+            method_b(connection)
+
+    # method_b either starts a transaction, or uses the one already
+    # present
+    def method_b(connection):
+        with transaction(connection):  # open a transaction
+            connection.execute(text("insert into mytable values ('bat', 'lala')"))
+            connection.execute(mytable.insert(), {"col1": "bat", "col2": "lala"})
+
+    # open a Connection and call method_a
+    with engine.connect() as conn:
+        method_a(conn)
+
+A similar approach may be taken such that connectivity is established
+on demand as well; the below approach features a single-use context manager
+that accesses an enclosing state in order to test if connectivity is already
+present::
+
+    import contextlib
+
+    def connectivity(engine):
+        connection = None
+
+        @contextlib.contextmanager
+        def connect():
+            nonlocal connection
+
+            if connection is None:
+                connection = engine.connect()
+                with connection:
+                    with connection.begin():
+                        yield connection
+            else:
+                yield connection
+
+        return connect
+
+Using the above would look like::
+
+    # method_a passes along connectivity context, at the same time
+    # it chooses to establish a connection by calling "with"
+    def method_a(connectivity):
+        with connectivity():
+            method_b(connectivity)
+
+    # method_b also wants to use a connection from the context, so it
+    # also calls "with:", but also it actually uses the connection.
+    def method_b(connectivity):
+        with connectivity() as connection:
+            connection.execute(text("insert into mytable values ('bat', 'lala')"))
+            connection.execute(mytable.insert(), {"col1": "bat", "col2": "lala"})
+
+    # create a new connection/transaction context object and call
+    # method_a
+    method_a(connectivity(engine))
+
+The above context manager acts not only as a "transaction" context but also
+as a context that manages having an open connection against a particular
+:class:`_engine.Engine`.   When using the ORM :class:`_orm.Session`, this
+connectivty management is provided by the :class:`_orm.Session` itself.
+An overview of ORM connectivity patterns is at :ref:`unitofwork_transaction`.
+
+.. seealso::
+
+  :ref:`session_subtransactions` - ORM version
 
 .. _autocommit:
 
index d85725b14743ea845afb8d31384dff268ed83fba..bf410d2d837ead9693152a50e7ce9a9a412746d8 100644 (file)
@@ -81,6 +81,14 @@ Glossary
 
             `Relational Algebra (via Wikipedia) <https://en.wikipedia.org/wiki/Relational_algebra>`_
 
+    cyclomatic complexity
+        A measure of code complexity based on the number of possible paths
+        through a program's source code.
+
+        .. seealso::
+
+            `Cyclomatic Complexity <https://en.wikipedia.org/wiki/Cyclomatic_complexity>`_
+
     selectable
         A term used in SQLAlchemy to describe a SQL construct that represents
         a collection of rows.   It's largely similar to the concept of a
index 4cffdc4a320c2e64df8242f95058751c24903c88..4ab42399a6b9023ef3a2bc1f86bfbccc00caf74f 100644 (file)
@@ -325,13 +325,16 @@ Explicit Begin
 
 .. versionchanged:: 1.4
     SQLAlchemy 1.4 deprecates "autocommit mode", which is historically enabled
-    by using the :paramref:`_orm.Session.autocommit` flag.    This flag allows
-    the :class:`_orm.Session` to invoke SQL statements within individual,
-    ad-hoc transactions and has been recommended against for many years.
-    Instead, the :meth:`_orm.Session.begin` method may now be called when
+    by using the :paramref:`_orm.Session.autocommit` flag.    Going forward,
+    a new approach to allowing usage of the :meth:`_orm.Session.begin` method
+    is new "autobegin" behavior so that the method may now be called when
     a :class:`_orm.Session` is first constructed, or after the previous
     transaction has ended and before it begins a new one.
 
+    For background on migrating away from the "subtransaction" pattern for
+    frameworks that rely upon nesting of begin()/commit() pairs, see the
+    next section :ref:`session_subtransactions`.
+
 The :class:`_orm.Session` features "autobegin" behavior, meaning that as soon
 as operations begin to take place, it ensures a :class:`_orm.SessionTransaction`
 is present to track ongoing operations.   This transaction is completed
@@ -405,22 +408,11 @@ a decorator may be used::
 
     @contextlib.contextmanager
     def transaction(session):
-
-        if session.in_transaction():
-            outermost = False
+        if not session.in_transaction():
+            with session.begin():
+                yield
         else:
-            outermost = True
-            session.begin()
-
-        try:
             yield
-        except:
-            if session.in_transaction():
-                session.rollback()
-            raise
-        else:
-            if outermost and session.in_transaction():
-                session.commit()
 
 
 The above context manager may be used in the same way the
@@ -439,6 +431,8 @@ The above context manager may be used in the same way the
         with transaction(session):
             session.add(SomeObject('bat', 'lala'))
 
+    Session = sessionmaker(engine)
+
     # create a Session and call method_a
     with Session() as session:
         method_a(session)
@@ -453,11 +447,16 @@ or methods to be concerned with the details of transaction demarcation::
     def method_b(session):
         session.add(SomeObject('bat', 'lala'))
 
+    Session = sessionmaker(engine)
+
     # create a Session and call method_a
     with Session() as session:
         with session.begin():
             method_a(session)
 
+.. seealso::
+
+    :ref:`connections_subtransactions` - similar pattern based on Core only
 
 .. _session_twophase:
 
index dc3af80f24cfee4808f218c8da06120f8a6d4794..e844836bafbcb4f1edd240d745fad9e8b63842a5 100644 (file)
@@ -1085,11 +1085,11 @@ class Session(_SessionClassMethods):
 
     @property
     @util.deprecated_20(
-        "The :attr:`_orm.Session.transaction` accessor is deprecated and "
-        "will be removed in SQLAlchemy version 2.0.  "
-        "For context manager use, use :meth:`_orm.Session.begin`.  To access "
+        ":attr:`_orm.Session.transaction`",
+        alternative="For context manager use, use "
+        ":meth:`_orm.Session.begin`.  To access "
         "the current root transaction, use "
-        ":meth:`_orm.Session.get_transaction`"
+        ":meth:`_orm.Session.get_transaction`.",
     )
     def transaction(self):
         """The current active or inactive :class:`.SessionTransaction`.
index 6095d8642eb27f20a1e37e2392c682c54e840b41..fd015a8b63b40266047e62c09918ccdf5109f7da 100644 (file)
@@ -1089,34 +1089,80 @@ class _LocalFixture(FixtureTest):
         mapper(Address, addresses)
 
 
-class SubtransactionRecipeTest(FixtureTest):
-    run_inserts = None
-    __backend__ = True
+def subtransaction_recipe_one(self):
+    @contextlib.contextmanager
+    def transaction(session):
 
-    future = False
-
-    @testing.fixture
-    def subtransaction_recipe(self):
-        @contextlib.contextmanager
-        def transaction(session):
+        if session.in_transaction():
+            outermost = False
+        else:
+            outermost = True
+            session.begin()
 
+        try:
+            yield
+        except:
             if session.in_transaction():
-                outermost = False
-            else:
-                outermost = True
-                session.begin()
+                session.rollback()
+            raise
+        else:
+            if outermost and session.in_transaction():
+                session.commit()
 
+    return transaction
+
+
+def subtransaction_recipe_two(self):
+    # shorter recipe
+    @contextlib.contextmanager
+    def transaction(session):
+        if not session.in_transaction():
+            with session.begin():
+                yield
+        else:
+            yield
+
+    return transaction
+
+
+def subtransaction_recipe_three(self):
+    @contextlib.contextmanager
+    def transaction(session):
+        if not session.in_transaction():
+            session.begin()
             try:
                 yield
             except:
                 if session.in_transaction():
                     session.rollback()
-                raise
             else:
-                if outermost and session.in_transaction():
-                    session.commit()
+                session.commit()
+        else:
+            try:
+                yield
+            except:
+                if session.in_transaction():
+                    session.rollback()
+                raise
+
+    return transaction
+
 
-        return transaction
+@testing.combinations(
+    (subtransaction_recipe_one, True),
+    (subtransaction_recipe_two, False),
+    (subtransaction_recipe_three, True),
+    argnames="target_recipe,recipe_rollsback_early",
+    id_="ns",
+)
+@testing.combinations((True,), (False,), argnames="future", id_="s")
+class SubtransactionRecipeTest(FixtureTest):
+    run_inserts = None
+    __backend__ = True
+
+    @testing.fixture
+    def subtransaction_recipe(self):
+        return self.target_recipe()
 
     @testing.requires.savepoints
     def test_recipe_heavy_nesting(self, subtransaction_recipe):
@@ -1253,10 +1299,15 @@ class SubtransactionRecipeTest(FixtureTest):
         except:
             pass
 
-        # that was a real rollback, so no transaction
-        is_(sess.get_transaction(), None)
+        if self.recipe_rollsback_early:
+            # that was a real rollback, so no transaction
+            assert not sess.in_transaction()
+            is_(sess.get_transaction(), None)
+        else:
+            assert sess.in_transaction()
 
         sess.close()
+        assert not sess.in_transaction()
 
     def test_recipe_multi_nesting(self, subtransaction_recipe):
         sess = Session(testing.db, future=self.future)
@@ -1271,7 +1322,12 @@ class SubtransactionRecipeTest(FixtureTest):
             except:
                 pass
 
-            assert not sess.in_transaction()
+            if self.recipe_rollsback_early:
+                assert not sess.in_transaction()
+            else:
+                assert sess.in_transaction()
+
+        assert not sess.in_transaction()
 
     def test_recipe_deactive_status_check(self, subtransaction_recipe):
         sess = Session(testing.db, future=self.future)
@@ -1284,10 +1340,6 @@ class SubtransactionRecipeTest(FixtureTest):
         sess.commit()  # no error
 
 
-class FutureSubtransactionRecipeTest(SubtransactionRecipeTest):
-    future = True
-
-
 class FixtureDataTest(_LocalFixture):
     run_inserts = "each"
     __backend__ = True