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