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