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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
- name: 🔧 setup uv
uses: ./.github/uv
- name: 🧪 pytest
run: uv run pytest --cov fastapi_async_sqla --cov-report=term-missing --cov-report=xml
run: uv run pytest --cov fastsqla --cov-report=term-missing --cov-report=xml
- name: "🐔 codecov: upload test coverage"
uses: codecov/codecov-action@v4.2.0
env:
Expand Down
221 changes: 176 additions & 45 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,87 +1,218 @@
# FastAPI-Async-SQLA
# 🚀 FastSQLA

[![PyPI - Version](https://img.shields.io/pypi/v/FastAPI-Async-SQLA?color=brightgreen)](https://pypi.org/project/FastAPI-Async-SQLA/)
[![PyPI - Version](https://img.shields.io/pypi/v/FastSQLA?color=brightgreen)](https://pypi.org/project/FastSQLA/)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-brightgreen.svg)](https://conventionalcommits.org)
[![codecov](https://codecov.io/gh/hadrien/fastapi-async-sqla/graph/badge.svg?token=XK3YT60MWK)](https://codecov.io/gh/hadrien/fastapi-async-sqla)
[![codecov](https://codecov.io/gh/hadrien/fastsqla/graph/badge.svg?token=XK3YT60MWK)](https://codecov.io/gh/hadrien/fastsqla)

FastAPI-Async-SQLA is an [SQLAlchemy] extension for [FastAPI]. It supports asynchronous
SQLAlchemy sessions using SQLAlchemy >= 2.0 and provides pagination support.
`FastSQLA` is an [`SQLAlchemy`] extension for [`FastAPI`].
It supports asynchronous `SQLAlchemy` sessions and includes built-in custimizable
pagination.

# Installing
## Features

Using [pip](https://pip.pypa.io/):
<details>
<summary>Automatic SQLAlchemy configuration at app startup.</summary>

Using [`FastAPI` Lifespan](https://fastapi.tiangolo.com/advanced/events/#lifespan):
```python
from fastapi import FastAPI
from fastsqla import lifespan

app = FastAPI(lifespan=lifespan)
```
</details>
<details>
<summary>Async SQLAlchemy session as a FastAPI dependency.</summary>

```python
...
from fastsqla import Session
from sqlalchemy import select
...

@app.get("/heros")
async def get_heros(session:Session):
stmt = select(...)
result = await session.execute(stmt)
...
```
</details>
<details>
<summary>Built-in pagination.</summary>

```python
...
from fastsqla import Page, Paginate
from sqlalchemy import select
...

@app.get("/heros", response_model=Page[HeroModel])
async def get_heros(paginate:Paginate):
return paginate(select(Hero))
```
</details>
<details>
<summary>Allows pagination customization.</summary>

```python
...
from fastapi import new_pagination
...

Paginate = new_pagination(min_page_size=5, max_page_size=500)

@app.get("/heros", response_model=Page[HeroModel])
async def get_heros(paginate:Paginate):
return paginate(select(Hero))
```
pip install fastapi-async-sqla
</details>

And more ...
<!-- <details><summary></summary></details> -->

## Installing

Using [uv](https://docs.astral.sh/uv/):
```bash
uv add fastsqla
```

# Quick Example
Using [pip](https://pip.pypa.io/):
```
pip install fastsqla
```

Assuming it runs against a DB with a table `user` with 2 columns `id` and `name`:
## Quick Example

```python
# main.py
# example.py
from http import HTTPStatus

from fastapi import FastAPI, HTTPException
from fastapi_async_sqla import Base, Item, Page, Paginate, Session, lifespan
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Mapped, mapped_column

from fastsqla import Base, Item, Page, Paginate, Session, lifespan

app = FastAPI(lifespan=lifespan)


class User(Base):
__tablename__ = "user"
class Hero(Base):
__tablename__ = "hero"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True)
secret_identity: Mapped[str]


class UserIn(BaseModel):
class HeroBase(BaseModel):
name: str
secret_identity: str


class UserModel(UserIn):
class HeroModel(HeroBase):
model_config = ConfigDict(from_attributes=True)
id: int


@app.get("/users", response_model=Page[UserModel])
@app.get("/heros", response_model=Page[HeroModel])
async def list_users(paginate: Paginate):
return await paginate(select(User))


@app.get("/users/{user_id}", response_model=Item[UserModel])
async def get_user(user_id: int, session: Session):
user = await session.get(User, user_id)
if user is None:
raise HTTPException(404)
return {"data": user}
return await paginate(select(Hero))


@app.get("/heros/{hero_id}", response_model=Item[HeroModel])
async def get_user(hero_id: int, session: Session):
hero = await session.get(Hero, hero_id)
if hero is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Hero not found")
return {"data": hero}


@app.post("/heros", response_model=Item[HeroModel])
async def create_user(new_hero: HeroBase, session: Session):
hero = Hero(**new_hero.model_dump())
session.add(hero)
try:
await session.flush()
except IntegrityError:
raise HTTPException(HTTPStatus.CONFLICT, "Duplicate hero name")
return {"data": hero}
```

> [!NOTE]
> Sqlite is used for the sake of the example.
> FastSQLA is compatible with all async db drivers that SQLAlchemy is compatible with.

@app.post("/users", response_model=Item[UserModel])
async def create_user(new_user: UserIn, session: Session):
user = User(**new_user.model_dump())
session.add(user)
await session.flush()
return {"data": user}
```
<details>
<summary>Create an <code>sqlite3</code> db:</summary>

Creating a db using `sqlite3`:
```bash
sqlite3 db.sqlite <<EOF
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
CREATE TABLE hero (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, -- Hero name (e.g., Superman)
secret_identity TEXT NOT NULL -- Secret identity (e.g., Clark Kent)
);

-- Insert heroes with hero name and secret identity
INSERT INTO hero (name, secret_identity) VALUES ('Superman', 'Clark Kent');
INSERT INTO hero (name, secret_identity) VALUES ('Batman', 'Bruce Wayne');
INSERT INTO hero (name, secret_identity) VALUES ('Wonder Woman', 'Diana Prince');
INSERT INTO hero (name, secret_identity) VALUES ('Iron Man', 'Tony Stark');
INSERT INTO hero (name, secret_identity) VALUES ('Spider-Man', 'Peter Parker');
INSERT INTO hero (name, secret_identity) VALUES ('Captain America', 'Steve Rogers');
INSERT INTO hero (name, secret_identity) VALUES ('Black Widow', 'Natasha Romanoff');
INSERT INTO hero (name, secret_identity) VALUES ('Thor', 'Thor Odinson');
INSERT INTO hero (name, secret_identity) VALUES ('Scarlet Witch', 'Wanda Maximoff');
INSERT INTO hero (name, secret_identity) VALUES ('Doctor Strange', 'Stephen Strange');
INSERT INTO hero (name, secret_identity) VALUES ('The Flash', 'Barry Allen');
INSERT INTO hero (name, secret_identity) VALUES ('Green Lantern', 'Hal Jordan');
EOF
```

Installing [aiosqlite] to connect to the sqlite db asynchronously:
</details>

<details>
<summary>Install dependencies & run the app</summary>

```bash
pip install aiosqlite
pip install uvicorn aiosqlite fastsqla
sqlalchemy_url=sqlite+aiosqlite:///db.sqlite?check_same_thread=false uvicorn example:app
```

Running the app:
</details>

Execute `GET /heros?offset=10`:

```bash
sqlalchemy_url=sqlite+aiosqlite:///db.sqlite?check_same_thread=false uvicorn main:app
curl -X 'GET' \
'http://127.0.0.1:8000/heros?offset=10&limit=10' \
-H 'accept: application/json'
```
Returns:
```json
{
"data": [
{
"name": "The Flash",
"secret_identity": "Barry Allen",
"id": 11
},
{
"name": "Green Lantern",
"secret_identity": "Hal Jordan",
"id": 12
}
],
"meta": {
"offset": 10,
"total_items": 12,
"total_pages": 2,
"page_number": 2
}
}
```

[aiosqlite]: https://github.com/omnilib/aiosqlite
[FastAPI]: https://fastapi.tiangolo.com/
[SQLAlchemy]: http://sqlalchemy.org/
[`FastAPI`]: https://fastapi.tiangolo.com/
[`SQLAlchemy`]: http://sqlalchemy.org/
20 changes: 8 additions & 12 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "FastAPI-Async-SQLA"
name = "FastSQLA"
version = "0.2.3"
description = "SQLAlchemy extension for FastAPI with support for asynchronous SQLAlchemy sessions and pagination."
description = "SQLAlchemy extension for FastAPI that supports asynchronous sessions and includes built-in pagination."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "Hadrien David", email = "bonjour@hadriendavid.com" }]
Expand Down Expand Up @@ -33,18 +33,14 @@ classifiers = [
]
keywords = ["FastAPI", "SQLAlchemy", "AsyncIO"]
license = { text = "MIT License" }
dependencies = [
"fastapi>=0.115.6",
"sqlalchemy[asyncio]>=2.0.34,<3",
"structlog>=24.4.0",
]
dependencies = ["fastapi>=0.115.6", "sqlalchemy[asyncio]>=2.0.37", "structlog>=24.4.0"]

[project.urls]
Homepage = "https://github.com/hadrien/fastapi-async-sqla"
Documentation = "https://github.com/hadrien/fastapi-async-sqla"
Repository = "https://github.com/hadrien/fastapi-async-sqla"
Issues = "https://github.com/hadrien/fastapi-async-sqla/issues"
Changelog = "https://github.com/hadrien/fastapi-async-sqla/releases"
Homepage = "https://github.com/hadrien/fastsqla"
Documentation = "https://github.com/hadrien/fastsqla"
Repository = "https://github.com/hadrien/fastsqla"
Issues = "https://github.com/hadrien/fastsqla/issues"
Changelog = "https://github.com/hadrien/fastsqla/releases"

[tool.uv]
package = true
Expand Down
6 changes: 3 additions & 3 deletions src/fastapi_async_sqla.py → src/fastsqla.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class Base(DeclarativeBase, DeferredReflection):


class State(TypedDict):
fastapi_async_sqla_engine: AsyncEngine
fastsqla_engine: AsyncEngine


@asynccontextmanager
Expand All @@ -60,7 +60,7 @@ async def lifespan(_) -> AsyncGenerator[State, None]:

await logger.ainfo("Configured SQLAlchemy.")

yield {"fastapi_async_sqla_engine": engine}
yield {"fastsqla_engine": engine}

SessionFactory.configure(bind=None)
await engine.dispose()
Expand Down Expand Up @@ -120,7 +120,7 @@ class Collection(BaseModel, Generic[T]):
data: list[T]


class Page(Collection):
class Page(Collection[T]):
meta: Meta


Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ async def session(engine):
def tear_down():
from sqlalchemy.orm import clear_mappers

from fastapi_async_sqla import Base
from fastsqla import Base

yield

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

@fixture
def app(environ):
from fastapi_async_sqla import lifespan
from fastsqla import lifespan

app = FastAPI(lifespan=lifespan)
return app
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ async def setup_tear_down(engine):


async def test_lifespan_reflects_user_table(environ):
from fastapi_async_sqla import Base, lifespan
from fastsqla import Base, lifespan

class User(Base):
__tablename__ = "user"
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async def setup_tear_down(engine, faker):

@fixture
def app(app):
from fastapi_async_sqla import (
from fastsqla import (
Base,
Page,
Paginate,
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_session_dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async def setup_tear_down(engine):

@fixture
def app(setup_tear_down, app):
from fastapi_async_sqla import Base, Item, Session
from fastsqla import Base, Item, Session

class User(Base):
__tablename__ = "user"
Expand Down
Loading
Loading