From 1138cc3c672f8baa0f28bdf848a55f2c7037923b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 16 Jan 2024 17:00:39 +0200 Subject: [PATCH] Provide a synchronous sessionmaker even with async engines --- docs/events.rst | 8 +++--- docs/versionhistory.rst | 3 +++ src/asphalt/sqlalchemy/component.py | 8 +++++- tests/test_component.py | 41 ++++++++++++++++++++++------- 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/docs/events.rst b/docs/events.rst index f30c2c4..35e79ca 100644 --- a/docs/events.rst +++ b/docs/events.rst @@ -28,10 +28,10 @@ Events with asynchronous engines/sessions ----------------------------------------- SQLAlchemy doesn't support asynchronous events yet, and sessionmakers producing async -sessions cannot currently be used as a target for event listeners. As a workaround, you can -register an event listener on the :class:`~sqlalchemy.orm.Session` class and then check -in the listener itself if the ``session`` argument matches the async session's -``sync_session`` attribute in the current context:: +sessions cannot currently be used as a target for event listeners. To work around this, +a synchronous ``sessionmaker`` resource is provided by this component even for async +engines. To add listeners, simply use this session maker as the target for the +listeners:: from asphalt.core import NoCurrentContext, get_resource, inject, resource from sqlalchemy.ext.asyncio import AsyncSession diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index f4581df..651a8f4 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -6,6 +6,9 @@ This library adheres to `Semantic Versioning 2.0 `_. **UNRELEASED** - Dropped support for Python 3.7 +- Changed component startup to always provide a ``sessionmaker`` resource, even with + async engines. This session maker is configured as the ``sync_session_class`` for the + async sessionmaker, and can be used to add session event listeners. **5.0.1** (2023-04-03) diff --git a/src/asphalt/sqlalchemy/component.py b/src/asphalt/sqlalchemy/component.py index 06d2287..52d73e7 100644 --- a/src/asphalt/sqlalchemy/component.py +++ b/src/asphalt/sqlalchemy/component.py @@ -50,6 +50,7 @@ class SQLAlchemyComponent(Component): For asynchronous engines, the following resources are provided: * :class:`~sqlalchemy.ext.asyncio.AsyncEngine` + * :class:`~sqlalchemy.orm.session.sessionmaker` * :class:`~sqlalchemy.ext.asyncio.async_sessionmaker` * :class:`~sqlalchemy.ext.asyncio.AsyncSession` @@ -163,8 +164,12 @@ def __init__( apply_sqlite_hacks(self.engine) if isinstance(self.engine, AsyncEngine): + # This is needed for listening to ORM events when async sessions are used + self._sessionmaker = sessionmaker() self._async_sessionmaker = async_sessionmaker( - bind=self._async_bind, **session_args + bind=self._async_bind, + sync_session_class=self._sessionmaker, + **session_args, ) else: self._sessionmaker = sessionmaker(bind=self._bind, **session_args) @@ -215,6 +220,7 @@ async def start(self, ctx: Context) -> AsyncGenerator[None, Exception | None]: bind = self._async_bind ctx.add_resource(self.engine, self.resource_name) + ctx.add_resource(self._sessionmaker, self.resource_name) ctx.add_resource(self._async_sessionmaker, self.resource_name) ctx.add_resource_factory( self.create_async_session, diff --git a/tests/test_component.py b/tests/test_component.py index 1e1b7ba..05a5722 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -4,6 +4,7 @@ from contextlib import ExitStack from pathlib import Path from threading import Thread, current_thread +from typing import Any import pytest from asphalt.core import NoCurrentContext, current_context @@ -30,28 +31,48 @@ pytestmark = pytest.mark.anyio -async def test_component_start_sync() -> None: +@pytest.mark.parametrize( + "component_opts, args", + [ + pytest.param({}, ()), + pytest.param({"resource_name": "alternate"}, ("alternate",)), + ], +) +async def test_component_start_sync( + component_opts: dict[str, Any], args: tuple[Any] +) -> None: """Test that the component creates all the expected (synchronous) resources.""" url = URL.create("sqlite", database=":memory:") - component = SQLAlchemyComponent(url=url) + component = SQLAlchemyComponent(url=url, **component_opts) async with Context() as ctx: await component.start(ctx) - ctx.require_resource(Engine) - ctx.require_resource(sessionmaker) - ctx.require_resource(Session) + ctx.require_resource(Engine, *args) + ctx.require_resource(sessionmaker, *args) + ctx.require_resource(Session, *args) -async def test_component_start_async() -> None: +@pytest.mark.parametrize( + "component_opts, args", + [ + pytest.param({}, ()), + pytest.param({"resource_name": "alternate"}, ("alternate",)), + ], +) +async def test_component_start_async( + component_opts: dict[str, Any], args: tuple[Any] +) -> None: """Test that the component creates all the expected (asynchronous) resources.""" url = URL.create("sqlite+aiosqlite", database=":memory:") - component = SQLAlchemyComponent(url=url) + component = SQLAlchemyComponent(url=url, **component_opts) async with Context() as ctx: await component.start(ctx) - ctx.require_resource(AsyncEngine) - ctx.require_resource(async_sessionmaker) - ctx.require_resource(AsyncSession) + ctx.require_resource(AsyncEngine, *args) + async_session_class = ctx.require_resource(async_sessionmaker, *args) + ctx.require_resource(AsyncSession, *args) + sync_session_class = ctx.require_resource(sessionmaker, *args) + assert async_session_class.kw["sync_session_class"] is sync_session_class @pytest.mark.parametrize(