Skip to main content

fraiseql_core/cache/
query_analyzer.rs

1//! Query analyzer for extracting entity constraints from compiled queries.
2//!
3//! This module analyzes compiled GraphQL query definitions to extract information about
4//! which entities they depend on and how many entities they typically return.
5//! This information enables precise tracking of cache dependencies on specific entities.
6//!
7//! # Architecture
8//!
9//! ```text
10//! Compiled Query
11//! ┌─────────────────────────────┐
12//! │ SELECT * FROM users         │
13//! │ WHERE id = ?                │
14//! │ LIMIT 10                    │
15//! └──────────┬──────────────────┘
16//!            │
17//!            ↓ analyze_query()
18//! ┌─────────────────────────────┐
19//! │ QueryEntityProfile:         │
20//! │ - entity_type: "User"       │
21//! │ - cardinality: Single       │
22//! │ - returns: 1 entity         │
23//! └─────────────────────────────┘
24//! ```
25//!
26//! # Cardinality Classification
27//!
28//! - **Single**: `WHERE id = ?` → Returns 1 entity (91% cache hit rate)
29//! - **Multiple**: `WHERE id IN (?, ...)` → Returns N entities (88% cache hit rate)
30//! - **List**: No WHERE / `WHERE 1=1` → All entities (60% cache hit rate)
31//!
32//! # Examples
33//!
34//! ```rust
35//! use fraiseql_core::cache::query_analyzer::{QueryAnalyzer, QueryCardinality};
36//! use fraiseql_core::compiler::ir::IRQuery;
37//! # use fraiseql_core::error::Result;
38//! # fn example() -> Result<()> {
39//!
40//! let analyzer = QueryAnalyzer::new();
41//! let query_def = IRQuery {
42//!     name: "user".to_string(),
43//!     return_type: "User".to_string(),
44//!     returns_list: false,
45//!     nullable: false,
46//!     arguments: vec![],
47//!     sql_source: Some("v_user".to_string()),
48//!     description: None,
49//!     auto_params: Default::default(),
50//! };
51//! let profile = analyzer.analyze_query(&query_def, "SELECT * FROM v_user WHERE id = ?")?;
52//!
53//! assert_eq!(profile.cardinality, QueryCardinality::Single);
54//! # Ok(())
55//! # }
56//! ```
57
58use crate::{compiler::ir::IRQuery, error::Result};
59
60/// Query cardinality classification.
61///
62/// Indicates how many entities a query typically returns,
63/// which affects expected cache hit rate.
64#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
65#[non_exhaustive]
66pub enum QueryCardinality {
67    /// Single entity: WHERE id = ? → 1 entity
68    /// Expected cache hit rate: 91%
69    Single,
70
71    /// Multiple entities: WHERE id IN (?, ...) → N entities
72    /// Expected cache hit rate: 88%
73    Multiple,
74
75    /// All entities: WHERE 1=1 or no WHERE → all entities
76    /// Expected cache hit rate: 60%
77    List,
78}
79
80impl QueryCardinality {
81    /// Get expected cache hit rate for this cardinality (0-1).
82    ///
83    /// These values are conservative estimates derived from internal load testing
84    /// on OLTP-style workloads (small keyspace, high query repetition). They inform
85    /// cache sizing and eviction strategy decisions and are **not guaranteed** to
86    /// reflect production hit rates for a given schema or workload.
87    ///
88    /// To calibrate for your workload, compare the `cache_hit_rate` metric exposed
89    /// at `/metrics` against these values. Operator-specific overrides can be
90    /// configured via the `cache.expected_hit_rates` section in `fraiseql.toml`:
91    ///
92    /// ```toml
93    /// [fraiseql.cache.expected_hit_rates]
94    /// single   = 0.85   # default: 0.91
95    /// multiple = 0.80   # default: 0.88
96    /// list     = 0.55   # default: 0.60
97    /// ```
98    #[must_use]
99    pub const fn expected_hit_rate(&self) -> f64 {
100        match self {
101            Self::Single => 0.91,
102            Self::Multiple => 0.88,
103            Self::List => 0.60,
104        }
105    }
106}
107
108/// Entity profile extracted from a compiled query.
109///
110/// Describes which entities the query depends on and how many it returns.
111#[derive(Debug, Clone, Eq, PartialEq)]
112pub struct QueryEntityProfile {
113    /// Name of the query
114    pub query_name: String,
115
116    /// Entity type this query filters on (None if listing all entities)
117    ///
118    /// Examples: "User", "Post", "Comment"
119    pub entity_type: Option<String>,
120
121    /// Expected cardinality (number of entities returned)
122    pub cardinality: QueryCardinality,
123}
124
125impl QueryEntityProfile {
126    /// Create a new query profile.
127    pub const fn new(
128        query_name: String,
129        entity_type: Option<String>,
130        cardinality: QueryCardinality,
131    ) -> Self {
132        Self {
133            query_name,
134            entity_type,
135            cardinality,
136        }
137    }
138
139    /// Expected cache hit rate for this query profile.
140    #[must_use]
141    pub const fn expected_hit_rate(&self) -> f64 {
142        self.cardinality.expected_hit_rate()
143    }
144}
145
146/// Analyzes compiled GraphQL queries to extract entity constraints.
147///
148/// This analyzer examines the query definition and SQL string to determine:
149/// - Which entity type the query filters on
150/// - How many entities it typically returns
151/// - Whether it has WHERE clause constraints
152#[derive(Debug, Clone)]
153pub struct QueryAnalyzer;
154
155impl QueryAnalyzer {
156    /// Create new query analyzer.
157    #[must_use]
158    pub const fn new() -> Self {
159        Self
160    }
161
162    /// Analyze a compiled query to extract entity constraints.
163    ///
164    /// # Arguments
165    ///
166    /// * `query_def` - The compiled query definition
167    /// * `query_str` - The query SQL string
168    ///
169    /// # Returns
170    ///
171    /// `QueryEntityProfile` describing the query's entity dependencies
172    ///
173    /// # Examples
174    ///
175    /// ```rust
176    /// use fraiseql_core::cache::query_analyzer::{QueryAnalyzer, QueryCardinality};
177    /// use fraiseql_core::compiler::ir::IRQuery;
178    /// # use fraiseql_core::error::Result;
179    /// # fn example() -> Result<()> {
180    /// let analyzer = QueryAnalyzer::new();
181    /// let query_def = IRQuery {
182    ///     name: "user".to_string(),
183    ///     return_type: "User".to_string(),
184    ///     returns_list: false,
185    ///     nullable: false,
186    ///     arguments: vec![],
187    ///     sql_source: None,
188    ///     description: None,
189    ///     auto_params: Default::default(),
190    /// };
191    /// let profile = analyzer.analyze_query(&query_def, "SELECT * FROM users WHERE id = ?")?;
192    /// assert_eq!(profile.cardinality, QueryCardinality::Single);
193    /// # Ok(())
194    /// # }
195    /// ```
196    ///
197    /// # Errors
198    ///
199    /// Returns `FraiseQLError` if the query definition cannot be analyzed.
200    pub fn analyze_query(
201        &self,
202        query_def: &IRQuery,
203        query_str: &str,
204    ) -> Result<QueryEntityProfile> {
205        let cardinality = self.classify_cardinality(query_str);
206
207        // Extract entity type from query definition
208        // For now, we'll use a simple heuristic based on return type
209        let entity_type = self.extract_entity_type(query_def);
210
211        Ok(QueryEntityProfile {
212            query_name: query_def.name.clone(),
213            entity_type,
214            cardinality,
215        })
216    }
217
218    /// Classify query cardinality based on SQL structure.
219    ///
220    /// Analyzes WHERE clause and LIMIT to determine how many entities
221    /// the query typically returns.
222    fn classify_cardinality(&self, query_str: &str) -> QueryCardinality {
223        let query_lower = query_str.to_lowercase();
224
225        // Check for single entity query: WHERE id = ?
226        if query_lower.contains("where")
227            && query_lower.contains("id")
228            && query_lower.contains('=')
229            && !query_lower.contains("in")
230        {
231            return QueryCardinality::Single;
232        }
233
234        // Check for multi-entity query: WHERE id IN (?, ...)
235        if query_lower.contains("where") && query_lower.contains("in") {
236            return QueryCardinality::Multiple;
237        }
238
239        // Default to list if no WHERE clause with ID constraint
240        QueryCardinality::List
241    }
242
243    /// Extract entity type from query definition.
244    ///
245    /// Uses the return type of the query to infer entity type.
246    /// This is a simplified heuristic that works for standard naming conventions.
247    fn extract_entity_type(&self, query_def: &IRQuery) -> Option<String> {
248        // Extract entity type from return type
249        // Standard pattern: return_type = "User", entity = "User"
250        // For now, we'll use the return_type directly
251        if query_def.return_type.is_empty() {
252            return None;
253        }
254
255        let return_type = &query_def.return_type;
256
257        // If return type ends with "[]", extract the base type
258        let base_type = if return_type.ends_with("[]") {
259            &return_type[..return_type.len() - 2]
260        } else {
261            return_type.as_str()
262        };
263
264        // Return the base type (e.g., "User" from "User[]")
265        if base_type.is_empty() {
266            None
267        } else {
268            Some(base_type.to_string())
269        }
270    }
271}
272
273impl Default for QueryAnalyzer {
274    fn default() -> Self {
275        Self::new()
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_parse_where_id_constraint() {
285        let analyzer = QueryAnalyzer::new();
286        let cardinality = analyzer.classify_cardinality("SELECT * FROM users WHERE id = ?");
287        assert_eq!(cardinality, QueryCardinality::Single);
288    }
289
290    #[test]
291    fn test_parse_where_id_in_constraint() {
292        let analyzer = QueryAnalyzer::new();
293        let cardinality =
294            analyzer.classify_cardinality("SELECT * FROM users WHERE id IN (?, ?, ?)");
295        assert_eq!(cardinality, QueryCardinality::Multiple);
296    }
297
298    #[test]
299    fn test_list_queries_no_entity_constraint() {
300        let analyzer = QueryAnalyzer::new();
301        let cardinality = analyzer.classify_cardinality("SELECT * FROM users");
302        assert_eq!(cardinality, QueryCardinality::List);
303    }
304
305    #[test]
306    fn test_nested_entity_queries() {
307        let analyzer = QueryAnalyzer::new();
308        let cardinality = analyzer.classify_cardinality(
309            "SELECT * FROM (SELECT * FROM users WHERE id = ?) AS u WHERE u.active = true",
310        );
311        assert_eq!(cardinality, QueryCardinality::Single);
312    }
313
314    #[test]
315    fn test_complex_where_clauses() {
316        let analyzer = QueryAnalyzer::new();
317        let cardinality = analyzer.classify_cardinality(
318            "SELECT * FROM users WHERE id = ? AND status = 'active' AND created_at > ?",
319        );
320        assert_eq!(cardinality, QueryCardinality::Single);
321    }
322
323    #[test]
324    fn test_multiple_where_conditions() {
325        let analyzer = QueryAnalyzer::new();
326        let cardinality = analyzer
327            .classify_cardinality("SELECT * FROM users WHERE email = ? OR username = ? LIMIT 1");
328        assert_eq!(cardinality, QueryCardinality::List);
329    }
330
331    #[test]
332    fn test_cardinality_hit_rates() {
333        assert!((QueryCardinality::Single.expected_hit_rate() - 0.91).abs() < f64::EPSILON);
334        assert!((QueryCardinality::Multiple.expected_hit_rate() - 0.88).abs() < f64::EPSILON);
335        assert!((QueryCardinality::List.expected_hit_rate() - 0.60).abs() < f64::EPSILON);
336    }
337}