Skip to main content

rullst_orm/
lib.rs

1#[cfg(not(any(
2    feature = "strict-postgres",
3    feature = "strict-mysql",
4    feature = "strict-sqlite"
5)))]
6pub use sqlx::AnyPool as RullstPool;
7
8#[cfg(not(any(
9    feature = "strict-postgres",
10    feature = "strict-mysql",
11    feature = "strict-sqlite"
12)))]
13pub use sqlx::any::AnyPoolOptions as RullstPoolOptions;
14
15#[cfg(feature = "strict-postgres")]
16pub use sqlx::PgPool as RullstPool;
17
18#[cfg(feature = "strict-postgres")]
19pub use sqlx::postgres::PgPoolOptions as RullstPoolOptions;
20
21#[cfg(all(feature = "strict-mysql", not(feature = "strict-postgres")))]
22pub use sqlx::MySqlPool as RullstPool;
23
24#[cfg(all(feature = "strict-mysql", not(feature = "strict-postgres")))]
25pub use sqlx::mysql::MySqlPoolOptions as RullstPoolOptions;
26
27#[cfg(all(
28    feature = "strict-sqlite",
29    not(feature = "strict-postgres"),
30    not(feature = "strict-mysql")
31))]
32pub use sqlx::SqlitePool as RullstPool;
33
34#[cfg(all(
35    feature = "strict-sqlite",
36    not(feature = "strict-postgres"),
37    not(feature = "strict-mysql")
38))]
39pub use sqlx::sqlite::SqlitePoolOptions as RullstPoolOptions;
40
41#[cfg(not(any(
42    feature = "strict-postgres",
43    feature = "strict-mysql",
44    feature = "strict-sqlite"
45)))]
46use sqlx::any::install_default_drivers;
47
48use std::sync::OnceLock;
49use std::sync::atomic::{AtomicUsize, Ordering};
50
51// Hide underlying libraries for macro usage while keeping the public API clean
52#[doc(hidden)]
53pub use futures as _futures;
54#[doc(hidden)]
55pub use serde as _serde;
56#[doc(hidden)]
57pub use serde_json as _serde_json;
58#[doc(hidden)]
59pub use sqlx as _sqlx;
60
61#[cfg(feature = "redis")]
62#[doc(hidden)]
63pub use redis as _redis;
64
65/// Helper to convert `?` placeholders to `$1`, `$2` etc. for Postgres.
66#[doc(hidden)]
67pub fn replace_placeholders(sql: &str) -> String {
68    let mut replaced = String::with_capacity(sql.len() + 10);
69    let mut last_idx = 0;
70    for (counter, (idx, _)) in (1..).zip(sql.match_indices('?')) {
71        replaced.push_str(&sql[last_idx..idx]);
72        use std::fmt::Write;
73        write!(replaced, "${}", counter).unwrap();
74        last_idx = idx + 1;
75    }
76    replaced.push_str(&sql[last_idx..]);
77    replaced
78}
79
80pub mod admin;
81pub mod audit;
82pub mod collection;
83pub mod database;
84pub mod db;
85pub mod error;
86pub mod resource;
87pub mod schema;
88pub mod scout;
89pub mod tenant;
90pub mod types;
91
92// Export the custom Error enum to the root
93pub use error::RullstError as Error;
94
95// Re-exports
96pub use _sqlx::FromRow;
97pub use admin::dashboard_html;
98pub use collection::RullstCollection;
99pub use database::RullstDatabase;
100pub use resource::{ApiResource, JsonResource, ResourceCollection};
101pub use rullst_orm_macros::Orm;
102pub use scout::{SearchEngine, get_search_engine, set_search_engine};
103pub use tenant::{get_tenant_id, with_tenant};
104pub use types::Json;
105
106// Re-export async_trait so the macro can use it implicitly
107pub use async_trait::async_trait;
108
109// Re-export sqlx and FromRow for database mapping
110pub use schema::{JoinClause, SubqueryBuilder};
111
112/// The global connection pool
113static DB_POOL: OnceLock<RullstPool> = OnceLock::new();
114
115/// The driver identifier (postgres, mysql, sqlite) to help macro syntax formatting
116static DB_DRIVER: OnceLock<String> = OnceLock::new();
117
118/// The replica connection pools for read operations
119static REPLICA_POOLS: OnceLock<Vec<RullstPool>> = OnceLock::new();
120
121/// Atomic index for replica round-robin selection
122static REPLICA_INDEX: AtomicUsize = AtomicUsize::new(0);
123
124#[cfg(feature = "redis")]
125static REDIS_CLIENT: OnceLock<_redis::Client> = OnceLock::new();
126
127#[cfg(feature = "redis")]
128static REDIS_MANAGER: OnceLock<_redis::aio::ConnectionManager> = OnceLock::new();
129
130/// Enum dinâmico para encapsular qualquer tipo que possa ser associado ao banco de dados pelo Macro
131#[derive(Clone, Debug)]
132pub enum RullstValue {
133    String(String),
134    Int(i32),
135    Float(f64),
136    Bool(bool),
137}
138
139impl From<&str> for RullstValue {
140    fn from(s: &str) -> Self {
141        RullstValue::String(s.to_string())
142    }
143}
144impl From<String> for RullstValue {
145    fn from(s: String) -> Self {
146        RullstValue::String(s)
147    }
148}
149impl From<i32> for RullstValue {
150    fn from(i: i32) -> Self {
151        RullstValue::Int(i)
152    }
153}
154impl From<f64> for RullstValue {
155    fn from(f: f64) -> Self {
156        RullstValue::Float(f)
157    }
158}
159impl From<bool> for RullstValue {
160    fn from(b: bool) -> Self {
161        RullstValue::Bool(b)
162    }
163}
164
165impl TryFrom<RullstValue> for String {
166    type Error = &'static str;
167    fn try_from(val: RullstValue) -> Result<Self, Self::Error> {
168        match val {
169            RullstValue::String(s) => Ok(s),
170            _ => Err("Not a string"),
171        }
172    }
173}
174impl TryFrom<RullstValue> for i32 {
175    type Error = &'static str;
176    fn try_from(val: RullstValue) -> Result<Self, Self::Error> {
177        match val {
178            RullstValue::Int(i) => Ok(i),
179            _ => Err("Not an i32"),
180        }
181    }
182}
183impl TryFrom<RullstValue> for f64 {
184    type Error = &'static str;
185    fn try_from(val: RullstValue) -> Result<Self, Self::Error> {
186        match val {
187            RullstValue::Float(f) => Ok(f),
188            _ => Err("Not an f64"),
189        }
190    }
191}
192impl TryFrom<RullstValue> for bool {
193    type Error = &'static str;
194    fn try_from(val: RullstValue) -> Result<Self, Self::Error> {
195        match val {
196            RullstValue::Bool(b) => Ok(b),
197            _ => Err("Not a bool"),
198        }
199    }
200}
201
202/// Orm configuration structure
203pub struct Orm;
204
205impl Orm {
206    /// Initialize the global database connection pool using an agnostic URI
207    pub async fn init(database_url: &str) -> Result<(), crate::Error> {
208        Self::validate_dsn(database_url);
209
210        #[cfg(not(any(
211            feature = "strict-postgres",
212            feature = "strict-mysql",
213            feature = "strict-sqlite"
214        )))]
215        install_default_drivers();
216
217        let pool = RullstPool::connect(database_url).await?;
218
219        if DB_POOL.set(pool).is_err() {
220            return Err(crate::Error::Internal(
221                "Orm has already been initialized".to_string(),
222            ));
223        }
224
225        let driver = if database_url.starts_with("postgres") {
226            "postgres"
227        } else if database_url.starts_with("mysql") {
228            "mysql"
229        } else {
230            "sqlite"
231        };
232
233        let _ = DB_DRIVER.set(driver.to_string());
234        let _ = REPLICA_POOLS.set(vec![]);
235
236        Ok(())
237    }
238
239    /// Initialize the global database connection pool with specific pool options
240    pub async fn init_with_options(
241        database_url: &str,
242        max_connections: u32,
243        acquire_timeout_secs: u64,
244    ) -> Result<(), crate::Error> {
245        Self::validate_dsn(database_url);
246
247        #[cfg(not(any(
248            feature = "strict-postgres",
249            feature = "strict-mysql",
250            feature = "strict-sqlite"
251        )))]
252        install_default_drivers();
253
254        let pool = RullstPoolOptions::new()
255            .max_connections(max_connections)
256            .acquire_timeout(std::time::Duration::from_secs(acquire_timeout_secs))
257            .connect(database_url)
258            .await?;
259
260        if DB_POOL.set(pool).is_err() {
261            return Err(crate::Error::Internal(
262                "Orm has already been initialized".to_string(),
263            ));
264        }
265
266        let driver = if database_url.starts_with("postgres") {
267            "postgres"
268        } else if database_url.starts_with("mysql") {
269            "mysql"
270        } else {
271            "sqlite"
272        };
273
274        let _ = DB_DRIVER.set(driver.to_string());
275        let _ = REPLICA_POOLS.set(vec![]);
276
277        Ok(())
278    }
279
280    #[cfg_attr(test, mutants::skip)]
281    fn validate_dsn(database_url: &str) {
282        if database_url.contains("sslmode=disable")
283            && !database_url.contains("localhost")
284            && !database_url.contains("127.0.0.1")
285        {
286            eprintln!(
287                "⚠️ [SECURITY WARNING] Rullst ORM: TLS/SSL disabled on external database connection! This is highly discouraged in production environments."
288            );
289        }
290    }
291
292    /// Initialize the global database connection pool and its read replicas
293    pub async fn init_with_replicas(
294        primary_url: &str,
295        replica_urls: Vec<&str>,
296    ) -> Result<(), crate::Error> {
297        #[cfg(not(any(
298            feature = "strict-postgres",
299            feature = "strict-mysql",
300            feature = "strict-sqlite"
301        )))]
302        install_default_drivers();
303
304        let pool = RullstPool::connect(primary_url).await?;
305
306        if DB_POOL.set(pool).is_err() {
307            return Err(crate::Error::Internal(
308                "Orm has already been initialized".to_string(),
309            ));
310        }
311
312        let driver = if primary_url.starts_with("postgres") {
313            "postgres"
314        } else if primary_url.starts_with("mysql") {
315            "mysql"
316        } else {
317            "sqlite"
318        };
319
320        let _ = DB_DRIVER.set(driver.to_string());
321
322        // Initialize all replica pools concurrently — each connect() is independent I/O.
323        let replica_futures: Vec<_> = replica_urls.into_iter().map(RullstPool::connect).collect();
324        let replicas = futures::future::try_join_all(replica_futures).await?;
325        let _ = REPLICA_POOLS.set(replicas);
326
327        Ok(())
328    }
329
330    /// Retrieve the global database connection pool (strictly for writes)
331    pub fn pool() -> &'static RullstPool {
332        DB_POOL
333            .get()
334            .expect("Orm must be initialized before querying")
335    }
336
337    /// Retrieve the connection pool for read operations.
338    /// Performs a round-robin load balancing over replicas if configured.
339    #[cfg_attr(test, mutants::skip)]
340    pub fn read_pool() -> &'static RullstPool {
341        if let Some(replicas) = REPLICA_POOLS.get()
342            && !replicas.is_empty()
343        {
344            let idx = REPLICA_INDEX.fetch_add(1, Ordering::Relaxed) % replicas.len();
345            return &replicas[idx];
346        }
347        Self::pool()
348    }
349
350    /// Retrieve the active driver string
351    pub fn driver() -> &'static str {
352        DB_DRIVER
353            .get()
354            .expect("Orm must be initialized before querying")
355            .as_str()
356    }
357
358    pub async fn begin_transaction() -> Result<crate::db::Transaction<'static>, crate::Error> {
359        let pool = Self::pool();
360        pool.begin().await.map_err(Into::into)
361    }
362
363    /// Run an array of seeders sequentially
364    #[cfg_attr(test, mutants::skip)]
365    pub async fn seed(seeders: Vec<Box<dyn Seeder>>) -> Result<(), crate::Error> {
366        for seeder in seeders {
367            seeder.run().await?;
368        }
369        Ok(())
370    }
371
372    /// Enable query logging to print all queries to the terminal
373    pub fn enable_query_log() {
374        crate::schema::enable_query_log();
375    }
376
377    /// Disable query logging
378    pub fn disable_query_log() {
379        crate::schema::disable_query_log();
380    }
381
382    /// Set a global maximum limit for all queries without an explicit limit override
383    pub fn set_max_query_limit(limit: usize) {
384        crate::schema::set_max_query_limit(limit);
385    }
386
387    /// Set a global maximum execution timeout for all queries
388    pub fn set_query_timeout(secs: u64) {
389        crate::schema::set_query_timeout(secs);
390    }
391
392    /// Initialize Redis connection and connection manager for caching and events
393    #[cfg(feature = "redis")]
394    #[cfg_attr(test, mutants::skip)]
395    pub async fn init_redis(redis_url: &str) -> Result<(), crate::Error> {
396        let client = _redis::Client::open(redis_url)?;
397        let manager = _redis::aio::ConnectionManager::new(client.clone()).await?;
398        let _ = REDIS_CLIENT.set(client);
399        let _ = REDIS_MANAGER.set(manager);
400        Ok(())
401    }
402
403    /// Get reference to the global Redis client
404    #[cfg(feature = "redis")]
405    #[cfg_attr(test, mutants::skip)]
406    pub fn redis_client() -> Result<&'static _redis::Client, crate::Error> {
407        REDIS_CLIENT.get().ok_or_else(|| {
408            crate::Error::Internal(
409                "Orm::init_redis() must be called before using cache features".to_string(),
410            )
411        })
412    }
413
414    /// Get clone of the thread-safe connection manager for async Redis queries
415    #[cfg(feature = "redis")]
416    #[cfg_attr(test, mutants::skip)]
417    pub fn redis_manager() -> Result<_redis::aio::ConnectionManager, crate::Error> {
418        REDIS_MANAGER.get().cloned().ok_or_else(|| {
419            crate::Error::Internal(
420                "Orm::init_redis() must be called before using cache features".to_string(),
421            )
422        })
423    }
424}
425
426/// A database seeder trait for populating tables
427#[async_trait]
428pub trait Seeder: Send + Sync {
429    async fn run(&self) -> Result<(), crate::Error>;
430}
431
432/// The core trait that all Orm models will implement via #[derive(Orm)]
433#[async_trait]
434pub trait RullstModel {
435    fn table_name() -> &'static str;
436}
437
438/// Represents a paginated result set
439#[derive(Debug, Clone)]
440pub struct PaginationResult<T> {
441    pub data: Vec<T>,
442    pub total: i64,
443    pub per_page: usize,
444    pub current_page: usize,
445    pub last_page: usize,
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn test_pagination_result() {
454        let mut pr = PaginationResult {
455            data: vec![1, 2, 3],
456            total: 3,
457            per_page: 10,
458            current_page: 1,
459            last_page: 1,
460        };
461        assert_eq!(pr.data.len(), 3);
462        assert_eq!(pr.total, 3);
463        pr.data.push(4);
464        assert_eq!(pr.data.len(), 4);
465    }
466
467    #[test]
468    fn test_replace_placeholders() {
469        assert_eq!(
470            super::replace_placeholders("SELECT * FROM users WHERE id = ? AND name = ?"),
471            "SELECT * FROM users WHERE id = $1 AND name = $2"
472        );
473        assert_eq!(
474            super::replace_placeholders("INSERT INTO users (name) VALUES (?)"),
475            "INSERT INTO users (name) VALUES ($1)"
476        );
477        assert_eq!(
478            super::replace_placeholders("SELECT * FROM users"),
479            "SELECT * FROM users"
480        );
481        assert_eq!(super::replace_placeholders("? ? ?"), "$1 $2 $3");
482    }
483
484    #[test]
485    fn test_rullst_value_conversions() {
486        // From
487        let v: RullstValue = "test".into();
488        assert!(matches!(v, RullstValue::String(_)));
489        let v_string: RullstValue = "test".to_string().into();
490        assert!(matches!(v_string, RullstValue::String(_)));
491        let v_int: RullstValue = 100.into();
492        assert!(matches!(v_int, RullstValue::Int(100)));
493        let v_bool: RullstValue = false.into();
494        assert!(matches!(v_bool, RullstValue::Bool(false)));
495        let v_float: RullstValue = std::f64::consts::PI.into();
496        assert!(matches!(v_float, RullstValue::Float(_)));
497
498        // TryFrom String
499        let v_str_conv = RullstValue::String("hello".to_string());
500        assert_eq!(String::try_from(v_str_conv).unwrap(), "hello");
501        assert!(String::try_from(RullstValue::Int(10)).is_err());
502
503        // TryFrom i32
504        let v_int_conv = RullstValue::Int(42);
505        assert_eq!(i32::try_from(v_int_conv).unwrap(), 42);
506        assert!(i32::try_from(RullstValue::Bool(true)).is_err());
507
508        // TryFrom f64
509        let v_float_conv = RullstValue::Float(2.71);
510        assert_eq!(f64::try_from(v_float_conv).unwrap(), 2.71);
511        assert!(f64::try_from(RullstValue::Int(10)).is_err());
512
513        // TryFrom bool
514        let v_bool_conv = RullstValue::Bool(true);
515        assert!(bool::try_from(v_bool_conv).unwrap());
516        assert!(bool::try_from(RullstValue::Int(10)).is_err());
517    }
518
519    #[test]
520    fn test_enable_query_log_wrapper() {
521        // Orm::enable/disable_query_log delegate to schema — verify the delegation works.
522        Orm::disable_query_log();
523        assert!(!crate::schema::is_query_log_enabled());
524        Orm::enable_query_log();
525        assert!(crate::schema::is_query_log_enabled());
526        Orm::disable_query_log();
527        assert!(!crate::schema::is_query_log_enabled());
528    }
529
530    #[test]
531    fn test_disable_query_log_wrapper() {
532        Orm::enable_query_log();
533        Orm::disable_query_log();
534        assert!(!crate::schema::is_query_log_enabled());
535    }
536
537    #[cfg(feature = "redis")]
538    #[test]
539    fn test_redis_client_uninitialized() {
540        let err = Orm::redis_client().unwrap_err();
541        assert!(matches!(err, crate::Error::Internal(_)));
542    }
543
544    #[cfg(feature = "redis")]
545    #[test]
546    fn test_redis_manager_uninitialized() {
547        let err = Orm::redis_manager().unwrap_err();
548        assert!(matches!(err, crate::Error::Internal(_)));
549    }
550
551    #[test]
552    #[should_panic(expected = "Orm must be initialized before querying")]
553    fn test_pool_uninitialized() {
554        let _ = Orm::pool();
555    }
556
557    #[test]
558    #[should_panic(expected = "Orm must be initialized before querying")]
559    fn test_driver_uninitialized() {
560        let _ = Orm::driver();
561    }
562
563    #[test]
564    #[should_panic(expected = "Orm must be initialized before querying")]
565    fn test_read_pool_uninitialized() {
566        let _ = Orm::read_pool();
567    }
568
569    #[test]
570    fn test_validate_dsn() {
571        // Safe case
572        Orm::validate_dsn("sqlite::memory:");
573        // Security warning case (printed to stderr, shouldn't panic)
574        Orm::validate_dsn("postgres://external-db.com/mydb?sslmode=disable");
575    }
576
577    #[cfg(feature = "redis")]
578    #[tokio::test]
579    async fn test_init_redis_failure() {
580        let err = Orm::init_redis("redis://127.0.0.1:0").await.unwrap_err();
581        assert!(matches!(err, crate::Error::CacheError(_)));
582    }
583
584    #[test]
585    fn test_orm_max_query_limit_and_timeout() {
586        Orm::set_max_query_limit(15);
587        assert_eq!(crate::schema::get_max_query_limit(), Some(15));
588        Orm::set_max_query_limit(0);
589        assert_eq!(crate::schema::get_max_query_limit(), None);
590
591        Orm::set_query_timeout(5);
592        assert_eq!(
593            crate::schema::get_query_timeout(),
594            Some(std::time::Duration::from_secs(5))
595        );
596        Orm::set_query_timeout(0);
597        assert_eq!(crate::schema::get_query_timeout(), None);
598    }
599}