simple-module-db 0.0.29__tar.gz → 0.0.31__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.
Files changed (28) hide show
  1. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/.gitignore +1 -0
  2. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/PKG-INFO +3 -3
  3. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/pyproject.toml +2 -2
  4. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/__init__.py +3 -0
  5. simple_module_db-0.0.31/simple_module_db/deps.py +49 -0
  6. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/migrations.py +35 -6
  7. simple_module_db-0.0.31/simple_module_db/transaction.py +215 -0
  8. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/_models.py +11 -0
  9. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_migrations.py +43 -0
  10. simple_module_db-0.0.31/tests/test_transaction.py +231 -0
  11. simple_module_db-0.0.29/simple_module_db/deps.py +0 -62
  12. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/LICENSE +0 -0
  13. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/README.md +0 -0
  14. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/audit.py +0 -0
  15. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/base.py +0 -0
  16. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/listeners.py +0 -0
  17. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/mixins.py +0 -0
  18. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/provider.py +0 -0
  19. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/py.typed +0 -0
  20. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/simple_module_db/session.py +0 -0
  21. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/_audit_models.py +0 -0
  22. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/conftest.py +0 -0
  23. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_audit.py +0 -0
  24. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_base.py +0 -0
  25. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_db_logging.py +0 -0
  26. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_mixins.py +0 -0
  27. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_multi_tenancy.py +0 -0
  28. {simple_module_db-0.0.29 → simple_module_db-0.0.31}/tests/test_session.py +0 -0
