Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 10 additions & 3 deletions docs/migration/v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,17 @@ The `paginate` shorthand was an alias for `offset_paginate`. It has been removed
=== "Before (`v1`)"

```python
result = await UserCrud.paginate(session=session, page=2, items_per_page=20, schema=UserRead)
result = await UserCrud.paginate(
session=session, page=2, items_per_page=20, schema=UserRead
)
```

=== "Now (`v2`)"

```python
result = await UserCrud.offset_paginate(session=session, page=2, items_per_page=20, schema=UserRead)
result = await UserCrud.offset_paginate(
session=session, page=2, items_per_page=20, schema=UserRead
)
```

---
Expand Down Expand Up @@ -124,8 +128,11 @@ For shared base classes that are not meant to be raised directly, use `abstract=
class BillingError(ApiException, abstract=True):
"""Base for all billing-related errors — not raised directly."""


class PaymentRequiredError(BillingError):
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
api_error = ApiError(
code=402, msg="Payment Required", desc="...", err_code="BILLING-402"
)
```

---
Expand Down
15 changes: 13 additions & 2 deletions docs/migration/v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,16 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr
```python
from fastapi_toolsets.crud import OrderByClause


@router.get("/offset")
async def list_articles_offset(
session: SessionDep,
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))],
order_by: Annotated[
OrderByClause | None,
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
],
search: str | None = None,
) -> OffsetPaginatedResponse[ArticleRead]:
return await ArticleCrud.offset_paginate(
Expand Down Expand Up @@ -79,7 +83,9 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr
),
],
) -> OffsetPaginatedResponse[ArticleRead]:
return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead)
return await ArticleCrud.offset_paginate(
session=session, **params, schema=ArticleRead
)
```

The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle:
Expand Down Expand Up @@ -109,6 +115,7 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch`
```python
from fastapi_toolsets.models import WatchedFieldsMixin, watch


@watch("status")
class Order(Base, UUIDMixin, WatchedFieldsMixin):
__tablename__ = "orders"
Expand All @@ -131,21 +138,25 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch`
```python
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for


class Order(Base, UUIDMixin):
__tablename__ = "orders"
__watched_fields__ = ("status",)

status: Mapped[str]


@listens_for(Order, [ModelEvent.CREATE])
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
await notify_new_order(order.id)


@listens_for(Order, [ModelEvent.UPDATE])
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
if "status" in changes:
await notify_status_change(order.id, changes["status"])


@listens_for(Order, [ModelEvent.DELETE])
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
await notify_order_cancelled(order.id)
Expand Down
4 changes: 3 additions & 1 deletion docs/migration/v4.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ The function creates and manages its own **dedicated session** internally, yield
user.balance += 100

# With a custom lock mode
async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session:
async with lock_tables(
session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE
) as session:
await process_order(session, order_id)
```
15 changes: 10 additions & 5 deletions docs/migration/v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ Build one `Database` with your URL (or an existing `engine=`), then use the inst
get_db = create_db_dependency(session_maker=SessionLocal)
get_db_context = create_db_context(session_maker=SessionLocal)


@app.get("/users")
async def list_users(session: AsyncSession = Depends(get_db)):
...
async def list_users(session: AsyncSession = Depends(get_db)): ...


async def seed():
async with get_db_context() as session:
Expand All @@ -40,9 +41,10 @@ Build one `Database` with your URL (or an existing `engine=`), then use the inst

db = Database(url="postgresql+asyncpg://...")


@app.get("/users")
async def list_users(session: AsyncSession = Depends(db)):
...
async def list_users(session: AsyncSession = Depends(db)): ...


async def seed():
async with db.session() as session:
Expand Down Expand Up @@ -89,7 +91,9 @@ The free `lock_tables(session_maker, tables, ...)` function still exists for cal
```python
from fastapi_toolsets.db import lock_tables, LockMode

async with lock_tables(session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE) as session:
async with lock_tables(
session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE
) as session:
await process_order(session, order_id)
```

Expand Down Expand Up @@ -127,6 +131,7 @@ Both also change their first argument: instead of the fixture *function*, pass t
```python
from fastapi_toolsets.fixtures import get_obj_by_attr, get_field_by_attr


@fixtures.register(depends_on=["roles"])
def users():
admin_role = get_obj_by_attr(fixtures=roles, attr_name="name", value="admin")
Expand Down
1 change: 1 addition & 0 deletions docs/module/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import typer

cli = typer.Typer()


@cli.command()
def hello():
print("Hello from my app!")
Expand Down
32 changes: 26 additions & 6 deletions docs/module/crud.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ UserCrud = CrudFactory(model=User)
from fastapi_toolsets.crud.factory import AsyncCrud
from myapp.models import User


class UserCrud(AsyncCrud[User]):
model = User
searchable_fields = [User.username, User.email]
Expand Down Expand Up @@ -61,6 +62,7 @@ from fastapi_toolsets.crud.factory import AsyncCrud

T = TypeVar("T", bound=DeclarativeBase)


class AuditedCrud(AsyncCrud[T], Generic[T]):
"""Base CRUD with custom function"""

Expand Down Expand Up @@ -101,7 +103,9 @@ user = await UserCrud.first(session=session, filters=[User.email == email])
users = await UserCrud.get_multi(session=session, filters=[User.is_active == True])

# Update
user = await UserCrud.update(session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id])
user = await UserCrud.update(
session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id]
)

