1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
//! Query classification — determines operation type for routing.
use super::super::{Executor, MutationRoot, QueryType};
use crate::{
error::Result,
graphql::{operation_selection_error, parse_query_with_operation_name, value_json},
runtime::{
collect_variable_references, validate_variable_types, validate_variable_uses,
validate_variables_used,
},
};
/// A classified document: its type, its AST where the dispatch needs it, and its
/// variables' declared defaults.
pub(in crate::runtime::executor) type Classification = (
QueryType,
Option<std::sync::Arc<crate::graphql::ParsedQuery>>,
Vec<(String, serde_json::Value)>,
Option<std::sync::Arc<CompletionTarget>>,
);
/// A [`Classification`] as the parse cache holds it: with the document and the
/// operation name it was computed from.
///
/// The cache is keyed by a 64-bit hash, so a hit is a claim that the document is
/// the same, not a proof. The entry carries what it answers for, and a hit whose
/// source or operation name differs is treated as a miss: a key collision costs a
/// re-classification, never another document's verdict.
#[derive(Debug)]
pub(in crate::runtime::executor) struct CachedClassification {
source: Box<str>,
operation_name: Option<Box<str>>,
/// The verdict, shared by every request for this document (#1562).
pub(in crate::runtime::executor) classification: Classification,
}
impl CachedClassification {
/// `classification`, as computed for `source` with `operation_name` selected.
pub(in crate::runtime::executor) fn new(
source: &str,
operation_name: Option<&str>,
classification: Classification,
) -> Self {
Self {
source: source.into(),
operation_name: operation_name.map(Into::into),
classification,
}
}
/// Whether this entry was computed for `source` with `operation_name` selected.
pub(in crate::runtime::executor) fn answers(
&self,
source: &str,
operation_name: Option<&str>,
) -> bool {
*self.source == *source && self.operation_name.as_deref() == operation_name
}
}
/// What a response is completed against (§ 6.4.4, #1522): the operation's root type and
/// its root selection set, fragment spreads expanded. Computed once per document, with its
/// classification, for every operation type; `None` when the spreads cannot be expanded
/// (the document is refused elsewhere).
#[derive(Debug)]
pub(in crate::runtime::executor) struct CompletionTarget {
/// `Query`, `Mutation` or `Subscription`.
pub(in crate::runtime::executor) root_type: &'static str,
/// The root selection set, spreads expanded, and the fields it collects per set of
/// directive outcomes.
pub(in crate::runtime::executor) selections: crate::graphql::selection_set::CollectedFields,
}
impl Executor {
/// Classify a GraphQL query into its operation type for routing.
///
/// This is the first phase of query execution. It determines which handler
/// to invoke based on the query structure and conventions:
///
/// - **Introspection** (`__schema`, `__type`) → Uses pre-built responses (zero-cost)
/// - **Federation** (`_service`, `_entities`) → Fed-specific logic
/// - **Relay node** (`node(id: "...")`) → Global ID lookup
/// - **Mutations** (`mutation { ... }`) → Write operations
/// - **Aggregates** (root field ends with `_aggregate`) → Analytics queries
/// - **Windows** (root field ends with `_window`) → Time-series queries
/// - **Regular** (default) → Standard field selections
///
/// # Errors
///
/// Returns `FraiseQLError::Parse` if the query string is malformed GraphQL.
///
/// # Example
///
/// ```text
/// // Illustrative — classify_query() is internal.
/// // Use executor.execute(query, None).await? for the public API.
///
/// // Regular query → Regular
/// // Mutation → Mutation → execute_mutation_query()
/// // __schema → Introspection
/// // _entities → Federation
/// ```
pub(in crate::runtime::executor) fn classify_query(
&self,
query: &str,
operation_name: Option<&str>,
) -> Result<QueryType> {
self.classify_query_with_parse(query, operation_name).map(|(qt, ..)| qt)
}
/// Classify a query and simultaneously return the parsed AST for `Regular`
/// queries, avoiding a redundant parse in the multi-root pipeline path.
///
/// Returns `(QueryType, Some(ParsedQuery), defaults)` for `Regular` queries and
/// `(QueryType, None, defaults)` for all other types (introspection, federation,
/// etc.). `defaults` are the operation's declared variable defaults, decoded: a
/// property of the document, applied to each request's variables at dispatch
/// (§ 6.4.1, #1504).
///
/// # Errors
///
/// Returns [`FraiseQLError::Parse`] if the query string is malformed GraphQL.
pub(in crate::runtime::executor) fn classify_query_with_parse(
&self,
query: &str,
operation_name: Option<&str>,
) -> Result<Classification> {
// Parse the query once; the AST is the canonical source of truth.
// Substring scans on the raw string produce false-positives on aliases,
// comments, and string argument values (e.g. `{ search(q: "_service") }`
// would be mis-routed as a federation query by a text scan).
//
// GraphQL § 6.1 — the request selects which operation runs. Passing the
// name here rather than taking the document's first operation is what
// keeps a two-operation document from answering with the wrong one.
let parsed = parse_query_with_operation_name(query, operation_name)
.map_err(|e| operation_selection_error(&e))?;
let defaults = value_json::variable_defaults(&parsed.variables)?;
let root_type = match parsed.operation_type.as_str() {
"mutation" => "Mutation",
"subscription" => "Subscription",
_ => "Query",
};
let completion = crate::graphql::selection_set::resolve(
&parsed.selections,
&parsed.fragments,
self.max_query_depth(),
)
.ok()
.map(|selections| {
std::sync::Arc::new(CompletionTarget {
root_type,
selections: crate::graphql::selection_set::CollectedFields::new(selections),
})
});
let (query_type, parsed) = self.classify_parsed(parsed)?;
Ok((query_type, parsed.map(std::sync::Arc::new), defaults, completion))
}
/// The routing half of [`classify_query_with_parse`](Self::classify_query_with_parse).
fn classify_parsed(
&self,
parsed: crate::graphql::ParsedQuery,
) -> Result<(QueryType, Option<crate::graphql::ParsedQuery>)> {
// GraphQL § 5.8.3 / § 5.8.2 / § 5.8.4 — variable definitions.
//
// Placed here, immediately after the parse and *before* the routing
// branches, because this is the only point every operation type shares
// while the AST still exists: `classify_query_with_parse` returns the
// parsed query only for `Regular`, so a check in `execute_dispatch`
// would have nothing to validate for a mutation, aggregate, window,
// node or federation document.
//
// Not in `QueryMatcher::match_query`: `field_selection_to_query`
// re-serialises each root of a multi-root query into a synthetic
// single-root string that carries no variable *definitions* while
// faithfully re-emitting `$name` references, so a check there would
// reject every multi-root query that uses a variable.
let operation_name = parsed.operation_name.as_deref();
let defined: Vec<String> = parsed.variables.iter().map(|v| v.name.clone()).collect();
let referenced = collect_variable_references(&parsed)?;
// Ordered most-useful-first: when a document has more than one of these
// problems, an undefined *reference* is the one that changed the answer,
// so it is the one worth reporting.
let suggestions = self.ctx.config.suggestions;
validate_variable_uses(operation_name, &defined, &referenced, suggestions)?;
validate_variable_types(&self.ctx.schema, operation_name, &parsed.variables, suggestions)?;
validate_variables_used(operation_name, &defined, &referenced)?;
let root_field = &parsed.root_field;
// Introspection (highest priority): `__schema` or `__type`.
// These are meta-fields defined by the GraphQL spec — always a root query.
//
// The AST travels with introspection so the dispatch can project the
// pre-built response onto the client's selection set (§ 6.3). It is
// memoised in the parse cache alongside the classification, so carrying
// it costs one clone per distinct query string, not one per request.
if root_field == "__schema" || root_field == "__type" {
return Ok((QueryType::Introspection, Some(parsed)));
}
// Root `__typename` meta-field (GraphQL spec §"Type Name Introspection"):
// a single-root selection consisting solely of `__typename` (optionally
// aliased) resolves to the operation's root type name without a DB
// round-trip. Placed before the mutation branch so `mutation { __typename }`
// and `subscription { __typename }` resolve correctly instead of being
// routed as a (missing) mutation field. The `len() == 1` guard is
// load-bearing: mixed roots like `{ __typename users { id } }` fall through
// to `Regular` and are resolved by the multi-root pipeline.
if root_field == "__typename" && parsed.selections.len() == 1 {
return Ok((
QueryType::TypeName {
selection: Box::new(parsed.selections[0].clone()),
operation_type: parsed.operation_type.clone(),
},
None,
));
}
// Federation (Apollo Federation v1/v2 entry-points).
if root_field == "_service" || root_field == "_entities" {
return Ok((QueryType::Federation(root_field.clone()), None));
}
// Relay global node lookup: root field is exactly "node" on a query.
//
// Named fragment spreads are expanded here, using the same routine as the
// query matcher and the mutation branch below. This branch used to lift
// `sel.nested_fields` out of every child whose name starts with `"..."`,
// which is right for an inline fragment (whose nested fields hold the real
// selections) and silently *dropped* a named spread, whose pseudo-field
// always has an empty `nested_fields` (#827). Relay Modern issues
// `node(id: $id) { id ...Container_data }` for every lookup, so that was
// the canonical shape.
//
// `@skip`/`@include` are evaluated later in `execute_node_query`, where
// the request variables are available — the same split as mutations, and
// what keeps this classification cacheable by query string alone.
if parsed.operation_type == "query" && root_field == "node" {
let raw = parsed.selections.first().map_or(&[][..], |s| s.nested_fields.as_slice());
let resolved = crate::graphql::selection_set::resolve(
raw,
&parsed.fragments,
self.max_query_depth(),
)?;
// Flatten inline fragments: `node { ... on Booking { id startDate } }`.
// Only `"...on "` entries carry their selections in `nested_fields`;
// after expansion no bare `"...Name"` spread survives. Lifting the
// children out discards the inline fragment itself, so its own
// directives travel with them.
let mut selections = Vec::with_capacity(resolved.len());
for sel in resolved {
if sel.name.starts_with("...on ") {
for mut child in sel.nested_fields {
child.inherit_directives(&sel.directives);
selections.push(child);
}
} else {
selections.push(sel);
}
}
return Ok((QueryType::NodeQuery { selections }, None));
}
// Mutations are routed by operation type. Carry the full result selection
// set (inline fragments intact) so the projector can subset and recurse
// exactly like the query path. Named fragment spreads are resolved here —
// the same as the query matcher — using the document's fragment
// definitions; `@skip`/`@include` directives are evaluated later in
// `execute_mutation_impl`, where the request variables are available.
//
// *Every* root is carried, not just the first: the spec requires all of a
// mutation's root fields to execute, serially, in document order (#759).
if parsed.operation_type == "mutation" {
let roots = parsed
.selections
.iter()
.map(|root| {
Ok(MutationRoot {
response_key: root.response_key().to_string(),
name: root.name.clone(),
selections: crate::graphql::selection_set::resolve(
&root.nested_fields,
&parsed.fragments,
self.max_query_depth(),
)?,
arguments: root.arguments.clone(),
})
})
.collect::<Result<Vec<_>>>()?;
return Ok((QueryType::Mutation { roots }, None));
}
// Aggregate and window queries (root field ends with `_aggregate` / `_window`). Their
// planners read the request — `groupBy`, `limit`, `offset`, … — from the root field's
// arguments, inline or as variables (#1609); the parsed document goes with them.
if root_field.ends_with("_aggregate") || root_field.ends_with("_window") {
let query_type = if root_field.ends_with("_aggregate") {
QueryType::Aggregate(root_field.clone())
} else {
QueryType::Window(root_field.clone())
};
return Ok((query_type, Some(parsed)));
}
// Regular query — return the already-parsed AST to avoid re-parsing in
// the multi-root pipeline path.
Ok((QueryType::Regular, Some(parsed)))
}
}