nest_rs_database/executor.rs
1use std::any::Any;
2use std::future::Future;
3use std::sync::Arc;
4
5/// An ambient handle to a unit of database work, installed in the
6/// task-local for the lifetime of a request or a worker job.
7///
8/// The trait is **object-safe** so the engine can carry it as
9/// `Arc<dyn Executor>` without naming a concrete ORM. The concrete handle
10/// (a SeaORM `Executor` enum, a `sqlx::Pool`, a `diesel_async::Connection`,
11/// …) implements this trait; an ORM-specific `Repo` recovers the concrete
12/// type via [`Executor::as_any`] when it needs to issue a query.
13///
14/// Downcasting is the documented seam: this crate stays free of every
15/// candidate ORM's query API, and each `Repo` knows exactly which executor
16/// shape its `Module` installs. A downcast miss is a framework bug
17/// (mismatched `Module` + `Repo`); the contract is **log at `error` and
18/// degrade to `None`** — the `Repo` then fails the operation (no ambient
19/// executor), so the request errors loudly instead of panicking a worker
20/// thread or silently reading "no rows".
21pub trait Executor: Any + Send + Sync + 'static {
22 /// Downcast handle. Used by an ORM-specific `Repo` to recover its
23 /// concrete executor type from the ambient `Arc<dyn Executor>`.
24 fn as_any(&self) -> &dyn Any;
25
26 /// A handle on the same database **outside** this executor's transaction,
27 /// or `None` when there is nothing to step out of (already a pool) or the
28 /// ORM cannot produce one.
29 ///
30 /// A transport that has *proven* an operation cannot write installs this
31 /// for the operation's duration, so the work runs without opening — and
32 /// without pinning a connection to — the request transaction. The one
33 /// caller today is the GraphQL endpoint: every operation arrives as a
34 /// POST, so the HTTP boundary hands even a pure query a transaction it
35 /// will never need.
36 ///
37 /// **Only ever pass work that cannot write.** A mutation on the returned
38 /// handle loses atomicity and rollback. The default `None` is therefore
39 /// the fail-closed answer: an ORM that ignores this keeps the request
40 /// executor it was given.
41 fn non_transactional(&self) -> Option<Arc<dyn Executor>> {
42 None
43 }
44}
45
46/// Whether the ambient executor belongs to a request or a worker job. An
47/// ORM's `Repo` reads this back to fail closed when a request path lacks
48/// an ambient authorization context (a missing principal on a worker is
49/// expected — it's system work; on a request it's a bug).
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum ExecutorScope {
52 /// A user request — a missing ambient ability is a bug, so `Repo` fails closed.
53 Request,
54 /// System work (cron/queue) — no principal is expected, so reads are unscoped.
55 Job,
56}
57
58tokio::task_local! {
59 static EXECUTOR: Arc<dyn Executor>;
60 static EXECUTOR_SCOPE: ExecutorScope;
61}
62
63/// The installed ambient executor, or `None` outside any scope. An
64/// ORM-specific `Repo` calls this and downcasts via [`Executor::as_any`].
65pub fn current_executor() -> Option<Arc<dyn Executor>> {
66 EXECUTOR.try_with(Arc::clone).ok()
67}
68
69/// The installed ambient executor scope, or `None` outside any scope.
70pub fn current_executor_scope() -> Option<ExecutorScope> {
71 EXECUTOR_SCOPE.try_with(Clone::clone).ok()
72}
73
74/// Install `executor` without tagging a scope. Prefer the request/job
75/// variants at framework boundaries so authorization can distinguish the
76/// two paths. An untagged (unset) scope is treated as **fail-closed** by a
77/// scope-aware `Repo`: with no ambient ability it denies every row, exactly
78/// like a request — only [`with_job_executor`] grants unscoped reads.
79pub async fn with_executor<F: Future>(executor: Arc<dyn Executor>, fut: F) -> F::Output {
80 EXECUTOR.scope(executor, fut).await
81}
82
83/// Install `executor` and tag the scope as a request — the path on which a
84/// `Repo` fails closed when no ambient authorization context is present.
85pub async fn with_request_executor<F: Future>(executor: Arc<dyn Executor>, fut: F) -> F::Output {
86 EXECUTOR
87 .scope(executor, EXECUTOR_SCOPE.scope(ExecutorScope::Request, fut))
88 .await
89}
90
91/// Install `executor` and tag the scope as a worker job — the path on
92/// which a `Repo` runs unscoped (no principal ⇒ system work).
93pub async fn with_job_executor<F: Future>(executor: Arc<dyn Executor>, fut: F) -> F::Output {
94 EXECUTOR
95 .scope(executor, EXECUTOR_SCOPE.scope(ExecutorScope::Job, fut))
96 .await
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 struct StubExecutor;
104 impl Executor for StubExecutor {
105 fn as_any(&self) -> &dyn Any {
106 self
107 }
108 }
109
110 fn stub() -> Arc<dyn Executor> {
111 Arc::new(StubExecutor)
112 }
113
114 #[tokio::test]
115 async fn no_ambient_state_outside_any_scope() {
116 assert!(current_executor().is_none());
117 assert!(current_executor_scope().is_none());
118 }
119
120 #[tokio::test]
121 async fn with_executor_installs_but_does_not_tag() {
122 with_executor(stub(), async {
123 assert!(current_executor().is_some());
124 assert!(current_executor_scope().is_none());
125 })
126 .await;
127 }
128
129 #[tokio::test]
130 async fn with_request_executor_tags_request() {
131 with_request_executor(stub(), async {
132 assert_eq!(current_executor_scope(), Some(ExecutorScope::Request));
133 assert!(current_executor().is_some());
134 })
135 .await;
136 }
137
138 #[tokio::test]
139 async fn with_job_executor_tags_job() {
140 with_job_executor(stub(), async {
141 assert_eq!(current_executor_scope(), Some(ExecutorScope::Job));
142 assert!(current_executor().is_some());
143 })
144 .await;
145 }
146
147 #[tokio::test]
148 async fn scope_unwinds_on_exit() {
149 with_request_executor(stub(), async {}).await;
150 assert!(current_executor().is_none());
151 assert!(current_executor_scope().is_none());
152 }
153
154 #[tokio::test]
155 async fn nested_scope_shadows_outer() {
156 with_request_executor(stub(), async {
157 assert_eq!(current_executor_scope(), Some(ExecutorScope::Request));
158 with_job_executor(stub(), async {
159 assert_eq!(current_executor_scope(), Some(ExecutorScope::Job));
160 })
161 .await;
162 assert_eq!(current_executor_scope(), Some(ExecutorScope::Request));
163 })
164 .await;
165 }
166
167 #[tokio::test]
168 async fn downcast_round_trips_the_concrete_type() {
169 with_request_executor(stub(), async {
170 let e = current_executor().expect("installed");
171 assert!(e.as_any().is::<StubExecutor>());
172 })
173 .await;
174 }
175}