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 requested fields.
116    ///
117    /// When a selection set is available (non-empty `projection_fields`), we
118    /// always use `Project` so that the response keys are emitted in camelCase
119    /// by `jsonb_build_object`.  The `Stream` strategy returns raw JSONB with
120    /// `snake_case` keys, which violates client expectations.
121    ///
122    /// `Stream` is only used as a fallback when no specific fields are requested.
123    const fn choose_jsonb_strategy(&self, projection_fields: &[String]) -> JsonbStrategy {
124        if projection_fields.is_empty() {
125            self.jsonb_options.default_strategy
126        } else {
127            JsonbStrategy::Project
128        }
129    }
130
131    /// Extract field names for projection from parsed selections.
132    ///
133    /// For a query like `{ users { id name } }`, this extracts `["id", "name"]`.
134    ///
135    /// Filter `__typename` from SQL projection fields.
136    /// `__typename` is a GraphQL meta-field not stored in JSONB.
137    /// The `ResultProjector` handles injection — see `projection.rs`.
138    /// Removing this filter causes `data->>'__typename'` (NULL) to overwrite
139    /// the value injected by `with_typename()`, depending on field iteration order.
140    fn extract_projection_fields(&self, selections: &[FieldSelection]) -> Vec<String> {
141        // Get the first (root) selection and extract its nested fields.
142        // Skip `__typename` — it is a GraphQL meta-field handled by the projector
143        // at the Rust level; including it in the field list causes the SQL projection
144        // to emit `data->>'__typename'` which returns NULL and then overwrites the
145        // correctly-computed typename injected by `ResultProjector::with_typename`.
146        if let Some(root_selection) = selections.first() {
147            root_selection
148                .nested_fields
149                .iter()
150                .filter(|f| f.name != "__typename")
151                .map(|f| f.response_key().to_string())
152                .collect()
153        } else {
154            Vec::new()
155        }
156    }
157
158    /// Generate SQL from query match.
159    fn generate_sql(&self, query_match: &QueryMatch) -> String {
160        // Get SQL source from query definition
161        let table = query_match.query_def.sql_source.as_ref().map_or("unknown", String::as_str);
162
163        // Build basic SELECT query
164        // Select all data — projection happens later in the execution pipeline
165        let fields_sql = "data".to_string();
166
167        format!("SELECT {fields_sql} FROM {table}")
168    }
169
170    /// Extract parameters from query match.
171    fn extract_parameters(&self, query_match: &QueryMatch) -> Vec<(String, serde_json::Value)> {
172        query_match.arguments.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
173    }
174
175    /// Estimate query cost (for optimization).
176    fn estimate_cost(&self, query_match: &QueryMatch) -> usize {
177        // Simple heuristic: base cost + field cost
178        let base_cost = 100;
179        let field_cost = query_match.fields.len() * 10;
180        let arg_cost = query_match.arguments.len() * 5;
181
182        base_cost + field_cost + arg_cost
183    }
184
185    /// Check if caching is enabled.
186    #[must_use]
187    pub const fn cache_enabled(&self) -> bool {
188        self.cache_enabled
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
195
196    use std::collections::HashMap;
197
198    use indexmap::IndexMap;
199
200    use super::*;
201    use crate::{
202        graphql::{FieldSelection, ParsedQuery},
203        schema::{AutoParams, CursorType, QueryDefinition},
204    };
205
206    fn test_query_match() -> QueryMatch {
207        QueryMatch {
208            query_def:      QueryDefinition {
209                name:                "users".to_string(),
210                return_type:         "User".to_string(),
211                returns_list:        true,
212                nullable:            false,
213                arguments:           Vec::new(),
214                sql_source:          Some("v_user".to_string()),
215                description:         None,
216                auto_params:         AutoParams::default(),
217                deprecation:         None,
218                jsonb_column:        "data".to_string(),
219                relay:               false,
220                relay_cursor_column: None,
221                relay_cursor_type:   CursorType::default(),
222                inject_params:       IndexMap::default(),
223                cache_ttl_seconds:   None,
224                additional_views:    vec![],
225                requires_role:       None,
226                rest_path:           None,
227                rest_method:         None,
228                native_columns:      HashMap::new(),
229            },
230            fields:         vec!["id".to_string(), "name".to_string()],
231            selections:     vec![FieldSelection {
232                name:          "users".to_string(),
233                alias:         None,
234                arguments:     vec![],
235                nested_fields: vec![
236                    FieldSelection {
237                        name:          "id".to_string(),
238                        alias:         None,
239                        arguments:     vec![],
240                        nested_fields: vec![],
241                        directives:    vec![],
242                    },
243                    FieldSelection {
244                        name:          "name".to_string(),
245                        alias:         None,
246                        arguments:     vec![],
247                        nested_fields: vec![],
248                        directives:    vec![],
249                    },
250                ],
251                directives:    vec![],
252            }],
253            arguments:      HashMap::new(),
254            operation_name: Some("users".to_string()),
255            parsed_query:   ParsedQuery {
256                operation_type: "query".to_string(),
257                operation_name: Some("users".to_string()),
258                root_field:     "users".to_string(),
259                selections:     vec![],
260                variables:      vec![],
261                fragments:      vec![],
262                source:         "{ users { id name } }".to_string(),
263            },
264        }
265    }
266
267    #[test]
268    fn test_planner_new() {
269        let planner = QueryPlanner::new(true);
270        assert!(planner.cache_enabled());
271
272        let planner = QueryPlanner::new(false);
273        assert!(!planner.cache_enabled());
274    }
275
276    #[test]
277    fn test_generate_sql() {
278        let planner = QueryPlanner::new(true);
279        let query_match = test_query_match();
280
281        let sql = planner.generate_sql(&query_match);
282        assert_eq!(sql, "SELECT data FROM v_user");
283    }
284
285    #[test]
286    fn test_extract_parameters() {
287        let planner = QueryPlanner::new(true);
288        let mut query_match = test_query_match();
289        query_match.arguments.insert("id".to_string(), serde_json::json!("123"));
290        query_match.arguments.insert("limit".to_string(), serde_json::json!(10));
291
292        let params = planner.extract_parameters(&query_match);
293        assert_eq!(params.len(), 2);
294    }
295
296    #[test]
297    fn test_estimate_cost() {
298        let planner = QueryPlanner::new(true);
299        let query_match = test_query_match();
300
301        let cost = planner.estimate_cost(&query_match);
302        // base (100) + 2 fields (20) + 0 args (0) = 120
303        assert_eq!(cost, 120);
304    }
305
306    #[test]
307    fn test_plan() {
308        let planner = QueryPlanner::new(true);
309        let query_match = test_query_match();
310
311        let plan = planner.plan(&query_match).unwrap();
312        assert!(!plan.sql.is_empty());
313        assert_eq!(plan.projection_fields.len(), 2);
314        assert!(!plan.is_cached);
315        assert_eq!(plan.estimated_cost, 120);
316        assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
317    }
318
319    // ========================================================================
320
321    // ========================================================================
322
323    #[test]
324    fn test_projection_fields_exclude_typename() {
325        let planner = QueryPlanner::new(true);
326        let mut query_match = test_query_match();
327
328        // Add __typename to the nested fields of the root selection
329        query_match.selections[0].nested_fields.push(FieldSelection {
330            name:          "__typename".to_string(),
331            alias:         None,
332            arguments:     vec![],
333            nested_fields: vec![],
334            directives:    vec![],
335        });
336
337        let plan = planner.plan(&query_match).unwrap();
338
339        // __typename must NOT appear in projection fields (it's a GraphQL meta-field)
340        assert!(!plan.projection_fields.contains(&"__typename".to_string()));
341        assert_eq!(plan.projection_fields, vec!["id".to_string(), "name".to_string()]);
342    }
343
344    #[test]
345    fn test_plan_includes_jsonb_strategy() {
346        let planner = QueryPlanner::new(true);
347        let query_match = test_query_match();
348
349        let plan = planner.plan(&query_match).unwrap();
350        // Should include strategy in execution plan
351        assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
352    }
353
354    #[test]
355    fn test_planner_always_projects_when_fields_present() {
356        let custom_options = JsonbOptimizationOptions {
357            default_strategy:       JsonbStrategy::Stream,
358            auto_threshold_percent: 50,
359        };
360        let planner = QueryPlanner::with_jsonb_options(true, custom_options);
361        let query_match = test_query_match();
362
363        let plan = planner.plan(&query_match).unwrap();
364        // Even with Stream default, must use Project when selections exist
365        // to ensure camelCase response keys
366        assert_eq!(plan.jsonb_strategy, JsonbStrategy::Project);
367    }
368
369    #[test]
370    fn test_choose_jsonb_strategy_forces_project_with_fields() {
371        let options = JsonbOptimizationOptions {
372            default_strategy:       JsonbStrategy::Stream,
373            auto_threshold_percent: 80,
374        };
375        let planner = QueryPlanner::with_jsonb_options(true, options);
376
377        // Any non-empty selection set must use Project for camelCase keys
378        let strategy = planner.choose_jsonb_strategy(&["id".to_string(), "name".to_string()]);
379        assert_eq!(strategy, JsonbStrategy::Project);
380    }
381
382    #[test]
383    fn test_choose_jsonb_strategy_forces_project_with_many_fields() {
384        let options = JsonbOptimizationOptions {
385            default_strategy:       JsonbStrategy::Project,
386            auto_threshold_percent: 80,
387        };
388        let planner = QueryPlanner::with_jsonb_options(true, options);
389
390        // Even with many fields (above old threshold), must use Project
391        let many_fields = (0..9).map(|i| format!("field_{}", i)).collect::<Vec<_>>();
392        let strategy = planner.choose_jsonb_strategy(&many_fields);
393        assert_eq!(strategy, JsonbStrategy::Project);
394    }
395
396    #[test]
397    fn test_choose_jsonb_strategy_empty_fields_uses_default() {
398        let options = JsonbOptimizationOptions {
399            default_strategy:       JsonbStrategy::Stream,
400            auto_threshold_percent: 80,
401        };
402        let planner = QueryPlanner::with_jsonb_options(true, options);
403
404        // Empty selection set falls back to default strategy
405        let strategy = planner.choose_jsonb_strategy(&[]);
406        assert_eq!(strategy, JsonbStrategy::Stream);
407    }
408}