Skip to main content

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}