Skip to main content

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}