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 #[must_use]
128 pub const fn new(
129 query_name: String,
130 entity_type: Option<String>,
131 cardinality: QueryCardinality,
132 ) -> Self {
133 Self {
134 query_name,
135 entity_type,
136 cardinality,
137 }
138 }
139
140 /// Expected cache hit rate for this query profile.
141 #[must_use]
142 pub const fn expected_hit_rate(&self) -> f64 {
143 self.cardinality.expected_hit_rate()
144 }
145}
146
147/// Analyzes compiled GraphQL queries to extract entity constraints.
148///
149/// This analyzer examines the query definition and SQL string to determine:
150/// - Which entity type the query filters on
151/// - How many entities it typically returns
152/// - Whether it has WHERE clause constraints
153#[derive(Debug, Clone)]
154pub struct QueryAnalyzer;
155
156impl QueryAnalyzer {
157 /// Create new query analyzer.
158 #[must_use]
159 pub const fn new() -> Self {
160 Self
161 }
162
163 /// Analyze a compiled query to extract entity constraints.
164 ///
165 /// # Arguments
166 ///
167 /// * `query_def` - The compiled query definition
168 /// * `query_str` - The query SQL string
169 ///
170 /// # Returns
171 ///
172 /// `QueryEntityProfile` describing the query's entity dependencies
173 ///
174 /// # Examples
175 ///
176 /// ```rust
177 /// use fraiseql_core::cache::query_analyzer::{QueryAnalyzer, QueryCardinality};
178 /// use fraiseql_core::compiler::ir::IRQuery;
179 /// # use fraiseql_core::error::Result;
180 /// # fn example() -> Result<()> {
181 /// let analyzer = QueryAnalyzer::new();
182 /// let query_def = IRQuery {
183 /// name: "user".to_string(),
184 /// return_type: "User".to_string(),
185 /// returns_list: false,
186 /// nullable: false,
187 /// arguments: vec![],
188 /// sql_source: None,
189 /// description: None,
190 /// auto_params: Default::default(),
191 /// };
192 /// let profile = analyzer.analyze_query(&query_def, "SELECT * FROM users WHERE id = ?")?;
193 /// assert_eq!(profile.cardinality, QueryCardinality::Single);
194 /// # Ok(())
195 /// # }
196 /// ```
197 ///
198 /// # Errors
199 ///
200 /// Returns `FraiseQLError` if the query definition cannot be analyzed.
201 pub fn analyze_query(
202 &self,
203 query_def: &IRQuery,
204 query_str: &str,
205 ) -> Result<QueryEntityProfile> {
206 let cardinality = self.classify_cardinality(query_str);
207
208 // Extract entity type from query definition
209 // For now, we'll use a simple heuristic based on return type
210 let entity_type = self.extract_entity_type(query_def);
211
212 Ok(QueryEntityProfile {
213 query_name: query_def.name.clone(),
214 entity_type,
215 cardinality,
216 })
217 }
218
219 /// Classify query cardinality based on SQL structure.
220 ///
221 /// Analyzes WHERE clause and LIMIT to determine how many entities
222 /// the query typically returns.
223 pub(crate) fn classify_cardinality(&self, query_str: &str) -> QueryCardinality {
224 let query_lower = query_str.to_lowercase();
225
226 // Check for single entity query: WHERE id = ?
227 if query_lower.contains("where")
228 && query_lower.contains("id")
229 && query_lower.contains('=')
230 && !query_lower.contains("in")
231 {
232 return QueryCardinality::Single;
233 }
234
235 // Check for multi-entity query: WHERE id IN (?, ...)
236 if query_lower.contains("where") && query_lower.contains("in") {
237 return QueryCardinality::Multiple;
238 }
239
240 // Default to list if no WHERE clause with ID constraint
241 QueryCardinality::List
242 }
243
244 /// Extract entity type from query definition.
245 ///
246 /// Uses the return type of the query to infer entity type.
247 /// This is a simplified heuristic that works for standard naming conventions.
248 fn extract_entity_type(&self, query_def: &IRQuery) -> Option<String> {
249 // Extract entity type from return type
250 // Standard pattern: return_type = "User", entity = "User"
251 // For now, we'll use the return_type directly
252 if query_def.return_type.is_empty() {
253 return None;
254 }
255
256 let return_type = &query_def.return_type;
257
258 // If return type ends with "[]", extract the base type
259 let base_type = if return_type.ends_with("[]") {
260 &return_type[..return_type.len() - 2]
261 } else {
262 return_type.as_str()
263 };
264
265 // Return the base type (e.g., "User" from "User[]")
266 if base_type.is_empty() {
267 None
268 } else {
269 Some(base_type.to_string())
270 }
271 }
272}
273
274impl Default for QueryAnalyzer {
275 fn default() -> Self {
276 Self::new()
277 }
278}