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
//! 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},
runtime::{
collect_variable_references, validate_variable_types, validate_variable_uses,
validate_variables_used,
},
};
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))` for `Regular` queries and
/// `(QueryType, None)` for all other types (introspection, federation, etc.).
///
/// # 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<(QueryType, Option<crate::graphql::ParsedQuery>)> {
// 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))?;
// 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.
validate_variable_uses(operation_name, &defined, &referenced)?;
validate_variable_types(&self.ctx.schema, operation_name, &parsed.variables)?;
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" {
return Ok((QueryType::IntrospectionSchema, Some(parsed)));
}
if root_field == "__type" {
let type_name = extract_root_string_arg(&parsed, "name");
return Ok((QueryType::IntrospectionType(type_name.unwrap_or_default()), 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 queries (root field ends with `_aggregate`).
if root_field.ends_with("_aggregate") {
return Ok((QueryType::Aggregate(root_field.clone()), None));
}
// Window queries (root field ends with `_window`).
if root_field.ends_with("_window") {
return Ok((QueryType::Window(root_field.clone()), None));
}
// Regular query — return the already-parsed AST to avoid re-parsing in
// the multi-root pipeline path.
Ok((QueryType::Regular, Some(parsed)))
}
}
/// Extract the value of a named string argument from the first (root) field of
/// a parsed query.
///
/// For `{ __type(name: "User") { ... } }`, calling `extract_root_string_arg(parsed, "name")`
/// returns `Some("User".to_string())`.
///
/// Returns `None` if the argument is absent or is not a JSON string literal.
fn extract_root_string_arg(parsed: &crate::graphql::ParsedQuery, arg_name: &str) -> Option<String> {
let root_field = parsed.selections.first()?;
let arg = root_field.arguments.iter().find(|a| a.name == arg_name)?;
// `value_json` holds a JSON document. Peeling the outer quotes by hand and
// unescaping only `\"` is the same defect as #719's writer: a value
// containing a backslash or a newline came back mangled.
let decoded = crate::graphql::value_json::decode(&arg.value_json).ok()?;
Some(decoded.as_str()?.to_string())
}