Skip to content

Commit a8cbd45

Browse files
committed
✨ feat: async database driver
1 parent be3acb2 commit a8cbd45

File tree

19 files changed

+439
-89
lines changed

19 files changed

+439
-89
lines changed

.github/workflows/ci.yaml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,72 @@ jobs:
209209
env:
210210
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_CHECK }}
211211
if: always()
212+
213+
test-postgresql:
214+
env:
215+
DB_TYPE: "postgre"
216+
DB_DRIVER: "asyncpg"
217+
DB_HOST: "127.0.0.1"
218+
DB_PORT: 5432
219+
DB_USER: "test"
220+
DB_PASSWORD: "test"
221+
DB_NAME: "test"
222+
DB_TABLE_CREATE: true
223+
services:
224+
postgresql:
225+
image: postgres:17
226+
env:
227+
PGPORT: ${{ env.DB_PORT }}
228+
POSTGRES_USER: ${{ env.DB_USER }}
229+
POSTGERS_PASSWORD: ${{ env.DB_PASSWORD }}
230+
POSTGRES_DB: ${{ env.DB_NAME }}
231+
ports:
232+
- 5432:5432
233+
options: >-
234+
--health-cmd=pg_isready -U "$POSTGRES_USER" -d "dbname=$POSTGRES_DB" -h 127.0.0.1 -p 5432
235+
--health-interval=10s
236+
--health-timeout=5s
237+
--health-retries=3
238+
239+
name: Test PostgreSQL
240+
needs: lint
241+
runs-on: ubuntu-latest
242+
steps:
243+
- name: Checkout repository
244+
uses: actions/checkout@v4
245+
246+
- name: Cache virtualenv
247+
uses: actions/cache@v4
248+
with:
249+
path: .venv
250+
key: ${{ runner.os }}-python-uv-venv-${{ github.sha }}
251+
restore-keys: |
252+
${{ runner.os }}-python-uv-venv-
253+
254+
- name: Run pytest
255+
run: |
256+
source .venv/bin/activate
257+
make test
258+
259+
- name: Upload test results to Codecov
260+
uses: codecov/test-results-action@v1
261+
with:
262+
flags: postgresql
263+
token: ${{ secrets.CODECOV_TOKEN }}
264+
265+
- name: Upload results to Codecov
266+
uses: codecov/codecov-action@v5
267+
with:
268+
flags: postgresql
269+
token: ${{ secrets.CODECOV_TOKEN }}
270+
271+
- name: Slack webhook
272+
uses: 8398a7/action-slack@v3
273+
with:
274+
status: ${{ job.status }}
275+
author_name: fastapi-cookbook
276+
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
277+
if_mention: failure,cancelled
278+
env:
279+
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_CHECK }}
280+
if: always()

Makefile

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,14 @@ prod:
3636

3737
.PHONY: k8s
3838
k8s:
39-
kubectl delete -n fastapi -f k8s/dev.yaml 2> /dev/null || echo "Not deployed to the dev environment! 🌎"
40-
kubectl apply -n fastapi -f k8s/secrets.yaml
41-
kubectl apply -n fastapi -f k8s/dev.yaml
39+
kubectl delete -n fastapi -f k8s/postgresql/dev.yaml 2> /dev/null || echo "Not deployed to the dev environment! 🌎"
40+
kubectl apply -n fastapi -f k8s/postgresql/secrets.yaml
41+
kubectl apply -n fastapi -f k8s/postgresql/dev.yaml
4242

4343
.PHONY: exec
4444
exec:
4545
kubectl exec -it -n fastapi deploy/fastapi-dev -- zsh
4646

4747
.PHONY: expose
4848
expose:
49-
kubectl expose -n fastapi po/mysql-0 --port 3306 --type=NodePort
49+
kubectl expose -n fastapi po/postgresql-0 --port 5432 --type=NodePort

app/api/v1/endpoints/shields.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from fastapi import APIRouter
44

55
from app.schemas.shields import Shields
6-
from app.services.shields import dday
6+
from app.utils.shields import dday
77

88
router = APIRouter(prefix="/shields", tags=["shields.io"])
99

app/api/v1/endpoints/users.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ async def create_user(
2222
user: UserCreateRequest,
2323
service: UserService = Depends(Provide[Container.user_service]),
2424
):
25-
return service.create(user)
25+
return await service.create(user)
2626

2727

