Skip to content

Commit 0cbcb30

Browse files
committed
Added database ORM support (Async SQLAlchemy)
* Updated .env.example file with sample config * Added database setup, session and dependency functions * Added utils file for common utility functions * Added User management resource to demonstrate database queries
1 parent 274aa08 commit 0cbcb30

File tree

17 files changed

+377
-4
lines changed

17 files changed

+377
-4
lines changed

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
SECRET_KEY = "SecretSecretSecret!"
22
UVICORN_PORT = 8000
3+
4+
DATABASE_URL = "sqlite+aiosqlite:///dev/app.db"
5+
ECHO_SQL = false

sample_fastapi/app/app_factory.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from fastapi import FastAPI
66
from tqdm.contrib.logging import logging_redirect_tqdm
77

8-
from . import middleware, resources
8+
from . import database, middleware, resources
99

1010
LOG = logging.getLogger(__name__)
1111

@@ -18,16 +18,22 @@ async def app_lifespan_handler(app: FastAPI):
1818
"""
1919

2020
async with AsyncExitStack() as stack:
21-
LOG.info('Adding lifespan handler for "resources"...')
21+
LOG.info('Executing lifespan handler for "resources"...')
2222
await stack.enter_async_context(resources.lifespan(app))
23-
LOG.info('Adding lifespan handler for "tqdm_logging"...')
23+
LOG.info('Executing lifespan handler for "database"...')
24+
await stack.enter_async_context(database.lifespan(app))
25+
LOG.info('Executing lifespan handler for "tqdm_logging"...')
2426
stack.enter_context(logging_redirect_tqdm())
2527
yield
2628

2729

2830
def init_app():
31+
db_settings = database.get_db_settings()
32+
2933
app = FastAPI(lifespan=app_lifespan_handler)
34+
3035
resources.init_app(app)
3136
middleware.init_middleware(app)
37+
database.init_app_db(app, db_settings)
3238

3339
return app
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from .base import BaseDBModel
2+
from .config import DBSettings
3+
from .depends import get_app_db_session_manager, get_db_session, get_db_settings
4+
from .engine import init_app_db, lifespan
5+
from .session import AsyncSession
6+
7+
__all__ = [
8+
"DBSettings",
9+
"get_db_settings",
10+
"get_app_db_session_manager",
11+
"get_db_session",
12+
"lifespan",
13+
"init_app_db",
14+
"BaseDBModel",
15+
"AsyncSession",
16+
]

sample_fastapi/app/database/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from sqlalchemy.orm import DeclarativeBase
2+
3+
4+
class BaseDBModel(DeclarativeBase):
5+
pass

sample_fastapi/app/database/config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import logging
2+
3+
from pydantic import Field
4+
5+
from ..config import BaseAppSettings
6+
7+
LOG = logging.getLogger(__name__)
8+
9+
APP_DB_SESSION_MANAGER_KEY = "_dbsm" # Lol
10+
11+
12+
def _default_db_dsn():
13+
LOG.warning("Using SQLite In-memory database as `database_url` config was not specified. Content WILL be lost after shutting down the app!")
14+
return "sqlite+aiosqlite:///:memory:"
15+
16+
17+
class DBSettings(BaseAppSettings):
18+
database_url: str = Field(default_factory=_default_db_dsn)
19+
echo_sql: bool = Field(default=True)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from functools import lru_cache
2+
from typing import Annotated
3+
4+
from fastapi import Depends, Request
5+
6+
from .config import APP_DB_SESSION_MANAGER_KEY, DBSettings
7+
from .session import AsyncDatabaseSessionManager
8+
9+
10+
@lru_cache()
11+
def get_db_settings():
12+
return DBSettings()
13+
14+
15+
def get_app_db_session_manager(req: Request) -> AsyncDatabaseSessionManager:
16+
sessionmanager: AsyncDatabaseSessionManager | None = req.app.extra.get(APP_DB_SESSION_MANAGER_KEY)
17+
if sessionmanager is None:
18+
raise RuntimeError(
19+
"Couldn't get the app's DB session manager - DB Session Manager with key `%s` was not initialized"
20+
% APP_DB_SESSION_MANAGER_KEY
21+
)
22+
return sessionmanager
23+
24+
25+
async def get_db_session(sessionmanager: Annotated[AsyncDatabaseSessionManager, Depends(get_app_db_session_manager)]):
26+
"""Dependency to get a session object. Use this in request handlers to get a database session."""
27+
28+
async with sessionmanager.session() as session:
29+
yield session

sample_fastapi/app/database/engine.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import logging
2+
from contextlib import asynccontextmanager
3+
from typing import Annotated
4+
5+
from fastapi import FastAPI
6+
7+
from .. import utils
8+
from .config import APP_DB_SESSION_MANAGER_KEY, DBSettings
9+
from .session import AsyncDatabaseSessionManager
10+
11+
LOG = logging.getLogger(__name__)
12+
13+
14+
async def create_schema_models(sessionmanager: AsyncDatabaseSessionManager):
15+
"""Create schema tables from models under `BaseDBModel`."""
16+
async with sessionmanager._engine.connect() as connection:
17+
from .base import BaseDBModel
18+
19+
LOG.info("Creating schema (if not already exists)...")
20+
LOG.info("DB models registered: [%s]", ",".join(map(utils.str_quote, BaseDBModel.metadata.tables.keys())))
21+
22+
await connection.run_sync(BaseDBModel.metadata.create_all)
23+
await connection.commit()
24+
25+
26+
@asynccontextmanager
27+
async def lifespan(app: FastAPI):
28+
"""Session manager, manage database connections for this app"""
29+
sessionmanager: AsyncDatabaseSessionManager = app.extra[APP_DB_SESSION_MANAGER_KEY]
30+
async with sessionmanager:
31+
await create_schema_models(sessionmanager)
32+
yield
33+
34+
35+
def init_app_db(app: FastAPI, db_settings: Annotated[DBSettings, DBSettings()]):
36+
"""Initialize database session manager and bind it with the FastAPI application"""
37+
app.extra[APP_DB_SESSION_MANAGER_KEY] = AsyncDatabaseSessionManager(
38+
db_settings.database_url,
39+
{
40+
"echo": db_settings.echo_sql,
41+
},
42+
)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import contextlib
2+
import logging
3+
from typing import Any, AsyncIterator
4+
5+
from sqlalchemy.ext.asyncio import (
6+
AsyncConnection,
7+
AsyncEngine,
8+
AsyncSession,
9+
async_sessionmaker,
10+
create_async_engine,
11+
)
12+
13+
__all__ = [
14+
"AsyncConnection",
15+
"AsyncSession",
16+
"AsyncDatabaseSessionManager",
17+
]
18+
19+
20+
LOG = logging.getLogger(__name__)
21+
22+
23+
# Heavily inspired by https://praciano.com.br/fastapi-and-async-sqlalchemy-20-with-pytest-done-right.html
24+
25+
26+
class AsyncDatabaseSessionManager:
27+
def __init__(self, host: str, engine_kwargs: dict[str, Any] = {}, session_kwargs: dict[str, Any] = {}):
28+
session_kwargs.setdefault("autocommit", False)
29+
session_kwargs.setdefault("expire_on_commit", False)
30+
31+
self._engine: AsyncEngine = create_async_engine(host, **engine_kwargs)
32+
self._sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(bind=self._engine, **session_kwargs)
33+
34+
async def close(self):
35+
await self._engine.dispose()
36+
37+
async def __aenter__(self):
38+
return self
39+
40+
async def __aexit__(self, exc_type, exc_val, exc_tb):
41+
await self.close()
42+
43+
@contextlib.asynccontextmanager
44+
async def connect(self) -> AsyncIterator[AsyncConnection]:
45+
async with self._engine.begin() as connection:
46+
try:
47+
yield connection
48+
except Exception:
49+
LOG.exception("DB connection was rolled back due to an exception:")
50+
await connection.rollback()
51+
raise
52+
53+
@contextlib.asynccontextmanager
54+
async def session(self) -> AsyncIterator[AsyncSession]:
55+
async with self._sessionmaker() as sess:
56+
yield sess
57+
# try:
58+
# except Exception:
59+
# LOG.exception("DB transaction was rolled back due to an exception:")
60+
# # TODO: Not sure if this is needed. Test if it does auto-rollback
61+
# await sess.rollback()
62+
# raise

sample_fastapi/app/resources/routes.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
def init_app(app: FastAPI):
55
"""Initialize routes, middleware, sub-apps, etc. to the given Application"""
6-
from . import calculator, hello
6+
from . import calculator, hello, user
77
hello.init_app(app)
88
calculator.init_app(app)
9+
user.init_app(app)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .routes import init_app, init_routes
2+
3+
__all__ = ["init_app", "init_routes"]

0 commit comments

Comments
 (0)