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 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    /// ```rust,ignore
77    /// let planner = QueryPlanner::new(true);
78    /// let plan = planner.plan(&query_match)?;
79    /// assert!(!plan.sql.is_empty());
80    /// ```
81    pub fn plan(&self, query_match: &QueryMatch) -> Result<ExecutionPlan> {
82        // Note: FraiseQL uses compiled SQL templates, so "query planning" means
83        // extracting the pre-compiled SQL from the matched query definition.
84        // No dynamic query optimization is needed - templates are pre-optimized.
85
86        let sql = self.generate_sql(query_match);
87        let parameters = self.extract_parameters(query_match);
88
89        // Extract nested field names from the first selection's nested_fields
90        // The first selection is typically the root query field (e.g., "users")
91        let projection_fields = self.extract_projection_fields(&query_match.selections);
92
93        // Determine JSONB optimization strategy based on field count
94        let jsonb_strategy = self.choose_jsonb_strategy(&projection_fields);
95
96        Ok(ExecutionPlan {
97            sql,
98            parameters,
99            is_cached: false,
100            estimated_cost: self.estimate_cost(query_match),
101            projection_fields,
102            jsonb_strategy,
103        })
104    }
105
106    /// Choose JSONB handling strategy based on number of requested fields.
107    fn choose_jsonb_strategy(&self, projection_fields: &[String]) -> JsonbStrategy {
108        // Estimate total fields - in reality this would come from schema
109        // For now, use a reasonable estimate based on common field counts
110        let estimated_total_fields = projection_fields.len().max(10); // assume at least 10 fields available
111        self.jsonb_options
112            .choose_strategy(projection_fields.len(), estimated_total_fields)
113    }
114
115    /// Extract field names for projection from parsed selections.
116    ///
117    /// For a query like `{ users { id name } }`, this extracts `["id", "name"]`.
118    fn extract_projection_fields(&self, selections: &[FieldSelection]) -> Vec<String> {
119        // Get the first (root) selection and extract its nested fields
120        if let Some(root_selection) = selections.first() {
121            root_selection
122                .nested_fields
123                .iter()
124                .map(|f| f.response_key().to_string())
125                .collect()
126        } else {
127            Vec::new()
128        }
129    }
130
131    /// Generate SQL from query match.
132    fn generate_sql(&self, query_match: &QueryMatch) -> String {
133        // Get SQL source from query definition
134        let table = query_match.query_def.sql_source.as_ref().map_or("unknown", String::as_str);
135
136        // Build basic SELECT query
137        let fields_sql = if query_match.fields.is_empty() {
138            "data".to_string()
139        } else {
140            // For now, select all data (projection happens later)
141            "data".to_string()
142        };
143
144        format!("SELECT {fields_sql} FROM {table}")
145    }
146
147    /// Extract parameters from query match.
148    fn extract_parameters(&self, query_match: &QueryMatch) -> Vec<(String, serde_json::Value)> {
149        query_match.arguments.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
150    }
151
152    /// Estimate query cost (for optimization).
153    fn estimate_cost(&self, query_match: &QueryMatch) -> usize {
154        // Simple heuristic: base cost + field cost
155        let base_cost = 100;
156        let field_cost = query_match.fields.len() * 10;
157        let arg_cost = query_match.arguments.len() * 5;
158
159        base_cost + field_cost + arg_cost
160    }
161
162    /// Check if caching is enabled.
163    #[must_use]
164    pub const fn cache_enabled(&self) -> bool {
165        self.cache_enabled
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use std::collections::HashMap;
172
173    use super::*;
174    use crate::{
175        graphql::{FieldSelection, ParsedQuery},
176        schema::{AutoParams, QueryDefinition},
177    };
178
179    fn test_query_match() -> QueryMatch {
180        QueryMatch {
181            query_def:      QueryDefinition {
182                name:         "users".to_string(),
183                return_type:  "User".to_string(),
184                returns_list: true,
185                nullable:     false,
186                arguments:    Vec::new(),
187                sql_source:   Some("v_user".to_string()),
188                description:  None,
189                auto_params:  AutoParams::default(),
190                deprecation:  None,
191                jsonb_column: "data".to_string(),
192            },
193            fields:         vec!["id".to_string(), "name".to_string()],
194            selections:     vec![FieldSelection {
195                name:          "users".to_string(),
196                alias:         None,
197                arguments:     vec![],
198                nested_fields: vec![
199                    FieldSelection {
200                        name:          "id".to_string(),
201                        alias:         None,
202                        arguments:     vec![],
203                        nested_fields: vec![],
204                        directives:    vec![],
205                    },
206                    FieldSelection {
207                        name:          "name".to_string(),
208                        alias:         None,
209                        arguments:     vec![],
210                        nested_fields: vec![],
211                        directives:    vec![],
212                    },
213                ],
214                directives:    vec![],
215            }],
216            arguments:      HashMap::new(),
217            operation_name: Some("users".to_string()),
218            parsed_query:   ParsedQuery {
219                operation_type: "query".to_string(),
220                operation_name: Some("users".to_string()),
221                root_field:     "users".to_string(),
222                selections:     vec![],
223                variables:      vec![],
224                fragments:      vec![],
225                source:         "{ users { id name } }".to_string(),
226            },
227        }
228    }
229
230    #[test]
231    fn test_planner_new() {
232        let planner = QueryPlanner::new(true);
233        assert!(planner.cache_enabled());
234
235        let planner = QueryPlanner::new(false);
236        assert!(!planner.cache_enabled());
237    }
238
239    #[test]
240    fn test_generate_sql() {
241        let planner = QueryPlanner::new(true);
242        let query_match = test_query_match();
243
244        let sql = planner.generate_sql(&query_match);
245        assert_eq!(sql, "SELECT data FROM v_user");
246    }
247
248    #[test]
249    fn test_extract_parameters() {
250        let planner = QueryPlanner::new(true);
251        let mut query_match = test_query_match();
252        query_match.arguments.insert("id".to_string(), serde_json::json!("123"));
253        query_match.arguments.insert("limit".to_string(), serde_json::json!(10));
254
255        let params = planner.extract_parameters(&query_match);
256        assert_eq!(params.len(), 2);
257    }
258
259    #[test]
260    fn test_estimate_cost() {
261        let planner = QueryPlanner::new(true);
262        let query_match = test_query_match();
263
264        let cost = planner.estimate_cost(&query_match);
265        // base (100) + 2 fields (20) + 0 args (0) = 120
266        assert_eq!(cost, 120);
267    }
268
269    #[test]
270    fn test_plan() {
271        let planner = QueryPlanner::new(true);
272        let query_match = test_query_match();
273
274        let plan = planner.plan(&query_match).unwrap();
275        assert!(!plan.sql.is_empty());
276        assert_eq!(plan.projection_fields.len(), 2);
277        assert!(!plan.is_cached);
278        assert_eq!(plan.estimated_cost, 120);
279        assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
280    }
281
282    // ========================================================================
283
284    // ========================================================================
285
286    #[test]
287    fn test_plan_includes_jsonb_strategy() {
288        let planner = QueryPlanner::new(true);
289        let query_match = test_query_match();
290
291        let plan = planner.plan(&query_match).unwrap();
292        // Should include strategy in execution plan
293        assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
294    }
295
296    #[test]
297    fn test_planner_with_custom_jsonb_options() {
298        let custom_options = JsonbOptimizationOptions {
299            default_strategy:       JsonbStrategy::Stream,
300            auto_threshold_percent: 50,
301        };
302        let planner = QueryPlanner::with_jsonb_options(true, custom_options);
303        let query_match = test_query_match();
304
305        let plan = planner.plan(&query_match).unwrap();
306        // With custom options, strategy selection changes
307        assert_eq!(plan.jsonb_strategy, JsonbStrategy::Stream);
308    }
309
310    #[test]
311    fn test_choose_jsonb_strategy_below_threshold() {
312        let options = JsonbOptimizationOptions {
313            default_strategy:       JsonbStrategy::Project,
314            auto_threshold_percent: 80,
315        };
316        let planner = QueryPlanner::with_jsonb_options(true, options);
317
318        // 2 fields requested out of ~10 estimated = 20% < 80% threshold
319        let strategy = planner.choose_jsonb_strategy(&["id".to_string(), "name".to_string()]);
320        assert_eq!(strategy, JsonbStrategy::Project);
321    }
322
323    #[test]
324    fn test_choose_jsonb_strategy_at_threshold() {
325        let options = JsonbOptimizationOptions {
326            default_strategy:       JsonbStrategy::Project,
327            auto_threshold_percent: 80,
328        };
329        let planner = QueryPlanner::with_jsonb_options(true, options);
330
331        // 9 fields requested out of ~10 estimated = 90% >= 80% threshold
332        let many_fields = (0..9).map(|i| format!("field_{}", i)).collect::<Vec<_>>();
333        let strategy = planner.choose_jsonb_strategy(&many_fields);
334        assert_eq!(strategy, JsonbStrategy::Stream);
335    }
336
337    #[test]
338    fn test_choose_jsonb_strategy_respects_default() {
339        let options = JsonbOptimizationOptions {
340            default_strategy:       JsonbStrategy::Stream,
341            auto_threshold_percent: 80,
342        };
343        let planner = QueryPlanner::with_jsonb_options(true, options);
344
345        // Even with few fields, should use Stream as default
346        let strategy = planner.choose_jsonb_strategy(&["id".to_string()]);
347        assert_eq!(strategy, JsonbStrategy::Stream);
348    }
349}