fraiseql_core/runtime/variable_validation.rs
1//! Variable-definition validation for the executed operation — GraphQL § 5.8.2,
2//! § 5.8.3 and § 5.8.4.
3//!
4//! The load-bearing one is **§ 5.8.3 (All Variable Uses Defined)**: *"Every
5//! variable used within an operation must be defined by that operation."* A
6//! document that references `$x` without defining it is **invalid**, and an
7//! invalid document must not execute.
8//!
9//! Its two siblings share this module because they share the reference walker,
10//! but they do **not** share its risk profile, and the difference matters to
11//! anyone deciding whether to upgrade:
12//!
13//! | Rule | What it catches | What breaks |
14//! |---|---|---|
15//! | § 5.8.3 | `$x` used, never defined | clients getting **wrong answers** today |
16//! | § 5.8.2 | `$w: NoSuchTypeAtAll` | clients whose declared type name is not published |
17//! | § 5.8.4 | `$unused` defined, never referenced | clients that work and answer **correctly** today |
18//!
19//! § 5.8.4 is the only rule here with no correctness payoff: nothing is silently
20//! dropped and no answer is wrong. What justifies it is not the spec text but the
21//! ecosystem — `graphql-js` has enforced § 5.8.4 for years, as does every other
22//! major implementation, so a client sending superset variable definitions is
23//! already refused by every other server it talks to. The population this breaks
24//! is not "everyone reusing documents"; it is code written specifically against
25//! FraiseQL's leniency. This aligns with every other implementation rather than
26//! inventing a restriction.
27//!
28//! Before this, an undefined reference was dropped on the floor along with the
29//! argument that carried it. [`QueryMatcher::resolve_inline_arg`] resolves a
30//! whole-argument variable by looking the name up in the request's variables map
31//! and returning `None` when it is absent — which is correct for a *declared*
32//! variable the caller chose not to supply, and silently destructive for one that
33//! was never declared at all:
34//!
35//! ```graphql
36//! query Q { orders(offset: $neverDeclared) { reference } }
37//! ```
38//!
39//! returned **every row** under an HTTP 200 with no `errors` array. `where:`
40//! loses the filter and returns the whole table; `limit:` loses the bound and
41//! the now-unbounded query trips the complexity ceiling, so the client gets an
42//! error *about cost* that never mentions the variable — worse for debugging
43//! than no error at all.
44//!
45//! # The distinction this rule is built around
46//!
47//! **Definitions, never values.** A variable that *is* defined but is simply not
48//! supplied still drops its argument, deliberately and spec-correctly:
49//!
50//! ```graphql
51//! query Q($o: Int) { orders(offset: $o) { reference } } # no variables sent
52//! → all rows, no error # CORRECT, unchanged
53//! ```
54//!
55//! That behaviour is load-bearing — it is what lets `limit: $limit` fall back to
56//! the query's compiled default instead of forcing `LIMIT NULL` — and
57//! `test_resolve_inline_arg_variable_not_found_drops_the_argument` encodes it.
58//! Nothing here reads the request's variables map. Validity of a *definition* is
59//! a property of the document alone, which is also what makes the result safe to
60//! memoise in the parse cache.
61//!
62//! # What this does not reject
63//!
64//! Following [`crate::graphql::validate_selection_set`] (#939): reject what the
65//! document positively contradicts, pass everything it cannot adjudicate.
66//!
67//! * **Variables of a *later* operation in a multi-operation document.** [`parse_query`] executes
68//! the *first* operation and ignores `operationName`, while fragment definitions are
69//! document-global. Only the fragments transitively reachable from the executed operation are
70//! walked, so a second operation's fragments — which legitimately reference *that* operation's
71//! variables — are never scored against this one. Scanning `parsed.fragments` wholesale would
72//! reject a valid document.
73//! * **Variable types, when the schema cannot adjudicate them.** § 5.8.2 resolves a declared type
74//! name against the *published* surface, and a schema carrying no enums and no input objects at
75//! all is treated as "the compiler emitted no input-type information" rather than "these names do
76//! not exist". See [`validate_variable_types`].
77//! * **Argument values, coercion and input-object shape.** Checked further down; these rules are
78//! about names alone.
79//! * **References nested deeper than [`value_json::MAX_DEPTH`].** The parser refuses to build
80//! anything deeper, so a deeper reference cannot exist; the cap keeps the walk off an unbounded
81//! stack rather than trading away coverage.
82
83use std::collections::HashSet;
84
85use crate::{
86 error::{FraiseQLError, Result},
87 graphql::{
88 types::{Directive, FieldSelection, FragmentDefinition, GraphQLArgument, ParsedQuery},
89 value_json,
90 },
91};
92
93/// Prefix marking an inline fragment's synthetic field name (`...on User`), as
94/// distinct from a named spread (`...UserFields`).
95///
96/// The trailing space is what disambiguates them: a fragment named `onCall`
97/// yields `...onCall`, which must be read as a spread. GraphQL reserves `on` as
98/// a fragment name, so `...on` alone cannot occur.
99const INLINE_FRAGMENT_PREFIX: &str = "...on ";
100
101/// Every variable referenced by the executed operation, in document order,
102/// without duplicates.
103///
104/// Walks field arguments (whole and nested), directive arguments, nested field
105/// arguments, and the selections of every fragment transitively reachable from
106/// the operation. See the module header for what is deliberately outside the
107/// walk.
108///
109/// # Errors
110///
111/// Returns [`FraiseQLError::Internal`] if a stored `value_json` is not valid
112/// JSON — the same loud failure [`value_json::decode`] raises, for the same
113/// reason: the alternative is dropping an argument, which widens a result set.
114pub fn collect_variable_references(parsed: &ParsedQuery) -> Result<Vec<String>> {
115 let mut acc = References::default();
116 let mut reached: HashSet<&str> = HashSet::new();
117
118 collect_from_selections(&parsed.selections, &parsed.fragments, &mut reached, &mut acc, 0)?;
119
120 Ok(acc.ordered)
121}
122
123/// Check that every variable referenced by the operation is defined by it.
124///
125/// `operation_name` is the executed operation's name, used verbatim in the error
126/// the way a client reads its own document; an anonymous operation drops the
127/// clause rather than inventing a name.
128///
129/// # Errors
130///
131/// Returns [`FraiseQLError::Validation`] naming the first undefined variable in
132/// document order, with a "did you mean" hint when a close *defined* name exists
133/// — a variable typo is precisely the case this rule catches, and it is one edit
134/// away from the name that works.
135pub fn validate_variable_uses(
136 operation_name: Option<&str>,
137 defined: &[String],
138 referenced: &[String],
139) -> Result<()> {
140 for name in referenced {
141 if defined.iter().any(|d| d == name) {
142 continue;
143 }
144
145 let candidates: Vec<&str> = defined.iter().map(String::as_str).collect();
146 let subject = match operation_name {
147 Some(op) => format!("Variable '${name}' is not defined by operation '{op}'."),
148 None => format!("Variable '${name}' is not defined by the operation."),
149 };
150 let message = match super::suggest_similar(name, &candidates).as_slice() {
151 [s] => format!("{subject} Did you mean '${s}'?"),
152 [a, b] => format!("{subject} Did you mean '${a}' or '${b}'?"),
153 [a, b, c, ..] => format!("{subject} Did you mean '${a}', '${b}', or '${c}'?"),
154 _ => subject,
155 };
156
157 return Err(FraiseQLError::Validation {
158 message,
159 path: Some(match operation_name {
160 Some(op) => format!("{op}.${name}"),
161 None => format!("${name}"),
162 }),
163 });
164 }
165
166 Ok(())
167}
168
169/// Input-position type names a federated subgraph's SDL declares but introspection
170/// does not publish as scalars, so no other source in this function can learn them.
171///
172/// A router's `_entities` call is invariably
173/// `query($representations: [_Any!]!)`, so refusing `_Any` refused every federated
174/// fan-out. `_FieldSet` is the other one — it is the argument type of `@key`,
175/// `@requires` and `@provides`.
176///
177/// Only accepted when the schema is actually a subgraph: to a non-federated schema
178/// these names mean nothing and are as wrong as any other unknown type.
179const FEDERATION_INPUT_BUILTINS: &[&str] = &["_Any", "_FieldSet"];
180
181/// Check that every variable definition names a type the schema publishes as an
182/// input type (GraphQL § 5.8.2).
183///
184/// Resolves the **innermost** type name — the list and non-null wrappers are
185/// structural — against the three sources a client can legitimately learn a name
186/// from: the scalars introspection publishes
187/// ([`published_scalar_names`](crate::schema::published_scalar_names)), declared
188/// enums, and declared input objects. Custom scalars named by a declared field
189/// are also accepted: introspection does not publish them as `SCALAR` types, but
190/// the schema does not positively say they are absent either.
191///
192/// # What this does not reject
193///
194/// **A schema carrying no enums *and* no input objects is not adjudicated.**
195/// That shape means the compiler emitted no input-type information, not that the
196/// schema genuinely has none, and a rejection cannot be justified from an absence
197/// of evidence — the #939 principle in
198/// [`crate::graphql::validate_selection_set`].
199///
200/// # Errors
201///
202/// Returns [`FraiseQLError::Validation`] naming the first unknown type name.
203pub fn validate_variable_types(
204 schema: &crate::schema::CompiledSchema,
205 operation_name: Option<&str>,
206 definitions: &[crate::graphql::types::VariableDefinition],
207) -> Result<()> {
208 // Cannot adjudicate: no input-type information was emitted at all.
209 if schema.enums.is_empty() && schema.input_types.is_empty() {
210 return Ok(());
211 }
212
213 let scalars = crate::schema::published_scalar_names();
214 let federated = schema.federation.as_ref().is_some_and(|f| f.enabled);
215
216 for def in definitions {
217 let type_name = innermost_type_name(&def.var_type.name);
218 let known = scalars.iter().any(|s| s == type_name)
219 || (federated && FEDERATION_INPUT_BUILTINS.contains(&type_name))
220 || schema.find_enum(type_name).is_some()
221 || schema.find_input_type(type_name).is_some()
222 || schema_declares_type_name(schema, type_name);
223 if known {
224 continue;
225 }
226
227 let subject = match operation_name {
228 Some(op) => format!(
229 "Variable '${}' of operation '{op}' declares unknown type '{type_name}'.",
230 def.name
231 ),
232 None => {
233 format!("Variable '${}' declares unknown type '{type_name}'.", def.name)
234 },
235 };
236 let candidates: Vec<&str> = scalars
237 .iter()
238 .map(String::as_str)
239 .chain(schema.enums.iter().map(|e| e.name.as_str()))
240 .chain(schema.input_types.iter().map(|i| i.name.as_str()))
241 .collect();
242 let message = match super::suggest_similar(type_name, &candidates).as_slice() {
243 [s] => format!("{subject} Did you mean '{s}'?"),
244 [a, b] => format!("{subject} Did you mean '{a}' or '{b}'?"),
245 [a, b, c, ..] => format!("{subject} Did you mean '{a}', '{b}', or '{c}'?"),
246 _ => subject,
247 };
248
249 return Err(FraiseQLError::Validation {
250 message,
251 path: Some(match operation_name {
252 Some(op) => format!("{op}.${}", def.name),
253 None => format!("${}", def.name),
254 }),
255 });
256 }
257
258 Ok(())
259}
260
261/// Strip the list wrappers a parsed variable type carries in its *name*.
262///
263/// `GraphQLType` records list-ness twice and inconsistently: `parse_graphql_type`
264/// sets `list: true` **and** rewrites the name to `"[Inner]"`, while a non-null
265/// wrapper only flips `nullable` and leaves the name alone. So `[ID!]!` arrives
266/// as `name: "[ID]"`, not `name: "ID"` with flags.
267///
268/// § 5.8.2 is a rule about the *named* type, so the wrappers are peeled off
269/// before resolution — and the innermost name is what the error reports, since
270/// `'[ID]'` is not a name a client could look up.
271fn innermost_type_name(name: &str) -> &str {
272 let mut inner = name;
273 while let Some(stripped) = inner.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
274 inner = stripped;
275 }
276 inner
277}
278
279/// Whether the schema itself declares `name` as a type — in **field or argument
280/// position** — even though introspection does not publish it as a scalar.
281///
282/// This is the #939 principle applied properly: reject what the schema
283/// positively says is not there, not merely what introspection happens to
284/// advertise. The published-scalar list is the set a client can *learn* names
285/// from; it is not the set of names that are legitimate. A hand-authored or
286/// externally-generated document does not need introspection to know a type the
287/// schema declares.
288///
289/// The concrete case this exists for is the pgvector family.
290/// `Vector`/`BitVector`/`HalfVector`/`SparseVector` are in the **authoring**
291/// table but are never published as `SCALAR` types — a vector field's
292/// introspection `type_ref` is `JSON` or `[Float!]!`. Resolving § 5.8.2 against
293/// the published list alone would therefore refuse `query Q($v: Vector)` against
294/// a schema that declares a `Vector`, turning a working query into an error.
295///
296/// The distinction that keeps this from collapsing into "union the two tables":
297/// a name is accepted because **this schema declares something of that type**,
298/// not because the name exists in some global list. A schema with no vector
299/// anywhere still refuses `$v: Vector`.
300fn schema_declares_type_name(schema: &crate::schema::CompiledSchema, name: &str) -> bool {
301 let matches_type = |ft: &crate::schema::FieldType| declared_type_name(ft) == Some(name);
302
303 schema
304 .types
305 .iter()
306 .any(|t| t.fields.iter().any(|f| matches_type(&f.field_type)))
307 || schema
308 .queries
309 .iter()
310 .any(|q| q.arguments.iter().any(|a| matches_type(&a.arg_type)))
311 || schema
312 .mutations
313 .iter()
314 .any(|m| m.arguments.iter().any(|a| matches_type(&a.arg_type)))
315}
316
317/// The name an author writes for `ft` in `schema.json`.
318///
319/// [`FieldType::type_name`] covers only the composite variants, and
320/// [`FieldType::to_graphql_string`] gives the *published* spelling — which for a
321/// vector is `[Float!]!`, not a name at all. The authoring table is the only
322/// place the scalar variants carry their written names, so it is the reverse
323/// lookup used here.
324fn declared_type_name(ft: &crate::schema::FieldType) -> Option<&str> {
325 if let Some(name) = ft.type_name() {
326 return Some(name);
327 }
328 if let crate::schema::FieldType::Scalar(name) = ft {
329 return Some(name);
330 }
331 crate::schema::BUILTIN_SCALARS
332 .iter()
333 .find_map(|(name, builtin)| (builtin == ft).then_some(*name))
334}
335
336/// Check that every variable the operation defines is also used by it
337/// (GraphQL § 5.8.4).
338///
339/// The inverse of [`validate_variable_uses`], over the same reference set — which
340/// means it inherits the fragment-reachability walk. That is load-bearing rather
341/// than incidental: a variable referenced **only** inside a reachable fragment
342/// *is* used, and a rule that missed that traversal would reject any document
343/// whose variables are consumed through fragments.
344///
345/// Unlike § 5.8.3, this rejects documents that execute and answer **correctly**
346/// today — a document reused across call sites and sent with a superset of
347/// variable definitions is a real client shape. The spec is unambiguous that it
348/// is invalid, but it is the one rule in this module with no correctness payoff.
349///
350/// # Errors
351///
352/// Returns [`FraiseQLError::Validation`] naming the first unused definition, in
353/// declaration order.
354pub fn validate_variables_used(
355 operation_name: Option<&str>,
356 defined: &[String],
357 referenced: &[String],
358) -> Result<()> {
359 for name in defined {
360 if referenced.iter().any(|r| r == name) {
361 continue;
362 }
363
364 let message = match operation_name {
365 Some(op) => format!("Variable '${name}' is never used in operation '{op}'."),
366 None => format!("Variable '${name}' is never used in the operation."),
367 };
368
369 return Err(FraiseQLError::Validation {
370 message,
371 path: Some(match operation_name {
372 Some(op) => format!("{op}.${name}"),
373 None => format!("${name}"),
374 }),
375 });
376 }
377
378 Ok(())
379}
380
381/// Ordered, de-duplicated accumulator for referenced variable names.
382///
383/// Document order is preserved so the error names the *first* offending
384/// reference a reader will find in their own query, rather than the
385/// alphabetically first.
386#[derive(Default)]
387struct References {
388 ordered: Vec<String>,
389 seen: HashSet<String>,
390}
391
392impl References {
393 fn push(&mut self, name: &str) {
394 if self.seen.insert(name.to_string()) {
395 self.ordered.push(name.to_string());
396 }
397 }
398}
399
400fn collect_from_selections<'a>(
401 selections: &'a [FieldSelection],
402 fragments: &'a [FragmentDefinition],
403 reached: &mut HashSet<&'a str>,
404 acc: &mut References,
405 depth: usize,
406) -> Result<()> {
407 if depth > value_json::MAX_DEPTH {
408 return Ok(());
409 }
410
411 for sel in selections {
412 // Directives ride on every selection kind, including a named spread's
413 // synthetic pseudo-field: `...F @include(if: $x)` references `$x`
414 // without the fragment itself doing so.
415 collect_from_directives(&sel.directives, acc)?;
416 collect_from_arguments(&sel.arguments, acc)?;
417
418 // A named spread carries no nested fields of its own; its references
419 // live in the fragment definition, which is walked at most once.
420 if let Some(fragment_name) = named_spread(&sel.name) {
421 if reached.insert(fragment_name) {
422 if let Some(def) = fragments.iter().find(|f| f.name == fragment_name) {
423 collect_from_selections(&def.selections, fragments, reached, acc, depth + 1)?;
424 }
425 }
426 continue;
427 }
428
429 collect_from_selections(&sel.nested_fields, fragments, reached, acc, depth + 1)?;
430 }
431
432 Ok(())
433}
434
435/// The fragment name a selection spreads, if it is a named spread.
436///
437/// Returns `None` for an ordinary field and for an inline fragment — the latter
438/// keeps its references in `nested_fields`, which the caller recurses into.
439fn named_spread(selection_name: &str) -> Option<&str> {
440 if selection_name.starts_with(INLINE_FRAGMENT_PREFIX) {
441 return None;
442 }
443 selection_name.strip_prefix("...")
444}
445
446fn collect_from_directives(directives: &[Directive], acc: &mut References) -> Result<()> {
447 for directive in directives {
448 collect_from_arguments(&directive.arguments, acc)?;
449 }
450 Ok(())
451}
452
453fn collect_from_arguments(arguments: &[GraphQLArgument], acc: &mut References) -> Result<()> {
454 for arg in arguments {
455 let value = value_json::decode(&arg.value_json)?;
456 collect_from_value(&value, acc, 0);
457 }
458 Ok(())
459}
460
461/// Walk a decoded argument value for `{"$var": "name"}` markers.
462///
463/// A marker is terminal: it is a reference, not a container, so the walk does
464/// not descend into it.
465fn collect_from_value(value: &serde_json::Value, acc: &mut References, depth: usize) {
466 if depth > value_json::MAX_DEPTH {
467 return;
468 }
469
470 if let Some(name) = value_json::variable_name(value) {
471 acc.push(name);
472 return;
473 }
474
475 match value {
476 serde_json::Value::Object(map) => {
477 for nested in map.values() {
478 collect_from_value(nested, acc, depth + 1);
479 }
480 },
481 serde_json::Value::Array(items) => {
482 for item in items {
483 collect_from_value(item, acc, depth + 1);
484 }
485 },
486 _ => {},
487 }
488}
489
490#[cfg(test)]
491#[path = "variable_validation_tests.rs"]
492mod tests;