voltwire-fastapi-db-txs 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voltwire_fastapi_db_txs-0.0.1/.gitignore +221 -0
- voltwire_fastapi_db_txs-0.0.1/PKG-INFO +169 -0
- voltwire_fastapi_db_txs-0.0.1/README.md +157 -0
- voltwire_fastapi_db_txs-0.0.1/pyproject.toml +26 -0
- voltwire_fastapi_db_txs-0.0.1/src/voltwire/fastapi/db_txs/__init__.py +19 -0
- voltwire_fastapi_db_txs-0.0.1/src/voltwire/fastapi/db_txs/dependencies.py +66 -0
- voltwire_fastapi_db_txs-0.0.1/src/voltwire/fastapi/db_txs/middleware.py +98 -0
- voltwire_fastapi_db_txs-0.0.1/src/voltwire/fastapi/db_txs/py.typed +0 -0
- voltwire_fastapi_db_txs-0.0.1/src/voltwire/fastapi/db_txs/resolvers.py +64 -0
- voltwire_fastapi_db_txs-0.0.1/tests/test_dependencies.py +76 -0
- voltwire_fastapi_db_txs-0.0.1/tests/test_middleware.py +92 -0
- voltwire_fastapi_db_txs-0.0.1/tests/test_resolvers.py +26 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
.idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
219
|
+
|
|
220
|
+
# Local tool state
|
|
221
|
+
.omc/
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: voltwire-fastapi-db-txs
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: FastAPI middleware and dependencies for request-scoped DB transaction management
|
|
5
|
+
Author-email: Hermann Steidel <hsteidel.software@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: <4.0,>=3.13
|
|
8
|
+
Requires-Dist: fastapi>=0.100.0
|
|
9
|
+
Requires-Dist: starlette>=0.27.0
|
|
10
|
+
Requires-Dist: voltwire-db-session>=0.0.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
<img src="https://raw.githubusercontent.com/hsteidel/voltwire/main/assets/icons/fastapi-db-txs.svg" alt="" width="56" height="56" align="left">
|
|
14
|
+
|
|
15
|
+
# voltwire-fastapi-db-txs
|
|
16
|
+
|
|
17
|
+
FastAPI integration for `voltwire.db.session.TransactionContext` (from
|
|
18
|
+
[voltwire-db-session](https://pypi.org/project/voltwire-db-session/)): request-scoped read-write and
|
|
19
|
+
read-only database sessions with automatic commit/rollback, so route handlers never manage
|
|
20
|
+
session lifecycle themselves.
|
|
21
|
+
|
|
22
|
+
**No hidden global state, no DI framework coupling.** Every component here depends on
|
|
23
|
+
*resolvers* — anything callable as `(request: Request) -> T` — for `TransactionContext`
|
|
24
|
+
and the relevant session factory, rather than plain instances. This lets apps defer
|
|
25
|
+
resolution to `request.app.state`, a DI container, or anywhere else, which matters for
|
|
26
|
+
apps whose DB config isn't finalized until after this middleware/dependency is
|
|
27
|
+
constructed (e.g. test harnesses that substitute a test database after module import
|
|
28
|
+
time). Apps with one fixed instance for the process's lifetime just pass
|
|
29
|
+
`lambda request: my_instance`.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install voltwire-fastapi-db-txs
|
|
35
|
+
# or with Poetry:
|
|
36
|
+
poetry add voltwire-fastapi-db-txs
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Wiring it into your app
|
|
40
|
+
|
|
41
|
+
Your app should hold exactly one `TransactionContext` instance for the life of the process.
|
|
42
|
+
The easiest way to get one — along with its matching `DatabaseSessionFactory`/
|
|
43
|
+
`RODatabaseSessionFactory` — is `auto_configure_database`, from voltwire-db-session. If your DB
|
|
44
|
+
config is ready before any route module gets imported, resolve it once and close over it:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from voltwire.db.session import auto_configure_database, DatabaseAutoConfigurationProperties
|
|
48
|
+
from voltwire.fastapi.db_txs import TransactionMiddleware, make_read_only_transaction
|
|
49
|
+
|
|
50
|
+
db = auto_configure_database(db_settings, DatabaseAutoConfigurationProperties(build_read_replica=True))
|
|
51
|
+
|
|
52
|
+
app.add_middleware(
|
|
53
|
+
TransactionMiddleware,
|
|
54
|
+
transaction_context=lambda request: db.transaction_context,
|
|
55
|
+
session_factory=lambda request: db.session_factory,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
ReadOnlyTransaction = make_read_only_transaction(
|
|
59
|
+
lambda request: db.ro_session_factory,
|
|
60
|
+
lambda request: db.transaction_context,
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
If your app defers DB config until later (e.g. it's set on `request.app.state` during
|
|
65
|
+
startup, or substituted for a test database after these components are constructed),
|
|
66
|
+
resolve from the request instead. If your app uses voltwire-di-core's
|
|
67
|
+
`request.app.state.provide(cls)` convention, `AppStateResolver` implements the resolver
|
|
68
|
+
protocol for you — pass the class you want resolved:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from voltwire.fastapi.db_txs import AppStateResolver
|
|
72
|
+
|
|
73
|
+
app.add_middleware(
|
|
74
|
+
TransactionMiddleware,
|
|
75
|
+
transaction_context=AppStateResolver(TransactionContext),
|
|
76
|
+
session_factory=AppStateResolver(DatabaseSessionFactory),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
ReadOnlyTransaction = make_read_only_transaction(
|
|
80
|
+
AppStateResolver(RODatabaseSessionFactory),
|
|
81
|
+
AppStateResolver(TransactionContext),
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Apps not using that convention can pass any other callable of the same shape —
|
|
86
|
+
`lambda request: request.app.state.my_custom_lookup(TransactionContext)`, a bound
|
|
87
|
+
method, or a small resolver class of their own.
|
|
88
|
+
|
|
89
|
+
If you'd rather assemble the session factories yourself (e.g. a custom
|
|
90
|
+
`DatabaseSessionFactory` subclass) instead of using `auto_configure_database`, build each
|
|
91
|
+
one directly — `TransactionMiddleware` and `make_read_only_transaction` only need
|
|
92
|
+
resolvers returning a `TransactionContext` and the relevant factory, however you got them:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from voltwire.db.session import TransactionContext, build_session_factory, build_ro_session_factory
|
|
96
|
+
|
|
97
|
+
transaction_context = TransactionContext()
|
|
98
|
+
session_factory = build_session_factory(db_settings)
|
|
99
|
+
ro_session_factory = build_ro_session_factory(db_settings)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Using it in routes
|
|
103
|
+
|
|
104
|
+
Write routes (RW by default — the middleware opens a session for every request):
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
@router.post("/users")
|
|
108
|
+
def create_user(user_repo: UserRepositoryDI):
|
|
109
|
+
return user_repo.save(user) # commits automatically on a 2xx/3xx response
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Read-only routes (route or router-level, targets your read replica instead):
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
@router.get("/users", dependencies=[ReadOnlyTransaction])
|
|
116
|
+
def search_users(user_repo: UserRepositoryDI) -> list[UserResponse]:
|
|
117
|
+
...
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Wherever your app resolves a `Session` for its repositories, call
|
|
121
|
+
`transaction_context.get_session()` — it returns the RO session if one is active for the
|
|
122
|
+
current request, otherwise the RW session opened by the middleware, otherwise `None`.
|
|
123
|
+
|
|
124
|
+
## Scripts and background tasks
|
|
125
|
+
|
|
126
|
+
For code outside a FastAPI request (management scripts, workers), use the context managers
|
|
127
|
+
directly instead of the middleware/dependency — either via the `db` object from
|
|
128
|
+
`auto_configure_database`:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
with db.transaction():
|
|
132
|
+
user_repo = UserRepository(db.transaction_context.get_session())
|
|
133
|
+
user_repo.save(user)
|
|
134
|
+
# commits on success, rolls back on exception
|
|
135
|
+
|
|
136
|
+
with db.read_transaction():
|
|
137
|
+
result = repo.find(...)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
or, if you built the pieces yourself, the same methods on `TransactionContext` directly:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
with transaction_context.transaction(session_factory):
|
|
144
|
+
user_repo = UserRepository(transaction_context.get_session())
|
|
145
|
+
user_repo.save(user)
|
|
146
|
+
|
|
147
|
+
with transaction_context.read_transaction(ro_session_factory):
|
|
148
|
+
result = repo.find(...)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
If a single `DatabaseAutoConfiguration` is truly the only one in the process (no multiple
|
|
152
|
+
databases, no multiple apps sharing the interpreter — e.g. in a pytest session), voltwire-db-session
|
|
153
|
+
also has an opt-in `voltwire.db.session.default` module for a bare `with transaction():` — see
|
|
154
|
+
voltwire-db-session's docs for when that tradeoff is worth it.
|
|
155
|
+
|
|
156
|
+
## Why resolvers instead of instances?
|
|
157
|
+
|
|
158
|
+
This library has no knowledge of any dependency-injection framework — `TransactionMiddleware`
|
|
159
|
+
and `make_read_only_transaction` never construct or own a `TransactionContext`/session
|
|
160
|
+
factory themselves. Depending on a resolver *protocol* instead of a plain instance means
|
|
161
|
+
your app decides *when* resolution happens: eagerly (a lambda that just returns a value
|
|
162
|
+
captured at wiring time) or lazily per request (e.g. `request.app.state.provide(...)`),
|
|
163
|
+
without this library needing to know which. `AppStateResolver` is the one piece that
|
|
164
|
+
assumes anything about `request.app.state` beyond FastAPI itself — it exists purely as a
|
|
165
|
+
convenience for voltwire-di-core's convention; everything else in this library only ever calls
|
|
166
|
+
the resolver, never reaches into `request.app.state` directly. The one thing that
|
|
167
|
+
matters regardless of which resolver you use: share the *same* `TransactionContext`
|
|
168
|
+
instance across the middleware, any read-only dependencies, and your app's own
|
|
169
|
+
session-resolution code — its ContextVars are what tie a request's session together.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/hsteidel/voltwire/main/assets/icons/fastapi-db-txs.svg" alt="" width="56" height="56" align="left">
|
|
2
|
+
|
|
3
|
+
# voltwire-fastapi-db-txs
|
|
4
|
+
|
|
5
|
+
FastAPI integration for `voltwire.db.session.TransactionContext` (from
|
|
6
|
+
[voltwire-db-session](https://pypi.org/project/voltwire-db-session/)): request-scoped read-write and
|
|
7
|
+
read-only database sessions with automatic commit/rollback, so route handlers never manage
|
|
8
|
+
session lifecycle themselves.
|
|
9
|
+
|
|
10
|
+
**No hidden global state, no DI framework coupling.** Every component here depends on
|
|
11
|
+
*resolvers* — anything callable as `(request: Request) -> T` — for `TransactionContext`
|
|
12
|
+
and the relevant session factory, rather than plain instances. This lets apps defer
|
|
13
|
+
resolution to `request.app.state`, a DI container, or anywhere else, which matters for
|
|
14
|
+
apps whose DB config isn't finalized until after this middleware/dependency is
|
|
15
|
+
constructed (e.g. test harnesses that substitute a test database after module import
|
|
16
|
+
time). Apps with one fixed instance for the process's lifetime just pass
|
|
17
|
+
`lambda request: my_instance`.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install voltwire-fastapi-db-txs
|
|
23
|
+
# or with Poetry:
|
|
24
|
+
poetry add voltwire-fastapi-db-txs
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Wiring it into your app
|
|
28
|
+
|
|
29
|
+
Your app should hold exactly one `TransactionContext` instance for the life of the process.
|
|
30
|
+
The easiest way to get one — along with its matching `DatabaseSessionFactory`/
|
|
31
|
+
`RODatabaseSessionFactory` — is `auto_configure_database`, from voltwire-db-session. If your DB
|
|
32
|
+
config is ready before any route module gets imported, resolve it once and close over it:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from voltwire.db.session import auto_configure_database, DatabaseAutoConfigurationProperties
|
|
36
|
+
from voltwire.fastapi.db_txs import TransactionMiddleware, make_read_only_transaction
|
|
37
|
+
|
|
38
|
+
db = auto_configure_database(db_settings, DatabaseAutoConfigurationProperties(build_read_replica=True))
|
|
39
|
+
|
|
40
|
+
app.add_middleware(
|
|
41
|
+
TransactionMiddleware,
|
|
42
|
+
transaction_context=lambda request: db.transaction_context,
|
|
43
|
+
session_factory=lambda request: db.session_factory,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
ReadOnlyTransaction = make_read_only_transaction(
|
|
47
|
+
lambda request: db.ro_session_factory,
|
|
48
|
+
lambda request: db.transaction_context,
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If your app defers DB config until later (e.g. it's set on `request.app.state` during
|
|
53
|
+
startup, or substituted for a test database after these components are constructed),
|
|
54
|
+
resolve from the request instead. If your app uses voltwire-di-core's
|
|
55
|
+
`request.app.state.provide(cls)` convention, `AppStateResolver` implements the resolver
|
|
56
|
+
protocol for you — pass the class you want resolved:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from voltwire.fastapi.db_txs import AppStateResolver
|
|
60
|
+
|
|
61
|
+
app.add_middleware(
|
|
62
|
+
TransactionMiddleware,
|
|
63
|
+
transaction_context=AppStateResolver(TransactionContext),
|
|
64
|
+
session_factory=AppStateResolver(DatabaseSessionFactory),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
ReadOnlyTransaction = make_read_only_transaction(
|
|
68
|
+
AppStateResolver(RODatabaseSessionFactory),
|
|
69
|
+
AppStateResolver(TransactionContext),
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Apps not using that convention can pass any other callable of the same shape —
|
|
74
|
+
`lambda request: request.app.state.my_custom_lookup(TransactionContext)`, a bound
|
|
75
|
+
method, or a small resolver class of their own.
|
|
76
|
+
|
|
77
|
+
If you'd rather assemble the session factories yourself (e.g. a custom
|
|
78
|
+
`DatabaseSessionFactory` subclass) instead of using `auto_configure_database`, build each
|
|
79
|
+
one directly — `TransactionMiddleware` and `make_read_only_transaction` only need
|
|
80
|
+
resolvers returning a `TransactionContext` and the relevant factory, however you got them:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from voltwire.db.session import TransactionContext, build_session_factory, build_ro_session_factory
|
|
84
|
+
|
|
85
|
+
transaction_context = TransactionContext()
|
|
86
|
+
session_factory = build_session_factory(db_settings)
|
|
87
|
+
ro_session_factory = build_ro_session_factory(db_settings)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Using it in routes
|
|
91
|
+
|
|
92
|
+
Write routes (RW by default — the middleware opens a session for every request):
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
@router.post("/users")
|
|
96
|
+
def create_user(user_repo: UserRepositoryDI):
|
|
97
|
+
return user_repo.save(user) # commits automatically on a 2xx/3xx response
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Read-only routes (route or router-level, targets your read replica instead):
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
@router.get("/users", dependencies=[ReadOnlyTransaction])
|
|
104
|
+
def search_users(user_repo: UserRepositoryDI) -> list[UserResponse]:
|
|
105
|
+
...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Wherever your app resolves a `Session` for its repositories, call
|
|
109
|
+
`transaction_context.get_session()` — it returns the RO session if one is active for the
|
|
110
|
+
current request, otherwise the RW session opened by the middleware, otherwise `None`.
|
|
111
|
+
|
|
112
|
+
## Scripts and background tasks
|
|
113
|
+
|
|
114
|
+
For code outside a FastAPI request (management scripts, workers), use the context managers
|
|
115
|
+
directly instead of the middleware/dependency — either via the `db` object from
|
|
116
|
+
`auto_configure_database`:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
with db.transaction():
|
|
120
|
+
user_repo = UserRepository(db.transaction_context.get_session())
|
|
121
|
+
user_repo.save(user)
|
|
122
|
+
# commits on success, rolls back on exception
|
|
123
|
+
|
|
124
|
+
with db.read_transaction():
|
|
125
|
+
result = repo.find(...)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
or, if you built the pieces yourself, the same methods on `TransactionContext` directly:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
with transaction_context.transaction(session_factory):
|
|
132
|
+
user_repo = UserRepository(transaction_context.get_session())
|
|
133
|
+
user_repo.save(user)
|
|
134
|
+
|
|
135
|
+
with transaction_context.read_transaction(ro_session_factory):
|
|
136
|
+
result = repo.find(...)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
If a single `DatabaseAutoConfiguration` is truly the only one in the process (no multiple
|
|
140
|
+
databases, no multiple apps sharing the interpreter — e.g. in a pytest session), voltwire-db-session
|
|
141
|
+
also has an opt-in `voltwire.db.session.default` module for a bare `with transaction():` — see
|
|
142
|
+
voltwire-db-session's docs for when that tradeoff is worth it.
|
|
143
|
+
|
|
144
|
+
## Why resolvers instead of instances?
|
|
145
|
+
|
|
146
|
+
This library has no knowledge of any dependency-injection framework — `TransactionMiddleware`
|
|
147
|
+
and `make_read_only_transaction` never construct or own a `TransactionContext`/session
|
|
148
|
+
factory themselves. Depending on a resolver *protocol* instead of a plain instance means
|
|
149
|
+
your app decides *when* resolution happens: eagerly (a lambda that just returns a value
|
|
150
|
+
captured at wiring time) or lazily per request (e.g. `request.app.state.provide(...)`),
|
|
151
|
+
without this library needing to know which. `AppStateResolver` is the one piece that
|
|
152
|
+
assumes anything about `request.app.state` beyond FastAPI itself — it exists purely as a
|
|
153
|
+
convenience for voltwire-di-core's convention; everything else in this library only ever calls
|
|
154
|
+
the resolver, never reaches into `request.app.state` directly. The one thing that
|
|
155
|
+
matters regardless of which resolver you use: share the *same* `TransactionContext`
|
|
156
|
+
instance across the middleware, any read-only dependencies, and your app's own
|
|
157
|
+
session-resolution code — its ContextVars are what tie a request's session together.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "voltwire-fastapi-db-txs"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "FastAPI middleware and dependencies for request-scoped DB transaction management"
|
|
5
|
+
authors = [{name = "Hermann Steidel", email = "hsteidel.software@gmail.com"}]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.13,<4.0"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"fastapi>=0.100.0",
|
|
11
|
+
"starlette>=0.27.0",
|
|
12
|
+
"voltwire-db-session>=0.0.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = ["pytest>=8.0"]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/voltwire"]
|
|
24
|
+
|
|
25
|
+
[tool.uv.sources]
|
|
26
|
+
voltwire-db-session = {workspace = true}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from voltwire.fastapi.db_txs.dependencies import make_read_only_transaction
|
|
2
|
+
from voltwire.fastapi.db_txs.middleware import TransactionMiddleware
|
|
3
|
+
from voltwire.fastapi.db_txs.resolvers import (
|
|
4
|
+
AppStateResolver,
|
|
5
|
+
RODatabaseSessionFactoryResolver,
|
|
6
|
+
SessionFactoryResolver,
|
|
7
|
+
TransactionContextResolver,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__version__ = "0.0.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"TransactionMiddleware",
|
|
14
|
+
"make_read_only_transaction",
|
|
15
|
+
"AppStateResolver",
|
|
16
|
+
"RODatabaseSessionFactoryResolver",
|
|
17
|
+
"SessionFactoryResolver",
|
|
18
|
+
"TransactionContextResolver",
|
|
19
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Read-Replica FastAPI Dependency Builder
|
|
3
|
+
|
|
4
|
+
FastAPI's ``Depends()`` callables can't take extra constructor arguments, so the
|
|
5
|
+
read-only dependency is produced by a builder function instead: call
|
|
6
|
+
``make_read_only_transaction`` once at app-wiring time with resolver callables for
|
|
7
|
+
your ``RODatabaseSessionFactory`` and the same ``TransactionContext`` instance used by
|
|
8
|
+
``TransactionMiddleware``, then bind the result to a name in your app::
|
|
9
|
+
|
|
10
|
+
ReadOnlyTransaction = make_read_only_transaction(
|
|
11
|
+
lambda request: my_ro_session_factory,
|
|
12
|
+
lambda request: my_transaction_context,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
@router.get("", dependencies=[ReadOnlyTransaction])
|
|
16
|
+
def search_items(service: MyServiceDI) -> MySearchResponse:
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
Both resolvers are invoked per request with the ``Request`` rather than passed as
|
|
20
|
+
plain instances — this lets apps defer resolution to whatever they've stashed on
|
|
21
|
+
``request.app.state`` (or a DI container), which matters for apps whose settings/
|
|
22
|
+
session factories aren't finalized until after this dependency is built (e.g. test
|
|
23
|
+
harnesses that substitute a test database via ``request.app.state`` after module
|
|
24
|
+
import time). Apps with a single fixed instance for the lifetime of the process can
|
|
25
|
+
pass ``lambda request: my_instance``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from collections.abc import AsyncGenerator
|
|
29
|
+
|
|
30
|
+
from fastapi import Depends, Request
|
|
31
|
+
from fastapi.params import Depends as DependsType
|
|
32
|
+
|
|
33
|
+
from voltwire.fastapi.db_txs.resolvers import RODatabaseSessionFactoryResolver, TransactionContextResolver
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def make_read_only_transaction(
|
|
37
|
+
ro_session_factory: RODatabaseSessionFactoryResolver,
|
|
38
|
+
transaction_context: TransactionContextResolver,
|
|
39
|
+
) -> DependsType:
|
|
40
|
+
"""
|
|
41
|
+
Build a FastAPI dependency that opens a read-only replica session for the
|
|
42
|
+
duration of a request.
|
|
43
|
+
|
|
44
|
+
Keeps the RO session open for the full duration of the request so that all
|
|
45
|
+
downstream session resolution within the route uses the replica.
|
|
46
|
+
|
|
47
|
+
Must be async so the ContextVar is set and reset within the async event-loop
|
|
48
|
+
context — mirroring ``TransactionMiddleware``. Sync generator dependencies run in
|
|
49
|
+
a thread pool where anyio snapshots the context, causing ContextVar tokens to be
|
|
50
|
+
bound to the thread's copied Context; the token would then be invalid when FastAPI
|
|
51
|
+
runs generator cleanup in a different context snapshot. As an async generator,
|
|
52
|
+
``set()``/``reset()`` both execute in the same async Context, and the sync route
|
|
53
|
+
thread inherits the session via the context copy.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
async def build_read_replica_context(request: Request) -> AsyncGenerator[None, None]:
|
|
57
|
+
session = ro_session_factory(request).get_session()
|
|
58
|
+
context = transaction_context(request)
|
|
59
|
+
token = context.set_ro_session(session)
|
|
60
|
+
try:
|
|
61
|
+
yield
|
|
62
|
+
finally:
|
|
63
|
+
context.reset_ro_session(token)
|
|
64
|
+
session.close()
|
|
65
|
+
|
|
66
|
+
return Depends(build_read_replica_context)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI Transaction Middleware
|
|
3
|
+
|
|
4
|
+
Provides transparent transaction management for web requests with automatic
|
|
5
|
+
commit/rollback behavior, built on top of ``voltwire.db.session.TransactionContext``.
|
|
6
|
+
|
|
7
|
+
This middleware:
|
|
8
|
+
- Opens a read-write session for each HTTP request
|
|
9
|
+
- Commits the transaction on successful response (2xx/3xx status codes)
|
|
10
|
+
- Rolls back the transaction on exceptions or error status codes
|
|
11
|
+
- Always closes the session for proper cleanup
|
|
12
|
+
- Works across FastAPI's thread boundaries via the injected ``TransactionContext``
|
|
13
|
+
|
|
14
|
+
Usage::
|
|
15
|
+
|
|
16
|
+
app.add_middleware(
|
|
17
|
+
TransactionMiddleware,
|
|
18
|
+
transaction_context=lambda request: my_transaction_context,
|
|
19
|
+
session_factory=lambda request: my_session_factory,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
Both ``transaction_context`` and ``session_factory`` are resolver callables invoked
|
|
23
|
+
per request with the ``Request``, not plain instances — this lets apps defer
|
|
24
|
+
resolution to whatever they've stashed on ``request.app.state`` (or a DI container),
|
|
25
|
+
which matters for apps whose settings/session factories aren't finalized until after
|
|
26
|
+
this middleware is constructed (e.g. test harnesses that substitute a test database
|
|
27
|
+
via ``request.app.state`` after module import time). Apps with a single fixed
|
|
28
|
+
instance for the lifetime of the process can pass ``lambda request: my_instance``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from collections.abc import Callable
|
|
32
|
+
|
|
33
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
34
|
+
from starlette.requests import Request
|
|
35
|
+
from starlette.responses import Response
|
|
36
|
+
|
|
37
|
+
from voltwire.fastapi.db_txs.resolvers import SessionFactoryResolver, TransactionContextResolver
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TransactionMiddleware(BaseHTTPMiddleware):
|
|
41
|
+
"""
|
|
42
|
+
Middleware that provides transparent transaction management for HTTP requests.
|
|
43
|
+
|
|
44
|
+
Automatically manages database session lifecycle:
|
|
45
|
+
- Opens a session before request processing
|
|
46
|
+
- Commits on successful completion (2xx/3xx status codes)
|
|
47
|
+
- Rolls back on exceptions or error status codes
|
|
48
|
+
- Always cleans up resources
|
|
49
|
+
|
|
50
|
+
``transaction_context`` and ``session_factory`` are resolver callables — invoked
|
|
51
|
+
with the current ``Request`` on every request — supplied by the caller. This
|
|
52
|
+
middleware has no knowledge of any DI framework or app-level session registry.
|
|
53
|
+
The resolved ``TransactionContext`` should be the same instance used by any
|
|
54
|
+
read-only dependency built with ``make_read_only_transaction`` and by the app's
|
|
55
|
+
own session-resolution code, since the ContextVars it owns are what tie a
|
|
56
|
+
request's session together across dependencies and route handlers.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
app,
|
|
62
|
+
transaction_context: TransactionContextResolver,
|
|
63
|
+
session_factory: SessionFactoryResolver,
|
|
64
|
+
):
|
|
65
|
+
super().__init__(app)
|
|
66
|
+
self._resolve_transaction_context = transaction_context
|
|
67
|
+
self._resolve_session_factory = session_factory
|
|
68
|
+
|
|
69
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
70
|
+
"""
|
|
71
|
+
Handle HTTP request with automatic transaction management.
|
|
72
|
+
|
|
73
|
+
Opens the RW session eagerly in the async context so that all thread-pool
|
|
74
|
+
workers dispatched by ``call_next`` inherit the same session via context copy.
|
|
75
|
+
Read-only routes shadow it via a dependency built from the same
|
|
76
|
+
``transaction_context``.
|
|
77
|
+
"""
|
|
78
|
+
transaction_context = self._resolve_transaction_context(request)
|
|
79
|
+
session_factory = self._resolve_session_factory(request)
|
|
80
|
+
|
|
81
|
+
session = session_factory.get_session()
|
|
82
|
+
transaction_context.set_rw_session(session)
|
|
83
|
+
try:
|
|
84
|
+
response = await call_next(request)
|
|
85
|
+
|
|
86
|
+
if 200 <= response.status_code < 399:
|
|
87
|
+
transaction_context.commit()
|
|
88
|
+
else:
|
|
89
|
+
transaction_context.rollback()
|
|
90
|
+
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
except Exception:
|
|
94
|
+
transaction_context.rollback()
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
finally:
|
|
98
|
+
transaction_context.close()
|
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Resolvers
|
|
3
|
+
|
|
4
|
+
``TransactionMiddleware`` and ``make_read_only_transaction`` depend on resolver
|
|
5
|
+
*protocols* — anything callable with ``(request: Request) -> T`` — rather than plain
|
|
6
|
+
instances, so resolution can be deferred to whatever the app has stashed on
|
|
7
|
+
``request.app.state`` (or elsewhere), instead of requiring a value fixed at wiring
|
|
8
|
+
time. A plain function or lambda satisfies the protocol structurally; no subclassing
|
|
9
|
+
required::
|
|
10
|
+
|
|
11
|
+
TransactionMiddleware(
|
|
12
|
+
app,
|
|
13
|
+
transaction_context=lambda request: my_transaction_context,
|
|
14
|
+
session_factory=lambda request: my_session_factory,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
For apps using voltwire-di-core's ``request.app.state.provide(cls)`` convention,
|
|
18
|
+
``AppStateResolver`` implements the protocol for you — pass the class you want
|
|
19
|
+
resolved::
|
|
20
|
+
|
|
21
|
+
TransactionMiddleware(
|
|
22
|
+
app,
|
|
23
|
+
transaction_context=AppStateResolver(TransactionContext),
|
|
24
|
+
session_factory=AppStateResolver(DatabaseSessionFactory),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
``AppStateResolver`` is the only piece of this library that assumes anything about
|
|
28
|
+
``request.app.state`` beyond FastAPI itself — apps not using that convention should
|
|
29
|
+
pass a plain callable instead.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from typing import Generic, Protocol, TypeVar
|
|
33
|
+
|
|
34
|
+
from voltwire.db.session import DatabaseSessionFactory, RODatabaseSessionFactory, TransactionContext
|
|
35
|
+
from starlette.requests import Request
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TransactionContextResolver(Protocol):
|
|
41
|
+
def __call__(self, request: Request) -> TransactionContext: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionFactoryResolver(Protocol):
|
|
45
|
+
def __call__(self, request: Request) -> DatabaseSessionFactory: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RODatabaseSessionFactoryResolver(Protocol):
|
|
49
|
+
def __call__(self, request: Request) -> RODatabaseSessionFactory: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AppStateResolver(Generic[T]):
|
|
53
|
+
"""
|
|
54
|
+
Resolves ``cls`` via ``request.app.state.provide(cls)`` — the voltwire-di-core
|
|
55
|
+
convention for reaching a request's DI container. Satisfies
|
|
56
|
+
``TransactionContextResolver``/``SessionFactoryResolver``/
|
|
57
|
+
``RODatabaseSessionFactoryResolver`` for whichever ``cls`` is passed.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, cls: type[T]) -> None:
|
|
61
|
+
self._cls = cls
|
|
62
|
+
|
|
63
|
+
def __call__(self, request: Request) -> T:
|
|
64
|
+
return request.app.state.provide(self._cls)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import MagicMock
|
|
3
|
+
|
|
4
|
+
from voltwire.db.session import RODatabaseSessionFactory, TransactionContext
|
|
5
|
+
from voltwire.fastapi.db_txs import make_read_only_transaction
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _ro_factory(session: MagicMock) -> MagicMock:
|
|
9
|
+
factory = MagicMock(spec=RODatabaseSessionFactory)
|
|
10
|
+
factory.get_session.return_value = session
|
|
11
|
+
return factory
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _drain(dependency) -> None:
|
|
15
|
+
"""Fully exercise a FastAPI Depends()-wrapped async generator, like FastAPI would."""
|
|
16
|
+
agen = dependency.dependency(MagicMock())
|
|
17
|
+
await agen.__anext__()
|
|
18
|
+
try:
|
|
19
|
+
await agen.__anext__()
|
|
20
|
+
except StopAsyncIteration:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_ro_session_active_during_yield():
|
|
25
|
+
context = TransactionContext()
|
|
26
|
+
session = MagicMock()
|
|
27
|
+
ro_transaction = make_read_only_transaction(lambda _request: _ro_factory(session), lambda _request: context)
|
|
28
|
+
|
|
29
|
+
seen = {}
|
|
30
|
+
|
|
31
|
+
async def run():
|
|
32
|
+
agen = ro_transaction.dependency(MagicMock())
|
|
33
|
+
await agen.__anext__()
|
|
34
|
+
seen["session"] = context.get_session()
|
|
35
|
+
try:
|
|
36
|
+
await agen.__anext__()
|
|
37
|
+
except StopAsyncIteration:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
asyncio.run(run())
|
|
41
|
+
|
|
42
|
+
assert seen["session"] is session
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_session_closed_and_context_cleared_after():
|
|
46
|
+
context = TransactionContext()
|
|
47
|
+
session = MagicMock()
|
|
48
|
+
ro_transaction = make_read_only_transaction(lambda _request: _ro_factory(session), lambda _request: context)
|
|
49
|
+
|
|
50
|
+
asyncio.run(_drain(ro_transaction))
|
|
51
|
+
|
|
52
|
+
session.close.assert_called_once()
|
|
53
|
+
assert context.get_ro_session() is None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_never_commits():
|
|
57
|
+
context = TransactionContext()
|
|
58
|
+
session = MagicMock()
|
|
59
|
+
ro_transaction = make_read_only_transaction(lambda _request: _ro_factory(session), lambda _request: context)
|
|
60
|
+
|
|
61
|
+
asyncio.run(_drain(ro_transaction))
|
|
62
|
+
|
|
63
|
+
session.commit.assert_not_called()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_restores_previous_ro_session_after_completion():
|
|
67
|
+
context = TransactionContext()
|
|
68
|
+
outer_session = MagicMock()
|
|
69
|
+
context.set_ro_session(outer_session)
|
|
70
|
+
|
|
71
|
+
inner_session = MagicMock()
|
|
72
|
+
ro_transaction = make_read_only_transaction(lambda _request: _ro_factory(inner_session), lambda _request: context)
|
|
73
|
+
|
|
74
|
+
asyncio.run(_drain(ro_transaction))
|
|
75
|
+
|
|
76
|
+
assert context.get_ro_session() is outer_session
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from voltwire.db.session import DatabaseSessionFactory, TransactionContext
|
|
7
|
+
from voltwire.fastapi.db_txs import TransactionMiddleware
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build_middleware(transaction_context: TransactionContext, session: MagicMock) -> TransactionMiddleware:
|
|
11
|
+
session_factory = MagicMock(spec=DatabaseSessionFactory)
|
|
12
|
+
session_factory.get_session.return_value = session
|
|
13
|
+
app = MagicMock()
|
|
14
|
+
return TransactionMiddleware(
|
|
15
|
+
app,
|
|
16
|
+
transaction_context=lambda _request: transaction_context,
|
|
17
|
+
session_factory=lambda _request: session_factory,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _response(status_code: int) -> MagicMock:
|
|
22
|
+
response = MagicMock()
|
|
23
|
+
response.status_code = status_code
|
|
24
|
+
return response
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_commits_on_success_status():
|
|
28
|
+
context = TransactionContext()
|
|
29
|
+
session = MagicMock()
|
|
30
|
+
middleware = _build_middleware(context, session)
|
|
31
|
+
call_next = AsyncMock(return_value=_response(200))
|
|
32
|
+
|
|
33
|
+
result = asyncio.run(middleware.dispatch(MagicMock(), call_next))
|
|
34
|
+
|
|
35
|
+
assert result.status_code == 200
|
|
36
|
+
session.commit.assert_called_once()
|
|
37
|
+
session.rollback.assert_not_called()
|
|
38
|
+
session.close.assert_called_once()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_rolls_back_on_error_status():
|
|
42
|
+
context = TransactionContext()
|
|
43
|
+
session = MagicMock()
|
|
44
|
+
middleware = _build_middleware(context, session)
|
|
45
|
+
call_next = AsyncMock(return_value=_response(500))
|
|
46
|
+
|
|
47
|
+
asyncio.run(middleware.dispatch(MagicMock(), call_next))
|
|
48
|
+
|
|
49
|
+
session.rollback.assert_called_once()
|
|
50
|
+
session.commit.assert_not_called()
|
|
51
|
+
session.close.assert_called_once()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_rolls_back_and_reraises_on_exception():
|
|
55
|
+
context = TransactionContext()
|
|
56
|
+
session = MagicMock()
|
|
57
|
+
middleware = _build_middleware(context, session)
|
|
58
|
+
call_next = AsyncMock(side_effect=ValueError("boom"))
|
|
59
|
+
|
|
60
|
+
with pytest.raises(ValueError, match="boom"):
|
|
61
|
+
asyncio.run(middleware.dispatch(MagicMock(), call_next))
|
|
62
|
+
|
|
63
|
+
session.rollback.assert_called_once()
|
|
64
|
+
session.commit.assert_not_called()
|
|
65
|
+
session.close.assert_called_once()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_session_visible_via_transaction_context_during_request():
|
|
69
|
+
context = TransactionContext()
|
|
70
|
+
session = MagicMock()
|
|
71
|
+
middleware = _build_middleware(context, session)
|
|
72
|
+
|
|
73
|
+
seen = {}
|
|
74
|
+
|
|
75
|
+
async def call_next(_request):
|
|
76
|
+
seen["session"] = context.get_session()
|
|
77
|
+
return _response(200)
|
|
78
|
+
|
|
79
|
+
asyncio.run(middleware.dispatch(MagicMock(), call_next))
|
|
80
|
+
|
|
81
|
+
assert seen["session"] is session
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_session_cleared_after_request():
|
|
85
|
+
context = TransactionContext()
|
|
86
|
+
session = MagicMock()
|
|
87
|
+
middleware = _build_middleware(context, session)
|
|
88
|
+
call_next = AsyncMock(return_value=_response(200))
|
|
89
|
+
|
|
90
|
+
asyncio.run(middleware.dispatch(MagicMock(), call_next))
|
|
91
|
+
|
|
92
|
+
assert context.get_session() is None
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
3
|
+
from voltwire.db.session import TransactionContext
|
|
4
|
+
from voltwire.fastapi.db_txs import AppStateResolver
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestAppStateResolver:
|
|
8
|
+
def test_calls_provide_with_the_given_class(self):
|
|
9
|
+
resolved = TransactionContext()
|
|
10
|
+
request = MagicMock()
|
|
11
|
+
request.app.state.provide.return_value = resolved
|
|
12
|
+
|
|
13
|
+
resolver = AppStateResolver(TransactionContext)
|
|
14
|
+
result = resolver(request)
|
|
15
|
+
|
|
16
|
+
request.app.state.provide.assert_called_once_with(TransactionContext)
|
|
17
|
+
assert result is resolved
|
|
18
|
+
|
|
19
|
+
def test_re_resolves_on_every_call(self):
|
|
20
|
+
request = MagicMock()
|
|
21
|
+
resolver = AppStateResolver(TransactionContext)
|
|
22
|
+
|
|
23
|
+
resolver(request)
|
|
24
|
+
resolver(request)
|
|
25
|
+
|
|
26
|
+
assert request.app.state.provide.call_count == 2
|