Package: fastapi-sqla
Fastapi-SQLAFastapi-SQLA is anSQLAlchemyextension forFastAPIeasy to setup with support for
pagination, asyncio,SQLModelandpytest.
It supports SQLAlchemy>=1.3 and is fully compliant withSQLAlchemy 2.0.
It is developped, maintained and used on production by the team at@dialoguemdwith
love from Montreal 🇨🇦.InstallingUsingpip:pip install fastapi-sqlaQuick ExampleAssuming it runs against a DB with a tableuserwith 3 columns,id,nameand
uniqueemail:# main.pyfromfastapiimportFastAPI,HTTPExceptionfromfastapi_sqlaimportBase,Item,Page,Paginate,Session,setupfrompydanticimportBaseModel,EmailStrfromsqlalchemyimportselectfromsqlalchemy.excimportIntegrityErrorapp=FastAPI()setup(app)classUser(Base):__tablename__="user"classUserIn(BaseModel):name:stremail:EmailStrclassUserModel(UserIn):id:intclassConfig:orm_mode=True@app.get("/users",response_model=Page[UserModel])deflist_users(paginate:Paginate):returnpaginate(select(User))@app.get("/users/{user_id}",response_model=Item[UserModel])defget_user(user_id:int,session:Session):user=session.get(User,user_id)ifuserisNone:raiseHTTPException(404)return{"data":user}@app.post("/users",response_model=Item[UserModel])defcreate_user(new_user:UserIn,session:Session):user=User(**new_user.model_dump())session.add(user)try:session.flush()exceptIntegrityError:raiseHTTPException(409,"Email is already taken.")return{"data":user}Creating a db usingsqlite3:sqlite3db.sqlite<<EOFCREATE TABLE user (id    INTEGER PRIMARY KEY AUTOINCREMENT,email TEXT NOT NULL,name  TEXT NOT NULL);CREATE UNIQUE INDEX user_email_idx ON user (email);EOFRunning the app:sqlalchemy_url=sqlite:///db.sqlite?check_same_thread=falseuvicornmain:appConfigurationEnvironment variables:The keys of interest inos.environare prefixed withsqlalchemy_.
Each matching key (after the prefix is stripped) is treated as though it were the
corresponding keyword argument tosqlalchemy.create_enginecall.The only required key issqlalchemy_url, which provides the database URL, example:exportsqlalchemy_url=postgresql://postgres@localhostMulti-session supportIn order to configure multiple sessions for the application,
set the environment variables with this prefix format:fastapi_sqla__MY_KEY__.As with the default session, each matching key (after the prefix is stripped)
is treated as though it were the corresponding keyword argument tosqlalchemy.create_enginecall.For example, to configure a session with theread_onlykey:exportfastapi_sqla__read_only__sqlalchemy_url=postgresql://postgres@localhostasynciosupport usingasyncpgSQLAlchemy>= 1.4supportsasyncio.
To enableasynciosupport against a Postgres DB, installasyncpg:pipinstallasyncpgAnd define the environment variablesqlalchemy_urlwithpostgres+asyncpgscheme:exportsqlalchemy_url=postgresql+asyncpg://postgres@localhostSetup the app:importfastapi_sqlafromfastapiimportFastAPIapp=FastAPI()fastapi_sqla.setup(app)SQLAlchemyAdding a new entity class:fromfastapi_sqlaimportBaseclassEntity(Base):__tablename__="table-name-in-db"Getting an sqla sessionUsing dependency injectionUseFastAPI dependency injectionto get a session as a parameter of a path operation
function.The SQLAlchemy session is committed before the response is returned or rollbacked if any
exception occurred:fromfastapiimportAPIRouterfromfastapi_sqlaimportAsyncSession,Sessionrouter=APIRouter()@router.get("/example")defexample(session:Session):returnsession.execute("SELECT now()").scalar()@router.get("/async_example")asyncdefasync_example(session:AsyncSession):returnawaitsession.scalar("SELECT now()")In order to get a session configured with a custom key:fromtypingimportAnnotatedfromfastapiimportAPIRouter,Dependsfromfastapi_sqlaimport(AsyncSessionDependency,SessionDependency,SqlaAsyncSession,SqlaSession,)router=APIRouter()# PreferredReadOnlySession=Annotated[SqlaSession,Depends(SessionDependency(key="read_only"))]AsyncReadOnlySession=Annotated[SqlaAsyncSession,Depends(AsyncSessionDependency(key="read_only"))]@router.get("/example")defexample(session:ReadOnlySession):returnsession.execute("SELECT now()").scalar()@router.get("/async_example")asyncdefasync_example(session:AsyncReadOnlySession):returnawaitsession.scalar("SELECT now()")# Alternative@router.get("/example/alt")defexample_alt(session:SqlaSession=Depends(SessionDependency(key="read_only"))):returnsession.execute("SELECT now()").scalar()@router.get("/async_example/alt")asyncdefasync_example_alt(session:SqlaAsyncSession=Depends(AsyncSessionDependency(key="read_only")),):returnawaitsession.scalar("SELECT now()")Using a context managerWhen needing a session outside of a path operation, like when usingFastAPI background tasks, usefastapi_sqla.open_sessioncontext manager.
The SQLAlchemy session is committed when exiting context or rollbacked if any exception
occurred:fromfastapiimportAPIRouter,BackgroundTasksfromfastapi_sqlaimportopen_async_session,open_sessionrouter=APIRouter()@router.get("/example")defexample(bg:BackgroundTasks):bg.add_task(run_bg)bg.add_task(run_async_bg)defrun_bg():withopen_session()assession:session.execute("SELECT now()").scalar()defrun_bg_with_key():withopen_session(key="read_only")assession:session.execute("SELECT now()").scalar()asyncdefrun_async_bg():asyncwithopen_async_session()assession:awaitsession.scalar("SELECT now()")asyncdefrun_async_bg_with_key():asyncwithopen_async_session(key="read_only")assession:awaitsession.scalar("SELECT now()")PaginationfromfastapiimportAPIRouterfromfastapi_sqlaimportBase,Page,PaginatefrompydanticimportBaseModelfromsqlalchemyimportselectrouter=APIRouter()classUser(Base):__tablename__="user"classUserModel(BaseModel):id:intname:strclassConfig:orm_mode=True@router.get("/users",response_model=Page[UserModel])defall_users(paginate:Paginate):returnpaginate(select(User))By default:It returns pages of 10 items, up to 100 items;Total number of items in the collection is queried usingQuery.count.Response example for/users?offset=40&limit=10:{"data":[{"id":41,"name":"Pat Thomas"},{"id":42,"name":"Mulatu Astatke"}],"meta":{"offset":40,"total_items":42,"total_pages":5,"page_number":5}}Paginating non-scalar resultsTo paginate a query which doesn't returnscalars, specifyscalars=Falsewhen invokingpaginate:fromfastapiimportAPIRouterfromfastapi_sqlaimportBase,Page,PaginatefrompydanticimportBaseModelfromsqlalchemyimportfunc,selectfromsqlalchemy.ormimportrelationshiprouter=APIRouter()classUser(Base):__tablename__="user"notes=relationship("Note")classNote(Base):__tablename__="note"classUserModel(BaseModel):id:intname:strnotes_count:int@router.get("/users",response_model=Page[UserModel])defall_users(paginate:Paginate):query=(select(User.id,User.name,func.count(Note.id).label("notes_count")).join(Note).group_by(User))returnpaginate(query,scalars=False)Customize paginationYou can customize:Minimum and maximum number of items per pages;How the total number of items in the collection is queried;To customize pagination, create a dependency usingfastapi_sqla.Pagination:fromfastapiimportAPIRouter,Dependsfromfastapi_sqlaimportBase,Page,Pagination,SessionfrompydanticimportBaseModelfromsqlalchemyimportfunc,selectrouter=APIRouter()classUser(Base):__tablename__="user"classUserModel(BaseModel):id:intname:strdefquery_count(session:Session)->int:returnsession.execute(select(func.count()).select_from(User)).scalar()CustomPaginate=Pagination(min_page_size=5,max_page_size=500,query_count=query_count)@router.get("/users",response_model=Page[UserModel])defall_users(paginate:CustomPaginate=Depends()):returnpaginate(select(User))Async paginationWhen using the asyncio support, use theAsyncPaginatedependency:fromfastapiimportAPIRouterfromfastapi_sqlaimportBase,Page,AsyncPaginatefrompydanticimportBaseModelfromsqlalchemyimportselectrouter=APIRouter()classUser(Base):__tablename__="user"classUserModel(BaseModel):id:intname:strclassConfig:orm_mode=True@router.get("/users",response_model=Page[UserModel])asyncdefall_users(paginate:AsyncPaginate):returnawaitpaginate(select(User))Customize pagination by creating a dependency usingfastapi_sqla.AsyncPagination:fromfastapiimportAPIRouter,Dependsfromfastapi_sqlaimportBase,Page,AsyncPagination,AsyncSessionfrompydanticimportBaseModelfromsqlalchemyimportfunc,selectrouter=APIRouter()classUser(Base):__tablename__="user"classUserModel(BaseModel):id:intname:strasyncdefquery_count(session:AsyncSession)->int:result=awaitsession.execute(select(func.count()).select_from(User))returnresult.scalar()CustomPaginate=AsyncPagination(min_page_size=5,max_page_size=500,query_count=query_count)@router.get("/users",response_model=Page[UserModel])asyncdefall_users(paginate:CustomPaginate=Depends()):returnawaitpaginate(select(User))Multi-session supportPagination supports multiple sessions as well. To paginate using a session
configured with a custom key:fromtypingimportAnnotatedfromfastapiimportAPIRouter,Dependsfromfastapi_sqlaimport(AsyncPaginateSignature,AsyncPagination,Base,Page,PaginateSignature,Pagination,)frompydanticimportBaseModelfromsqlalchemyimportfunc,selectrouter=APIRouter()classUser(Base):__tablename__="user"classUserModel(BaseModel):id:intname:str# PreferredReadOnlyPaginate=Annotated[PaginateSignature,Depends(Pagination(session_key="read_only"))]AsyncReadOnlyPaginate=Annotated[AsyncPaginateSignature,Depends(AsyncPagination(session_key="read_only"))]@router.get("/users",response_model=Page[UserModel])defall_users(paginate:ReadOnlyPaginate):returnpaginate(select(User))@router.get("/async_users",response_model=Page[UserModel])asyncdefasync_all_users(paginate:AsyncReadOnlyPaginate):returnawaitpaginate(select(User))# Alternative@router.get("/users/alt",response_model=Page[UserModel])defall_users_alt(paginate:PaginateSignature=Depends(Pagination(session_key="read_only")),):returnpaginate(select(User))@router.get("/async_users/alt",response_model=Page[UserModel])asyncdefasync_all_users_alt(paginate:AsyncPaginateSignature=Depends(AsyncPagination(session_key="read_only")),):returnawaitpaginate(select(User))SQLModel support 🎉If your project usesSQLModel, thenSessiondependency is an SQLModel session::fromhttpimportHTTPStatusfromfastapiimportFastAPI,HTTPExceptionfromfastapi_sqlaimportItem,Page,Paginate,Session,setupfromsqlmodelimportField,SQLModel,selectclassHero(SQLModel,table=True):id:int|None=Field(default=None,primary_key=True)name:strsecret_name:strage:int|None=Noneapp=FastAPI()setup(app)@app.get("/heros",response_model=Page[Hero])deflist_hero(paginate:Paginate)->Page[Hero]:returnpaginate(select(Hero))@app.get("/heros/{hero_id}",response_model=Item[Hero])defget_hero(hero_id:int,session:Session)->Item[Hero]:hero=session.get(Hero,hero_id)ifheroisNone:raiseHTTPException(HTTPStatus.NOT_FOUND)return{"data":hero}Pytest fixturesThis library provides a set of utility fixtures, through its PyTest plugin, which is
automatically installed with the library.By default, no records are actually written to the database when running tests.
There currently is no way to change this behaviour.sqla_modulesYou must define this fixture, in order for the plugin to reflect table metadata in your
SQLAlchemy entities. It should just import all of the application's modules which contain
SQLAlchemy models.Example:# tests/conftest.pyfrompytestimportfixture@fixturedefsqla_modules():fromappimportsqla# noqadb_urlThe DB url to use.WhenCIkey is set in environment variables, it defaults to usingpostgresas the
host name:postgresql://postgres@postgres/postgresIn other cases, the host is set tolocalhost:postgresql://postgres@localhost/postgresOf course, you can override it by overloading the fixture:frompytestimportfixture@fixture(scope="session")defdb_url():return"postgresql://postgres@localhost/test_database"async_sqlalchemy_urlDB url to use when usingasynciosupport. Defaults todb_urlfixture withpostgresql+asyncpg://scheme.session&async_sessionSqla sessions to create db fixture:All changes done at test setup or during the test are rollbacked at test tear down;No record will actually be written in the database;Changes in one regular session need to be committed to be available from other regular
sessions;Changes in one async session need to be committed to be available from other async
sessions;Changes from regular sessions are not available fromasyncsession and vice-versa
even when committed;Example:frompytestimportfixture@fixturedefpatient(session):fromer.sqlaimportPatientpatient=Patient(first_name="Bob",last_name="David")session.add(patient)session.commit()returnpatient@fixtureasyncdefdoctor(async_session):fromer.sqlaimportDoctordoctor=Doctor(name="who")async_session.add(doctor)awaitasync_session.commit()returndoctordb_migrationA session scope fixture that runsalembic upgradeat test session setup andalembic downgradeat tear down.It depends onalembic_ini_pathfixture to get the path ofalembic.inifile.To use in a test or test module:frompytestimportmarkpytestmark=mark.usefixtures("db_migration")To use globally, add topytest options:[pytest]usefixtures=db_migrationOr depends on it in top-levelconftest.pyand mark it asauto-used:frompytestimportfixture@fixture(scope="session",autouse=True)defdb_migration(db_migration):passalembic_ini_pathIt returns the path ofalembic.iniconfiguration file. By default, it returns./alembic.ini.DevelopmentPrerequisitesPython >=3.9Poetryto install package dependencies.A postgres DB reachable atpostgresql://postgres@localhost/postgresSetup$poetryinstall--extrastests--extrasasyncpg--extrasaws_rds_iamRunning tests$poetryrunpytestRuning tests on multiple environments$poetryruntox