fraiseql_core/runtime/argument_value_validation.rs
1//! Argument-**value** validation for a root field — GraphQL § 5.6.1 (*Values of
2//! Correct Type*), § 5.8.5 (*All Variable Usages Are Allowed*) and § 6.1.2
3//! (*`CoerceVariableValues`*).
4//!
5//! Its sibling next door ([`super::argument_validation`]) checks argument
6//! *names*; this one checks that the value written against a name has that
7//! name's type. Before it, nothing did — on the literal path and the variable
8//! path alike — and the consequence was not a lenient server but a **wrong
9//! answer**:
10//!
11//! ```graphql
12//! { products(limit: "2") { sku } } # 12 rows, exit 0, no `errors` array
13//! ```
14//!
15//! `limit` and `offset` are read out of the merged argument map with
16//! `as_u64()`. A `String`, a `Bool`, a `Float`, a negative number or one past
17//! `u32` all answer `None` there, and `None` means *no clause was emitted* — so
18//! a request that explicitly asked to be bounded came back unbounded (#1197).
19//! Every other argument instead reached PostgreSQL, which answered with a
20//! `Database` error whose message can carry a **stored value** back to an
21//! unauthenticated caller.
22//!
23//! The read sites fail closed on their own now
24//! ([`crate::runtime::coerce_pagination_arg`]); this
25//! module is what turns "the engine refused to paginate" into a diagnostic that
26//! names the argument, the type it declares, and the type the document wrote.
27//!
28//! # What this adjudicates, and what it deliberately does not
29//!
30//! Following [`crate::graphql::validate_selection_set`] and § 5.4.1's module:
31//! **reject what the document positively contradicts, pass everything it cannot
32//! adjudicate.** A mismatch is reported only when *both* sides are one of the
33//! built-in scalars in [`Scalar`] — the ten whose value space is a property of
34//! the spec rather than of a project's own scalar wiring.
35//!
36//! Outside that set, execution is unchanged:
37//!
38//! * **Custom scalars, input objects, lists and vectors.** A project may back any of these with any
39//! JSON shape, so a disagreement here is not evidence of a client mistake.
40//! * **Nested input-object fields**, for the scalar check above. Only the value written *at* the
41//! argument is adjudicated as a scalar, not the keys inside a `where:` predicate. Those have
42//! their own surface and their own operators.
43//!
44//! # Enums are the exception, and were wrongly inside the exclusion (#1362)
45//!
46//! Enums sat in that first bullet until #1362, and the bullet's own rationale is
47//! what makes it wrong for them. A custom scalar's value space *is* a project's
48//! choice — `Email` can be backed by anything the project's SQL accepts. An
49//! enum's is not: [`EnumDefinition::values`] enumerates it, exhaustively, in the
50//! compiled schema, and introspection publishes the same list. So a value that
51//! is not one of those members is not an undecidable disagreement; it is
52//! positively contradicted by the schema, which is exactly the standard the
53//! paragraph above sets.
54//!
55//! Nothing checked it. `find_enum` had three callers — the § 5.8.2 *name* check,
56//! a `SortDirection` presence test and introspection's kind resolution — and
57//! every consumer of `EnumDefinition::values` was a generator (the client
58//! emitters, the OpenAPI schema), never a validator. `BANANA`, `"pending"` and
59//! `42` all reached the resolver, on the literal path and the variable path
60//! alike, against an argument the schema says has three members.
61//!
62//! Enum membership is therefore adjudicated, and **nested input-object fields
63//! are walked for it** — unlike the scalar check, which stops at the argument.
64//! It has to: the shape the defect was reported against is an enum *inside* an
65//! input object, and a `where:` predicate reaches its enums the same way.
66//!
67//! [`EnumDefinition::values`]: crate::schema::EnumDefinition::values
68//! * **Nullability.** An explicit `null` is accepted for every argument, including a non-null one.
69//! That is § 5.6.1's other half; it changes which *documents* are valid rather than which
70//! *answers* are correct, so it is not folded in here.
71//! * **Mutations**, for the scalar check. Their arguments are input objects almost without
72//! exception, which the paragraph above excludes anyway. The *enum* check does cover them, from
73//! the mutation chokepoint rather than from here — see [`validate_enum_argument_values`].
74//!
75//! # Variable *values* are the half a spec-shaped fix would miss
76//!
77//! § 5.8.5 compares a variable's **declared type** against the argument's, which
78//! catches `query($n: String!) { products(limit: $n) }`. It says nothing about
79//! `query($n: Int!)` supplied with `{"n": "2"}` — the declaration is impeccable
80//! and the *value* is wrong. That document returned the whole table too, so
81//! [`validate_variable_values`] checks supplied values against their own
82//! declarations (§ 6.1.2), including the case where a non-null variable is not
83//! supplied at all: that also dropped its argument and widened the result.
84
85use serde_json::Value;
86
87use crate::{
88 error::{FraiseQLError, Result},
89 graphql::{
90 types::{GraphQLArgument, VariableDefinition},
91 value_json,
92 },
93 schema::{ArgumentDefinition, CompiledSchema, EnumDefinition, FieldType},
94};
95
96/// A built-in scalar whose value space this module is willing to adjudicate.
97///
98/// Membership is the whole leniency policy: a type that maps to `None` on both
99/// constructors below is passed through untouched, because a project can back
100/// it with any JSON shape and a disagreement would not be evidence of a client
101/// mistake.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103enum Scalar {
104 Int,
105 Float,
106 String,
107 Boolean,
108 Id,
109 Uuid,
110 DateTime,
111 Date,
112 Time,
113 Decimal,
114}
115
116impl Scalar {
117 /// The scalar an argument's compiled type adjudicates as, or `None` for
118 /// everything this module leaves alone.
119 const fn from_field_type(field_type: &FieldType) -> Option<Self> {
120 match field_type {
121 FieldType::Int => Some(Self::Int),
122 FieldType::Float => Some(Self::Float),
123 FieldType::String => Some(Self::String),
124 FieldType::Boolean => Some(Self::Boolean),
125 FieldType::Id => Some(Self::Id),
126 FieldType::Uuid => Some(Self::Uuid),
127 FieldType::DateTime => Some(Self::DateTime),
128 FieldType::Date => Some(Self::Date),
129 FieldType::Time => Some(Self::Time),
130 FieldType::Decimal => Some(Self::Decimal),
131 _ => None,
132 }
133 }
134
135 /// The scalar a *declared variable type name* adjudicates as.
136 ///
137 /// A name outside this set is a project's own scalar, an enum or an input
138 /// object, and is not adjudicated — see the module header.
139 fn from_type_name(name: &str) -> Option<Self> {
140 match name {
141 "Int" => Some(Self::Int),
142 "Float" => Some(Self::Float),
143 "String" => Some(Self::String),
144 "Boolean" => Some(Self::Boolean),
145 "ID" => Some(Self::Id),
146 "UUID" => Some(Self::Uuid),
147 "DateTime" => Some(Self::DateTime),
148 "Date" => Some(Self::Date),
149 "Time" => Some(Self::Time),
150 "Decimal" => Some(Self::Decimal),
151 _ => None,
152 }
153 }
154
155 /// The name this scalar publishes, for error messages.
156 const fn name(self) -> &'static str {
157 match self {
158 Self::Int => "Int",
159 Self::Float => "Float",
160 Self::String => "String",
161 Self::Boolean => "Boolean",
162 Self::Id => "ID",
163 Self::Uuid => "UUID",
164 Self::DateTime => "DateTime",
165 Self::Date => "Date",
166 Self::Time => "Time",
167 Self::Decimal => "Decimal",
168 }
169 }
170
171 /// Why `value` does not belong to this scalar, phrased for the client, or
172 /// `None` when it does belong.
173 ///
174 /// Separate from [`Self::accepts`] so an integer that is merely too large
175 /// does not read as "you wrote an Int where an Int was expected" — the
176 /// message that first came out of `limit: 99999999999999`.
177 fn rejection(self, value: &Value) -> Option<String> {
178 if self.accepts(value) {
179 return None;
180 }
181 if self == Self::Int && value.is_i64() {
182 return Some(format!("an Int outside the 32-bit range ({}..={})", i32::MIN, i32::MAX));
183 }
184 Some(format!("a {} value", json_shape(value)))
185 }
186
187 /// Does `value` belong to this scalar's value space?
188 ///
189 /// `null` belongs to every one of them: nullability is not adjudicated here
190 /// (module header).
191 fn accepts(self, value: &Value) -> bool {
192 if value.is_null() {
193 return true;
194 }
195 match self {
196 // § 3.5.1: Int is 32-bit signed, and a Float literal is not an Int
197 // even when its fractional part is zero. The range half is
198 // load-bearing rather than pedantic — `limit: 99999999999999`
199 // overflowed `u32` at the read site and dropped the clause.
200 Self::Int => value.as_i64().is_some_and(|v| i32::try_from(v).is_ok()),
201 Self::Float => value.is_number(),
202 Self::String | Self::Uuid | Self::DateTime | Self::Date | Self::Time => {
203 value.is_string()
204 },
205 Self::Boolean => value.is_boolean(),
206 // § 3.5.5: ID serializes as a String but accepts an integer input.
207 Self::Id => value.is_string() || value.as_i64().is_some(),
208 // Decimal travels as a string to keep precision, and an integer
209 // literal is an ordinary way to write one.
210 Self::Decimal => value.is_string() || value.is_number(),
211 }
212 }
213
214 /// May a variable declared as `self` be used where `location` is expected
215 /// (§ 5.8.5)?
216 ///
217 /// Deliberately wider than the spec's `AreTypesCompatible`, which admits
218 /// only identical named types: a client declaring `$id: String!` for a
219 /// `UUID` argument is writing the type its code generator produced for a
220 /// custom scalar, not making the mistake this rule exists to catch. What is
221 /// refused is a declaration whose value space cannot supply the location's
222 /// — `String!` at `Int`, which is exactly how the unbounded page arrived.
223 fn usable_at(self, location: Self) -> bool {
224 if self == location {
225 return true;
226 }
227 matches!(
228 (self, location),
229 (Self::Int, Self::Float | Self::Decimal | Self::Id)
230 | (Self::Float, Self::Decimal)
231 | (
232 Self::String,
233 Self::Id
234 | Self::Uuid
235 | Self::DateTime
236 | Self::Date
237 | Self::Time
238 | Self::Decimal
239 )
240 | (Self::Id, Self::String | Self::Uuid)
241 | (Self::Uuid, Self::String | Self::Id)
242 | (Self::DateTime | Self::Date | Self::Time | Self::Decimal, Self::String)
243 )
244 }
245}
246
247/// The scalar a supplied JSON value looks like, for an error message that says
248/// what arrived rather than echoing it.
249fn json_shape(value: &Value) -> &'static str {
250 match value {
251 Value::Null => "null",
252 Value::Bool(_) => "Boolean",
253 Value::Number(n) => {
254 if n.is_f64() {
255 "Float"
256 } else {
257 "Int"
258 }
259 },
260 Value::String(_) => "String",
261 Value::Array(_) => "a list",
262 Value::Object(_) => "an object",
263 }
264}
265
266/// Check the value written at each of `provided` against the type `declared`
267/// gives that argument (§ 5.6.1), and the declared type of each variable used
268/// there against the same (§ 5.8.5).
269///
270/// `field_label` names the field the way a client reads a schema —
271/// `Query.orders` — and `declared` is the field's published argument list
272/// ([`QueryDefinition::graphql_arguments`](crate::schema::QueryDefinition::graphql_arguments)),
273/// which is where the auto-wired `limit: Int` and `offset: Int` acquire a type.
274/// An argument with no entry there is not adjudicated: the relay cursor window
275/// and `nearest` are read by their own runners and are accepted by name alone.
276///
277/// # Errors
278///
279/// Returns [`FraiseQLError::Validation`] naming the field, the argument, the
280/// type it declares and the type the document wrote. The offending value is
281/// **not** quoted back — the shape is what the client needs, and a value in an
282/// error message is how #1197's second half returned a stored row to its caller.
283pub fn validate_argument_values(
284 field_label: &str,
285 declared: &[ArgumentDefinition],
286 provided: &[GraphQLArgument],
287 variable_defs: &[VariableDefinition],
288) -> Result<()> {
289 for arg in provided {
290 let Some(def) = declared.iter().find(|d| d.name == arg.name) else {
291 continue;
292 };
293 let Some(location) = Scalar::from_field_type(&def.arg_type) else {
294 continue;
295 };
296
297 // An enum literal is a bare name that JSON has to carry as a string;
298 // whether it belongs at a scalar argument is an enum question, not this
299 // one.
300 if arg.value_type == "enum" {
301 continue;
302 }
303
304 let value = value_json::decode(&arg.value_json)?;
305
306 if let Some(var_name) = value_json::variable_name(&value) {
307 check_variable_usage(field_label, &arg.name, location, var_name, variable_defs)?;
308 } else if let Some(reason) = location.rejection(&value) {
309 return Err(FraiseQLError::Validation {
310 message: format!(
311 "Argument `{}` on {field_label} has type `{}`, but the document wrote {reason}",
312 arg.name,
313 location.name(),
314 ),
315 path: Some(arg.name.clone()),
316 });
317 }
318 }
319 Ok(())
320}
321
322/// § 5.8.5 for one argument: the variable used there must be declared at a type
323/// whose values the argument can accept.
324fn check_variable_usage(
325 field_label: &str,
326 arg_name: &str,
327 location: Scalar,
328 var_name: &str,
329 variable_defs: &[VariableDefinition],
330) -> Result<()> {
331 // An undeclared reference is § 5.8.3's error, raised before this runs.
332 let Some(def) = variable_defs.iter().find(|v| v.name == var_name) else {
333 return Ok(());
334 };
335 // A list-typed variable at a scalar argument is a shape question, and § 3.11
336 // lets a single value stand for a list, so the wrapper is not adjudicated.
337 if def.var_type.list {
338 return Ok(());
339 }
340 let Some(declared) = Scalar::from_type_name(&def.var_type.name) else {
341 return Ok(());
342 };
343 if declared.usable_at(location) {
344 return Ok(());
345 }
346 Err(FraiseQLError::Validation {
347 message: format!(
348 "Variable `${var_name}` is declared as `{}` but is used for argument `{arg_name}` on \
349 {field_label}, which has type `{}`",
350 def.var_type.name,
351 location.name()
352 ),
353 path: Some(arg_name.to_string()),
354 })
355}
356
357/// Check each supplied variable against the type its operation declares, and
358/// refuse a non-null variable that carries no value at all (§ 6.1.2).
359///
360/// This is the half § 5.8.5 cannot reach. `query($n: Int!) { products(limit:
361/// $n) }` is a correct *usage* whatever `{"n": "2"}` does to it, and what it
362/// did was return the whole table: the value failed `as_u64()` at the read site
363/// and the `LIMIT` clause was never emitted. The same happened when `$n` was
364/// declared `Int!` and simply not supplied.
365///
366/// A variable with a **default** and no supplied value is left alone: the
367/// default is what applies, and it was written against the same declaration.
368/// A *nullable* variable with no value is also left alone, deliberately — that
369/// is what lets `limit: $limit` fall back to the query's compiled default
370/// instead of forcing `LIMIT NULL`.
371///
372/// # Errors
373///
374/// Returns [`FraiseQLError::Validation`] naming the variable and its declared
375/// type. As above, the supplied value is described, never quoted.
376pub fn validate_variable_values(
377 operation_name: Option<&str>,
378 variable_defs: &[VariableDefinition],
379 values: Option<&Value>,
380) -> Result<()> {
381 let supplied = values.and_then(Value::as_object);
382
383 for def in variable_defs {
384 let value = supplied.and_then(|map| map.get(&def.name));
385
386 let Some(value) = value else {
387 if !def.var_type.nullable && def.default_value.is_none() {
388 return Err(FraiseQLError::Validation {
389 message: format!(
390 "Variable `${}` is declared `{}!`{} but no value was supplied",
391 def.name,
392 def.var_type.name,
393 operation_label(operation_name)
394 ),
395 path: Some(def.name.clone()),
396 });
397 }
398 continue;
399 };
400
401 if value.is_null() && !def.var_type.nullable {
402 return Err(FraiseQLError::Validation {
403 message: format!(
404 "Variable `${}` is declared `{}!`{} but was supplied as null",
405 def.name,
406 def.var_type.name,
407 operation_label(operation_name)
408 ),
409 path: Some(def.name.clone()),
410 });
411 }
412
413 // A list declaration carries its items' type, not the value's; § 3.11
414 // also lets a bare value stand for a one-element list. Neither is
415 // adjudicated here.
416 if def.var_type.list {
417 continue;
418 }
419 let Some(declared) = Scalar::from_type_name(&def.var_type.name) else {
420 continue;
421 };
422 if let Some(reason) = declared.rejection(value) {
423 return Err(FraiseQLError::Validation {
424 message: format!(
425 "Variable `${}` is declared `{}`{} but was supplied {reason}",
426 def.name,
427 def.var_type.name,
428 operation_label(operation_name),
429 ),
430 path: Some(def.name.clone()),
431 });
432 }
433 }
434 Ok(())
435}
436
437// ── Enum membership — § 5.6.1 and § 6.1.2 for the one kind the schema fully
438// ── specifies (#1362) ────────────────────────────────────────────────────────
439
440/// How deep the walk follows nested input objects before giving up.
441///
442/// The recursion is driven by the *value*, which is finite, so this is a stack
443/// guard and not a termination argument: a self-referential input object like
444/// `OrderWhereInput._and: [OrderWhereInput!]` recurses only as far as the client
445/// actually nested. Matches `value_json`'s own literal-nesting cap, so a document
446/// that parsed cannot be refused here for depth it was already allowed.
447const MAX_WALK_DEPTH: usize = 32;
448
449/// At most this many members are named in a refusal before it summarises.
450const MAX_MEMBERS_LISTED: usize = 12;
451
452/// Adjudicate enum membership for the values written at a field's arguments,
453/// from a **resolved** argument map (#1362).
454///
455/// This is the entry the mutation chokepoint uses, and the reason it takes a map
456/// rather than a document's `[GraphQLArgument]`: `execute_mutation_impl` is where
457/// every transport that writes converges, and REST, gRPC and MCP arrive there
458/// with a JSON payload and no GraphQL document at all. Inline literals are
459/// already merged into that same map (#719), so one call covers a literal, a
460/// variable and a payload alike.
461///
462/// `provided` is the map arguments are read out of — the value under each
463/// argument's own name. An argument with no entry is not adjudicated; whether it
464/// was required is the mutation runner's question, asked a few lines later.
465///
466/// # Errors
467///
468/// Returns [`FraiseQLError::Validation`] naming the path to the offending field
469/// and the members its enum declares.
470pub fn validate_enum_argument_values(
471 schema: &CompiledSchema,
472 field_label: &str,
473 declared: &[ArgumentDefinition],
474 provided: Option<&Value>,
475) -> Result<()> {
476 let Some(map) = provided.and_then(Value::as_object) else {
477 return Ok(());
478 };
479 for arg in declared {
480 let Some(value) = map.get(&arg.name) else {
481 continue;
482 };
483 walk_field_type(schema, field_label, &arg.name, &arg.arg_type, value, 0)?;
484 }
485 Ok(())
486}
487
488/// Adjudicate enum membership for the **literals** a document writes at a field's
489/// arguments (#1362).
490///
491/// The read path's counterpart to [`validate_enum_argument_values`]. A value that
492/// is a variable *reference* is skipped here and adjudicated by
493/// [`validate_enum_variable_values`] against its own declaration, so neither
494/// check has to resolve the other's half and a reference cannot be mistaken for
495/// an object whose single key happens to be the variable marker.
496///
497/// # Errors
498///
499/// Returns [`FraiseQLError::Validation`] as above.
500pub fn validate_enum_argument_literals(
501 schema: &CompiledSchema,
502 field_label: &str,
503 declared: &[ArgumentDefinition],
504 provided: &[GraphQLArgument],
505) -> Result<()> {
506 for arg in provided {
507 let Some(def) = declared.iter().find(|d| d.name == arg.name) else {
508 continue;
509 };
510 let value = value_json::decode(&arg.value_json)?;
511 if value_json::variable_name(&value).is_some() {
512 continue;
513 }
514 walk_field_type(schema, field_label, &arg.name, &def.arg_type, &value, 0)?;
515 }
516 Ok(())
517}
518
519/// Adjudicate enum membership for supplied variable values against their own
520/// declarations — § 6.1.2, for enums (#1362).
521///
522/// Kept separate from [`validate_variable_values`] rather than folded into it
523/// because that function answers a question about built-in scalars and needs no
524/// schema; this one cannot be asked without one. They are called together at
525/// every site, so the pair is the check.
526///
527/// # Errors
528///
529/// Returns [`FraiseQLError::Validation`] naming the variable and the members its
530/// enum declares.
531pub fn validate_enum_variable_values(
532 schema: &CompiledSchema,
533 operation_name: Option<&str>,
534 variable_defs: &[VariableDefinition],
535 values: Option<&Value>,
536) -> Result<()> {
537 let Some(map) = values.and_then(Value::as_object) else {
538 return Ok(());
539 };
540 for def in variable_defs {
541 let Some(value) = map.get(&def.name) else {
542 continue;
543 };
544 let subject = format!("Variable `${}`{}", def.name, operation_label(operation_name));
545 walk_type_ref(schema, &subject, "", &def.var_type.name, value, 0)?;
546 }
547 Ok(())
548}
549
550/// Walk a compiled [`FieldType`] against `value`, adjudicating every enum under it.
551fn walk_field_type(
552 schema: &CompiledSchema,
553 subject: &str,
554 path: &str,
555 declared: &FieldType,
556 value: &Value,
557 depth: usize,
558) -> Result<()> {
559 if value.is_null() || depth > MAX_WALK_DEPTH {
560 return Ok(());
561 }
562 match declared {
563 FieldType::List(inner) => walk_list(schema, subject, path, value, depth, |v, p, d| {
564 walk_field_type(schema, subject, p, inner, v, d)
565 }),
566 FieldType::Enum(name) => check_membership(schema, subject, path, name, value),
567 // The compiler emits an input-type reference as `Object`, never `Input`
568 // (`parse_field_type` has no `Input` variant), so both have to resolve
569 // through the input registry — the same pairing `execute_mutation_impl`
570 // makes when it decides whether an argument is a structured input.
571 FieldType::Object(name) | FieldType::Input(name) => {
572 walk_input_object(schema, subject, path, name, value, depth)
573 },
574 _ => Ok(()),
575 }
576}
577
578/// Walk a **written** GraphQL type reference — the form an input field and a
579/// variable declaration carry — against `value`.
580///
581/// Input fields store their type as a string (`"[OrderStatus!]"`), not a
582/// [`FieldType`], so the wrappers are peeled here rather than matched.
583fn walk_type_ref(
584 schema: &CompiledSchema,
585 subject: &str,
586 path: &str,
587 declared: &str,
588 value: &Value,
589 depth: usize,
590) -> Result<()> {
591 if depth > MAX_WALK_DEPTH {
592 return Ok(());
593 }
594 let declared = declared.trim();
595 // `!` before `[`: `[X]!` peels to `[X]` and then to `X`, and `[X!]` to `X!`
596 // to `X`. Peeling brackets first would leave a stray `!` on the inner name.
597 if let Some(inner) = declared.strip_suffix('!') {
598 return walk_type_ref(schema, subject, path, inner, value, depth);
599 }
600 if let Some(inner) = declared.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
601 return walk_list(schema, subject, path, value, depth, |v, p, d| {
602 walk_type_ref(schema, subject, p, inner, v, d)
603 });
604 }
605 if value.is_null() {
606 return Ok(());
607 }
608 if schema.find_enum(declared).is_some() {
609 return check_membership(schema, subject, path, declared, value);
610 }
611 walk_input_object(schema, subject, path, declared, value, depth)
612}
613
614/// Apply `walk` to each element of a list value.
615///
616/// A value that is not an array is walked as a single element rather than
617/// skipped: § 3.11 lets a bare value stand for a one-element list, and that
618/// coercion is how `where: {status: {in: PENDING}}` is written in practice. A
619/// list declaration must not become a hole an unchecked enum fits through.
620fn walk_list(
621 _schema: &CompiledSchema,
622 _subject: &str,
623 path: &str,
624 value: &Value,
625 depth: usize,
626 mut walk: impl FnMut(&Value, &str, usize) -> Result<()>,
627) -> Result<()> {
628 match value.as_array() {
629 Some(items) => {
630 for (index, item) in items.iter().enumerate() {
631 walk(item, &format!("{path}[{index}]"), depth + 1)?;
632 }
633 Ok(())
634 },
635 None => walk(value, path, depth + 1),
636 }
637}
638
639/// Walk the fields of a declared input object, if `name` is one.
640///
641/// A name that is not a registered input object resolves to nothing and is
642/// passed through: a custom scalar, an output object or a type this schema does
643/// not declare is not this check's business.
644fn walk_input_object(
645 schema: &CompiledSchema,
646 subject: &str,
647 path: &str,
648 name: &str,
649 value: &Value,
650 depth: usize,
651) -> Result<()> {
652 let (Some(input_type), Some(object)) = (schema.find_input_type(name), value.as_object()) else {
653 return Ok(());
654 };
655 for field in &input_type.fields {
656 // The client writes the *surface* name, which under `camelCase` differs
657 // from the stored one. Both are accepted: `display_name` is what the
658 // required-field check (#414) looks the value up by, and the canonical
659 // name is what a payload arriving over REST or gRPC carries. Validating
660 // only one of the two would leave the other transport's enums unchecked
661 // — and a value present under either key is a value that reaches SQL.
662 let surface = schema.display_name(&field.name);
663 let value = object
664 .get(surface.as_str())
665 .or_else(|| (surface != field.name).then(|| object.get(&field.name)).flatten());
666 let Some(value) = value else {
667 continue;
668 };
669 let child = if path.is_empty() {
670 surface
671 } else {
672 format!("{path}.{surface}")
673 };
674 walk_type_ref(schema, subject, &child, &field.field_type, value, depth + 1)?;
675 }
676 Ok(())
677}
678
679/// The adjudication itself: `value` must name a member of `enum_name`.
680fn check_membership(
681 schema: &CompiledSchema,
682 subject: &str,
683 path: &str,
684 enum_name: &str,
685 value: &Value,
686) -> Result<()> {
687 let Some(def) = schema.find_enum(enum_name) else {
688 return Ok(());
689 };
690 // A GraphQL enum value is a bare name, which JSON carries as a string —
691 // `value_json` encodes a literal `PENDING` and a variable-supplied
692 // `"PENDING"` identically, which is what lets one check cover both paths.
693 if let Some(written) = value.as_str() {
694 if def.values.iter().any(|member| member.name == written) {
695 return Ok(());
696 }
697 }
698 let wrote = if value.is_string() {
699 "a name that is not one of its members".to_string()
700 } else {
701 format!("{} , which is not a name at all", json_shape(value))
702 };
703 let at = if path.is_empty() {
704 String::new()
705 } else {
706 format!(" at `{path}`")
707 };
708 Err(FraiseQLError::Validation {
709 // The offending value is described, never quoted back — the same rule
710 // the scalar half follows, and for the same reason (#1197). The members
711 // are safe to name: introspection publishes exactly this list.
712 message: format!(
713 "{subject}{at} has enum type `{enum_name}`, but the document wrote {wrote}. \
714 Valid members: {}",
715 member_list(def)
716 ),
717 path: (!path.is_empty()).then(|| path.to_string()),
718 })
719}
720
721/// The members of `def`, capped so a large enum does not produce an unreadable
722/// error.
723fn member_list(def: &EnumDefinition) -> String {
724 let total = def.values.len();
725 let shown: Vec<&str> =
726 def.values.iter().take(MAX_MEMBERS_LISTED).map(|v| v.name.as_str()).collect();
727 if total > shown.len() {
728 format!("{} (and {} more)", shown.join(", "), total - shown.len())
729 } else {
730 shown.join(", ")
731 }
732}
733
734/// The " in operation ..." clause an error message carries, or nothing for an
735/// anonymous operation.
736fn operation_label(operation_name: Option<&str>) -> String {
737 operation_name.map_or_else(String::new, |name| format!(" in operation `{name}`"))
738}
739
740#[cfg(test)]
741#[path = "argument_value_validation_tests.rs"]
742mod argument_value_validation_tests;