1use super::matcher::QueryMatch;
4use crate::{
5 error::Result,
6 graphql::FieldSelection,
7 runtime::{JsonbOptimizationOptions, JsonbStrategy},
8};
9
10#[derive(Debug, Clone)]
12pub struct ExecutionPlan {
13 pub sql: String,
15
16 pub parameters: Vec<(String, serde_json::Value)>,
18
19 pub is_cached: bool,
21
22 pub estimated_cost: usize,
24
25 pub projection_fields: Vec<String>,
27
28 pub jsonb_strategy: JsonbStrategy,
30}
31
32pub struct QueryPlanner {
34 cache_enabled: bool,
36
37 jsonb_options: JsonbOptimizationOptions,
39}
40
41impl QueryPlanner {
42 #[must_use]
44 pub fn new(cache_enabled: bool) -> Self {
45 Self::with_jsonb_options(cache_enabled, JsonbOptimizationOptions::default())
46 }
47
48 #[must_use]
50 pub const fn with_jsonb_options(
51 cache_enabled: bool,
52 jsonb_options: JsonbOptimizationOptions,
53 ) -> Self {
54 Self {
55 cache_enabled,
56 jsonb_options,
57 }
58 }
59
60 pub fn plan(&self, query_match: &QueryMatch) -> Result<ExecutionPlan> {
91 let sql = self.generate_sql(query_match);
96 let parameters = self.extract_parameters(query_match);
97
98 let projection_fields = self.extract_projection_fields(&query_match.selections);
101
102 let jsonb_strategy = self.choose_jsonb_strategy(&projection_fields);
104
105 Ok(ExecutionPlan {
106 sql,
107 parameters,
108 is_cached: false,
109 estimated_cost: self.estimate_cost(query_match),
110 projection_fields,
111 jsonb_strategy,
112 })
113 }
114
115 const fn choose_jsonb_strategy(&self, projection_fields: &[String]) -> JsonbStrategy {
124 if projection_fields.is_empty() {
125 self.jsonb_options.default_strategy
126 } else {
127 JsonbStrategy::Project
128 }
129 }
130
131 fn extract_projection_fields(&self, selections: &[FieldSelection]) -> Vec<String> {
141 if let Some(root_selection) = selections.first() {
147 root_selection
148 .nested_fields
149 .iter()
150 .filter(|f| f.name != "__typename")
151 .map(|f| f.response_key().to_string())
152 .collect()
153 } else {
154 Vec::new()
155 }
156 }
157
158 fn generate_sql(&self, query_match: &QueryMatch) -> String {
160 let table = query_match.query_def.sql_source.as_ref().map_or("unknown", String::as_str);
162
163 let fields_sql = "data".to_string();
166
167 format!("SELECT {fields_sql} FROM {table}")
168 }
169
170 fn extract_parameters(&self, query_match: &QueryMatch) -> Vec<(String, serde_json::Value)> {
172 query_match.arguments.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
173 }
174
175 fn estimate_cost(&self, query_match: &QueryMatch) -> usize {
177 let base_cost = 100;
179 let field_cost = query_match.fields.len() * 10;
180 let arg_cost = query_match.arguments.len() * 5;
181
182 base_cost + field_cost + arg_cost
183 }
184
185 #[must_use]
187 pub const fn cache_enabled(&self) -> bool {
188 self.cache_enabled
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 #![allow(clippy::unwrap_used)] use std::collections::HashMap;
197
198 use indexmap::IndexMap;
199
200 use super::*;
201 use crate::{
202 graphql::{FieldSelection, ParsedQuery},
203 schema::{AutoParams, CursorType, QueryDefinition},
204 };
205
206 fn test_query_match() -> QueryMatch {
207 QueryMatch {
208 query_def: QueryDefinition {
209 name: "users".to_string(),
210 return_type: "User".to_string(),
211 returns_list: true,
212 nullable: false,
213 arguments: Vec::new(),
214 sql_source: Some("v_user".to_string()),
215 description: None,
216 auto_params: AutoParams::default(),
217 deprecation: None,
218 jsonb_column: "data".to_string(),
219 relay: false,
220 relay_cursor_column: None,
221 relay_cursor_type: CursorType::default(),
222 inject_params: IndexMap::default(),
223 cache_ttl_seconds: None,
224 additional_views: vec![],
225 requires_role: None,
226 rest_path: None,
227 rest_method: None,
228 native_columns: HashMap::new(),
229 },
230 fields: vec!["id".to_string(), "name".to_string()],
231 selections: vec![FieldSelection {
232 name: "users".to_string(),
233 alias: None,
234 arguments: vec![],
235 nested_fields: vec![
236 FieldSelection {
237 name: "id".to_string(),
238 alias: None,
239 arguments: vec![],
240 nested_fields: vec![],
241 directives: vec![],
242 },
243 FieldSelection {
244 name: "name".to_string(),
245 alias: None,
246 arguments: vec![],
247 nested_fields: vec![],
248 directives: vec![],
249 },
250 ],
251 directives: vec![],
252 }],
253 arguments: HashMap::new(),
254 operation_name: Some("users".to_string()),
255 parsed_query: ParsedQuery {
256 operation_type: "query".to_string(),
257 operation_name: Some("users".to_string()),
258 root_field: "users".to_string(),
259 selections: vec![],
260 variables: vec![],
261 fragments: vec![],
262 source: "{ users { id name } }".to_string(),
263 },
264 }
265 }
266
267 #[test]
268 fn test_planner_new() {
269 let planner = QueryPlanner::new(true);
270 assert!(planner.cache_enabled());
271
272 let planner = QueryPlanner::new(false);
273 assert!(!planner.cache_enabled());
274 }
275
276 #[test]
277 fn test_generate_sql() {
278 let planner = QueryPlanner::new(true);
279 let query_match = test_query_match();
280
281 let sql = planner.generate_sql(&query_match);
282 assert_eq!(sql, "SELECT data FROM v_user");
283 }
284
285 #[test]
286 fn test_extract_parameters() {
287 let planner = QueryPlanner::new(true);
288 let mut query_match = test_query_match();
289 query_match.arguments.insert("id".to_string(), serde_json::json!("123"));
290 query_match.arguments.insert("limit".to_string(), serde_json::json!(10));
291
292 let params = planner.extract_parameters(&query_match);
293 assert_eq!(params.len(), 2);
294 }
295
296 #[test]
297 fn test_estimate_cost() {
298 let planner = QueryPlanner::new(true);
299 let query_match = test_query_match();
300
301 let cost = planner.estimate_cost(&query_match);
302 assert_eq!(cost, 120);
304 }
305
306 #[test]
307 fn test_plan() {
308 let planner = QueryPlanner::new(true);
309 let query_match = test_query_match();
310
311 let plan = planner.plan(&query_match).unwrap();
312 assert!(!plan.sql.is_empty());
313 assert_eq!(plan.projection_fields.len(), 2);
314 assert!(!plan.is_cached);
315 assert_eq!(plan.estimated_cost, 120);
316 assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
317 }
318
319 #[test]
324 fn test_projection_fields_exclude_typename() {
325 let planner = QueryPlanner::new(true);
326 let mut query_match = test_query_match();
327
328 query_match.selections[0].nested_fields.push(FieldSelection {
330 name: "__typename".to_string(),
331 alias: None,
332 arguments: vec![],
333 nested_fields: vec![],
334 directives: vec![],
335 });
336
337 let plan = planner.plan(&query_match).unwrap();
338
339 assert!(!plan.projection_fields.contains(&"__typename".to_string()));
341 assert_eq!(plan.projection_fields, vec!["id".to_string(), "name".to_string()]);
342 }
343
344 #[test]
345 fn test_plan_includes_jsonb_strategy() {
346 let planner = QueryPlanner::new(true);
347 let query_match = test_query_match();
348
349 let plan = planner.plan(&query_match).unwrap();
350 assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
352 }
353
354 #[test]
355 fn test_planner_always_projects_when_fields_present() {
356 let custom_options = JsonbOptimizationOptions {
357 default_strategy: JsonbStrategy::Stream,
358 auto_threshold_percent: 50,
359 };
360 let planner = QueryPlanner::with_jsonb_options(true, custom_options);
361 let query_match = test_query_match();
362
363 let plan = planner.plan(&query_match).unwrap();
364 assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
367 }
368
369 #[test]
370 fn test_choose_jsonb_strategy_forces_project_with_fields() {
371 let options = JsonbOptimizationOptions {
372 default_strategy: JsonbStrategy::Stream,
373 auto_threshold_percent: 80,
374 };
375 let planner = QueryPlanner::with_jsonb_options(true, options);
376
377 let strategy = planner.choose_jsonb_strategy(&["id".to_string(), "name".to_string()]);
379 assert_eq!(strategy, JsonbStrategy::Project);
380 }
381
382 #[test]
383 fn test_choose_jsonb_strategy_forces_project_with_many_fields() {
384 let options = JsonbOptimizationOptions {
385 default_strategy: JsonbStrategy::Project,
386 auto_threshold_percent: 80,
387 };
388 let planner = QueryPlanner::with_jsonb_options(true, options);
389
390 let many_fields = (0..9).map(|i| format!("field_{}", i)).collect::<Vec<_>>();
392 let strategy = planner.choose_jsonb_strategy(&many_fields);
393 assert_eq!(strategy, JsonbStrategy::Project);
394 }
395
396 #[test]
397 fn test_choose_jsonb_strategy_empty_fields_uses_default() {
398 let options = JsonbOptimizationOptions {
399 default_strategy: JsonbStrategy::Stream,
400 auto_threshold_percent: 80,
401 };
402 let planner = QueryPlanner::with_jsonb_options(true, options);
403
404 let strategy = planner.choose_jsonb_strategy(&[]);
406 assert_eq!(strategy, JsonbStrategy::Stream);
407 }
408}