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}