Skip to main content

gqls/
example.rs

1//! Draft a ready-to-paste example operation for a matched field.
2//!
3//! Everything here is mechanical — the schema already says what the arguments
4//! are, what the field returns, and which of that type's fields are leaves. The
5//! rules, and why each one:
6//!
7//! * **Arguments you must supply become variables.** Never inline a literal
8//!   into the query body; a pasted operation should be parameterized from the
9//!   start. An argument the server can fill in for itself — nullable, or with
10//!   a schema default — is left out of the operation entirely and listed
11//!   underneath, so the query runs as-is and the knobs are still discoverable.
12//! * **A placeholder names its type.** `"<ID!>"` says both what to put there
13//!   and that it's required; `""` or `0` look like real values and get pasted
14//!   by accident.
15//! * **One level of selection, leaves only.** A scalar or enum return needs no
16//!   selection set at all. An object return gets its scalar/enum fields, and a
17//!   `# add fields you need` marker for the object-valued ones — guessing how
18//!   deep someone wants to go is worse than leaving a hole.
19//! * **An `errors` block only when the schema has one.** The payload/errors
20//!   convention is widespread but not universal, so it's expanded only when
21//!   that field really exists.
22//! * **A nested field is reached through a root.** `Company.employee` isn't
23//!   callable on its own, so it's wrapped in a root field that returns
24//!   `Company`. When several roots qualify, the caller is told, rather than the
25//!   pick being passed off as obvious.
26
27use std::collections::HashMap;
28
29use anyhow::{bail, Result};
30use serde_json::{Map, Value};
31
32use crate::model::{Kind, SchemaRecord};
33
34/// A drafted operation and the variables it expects.
35#[derive(Debug)]
36pub struct Example {
37    /// The GraphQL document, ready to paste.
38    pub operation: String,
39    /// A JSON object of placeholder variable values, one per required argument.
40    pub variables: Value,
41    /// Arguments left out because the server can supply them — rendered as
42    /// `field(name: Type = default)`, ready to paste back in.
43    pub optional: Vec<String>,
44    /// The input objects and enums the arguments refer to, expanded so the
45    /// variables can be filled in without opening the schema. Each entry is a
46    /// block of lines; nested input objects appear as their own entries.
47    pub input_types: Vec<Vec<String>>,
48    /// The root field a nested target was reached through, if it needed one.
49    pub via: Option<String>,
50    /// Other root fields that could have reached a nested target. Non-empty
51    /// only when the choice was ambiguous.
52    pub alternatives: Vec<String>,
53}
54
55/// Draft an operation that reaches `target`.
56pub fn build(target: &SchemaRecord, records: &[SchemaRecord]) -> Result<Example> {
57    let schema = Schema::index(records);
58
59    // The chain of fields to nest, outermost first. A root operation field is
60    // already reachable; anything else needs a root that returns its parent.
61    let (chain, via, alternatives) = match target.kind {
62        Kind::Query | Kind::Mutation | Kind::Subscription => (vec![target], None, Vec::new()),
63        Kind::Field => {
64            let parent = target
65                .parent
66                .as_deref()
67                .ok_or_else(|| anyhow::anyhow!("{} has no enclosing type", target.path))?;
68            let mut roots = schema.roots_returning(parent);
69            if roots.is_empty() {
70                bail!(
71                    "{} isn't reachable in one hop — no root field returns {parent}. \
72                     Try `gqls --returns {parent}` to see what's close.",
73                    target.path
74                );
75            }
76            let chosen = roots.remove(0);
77            let via = Some(chosen.path.clone());
78            let alternatives = roots.iter().map(|r| r.path.clone()).collect();
79            (vec![chosen, target], via, alternatives)
80        }
81        other => bail!(
82            "can't draft an operation for a {} — pick a field, query, or mutation",
83            other.as_str()
84        ),
85    };
86
87    let operation_kind = match chain[0].kind {
88        Kind::Mutation => "mutation",
89        Kind::Subscription => "subscription",
90        _ => "query",
91    };
92
93    // Variables first: every argument along the chain, deduped so a repeated
94    // name (two `id`s) doesn't collide in the signature.
95    let (vars, optional) = Variables::collect(&chain);
96
97    // Then the selection, innermost outward.
98    let leaf_type = chain
99        .last()
100        .and_then(|r| r.base_type())
101        .unwrap_or_default()
102        .to_string();
103    let mut body = schema.selection(&leaf_type);
104
105    for (depth, field) in chain.iter().enumerate().rev() {
106        let args = vars.rendered_for(depth);
107        body = if body.is_empty() {
108            // A leaf-returning field takes no selection set at all.
109            vec![format!("{}{}", field.name, args)]
110        } else {
111            let mut wrapped = vec![format!("{}{} {{", field.name, args)];
112            wrapped.extend(body.into_iter().map(|l| format!("  {l}")));
113            wrapped.push("}".to_string());
114            wrapped
115        };
116    }
117
118    let mut operation = String::new();
119    operation.push_str(operation_kind);
120    operation.push(' ');
121    operation.push_str(&pascal_case(&chain.last().unwrap().name));
122    operation.push_str(&vars.signature());
123    operation.push_str(" {\n");
124    for line in &body {
125        operation.push_str("  ");
126        operation.push_str(line);
127        operation.push('\n');
128    }
129    operation.push_str("}\n");
130
131    Ok(Example {
132        operation,
133        variables: vars.placeholders(),
134        input_types: schema.input_types(&chain),
135        optional,
136        via,
137        alternatives,
138    })
139}
140
141/// Records indexed the two ways drafting needs: what kind a type name is, and
142/// what fields a type has.
143struct Schema<'a> {
144    kinds: HashMap<&'a str, Kind>,
145    fields: HashMap<&'a str, Vec<&'a SchemaRecord>>,
146    roots: Vec<&'a SchemaRecord>,
147}
148
149impl<'a> Schema<'a> {
150    fn index(records: &'a [SchemaRecord]) -> Self {
151        let mut kinds = HashMap::new();
152        let mut fields: HashMap<&str, Vec<&SchemaRecord>> = HashMap::new();
153        let mut roots = Vec::new();
154        for r in records {
155            match r.kind {
156                Kind::Query | Kind::Mutation | Kind::Subscription => {
157                    roots.push(r);
158                    if let Some(p) = r.parent.as_deref() {
159                        fields.entry(p).or_default().push(r);
160                    }
161                }
162                Kind::Field | Kind::InputField | Kind::EnumValue => {
163                    if let Some(p) = r.parent.as_deref() {
164                        fields.entry(p).or_default().push(r);
165                    }
166                }
167                _ => {
168                    kinds.insert(r.name.as_str(), r.kind);
169                }
170            }
171        }
172        Self {
173            kinds,
174            fields,
175            roots,
176        }
177    }
178
179    /// Expand the input objects and enums an operation's arguments refer to,
180    /// so the variables can be filled in without going back to the schema. A
181    /// nested input object becomes its own entry rather than a deeper
182    /// indent — flat reads better and makes cycles (`Filter { and: [Filter] }`)
183    /// a non-issue, since each type is expanded once.
184    fn input_types(&self, chain: &[&SchemaRecord]) -> Vec<Vec<String>> {
185        /// Enough for any real argument list; a guard, not a policy.
186        const MAX_TYPES: usize = 12;
187
188        let mut queue: Vec<String> = chain
189            .iter()
190            .flat_map(|f| f.args.iter())
191            .map(|a| base_of(split_arg(a).type_ref).to_string())
192            .collect();
193        let mut seen: Vec<String> = Vec::new();
194        let mut out = Vec::new();
195
196        while let Some(name) = queue.pop() {
197            if seen.contains(&name) || out.len() >= MAX_TYPES {
198                continue;
199            }
200            seen.push(name.clone());
201            match self.kinds.get(name.as_str()) {
202                Some(Kind::InputObject) => {
203                    let mut block = vec![format!("{name} {{")];
204                    for f in self.fields.get(name.as_str()).into_iter().flatten() {
205                        let ty = f.type_ref.as_deref().unwrap_or("");
206                        block.push(format!("  {}: {}", f.name, ty));
207                        queue.push(base_of(ty).to_string());
208                    }
209                    block.push("}".to_string());
210                    out.push(block);
211                }
212                Some(Kind::Enum) => {
213                    let values: Vec<&str> = self
214                        .fields
215                        .get(name.as_str())
216                        .into_iter()
217                        .flatten()
218                        .map(|v| v.name.as_str())
219                        .collect();
220                    if !values.is_empty() {
221                        out.push(vec![format!("{name} = {}", values.join(" | "))]);
222                    }
223                }
224                _ => {}
225            }
226        }
227        out.sort();
228        out
229    }
230
231    /// Root operation fields returning `type_name`, best first. Fewest required
232    /// arguments wins: `viewer` is a friendlier entry point than `node(id:)`,
233    /// which needs one you may not have yet.
234    fn roots_returning(&self, type_name: &str) -> Vec<&'a SchemaRecord> {
235        let mut hits: Vec<&SchemaRecord> = self
236            .roots
237            .iter()
238            .copied()
239            .filter(|r| {
240                r.base_type()
241                    .is_some_and(|t| t.eq_ignore_ascii_case(type_name))
242            })
243            .collect();
244        hits.sort_by_key(|r| (required_args(r), r.path.len(), r.path.clone()));
245        hits
246    }
247
248    /// Whether a type needs no selection set — a scalar, an enum, or a name
249    /// the schema never defines (the built-in scalars, which SDL omits).
250    fn is_leaf(&self, type_name: &str) -> bool {
251        !matches!(
252            self.kinds.get(type_name),
253            Some(Kind::Object | Kind::Interface | Kind::Union | Kind::InputObject)
254        )
255    }
256
257    /// The selection set for `type_name`: its leaf fields, plus a marker for
258    /// each object-valued field so the hole is visible. Empty for a leaf type.
259    fn selection(&self, type_name: &str) -> Vec<String> {
260        if type_name.is_empty() || self.is_leaf(type_name) {
261            return Vec::new();
262        }
263        let mut lines = Vec::new();
264        let mut deferred = Vec::new();
265        for f in self.fields.get(type_name).into_iter().flatten() {
266            if f.kind != Kind::Field {
267                continue;
268            }
269            let Some(base) = f.base_type() else { continue };
270            if self.is_leaf(base) {
271                // A field with required arguments can't be selected bare.
272                if f.args.iter().any(|a| a.trim_end().ends_with('!')) {
273                    deferred.push(format!("# {}: {} — needs arguments", f.name, base));
274                } else {
275                    lines.push(f.name.clone());
276                }
277            } else if f.name.eq_ignore_ascii_case("errors") {
278                // The payload/errors convention, expanded only because this
279                // schema really has the field.
280                let inner = self.selection(base);
281                lines.push(format!("{} {{", f.name));
282                lines.extend(inner.into_iter().map(|l| format!("  {l}")));
283                lines.push("}".to_string());
284            } else {
285                deferred.push(format!("# {}: {} — add fields you need", f.name, base));
286            }
287        }
288        lines.extend(deferred);
289        if lines.is_empty() {
290            // A union (no fields of its own), or a type this schema doesn't
291            // detail. `__typename` is always valid and keeps the query runnable.
292            lines.push("__typename".to_string());
293            lines.push("# add inline fragments: ... on ConcreteType { … }".to_string());
294        }
295        lines
296    }
297}
298
299/// The operation's variables: one per argument along the field chain.
300struct Variables {
301    /// `(depth, arg name, variable name, type)`
302    entries: Vec<(usize, String, String, String)>,
303}
304
305impl Variables {
306    /// Split the chain's arguments: the ones a caller must supply become
307    /// variables, the rest are returned as notes.
308    fn collect(chain: &[&SchemaRecord]) -> (Self, Vec<String>) {
309        let mut entries: Vec<(usize, String, String, String)> = Vec::new();
310        let mut optional = Vec::new();
311        for (depth, field) in chain.iter().enumerate() {
312            for arg in &field.args {
313                let Arg {
314                    name,
315                    type_ref,
316                    default,
317                } = split_arg(arg);
318                // A default means the server fills it in, so even a non-null
319                // argument needs nothing from the caller.
320                if !type_ref.ends_with('!') || default.is_some() {
321                    optional.push(format!("{}({})", field.name, arg.trim()));
322                    continue;
323                }
324                // Disambiguate a name already taken by an outer field's arg.
325                let taken = entries.iter().any(|(_, _, var, _)| var == name);
326                let var = if taken {
327                    format!("{}{}", field.name, pascal_case(name))
328                } else {
329                    name.to_string()
330                };
331                entries.push((depth, name.to_string(), var, type_ref.to_string()));
332            }
333        }
334        (Self { entries }, optional)
335    }
336
337    /// `($id: ID!, $first: Int)`, or empty when there are no arguments.
338    fn signature(&self) -> String {
339        if self.entries.is_empty() {
340            return String::new();
341        }
342        let inner: Vec<String> = self
343            .entries
344            .iter()
345            .map(|(_, _, var, ty)| format!("${var}: {ty}"))
346            .collect();
347        format!("({})", inner.join(", "))
348    }
349
350    /// `(id: $id, first: $first)` for the field at `depth`, or empty.
351    fn rendered_for(&self, depth: usize) -> String {
352        let inner: Vec<String> = self
353            .entries
354            .iter()
355            .filter(|(d, _, _, _)| *d == depth)
356            .map(|(_, name, var, _)| format!("{name}: ${var}"))
357            .collect();
358        if inner.is_empty() {
359            String::new()
360        } else {
361            format!("({})", inner.join(", "))
362        }
363    }
364
365    /// Every variable is required by construction, so each placeholder names
366    /// its type — unmistakably a blank to fill rather than a usable value.
367    fn placeholders(&self) -> Value {
368        let mut map = Map::new();
369        for (_, _, var, ty) in &self.entries {
370            map.insert(var.clone(), Value::String(format!("<{ty}>")));
371        }
372        Value::Object(map)
373    }
374}
375
376/// How many of a field's arguments are non-null, and so must be supplied.
377fn required_args(r: &SchemaRecord) -> usize {
378    r.args
379        .iter()
380        .map(|a| split_arg(a))
381        .filter(|a| a.type_ref.ends_with('!') && a.default.is_none())
382        .count()
383}
384
385/// A type reference with its list and non-null wrappers peeled: `[User!]!`
386/// → `User`.
387fn base_of(type_ref: &str) -> &str {
388    type_ref.trim_matches(|c| matches!(c, '[' | ']' | '!' | ' '))
389}
390
391/// One parsed argument signature.
392struct Arg<'a> {
393    name: &'a str,
394    type_ref: &'a str,
395    default: Option<&'a str>,
396}
397
398/// `"first: Int = 10"` → name `first`, type `Int`, default `10`. gqls renders
399/// arguments as `name: Type` with ` = default` appended when the schema has one.
400fn split_arg(arg: &str) -> Arg<'_> {
401    let (name, rest) = arg.split_once(':').unwrap_or((arg, ""));
402    let (type_ref, default) = match rest.split_once('=') {
403        Some((t, d)) => (t, Some(d.trim())),
404        None => (rest, None),
405    };
406    Arg {
407        name: name.trim(),
408        type_ref: type_ref.trim(),
409        default,
410    }
411}
412
413/// `updateEmployee` → `UpdateEmployee`, for the operation name.
414fn pascal_case(name: &str) -> String {
415    let mut chars = name.chars();
416    match chars.next() {
417        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
418        None => String::new(),
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    fn rec(
427        path: &str,
428        name: &str,
429        kind: Kind,
430        parent: Option<&str>,
431        type_ref: Option<&str>,
432        args: &[&str],
433    ) -> SchemaRecord {
434        SchemaRecord {
435            path: path.into(),
436            name: name.into(),
437            kind,
438            parent: parent.map(Into::into),
439            type_ref: type_ref.map(Into::into),
440            args: args.iter().map(|a| a.to_string()).collect(),
441            description: None,
442            deprecated: None,
443            directives: vec![],
444        }
445    }
446
447    /// Query.user(id) -> User { id name role posts(Post) }, plus a mutation
448    /// whose payload carries an errors block.
449    fn schema() -> Vec<SchemaRecord> {
450        vec![
451            rec("Query", "Query", Kind::Object, None, None, &[]),
452            rec("User", "User", Kind::Object, None, None, &[]),
453            rec("Post", "Post", Kind::Object, None, None, &[]),
454            rec("Role", "Role", Kind::Enum, None, None, &[]),
455            rec("Payload", "Payload", Kind::Object, None, None, &[]),
456            rec("UserError", "UserError", Kind::Object, None, None, &[]),
457            rec("Input", "Input", Kind::InputObject, None, None, &[]),
458            rec(
459                "Query.user",
460                "user",
461                Kind::Query,
462                Some("Query"),
463                Some("User"),
464                &["id: ID!"],
465            ),
466            rec(
467                "Query.count",
468                "count",
469                Kind::Query,
470                Some("Query"),
471                Some("Int!"),
472                &[],
473            ),
474            rec("User.id", "id", Kind::Field, Some("User"), Some("ID!"), &[]),
475            rec(
476                "User.name",
477                "name",
478                Kind::Field,
479                Some("User"),
480                Some("String"),
481                &[],
482            ),
483            rec(
484                "User.role",
485                "role",
486                Kind::Field,
487                Some("User"),
488                Some("Role!"),
489                &[],
490            ),
491            rec(
492                "User.posts",
493                "posts",
494                Kind::Field,
495                Some("User"),
496                Some("[Post!]!"),
497                &[],
498            ),
499            rec(
500                "User.avatar",
501                "avatar",
502                Kind::Field,
503                Some("User"),
504                Some("String"),
505                &["size: Int!"],
506            ),
507            rec(
508                "Mutation.save",
509                "save",
510                Kind::Mutation,
511                Some("Mutation"),
512                Some("Payload!"),
513                &["input: Input!", "dryRun: Boolean"],
514            ),
515            rec(
516                "Payload.ok",
517                "ok",
518                Kind::Field,
519                Some("Payload"),
520                Some("Boolean!"),
521                &[],
522            ),
523            rec(
524                "Payload.errors",
525                "errors",
526                Kind::Field,
527                Some("Payload"),
528                Some("[UserError!]!"),
529                &[],
530            ),
531            rec(
532                "UserError.message",
533                "message",
534                Kind::Field,
535                Some("UserError"),
536                Some("String!"),
537                &[],
538            ),
539            rec(
540                "Role.ADMIN",
541                "ADMIN",
542                Kind::EnumValue,
543                Some("Role"),
544                None,
545                &[],
546            ),
547        ]
548    }
549
550    fn build_for(path: &str) -> Example {
551        let records = schema();
552        let target = records.iter().find(|r| r.path == path).unwrap();
553        build(target, &records).unwrap()
554    }
555
556    #[test]
557    fn root_field_becomes_a_parameterized_query() {
558        let ex = build_for("Query.user");
559        assert_eq!(
560            ex.operation,
561            "query User($id: ID!) {\n  \
562               user(id: $id) {\n    \
563                 id\n    \
564                 name\n    \
565                 role\n    \
566                 # posts: Post — add fields you need\n    \
567                 # avatar: String — needs arguments\n  \
568               }\n\
569             }\n"
570        );
571        assert_eq!(ex.variables, serde_json::json!({ "id": "<ID!>" }));
572    }
573
574    #[test]
575    fn a_scalar_return_gets_no_selection_set() {
576        let ex = build_for("Query.count");
577        assert_eq!(ex.operation, "query Count {\n  count\n}\n");
578        assert_eq!(ex.variables, serde_json::json!({}));
579    }
580
581    #[test]
582    fn mutation_expands_a_real_errors_block() {
583        let ex = build_for("Mutation.save");
584        assert!(
585            ex.operation
586                .starts_with("mutation Save($input: Input!) {\n  save(input: $input) {"),
587            "{}",
588            ex.operation
589        );
590        // the nullable arg is left out of the operation, but still surfaced
591        assert_eq!(ex.optional, ["save(dryRun: Boolean)"]);
592        assert!(
593            ex.operation.contains("errors {\n      message\n    }"),
594            "{}",
595            ex.operation
596        );
597        // only what the caller must supply, typed so it can't be mistaken
598        // for a usable value
599        assert_eq!(ex.variables, serde_json::json!({ "input": "<Input!>" }));
600    }
601
602    #[test]
603    fn nested_field_is_wrapped_in_a_root_that_returns_its_type() {
604        let ex = build_for("User.posts");
605        // User.posts isn't callable directly; Query.user returns a User
606        assert_eq!(
607            ex.operation,
608            "query Posts($id: ID!) {\n  \
609               user(id: $id) {\n    \
610                 posts {\n      \
611                   __typename\n      \
612                   # add inline fragments: ... on ConcreteType { … }\n    \
613                 }\n  \
614               }\n\
615             }\n"
616        );
617    }
618
619    #[test]
620    fn an_unreachable_field_is_an_error_not_a_guess() {
621        let mut records = schema();
622        // nothing returns UserError, so UserError.message can't be reached
623        let target = records
624            .iter()
625            .position(|r| r.path == "UserError.message")
626            .unwrap();
627        let target = records.remove(target);
628        let err = build(&target, &records).unwrap_err().to_string();
629        assert!(err.contains("no root field returns UserError"), "{err}");
630    }
631
632    #[test]
633    fn ambiguous_roots_are_reported_rather_than_hidden() {
634        let mut records = schema();
635        records.push(rec(
636            "Query.viewer",
637            "viewer",
638            Kind::Query,
639            Some("Query"),
640            Some("User"),
641            &[],
642        ));
643        let target = records.iter().find(|r| r.path == "User.name").unwrap();
644        let ex = build(target, &records).unwrap();
645        // both Query.user and Query.viewer return User
646        assert_eq!(ex.alternatives.len(), 1);
647    }
648
649    #[test]
650    fn a_defaulted_argument_is_omitted_even_when_non_null() {
651        let records = vec![
652            rec("Query", "Query", Kind::Object, None, None, &[]),
653            rec(
654                "Query.feed",
655                "feed",
656                Kind::Query,
657                Some("Query"),
658                Some("Int!"),
659                // non-null, but the schema supplies a default — nothing is
660                // required of the caller
661                &["first: Int! = 10", "after: String"],
662            ),
663        ];
664        let target = records.iter().find(|r| r.path == "Query.feed").unwrap();
665        let ex = build(target, &records).unwrap();
666        assert_eq!(ex.operation, "query Feed {\n  feed\n}\n");
667        assert_eq!(ex.variables, serde_json::json!({}));
668        assert_eq!(
669            ex.optional,
670            ["feed(first: Int! = 10)", "feed(after: String)"]
671        );
672    }
673
674    #[test]
675    fn a_self_referential_input_expands_once() {
676        let records = vec![
677            rec("Query", "Query", Kind::Object, None, None, &[]),
678            rec("Filter", "Filter", Kind::InputObject, None, None, &[]),
679            rec(
680                "Filter.and",
681                "and",
682                Kind::InputField,
683                Some("Filter"),
684                // Filter refers to itself — the expansion must terminate
685                Some("[Filter!]"),
686                &[],
687            ),
688            rec(
689                "Filter.eq",
690                "eq",
691                Kind::InputField,
692                Some("Filter"),
693                Some("String"),
694                &[],
695            ),
696            rec(
697                "Query.search",
698                "search",
699                Kind::Query,
700                Some("Query"),
701                Some("Int!"),
702                &["filter: Filter!"],
703            ),
704        ];
705        let target = records.iter().find(|r| r.path == "Query.search").unwrap();
706        let ex = build(target, &records).unwrap();
707        assert_eq!(
708            ex.input_types,
709            vec![vec![
710                "Filter {".to_string(),
711                "  and: [Filter!]".to_string(),
712                "  eq: String".to_string(),
713                "}".to_string(),
714            ]]
715        );
716    }
717
718    /// The point of the whole module: what it prints must parse as GraphQL.
719    #[test]
720    fn every_drafted_operation_is_valid_graphql() {
721        for path in [
722            "Query.user",
723            "Query.count",
724            "Mutation.save",
725            "User.posts",
726            "User.name",
727        ] {
728            let ex = build_for(path);
729            graphql_parser::parse_query::<String>(&ex.operation).unwrap_or_else(|e| {
730                panic!("{path} drafted invalid GraphQL: {e}\n{}", ex.operation)
731            });
732        }
733    }
734
735    #[test]
736    fn colliding_argument_names_are_disambiguated() {
737        let records = vec![
738            rec("Query", "Query", Kind::Object, None, None, &[]),
739            rec("Role", "Role", Kind::Enum, None, None, &[]),
740            rec(
741                "Role.ADMIN",
742                "ADMIN",
743                Kind::EnumValue,
744                Some("Role"),
745                None,
746                &[],
747            ),
748            rec("Thing", "Thing", Kind::Object, None, None, &[]),
749            rec(
750                "Query.thing",
751                "thing",
752                Kind::Query,
753                Some("Query"),
754                Some("Thing"),
755                &["id: ID!"],
756            ),
757            rec(
758                "Thing.child",
759                "child",
760                Kind::Field,
761                Some("Thing"),
762                Some("String"),
763                &["id: ID!", "role: Role!"],
764            ),
765        ];
766        let target = records.iter().find(|r| r.path == "Thing.child").unwrap();
767        let ex = build(target, &records).unwrap();
768        // the inner `id` collides with the root's, so it's prefixed
769        assert!(
770            ex.operation.contains("child(id: $childId, role: $role)"),
771            "{}",
772            ex.operation
773        );
774        assert_eq!(
775            ex.variables,
776            serde_json::json!({ "id": "<ID!>", "childId": "<ID!>", "role": "<Role!>" })
777        );
778    }
779}