|
2 | 2 |
|
3 | 3 | [](https://pypi.org/project/context-async-sqlalchemy/) |
4 | 4 |
|
| 5 | +[DOCUMENTATION](https://krylosov-aa.github.io/context-async-sqlalchemy/) |
5 | 6 |
|
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 |
7 | 28 |
|
8 | | -A convenient way to configure and interact with async sqlalchemy session |
9 | | - through context in asynchronous applications. |
10 | 29 |
|
11 | 30 | ## What does usage look like? |
12 | 31 |
|
13 | 32 | ```python |
14 | 33 | from context_async_sqlalchemy import db_session |
15 | 34 | from sqlalchemy import insert |
16 | 35 |
|
17 | | -from database import master # your configured connection to the database |
| 36 | +from database import connection # your configured connection to the database |
18 | 37 | from models import ExampleTable # just some model for example |
19 | 38 |
|
20 | 39 | async def some_func() -> None: |
21 | 40 | # 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) |
25 | 42 |
|
26 | 43 | stmt = insert(ExampleTable).values(text="example_with_db_session") |
27 | 44 |
|
28 | 45 | # On the first request, a connection and transaction were opened |
29 | 46 | 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 | + |
211 | 48 | # If you call db_session again, it will return the same session |
212 | 49 | # 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 |
218 | 53 | 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 | 54 |
|
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 |
268 | 56 | ``` |
269 | 57 |
|
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 |
277 | 59 |
|
278 | | -You can see the capabilities in the examples: |
| 60 | +Here is a very simplified diagram of how everything works: |
279 | 61 |
|
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 | + |
282 | 63 |
|
| 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. |
283 | 71 |
|
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. |
0 commit comments