2828
@router.get(
@@ -37,7 +37,7 @@ async def get_user(
3737
id: int,
3838
service: UserService = Depends(Provide[Container.user_service]),
3939
):
40-
return service.get_by_id(id)
40+
return await service.get_by_id(id)
4141

4242

4343
@router.put(
@@ -53,7 +53,7 @@ async def put_user(
5353
user: UserCreateRequest,
5454
service: UserService = Depends(Provide[Container.user_service]),
5555
):
56-
return service.put_by_id(id=id, schema=user)
56+
return await service.put_by_id(id=id, schema=user)
5757

5858

5959
@router.patch(
@@ -69,7 +69,7 @@ async def patch_user(
6969
user: UserCreateRequest,
7070
service: UserService = Depends(Provide[Container.user_service]),
7171
):
72-
return service.patch_by_id(id=id, schema=user)
72+
return await service.patch_by_id(id=id, schema=user)
7373

7474

7575
@router.delete(
@@ -84,4 +84,4 @@ async def delete_user(
8484
id: int,
8585
service: UserService = Depends(Provide[Container.user_service]),
8686
):
87-
return service.delete_by_id(id)
87+
return await service.delete_by_id(id)

app/core/configs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ def DB_SCHEME(self) -> str:
3333
@property
3434
def DATABASE_URI(self) -> str:
3535
if self.DB_TYPE == "sqlite" and self.DB_PORT == 0:
36+
if self.DB_DRIVER:
37+
return f"{self.DB_TYPE}+{self.DB_DRIVER}:///{self.DB_NAME}"
3638
return f"{self.DB_TYPE}:///{self.DB_NAME}"
3739
return str(
3840
MultiHostUrl.build(

app/core/database.py

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from contextlib import contextmanager
1+
from contextlib import asynccontextmanager
22
from typing import Any, Generator
33

4-
from sqlalchemy import create_engine
4+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
55
from sqlalchemy.orm import Session, scoped_session, sessionmaker
66

77
from app.core.configs import configs
@@ -10,25 +10,22 @@
1010

1111
class Database:
1212
def __init__(self) -> None:
13-
self.engine = create_engine(configs.DATABASE_URI, echo=configs.DB_ECHO)
14-
self.scoped_session = scoped_session(
15-
sessionmaker(
16-
autocommit=False,
17-
autoflush=False,
18-
bind=self.engine,
19-
),
13+
self.engine = create_async_engine(configs.DATABASE_URI, echo=configs.DB_ECHO)
14+
self.sessionmaker = sessionmaker(
15+
bind=self.engine, class_=AsyncSession, expire_on_commit=False
2016
)
2117

22-
def create_all(self) -> None:
23-
BaseModel.metadata.create_all(self.engine)
18+
async def create_all(self) -> None:
19+
async with self.engine.begin() as conn:
20+
await conn.run_sync(BaseModel.metadata.create_all)
2421

25-
@contextmanager
26-
def session(self) -> Generator[Any, Any, None]:
27-
session: Session = self.scoped_session()
28-
try:
29-
yield session
30-
except Exception:
31-
session.rollback()
32-
raise
33-
finally:
34-
session.close()
22+
@asynccontextmanager
23+
async def session(self) -> Generator[Any, Any, None]:
24+
async with self.sessionmaker() as session:
25+
try:
26+
yield session
27+
except Exception:
28+
await session.rollback()
29+
raise
30+
finally:
31+
await session.close()

app/core/lifespan.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ async def lifespan(app: FastAPI): # pylint: disable=unused-argument
3131
if configs.DB_TABLE_CREATE:
3232
logger.warning("Create database")
3333
database = container.database()
34-
database.create_all()
34+
await database.create_all()
3535

3636
yield

app/repositories/base.py

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from contextlib import AbstractContextManager
22
from typing import Any, Callable, Generic, Type, TypeVar
33

4+
from loguru import logger
5+
from sqlalchemy import update
6+
from sqlalchemy.future import select
47
from sqlalchemy.orm import Session, joinedload
58

69
from app.exceptions.database import EntityNotFound
@@ -18,49 +21,59 @@ def __init__(
1821
self.session = session
1922
self.model = model
2023

21-
def create(self, model: T) -> T:
22-
with self.session() as session:
24+
async def create(self, model: T) -> T:
25+
async with self.session() as session:
2326
session.add(model)
24-
session.commit()
25-
session.refresh(model)
27+
await session.commit()
28+
await session.refresh(model)
2629
return model
2730

28-
def read_by_id(self, id: int, eager: bool = False) -> T:
29-
with self.session() as session:
30-
query = session.query(self.model)
31+
async def read_by_id(self, id: int, eager: bool = False) -> T:
32+
async with self.session() as session:
33+
query = select(self.model)
3134
if eager:
3235
for _eager in getattr(self.model, "eagers"):
3336
query = query.options(joinedload(getattr(self.model, _eager)))
34-
result = query.filter(self.model.id == id).first()
37+
query = query.filter(self.model.id == id)
38+
result = await session.execute(query)
39+
result = result.scalar_one_or_none()
3540
if not result:
3641
raise EntityNotFound
3742
return result
3843

39-
def update_by_id(self, id: int, model: dict) -> T:
40-
with self.session() as session:
41-
session.query(self.model).filter(self.model.id == id).update(model)
42-
session.commit()
43-
result = session.query(self.model).filter(self.model.id == id).first()
44+
async def update_by_id(self, id: int, model: dict) -> T:
45+
async with self.session() as session:
46+
query = select(self.model).filter(self.model.id == id)
47+
result = await session.execute(query)
48+
result = result.scalar_one_or_none()
4449
if not result:
4550
raise EntityNotFound
51+
logger.warning(result.updated_at)
52+
for key, value in model.items():
53+
setattr(result, key, value)
54+
await session.commit()
55+
await session.refresh(result)
56+
logger.warning(result.updated_at)
4657
return result
4758

48-
def update_attr_by_id(self, id: int, column: str, value: Any) -> T:
49-
with self.session() as session:
50-
session.query(self.model).filter(self.model.id == id).update(
51-
{column: value}
52-
)
53-
session.commit()
54-
result = session.query(self.model).filter(self.model.id == id).first()
59+
async def update_attr_by_id(self, id: int, column: str, value: Any) -> T:
60+
async with self.session() as session:
61+
query = select(self.model).filter(self.model.id == id)
62+
result = await session.execute(query)
63+
result = result.scalar_one_or_none()
5564
if not result:
5665
raise EntityNotFound
66+
setattr(result, column, value)
67+
await session.commit()
5768
return result
5869

59-
def delete_by_id(self, id: int) -> T:
60-
with self.session() as session:
61-
query = session.query(self.model).filter(self.model.id == id).first()
62-
if not query:
70+
async def delete_by_id(self, id: int) -> T:
71+
async with self.session() as session:
72+
query = select(self.model).filter(self.model.id == id)
73+
result = await session.execute(query)
74+
result = result.scalar_one_or_none()
75+
if not result:
6376
raise EntityNotFound
64-
session.delete(query)
65-
session.commit()
66-
return query
77+
await session.delete(result)
78+
await session.commit()
79+
return result

app/services/base.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,33 @@ def mapper(self, data: BaseSchemaRequest | T) -> T | BaseSchemaResponse:
2222
return self.repository.model(**data.model_dump())
2323
return BaseSchemaResponse.model_validate(data)
2424

25-
def create(self, schema: BaseSchemaRequest) -> BaseSchemaResponse:
25+
async def create(self, schema: BaseSchemaRequest) -> BaseSchemaResponse:
2626
model = self.mapper(schema)
27-
model = self.repository.create(model=model)
27+
model = await self.repository.create(model=model)
2828
return self.mapper(model)
2929

30-
def get_by_id(self, id: int) -> BaseSchemaResponse:
31-
model = self.repository.read_by_id(id=id)
30+
async def get_by_id(self, id: int) -> BaseSchemaResponse:
31+
model = await self.repository.read_by_id(id=id)
3232
return self.mapper(model)
3333

34-
def put_by_id(self, id: int, schema: BaseSchemaRequest) -> BaseSchemaResponse:
35-
model = self.repository.update_by_id(id=id, model=schema.model_dump())
34+
async def put_by_id(self, id: int, schema: BaseSchemaRequest) -> BaseSchemaResponse:
35+
model = await self.repository.update_by_id(id=id, model=schema.model_dump())
3636
return self.mapper(model)
3737

38-
def patch_by_id(self, id: int, schema: BaseSchemaRequest) -> BaseSchemaResponse:
39-
model = self.repository.update_by_id(
38+
async def patch_by_id(
39+
self, id: int, schema: BaseSchemaRequest
40+
) -> BaseSchemaResponse:
41+
model = await self.repository.update_by_id(
4042
id=id, model=schema.model_dump(exclude_none=True)
4143
)
4244
return self.mapper(model)
4345

44-
def patch_attr_by_id(self, id: int, attr: str, value: Any) -> BaseSchemaResponse:
45-
model = self.repository.update_attr_by_id(id=id, column=attr, value=value)
46+
async def patch_attr_by_id(
47+
self, id: int, attr: str, value: Any
48+
) -> BaseSchemaResponse:
49+
model = await self.repository.update_attr_by_id(id=id, column=attr, value=value)
4650
return self.mapper(model)
4751

48-
def delete_by_id(self, id: int) -> BaseSchemaResponse:
49-
model = self.repository.delete_by_id(id=id)
52+
async def delete_by_id(self, id: int) -> BaseSchemaResponse:
53+
model = await self.repository.delete_by_id(id=id)
5054
return self.mapper(model)
File renamed without changes.

0 commit comments

Comments
 (0)