Skip to main content

fraiseql_core/runtime/
planner.rs

1//! Query plan selection - chooses optimal execution strategy.
2
3use super::matcher::QueryMatch;
4use crate::{
5    error::Result,
6    graphql::FieldSelection,
7    runtime::{JsonbOptimizationOptions, JsonbStrategy},
8};
9
10/// Execution plan for a query.
11#[derive(Debug, Clone)]
12pub struct ExecutionPlan {
13    /// SQL query to execute.
14    pub sql: String,
15
16    /// Parameter bindings (parameter name → value).
17    pub parameters: Vec<(String, serde_json::Value)>,
18
19    /// Whether this plan uses a cached result.
20    pub is_cached: bool,
21
22    /// Estimated cost (for optimization).
23    pub estimated_cost: usize,
24
25    /// Fields to project from JSONB result.
26    pub projection_fields: Vec<String>,
27
28    /// JSONB handling strategy for this query
29    pub jsonb_strategy: JsonbStrategy,
30}
31
32/// Query planner - selects optimal execution strategy.
33pub struct QueryPlanner {
34    /// Enable query plan caching.
35    cache_enabled: bool,
36
37    /// JSONB optimization options for strategy selection
38    jsonb_options: JsonbOptimizationOptions,
39}
40
41impl QueryPlanner {
42    /// Create new query planner with default JSONB optimization options.
43    #[must_use]
44    pub fn new(cache_enabled: bool) -> Self {
45        Self::with_jsonb_options(cache_enabled, JsonbOptimizationOptions::default())
46    }
47
48    /// Create query planner with custom JSONB optimization options.
49    #[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    /// Create an execution plan for a matched query.
61    ///
62    /// # Arguments
63    ///
64    /// * `query_match` - Matched query with extracted information
65    ///
66    /// # Returns
67    ///
68    /// Execution plan with SQL, parameters, and optimization hints
69    ///
70    /// # Errors
71    ///
72    /// Returns error if plan generation fails.
73    ///
74    /// # Example
75    ///
76    /// ```no_run
77    /// // Requires: a QueryMatch from compiled schema matching.
78    /// # use fraiseql_core::runtime::{QueryMatcher, QueryPlanner};
79    /// # use fraiseql_core::schema::CompiledSchema;
80    /// # use fraiseql_error::Result;
81    /// # fn example() -> Result<()> {
82    /// # let schema: CompiledSchema = panic!("example");
83    /// # let query_match = QueryMatcher::new(schema).match_query("query{users{id}}", None)?;
84    /// let planner = QueryPlanner::new(true);
85    /// let plan = planner.plan(&query_match)?;
86    /// assert!(!plan.sql.is_empty());
87    /// # Ok(())
88    /// # }
89    /// ```
90    pub fn plan(&self, query_match: &QueryMatch) -> Result<ExecutionPlan> {
91        // Note: FraiseQL uses compiled SQL templates, so "query planning" means
92        // extracting the pre-compiled SQL from the matched query definition.
93        // No dynamic query optimization is needed - templates are pre-optimized.
94
95        let sql = self.generate_sql(query_match);
96        let parameters = self.extract_parameters(query_match);
97
98        // Extract nested field names from the first selection's nested_fields
99        // The first selection is typically the root query field (e.g., "users")
100        let projection_fields = self.extract_projection_fields(&query_match.selections);
101
102        // Determine JSONB optimization strategy based on field count
103        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    /// Choose JSONB handling strategy based on number of requested fields.
116    fn choose_jsonb_strategy(&self, projection_fields: &[String]) -> JsonbStrategy {
117        // Estimate total fields - in reality this would come from schema
118        // For now, use a reasonable estimate based on common field counts
119        let estimated_total_fields = projection_fields.len().max(10); // assume at least 10 fields available
120        self.jsonb_options
121            .choose_strategy(projection_fields.len(), estimated_total_fields)
122    }
123
124    /// Extract field names for projection from parsed selections.
125    ///
126    /// For a query like `{ users { id name } }`, this extracts `["id", "name"]`.
127    fn extract_projection_fields(&self, selections: &[FieldSelection]) -> Vec<String> {
128        // Get the first (root) selection and extract its nested fields
129        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    /// Generate SQL from query match.
141    fn generate_sql(&self, query_match: &QueryMatch) -> String {
142        // Get SQL source from query definition
143        let table = query_match.query_def.sql_source.as_ref().map_or("unknown", String::as_str);
144
145        // Build basic SELECT query
146        // Select all data — projection happens later in the execution pipeline
147        let fields_sql = "data".to_string();
148
149        format!("SELECT {fields_sql} FROM {table}")
150    }
151
152    /// Extract parameters from query match.
153    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    /// Estimate query cost (for optimization).
158    fn estimate_cost(&self, query_match: &QueryMatch) -> usize {
159        // Simple heuristic: base cost + field cost
160        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    /// Check if caching is enabled.
168    #[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)] // Reason: test code, panics are acceptable
177
178    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        // base (100) + 2 fields (20) + 0 args (0) = 120
285        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    // ========================================================================
302
303    // ========================================================================
304
305    #[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        // Should include strategy in execution plan
312        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        // With custom options, strategy selection changes
326        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        // 2 fields requested out of ~10 estimated = 20% < 80% threshold
338        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        // 9 fields requested out of ~10 estimated = 90% >= 80% threshold
351        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        // Even with few fields, should use Stream as default
365        let strategy = planner.choose_jsonb_strategy(&["id".to_string()]);
366        assert_eq!(strategy, JsonbStrategy::Stream);
367    }
368}