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 fn choose_jsonb_strategy(&self, projection_fields: &[String]) -> JsonbStrategy {
117 let estimated_total_fields = projection_fields.len().max(10); self.jsonb_options
121 .choose_strategy(projection_fields.len(), estimated_total_fields)
122 }
123
124 fn extract_projection_fields(&self, selections: &[FieldSelection]) -> Vec<String> {
128 if let Some(root_selection) = selections.first() {
130 root_selection
131 .nested_fields
132 .iter()
133 .map(|f| f.response_key().to_string())
134 .collect()
135 } else {
136 Vec::new()
137 }
138 }
139
140 fn generate_sql(&self, query_match: &QueryMatch) -> String {
142 let table = query_match.query_def.sql_source.as_ref().map_or("unknown", String::as_str);
144
145 let fields_sql = "data".to_string();
148
149 format!("SELECT {fields_sql} FROM {table}")
150 }
151
152 fn extract_parameters(&self, query_match: &QueryMatch) -> Vec<(String, serde_json::Value)> {
154 query_match.arguments.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
155 }
156
157 fn estimate_cost(&self, query_match: &QueryMatch) -> usize {
159 let base_cost = 100;
161 let field_cost = query_match.fields.len() * 10;
162 let arg_cost = query_match.arguments.len() * 5;
163
164 base_cost + field_cost + arg_cost
165 }
166
167 #[must_use]
169 pub const fn cache_enabled(&self) -> bool {
170 self.cache_enabled
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 #![allow(clippy::unwrap_used)] use std::collections::HashMap;
179
180 use indexmap::IndexMap;
181
182 use super::*;
183 use crate::{
184 graphql::{FieldSelection, ParsedQuery},
185 schema::{AutoParams, CursorType, QueryDefinition},
186 };
187
188 fn test_query_match() -> QueryMatch {
189 QueryMatch {
190 query_def: QueryDefinition {
191 name: "users".to_string(),
192 return_type: "User".to_string(),
193 returns_list: true,
194 nullable: false,
195 arguments: Vec::new(),
196 sql_source: Some("v_user".to_string()),
197 description: None,
198 auto_params: AutoParams::default(),
199 deprecation: None,
200 jsonb_column: "data".to_string(),
201 relay: false,
202 relay_cursor_column: None,
203 relay_cursor_type: CursorType::default(),
204 inject_params: IndexMap::default(),
205 cache_ttl_seconds: None,
206 additional_views: vec![],
207 requires_role: None,
208 rest_path: None,
209 rest_method: None,
210 },
211 fields: vec!["id".to_string(), "name".to_string()],
212 selections: vec![FieldSelection {
213 name: "users".to_string(),
214 alias: None,
215 arguments: vec![],
216 nested_fields: vec![
217 FieldSelection {
218 name: "id".to_string(),
219 alias: None,
220 arguments: vec![],
221 nested_fields: vec![],
222 directives: vec![],
223 },
224 FieldSelection {
225 name: "name".to_string(),
226 alias: None,
227 arguments: vec![],
228 nested_fields: vec![],
229 directives: vec![],
230 },
231 ],
232 directives: vec![],
233 }],
234 arguments: HashMap::new(),
235 operation_name: Some("users".to_string()),
236 parsed_query: ParsedQuery {
237 operation_type: "query".to_string(),
238 operation_name: Some("users".to_string()),
239 root_field: "users".to_string(),
240 selections: vec![],
241 variables: vec![],
242 fragments: vec![],
243 source: "{ users { id name } }".to_string(),
244 },
245 }
246 }
247
248 #[test]
249 fn test_planner_new() {
250 let planner = QueryPlanner::new(true);
251 assert!(planner.cache_enabled());
252
253 let planner = QueryPlanner::new(false);
254 assert!(!planner.cache_enabled());
255 }
256
257 #[test]
258 fn test_generate_sql() {
259 let planner = QueryPlanner::new(true);
260 let query_match = test_query_match();
261
262 let sql = planner.generate_sql(&query_match);
263 assert_eq!(sql, "SELECT data FROM v_user");
264 }
265
266 #[test]
267 fn test_extract_parameters() {
268 let planner = QueryPlanner::new(true);
269 let mut query_match = test_query_match();
270 query_match.arguments.insert("id".to_string(), serde_json::json!("123"));
271 query_match.arguments.insert("limit".to_string(), serde_json::json!(10));
272
273 let params = planner.extract_parameters(&query_match);
274 assert_eq!(params.len(), 2);
275 }
276
277 #[test]
278 fn test_estimate_cost() {
279 let planner = QueryPlanner::new(true);
280 let query_match = test_query_match();
281
282 let cost = planner.estimate_cost(&query_match);
283 assert_eq!(cost, 120);
285 }
286
287 #[test]
288 fn test_plan() {
289 let planner = QueryPlanner::new(true);
290 let query_match = test_query_match();
291
292 let plan = planner.plan(&query_match).unwrap();
293 assert!(!plan.sql.is_empty());
294 assert_eq!(plan.projection_fields.len(), 2);
295 assert!(!plan.is_cached);
296 assert_eq!(plan.estimated_cost, 120);
297 assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
298 }
299
300 #[test]
305 fn test_plan_includes_jsonb_strategy() {
306 let planner = QueryPlanner::new(true);
307 let query_match = test_query_match();
308
309 let plan = planner.plan(&query_match).unwrap();
310 assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
312 }
313
314 #[test]
315 fn test_planner_with_custom_jsonb_options() {
316 let custom_options = JsonbOptimizationOptions {
317 default_strategy: JsonbStrategy::Stream,
318 auto_threshold_percent: 50,
319 };
320 let planner = QueryPlanner::with_jsonb_options(true, custom_options);
321 let query_match = test_query_match();
322
323 let plan = planner.plan(&query_match).unwrap();
324 assert_eq!(plan.jsonb_strategy, JsonbStrategy::Stream);
326 }
327
328 #[test]
329 fn test_choose_jsonb_strategy_below_threshold() {
330 let options = JsonbOptimizationOptions {
331 default_strategy: JsonbStrategy::Project,
332 auto_threshold_percent: 80,
333 };
334 let planner = QueryPlanner::with_jsonb_options(true, options);
335
336 let strategy = planner.choose_jsonb_strategy(&["id".to_string(), "name".to_string()]);
338 assert_eq!(strategy, JsonbStrategy::Project);
339 }
340
341 #[test]
342 fn test_choose_jsonb_strategy_at_threshold() {
343 let options = JsonbOptimizationOptions {
344 default_strategy: JsonbStrategy::Project,
345 auto_threshold_percent: 80,
346 };
347 let planner = QueryPlanner::with_jsonb_options(true, options);
348
349 let many_fields = (0..9).map(|i| format!("field_{}", i)).collect::<Vec<_>>();
351 let strategy = planner.choose_jsonb_strategy(&many_fields);
352 assert_eq!(strategy, JsonbStrategy::Stream);
353 }
354
355 #[test]
356 fn test_choose_jsonb_strategy_respects_default() {
357 let options = JsonbOptimizationOptions {
358 default_strategy: JsonbStrategy::Stream,
359 auto_threshold_percent: 80,
360 };
361 let planner = QueryPlanner::with_jsonb_options(true, options);
362
363 let strategy = planner.choose_jsonb_strategy(&["id".to_string()]);
365 assert_eq!(strategy, JsonbStrategy::Stream);
366 }
367}