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}