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, PG 15+).
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                  force on {} of its source relation(s), so tenants are not isolated and                  cached responses can cross tenant boundaries:\n  - {}\n\
682                 Enable RLS and declare a policy on each source table                  (`ALTER TABLE … ENABLE ROW LEVEL SECURITY` + `CREATE POLICY …`), define                  views `WITH (security_invoker = true)` so they honour the caller's                  policies, or remove `[security.rls] enabled = true` if this deployment                  does not rely on database RLS.",
683                unprotected.len(),
684                unprotected.join("\n  - ")
685            ),
686        })
687    }
688
689    /// Read one relation's RLS posture from the catalog. `None` = no such relation.
690    async fn relation_rls_status(&self, probe: &SourceProbe) -> Result<Option<RelationRls>> {
691        // `execute_raw_query` takes no bind parameters, so the identifier is embedded.
692        // It comes from the compiled schema, and single quotes are doubled defensively —
693        // the same posture `sql_source_check` takes for the existence probe.
694        let ident = match &probe.schema {
695            Some(s) => format!(
696                "{}.{}",
697                quote_postgres_identifier(s),
698                quote_postgres_identifier(&probe.name)
699            ),
700            None => quote_postgres_identifier(&probe.name),
701        };
702        let literal = ident.replace('\'', "''");
703        let sql = format!(
704            "SELECT c.relkind::text AS kind, \
705                    c.relrowsecurity AS rls_enabled, \
706                    (SELECT count(*) FROM pg_policy p WHERE p.polrelid = c.oid)::text \
707                      AS policy_count, \
708                    COALESCE((SELECT bool_or(lower(o.option_value) IN ('true','on','1')) \
709                              FROM pg_options_to_table(c.reloptions) o \
710                              WHERE o.option_name = 'security_invoker'), false) \
711                      AS security_invoker \
712             FROM pg_class c WHERE c.oid = to_regclass('{literal}')"
713        );
714
715        let rows = self.adapter.execute_raw_query(&sql).await?;
716        let Some(row) = rows.first() else {
717            return Ok(None);
718        };
719        let cell_bool = |k: &str| row.get(k).and_then(serde_json::Value::as_bool).unwrap_or(false);
720        Ok(Some(RelationRls {
721            is_view:          row.get("kind").and_then(serde_json::Value::as_str) == Some("v"),
722            rls_enabled:      cell_bool("rls_enabled"),
723            has_policy:       row
724                .get("policy_count")
725                .and_then(serde_json::Value::as_str)
726                .is_some_and(|n| n != "0"),
727            security_invoker: cell_bool("security_invoker"),
728        }))
729    }
730
731    /// Apply the RLS enforcement policy from `CacheConfig`.
732    ///
733    /// Runs [`validate_rls_active`](Self::validate_rls_active) and handles the result
734    /// according to `enforcement`:
735    /// - [`RlsEnforcement::Error`]: propagates the error (default)
736    /// - [`RlsEnforcement::Warn`]: logs a warning and returns `Ok(())`
737    /// - [`RlsEnforcement::Off`]: skips the check entirely
738    ///
739    /// # Errors
740    ///
741    /// Returns the error from `validate_rls_active` when enforcement is `Error`.
742    pub async fn enforce_rls(
743        &self,
744        schema: &CompiledSchema,
745        enforcement: RlsEnforcement,
746    ) -> Result<()> {
747        if enforcement == RlsEnforcement::Off {
748            return Ok(());
749        }
750
751        match self.validate_rls_active(schema).await {
752            Ok(()) => Ok(()),
753            Err(e) => match enforcement {
754                RlsEnforcement::Error => Err(e),
755                RlsEnforcement::Warn => {
756                    tracing::warn!(
757                        "RLS check failed (rls_enforcement = \"warn\"): {}. \
758                         Cross-tenant cache leakage is possible.",
759                        e
760                    );
761                    Ok(())
762                },
763                RlsEnforcement::Off => Ok(()), // unreachable but exhaustive
764            },
765        }
766    }
767}
768
769impl<A: DatabaseAdapter + Clone> Clone for CachedDatabaseAdapter<A> {
770    fn clone(&self) -> Self {
771        Self {
772            adapter:              self.adapter.clone(),
773            cache:                Arc::clone(&self.cache),
774            schema_version:       self.schema_version.clone(),
775            view_ttl_overrides:   self.view_ttl_overrides.clone(),
776            cacheable_views:      self.cacheable_views.clone(),
777            view_secondary_views: self.view_secondary_views.clone(),
778            opt_in_mode:          self.opt_in_mode,
779            has_rls:              self.has_rls,
780            fact_table_config:    self.fact_table_config.clone(),
781            version_provider:     Arc::clone(&self.version_provider),
782            cascade_invalidator:  self.cascade_invalidator.clone(),
783        }
784    }
785}
786
787// Reason: DatabaseAdapter is defined with #[async_trait]; all implementations must match
788// its transformed method signatures to satisfy the trait contract
789// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
790#[async_trait]
791impl<A: DatabaseAdapter> DatabaseAdapter for CachedDatabaseAdapter<A> {
792    async fn execute_with_projection(
793        &self,
794        view: &str,
795        projection: Option<&crate::schema::SqlProjectionHint>,
796        where_clause: Option<&WhereClause>,
797        limit: Option<u32>,
798        offset: Option<u32>,
799        order_by: Option<&[OrderByClause]>,
800    ) -> Result<Vec<JsonbValue>> {
801        self.execute_with_projection_impl(view, projection, where_clause, limit, offset, order_by)
802            .await
803            .map(Arc::unwrap_or_clone)
804    }
805
806    async fn execute_where_query(
807        &self,
808        view: &str,
809        where_clause: Option<&WhereClause>,
810        limit: Option<u32>,
811        offset: Option<u32>,
812        order_by: Option<&[OrderByClause]>,
813    ) -> Result<Vec<JsonbValue>> {
814        self.execute_where_query_impl(view, where_clause, limit, offset, order_by)
815            .await
816            .map(Arc::unwrap_or_clone)
817    }
818
819    async fn execute_with_projection_arc(
820        &self,
821        request: &crate::backend::ProjectionRequest<'_>,
822    ) -> Result<Arc<Vec<JsonbValue>>> {
823        self.execute_with_projection_impl(
824            request.view,
825            request.projection,
826            request.where_clause,
827            request.limit,
828            request.offset,
829            request.order_by,
830        )
831        .await
832    }
833
834    async fn execute_where_query_arc(
835        &self,
836        view: &str,
837        where_clause: Option<&WhereClause>,
838        limit: Option<u32>,
839        offset: Option<u32>,
840        order_by: Option<&[OrderByClause]>,
841    ) -> Result<Arc<Vec<JsonbValue>>> {
842        self.execute_where_query_impl(view, where_clause, limit, offset, order_by).await
843    }
844
845    fn database_type(&self) -> DatabaseType {
846        self.adapter.database_type()
847    }
848
849    async fn health_check(&self) -> Result<()> {
850        self.adapter.health_check().await
851    }
852
853    fn pool_metrics(&self) -> PoolMetrics {
854        self.adapter.pool_metrics()
855    }
856
857    async fn execute_raw_query(
858        &self,
859        sql: &str,
860    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
861        // Use the aggregation caching method which handles fact table versioning
862        self.execute_aggregation_query(sql).await
863    }
864
865    async fn execute_parameterized_aggregate(
866        &self,
867        sql: &str,
868        params: &[serde_json::Value],
869    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
870        // Parameterized aggregate results are not cacheable by SQL template alone;
871        // delegate directly to the underlying adapter to avoid caching with an
872        // incorrect key (the same SQL template with different params would return
873        // different results).
874        self.adapter.execute_parameterized_aggregate(sql, params).await
875    }
876
877    async fn execute_parameterized_aggregate_with_session(
878        &self,
879        sql: &str,
880        params: &[serde_json::Value],
881        session_vars: &[(&str, &str)],
882        routing: ReadRouting,
883    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
884        // Not cached (see execute_parameterized_aggregate); forward with session
885        // affinity so current_setting()-backed aggregate RLS sees the values (#329),
886        // and with the query's routing so #957's per-query overrides reach the pool.
887        self.adapter
888            .execute_parameterized_aggregate_with_session(sql, params, session_vars, routing)
889            .await
890    }
891
892    // Forwarded with `execute_composed_with_session`, which it describes: the server wraps
893    // every adapter in this one, so a default here would refuse embeds for all of them.
894    fn supports_composed_reads(&self) -> bool {
895        self.adapter.supports_composed_reads()
896    }
897
898    async fn count_where_query(
899        &self,
900        view: &str,
901        where_clause: Option<&WhereClause>,
902        session_vars: &[(&str, &str)],
903        routing: ReadRouting,
904    ) -> Result<u64> {
905        // Straight through to the inner adapter, so the count reaches PostgreSQL's
906        // `SELECT COUNT(*)` rather than the trait default's fetch-and-len.
907        //
908        // Deliberately uncached: the result cache stores row sets keyed by view +
909        // predicate, and a count is neither — caching it would need its own key
910        // space and its own invalidation, and a *stale* count is worse than a
911        // slightly expensive one (it is read to size a pager, so a stale total
912        // renders pages that do not exist).
913        self.adapter.count_where_query(view, where_clause, session_vars, routing).await
914    }
915
916    async fn execute_where_query_arc_with_session(
917        &self,
918        view: &str,
919        where_clause: Option<&WhereClause>,
920        limit: Option<u32>,
921        offset: Option<u32>,
922        order_by: Option<&[OrderByClause]>,
923        session_vars: &[(&str, &str)],
924        routing: ReadRouting,
925    ) -> Result<Arc<Vec<JsonbValue>>> {
926        // No session variables => preserve the cached read path unchanged, unless
927        // the query refused staleness (#957). `read_routing = primary` is asked for
928        // when stale data is a correctness problem, and a cache hit is stale data by
929        // construction — serving one would give that query the opposite of what it
930        // asked for, through a different door.
931        if session_vars.is_empty() && routing.allows_cached_result() {
932            return self
933                .execute_where_query_impl(view, where_clause, limit, offset, order_by)
934                .await;
935        }
936        // Security: the result-cache key is NOT session-variable-aware, so a
937        // tenant-scoped read (RLS via current_setting) could otherwise leak
938        // another tenant's cached rows. Bypass the cache and run the read with
939        // session affinity on the inner adapter. Tracked for a cache-key fix:
940        // see #329 follow-up.
941        self.adapter
942            .execute_where_query_arc_with_session(
943                view,
944                where_clause,
945                limit,
946                offset,
947                order_by,
948                session_vars,
949                routing,
950            )
951            .await
952    }
953
954    async fn execute_with_projection_arc_with_session(
955        &self,
956        request: &crate::backend::ProjectionRequest<'_>,
957        session_vars: &[(&str, &str)],
958        routing: ReadRouting,
959    ) -> Result<Arc<Vec<JsonbValue>>> {
960        // No session variables => preserve the cached read path unchanged, unless
961        // the query refused staleness — see execute_where_query_arc_with_session.
962        if session_vars.is_empty() && routing.allows_cached_result() {
963            return self
964                .execute_with_projection_impl(
965                    request.view,
966                    request.projection,
967                    request.where_clause,
968                    request.limit,
969                    request.offset,
970                    request.order_by,
971                )
972                .await;
973        }
974        // Security: see execute_where_query_arc_with_session — bypass the
975        // non-tenant-aware cache for session-scoped reads.
976        self.adapter
977            .execute_with_projection_arc_with_session(request, session_vars, routing)
978            .await
979    }
980
981    /// Forwarded to the inner adapter, and deliberately **uncached** (#958).
982    ///
983    /// Forwarding at all is the load-bearing part. The trait's default would
984    /// satisfy this method by calling the *cached* read above and replaying the
985    /// buffer, which returns exactly the right rows — and silently converts every
986    /// streaming caller in the process back into one that materialises the whole
987    /// result set. A wrapper that inherits the default is how a memory bound
988    /// disappears without a single test failing.
989    ///
990    /// Uncached because the two are incompatible by construction: populating a
991    /// result-cache entry means holding every row, which is the cost this call
992    /// exists to avoid, and serving one means the caller never streams. A read
993    /// that wants the cache asks for the buffered method.
994    async fn stream_with_projection(
995        &self,
996        request: &crate::backend::ProjectionRequest<'_>,
997        session_vars: &[(&str, &str)],
998        routing: ReadRouting,
999    ) -> Result<fraiseql_db::JsonbRowStream> {
1000        self.adapter.stream_with_projection(request, session_vars, routing).await
1001    }
1002
1003    /// Forwarded to the inner adapter, **uncached**.
1004    ///
1005    /// A composed read spans every view its embedded levels name, and this cache
1006    /// invalidates by the view a read targets. A cached tree would therefore survive a
1007    /// write to any view but the root's — serving related rows that no longer exist.
1008    /// Caching it correctly needs invalidation by every view in the tree, which is a
1009    /// change to the cache, not to this forwarder.
1010    async fn execute_composed_with_session(
1011        &self,
1012        read: &fraiseql_db::ComposedLevel,
1013        session_vars: &[(&str, &str)],
1014        routing: ReadRouting,
1015    ) -> Result<Arc<Vec<JsonbValue>>> {
1016        self.adapter.execute_composed_with_session(read, session_vars, routing).await
1017    }
1018
1019    /// Forwarded to the inner adapter — see
1020    /// [`stream_with_projection`](Self::stream_with_projection). The row-shaped
1021    /// read was never cached in the first place.
1022    async fn stream_row_query(
1023        &self,
1024        view_name: &str,
1025        columns: &[fraiseql_db::types::ColumnSpec],
1026        where_sql: Option<&str>,
1027        order_by: Option<&str>,
1028        limit: Option<u32>,
1029        offset: Option<u32>,
1030    ) -> Result<fraiseql_db::ColumnRowStream> {
1031        self.adapter
1032            .stream_row_query(view_name, columns, where_sql, order_by, limit, offset)
1033            .await
1034    }
1035
1036    /// Forwarded to the inner adapter — see
1037    /// [`stream_row_query`](Self::stream_row_query).
1038    ///
1039    /// Forwarding is load-bearing: the trait default drops the session variables,
1040    /// so a wrapper that inherited it would leave every row read behind this cache
1041    /// running without the `set_config` an RLS policy reads (#329). The shipped
1042    /// binary always wraps its adapter in this one, so "the default is fine" is
1043    /// never true here.
1044    async fn execute_row_query_with_session(
1045        &self,
1046        view_name: &str,
1047        columns: &[fraiseql_db::types::ColumnSpec],
1048        where_sql: Option<&str>,
1049        order_by: Option<&str>,
1050        limit: Option<u32>,
1051        offset: Option<u32>,
1052        session_vars: &[(&str, &str)],
1053    ) -> Result<Vec<Vec<fraiseql_db::types::ColumnValue>>> {
1054        self.adapter
1055            .execute_row_query_with_session(
1056                view_name,
1057                columns,
1058                where_sql,
1059                order_by,
1060                limit,
1061                offset,
1062                session_vars,
1063            )
1064            .await
1065    }
1066
1067    /// Forwarded to the inner adapter — the streaming twin of
1068    /// [`execute_row_query_with_session`](Self::execute_row_query_with_session),
1069    /// load-bearing for the same reason.
1070    async fn stream_row_query_with_session(
1071        &self,
1072        view_name: &str,
1073        columns: &[fraiseql_db::types::ColumnSpec],
1074        where_sql: Option<&str>,
1075        order_by: Option<&str>,
1076        limit: Option<u32>,
1077        offset: Option<u32>,
1078        session_vars: &[(&str, &str)],
1079    ) -> Result<fraiseql_db::ColumnRowStream> {
1080        self.adapter
1081            .stream_row_query_with_session(
1082                view_name,
1083                columns,
1084                where_sql,
1085                order_by,
1086                limit,
1087                offset,
1088                session_vars,
1089            )
1090            .await
1091    }
1092
1093    /// Forwarded to the inner adapter, and deliberately **uncached** (#962).
1094    ///
1095    /// Forwarding is load-bearing for the same reason as
1096    /// [`stream_with_projection`](Self::stream_with_projection), in the other
1097    /// direction: the trait's default here *refuses*, so a wrapper that inherited
1098    /// it would turn every PostgreSQL deployment — which is every deployment,
1099    /// since the shipped binary always wraps its adapter in this one — into a
1100    /// server whose SQL console answers "unsupported".
1101    ///
1102    /// Uncached because the statement is not a query FraiseQL knows anything
1103    /// about: it has no cache key, its result is a preview of a moment, and a
1104    /// committed one changes rows other entries describe. Invalidation is
1105    /// deliberately not attempted either — see the endpoint's documentation for
1106    /// why a committed console write is an operator action that should be
1107    /// followed by `cache/clear`.
1108    async fn execute_admin_sql(
1109        &self,
1110        request: &fraiseql_db::AdminSqlRequest,
1111    ) -> Result<fraiseql_db::AdminSqlOutcome> {
1112        self.adapter.execute_admin_sql(request).await
1113    }
1114
1115    async fn invalidate_views(&self, views: &[fraiseql_db::ViewName]) -> Result<u64> {
1116        // Delegate to the inherent (synchronous) method which handles cascade
1117        // expansion and cache eviction.
1118        CachedDatabaseAdapter::invalidate_views(self, views)
1119    }
1120
1121    async fn invalidate_by_entity(&self, entity_type: &str, entity_id: &str) -> Result<u64> {
1122        CachedDatabaseAdapter::invalidate_by_entity(self, entity_type, entity_id)
1123    }
1124
1125    /// The operator-visible snapshot of the cache that actually serves queries (#941).
1126    ///
1127    /// `None` when the cache is disabled: an operator asking "what is cached?" of a
1128    /// server running `cache_enabled = false` must be told there is no cache, not
1129    /// handed a zero-entry snapshot of one.
1130    fn result_cache_stats(&self) -> Option<fraiseql_db::ResultCacheStats> {
1131        if !self.cache.is_enabled() {
1132            return None;
1133        }
1134        // Settle moka's pending writes first: without this an entry cached moments ago
1135        // is not counted, and the endpoint reports an empty cache that is not empty.
1136        self.cache.run_pending_tasks();
1137        let metrics = self.cache.metrics().ok()?;
1138        let config = self.cache.config();
1139        Some(fraiseql_db::ResultCacheStats {
1140            entries:       metrics.size,
1141            hits:          metrics.hits,
1142            misses:        metrics.misses,
1143            invalidations: metrics.invalidations,
1144            memory_bytes:  metrics.memory_bytes,
1145            ttl_seconds:   config.ttl_seconds,
1146            max_entries:   config.max_entries,
1147        })
1148    }
1149
1150    async fn clear_result_cache(&self) -> Result<Option<usize>> {
1151        if !self.cache.is_enabled() {
1152            return Ok(None);
1153        }
1154        self.cache.run_pending_tasks();
1155        let before = self.cache.metrics().map(|m| m.size).unwrap_or_default();
1156        self.cache.clear()?;
1157        Ok(Some(before))
1158    }
1159
1160    async fn query_stats(&self, limit: u32) -> Result<Vec<fraiseql_db::QueryStatEntry>> {
1161        self.adapter.query_stats(limit).await
1162    }
1163
1164    async fn query_stats_by_id(&self, id: &str) -> Result<Option<fraiseql_db::QueryStatEntry>> {
1165        self.adapter.query_stats_by_id(id).await
1166    }
1167
1168    async fn reset_query_stats(&self) -> Result<()> {
1169        self.adapter.reset_query_stats().await
1170    }
1171
1172    fn on_schema_reload(&self) {
1173        // Clear all cached entries — they reference the old schema's content hash
1174        // and per-view TTL configuration.
1175        let _ = self.cache.clear();
1176    }
1177}
1178
1179/// A cache-wrapped adapter writes exactly when the adapter it wraps does. Writes are never
1180/// cached: every one passes through, in either mode (ruling AA 6).
1181// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
1182#[async_trait]
1183impl<A: Writer> Writer for CachedDatabaseAdapter<A> {
1184    async fn execute_write(
1185        &self,
1186        request: &WriteRequest<'_>,
1187        gate: MutationRowGate<'_>,
1188    ) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
1189        self.adapter.execute_write(request, gate).await
1190    }
1191
1192    async fn bump_fact_table_versions(&self, tables: &[String]) -> Result<()> {
1193        self.bump_fact_table_versions_impl(tables).await
1194    }
1195}