Skip to content

Commit 9ad28f8

Browse files
committed
docs
1 parent 9dfc8c7 commit 9ad28f8

File tree

1 file changed

+284
-0
lines changed

1 file changed

+284
-0
lines changed

docs/README.md

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
# context-async-sqlalchemy
2+
3+
[![PyPI](https://img.shields.io/pypi/v/context-async-sqlalchemy.svg)](https://pypi.org/project/context-async-sqlalchemy/)
4+
5+
6+
ContextVar + async sqlalchemy = happiness.
7+
8+
A convenient way to configure and interact with async sqlalchemy session
9+
through context in asynchronous applications.
10+
11+
## What does usage look like?
12+
13+
```python
14+
from context_async_sqlalchemy import db_session
15+
from sqlalchemy import insert
16+
17+
from database import master # your configured connection to the database
18+
from models import ExampleTable # just some model for example
19+
20+
async def some_func() -> None:
21+
# Created a session (no connection to the database yet)
22+
# If you call db_session again, it will return the same session
23+
# even in child coroutines.
24+
session = await db_session(master)
25+
26+
stmt = insert(ExampleTable).values(text="example_with_db_session")
27+
28+
# On the first request, a connection and transaction were opened
29+
await session.execute(stmt)
30+
31+
# The commit and closing of the session will occur automatically
32+
```
33+
34+
35+
## How to use
36+
37+
The repository includes an example integration with FastAPI,
38+
which describes numerous workflows.
39+
[FastAPI example](https://github.com/krylosov-aa/context-async-sqlalchemy/tree/main/examples/fastapi_example/routes)
40+
41+
42+
It also includes two types of test setups you can use in your projects.
43+
The library currently has 90% test coverage. The tests are in the
44+
examples, as we want to test not in the abstract but in the context of a real
45+
asynchronous web application.
46+
47+
[FastAPI tests example](https://github.com/krylosov-aa/context-async-sqlalchemy/tree/main/examples/fastapi_example/tests)
48+
49+
### The most basic example
50+
51+
#### 1. Configure the connection to the database
52+
53+
for example for PostgreSQL database.py:
54+
```python
55+
from sqlalchemy.ext.asyncio import (
56+
async_sessionmaker,
57+
AsyncEngine,
58+
AsyncSession,
59+
create_async_engine,
60+
)
61+
62+
from context_async_sqlalchemy import DBConnect
63+
64+
65+
def create_engine(host: str) -> AsyncEngine:
66+
"""
67+
database connection parameters.
68+
"""
69+
# In production code, you will probably take these parameters from env
70+
pg_user = "krylosov-aa"
71+
pg_password = ""
72+
pg_port = 6432
73+
pg_db = "test"
74+
return create_async_engine(
75+
f"postgresql+asyncpg://"
76+
f"{pg_user}:{pg_password}"
77+
f"@{host}:{pg_port}"
78+
f"/{pg_db}",
79+
future=True,
80+
pool_pre_ping=True,
81+
)
82+
83+
84+
def create_session_maker(
85+
engine: AsyncEngine,
86+
) -> async_sessionmaker[AsyncSession]:
87+
"""session parameters"""
88+
return async_sessionmaker(
89+
engine, class_=AsyncSession, expire_on_commit=False
90+
)
91+
92+
93+
master = DBConnect(
94+
host="127.0.0.1",
95+
engine_creator=create_engine,
96+
session_maker_creator=create_session_maker,
97+
)
98+
99+
```
100+
101+
#### 2. Manage Database connection lifecycle
102+
Configure the connection to the database at the begin of your application's life.
103+
Close the resources at the end of your application's life
104+
105+
106+
Example for FastAPI:
107+
108+
```python
109+
from contextlib import asynccontextmanager
110+
from typing import Any, AsyncGenerator
111+
from fastapi import FastAPI
112+
113+
from database import master
114+
115+
116+
@asynccontextmanager
117+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, Any]:
118+
"""Database connection lifecycle management"""
119+
yield
120+
await master.close() # Close the engine if it was open
121+
```
122+
123+
124+
#### 3. Setup context lifetime
125+
126+
For a contextual session to work, a context needs to be set. This assumes some
127+
kind of middleware.
128+
129+
130+
You can use ready-made FastAPI middleware:
131+
```python
132+
from fastapi import FastAPI
133+
from context_async_sqlalchemy import add_fastapi_db_session_middleware
134+
135+
app = FastAPI()
136+
137+
add_fastapi_db_session_middleware(app)
138+
```
139+
140+
141+
I'll use FastAPI middleware as an example:
142+
```python
143+
from fastapi import Request
144+
from starlette.middleware.base import ( # type: ignore[attr-defined]
145+
Response,
146+
RequestResponseEndpoint,
147+
)
148+
149+
from context_async_sqlalchemy import (
150+
init_db_session_ctx,
151+
is_context_initiated,
152+
reset_db_session_ctx,
153+
auto_commit_by_status_code,
154+
rollback_all_sessions,
155+
)
156+
157+
158+
async def fastapi_db_session_middleware(
159+
request: Request, call_next: RequestResponseEndpoint
160+
) -> Response:
161+
"""
162+
Database session lifecycle management.
163+
The session itself is created on demand in db_session().
164+
165+
Transaction auto-commit is implemented if there is no exception and
166+
the response status is < 400. Otherwise, a rollback is performed.
167+
168+
But you can commit or rollback manually in the handler.
169+
"""
170+
# Tests have different session management rules
171+
# so if the context variable is already set, we do nothing
172+
if is_context_initiated():
173+
return await call_next(request)
174+
175+
# We set the context here, meaning all child coroutines will receive the
176+
# same context. And even if a child coroutine requests the
177+
# session first, the dictionary itself is shared, and this coroutine will
178+
# add the session to dictionary = shared context.
179+
token = init_db_session_ctx()
180+
try:
181+
response = await call_next(request)
182+
await auto_commit_by_status_code(response.status_code)
183+
return response
184+
except Exception:
185+
await rollback_all_sessions()
186+
raise
187+
finally:
188+
await reset_db_session_ctx(token)
189+
```
190+
191+
192+
#### 4. Write a function that will work with the session
193+
194+
```python
195+
from sqlalchemy import insert
196+
197+
from context_async_sqlalchemy import db_session
198+
199+
from database import master
200+
from models import ExampleTable
201+
202+
203+
async def handler_with_db_session() -> None:
204+
"""
205+
An example of a typical handle that uses a context session to work with
206+
a database.
207+
Autocommit or autorollback occurs automatically at the end of a request
208+
(in middleware).
209+
"""
210+
# Created a session (no connection to the database yet)
211+
# If you call db_session again, it will return the same session
212+
# even in child coroutines.
213+
session = await db_session(master)
214+
215+
stmt = insert(ExampleTable).values(text="example_with_db_session")
216+
217+
# On the first request, a connection and transaction were opened
218+
await session.execute(stmt)
219+
```
220+
221+
222+
## Master/Replica or several databases at the same time
223+
224+
This is why `db_session` and other functions accept `DBConnect` as input.
225+
This way, you can work with multiple hosts simultaneously,
226+
for example, with the master and the replica.
227+
228+
libpq can detect the master and replica to create an engine. However, it only
229+
does this once during creation. This handler helps change the host on the fly
230+
if the master or replica changes. Let's imagine that you have a third-party
231+
functionality that helps determine the master or replica.
232+
233+
In this example, the host is not set from the very beginning, but will be
234+
calculated during the first call to create a session.
235+
236+
```python
237+
from context_async_sqlalchemy import DBConnect
238+
239+
from master_replica_helper import get_master, get_replica
240+
241+
242+
async def renew_master_connect(connect: DBConnect) -> None:
243+
"""Updates the host if the master has changed"""
244+
master_host = await get_master()
245+
if master_host != connect.host:
246+
await connect.change_host(master_host)
247+
248+
249+
master = DBConnect(
250+
engine_creator=create_engine,
251+
session_maker_creator=create_session_maker,
252+
before_create_session_handler=renew_master_connect,
253+
)
254+
255+
256+
async def renew_replica_connect(connect: DBConnect) -> None:
257+
"""Updates the host if the replica has changed"""
258+
replica_host = await get_replica()
259+
if replica_host != connect.host:
260+
await connect.change_host(replica_host)
261+
262+
263+
replica = DBConnect(
264+
engine_creator=create_engine,
265+
session_maker_creator=create_session_maker,
266+
before_create_session_handler=renew_replica_connect,
267+
)
268+
```
269+
270+
## Testing
271+
272+
The library provides several ready-made utils that can be used in tests,
273+
for example in fixtures. It helps write tests that share a common transaction
274+
between the test and the application, so data isolation between tests is
275+
achieved through fast transaction rollback.
276+
277+
278+
You can see the capabilities in the examples:
279+
280+
[Here are tests with a common transaction between the
281+
application and the tests.](https://github.com/krylosov-aa/context-async-sqlalchemy/blob/main/examples/fastapi_example/tests/transactional/__init__.py)
282+
283+
284+
[And here's an example with different transactions.](https://github.com/krylosov-aa/context-async-sqlalchemy/blob/main/examples/fastapi_example/tests/non_transactional/__init__.py)

0 commit comments

Comments
 (0)