Coverage for fastapi_restly / db / _session.py: 98%
196 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-09 18:41 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-09 18:41 +0000
1import warnings
2from collections.abc import AsyncIterator, Callable, Iterator
3from inspect import Parameter, Signature, signature
4from typing import Annotated, Any, cast
6from fastapi import Depends, FastAPI
7from sqlalchemy import Connection, Engine, MetaData, create_engine, event
8from sqlalchemy.ext.asyncio import (
9 AsyncConnection,
10 AsyncEngine,
11 async_sessionmaker,
12 create_async_engine,
13)
14from sqlalchemy.ext.asyncio import AsyncSession as SA_AsyncSession
15from sqlalchemy.orm import DeclarativeBase, sessionmaker
16from sqlalchemy.orm import Session as SA_Session
18from .._exception_handlers import register_default_exception_handlers
19from .._routes import iter_routes
20from ..exc import RestlyConfigurationError, RestlyUncommittedChangesWarning
21from ._engine_defaults import apply_connect_hooks, engine_options
22from ._globals import _fr_globals
25def _setup_async_database_connection(
26 async_database_url: str | None = None,
27 *,
28 async_engine: AsyncEngine | None = None,
29 async_make_session: async_sessionmaker[Any] | None = None,
30) -> async_sessionmaker[Any]:
31 if not async_make_session:
32 if not async_engine:
33 async_engine = create_async_engine(
34 async_database_url, # type: ignore[arg-type]
35 **engine_options(async_database_url), # type: ignore[arg-type]
36 )
37 apply_connect_hooks(async_engine)
38 async_make_session = async_sessionmaker(
39 bind=async_engine, autoflush=False, expire_on_commit=False
40 )
42 factory_kw = getattr(async_make_session, "kw", None)
43 if factory_kw is not None and factory_kw.get("expire_on_commit", True): 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true
44 warnings.warn(
45 "The async session factory passed to fr.configure() has "
46 "expire_on_commit=True. Restly's write handlers commit inside the "
47 "request, so the commit expires every loaded attribute on the "
48 "object the response is built from. Reading one back then happens in "
49 "plain async context, where SQLAlchemy raises MissingGreenlet: always "
50 "in the response serializer, and earlier too if an after_action_commit hook "
51 "reads the committed object. Pass expire_on_commit=False to your "
52 "async_sessionmaker.",
53 stacklevel=3,
54 )
56 _fr_globals.async_database_url = async_database_url
57 _fr_globals.async_make_session = async_make_session
58 return async_make_session
61def _setup_database_connection(
62 database_url: str | None = None,
63 *,
64 engine: Engine | None = None,
65 make_session: sessionmaker[Any] | None = None,
66) -> sessionmaker[Any]:
67 if make_session is None:
68 if engine is None:
69 # create_engine is overloaded, so the options splat costs pyright
70 # the return type; the cast restores it.
71 engine = cast(
72 Engine,
73 create_engine(
74 database_url, # type: ignore[arg-type]
75 **engine_options(database_url), # type: ignore[arg-type]
76 ),
77 )
78 apply_connect_hooks(engine)
79 make_session = sessionmaker(bind=engine, expire_on_commit=False)
81 _fr_globals.database_url = database_url
82 _fr_globals.make_session = make_session
83 return make_session
86async def _health() -> dict[str, str]:
87 """Liveness response for the route ``fr.configure(health=...)`` mounts."""
88 return {"status": "ok"}
91def _register_health_route(app: FastAPI, path: str) -> None:
92 """Mount the liveness endpoint at ``path``.
94 Skips if ``app`` already has a route at that path, whatever its method: a
95 repeated :func:`configure` call does not mount a second one, and an
96 application's own endpoint there is left in place.
97 """
98 if any(getattr(route, "path", None) == path for route in iter_routes(app.routes)):
99 return
100 # name= keeps the private handler out of the generated operationId.
101 app.add_api_route(path, _health, methods=["GET"], name="health")
104def configure(
105 app: FastAPI | None = None,
106 *,
107 async_database_url: str | None = None,
108 async_engine: AsyncEngine | None = None,
109 async_make_session: async_sessionmaker[Any] | None = None,
110 database_url: str | None = None,
111 engine: Engine | None = None,
112 make_session: sessionmaker[Any] | None = None,
113 session_generator: Callable[..., AsyncIterator[SA_AsyncSession]] | None = None,
114 sync_session_generator: Callable[..., Iterator[SA_Session]] | None = None,
115 warn_on_misuse: bool | None = None,
116 warn_on_uncommitted: bool | None = None,
117 install_default_exception_handlers: bool = True,
118 health: str | None = None,
119) -> None:
120 """Configure FastAPI-Restly. Call once at startup.
122 Async support comes from ``async_database_url``, ``async_engine``, or
123 ``async_make_session``; sync support from ``database_url``, ``engine``, or
124 ``make_session``. Pass both sets if the application uses both.
126 A URL is the one form Restly builds an engine from, and that engine gets
127 defaults suited to a web application: ``StaticPool`` for in-memory SQLite,
128 ``PRAGMA foreign_keys=ON`` on every SQLite connection, and ``pool_pre_ping``
129 with ``pool_recycle`` on PostgreSQL. Pool sizing is left alone, and so is
130 anything written into the database itself, which is why ``journal_mode=WAL``
131 is not among them. Every other form is used as given, so passing ``engine=``
132 declines all of this.
134 Restly owns the commit: the CRUD handlers and ``write_action`` run
135 ``before_action_commit`` -> commit -> ``after_action_commit`` around your domain logic. A
136 custom session generator constructs, yields, and cleans up, and must not
137 commit. A custom write route brackets its mutation with ``write_action(...)``
138 or commits the session itself.
140 Configure custom generators before registering routes that use ``SessionDep``
141 or ``AsyncSessionDep``. FastAPI resolves the generator as a sub-dependency,
142 sharing its cached session with the application's ``Depends(get_db)``.
143 Explicit view dependencies remain as declared. Generators used outside a
144 request through ``open_session()`` or ``open_async_session()`` must be callable
145 without arguments.
147 :func:`fastapi_restly.testing.configure_tests` freezes the database sources
148 for the rest of that pytest process, so configure the application before
149 enabling managed testing; changing them afterwards raises.
151 :param app: Application to install the default exception handlers and the
152 health route on. Restly also checks its OpenAPI spec for two classes
153 with the same name (see
154 :class:`~fastapi_restly.exc.RestlyDuplicateSchemaNameWarning`).
155 :param async_database_url: Async URL to build an
156 :class:`~sqlalchemy.ext.asyncio.AsyncEngine` from.
157 :param async_engine: Async engine to use as given.
158 :param async_make_session: ``async_sessionmaker`` to use as given.
159 :param database_url: Sync URL to build an
160 :class:`~sqlalchemy.engine.Engine` from.
161 :param engine: Sync engine to use as given.
162 :param make_session: ``sessionmaker`` to use as given.
163 :param session_generator: Callable yielding the
164 :class:`~sqlalchemy.ext.asyncio.AsyncSession` for each request.
165 :param sync_session_generator: Callable yielding the
166 :class:`~sqlalchemy.orm.Session` for each request.
167 :param warn_on_misuse: Emit
168 :class:`~fastapi_restly.exc.RestlyMisuseWarning` when ``include_view``
169 registers a view with an endpoint method override, a direct
170 ``session.commit()``, a hand-rolled CRUD route set on a bare ``View``,
171 or a scalar foreign key typed as an ``IDRef`` / ``IDSchema`` reference
172 instead of ``fr.MustExist``. Off by default; set it before registering
173 views.
174 :param warn_on_uncommitted: Emit
175 :class:`~fastapi_restly.exc.RestlyUncommittedChangesWarning` when a
176 request finishes with uncommitted changes. On by default. Suppress one
177 deliberate case with ``session.info["_fr_suppress_uncommitted"] = True``
178 rather than turning the check off.
179 :param install_default_exception_handlers: Install the translator that
180 turns :class:`~sqlalchemy.exc.IntegrityError` into HTTP 409. On by
181 default. Without ``app``, the first
182 :func:`~fastapi_restly.views.include_view` installs them instead.
183 :param health: Path to mount a liveness endpoint on, such as ``"/health"``.
184 It answers ``200`` with ``{"status": "ok"}``, appears in the OpenAPI
185 schema, and makes no database round-trip. Off unless set, and requires
186 ``app``. A route already mounted at that path is left in place.
187 Readiness is a separate endpoint of your own, not a mode of this one.
188 :raises TypeError: No setup argument was given.
189 :raises RestlyConfigurationError: ``health`` is not an absolute path or was
190 passed without ``app``, a generator changed after route registration, or
191 the database configuration changed after ``configure_tests()`` recorded it.
192 """
193 configures_database = any(
194 (
195 async_database_url is not None,
196 async_engine is not None,
197 async_make_session is not None,
198 database_url is not None,
199 engine is not None,
200 make_session is not None,
201 session_generator is not None,
202 sync_session_generator is not None,
203 )
204 )
205 if configures_database and _fr_globals.database_configuration_locked:
206 raise RestlyConfigurationError(
207 "Restly's database configuration cannot be changed after "
208 "fr.testing.configure_tests() recorded it. This is often a "
209 "per-test factory fixture (e.g. a @pytest.fixture that calls "
210 "create_app(...) again for every test) calling fr.configure() "
211 "after collection. Build the application once in conftest.py "
212 "before calling configure_tests(), and do not reconfigure its "
213 "database later during collection or application lifespan "
214 "startup."
215 )
217 if not any(
218 (
219 configures_database,
220 warn_on_misuse is not None,
221 warn_on_uncommitted is not None,
222 health is not None,
223 app is not None and install_default_exception_handlers,
224 )
225 ):
226 raise TypeError("fr.configure() requires at least one setup argument.")
228 for name, generator in (
229 ("session_generator", session_generator),
230 ("sync_session_generator", sync_session_generator),
231 ):
232 if (
233 generator is not None
234 and name in _fr_globals.registered_session_dependencies
235 and generator is not getattr(_fr_globals, name)
236 ):
237 raise RestlyConfigurationError(
238 f"Configure {name} before registering routes that use Restly's "
239 "session dependency. FastAPI has already built their dependency "
240 "graphs. Move fr.configure() before route registration. For "
241 "tests, use app.dependency_overrides instead."
242 )
244 # Validate before applying anything, so a bad health path cannot leave a
245 # half-configured process behind.
246 if health is not None:
247 if not health.startswith("/"):
248 raise RestlyConfigurationError(
249 f"fr.configure(health={health!r}) needs a path starting with "
250 "'/', such as '/health'."
251 )
252 if app is None:
253 raise RestlyConfigurationError(
254 f"fr.configure(health={health!r}) also needs the app to mount "
255 "the route on: fr.configure(app, health=...)."
256 )
258 if warn_on_misuse is not None:
259 _fr_globals.warn_on_misuse = warn_on_misuse
260 if warn_on_uncommitted is not None:
261 _fr_globals.warn_on_uncommitted = warn_on_uncommitted
262 if (
263 async_database_url is not None
264 or async_engine is not None
265 or async_make_session is not None
266 ):
267 _setup_async_database_connection(
268 async_database_url=async_database_url,
269 async_engine=async_engine,
270 async_make_session=async_make_session,
271 )
272 if database_url is not None or engine is not None or make_session is not None:
273 _setup_database_connection(
274 database_url=database_url, engine=engine, make_session=make_session
275 )
276 if session_generator is not None:
277 _fr_globals.session_generator = session_generator
278 if sync_session_generator is not None:
279 _fr_globals.sync_session_generator = sync_session_generator
280 if app is not None:
281 from ..views._openapi import _ensure_patched
283 # The check for two classes with the same name in OpenAPI.
284 _ensure_patched(app)
285 if install_default_exception_handlers:
286 register_default_exception_handlers(app)
287 if health is not None:
288 _register_health_route(app, health)
291def _active_make_session():
292 """The sync factory in force: a test's, if one installed it."""
293 return _fr_globals.test_make_session or _fr_globals.make_session
296def _active_async_make_session():
297 """The async factory in force: a test's, if one installed it."""
298 return _fr_globals.test_async_make_session or _fr_globals.async_make_session
301def get_async_engine() -> AsyncEngine:
302 """Return the async engine registered via configure()."""
303 if _fr_globals.async_make_session is None:
304 raise RestlyConfigurationError(
305 "Call fr.configure() before using get_async_engine()."
306 )
307 # This is a read-only lookup of application configuration. Test overrides may
308 # be bound to a pinned connection or deliberately refuse session creation;
309 # neither changes the engine that configure() registered.
310 bind = _fr_globals.async_make_session.kw["bind"]
311 if isinstance(bind, AsyncConnection): 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true
312 return bind.engine
313 return bind
316def get_engine() -> Engine:
317 """Return the sync engine registered via configure()."""
318 if _fr_globals.make_session is None:
319 raise RestlyConfigurationError("Call fr.configure() before using get_engine().")
320 bind = _active_make_session().kw["bind"]
321 # Under restly_session the factory is bound to a pinned Connection; resolve
322 # its engine so this keeps returning the real Engine.
323 if isinstance(bind, Connection):
324 return bind.engine
325 return bind
328def _resolve_metadata(base_or_metadata: type[DeclarativeBase] | MetaData) -> MetaData:
329 if isinstance(base_or_metadata, MetaData):
330 return base_or_metadata
331 metadata = getattr(base_or_metadata, "metadata", None)
332 if isinstance(metadata, MetaData):
333 return metadata
334 raise TypeError(
335 "create_all() expects a DeclarativeBase subclass or a MetaData; got "
336 f"{base_or_metadata!r}"
337 )
340def create_all(base_or_metadata: type[DeclarativeBase] | MetaData) -> None:
341 """Create all tables for ``base_or_metadata`` on the configured sync engine.
343 A dev/demo convenience over ``metadata.create_all(engine)`` so a quickstart
344 can create its schema without reaching for the raw engine::
346 fr.db.create_all(Base) # or fr.db.create_all(Base.metadata)
348 Accepts a ``DeclarativeBase`` subclass (its ``.metadata`` is used) or a
349 ``MetaData``. Requires :func:`configure` first. Use Alembic migrations in
350 production.
351 """
352 metadata = _resolve_metadata(base_or_metadata)
353 if _fr_globals.make_session is None:
354 raise RestlyConfigurationError("Call fr.configure() before using create_all().")
355 # Create against the configured bind: the engine in production, or the pinned
356 # Connection under restly_session so the schema is visible to the test's
357 # isolated sessions instead of silently landing on a throwaway connection.
358 metadata.create_all(_active_make_session().kw["bind"])
361async def async_create_all(base_or_metadata: type[DeclarativeBase] | MetaData) -> None:
362 """Async equivalent of :func:`create_all`, on the configured async engine.
364 Usage::
366 await fr.db.async_create_all(Base)
367 """
368 metadata = _resolve_metadata(base_or_metadata)
369 if _fr_globals.async_make_session is None:
370 raise RestlyConfigurationError(
371 "Call fr.configure() before using async_create_all()."
372 )
373 bind = _active_async_make_session().kw["bind"]
374 if isinstance(bind, AsyncConnection):
375 # Under restly_async_session: create on the pinned connection, inside the
376 # outer transaction, so the tables are visible to the test's sessions.
377 await bind.run_sync(metadata.create_all)
378 else:
379 async with bind.begin() as conn:
380 await conn.run_sync(metadata.create_all)
383def _should_warn_uncommitted() -> bool:
384 """The uncommitted-changes check applies whenever ``warn_on_uncommitted`` is
385 on. Restly owns the commit, so changes still pending when a request ends are
386 the tell of a custom write route that never committed.
387 """
388 return _fr_globals.warn_on_uncommitted
391def _mark_uncommitted(session: SA_Session, flush_context: Any = None) -> None:
392 session.info["_fr_uncommitted"] = True
395def _clear_uncommitted(session: SA_Session, *args: Any) -> None:
396 session.info.pop("_fr_uncommitted", None)
399def _arm_uncommitted_warning(session: SA_AsyncSession | SA_Session) -> None:
400 """Register flush/commit/rollback listeners so an uncommitted flush at the
401 end of a request can be detected. Async sessions delegate to a sync
402 ``Session``; that is where ORM events fire (and whose ``info`` is shared).
403 """
404 if not _should_warn_uncommitted():
405 return
406 target = getattr(session, "sync_session", session)
407 try:
408 event.listen(target, "after_flush", _mark_uncommitted)
409 event.listen(target, "after_commit", _clear_uncommitted)
410 event.listen(target, "after_rollback", _clear_uncommitted)
411 except Exception:
412 # Best-effort dev aid: unusual sessions (test stubs, or session types
413 # without ORM flush events) opt out. Never break a request.
414 pass
417def _warn_if_uncommitted(session: SA_AsyncSession | SA_Session) -> None:
418 """Warn if the request is ending with changes that were flushed but never
419 committed (the ``_fr_uncommitted`` flag), or added but never flushed
420 (``new``/``dirty``/``deleted``) -- all about to be rolled back. Called only
421 on the success path; an endpoint that raised never reaches this point.
422 """
423 if not _should_warn_uncommitted():
424 return
425 target = getattr(session, "sync_session", session)
426 try:
427 if target.info.get("_fr_suppress_uncommitted"):
428 return
429 uncommitted = bool(
430 target.info.get("_fr_uncommitted")
431 or target.new
432 or target.dirty
433 or target.deleted
434 )
435 except Exception:
436 return # unusual session -> opt out silently
437 if uncommitted:
438 warnings.warn(
439 "Request finished with uncommitted changes in the database session; "
440 "they will be rolled back when the session closes. A custom write "
441 "route must commit its changes -- bracket the mutation with "
442 "write_action(...) (the framework then commits), or reuse "
443 "handle_<verb>(). Only if the rollback is intentional (e.g. a "
444 "validate-then-rollback dry run), suppress the warning for that "
445 'route with session.info["_fr_suppress_uncommitted"] = True.',
446 RestlyUncommittedChangesWarning,
447 stacklevel=2,
448 )
451async def _async_generate_session() -> AsyncIterator[SA_AsyncSession]:
452 """FastAPI dependency for async database session."""
453 if _fr_globals.test_async_make_session is not None:
454 async with _fr_globals.test_async_make_session() as session:
455 yield session
456 return
457 if _fr_globals.session_generator is not None:
458 async for session in _fr_globals.session_generator():
459 yield session
460 return
461 if _fr_globals.async_make_session is None:
462 raise RestlyConfigurationError(
463 "Call fr.configure() before using AsyncSessionDep."
464 )
466 # FastAPI does not support contextmanagers as dependency directly,
467 # but it does support generators. Restly owns the commit (the handle
468 # design runs it inside ``handle_<verb>`` / ``write_action``), so this
469 # dependency only manages the session lifecycle: the context manager rolls
470 # back and closes on the way out, and any change a custom route flushed but
471 # never committed is discarded (and warned about).
472 async with _fr_globals.async_make_session() as session:
473 yield session
476def _session_dependency(dependency: Callable[..., Any]) -> Any:
477 depends = cast(Callable[..., Any], Depends)
478 if "scope" in signature(Depends).parameters: 478 ↛ 480line 478 didn't jump to line 480 because the condition on line 478 was always true
479 return depends(dependency, scope="function")
480 return depends(dependency)
483def _generate_session() -> Iterator[SA_Session]:
484 """FastAPI dependency for sync database session."""
485 # A test's session source wins over everything, including a generator and
486 # anything reconfigured after the test started.
487 if _fr_globals.test_make_session is not None:
488 with _fr_globals.test_make_session() as session:
489 yield session
490 return
491 if _fr_globals.sync_session_generator is not None:
492 for session in _fr_globals.sync_session_generator():
493 yield session
494 return
495 if _fr_globals.make_session is None:
496 raise RestlyConfigurationError("Call fr.configure() before using SessionDep.")
498 with _fr_globals.make_session() as session:
499 yield session
502class _SyncSessionDependency:
503 @property
504 def __signature__(self) -> Signature:
505 return _session_signature(
506 "sync_session_generator", _generate_session, SA_Session
507 )
509 def __call__(self, session: SA_Session) -> Iterator[SA_Session]:
510 _arm_uncommitted_warning(session)
511 yield session
512 _warn_if_uncommitted(session)
515class _AsyncSessionDependency:
516 @property
517 def __signature__(self) -> Signature:
518 return _session_signature(
519 "session_generator", _async_generate_session, SA_AsyncSession
520 )
522 async def __call__(
523 self, session: SA_AsyncSession
524 ) -> AsyncIterator[SA_AsyncSession]:
525 _arm_uncommitted_warning(session)
526 yield session
527 _warn_if_uncommitted(session)
530def _session_signature(
531 generator_name: str, default: Callable[..., Any], session_type: type
532) -> Signature:
533 """Bind the source when FastAPI builds a route's dependency graph.
535 The public alias keeps one callable identity. Its source is a sub-dependency,
536 so an application's Depends(get_db) shares the same cached session. Reading
537 the active context here also keeps app factories from mutating a global
538 function signature when they configure different generators.
539 """
540 _fr_globals.registered_session_dependencies.add(generator_name)
541 source = getattr(_fr_globals, generator_name)
542 dependency = Depends(source) if source is not None else _session_dependency(default)
543 return Signature(
544 [
545 Parameter(
546 "session",
547 Parameter.KEYWORD_ONLY,
548 default=dependency,
549 annotation=session_type,
550 )
551 ]
552 )
555SessionDep = Annotated[SA_Session, _session_dependency(_SyncSessionDependency())]
556AsyncSessionDep = Annotated[
557 SA_AsyncSession, _session_dependency(_AsyncSessionDependency())
558]