rust_ef/db_context/options.rs
1//! `DbContextOptions` and `DbContextOptionsBuilder` — provider factory and
2//! context-level configuration.
3
4use crate::error::EFResult;
5use crate::provider::IDatabaseProvider;
6use std::future::Future;
7use std::pin::Pin;
8use std::sync::Arc;
9
10/// Future returned by an async `DbContext` preflight hook.
11pub type AsyncPreflightFuture = Pin<Box<dyn Future<Output = EFResult<()>> + Send>>;
12
13/// Async hook run during `build_async` after the provider (connection pool) has
14/// been created for an async `DbContext` registration.
15///
16/// Used by [`DbContextOptionsBuilder::async_preflight`] and
17/// `add_dbcontext_async*`; synchronous registrations ignore it.
18pub type AsyncPreflight = Arc<dyn Fn(Arc<DbContextOptions>) -> AsyncPreflightFuture + Send + Sync>;
19
20#[derive(Clone)]
21pub struct DbContextOptions {
22 pub(crate) connection_string: String,
23 pub(crate) provider_tag: Option<String>,
24 #[allow(clippy::type_complexity)]
25 pub(crate) provider_factory:
26 Option<Arc<dyn Fn(&str) -> EFResult<Arc<dyn IDatabaseProvider>> + Send + Sync>>,
27 /// Process-level cache of the built provider (which owns the connection
28 /// pool). Built once on the first `create_provider()` call and shared
29 /// across every `DbContext` created from the same `Arc<DbContextOptions>`
30 /// (i.e. the same `add_dbcontext` registration). Keeping the provider
31 /// alive for the application lifetime means the connection pool is reused
32 /// across requests instead of being recreated per request.
33 pub(crate) provider_cache: Arc<std::sync::Mutex<Option<Arc<dyn IDatabaseProvider>>>>,
34 pub(crate) interceptors: Vec<Arc<dyn crate::interceptor::ISaveChangesInterceptor>>,
35 /// When `true`, `QueryBuilder::to_list()` attaches `LazyContext` to every
36 /// navigation container on materialized entities, enabling on-demand
37 /// loading via `BelongsTo::load()` / `HasMany::load()` / `HasOne::load()`.
38 ///
39 /// Defaults to `false` (opt-in) to preserve v1.0 eager-only behavior.
40 pub(crate) lazy_loading_enabled: bool,
41 pub(crate) context_key: Option<String>,
42 /// When `true`, a keyed context shares the default entity model instead of
43 /// filtering entities by `#[context(key)]`. Read/write replica contexts use
44 /// the same schema and should set this.
45 pub(crate) shared_entity_model: bool,
46 /// Optional async validation hook, executed during `build_async` for async
47 /// `DbContext` registrations after provider creation. Ignored by sync
48 /// registrations.
49 pub(crate) async_preflight: Option<AsyncPreflight>,
50 /// Process-level cache of `discover_entities()` output, keyed by
51 /// `context_key`. Shared across all `DbContext` instances created from
52 /// the same `DbContextOptions` (which is `Arc`-shared per `add_dbcontext`
53 /// registration). The first `from_options()` call builds the metadata;
54 /// subsequent calls `Arc::clone` it.
55 pub(crate) metadata_cache: Arc<crate::metadata_cache::MetadataCache>,
56 /// Slow query threshold for tracing. When set, queries exceeding this
57 /// duration emit a `tracing::warn!` event.
58 #[cfg(feature = "tracing")]
59 pub(crate) slow_query_threshold: Option<std::time::Duration>,
60}
61
62impl std::fmt::Debug for DbContextOptions {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("DbContextOptions")
65 .field(
66 "connection_string",
67 &redact_connection_string(&self.connection_string),
68 )
69 .field("provider_tag", &self.provider_tag)
70 .finish()
71 }
72}
73
74/// Redacts credentials from a connection string so `Debug` output never leaks
75/// passwords. Handles URL form (`scheme://user:pass@host`) and key=value form
76/// (`...;Password=...;...`). SQLite file paths and other credential-free
77/// strings are returned unchanged for debuggability.
78fn redact_connection_string(cs: &str) -> String {
79 // URL form: scheme://[user[:pass]@]host...
80 if let Some(scheme_end) = cs.find("://") {
81 let (scheme, rest) = cs.split_at(scheme_end + 3);
82 if let Some(at) = rest.find('@') {
83 let (userinfo, host_and_rest) = rest.split_at(at);
84 let redacted_user = match userinfo.find(':') {
85 Some(colon) => &userinfo[..colon],
86 None => userinfo,
87 };
88 return format!("{}{}***@{}", scheme, redacted_user, &host_and_rest[1..]);
89 }
90 return cs.to_string();
91 }
92 // Key=value form: redact any token whose key mentions password/pwd.
93 if cs.contains('=') {
94 return cs
95 .split(';')
96 .map(|pair| {
97 let eq = match pair.find('=') {
98 Some(e) => e,
99 None => return pair.to_string(),
100 };
101 let key = pair[..eq].trim().to_lowercase();
102 if key.contains("password") || key.contains("pwd") {
103 format!("{}=***", &pair[..eq])
104 } else {
105 pair.to_string()
106 }
107 })
108 .collect::<Vec<_>>()
109 .join(";");
110 }
111 cs.to_string()
112}
113
114impl DbContextOptions {
115 pub fn connection_string(&self) -> &str {
116 &self.connection_string
117 }
118 pub fn provider_tag(&self) -> Option<&str> {
119 self.provider_tag.as_deref()
120 }
121 pub fn lazy_loading_enabled(&self) -> bool {
122 self.lazy_loading_enabled
123 }
124 pub fn context_key(&self) -> Option<&str> {
125 self.context_key.as_deref()
126 }
127 pub fn create_provider(&self) -> EFResult<Arc<dyn IDatabaseProvider>> {
128 // Recover from a poisoned lock rather than panicking (consistent with
129 // `MetadataCache`): if a previous build panicked, `into_inner()` yields
130 // the still-`None` cache and we retry the build below.
131 let mut guard = self
132 .provider_cache
133 .lock()
134 .unwrap_or_else(|p| p.into_inner());
135 if let Some(provider) = guard.as_ref() {
136 return Ok(Arc::clone(provider));
137 }
138 let factory = self.provider_factory.as_ref().ok_or_else(|| {
139 crate::error::EFError::configuration(
140 "No provider configured. Call use_sqlite / use_postgres / use_mysql first.",
141 )
142 })?;
143 let provider = factory(self.connection_string())?;
144 #[cfg(feature = "tracing")]
145 if let Some(threshold) = self.slow_query_threshold {
146 provider.set_slow_query_threshold(threshold);
147 }
148 *guard = Some(Arc::clone(&provider));
149 Ok(provider)
150 }
151}
152
153#[allow(clippy::derivable_impls)]
154impl Default for DbContextOptions {
155 fn default() -> Self {
156 Self {
157 connection_string: String::new(),
158 provider_tag: None,
159 provider_factory: None,
160 provider_cache: Arc::new(std::sync::Mutex::new(None)),
161 interceptors: Vec::new(),
162 lazy_loading_enabled: false,
163 context_key: None,
164 shared_entity_model: false,
165 async_preflight: None,
166 metadata_cache: Arc::new(crate::metadata_cache::MetadataCache::new()),
167 #[cfg(feature = "tracing")]
168 slow_query_threshold: None,
169 }
170 }
171}
172
173pub struct DbContextOptionsBuilder {
174 inner: DbContextOptions,
175}
176
177impl DbContextOptionsBuilder {
178 pub fn new() -> Self {
179 Self {
180 inner: DbContextOptions::default(),
181 }
182 }
183 pub fn connection_string(&mut self, cs: impl Into<String>) -> &mut Self {
184 self.inner.connection_string = cs.into();
185 self
186 }
187 pub fn set_provider(&mut self, tag: &str, cs: impl Into<String>) -> &mut Self {
188 self.inner.provider_tag = Some(tag.to_string());
189 self.inner.connection_string = cs.into();
190 self
191 }
192 #[allow(clippy::type_complexity)]
193 pub fn set_provider_factory(
194 &mut self,
195 tag: &str,
196 cs: impl Into<String>,
197 factory: Arc<dyn Fn(&str) -> EFResult<Arc<dyn IDatabaseProvider>> + Send + Sync>,
198 ) -> &mut Self {
199 self.inner.provider_tag = Some(tag.to_string());
200 self.inner.connection_string = cs.into();
201 self.inner.provider_factory = Some(factory);
202 self
203 }
204 /// Registers a `SaveChanges` interceptor.
205 ///
206 /// Interceptors are called in registration order during
207 /// `save_changes()`. Use this for auditing, soft-delete,
208 /// validation, and other cross-cutting concerns.
209 ///
210 /// # Example
211 ///
212 /// ```rust,ignore
213 /// options
214 /// .use_sqlite("app.db")
215 /// .add_interceptor(AuditInterceptor::new());
216 /// ```
217 pub fn add_interceptor(
218 &mut self,
219 interceptor: impl crate::interceptor::ISaveChangesInterceptor + 'static,
220 ) -> &mut Self {
221 self.inner.interceptors.push(Arc::new(interceptor));
222 self
223 }
224
225 /// Enables or disables lazy loading of navigation properties.
226 ///
227 /// When enabled (`true`), `to_list()` attaches a `LazyContext` to every
228 /// navigation container on each materialized entity. The user can then
229 /// call `nav.load().await` to trigger a single-entity query on first
230 /// access; subsequent accesses read from the in-memory cache.
231 ///
232 /// When disabled (`false`, the default), navigation properties are
233 /// empty unless explicitly loaded via `Include` — matching v1.0
234 /// eager-only behavior.
235 ///
236 /// # Example
237 ///
238 /// ```rust,ignore
239 /// let mut options = DbContextOptionsBuilder::new();
240 /// options.use_sqlite_in_memory().use_lazy_loading(true);
241 /// ```
242 pub fn use_lazy_loading(&mut self, enabled: bool) -> &mut Self {
243 self.inner.lazy_loading_enabled = enabled;
244 self
245 }
246
247 /// Registers an async preflight hook for async `DbContext` registrations.
248 ///
249 /// The hook runs inside `build_async().await` after the provider (connection
250 /// pool) has been created, and receives the fully built options. Returning
251 /// `Err` aborts startup with a typed `RdiError::FactoryPanic`; the hook is
252 /// ideal for an async `SELECT 1`/schema check.
253 ///
254 /// Ignored by synchronous `add_dbcontext*` registrations.
255 ///
256 /// ```rust,ignore
257 /// options.use_postgres("postgres://app/db").async_preflight(|options| {
258 /// Box::pin(async move {
259 /// let provider = options.create_provider()?;
260 /// let mut conn = provider.get_connection().await?;
261 /// conn.query("SELECT 1", &[]).await?;
262 /// Ok(())
263 /// })
264 /// });
265 /// ```
266 pub fn async_preflight(
267 &mut self,
268 preflight: impl Fn(Arc<DbContextOptions>) -> AsyncPreflightFuture + Send + Sync + 'static,
269 ) -> &mut Self {
270 self.inner.async_preflight = Some(Arc::new(preflight));
271 self
272 }
273
274 /// Sets the context key used to filter entities and configurations
275 /// during `DbContext::discover_entities()`. Set automatically by
276 /// `add_dbcontext_keyed`; `None` (the default) selects the default
277 /// context.
278 pub fn context_key(&mut self, key: impl Into<String>) -> &mut Self {
279 self.inner.context_key = Some(key.into());
280 self
281 }
282
283 /// Shares the default entity model with a keyed context.
284 ///
285 /// `add_dbcontext_keyed("key", ...)` normally filters entities to those
286 /// tagged `#[context("key")]`. Read and write replicas use the same schema,
287 /// so both should call this to discover the default model instead.
288 ///
289 /// ```rust,ignore
290 /// .add_dbcontext_keyed("read", |options| {
291 /// options.use_default_entity_model();
292 /// options.use_mysql(replica_cs);
293 /// })
294 /// ```
295 pub fn use_default_entity_model(&mut self) -> &mut Self {
296 self.inner.shared_entity_model = true;
297 self.inner.context_key = None;
298 self
299 }
300
301 pub(crate) fn uses_default_entity_model(&self) -> bool {
302 self.inner.shared_entity_model
303 }
304
305 /// Sets the slow query threshold. Queries exceeding this duration
306 /// emit a `tracing::warn!` event with SQL and elapsed time.
307 ///
308 /// Only available when the `tracing` feature is enabled.
309 #[cfg(feature = "tracing")]
310 pub fn slow_query_threshold(&mut self, threshold: std::time::Duration) -> &mut Self {
311 self.inner.slow_query_threshold = Some(threshold);
312 self
313 }
314
315 pub fn build(self) -> DbContextOptions {
316 self.inner
317 }
318}
319
320impl Default for DbContextOptionsBuilder {
321 fn default() -> Self {
322 Self::new()
323 }
324}