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}