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}