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///
117/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
118/// let db = PostgresAdapter::new("postgresql://localhost/db").await?;
119/// let cache = QueryResultCache::new(CacheConfig::default());
120/// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string());
121///
122/// // First query - cache miss (slower)
123/// let users1 = adapter.execute_where_query("v_user", None, None, None, None).await?;
124///
125/// // Second query - cache hit (fast!)
126/// let users2 = adapter.execute_where_query("v_user", None, None, None, None).await?;
127///
128/// // After mutation, invalidate
129/// let invalidation = InvalidationContext::for_mutation(
130///     "createUser",
131///     vec!["v_user".to_string()]
132/// );
133/// adapter.invalidate_views(&invalidation.modified_views)?;
134/// # Ok(())
135/// # }
136/// ```
137pub struct CachedDatabaseAdapter<A: DatabaseAdapter> {
138    /// Underlying database adapter.
139    pub(super) adapter: A,
140
141    /// Query result cache.
142    pub(super) cache: Arc<QueryResultCache>,
143
144    /// Schema version for cache key generation.
145    ///
146    /// When schema version changes (e.g., after deployment), all cache entries
147    /// with old version become invalid automatically.
148    pub(super) schema_version: String,
149
150    /// Per-view TTL overrides in seconds.
151    ///
152    /// Populated from `QueryDefinition::cache_ttl_seconds` at server startup:
153    /// view name → TTL seconds.  `None` for a view falls back to the global
154    /// `CacheConfig::ttl_seconds`.
155    pub(super) view_ttl_overrides: HashMap<String, u64>,
156
157    /// Set of views that explicitly opt into caching via `cache_ttl_seconds`.
158    ///
159    /// Derived from `view_ttl_overrides` keys.  When `opt_in_mode` is active,
160    /// views **not** in this set bypass cache key generation entirely, eliminating
161    /// allocation overhead for uncached queries.
162    pub(super) cacheable_views: HashSet<String>,
163
164    /// Whether opt-in caching mode is active.
165    ///
166    /// Set to `true` by [`CachedDatabaseAdapter::with_view_ttl_overrides`] and
167    /// [`CachedDatabaseAdapter::with_ttl_overrides_from_schema`] to indicate that
168    /// the caller has intentionally configured per-view TTL overrides.  In this
169    /// mode, **only** views in `cacheable_views` are cached; all others bypass
170    /// key-generation entirely.
171    ///
172    /// When `false` (default, adapter created with [`Self::new`] or
173    /// [`Self::with_fact_table_config`] without a schema call), all views remain
174    /// cacheable — preserving backward-compatible behaviour for tests and direct
175    /// usage that do not use per-query TTL annotations.
176    pub(super) opt_in_mode: bool,
177
178    /// Configuration for fact table aggregation caching.
179    pub(super) fact_table_config: FactTableCacheConfig,
180
181    /// Version provider for fact tables (caches version lookups).
182    pub(super) version_provider: Arc<FactTableVersionProvider>,
183
184    /// Optional cascade invalidator for transitive view dependency expansion.
185    ///
186    /// When set, `invalidate_views()` uses BFS to expand the initial view list
187    /// to include all transitively dependent views before clearing cache entries.
188    pub(super) cascade_invalidator: Option<Arc<Mutex<CascadeInvalidator>>>,
189}
190
191impl<A: DatabaseAdapter> CachedDatabaseAdapter<A> {
192    /// Create new cached database adapter.
193    ///
194    /// # Arguments
195    ///
196    /// * `adapter` - Underlying database adapter to wrap
197    /// * `cache` - Query result cache instance
198    /// * `schema_version` - Uniquely identifies the compiled schema. Use `schema.content_hash()`
199    ///   (NOT `env!("CARGO_PKG_VERSION")`) so that any schema content change automatically
200    ///   invalidates cached entries across deploys.
201    ///
202    /// # Example
203    ///
204    /// ```rust,no_run
205    /// use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig};
206    /// use fraiseql_core::db::postgres::PostgresAdapter;
207    /// use fraiseql_core::schema::CompiledSchema;
208    ///
209    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
210    /// # let schema = CompiledSchema::default();
211    /// let db = PostgresAdapter::new("postgresql://localhost/db").await?;
212    /// let cache = QueryResultCache::new(CacheConfig::default());
213    /// let adapter = CachedDatabaseAdapter::new(
214    ///     db,
215    ///     cache,
216    ///     schema.content_hash()  // Use content hash for automatic invalidation
217    /// );
218    /// # Ok(())
219    /// # }
220    /// ```
221    #[must_use]
222    pub fn new(adapter: A, cache: QueryResultCache, schema_version: String) -> Self {
223        Self {
224            adapter,
225            cache: Arc::new(cache),
226            schema_version,
227            view_ttl_overrides: HashMap::new(),
228            cacheable_views: HashSet::new(),
229            opt_in_mode: false,
230            fact_table_config: FactTableCacheConfig::default(),
231            version_provider: Arc::new(FactTableVersionProvider::default()),
232            cascade_invalidator: None,
233        }
234    }
235
236    /// Set per-view TTL overrides.
237    ///
238    /// Maps `sql_source` (view name) → TTL in seconds.  Built at server startup
239    /// from compiled `QueryDefinition::cache_ttl_seconds` entries.
240    ///
241    /// # Example
242    ///
243    /// ```rust,no_run
244    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig};
245    /// # use fraiseql_core::db::postgres::PostgresAdapter;
246    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
247    /// # let db = PostgresAdapter::new("postgresql://localhost/db").await?;
248    /// # let cache = QueryResultCache::new(CacheConfig::default());
249    /// let overrides = std::collections::HashMap::from([
250    ///     ("v_country".to_string(), 3600_u64),      // 1 h for reference data
251    ///     ("v_live_price".to_string(), 0_u64),      // no TTL — mutation-invalidated only
252    /// ]);
253    /// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string())
254    ///     .with_view_ttl_overrides(overrides);
255    /// # Ok(())
256    /// # }
257    /// ```
258    #[must_use]
259    pub fn with_view_ttl_overrides(mut self, overrides: HashMap<String, u64>) -> Self {
260        self.cacheable_views = overrides.keys().cloned().collect();
261        self.view_ttl_overrides = overrides;
262        self.opt_in_mode = true;
263        self
264    }
265
266    /// Set a cascade invalidator for transitive view dependency expansion.
267    ///
268    /// When set, `invalidate_views()` uses BFS to expand the initial view list
269    /// to include all views that transitively depend on the invalidated views.
270    ///
271    /// # Example
272    ///
273    /// ```rust,no_run
274    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig, CascadeInvalidator};
275    /// # use fraiseql_core::db::postgres::PostgresAdapter;
276    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
277    /// # let db = PostgresAdapter::new("postgresql://localhost/db").await?;
278    /// # let cache = QueryResultCache::new(CacheConfig::default());
279    /// let mut cascade = CascadeInvalidator::new();
280    /// cascade.add_dependency("v_user_stats", "v_user")?;
281    /// cascade.add_dependency("v_dashboard", "v_user_stats")?;
282    /// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string())
283    ///     .with_cascade_invalidator(cascade);
284    /// # Ok(())
285    /// # }
286    /// ```
287    #[must_use]
288    pub fn with_cascade_invalidator(mut self, invalidator: CascadeInvalidator) -> Self {
289        self.cascade_invalidator = Some(Arc::new(Mutex::new(invalidator)));
290        self
291    }
292
293    /// Populate per-view TTL overrides from a compiled schema.
294    ///
295    /// For each query that has `cache_ttl_seconds` set and a non-null `sql_source`,
296    /// this maps the view name → TTL so the cache adapter uses the per-query TTL
297    /// instead of the global default.
298    ///
299    /// # Example
300    ///
301    /// ```rust,no_run
302    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig};
303    /// # use fraiseql_core::db::postgres::PostgresAdapter;
304    /// # use fraiseql_core::schema::CompiledSchema;
305    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
306    /// # let db = PostgresAdapter::new("postgresql://localhost/db").await?;
307    /// # let cache = QueryResultCache::new(CacheConfig::default());
308    /// # let schema = CompiledSchema::default();
309    /// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string())
310    ///     .with_ttl_overrides_from_schema(&schema);
311    /// # Ok(())
312    /// # }
313    /// ```
314    #[must_use]
315    pub fn with_ttl_overrides_from_schema(mut self, schema: &CompiledSchema) -> Self {
316        for query in &schema.queries {
317            if let (Some(view), Some(ttl)) = (&query.sql_source, query.cache_ttl_seconds) {
318                self.cacheable_views.insert(view.clone());
319                self.view_ttl_overrides.insert(view.clone(), ttl);
320            }
321        }
322        // Always activate opt-in mode when this method is called, regardless of
323        // whether any annotations were found.  If the schema has no cache_ttl_seconds
324        // annotations, cacheable_views stays empty and every query bypasses the cache
325        // entirely — zero overhead.  If annotations are present, only the annotated
326        // views are cached; all others bypass.
327        self.opt_in_mode = true;
328        self
329    }
330
331    /// Create new cached database adapter with fact table caching configuration.
332    ///
333    /// # Arguments
334    ///
335    /// * `adapter` - Underlying database adapter to wrap
336    /// * `cache` - Query result cache instance
337    /// * `schema_version` - Current schema version (e.g., git hash, semver)
338    /// * `fact_table_config` - Configuration for fact table aggregation caching
339    ///
340    /// # Example
341    ///
342    /// ```rust,no_run
343    /// use fraiseql_core::cache::{
344    ///     CachedDatabaseAdapter, QueryResultCache, CacheConfig,
345    ///     FactTableCacheConfig, FactTableVersionStrategy,
346    /// };
347    /// use fraiseql_core::db::postgres::PostgresAdapter;
348    ///
349    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
350    /// let db = PostgresAdapter::new("postgresql://localhost/db").await?;
351    /// let cache = QueryResultCache::new(CacheConfig::default());
352    ///
353    /// // Configure fact table caching strategies
354    /// let mut ft_config = FactTableCacheConfig::default();
355    /// ft_config.set_strategy("tf_sales", FactTableVersionStrategy::VersionTable);
356    /// ft_config.set_strategy("tf_events", FactTableVersionStrategy::time_based(300));
357    ///
358    /// let adapter = CachedDatabaseAdapter::with_fact_table_config(
359    ///     db,
360    ///     cache,
361    ///     "1.0.0".to_string(),
362    ///     ft_config,
363    /// );
364    /// # Ok(())
365    /// # }
366    /// ```
367    #[must_use]
368    pub fn with_fact_table_config(
369        adapter: A,
370        cache: QueryResultCache,
371        schema_version: String,
372        fact_table_config: FactTableCacheConfig,
373    ) -> Self {
374        Self {
375            adapter,
376            cache: Arc::new(cache),
377            schema_version,
378            view_ttl_overrides: HashMap::new(),
379            cacheable_views: HashSet::new(),
380            opt_in_mode: false,
381            fact_table_config,
382            version_provider: Arc::new(FactTableVersionProvider::default()),
383            cascade_invalidator: None,
384        }
385    }
386
387    /// Get reference to underlying adapter.
388    ///
389    /// Useful for accessing adapter-specific methods not in the `DatabaseAdapter` trait.
390    ///
391    /// # Example
392    ///
393    /// ```rust,no_run
394    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
395    /// # use fraiseql_core::db::postgres::PostgresAdapter;
396    /// # fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) {
397    /// // Access PostgreSQL-specific functionality
398    /// let pg_adapter = adapter.inner();
399    /// # }
400    /// ```
401    #[must_use]
402    pub const fn inner(&self) -> &A {
403        &self.adapter
404    }
405
406    /// Get reference to cache.
407    ///
408    /// Useful for metrics and monitoring.
409    ///
410    /// # Example
411    ///
412    /// ```rust,no_run
413    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
414    /// # use fraiseql_core::db::postgres::PostgresAdapter;
415    /// # async fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) -> Result<(), Box<dyn std::error::Error>> {
416    /// let metrics = adapter.cache().metrics()?;
417    /// println!("Cache hit rate: {:.1}%", metrics.hit_rate() * 100.0);
418    /// # Ok(())
419    /// # }
420    /// ```
421    #[must_use]
422    pub fn cache(&self) -> &QueryResultCache {
423        &self.cache
424    }
425
426    /// Get schema version.
427    ///
428    /// # Example
429    ///
430    /// ```rust,no_run
431    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
432    /// # use fraiseql_core::db::postgres::PostgresAdapter;
433    /// # fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) {
434    /// println!("Schema version: {}", adapter.schema_version());
435    /// # }
436    /// ```
437    #[must_use]
438    pub fn schema_version(&self) -> &str {
439        &self.schema_version
440    }
441
442    /// Get fact table cache configuration.
443    #[must_use]
444    pub const fn fact_table_config(&self) -> &FactTableCacheConfig {
445        &self.fact_table_config
446    }
447
448    /// Get the version provider for fact tables.
449    #[must_use]
450    pub fn version_provider(&self) -> &FactTableVersionProvider {
451        &self.version_provider
452    }
453
454    /// Verify that Row-Level Security is active on the database connection.
455    ///
456    /// Call this during server initialization when both caching and multi-tenancy
457    /// (`schema.is_multi_tenant()`) are enabled. Without RLS, users sharing the same
458    /// query parameters will receive the same cached response regardless of tenant.
459    ///
460    /// # What this checks
461    ///
462    /// Runs `SELECT current_setting('row_security', true) AS rls_setting`. The result
463    /// must be `'on'` or `'force'` for the check to pass. Non-PostgreSQL databases
464    /// (which return an error or unsupported) are treated as "RLS not active".
465    ///
466    /// # Errors
467    ///
468    /// Returns [`FraiseQLError::Configuration`] if RLS appears inactive.
469    pub async fn validate_rls_active(&self) -> Result<()> {
470        let result = self
471            .adapter
472            .execute_raw_query("SELECT current_setting('row_security', true) AS rls_setting")
473            .await;
474
475        let rls_active = match result {
476            Ok(rows) => rows
477                .first()
478                .and_then(|row| row.get("rls_setting"))
479                .and_then(serde_json::Value::as_str)
480                .is_some_and(|s| s == "on" || s == "force"),
481            Err(_) => false, // Non-PostgreSQL or query failure: RLS not active
482        };
483
484        if rls_active {
485            Ok(())
486        } else {
487            Err(FraiseQLError::Configuration {
488                message: "Caching is enabled in a multi-tenant schema but Row-Level Security \
489                          does not appear to be active on the database. This would allow \
490                          cross-tenant data leakage through the cache. \
491                          Either disable caching, enable RLS, or set \
492                          `rls_enforcement = \"off\"` in CacheConfig for single-tenant \
493                          deployments."
494                    .to_string(),
495            })
496        }
497    }
498
499    /// Apply the RLS enforcement policy from `CacheConfig`.
500    ///
501    /// Runs [`validate_rls_active`](Self::validate_rls_active) and handles the result
502    /// according to `enforcement`:
503    /// - [`RlsEnforcement::Error`]: propagates the error (default)
504    /// - [`RlsEnforcement::Warn`]: logs a warning and returns `Ok(())`
505    /// - [`RlsEnforcement::Off`]: skips the check entirely
506    ///
507    /// # Errors
508    ///
509    /// Returns the error from `validate_rls_active` when enforcement is `Error`.
510    pub async fn enforce_rls(&self, enforcement: RlsEnforcement) -> Result<()> {
511        if enforcement == RlsEnforcement::Off {
512            return Ok(());
513        }
514
515        match self.validate_rls_active().await {
516            Ok(()) => Ok(()),
517            Err(e) => match enforcement {
518                RlsEnforcement::Error => Err(e),
519                RlsEnforcement::Warn => {
520                    tracing::warn!(
521                        "RLS check failed (rls_enforcement = \"warn\"): {}. \
522                         Cross-tenant cache leakage is possible.",
523                        e
524                    );
525                    Ok(())
526                },
527                RlsEnforcement::Off => Ok(()), // unreachable but exhaustive
528            },
529        }
530    }
531}
532
533impl<A: DatabaseAdapter + Clone> Clone for CachedDatabaseAdapter<A> {
534    fn clone(&self) -> Self {
535        Self {
536            adapter:             self.adapter.clone(),
537            cache:               Arc::clone(&self.cache),
538            schema_version:      self.schema_version.clone(),
539            view_ttl_overrides:  self.view_ttl_overrides.clone(),
540            cacheable_views:     self.cacheable_views.clone(),
541            opt_in_mode:         self.opt_in_mode,
542            fact_table_config:   self.fact_table_config.clone(),
543            version_provider:    Arc::clone(&self.version_provider),
544            cascade_invalidator: self.cascade_invalidator.clone(),
545        }
546    }
547}
548
549// Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
550// its transformed method signatures to satisfy the trait contract
551// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
552#[async_trait]
553impl<A: DatabaseAdapter> DatabaseAdapter for CachedDatabaseAdapter<A> {
554    async fn execute_with_projection(
555        &self,
556        view: &str,
557        projection: Option<&crate::schema::SqlProjectionHint>,
558        where_clause: Option<&WhereClause>,
559        limit: Option<u32>,
560        offset: Option<u32>,
561        _order_by: Option<&[OrderByClause]>,
562    ) -> Result<Vec<JsonbValue>> {
563        self.execute_with_projection_impl(view, projection, where_clause, limit, offset)
564            .await
565            .map(Arc::unwrap_or_clone)
566    }
567
568    async fn execute_where_query(
569        &self,
570        view: &str,
571        where_clause: Option<&WhereClause>,
572        limit: Option<u32>,
573        offset: Option<u32>,
574        _order_by: Option<&[OrderByClause]>,
575    ) -> Result<Vec<JsonbValue>> {
576        self.execute_where_query_impl(view, where_clause, limit, offset)
577            .await
578            .map(Arc::unwrap_or_clone)
579    }
580
581    async fn execute_with_projection_arc(
582        &self,
583        view: &str,
584        projection: Option<&crate::schema::SqlProjectionHint>,
585        where_clause: Option<&WhereClause>,
586        limit: Option<u32>,
587        offset: Option<u32>,
588        _order_by: Option<&[OrderByClause]>,
589    ) -> Result<Arc<Vec<JsonbValue>>> {
590        self.execute_with_projection_impl(view, projection, where_clause, limit, offset)
591            .await
592    }
593
594    async fn execute_where_query_arc(
595        &self,
596        view: &str,
597        where_clause: Option<&WhereClause>,
598        limit: Option<u32>,
599        offset: Option<u32>,
600        _order_by: Option<&[OrderByClause]>,
601    ) -> Result<Arc<Vec<JsonbValue>>> {
602        self.execute_where_query_impl(view, where_clause, limit, offset).await
603    }
604
605    fn database_type(&self) -> DatabaseType {
606        self.adapter.database_type()
607    }
608
609    async fn health_check(&self) -> Result<()> {
610        self.adapter.health_check().await
611    }
612
613    fn pool_metrics(&self) -> PoolMetrics {
614        self.adapter.pool_metrics()
615    }
616
617    async fn execute_raw_query(
618        &self,
619        sql: &str,
620    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
621        // Use the aggregation caching method which handles fact table versioning
622        self.execute_aggregation_query(sql).await
623    }
624
625    async fn execute_parameterized_aggregate(
626        &self,
627        sql: &str,
628        params: &[serde_json::Value],
629    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
630        // Parameterized aggregate results are not cacheable by SQL template alone;
631        // delegate directly to the underlying adapter to avoid caching with an
632        // incorrect key (the same SQL template with different params would return
633        // different results).
634        self.adapter.execute_parameterized_aggregate(sql, params).await
635    }
636
637    async fn execute_function_call(
638        &self,
639        function_name: &str,
640        args: &[serde_json::Value],
641    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
642        // Mutations are never cached — always delegate to the underlying adapter
643        self.adapter.execute_function_call(function_name, args).await
644    }
645
646    async fn invalidate_views(&self, views: &[String]) -> Result<u64> {
647        // Delegate to the inherent (synchronous) method which handles cascade
648        // expansion and cache eviction.
649        CachedDatabaseAdapter::invalidate_views(self, views)
650    }
651
652    async fn invalidate_by_entity(&self, entity_type: &str, entity_id: &str) -> Result<u64> {
653        CachedDatabaseAdapter::invalidate_by_entity(self, entity_type, entity_id)
654    }
655
656    async fn invalidate_list_queries(&self, views: &[String]) -> Result<u64> {
657        CachedDatabaseAdapter::invalidate_list_queries(self, views)
658    }
659
660    async fn bump_fact_table_versions(&self, tables: &[String]) -> Result<()> {
661        self.bump_fact_table_versions_impl(tables).await
662    }
663}
664
665impl<A: SupportsMutations + Send + Sync> SupportsMutations for CachedDatabaseAdapter<A> {}