Skip to main content

fraiseql_core/graphql/
selection_set.rs

1//! The one routine that answers "which fields did the client ask for?".
2//!
3//! Every GraphQL entry point — `/graphql`, the multi-root fan-out, `node(id:)`,
4//! mutations — has to expand fragment spreads and evaluate `@skip`/`@include`,
5//! and each one used to do it (or skip doing it) on its own. `node(id:)` never
6//! expanded spreads at all (#827); the multi-root fan-out re-serialized each root
7//! and lost both the fragments and the directives; the two paths that did resolve
8//! dropped a spread's own directives on the floor (#826).
9//!
10//! Resolution is split in two because the runtime is:
11//!
12//! * `resolve` expands spreads. It depends only on the document, so it is safe to memoise — the
13//!   executor's parse cache holds classified mutation selections keyed by the query string alone.
14//! * `resolve_and_filter` additionally evaluates `@skip`/`@include`, which needs the request's
15//!   variables and therefore must run per request.
16//!
17//! Entry points that classify before they see variables (mutations, `node(id:)`)
18//! call `resolve` at classification time and `filter` in their runner; every
19//! other caller uses `resolve_and_filter`.
20
21use std::collections::HashMap;
22
23use serde_json::Value as JsonValue;
24use thiserror::Error;
25
26use crate::graphql::{
27    DirectiveError, DirectiveEvaluator, FragmentError, FragmentResolver,
28    types::{FieldSelection, FragmentDefinition},
29};
30
31/// Failure to reduce a document's selection set to the fields it requests.
32#[derive(Debug, Error)]
33#[non_exhaustive]
34pub enum SelectionError {
35    /// A fragment spread could not be expanded.
36    #[error(transparent)]
37    Fragment(#[from] FragmentError),
38
39    /// A `@skip`/`@include` condition could not be evaluated.
40    #[error(transparent)]
41    Directive(#[from] DirectiveError),
42}
43
44impl From<SelectionError> for crate::error::FraiseQLError {
45    fn from(err: SelectionError) -> Self {
46        let path = match &err {
47            SelectionError::Fragment(_) => "fragments",
48            SelectionError::Directive(_) => "directives",
49        };
50        Self::Validation {
51            message: err.to_string(),
52            path:    Some(path.to_string()),
53        }
54    }
55}
56
57/// Expand every fragment spread, carrying each spread's directives onto the
58/// fields it contributes.
59///
60/// Variable-independent, so the result may be cached against the query string.
61///
62/// # Errors
63///
64/// Returns [`SelectionError::Fragment`] if a spread names an undefined fragment,
65/// the document is circular, or nesting exceeds `max_depth` — the executor's
66/// effective `max_query_depth`, so the resolver never refuses a document the
67/// depth gate admits, and never admits one it would refuse by more than the
68/// level or two it counts from.
69pub fn resolve(
70    selections: &[FieldSelection],
71    fragments: &[FragmentDefinition],
72    max_depth: u32,
73) -> Result<Vec<FieldSelection>, SelectionError> {
74    Ok(FragmentResolver::new(fragments)
75        .with_max_depth(max_depth)
76        .resolve_spreads(selections)?)
77}
78
79/// Evaluate `@skip`/`@include` over an already-expanded selection set.
80///
81/// # Errors
82///
83/// Returns [`SelectionError::Directive`] if a condition references an undefined
84/// variable, or is not a boolean.
85#[allow(clippy::implicit_hasher)]
86// Reason: the variables map is built by the runtime with the default hasher; a
87// generic parameter here would leak into every caller for no benefit (same
88// treatment as `value_json::resolve_variables`).
89pub fn filter(
90    selections: &[FieldSelection],
91    variables: &HashMap<String, JsonValue>,
92) -> Result<Vec<FieldSelection>, SelectionError> {
93    Ok(DirectiveEvaluator::filter_selections(selections, variables)?)
94}
95
96/// Expand spreads and evaluate directives in one step — the field set the client
97/// actually asked for.
98///
99/// # Errors
100///
101/// Returns [`SelectionError`] if expansion or directive evaluation fails.
102#[allow(clippy::implicit_hasher)]
103// Reason: the variables map is built by the runtime with the default hasher; a
104// generic parameter here would leak into every caller for no benefit (same
105// treatment as `value_json::resolve_variables`).
106pub fn resolve_and_filter(
107    selections: &[FieldSelection],
108    fragments: &[FragmentDefinition],
109    variables: &HashMap<String, JsonValue>,
110    max_depth: u32,
111) -> Result<Vec<FieldSelection>, SelectionError> {
112    filter(&resolve(selections, fragments, max_depth)?, variables)
113}
114
115/// Convert a request's `variables` payload into the map the directive evaluator
116/// takes.
117///
118/// A non-object payload (including `null`) yields an empty map, matching the
119/// GraphQL treatment of an omitted `variables` key.
120#[must_use]
121pub fn variables_map(variables: Option<&JsonValue>) -> HashMap<String, JsonValue> {
122    match variables {
123        Some(JsonValue::Object(map)) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
124        _ => HashMap::new(),
125    }
126}
127
128#[cfg(test)]
129#[path = "selection_set_tests.rs"]
130mod selection_set_tests;