Skip to main content

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}