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}