]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
implement autobegin=False option
authorMike Bayer <mike_mp@zzzcomputing.com>
Tue, 11 Oct 2022 21:01:43 +0000 (17:01 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Tue, 11 Oct 2022 23:05:41 +0000 (19:05 -0400)
Added new parameter :paramref:`_orm.Session.autobegin`, which when set to
``False`` will prevent the :class:`_orm.Session` from beginning a
transaction implicitly. The :meth:`_orm.Session.begin` method must be
called explicitly first in order to proceed with operations, otherwise an
error is raised whenever any operation would otherwise have begun
automatically. This option can be used to create a "safe"
:class:`_orm.Session` that won't implicitly start new transactions.

As part of this change, also added a new status variable
:class:`_orm.SessionTransaction.origin` which may be useful for event
handling code to be aware of the origin of a particular
:class:`_orm.SessionTransaction`.

Fixes: #6928
Change-Id: I246f895c4a475bff352216e5bc74b6a25e6a4ae7

doc/build/changelog/unreleased_20/6928.rst [new file with mode: 0644]
doc/build/orm/session_api.rst
doc/build/orm/session_basics.rst
doc/build/orm/session_transaction.rst
lib/sqlalchemy/orm/__init__.py
lib/sqlalchemy/orm/scoping.py
lib/sqlalchemy/orm/session.py
test/orm/test_session.py
test/orm/test_transaction.py

diff --git a/doc/build/changelog/unreleased_20/6928.rst b/doc/build/changelog/unreleased_20/6928.rst
new file mode 100644 (file)
index 0000000..b2d7e95
--- /dev/null
@@ -0,0 +1,18 @@
+.. change::
+    :tags: feature, orm
+    :tickets: 6928
+
+    Added new parameter :paramref:`_orm.Session.autobegin`, which when set to
+    ``False`` will prevent the :class:`_orm.Session` from beginning a
+    transaction implicitly. The :meth:`_orm.Session.begin` method must be
+    called explicitly first in order to proceed with operations, otherwise an
+    error is raised whenever any operation would otherwise have begun
+    automatically. This option can be used to create a "safe"
+    :class:`_orm.Session` that won't implicitly start new transactions.
+
+    As part of this change, also added a new status variable
+    :class:`_orm.SessionTransaction.origin` which may be useful for event
+    handling code to be aware of the origin of a particular
+    :class:`_orm.SessionTransaction`.
+
+
index 24dd7c900cc210f4b9f1a05540a73d2eb93b3e77..e26aca8d74432fd50f853f571a1a0b77ecdd3f7c 100644 (file)
@@ -13,7 +13,6 @@ Session and sessionmaker()
 .. autoclass:: ORMExecuteState
     :members:
 
-
 .. autoclass:: Session
    :members:
    :inherited-members:
@@ -21,6 +20,9 @@ Session and sessionmaker()
 .. autoclass:: SessionTransaction
    :members:
 
+.. autoclass:: SessionTransactionOrigin
+   :members:
+
 Session Utilities
 -----------------
 
index 5046ef0b54abee9426609aa4bf7eba3204b00976..12413336a06f24dfea480c8a3c6968a198849881 100644 (file)
@@ -562,12 +562,6 @@ document at :doc:`queryguide/dml` for documentation.
 Auto Begin
 ~~~~~~~~~~
 
-.. versionadded:: 1.4
-
-  This section describes a behavior that is new in SQLAlchemy 1.4 and does
-  not apply to previous versions.  Further details on the "autobegin"
-  change are at :ref:`change_5074`.
-
 The :class:`_orm.Session` object features a behavior known as **autobegin**.
 This indicates that the :class:`_orm.Session` will internally consider itself
 to be in a "transactional" state as soon as any work is performed with the
@@ -595,9 +589,33 @@ method is called, the :class:`_orm.Session` is placed into the "transactional"
 state unconditionally.   :meth:`_orm.Session.begin` may be used as a context
 manager as described at :ref:`session_begin_commit_rollback_block`.
 
-.. versionchanged:: 1.4.12 - autobegin now correctly occurs if object
-   attributes are modified; previously this was not occurring.
+.. _session_autobegin_disable:
+
+Disabling Autobegin to Prevent Implicit Transactions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The "autobegin" behavior may be disabled using the
+:paramref:`_orm.Session.autobegin` parameter set to ``False``. By using this
+parameter, a :class:`_orm.Session` will require that the
+:meth:`_orm.Session.begin` method is called explicitly. Upon construction, as
+well as after any of the :meth:`_orm.Session.rollback`,
+:meth:`_orm.Session.commit`, or :meth:`_orm.Session.close` methods are called,
+the :class:`_orm.Session` won't implicitly begin any new transactions and will
+raise an error if an attempt to use the :class:`_orm.Session` is made without
+first calling :meth:`_orm.Session.begin`::
+
+    with Session(engine, autobegin=False) as session:
+        session.begin()  # <-- required, else InvalidRequestError raised on next call
+
+        session.add(User(name="u1"))
+        session.commit()
+
+        session.begin()  # <-- required, else InvalidRequestError raised on next call
+
+        u1 = session.scalar(select(User).filter_by(name="u1"))
 
+.. versionadded:: 2.0 Added :paramref:`_orm.Session.autobegin`, allowing
+   "autobegin" behavior to be disabled
 
 .. _session_committing:
 
index 7ae6936328d19c99b3904191246b6416c13198fe..be45906709063d688ae21bf8afffd13aaeaa8b5c 100644 (file)
@@ -251,12 +251,15 @@ Commit as you go
 Both :class:`_orm.Session` and :class:`_engine.Connection` feature
 :meth:`_engine.Connection.commit` and :meth:`_engine.Connection.rollback`
 methods.   Using SQLAlchemy 2.0-style operation, these methods affect the
-**outermost** transaction in all cases.
+**outermost** transaction in all cases.   For the :class:`_orm.Session`, it is
+assumed that :paramref:`_orm.Session.autobegin` is left at its default
+value of ``True``.
 
 
-Engine::
 
-    engine = create_engine("postgresql+psycopg2://user:pass@host/dbname", future=True)
+:class:`_engine.Engine`::
+
+    engine = create_engine("postgresql+psycopg2://user:pass@host/dbname")
 
     with engine.connect() as conn:
         conn.execute(
@@ -269,9 +272,9 @@ Engine::
         )
         conn.commit()
 
-Session::
+:class:`_orm.Session`::
 
-    Session = sessionmaker(engine, future=True)
+    Session = sessionmaker(engine)
 
     with Session() as session:
         session.add_all(
index c6b61f3b47ea6f6bdd8010fe841973e1c743bb0a..5e21615155b29f66237df7be5ef7adf7d6996d8f 100644 (file)
@@ -127,6 +127,7 @@ from .session import ORMExecuteState as ORMExecuteState
 from .session import Session as Session
 from .session import sessionmaker as sessionmaker
 from .session import SessionTransaction as SessionTransaction
+from .session import SessionTransactionOrigin as SessionTransactionOrigin
 from .state import AttributeState as AttributeState
 from .state import InstanceState as InstanceState
 from .strategy_options import contains_eager as contains_eager
index e3ba86c5a29faff625557b947404ab2a098438e5..1971caedc02857516a4e28c0b4897b8f0a22c8e5 100644 (file)
@@ -382,9 +382,7 @@ class scoped_session(Generic[_S]):
 
         return self._proxied.add_all(instances)
 
-    def begin(
-        self, nested: bool = False, _subtrans: bool = False
-    ) -> SessionTransaction:
+    def begin(self, nested: bool = False) -> SessionTransaction:
         r"""Begin a transaction, or nested transaction,
         on this :class:`.Session`, if one is not already begun.
 
@@ -425,7 +423,7 @@ class scoped_session(Generic[_S]):
 
         """  # noqa: E501
 
-        return self._proxied.begin(nested=nested, _subtrans=_subtrans)
+        return self._proxied.begin(nested=nested)
 
     def begin_nested(self) -> SessionTransaction:
         r"""Begin a "nested" transaction on this Session, e.g. SAVEPOINT.
@@ -1772,6 +1770,11 @@ class scoped_session(Generic[_S]):
 
         .. versionadded:: 1.4.24
 
+        .. seealso::
+
+            :ref:`orm_queryguide_select_orm_entities` - contrasts the behavior
+            of :meth:`_orm.Session.execute` to :meth:`_orm.Session.scalars`
+
 
         """  # noqa: E501
 
index c759f65413f07f3dae70605552261658c4f64d71..22e47585d7b1688fae25dae5cc29053618eb6a16 100644 (file)
@@ -10,6 +10,7 @@
 from __future__ import annotations
 
 import contextlib
+from enum import Enum
 import itertools
 import sys
 import typing
@@ -734,6 +735,30 @@ class ORMExecuteState(util.MemoizedSlots):
         ]
 
 
+class SessionTransactionOrigin(Enum):
+    """indicates the origin of a :class:`.SessionTransaction`.
+
+    This enumeration is present on the
+    :attr:`.SessionTransaction.origin` attribute of any
+    :class:`.SessionTransaction` object.
+
+    .. versionadded:: 2.0
+
+    """
+
+    AUTOBEGIN = 0
+    """transaction were started by autobegin"""
+
+    BEGIN = 1
+    """transaction were started by calling :meth:`_orm.Session.begin`"""
+
+    BEGIN_NESTED = 2
+    """tranaction were started by :meth:`_orm.Session.begin_nested`"""
+
+    SUBTRANSACTION = 3
+    """transaction is an internal "subtransaction" """
+
+
 class SessionTransaction(_StateChange, TransactionalContext):
     """A :class:`.Session`-level transaction.
 
@@ -790,29 +815,60 @@ class SessionTransaction(_StateChange, TransactionalContext):
         InstanceState[Any], Tuple[Any, Any]
     ]
 
+    origin: SessionTransactionOrigin
+    """Origin of this :class:`_orm.SessionTransaction`.
+
+    Refers to a :class:`.SessionTransactionOrigin` instance which is an
+    enumeration indicating the source event that led to constructing
+    this :class:`_orm.SessionTransaction`.
+
+    .. versionadded:: 2.0
+
+    """
+
+    nested: bool = False
+    """Indicates if this is a nested, or SAVEPOINT, transaction.
+
+    When :attr:`.SessionTransaction.nested` is True, it is expected
+    that :attr:`.SessionTransaction.parent` will be present as well,
+    linking to the enclosing :class:`.SessionTransaction`.
+
+    .. seealso::
+
+        :attr:`.SessionTransaction.origin`
+
+    """
+
     def __init__(
         self,
         session: Session,
+        origin: SessionTransactionOrigin,
         parent: Optional[SessionTransaction] = None,
-        nested: bool = False,
-        autobegin: bool = False,
     ):
         TransactionalContext._trans_ctx_check(session)
 
         self.session = session
         self._connections = {}
         self._parent = parent
-        self.nested = nested
+        self.nested = nested = origin is SessionTransactionOrigin.BEGIN_NESTED
+        self.origin = origin
+
         if nested:
+            if not parent:
+                raise sa_exc.InvalidRequestError(
+                    "Can't start a SAVEPOINT transaction when no existing "
+                    "transaction is in progress"
+                )
+
             self._previous_nested_transaction = session._nested_transaction
+        elif origin is SessionTransactionOrigin.SUBTRANSACTION:
+            assert parent is not None
+        else:
+            assert parent is None
+
         self._state = SessionTransactionState.ACTIVE
-        if not parent and nested:
-            raise sa_exc.InvalidRequestError(
-                "Can't start a SAVEPOINT transaction when no existing "
-                "transaction is in progress"
-            )
 
-        self._take_snapshot(autobegin=autobegin)
+        self._take_snapshot()
 
         # make sure transaction is assigned before we call the
         # dispatch
@@ -866,14 +922,6 @@ class SessionTransaction(_StateChange, TransactionalContext):
         """
         return self._parent
 
-    nested: bool = False
-    """Indicates if this is a nested, or SAVEPOINT, transaction.
-
-    When :attr:`.SessionTransaction.nested` is True, it is expected
-    that :attr:`.SessionTransaction.parent` will be True as well.
-
-    """
-
     @property
     def is_active(self) -> bool:
         return (
@@ -901,7 +949,13 @@ class SessionTransaction(_StateChange, TransactionalContext):
         (SessionTransactionState.ACTIVE,), _StateChangeStates.NO_CHANGE
     )
     def _begin(self, nested: bool = False) -> SessionTransaction:
-        return SessionTransaction(self.session, self, nested=nested)
+        return SessionTransaction(
+            self.session,
+            SessionTransactionOrigin.BEGIN_NESTED
+            if nested
+            else SessionTransactionOrigin.SUBTRANSACTION,
+            self,
+        )
 
     def _iterate_self_and_parents(
         self, upto: Optional[SessionTransaction] = None
@@ -923,7 +977,7 @@ class SessionTransaction(_StateChange, TransactionalContext):
 
         return result
 
-    def _take_snapshot(self, autobegin: bool = False) -> None:
+    def _take_snapshot(self) -> None:
         if not self._is_transaction_boundary:
             parent = self._parent
             assert parent is not None
@@ -933,7 +987,11 @@ class SessionTransaction(_StateChange, TransactionalContext):
             self._key_switches = parent._key_switches
             return
 
-        if not autobegin and not self.session._flushing:
+        is_begin = self.origin in (
+            SessionTransactionOrigin.BEGIN,
+            SessionTransactionOrigin.AUTOBEGIN,
+        )
+        if not is_begin and not self.session._flushing:
             self.session.flush()
 
         self._new = weakref.WeakKeyDictionary()
@@ -1307,6 +1365,7 @@ class Session(_SessionClassMethods, EventTarget):
         autoflush: bool = True,
         future: Literal[True] = True,
         expire_on_commit: bool = True,
+        autobegin: bool = True,
         twophase: bool = False,
         binds: Optional[Dict[_SessionBindKey, _SessionBind]] = None,
         enable_baked_queries: bool = True,
@@ -1330,6 +1389,20 @@ class Session(_SessionClassMethods, EventTarget):
 
                :ref:`session_flushing` - additional background on autoflush
 
+        :param autobegin: Automatically start transactions (i.e. equivalent to
+           invoking :meth:`_orm.Session.begin`) when database access is
+           requested by an operation.   Defaults to ``True``.    Set to
+           ``False`` to prevent a :class:`_orm.Session` from implicitly
+           beginning transactions after construction, as well as after any of
+           the :meth:`_orm.Session.rollback`, :meth:`_orm.Session.commit`,
+           or :meth:`_orm.Session.close` methods are called.
+
+           .. versionadded:: 2.0
+
+           .. seealso::
+
+                :ref:`session_autobegin_disable`
+
         :param bind: An optional :class:`_engine.Engine` or
            :class:`_engine.Connection` to
            which this ``Session`` should be bound. When specified, all SQL
@@ -1455,6 +1528,7 @@ class Session(_SessionClassMethods, EventTarget):
         self._transaction = None
         self._nested_transaction = None
         self.hash_key = _new_sessionid()
+        self.autobegin = autobegin
         self.autoflush = autoflush
         self.expire_on_commit = expire_on_commit
         self.enable_baked_queries = enable_baked_queries
@@ -1542,18 +1616,26 @@ class Session(_SessionClassMethods, EventTarget):
         """
         return {}
 
-    def _autobegin_t(self) -> SessionTransaction:
+    def _autobegin_t(self, begin: bool = False) -> SessionTransaction:
         if self._transaction is None:
 
-            trans = SessionTransaction(self, autobegin=True)
+            if not begin and not self.autobegin:
+                raise sa_exc.InvalidRequestError(
+                    "Autobegin is disabled on this Session; please call "
+                    "session.begin() to start a new transaction"
+                )
+            trans = SessionTransaction(
+                self,
+                SessionTransactionOrigin.BEGIN
+                if begin
+                else SessionTransactionOrigin.AUTOBEGIN,
+            )
             assert self._transaction is trans
             return trans
 
         return self._transaction
 
-    def begin(
-        self, nested: bool = False, _subtrans: bool = False
-    ) -> SessionTransaction:
+    def begin(self, nested: bool = False) -> SessionTransaction:
         """Begin a transaction, or nested transaction,
         on this :class:`.Session`, if one is not already begun.
 
@@ -1590,31 +1672,21 @@ class Session(_SessionClassMethods, EventTarget):
 
         trans = self._transaction
         if trans is None:
-            trans = self._autobegin_t()
+            trans = self._autobegin_t(begin=True)
 
-            if not nested and not _subtrans:
+            if not nested:
                 return trans
 
-        if trans is not None:
-            if _subtrans or nested:
-                trans = trans._begin(nested=nested)
-                assert self._transaction is trans
-                if nested:
-                    self._nested_transaction = trans
-            else:
-                raise sa_exc.InvalidRequestError(
-                    "A transaction is already begun on this Session."
-                )
-        else:
-            # outermost transaction.  must be a not nested and not
-            # a subtransaction
+        assert trans is not None
 
-            assert not nested and not _subtrans
-            trans = SessionTransaction(self)
+        if nested:
+            trans = trans._begin(nested=nested)
             assert self._transaction is trans
-
-            if TYPE_CHECKING:
-                assert self._transaction is not None
+            self._nested_transaction = trans
+        else:
+            raise sa_exc.InvalidRequestError(
+                "A transaction is already begun on this Session."
+            )
 
         return trans  # needed for __enter__/__exit__ hook
 
@@ -3957,7 +4029,7 @@ class Session(_SessionClassMethods, EventTarget):
         if not flush_context.has_work:
             return
 
-        flush_context.transaction = transaction = self.begin(_subtrans=True)
+        flush_context.transaction = transaction = self._autobegin_t()._begin()
         try:
             self._warn_on_events = True
             try:
@@ -4246,7 +4318,7 @@ class Session(_SessionClassMethods, EventTarget):
         mapper = _class_to_mapper(mapper)
         self._flushing = True
 
-        transaction = self.begin(_subtrans=True)
+        transaction = self._autobegin_t()._begin()
         try:
             if isupdate:
                 bulk_persistence._bulk_update(
index 49f769f813e12270671e53bee7ab52477f741e09..520ff48dc126b5f25d2c847219b0affe3b8bc255 100644 (file)
@@ -220,6 +220,73 @@ class TransScopingTest(_fixtures.FixtureTest):
         assert not s.in_transaction()
         eq_(s.connection().scalar(select(User.name)), "u1")
 
+    @testing.combinations(
+        "select1", "lazyload", "unitofwork", argnames="trigger"
+    )
+    @testing.combinations("commit", "close", "rollback", None, argnames="op")
+    def test_no_autobegin(self, op, trigger):
+        User, users = self.classes.User, self.tables.users
+        Address, addresses = self.classes.Address, self.tables.addresses
+
+        self.mapper_registry.map_imperatively(
+            User, users, properties={"addresses": relationship(Address)}
+        )
+        self.mapper_registry.map_imperatively(Address, addresses)
+
+        with Session(testing.db) as sess:
+
+            sess.add(User(name="u1"))
+            sess.commit()
+
+        s = Session(testing.db, autobegin=False)
+
+        orm_trigger = trigger == "lazyload" or trigger == "unitofwork"
+        with expect_raises_message(
+            sa.exc.InvalidRequestError,
+            r"Autobegin is disabled on this Session; please call "
+            r"session.begin\(\) to start a new transaction",
+        ):
+            if op or orm_trigger:
+                s.begin()
+
+                is_true(s.in_transaction())
+
+                if orm_trigger:
+                    u1 = s.scalar(select(User).filter_by(name="u1"))
+                else:
+                    eq_(s.scalar(select(1)), 1)
+
+                if op:
+                    getattr(s, op)()
+                elif orm_trigger:
+                    s.rollback()
+
+                is_false(s.in_transaction())
+
+            if trigger == "select1":
+                s.execute(select(1))
+            elif trigger == "lazyload":
+                if op == "close":
+                    s.add(u1)
+                else:
+                    u1.addresses
+            elif trigger == "unitofwork":
+                s.add(u1)
+
+        s.begin()
+        if trigger == "select1":
+            s.execute(select(1))
+        elif trigger == "lazyload":
+            if op == "close":
+                s.add(u1)
+            u1.addresses
+
+        is_true(s.in_transaction())
+
+        if op:
+            getattr(s, op)()
+            is_false(s.in_transaction())
+
     def test_autobegin_begin_method(self):
         s = Session(testing.db)
 
@@ -783,7 +850,7 @@ class SessionStateTest(_fixtures.FixtureTest):
         assert not sess.in_transaction()
         sess.begin()
         assert sess.is_active
-        sess.begin(_subtrans=True)
+        sess._autobegin_t()._begin()
         sess.rollback()
         assert sess.is_active
 
index ec4fb2d68296a2f390d3d75297d88336eb189035..c40cbfd579f0c9453742d444f8646a28ffc2677a 100644 (file)
@@ -2108,7 +2108,7 @@ class TransactionFlagsTest(fixtures.TestBase):
             eq_(s1.in_transaction(), True)
             is_(s1.get_transaction(), trans)
 
-            subtrans = s1.begin(_subtrans=True)
+            subtrans = s1._autobegin_t()._begin()
             is_(s1.get_transaction(), trans)
             eq_(s1.in_transaction(), True)