# Delete
await UserCrud.delete(session=session, filters=[User.id == user_id])
Expand Down Expand Up @@ -160,10 +164,14 @@ user = await UserCrud.get(session, [User.id == user_id], with_for_update=True)
user = await UserCrud.get(session, [User.id == user_id], with_for_update="nowait")

# Skip rows already locked by another transaction (e.g. job queues)
rows = await JobCrud.get_multi(session, filters=[Job.status == "pending"], with_for_update="skip_locked")
rows = await JobCrud.get_multi(
session, filters=[Job.status == "pending"], with_for_update="skip_locked"
)

# Lock atomically as part of update (prevents race between SELECT and UPDATE)
user = await UserCrud.update(session, UserUpdate(credits=10), [User.id == user_id], with_for_update=True)
user = await UserCrud.update(
session, UserUpdate(credits=10), [User.id == user_id], with_for_update=True
)
```

!!! warning
Expand Down Expand Up @@ -193,6 +201,7 @@ Three pagination methods are available. All return a typed response whose `pagi
from typing import Annotated
from fastapi import Depends


@router.get("")
async def get_users(
session: SessionDep,
Expand Down Expand Up @@ -228,7 +237,9 @@ By default `offset_paginate` runs two queries: one for the page items and one `C
@router.get("")
async def get_users(
session: SessionDep,
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_total=False))],
params: Annotated[
dict, Depends(UserCrud.offset_paginate_params(include_total=False))
],
) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
```
Expand Down Expand Up @@ -303,6 +314,7 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
```python
from fastapi_toolsets.schemas import PaginatedResponse


@router.get("")
async def list_users(
session: SessionDep,
Expand Down Expand Up @@ -446,6 +458,7 @@ from typing import Annotated

from fastapi import Depends


@router.get("", response_model_exclude_none=True)
async def list_users(
session: SessionDep,
Expand Down Expand Up @@ -540,6 +553,7 @@ from typing import Annotated

from fastapi import Depends


@router.get("")
async def list_users(
session: SessionDep,
Expand Down Expand Up @@ -632,7 +646,9 @@ PostCrud = CrudFactory(
m2m_fields={"tag_ids": Post.tags},
)

post = await PostCrud.create(session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3]))
post = await PostCrud.create(
session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3])
)
```

## Upsert
Expand All @@ -659,6 +675,7 @@ class UserRead(PydanticBase):
id: UUID
username: str


@router.get(
"/{uuid}",
responses=generate_error_responses(NotFoundError),
Expand All @@ -670,12 +687,15 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
schema=UserRead,
)


@router.get("")
async def list_users(
session: SessionDep,
params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
) -> OffsetPaginatedResponse[UserRead]:
return await crud.UserCrud.offset_paginate(session=session, **params, schema=UserRead)
return await crud.UserCrud.offset_paginate(
session=session, **params, schema=UserRead
)
```

The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
Expand Down
12 changes: 9 additions & 3 deletions docs/module/db.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
app = FastAPI()
db.install(app) # commit middleware + engine disposal on shutdown


@app.get("/users")
async def list_users(session: AsyncSession = Depends(db)):
...
async def list_users(session: AsyncSession = Depends(db)): ...
```

The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it).
Expand All @@ -37,9 +37,11 @@ The **URL** may be a plain string or a Pydantic [`PostgresDsn`](https://docs.pyd
from pydantic_settings import BaseSettings
from pydantic import PostgresDsn


class Settings(BaseSettings):
database_url: PostgresDsn


settings = Settings()

db = Database(
Expand Down Expand Up @@ -72,12 +74,14 @@ Without `install`, the session commits in the dependency teardown, which runs af
```python
from contextlib import asynccontextmanager


@asynccontextmanager
async def lifespan(app):
await warm_cache() # your startup
await warm_cache() # your startup
yield
await flush_metrics() # your shutdown


app = FastAPI(lifespan=lifespan)
db.install(app) # your shutdown runs first, then the engine is disposed
```
Expand All @@ -101,6 +105,7 @@ async def seed():
```python
from fastapi_toolsets.db import transaction


async def create_user_with_role(session):
async with transaction(session):
...
Expand Down Expand Up @@ -207,6 +212,7 @@ For test isolation with automatic cleanup, use [`create_worker_database`](../ref
```python
from fastapi_toolsets.db.testing import cleanup_tables


@pytest.fixture(autouse=True)
async def clean(db_session):
yield
Expand Down
10 changes: 8 additions & 2 deletions docs/module/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ UserDep = PathDependency(model=User, field=User.id, session_dep=get_db)
SessionDep = Annotated[AsyncSession, Depends(get_db)]
UserDep = PathDependency(model=User, field=User.id, session_dep=SessionDep)


@router.get("/users/{user_id}")
async def get_user(user: User = UserDep):
return user
Expand All @@ -30,6 +31,7 @@ By default the parameter name is inferred from the field (`user_id` for `User.id
```python
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db, param_name="id")


@router.get("/users/{id}")
async def get_user(user: User = UserDep):
return user
Expand All @@ -43,11 +45,15 @@ async def get_user(user: User = UserDep):
from fastapi_toolsets.dependencies import BodyDependency

# Plain callable
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=get_db, body_field="role_id")
RoleDep = BodyDependency(
model=Role, field=Role.id, session_dep=get_db, body_field="role_id"
)

# Annotated
SessionDep = Annotated[AsyncSession, Depends(get_db)]
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id")
RoleDep = BodyDependency(
model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id"
)


@router.post("/users")
Expand Down
Loading
Loading