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