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