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> {}