From 51f41ad4019d262f86a826a0c4405d33b60516cb Mon Sep 17 00:00:00 2001 From: d3vyce <44915747+d3vyce@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:53:29 +0200 Subject: [PATCH 1/5] fix: lock_tables acquires dedicated session to enforce RAII lock boundaries (#261) --- src/fastapi_toolsets/db.py | 38 ++++++------ tests/conftest.py | 15 +++++ tests/test_db.py | 116 +++++++++++++++++-------------------- tests/test_models.py | 29 ++++++++-- 4 files changed, 110 insertions(+), 88 deletions(-) diff --git a/src/fastapi_toolsets/db.py b/src/fastapi_toolsets/db.py index 8552c80..e9767e4 100644 --- a/src/fastapi_toolsets/db.py +++ b/src/fastapi_toolsets/db.py @@ -153,7 +153,7 @@ class LockMode(str, Enum): @asynccontextmanager async def lock_tables( - session: AsyncSession, + session_maker: async_sessionmaker[AsyncSession], tables: list[type[DeclarativeBase]], *, mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE, @@ -161,42 +161,44 @@ async def lock_tables( ) -> AsyncGenerator[AsyncSession, None]: """Lock PostgreSQL tables for the duration of a transaction. - Acquires table-level locks that are held until the transaction ends. - Useful for preventing concurrent modifications during critical operations. - Args: - session: AsyncSession instance - tables: List of SQLAlchemy model classes to lock - mode: Lock mode (default: SHARE UPDATE EXCLUSIVE) - timeout: Lock timeout (default: "5s") + session_maker: Async session factory used to create the dedicated + session. + tables: List of SQLAlchemy model classes to lock. + mode: Lock mode (default: SHARE UPDATE EXCLUSIVE). + timeout: Lock timeout (default: "5s"). Yields: - The session with locked tables + The dedicated session, open within the locked transaction. Raises: - SQLAlchemyError: If lock cannot be acquired within timeout + SQLAlchemyError: If the lock cannot be acquired within *timeout*. Example: ```python from fastapi_toolsets.db import lock_tables, LockMode - async with lock_tables(session, [User, Account]): - # Tables are locked with SHARE UPDATE EXCLUSIVE mode + async with lock_tables(session_maker, [User, Account]) as session: + # Tables are locked; changes are committed when the context exits. user = await UserCrud.get(session, [User.id == 1]) user.balance += 100 # With custom lock mode - async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE): - # Exclusive lock - no other transactions can access + async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session: await process_order(session, order_id) ``` """ table_names = ",".join(table.__tablename__ for table in tables) - async with get_transaction(session): - await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'")) - await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE")) - yield session + async with session_maker() as session: + try: + await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'")) + await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE")) + yield session + await session.commit() + except BaseException: + await session.rollback() + raise async def create_database( diff --git a/tests/conftest.py b/tests/conftest.py index af85c16..ef7aed6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -439,6 +439,21 @@ async def engine(): await engine.dispose() +@pytest.fixture(scope="function") +async def session_maker(engine): + """Provide a session factory with tables created and dropped around the test.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + factory = async_sessionmaker(engine, expire_on_commit=False) + + try: + yield factory + finally: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + @pytest.fixture(scope="function") async def db_session(engine): """Create a test database session with tables. diff --git a/tests/test_db.py b/tests/test_db.py index 55fa5eb..df7b4b9 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -116,13 +116,8 @@ async def test_no_commit_when_not_in_transaction(self): await engine.dispose() @pytest.mark.anyio - async def test_update_after_lock_tables_is_persisted(self): - """Changes made after lock_tables exits (before endpoint returns) are committed. - - Regression: without the auto-begin fix, lock_tables would start and commit a - real outer transaction, leaving the session idle. Any modifications after that - point were silently dropped. - """ + async def test_data_inside_lock_is_committed(self): + """Changes made inside lock_tables are committed when the context exits.""" engine = create_async_engine(DATABASE_URL, echo=False) session_factory = async_sessionmaker(engine, expire_on_commit=False) @@ -130,21 +125,12 @@ async def test_update_after_lock_tables_is_persisted(self): await conn.run_sync(Base.metadata.create_all) try: - get_db = create_db_dependency(session_factory) - - async for session in get_db(): - async with lock_tables(session, [Role]): - role = Role(name="lock_then_update") - session.add(role) - await session.flush() - # lock_tables has exited — outer transaction must still be open - assert session.in_transaction() - role.name = "updated_after_lock" + async with lock_tables(session_factory, [Role]) as session: + role = Role(name="lock_committed") + session.add(role) async with session_factory() as verify: - result = await RoleCrud.first( - verify, [Role.name == "updated_after_lock"] - ) + result = await RoleCrud.first(verify, [Role.name == "lock_committed"]) assert result is not None finally: async with engine.begin() as conn: @@ -287,54 +273,55 @@ class TestLockTables: """Tests for lock_tables context manager (PostgreSQL-specific).""" @pytest.mark.anyio - async def test_lock_single_table(self, db_session: AsyncSession): - """Lock a single table.""" - async with lock_tables(db_session, [Role]): - # Inside the lock, we can still perform operations + async def test_lock_single_table(self, session_maker): + """Lock a single table; changes inside are committed on context exit.""" + async with lock_tables(session_maker, [Role]) as session: role = Role(name="locked_role") - db_session.add(role) - await db_session.flush() + session.add(role) - # After lock is released, verify the data was committed - result = await RoleCrud.first(db_session, [Role.name == "locked_role"]) - assert result is not None + async with session_maker() as verify: + result = await RoleCrud.first(verify, [Role.name == "locked_role"]) + assert result is not None @pytest.mark.anyio - async def test_lock_multiple_tables(self, db_session: AsyncSession): + async def test_lock_multiple_tables(self, session_maker): """Lock multiple tables.""" - async with lock_tables(db_session, [Role, User]): + async with lock_tables(session_maker, [Role, User]) as session: role = Role(name="multi_lock_role") - db_session.add(role) - await db_session.flush() + session.add(role) - result = await RoleCrud.first(db_session, [Role.name == "multi_lock_role"]) - assert result is not None + async with session_maker() as verify: + result = await RoleCrud.first(verify, [Role.name == "multi_lock_role"]) + assert result is not None @pytest.mark.anyio - async def test_lock_with_custom_mode(self, db_session: AsyncSession): + async def test_lock_with_custom_mode(self, session_maker): """Lock with custom lock mode.""" - async with lock_tables(db_session, [Role], mode=LockMode.EXCLUSIVE): + async with lock_tables( + session_maker, [Role], mode=LockMode.EXCLUSIVE + ) as session: role = Role(name="exclusive_lock_role") - db_session.add(role) - await db_session.flush() + session.add(role) - result = await RoleCrud.first(db_session, [Role.name == "exclusive_lock_role"]) - assert result is not None + async with session_maker() as verify: + result = await RoleCrud.first(verify, [Role.name == "exclusive_lock_role"]) + assert result is not None @pytest.mark.anyio - async def test_lock_rollback_on_exception(self, db_session: AsyncSession): + async def test_lock_rollback_on_exception(self, session_maker): """Lock context rolls back on exception.""" try: - async with lock_tables(db_session, [Role]): + async with lock_tables(session_maker, [Role]) as session: role = Role(name="lock_rollback_role") - db_session.add(role) - await db_session.flush() + session.add(role) + await session.flush() raise ValueError("Simulated error") except ValueError: pass - result = await RoleCrud.first(db_session, [Role.name == "lock_rollback_role"]) - assert result is None + async with session_maker() as verify: + result = await RoleCrud.first(verify, [Role.name == "lock_rollback_role"]) + assert result is None class TestWaitForRowChange: @@ -643,29 +630,30 @@ async def test_non_m2m_raises_type_error(self, db_session: AsyncSession): await m2m_add(db_session, user, User.role, role) @pytest.mark.anyio - async def test_works_inside_lock_tables(self, db_session: AsyncSession): - """m2m_add works correctly inside a lock_tables nested transaction.""" - user = User(username="m2m_lock_author", email="m2m_lock@test.com") - db_session.add(user) - await db_session.flush() + async def test_works_inside_lock_tables(self, session_maker): + """m2m_add works correctly inside a lock_tables context.""" + async with lock_tables(session_maker, [Tag]) as session: + user = User(username="m2m_lock_author", email="m2m_lock@test.com") + session.add(user) + await session.flush() - async with lock_tables(db_session, [Tag]): tag = Tag(name="locked_tag") - db_session.add(tag) - await db_session.flush() + session.add(tag) + await session.flush() post = Post(title="Post Lock", author_id=user.id) - db_session.add(post) - await db_session.flush() + session.add(post) + await session.flush() - await m2m_add(db_session, post, Post.tags, tag) + await m2m_add(session, post, Post.tags, tag) - result = await db_session.execute( - select(Post).where(Post.id == post.id).options(selectinload(Post.tags)) - ) - loaded = result.scalar_one() - assert len(loaded.tags) == 1 - assert loaded.tags[0].name == "locked_tag" + async with session_maker() as verify: + result = await verify.execute( + select(Post).where(Post.id == post.id).options(selectinload(Post.tags)) + ) + loaded = result.scalar_one() + assert len(loaded.tags) == 1 + assert loaded.tags[0].name == "locked_tag" class _LocalBase(DeclarativeBase): diff --git a/tests/test_models.py b/tests/test_models.py index 95741ea..8f98ecd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,6 +7,7 @@ import pytest from sqlalchemy import String +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column import fastapi_toolsets.models.watched as _watched_module @@ -20,6 +21,7 @@ listens_for, ) from fastapi_toolsets.models.watched import ( + EventSession, _EVENT_HANDLERS, _SESSION_CREATES, _SESSION_DELETES, @@ -338,6 +340,23 @@ async def mixin_session_expire(): yield session +@pytest.fixture(scope="function") +async def mixin_session_maker(): + """Provide an EventSession-backed session factory with MixinBase tables.""" + engine = create_async_engine(DATABASE_URL, echo=False) + async with engine.begin() as conn: + await conn.run_sync(MixinBase.metadata.create_all) + + factory = async_sessionmaker(engine, expire_on_commit=False, class_=EventSession) + + try: + yield factory + finally: + async with engine.begin() as conn: + await conn.run_sync(MixinBase.metadata.drop_all) + await engine.dispose() + + class TestUUIDMixin: @pytest.mark.anyio async def test_uuid_generated_by_db(self, mixin_session): @@ -1559,15 +1578,13 @@ async def test_savepoint_rollback_suppresses_events(self, mixin_session): assert creates[0]["obj_id"] == survivor.id @pytest.mark.anyio - async def test_lock_tables_with_events(self, mixin_session): - """Events fire correctly after lock_tables context.""" + async def test_lock_tables_with_events(self, mixin_session_maker): + """Events fire correctly when lock_tables commits on context exit.""" from fastapi_toolsets.db import lock_tables - async with lock_tables(mixin_session, [WatchedModel]): + async with lock_tables(mixin_session_maker, [WatchedModel]) as session: obj = WatchedModel(status="locked", other="x") - mixin_session.add(obj) - - await mixin_session.commit() + session.add(obj) creates = [e for e in _test_events if e["event"] == "create"] assert len(creates) == 1 From 8158f8605a30d14ce247e0ad4dd925228238df82 Mon Sep 17 00:00:00 2001 From: d3vyce <44915747+d3vyce@users.noreply.github.com> Date: Mon, 4 May 2026 20:25:00 +0200 Subject: [PATCH 2/5] fix: make lock_tables generic over session type (#270) --- src/fastapi_toolsets/db.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/fastapi_toolsets/db.py b/src/fastapi_toolsets/db.py index e9767e4..783c40a 100644 --- a/src/fastapi_toolsets/db.py +++ b/src/fastapi_toolsets/db.py @@ -151,14 +151,13 @@ class LockMode(str, Enum): ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE" -@asynccontextmanager -async def lock_tables( - session_maker: async_sessionmaker[AsyncSession], +def lock_tables( + session_maker: async_sessionmaker[_SessionT], tables: list[type[DeclarativeBase]], *, mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE, timeout: str = "5s", -) -> AsyncGenerator[AsyncSession, None]: +) -> AbstractAsyncContextManager[_SessionT]: """Lock PostgreSQL tables for the duration of a transaction. Args: @@ -190,15 +189,19 @@ async def lock_tables( """ table_names = ",".join(table.__tablename__ for table in tables) - async with session_maker() as session: - try: - await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'")) - await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE")) - yield session - await session.commit() - except BaseException: - await session.rollback() - raise + @asynccontextmanager + async def _lock() -> AsyncGenerator[_SessionT, None]: + async with session_maker() as session: + try: + await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'")) + await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE")) + yield session + await session.commit() + except BaseException: + await session.rollback() + raise + + return _lock() async def create_database( From d67fd042db35ac24ce629781d009b8b8852a45b2 Mon Sep 17 00:00:00 2001 From: d3vyce Date: Fri, 8 May 2026 06:55:31 -0400 Subject: [PATCH 3/5] docs: fix zensical warnings --- docs/examples/pagination-search.md | 2 +- docs/module/schemas.md | 4 ++-- docs/reference/exceptions.md | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/examples/pagination-search.md b/docs/examples/pagination-search.md index 1995673..6321a1b 100644 --- a/docs/examples/pagination-search.md +++ b/docs/examples/pagination-search.md @@ -130,7 +130,7 @@ Pass `next_cursor` as the `cursor` query parameter on the next request to advanc !!! info "Added in `v2.3.0`" -[`paginate()`](../module/crud.md#unified-paginate--both-strategies-on-one-endpoint) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling. +[`paginate()`](../module/crud.md#unified-endpoint-both-strategies) lets a single endpoint support both strategies via a `pagination_type` query parameter. The `pagination_type` field in the response acts as a discriminator for frontend tooling. ```python title="routes.py:61:79" --8<-- "docs_src/examples/pagination_search/routes.py:61:79" diff --git a/docs/module/schemas.md b/docs/module/schemas.md index ca8686e..68fa810 100644 --- a/docs/module/schemas.md +++ b/docs/module/schemas.md @@ -102,7 +102,7 @@ async def list_events( #### [`PaginatedResponse[T]`](../reference/schemas.md#fastapi_toolsets.schemas.PaginatedResponse) -Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-paginate--both-strategies-on-one-endpoint)). +Return type for endpoints that support **both** pagination strategies via a `pagination_type` query parameter (using [`paginate()`](crud.md#unified-endpoint-both-strategies)). When used as a return annotation, `PaginatedResponse[T]` automatically expands to `Annotated[Union[CursorPaginatedResponse[T], OffsetPaginatedResponse[T]], Field(discriminator="pagination_type")]`, so FastAPI emits a proper `oneOf` + discriminator in the OpenAPI schema with no extra boilerplate: @@ -129,7 +129,7 @@ async def list_users( #### Pagination metadata models -The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#filter-attributes-facets)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`. +The optional `filter_attributes` field is populated when `facet_fields` are configured on the CRUD class (see [Filter attributes](crud.md#faceted-search)). It is `None` by default and can be hidden from API responses with `response_model_exclude_none=True`. ### [`ErrorResponse`](../reference/schemas.md#fastapi_toolsets.schemas.ErrorResponse) diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md index 4368062..3ee24c8 100644 --- a/docs/reference/exceptions.md +++ b/docs/reference/exceptions.md @@ -12,6 +12,7 @@ from fastapi_toolsets.exceptions import ( NotFoundError, ConflictError, NoSearchableFieldsError, + InvalidSearchColumnError, InvalidFacetFilterError, InvalidOrderFieldError, generate_error_responses, @@ -31,6 +32,8 @@ from fastapi_toolsets.exceptions import ( ## ::: fastapi_toolsets.exceptions.exceptions.NoSearchableFieldsError +## ::: fastapi_toolsets.exceptions.exceptions.InvalidSearchColumnError + ## ::: fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError ## ::: fastapi_toolsets.exceptions.exceptions.InvalidOrderFieldError From 2f174e57c9625e0ee140f470db7705ff589a1f12 Mon Sep 17 00:00:00 2001 From: d3vyce Date: Fri, 8 May 2026 06:57:05 -0400 Subject: [PATCH 4/5] Version 4.0.0 --- pyproject.toml | 2 +- src/fastapi_toolsets/__init__.py | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 46653a6..713db38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fastapi-toolsets" -version = "3.1.1" +version = "4.0.0" description = "Production-ready utilities for FastAPI applications" readme = "README.md" license = "MIT" diff --git a/src/fastapi_toolsets/__init__.py b/src/fastapi_toolsets/__init__.py index fce7d88..7523c4a 100644 --- a/src/fastapi_toolsets/__init__.py +++ b/src/fastapi_toolsets/__init__.py @@ -21,4 +21,4 @@ async def get_user(user_id: int, session = Depends(get_db)): return Response(data={"user": user.username}, message="Success") """ -__version__ = "3.1.1" +__version__ = "4.0.0" diff --git a/uv.lock b/uv.lock index 0913328..6d8c13e 100644 --- a/uv.lock +++ b/uv.lock @@ -330,7 +330,7 @@ wheels = [ [[package]] name = "fastapi-toolsets" -version = "3.1.1" +version = "4.0.0" source = { editable = "." } dependencies = [ { name = "asyncpg" }, From 22a9132512ff09eb30e68b8230c0dc6e5af7a765 Mon Sep 17 00:00:00 2001 From: d3vyce Date: Fri, 8 May 2026 07:01:43 -0400 Subject: [PATCH 5/5] docs: add v4 migration + fix lock_tables documentation --- docs/migration/v4.md | 40 ++++++++++++++++++++++++++++++++++++++++ docs/module/db.md | 8 ++++---- zensical.toml | 1 + 3 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 docs/migration/v4.md diff --git a/docs/migration/v4.md b/docs/migration/v4.md new file mode 100644 index 0000000..fc7587e --- /dev/null +++ b/docs/migration/v4.md @@ -0,0 +1,40 @@ +# Migrating to v4.0 + +This page covers every breaking change introduced in **v4.0** and the steps required to update your code. + +--- + +## Database + +### `lock_tables` now takes a `session_maker` instead of a `session` + +The first argument of `lock_tables` changed from an `AsyncSession` instance to an `async_sessionmaker`. +The function creates and manages its own **dedicated session** internally, yielding it to the caller. + +=== "Before (`v3`)" + + ```python + from fastapi_toolsets.db import lock_tables, LockMode + + async with lock_tables(session=session, tables=[User, Account]): + user = await UserCrud.get(session, [User.id == 1]) + user.balance += 100 + + # With a custom lock mode + async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE): + await process_order(session, order_id) + ``` + +=== "Now (`v4`)" + + ```python + from fastapi_toolsets.db import lock_tables, LockMode + + async with lock_tables(session_maker=session_maker, tables=[User, Account]) as session: + user = await UserCrud.get(session, [User.id == 1]) + user.balance += 100 + + # With a custom lock mode + async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session: + await process_order(session, order_id) + ``` diff --git a/docs/module/db.md b/docs/module/db.md index b79ea63..0c3bd9d 100644 --- a/docs/module/db.md +++ b/docs/module/db.md @@ -57,12 +57,12 @@ async def create_user_with_role(session=session): ## Table locking -[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections: +[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections. It opens a **dedicated session** internally and yields it to the caller, so the lock is guaranteed to be released when the context exits: ```python -from fastapi_toolsets.db import lock_tables +from fastapi_toolsets.db import lock_tables, LockMode -async with lock_tables(session=session, tables=[User], mode="EXCLUSIVE"): +async with lock_tables(session_maker=session_maker, tables=[User], mode=LockMode.EXCLUSIVE) as session: # No other transaction can modify User until this block exits ... ``` @@ -129,7 +129,7 @@ SQLAlchemy's ORM collection API triggers lazy-loads when you append to a relatio ```python from fastapi_toolsets.db import lock_tables, m2m_add -async with lock_tables(session, [Tag]): +async with lock_tables(session_maker, [Tag]) as session: tag = await TagCrud.create(session, TagCreate(name="python")) await m2m_add(session, post, Post.tags, tag) ``` diff --git a/zensical.toml b/zensical.toml index f7e4a09..d4cc6fe 100644 --- a/zensical.toml +++ b/zensical.toml @@ -147,6 +147,7 @@ Examples = [ [[project.nav]] Migration = [ + {"v4.0" = "migration/v4.md"}, {"v3.0" = "migration/v3.md"}, {"v2.0" = "migration/v2.md"}, ]