Skip to main content

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;