Skip to main content

rust_zero_core/
stores_sql.rs

1//! Typed SQLx pools, instrumentation, batching, and cache-aside records.
2//!
3//! ```no_run
4//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
5//! use rust_zero_core::{SqlStoreConfig, SqliteStore};
6//! let store = SqliteStore::connect_sqlite(SqlStoreConfig::new("sqlite::memory:")).await?;
7//! store.health_check().await?;
8//! # Ok(())
9//! # }
10//! ```
11
12use sqlx::{
13    mysql::{MySqlPool, MySqlPoolOptions},
14    postgres::{PgPool, PgPoolOptions},
15    sqlite::{SqlitePool, SqlitePoolOptions},
16    Database, MySql, Pool, Postgres, Sqlite, Transaction,
17};
18use std::{
19    error::Error,
20    fmt,
21    future::Future,
22    hash::Hash,
23    sync::{
24        atomic::{AtomicU64, Ordering},
25        Mutex,
26    },
27    time::{Duration, Instant},
28};
29
30use crate::{
31    cache::jittered_ttl, CacheStats, CounterVec, HistogramOptions, HistogramVec, MemoryCache,
32    Metrics, MetricsError, SingleFlight, SingleFlightError, VectorOptions,
33};
34
35#[cfg(feature = "telemetry")]
36use crate::{TelemetrySpan, TelemetrySpanKind};
37
38/// Stable failures returned by the typed SQL helpers.
39#[derive(Debug)]
40pub enum SqlStoreError {
41    Database(sqlx::Error),
42    NotFound { entity: String },
43    InvalidBatchSize,
44}
45
46impl SqlStoreError {
47    pub fn not_found(entity: impl Into<String>) -> Self {
48        Self::NotFound {
49            entity: entity.into(),
50        }
51    }
52
53    pub fn is_not_found(&self) -> bool {
54        matches!(self, Self::NotFound { .. })
55    }
56}
57
58impl fmt::Display for SqlStoreError {
59    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            Self::Database(error) => write!(formatter, "SQL operation failed: {error}"),
62            Self::NotFound { entity } => write!(formatter, "{entity} not found"),
63            Self::InvalidBatchSize => formatter.write_str("SQL bulk batch size must be positive"),
64        }
65    }
66}
67
68impl Error for SqlStoreError {
69    fn source(&self) -> Option<&(dyn Error + 'static)> {
70        match self {
71            Self::Database(error) => Some(error),
72            Self::NotFound { .. } | Self::InvalidBatchSize => None,
73        }
74    }
75}
76
77impl From<sqlx::Error> for SqlStoreError {
78    fn from(error: sqlx::Error) -> Self {
79        Self::Database(error)
80    }
81}
82
83/// Cardinality-bounded SQL operation metrics used by [`SqlStore`]'s typed helpers.
84#[derive(Clone)]
85pub struct SqlStoreMetrics {
86    operations: CounterVec,
87    duration: HistogramVec,
88}
89
90impl SqlStoreMetrics {
91    pub fn register(metrics: &Metrics) -> Result<Self, MetricsError> {
92        let labels = ["operation", "kind", "outcome"];
93        Ok(Self {
94            operations: metrics.counter_vec(
95                VectorOptions::new("operations_total", "Completed SQL store operations")
96                    .with_namespace("rust_zero")
97                    .with_subsystem("sql")
98                    .with_labels(labels),
99            )?,
100            duration: metrics.histogram_vec(
101                HistogramOptions::new("operation_duration_seconds", "SQL store operation latency")
102                    .with_vector_options(
103                        VectorOptions::new(
104                            "operation_duration_seconds",
105                            "SQL store operation latency",
106                        )
107                        .with_namespace("rust_zero")
108                        .with_subsystem("sql")
109                        .with_labels(labels),
110                    ),
111            )?,
112        })
113    }
114
115    fn observe(&self, operation: &str, kind: SqlOperationKind, outcome: &str, elapsed: Duration) {
116        let labels = [operation, kind.as_str(), outcome];
117        // Registration validates the instruments and labels. Recording failures are deliberately
118        // non-fatal: observability must never change the result of a database operation.
119        let _ = self.operations.inc(&labels);
120        let _ = self.duration.observe(elapsed.as_secs_f64(), &labels);
121    }
122}
123
124#[derive(Debug, Clone, Copy)]
125enum SqlOperationKind {
126    Query,
127    Execute,
128    BulkInsert,
129}
130
131impl SqlOperationKind {
132    fn as_str(self) -> &'static str {
133        match self {
134            Self::Query => "query",
135            Self::Execute => "execute",
136            Self::BulkInsert => "bulk_insert",
137        }
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct SqlStoreConfig {
143    pub url: String,
144    pub min_connections: u32,
145    pub max_connections: u32,
146    pub acquire_timeout: Duration,
147    pub idle_timeout: Option<Duration>,
148    pub max_lifetime: Option<Duration>,
149}
150
151/// Settings for the in-process cache used by [`CachedSqlStore`].
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct SqlCacheConfig {
154    pub capacity: usize,
155    pub ttl: Duration,
156    pub not_found_ttl: Option<Duration>,
157    pub ttl_jitter: Duration,
158}
159
160impl SqlCacheConfig {
161    pub fn new(capacity: usize, ttl: Duration) -> Self {
162        assert!(capacity > 0, "SQL cache capacity must be positive");
163        assert!(!ttl.is_zero(), "SQL cache TTL must be positive");
164        Self {
165            capacity,
166            ttl,
167            not_found_ttl: Some(ttl),
168            ttl_jitter: Duration::ZERO,
169        }
170    }
171
172    /// Controls negative caching. `None` makes every missing record hit the database.
173    pub fn with_not_found_ttl(mut self, ttl: Option<Duration>) -> Self {
174        assert!(
175            ttl.is_none_or(|ttl| !ttl.is_zero()),
176            "SQL not-found cache TTL must be positive"
177        );
178        self.not_found_ttl = ttl;
179        self
180    }
181
182    /// Randomizes each positive and negative expiry by up to `jitter`.
183    pub fn with_ttl_jitter(mut self, jitter: Duration) -> Self {
184        self.ttl_jitter = jitter;
185        self
186    }
187}
188
189impl SqlStoreConfig {
190    pub fn new(url: impl Into<String>) -> Self {
191        Self {
192            url: url.into(),
193            min_connections: 0,
194            max_connections: 10,
195            acquire_timeout: Duration::from_secs(3),
196            idle_timeout: Some(Duration::from_secs(10 * 60)),
197            max_lifetime: Some(Duration::from_secs(30 * 60)),
198        }
199    }
200
201    pub fn with_pool_size(mut self, min: u32, max: u32) -> Self {
202        assert!(max > 0, "SQL maximum pool size must be positive");
203        assert!(min <= max, "SQL minimum pool size cannot exceed maximum");
204        self.min_connections = min;
205        self.max_connections = max;
206        self
207    }
208
209    pub fn with_acquire_timeout(mut self, timeout: Duration) -> Self {
210        assert!(!timeout.is_zero(), "SQL acquire timeout must be positive");
211        self.acquire_timeout = timeout;
212        self
213    }
214}
215
216/// A typed SQL connection pool.
217///
218/// The underlying [`Pool`] remains available so applications retain SQLx's compile-time checked
219/// queries. The wrapper standardizes pool configuration, health checks, and transaction creation.
220#[derive(Clone)]
221pub struct SqlStore<DB: Database> {
222    pool: Pool<DB>,
223    metrics: Option<SqlStoreMetrics>,
224}
225
226impl<DB: Database> fmt::Debug for SqlStore<DB> {
227    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
228        formatter
229            .debug_struct("SqlStore")
230            .field("pool", &self.pool)
231            .field("instrumented", &self.metrics.is_some())
232            .finish()
233    }
234}
235
236/// A typed SQL pool with cache-aside record loading and mutation invalidation.
237///
238/// Both present records and missing records are cached, which protects the database from repeated
239/// lookups for absent keys. Concurrent misses for one key are coalesced. Successful mutations
240/// invalidate their keys while coordinating with in-flight loads, preventing an older query from
241/// restoring stale data after the mutation completes.
242pub struct CachedSqlStore<DB, K, V, E, I = K>
243where
244    DB: Database,
245{
246    store: SqlStore<DB>,
247    cache: MemoryCache<K, Option<V>>,
248    indexes: MemoryCache<I, Option<K>>,
249    flights: SingleFlight<K, Option<V>, E>,
250    index_flights: SingleFlight<I, Option<(K, V)>, E>,
251    ttl: Duration,
252    not_found_ttl: Option<Duration>,
253    ttl_jitter: Duration,
254    expiry_sequence: AtomicU64,
255    generation: AtomicU64,
256    cache_gate: Mutex<()>,
257}
258
259impl<DB, K, V, E, I> CachedSqlStore<DB, K, V, E, I>
260where
261    DB: Database,
262    K: Clone + Eq + Hash,
263    I: Clone + Eq + Hash,
264    V: Clone,
265{
266    pub fn new(store: SqlStore<DB>, config: SqlCacheConfig) -> Self {
267        Self {
268            store,
269            cache: MemoryCache::new(config.capacity),
270            indexes: MemoryCache::new(config.capacity),
271            flights: SingleFlight::new(),
272            index_flights: SingleFlight::new(),
273            ttl: config.ttl,
274            not_found_ttl: config.not_found_ttl,
275            ttl_jitter: config.ttl_jitter,
276            expiry_sequence: AtomicU64::new(0),
277            generation: AtomicU64::new(0),
278            cache_gate: Mutex::new(()),
279        }
280    }
281
282    pub fn store(&self) -> &SqlStore<DB> {
283        &self.store
284    }
285
286    pub fn pool(&self) -> &Pool<DB> {
287        self.store.pool()
288    }
289
290    /// Returns a cached record or loads it from SQL once across concurrent callers.
291    ///
292    /// The query receives a cheap clone of the underlying SQLx pool, allowing its returned future
293    /// to own everything it needs without boxed futures or lifetime plumbing.
294    pub async fn find<F, Fut>(&self, key: K, query: F) -> Result<Option<V>, SingleFlightError<E>>
295    where
296        F: FnOnce(Pool<DB>) -> Fut,
297        Fut: Future<Output = Result<Option<V>, E>>,
298    {
299        if let Some(value) = self.cache.get(&key) {
300            return Ok(value);
301        }
302
303        self.flights
304            .execute(key.clone(), || async {
305                if let Some(value) = self.cache.get(&key) {
306                    return Ok(value);
307                }
308
309                let generation = self.generation.load(Ordering::Acquire);
310                let value = query(self.store.pool().clone()).await?;
311
312                // Checking the generation and inserting while holding the same gate used by
313                // invalidation closes the otherwise-small stale repopulation race.
314                let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
315                if self.generation.load(Ordering::Acquire) == generation {
316                    let base_ttl = if value.is_some() {
317                        Some(self.ttl)
318                    } else {
319                        self.not_found_ttl
320                    };
321                    if let Some(base_ttl) = base_ttl {
322                        let sequence = self.expiry_sequence.fetch_add(1, Ordering::Relaxed);
323                        self.cache.insert(
324                            key,
325                            value.clone(),
326                            jittered_ttl(base_ttl, self.ttl_jitter, sequence),
327                        );
328                    }
329                }
330                Ok(value)
331            })
332            .await
333    }
334
335    /// Returns a record selected by a secondary key.
336    ///
337    /// Successful lookups cache both the primary record and the secondary-to-primary mapping.
338    /// Missing secondary keys use the configured not-found policy. Concurrent misses for the same
339    /// secondary key are coalesced independently from primary-key lookups.
340    pub async fn find_by_index<F, Fut>(
341        &self,
342        index: I,
343        query: F,
344    ) -> Result<Option<V>, SingleFlightError<E>>
345    where
346        F: FnOnce(Pool<DB>) -> Fut,
347        Fut: Future<Output = Result<Option<(K, V)>, E>>,
348    {
349        if let Some(primary) = self.indexes.get(&index) {
350            match primary {
351                Some(primary) => {
352                    if let Some(value) = self.cache.get(&primary) {
353                        return Ok(value);
354                    }
355                }
356                None => return Ok(None),
357            }
358        }
359
360        let loaded = self
361            .index_flights
362            .execute(index.clone(), || async {
363                if let Some(primary) = self.indexes.get(&index) {
364                    match primary {
365                        Some(primary) => {
366                            if let Some(Some(value)) = self.cache.get(&primary) {
367                                return Ok(Some((primary, value)));
368                            }
369                        }
370                        None => return Ok(None),
371                    }
372                }
373
374                let generation = self.generation.load(Ordering::Acquire);
375                let value = query(self.store.pool().clone()).await?;
376                let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
377                if self.generation.load(Ordering::Acquire) == generation {
378                    let base_ttl = if value.is_some() {
379                        Some(self.ttl)
380                    } else {
381                        self.not_found_ttl
382                    };
383                    if let Some(base_ttl) = base_ttl {
384                        let sequence = self.expiry_sequence.fetch_add(1, Ordering::Relaxed);
385                        let ttl = jittered_ttl(base_ttl, self.ttl_jitter, sequence);
386                        let primary = value.as_ref().map(|(primary, _)| primary.clone());
387                        self.indexes.insert(index, primary, ttl);
388                        if let Some((primary, value)) = &value {
389                            self.cache.insert(primary.clone(), Some(value.clone()), ttl);
390                        }
391                    }
392                }
393                Ok(value)
394            })
395            .await?;
396        Ok(loaded.map(|(_, value)| value))
397    }
398
399    /// Runs a SQL mutation and invalidates the affected cache keys after it succeeds.
400    pub async fn execute<PI, F, Fut, R>(&self, keys: PI, operation: F) -> Result<R, E>
401    where
402        PI: IntoIterator<Item = K>,
403        F: FnOnce(Pool<DB>) -> Fut,
404        Fut: Future<Output = Result<R, E>>,
405    {
406        let result = operation(self.store.pool().clone()).await?;
407        self.invalidate_many(keys);
408        Ok(result)
409    }
410
411    /// Runs a mutation and invalidates primary keys plus explicitly affected secondary keys.
412    ///
413    /// Learned secondary mappings for each primary key are removed automatically. Callers should
414    /// also pass newly introduced or changed secondary keys so a previously cached not-found
415    /// result cannot hide an insert or update.
416    pub async fn execute_indexed<PI, SI, F, Fut, R>(
417        &self,
418        primary_keys: PI,
419        index_keys: SI,
420        operation: F,
421    ) -> Result<R, E>
422    where
423        PI: IntoIterator<Item = K>,
424        SI: IntoIterator<Item = I>,
425        F: FnOnce(Pool<DB>) -> Fut,
426        Fut: Future<Output = Result<R, E>>,
427    {
428        let result = operation(self.store.pool().clone()).await?;
429        self.invalidate_related(primary_keys, index_keys);
430        Ok(result)
431    }
432
433    /// Invalidates a key and reports whether it held a positive or negative cached record.
434    pub fn invalidate(&self, key: &K) -> bool {
435        let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
436        self.generation.fetch_add(1, Ordering::AcqRel);
437        let removed = self.cache.remove(key).is_some();
438        self.indexes
439            .remove_where(|_, primary| primary.as_ref() == Some(key));
440        removed
441    }
442
443    pub fn invalidate_many<PI>(&self, keys: PI)
444    where
445        PI: IntoIterator<Item = K>,
446    {
447        let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
448        self.generation.fetch_add(1, Ordering::AcqRel);
449        let keys: std::collections::HashSet<_> = keys.into_iter().collect();
450        for key in &keys {
451            self.cache.remove(key);
452        }
453        self.indexes
454            .remove_where(|_, primary| primary.as_ref().is_some_and(|key| keys.contains(key)));
455    }
456
457    pub fn invalidate_index(&self, index: &I) -> bool {
458        let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
459        self.generation.fetch_add(1, Ordering::AcqRel);
460        self.indexes.remove(index).is_some()
461    }
462
463    pub fn invalidate_related<PI, SI>(&self, primary_keys: PI, index_keys: SI)
464    where
465        PI: IntoIterator<Item = K>,
466        SI: IntoIterator<Item = I>,
467    {
468        let _guard = self.cache_gate.lock().expect("SQL cache gate poisoned");
469        self.generation.fetch_add(1, Ordering::AcqRel);
470        let primary_keys: std::collections::HashSet<_> = primary_keys.into_iter().collect();
471        for key in &primary_keys {
472            self.cache.remove(key);
473        }
474        self.indexes.remove_where(|_, primary| {
475            primary
476                .as_ref()
477                .is_some_and(|key| primary_keys.contains(key))
478        });
479        for index in index_keys {
480            self.indexes.remove(&index);
481        }
482    }
483
484    pub fn cache_stats(&self) -> CacheStats {
485        self.cache.stats()
486    }
487
488    pub fn index_cache_stats(&self) -> CacheStats {
489        self.indexes.stats()
490    }
491}
492
493impl<DB: Database> SqlStore<DB> {
494    pub fn from_pool(pool: Pool<DB>) -> Self {
495        Self {
496            pool,
497            metrics: None,
498        }
499    }
500
501    /// Installs metrics for operations run through the typed helper methods.
502    pub fn with_metrics(mut self, metrics: SqlStoreMetrics) -> Self {
503        self.metrics = Some(metrics);
504        self
505    }
506
507    pub fn pool(&self) -> &Pool<DB> {
508        &self.pool
509    }
510
511    pub async fn begin(&self) -> Result<Transaction<'static, DB>, sqlx::Error> {
512        self.pool.begin().await
513    }
514
515    /// Runs a typed query closure with consistent metrics, tracing, and error conversion.
516    pub async fn query<T, F, Fut>(
517        &self,
518        operation: &'static str,
519        query: F,
520    ) -> Result<T, SqlStoreError>
521    where
522        F: FnOnce(Pool<DB>) -> Fut,
523        Fut: Future<Output = Result<T, sqlx::Error>>,
524    {
525        let pool = self.pool.clone();
526        self.instrument(operation, SqlOperationKind::Query, async move {
527            query(pool).await.map_err(SqlStoreError::Database)
528        })
529        .await
530    }
531
532    /// Runs an optional typed query and converts an absent row into [`SqlStoreError::NotFound`].
533    pub async fn query_one<T, F, Fut>(
534        &self,
535        operation: &'static str,
536        entity: impl Into<String>,
537        query: F,
538    ) -> Result<T, SqlStoreError>
539    where
540        F: FnOnce(Pool<DB>) -> Fut,
541        Fut: Future<Output = Result<Option<T>, sqlx::Error>>,
542    {
543        let pool = self.pool.clone();
544        let entity = entity.into();
545        self.instrument(operation, SqlOperationKind::Query, async move {
546            query(pool)
547                .await
548                .map_err(SqlStoreError::Database)?
549                .ok_or_else(|| SqlStoreError::not_found(entity))
550        })
551        .await
552    }
553
554    /// Runs a typed mutation closure with consistent metrics, tracing, and error conversion.
555    pub async fn execute<T, F, Fut>(
556        &self,
557        operation: &'static str,
558        execute: F,
559    ) -> Result<T, SqlStoreError>
560    where
561        F: FnOnce(Pool<DB>) -> Fut,
562        Fut: Future<Output = Result<T, sqlx::Error>>,
563    {
564        let pool = self.pool.clone();
565        self.instrument(operation, SqlOperationKind::Execute, async move {
566            execute(pool).await.map_err(SqlStoreError::Database)
567        })
568        .await
569    }
570
571    /// Inserts items in bounded batches using a database-specific typed SQLx closure.
572    ///
573    /// The returned vector contains one result per batch. The helper intentionally leaves SQL
574    /// construction to the caller because placeholder syntax and optimal multi-row statements
575    /// differ across SQLite, PostgreSQL, and MySQL.
576    pub async fn bulk_insert<T, R, F, Fut>(
577        &self,
578        operation: &'static str,
579        items: impl IntoIterator<Item = T>,
580        batch_size: usize,
581        mut insert_batch: F,
582    ) -> Result<Vec<R>, SqlStoreError>
583    where
584        F: FnMut(Pool<DB>, Vec<T>) -> Fut,
585        Fut: Future<Output = Result<R, sqlx::Error>>,
586    {
587        if batch_size == 0 {
588            return Err(SqlStoreError::InvalidBatchSize);
589        }
590
591        let started = Instant::now();
592        #[cfg(feature = "telemetry")]
593        let span = TelemetrySpan::start(
594            format!("sql.{operation}"),
595            TelemetrySpanKind::Client,
596            None,
597            [
598                ("db.operation.name", operation.to_owned()),
599                (
600                    "rust_zero.sql.kind",
601                    SqlOperationKind::BulkInsert.as_str().to_owned(),
602                ),
603            ],
604        );
605        let mut results = Vec::new();
606        let mut batch = Vec::with_capacity(batch_size);
607        let mut items = items.into_iter();
608        let result = loop {
609            batch.extend(items.by_ref().take(batch_size));
610            if batch.is_empty() {
611                break Ok(results);
612            }
613            let current = std::mem::replace(&mut batch, Vec::with_capacity(batch_size));
614            match insert_batch(self.pool.clone(), current).await {
615                Ok(result) => results.push(result),
616                Err(error) => break Err(SqlStoreError::Database(error)),
617            }
618        };
619        let outcome = sql_outcome(&result);
620        if let Some(metrics) = &self.metrics {
621            metrics.observe(
622                operation,
623                SqlOperationKind::BulkInsert,
624                outcome,
625                started.elapsed(),
626            );
627        }
628        #[cfg(feature = "telemetry")]
629        if let Err(error) = &result {
630            span.set_error(error.to_string());
631        }
632        result
633    }
634
635    async fn instrument<T, Fut>(
636        &self,
637        operation: &'static str,
638        kind: SqlOperationKind,
639        future: Fut,
640    ) -> Result<T, SqlStoreError>
641    where
642        Fut: Future<Output = Result<T, SqlStoreError>>,
643    {
644        let started = Instant::now();
645        #[cfg(feature = "telemetry")]
646        let span = TelemetrySpan::start(
647            format!("sql.{operation}"),
648            TelemetrySpanKind::Client,
649            None,
650            [
651                ("db.operation.name", operation.to_owned()),
652                ("rust_zero.sql.kind", kind.as_str().to_owned()),
653            ],
654        );
655        let result = future.await;
656        let outcome = sql_outcome(&result);
657        if let Some(metrics) = &self.metrics {
658            metrics.observe(operation, kind, outcome, started.elapsed());
659        }
660        #[cfg(feature = "telemetry")]
661        if let Err(error) = &result {
662            span.set_error(error.to_string());
663        }
664        result
665    }
666
667    pub async fn close(&self) {
668        self.pool.close().await;
669    }
670}
671
672fn sql_outcome<T>(result: &Result<T, SqlStoreError>) -> &'static str {
673    match result {
674        Ok(_) => "success",
675        Err(SqlStoreError::NotFound { .. }) => "not_found",
676        Err(SqlStoreError::Database(_) | SqlStoreError::InvalidBatchSize) => "error",
677    }
678}
679
680impl SqlStore<Sqlite> {
681    pub async fn connect_sqlite(config: SqlStoreConfig) -> Result<Self, sqlx::Error> {
682        let pool = configure_sqlite(&config).connect(&config.url).await?;
683        Ok(Self::from_pool(pool))
684    }
685
686    pub async fn health_check(&self) -> Result<(), sqlx::Error> {
687        sqlx::query("SELECT 1").execute(&self.pool).await?;
688        Ok(())
689    }
690}
691
692impl SqlStore<Postgres> {
693    pub async fn connect_postgres(config: SqlStoreConfig) -> Result<Self, sqlx::Error> {
694        let pool = configure_postgres(&config).connect(&config.url).await?;
695        Ok(Self::from_pool(pool))
696    }
697
698    pub async fn health_check(&self) -> Result<(), sqlx::Error> {
699        sqlx::query("SELECT 1").execute(&self.pool).await?;
700        Ok(())
701    }
702}
703
704impl SqlStore<MySql> {
705    pub async fn connect_mysql(config: SqlStoreConfig) -> Result<Self, sqlx::Error> {
706        let pool = configure_mysql(&config).connect(&config.url).await?;
707        Ok(Self::from_pool(pool))
708    }
709
710    pub async fn health_check(&self) -> Result<(), sqlx::Error> {
711        sqlx::query("SELECT 1").execute(&self.pool).await?;
712        Ok(())
713    }
714}
715
716fn configure_sqlite(config: &SqlStoreConfig) -> SqlitePoolOptions {
717    SqlitePoolOptions::new()
718        .min_connections(config.min_connections)
719        .max_connections(config.max_connections)
720        .acquire_timeout(config.acquire_timeout)
721        .idle_timeout(config.idle_timeout)
722        .max_lifetime(config.max_lifetime)
723}
724
725fn configure_postgres(config: &SqlStoreConfig) -> PgPoolOptions {
726    PgPoolOptions::new()
727        .min_connections(config.min_connections)
728        .max_connections(config.max_connections)
729        .acquire_timeout(config.acquire_timeout)
730        .idle_timeout(config.idle_timeout)
731        .max_lifetime(config.max_lifetime)
732}
733
734fn configure_mysql(config: &SqlStoreConfig) -> MySqlPoolOptions {
735    MySqlPoolOptions::new()
736        .min_connections(config.min_connections)
737        .max_connections(config.max_connections)
738        .acquire_timeout(config.acquire_timeout)
739        .idle_timeout(config.idle_timeout)
740        .max_lifetime(config.max_lifetime)
741}
742
743pub type SqliteStore = SqlStore<Sqlite>;
744pub type PostgresStore = SqlStore<Postgres>;
745pub type MySqlStore = SqlStore<MySql>;
746
747// Keep the concrete pool aliases visible in generated API documentation.
748const _: Option<(SqlitePool, PgPool, MySqlPool)> = None;
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use sqlx::Row;
754    use std::sync::{
755        atomic::{AtomicUsize, Ordering},
756        Arc,
757    };
758    use tokio::sync::Notify;
759
760    #[tokio::test]
761    async fn sqlite_supports_queries_and_commit_or_rollback_transactions() {
762        let store = SqliteStore::connect_sqlite(
763            SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
764        )
765        .await
766        .unwrap();
767        store.health_check().await.unwrap();
768        sqlx::query("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
769            .execute(store.pool())
770            .await
771            .unwrap();
772
773        let mut transaction = store.begin().await.unwrap();
774        sqlx::query("INSERT INTO users (name) VALUES (?)")
775            .bind("Ada")
776            .execute(&mut *transaction)
777            .await
778            .unwrap();
779        transaction.commit().await.unwrap();
780
781        let row = sqlx::query("SELECT name FROM users")
782            .fetch_one(store.pool())
783            .await
784            .unwrap();
785        assert_eq!(row.try_get::<String, _>("name").unwrap(), "Ada");
786
787        let mut transaction = store.begin().await.unwrap();
788        sqlx::query("DELETE FROM users")
789            .execute(&mut *transaction)
790            .await
791            .unwrap();
792        transaction.rollback().await.unwrap();
793        let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
794            .fetch_one(store.pool())
795            .await
796            .unwrap();
797        assert_eq!(count, 1);
798    }
799
800    #[tokio::test]
801    async fn typed_helpers_batch_inserts_standardize_not_found_and_emit_metrics() {
802        let registry = Metrics::new();
803        let metrics = SqlStoreMetrics::register(&registry).unwrap();
804        let store = SqliteStore::connect_sqlite(
805            SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
806        )
807        .await
808        .unwrap()
809        .with_metrics(metrics);
810
811        store
812            .execute("create_users", |pool| async move {
813                sqlx::query("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
814                    .execute(&pool)
815                    .await
816            })
817            .await
818            .unwrap();
819
820        let batches = store
821            .bulk_insert(
822                "insert_users",
823                (1_i64..=5).map(|id| (id, format!("user-{id}"))),
824                2,
825                |pool, users| async move {
826                    let mut query =
827                        sqlx::QueryBuilder::<Sqlite>::new("INSERT INTO users (id, name) ");
828                    query.push_values(users, |mut row, (id, name)| {
829                        row.push_bind(id).push_bind(name);
830                    });
831                    query
832                        .build()
833                        .execute(&pool)
834                        .await
835                        .map(|result| result.rows_affected())
836                },
837            )
838            .await
839            .unwrap();
840        assert_eq!(batches, vec![2, 2, 1]);
841
842        let name: String = store
843            .query_one("find_user", "user", |pool| async move {
844                sqlx::query_scalar("SELECT name FROM users WHERE id = ?")
845                    .bind(3_i64)
846                    .fetch_optional(&pool)
847                    .await
848            })
849            .await
850            .unwrap();
851        assert_eq!(name, "user-3");
852
853        let missing = store
854            .query_one::<String, _, _>("find_user", "user", |pool| async move {
855                sqlx::query_scalar("SELECT name FROM users WHERE id = ?")
856                    .bind(404_i64)
857                    .fetch_optional(&pool)
858                    .await
859            })
860            .await
861            .unwrap_err();
862        assert!(missing.is_not_found());
863        assert_eq!(missing.to_string(), "user not found");
864
865        let rendered = registry.render();
866        assert!(rendered.contains(
867            "rust_zero_sql_operations_total{operation=\"insert_users\",kind=\"bulk_insert\",outcome=\"success\"} 1"
868        ));
869        assert!(rendered.contains(
870            "rust_zero_sql_operations_total{operation=\"find_user\",kind=\"query\",outcome=\"not_found\"} 1"
871        ));
872        assert!(matches!(
873            store
874                .bulk_insert::<i64, (), _, _>("invalid", [], 0, |_pool, _items| async { Ok(()) })
875                .await,
876            Err(SqlStoreError::InvalidBatchSize)
877        ));
878    }
879
880    #[test]
881    #[should_panic(expected = "minimum pool size")]
882    fn rejects_invalid_pool_bounds() {
883        let _ = SqlStoreConfig::new("sqlite::memory:").with_pool_size(2, 1);
884    }
885
886    #[tokio::test]
887    async fn cached_store_caches_records_and_missing_rows_then_invalidates_mutations() {
888        let store = SqliteStore::connect_sqlite(
889            SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
890        )
891        .await
892        .unwrap();
893        sqlx::query("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
894            .execute(store.pool())
895            .await
896            .unwrap();
897        sqlx::query("INSERT INTO users (id, name) VALUES (1, 'Ada')")
898            .execute(store.pool())
899            .await
900            .unwrap();
901
902        let cached = CachedSqlStore::<Sqlite, i64, String, sqlx::Error>::new(
903            store,
904            SqlCacheConfig::new(10, Duration::from_secs(30)),
905        );
906        let queries = AtomicUsize::new(0);
907
908        for _ in 0..2 {
909            let queries = &queries;
910            let name = cached
911                .find(1, move |pool| async move {
912                    queries.fetch_add(1, Ordering::SeqCst);
913                    sqlx::query_scalar("SELECT name FROM users WHERE id = ?")
914                        .bind(1_i64)
915                        .fetch_optional(&pool)
916                        .await
917                })
918                .await
919                .unwrap();
920            assert_eq!(name.as_deref(), Some("Ada"));
921        }
922        for _ in 0..2 {
923            let queries = &queries;
924            assert_eq!(
925                cached
926                    .find(404, move |pool| async move {
927                        queries.fetch_add(1, Ordering::SeqCst);
928                        sqlx::query_scalar("SELECT name FROM users WHERE id = ?")
929                            .bind(404_i64)
930                            .fetch_optional(&pool)
931                            .await
932                    })
933                    .await
934                    .unwrap(),
935                None
936            );
937        }
938        assert_eq!(queries.load(Ordering::SeqCst), 2);
939
940        cached
941            .execute([1], |pool| async move {
942                sqlx::query("UPDATE users SET name = 'Grace' WHERE id = 1")
943                    .execute(&pool)
944                    .await
945            })
946            .await
947            .unwrap();
948        let name = cached
949            .find(1, |pool| async move {
950                sqlx::query_scalar("SELECT name FROM users WHERE id = 1")
951                    .fetch_optional(&pool)
952                    .await
953            })
954            .await
955            .unwrap();
956        assert_eq!(name.as_deref(), Some("Grace"));
957    }
958
959    #[tokio::test]
960    async fn secondary_indexes_share_primary_records_and_are_invalidated_with_mutations() {
961        let store = SqliteStore::connect_sqlite(
962            SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
963        )
964        .await
965        .unwrap();
966        sqlx::query(
967            "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, name TEXT NOT NULL)",
968        )
969        .execute(store.pool())
970        .await
971        .unwrap();
972        sqlx::query("INSERT INTO users (id, email, name) VALUES (1, 'ada@test', 'Ada')")
973            .execute(store.pool())
974            .await
975            .unwrap();
976
977        let cached = CachedSqlStore::<Sqlite, i64, String, sqlx::Error, String>::new(
978            store,
979            SqlCacheConfig::new(10, Duration::from_secs(30)),
980        );
981        let queries = AtomicUsize::new(0);
982
983        for _ in 0..2 {
984            let queries = &queries;
985            let user = cached
986                .find_by_index("ada@test".to_owned(), move |pool| async move {
987                    queries.fetch_add(1, Ordering::SeqCst);
988                    sqlx::query_as::<_, (i64, String)>(
989                        "SELECT id, name FROM users WHERE email = 'ada@test'",
990                    )
991                    .fetch_optional(&pool)
992                    .await
993                })
994                .await
995                .unwrap();
996            assert_eq!(user.as_deref(), Some("Ada"));
997        }
998        assert_eq!(queries.load(Ordering::SeqCst), 1);
999
1000        // A secondary lookup populates the ordinary primary cache as well.
1001        let primary = cached
1002            .find(1, |_pool| async { Ok(Some("uncached".to_owned())) })
1003            .await
1004            .unwrap();
1005        assert_eq!(primary.as_deref(), Some("Ada"));
1006
1007        // Cache a miss for the key that the mutation is about to introduce.
1008        assert!(cached
1009            .find_by_index("grace@test".to_owned(), |pool| async move {
1010                sqlx::query_as::<_, (i64, String)>(
1011                    "SELECT id, name FROM users WHERE email = 'grace@test'",
1012                )
1013                .fetch_optional(&pool)
1014                .await
1015            })
1016            .await
1017            .unwrap()
1018            .is_none());
1019
1020        cached
1021            .execute_indexed(
1022                [1],
1023                ["ada@test".to_owned(), "grace@test".to_owned()],
1024                |pool| async move {
1025                    sqlx::query(
1026                        "UPDATE users SET email = 'grace@test', name = 'Grace' WHERE id = 1",
1027                    )
1028                    .execute(&pool)
1029                    .await
1030                },
1031            )
1032            .await
1033            .unwrap();
1034
1035        let old = cached
1036            .find_by_index("ada@test".to_owned(), |pool| async move {
1037                sqlx::query_as::<_, (i64, String)>(
1038                    "SELECT id, name FROM users WHERE email = 'ada@test'",
1039                )
1040                .fetch_optional(&pool)
1041                .await
1042            })
1043            .await
1044            .unwrap();
1045        assert_eq!(old, None);
1046        let renamed = cached
1047            .find_by_index("grace@test".to_owned(), |pool| async move {
1048                sqlx::query_as::<_, (i64, String)>(
1049                    "SELECT id, name FROM users WHERE email = 'grace@test'",
1050                )
1051                .fetch_optional(&pool)
1052                .await
1053            })
1054            .await
1055            .unwrap();
1056        assert_eq!(renamed.as_deref(), Some("Grace"));
1057        assert!(cached.index_cache_stats().insertions >= 4);
1058    }
1059
1060    #[tokio::test]
1061    async fn cached_store_can_disable_or_shorten_not_found_caching() {
1062        let store = SqliteStore::connect_sqlite(
1063            SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
1064        )
1065        .await
1066        .unwrap();
1067        let cached = CachedSqlStore::<Sqlite, i64, String, sqlx::Error>::new(
1068            store,
1069            SqlCacheConfig::new(10, Duration::from_secs(30))
1070                .with_not_found_ttl(None)
1071                .with_ttl_jitter(Duration::from_secs(5)),
1072        );
1073        let queries = AtomicUsize::new(0);
1074
1075        for _ in 0..2 {
1076            let queries = &queries;
1077            let value = cached
1078                .find(404, move |_pool| async move {
1079                    queries.fetch_add(1, Ordering::SeqCst);
1080                    Ok(None)
1081                })
1082                .await
1083                .unwrap();
1084            assert_eq!(value, None);
1085        }
1086
1087        assert_eq!(queries.load(Ordering::SeqCst), 2);
1088        assert_eq!(cached.cache_stats().insertions, 0);
1089    }
1090
1091    #[tokio::test]
1092    async fn mutation_prevents_an_inflight_read_from_repopulating_stale_data() {
1093        let store = SqliteStore::connect_sqlite(
1094            SqlStoreConfig::new("sqlite::memory:").with_pool_size(1, 1),
1095        )
1096        .await
1097        .unwrap();
1098        let cached = Arc::new(CachedSqlStore::<Sqlite, i64, String, sqlx::Error>::new(
1099            store,
1100            SqlCacheConfig::new(10, Duration::from_secs(30)),
1101        ));
1102        let query_started = Arc::new(Notify::new());
1103        let release_query = Arc::new(Notify::new());
1104
1105        let stale_read = {
1106            let cached = Arc::clone(&cached);
1107            let query_started = Arc::clone(&query_started);
1108            let release_query = Arc::clone(&release_query);
1109            tokio::spawn(async move {
1110                cached
1111                    .find(1, move |_pool| async move {
1112                        query_started.notify_one();
1113                        release_query.notified().await;
1114                        Ok(Some("stale".to_owned()))
1115                    })
1116                    .await
1117            })
1118        };
1119        query_started.notified().await;
1120        cached
1121            .execute([1], |_pool| async { Ok::<_, sqlx::Error>(()) })
1122            .await
1123            .unwrap();
1124        release_query.notify_one();
1125        assert_eq!(stale_read.await.unwrap().unwrap().as_deref(), Some("stale"));
1126        assert_eq!(cached.cache.get(&1), None);
1127    }
1128
1129    #[tokio::test]
1130    async fn postgres_integration_covers_health_crud_and_transactions() {
1131        let Ok(url) = std::env::var("RUST_ZERO_POSTGRES_URL") else {
1132            return;
1133        };
1134        let store = PostgresStore::connect_postgres(SqlStoreConfig::new(url).with_pool_size(1, 1))
1135            .await
1136            .unwrap();
1137        store.health_check().await.unwrap();
1138        sqlx::query(
1139            "CREATE TEMPORARY TABLE rust_zero_users (id BIGINT PRIMARY KEY, name TEXT NOT NULL)",
1140        )
1141        .execute(store.pool())
1142        .await
1143        .unwrap();
1144        let mut transaction = store.begin().await.unwrap();
1145        sqlx::query("INSERT INTO rust_zero_users (id, name) VALUES ($1, $2)")
1146            .bind(1_i64)
1147            .bind("Ada")
1148            .execute(&mut *transaction)
1149            .await
1150            .unwrap();
1151        transaction.commit().await.unwrap();
1152        let name: String = sqlx::query_scalar("SELECT name FROM rust_zero_users WHERE id = $1")
1153            .bind(1_i64)
1154            .fetch_one(store.pool())
1155            .await
1156            .unwrap();
1157        assert_eq!(name, "Ada");
1158    }
1159
1160    #[tokio::test]
1161    async fn mysql_integration_covers_health_crud_and_transactions() {
1162        let Ok(url) = std::env::var("RUST_ZERO_MYSQL_URL") else {
1163            return;
1164        };
1165        let store = MySqlStore::connect_mysql(SqlStoreConfig::new(url).with_pool_size(1, 1))
1166            .await
1167            .unwrap();
1168        store.health_check().await.unwrap();
1169        sqlx::query(
1170            "CREATE TEMPORARY TABLE rust_zero_users (id BIGINT PRIMARY KEY, name TEXT NOT NULL)",
1171        )
1172        .execute(store.pool())
1173        .await
1174        .unwrap();
1175        let mut transaction = store.begin().await.unwrap();
1176        sqlx::query("INSERT INTO rust_zero_users (id, name) VALUES (?, ?)")
1177            .bind(1_i64)
1178            .bind("Ada")
1179            .execute(&mut *transaction)
1180            .await
1181            .unwrap();
1182        transaction.commit().await.unwrap();
1183        let name: String = sqlx::query_scalar("SELECT name FROM rust_zero_users WHERE id = ?")
1184            .bind(1_i64)
1185            .fetch_one(store.pool())
1186            .await
1187            .unwrap();
1188        assert_eq!(name, "Ada");
1189    }
1190}