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 native_columns: HashMap::new(),
211 },
212 fields: vec!["id".to_string(), "name".to_string()],
213 selections: vec![FieldSelection {
214 name: "users".to_string(),
215 alias: None,
216 arguments: vec![],
217 nested_fields: vec![
218 FieldSelection {
219 name: "id".to_string(),
220 alias: None,
221 arguments: vec![],
222 nested_fields: vec![],
223 directives: vec![],
224 },
225 FieldSelection {
226 name: "name".to_string(),
227 alias: None,
228 arguments: vec![],
229 nested_fields: vec![],
230 directives: vec![],
231 },
232 ],
233 directives: vec![],
234 }],
235 arguments: HashMap::new(),
236 operation_name: Some("users".to_string()),
237 parsed_query: ParsedQuery {
238 operation_type: "query".to_string(),
239 operation_name: Some("users".to_string()),
240 root_field: "users".to_string(),
241 selections: vec![],
242 variables: vec![],
243 fragments: vec![],
244 source: "{ users { id name } }".to_string(),
245 },
246 }
247 }
248
249 #[test]
250 fn test_planner_new() {
251 let planner = QueryPlanner::new(true);
252 assert!(planner.cache_enabled());
253
254 let planner = QueryPlanner::new(false);
255 assert!(!planner.cache_enabled());
256 }
257
258 #[test]
259 fn test_generate_sql() {
260 let planner = QueryPlanner::new(true);
261 let query_match = test_query_match();
262
263 let sql = planner.generate_sql(&query_match);
264 assert_eq!(sql, "SELECT data FROM v_user");
265 }
266
267 #[test]
268 fn test_extract_parameters() {
269 let planner = QueryPlanner::new(true);
270 let mut query_match = test_query_match();
271 query_match.arguments.insert("id".to_string(), serde_json::json!("123"));
272 query_match.arguments.insert("limit".to_string(), serde_json::json!(10));
273
274 let params = planner.extract_parameters(&query_match);
275 assert_eq!(params.len(), 2);
276 }
277
278 #[test]
279 fn test_estimate_cost() {
280 let planner = QueryPlanner::new(true);
281 let query_match = test_query_match();
282
283 let cost = planner.estimate_cost(&query_match);
284 assert_eq!(cost, 120);
286 }
287
288 #[test]
289 fn test_plan() {
290 let planner = QueryPlanner::new(true);
291 let query_match = test_query_match();
292
293 let plan = planner.plan(&query_match).unwrap();
294 assert!(!plan.sql.is_empty());
295 assert_eq!(plan.projection_fields.len(), 2);
296 assert!(!plan.is_cached);
297 assert_eq!(plan.estimated_cost, 120);
298 assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
299 }
300
301 #[test]
306 fn test_plan_includes_jsonb_strategy() {
307 let planner = QueryPlanner::new(true);
308 let query_match = test_query_match();
309
310 let plan = planner.plan(&query_match).unwrap();
311 assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
313 }
314
315 #[test]
316 fn test_planner_with_custom_jsonb_options() {
317 let custom_options = JsonbOptimizationOptions {
318 default_strategy: JsonbStrategy::Stream,
319 auto_threshold_percent: 50,
320 };
321 let planner = QueryPlanner::with_jsonb_options(true, custom_options);
322 let query_match = test_query_match();
323
324 let plan = planner.plan(&query_match).unwrap();
325 assert_eq!(plan.jsonb_strategy, JsonbStrategy::Stream);
327 }
328
329 #[test]
330 fn test_choose_jsonb_strategy_below_threshold() {
331 let options = JsonbOptimizationOptions {
332 default_strategy: JsonbStrategy::Project,
333 auto_threshold_percent: 80,
334 };
335 let planner = QueryPlanner::with_jsonb_options(true, options);
336
337 let strategy = planner.choose_jsonb_strategy(&["id".to_string(), "name".to_string()]);
339 assert_eq!(strategy, JsonbStrategy::Project);
340 }
341
342 #[test]
343 fn test_choose_jsonb_strategy_at_threshold() {
344 let options = JsonbOptimizationOptions {
345 default_strategy: JsonbStrategy::Project,
346 auto_threshold_percent: 80,
347 };
348 let planner = QueryPlanner::with_jsonb_options(true, options);
349
350 let many_fields = (0..9).map(|i| format!("field_{}", i)).collect::<Vec<_>>();
352 let strategy = planner.choose_jsonb_strategy(&many_fields);
353 assert_eq!(strategy, JsonbStrategy::Stream);
354 }
355
356 #[test]
357 fn test_choose_jsonb_strategy_respects_default() {
358 let options = JsonbOptimizationOptions {
359 default_strategy: JsonbStrategy::Stream,
360 auto_threshold_percent: 80,
361 };
362 let planner = QueryPlanner::with_jsonb_options(true, options);
363
364 let strategy = planner.choose_jsonb_strategy(&["id".to_string()]);
366 assert_eq!(strategy, JsonbStrategy::Stream);
367 }
368}