1use async_trait::async_trait;
4use std::sync::OnceLock;
5use std::sync::atomic::{AtomicUsize, Ordering};
6
7use crate::{RullstPool, RullstPoolOptions};
8
9const POOL_SLOW_ACQUIRE_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(500);
10
11mod dsn;
12mod placeholders;
13mod savepoint;
14mod telemetry;
15#[doc(hidden)]
16pub use placeholders::portable_subquery;
17pub use placeholders::replace_placeholders;
18
19#[cfg(not(any(
20 feature = "strict-postgres",
21 feature = "strict-mysql",
22 feature = "strict-sqlite"
23)))]
24use sqlx::any::install_default_drivers;
25
26struct OrmState {
29 primary: RullstPool,
30 driver: &'static str,
31 replicas: Vec<RullstPool>,
32 replica_index: AtomicUsize,
33}
34
35impl OrmState {
36 fn new(primary: RullstPool, driver: &'static str, replicas: Vec<RullstPool>) -> Self {
37 Self {
38 primary,
39 driver,
40 replicas,
41 replica_index: AtomicUsize::new(0),
42 }
43 }
44
45 fn read_pool(&self) -> &RullstPool {
46 if self.replicas.is_empty() {
47 return &self.primary;
48 }
49
50 let index = self.replica_index.fetch_add(1, Ordering::Relaxed) % self.replicas.len();
51 &self.replicas[index]
52 }
53}
54
55static ORM_STATE: OnceLock<OrmState> = OnceLock::new();
56
57#[cfg(feature = "redis")]
58struct RedisState {
59 client: crate::_redis::Client,
60 manager: crate::_redis::aio::ConnectionManager,
61 cache_namespace: String,
62}
63
64#[cfg(feature = "redis")]
65static REDIS_STATE: OnceLock<RedisState> = OnceLock::new();
66
67static PREVENT_LAZY_LOADING: std::sync::atomic::AtomicBool =
68 std::sync::atomic::AtomicBool::new(false);
69
70pub fn prevent_lazy_loading(prevent: bool) {
73 PREVENT_LAZY_LOADING.store(prevent, std::sync::atomic::Ordering::Relaxed);
74}
75
76#[doc(hidden)]
77pub fn is_lazy_loading_prevented() -> bool {
78 PREVENT_LAZY_LOADING.load(std::sync::atomic::Ordering::Relaxed)
79}
80
81pub trait RagContext {
83 fn get_context(&self) -> String;
84}
85
86pub struct Orm;
88
89impl Orm {
90 fn pool_options() -> RullstPoolOptions {
91 RullstPoolOptions::new()
92 .acquire_time_level(tracing::log::LevelFilter::Info)
93 .acquire_slow_level(tracing::log::LevelFilter::Warn)
94 .acquire_slow_threshold(POOL_SLOW_ACQUIRE_THRESHOLD)
95 .after_release(savepoint::release_outside_transaction)
96 }
97
98 fn ensure_uninitialized() -> Result<(), crate::Error> {
99 if ORM_STATE.get().is_some() {
100 Err(crate::Error::AlreadyInitialized)
101 } else {
102 Ok(())
103 }
104 }
105
106 fn publish(state: OrmState) -> Result<(), crate::Error> {
107 ORM_STATE
108 .set(state)
109 .map_err(|_| crate::Error::AlreadyInitialized)
110 }
111
112 fn state() -> Result<&'static OrmState, crate::Error> {
113 ORM_STATE.get().ok_or(crate::Error::NotInitialized)
114 }
115
116 pub async fn init(database_url: &str) -> Result<(), crate::Error> {
118 Self::ensure_uninitialized()?;
119 dsn::ensure_configured_dsn(database_url)?;
120
121 Self::validate_dsn(database_url);
122
123 #[cfg(not(any(
124 feature = "strict-postgres",
125 feature = "strict-mysql",
126 feature = "strict-sqlite"
127 )))]
128 install_default_drivers();
129
130 let pool = Self::pool_options()
131 .acquire_timeout(std::time::Duration::from_secs(10))
132 .idle_timeout(Some(std::time::Duration::from_secs(300)))
133 .max_lifetime(Some(std::time::Duration::from_secs(1800)))
134 .connect(database_url)
135 .await?;
136
137 Self::publish(OrmState::new(
138 pool,
139 dsn::driver_for_url(database_url),
140 Vec::new(),
141 ))
142 }
143
144 pub async fn init_with_options(
146 database_url: &str,
147 max_connections: u32,
148 acquire_timeout_secs: u64,
149 ) -> Result<(), crate::Error> {
150 Self::ensure_uninitialized()?;
151 Self::validate_dsn(database_url);
152
153 #[cfg(not(any(
154 feature = "strict-postgres",
155 feature = "strict-mysql",
156 feature = "strict-sqlite"
157 )))]
158 install_default_drivers();
159
160 let pool = Self::pool_options()
161 .max_connections(max_connections)
162 .acquire_timeout(std::time::Duration::from_secs(acquire_timeout_secs))
163 .idle_timeout(Some(std::time::Duration::from_secs(300)))
164 .max_lifetime(Some(std::time::Duration::from_secs(1800)))
165 .connect(database_url)
166 .await?;
167
168 Self::publish(OrmState::new(
169 pool,
170 dsn::driver_for_url(database_url),
171 Vec::new(),
172 ))
173 }
174
175 #[cfg_attr(test, mutants::skip)]
176 pub(crate) fn validate_dsn(database_url: &str) {
177 if dsn::has_placeholder_brackets(database_url) {
180 eprintln!(
181 "⚠️ [CONFIG] Rullst ORM: DATABASE_URL still contains placeholder brackets (e.g. [your-database-id]). \
182 Update your .env file with a real connection string before using database features."
183 );
184 return;
185 }
186
187 if database_url.starts_with("sqlite") {
188 let uses_named_memory = database_url.split_once('?').is_some_and(|(_, query)| {
189 query
190 .split('&')
191 .any(|parameter| parameter.eq_ignore_ascii_case("mode=memory"))
192 });
193 let mut path_part = database_url
194 .trim_start_matches("sqlite:")
195 .trim_start_matches("//")
196 .trim_start_matches("file:");
197 if let Some(idx) = path_part.find('?') {
198 path_part = &path_part[..idx];
199 }
200 if !path_part.is_empty() && path_part != ":memory:" && !uses_named_memory {
201 let path = std::path::Path::new(path_part);
202 if let Some(parent) = path.parent()
204 && !parent.as_os_str().is_empty()
205 {
206 let _ = std::fs::create_dir_all(parent);
207 }
208 if !path.exists() {
212 let _ = std::fs::File::create(path);
213 }
214 }
215 }
216
217 if database_url.contains("sslmode=disable")
218 && !database_url.contains("localhost")
219 && !database_url.contains("127.0.0.1")
220 {
221 eprintln!(
222 "⚠️ [SECURITY WARNING] Rullst ORM: TLS/SSL disabled on external database connection! This is highly discouraged in production environments."
223 );
224 }
225 }
226
227 pub async fn init_with_replicas(
229 primary_url: &str,
230 replica_urls: Vec<&str>,
231 ) -> Result<(), crate::Error> {
232 Self::ensure_uninitialized()?;
233 Self::validate_dsn(primary_url);
234 for replica_url in &replica_urls {
235 Self::validate_dsn(replica_url);
236 }
237 #[cfg(not(any(
238 feature = "strict-postgres",
239 feature = "strict-mysql",
240 feature = "strict-sqlite"
241 )))]
242 install_default_drivers();
243
244 let pool = Self::pool_options()
245 .acquire_timeout(std::time::Duration::from_secs(10))
246 .idle_timeout(Some(std::time::Duration::from_secs(300)))
247 .max_lifetime(Some(std::time::Duration::from_secs(1800)))
248 .connect(primary_url)
249 .await?;
250
251 let replica_futures: Vec<_> = replica_urls
255 .into_iter()
256 .map(|replica_url| Self::pool_options().connect(replica_url))
257 .collect();
258 let replicas = futures::future::try_join_all(replica_futures).await?;
259
260 Self::publish(OrmState::new(
261 pool,
262 dsn::driver_for_url(primary_url),
263 replicas,
264 ))
265 }
266
267 pub fn pool() -> Result<&'static RullstPool, crate::Error> {
269 Self::try_pool()
270 }
271
272 pub fn try_pool() -> Result<&'static RullstPool, crate::Error> {
275 crate::__transaction_access::ensure_allowed()?;
276 Ok(&Self::state()?.primary)
277 }
278
279 #[cfg_attr(test, mutants::skip)]
282 pub fn read_pool() -> Result<&'static RullstPool, crate::Error> {
283 Self::try_read_pool()
284 }
285
286 #[cfg_attr(test, mutants::skip)]
288 pub fn try_read_pool() -> Result<&'static RullstPool, crate::Error> {
289 crate::__transaction_access::ensure_allowed()?;
290 Ok(Self::state()?.read_pool())
291 }
292
293 pub fn driver() -> Result<&'static str, crate::Error> {
295 Self::try_driver()
296 }
297
298 pub fn try_driver() -> Result<&'static str, crate::Error> {
300 Ok(Self::state()?.driver)
301 }
302
303 pub fn raw(sql: impl Into<String>) -> crate::raw::RawQueryBuilder {
305 crate::raw::RawQueryBuilder::new(sql)
306 }
307
308 #[cfg_attr(test, mutants::skip)]
310 pub async fn seed(seeders: Vec<Box<dyn Seeder>>) -> Result<(), crate::Error> {
311 for seeder in seeders {
312 seeder.run().await?;
313 }
314 Ok(())
315 }
316
317 pub fn enable_query_log() {
319 crate::schema::enable_query_log();
320 }
321
322 pub fn disable_query_log() {
324 crate::schema::disable_query_log();
325 }
326
327 pub fn set_max_query_limit(limit: usize) {
329 crate::schema::set_max_query_limit(limit);
330 }
331
332 pub fn set_query_timeout(secs: u64) {
334 crate::schema::set_query_timeout(secs);
335 }
336
337 #[cfg(feature = "redis")]
342 #[cfg_attr(test, mutants::skip)]
343 pub async fn init_redis(redis_url: &str) -> Result<(), crate::Error> {
344 Self::init_redis_with_namespace(redis_url, "default").await
345 }
346
347 #[cfg(feature = "redis")]
353 #[cfg_attr(test, mutants::skip)]
354 pub async fn init_redis_with_namespace(
355 redis_url: &str,
356 cache_namespace: impl Into<String>,
357 ) -> Result<(), crate::Error> {
358 if REDIS_STATE.get().is_some() {
359 return Err(crate::Error::AlreadyInitialized);
360 }
361 let cache_namespace = cache_namespace.into();
362 crate::query_cache::validate_namespace(&cache_namespace)?;
363 let client = crate::_redis::Client::open(redis_url)?;
364 let manager = crate::_redis::aio::ConnectionManager::new(client.clone()).await?;
365 REDIS_STATE
366 .set(RedisState {
367 client,
368 manager,
369 cache_namespace,
370 })
371 .map_err(|_| crate::Error::AlreadyInitialized)
372 }
373
374 #[cfg(feature = "redis")]
376 #[cfg_attr(test, mutants::skip)]
377 pub fn redis_client() -> Result<&'static crate::_redis::Client, crate::Error> {
378 Ok(&Self::redis_state()?.client)
379 }
380
381 #[cfg(feature = "redis")]
383 #[cfg_attr(test, mutants::skip)]
384 pub fn redis_manager() -> Result<crate::_redis::aio::ConnectionManager, crate::Error> {
385 Ok(Self::redis_state()?.manager.clone())
386 }
387
388 #[cfg(feature = "redis")]
389 fn redis_state() -> Result<&'static RedisState, crate::Error> {
390 REDIS_STATE.get().ok_or_else(|| {
391 crate::Error::Internal(
392 "Orm::init_redis() or Orm::init_redis_with_namespace() must be called before using Redis features".to_string(),
393 )
394 })
395 }
396
397 #[cfg(feature = "redis")]
399 #[doc(hidden)]
400 pub fn redis_cache_namespace() -> Result<&'static str, crate::Error> {
401 Ok(Self::redis_state()?.cache_namespace.as_str())
402 }
403}
404
405#[async_trait]
407pub trait Seeder: Send + Sync {
408 async fn run(&self) -> Result<(), crate::Error>;
409}
410
411#[async_trait]
413pub trait RullstModel {
414 fn table_name() -> &'static str;
415}
416
417#[derive(Debug, Clone)]
419pub struct PaginationResult<T> {
420 pub data: Vec<T>,
421 pub total: i64,
422 pub per_page: usize,
423 pub current_page: usize,
424 pub last_page: usize,
425}
426
427#[cfg(test)]
428mod tests {
429 use super::Orm;
430
431 fn unique_database_path(label: &str) -> std::path::PathBuf {
432 std::env::temp_dir().join(format!(
433 "rullst-orm-{label}-{}-{}",
434 std::process::id(),
435 rand::random::<u64>()
436 ))
437 }
438
439 #[test]
440 fn named_memory_dsn_does_not_touch_a_backing_file() {
441 let database_path = unique_database_path("named-memory");
442 let dsn = format!(
443 "sqlite:file:{}?mode=memory&cache=shared",
444 database_path.display()
445 );
446
447 Orm::validate_dsn(&dsn);
448
449 assert!(!database_path.exists());
450 }
451
452 #[test]
453 fn disk_dsn_still_prepares_the_backing_file() {
454 let database_path = unique_database_path("disk");
455 let dsn = format!("sqlite:{}", database_path.display());
456
457 Orm::validate_dsn(&dsn);
458
459 assert!(database_path.is_file());
460 std::fs::remove_file(database_path).expect("temporary SQLite file should be removable");
461 }
462}