Skip to main content

fraiseql_core/cache/adapter/
mod.rs

1//! Cached database adapter wrapper.
2//!
3//! Provides transparent caching for `DatabaseAdapter` implementations by wrapping
4//! `execute_where_query()` calls with cache lookup and storage.
5//!
6//! # Security: Cache Isolation via RLS
7//!
8//! Automatic Persisted Query (APQ) caching provides no user-level isolation on its own.
9//! Cache key isolation derives entirely from Row-Level Security: different users MUST
10//! produce different WHERE clauses via their RLS policies. If RLS is disabled or
11//! returns an empty WHERE clause, two users with the same query and variables will
12//! receive the same cached response.
13//!
14//! **Always verify RLS is active when caching is enabled in multi-tenant deployments.**
15//!
16//! # Architecture
17//!
18//! ```text
19//! ┌─────────────────────────┐
20//! │ CachedDatabaseAdapter   │
21//! │                         │
22//! │  execute_where_query()  │
23//! └───────────┬─────────────┘
24//!             │
25//!             ↓ generate_cache_key()
26//! ┌─────────────────────────┐
27//! │ Cache Hit?              │
28//! └───────────┬─────────────┘
29//!             │
30//!       ┌─────┴─────┐
31//!       │           │
32//!      HIT         MISS
33//!       │           │
34//!       ↓           ↓ DatabaseAdapter
35//! Return Cached   Execute Query
36//! Result          + Store in Cache
37//! ```
38//!
39//! # Example
40//!
41//! ```no_run
42//! use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig};
43//! use fraiseql_core::db::{postgres::PostgresAdapter, DatabaseAdapter};
44//!
45//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
46//! // Create underlying database adapter
47//! let db_adapter = PostgresAdapter::new("postgresql://localhost/db").await?;
48//!
49//! // Wrap with caching
50//! let cache = QueryResultCache::new(CacheConfig::default());
51//! let cached_adapter = CachedDatabaseAdapter::new(
52//!     db_adapter,
53//!     cache,
54//!     "1.0.0".to_string()  // schema version
55//! );
56//!
57//! // Use as normal DatabaseAdapter - caching is transparent
58//! let users = cached_adapter
59//!     .execute_where_query("v_user", None, Some(10), None, None)
60//!     .await?;
61//! # Ok(())
62//! # }
63//! ```
64
65use std::{
66    collections::{HashMap, HashSet},
67    sync::{Arc, Mutex},
68};
69
70use async_trait::async_trait;
71
72use super::{
73    cascade_invalidator::CascadeInvalidator,
74    fact_table_version::{FactTableCacheConfig, FactTableVersionProvider},
75    result::QueryResultCache,
76};
77use crate::{
78    cache::config::RlsEnforcement,
79    db::{
80        DatabaseAdapter, DatabaseType, PoolMetrics, SupportsMutations, WhereClause,
81        types::{JsonbValue, OrderByClause},
82    },
83    error::{FraiseQLError, Result},
84    schema::CompiledSchema,
85};
86
87mod mutation;
88mod query;
89#[cfg(test)]
90mod tests;
91
92pub use query::view_name_to_entity_type;
93
94/// Cached database adapter wrapper.
95///
96/// Wraps any `DatabaseAdapter` implementation with transparent query result caching.
97/// Cache keys include query, variables, WHERE clause, and schema version for security
98/// and correctness.
99///
100/// # Cache Behavior
101///
102/// - **Cache Hit**: Returns cached result in ~0.1ms (50-200x faster than database)
103/// - **Cache Miss**: Executes query via underlying adapter, stores result in cache
104/// - **Invalidation**: Call `invalidate_views()` after mutations to clear affected caches
105///
106/// # Thread Safety
107///
108/// This adapter is `Send + Sync` and can be safely shared across async tasks.
109/// The underlying cache uses `Arc<Mutex<>>` for thread-safe access.
110///
111/// # Example
112///
113/// ```no_run
114/// use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig, InvalidationContext};
115/// use fraiseql_core::db::{postgres::PostgresAdapter, DatabaseAdapter};
116/// use fraiseql_db::ViewName;
117///
118/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
119/// let db = PostgresAdapter::new("postgresql://localhost/db").await?;
120/// let cache = QueryResultCache::new(CacheConfig::default());
121/// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string());
122///
123/// // First query - cache miss (slower)
124/// let users1 = adapter.execute_where_query("v_user", None, None, None, None).await?;
125///
126/// // Second query - cache hit (fast!)
127/// let users2 = adapter.execute_where_query("v_user", None, None, None, None).await?;
128///
129/// // After mutation, invalidate. `InvalidationContext` keeps its `Vec<String>`
130/// // shape for compatibility with the audit-logging facade; callers convert at
131/// // the adapter boundary.
132/// let invalidation = InvalidationContext::for_mutation(
133///     "createUser",
134///     vec!["v_user".to_string()],
135/// );
136/// let views: Vec<ViewName> = invalidation
137///     .modified_views
138///     .iter()
139///     .map(ViewName::from)
140///     .collect();
141/// adapter.invalidate_views(&views)?;
142/// # Ok(())
143/// # }
144/// ```
145pub struct CachedDatabaseAdapter<A: DatabaseAdapter> {
146    /// Underlying database adapter.
147    pub(super) adapter: A,
148
149    /// Query result cache.
150    pub(super) cache: Arc<QueryResultCache>,
151
152    /// Schema version for cache key generation.
153    ///
154    /// When schema version changes (e.g., after deployment), all cache entries
155    /// with old version become invalid automatically.
156    pub(super) schema_version: String,
157
158    /// Per-view TTL overrides in seconds.
159    ///
160    /// Populated from `QueryDefinition::cache_ttl_seconds` at server startup:
161    /// view name → TTL seconds.  `None` for a view falls back to the global
162    /// `CacheConfig::ttl_seconds`.
163    pub(super) view_ttl_overrides: HashMap<String, u64>,
164
165    /// Set of views that explicitly opt into caching via `cache_ttl_seconds`.
166    ///
167    /// Derived from `view_ttl_overrides` keys.  When `opt_in_mode` is active,
168    /// views **not** in this set bypass cache key generation entirely, eliminating
169    /// allocation overhead for uncached queries.
170    pub(super) cacheable_views: HashSet<String>,
171
172    /// Whether opt-in caching mode is active.
173    ///
174    /// Set to `true` by [`CachedDatabaseAdapter::with_view_ttl_overrides`] and
175    /// [`CachedDatabaseAdapter::with_ttl_overrides_from_schema`] to indicate that
176    /// the caller has intentionally configured per-view TTL overrides.  In this
177    /// mode, **only** views in `cacheable_views` are cached; all others bypass
178    /// key-generation entirely.
179    ///
180    /// When `false` (default, adapter created with [`Self::new`] or
181    /// [`Self::with_fact_table_config`] without a schema call), all views remain
182    /// cacheable — preserving backward-compatible behaviour for tests and direct
183    /// usage that do not use per-query TTL annotations.
184    pub(super) opt_in_mode: bool,
185
186    /// Whether the schema has RLS configured (affects caching for unauthenticated requests).
187    pub(super) has_rls: bool,
188
189    /// Configuration for fact table aggregation caching.
190    pub(super) fact_table_config: FactTableCacheConfig,
191
192    /// Version provider for fact tables (caches version lookups).
193    pub(super) version_provider: Arc<FactTableVersionProvider>,
194
195    /// Optional cascade invalidator for transitive view dependency expansion.
196    ///
197    /// When set, `invalidate_views()` uses BFS to expand the initial view list
198    /// to include all transitively dependent views before clearing cache entries.
199    pub(super) cascade_invalidator: Option<Arc<Mutex<CascadeInvalidator>>>,
200}
201
202impl<A: DatabaseAdapter> CachedDatabaseAdapter<A> {
203    /// Create new cached database adapter.
204    ///
205    /// # Arguments
206    ///
207    /// * `adapter` - Underlying database adapter to wrap
208    /// * `cache` - Query result cache instance
209    /// * `schema_version` - Uniquely identifies the compiled schema. Use `schema.content_hash()`
210    ///   (NOT `env!("CARGO_PKG_VERSION")`) so that any schema content change automatically
211    ///   invalidates cached entries across deploys.
212    ///
213    /// # Example
214    ///
215    /// ```rust,no_run
216    /// use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig};
217    /// use fraiseql_core::db::postgres::PostgresAdapter;
218    /// use fraiseql_core::schema::CompiledSchema;
219    ///
220    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
221    /// # let schema = CompiledSchema::default();
222    /// let db = PostgresAdapter::new("postgresql://localhost/db").await?;
223    /// let cache = QueryResultCache::new(CacheConfig::default());
224    /// let adapter = CachedDatabaseAdapter::new(
225    ///     db,
226    ///     cache,
227    ///     schema.content_hash()  // Use content hash for automatic invalidation
228    /// );
229    /// # Ok(())
230    /// # }
231    /// ```
232    #[must_use]
233    pub fn new(adapter: A, cache: QueryResultCache, schema_version: String) -> Self {
234        Self {
235            adapter,
236            cache: Arc::new(cache),
237            schema_version,
238            view_ttl_overrides: HashMap::new(),
239            cacheable_views: HashSet::new(),
240            opt_in_mode: false,
241            has_rls: false,
242            fact_table_config: FactTableCacheConfig::default(),
243            version_provider: Arc::new(FactTableVersionProvider::default()),
244            cascade_invalidator: None,
245        }
246    }
247
248    /// Set per-view TTL overrides.
249    ///
250    /// Maps `sql_source` (view name) → TTL in seconds.  Built at server startup
251    /// from compiled `QueryDefinition::cache_ttl_seconds` entries.
252    ///
253    /// # Example
254    ///
255    /// ```rust,no_run
256    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig};
257    /// # use fraiseql_core::db::postgres::PostgresAdapter;
258    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
259    /// # let db = PostgresAdapter::new("postgresql://localhost/db").await?;
260    /// # let cache = QueryResultCache::new(CacheConfig::default());
261    /// let overrides = std::collections::HashMap::from([
262    ///     ("v_country".to_string(), 3600_u64),      // 1 h for reference data
263    ///     ("v_live_price".to_string(), 0_u64),      // no TTL — mutation-invalidated only
264    /// ]);
265    /// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string())
266    ///     .with_view_ttl_overrides(overrides);
267    /// # Ok(())
268    /// # }
269    /// ```
270    #[must_use]
271    pub fn with_view_ttl_overrides(mut self, overrides: HashMap<String, u64>) -> Self {
272        self.cacheable_views = overrides.keys().cloned().collect();
273        self.view_ttl_overrides = overrides;
274        self.opt_in_mode = true;
275        self
276    }
277
278    /// Enable or disable the RLS unauthenticated-request cache bypass.
279    ///
280    /// When `true`, any request without a `SecurityContext` will bypass both
281    /// cache read and write, preventing unauthenticated requests from being
282    /// served stale data that belongs to an authenticated tenant.
283    ///
284    /// Set this to `schema.has_rls_configured()` at server startup.
285    #[must_use]
286    pub const fn with_rls(mut self, has_rls: bool) -> Self {
287        self.has_rls = has_rls;
288        self
289    }
290
291    /// Set a cascade invalidator for transitive view dependency expansion.
292    ///
293    /// When set, `invalidate_views()` uses BFS to expand the initial view list
294    /// to include all views that transitively depend on the invalidated views.
295    ///
296    /// # Example
297    ///
298    /// ```rust,no_run
299    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig, CascadeInvalidator};
300    /// # use fraiseql_core::db::postgres::PostgresAdapter;
301    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
302    /// # let db = PostgresAdapter::new("postgresql://localhost/db").await?;
303    /// # let cache = QueryResultCache::new(CacheConfig::default());
304    /// let mut cascade = CascadeInvalidator::new();
305    /// cascade.add_dependency("v_user_stats", "v_user")?;
306    /// cascade.add_dependency("v_dashboard", "v_user_stats")?;
307    /// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string())
308    ///     .with_cascade_invalidator(cascade);
309    /// # Ok(())
310    /// # }
311    /// ```
312    #[must_use]
313    pub fn with_cascade_invalidator(mut self, invalidator: CascadeInvalidator) -> Self {
314        self.cascade_invalidator = Some(Arc::new(Mutex::new(invalidator)));
315        self
316    }
317
318    /// Populate per-view TTL overrides from a compiled schema.
319    ///
320    /// For each query that has `cache_ttl_seconds` set and a non-null `sql_source`,
321    /// this maps the view name → TTL so the cache adapter uses the per-query TTL
322    /// instead of the global default.
323    ///
324    /// # Example
325    ///
326    /// ```rust,no_run
327    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig};
328    /// # use fraiseql_core::db::postgres::PostgresAdapter;
329    /// # use fraiseql_core::schema::CompiledSchema;
330    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
331    /// # let db = PostgresAdapter::new("postgresql://localhost/db").await?;
332    /// # let cache = QueryResultCache::new(CacheConfig::default());
333    /// # let schema = CompiledSchema::default();
334    /// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string())
335    ///     .with_ttl_overrides_from_schema(&schema);
336    /// # Ok(())
337    /// # }
338    /// ```
339    #[must_use]
340    pub fn with_ttl_overrides_from_schema(mut self, schema: &CompiledSchema) -> Self {
341        for query in &schema.queries {
342            if let (Some(view), Some(ttl)) = (&query.sql_source, query.cache_ttl_seconds) {
343                self.cacheable_views.insert(view.clone());
344                self.view_ttl_overrides.insert(view.clone(), ttl);
345            }
346        }
347        // Always activate opt-in mode when this method is called, regardless of
348        // whether any annotations were found.  If the schema has no cache_ttl_seconds
349        // annotations, cacheable_views stays empty and every query bypasses the cache
350        // entirely — zero overhead.  If annotations are present, only the annotated
351        // views are cached; all others bypass.
352        self.opt_in_mode = true;
353        self
354    }
355
356    /// Rebuild this adapter for a new compiled schema.
357    ///
358    /// Returns a new `CachedDatabaseAdapter` that:
359    /// - Shares the same underlying `QueryResultCache` (via `Arc`)
360    /// - Uses the new schema's content hash as schema version
361    /// - Has per-view TTL overrides from the new schema
362    /// - Clears the shared cache (stale entries from old schema)
363    ///
364    /// The underlying database adapter (connection pool) is **reused** — no
365    /// connections are closed or reopened.
366    ///
367    /// This is the hot-reload counterpart to the startup path
368    /// (`new()` + `with_ttl_overrides_from_schema()`).
369    #[must_use]
370    pub fn rebuilt_for_schema(self, schema: &CompiledSchema) -> Self {
371        // Clear existing cache entries (stale under new schema).
372        let _ = self.cache.clear();
373
374        // Construct a new adapter with updated schema version and TTL overrides,
375        // reusing the same inner adapter and shared cache.
376        let mut rebuilt = Self {
377            adapter:             self.adapter,
378            cache:               self.cache,
379            schema_version:      schema.content_hash(),
380            view_ttl_overrides:  HashMap::new(),
381            cacheable_views:     HashSet::new(),
382            opt_in_mode:         true,
383            has_rls:             self.has_rls,
384            fact_table_config:   self.fact_table_config,
385            version_provider:    self.version_provider,
386            cascade_invalidator: self.cascade_invalidator,
387        };
388        for query in &schema.queries {
389            if let (Some(view), Some(ttl)) = (&query.sql_source, query.cache_ttl_seconds) {
390                rebuilt.cacheable_views.insert(view.clone());
391                rebuilt.view_ttl_overrides.insert(view.clone(), ttl);
392            }
393        }
394        rebuilt
395    }
396
397    /// Create new cached database adapter with fact table caching configuration.
398    ///
399    /// # Arguments
400    ///
401    /// * `adapter` - Underlying database adapter to wrap
402    /// * `cache` - Query result cache instance
403    /// * `schema_version` - Current schema version (e.g., git hash, semver)
404    /// * `fact_table_config` - Configuration for fact table aggregation caching
405    ///
406    /// # Example
407    ///
408    /// ```rust,no_run
409    /// use fraiseql_core::cache::{
410    ///     CachedDatabaseAdapter, QueryResultCache, CacheConfig,
411    ///     FactTableCacheConfig, FactTableVersionStrategy,
412    /// };
413    /// use fraiseql_core::db::postgres::PostgresAdapter;
414    ///
415    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
416    /// let db = PostgresAdapter::new("postgresql://localhost/db").await?;
417    /// let cache = QueryResultCache::new(CacheConfig::default());
418    ///
419    /// // Configure fact table caching strategies
420    /// let mut ft_config = FactTableCacheConfig::default();
421    /// ft_config.set_strategy("tf_sales", FactTableVersionStrategy::VersionTable);
422    /// ft_config.set_strategy("tf_events", FactTableVersionStrategy::time_based(300));
423    ///
424    /// let adapter = CachedDatabaseAdapter::with_fact_table_config(
425    ///     db,
426    ///     cache,
427    ///     "1.0.0".to_string(),
428    ///     ft_config,
429    /// );
430    /// # Ok(())
431    /// # }
432    /// ```
433    #[must_use]
434    pub fn with_fact_table_config(
435        adapter: A,
436        cache: QueryResultCache,
437        schema_version: String,
438        fact_table_config: FactTableCacheConfig,
439    ) -> Self {
440        Self {
441            adapter,
442            cache: Arc::new(cache),
443            schema_version,
444            view_ttl_overrides: HashMap::new(),
445            cacheable_views: HashSet::new(),
446            opt_in_mode: false,
447            has_rls: false,
448            fact_table_config,
449            version_provider: Arc::new(FactTableVersionProvider::default()),
450            cascade_invalidator: None,
451        }
452    }
453
454    /// Get reference to underlying adapter.
455    ///
456    /// Useful for accessing adapter-specific methods not in the `DatabaseAdapter` trait.
457    ///
458    /// # Example
459    ///
460    /// ```rust,no_run
461    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
462    /// # use fraiseql_core::db::postgres::PostgresAdapter;
463    /// # fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) {
464    /// // Access PostgreSQL-specific functionality
465    /// let pg_adapter = adapter.inner();
466    /// # }
467    /// ```
468    #[must_use]
469    pub const fn inner(&self) -> &A {
470        &self.adapter
471    }
472
473    /// Get reference to cache.
474    ///
475    /// Useful for metrics and monitoring.
476    ///
477    /// # Example
478    ///
479    /// ```rust,no_run
480    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
481    /// # use fraiseql_core::db::postgres::PostgresAdapter;
482    /// # async fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) -> Result<(), Box<dyn std::error::Error>> {
483    /// let metrics = adapter.cache().metrics()?;
484    /// println!("Cache hit rate: {:.1}%", metrics.hit_rate() * 100.0);
485    /// # Ok(())
486    /// # }
487    /// ```
488    #[must_use]
489    pub fn cache(&self) -> &QueryResultCache {
490        &self.cache
491    }
492
493    /// Get schema version.
494    ///
495    /// # Example
496    ///
497    /// ```rust,no_run
498    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
499    /// # use fraiseql_core::db::postgres::PostgresAdapter;
500    /// # fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) {
501    /// println!("Schema version: {}", adapter.schema_version());
502    /// # }
503    /// ```
504    #[must_use]
505    pub fn schema_version(&self) -> &str {
506        &self.schema_version
507    }
508
509    /// Get fact table cache configuration.
510    #[must_use]
511    pub const fn fact_table_config(&self) -> &FactTableCacheConfig {
512        &self.fact_table_config
513    }
514
515    /// Get the version provider for fact tables.
516    #[must_use]
517    pub fn version_provider(&self) -> &FactTableVersionProvider {
518        &self.version_provider
519    }
520
521    /// Verify that Row-Level Security is active on the database connection.
522    ///
523    /// Call this during server initialization when both caching and multi-tenancy
524    /// (`schema.is_multi_tenant()`) are enabled. Without RLS, users sharing the same
525    /// query parameters will receive the same cached response regardless of tenant.
526    ///
527    /// # What this checks
528    ///
529    /// Runs `SELECT current_setting('row_security', true) AS rls_setting`. The result
530    /// must be `'on'` or `'force'` for the check to pass. Non-PostgreSQL databases
531    /// (which return an error or unsupported) are treated as "RLS not active".
532    ///
533    /// # Errors
534    ///
535    /// Returns [`FraiseQLError::Configuration`] if RLS appears inactive.
536    pub async fn validate_rls_active(&self) -> Result<()> {
537        let result = self
538            .adapter
539            .execute_raw_query("SELECT current_setting('row_security', true) AS rls_setting")
540            .await;
541
542        let rls_active = match result {
543            Ok(rows) => rows
544                .first()
545                .and_then(|row| row.get("rls_setting"))
546                .and_then(serde_json::Value::as_str)
547                .is_some_and(|s| s == "on" || s == "force"),
548            Err(_) => false, // Non-PostgreSQL or query failure: RLS not active
549        };
550
551        if rls_active {
552            Ok(())
553        } else {
554            Err(FraiseQLError::Configuration {
555                message: "Caching is enabled in a multi-tenant schema but Row-Level Security \
556                          does not appear to be active on the database. This would allow \
557                          cross-tenant data leakage through the cache. \
558                          Either disable caching, enable RLS, or set \
559                          `rls_enforcement = \"off\"` in CacheConfig for single-tenant \
560                          deployments."
561                    .to_string(),
562            })
563        }
564    }
565
566    /// Apply the RLS enforcement policy from `CacheConfig`.
567    ///
568    /// Runs [`validate_rls_active`](Self::validate_rls_active) and handles the result
569    /// according to `enforcement`:
570    /// - [`RlsEnforcement::Error`]: propagates the error (default)
571    /// - [`RlsEnforcement::Warn`]: logs a warning and returns `Ok(())`
572    /// - [`RlsEnforcement::Off`]: skips the check entirely
573    ///
574    /// # Errors
575    ///
576    /// Returns the error from `validate_rls_active` when enforcement is `Error`.
577    pub async fn enforce_rls(&self, enforcement: RlsEnforcement) -> Result<()> {
578        if enforcement == RlsEnforcement::Off {
579            return Ok(());
580        }
581
582        match self.validate_rls_active().await {
583            Ok(()) => Ok(()),
584            Err(e) => match enforcement {
585                RlsEnforcement::Error => Err(e),
586                RlsEnforcement::Warn => {
587                    tracing::warn!(
588                        "RLS check failed (rls_enforcement = \"warn\"): {}. \
589                         Cross-tenant cache leakage is possible.",
590                        e
591                    );
592                    Ok(())
593                },
594                RlsEnforcement::Off => Ok(()), // unreachable but exhaustive
595            },
596        }
597    }
598}
599
600impl<A: DatabaseAdapter + Clone> Clone for CachedDatabaseAdapter<A> {
601    fn clone(&self) -> Self {
602        Self {
603            adapter:             self.adapter.clone(),
604            cache:               Arc::clone(&self.cache),
605            schema_version:      self.schema_version.clone(),
606            view_ttl_overrides:  self.view_ttl_overrides.clone(),
607            cacheable_views:     self.cacheable_views.clone(),
608            opt_in_mode:         self.opt_in_mode,
609            has_rls:             self.has_rls,
610            fact_table_config:   self.fact_table_config.clone(),
611            version_provider:    Arc::clone(&self.version_provider),
612            cascade_invalidator: self.cascade_invalidator.clone(),
613        }
614    }
615}
616
617// Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
618// its transformed method signatures to satisfy the trait contract
619// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
620#[async_trait]
621impl<A: DatabaseAdapter> DatabaseAdapter for CachedDatabaseAdapter<A> {
622    async fn execute_with_projection(
623        &self,
624        view: &str,
625        projection: Option<&crate::schema::SqlProjectionHint>,
626        where_clause: Option<&WhereClause>,
627        limit: Option<u32>,
628        offset: Option<u32>,
629        order_by: Option<&[OrderByClause]>,
630    ) -> Result<Vec<JsonbValue>> {
631        self.execute_with_projection_impl(view, projection, where_clause, limit, offset, order_by)
632            .await
633            .map(Arc::unwrap_or_clone)
634    }
635
636    async fn execute_where_query(
637        &self,
638        view: &str,
639        where_clause: Option<&WhereClause>,
640        limit: Option<u32>,
641        offset: Option<u32>,
642        order_by: Option<&[OrderByClause]>,
643    ) -> Result<Vec<JsonbValue>> {
644        self.execute_where_query_impl(view, where_clause, limit, offset, order_by)
645            .await
646            .map(Arc::unwrap_or_clone)
647    }
648
649    async fn execute_with_projection_arc(
650        &self,
651        request: &crate::db::ProjectionRequest<'_>,
652    ) -> Result<Arc<Vec<JsonbValue>>> {
653        self.execute_with_projection_impl(
654            request.view,
655            request.projection,
656            request.where_clause,
657            request.limit,
658            request.offset,
659            request.order_by,
660        )
661        .await
662    }
663
664    async fn execute_where_query_arc(
665        &self,
666        view: &str,
667        where_clause: Option<&WhereClause>,
668        limit: Option<u32>,
669        offset: Option<u32>,
670        order_by: Option<&[OrderByClause]>,
671    ) -> Result<Arc<Vec<JsonbValue>>> {
672        self.execute_where_query_impl(view, where_clause, limit, offset, order_by).await
673    }
674
675    fn database_type(&self) -> DatabaseType {
676        self.adapter.database_type()
677    }
678
679    async fn health_check(&self) -> Result<()> {
680        self.adapter.health_check().await
681    }
682
683    fn pool_metrics(&self) -> PoolMetrics {
684        self.adapter.pool_metrics()
685    }
686
687    async fn execute_raw_query(
688        &self,
689        sql: &str,
690    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
691        // Use the aggregation caching method which handles fact table versioning
692        self.execute_aggregation_query(sql).await
693    }
694
695    async fn execute_parameterized_aggregate(
696        &self,
697        sql: &str,
698        params: &[serde_json::Value],
699    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
700        // Parameterized aggregate results are not cacheable by SQL template alone;
701        // delegate directly to the underlying adapter to avoid caching with an
702        // incorrect key (the same SQL template with different params would return
703        // different results).
704        self.adapter.execute_parameterized_aggregate(sql, params).await
705    }
706
707    async fn execute_parameterized_aggregate_with_session(
708        &self,
709        sql: &str,
710        params: &[serde_json::Value],
711        session_vars: &[(&str, &str)],
712    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
713        // Not cached (see execute_parameterized_aggregate); forward with session
714        // affinity so current_setting()-backed aggregate RLS sees the values (#329).
715        self.adapter
716            .execute_parameterized_aggregate_with_session(sql, params, session_vars)
717            .await
718    }
719
720    async fn execute_function_call(
721        &self,
722        function_name: &str,
723        args: &[serde_json::Value],
724    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
725        // Mutations are never cached — always delegate to the underlying adapter
726        self.adapter.execute_function_call(function_name, args).await
727    }
728
729    async fn execute_function_call_with_session(
730        &self,
731        function_name: &str,
732        args: &[serde_json::Value],
733        session_vars: &[(&str, &str)],
734    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
735        // Mutations are never cached; pass through with session affinity.
736        self.adapter
737            .execute_function_call_with_session(function_name, args, session_vars)
738            .await
739    }
740
741    async fn execute_where_query_arc_with_session(
742        &self,
743        view: &str,
744        where_clause: Option<&WhereClause>,
745        limit: Option<u32>,
746        offset: Option<u32>,
747        order_by: Option<&[OrderByClause]>,
748        session_vars: &[(&str, &str)],
749    ) -> Result<Arc<Vec<JsonbValue>>> {
750        // No session variables => preserve the cached read path unchanged.
751        if session_vars.is_empty() {
752            return self
753                .execute_where_query_impl(view, where_clause, limit, offset, order_by)
754                .await;
755        }
756        // Security: the result-cache key is NOT session-variable-aware, so a
757        // tenant-scoped read (RLS via current_setting) could otherwise leak
758        // another tenant's cached rows. Bypass the cache and run the read with
759        // session affinity on the inner adapter. Tracked for a cache-key fix:
760        // see #329 follow-up.
761        self.adapter
762            .execute_where_query_arc_with_session(
763                view,
764                where_clause,
765                limit,
766                offset,
767                order_by,
768                session_vars,
769            )
770            .await
771    }
772
773    async fn execute_with_projection_arc_with_session(
774        &self,
775        request: &crate::db::ProjectionRequest<'_>,
776        session_vars: &[(&str, &str)],
777    ) -> Result<Arc<Vec<JsonbValue>>> {
778        // No session variables => preserve the cached read path unchanged.
779        if session_vars.is_empty() {
780            return self
781                .execute_with_projection_impl(
782                    request.view,
783                    request.projection,
784                    request.where_clause,
785                    request.limit,
786                    request.offset,
787                    request.order_by,
788                )
789                .await;
790        }
791        // Security: see execute_where_query_arc_with_session — bypass the
792        // non-tenant-aware cache for session-scoped reads.
793        self.adapter
794            .execute_with_projection_arc_with_session(request, session_vars)
795            .await
796    }
797
798    async fn invalidate_views(&self, views: &[fraiseql_db::ViewName]) -> Result<u64> {
799        // Delegate to the inherent (synchronous) method which handles cascade
800        // expansion and cache eviction.
801        CachedDatabaseAdapter::invalidate_views(self, views)
802    }
803
804    async fn invalidate_by_entity(&self, entity_type: &str, entity_id: &str) -> Result<u64> {
805        CachedDatabaseAdapter::invalidate_by_entity(self, entity_type, entity_id)
806    }
807
808    async fn invalidate_list_queries(&self, views: &[fraiseql_db::ViewName]) -> Result<u64> {
809        CachedDatabaseAdapter::invalidate_list_queries(self, views)
810    }
811
812    async fn bump_fact_table_versions(&self, tables: &[String]) -> Result<()> {
813        self.bump_fact_table_versions_impl(tables).await
814    }
815
816    async fn query_stats(&self, limit: u32) -> Result<Vec<fraiseql_db::QueryStatEntry>> {
817        self.adapter.query_stats(limit).await
818    }
819
820    async fn query_stats_by_id(&self, id: &str) -> Result<Option<fraiseql_db::QueryStatEntry>> {
821        self.adapter.query_stats_by_id(id).await
822    }
823
824    async fn reset_query_stats(&self) -> Result<()> {
825        self.adapter.reset_query_stats().await
826    }
827
828    fn on_schema_reload(&self) {
829        // Clear all cached entries — they reference the old schema's content hash
830        // and per-view TTL configuration.
831        let _ = self.cache.clear();
832    }
833}
834
835impl<A: SupportsMutations + Send + Sync> SupportsMutations for CachedDatabaseAdapter<A> {}