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 db::{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/// Recursively hash a `serde_json::Value` into the given hasher.
277///
278/// Object keys are sorted before hashing so that insertion order does not
279/// affect the output (critical for variable-order independence).
280fn hash_json_value(h: &mut impl Hasher, value: &JsonValue) {
281 // Write a type discriminant so that `null`, `false`, `0`, `""`, `[]`, and `{}`
282 // all produce distinct hashes.
283 match value {
284 JsonValue::Null => h.write_u8(0),
285 JsonValue::Bool(b) => {
286 h.write_u8(1);
287 b.hash(h);
288 },
289 JsonValue::Number(n) => {
290 h.write_u8(2);
291 // Use the canonical string form so that 1.0 and 1 hash identically
292 // when serde represents them the same way.
293 h.write(n.to_string().as_bytes());
294 },
295 JsonValue::String(s) => {
296 h.write_u8(3);
297 h.write(s.as_bytes());
298 },
299 JsonValue::Array(arr) => {
300 h.write_u8(4);
301 h.write_usize(arr.len());
302 for item in arr {
303 hash_json_value(h, item);
304 }
305 },
306 JsonValue::Object(map) => {
307 h.write_u8(5);
308 h.write_usize(map.len());
309 // Sort keys for canonical ordering.
310 let mut keys: Vec<&String> = map.keys().collect();
311 keys.sort_unstable();
312 for key in keys {
313 h.write(key.as_bytes());
314 hash_json_value(h, &map[key]);
315 }
316 },
317 }
318}
319
320/// Hash a `WhereClause` tree structurally.
321///
322/// Uses discriminant tags and recursion so that structurally different clauses
323/// always produce different hash contributions.
324fn hash_where_clause(h: &mut impl Hasher, clause: &WhereClause) {
325 match clause {
326 WhereClause::Field {
327 path,
328 operator,
329 value,
330 } => {
331 h.write_u8(b'F');
332 h.write_usize(path.len());
333 for segment in path {
334 h.write(segment.as_bytes());
335 h.write_u8(0); // separator
336 }
337 hash_where_operator(h, operator);
338 hash_json_value(h, value);
339 },
340 WhereClause::And(clauses) => {
341 h.write_u8(b'A');
342 h.write_usize(clauses.len());
343 for c in clauses {
344 hash_where_clause(h, c);
345 }
346 },
347 WhereClause::Or(clauses) => {
348 h.write_u8(b'O');
349 h.write_usize(clauses.len());
350 for c in clauses {
351 hash_where_clause(h, c);
352 }
353 },
354 WhereClause::Not(inner) => {
355 h.write_u8(b'N');
356 hash_where_clause(h, inner);
357 },
358 // WhereClause is #[non_exhaustive]; unknown variants get a distinct tag
359 // plus their Debug representation as a conservative fallback.
360 _ => {
361 h.write_u8(b'?');
362 h.write(format!("{clause:?}").as_bytes());
363 },
364 }
365}
366
367/// Hash a `WhereOperator` by its `Debug` representation.
368///
369/// `WhereOperator` is `#[non_exhaustive]` with 40+ variants (including
370/// `Extended(ExtendedOperator)`). Using the `Debug` string is stable across
371/// refactors and automatically covers new variants without maintenance.
372/// Hash a `WhereOperator` without allocating.
373///
374/// Uses `std::mem::discriminant` for the variant tag (zero-allocation).
375/// For the `Extended(op)` variant which carries data, also hashes the
376/// Debug representation of the inner operator (rare path, acceptable allocation).
377fn hash_where_operator(h: &mut impl Hasher, op: &WhereOperator) {
378 // discriminant is a fixed-size hashable value — no allocation
379 std::mem::discriminant(op).hash(h);
380
381 // Extended operators carry inner data that affects the hash.
382 // All other variants are fully distinguished by their discriminant.
383 if let WhereOperator::Extended(inner) = op {
384 // Rare path: Extended operators are uncommon. The Debug allocation
385 // here is acceptable because it only triggers for rich-filter queries.
386 let inner_str = format!("{inner:?}");
387 h.write(inner_str.as_bytes());
388 }
389}
390
391/// Hash an optional `OrderByClause` slice into the given hasher.
392///
393/// Hashes each clause's `storage_key()` (`snake_case`) and `direction` discriminant,
394/// ensuring that different orderings produce different cache keys.
395fn hash_order_by(h: &mut impl Hasher, order_by: Option<&[OrderByClause]>) {
396 match order_by.filter(|c| !c.is_empty()) {
397 Some(clauses) => {
398 h.write_u8(1);
399 h.write_usize(clauses.len());
400 for clause in clauses {
401 let key = clause.storage_key();
402 h.write(key.as_bytes());
403 h.write_u8(clause.direction as u8);
404 }
405 },
406 None => h.write_u8(0),
407 }
408}
409
410/// Extract accessed views from query definition.
411///
412/// We track which database views/tables a query accesses for view-based
413/// cache invalidation. When a mutation modifies a view, we can invalidate
414/// all cached queries that read from that view.
415///
416/// # Current Scope
417///
418/// Currently extracts only the primary SQL source from the query definition.
419/// Does not analyze:
420/// - JOIN clauses (requires compiled SQL)
421/// - Resolver chains (requires runtime context)
422/// - Nested queries (requires query analyzer)
423///
424/// # Future Enhancements
425///
426/// - Extract views from JOIN clauses in compiled SQL
427/// - Extract views from resolver chains
428/// - Support for custom resolver view tracking
429/// - Entity-level tracking (extract IDs from results)
430///
431/// # Arguments
432///
433/// * `query_def` - The compiled query definition from schema
434///
435/// # Returns
436///
437/// List of view/table names accessed by this query
438///
439/// # Examples
440///
441/// ```rust
442/// use fraiseql_core::cache::extract_accessed_views;
443/// use fraiseql_core::schema::QueryDefinition;
444///
445/// let query_def = QueryDefinition::new("users", "User")
446/// .returning_list()
447/// .with_sql_source("v_user");
448///
449/// let views = extract_accessed_views(&query_def);
450/// assert_eq!(views, vec!["v_user"]);
451/// ```
452#[must_use]
453pub fn extract_accessed_views(query_def: &QueryDefinition) -> Vec<String> {
454 let mut views = Vec::new();
455
456 // Add primary SQL source
457 if let Some(sql_source) = &query_def.sql_source {
458 views.push(sql_source.clone());
459 }
460
461 // Add developer-declared secondary views (JOINs, nested queries, etc.)
462 // Required for correct invalidation when a query reads from multiple views.
463 views.extend(query_def.additional_views.iter().cloned());
464
465 views
466}
467
468/// Verify cache key generation is deterministic.
469///
470/// Used in testing to ensure cache hits work correctly.
471/// Same inputs must always produce the same key.
472///
473/// # Arguments
474///
475/// * `query` - GraphQL query string
476/// * `variables` - Query variables
477/// * `schema_version` - Schema version hash
478///
479/// # Returns
480///
481/// `true` if two sequential key generations produce identical keys
482#[cfg(test)]
483#[must_use]
484pub fn verify_deterministic(query: &str, variables: &JsonValue, schema_version: &str) -> bool {
485 let key1 = generate_cache_key(query, variables, None, schema_version);
486 let key2 = generate_cache_key(query, variables, None, schema_version);
487 key1 == key2
488}