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 error::{FraiseQLError, Result},
9 security::SecurityContext,
10};
11
12impl Executor {
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_dispatch`.
29 pub async fn execute(
30 &self,
31 query: &str,
32 variables: Option<&serde_json::Value>,
33 ) -> Result<serde_json::Value> {
34 // Anonymous entry: no principal. GATE-1, the parse cache, multi-root
35 // fan-out, and dispatch all live in the shared `execute_dispatch`.
36 //
37 // No operation name: the document must define exactly one operation
38 // (GraphQL § 6.1). Callers that carry an `operationName` — every HTTP
39 // request does — must use [`execute_operation`](Self::execute_operation)
40 // instead, or a two-operation document silently runs the first one.
41 self.execute_with_timeout(query, variables, None, None).await
42 }
43
44 /// Execute a document, selecting the operation named by `operation_name`
45 /// (GraphQL § 6.1 *`GetOperation`*).
46 ///
47 /// [`execute`](Self::execute) is this with `None`, which requires the
48 /// document to define exactly one operation.
49 ///
50 /// # Errors
51 ///
52 /// - [`FraiseQLError::Parse`] — the document does not parse, names an operation that does not
53 /// exist, or defines several operations while `operation_name` is `None`.
54 /// - Any error returned by [`execute`](Self::execute).
55 pub async fn execute_operation(
56 &self,
57 query: &str,
58 variables: Option<&serde_json::Value>,
59 operation_name: Option<&str>,
60 ) -> Result<serde_json::Value> {
61 self.execute_with_timeout(query, variables, None, normalize_operation_name(operation_name))
62 .await
63 }
64
65 /// Apply the configured query timeout (if any) around `execute_dispatch`.
66 ///
67 /// Shared by the anonymous [`execute`](Self::execute) and the authenticated
68 /// `execute_with_security` entry points so both honor `query_timeout_ms`
69 /// identically.
70 ///
71 /// # Errors
72 ///
73 /// - [`FraiseQLError::Timeout`] — execution exceeded `query_timeout_ms`.
74 /// - Any error returned by [`execute_dispatch`](Self::execute_dispatch).
75 pub(super) async fn execute_with_timeout(
76 &self,
77 query: &str,
78 variables: Option<&serde_json::Value>,
79 security_context: Option<&SecurityContext>,
80 operation_name: Option<&str>,
81 ) -> Result<serde_json::Value> {
82 // #1336 backstop: a principal that never passed a transport's enrichment seam
83 // does not execute. Placed here rather than at the four public entry points
84 // above it, because every GraphQL-document path funnels through this one.
85 crate::runtime::executor::support::security::enforce_enrichment_resolved(
86 &self.ctx.schema,
87 security_context,
88 )?;
89
90 if self.ctx.config.query_timeout_ms > 0 {
91 let timeout_duration = Duration::from_millis(self.ctx.config.query_timeout_ms);
92 tokio::time::timeout(
93 timeout_duration,
94 self.execute_dispatch(query, variables, security_context, operation_name),
95 )
96 .await
97 .map_err(|_| {
98 // Truncate query (char-boundary-safe) for error reporting.
99 let query_snippet = crate::utils::text::truncate_for_display(query, 100);
100 FraiseQLError::Timeout {
101 timeout_ms: self.ctx.config.query_timeout_ms,
102 query: Some(query_snippet),
103 }
104 })?
105 } else {
106 self.execute_dispatch(query, variables, security_context, operation_name).await
107 }
108 }
109
110 /// GATE 1: query-structure validation (depth/complexity/size `DoS` limits).
111 ///
112 /// The single implementation behind every entry point — `execute_dispatch`
113 /// and `execute_with_scopes` both call this, so the gate cannot drift
114 /// between them (#736).
115 ///
116 /// The validator is resolved once at construction: the embedder-installed
117 /// `RuntimeConfig::query_validation` when set, otherwise derived from the
118 /// compiled schema's declared `[validation]` limits (#379). Variables are
119 /// threaded through so a variable-valued pagination argument is scored at
120 /// its resolved value, not the fail-closed ceiling (#869).
121 fn run_gate1(&self, query: &str, variables: Option<&serde_json::Value>) -> Result<()> {
122 self.ctx.gate1.validate_with_variables(query, variables).map_err(|e| {
123 FraiseQLError::Validation {
124 message: e.to_string(),
125 path: Some("query".to_string()),
126 }
127 })?;
128
129 // #379: the compiled [security.cost_budget] per_request_max is enforced
130 // here, at the one chokepoint every document-executing transport
131 // shares, so no transport can serve an operation the operator declared
132 // too expensive. An unparseable document is left for the dispatch's own
133 // parse-error path.
134 if let Some(cap) = self.ctx.config.max_operation_cost {
135 if let Ok(doc) = crate::graphql::parse_graphql_document(query) {
136 let cost = crate::graphql::estimate_query_cost(
137 &doc,
138 &self.ctx.schema.operation_cost_weights,
139 variables,
140 ) as u64;
141 if cost > cap {
142 return Err(FraiseQLError::CostExceeded {
143 message: format!(
144 "operation cost {cost} exceeds the schema-wide per-request maximum of \
145 {cap} ([security.cost_budget] per_request_max)"
146 ),
147 cost,
148 limit: cap,
149 retry_after_secs: None,
150 });
151 }
152 }
153 }
154 Ok(())
155 }
156
157 /// Answer an introspection document: every root, under its response key
158 /// (GraphQL § 2.7), with `__type`'s name read from this request (#1445).
159 ///
160 /// A data root cannot be answered here, and is refused rather than dropped.
161 fn execute_introspection(
162 &self,
163 parsed: &crate::graphql::ParsedQuery,
164 variables: Option<&serde_json::Value>,
165 ) -> Result<serde_json::Value> {
166 let vars: std::collections::HashMap<String, serde_json::Value> = match variables {
167 Some(serde_json::Value::Object(map)) => {
168 map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
169 },
170 _ => std::collections::HashMap::new(),
171 };
172
173 let mut data = serde_json::Map::new();
174 for root in &parsed.selections {
175 let included = crate::graphql::DirectiveEvaluator::evaluate_directives(root, &vars)
176 .map_err(|e| FraiseQLError::Validation {
177 message: e.to_string(),
178 path: Some("directives".to_string()),
179 })?;
180 if !included {
181 continue;
182 }
183 let value = match root.name.as_str() {
184 "__schema" => self.project_introspection(
185 "__schema",
186 &self.ctx.introspection.schema_response,
187 root,
188 parsed,
189 ),
190 "__type" => {
191 let type_name = introspection_type_name(root, &vars)?;
192 let built = Arc::new(self.ctx.introspection.get_type_response(&type_name));
193 // The type name is part of the memo key: two `__type` roots
194 // with the same selection set but different names project
195 // different values.
196 self.project_introspection(
197 &format!("__type\u{1}{type_name}"),
198 &built,
199 root,
200 parsed,
201 )
202 },
203 "__typename" => {
204 serde_json::Value::String(root_type_name(&parsed.operation_type).to_string())
205 },
206 other => {
207 return Err(FraiseQLError::Validation {
208 message: format!(
209 "`{other}` cannot be selected in the same operation as `__schema` or \
210 `__type`: send it as a separate request"
211 ),
212 path: Some(root.response_key().to_string()),
213 });
214 },
215 };
216 data.insert(root.response_key().to_string(), value);
217 }
218 Ok(serde_json::json!({ "data": data }))
219 }
220
221 /// Project the value a pre-built introspection response holds for `root`
222 /// onto that root's selection set (GraphQL § 6.3), memoised by the shape of
223 /// the selection set.
224 ///
225 /// `memo_root` keys the cache and must distinguish values that differ for
226 /// reasons other than the selection set — hence the type name is folded into
227 /// it for `__type`.
228 ///
229 /// Falls back to the unprojected value when there is no selection to project
230 /// against: over-delivering is strictly better than answering with nothing.
231 fn project_introspection(
232 &self,
233 memo_root: &str,
234 built: &Arc<serde_json::Value>,
235 root: &crate::graphql::FieldSelection,
236 parsed: &crate::graphql::ParsedQuery,
237 ) -> serde_json::Value {
238 use support::introspection_projection as projection;
239
240 let inner = built
241 .get("data")
242 .and_then(|d| d.get(&root.name))
243 .cloned()
244 .unwrap_or(serde_json::Value::Null);
245 // Fragments and directives are resolved first so a spread projects like
246 // an inline selection.
247 let Ok(selections) = crate::graphql::selection_set::resolve(
248 &root.nested_fields,
249 &parsed.fragments,
250 self.max_query_depth(),
251 ) else {
252 return inner;
253 };
254 if selections.is_empty() {
255 return inner;
256 }
257
258 let key = projection::selection_shape_hash(memo_root, &selections);
259 if let Some(hit) = self.ctx.introspection_projections.get(&key) {
260 return hit.as_ref().clone();
261 }
262 let projected = Arc::new(projection::project(&inner, &selections));
263 self.ctx.introspection_projections.insert(key, Arc::clone(&projected));
264 projected.as_ref().clone()
265 }
266
267 /// Classify a document, memoising the verdict in the parse cache.
268 ///
269 /// The one place a document is turned into a [`QueryType`]. Both
270 /// [`execute_dispatch`](Self::execute_dispatch) — which routes on the verdict —
271 /// and [`execute_read_only`](Self::execute_read_only) — which refuses a write
272 /// on it — go through here, so the refusal cannot answer a different question
273 /// than the dispatch it is guarding.
274 ///
275 /// Only the `Ok` pair is cached, so a document rejected by GraphQL § 5.8.3 is
276 /// re-checked rather than serving a cached verdict; and because validity of a
277 /// *definition* is a property of the document alone — never of this request's
278 /// variable *values* — caching the accepted verdict is sound.
279 fn classify_cached(
280 &self,
281 query: &str,
282 operation_name: Option<&str>,
283 ) -> Result<support::classify::Classification> {
284 let cache_key = parse_cache_key(query, operation_name);
285 if let Some(arc) = self.ctx.parse_cache.get(&cache_key) {
286 return Ok(arc.as_ref().clone());
287 }
288 let pair = self.classify_query_with_parse(query, operation_name)?;
289 self.ctx.parse_cache.insert(cache_key, Arc::new(pair.clone()));
290 Ok(pair)
291 }
292
293 /// Execute a document that the engine would **not** execute as a write, as
294 /// `security_context` (#1328).
295 ///
296 /// The engine half of the `before:mutation` read bridge. It is
297 /// [`execute_with_timeout`](Self::execute_with_timeout) with one thing in
298 /// front: a document the classifier calls a mutation is refused by name,
299 /// before dispatch, so the bridge cannot become a second write path.
300 ///
301 /// The refusal is decided on
302 /// [`classify_cached`](Self::classify_cached) — the same verdict, from the same
303 /// memoised classification, that `execute_dispatch` would have routed on. A
304 /// second, independent parse here could disagree with the dispatch it is meant
305 /// to guard; this cannot.
306 ///
307 /// No operation name: the guest bridge (`fraiseql_query`) has no field to carry
308 /// one, so a multi-operation document is already refused as ambiguous by
309 /// GraphQL § 6.1.
310 ///
311 /// The refusal is also what terminates the recursion. A hook reaches this
312 /// through `execute_mutation_impl`, so a bridge that executed a mutation would
313 /// run that mutation's own `before:mutation` chain, which would issue the same
314 /// document again: with the check removed, the pin in
315 /// `runners::mutation::tests::before_mutation_read_bridge` does not fail an
316 /// assertion, it overflows the stack.
317 ///
318 /// # Re-entry into a function is refused (#1329)
319 ///
320 /// A document whose root field is **function-backed** is refused here too, and
321 /// for a reason of the same kind: the bridge is what a guest reads through, so a
322 /// guest reading a function-backed field spins a second isolate inside the first,
323 /// and a field that reads itself recurses until the query timeout, one isolate
324 /// per level. Root fields are the only place a function can be bound, so
325 /// checking the roots checks the document.
326 ///
327 /// This is the guest-side half of the "one invocation per query" property the
328 /// root-only rule gives the client-side half of. Composition belongs inside the
329 /// function, where it costs no isolate.
330 ///
331 /// # Errors
332 ///
333 /// - [`FraiseQLError::Authorization`] — the document's operation is a mutation.
334 /// - [`FraiseQLError::Validation`] — a root field is function-backed (#1329).
335 /// - Any error returned by [`execute_with_timeout`](Self::execute_with_timeout).
336 pub(super) async fn execute_read_only(
337 &self,
338 query: &str,
339 variables: Option<&serde_json::Value>,
340 security_context: Option<&SecurityContext>,
341 ) -> Result<serde_json::Value> {
342 let (query_type, parsed, _) = self.classify_cached(query, None)?;
343 if matches!(query_type, QueryType::Mutation { .. }) {
344 return Err(FraiseQLError::Authorization {
345 message: "the before:mutation read bridge is read-only: this document is a \
346 mutation, and a before-hook may not write"
347 .to_string(),
348 action: Some("before_mutation_read".to_string()),
349 resource: None,
350 });
351 }
352 if let Some(parsed) = parsed.as_ref() {
353 self.refuse_function_backed_roots(parsed)?;
354 }
355 self.execute_with_timeout(query, variables, security_context, None).await
356 }
357
358 /// Refuse a guest read whose root field is answered by a function (#1329).
359 ///
360 /// Keyed on the field **name**, never the alias, for the same reason the
361 /// `before:mutation` chain is: an alias changes only the response key, and
362 /// keying on it would let `alias: quotePreview` through the check that
363 /// `quotePreview` fails.
364 fn refuse_function_backed_roots(&self, parsed: &crate::graphql::ParsedQuery) -> Result<()> {
365 for root in &parsed.selections {
366 let Some(query_def) = self.ctx.schema.find_query(&root.name) else {
367 continue;
368 };
369 if let Some(function) = query_def.function.as_deref() {
370 return Err(FraiseQLError::Validation {
371 message: format!(
372 "`{}` is backed by the function `{function}`, and a function may not be \
373 invoked from inside another one: the read bridge would spin a second \
374 isolate inside the first, and a field reading itself would recurse \
375 until the query timed out. Call what that function calls, or move the \
376 shared work into a module both import.",
377 root.name
378 ),
379 path: Some(root.name.clone()),
380 });
381 }
382 }
383 Ok(())
384 }
385
386 /// Unified query dispatch for both the anonymous and authenticated entry
387 /// points (H19). `security_context` is `None` for anonymous requests and
388 /// `Some` for authenticated ones; it threads through GATE-1, the parse
389 /// cache, the multi-root fan-out, and every per-operation runner so the two
390 /// paths cannot diverge in which roots they return, whether GATE-1 runs, or
391 /// whether the parse cache is consulted.
392 ///
393 /// # Errors
394 ///
395 /// - [`FraiseQLError::Validation`] — GATE-1 limits exceeded, or a runner rejects.
396 /// - [`FraiseQLError::Parse`] — GraphQL query string is not valid GraphQL syntax.
397 /// - [`FraiseQLError::NotFound`] — the query name does not match any compiled query template.
398 /// - [`FraiseQLError::Database`] — the underlying database returned an error.
399 /// - [`FraiseQLError::Internal`] — response serialisation failed.
400 /// - [`FraiseQLError::Authorization`] — field-level access control denied a field.
401 pub(super) async fn execute_dispatch(
402 &self,
403 query: &str,
404 variables: Option<&serde_json::Value>,
405 security_context: Option<&SecurityContext>,
406 operation_name: Option<&str>,
407 ) -> Result<serde_json::Value> {
408 // 1. Classify query type — also returns the ParsedQuery for Regular
409 // queries so we do not parse the same string twice. Classification also
410 // enforces GraphQL § 5.8.3 (every variable used must be defined).
411 //
412 // The parse result is memoised in `parse_cache` (keyed by xxHash64 of
413 // the query string) so repeated identical queries skip re-parsing — on
414 // both the anonymous and authenticated paths (L-parse-cache). Only the
415 // `Ok` pair is cached, so a document rejected by § 5.8.3 is re-checked
416 // rather than serving a cached verdict; and because validity of a
417 // *definition* is a property of the document alone — never of this
418 // request's variable *values* — caching the accepted verdict is sound.
419 //
420 // This runs BEFORE GATE-1, deliberately. GATE-1 scores cost, and a
421 // document whose `limit: $undeclared` was silently dropped becomes
422 // unbounded and trips the complexity ceiling — so with GATE-1 first the
423 // client receives an error about *cost* that never mentions the
424 // variable, which is harder to debug than no error at all. Reordering a
425 // DoS gate exposes no new surface here: `parse_query` goes through
426 // `complexity::parse_graphql_document`, the same panic-guarded seam
427 // GATE-1 itself parses with (`validate_with_variables`, and
428 // `parse_graphql_document` again for `max_operation_cost`), so the
429 // parser sees nothing it was not already going to see.
430 let (query_type, maybe_parsed, variable_defaults) =
431 self.classify_cached(query, operation_name)?;
432
433 // GraphQL § 6.4.1 (#1504): a variable the request omits takes its declared
434 // default, before anything reads the variables — GATE-1, the authorizer and
435 // every runner — so `$l: Int = 1` limits the read and a defaulted `where`
436 // filters it. The defaults belong to the document, so they are cached with
437 // its classification and merged here, per request.
438 let defaulted =
439 crate::graphql::value_json::with_variable_defaults(&variable_defaults, variables);
440 let variables = defaulted.as_ref().or(variables);
441
442 // GATE 1: query-structure validation (DoS protection for direct embedders).
443 // Runs on BOTH the anonymous and authenticated paths (L-gate1-skip).
444 self.run_gate1(query, variables)?;
445
446 // 1b. Operation-level authorization (#422). Runs before single- AND
447 // multi-root dispatch so a deny short-circuits the parallel pipeline.
448 // Mutations are gated downstream at `execute_mutation_impl`.
449 // Fail-closed: a `Deny` or any policy error → 403.
450 if let Some(authorizer) = self.ctx.config.authorizer.as_ref() {
451 let ops = support::authz::collect_authz_ops(
452 &query_type,
453 maybe_parsed.as_ref(),
454 &self.ctx.schema,
455 );
456 crate::security::authorizer::enforce_authz(
457 authorizer.as_ref(),
458 security_context,
459 &ops,
460 variables,
461 )?;
462 }
463
464 // 2. Route to appropriate handler, threading the (optional) principal.
465 match query_type {
466 QueryType::Regular => {
467 // Detect multi-root queries and dispatch them in parallel.
468 // `maybe_parsed` is always Some for Regular queries (see
469 // classify_query_with_parse).
470 let parsed = maybe_parsed.ok_or_else(|| FraiseQLError::Internal {
471 message: "classifier returned Regular without a parsed query — this is a bug"
472 .to_string(),
473 source: None,
474 })?;
475 if pipeline::is_multi_root(&parsed) {
476 // § 6.1.2 for the one shape `QueryMatcher` structurally
477 // cannot check: `field_selection_to_query` re-serialises
478 // each root into a synthetic single-root document that
479 // re-emits `$name` references but carries no variable
480 // *definitions*, so the matcher's copy of this check sees
481 // an empty declaration list and passes. Not in
482 // `classify_query_with_parse` with its siblings: that
483 // function's verdict is memoised in the parse cache, which
484 // is keyed on the document and not on the variables, so a
485 // value-dependent check there would answer the first
486 // request's question for every later one (#1197).
487 crate::runtime::validate_variable_values(
488 parsed.operation_name.as_deref(),
489 &parsed.variables,
490 variables,
491 )?;
492 // #1362, for the same shape and the same reason: the matcher's
493 // copy of the enum check also sees an empty declaration list on a
494 // re-serialised root, so a multi-root document's variables would
495 // reach SQL with an enum nobody adjudicated.
496 crate::runtime::validate_enum_variable_values(
497 &self.ctx.schema,
498 parsed.operation_name.as_deref(),
499 &parsed.variables,
500 variables,
501 )?;
502 let pr = self.execute_parallel(&parsed, variables, security_context).await?;
503 let data = pr.merge_into_data_map();
504 return Ok(serde_json::json!({ "data": data }));
505 }
506 self.query_runner()
507 .execute_regular_query_maybe_security(
508 query,
509 variables,
510 security_context,
511 operation_name,
512 )
513 .await
514 },
515 QueryType::Aggregate(query_name) => {
516 self.aggregate_runner()
517 .execute_aggregate_dispatch(&query_name, variables, security_context)
518 .await
519 },
520 QueryType::Window(query_name) => {
521 self.aggregate_runner()
522 .execute_window_dispatch(&query_name, variables, security_context)
523 .await
524 },
525 #[cfg(feature = "federation")]
526 QueryType::Federation(query_name) => {
527 // The `_entities` path fails closed for RLS-/inject-/role-gated
528 // types when `security_context` is `None` (C1b).
529 self.execute_federation_query(&query_name, query, variables, security_context)
530 .await
531 },
532 #[cfg(not(feature = "federation"))]
533 QueryType::Federation(_) => {
534 let _ = (query, variables);
535 Err(FraiseQLError::Validation {
536 message: "Federation is not enabled in this build".to_string(),
537 path: None,
538 })
539 },
540 QueryType::Introspection => {
541 let parsed = maybe_parsed.ok_or_else(|| FraiseQLError::Internal {
542 message: "classifier returned Introspection without a parsed query — this \
543 is a bug"
544 .to_string(),
545 source: None,
546 })?;
547 self.execute_introspection(&parsed, variables)
548 },
549 QueryType::Mutation { roots } => {
550 self.execute_mutation_roots(&roots, variables, security_context).await
551 },
552 QueryType::NodeQuery { selections } => {
553 // The node runner fails closed for any RLS/inject/role-gated type
554 // when `security_context` is `None` (H2 IDOR fix).
555 self.query_runner()
556 .execute_node_query(query, variables, &selections, security_context)
557 .await
558 },
559 QueryType::TypeName {
560 selection,
561 operation_type,
562 } => {
563 // Root `__typename` meta-field: resolve to the operation's root
564 // type name with no DB round-trip (spec §"Type Name Introspection").
565 // Honour `@skip`/`@include` on the field — a skipped meta-field is
566 // omitted from `data`, exactly like any other skipped field.
567 let vars: std::collections::HashMap<String, serde_json::Value> = match variables {
568 Some(serde_json::Value::Object(map)) => {
569 map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
570 },
571 _ => std::collections::HashMap::new(),
572 };
573 let included =
574 crate::graphql::DirectiveEvaluator::evaluate_directives(&selection, &vars)
575 .map_err(|e| FraiseQLError::Validation {
576 message: e.to_string(),
577 path: Some("directives".to_string()),
578 })?;
579 let mut data = serde_json::Map::new();
580 if included {
581 data.insert(
582 selection.response_key().to_string(),
583 serde_json::Value::String(root_type_name(&operation_type).to_string()),
584 );
585 }
586 Ok(serde_json::json!({ "data": data }))
587 },
588 }
589 }
590
591 /// Execute a GraphQL query with user context for field-level access control.
592 ///
593 /// This method validates that the user has permission to access all requested
594 /// fields before executing the query. If field filtering is enabled in the
595 /// `RuntimeConfig` and the user lacks required scopes, this returns an error.
596 ///
597 /// # Arguments
598 ///
599 /// * `query` - GraphQL query string
600 /// * `variables` - Query variables (optional)
601 /// * `user_scopes` - User's scopes from JWT token (pass empty slice if unauthenticated)
602 ///
603 /// # Returns
604 ///
605 /// GraphQL response as JSON string, or error if access denied
606 ///
607 /// # Errors
608 ///
609 /// * [`FraiseQLError::Validation`] — query validation fails, or the user's scopes do not
610 /// include a field required by the `field_filter` policy.
611 /// * Propagates errors from query classification and execution.
612 ///
613 /// # Example
614 ///
615 /// ```no_run
616 /// // Requires: a live database adapter and authenticated user context.
617 /// // See: tests/integration/ for runnable examples.
618 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
619 /// let query = r#"query { users { id name salary } }"#;
620 /// // let user_scopes = user.scopes.clone();
621 /// // let result = executor.execute_with_scopes(query, None, &user_scopes).await?;
622 /// # Ok(()) }
623 /// ```
624 pub async fn execute_with_scopes(
625 &self,
626 query: &str,
627 variables: Option<&serde_json::Value>,
628 user_scopes: &[String],
629 ) -> Result<serde_json::Value> {
630 // GATE 1: Query structure validation (mirrors execute() — DoS protection).
631 // Same shared gate as execute_dispatch — the two call sites cannot drift (#736).
632 self.run_gate1(query, variables)?;
633
634 // 2. Classify query type
635 let query_type = self.classify_query(query, None)?;
636
637 // 3. Validate field access if filter is configured
638 if let Some(ref filter) = self.ctx.config.field_filter {
639 // Only validate for regular queries (not introspection)
640 if matches!(query_type, QueryType::Regular) {
641 self.validate_field_access(query, variables, user_scopes, filter)?;
642 }
643 }
644
645 // 4. Delegate to execute_dispatch — single source of routing truth. Field-access validation
646 // (step 3) has already run for Regular queries; all other query types (introspection,
647 // aggregate, federation, …) are routed correctly via execute_dispatch without
648 // duplication. Scope-based filtering is not RLS, so no SecurityContext is threaded.
649 self.execute_dispatch(query, variables, None, None).await
650 }
651
652 /// Execute a mutation operation's root fields serially, in document order.
653 ///
654 /// GraphQL requires this: mutation roots may depend on each other's side
655 /// effects, so they are never run concurrently and never reordered. Each
656 /// root's result appears under its response key (its alias when it has one),
657 /// so two calls to the same mutation stay distinct.
658 ///
659 /// A root that fails contributes `null` at its key plus an entry in `errors`,
660 /// and the remaining roots still execute — a client that issued three writes
661 /// needs to know which of them landed, and a single error envelope naming
662 /// none of them does not say. The one-root case keeps propagating `Err`
663 /// unchanged: there is no partial state to report, and every mutation error
664 /// test in the workspace pins that shape.
665 ///
666 /// # Errors
667 ///
668 /// Returns the root's error when the operation has exactly one root, or
669 /// [`FraiseQLError::Validation`] when the operation has none.
670 async fn execute_mutation_roots(
671 &self,
672 roots: &[super::MutationRoot],
673 variables: Option<&serde_json::Value>,
674 security_context: Option<&SecurityContext>,
675 ) -> Result<serde_json::Value> {
676 let [single] = roots else {
677 return self.execute_mutation_roots_serially(roots, variables, security_context).await;
678 };
679 self.execute_mutation_query(
680 &single.name,
681 &single.response_key,
682 variables,
683 security_context,
684 &single.selections,
685 &single.arguments,
686 )
687 .await
688 }
689
690 /// The multi-root arm of [`execute_mutation_roots`].
691 async fn execute_mutation_roots_serially(
692 &self,
693 roots: &[super::MutationRoot],
694 variables: Option<&serde_json::Value>,
695 security_context: Option<&SecurityContext>,
696 ) -> Result<serde_json::Value> {
697 if roots.is_empty() {
698 return Err(FraiseQLError::Validation {
699 message: "mutation operation has no root field".to_string(),
700 path: None,
701 });
702 }
703
704 let mut data = serde_json::Map::with_capacity(roots.len());
705 let mut errors: Vec<serde_json::Value> = Vec::new();
706
707 for root in roots {
708 let outcome = self
709 .execute_mutation_query(
710 &root.name,
711 &root.response_key,
712 variables,
713 security_context,
714 &root.selections,
715 &root.arguments,
716 )
717 .await;
718 match outcome {
719 Ok(response) => {
720 // The runner already wrapped its result under the response
721 // key; lift it back out so every root shares one envelope.
722 let value = response
723 .get("data")
724 .and_then(|d| d.get(root.response_key.as_str()))
725 .cloned()
726 .unwrap_or(serde_json::Value::Null);
727 data.insert(root.response_key.clone(), value);
728 },
729 Err(e) => {
730 data.insert(root.response_key.clone(), serde_json::Value::Null);
731 let mut entry = self.ctx.config.root_error_renderer.as_ref().map_or_else(
732 || serde_json::json!({ "message": e.to_string() }),
733 |render| render(&e),
734 );
735 if let Some(entry) = entry.as_object_mut() {
736 entry.insert(
737 "path".to_string(),
738 serde_json::json!([root.response_key.as_str()]),
739 );
740 }
741 errors.push(entry);
742 },
743 }
744 }
745
746 let mut response = serde_json::Map::with_capacity(2);
747 response.insert("data".to_string(), serde_json::Value::Object(data));
748 if !errors.is_empty() {
749 response.insert("errors".to_string(), serde_json::Value::Array(errors));
750 }
751 Ok(serde_json::Value::Object(response))
752 }
753}
754
755/// Treat an empty `operationName` as absent.
756///
757/// Clients that always populate the field send `""` for an unnamed operation.
758/// Left as `Some("")` it would match no operation and turn every such request
759/// into an `UnknownOperation` error.
760pub(super) fn normalize_operation_name(operation_name: Option<&str>) -> Option<&str> {
761 operation_name.filter(|name| !name.is_empty())
762}
763
764/// Parse-cache key for a document **and the operation selected within it**.
765///
766/// The operation name is part of the key, not decoration. The cached value is a
767/// `(QueryType, Option<ParsedQuery>)` pair describing *one selected operation*,
768/// so keying on the document alone would serve the first request's operation to
769/// every later request naming a different one — the § 6.1 defect, made
770/// permanent by the cache and invisible to the request that triggers it.
771fn parse_cache_key(query: &str, operation_name: Option<&str>) -> u64 {
772 let document = xxhash_rust::xxh3::xxh3_64(query.as_bytes());
773 match operation_name {
774 None => document,
775 // Seeded with the document hash so the pair is mixed, not concatenated.
776 Some(name) => xxhash_rust::xxh3::xxh3_64_with_seed(name.as_bytes(), document),
777 }
778}
779
780/// The type `__type(name:)` names in this request (#1445).
781///
782/// The argument is `String!`: a literal, or a variable read from the request (with
783/// its declared default already applied by the dispatch, § 6.4.1). A missing,
784/// null or non-string name is refused: answering `null` would read as "no such
785/// type".
786fn introspection_type_name(
787 root: &crate::graphql::FieldSelection,
788 variables: &std::collections::HashMap<String, serde_json::Value>,
789) -> Result<String> {
790 use crate::graphql::value_json;
791
792 let refuse = |why: &str| FraiseQLError::Validation {
793 message: format!("`__type` takes a `name: String!` argument, and {why}"),
794 path: Some(root.response_key().to_string()),
795 };
796 let arg = root
797 .arguments
798 .iter()
799 .find(|a| a.name == "name")
800 .ok_or_else(|| refuse("none was given"))?;
801 let literal = value_json::decode(&arg.value_json)?;
802 // The dispatch has already given an omitted variable its default (§ 6.4.1).
803 let value = match value_json::variable_name(&literal) {
804 Some(var) => variables.get(var).cloned().unwrap_or(serde_json::Value::Null),
805 None => literal,
806 };
807 value
808 .as_str()
809 .map(str::to_string)
810 .ok_or_else(|| refuse("it was null or not a string"))
811}