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