fraiseql_core/runtime/executor/execution.rs
1//! Core query execution — `execute()`, `execute_internal()`, `execute_with_scopes()`.
2
3use std::{sync::Arc, time::Duration};
4
5use super::{Executor, QueryType, pipeline, support};
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<serde_json::Value> {
34 // GATE 1: Query structure validation (DoS protection for direct embedders).
35 if let Some(ref cfg) = self.ctx.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.ctx.config.query_timeout_ms > 0 {
46 let timeout_duration = Duration::from_millis(self.ctx.config.query_timeout_ms);
47 tokio::time::timeout(timeout_duration, self.execute_internal(query, variables))
48 .await
49 .map_err(|_| {
50 // Truncate query (char-boundary-safe) for error reporting.
51 let query_snippet = crate::utils::text::truncate_for_display(query, 100);
52 FraiseQLError::Timeout {
53 timeout_ms: self.ctx.config.query_timeout_ms,
54 query: Some(query_snippet),
55 }
56 })?
57 } else {
58 self.execute_internal(query, variables).await
59 }
60 }
61
62 /// Internal execution logic (called by `execute` with the timeout wrapper).
63 ///
64 /// # Errors
65 ///
66 /// - [`FraiseQLError::Parse`] — GraphQL query string is not valid GraphQL syntax.
67 /// - [`FraiseQLError::NotFound`] — the query name does not match any compiled query template.
68 /// - [`FraiseQLError::Database`] — the underlying database returned an error.
69 /// - [`FraiseQLError::Internal`] — response serialisation failed.
70 /// - [`FraiseQLError::Authorization`] — field-level access control denied a field.
71 pub(super) async fn execute_internal(
72 &self,
73 query: &str,
74 variables: Option<&serde_json::Value>,
75 ) -> Result<serde_json::Value> {
76 // 1. Classify query type — also returns the ParsedQuery for Regular
77 // queries so we do not parse the same string twice.
78 //
79 // The parse result is memoised in `parse_cache` (keyed by xxHash64 of
80 // the query string) so repeated identical queries skip re-parsing.
81 let cache_key = xxhash_rust::xxh3::xxh3_64(query.as_bytes());
82 let (query_type, maybe_parsed) = if let Some(arc) = self.ctx.parse_cache.get(&cache_key) {
83 arc.as_ref().clone()
84 } else {
85 let pair = self.classify_query_with_parse(query)?;
86 self.ctx.parse_cache.insert(cache_key, Arc::new(pair.clone()));
87 pair
88 };
89
90 // 1b. Operation-level authorization (#422): anonymous path (no principal →
91 // `None`). Runs before single- AND multi-root dispatch so a deny
92 // short-circuits the parallel pipeline. Mutations are gated downstream at
93 // `execute_mutation_impl`. Fail-closed: a `Deny` or any policy error → 403.
94 if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
95 let ops = support::authz::collect_authz_ops(&query_type, maybe_parsed.as_ref());
96 crate::security::authorizer::enforce_authz(authorizer.as_ref(), None, &ops, variables)?;
97 }
98
99 // 2. Route to appropriate handler
100 match query_type {
101 QueryType::Regular => {
102 // Detect multi-root queries and dispatch them in parallel.
103 // `maybe_parsed` is always Some for Regular queries (see
104 // classify_query_with_parse).
105 let parsed = maybe_parsed.ok_or_else(|| FraiseQLError::Internal {
106 message: "classifier returned Regular without a parsed query — this is a bug"
107 .to_string(),
108 source: None,
109 })?;
110 if pipeline::is_multi_root(&parsed) {
111 let pr = self.execute_parallel(&parsed, variables).await?;
112 let data = pr.merge_into_data_map();
113 return Ok(serde_json::json!({ "data": data }));
114 }
115 self.query_runner().execute_regular_query(query, variables).await
116 },
117 QueryType::Aggregate(query_name) => {
118 self.aggregate_runner()
119 .execute_aggregate_dispatch(&query_name, variables, None)
120 .await
121 },
122 QueryType::Window(query_name) => {
123 self.aggregate_runner()
124 .execute_window_dispatch(&query_name, variables, None)
125 .await
126 },
127 #[cfg(feature = "federation")]
128 QueryType::Federation(query_name) => {
129 // Anonymous entrypoint: no SecurityContext. The `_entities` path fails
130 // closed for RLS-/inject-/role-gated types (C1b).
131 self.execute_federation_query(&query_name, query, variables, None).await
132 },
133 #[cfg(not(feature = "federation"))]
134 QueryType::Federation(_) => {
135 let _ = (query, variables);
136 Err(FraiseQLError::Validation {
137 message: "Federation is not enabled in this build".to_string(),
138 path: None,
139 })
140 },
141 QueryType::IntrospectionSchema => {
142 // Return pre-built __schema response (zero-cost at runtime)
143 Ok(self.ctx.introspection.schema_response.as_ref().clone())
144 },
145 QueryType::IntrospectionType(type_name) => {
146 // Return pre-built __type response (zero-cost at runtime)
147 Ok(self.ctx.introspection.get_type_response(&type_name))
148 },
149 QueryType::Mutation { name, selections } => {
150 self.execute_mutation_query(&name, variables, &selections).await
151 },
152 QueryType::NodeQuery { selections } => {
153 // Anonymous entrypoint: no SecurityContext. The node runner fails closed for
154 // any RLS/inject/role-gated type (H2 IDOR fix).
155 self.query_runner()
156 .execute_node_query(query, variables, &selections, None)
157 .await
158 },
159 }
160 }
161
162 /// Execute a GraphQL query with user context for field-level access control.
163 ///
164 /// This method validates that the user has permission to access all requested
165 /// fields before executing the query. If field filtering is enabled in the
166 /// `RuntimeConfig` and the user lacks required scopes, this returns an error.
167 ///
168 /// # Arguments
169 ///
170 /// * `query` - GraphQL query string
171 /// * `variables` - Query variables (optional)
172 /// * `user_scopes` - User's scopes from JWT token (pass empty slice if unauthenticated)
173 ///
174 /// # Returns
175 ///
176 /// GraphQL response as JSON string, or error if access denied
177 ///
178 /// # Errors
179 ///
180 /// * [`FraiseQLError::Validation`] — query validation fails, or the user's scopes do not
181 /// include a field required by the `field_filter` policy.
182 /// * Propagates errors from query classification and execution.
183 ///
184 /// # Example
185 ///
186 /// ```no_run
187 /// // Requires: a live database adapter and authenticated user context.
188 /// // See: tests/integration/ for runnable examples.
189 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
190 /// let query = r#"query { users { id name salary } }"#;
191 /// // let user_scopes = user.scopes.clone();
192 /// // let result = executor.execute_with_scopes(query, None, &user_scopes).await?;
193 /// # Ok(()) }
194 /// ```
195 pub async fn execute_with_scopes(
196 &self,
197 query: &str,
198 variables: Option<&serde_json::Value>,
199 user_scopes: &[String],
200 ) -> Result<serde_json::Value> {
201 // GATE 1: Query structure validation (mirrors execute() — DoS protection).
202 if let Some(ref cfg) = self.ctx.config.query_validation {
203 QueryValidator::from_config(cfg.clone()).validate(query).map_err(|e| {
204 FraiseQLError::Validation {
205 message: e.to_string(),
206 path: Some("query".to_string()),
207 }
208 })?;
209 }
210
211 // 2. Classify query type
212 let query_type = self.classify_query(query)?;
213
214 // 3. Validate field access if filter is configured
215 if let Some(ref filter) = self.ctx.config.field_filter {
216 // Only validate for regular queries (not introspection)
217 if matches!(query_type, QueryType::Regular) {
218 self.validate_field_access(query, variables, user_scopes, filter)?;
219 }
220 }
221
222 // 4. Delegate to execute_internal — single source of routing truth. Field-access validation
223 // (step 3) has already run for Regular queries; all other query types (introspection,
224 // aggregate, federation, …) are routed correctly via execute_internal without
225 // duplication.
226 self.execute_internal(query, variables).await
227 }
228}