From: Mike Bayer Date: Tue, 20 Dec 2022 17:48:08 +0000 (-0500) Subject: establish explicit join transaction modes X-Git-Tag: rel_2_0_0rc1~7 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=0f7ba068a91cbaa7233315d93d0d8624a6a7930f;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git establish explicit join transaction modes The behavior of "joining an external transaction into a Session" has been revised and improved, allowing explicit control over how the :class:`_orm.Session` will accommodate an incoming :class:`_engine.Connection` that already has a transaction and possibly a savepoint already established. The new parameter :paramref:`_orm.Session.join_transaction_mode` includes a series of option values which can accommodate the existing transaction in several ways, most importantly allowing a :class:`_orm.Session` to operate in a fully transactional style using savepoints exclusively, while leaving the externally initiated transaction non-committed and active under all circumstances, allowing test suites to rollback all changes that take place within tests. Additionally, revised the :meth:`_orm.Session.close` method to fully close out savepoints that may still be present, which also allows the "external transaction" recipe to proceed without warnings if the :class:`_orm.Session` did not explicitly end its own SAVEPOINT transactions. Fixes: #9015 Change-Id: I31c22ee0fd9372fa0eddfe057e76544aee627107 --- diff --git a/doc/build/changelog/unreleased_20/9015.rst b/doc/build/changelog/unreleased_20/9015.rst new file mode 100644 index 0000000000..1d53a2ccb7 --- /dev/null +++ b/doc/build/changelog/unreleased_20/9015.rst @@ -0,0 +1,27 @@ +.. change:: + :tags: usecase, orm + :tickets: 9015 + + The behavior of "joining an external transaction into a Session" has been + revised and improved, allowing explicit control over how the + :class:`_orm.Session` will accommodate an incoming + :class:`_engine.Connection` that already has a transaction and possibly a + savepoint already established. The new parameter + :paramref:`_orm.Session.join_transaction_mode` includes a series of option + values which can accommodate the existing transaction in several ways, most + importantly allowing a :class:`_orm.Session` to operate in a fully + transactional style using savepoints exclusively, while leaving the + externally initiated transaction non-committed and active under all + circumstances, allowing test suites to rollback all changes that take place + within tests. + + Additionally, revised the :meth:`_orm.Session.close` method to fully close + out savepoints that may still be present, which also allows the + "external transaction" recipe to proceed without warnings if the + :class:`_orm.Session` did not explicitly end its own SAVEPOINT + transactions. + + .. seealso:: + + :ref:`change_9015` + diff --git a/doc/build/changelog/whatsnew_20.rst b/doc/build/changelog/whatsnew_20.rst index 8c23c24cd9..d94250d712 100644 --- a/doc/build/changelog/whatsnew_20.rst +++ b/doc/build/changelog/whatsnew_20.rst @@ -1632,6 +1632,112 @@ not otherwise part of the major 1.4->2.0 migration path; changes here are not expected to have significant effects on backwards compatibility. +.. _change_9015: + +New transaction join modes for ``Session`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The behavior of "joining an external transaction into a Session" has been +revised and improved, allowing explicit control over how the +:class:`_orm.Session` will accommodate an incoming :class:`_engine.Connection` +that already has a transaction and possibly a savepoint already established. +The new parameter :paramref:`_orm.Session.join_transaction_mode` includes a +series of option values which can accommodate the existing transaction in +several ways, most importantly allowing a :class:`_orm.Session` to operate in a +fully transactional style using savepoints exclusively, while leaving the +externally initiated transaction non-committed and active under all +circumstances, allowing test suites to rollback all changes that take place +within tests. + +The primary improvement this allows is that the recipe documented at +:ref:`session_external_transaction`, which also changed from SQLAlchemy 1.3 +to 1.4, is now simplified to no longer require explicit use of an event +handler or any mention of an explicit savepoint; by using +``join_transaction_mode="create_savepoint"``, the :class:`_orm.Session` will +never affect the state of an incoming transaction, and will instead create a +savepoint (i.e. "nested transaction") as its root transaction. + +The following illustrates part of the example given at +:ref:`session_external_transaction`; see that section for a full example:: + + class SomeTest(TestCase): + def setUp(self): + # connect to the database + self.connection = engine.connect() + + # begin a non-ORM transaction + self.trans = self.connection.begin() + + # bind an individual Session to the connection, selecting + # "create_savepoint" join_transaction_mode + self.session = Session( + bind=self.connection, join_transaction_mode="create_savepoint" + ) + + def tearDown(self): + self.session.close() + + # rollback non-ORM transaction + self.trans.rollback() + + # return connection to the Engine + self.connection.close() + +The default mode selected for :paramref:`_orm.Session.join_transaction_mode` +is ``"conditional_savepoint"``, which uses ``"create_savepoint"`` behavior +if the given :class:`_engine.Connection` is itself already on a savepoint. +If the given :class:`_engine.Connection` is in a transaction but not a +savepoint, the :class:`_orm.Session` will propagate "rollback" calls +but not "commit" calls, but will not begin a new savepoint on its own. This +behavior is chosen by default for its maximum compatibility with +older SQLAlchemy versions as well as that it does not start a new SAVEPOINT +unless the given driver is already making use of SAVEPOINT, as support +for SAVEPOINT varies not only with specific backend and driver but also +configurationally. + +The following illustrates a case that worked in SQLAlchemy 1.3, stopped working +in SQLAlchemy 1.4, and is now restored in SQLAlchemy 2.0:: + + engine = create_engine("...") + + # setup outer connection with a transaction and a SAVEPOINT + conn = engine.connect() + trans = conn.begin() + nested = conn.begin_nested() + + # bind a Session to that connection and operate upon it, including + # a commit + session = Session(conn) + session.connection() + session.commit() + session.close() + + # assert both SAVEPOINT and transaction remain active + assert nested.is_active + nested.rollback() + trans.rollback() + +Where above, a :class:`_orm.Session` is joined to a :class:`_engine.Connection` +that has a savepoint started on it; the state of these two units remains +unchanged after the :class:`_orm.Session` has worked with the transaction. In +SQLAlchemy 1.3, the above case worked because the :class:`_orm.Session` would +begin a "subtransaction" upon the :class:`_engine.Connection`, which would +allow the outer savepoint / transaction to remain unaffected for simple cases +as above. Since subtransactions were deprecated in 1.4 and are now removed in +2.0, this behavior was no longer available. The new default behavior improves +upon the behavior of "subtransactions" by using a real, second SAVEPOINT +instead, so that even calls to :meth:`_orm.Session.rollback` prevent the +:class:`_orm.Session` from "breaking out" into the externally initiated +SAVEPOINT or transaction. + +New code that is joining a transaction-started :class:`_engine.Connection` into +a :class:`_orm.Session` should however select a +:paramref:`_orm.Session.join_transaction_mode` explicitly, so that the desired +behavior is explicitly defined. + +:ticket:`9015` + + .. _Cython: https://cython.org/ .. _change_8567: diff --git a/doc/build/orm/session_transaction.rst b/doc/build/orm/session_transaction.rst index aca558d6ff..153d4ea054 100644 --- a/doc/build/orm/session_transaction.rst +++ b/doc/build/orm/session_transaction.rst @@ -642,20 +642,21 @@ is a test suite that allows ORM code to work freely with a :class:`.Session`, including the ability to call :meth:`.Session.commit`, where afterwards the entire database interaction is rolled back. -.. versionchanged:: 1.4 This section introduces a new version of the - "join into an external transaction" recipe that will work equally well - for both :term:`2.0 style` and :term:`1.x style` engines and sessions. - The recipe here from previous versions such as 1.3 will also continue to - work for 1.x engines and sessions. - +.. versionchanged:: 2.0 The "join into an external transaction" recipe is + newly improved again in 2.0; event handlers to "reset" the nested + transaction are no longer required. The recipe works by establishing a :class:`_engine.Connection` within a -transaction and optionally a SAVEPOINT, then passing it to a :class:`_orm.Session` as the -"bind". The :class:`_orm.Session` detects that the given :class:`_engine.Connection` -is already in a transaction and will not run COMMIT on it if the transaction -is in fact an outermost transaction. Then when the test tears down, the -transaction is rolled back so that any data changes throughout the test -are reverted:: +transaction and optionally a SAVEPOINT, then passing it to a +:class:`_orm.Session` as the "bind"; the +:paramref:`_orm.Session.join_transaction_mode` parameter is passed with the +setting ``"create_savepoint"``, which indicates that new SAVEPOINTs should be +created in order to implement BEGIN/COMMIT/ROLLBACK for the +:class:`_orm.Session`, which will leave the external transaction in the same +state in which it was passed. + +When the test tears down, the external transaction is rolled back so that any +data changes throughout the test are reverted:: from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine @@ -675,23 +676,11 @@ are reverted:: # begin a non-ORM transaction self.trans = self.connection.begin() - # bind an individual Session to the connection - self.session = Session(bind=self.connection) - - ### optional ### - - # if the database supports SAVEPOINT (SQLite needs special - # config for this to work), starting a savepoint - # will allow tests to also use rollback within tests - - self.nested = self.connection.begin_nested() - - @event.listens_for(self.session, "after_transaction_end") - def end_savepoint(session, transaction): - if not self.nested.is_active: - self.nested = self.connection.begin_nested() - - ### ^^^ optional ^^^ ### + # bind an individual Session to the connection, selecting + # "create_savepoint" join_transaction_mode + self.session = Session( + bind=self.connection, join_transaction_mode="create_savepoint" + ) def test_something(self): # use the session in tests. @@ -700,8 +689,6 @@ are reverted:: self.session.commit() def test_something_with_rollbacks(self): - # if the SAVEPOINT steps are taken, then a test can also - # use session.rollback() and continue working with the database self.session.add(Bar()) self.session.flush() diff --git a/lib/sqlalchemy/orm/session.py b/lib/sqlalchemy/orm/session.py index 8b5f7c88ab..f1ff566343 100644 --- a/lib/sqlalchemy/orm/session.py +++ b/lib/sqlalchemy/orm/session.py @@ -155,6 +155,13 @@ _EntityBindKey = Union[Type[_O], "Mapper[_O]"] _SessionBindKey = Union[Type[Any], "Mapper[Any]", "Table", str] _SessionBind = Union["Engine", "Connection"] +JoinTransactionMode = Literal[ + "conditional_savepoint", + "rollback_only", + "control_fully", + "create_savepoint", +] + class _ConnectionCallableProto(Protocol): """a callable that returns a :class:`.Connection` given an instance. @@ -1097,17 +1104,36 @@ class SessionTransaction(_StateChange, TransactionalContext): transaction: Transaction if self.session.twophase and self._parent is None: + # TODO: shouldn't we only be here if not + # conn.in_transaction() ? + # if twophase is set and conn.in_transaction(), validate + # that it is in fact twophase. transaction = conn.begin_twophase() elif self.nested: transaction = conn.begin_nested() elif conn.in_transaction(): - # if given a future connection already in a transaction, don't - # commit that transaction unless it is a savepoint - if conn.in_nested_transaction(): - transaction = conn._get_required_nested_transaction() + join_transaction_mode = self.session.join_transaction_mode + + if join_transaction_mode == "conditional_savepoint": + if conn.in_nested_transaction(): + join_transaction_mode = "create_savepoint" + else: + join_transaction_mode = "rollback_only" + + if join_transaction_mode in ( + "control_fully", + "rollback_only", + ): + if conn.in_nested_transaction(): + transaction = conn._get_required_nested_transaction() + else: + transaction = conn._get_required_transaction() + if join_transaction_mode == "rollback_only": + should_commit = False + elif join_transaction_mode == "create_savepoint": + transaction = conn.begin_nested() else: - transaction = conn._get_required_transaction() - should_commit = False + assert False, join_transaction_mode else: transaction = conn.begin() except: @@ -1274,6 +1300,7 @@ class SessionTransaction(_StateChange, TransactionalContext): _StateChangeStates.ANY, SessionTransactionState.CLOSED ) def close(self, invalidate: bool = False) -> None: + if self.nested: self.session._nested_transaction = ( self._previous_nested_transaction @@ -1281,16 +1308,15 @@ class SessionTransaction(_StateChange, TransactionalContext): self.session._transaction = self._parent - if self._parent is None: - for connection, transaction, should_commit, autoclose in set( - self._connections.values() - ): - if invalidate: - connection.invalidate() - if should_commit and transaction.is_active: - transaction.close() - if autoclose: - connection.close() + for connection, transaction, should_commit, autoclose in set( + self._connections.values() + ): + if invalidate and self._parent is None: + connection.invalidate() + if should_commit and transaction.is_active: + transaction.close() + if autoclose and self._parent is None: + connection.close() self._state = SessionTransactionState.CLOSED sess = self.session @@ -1357,6 +1383,7 @@ class Session(_SessionClassMethods, EventTarget): expire_on_commit: bool enable_baked_queries: bool twophase: bool + join_transaction_mode: JoinTransactionMode _query_cls: Type[Query[Any]] def __init__( @@ -1373,6 +1400,7 @@ class Session(_SessionClassMethods, EventTarget): info: Optional[_InfoType] = None, query_cls: Optional[Type[Query[Any]]] = None, autocommit: Literal[False] = False, + join_transaction_mode: JoinTransactionMode = "conditional_savepoint", ): r"""Construct a new Session. @@ -1502,6 +1530,85 @@ class Session(_SessionClassMethods, EventTarget): :param autocommit: the "autocommit" keyword is present for backwards compatibility but must remain at its default value of ``False``. + :param join_transaction_mode: Describes the transactional behavior to + take when a given bind is a :class:`_engine.Connection` that + has already begun a transaction outside the scope of this + :class:`_orm.Session`; in other words the + :meth:`_engine.Connection.in_transaction()` method returns True. + + The following behaviors only take effect when the :class:`_orm.Session` + **actually makes use of the connection given**; that is, a method + such as :meth:`_orm.Session.execute`, :meth:`_orm.Session.connection`, + etc. are actually invoked: + + * ``"conditional_savepoint"`` - this is the default. if the given + :class:`_engine.Connection` is begun within a transaction but + does not have a SAVEPOINT, then ``"rollback_only"`` is used. + If the :class:`_engine.Connection` is additionally within + a SAVEPOINT, in other words + :meth:`_engine.Connection.in_nested_transaction()` method returns + True, then ``"create_savepoint"`` is used. + + ``"conditional_savepoint"`` behavior attempts to make use of + savepoints in order to keep the state of the existing transaction + unchanged, but only if there is already a savepoint in progress; + otherwise, it is not assumed that the backend in use has adequate + support for SAVEPOINT, as availability of this feature varies. + ``"conditional_savepoint"`` also seeks to establish approximate + backwards compatibility with previous :class:`_orm.Session` + behavior, for applications that are not setting a specific mode. It + is recommended that one of the explicit settings be used. + + * ``"create_savepoint"`` - the :class:`_orm.Session` will use + :meth:`_engine.Connection.begin_nested()` in all cases to create + its own transaction. This transaction by its nature rides + "on top" of any existing transaction that's opened on the given + :class:`_engine.Connection`; if the underlying database and + the driver in use has full, non-broken support for SAVEPOINT, the + external transaction will remain unaffected throughout the + lifespan of the :class:`_orm.Session`. + + The ``"create_savepoint"`` mode is the most useful for integrating + a :class:`_orm.Session` into a test suite where an externally + initiated transaction should remain unaffected; however, it relies + on proper SAVEPOINT support from the underlying driver and + database. + + .. tip:: When using SQLite, the SQLite driver included through + Python 3.11 does not handle SAVEPOINTs correctly in all cases + without workarounds. See the section + :ref:`pysqlite_serializable` for details on current workarounds. + + * ``"control_fully"`` - the :class:`_orm.Session` will take + control of the given transaction as its own; + :meth:`_orm.Session.commit` will call ``.commit()`` on the + transaction, :meth:`_orm.Session.rollback` will call + ``.rollback()`` on the transaction, :meth:`_orm.Session.close` will + call ``.rollback`` on the transaction. + + .. tip:: This mode of use is equivalent to how SQLAlchemy 1.4 would + handle a :class:`_engine.Connection` given with an existing + SAVEPOINT (i.e. :meth:`_engine.Connection.begin_nested`); the + :class:`_orm.Session` would take full control of the existing + SAVEPOINT. + + * ``"rollback_only"`` - the :class:`_orm.Session` will take control + of the given transaction for ``.rollback()`` calls only; + ``.commit()`` calls will not be propagated to the given + transaction. ``.close()`` calls will have no effect on the + given transaction. + + .. tip:: This mode of use is equivalent to how SQLAlchemy 1.4 would + handle a :class:`_engine.Connection` given with an existing + regular database transaction (i.e. + :meth:`_engine.Connection.begin`); the :class:`_orm.Session` + would propagate :meth:`_orm.Session.rollback` calls to the + underlying transaction, but not :meth:`_orm.Session.commit` or + :meth:`_orm.Session.close` calls. + + .. versionadded:: 2.0.0b5 + + """ # noqa # considering allowing the "autocommit" keyword to still be accepted @@ -1533,6 +1640,16 @@ class Session(_SessionClassMethods, EventTarget): self.autoflush = autoflush self.expire_on_commit = expire_on_commit self.enable_baked_queries = enable_baked_queries + if ( + join_transaction_mode + and join_transaction_mode + not in JoinTransactionMode.__args__ # type: ignore + ): + raise sa_exc.ArgumentError( + f"invalid selection for join_transaction_mode: " + f'"{join_transaction_mode}"' + ) + self.join_transaction_mode = join_transaction_mode self.twophase = twophase self._query_cls = query_cls if query_cls else query.Query diff --git a/test/orm/test_session.py b/test/orm/test_session.py index 921c55f74a..5a0431788d 100644 --- a/test/orm/test_session.py +++ b/test/orm/test_session.py @@ -2057,6 +2057,13 @@ class SessionInterface(fixtures.MappedTest): watchdog.symmetric_difference(self._class_methods), ) + def test_join_transaction_mode(self): + with expect_raises_message( + sa.exc.ArgumentError, + 'invalid selection for join_transaction_mode: "bogus"', + ): + Session(join_transaction_mode="bogus") + def test_unmapped_instance(self): class Unmapped: pass diff --git a/test/orm/test_transaction.py b/test/orm/test_transaction.py index f66908fc9c..c7df66e9d5 100644 --- a/test/orm/test_transaction.py +++ b/test/orm/test_transaction.py @@ -1,4 +1,9 @@ +from __future__ import annotations + import contextlib +import random +from typing import Optional +from typing import TYPE_CHECKING from sqlalchemy import Column from sqlalchemy import event @@ -32,10 +37,15 @@ from sqlalchemy.testing import fixtures from sqlalchemy.testing import is_ from sqlalchemy.testing import is_not from sqlalchemy.testing import mock +from sqlalchemy.testing.config import Variation from sqlalchemy.testing.fixtures import fixture_session from sqlalchemy.testing.util import gc_collect from test.orm._fixtures import FixtureTest +if TYPE_CHECKING: + from sqlalchemy import NestedTransaction + from sqlalchemy import Transaction + class SessionTransactionTest(fixtures.RemovesEvents, FixtureTest): run_inserts = None @@ -98,6 +108,142 @@ class SessionTransactionTest(fixtures.RemovesEvents, FixtureTest): trans.commit() assert len(sess.query(User).all()) == 1 + @testing.variation( + "join_transaction_mode", + [ + "none", + "conditional_savepoint", + "create_savepoint", + "control_fully", + "rollback_only", + ], + ) + @testing.variation("operation", ["commit", "close", "rollback", "nothing"]) + @testing.variation("external_state", ["none", "transaction", "savepoint"]) + def test_join_transaction_modes( + self, + connection_no_trans, + join_transaction_mode, + operation, + external_state: testing.Variation, + ): + """test new join_transaction modes added in #9015""" + + connection = connection_no_trans + + t1: Optional[Transaction] + s1: Optional[NestedTransaction] + + if external_state.none: + t1 = s1 = None + elif external_state.transaction: + t1 = connection.begin() + s1 = None + elif external_state.savepoint: + t1 = connection.begin() + s1 = connection.begin_nested() + else: + external_state.fail() + + if join_transaction_mode.none: + sess = Session(connection) + else: + sess = Session( + connection, join_transaction_mode=join_transaction_mode.name + ) + + sess.connection() + + if operation.close: + sess.close() + elif operation.commit: + sess.commit() + elif operation.rollback: + sess.rollback() + elif operation.nothing: + pass + else: + operation.fail() + + if external_state.none: + if operation.nothing: + assert connection.in_transaction() + else: + assert not connection.in_transaction() + + elif external_state.transaction: + + assert t1 is not None + + if ( + join_transaction_mode.none + or join_transaction_mode.conditional_savepoint + or join_transaction_mode.rollback_only + ): + if operation.rollback: + assert t1._deactivated_from_connection + assert not t1.is_active + else: + assert not t1._deactivated_from_connection + assert t1.is_active + elif join_transaction_mode.create_savepoint: + assert not t1._deactivated_from_connection + assert t1.is_active + elif join_transaction_mode.control_fully: + if operation.nothing: + assert not t1._deactivated_from_connection + assert t1.is_active + else: + assert t1._deactivated_from_connection + assert not t1.is_active + else: + join_transaction_mode.fail() + + if t1.is_active: + t1.rollback() + elif external_state.savepoint: + assert s1 is not None + assert t1 is not None + + assert not t1._deactivated_from_connection + assert t1.is_active + + if join_transaction_mode.rollback_only: + if operation.rollback: + assert s1._deactivated_from_connection + assert not s1.is_active + else: + assert not s1._deactivated_from_connection + assert s1.is_active + elif join_transaction_mode.control_fully: + if operation.nothing: + assert not s1._deactivated_from_connection + assert s1.is_active + else: + assert s1._deactivated_from_connection + assert not s1.is_active + else: + if operation.nothing: + # session is still open in the sub-savepoint, + # so we are not activated on connection + assert s1._deactivated_from_connection + + # but we are still an active savepoint + assert s1.is_active + + # close session, then we're good + sess.close() + + assert not s1._deactivated_from_connection + assert s1.is_active + + if s1.is_active: + s1.rollback() + if t1.is_active: + t1.rollback() + else: + external_state.fail() + def test_subtransaction_on_external_commit(self, connection_no_trans): users, User = self.tables.users, self.classes.User @@ -2351,11 +2497,68 @@ class JoinIntoAnExternalTransactionFixture: eq_(result, count) +class CtxManagerJoinIntoAnExternalTransactionFixture( + JoinIntoAnExternalTransactionFixture +): + @testing.requires.compat_savepoints + def test_something_with_context_managers(self): + A = self.A + + a1 = A() + + with self.session.begin(): + self.session.add(a1) + self.session.flush() + + self._assert_count(1) + self.session.rollback() + + self._assert_count(0) + + a1 = A() + with self.session.begin(): + self.session.add(a1) + + self._assert_count(1) + + a2 = A() + + with self.session.begin(): + self.session.add(a2) + self.session.flush() + self._assert_count(2) + + self.session.rollback() + self._assert_count(1) + + @testing.requires.compat_savepoints + def test_super_abusive_nesting(self): + session = self.session + + for i in range(random.randint(5, 30)): + choice = random.randint(1, 3) + if choice == 1: + if session.in_transaction(): + session.begin_nested() + else: + session.begin() + elif choice == 2: + session.rollback() + elif choice == 3: + session.commit() + + session.connection() + + # remaining nested / etc. are cleanly cleared out + session.close() + + class NewStyleJoinIntoAnExternalTransactionTest( - JoinIntoAnExternalTransactionFixture, fixtures.MappedTest + CtxManagerJoinIntoAnExternalTransactionFixture, fixtures.MappedTest ): - """A new recipe for "join into an external transaction" that works - for both legacy and future engines/sessions + """test the 1.4 join to an external transaction fixture. + + In 1.4, this works for both legacy and future engines/sessions """ @@ -2390,42 +2593,75 @@ class NewStyleJoinIntoAnExternalTransactionTest( if self.trans.is_active: self.trans.rollback() - @testing.requires.compat_savepoints - def test_something_with_context_managers(self): - A = self.A - a1 = A() +@testing.combinations( + *Variation.generate_cases( + "join_mode", + [ + "create_savepoint", + "conditional_w_savepoint", + "create_savepoint_w_savepoint", + ], + ), + argnames="join_mode", + id_="s", +) +class ReallyNewJoinIntoAnExternalTransactionTest( + CtxManagerJoinIntoAnExternalTransactionFixture, fixtures.MappedTest +): + """2.0 only recipe for "join into an external transaction" that works + without event handlers - with self.session.begin(): - self.session.add(a1) - self.session.flush() + """ - self._assert_count(1) - self.session.rollback() + def setup_session(self): + self.trans = self.connection.begin() - self._assert_count(0) + if ( + self.join_mode.conditional_w_savepoint + or self.join_mode.create_savepoint_w_savepoint + ): + self.nested = self.connection.begin_nested() - a1 = A() - with self.session.begin(): - self.session.add(a1) + class A: + pass - self._assert_count(1) + clear_mappers() + self.mapper_registry.map_imperatively(A, self.table) + self.A = A - a2 = A() + self.session = Session( + self.connection, + join_transaction_mode="create_savepoint" + if ( + self.join_mode.create_savepoint + or self.join_mode.create_savepoint_w_savepoint + ) + else "conditional_savepoint", + ) - with self.session.begin(): - self.session.add(a2) - self.session.flush() - self._assert_count(2) + def teardown_session(self): + self.session.close() - self.session.rollback() - self._assert_count(1) + if ( + self.join_mode.conditional_w_savepoint + or self.join_mode.create_savepoint_w_savepoint + ): + assert not self.nested._deactivated_from_connection + assert self.nested.is_active + self.nested.rollback() + + assert not self.trans._deactivated_from_connection + assert self.trans.is_active + self.trans.rollback() class LegacyJoinIntoAnExternalTransactionTest( JoinIntoAnExternalTransactionFixture, fixtures.MappedTest, ): + """test the 1.3 join to an external transaction fixture""" + def setup_session(self): # begin a non-ORM transaction self.trans = self.connection.begin()