Skip to main content

fraiseql_core/runtime/executor/
execution.rs

1//! Core query execution — `execute()`, the shared `execute_dispatch()`, and
2//! `execute_with_scopes()`.
3
4use std::{sync::Arc, time::Duration};
5
6use super::{Executor, QueryType, pipeline, root_type_name, support};
7use crate::{
8    db::traits::DatabaseAdapter,
9    error::{FraiseQLError, Result},
10    security::{QueryValidator, SecurityContext},
11};
12
13impl<A: DatabaseAdapter> Executor<A> {
14    /// Execute a GraphQL query string and return a serialized JSON response.
15    ///
16    /// Applies the configured query timeout if one is set. Handles queries,
17    /// mutations, introspection, federation, and node lookups.
18    ///
19    /// If `RuntimeConfig::query_validation` is set, `QueryValidator::validate()`
20    /// runs first (before parsing or SQL dispatch) to enforce size, depth, and
21    /// complexity limits. This protects direct `fraiseql-core` embedders that do
22    /// not route through `fraiseql-server`.
23    ///
24    /// # Errors
25    ///
26    /// - `FraiseQLError::Validation` — query violates configured depth/complexity/alias limits
27    ///   (only when `RuntimeConfig::query_validation` is `Some`).
28    /// - `FraiseQLError::Timeout` — query exceeded `RuntimeConfig::query_timeout_ms`.
29    /// - Any error returned by `execute_dispatch`.
30    pub async fn execute(
31        &self,
32        query: &str,
33        variables: Option<&serde_json::Value>,
34    ) -> Result<serde_json::Value> {
35        // Anonymous entry: no principal. GATE-1, the parse cache, multi-root
36        // fan-out, and dispatch all live in the shared `execute_dispatch`.
37        self.execute_with_timeout(query, variables, None).await
38    }
39
40    /// Apply the configured query timeout (if any) around `execute_dispatch`.
41    ///
42    /// Shared by the anonymous [`execute`](Self::execute) and the authenticated
43    /// `execute_with_security` entry points so both honor `query_timeout_ms`
44    /// identically.
45    ///
46    /// # Errors
47    ///
48    /// - [`FraiseQLError::Timeout`] — execution exceeded `query_timeout_ms`.
49    /// - Any error returned by [`execute_dispatch`](Self::execute_dispatch).
50    pub(super) async fn execute_with_timeout(
51        &self,
52        query: &str,
53        variables: Option<&serde_json::Value>,
54        security_context: Option<&SecurityContext>,
55    ) -> Result<serde_json::Value> {
56        if self.ctx.config.query_timeout_ms > 0 {
57            let timeout_duration = Duration::from_millis(self.ctx.config.query_timeout_ms);
58            tokio::time::timeout(
59                timeout_duration,
60                self.execute_dispatch(query, variables, security_context),
61            )
62            .await
63            .map_err(|_| {
64                // Truncate query (char-boundary-safe) for error reporting.
65                let query_snippet = crate::utils::text::truncate_for_display(query, 100);
66                FraiseQLError::Timeout {
67                    timeout_ms: self.ctx.config.query_timeout_ms,
68                    query:      Some(query_snippet),
69                }
70            })?
71        } else {
72            self.execute_dispatch(query, variables, security_context).await
73        }
74    }
75
76    /// Unified query dispatch for both the anonymous and authenticated entry
77    /// points (H19). `security_context` is `None` for anonymous requests and
78    /// `Some` for authenticated ones; it threads through GATE-1, the parse
79    /// cache, the multi-root fan-out, and every per-operation runner so the two
80    /// paths cannot diverge in which roots they return, whether GATE-1 runs, or
81    /// whether the parse cache is consulted.
82    ///
83    /// # Errors
84    ///
85    /// - [`FraiseQLError::Validation`] — GATE-1 limits exceeded, or a runner rejects.
86    /// - [`FraiseQLError::Parse`] — GraphQL query string is not valid GraphQL syntax.
87    /// - [`FraiseQLError::NotFound`] — the query name does not match any compiled query template.
88    /// - [`FraiseQLError::Database`] — the underlying database returned an error.
89    /// - [`FraiseQLError::Internal`] — response serialisation failed.
90    /// - [`FraiseQLError::Authorization`] — field-level access control denied a field.
91    pub(super) async fn execute_dispatch(
92        &self,
93        query: &str,
94        variables: Option<&serde_json::Value>,
95        security_context: Option<&SecurityContext>,
96    ) -> Result<serde_json::Value> {
97        // GATE 1: query-structure validation (DoS protection for direct embedders).
98        // Runs on BOTH the anonymous and authenticated paths (L-gate1-skip).
99        if let Some(ref cfg) = self.ctx.config.query_validation {
100            QueryValidator::from_config(cfg.clone()).validate(query).map_err(|e| {
101                FraiseQLError::Validation {
102                    message: e.to_string(),
103                    path:    Some("query".to_string()),
104                }
105            })?;
106        }
107
108        // 1. Classify query type — also returns the ParsedQuery for Regular
109        // queries so we do not parse the same string twice.
110        //
111        // The parse result is memoised in `parse_cache` (keyed by xxHash64 of
112        // the query string) so repeated identical queries skip re-parsing — on
113        // both the anonymous and authenticated paths (L-parse-cache).
114        let cache_key = xxhash_rust::xxh3::xxh3_64(query.as_bytes());
115        let (query_type, maybe_parsed) = if let Some(arc) = self.ctx.parse_cache.get(&cache_key) {
116            arc.as_ref().clone()
117        } else {
118            let pair = self.classify_query_with_parse(query)?;
119            self.ctx.parse_cache.insert(cache_key, Arc::new(pair.clone()));
120            pair
121        };
122
123        // 1b. Operation-level authorization (#422). Runs before single- AND
124        //     multi-root dispatch so a deny short-circuits the parallel pipeline.
125        //     Mutations are gated downstream at `execute_mutation_impl`.
126        //     Fail-closed: a `Deny` or any policy error → 403.
127        if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
128            let ops = support::authz::collect_authz_ops(&query_type, maybe_parsed.as_ref());
129            crate::security::authorizer::enforce_authz(
130                authorizer.as_ref(),
131                security_context,
132                &ops,
133                variables,
134            )?;
135        }
136
137        // 2. Route to appropriate handler, threading the (optional) principal.
138        match query_type {
139            QueryType::Regular => {
140                // Detect multi-root queries and dispatch them in parallel.
141                // `maybe_parsed` is always Some for Regular queries (see
142                // classify_query_with_parse).
143                let parsed = maybe_parsed.ok_or_else(|| FraiseQLError::Internal {
144                    message: "classifier returned Regular without a parsed query — this is a bug"
145                        .to_string(),
146                    source:  None,
147                })?;
148                if pipeline::is_multi_root(&parsed) {
149                    let pr = self.execute_parallel(&parsed, variables, security_context).await?;
150                    let data = pr.merge_into_data_map();
151                    return Ok(serde_json::json!({ "data": data }));
152                }
153                self.query_runner()
154                    .execute_regular_query_maybe_security(query, variables, security_context)
155                    .await
156            },
157            QueryType::Aggregate(query_name) => {
158                self.aggregate_runner()
159                    .execute_aggregate_dispatch(&query_name, variables, security_context)
160                    .await
161            },
162            QueryType::Window(query_name) => {
163                self.aggregate_runner()
164                    .execute_window_dispatch(&query_name, variables, security_context)
165                    .await
166            },
167            #[cfg(feature = "federation")]
168            QueryType::Federation(query_name) => {
169                // The `_entities` path fails closed for RLS-/inject-/role-gated
170                // types when `security_context` is `None` (C1b).
171                self.execute_federation_query(&query_name, query, variables, security_context)
172                    .await
173            },
174            #[cfg(not(feature = "federation"))]
175            QueryType::Federation(_) => {
176                let _ = (query, variables);
177                Err(FraiseQLError::Validation {
178                    message: "Federation is not enabled in this build".to_string(),
179                    path:    None,
180                })
181            },
182            QueryType::IntrospectionSchema => {
183                // Return pre-built __schema response (zero-cost at runtime)
184                Ok(self.ctx.introspection.schema_response.as_ref().clone())
185            },
186            QueryType::IntrospectionType(type_name) => {
187                // Return pre-built __type response (zero-cost at runtime)
188                Ok(self.ctx.introspection.get_type_response(&type_name))
189            },
190            QueryType::Mutation {
191                name,
192                selections,
193                arguments,
194            } => {
195                self.execute_mutation_query(
196                    &name,
197                    variables,
198                    security_context,
199                    &selections,
200                    &arguments,
201                )
202                .await
203            },
204            QueryType::NodeQuery { selections } => {
205                // The node runner fails closed for any RLS/inject/role-gated type
206                // when `security_context` is `None` (H2 IDOR fix).
207                self.query_runner()
208                    .execute_node_query(query, variables, &selections, security_context)
209                    .await
210            },
211            QueryType::TypeName {
212                selection,
213                operation_type,
214            } => {
215                // Root `__typename` meta-field: resolve to the operation's root
216                // type name with no DB round-trip (spec §"Type Name Introspection").
217                // Honour `@skip`/`@include` on the field — a skipped meta-field is
218                // omitted from `data`, exactly like any other skipped field.
219                let vars: std::collections::HashMap<String, serde_json::Value> = match variables {
220                    Some(serde_json::Value::Object(map)) => {
221                        map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
222                    },
223                    _ => std::collections::HashMap::new(),
224                };
225                let included =
226                    crate::graphql::DirectiveEvaluator::evaluate_directives(&selection, &vars)
227                        .map_err(|e| FraiseQLError::Validation {
228                            message: e.to_string(),
229                            path:    Some("directives".to_string()),
230                        })?;
231                let mut data = serde_json::Map::new();
232                if included {
233                    data.insert(
234                        selection.response_key().to_string(),
235                        serde_json::Value::String(root_type_name(&operation_type).to_string()),
236                    );
237                }
238                Ok(serde_json::json!({ "data": data }))
239            },
240        }
241    }
242
243    /// Execute a GraphQL query with user context for field-level access control.
244    ///
245    /// This method validates that the user has permission to access all requested
246    /// fields before executing the query. If field filtering is enabled in the
247    /// `RuntimeConfig` and the user lacks required scopes, this returns an error.
248    ///
249    /// # Arguments
250    ///
251    /// * `query` - GraphQL query string
252    /// * `variables` - Query variables (optional)
253    /// * `user_scopes` - User's scopes from JWT token (pass empty slice if unauthenticated)
254    ///
255    /// # Returns
256    ///
257    /// GraphQL response as JSON string, or error if access denied
258    ///
259    /// # Errors
260    ///
261    /// * [`FraiseQLError::Validation`] — query validation fails, or the user's scopes do not
262    ///   include a field required by the `field_filter` policy.
263    /// * Propagates errors from query classification and execution.
264    ///
265    /// # Example
266    ///
267    /// ```no_run
268    /// // Requires: a live database adapter and authenticated user context.
269    /// // See: tests/integration/ for runnable examples.
270    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
271    /// let query = r#"query { users { id name salary } }"#;
272    /// // let user_scopes = user.scopes.clone();
273    /// // let result = executor.execute_with_scopes(query, None, &user_scopes).await?;
274    /// # Ok(()) }
275    /// ```
276    pub async fn execute_with_scopes(
277        &self,
278        query: &str,
279        variables: Option<&serde_json::Value>,
280        user_scopes: &[String],
281    ) -> Result<serde_json::Value> {
282        // GATE 1: Query structure validation (mirrors execute() — DoS protection).
283        if let Some(ref cfg) = self.ctx.config.query_validation {
284            QueryValidator::from_config(cfg.clone()).validate(query).map_err(|e| {
285                FraiseQLError::Validation {
286                    message: e.to_string(),
287                    path:    Some("query".to_string()),
288                }
289            })?;
290        }
291
292        // 2. Classify query type
293        let query_type = self.classify_query(query)?;
294
295        // 3. Validate field access if filter is configured
296        if let Some(ref filter) = self.ctx.config.field_filter {
297            // Only validate for regular queries (not introspection)
298            if matches!(query_type, QueryType::Regular) {
299                self.validate_field_access(query, variables, user_scopes, filter)?;
300            }
301        }
302
303        // 4. Delegate to execute_dispatch — single source of routing truth. Field-access validation
304        //    (step 3) has already run for Regular queries; all other query types (introspection,
305        //    aggregate, federation, …) are routed correctly via execute_dispatch without
306        //    duplication. Scope-based filtering is not RLS, so no SecurityContext is threaded.
307        self.execute_dispatch(query, variables, None).await
308    }
309}