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    backend::{
79        DatabaseAdapter, DatabaseType, MutationRowGate, PoolMetrics, WhereClause, WriteRequest,
80        Writer, quote_postgres_identifier,
81        types::{JsonbValue, OrderByClause, ReadRouting},
82    },
83    cache::config::RlsEnforcement,
84    error::{FraiseQLError, Result},
85    schema::{CompiledSchema, SourceKind, SourceProbe, sql_source_probes},
86};
87
88mod mutation;
89mod query;
90#[cfg(test)]
91mod tests;
92
93pub use query::view_name_to_entity_type;
94
95/// One source relation's Row-Level Security posture, as read from `pg_class`.
96#[derive(Debug, Clone, Copy)]
97struct RelationRls {
98    /// `relkind = 'v'` — a view, which carries no policies of its own.
99    is_view:          bool,
100    /// `relrowsecurity` — RLS switched on for the relation.
101    rls_enabled:      bool,
102    /// At least one `pg_policy` row targets it.
103    has_policy:       bool,
104    /// `security_invoker = true` in `reloptions` (views only).
105    security_invoker: bool,
106}
107
108impl RelationRls {
109    /// A view protects rows only by deferring to the caller's policies; a table
110    /// protects rows only when RLS is on *and* a policy exists — RLS enabled with
111    /// no policy denies everything to non-owners and is silently bypassed by the
112    /// owner, so it is not an isolation mechanism either way.
113    const fn is_protected(self) -> bool {
114        if self.is_view {
115            self.security_invoker
116        } else {
117            self.rls_enabled && self.has_policy
118        }
119    }
120
121    /// Why it is not protected, in the operator's terms.
122    const fn explain(self) -> &'static str {
123        if self.is_view {
124            "view is not `security_invoker`, so it runs with its owner's privileges and \
125             bypasses the caller's RLS policies"
126        } else if !self.rls_enabled {
127            "row level security is not enabled on this table"
128        } else {
129            "row level security is enabled but no policy is defined, so it isolates nothing"
130        }
131    }
132}
133
134/// Cached database adapter wrapper.
135///
136/// Wraps any `DatabaseAdapter` implementation with transparent query result caching.
137/// Cache keys include query, variables, WHERE clause, and schema version for security
138/// and correctness.
139///
140/// # Cache Behavior
141///
142/// - **Cache Hit**: Returns cached result in ~0.1ms (50-200x faster than database)
143/// - **Cache Miss**: Executes query via underlying adapter, stores result in cache
144/// - **Invalidation**: Call `invalidate_views()` after mutations to clear affected caches
145///
146/// # Thread Safety
147///
148/// This adapter is `Send + Sync` and can be safely shared across async tasks.
149/// The underlying cache uses `Arc<Mutex<>>` for thread-safe access.
150///
151/// # Example
152///
153/// ```no_run
154/// use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig, InvalidationContext};
155/// use fraiseql_core::db::{postgres::PostgresAdapter, DatabaseAdapter};
156/// use fraiseql_db::ViewName;
157///
158/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
159/// let db = PostgresAdapter::new("postgresql://localhost/db").await?;
160/// let cache = QueryResultCache::new(CacheConfig::default());
161/// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string());
162///
163/// // First query - cache miss (slower)
164/// let users1 = adapter.execute_where_query("v_user", None, None, None, None).await?;
165///
166/// // Second query - cache hit (fast!)
167/// let users2 = adapter.execute_where_query("v_user", None, None, None, None).await?;
168///
169/// // After mutation, invalidate. `InvalidationContext` keeps its `Vec<String>`
170/// // shape for compatibility with the audit-logging facade; callers convert at
171/// // the adapter boundary.
172/// let invalidation = InvalidationContext::for_mutation(
173///     "createUser",
174///     vec!["v_user".to_string()],
175/// );
176/// let views: Vec<ViewName> = invalidation
177///     .modified_views
178///     .iter()
179///     .map(ViewName::from)
180///     .collect();
181/// adapter.invalidate_views(&views)?;
182/// # Ok(())
183/// # }
184/// ```
185pub struct CachedDatabaseAdapter<A: DatabaseAdapter> {
186    /// Underlying database adapter.
187    pub(super) adapter: A,
188
189    /// Query result cache.
190    pub(super) cache: Arc<QueryResultCache>,
191
192    /// Schema version for cache key generation.
193    ///
194    /// When schema version changes (e.g., after deployment), all cache entries
195    /// with old version become invalid automatically.
196    pub(super) schema_version: String,
197
198    /// Per-view TTL overrides in seconds.
199    ///
200    /// Populated from `QueryDefinition::cache_ttl_seconds` at server startup:
201    /// view name → TTL seconds.  `None` for a view falls back to the global
202    /// `CacheConfig::ttl_seconds`.
203    pub(super) view_ttl_overrides: HashMap<String, u64>,
204
205    /// Set of views that explicitly opt into caching via `cache_ttl_seconds`.
206    ///
207    /// Derived from `view_ttl_overrides` keys.  When `opt_in_mode` is active,
208    /// views **not** in this set bypass cache key generation entirely, eliminating
209    /// allocation overhead for uncached queries.
210    pub(super) cacheable_views: HashSet<String>,
211
212    /// Primary view → the secondary views queries over it also read.
213    ///
214    /// Populated from `QueryDefinition::additional_views` by
215    /// [`CachedDatabaseAdapter::with_cache_metadata_from_schema`]. A cached entry
216    /// for the primary view is registered under all of them, so a mutation on a
217    /// secondary view evicts it (#761).
218    pub(super) view_secondary_views: HashMap<String, Vec<String>>,
219
220    /// Whether opt-in caching mode is active.
221    ///
222    /// Set to `true` by [`CachedDatabaseAdapter::with_view_ttl_overrides`] and
223    /// [`CachedDatabaseAdapter::with_cache_metadata_from_schema`] to indicate that
224    /// the caller has intentionally configured per-view TTL overrides.  In this
225    /// mode, **only** views in `cacheable_views` are cached; all others bypass
226    /// key-generation entirely.
227    ///
228    /// When `false` (default, adapter created with [`Self::new`] or
229    /// [`Self::with_fact_table_config`] without a schema call), all views remain
230    /// cacheable — preserving backward-compatible behaviour for tests and direct
231    /// usage that do not use per-query TTL annotations.
232    pub(super) opt_in_mode: bool,
233
234    /// Whether the schema has RLS configured (affects caching for unauthenticated requests).
235    pub(super) has_rls: bool,
236
237    /// Configuration for fact table aggregation caching.
238    pub(super) fact_table_config: FactTableCacheConfig,
239
240    /// Version provider for fact tables (caches version lookups).
241    pub(super) version_provider: Arc<FactTableVersionProvider>,
242
243    /// Optional cascade invalidator for transitive view dependency expansion.
244    ///
245    /// When set, `invalidate_views()` uses BFS to expand the initial view list
246    /// to include all transitively dependent views before clearing cache entries.
247    pub(super) cascade_invalidator: Option<Arc<Mutex<CascadeInvalidator>>>,
248}
249
250impl<A: DatabaseAdapter> CachedDatabaseAdapter<A> {
251    /// Create new cached database adapter.
252    ///
253    /// # Arguments
254    ///
255    /// * `adapter` - Underlying database adapter to wrap
256    /// * `cache` - Query result cache instance
257    /// * `schema_version` - Uniquely identifies the compiled schema. Use `schema.content_hash()`
258    ///   (NOT `env!("CARGO_PKG_VERSION")`) so that any schema content change automatically
259    ///   invalidates cached entries across deploys.
260    ///
261    /// # Example
262    ///
263    /// ```rust,no_run
264    /// use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig};
265    /// use fraiseql_core::db::postgres::PostgresAdapter;
266    /// use fraiseql_core::schema::CompiledSchema;
267    ///
268    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
269    /// # let schema = CompiledSchema::default();
270    /// let db = PostgresAdapter::new("postgresql://localhost/db").await?;
271    /// let cache = QueryResultCache::new(CacheConfig::default());
272    /// let adapter = CachedDatabaseAdapter::new(
273    ///     db,
274    ///     cache,
275    ///     schema.content_hash()  // Use content hash for automatic invalidation
276    /// );
277    /// # Ok(())
278    /// # }
279    /// ```
280    #[must_use]
281    pub fn new(adapter: A, cache: QueryResultCache, schema_version: String) -> Self {
282        Self {
283            adapter,
284            cache: Arc::new(cache),
285            schema_version,
286            view_ttl_overrides: HashMap::new(),
287            cacheable_views: HashSet::new(),
288            view_secondary_views: HashMap::new(),
289            opt_in_mode: false,
290            has_rls: false,
291            fact_table_config: FactTableCacheConfig::default(),
292            version_provider: Arc::new(FactTableVersionProvider::default()),
293            cascade_invalidator: None,
294        }
295    }
296
297    /// Set per-view TTL overrides.
298    ///
299    /// Maps `sql_source` (view name) → TTL in seconds.  Built at server startup
300    /// from compiled `QueryDefinition::cache_ttl_seconds` entries.
301    ///
302    /// # Example
303    ///
304    /// ```rust,no_run
305    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig};
306    /// # use fraiseql_core::db::postgres::PostgresAdapter;
307    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
308    /// # let db = PostgresAdapter::new("postgresql://localhost/db").await?;
309    /// # let cache = QueryResultCache::new(CacheConfig::default());
310    /// let overrides = std::collections::HashMap::from([
311    ///     ("v_country".to_string(), 3600_u64),      // 1 h for reference data
312    ///     ("v_live_price".to_string(), 0_u64),      // no TTL — mutation-invalidated only
313    /// ]);
314    /// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string())
315    ///     .with_view_ttl_overrides(overrides);
316    /// # Ok(())
317    /// # }
318    /// ```
319    /// ⚠ A `0` TTL means the entry lives until a mutation invalidates it, so its
320    /// correctness rests entirely on invalidation being unmissable. That is what the
321    /// generation fence guarantees (#1079): a read whose database round trip straddled an
322    /// invalidation refuses to cache, instead of storing pre-mutation rows that — at TTL 0
323    /// — nothing would ever expire.
324    #[must_use]
325    pub fn with_view_ttl_overrides(mut self, overrides: HashMap<String, u64>) -> Self {
326        self.cacheable_views = overrides.keys().cloned().collect();
327        self.view_ttl_overrides = overrides;
328        self.opt_in_mode = true;
329        self
330    }
331
332    /// Enable or disable the RLS unauthenticated-request cache bypass.
333    ///
334    /// When `true`, any request without a `SecurityContext` will bypass both
335    /// cache read and write, preventing unauthenticated requests from being
336    /// served stale data that belongs to an authenticated tenant.
337    ///
338    /// Set this to `schema.has_rls_configured()` at server startup.
339    #[must_use]
340    pub const fn with_rls(mut self, has_rls: bool) -> Self {
341        self.has_rls = has_rls;
342        self
343    }
344
345    /// Set a cascade invalidator for transitive view dependency expansion.
346    ///
347    /// When set, `invalidate_views()` uses BFS to expand the initial view list
348    /// to include all views that transitively depend on the invalidated views.
349    ///
350    /// # Example
351    ///
352    /// ```rust,no_run
353    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig, CascadeInvalidator};
354    /// # use fraiseql_core::db::postgres::PostgresAdapter;
355    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
356    /// # let db = PostgresAdapter::new("postgresql://localhost/db").await?;
357    /// # let cache = QueryResultCache::new(CacheConfig::default());
358    /// let mut cascade = CascadeInvalidator::new();
359    /// cascade.add_dependency("v_user_stats", "v_user")?;
360    /// cascade.add_dependency("v_dashboard", "v_user_stats")?;
361    /// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string())
362    ///     .with_cascade_invalidator(cascade);
363    /// # Ok(())
364    /// # }
365    /// ```
366    #[must_use]
367    pub fn with_cascade_invalidator(mut self, invalidator: CascadeInvalidator) -> Self {
368        self.cascade_invalidator = Some(Arc::new(Mutex::new(invalidator)));
369        self
370    }
371
372    /// Read every per-query cache declaration out of a compiled schema.
373    ///
374    /// This is the single seam between the compiled schema and the row cache.
375    /// Both things it reads are per-query annotations that the runtime otherwise
376    /// has no way to see, because the `DatabaseAdapter` boundary carries only a
377    /// view name:
378    ///
379    /// - `cache_ttl_seconds` → the view's TTL override, and its membership of `cacheable_views`
380    ///   (opt-in mode).
381    /// - `additional_views` → the secondary views a query also reads, so a mutation on one of them
382    ///   evicts the joined query's cached rows (#761).
383    ///
384    /// Add any further per-query cache annotation here, not at a call site:
385    /// `rebuilt_for_schema` (hot reload) delegates to the same code, so an
386    /// annotation wired in one place and not the other silently stops working
387    /// after the first schema reload.
388    ///
389    /// # Example
390    ///
391    /// ```rust,no_run
392    /// # use fraiseql_core::cache::{CachedDatabaseAdapter, QueryResultCache, CacheConfig};
393    /// # use fraiseql_core::db::postgres::PostgresAdapter;
394    /// # use fraiseql_core::schema::CompiledSchema;
395    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
396    /// # let db = PostgresAdapter::new("postgresql://localhost/db").await?;
397    /// # let cache = QueryResultCache::new(CacheConfig::default());
398    /// # let schema = CompiledSchema::default();
399    /// let adapter = CachedDatabaseAdapter::new(db, cache, "1.0.0".to_string())
400    ///     .with_cache_metadata_from_schema(&schema);
401    /// # Ok(())
402    /// # }
403    /// ```
404    #[must_use]
405    pub fn with_cache_metadata_from_schema(mut self, schema: &CompiledSchema) -> Self {
406        self.read_schema_cache_metadata(schema);
407        // Always activate opt-in mode when this method is called, regardless of
408        // whether any annotations were found.  If the schema has no cache_ttl_seconds
409        // annotations, cacheable_views stays empty and every query bypasses the cache
410        // entirely — zero overhead.  If annotations are present, only the annotated
411        // views are cached; all others bypass.
412        self.opt_in_mode = true;
413        self
414    }
415
416    /// Populate `view_ttl_overrides`, `cacheable_views` and `view_secondary_views`
417    /// from the schema's per-query annotations.
418    ///
419    /// `view_secondary_views` is keyed by *primary* view because that is all the
420    /// adapter boundary knows at put time. Two queries over the same primary view
421    /// that declare different secondaries therefore share the union — which
422    /// over-invalidates one of them by at most the other's declarations. Being
423    /// evicted early costs a re-read; not being evicted serves the wrong answer.
424    fn read_schema_cache_metadata(&mut self, schema: &CompiledSchema) {
425        for query in &schema.queries {
426            let Some(view) = &query.sql_source else {
427                continue;
428            };
429            if let Some(ttl) = query.cache_ttl_seconds {
430                self.cacheable_views.insert(view.clone());
431                self.view_ttl_overrides.insert(view.clone(), ttl);
432            }
433            // `extract_accessed_views` is the one definition of "which views does
434            // this query read"; the response cache uses it too.
435            let secondary: Vec<String> = crate::cache::key::extract_accessed_views(query)
436                .into_iter()
437                .filter(|v| v != view)
438                .collect();
439            if !secondary.is_empty() {
440                let entry = self.view_secondary_views.entry(view.clone()).or_default();
441                for v in secondary {
442                    if !entry.contains(&v) {
443                        entry.push(v);
444                    }
445                }
446            }
447        }
448    }
449
450    /// Every view a cached entry for `view` must be registered under: the view
451    /// itself plus any secondary views declared by queries reading it (#761).
452    pub(super) fn accessed_views_for(&self, view: &str) -> Vec<String> {
453        let mut views = Vec::with_capacity(1);
454        views.push(view.to_string());
455        if let Some(secondary) = self.view_secondary_views.get(view) {
456            views.extend(secondary.iter().cloned());
457        }
458        views
459    }
460
461    /// Rebuild this adapter for a new compiled schema.
462    ///
463    /// Returns a new `CachedDatabaseAdapter` that:
464    /// - Shares the same underlying `QueryResultCache` (via `Arc`)
465    /// - Uses the new schema's content hash as schema version
466    /// - Has per-view TTL overrides from the new schema
467    /// - Clears the shared cache (stale entries from old schema)
468    ///
469    /// The underlying database adapter (connection pool) is **reused** — no
470    /// connections are closed or reopened.
471    ///
472    /// This is the hot-reload counterpart to the startup path
473    /// (`new()` + `with_cache_metadata_from_schema()`).
474    #[must_use]
475    pub fn rebuilt_for_schema(self, schema: &CompiledSchema) -> Self {
476        // Clear existing cache entries (stale under new schema).
477        let _ = self.cache.clear();
478
479        // Construct a new adapter with updated schema version and TTL overrides,
480        // reusing the same inner adapter and shared cache.
481        let mut rebuilt = Self {
482            adapter:              self.adapter,
483            cache:                self.cache,
484            schema_version:       schema.content_hash(),
485            view_ttl_overrides:   HashMap::new(),
486            cacheable_views:      HashSet::new(),
487            view_secondary_views: HashMap::new(),
488            opt_in_mode:          true,
489            has_rls:              self.has_rls,
490            fact_table_config:    self.fact_table_config,
491            version_provider:     self.version_provider,
492            cascade_invalidator:  self.cascade_invalidator,
493        };
494        // Same reader as the startup path, so a new per-query cache annotation
495        // cannot work at boot and stop working after a hot reload.
496        rebuilt.read_schema_cache_metadata(schema);
497        rebuilt
498    }
499
500    /// Create new cached database adapter with fact table caching configuration.
501    ///
502    /// # Arguments
503    ///
504    /// * `adapter` - Underlying database adapter to wrap
505    /// * `cache` - Query result cache instance
506    /// * `schema_version` - Current schema version (e.g., git hash, semver)
507    /// * `fact_table_config` - Configuration for fact table aggregation caching
508    ///
509    /// # Example
510    ///
511    /// ```rust,no_run
512    /// use fraiseql_core::cache::{
513    ///     CachedDatabaseAdapter, QueryResultCache, CacheConfig,
514    ///     FactTableCacheConfig, FactTableVersionStrategy,
515    /// };
516    /// use fraiseql_core::db::postgres::PostgresAdapter;
517    ///
518    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
519    /// let db = PostgresAdapter::new("postgresql://localhost/db").await?;
520    /// let cache = QueryResultCache::new(CacheConfig::default());
521    ///
522    /// // Configure fact table caching strategies
523    /// let mut ft_config = FactTableCacheConfig::default();
524    /// ft_config.set_strategy("tf_sales", FactTableVersionStrategy::VersionTable);
525    /// ft_config.set_strategy("tf_events", FactTableVersionStrategy::time_based(300));
526    ///
527    /// let adapter = CachedDatabaseAdapter::with_fact_table_config(
528    ///     db,
529    ///     cache,
530    ///     "1.0.0".to_string(),
531    ///     ft_config,
532    /// );
533    /// # Ok(())
534    /// # }
535    /// ```
536    #[must_use]
537    pub fn with_fact_table_config(
538        adapter: A,
539        cache: QueryResultCache,
540        schema_version: String,
541        fact_table_config: FactTableCacheConfig,
542    ) -> Self {
543        Self {
544            adapter,
545            cache: Arc::new(cache),
546            schema_version,
547            view_ttl_overrides: HashMap::new(),
548            cacheable_views: HashSet::new(),
549            view_secondary_views: HashMap::new(),
550            opt_in_mode: false,
551            has_rls: false,
552            fact_table_config,
553            version_provider: Arc::new(FactTableVersionProvider::default()),
554            cascade_invalidator: None,
555        }
556    }
557
558    /// Get reference to underlying adapter.
559    ///
560    /// Useful for accessing adapter-specific methods not in the `DatabaseAdapter` trait.
561    ///
562    /// # Example
563    ///
564    /// ```rust,no_run
565    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
566    /// # use fraiseql_core::db::postgres::PostgresAdapter;
567    /// # fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) {
568    /// // Access PostgreSQL-specific functionality
569    /// let pg_adapter = adapter.inner();
570    /// # }
571    /// ```
572    #[must_use]
573    pub const fn inner(&self) -> &A {
574        &self.adapter
575    }
576
577    /// Get reference to cache.
578    ///
579    /// Useful for metrics and monitoring.
580    ///
581    /// # Example
582    ///
583    /// ```rust,no_run
584    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
585    /// # use fraiseql_core::db::postgres::PostgresAdapter;
586    /// # async fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) -> Result<(), Box<dyn std::error::Error>> {
587    /// let metrics = adapter.cache().metrics()?;
588    /// println!("Cache hit rate: {:.1}%", metrics.hit_rate() * 100.0);
589    /// # Ok(())
590    /// # }
591    /// ```
592    #[must_use]
593    pub fn cache(&self) -> &QueryResultCache {
594        &self.cache
595    }
596
597    /// Get schema version.
598    ///
599    /// # Example
600    ///
601    /// ```rust,no_run
602    /// # use fraiseql_core::cache::CachedDatabaseAdapter;
603    /// # use fraiseql_core::db::postgres::PostgresAdapter;
604    /// # fn example(adapter: CachedDatabaseAdapter<PostgresAdapter>) {
605    /// println!("Schema version: {}", adapter.schema_version());
606    /// # }
607    /// ```
608    #[must_use]
609    pub fn schema_version(&self) -> &str {
610        &self.schema_version
611    }
612
613    /// Get fact table cache configuration.
614    #[must_use]
615    pub const fn fact_table_config(&self) -> &FactTableCacheConfig {
616        &self.fact_table_config
617    }
618
619    /// Get the version provider for fact tables.
620    #[must_use]
621    pub fn version_provider(&self) -> &FactTableVersionProvider {
622        &self.version_provider
623    }
624
625    /// Verify that Row-Level Security is genuinely enforceable on the relations
626    /// this schema reads.
627    ///
628    /// Call this during server initialization when a multi-tenant schema declares
629    /// `[security.rls] enabled = true`. Without real RLS, users sharing the same
630    /// query parameters receive the same cached response regardless of tenant.
631    ///
632    /// # What this checks
633    ///
634    /// Every relation named by a query's `sql_source`, resolved through
635    /// `to_regclass`, must be one of:
636    ///
637    /// * a **table** with `relrowsecurity` set and at least one `pg_policy` row, or
638    /// * a **`security_invoker` view** — a default view executes with its owner's privileges and
639    ///   bypasses the caller's RLS entirely, so a non-invoker view over an RLS-protected table
640    ///   provides no isolation at all.
641    ///
642    /// A relation that does not exist fails the check: an absent relation cannot be
643    /// protected.
644    ///
645    /// # What it used to check, and why that was worthless
646    ///
647    /// It ran `SELECT current_setting('row_security', true)` and passed on `'on'` or
648    /// `'force'`. `row_security` governs whether *existing* policies are applied; it
649    /// defaults to `on` and says nothing about whether any policy exists. So the
650    /// documented "refuse startup if RLS appears inactive" gate returned `Ok(())` on
651    /// a stock PostgreSQL with no RLS anywhere — reporting success for a guarantee it
652    /// never checked (#762).
653    ///
654    /// # Errors
655    ///
656    /// Returns [`FraiseQLError::Configuration`] naming every relation that is not
657    /// RLS-protected, or [`FraiseQLError::Database`] if the catalog cannot be read
658    /// (including on non-PostgreSQL adapters, which have no equivalent mechanism and
659    /// therefore fail closed).
660    pub async fn validate_rls_active(&self, schema: &CompiledSchema) -> Result<()> {
661        let mut unprotected: Vec<String> = Vec::new();
662
663        for probe in sql_source_probes(schema) {
664            if probe.kind != SourceKind::Relation {
665                continue;
666            }
667            let display = probe.display_name();
668            match self.relation_rls_status(&probe).await? {
669                None => unprotected.push(format!("{display} — relation does not exist")),
670                Some(status) if status.is_protected() => {},
671                Some(status) => unprotected.push(format!("{display} — {}", status.explain())),
672            }
673        }
674
675        if unprotected.is_empty() {
676            return Ok(());
677        }
678
679        Err(FraiseQLError::Configuration {
680            message: format!(
681                "Row-Level Security is declared for this multi-tenant schema but is not in \
682                 force on {} of its source relation(s), so tenants are not isolated and \
683                 cached responses can cross tenant boundaries:\n  - {}\n\
684                 Enable RLS and declare a policy on each source table \
685                 (`ALTER TABLE … ENABLE ROW LEVEL SECURITY` + `CREATE POLICY …`), define \
686                 views `WITH (security_invoker = true)` so they honour the caller's \
687                 policies, or remove `[security.rls] enabled = true` if this deployment \
688                 does not rely on database RLS.",
689                unprotected.len(),
690                unprotected.join("\n  - ")
691            ),
692        })
693    }
694
695    /// Read one relation's RLS posture from the catalog. `None` = no such relation.
696    async fn relation_rls_status(&self, probe: &SourceProbe) -> Result<Option<RelationRls>> {
697        // `execute_raw_query` takes no bind parameters, so the identifier is embedded.
698        // It comes from the compiled schema, and single quotes are doubled defensively —
699        // the same posture `sql_source_check` takes for the existence probe.
700        let ident = match &probe.schema {
701            Some(s) => format!(
702                "{}.{}",
703                quote_postgres_identifier(s),
704                quote_postgres_identifier(&probe.name)
705            ),
706            None => quote_postgres_identifier(&probe.name),
707        };
708        let literal = ident.replace('\'', "''");
709        let sql = format!(
710            "SELECT c.relkind::text AS kind, \
711                    c.relrowsecurity AS rls_enabled, \
712                    (SELECT count(*) FROM pg_policy p WHERE p.polrelid = c.oid)::text \
713                      AS policy_count, \
714                    COALESCE((SELECT bool_or(lower(o.option_value) IN ('true','on','1')) \
715                              FROM pg_options_to_table(c.reloptions) o \
716                              WHERE o.option_name = 'security_invoker'), false) \
717                      AS security_invoker \
718             FROM pg_class c WHERE c.oid = to_regclass('{literal}')"
719        );
720
721        let rows = self.adapter.execute_raw_query(&sql).await?;
722        let Some(row) = rows.first() else {
723            return Ok(None);
724        };
725        let cell_bool = |k: &str| row.get(k).and_then(serde_json::Value::as_bool).unwrap_or(false);
726        Ok(Some(RelationRls {
727            is_view:          row.get("kind").and_then(serde_json::Value::as_str) == Some("v"),
728            rls_enabled:      cell_bool("rls_enabled"),
729            has_policy:       row
730                .get("policy_count")
731                .and_then(serde_json::Value::as_str)
732                .is_some_and(|n| n != "0"),
733            security_invoker: cell_bool("security_invoker"),
734        }))
735    }
736
737    /// Apply the RLS enforcement policy from `CacheConfig`.
738    ///
739    /// Runs [`validate_rls_active`](Self::validate_rls_active) and handles the result
740    /// according to `enforcement`:
741    /// - [`RlsEnforcement::Error`]: propagates the error (default)
742    /// - [`RlsEnforcement::Warn`]: logs a warning and returns `Ok(())`
743    /// - [`RlsEnforcement::Off`]: skips the check entirely
744    ///
745    /// # Errors
746    ///
747    /// Returns the error from `validate_rls_active` when enforcement is `Error`.
748    pub async fn enforce_rls(
749        &self,
750        schema: &CompiledSchema,
751        enforcement: RlsEnforcement,
752    ) -> Result<()> {
753        if enforcement == RlsEnforcement::Off {
754            return Ok(());
755        }
756
757        match self.validate_rls_active(schema).await {
758            Ok(()) => Ok(()),
759            Err(e) => match enforcement {
760                RlsEnforcement::Error => Err(e),
761                RlsEnforcement::Warn => {
762                    tracing::warn!(
763                        "RLS check failed (rls_enforcement = \"warn\"): {}. \
764                         Cross-tenant cache leakage is possible.",
765                        e
766                    );
767                    Ok(())
768                },
769                RlsEnforcement::Off => Ok(()), // unreachable but exhaustive
770            },
771        }
772    }
773}
774
775impl<A: DatabaseAdapter + Clone> Clone for CachedDatabaseAdapter<A> {
776    fn clone(&self) -> Self {
777        Self {
778            adapter:              self.adapter.clone(),
779            cache:                Arc::clone(&self.cache),
780            schema_version:       self.schema_version.clone(),
781            view_ttl_overrides:   self.view_ttl_overrides.clone(),
782            cacheable_views:      self.cacheable_views.clone(),
783            view_secondary_views: self.view_secondary_views.clone(),
784            opt_in_mode:          self.opt_in_mode,
785            has_rls:              self.has_rls,
786            fact_table_config:    self.fact_table_config.clone(),
787            version_provider:     Arc::clone(&self.version_provider),
788            cascade_invalidator:  self.cascade_invalidator.clone(),
789        }
790    }
791}
792
793// Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
794// its transformed method signatures to satisfy the trait contract
795// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
796#[async_trait]
797impl<A: DatabaseAdapter> DatabaseAdapter for CachedDatabaseAdapter<A> {
798    async fn execute_with_projection(
799        &self,
800        view: &str,
801        projection: Option<&crate::schema::SqlProjectionHint>,
802        where_clause: Option<&WhereClause>,
803        limit: Option<u32>,
804        offset: Option<u32>,
805        order_by: Option<&[OrderByClause]>,
806    ) -> Result<Vec<JsonbValue>> {
807        let request = crate::backend::ProjectionRequest {
808            view,
809            projection,
810            where_clause,
811            order_by,
812            limit,
813            offset,
814        };
815        self.execute_with_projection_impl(&request, &[]).await.map(Arc::unwrap_or_clone)
816    }
817
818    async fn execute_where_query(
819        &self,
820        view: &str,
821        where_clause: Option<&WhereClause>,
822        limit: Option<u32>,
823        offset: Option<u32>,
824        order_by: Option<&[OrderByClause]>,
825    ) -> Result<Vec<JsonbValue>> {
826        self.execute_where_query_impl(view, where_clause, limit, offset, order_by, &[])
827            .await
828            .map(Arc::unwrap_or_clone)
829    }
830
831    async fn execute_with_projection_arc(
832        &self,
833        request: &crate::backend::ProjectionRequest<'_>,
834    ) -> Result<Arc<Vec<JsonbValue>>> {
835        self.execute_with_projection_impl(request, &[]).await
836    }
837
838    async fn execute_where_query_arc(
839        &self,
840        view: &str,
841        where_clause: Option<&WhereClause>,
842        limit: Option<u32>,
843        offset: Option<u32>,
844        order_by: Option<&[OrderByClause]>,
845    ) -> Result<Arc<Vec<JsonbValue>>> {
846        self.execute_where_query_impl(view, where_clause, limit, offset, order_by, &[])
847            .await
848    }
849
850    fn database_type(&self) -> DatabaseType {
851        self.adapter.database_type()
852    }
853
854    fn serves_reads_from_standbys(&self) -> bool {
855        self.adapter.serves_reads_from_standbys()
856    }
857
858    async fn health_check(&self) -> Result<()> {
859        self.adapter.health_check().await
860    }
861
862    fn pool_metrics(&self) -> PoolMetrics {
863        self.adapter.pool_metrics()
864    }
865
866    async fn execute_raw_query(
867        &self,
868        sql: &str,
869    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
870        // Use the aggregation caching method which handles fact table versioning
871        self.execute_aggregation_query(sql).await
872    }
873
874    async fn execute_parameterized_aggregate(
875        &self,
876        sql: &str,
877        params: &[serde_json::Value],
878    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
879        // Parameterized aggregate results are not cacheable by SQL template alone;
880        // delegate directly to the underlying adapter to avoid caching with an
881        // incorrect key (the same SQL template with different params would return
882        // different results).
883        self.adapter.execute_parameterized_aggregate(sql, params).await
884    }
885
886    async fn execute_parameterized_aggregate_with_session(
887        &self,
888        sql: &str,
889        params: &[serde_json::Value],
890        session_vars: &[(&str, &str)],
891        routing: ReadRouting,
892    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
893        // Not cached (see execute_parameterized_aggregate); forward with session
894        // affinity so current_setting()-backed aggregate RLS sees the values (#329),
895        // and with the query's routing so #957's per-query overrides reach the pool.
896        self.adapter
897            .execute_parameterized_aggregate_with_session(sql, params, session_vars, routing)
898            .await
899    }
900
901    // Forwarded with `execute_composed_with_session`, which it describes: the server wraps
902    // every adapter in this one, so a default here would refuse embeds for all of them.
903    fn supports_composed_reads(&self) -> bool {
904        self.adapter.supports_composed_reads()
905    }
906
907    async fn count_where_query(
908        &self,
909        view: &str,
910        where_clause: Option<&WhereClause>,
911        session_vars: &[(&str, &str)],
912        routing: ReadRouting,
913    ) -> Result<u64> {
914        // Straight through to the inner adapter, so the count reaches PostgreSQL's
915        // `SELECT COUNT(*)` rather than the trait default's fetch-and-len.
916        //
917        // Deliberately uncached: the result cache stores row sets keyed by view +
918        // predicate, and a count is neither — caching it would need its own key
919        // space and its own invalidation, and a *stale* count is worse than a
920        // slightly expensive one (it is read to size a pager, so a stale total
921        // renders pages that do not exist).
922        self.adapter.count_where_query(view, where_clause, session_vars, routing).await
923    }
924
925    async fn execute_where_query_arc_with_session(
926        &self,
927        view: &str,
928        where_clause: Option<&WhereClause>,
929        limit: Option<u32>,
930        offset: Option<u32>,
931        order_by: Option<&[OrderByClause]>,
932        session_vars: &[(&str, &str)],
933        routing: ReadRouting,
934    ) -> Result<Arc<Vec<JsonbValue>>> {
935        // The cached path, keyed on the session variables (#1373), unless the query
936        // refused staleness (#957). `read_routing = primary` is asked for when stale
937        // data is a correctness problem, and a cache hit is stale data by construction
938        // — serving one would give that query the opposite of what it asked for,
939        // through a different door.
940        //
941        // This used to bypass the cache whenever any session variable was set, because
942        // the key did not include them: correct, but it meant a deployment declaring
943        // `[session_variables]` cached no authenticated read at all.
944        if routing.allows_cached_result() {
945            return self
946                .execute_where_query_impl(view, where_clause, limit, offset, order_by, session_vars)
947                .await;
948        }
949        self.adapter
950            .execute_where_query_arc_with_session(
951                view,
952                where_clause,
953                limit,
954                offset,
955                order_by,
956                session_vars,
957                routing,
958            )
959            .await
960    }
961
962    async fn execute_with_projection_arc_with_session(
963        &self,
964        request: &crate::backend::ProjectionRequest<'_>,
965        session_vars: &[(&str, &str)],
966        routing: ReadRouting,
967    ) -> Result<Arc<Vec<JsonbValue>>> {
968        // Keyed on the session variables (#1373), unless the query refused staleness —
969        // see execute_where_query_arc_with_session.
970        if routing.allows_cached_result() {
971            return self.execute_with_projection_impl(request, session_vars).await;
972        }
973        self.adapter
974            .execute_with_projection_arc_with_session(request, session_vars, routing)
975            .await
976    }
977
978    /// Forwarded to the inner adapter, and deliberately **uncached** (#958).
979    ///
980    /// Forwarding at all is the load-bearing part. The trait's default would
981    /// satisfy this method by calling the *cached* read above and replaying the
982    /// buffer, which returns exactly the right rows — and silently converts every
983    /// streaming caller in the process back into one that materialises the whole
984    /// result set. A wrapper that inherits the default is how a memory bound
985    /// disappears without a single test failing.
986    ///
987    /// Uncached because the two are incompatible by construction: populating a
988    /// result-cache entry means holding every row, which is the cost this call
989    /// exists to avoid, and serving one means the caller never streams. A read
990    /// that wants the cache asks for the buffered method.
991    async fn stream_with_projection(
992        &self,
993        request: &crate::backend::ProjectionRequest<'_>,
994        session_vars: &[(&str, &str)],
995        routing: ReadRouting,
996    ) -> Result<fraiseql_db::JsonbRowStream> {
997        self.adapter.stream_with_projection(request, session_vars, routing).await
998    }
999
1000    /// Forwarded to the inner adapter, **uncached**.
1001    ///
1002    /// A composed read spans every view its embedded levels name, and this cache
1003    /// invalidates by the view a read targets. A cached tree would therefore survive a
1004    /// write to any view but the root's — serving related rows that no longer exist.
1005    /// Caching it correctly needs invalidation by every view in the tree, which is a
1006    /// change to the cache, not to this forwarder.
1007    async fn execute_composed_with_session(
1008        &self,
1009        read: &fraiseql_db::ComposedLevel,
1010        session_vars: &[(&str, &str)],
1011        routing: ReadRouting,
1012    ) -> Result<Arc<Vec<JsonbValue>>> {
1013        self.adapter.execute_composed_with_session(read, session_vars, routing).await
1014    }
1015
1016    /// Forwarded to the inner adapter — see
1017    /// [`stream_with_projection`](Self::stream_with_projection). The row-shaped
1018    /// read was never cached in the first place.
1019    async fn stream_row_query(
1020        &self,
1021        view_name: &str,
1022        columns: &[fraiseql_db::types::ColumnSpec],
1023        where_sql: Option<&str>,
1024        order_by: Option<&str>,
1025        limit: Option<u32>,
1026        offset: Option<u32>,
1027    ) -> Result<fraiseql_db::ColumnRowStream> {
1028        self.adapter
1029            .stream_row_query(view_name, columns, where_sql, order_by, limit, offset)
1030            .await
1031    }
1032
1033    /// Forwarded to the inner adapter — see
1034    /// [`stream_row_query`](Self::stream_row_query).
1035    ///
1036    /// Forwarding is load-bearing: the trait default drops the session variables,
1037    /// so a wrapper that inherited it would leave every row read behind this cache
1038    /// running without the `set_config` an RLS policy reads (#329). The shipped
1039    /// binary always wraps its adapter in this one, so "the default is fine" is
1040    /// never true here.
1041    async fn execute_row_query_with_session(
1042        &self,
1043        view_name: &str,
1044        columns: &[fraiseql_db::types::ColumnSpec],
1045        where_sql: Option<&str>,
1046        order_by: Option<&str>,
1047        limit: Option<u32>,
1048        offset: Option<u32>,
1049        session_vars: &[(&str, &str)],
1050    ) -> Result<Vec<Vec<fraiseql_db::types::ColumnValue>>> {
1051        self.adapter
1052            .execute_row_query_with_session(
1053                view_name,
1054                columns,
1055                where_sql,
1056                order_by,
1057                limit,
1058                offset,
1059                session_vars,
1060            )
1061            .await
1062    }
1063
1064    /// Forwarded to the inner adapter — the streaming twin of
1065    /// [`execute_row_query_with_session`](Self::execute_row_query_with_session),
1066    /// load-bearing for the same reason.
1067    async fn stream_row_query_with_session(
1068        &self,
1069        view_name: &str,
1070        columns: &[fraiseql_db::types::ColumnSpec],
1071        where_sql: Option<&str>,
1072        order_by: Option<&str>,
1073        limit: Option<u32>,
1074        offset: Option<u32>,
1075        session_vars: &[(&str, &str)],
1076    ) -> Result<fraiseql_db::ColumnRowStream> {
1077        self.adapter
1078            .stream_row_query_with_session(
1079                view_name,
1080                columns,
1081                where_sql,
1082                order_by,
1083                limit,
1084                offset,
1085                session_vars,
1086            )
1087            .await
1088    }
1089
1090    /// Forwarded to the inner adapter, and deliberately **uncached** (#962).
1091    ///
1092    /// Forwarding is load-bearing for the same reason as
1093    /// [`stream_with_projection`](Self::stream_with_projection), in the other
1094    /// direction: the trait's default here *refuses*, so a wrapper that inherited
1095    /// it would turn every PostgreSQL deployment — which is every deployment,
1096    /// since the shipped binary always wraps its adapter in this one — into a
1097    /// server whose SQL console answers "unsupported".
1098    ///
1099    /// Uncached because the statement is not a query FraiseQL knows anything
1100    /// about: it has no cache key, its result is a preview of a moment, and a
1101    /// committed one changes rows other entries describe. Invalidation is
1102    /// deliberately not attempted either — see the endpoint's documentation for
1103    /// why a committed console write is an operator action that should be
1104    /// followed by `cache/clear`.
1105    async fn execute_admin_sql(
1106        &self,
1107        request: &fraiseql_db::AdminSqlRequest,
1108    ) -> Result<fraiseql_db::AdminSqlOutcome> {
1109        self.adapter.execute_admin_sql(request).await
1110    }
1111
1112    async fn invalidate_views(&self, views: &[fraiseql_db::ViewName]) -> Result<u64> {
1113        // Delegate to the inherent (synchronous) method which handles cascade
1114        // expansion and cache eviction.
1115        CachedDatabaseAdapter::invalidate_views(self, views)
1116    }
1117
1118    async fn invalidate_by_entity(&self, entity_type: &str, entity_id: &str) -> Result<u64> {
1119        CachedDatabaseAdapter::invalidate_by_entity(self, entity_type, entity_id)
1120    }
1121
1122    /// The operator-visible snapshot of the cache that actually serves queries (#941).
1123    ///
1124    /// `None` when the cache is disabled: an operator asking "what is cached?" of a
1125    /// server running `cache_enabled = false` must be told there is no cache, not
1126    /// handed a zero-entry snapshot of one.
1127    fn result_cache_stats(&self) -> Option<fraiseql_db::ResultCacheStats> {
1128        if !self.cache.is_enabled() {
1129            return None;
1130        }
1131        // Settle moka's pending writes first: without this an entry cached moments ago
1132        // is not counted, and the endpoint reports an empty cache that is not empty.
1133        self.cache.run_pending_tasks();
1134        let metrics = self.cache.metrics().ok()?;
1135        let config = self.cache.config();
1136        Some(fraiseql_db::ResultCacheStats {
1137            entries:       metrics.size,
1138            hits:          metrics.hits,
1139            misses:        metrics.misses,
1140            invalidations: metrics.invalidations,
1141            memory_bytes:  metrics.memory_bytes,
1142            ttl_seconds:   config.ttl_seconds,
1143            max_entries:   config.max_entries,
1144        })
1145    }
1146
1147    async fn clear_result_cache(&self) -> Result<Option<usize>> {
1148        if !self.cache.is_enabled() {
1149            return Ok(None);
1150        }
1151        self.cache.run_pending_tasks();
1152        let before = self.cache.metrics().map(|m| m.size).unwrap_or_default();
1153        self.cache.clear()?;
1154        Ok(Some(before))
1155    }
1156
1157    async fn query_stats(&self, limit: u32) -> Result<Vec<fraiseql_db::QueryStatEntry>> {
1158        self.adapter.query_stats(limit).await
1159    }
1160
1161    async fn query_stats_by_id(&self, id: &str) -> Result<Option<fraiseql_db::QueryStatEntry>> {
1162        self.adapter.query_stats_by_id(id).await
1163    }
1164
1165    async fn reset_query_stats(&self) -> Result<()> {
1166        self.adapter.reset_query_stats().await
1167    }
1168
1169    fn on_schema_reload(&self) {
1170        // Clear all cached entries — they reference the old schema's content hash
1171        // and per-view TTL configuration.
1172        let _ = self.cache.clear();
1173    }
1174}
1175
1176/// A cache-wrapped adapter writes exactly when the adapter it wraps does. Writes are never
1177/// cached: every one passes through, in either mode (ruling AA 6).
1178// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
1179#[async_trait]
1180impl<A: Writer> Writer for CachedDatabaseAdapter<A> {
1181    async fn execute_write(
1182        &self,
1183        request: &WriteRequest<'_>,
1184        gate: MutationRowGate<'_>,
1185    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
1186        self.adapter.execute_write(request, gate).await
1187    }
1188
1189    async fn bump_fact_table_versions(&self, tables: &[String]) -> Result<()> {
1190        self.bump_fact_table_versions_impl(tables).await
1191    }
1192}