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    pub(crate) const fn choose_jsonb_strategy(
124        &self,
125        projection_fields: &[String],
126    ) -> JsonbStrategy {
127        if projection_fields.is_empty() {
128            self.jsonb_options.default_strategy
129        } else {
130            JsonbStrategy::Project
131        }
132    }
133
134    /// Extract field names for projection from parsed selections.
135    ///
136    /// For a query like `{ users { id name } }`, this extracts `["id", "name"]`.
137    ///
138    /// Filter `__typename` from SQL projection fields.
139    /// `__typename` is a GraphQL meta-field not stored in JSONB.
140    /// The `ResultProjector` handles injection — see `projection.rs`.
141    /// Removing this filter causes `data->>'__typename'` (NULL) to overwrite
142    /// the value injected by `with_typename()`, depending on field iteration order.
143    fn extract_projection_fields(&self, selections: &[FieldSelection]) -> Vec<String> {
144        // Get the first (root) selection and extract its nested fields.
145        // Skip `__typename` — it is a GraphQL meta-field handled by the projector
146        // at the Rust level; including it in the field list causes the SQL projection
147        // to emit `data->>'__typename'` which returns NULL and then overwrites the
148        // correctly-computed typename injected by `ResultProjector::with_typename`.
149        if let Some(root_selection) = selections.first() {
150            root_selection
151                .nested_fields
152                .iter()
153                .filter(|f| f.name != "__typename")
154                .map(|f| f.response_key().to_string())
155                .collect()
156        } else {
157            Vec::new()
158        }
159    }
160
161    /// Generate SQL from query match.
162    pub(crate) fn generate_sql(&self, query_match: &QueryMatch) -> String {
163        // Get SQL source from query definition
164        let table = query_match.query_def.sql_source.as_ref().map_or("unknown", String::as_str);
165
166        // Build basic SELECT query
167        // Select all data — projection happens later in the execution pipeline
168        let fields_sql = "data".to_string();
169
170        format!("SELECT {fields_sql} FROM {table}")
171    }
172
173    /// Extract parameters from query match.
174    pub(crate) fn extract_parameters(
175        &self,
176        query_match: &QueryMatch,
177    ) -> Vec<(String, serde_json::Value)> {
178        query_match.arguments.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
179    }
180
181    /// Estimate query cost (for optimization).
182    pub(crate) fn estimate_cost(&self, query_match: &QueryMatch) -> usize {
183        // Simple heuristic: base cost + field cost
184        let base_cost = 100;
185        let field_cost = query_match.fields.len() * 10;
186        let arg_cost = query_match.arguments.len() * 5;
187
188        base_cost + field_cost + arg_cost
189    }
190
191    /// Check if caching is enabled.
192    #[must_use]
193    pub const fn cache_enabled(&self) -> bool {
194        self.cache_enabled
195    }
196}