Skip to main content

fraiseql_core/runtime/executor/
execution.rs

1//! Core query execution — `execute()`, `execute_internal()`, `execute_with_scopes()`.
2
3use std::time::Duration;
4
5use super::{Executor, QueryType, pipeline};
6use crate::{
7    db::traits::DatabaseAdapter,
8    error::{FraiseQLError, Result},
9    security::QueryValidator,
10};
11
12impl<A: DatabaseAdapter> Executor<A> {
13    /// Execute a GraphQL query string and return a serialized JSON response.
14    ///
15    /// Applies the configured query timeout if one is set. Handles queries,
16    /// mutations, introspection, federation, and node lookups.
17    ///
18    /// If `RuntimeConfig::query_validation` is set, `QueryValidator::validate()`
19    /// runs first (before parsing or SQL dispatch) to enforce size, depth, and
20    /// complexity limits. This protects direct `fraiseql-core` embedders that do
21    /// not route through `fraiseql-server`.
22    ///
23    /// # Errors
24    ///
25    /// - `FraiseQLError::Validation` — query violates configured depth/complexity/alias limits
26    ///   (only when `RuntimeConfig::query_validation` is `Some`).
27    /// - `FraiseQLError::Timeout` — query exceeded `RuntimeConfig::query_timeout_ms`.
28    /// - Any error returned by `execute_internal`.
29    pub async fn execute(
30        &self,
31        query: &str,
32        variables: Option<&serde_json::Value>,
33    ) -> Result<String> {
34        // GATE 1: Query structure validation (DoS protection for direct embedders).
35        if let Some(ref cfg) = self.config.query_validation {
36            QueryValidator::from_config(cfg.clone()).validate(query).map_err(|e| {
37                FraiseQLError::Validation {
38                    message: e.to_string(),
39                    path:    Some("query".to_string()),
40                }
41            })?;
42        }
43
44        // Apply query timeout if configured
45        if self.config.query_timeout_ms > 0 {
46            let timeout_duration = Duration::from_millis(self.config.query_timeout_ms);
47            tokio::time::timeout(timeout_duration, self.execute_internal(query, variables))
48                .await
49                .map_err(|_| {
50                    // Truncate query if too long for error reporting
51                    let query_snippet = if query.len() > 100 {
52                        format!("{}...", &query[..100])
53                    } else {
54                        query.to_string()
55                    };
56                    FraiseQLError::Timeout {
57                        timeout_ms: self.config.query_timeout_ms,
58                        query:      Some(query_snippet),
59                    }
60                })?
61        } else {
62            self.execute_internal(query, variables).await
63        }
64    }
65
66    /// Internal execution logic (called by `execute` with the timeout wrapper).
67    ///
68    /// # Errors
69    ///
70    /// - [`FraiseQLError::Parse`] — GraphQL query string is not valid GraphQL syntax.
71    /// - [`FraiseQLError::NotFound`] — the query name does not match any compiled query template.
72    /// - [`FraiseQLError::Database`] — the underlying database returned an error.
73    /// - [`FraiseQLError::Internal`] — response serialisation failed.
74    /// - [`FraiseQLError::Authorization`] — field-level access control denied a field.
75    pub(super) async fn execute_internal(
76        &self,
77        query: &str,
78        variables: Option<&serde_json::Value>,
79    ) -> Result<String> {
80        // 1. Classify query type — also returns the ParsedQuery for Regular
81        // queries so we do not parse the same string twice.
82        let (query_type, maybe_parsed) = self.classify_query_with_parse(query)?;
83
84        // 2. Route to appropriate handler
85        match query_type {
86            QueryType::Regular => {
87                // Detect multi-root queries and dispatch them in parallel.
88                // `maybe_parsed` is always Some for Regular queries (see
89                // classify_query_with_parse).
90                let parsed = maybe_parsed.ok_or_else(|| FraiseQLError::Internal {
91                    message: "classifier returned Regular without a parsed query — this is a bug"
92                        .to_string(),
93                    source:  None,
94                })?;
95                if pipeline::is_multi_root(&parsed) {
96                    let pr = self.execute_parallel(&parsed, variables).await?;
97                    let data = pr.merge_into_data_map();
98                    return serde_json::to_string(&serde_json::json!({ "data": data })).map_err(
99                        |e| FraiseQLError::Internal {
100                            message: e.to_string(),
101                            source:  None,
102                        },
103                    );
104                }
105                self.execute_regular_query(query, variables).await
106            },
107            QueryType::Aggregate(query_name) => {
108                self.execute_aggregate_dispatch(&query_name, variables).await
109            },
110            QueryType::Window(query_name) => {
111                self.execute_window_dispatch(&query_name, variables).await
112            },
113            #[cfg(feature = "federation")]
114            QueryType::Federation(query_name) => {
115                self.execute_federation_query(&query_name, query, variables).await
116            },
117            #[cfg(not(feature = "federation"))]
118            QueryType::Federation(_) => {
119                let _ = (query, variables);
120                Err(FraiseQLError::Validation {
121                    message: "Federation is not enabled in this build".to_string(),
122                    path:    None,
123                })
124            },
125            QueryType::IntrospectionSchema => {
126                // Return pre-built __schema response (zero-cost at runtime)
127                Ok(self.introspection.schema_response.clone())
128            },
129            QueryType::IntrospectionType(type_name) => {
130                // Return pre-built __type response (zero-cost at runtime)
131                Ok(self.introspection.get_type_response(&type_name))
132            },
133            QueryType::Mutation { name, selection_fields } => {
134                self.execute_mutation_query(&name, variables, &selection_fields).await
135            },
136            QueryType::NodeQuery => self.execute_node_query(query, variables).await,
137        }
138    }
139
140    /// Execute a GraphQL query with user context for field-level access control.
141    ///
142    /// This method validates that the user has permission to access all requested
143    /// fields before executing the query. If field filtering is enabled in the
144    /// `RuntimeConfig` and the user lacks required scopes, this returns an error.
145    ///
146    /// # Arguments
147    ///
148    /// * `query` - GraphQL query string
149    /// * `variables` - Query variables (optional)
150    /// * `user_scopes` - User's scopes from JWT token (pass empty slice if unauthenticated)
151    ///
152    /// # Returns
153    ///
154    /// GraphQL response as JSON string, or error if access denied
155    ///
156    /// # Errors
157    ///
158    /// * [`FraiseQLError::Validation`] — query validation fails, or the user's scopes do not
159    ///   include a field required by the `field_filter` policy.
160    /// * Propagates errors from query classification and execution.
161    ///
162    /// # Example
163    ///
164    /// ```no_run
165    /// // Requires: a live database adapter and authenticated user context.
166    /// // See: tests/integration/ for runnable examples.
167    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
168    /// let query = r#"query { users { id name salary } }"#;
169    /// // let user_scopes = user.scopes.clone();
170    /// // let result = executor.execute_with_scopes(query, None, &user_scopes).await?;
171    /// # Ok(()) }
172    /// ```
173    pub async fn execute_with_scopes(
174        &self,
175        query: &str,
176        variables: Option<&serde_json::Value>,
177        user_scopes: &[String],
178    ) -> Result<String> {
179        // GATE 1: Query structure validation (mirrors execute() — DoS protection).
180        if let Some(ref cfg) = self.config.query_validation {
181            QueryValidator::from_config(cfg.clone()).validate(query).map_err(|e| {
182                FraiseQLError::Validation {
183                    message: e.to_string(),
184                    path:    Some("query".to_string()),
185                }
186            })?;
187        }
188
189        // 2. Classify query type
190        let query_type = self.classify_query(query)?;
191
192        // 3. Validate field access if filter is configured
193        if let Some(ref filter) = self.config.field_filter {
194            // Only validate for regular queries (not introspection)
195            if matches!(query_type, QueryType::Regular) {
196                self.validate_field_access(query, variables, user_scopes, filter)?;
197            }
198        }
199
200        // 4. Delegate to execute_internal — single source of routing truth. Field-access validation
201        //    (step 3) has already run for Regular queries; all other query types (introspection,
202        //    aggregate, federation, …) are routed correctly via execute_internal without
203        //    duplication.
204        self.execute_internal(query, variables).await
205    }
206}