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