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    db::{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    schema_version: &str,
177) -> u64 {
178    let mut h = new_hasher();
179    h.write(b"v:");
180    h.write(view.as_bytes());
181    h.write(b"\0w:");
182    if let Some(wc) = where_clause {
183        h.write_u8(1);
184        hash_where_clause(&mut h, wc);
185    } else {
186        h.write_u8(0);
187    }
188    h.write(b"\0l:");
189    match limit {
190        Some(l) => {
191            h.write_u8(1);
192            h.write_u32(l);
193        },
194        None => h.write_u8(0),
195    }
196    h.write(b"\0o:");
197    match offset {
198        Some(o) => {
199            h.write_u8(1);
200            h.write_u32(o);
201        },
202        None => h.write_u8(0),
203    }
204    h.write(b"\0s:");
205    h.write(schema_version.as_bytes());
206    h.finish()
207}
208
209/// Fast cache key for a projection query — **zero heap allocations**.
210///
211/// Like [`generate_view_query_key`] but also hashes the projection template.
212/// Domain tag `"p:"` separates these keys from plain view keys.
213///
214/// # Arguments
215///
216/// * `view` - Database view / table name
217/// * `projection` - Optional SQL projection hint (column subset)
218/// * `where_clause` - Optional WHERE filter
219/// * `limit` - Optional row limit
220/// * `offset` - Optional row offset
221/// * `schema_version` - Schema hash from `CompiledSchema::content_hash()`
222#[must_use]
223pub fn generate_projection_query_key(
224    view: &str,
225    projection: Option<&SqlProjectionHint>,
226    where_clause: Option<&WhereClause>,
227    limit: Option<u32>,
228    offset: Option<u32>,
229    schema_version: &str,
230) -> u64 {
231    let mut h = new_hasher();
232    h.write(b"p:");
233    h.write(view.as_bytes());
234    h.write(b"\0j:");
235    match projection {
236        Some(p) => {
237            h.write_u8(1);
238            h.write(p.projection_template.as_bytes());
239        },
240        None => h.write_u8(0),
241    }
242    h.write(b"\0w:");
243    if let Some(wc) = where_clause {
244        h.write_u8(1);
245        hash_where_clause(&mut h, wc);
246    } else {
247        h.write_u8(0);
248    }
249    h.write(b"\0l:");
250    match limit {
251        Some(l) => {
252            h.write_u8(1);
253            h.write_u32(l);
254        },
255        None => h.write_u8(0),
256    }
257    h.write(b"\0o:");
258    match offset {
259        Some(o) => {
260            h.write_u8(1);
261            h.write_u32(o);
262        },
263        None => h.write_u8(0),
264    }
265    h.write(b"\0s:");
266    h.write(schema_version.as_bytes());
267    h.finish()
268}
269
270/// Recursively hash a `serde_json::Value` into the given hasher.
271///
272/// Object keys are sorted before hashing so that insertion order does not
273/// affect the output (critical for variable-order independence).
274fn hash_json_value(h: &mut impl Hasher, value: &JsonValue) {
275    // Write a type discriminant so that `null`, `false`, `0`, `""`, `[]`, and `{}`
276    // all produce distinct hashes.
277    match value {
278        JsonValue::Null => h.write_u8(0),
279        JsonValue::Bool(b) => {
280            h.write_u8(1);
281            b.hash(h);
282        },
283        JsonValue::Number(n) => {
284            h.write_u8(2);
285            // Use the canonical string form so that 1.0 and 1 hash identically
286            // when serde represents them the same way.
287            h.write(n.to_string().as_bytes());
288        },
289        JsonValue::String(s) => {
290            h.write_u8(3);
291            h.write(s.as_bytes());
292        },
293        JsonValue::Array(arr) => {
294            h.write_u8(4);
295            h.write_usize(arr.len());
296            for item in arr {
297                hash_json_value(h, item);
298            }
299        },
300        JsonValue::Object(map) => {
301            h.write_u8(5);
302            h.write_usize(map.len());
303            // Sort keys for canonical ordering.
304            let mut keys: Vec<&String> = map.keys().collect();
305            keys.sort_unstable();
306            for key in keys {
307                h.write(key.as_bytes());
308                hash_json_value(h, &map[key]);
309            }
310        },
311    }
312}
313
314/// Hash a `WhereClause` tree structurally.
315///
316/// Uses discriminant tags and recursion so that structurally different clauses
317/// always produce different hash contributions.
318fn hash_where_clause(h: &mut impl Hasher, clause: &WhereClause) {
319    match clause {
320        WhereClause::Field {
321            path,
322            operator,
323            value,
324        } => {
325            h.write_u8(b'F');
326            h.write_usize(path.len());
327            for segment in path {
328                h.write(segment.as_bytes());
329                h.write_u8(0); // separator
330            }
331            hash_where_operator(h, operator);
332            hash_json_value(h, value);
333        },
334        WhereClause::And(clauses) => {
335            h.write_u8(b'A');
336            h.write_usize(clauses.len());
337            for c in clauses {
338                hash_where_clause(h, c);
339            }
340        },
341        WhereClause::Or(clauses) => {
342            h.write_u8(b'O');
343            h.write_usize(clauses.len());
344            for c in clauses {
345                hash_where_clause(h, c);
346            }
347        },
348        WhereClause::Not(inner) => {
349            h.write_u8(b'N');
350            hash_where_clause(h, inner);
351        },
352        // WhereClause is #[non_exhaustive]; unknown variants get a distinct tag
353        // plus their Debug representation as a conservative fallback.
354        _ => {
355            h.write_u8(b'?');
356            h.write(format!("{clause:?}").as_bytes());
357        },
358    }
359}
360
361/// Hash a `WhereOperator` by its `Debug` representation.
362///
363/// `WhereOperator` is `#[non_exhaustive]` with 40+ variants (including
364/// `Extended(ExtendedOperator)`). Using the `Debug` string is stable across
365/// refactors and automatically covers new variants without maintenance.
366/// Hash a `WhereOperator` without allocating.
367///
368/// Uses `std::mem::discriminant` for the variant tag (zero-allocation).
369/// For the `Extended(op)` variant which carries data, also hashes the
370/// Debug representation of the inner operator (rare path, acceptable allocation).
371fn hash_where_operator(h: &mut impl Hasher, op: &WhereOperator) {
372    // discriminant is a fixed-size hashable value — no allocation
373    std::mem::discriminant(op).hash(h);
374
375    // Extended operators carry inner data that affects the hash.
376    // All other variants are fully distinguished by their discriminant.
377    if let WhereOperator::Extended(inner) = op {
378        // Rare path: Extended operators are uncommon. The Debug allocation
379        // here is acceptable because it only triggers for rich-filter queries.
380        let inner_str = format!("{inner:?}");
381        h.write(inner_str.as_bytes());
382    }
383}
384
385/// Extract accessed views from query definition.
386///
387/// We track which database views/tables a query accesses for view-based
388/// cache invalidation. When a mutation modifies a view, we can invalidate
389/// all cached queries that read from that view.
390///
391/// # Current Scope
392///
393/// Currently extracts only the primary SQL source from the query definition.
394/// Does not analyze:
395/// - JOIN clauses (requires compiled SQL)
396/// - Resolver chains (requires runtime context)
397/// - Nested queries (requires query analyzer)
398///
399/// # Future Enhancements
400///
401/// - Extract views from JOIN clauses in compiled SQL
402/// - Extract views from resolver chains
403/// - Support for custom resolver view tracking
404/// - Entity-level tracking (extract IDs from results)
405///
406/// # Arguments
407///
408/// * `query_def` - The compiled query definition from schema
409///
410/// # Returns
411///
412/// List of view/table names accessed by this query
413///
414/// # Examples
415///
416/// ```rust
417/// use fraiseql_core::cache::extract_accessed_views;
418/// use fraiseql_core::schema::QueryDefinition;
419///
420/// let query_def = QueryDefinition::new("users", "User")
421///     .returning_list()
422///     .with_sql_source("v_user");
423///
424/// let views = extract_accessed_views(&query_def);
425/// assert_eq!(views, vec!["v_user"]);
426/// ```
427#[must_use]
428pub fn extract_accessed_views(query_def: &QueryDefinition) -> Vec<String> {
429    let mut views = Vec::new();
430
431    // Add primary SQL source
432    if let Some(sql_source) = &query_def.sql_source {
433        views.push(sql_source.clone());
434    }
435
436    // Add developer-declared secondary views (JOINs, nested queries, etc.)
437    // Required for correct invalidation when a query reads from multiple views.
438    views.extend(query_def.additional_views.iter().cloned());
439
440    views
441}
442
443/// Verify cache key generation is deterministic.
444///
445/// Used in testing to ensure cache hits work correctly.
446/// Same inputs must always produce the same key.
447///
448/// # Arguments
449///
450/// * `query` - GraphQL query string
451/// * `variables` - Query variables
452/// * `schema_version` - Schema version hash
453///
454/// # Returns
455///
456/// `true` if two sequential key generations produce identical keys
457#[cfg(test)]
458#[must_use]
459pub fn verify_deterministic(query: &str, variables: &JsonValue, schema_version: &str) -> bool {
460    let key1 = generate_cache_key(query, variables, None, schema_version);
461    let key2 = generate_cache_key(query, variables, None, schema_version);
462    key1 == key2
463}
464
465#[cfg(test)]
466mod tests {
467    use std::collections::{HashMap, HashSet};
468
469    use indexmap::IndexMap;
470    use serde_json::json;
471
472    use super::*;
473    use crate::schema::CursorType;
474
475    // ========================================================================
476    // Security Tests (CRITICAL)
477    // ========================================================================
478
479    #[test]
480    fn test_different_variables_produce_different_keys() {
481        // SECURITY CRITICAL: Different variables MUST produce different keys
482        // to prevent User A from seeing User B's cached data
483        let query = "query getUser($id: ID!) { user(id: $id) { name email } }";
484
485        let key_alice = generate_cache_key(query, &json!({"id": "alice"}), None, "v1");
486        let key_bob = generate_cache_key(query, &json!({"id": "bob"}), None, "v1");
487
488        assert_ne!(
489            key_alice, key_bob,
490            "SECURITY: Different variables MUST produce different cache keys"
491        );
492    }
493
494    #[test]
495    fn test_different_variable_values_produce_different_keys() {
496        let query = "query getUsers($limit: Int!) { users(limit: $limit) { id } }";
497
498        let key_10 = generate_cache_key(query, &json!({"limit": 10}), None, "v1");
499        let key_20 = generate_cache_key(query, &json!({"limit": 20}), None, "v1");
500
501        assert_ne!(
502            key_10, key_20,
503            "SECURITY: Different variable values MUST produce different keys"
504        );
505    }
506
507    #[test]
508    fn test_empty_vs_non_empty_variables() {
509        let query = "query { users { id } }";
510
511        let key_empty = generate_cache_key(query, &json!({}), None, "v1");
512        let key_with_vars = generate_cache_key(query, &json!({"limit": 10}), None, "v1");
513
514        assert_ne!(
515            key_empty, key_with_vars,
516            "Empty variables must produce different key than non-empty"
517        );
518    }
519
520    #[test]
521    fn test_variable_order_independence() {
522        // Object keys are sorted before hashing, so insertion order should
523        // not affect the result. serde_json's default Map is BTreeMap (sorted),
524        // but we sort explicitly in hash_json_value to be safe regardless.
525        let query = "query($a: Int, $b: Int) { users { id } }";
526
527        let key1 = generate_cache_key(query, &json!({"a": 1, "b": 2}), None, "v1");
528        let key2 = generate_cache_key(query, &json!({"a": 1, "b": 2}), None, "v1");
529
530        assert_eq!(key1, key2, "Same variables must produce same key");
531    }
532
533    // ========================================================================
534    // Determinism Tests
535    // ========================================================================
536
537    #[test]
538    fn test_cache_key_deterministic() {
539        // Same inputs must always produce same output
540        let query = "query { users { id } }";
541        let vars = json!({"limit": 10});
542
543        let key1 = generate_cache_key(query, &vars, None, "v1");
544        let key2 = generate_cache_key(query, &vars, None, "v1");
545
546        assert_eq!(key1, key2, "Cache keys must be deterministic");
547    }
548
549    #[test]
550    fn test_verify_deterministic_helper() {
551        assert!(
552            verify_deterministic("query { users }", &json!({}), "v1"),
553            "Helper should verify determinism"
554        );
555    }
556
557    // ========================================================================
558    // WHERE Clause Tests
559    // ========================================================================
560
561    #[test]
562    fn test_different_where_clauses_produce_different_keys() {
563        let query = "query { users { id } }";
564
565        let where1 = WhereClause::Field {
566            path:     vec!["email".to_string()],
567            operator: WhereOperator::Eq,
568            value:    json!("alice@example.com"),
569        };
570
571        let where2 = WhereClause::Field {
572            path:     vec!["email".to_string()],
573            operator: WhereOperator::Eq,
574            value:    json!("bob@example.com"),
575        };
576
577        let key1 = generate_cache_key(query, &json!({}), Some(&where1), "v1");
578        let key2 = generate_cache_key(query, &json!({}), Some(&where2), "v1");
579
580        assert_ne!(key1, key2, "Different WHERE clauses must produce different keys");
581    }
582
583    #[test]
584    fn test_different_where_operators_produce_different_keys() {
585        let query = "query { users { id } }";
586
587        let where_eq = WhereClause::Field {
588            path:     vec!["age".to_string()],
589            operator: WhereOperator::Eq,
590            value:    json!(30),
591        };
592
593        let where_gt = WhereClause::Field {
594            path:     vec!["age".to_string()],
595            operator: WhereOperator::Gt,
596            value:    json!(30),
597        };
598
599        let key_eq = generate_cache_key(query, &json!({}), Some(&where_eq), "v1");
600        let key_gt = generate_cache_key(query, &json!({}), Some(&where_gt), "v1");
601
602        assert_ne!(key_eq, key_gt, "Different operators must produce different keys");
603    }
604
605    #[test]
606    fn test_with_and_without_where_clause() {
607        let query = "query { users { id } }";
608
609        let where_clause = WhereClause::Field {
610            path:     vec!["active".to_string()],
611            operator: WhereOperator::Eq,
612            value:    json!(true),
613        };
614
615        let key_without = generate_cache_key(query, &json!({}), None, "v1");
616        let key_with = generate_cache_key(query, &json!({}), Some(&where_clause), "v1");
617
618        assert_ne!(key_without, key_with, "Presence of WHERE clause must change key");
619    }
620
621    #[test]
622    fn test_complex_where_clause() {
623        let query = "query { users { id } }";
624
625        let where_clause = WhereClause::And(vec![
626            WhereClause::Field {
627                path:     vec!["age".to_string()],
628                operator: WhereOperator::Gte,
629                value:    json!(18),
630            },
631            WhereClause::Field {
632                path:     vec!["active".to_string()],
633                operator: WhereOperator::Eq,
634                value:    json!(true),
635            },
636        ]);
637
638        // Should not panic; produces a valid u64.
639        let _key = generate_cache_key(query, &json!({}), Some(&where_clause), "v1");
640    }
641
642    // ========================================================================
643    // Schema Version Tests
644    // ========================================================================
645
646    #[test]
647    fn test_different_schema_versions_produce_different_keys() {
648        let query = "query { users { id } }";
649
650        let key_v1 = generate_cache_key(query, &json!({}), None, "v1");
651        let key_v2 = generate_cache_key(query, &json!({}), None, "v2");
652
653        assert_ne!(key_v1, key_v2, "Different schema versions must produce different keys");
654    }
655
656    #[test]
657    fn test_schema_version_invalidation() {
658        // When schema changes, all cache keys change (automatic invalidation)
659        let query = "query { users { id } }";
660
661        let old_schema = "abc123";
662        let new_schema = "def456";
663
664        let key_old = generate_cache_key(query, &json!({}), None, old_schema);
665        let key_new = generate_cache_key(query, &json!({}), None, new_schema);
666
667        assert_ne!(key_old, key_new, "Schema changes should invalidate cache");
668    }
669
670    // ========================================================================
671    // Collision Avoidance Test
672    // ========================================================================
673
674    #[test]
675    fn test_no_collisions_in_sample() {
676        // Generate a sample of cache keys from varied inputs and verify
677        // that no two distinct inputs produce the same u64.
678        let mut keys = HashSet::new();
679        let mut count = 0u32;
680
681        let queries = [
682            "query { users { id } }",
683            "query { posts { id } }",
684            "query { users { id name } }",
685            "query getUser($id: ID!) { user(id: $id) { name } }",
686            "",
687        ];
688        let variable_sets: &[JsonValue] = &[
689            json!({}),
690            json!(null),
691            json!({"id": 1}),
692            json!({"id": 2}),
693            json!({"id": "alice"}),
694            json!({"limit": 10, "offset": 0}),
695            json!({"filter": {"active": true}}),
696        ];
697        let schema_versions = ["v1", "v2", "abc123"];
698
699        for query in &queries {
700            for vars in variable_sets {
701                for sv in &schema_versions {
702                    let key = generate_cache_key(query, vars, None, sv);
703                    keys.insert(key);
704                    count += 1;
705                }
706            }
707        }
708
709        assert_eq!(
710            keys.len(),
711            count as usize,
712            "Collision detected among {count} sample cache keys"
713        );
714    }
715
716    // ========================================================================
717    // Extract Views Tests
718    // ========================================================================
719
720    #[test]
721    fn test_extract_accessed_views_with_sql_source() {
722        use crate::schema::AutoParams;
723
724        let query_def = QueryDefinition {
725            name:                "users".to_string(),
726            return_type:         "User".to_string(),
727            returns_list:        true,
728            nullable:            false,
729            arguments:           vec![],
730            sql_source:          Some("v_user".to_string()),
731            description:         None,
732            auto_params:         AutoParams {
733                has_where:    true,
734                has_order_by: false,
735                has_limit:    true,
736                has_offset:   false,
737            },
738            deprecation:         None,
739            jsonb_column:        "data".to_string(),
740            relay:               false,
741            relay_cursor_column: None,
742            relay_cursor_type:   CursorType::default(),
743            inject_params:       IndexMap::default(),
744            cache_ttl_seconds:   None,
745            additional_views:    vec![],
746            requires_role:       None,
747            rest_path:           None,
748            rest_method:         None,
749            native_columns:      HashMap::new(),
750        };
751
752        let views = extract_accessed_views(&query_def);
753        assert_eq!(views, vec!["v_user"]);
754    }
755
756    #[test]
757    fn test_extract_accessed_views_without_sql_source() {
758        use crate::schema::AutoParams;
759
760        let query_def = QueryDefinition {
761            name:                "customQuery".to_string(),
762            return_type:         "Custom".to_string(),
763            returns_list:        false,
764            nullable:            false,
765            arguments:           vec![],
766            sql_source:          None, // No SQL source (custom resolver)
767            description:         None,
768            auto_params:         AutoParams {
769                has_where:    false,
770                has_order_by: false,
771                has_limit:    false,
772                has_offset:   false,
773            },
774            deprecation:         None,
775            jsonb_column:        "data".to_string(),
776            relay:               false,
777            relay_cursor_column: None,
778            relay_cursor_type:   CursorType::default(),
779            inject_params:       IndexMap::default(),
780            cache_ttl_seconds:   None,
781            additional_views:    vec![],
782            requires_role:       None,
783            rest_path:           None,
784            rest_method:         None,
785            native_columns:      HashMap::new(),
786        };
787
788        let views = extract_accessed_views(&query_def);
789        assert_eq!(views, Vec::<String>::new());
790    }
791
792    #[test]
793    fn test_extract_accessed_views_with_additional_views() {
794        use crate::schema::AutoParams;
795
796        let query_def = QueryDefinition {
797            name:                "usersWithPosts".to_string(),
798            return_type:         "UserWithPosts".to_string(),
799            returns_list:        true,
800            nullable:            false,
801            arguments:           vec![],
802            sql_source:          Some("v_user_with_posts".to_string()),
803            description:         None,
804            auto_params:         AutoParams::default(),
805            deprecation:         None,
806            jsonb_column:        "data".to_string(),
807            relay:               false,
808            relay_cursor_column: None,
809            relay_cursor_type:   CursorType::default(),
810            inject_params:       IndexMap::default(),
811            cache_ttl_seconds:   None,
812            additional_views:    vec!["v_post".to_string(), "v_tag".to_string()],
813            requires_role:       None,
814            rest_path:           None,
815            rest_method:         None,
816            native_columns:      HashMap::new(),
817        };
818
819        let views = extract_accessed_views(&query_def);
820        assert_eq!(views, vec!["v_user_with_posts", "v_post", "v_tag"]);
821    }
822
823    // ========================================================================
824    // Edge Case Tests
825    // ========================================================================
826
827    #[test]
828    fn test_empty_query_string() {
829        // Should not panic; produces a valid u64.
830        let _key = generate_cache_key("", &json!({}), None, "v1");
831    }
832
833    #[test]
834    fn test_null_variables() {
835        // Should not panic; produces a valid u64.
836        let _key = generate_cache_key("query { users }", &json!(null), None, "v1");
837    }
838
839    #[test]
840    fn test_large_variable_object() {
841        let large_vars = json!({
842            "filter": {
843                "age": 30,
844                "active": true,
845                "tags": ["rust", "graphql", "database"],
846                "metadata": {
847                    "created_after": "2024-01-01",
848                    "updated_before": "2024-12-31"
849                }
850            }
851        });
852
853        // Should not panic; produces a valid u64.
854        let _key = generate_cache_key("query { users }", &large_vars, None, "v1");
855    }
856
857    #[test]
858    fn test_special_characters_in_query() {
859        let query = r#"query { user(email: "test@example.com") { name } }"#;
860        // Should not panic; produces a valid u64.
861        let _key = generate_cache_key(query, &json!({}), None, "v1");
862    }
863}