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

1import warnings 

2from collections.abc import AsyncIterator, Callable, Iterator 

3from inspect import Parameter, Signature, signature 

4from typing import Annotated, Any, cast 

5 

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 

17 

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 

23 

24 

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 ) 

41 

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 ) 

55 

56 _fr_globals.async_database_url = async_database_url 

57 _fr_globals.async_make_session = async_make_session 

58 return async_make_session 

59 

60 

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) 

80 

81 _fr_globals.database_url = database_url 

82 _fr_globals.make_session = make_session 

83 return make_session 

84 

85 

86async def _health() -> dict[str, str]: 

87 """Liveness response for the route ``fr.configure(health=...)`` mounts.""" 

88 return {"status": "ok"} 

89 

90 

91def _register_health_route(app: FastAPI, path: str) -> None: 

92 """Mount the liveness endpoint at ``path``. 

93 

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") 

102 

103 

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. 

121 

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. 

125 

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. 

133 

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. 

139 

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. 

146 

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. 

150 

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 ) 

216 

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.") 

227 

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 ) 

243 

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 ) 

257 

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 

282 

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) 

289 

290 

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 

294 

295 

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 

299 

300 

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 

314 

315 

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 

326 

327 

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 ) 

338 

339 

340def create_all(base_or_metadata: type[DeclarativeBase] | MetaData) -> None: 

341 """Create all tables for ``base_or_metadata`` on the configured sync engine. 

342 

343 A dev/demo convenience over ``metadata.create_all(engine)`` so a quickstart 

344 can create its schema without reaching for the raw engine:: 

345 

346 fr.db.create_all(Base) # or fr.db.create_all(Base.metadata) 

347 

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"]) 

359 

360 

361async def async_create_all(base_or_metadata: type[DeclarativeBase] | MetaData) -> None: 

362 """Async equivalent of :func:`create_all`, on the configured async engine. 

363 

364 Usage:: 

365 

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) 

381 

382 

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 

389 

390 

391def _mark_uncommitted(session: SA_Session, flush_context: Any = None) -> None: 

392 session.info["_fr_uncommitted"] = True 

393 

394 

395def _clear_uncommitted(session: SA_Session, *args: Any) -> None: 

396 session.info.pop("_fr_uncommitted", None) 

397 

398 

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 

415 

416 

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 ) 

449 

450 

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 ) 

465 

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 

474 

475 

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) 

481 

482 

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.") 

497 

498 with _fr_globals.make_session() as session: 

499 yield session 

500 

501 

502class _SyncSessionDependency: 

503 @property 

504 def __signature__(self) -> Signature: 

505 return _session_signature( 

506 "sync_session_generator", _generate_session, SA_Session 

507 ) 

508 

509 def __call__(self, session: SA_Session) -> Iterator[SA_Session]: 

510 _arm_uncommitted_warning(session) 

511 yield session 

512 _warn_if_uncommitted(session) 

513 

514 

515class _AsyncSessionDependency: 

516 @property 

517 def __signature__(self) -> Signature: 

518 return _session_signature( 

519 "session_generator", _async_generate_session, SA_AsyncSession 

520 ) 

521 

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) 

528 

529 

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. 

534 

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 ) 

553 

554 

555SessionDep = Annotated[SA_Session, _session_dependency(_SyncSessionDependency())] 

556AsyncSessionDep = Annotated[ 

557 SA_AsyncSession, _session_dependency(_AsyncSessionDependency()) 

558]