Skip to main content

fraiseql_core/cache/
key.rs

1//! Cache key generation for query results.
2//!
3//! # Security Critical
4//!
5//! This module is **security-critical**. Cache keys MUST include variable values
6//! to prevent data leakage between different users or requests. Incorrect key
7//! generation could allow User A to see User B's cached data.
8//!
9//! # Key Composition
10//!
11//! Cache keys are generated from a single-pass ahash over:
12//! 1. Query string bytes
13//! 2. Recursively hashed variable values (canonical ordering)
14//! 3. WHERE clause structure (hashed structurally, not via serde)
15//! 4. Schema version string
16//!
17//! The hasher uses fixed seeds so that keys are deterministic across restarts.
18//!
19//! # Example
20//!
21//! ```rust
22//! use fraiseql_core::cache::generate_cache_key;
23//! use fraiseql_core::db::{WhereClause, WhereOperator};
24//! use serde_json::json;
25//!
26//! // Two different users querying their own data
27//! let key1 = generate_cache_key(
28//!     "query { user(id: $id) { name } }",
29//!     &json!({"id": "alice"}),
30//!     None,
31//!     "v1"
32//! );
33//!
34//! let key2 = generate_cache_key(
35//!     "query { user(id: $id) { name } }",
36//!     &json!({"id": "bob"}),
37//!     None,
38//!     "v1"
39//! );
40//!
41//! // Different variables MUST produce different keys (security requirement)
42//! assert_ne!(key1, key2);
43//! ```
44
45use std::hash::{BuildHasher, Hash, Hasher};
46
47use ahash::RandomState;
48use serde_json::Value as JsonValue;
49
50use crate::{
51    backend::{OrderByClause, WhereOperator, where_clause::WhereClause},
52    schema::{QueryDefinition, SqlProjectionHint},
53};
54
55// Fixed seeds for deterministic hashing across process restarts.
56// These are arbitrary constants — changing them invalidates all cached entries.
57const SEED_K0: u64 = 0x5241_4953_454F_4E31; // "RAISEON1"
58const SEED_K1: u64 = 0x4652_4149_5345_514C; // "FRAISEQL"
59const SEED_K2: u64 = 0x4341_4348_454B_4559; // "CACHEKEY"
60const SEED_K3: u64 = 0x5632_5F43_4143_4845; // "V2_CACHE"
61
62/// Create a new hasher from the fixed-seed `RandomState`.
63fn new_hasher() -> impl Hasher {
64    RandomState::with_seeds(SEED_K0, SEED_K1, SEED_K2, SEED_K3).build_hasher()
65}
66
67/// Generate cache key for query result.
68///
69/// # Security Critical
70///
71/// **DIFFERENT VARIABLE VALUES MUST PRODUCE DIFFERENT KEYS** to prevent data
72/// leakage between users. This function feeds the full query, variables, WHERE
73/// clause, and schema version into a single-pass ahash for a fast, deterministic
74/// `u64` key.
75///
76/// # Key Composition
77///
78/// The cache key is a single ahash pass over:
79/// ```text
80/// ahash(
81///   query_bytes          +
82///   hash(variables)      +   ← recursive, canonical key ordering
83///   hash(WHERE_clause)   +   ← structural, not serde-dependent
84///   schema_version_bytes
85/// )
86/// ```
87///
88/// This ensures:
89/// - Same query + variables = same key (cache hit)
90/// - Different variables = different key (security)
91/// - Different WHERE clauses = different key (correctness)
92/// - Schema changes = different key (validity)
93///
94/// # Arguments
95///
96/// * `query` - GraphQL query string
97/// * `variables` - Query variables from GraphQL request (optional)
98/// * `where_clause` - WHERE filter from auto-params (optional)
99/// * `schema_version` - Schema hash from `CompiledSchema`
100///
101/// # Returns
102///
103/// A `u64` cache key suitable for use as a hash-map key.
104///
105/// # Security Examples
106///
107/// ```rust
108/// use fraiseql_core::cache::generate_cache_key;
109/// use serde_json::json;
110///
111/// let query = "query getUser($id: ID!) { user(id: $id) { name } }";
112///
113/// // Different users MUST get different cache keys
114/// let alice_key = generate_cache_key(query, &json!({"id": "alice"}), None, "v1");
115/// let bob_key = generate_cache_key(query, &json!({"id": "bob"}), None, "v1");
116/// assert_ne!(alice_key, bob_key, "Security: different variables must produce different keys");
117///
118/// // Same user MUST get same key (determinism)
119/// let alice_key2 = generate_cache_key(query, &json!({"id": "alice"}), None, "v1");
120/// assert_eq!(alice_key, alice_key2, "Determinism: same inputs must produce same key");
121/// ```
122#[must_use]
123pub fn generate_cache_key(
124    query: &str,
125    variables: &JsonValue,
126    where_clause: Option<&WhereClause>,
127    schema_version: &str,
128) -> u64 {
129    let mut h = new_hasher();
130
131    // Domain-separate the four sections with unique tags so that, e.g.,
132    // a query ending with "v1" and an empty schema_version can never
133    // collide with a shorter query and schema_version = "v1".
134    h.write(b"q:");
135    h.write(query.as_bytes());
136
137    h.write(b"\0v:");
138    hash_json_value(&mut h, variables);
139
140    h.write(b"\0w:");
141    if let Some(wc) = where_clause {
142        h.write_u8(1);
143        hash_where_clause(&mut h, wc);
144    } else {
145        h.write_u8(0);
146    }
147
148    h.write(b"\0s:");
149    h.write(schema_version.as_bytes());
150
151    h.finish()
152}
153
154/// Fast cache key for a view query — **zero heap allocations**.
155///
156/// Hashes `view + where_clause + limit + offset + schema_version` directly
157/// without constructing an intermediate `String` or `serde_json::Value`.
158/// Use this instead of [`generate_cache_key`] in the cache adapter hot path.
159///
160/// Domain tag `"v:"` separates these keys from projection keys (`"p:"`) and
161/// generic query keys (`"q:"`), preventing cross-path collisions.
162///
163/// # Arguments
164///
165/// * `view` - Database view / table name
166/// * `where_clause` - Optional WHERE filter (e.g. from RLS injection)
167/// * `limit` - Optional row limit
168/// * `offset` - Optional row offset
169/// * `schema_version` - Schema hash from `CompiledSchema::content_hash()`
170#[must_use]
171pub fn generate_view_query_key(
172    view: &str,
173    where_clause: Option<&WhereClause>,
174    limit: Option<u32>,
175    offset: Option<u32>,
176    order_by: Option<&[OrderByClause]>,
177    schema_version: &str,
178) -> u64 {
179    let mut h = new_hasher();
180    h.write(b"v:");
181    h.write(view.as_bytes());
182    h.write(b"\0w:");
183    if let Some(wc) = where_clause {
184        h.write_u8(1);
185        hash_where_clause(&mut h, wc);
186    } else {
187        h.write_u8(0);
188    }
189    h.write(b"\0l:");
190    match limit {
191        Some(l) => {
192            h.write_u8(1);
193            h.write_u32(l);
194        },
195        None => h.write_u8(0),
196    }
197    h.write(b"\0o:");
198    match offset {
199        Some(o) => {
200            h.write_u8(1);
201            h.write_u32(o);
202        },
203        None => h.write_u8(0),
204    }
205    h.write(b"\0b:");
206    hash_order_by(&mut h, order_by);
207    h.write(b"\0s:");
208    h.write(schema_version.as_bytes());
209    h.finish()
210}
211
212/// Fast cache key for a projection query — **zero heap allocations**.
213///
214/// Like [`generate_view_query_key`] but also hashes the projection template.
215/// Domain tag `"p:"` separates these keys from plain view keys.
216///
217/// # Arguments
218///
219/// * `view` - Database view / table name
220/// * `projection` - Optional SQL projection hint (column subset)
221/// * `where_clause` - Optional WHERE filter
222/// * `limit` - Optional row limit
223/// * `offset` - Optional row offset
224/// * `schema_version` - Schema hash from `CompiledSchema::content_hash()`
225#[must_use]
226pub fn generate_projection_query_key(
227    view: &str,
228    projection: Option<&SqlProjectionHint>,
229    where_clause: Option<&WhereClause>,
230    limit: Option<u32>,
231    offset: Option<u32>,
232    order_by: Option<&[OrderByClause]>,
233    schema_version: &str,
234) -> u64 {
235    let mut h = new_hasher();
236    h.write(b"p:");
237    h.write(view.as_bytes());
238    h.write(b"\0j:");
239    match projection {
240        Some(p) => {
241            h.write_u8(1);
242            h.write(p.projection_template.as_bytes());
243        },
244        None => h.write_u8(0),
245    }
246    h.write(b"\0w:");
247    if let Some(wc) = where_clause {
248        h.write_u8(1);
249        hash_where_clause(&mut h, wc);
250    } else {
251        h.write_u8(0);
252    }
253    h.write(b"\0l:");
254    match limit {
255        Some(l) => {
256            h.write_u8(1);
257            h.write_u32(l);
258        },
259        None => h.write_u8(0),
260    }
261    h.write(b"\0o:");
262    match offset {
263        Some(o) => {
264            h.write_u8(1);
265            h.write_u32(o);
266        },
267        None => h.write_u8(0),
268    }
269    h.write(b"\0b:");
270    hash_order_by(&mut h, order_by);
271    h.write(b"\0s:");
272    h.write(schema_version.as_bytes());
273    h.finish()
274}
275
276/// Cache key for a **projected GraphQL response**.
277///
278/// The response cache stores the final envelope — the exact fields, in the exact
279/// shape, under the exact response keys the document asked for. Its key must
280/// therefore cover the whole operation, not just the parts that reach SQL:
281///
282/// - the compiled query name, so two queries never share a slot;
283/// - the operation name, when the document supplies one;
284/// - the **full selection tree** — every field's name, alias, arguments and directives,
285///   recursively;
286/// - the request variables, hashed canonically.
287///
288/// # Why the whole tree (#760)
289///
290/// The previous derivation hashed `QueryMatch::fields`, the *top-level* selection
291/// names. For `{ users { id } }` that is `["users"]`: the sub-selection never
292/// reached the hash, so `{ users { id } }` and `{ users { id name email } }`
293/// mapped to one entry and whichever ran first decided the shape the other
294/// client received. Aliases were invisible for the same reason — `fields` holds
295/// the field *name*, so `{ people: users { id } }` replayed the envelope keyed
296/// under `users` and answered nothing at all under `people`.
297///
298/// Domain tag `"r:"` separates these from view (`"v:"`), projection (`"p:"`) and
299/// generic query (`"q:"`) keys.
300#[must_use]
301#[allow(clippy::implicit_hasher)]
302// Reason: called with the executor's std HashMap; a generic S would leak into
303// every caller for no benefit (house pattern, see `value_json::resolve_variables`).
304pub fn generate_response_cache_key(
305    query_name: &str,
306    operation_name: Option<&str>,
307    selections: &[crate::graphql::FieldSelection],
308    variables: &std::collections::HashMap<String, JsonValue>,
309) -> u64 {
310    let mut h = new_hasher();
311    h.write(b"r:");
312    h.write(query_name.as_bytes());
313
314    h.write(b"\0n:");
315    match operation_name {
316        Some(name) => {
317            h.write_u8(1);
318            h.write(name.as_bytes());
319        },
320        None => h.write_u8(0),
321    }
322
323    h.write(b"\0f:");
324    hash_selections(&mut h, selections);
325
326    h.write(b"\0v:");
327    h.write_usize(variables.len());
328    let mut keys: Vec<&String> = variables.keys().collect();
329    keys.sort_unstable();
330    for key in keys {
331        h.write(key.as_bytes());
332        h.write_u8(0); // separator
333        hash_json_value(&mut h, &variables[key]);
334    }
335
336    h.finish()
337}
338
339/// Hash a selection set: every field's response key, name, arguments,
340/// directives and children, recursively.
341///
342/// Order is significant — the response echoes the document's field order — so
343/// the slice is hashed as written, not sorted.
344fn hash_selections(h: &mut impl Hasher, selections: &[crate::graphql::FieldSelection]) {
345    h.write_usize(selections.len());
346    for field in selections {
347        h.write(b"|");
348        h.write(field.name.as_bytes());
349        h.write(b"@");
350        h.write(field.response_key().as_bytes());
351        h.write(b"(");
352        hash_graphql_arguments(h, &field.arguments);
353        h.write(b")d:");
354        h.write_usize(field.directives.len());
355        for directive in &field.directives {
356            h.write(directive.name.as_bytes());
357            h.write(b"(");
358            hash_graphql_arguments(h, &directive.arguments);
359            h.write(b")");
360        }
361        h.write(b"{");
362        hash_selections(h, &field.nested_fields);
363        h.write(b"}");
364    }
365}
366
367/// Hash literal GraphQL arguments in document order.
368fn hash_graphql_arguments(h: &mut impl Hasher, arguments: &[crate::graphql::GraphQLArgument]) {
369    h.write_usize(arguments.len());
370    for arg in arguments {
371        h.write(arg.name.as_bytes());
372        h.write_u8(b'=');
373        // `value_json` is the parser's rendering of the literal, including a
374        // `$var` reference, so a variable used at a nested position is covered
375        // here and its *value* is covered by the variables section.
376        h.write(arg.value_json.as_bytes());
377        h.write_u8(0); // separator
378    }
379}
380
381/// Recursively hash a `serde_json::Value` into the given hasher.
382///
383/// Object keys are sorted before hashing so that insertion order does not
384/// affect the output (critical for variable-order independence).
385fn hash_json_value(h: &mut impl Hasher, value: &JsonValue) {
386    // Write a type discriminant so that `null`, `false`, `0`, `""`, `[]`, and `{}`
387    // all produce distinct hashes.
388    match value {
389        JsonValue::Null => h.write_u8(0),
390        JsonValue::Bool(b) => {
391            h.write_u8(1);
392            b.hash(h);
393        },
394        JsonValue::Number(n) => {
395            h.write_u8(2);
396            // Use the canonical string form so that 1.0 and 1 hash identically
397            // when serde represents them the same way.
398            h.write(n.to_string().as_bytes());
399        },
400        JsonValue::String(s) => {
401            h.write_u8(3);
402            h.write(s.as_bytes());
403        },
404        JsonValue::Array(arr) => {
405            h.write_u8(4);
406            h.write_usize(arr.len());
407            for item in arr {
408                hash_json_value(h, item);
409            }
410        },
411        JsonValue::Object(map) => {
412            h.write_u8(5);
413            h.write_usize(map.len());
414            // Sort keys for canonical ordering.
415            let mut keys: Vec<&String> = map.keys().collect();
416            keys.sort_unstable();
417            for key in keys {
418                h.write(key.as_bytes());
419                hash_json_value(h, &map[key]);
420            }
421        },
422    }
423}
424
425/// Hash a `WhereClause` tree structurally.
426///
427/// Uses discriminant tags and recursion so that structurally different clauses
428/// always produce different hash contributions.
429fn hash_where_clause(h: &mut impl Hasher, clause: &WhereClause) {
430    match clause {
431        WhereClause::Field {
432            path,
433            operator,
434            value,
435        } => {
436            h.write_u8(b'F');
437            h.write_usize(path.len());
438            for segment in path {
439                h.write(segment.as_bytes());
440                h.write_u8(0); // separator
441            }
442            hash_where_operator(h, operator);
443            hash_json_value(h, value);
444        },
445        WhereClause::And(clauses) => {
446            h.write_u8(b'A');
447            h.write_usize(clauses.len());
448            for c in clauses {
449                hash_where_clause(h, c);
450            }
451        },
452        WhereClause::Or(clauses) => {
453            h.write_u8(b'O');
454            h.write_usize(clauses.len());
455            for c in clauses {
456                hash_where_clause(h, c);
457            }
458        },
459        WhereClause::Not(inner) => {
460            h.write_u8(b'N');
461            hash_where_clause(h, inner);
462        },
463        // The declared field types are a function of the query's return type,
464        // which the key already covers, so only the annotated subtree needs
465        // hashing. The tag keeps an annotated clause distinct from a bare one.
466        WhereClause::Typed { inner, .. } => {
467            h.write_u8(b'T');
468            hash_where_clause(h, inner);
469        },
470        // The guard carries the caller's row predicate (ruling AH): two callers whose
471        // predicates differ must not share an entry, so it is hashed in full.
472        WhereClause::Guarded {
473            under,
474            guard,
475            inner,
476        } => {
477            h.write_u8(b'G');
478            h.write_usize(under.len());
479            for segment in under {
480                h.write(segment.as_bytes());
481                h.write_u8(0);
482            }
483            hash_where_clause(h, guard);
484            hash_where_clause(h, inner);
485        },
486        // Ruling AL: every part, the predicate included — it is the caller's.
487        WhereClause::KeyIn {
488            path,
489            key_type,
490            view,
491            target_key,
492            predicate,
493        } => {
494            h.write_u8(b'K');
495            for segments in [path, target_key] {
496                h.write_usize(segments.len());
497                for segment in segments {
498                    h.write(segment.as_bytes());
499                    h.write_u8(0);
500                }
501            }
502            h.write(format!("{key_type:?}").as_bytes());
503            h.write_u8(0);
504            h.write(view.as_bytes());
505            h.write_u8(0);
506            hash_where_clause(h, predicate);
507        },
508        // WhereClause is #[non_exhaustive]; unknown variants get a distinct tag
509        // plus their Debug representation as a conservative fallback.
510        _ => {
511            h.write_u8(b'?');
512            h.write(format!("{clause:?}").as_bytes());
513        },
514    }
515}
516
517/// Hash a `WhereOperator` without allocating.
518///
519/// Uses `std::mem::discriminant` for the variant tag (zero-allocation).
520/// Every variant is a unit variant, so the discriminant fully distinguishes it.
521fn hash_where_operator(h: &mut impl Hasher, op: &WhereOperator) {
522    // discriminant is a fixed-size hashable value — no allocation
523    std::mem::discriminant(op).hash(h);
524}
525
526/// Hash an optional `OrderByClause` slice into the given hasher.
527///
528/// Hashes each clause's `storage_key()` (`snake_case`) and `direction` discriminant,
529/// ensuring that different orderings produce different cache keys.
530fn hash_order_by(h: &mut impl Hasher, order_by: Option<&[OrderByClause]>) {
531    match order_by.filter(|c| !c.is_empty()) {
532        Some(clauses) => {
533            h.write_u8(1);
534            h.write_usize(clauses.len());
535            for clause in clauses {
536                let key = clause.storage_key();
537                h.write(key.as_bytes());
538                h.write_u8(clause.direction as u8);
539            }
540        },
541        None => h.write_u8(0),
542    }
543}
544
545/// Extract accessed views from query definition.
546///
547/// We track which database views/tables a query accesses for view-based
548/// cache invalidation. When a mutation modifies a view, we can invalidate
549/// all cached queries that read from that view.
550///
551/// # Current Scope
552///
553/// Currently extracts only the primary SQL source from the query definition.
554/// Does not analyze:
555/// - JOIN clauses (requires compiled SQL)
556/// - Resolver chains (requires runtime context)
557/// - Nested queries (requires query analyzer)
558///
559/// # Future Enhancements
560///
561/// - Extract views from JOIN clauses in compiled SQL
562/// - Extract views from resolver chains
563/// - Support for custom resolver view tracking
564/// - Entity-level tracking (extract IDs from results)
565///
566/// # Arguments
567///
568/// * `query_def` - The compiled query definition from schema
569///
570/// # Returns
571///
572/// List of view/table names accessed by this query
573///
574/// # Examples
575///
576/// ```rust
577/// use fraiseql_core::cache::extract_accessed_views;
578/// use fraiseql_core::schema::QueryDefinition;
579///
580/// let query_def = QueryDefinition::new("users", "User")
581///     .returning_list()
582///     .with_sql_source("v_user");
583///
584/// let views = extract_accessed_views(&query_def);
585/// assert_eq!(views, vec!["v_user"]);
586/// ```
587#[must_use]
588pub fn extract_accessed_views(query_def: &QueryDefinition) -> Vec<String> {
589    let mut views = Vec::new();
590
591    // Add primary SQL source
592    if let Some(sql_source) = &query_def.sql_source {
593        views.push(sql_source.clone());
594    }
595
596    // Add developer-declared secondary views (JOINs, nested queries, etc.)
597    // Required for correct invalidation when a query reads from multiple views.
598    views.extend(query_def.additional_views.iter().cloned());
599
600    views
601}
602
603/// Verify cache key generation is deterministic.
604///
605/// Used in testing to ensure cache hits work correctly.
606/// Same inputs must always produce the same key.
607///
608/// # Arguments
609///
610/// * `query` - GraphQL query string
611/// * `variables` - Query variables
612/// * `schema_version` - Schema version hash
613///
614/// # Returns
615///
616/// `true` if two sequential key generations produce identical keys
617#[cfg(test)]
618#[must_use]
619pub fn verify_deterministic(query: &str, variables: &JsonValue, schema_version: &str) -> bool {
620    let key1 = generate_cache_key(query, variables, None, schema_version);
621    let key2 = generate_cache_key(query, variables, None, schema_version);
622    key1 == key2
623}