@@ -63,3 +63,4 @@ Thumbs.db
63
63
  host/client_app/.playwright-cli/*
64
64
  .superpowers/
65
65
  .qa/
66
+ .verify/
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: simple_module_db
3
- Version: 0.0.29
3
+ Version: 0.0.31
4
4
  Summary: Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -25,7 +25,7 @@ Requires-Dist: aiosqlite>=0.20
25
25
  Requires-Dist: alembic>=1.14
26
26
  Requires-Dist: asyncpg>=0.30
27
27
  Requires-Dist: psycopg2-binary>=2.9
28
- Requires-Dist: simple-module-core==0.0.29
28
+ Requires-Dist: simple-module-core==0.0.31
29
29
  Requires-Dist: sqlalchemy[asyncio]>=2.0
30
30
  Requires-Dist: sqlmodel>=0.0.22
31
31
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_db"
3
- version = "0.0.29"
3
+ version = "0.0.31"
4
4
  description = "Per-module SQLModel Base, async session, standard mixins (Audit, SoftDelete, MultiTenant, Versioned) for simple_module"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -28,7 +28,7 @@ dependencies = [
28
28
  # The async app code goes through asyncpg; migration env.py rewrites the
29
29
  # URL to +psycopg2 because Alembic's online migrations are sync-only.
30
30
  "psycopg2-binary>=2.9",
31
- "simple_module_core==0.0.29",
31
+ "simple_module_core==0.0.31",
32
32
  "sqlalchemy[asyncio]>=2.0",
33
33
  "sqlmodel>=0.0.22",
34
34
  ]
@@ -13,10 +13,12 @@ from simple_module_db.migrations import (
13
13
  from simple_module_db.mixins import AuditMixin, MultiTenantMixin, SoftDeleteMixin, VersionedMixin
14
14
  from simple_module_db.provider import DatabaseProvider, detect_provider
15
15
  from simple_module_db.session import DatabaseState, init_db
16
+ from simple_module_db.transaction import CommitBeforeResponseMiddleware, finalize_session
16
17
 
17
18
  __all__ = [
18
19
  "AuditMixin",
19
20
  "AuditRecord",
21
+ "CommitBeforeResponseMiddleware",
20
22
  "DatabaseProvider",
21
23
  "DatabaseState",
22
24
  "MultiTenantMixin",
@@ -27,6 +29,7 @@ __all__ = [
27
29
  "create_module_base",
28
30
  "current_tenant_id",
29
31
  "detect_provider",
32
+ "finalize_session",
30
33
  "get_db",
31
34
  "init_db",
32
35
  "make_include_object",
@@ -0,0 +1,49 @@
1
+ """FastAPI dependencies for database access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import AsyncGenerator
7
+
8
+ from fastapi import Request
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from simple_module_db.transaction import (
12
+ SESSION_START_KEY,
13
+ finalize_session,
14
+ register_request_session,
15
+ rollback_session,
16
+ )
17
+
18
+
19
+ async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
20
+ """Yield an async database session, auto-closing on exit.
21
+
22
+ Commits only when the session has pending writes (``new``, ``dirty``,
23
+ or ``deleted`` objects); read-only handlers exit via ``rollback``.
24
+
25
+ The commit itself normally happens in
26
+ :class:`~simple_module_db.transaction.CommitBeforeResponseMiddleware`,
27
+ which fires while the response is still in the server's hands. The
28
+ finalize below is the fallback for when that middleware isn't in the
29
+ stack, and a no-op when it already ran — FastAPI runs this exit code
30
+ *after* the response has been delivered, so a client that immediately
31
+ reads back what it just wrote would otherwise race the commit and lose
32
+ (GH #257).
33
+
34
+ Usage in FastAPI endpoints::
35
+
36
+ @router.get("/items")
37
+ async def list_items(db: AsyncSession = Depends(get_db)):
38
+ ...
39
+ """
40
+ factory = request.app.state.sm.db.session_factory
41
+ async with factory() as session:
42
+ session.info[SESSION_START_KEY] = time.perf_counter()
43
+ register_request_session(request.scope, session)
44
+ try:
45
+ yield session
46
+ await finalize_session(session)
47
+ except Exception:
48
+ await rollback_session(session)
49
+ raise
@@ -132,6 +132,13 @@ def make_process_revision_directives(
132
132
  not strictly required (dropping the table drops the index) but it keeps
133
133
  autogen output readable.
134
134
 
135
+ The injection is idempotent, and has to be: a dialect that *can* reflect
136
+ expression-based indexes (PostgreSQL) already emits the index itself, so
137
+ re-adding it unconditionally produced a migration with two identical
138
+ ``create_index`` calls that failed on first run with ``DuplicateTable``.
139
+ Any index already named in the op tree — at any nesting depth — is left
140
+ alone, which keeps this dialect-agnostic rather than special-casing SQLite.
141
+
135
142
  Call as::
136
143
 
137
144
  context.configure(
@@ -164,10 +171,34 @@ def _index_is_expression_based(index: Index) -> bool:
164
171
  return any(not isinstance(expr, Column) for expr in index.expressions)
165
172
 
166
173
 
167
- def _inject_create_index_after_create_table(upgrade_ops, expression_indexes) -> None:
168
- existing_index_names = {
169
- getattr(op, "index_name", None) for op in upgrade_ops.ops if isinstance(op, CreateIndexOp)
174
+ def _iter_ops_recursive(container):
175
+ """Yield every op under ``container``, descending into nested op groups.
176
+
177
+ Autogenerate does not emit a flat op list: index operations for a table are
178
+ grouped inside a ``ModifyTableOps`` container alongside the top-level
179
+ ``CreateTableOp``/``DropTableOp``. A dedup check that only looks at
180
+ ``container.ops`` therefore sees no ``CreateIndexOp`` at all and re-injects
181
+ an index the dialect already emitted — which is exactly how a dialect that
182
+ *can* reflect expression-based indexes (PostgreSQL) ended up with a
183
+ duplicate ``CREATE INDEX`` in its initial migration.
184
+ """
185
+ for op in container.ops:
186
+ yield op
187
+ if hasattr(op, "ops"):
188
+ yield from _iter_ops_recursive(op)
189
+
190
+
191
+ def _existing_index_names(container, op_type) -> set[str | None]:
192
+ """Names of every ``op_type`` index op already present anywhere under ``container``."""
193
+ return {
194
+ getattr(op, "index_name", None)
195
+ for op in _iter_ops_recursive(container)
196
+ if isinstance(op, op_type)
170
197
  }
198
+
199
+
200
+ def _inject_create_index_after_create_table(upgrade_ops, expression_indexes) -> None:
201
+ existing_index_names = _existing_index_names(upgrade_ops, CreateIndexOp)
171
202
  new_ops: list = []
172
203
  for op in upgrade_ops.ops:
173
204
  new_ops.append(op)
@@ -188,9 +219,7 @@ def _inject_create_index_after_create_table(upgrade_ops, expression_indexes) ->
188
219
 
189
220
 
190
221
  def _inject_drop_index_before_drop_table(downgrade_ops, expression_indexes) -> None:
191
- existing_drop_names = {
192
- getattr(op, "index_name", None) for op in downgrade_ops.ops if isinstance(op, DropIndexOp)
193
- }
222
+ existing_drop_names = _existing_index_names(downgrade_ops, DropIndexOp)
194
223
  new_ops: list = []
195
224
  for op in downgrade_ops.ops:
196
225
  if isinstance(op, DropTableOp):
@@ -0,0 +1,215 @@
1
+ """Commit the request's unit of work before the response leaves the server.
2
+
3
+ ``get_db`` is a FastAPI ``yield`` dependency, and FastAPI runs a yield
4
+ dependency's exit code *after* the response has been delivered. Committing
5
+ there means a client that creates a row and immediately references it by id in
6
+ a second request loses the race: the create's ``201`` reaches the client before
7
+ the create's commit runs, so the follow-up request opens a fresh session, finds
8
+ nothing, and 404s. It is deterministic rather than flaky — the follow-up
9
+ request reliably beats the post-response commit — which is why a seed script
10
+ publishes 0 pages on its first pass and all of them on a second, identical one.
11
+ See GH #257.
12
+
13
+ This module moves the commit to the last point that is still *inside* the
14
+ request: the ASGI ``http.response.start`` message. Nothing has reached the
15
+ client yet, so a failure there can still become a 500, and every caller —
16
+ seeds, provisioning scripts, integration tests, single-run k8s jobs — gets the
17
+ guarantee without changing a line of endpoint code.
18
+
19
+ A session is finalized exactly once. Whichever runs first wins and the other
20
+ becomes a no-op, so the framework stays correct when the middleware is absent
21
+ (a bare ``get_db``, a WebSocket, a test calling the dependency directly) and on
22
+ the error path, where FastAPI unwinds the dependency — rolling it back —
23
+ *before* the error response is sent.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import logging
30
+ import time
31
+
32
+ from sqlalchemy.ext.asyncio import AsyncSession
33
+
34
+ from simple_module_db.listeners import SESSION_HAS_WRITES_KEY
35
+
36
+ logger = logging.getLogger("simple_module.db")
37
+
38
+ REQUEST_SESSIONS_KEY = "sm_db_sessions"
39
+ """Key under ASGI ``scope["state"]`` holding the sessions opened this request."""
40
+
41
+ SESSION_START_KEY = "sm_db_started_at"
42
+ """``session.info`` key carrying the perf-counter reading taken at open."""
43
+
44
+ _FINALIZED_KEY = "sm_db_finalized"
45
+
46
+ _INTERNAL_ERROR_BODY = json.dumps({"detail": "Internal Server Error"}).encode()
47
+
48
+
49
+ def _elapsed_ms(session: AsyncSession) -> float:
50
+ start = session.info.get(SESSION_START_KEY)
51
+ return round((time.perf_counter() - start) * 1000, 2) if start else 0.0
52
+
53
+
54
+ def _has_pending(session: AsyncSession) -> bool:
55
+ """Whether ``session`` holds work that still needs committing.
56
+
57
+ ``has_writes`` is stamped by the after_flush listener and survives the flush
58
+ emptying ``session.new``/``.dirty``/``.deleted`` — the raw collections alone
59
+ would report a flushed-but-uncommitted write as read-only.
60
+ """
61
+ return bool(
62
+ session.info.get(SESSION_HAS_WRITES_KEY) or session.new or session.dirty or session.deleted
63
+ )
64
+
65
+
66
+ def _settle(session: AsyncSession) -> None:
67
+ """Mark the session finalized and clear the pending-write marker.
68
+
69
+ Clearing matters: the marker is sticky, so without this a later finalize
70
+ would try to re-commit a session whose work has already landed.
71
+ """
72
+ session.info[_FINALIZED_KEY] = True
73
+ session.info.pop(SESSION_HAS_WRITES_KEY, None)
74
+
75
+
76
+ async def finalize_session(session: AsyncSession) -> None:
77
+ """Commit ``session`` if it has pending writes, else roll it back.
78
+
79
+ Safe to call more than once, and deliberately **re-armable** rather than
80
+ one-shot. Work can still be done after the response has started — Starlette
81
+ runs ``BackgroundTasks`` once the body is sent, and a ``StreamingResponse``
82
+ writes its body after ``http.response.start`` — and both share this request's
83
+ session. A one-shot claim would let the middleware's commit consume the
84
+ session and then silently drop those later writes: they would flush and
85
+ never commit. So each call commits whatever is pending *at that moment*, and
86
+ ``get_db``'s exit code still runs afterwards to catch the rest.
87
+
88
+ Read-only handlers exit via ``rollback`` — one round-trip cheaper than
89
+ ``commit``, and it keeps read-only queries from showing up as writes in
90
+ query logs / ``pg_stat_statements``. A repeat call with nothing new pending
91
+ returns immediately rather than paying for a second rollback.
92
+
93
+ On commit failure the session is rolled back before the error propagates,
94
+ so the caller never has to reason about a half-finalized session.
95
+ """
96
+ if not _has_pending(session):
97
+ if session.info.get(_FINALIZED_KEY):
98
+ return
99
+ _settle(session)
100
+ await session.rollback()
101
+ logger.debug(
102
+ "db.session.read_only",
103
+ extra={"operation": "read_only_rollback", "db_duration_ms": _elapsed_ms(session)},
104
+ )
105
+ return
106
+ try:
107
+ await session.commit()
108
+ except Exception:
109
+ await session.rollback()
110
+ raise
111
+ finally:
112
+ # Settled either way: a failed commit must not be retried by the
113
+ # fallback finalize in get_db's exit code.
114
+ _settle(session)
115
+ logger.info(
116
+ "db.session.commit",
117
+ extra={"operation": "commit", "db_duration_ms": _elapsed_ms(session)},
118
+ )
119
+
120
+
121
+ async def rollback_session(session: AsyncSession) -> None:
122
+ """Roll ``session`` back and drop its pending work. Idempotent."""
123
+ if session.info.get(_FINALIZED_KEY) and not _has_pending(session):
124
+ return
125
+ _settle(session)
126
+ await session.rollback()
127
+ logger.warning(
128
+ "db.session.rollback",
129
+ extra={"operation": "rollback", "db_duration_ms": _elapsed_ms(session)},
130
+ )
131
+
132
+
133
+ def register_request_session(scope: dict, session: AsyncSession) -> None:
134
+ """Enlist ``session`` for commit-before-response, if the middleware is installed.
135
+
136
+ A no-op when it isn't: ``get_db`` still commits in its own exit code, just
137
+ later. That keeps the dependency usable on its own — in a WebSocket handler,
138
+ a background task, or a test that never builds the middleware stack.
139
+ """
140
+ sessions = scope.get("state", {}).get(REQUEST_SESSIONS_KEY)
141
+ if sessions is None:
142
+ return
143
+ sessions.append(session)
144
+
145
+
146
+ class CommitBeforeResponseMiddleware:
147
+ """Finalize this request's DB sessions before the response is transmitted.
148
+
149
+ Pure ASGI rather than ``BaseHTTPMiddleware`` because the hook point is the
150
+ ``send`` channel, not the response object: intercepting
151
+ ``http.response.start`` is what lets the commit land before any byte is
152
+ written, and lets a commit failure still be turned into a 500.
153
+
154
+ Install this **innermost** (add it first — Starlette's ``add_middleware`` is
155
+ LIFO) so its wrapper is the first to see the response and the commit happens
156
+ as early as possible.
157
+ """
158
+
159
+ def __init__(self, app):
160
+ self.app = app
161
+
162
+ async def __call__(self, scope, receive, send) -> None:
163
+ if scope["type"] != "http":
164
+ await self.app(scope, receive, send)
165
+ return
166
+
167
+ sessions: list[AsyncSession] = []
168
+ scope.setdefault("state", {})[REQUEST_SESSIONS_KEY] = sessions
169
+ aborted = False
170
+
171
+ async def send_wrapper(message) -> None:
172
+ nonlocal aborted
173
+ if aborted:
174
+ # The response we replaced is still streaming its body into a
175
+ # channel we've already closed off. Drop it.
176
+ return
177
+ if message["type"] == "http.response.start" and sessions:
178
+ try:
179
+ # A request normally has exactly one session (FastAPI caches
180
+ # the dependency). With several — Depends(get_db,
181
+ # use_cache=False) — a failure part-way leaves the earlier
182
+ # commits durable while the client sees a 500. There is no
183
+ # cross-session atomicity to recover here short of two-phase
184
+ # commit; the log line below is what makes it diagnosable.
185
+ for session in sessions:
186
+ await finalize_session(session)
187
+ except Exception:
188
+ aborted = True
189
+ logger.exception(
190
+ "db.session.commit_failed", extra={"operation": "commit_failed"}
191
+ )
192
+ await _send_internal_error(send)
193
+ return
194
+ await send(message)
195
+
196
+ await self.app(scope, receive, send_wrapper)
197
+
198
+
199
+ async def _send_internal_error(send) -> None:
200
+ """Replace the not-yet-sent response with a 500.
201
+
202
+ Reachable only from ``http.response.start``, so nothing has been written
203
+ and this cannot collide with a partially-sent response.
204
+ """
205
+ await send(
206
+ {
207
+ "type": "http.response.start",
208
+ "status": 500,
209
+ "headers": [
210
+ (b"content-type", b"application/json"),
211
+ (b"content-length", str(len(_INTERNAL_ERROR_BODY)).encode()),
212
+ ],
213
+ }
214
+ )
215
+ await send({"type": "http.response.body", "body": _INTERNAL_ERROR_BODY})
@@ -27,3 +27,14 @@ class _TenantSoftItem(_TenantBase, MultiTenantMixin, SoftDeleteMixin, table=True
27
27
  __tablename__ = "mt_test_soft_item"
28
28
  id: int | None = Field(default=None, primary_key=True)
29
29
  name: str = Field(max_length=100)
30
+
31
+
32
+ _TxnBase = create_module_base("txn_test")
33
+
34
+
35
+ class _TxnThing(_TxnBase, table=True): # ty: ignore[unsupported-base]
36
+ """Plain table for exercising when the request's unit of work commits."""
37
+
38
+ __tablename__ = "txn_test_thing"
39
+ id: int | None = Field(default=None, primary_key=True)
40
+ name: str = Field(max_length=100)
@@ -188,6 +188,49 @@ class TestProcessRevisionDirectives:
188
188
  index_ops = [op for op in directives[0].upgrade_ops.ops if isinstance(op, CreateIndexOp)]
189
189
  assert len(index_ops) == 1
190
190
 
191
+ def test_does_not_double_inject_when_nested_in_modify_table_ops(self):
192
+ """Autogenerate does not hand us a flat op list: index ops for a table
193
+ arrive inside a ``ModifyTableOps`` group next to the ``CreateTableOp``.
194
+ A dedup check that only scans the top level sees no ``CreateIndexOp``
195
+ there and re-injects the index PostgreSQL already emitted, producing a
196
+ migration that dies with ``DuplicateTable`` on its first run."""
197
+ from alembic.operations.ops import CreateIndexOp, ModifyTableOps
198
+ from simple_module_db.migrations import make_process_revision_directives
199
+
200
+ meta = self._build_meta()
201
+ idx = next(iter(meta.tables["things"].indexes))
202
+ nested = ModifyTableOps("things", ops=[CreateIndexOp.from_index(idx)])
203
+ directives = self._build_directives("things", nested)
204
+ make_process_revision_directives(meta)(None, None, directives)
205
+
206
+ assert self._count_index_ops(directives[0].upgrade_ops, CreateIndexOp) == 1
207
+
208
+ def test_does_not_double_inject_drop_nested_in_modify_table_ops(self):
209
+ """Same nesting applies to the downgrade: the reflected ``DropIndexOp``
210
+ sits inside a ``ModifyTableOps`` group before the ``DropTableOp``."""
211
+ from alembic.operations.ops import DropIndexOp, ModifyTableOps
212
+ from simple_module_db.migrations import make_process_revision_directives
213
+
214
+ meta = self._build_meta()
215
+ directives = self._build_directives("things")
216
+ directives[0].downgrade_ops.ops.insert(
217
+ 0, ModifyTableOps("things", ops=[DropIndexOp("ix_things_email_lower", "things")])
218
+ )
219
+ make_process_revision_directives(meta)(None, None, directives)
220
+
221
+ assert self._count_index_ops(directives[0].downgrade_ops, DropIndexOp) == 1
222
+
223
+ @staticmethod
224
+ def _count_index_ops(container, op_type) -> int:
225
+ """Count ``op_type`` ops anywhere under ``container``, nesting included."""
226
+ total = 0
227
+ for op in container.ops:
228
+ if isinstance(op, op_type):
229
+ total += 1
230
+ if hasattr(op, "ops"):
231
+ total += TestProcessRevisionDirectives._count_index_ops(op, op_type)
232
+ return total
233
+
191
234
  def test_ignores_column_based_indexes(self):
192
235
  """Plain column indexes are already handled correctly by autogenerate;
193
236
  the hook must not touch them."""
@@ -0,0 +1,231 @@
1
+ """Tests for commit-before-response (GH #257).
2
+
3
+ A client that creates a row and immediately references it by id used to get a
4
+ deterministic 404: FastAPI runs a ``yield`` dependency's exit code *after* the
5
+ response is delivered, so the create's ``201`` beat the create's commit and the
6
+ follow-up request opened a fresh session that saw nothing.
7
+
8
+ These use a **file-backed** SQLite database on purpose. An in-memory SQLite URL
9
+ shares one connection across every session, which makes uncommitted writes
10
+ visible to the "second request" and hides the very race under test.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import AsyncGenerator
16
+ from types import SimpleNamespace
17
+
18
+ import httpx
19
+ import pytest
20
+ from _models import _TxnBase, _TxnThing
21
+ from fastapi import BackgroundTasks, Depends, FastAPI
22
+ from fastapi.responses import StreamingResponse
23
+ from simple_module_db.deps import get_db
24
+ from simple_module_db.listeners import register_listeners
25
+ from simple_module_db.session import init_db
26
+ from simple_module_db.transaction import CommitBeforeResponseMiddleware
27
+ from sqlalchemy.ext.asyncio import AsyncSession
28
+ from sqlmodel import select
29
+
30
+
31
+ def _build_app(db_state, *, with_middleware: bool = True) -> FastAPI:
32
+ """A miniature host: create flushes only, read opens its own session."""
33
+ app = FastAPI()
34
+ if with_middleware:
35
+ app.add_middleware(CommitBeforeResponseMiddleware)
36
+ app.state.sm = SimpleNamespace(db=db_state)
37
+
38
+ @app.post("/things", status_code=201)
39
+ async def create(name: str, db: AsyncSession = Depends(get_db)):
40
+ # Deliberately no commit() — get_db owns the unit of work, which is
41
+ # exactly the pattern the framework documents for service code.
42
+ thing = _TxnThing(name=name)
43
+ db.add(thing)
44
+ await db.flush()
45
+ return {"id": thing.id}
46
+
47
+ @app.get("/things/{thing_id}")
48
+ async def read(thing_id: int, db: AsyncSession = Depends(get_db)):
49
+ found = (
50
+ await db.execute(select(_TxnThing).where(_TxnThing.id == thing_id))
51
+ ).scalar_one_or_none()
52
+ if found is None:
53
+ return {"found": False}
54
+ return {"found": True, "name": found.name}
55
+
56
+ @app.post("/boom", status_code=201)
57
+ async def boom(db: AsyncSession = Depends(get_db)):
58
+ db.add(_TxnThing(name="doomed"))
59
+ await db.flush()
60
+ raise RuntimeError("endpoint blew up after writing")
61
+
62
+ return app
63
+
64
+
65
+ @pytest.fixture
66
+ async def db_state(tmp_path) -> AsyncGenerator[object, None]:
67
+ state = init_db(f"sqlite+aiosqlite:///{tmp_path / 'txn.db'}")
68
+ try:
69
+ # The after_flush listener is what marks a session as having writes
70
+ # once flush() has emptied new/dirty/deleted — create_app registers it,
71
+ # so the fixture must too or every request looks read-only.
72
+ register_listeners(state)
73
+ async with state.engine.begin() as conn:
74
+ await conn.run_sync(_TxnBase.metadata.create_all)
75
+ yield state
76
+ finally:
77
+ await state.engine.dispose()
78
+
79
+
80
+ async def _client(app) -> httpx.AsyncClient:
81
+ return httpx.AsyncClient(
82
+ transport=httpx.ASGITransport(app=app, raise_app_exceptions=False),
83
+ base_url="http://test",
84
+ )
85
+
86
+
87
+ class TestCommitBeforeResponse:
88
+ async def test_created_row_is_readable_on_the_very_next_request(self, db_state):
89
+ """The client-visible contract: no second pass, no retry loop.
90
+
91
+ Note this one does *not* fail without the fix — httpx's in-process ASGI
92
+ transport awaits the whole request, teardown included, before issuing
93
+ the next one, so the follow-up can never beat the commit here. It
94
+ documents the intended behaviour; the ordering guarantee is pinned by
95
+ ``test_write_is_durable_before_the_response_is_delivered`` below, which
96
+ does fail without the middleware.
97
+ """
98
+ async with await _client(_build_app(db_state)) as client:
99
+ created = await client.post("/things", params={"name": "page-1"})
100
+ assert created.status_code == 201
101
+ thing_id = created.json()["id"]
102
+
103
+ found = await client.get(f"/things/{thing_id}")
104
+
105
+ assert found.json() == {"found": True, "name": "page-1"}
106
+
107
+ async def test_write_is_durable_before_the_response_is_delivered(self, db_state):
108
+ """Stronger than the round-trip above: assert against a connection that
109
+ the request never touched, at the moment the response is emitted."""
110
+ app = _build_app(db_state)
111
+ visible_at_response_start: list[bool] = []
112
+
113
+ class Probe:
114
+ def __init__(self, inner):
115
+ self.inner = inner
116
+
117
+ async def __call__(self, scope, receive, send):
118
+ async def spy(message):
119
+ if message["type"] == "http.response.start":
120
+ async with db_state.session_factory() as other:
121
+ rows = (await other.execute(select(_TxnThing))).scalars().all()
122
+ visible_at_response_start.append(bool(rows))
123
+ await send(message)
124
+
125
+ await self.inner(scope, receive, spy)
126
+
127
+ async with await _client(Probe(app)) as client:
128
+ assert (await client.post("/things", params={"name": "durable"})).status_code == 201
129
+
130
+ assert visible_at_response_start == [True], (
131
+ "row was not committed by the time the response left the server"
132
+ )
133
+
134
+ async def test_endpoint_exception_still_rolls_back(self, db_state):
135
+ """The middleware must not turn a failed request's writes into a commit."""
136
+ async with await _client(_build_app(db_state)) as client:
137
+ assert (await client.post("/boom")).status_code == 500
138
+
139
+ async with db_state.session_factory() as session:
140
+ assert (await session.execute(select(_TxnThing))).scalars().all() == []
141
+
142
+ async def test_commit_failure_becomes_a_500_and_persists_nothing(self, db_state, monkeypatch):
143
+ """A commit that blows up at response.start replaces the response rather
144
+ than shipping a 201 for work that never landed.
145
+
146
+ The failure is injected at ``AsyncSession.commit`` rather than at
147
+ ``finalize_session``: stubbing the finalizer would bypass its bookkeeping,
148
+ leaving get_db's fallback free to commit the row the test claims was
149
+ lost — the assertion would pass while the guarantee was broken.
150
+ """
151
+
152
+ async def explode(self, *args, **kwargs):
153
+ raise RuntimeError("commit failed")
154
+
155
+ monkeypatch.setattr(AsyncSession, "commit", explode)
156
+
157
+ async with await _client(_build_app(db_state)) as client:
158
+ response = await client.post("/things", params={"name": "nope"})
159
+
160
+ assert response.status_code == 500
161
+ assert response.json() == {"detail": "Internal Server Error"}
162
+
163
+ monkeypatch.undo()
164
+ async with db_state.session_factory() as session:
165
+ assert (await session.execute(select(_TxnThing))).scalars().all() == []
166
+
167
+ async def test_background_task_writes_still_commit(self, db_state):
168
+ """Starlette runs BackgroundTasks after the body is sent, on this same
169
+ session. Finalizing at response.start must not consume the session and
170
+ strand them — they flushed but never committed, losing writes silently."""
171
+ app = _build_app(db_state)
172
+
173
+ @app.post("/with-task", status_code=202)
174
+ async def with_task(bg: BackgroundTasks, db: AsyncSession = Depends(get_db)):
175
+ db.add(_TxnThing(name="before-response"))
176
+ await db.flush()
177
+
178
+ async def task():
179
+ db.add(_TxnThing(name="from-background-task"))
180
+ await db.flush()
181
+
182
+ bg.add_task(task)
183
+ return {"ok": True}
184
+
185
+ async with await _client(app) as client:
186
+ assert (await client.post("/with-task")).status_code == 202
187
+
188
+ async with db_state.session_factory() as session:
189
+ names = sorted(t.name for t in (await session.execute(select(_TxnThing))).scalars())
190
+ assert names == ["before-response", "from-background-task"]
191
+
192
+ async def test_streaming_response_body_writes_still_commit(self, db_state):
193
+ """A StreamingResponse writes its body *after* response.start, so work
194
+ done while streaming lands after the middleware's commit."""
195
+ app = _build_app(db_state)
196
+
197
+ @app.get("/stream")
198
+ async def stream(db: AsyncSession = Depends(get_db)):
199
+ db.add(_TxnThing(name="before-stream"))
200
+ await db.flush()
201
+
202
+ async def body():
203
+ yield b"chunk"
204
+ db.add(_TxnThing(name="during-stream"))
205
+ await db.flush()
206
+
207
+ return StreamingResponse(body())
208
+
209
+ async with await _client(app) as client:
210
+ assert (await client.get("/stream")).status_code == 200
211
+
212
+ async with db_state.session_factory() as session:
213
+ names = sorted(t.name for t in (await session.execute(select(_TxnThing))).scalars())
214
+ assert names == ["before-stream", "during-stream"]
215
+
216
+ async def test_still_commits_without_the_middleware(self, db_state):
217
+ """get_db keeps its own fallback finalize, so the dependency works
218
+ standalone — in a WebSocket handler, or a test that builds no stack."""
219
+ app = _build_app(db_state, with_middleware=False)
220
+ async with await _client(app) as client:
221
+ assert (await client.post("/things", params={"name": "solo"})).status_code == 201
222
+
223
+ async with db_state.session_factory() as session:
224
+ names = [t.name for t in (await session.execute(select(_TxnThing))).scalars().all()]
225
+ assert names == ["solo"]
226
+
227
+ async def test_read_only_request_is_not_committed(self, db_state):
228
+ """Read-only handlers still exit via rollback — one round-trip cheaper."""
229
+ app = _build_app(db_state)
230
+ async with await _client(app) as client:
231
+ assert (await client.get("/things/999")).json() == {"found": False}
@@ -1,62 +0,0 @@
1
- """FastAPI dependencies for database access."""
2
-
3
- from __future__ import annotations
4
-
5
- import logging
6
- import time
7
- from collections.abc import AsyncGenerator
8
-
9
- from fastapi import Request
10
- from sqlalchemy.ext.asyncio import AsyncSession
11
-
12
- from simple_module_db.listeners import SESSION_HAS_WRITES_KEY
13
-
14
- _db_logger = logging.getLogger("simple_module.db")
15
-
16
-
17
- async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
18
- """Yield an async database session, auto-closing on exit.
19
-
20
- Commits only when the session has pending writes (``new``, ``dirty``,
21
- or ``deleted`` objects). Read-only handlers still open an implicit
22
- transaction but exit via ``rollback`` — that's one round-trip
23
- cheaper than ``commit`` and keeps read-only queries from showing up
24
- as writes in query logs / ``pg_stat_statements``.
25
-
26
- Usage in FastAPI endpoints::
27
-
28
- @router.get("/items")
29
- async def list_items(db: AsyncSession = Depends(get_db)):
30
- ...
31
- """
32
- factory = request.app.state.sm.db.session_factory
33
- start = time.perf_counter()
34
- async with factory() as session:
35
- try:
36
- yield session
37
- # ``has_writes`` is set by the after_flush listener and
38
- # survives the flush emptying session.new/.dirty/.deleted.
39
- has_pending = bool(
40
- session.info.get(SESSION_HAS_WRITES_KEY)
41
- or session.new
42
- or session.dirty
43
- or session.deleted
44
- )
45
- if has_pending:
46
- await session.commit()
47
- op, log_message = "commit", "db.session.commit"
48
- log = _db_logger.info
49
- else:
50
- await session.rollback()
51
- op, log_message = "read_only_rollback", "db.session.read_only"
52
- log = _db_logger.debug
53
- duration_ms = round((time.perf_counter() - start) * 1000, 2)
54
- log(log_message, extra={"operation": op, "db_duration_ms": duration_ms})
55
- except Exception:
56
- await session.rollback()
57
- duration_ms = round((time.perf_counter() - start) * 1000, 2)
58
- _db_logger.warning(
59
- "db.session.rollback",
60
- extra={"operation": "rollback", "db_duration_ms": duration_ms},
61
- )
62
- raise