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            },
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        // base (100) + 2 fields (20) + 0 args (0) = 120
284        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    // ========================================================================
301
302    // ========================================================================
303
304    #[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        // Should include strategy in execution plan
311        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        // With custom options, strategy selection changes
325        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        // 2 fields requested out of ~10 estimated = 20% < 80% threshold
337        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        // 9 fields requested out of ~10 estimated = 90% >= 80% threshold
350        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        // Even with few fields, should use Stream as default
364        let strategy = planner.choose_jsonb_strategy(&["id".to_string()]);
365        assert_eq!(strategy, JsonbStrategy::Stream);
366    }
367}