--- /dev/null
+.. change::
+ :tags: usecase, orm
+ :tickets: 7787
+
+ Added an option to permanently close sessions.
+ Set to ``False`` the new parameter :paramref:`_orm.Session.close_resets_only`
+ will prevent a :class:`_orm.Session` from performing any other
+ operation after :meth:`_orm.Session.close` has been called.
+
+ Added new method :meth:`_orm.Session.reset` that will reset a :class:`_orm.Session`
+ to its initial state. This is an alias of :meth:`_orm.Session.close`,
+ unless :paramref:`_orm.Session.close_resets_only` is set to ``False``.
\ No newline at end of file
to which it is bound. When connections are returned to the connection pool,
transactional state is rolled back as well.
-When the :class:`_orm.Session` is closed, it is essentially in the
+By default, when the :class:`_orm.Session` is closed, it is essentially in the
original state as when it was first constructed, and **may be used again**.
In this sense, the :meth:`_orm.Session.close` method is more like a "reset"
back to the clean state and not as much like a "database close" method.
+In this mode of operation the method :meth:`_orm.Session.reset` is an alias to
+:meth:`_orm.Session.close` and behaves in the same way.
+
+The default behavior of :meth:`_orm.Session.close` can be changed by setting the
+parameter :paramref:`_orm.Session.close_resets_only` to ``False``, indicating that
+the :class:`_orm.Session` cannot be reused after the method
+:meth:`_orm.Session.close` has been called. In this mode of operation the
+:meth:`_orm.Session.reset` method will allow multiple "reset" of the session,
+behaving like :meth:`_orm.Session.close` when
+:paramref:`_orm.Session.close_resets_only` is set to ``True``.
+
+.. versionadded:: 2.0.22
It's recommended that the scope of a :class:`_orm.Session` be limited by
a call to :meth:`_orm.Session.close` at the end, especially if the
"begin",
"begin_nested",
"close",
+ "reset",
"commit",
"connection",
"delete",
Proxied for the :class:`_asyncio.AsyncSession` class on
behalf of the :class:`_asyncio.scoping.async_scoped_session` class.
- This expunges all ORM objects associated with this
- :class:`_asyncio.AsyncSession`, ends any transaction in progress and
- :term:`releases` any :class:`_asyncio.AsyncConnection` objects which
- this :class:`_asyncio.AsyncSession` itself has checked out from
- associated :class:`_asyncio.AsyncEngine` objects. The operation then
- leaves the :class:`_asyncio.AsyncSession` in a state which it may be
- used again.
+ .. seealso::
+
+ :meth:`_orm.Session.close` - main documentation for
+ "close"
+
+ :ref:`session_closing` - detail on the semantics of
+ :meth:`_asyncio.AsyncSession.close` and
+ :meth:`_asyncio.AsyncSession.reset`.
+
+
+ """ # noqa: E501
+
+ return await self._proxied.close()
+
+ async def reset(self) -> None:
+ r"""Close out the transactional resources and ORM objects used by this
+ :class:`_orm.Session`, resetting the session to its initial state.
- .. tip::
+ .. container:: class_bases
- The :meth:`_asyncio.AsyncSession.close` method **does not prevent
- the Session from being used again**. The
- :class:`_asyncio.AsyncSession` itself does not actually have a
- distinct "closed" state; it merely means the
- :class:`_asyncio.AsyncSession` will release all database
- connections and ORM objects.
+ Proxied for the :class:`_asyncio.AsyncSession` class on
+ behalf of the :class:`_asyncio.scoping.async_scoped_session` class.
+ .. versionadded:: 2.0.22
.. seealso::
+ :meth:`_orm.Session.reset` - main documentation for
+ "reset"
+
:ref:`session_closing` - detail on the semantics of
- :meth:`_asyncio.AsyncSession.close`
+ :meth:`_asyncio.AsyncSession.close` and
+ :meth:`_asyncio.AsyncSession.reset`.
""" # noqa: E501
- return await self._proxied.close()
+ return await self._proxied.reset()
async def commit(self) -> None:
r"""Commit the current transaction in progress.
Proxied for the :class:`_asyncio.AsyncSession` class on
behalf of the :class:`_asyncio.scoping.async_scoped_session` class.
+ .. seealso::
+
+ :meth:`_orm.Session.commit` - main documentation for
+ "commit"
+
""" # noqa: E501
return await self._proxied.commit()
Proxied for the :class:`_asyncio.AsyncSession` class on
behalf of the :class:`_asyncio.scoping.async_scoped_session` class.
+ .. seealso::
+
+ :meth:`_orm.Session.rollback` - main documentation for
+ "rollback"
+
""" # noqa: E501
return await self._proxied.rollback()
return AsyncSessionTransaction(self, nested=True)
async def rollback(self) -> None:
- """Rollback the current transaction in progress."""
+ """Rollback the current transaction in progress.
+
+ .. seealso::
+
+ :meth:`_orm.Session.rollback` - main documentation for
+ "rollback"
+ """
await greenlet_spawn(self.sync_session.rollback)
async def commit(self) -> None:
- """Commit the current transaction in progress."""
+ """Commit the current transaction in progress.
+
+ .. seealso::
+
+ :meth:`_orm.Session.commit` - main documentation for
+ "commit"
+ """
await greenlet_spawn(self.sync_session.commit)
async def close(self) -> None:
"""Close out the transactional resources and ORM objects used by this
:class:`_asyncio.AsyncSession`.
- This expunges all ORM objects associated with this
- :class:`_asyncio.AsyncSession`, ends any transaction in progress and
- :term:`releases` any :class:`_asyncio.AsyncConnection` objects which
- this :class:`_asyncio.AsyncSession` itself has checked out from
- associated :class:`_asyncio.AsyncEngine` objects. The operation then
- leaves the :class:`_asyncio.AsyncSession` in a state which it may be
- used again.
+ .. seealso::
+
+ :meth:`_orm.Session.close` - main documentation for
+ "close"
- .. tip::
+ :ref:`session_closing` - detail on the semantics of
+ :meth:`_asyncio.AsyncSession.close` and
+ :meth:`_asyncio.AsyncSession.reset`.
- The :meth:`_asyncio.AsyncSession.close` method **does not prevent
- the Session from being used again**. The
- :class:`_asyncio.AsyncSession` itself does not actually have a
- distinct "closed" state; it merely means the
- :class:`_asyncio.AsyncSession` will release all database
- connections and ORM objects.
+ """
+ await greenlet_spawn(self.sync_session.close)
+ async def reset(self) -> None:
+ """Close out the transactional resources and ORM objects used by this
+ :class:`_orm.Session`, resetting the session to its initial state.
+
+ .. versionadded:: 2.0.22
.. seealso::
+ :meth:`_orm.Session.reset` - main documentation for
+ "reset"
+
:ref:`session_closing` - detail on the semantics of
- :meth:`_asyncio.AsyncSession.close`
+ :meth:`_asyncio.AsyncSession.close` and
+ :meth:`_asyncio.AsyncSession.reset`.
"""
- await greenlet_spawn(self.sync_session.close)
+ await greenlet_spawn(self.sync_session.reset)
async def aclose(self) -> None:
"""A synonym for :meth:`_asyncio.AsyncSession.close`.
"begin",
"begin_nested",
"close",
+ "reset",
"commit",
"connection",
"delete",
.. tip::
- The :meth:`_orm.Session.close` method **does not prevent the
- Session from being used again**. The :class:`_orm.Session` itself
- does not actually have a distinct "closed" state; it merely means
+ In the default running mode the :meth:`_orm.Session.close`
+ method **does not prevent the Session from being used again**.
+ The :class:`_orm.Session` itself does not actually have a
+ distinct "closed" state; it merely means
the :class:`_orm.Session` will release all database connections
and ORM objects.
+ Setting the parameter :paramref:`_orm.Session.close_resets_only`
+ to ``False`` will instead make the ``close`` final, meaning that
+ any further action on the session will be forbidden.
+
.. versionchanged:: 1.4 The :meth:`.Session.close` method does not
immediately create a new :class:`.SessionTransaction` object;
instead, the new :class:`.SessionTransaction` is created only if
.. seealso::
:ref:`session_closing` - detail on the semantics of
- :meth:`_orm.Session.close`
+ :meth:`_orm.Session.close` and :meth:`_orm.Session.reset`.
+
+ :meth:`_orm.Session.reset` - a similar method that behaves like
+ ``close()`` with the parameter
+ :paramref:`_orm.Session.close_resets_only` set to ``True``.
""" # noqa: E501
return self._proxied.close()
+ def reset(self) -> None:
+ r"""Close out the transactional resources and ORM objects used by this
+ :class:`_orm.Session`, resetting the session to its initial state.
+
+ .. container:: class_bases
+
+ Proxied for the :class:`_orm.Session` class on
+ behalf of the :class:`_orm.scoping.scoped_session` class.
+
+ This method provides for same "reset-only" behavior that the
+ :meth:_orm.Session.close method has provided historically, where the
+ state of the :class:`_orm.Session` is reset as though the object were
+ brand new, and ready to be used again.
+ The method may then be useful for :class:`_orm.Session` objects
+ which set :paramref:`_orm.Session.close_resets_only` to ``False``,
+ so that "reset only" behavior is still available from this method.
+
+ .. versionadded:: 2.0.22
+
+ .. seealso::
+
+ :ref:`session_closing` - detail on the semantics of
+ :meth:`_orm.Session.close` and :meth:`_orm.Session.reset`.
+
+ :meth:`_orm.Session.close` - a similar method will additionally
+ prevent re-use of the Session when the parameter
+ :paramref:`_orm.Session.close_resets_only` is set to ``False``.
+
+ """ # noqa: E501
+
+ return self._proxied.reset()
+
def commit(self) -> None:
r"""Flush pending changes and commit the current transaction.
from ..sql import Select
from ..sql import TableClause
from ..sql import visitors
+from ..sql.base import _NoArg
from ..sql.base import CompileState
from ..sql.schema import Table
from ..sql.selectable import ForUpdateArg
self.nested = nested = origin is SessionTransactionOrigin.BEGIN_NESTED
self.origin = origin
+ if session._close_state is _SessionCloseState.CLOSED:
+ raise sa_exc.InvalidRequestError(
+ "This Session has been permanently closed and is unable "
+ "to handle any more transaction requests."
+ )
+
if nested:
if not parent:
raise sa_exc.InvalidRequestError(
return self._state not in (COMMITTED, CLOSED)
+class _SessionCloseState(Enum):
+ ACTIVE = 1
+ CLOSED = 2
+ CLOSE_IS_RESET = 3
+
+
class Session(_SessionClassMethods, EventTarget):
"""Manages persistence operations for ORM-mapped objects.
twophase: bool
join_transaction_mode: JoinTransactionMode
_query_cls: Type[Query[Any]]
+ _close_state: _SessionCloseState
def __init__(
self,
query_cls: Optional[Type[Query[Any]]] = None,
autocommit: Literal[False] = False,
join_transaction_mode: JoinTransactionMode = "conditional_savepoint",
+ close_resets_only: Union[bool, _NoArg] = _NoArg.NO_ARG,
):
r"""Construct a new :class:`_orm.Session`.
.. versionadded:: 2.0.0rc1
+ :param close_resets_only: Defaults to ``True``. Determines if
+ the session should reset itself after calling ``.close()``
+ or should pass in a no longer usable state, disabling re-use.
+
+ .. versionadded:: 2.0.22 added flag ``close_resets_only``.
+ A future SQLAlchemy version may change the default value of
+ this flag to ``False``.
+
+ .. seealso::
+
+ :ref:`session_closing` - Detail on the semantics of
+ :meth:`_orm.Session.close` and :meth:`_orm.Session.reset`.
""" # noqa
self.autoflush = autoflush
self.expire_on_commit = expire_on_commit
self.enable_baked_queries = enable_baked_queries
+
+ # the idea is that at some point NO_ARG will warn that in the future
+ # the default will switch to close_resets_only=False.
+ if close_resets_only or close_resets_only is _NoArg.NO_ARG:
+ self._close_state = _SessionCloseState.CLOSE_IS_RESET
+ else:
+ self._close_state = _SessionCloseState.ACTIVE
if (
join_transaction_mode
and join_transaction_mode
.. tip::
- The :meth:`_orm.Session.close` method **does not prevent the
- Session from being used again**. The :class:`_orm.Session` itself
- does not actually have a distinct "closed" state; it merely means
+ In the default running mode the :meth:`_orm.Session.close`
+ method **does not prevent the Session from being used again**.
+ The :class:`_orm.Session` itself does not actually have a
+ distinct "closed" state; it merely means
the :class:`_orm.Session` will release all database connections
and ORM objects.
+ Setting the parameter :paramref:`_orm.Session.close_resets_only`
+ to ``False`` will instead make the ``close`` final, meaning that
+ any further action on the session will be forbidden.
+
.. versionchanged:: 1.4 The :meth:`.Session.close` method does not
immediately create a new :class:`.SessionTransaction` object;
instead, the new :class:`.SessionTransaction` is created only if
.. seealso::
:ref:`session_closing` - detail on the semantics of
- :meth:`_orm.Session.close`
+ :meth:`_orm.Session.close` and :meth:`_orm.Session.reset`.
+
+ :meth:`_orm.Session.reset` - a similar method that behaves like
+ ``close()`` with the parameter
+ :paramref:`_orm.Session.close_resets_only` set to ``True``.
"""
self._close_impl(invalidate=False)
+ def reset(self) -> None:
+ """Close out the transactional resources and ORM objects used by this
+ :class:`_orm.Session`, resetting the session to its initial state.
+
+ This method provides for same "reset-only" behavior that the
+ :meth:_orm.Session.close method has provided historically, where the
+ state of the :class:`_orm.Session` is reset as though the object were
+ brand new, and ready to be used again.
+ The method may then be useful for :class:`_orm.Session` objects
+ which set :paramref:`_orm.Session.close_resets_only` to ``False``,
+ so that "reset only" behavior is still available from this method.
+
+ .. versionadded:: 2.0.22
+
+ .. seealso::
+
+ :ref:`session_closing` - detail on the semantics of
+ :meth:`_orm.Session.close` and :meth:`_orm.Session.reset`.
+
+ :meth:`_orm.Session.close` - a similar method will additionally
+ prevent re-use of the Session when the parameter
+ :paramref:`_orm.Session.close_resets_only` is set to ``False``.
+ """
+ self._close_impl(invalidate=False, is_reset=True)
+
def invalidate(self) -> None:
"""Close this Session, using connection invalidation.
"""
self._close_impl(invalidate=True)
- def _close_impl(self, invalidate: bool) -> None:
+ def _close_impl(self, invalidate: bool, is_reset: bool = False) -> None:
+ if not is_reset and self._close_state is _SessionCloseState.ACTIVE:
+ self._close_state = _SessionCloseState.CLOSED
self.expunge_all()
if self._transaction is not None:
for transaction in self._transaction._iterate_self_and_parents():
is_(async_object_session(u3), None)
+ await s2.reset()
+ is_(async_object_session(u2), None)
+ s2.add(u2)
+
+ is_(async_object_session(u2), s2)
await s2.close()
is_(async_object_session(u2), None)
import sqlalchemy as sa
from sqlalchemy import delete
from sqlalchemy import event
+from sqlalchemy import exc
from sqlalchemy import ForeignKey
from sqlalchemy import insert
from sqlalchemy import inspect
def test_no_string_execute(self, connection):
with Session(bind=connection) as sess:
with expect_raises_message(
- sa.exc.ArgumentError,
+ exc.ArgumentError,
r"Textual SQL expression 'select \* from users where.*' "
"should be explicitly declared",
):
sess.execute("select * from users where id=:id", {"id": 7})
with expect_raises_message(
- sa.exc.ArgumentError,
+ exc.ArgumentError,
r"Textual SQL expression 'select id from users .*' "
"should be explicitly declared",
):
orm_trigger = trigger == "lazyload" or trigger == "unitofwork"
with expect_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
r"Autobegin is disabled on this Session; please call "
r"session.begin\(\) to start a new transaction",
):
s.begin() # OK
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"A transaction is already begun on this Session.",
s.begin,
)
sess.flush()
assert u1 not in sess
- assert_raises(sa.exc.InvalidRequestError, sess.add, u1)
+ assert_raises(exc.InvalidRequestError, sess.add, u1)
make_transient(u1)
sess.add(u1)
sess.flush()
u1 = User(id=1, name="test")
sess.add(u1)
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"Given object must be transient",
make_transient_to_detached,
u1,
sess.commit()
sess.expunge(u1)
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"Given object must be transient",
make_transient_to_detached,
u1,
Session(autocommit=False)
with expect_raises_message(
- sa.exc.ArgumentError, "autocommit=True is no longer supported"
+ exc.ArgumentError, "autocommit=True is no longer supported"
):
Session(autocommit=True)
# will raise for null email address
assert_raises_message(
- sa.exc.DBAPIError,
+ exc.DBAPIError,
".*raised as a result of Query-invoked autoflush; consider using "
"a session.no_autoflush block.*",
s.query(User).first,
sess.delete(u1)
sess.flush()
assert u1 not in sess
- assert_raises(sa.exc.InvalidRequestError, sess.add, u1)
+ assert_raises(exc.InvalidRequestError, sess.add, u1)
assert sess.in_transaction()
sess.rollback()
assert u1 in sess
sess.delete(u1)
sess.commit()
assert u1 not in sess
- assert_raises(sa.exc.InvalidRequestError, sess.add, u1)
+ assert_raises(exc.InvalidRequestError, sess.add, u1)
make_transient(u1)
sess.add(u1)
user = User(name="u1")
assert_raises_message(
- sa.exc.InvalidRequestError, "is not persisted", s.delete, user
+ exc.InvalidRequestError, "is not persisted", s.delete, user
)
s.add(user)
s2 = fixture_session()
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"is already attached to session",
s2.delete,
user,
u2 = s2.get(User, user.id)
s2.expunge(u2)
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"another instance .* is already present",
s.delete,
u2,
s1.add(u1)
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"Object '<User.*?>' is already attached to session",
s2.add,
u1,
s.identity_map.add(sa.orm.attributes.instance_state(u1))
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"Can't attach instance <User.*?>; another instance "
"with key .*? is already "
"present in this session.",
u1 = User(name="u1")
sess1.add(u1)
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"already attached to session",
sess2.add,
u1,
assert u2 in sess
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"Can't attach instance <User.*?>; another instance "
"with key .*? is already "
"present in this session.",
def test_extra_dirty_state_post_flush_warning(self):
s, a1, a2 = self._test_extra_dirty_state()
assert_warns_message(
- sa.exc.SAWarning,
+ exc.SAWarning,
"Attribute history events accumulated on 1 previously "
"clean instances",
s.commit,
assert u1 not in sess
assert object_session(u1) is None
+ @testing.combinations(True, False, "default", argnames="close_resets_only")
+ @testing.variation("method", ["close", "reset"])
+ def test_session_close_resets_only(self, close_resets_only, method):
+ users, User = self.tables.users, self.classes.User
+ self.mapper_registry.map_imperatively(User, users)
+
+ if close_resets_only is True:
+ kw = {"close_resets_only": True}
+ elif close_resets_only is False:
+ kw = {"close_resets_only": False}
+ else:
+ eq_(close_resets_only, "default")
+ kw = {}
+
+ s = fixture_session(**kw)
+ u1 = User()
+ s.add(u1)
+ assertions.in_(u1, s)
+
+ if method.reset:
+ s.reset()
+ elif method.close:
+ s.close()
+ else:
+ method.fail()
+
+ assertions.not_in(u1, s)
+
+ u2 = User()
+ if method.close and close_resets_only is False:
+ with expect_raises_message(
+ exc.InvalidRequestError,
+ "This Session has been permanently closed and is unable "
+ "to handle any more transaction requests.",
+ ):
+ s.add(u2)
+ assertions.not_in(u2, s)
+ else:
+ s.add(u2)
+ assertions.in_(u2, s)
+
class DeferredRelationshipExpressionTest(_fixtures.FixtureTest):
run_inserts = None
u = User(name="ed", addresses=[Address(email_address="foo")])
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"Can't resolve value for column users.id on object "
".User.*.; no value has been set for this column",
(Address.user == u).left.callable,
q = sess.query(Address).filter(Address.user == u)
assert_raises_message(
- sa.exc.StatementError,
+ exc.StatementError,
"Can't resolve value for column users.id on object "
".User.*.; no value has been set for this column",
q.one,
sess.expunge(u)
assert_raises_message(
- sa.exc.StatementError,
+ exc.StatementError,
"Can't resolve value for column users.id on object "
".User.*.; the object is detached and the value was expired",
q.one,
# already belongs to u2
s2 = Session(testing.db)
assert_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
r".*is already attached to session",
s2.add,
u1,
)
else:
assert_raises(
- sa.exc.NoInspectionAvailable, callable_, *args, **kw
+ exc.NoInspectionAvailable, callable_, *args, **kw
)
raises_("connection", bind_arguments=dict(mapper=user_arg))
def test_join_transaction_mode(self):
with expect_raises_message(
- sa.exc.ArgumentError,
+ exc.ArgumentError,
'invalid selection for join_transaction_mode: "bogus"',
):
Session(join_transaction_mode="bogus")
with mock.patch.object(s, "_validate_persistent"):
assert_raises_message(
- sa.exc.ArgumentError,
+ exc.ArgumentError,
"with_for_update should be the boolean value True, "
"or a dictionary with options",
s.refresh,
getattr(sess, meth)()
with expect_raises_message(
- sa.exc.InvalidRequestError,
+ exc.InvalidRequestError,
"Object .*User.* cannot be converted to 'persistent' state, "
"as this identity map is no longer valid.",
):
object_session(instance).delete(Address(email="x1"))
with expect_raises_message(
- sa.exc.InvalidRequestError, ".*is not persisted"
+ exc.InvalidRequestError, ".*is not persisted"
):
self._test(evt, r"Session.delete\(\)")