Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
83fda8b
test: add failing listener-registration tests for Litestar async config
hasansezertasan Apr 10, 2026
c536111
test: add failing listener-registration tests for Litestar sync config
hasansezertasan Apr 10, 2026
b27febe
test: add failing listener-registration tests for Starlette config
hasansezertasan Apr 10, 2026
4a0122d
test: add failing listener-registration tests for Sanic config
hasansezertasan Apr 10, 2026
76f56f0
test: add failing listener-registration tests for Flask config
hasansezertasan Apr 10, 2026
d2d46b4
fix(litestar): delegate create_session_maker to super for listener wi…
hasansezertasan Apr 10, 2026
a81b484
fix(litestar): delegate sync create_session_maker to super for listen…
hasansezertasan Apr 10, 2026
b08c820
fix(starlette): delegate async create_session_maker to super for list…
hasansezertasan Apr 10, 2026
3ffc3eb
fix(starlette): delegate sync create_session_maker to super for liste…
hasansezertasan Apr 10, 2026
f33ea90
fix(sanic): delegate async create_session_maker to super for listener…
hasansezertasan Apr 10, 2026
07b034d
fix(sanic): delegate sync create_session_maker to super for listener …
hasansezertasan Apr 10, 2026
103b6f3
fix(flask): delegate sync create_session_maker to super for listener …
hasansezertasan Apr 10, 2026
1259ceb
fix(flask): delegate async create_session_maker to super for listener…
hasansezertasan Apr 10, 2026
7207763
test: pre-populate engine_instance to isolate listener-count assertions
hasansezertasan Apr 10, 2026
7568fbd
fix(starlette): restore engine_instance pre-step in create_session_maker
hasansezertasan Apr 10, 2026
5880d37
fix(sanic): restore engine_instance pre-step in create_session_maker
hasansezertasan Apr 10, 2026
bd22f74
fix(flask): restore engine_instance pre-step in create_session_maker
hasansezertasan Apr 10, 2026
efe8ef8
test: strengthen Starlette/Sanic/Flask async listener assertions
hasansezertasan Apr 10, 2026
a393376
test: extract shared listener-contract helpers for extension tests
hasansezertasan Apr 10, 2026
71d2651
test: pin FastAPI listener-registration contract (#709)
hasansezertasan Apr 10, 2026
46badd7
Update tests/unit/test_extensions/_listener_contract.py
hasansezertasan Apr 27, 2026
751a86a
Update advanced_alchemy/extensions/flask/config.py
hasansezertasan Apr 27, 2026
65a2d17
Update advanced_alchemy/extensions/litestar/plugins/init/config/async…
hasansezertasan Apr 27, 2026
f169d58
fix(ci): stabilize uv exclude-newer timestamp and drop blank line
hasansezertasan Apr 27, 2026
2f3ab18
fix(routing): close routing session-maker engines on extension teardown
hasansezertasan Apr 28, 2026
0dd2999
fix(starlette): wrap dispose_session_maker for run_in_threadpool typing
hasansezertasan Apr 28, 2026
993fa4d
Merge branch 'main' into fix/extension-create-session-maker-listeners
hasansezertasan May 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions advanced_alchemy/extensions/flask/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,18 @@ class SQLAlchemySyncConfig(_SQLAlchemySyncConfig):
def create_session_maker(self) -> "Callable[[], Session]":
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration runs.
See issue #709.

Returns:
Callable[[], Session]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()

def init_app(self, app: "Flask", portal: "Optional[Portal]" = None) -> None:
"""Initialize the Flask application with this configuration.
Expand Down Expand Up @@ -173,19 +172,18 @@ class SQLAlchemyAsyncConfig(_SQLAlchemyAsyncConfig):
def create_session_maker(self) -> "Callable[[], AsyncSession]":
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration runs.
See issue #709.
Comment thread
hasansezertasan marked this conversation as resolved.
Outdated

Returns:
Callable[[], AsyncSession]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()

def init_app(self, app: "Flask", portal: "Optional[Portal]" = None) -> None:
"""Initialize the Flask application with this configuration.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,16 +201,15 @@ def __post_init__(self) -> None:
def create_session_maker(self) -> "Callable[[], AsyncSession]":
"""Get a session maker. If none exists yet, create one.

Delegates to the base-class implementation so that listener
registration (file-object, timestamp, cache) runs. See issue #709.
Comment thread
hasansezertasan marked this conversation as resolved.
Outdated

Returns:
Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if session_kws.get("bind") is None:
session_kws["bind"] = self.get_engine()
return self.session_maker_class(**session_kws) # pyright: ignore[reportUnknownVariableType,reportUnknownMemberType]
return super().create_session_maker()

@asynccontextmanager
async def lifespan(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,16 +202,15 @@ def __post_init__(self) -> None:
def create_session_maker(self) -> "Callable[[], Session]":
"""Get a session maker. If none exists yet, create one.

Delegates to the base-class implementation so that listener
registration (file-object, timestamp, cache) runs. See issue #709.

Returns:
Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if session_kws.get("bind") is None:
session_kws["bind"] = self.get_engine()
return self.session_maker_class(**session_kws)
return super().create_session_maker()

@asynccontextmanager
async def lifespan(
Expand Down
22 changes: 10 additions & 12 deletions advanced_alchemy/extensions/sanic/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,19 +213,18 @@ async def on_startup(self) -> None:
def create_session_maker(self) -> Callable[[], "AsyncSession"]:
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration
(file-object, timestamp, cache) runs. See issue #709.

Returns:
Callable[[], Session]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()

async def session_handler(
self, session: "AsyncSession", request: "Request", response: "HTTPResponse"
Expand Down Expand Up @@ -426,19 +425,18 @@ async def on_startup(self) -> None:
def create_session_maker(self) -> Callable[[], "Session"]:
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration runs.
See issue #709.

Returns:
Callable[[], Session]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()

async def session_handler(
self, session: "Session", request: "Request", response: "HTTPResponse"
Expand Down
22 changes: 10 additions & 12 deletions advanced_alchemy/extensions/starlette/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,19 +245,18 @@ async def on_startup(self) -> None:
def create_session_maker(self) -> Callable[[], "AsyncSession"]:
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration
(file-object, timestamp, cache) runs. See issue #709.

Returns:
Callable[[], Session]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()
Comment thread
hasansezertasan marked this conversation as resolved.

async def session_handler(
self, session: "AsyncSession", request: "Request", response: "Response"
Expand Down Expand Up @@ -408,19 +407,18 @@ async def on_startup(self) -> None:
def create_session_maker(self) -> Callable[[], "Session"]:
"""Get a session maker. If none exists yet, create one.

Preserves ``engine_instance`` caching and then delegates to the
base-class implementation so that listener registration runs.
See issue #709.

Returns:
Callable[[], Session]: Session factory used by the plugin.
"""
if self.session_maker:
return self.session_maker

session_kws = self.session_config_dict
if self.engine_instance is None:
self.engine_instance = self.get_engine()
if session_kws.get("bind") is None:
session_kws["bind"] = self.engine_instance
self.session_maker = self.session_maker_class(**session_kws)
return self.session_maker
return super().create_session_maker()

async def session_handler(
self, session: "Session", request: "Request", response: "Response"
Expand Down
172 changes: 172 additions & 0 deletions tests/unit/test_extensions/_listener_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""Shared helpers for extension listener-registration contract tests.

Used by ``test_flask_listeners.py``, ``test_sanic_listeners.py``, and
``test_starlette_listeners.py`` to eliminate duplication. Each extension's
async/sync config overrides ``create_session_maker``; these helpers patch
the base class's ``create_session_maker`` and ``sqlalchemy.event.listen``,
then assert the middle-layer listener-registration contract per subclass.

Regression helpers for
https://github.com/litestar-org/advanced-alchemy/issues/709.
"""

from __future__ import annotations
Comment thread
hasansezertasan marked this conversation as resolved.
Outdated

from typing import Any
from unittest.mock import MagicMock, patch

from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import sessionmaker

from advanced_alchemy.config.common import GenericSQLAlchemyConfig

__all__ = (
"assert_async_file_object_listener_disabled",
"assert_async_registers_all_listeners",
"assert_async_timestamp_listener_disabled",
"assert_sync_file_object_listener_disabled",
"assert_sync_registers_all_listeners",
"assert_sync_timestamp_listener_disabled",
)

_ASYNC_URL = "sqlite+aiosqlite:///"
_SYNC_URL = "sqlite:///"


def _make_async_config(config_cls: type, **kwargs: Any) -> Any:
"""Build an async config with ``engine_instance`` pre-populated.

Pre-populating ``engine_instance`` isolates listener-count assertions
from the aiosqlite dialect's internal ``event.listen`` calls that
would otherwise fire during ``get_engine()``.
"""
config = config_cls(connection_string=_ASYNC_URL, **kwargs)
config.engine_instance = MagicMock()
return config


def _make_sync_config(config_cls: type, **kwargs: Any) -> Any:
"""Build a sync config with ``engine_instance`` pre-populated."""
config = config_cls(connection_string=_SYNC_URL, **kwargs)
config.engine_instance = MagicMock()
return config


def assert_async_registers_all_listeners(config_cls: type) -> None:
"""Default async config registers 6 listeners on the synthetic sync_maker."""
mock_session_maker = MagicMock(spec=async_sessionmaker)
config = _make_async_config(config_cls)

with (
patch.object(
GenericSQLAlchemyConfig,
"create_session_maker",
return_value=mock_session_maker,
),
patch("sqlalchemy.event.listen") as mock_listen,
patch("advanced_alchemy.config.asyncio.sync_sessionmaker") as mock_sync_factory,
):
mock_sync_maker = MagicMock()
mock_sync_factory.return_value = mock_sync_maker
result = config.create_session_maker()

assert result is mock_session_maker
assert mock_listen.call_count == 6
mock_session_maker.configure.assert_called_once_with(sync_session_class=mock_sync_maker)
for call in mock_listen.call_args_list:
assert call.args[0] is mock_sync_maker
listener_events = {c.args[1] for c in mock_listen.call_args_list}
assert {"before_flush", "after_commit", "after_rollback"} <= listener_events


def assert_async_file_object_listener_disabled(config_cls: type) -> None:
"""With file-object listener disabled, only timestamp + cache listeners register (3)."""
mock_session_maker = MagicMock(spec=async_sessionmaker)
config = _make_async_config(config_cls, enable_file_object_listener=False)

with (
patch.object(
GenericSQLAlchemyConfig,
"create_session_maker",
return_value=mock_session_maker,
),
patch("sqlalchemy.event.listen") as mock_listen,
patch("advanced_alchemy.config.asyncio.sync_sessionmaker"),
):
config.create_session_maker()

assert mock_listen.call_count == 3


def assert_async_timestamp_listener_disabled(config_cls: type) -> None:
"""With timestamp listener disabled, only file-object + cache listeners register (5)."""
mock_session_maker = MagicMock(spec=async_sessionmaker)
config = _make_async_config(config_cls, enable_touch_updated_timestamp_listener=False)

with (
patch.object(
GenericSQLAlchemyConfig,
"create_session_maker",
return_value=mock_session_maker,
),
patch("sqlalchemy.event.listen") as mock_listen,
patch("advanced_alchemy.config.asyncio.sync_sessionmaker"),
):
config.create_session_maker()

assert mock_listen.call_count == 5


def assert_sync_registers_all_listeners(config_cls: type) -> None:
"""Default sync config registers 6 listeners directly on the session_maker."""
mock_session_maker = MagicMock(spec=sessionmaker)
config = _make_sync_config(config_cls)

with (
patch.object(
GenericSQLAlchemyConfig,
"create_session_maker",
return_value=mock_session_maker,
),
patch("sqlalchemy.event.listen") as mock_listen,
):
result = config.create_session_maker()

assert result is mock_session_maker
assert mock_listen.call_count == 6


def assert_sync_file_object_listener_disabled(config_cls: type) -> None:
"""With file-object listener disabled, only timestamp + cache listeners register (3)."""
mock_session_maker = MagicMock(spec=sessionmaker)
config = _make_sync_config(config_cls, enable_file_object_listener=False)

with (
patch.object(
GenericSQLAlchemyConfig,
"create_session_maker",
return_value=mock_session_maker,
),
patch("sqlalchemy.event.listen") as mock_listen,
):
config.create_session_maker()

assert mock_listen.call_count == 3


def assert_sync_timestamp_listener_disabled(config_cls: type) -> None:
"""With timestamp listener disabled, only file-object + cache listeners register (5)."""
mock_session_maker = MagicMock(spec=sessionmaker)
config = _make_sync_config(config_cls, enable_touch_updated_timestamp_listener=False)

with (
patch.object(
GenericSQLAlchemyConfig,
"create_session_maker",
return_value=mock_session_maker,
),
patch("sqlalchemy.event.listen") as mock_listen,
):
config.create_session_maker()

assert mock_listen.call_count == 5
Loading
Loading