Skip to content

Commit fd1a892

Browse files
lesnik512claude
andauthored
refactor: extract test-transaction seam from production session (#53)
CustomAsyncSession existed only so the per-test rollback worked: its close() became a no-op (expunge_all) when bound to a connection — a test concern leaking into the production data-access module. Remove it. Test-transaction ownership now lives in the db_session fixture via SQLAlchemy 2.0's native join_transaction_mode="create_savepoint". Each session owns its own savepoint; advanced-alchemy's auto_commit releases it while the fixture's outer transaction survives and is rolled back per test. This obsoletes both the close() override and the begin_nested() the old conditional_savepoint default required. The kwarg is inert in production: it only takes effect when the session is bound to a connection already in a transaction, which production never does (it binds to an engine). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1356e3c commit fd1a892

3 files changed

Lines changed: 17 additions & 13 deletions

File tree

‎CLAUDE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Endpoints inject repositories with `FromDI(Repository)` from `modern_di_fastapi`
4949

5050
- `app/models.py` — `BigIntAuditBase` from `advanced_alchemy` (auto `id`, `created_at`, `updated_at`). The module aliases `orm_registry.metadata` onto `orm.DeclarativeBase.metadata` so Alembic autogenerate sees both. New models go here.
5151
- `app/repositories.py` — Subclass `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. Routes use the service methods (`list`, `get_one_or_none`, `create`, `update`, `create_many`, `upsert_many`).
52-
- `app/resources/db.py` — `CustomAsyncSession.close()` does `expunge_all()` instead of closing when bound to an `AsyncConnection`. This is what enables the test rollback pattern below — do not "fix" it.
52+
- `app/resources/db.py` — `create_session` passes `join_transaction_mode="create_savepoint"`. This is inert in production (the session binds to an engine) but enables the test rollback pattern below: when a test binds the session to a connection already in a transaction, the session owns its own savepoint so the outer transaction survives commits — do not "fix" it.
5353
- `migrations/env.py` swaps the asyncpg driver for the sync `postgresql` driver and uses `app.models.METADATA` as `target_metadata`.
5454

5555
### Settings
@@ -61,7 +61,7 @@ Endpoints inject repositories with `FromDI(Repository)` from `modern_di_fastapi`
6161
`tests/conftest.py` provides the test isolation pattern — read it before adding fixtures:
6262

6363
- `app` fixture builds a fresh app via `LifespanManager`.
64-
- `db_session` opens a connection, begins a transaction, begins a nested savepoint, and **overrides `Dependencies.database_engine`** with the connection itself. The nested savepoint is rolled back at teardown so each test starts clean. This is why `CustomAsyncSession.close` must `expunge_all` rather than close — closing would commit the outer transaction.
64+
- `db_session` opens a connection, begins a transaction, and **overrides `Dependencies.database_engine`** with the connection itself. Each session built against that connection uses `join_transaction_mode="create_savepoint"`, so `auto_commit` releases the session's own savepoint while the outer transaction is rolled back at teardown — each test starts clean.
6565
- `set_async_session_in_base_sqlalchemy_factory` wires `db_session` into `SQLAlchemyFactory.__async_session__` so `polyfactory` factories in `tests/factories.py` (`DeckModelFactory`, `CardModelFactory`) persist via the rolled-back session. Test modules that use these factories opt in with `pytestmark = [pytest.mark.usefixtures("set_async_session_in_base_sqlalchemy_factory")]`.
6666

6767
`pytest.ini_options` sets `asyncio_mode = "auto"` — async tests do not need `@pytest.mark.asyncio`. Coverage runs by default (`--cov=. --cov-report term-missing`).

‎app/resources/db.py‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@ async def close_sa_engine(engine: sa.AsyncEngine) -> None:
2525
await engine.dispose()
2626

2727

28-
class CustomAsyncSession(sa.AsyncSession):
29-
async def close(self) -> None:
30-
if isinstance(self.bind, sa.AsyncConnection):
31-
return self.expunge_all()
32-
33-
return await super().close()
34-
35-
3628
def create_session(engine: sa.AsyncEngine) -> sa.AsyncSession:
37-
return CustomAsyncSession(engine, expire_on_commit=False, autoflush=False)
29+
# join_transaction_mode is inert in production (the session binds to an engine); when tests bind
30+
# the session to a connection already in a transaction, it makes the session own a savepoint so
31+
# the outer transaction survives commits and the per-test rollback stays clean.
32+
return sa.AsyncSession(
33+
engine,
34+
expire_on_commit=False,
35+
autoflush=False,
36+
join_transaction_mode="create_savepoint",
37+
)
3838

3939

4040
async def close_session(session: sa.AsyncSession) -> None:

‎tests/conftest.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,15 @@ async def db_session(di_container: modern_di.Container) -> typing.AsyncIterator[
4747
engine = create_sa_engine()
4848
connection = await engine.connect()
4949
transaction = await connection.begin()
50-
await connection.begin_nested()
5150
di_container.override(ioc.Dependencies.database_engine, connection)
5251

5352
try:
54-
yield AsyncSession(connection, expire_on_commit=False, autoflush=False)
53+
yield AsyncSession(
54+
connection,
55+
expire_on_commit=False,
56+
autoflush=False,
57+
join_transaction_mode="create_savepoint",
58+
)
5559
finally:
5660
if connection.in_transaction():
5761
await transaction.rollback()

0 commit comments

Comments
 (0)