Skip to content

Commit 748ebf8

Browse files
committed
https://github.com/krylosov-aa/context-async-sqlalchemy/issues/5
1 parent 6bf8c1b commit 748ebf8

File tree

22 files changed

+1546
-297
lines changed

22 files changed

+1546
-297
lines changed

README.md

Lines changed: 41 additions & 252 deletions
Original file line numberDiff line numberDiff line change
@@ -2,283 +2,72 @@
22

33
[![PyPI](https://img.shields.io/pypi/v/context-async-sqlalchemy.svg)](https://pypi.org/project/context-async-sqlalchemy/)
44

5+
[DOCUMENTATION](https://krylosov-aa.github.io/context-async-sqlalchemy/)
56

6-
ContextVar + async sqlalchemy = happiness.
7+
Provides a super convenient way to work with sqlalchemy in asynchronous
8+
applications. It takes care of the issues of managing the lifecycle of engine,
9+
session, and transactions without being a wrapper.
10+
11+
The main task is to get quick and easy access to the session and not worry
12+
about when to open and when to close it.
13+
14+
The key features are:
15+
16+
- Super easy to use
17+
- Automatically manages the lifecycle of engine, session, and transaction
18+
(autocommit/autorollback)
19+
- It doesn't interfere with manually opening and closing sessions and
20+
transactions when needed.
21+
- Does not depend on the web framework
22+
- It is not a wrapper over sqlalchemy
23+
- It is convenient to test
24+
- Host switching in runtime
25+
- It can manage multiple databases and multiple sessions to a single database
26+
- Provides tools for concurrent sql queries
27+
- Lazy initialization is everywhere
728

8-
A convenient way to configure and interact with async sqlalchemy session
9-
through context in asynchronous applications.
1029

1130
## What does usage look like?
1231

1332
```python
1433
from context_async_sqlalchemy import db_session
1534
from sqlalchemy import insert
1635

17-
from database import master # your configured connection to the database
36+
from database import connection # your configured connection to the database
1837
from models import ExampleTable # just some model for example
1938

2039
async def some_func() -> None:
2140
# 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)
41+
session = await db_session(connection)
2542

2643
stmt = insert(ExampleTable).values(text="example_with_db_session")
2744

2845
# On the first request, a connection and transaction were opened
2946
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)
47+
21148
# If you call db_session again, it will return the same session
21249
# 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
50+
session = await db_session(connection)
51+
52+
# The second request will use the same connection and the same transaction
21853
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.
23554

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-
)
55+
# The commit and closing of the session will occur automatically
26856
```
26957

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-
58+
## How it works
27759

278-
You can see the capabilities in the examples:
60+
Here is a very simplified diagram of how everything works:
27961

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)
62+
![basic schema.png](docs_sources/docs/img/basic_schema.png)
28263

64+
1. Before executing your code, the middleware will prepare a container in
65+
which the sessions required by your code will be stored.
66+
The container is saved in contextvars
67+
2. Your code accesses the library to create new sessions and retrieve
68+
existing ones
69+
3. After your code, middleware will automatically commit or roll back open
70+
transactions. Closes open sessions and clears the context.
28371

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)
72+
The library also provides the ability to commit, rollback, and close at any
73+
time, without waiting for the end of the request, without any problems.

context_async_sqlalchemy/starlette_utils/http_middleware.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# from starlette import Starlette
21
from starlette.applications import Starlette
32
from starlette.middleware.base import ( # type: ignore[attr-defined]
43
Request,
@@ -42,15 +41,18 @@ async def starlette_http_db_session_middleware(
4241

4342
# We set the context here, meaning all child coroutines will receive the
4443
# same context. And even if a child coroutine requests the
45-
# session first, the dictionary itself is shared, and this coroutine will
46-
# add the session to dictionary = shared context.
44+
# session first, the container itself is shared, and this coroutine will
45+
# add the session to container = shared context.
4746
token = init_db_session_ctx()
4847
try:
4948
response = await call_next(request)
49+
# using the status code, we decide to commit or rollback all sessions
5050
await auto_commit_by_status_code(response.status_code)
5151
return response
5252
except Exception:
53+
# If an exception occurs, we roll all sessions back
5354
await rollback_all_sessions()
5455
raise
5556
finally:
57+
# Close all sessions and clear the context
5658
await reset_db_session_ctx(token)

docs/404.html

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,23 @@
3333

3434
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="Navigation menu">
3535
<ul>
36-
<li class="toctree-l1"><a class="reference internal" href="/.">Welcome to MkDocs</a>
36+
<li class="toctree-l1"><a class="reference internal" href="/.">context-async-sqlalchemy</a>
37+
</li>
38+
</ul>
39+
<ul>
40+
<li class="toctree-l1"><a class="reference internal" href="/getting_started/">Getting started</a>
41+
</li>
42+
</ul>
43+
<ul>
44+
<li class="toctree-l1"><a class="reference internal" href="/master_replica/">Master/Replica or several databases at the same time</a>
45+
</li>
46+
</ul>
47+
<ul>
48+
<li class="toctree-l1"><a class="reference internal" href="/testing/">Testing</a>
49+
</li>
50+
</ul>
51+
<ul>
52+
<li class="toctree-l1"><a class="reference internal" href="/how_middleware_works/">How middleware works</a>
3753
</li>
3854
</ul>
3955
</div>
@@ -86,6 +102,10 @@ <h1 id="404-page-not-found">404</h1>
86102
<div class="rst-versions" role="note" aria-label="Versions">
87103
<span class="rst-current-version" data-toggle="rst-current-version">
88104

105+
<span>
106+
<a href="https://github.com/krylosov-aa/context-async-sqlalchemy" class="fa fa-github" style="color: #fcfcfc"> GitHub</a>
107+
</span>
108+
89109

90110

91111
</span>

0 commit comments

Comments
 (0)