1use std::collections::HashMap;
35
36use anyhow::{bail, Result};
37use serde_json::{Map, Value};
38
39use crate::model::{Kind, SchemaRecord};
40
41#[derive(Debug)]
43pub struct Example {
44 pub operation: String,
46 pub variables: Value,
48 pub optional: Vec<String>,
51 pub input_types: Vec<Vec<String>>,
55 pub deprecated: Vec<String>,
58 pub via: Option<String>,
60 pub alternatives: Vec<String>,
63}
64
65pub fn build(target: &SchemaRecord, records: &[SchemaRecord], depth: usize) -> Result<Example> {
67 let schema = Schema::index(records);
68
69 let (chain, via, alternatives) = match target.kind {
72 Kind::Query | Kind::Mutation | Kind::Subscription => (vec![target], None, Vec::new()),
73 Kind::Field => {
74 let parent = target
75 .parent
76 .as_deref()
77 .ok_or_else(|| anyhow::anyhow!("{} has no enclosing type", target.path))?;
78 let mut roots = schema.roots_returning(parent);
79 if roots.is_empty() {
80 bail!(
81 "{} isn't reachable in one hop — no root field returns {parent}. \
82 Try `gqls --returns {parent}` to see what's close.",
83 target.path
84 );
85 }
86 let chosen = roots.remove(0);
87 let via = Some(chosen.path.clone());
88 let alternatives = roots.iter().map(|r| r.path.clone()).collect();
89 (vec![chosen, target], via, alternatives)
90 }
91 other => bail!(
92 "can't draft an operation for a {} — pick a field, query, or mutation",
93 other.as_str()
94 ),
95 };
96
97 let operation_kind = match chain[0].kind {
98 Kind::Mutation => "mutation",
99 Kind::Subscription => "subscription",
100 _ => "query",
101 };
102
103 let (vars, optional) = Variables::collect(&chain);
106
107 let leaf_type = chain
109 .last()
110 .and_then(|r| r.base_type())
111 .unwrap_or_default()
112 .to_string();
113 let mut deprecated = Vec::new();
114 if let Some(reason) = &target.deprecated {
115 deprecated.push(match reason.is_empty() {
116 true => target.path.clone(),
117 false => format!("{} ({reason})", target.path),
118 });
119 }
120 let mut body = schema.selection(&leaf_type, depth.max(1), &mut deprecated);
121
122 for (depth, field) in chain.iter().enumerate().rev() {
123 let args = vars.rendered_for(depth);
124 body = if body.is_empty() {
125 vec![format!("{}{}", field.name, args)]
127 } else {
128 let mut wrapped = vec![format!("{}{} {{", field.name, args)];
129 wrapped.extend(body.into_iter().map(|l| format!(" {l}")));
130 wrapped.push("}".to_string());
131 wrapped
132 };
133 }
134
135 let mut operation = String::new();
136 operation.push_str(operation_kind);
137 operation.push(' ');
138 operation.push_str(&pascal_case(&chain.last().unwrap().name));
139 operation.push_str(&vars.signature());
140 operation.push_str(" {\n");
141 for line in &body {
142 operation.push_str(" ");
143 operation.push_str(line);
144 operation.push('\n');
145 }
146 operation.push_str("}\n");
147
148 Ok(Example {
149 operation,
150 variables: vars.placeholders(),
151 input_types: schema.input_types(&chain),
152 optional,
153 deprecated,
154 via,
155 alternatives,
156 })
157}
158
159struct Schema<'a> {
162 kinds: HashMap<&'a str, Kind>,
163 types: HashMap<&'a str, &'a SchemaRecord>,
166 fields: HashMap<&'a str, Vec<&'a SchemaRecord>>,
167 roots: Vec<&'a SchemaRecord>,
168}
169
170impl<'a> Schema<'a> {
171 fn index(records: &'a [SchemaRecord]) -> Self {
172 let mut kinds = HashMap::new();
173 let mut types = HashMap::new();
174 let mut fields: HashMap<&str, Vec<&SchemaRecord>> = HashMap::new();
175 let mut roots = Vec::new();
176 for r in records {
177 match r.kind {
178 Kind::Query | Kind::Mutation | Kind::Subscription => {
179 roots.push(r);
180 if let Some(p) = r.parent.as_deref() {
181 fields.entry(p).or_default().push(r);
182 }
183 }
184 Kind::Field | Kind::InputField | Kind::EnumValue => {
185 if let Some(p) = r.parent.as_deref() {
186 fields.entry(p).or_default().push(r);
187 }
188 }
189 _ => {
190 kinds.insert(r.name.as_str(), r.kind);
191 types.insert(r.name.as_str(), r);
192 }
193 }
194 }
195 Self {
196 kinds,
197 types,
198 fields,
199 roots,
200 }
201 }
202
203 fn input_types(&self, chain: &[&SchemaRecord]) -> Vec<Vec<String>> {
209 const MAX_TYPES: usize = 12;
211
212 let mut queue: Vec<String> = chain
213 .iter()
214 .flat_map(|f| f.args.iter())
215 .map(|a| base_of(split_arg(a).type_ref).to_string())
216 .collect();
217 let mut seen: Vec<String> = Vec::new();
218 let mut out = Vec::new();
219
220 while let Some(name) = queue.pop() {
221 if seen.contains(&name) || out.len() >= MAX_TYPES {
222 continue;
223 }
224 seen.push(name.clone());
225 match self.kinds.get(name.as_str()) {
226 Some(Kind::InputObject) => {
227 let mut block = vec![format!("{name} {{")];
228 for f in self.fields.get(name.as_str()).into_iter().flatten() {
229 let ty = f.type_ref.as_deref().unwrap_or("");
230 block.push(format!(" {}: {}", f.name, ty));
231 queue.push(base_of(ty).to_string());
232 }
233 block.push("}".to_string());
234 out.push(block);
235 }
236 Some(Kind::Enum) => {
237 let values: Vec<&str> = self
238 .fields
239 .get(name.as_str())
240 .into_iter()
241 .flatten()
242 .map(|v| v.name.as_str())
243 .collect();
244 if !values.is_empty() {
245 out.push(vec![format!("{name} = {}", values.join(" | "))]);
246 }
247 }
248 _ => {}
249 }
250 }
251 out.sort();
252 out
253 }
254
255 fn roots_returning(&self, type_name: &str) -> Vec<&'a SchemaRecord> {
259 let mut hits: Vec<&SchemaRecord> = self
260 .roots
261 .iter()
262 .copied()
263 .filter(|r| {
264 r.base_type()
265 .is_some_and(|t| t.eq_ignore_ascii_case(type_name))
266 })
267 .collect();
268 hits.sort_by_key(|r| (required_args(r), r.path.len(), r.path.clone()));
269 hits
270 }
271
272 fn is_leaf(&self, type_name: &str) -> bool {
275 !matches!(
276 self.kinds.get(type_name),
277 Some(Kind::Object | Kind::Interface | Kind::Union | Kind::InputObject)
278 )
279 }
280
281 fn selection(
284 &self,
285 type_name: &str,
286 depth: usize,
287 deprecated: &mut Vec<String>,
288 ) -> Vec<String> {
289 if type_name.is_empty() || self.is_leaf(type_name) || depth == 0 {
290 return Vec::new();
291 }
292 if let Some(rec) = self.types.get(type_name) {
296 if rec.kind == Kind::Union {
297 return self.inline_fragments(rec, depth, deprecated);
298 }
299 }
300
301 let mut lines = Vec::new();
302 let mut deferred = Vec::new();
303 for f in self.fields.get(type_name).into_iter().flatten() {
304 if f.kind != Kind::Field {
305 continue;
306 }
307 let Some(base) = f.base_type() else { continue };
308 if f.args.iter().any(|a| a.trim_end().ends_with('!')) {
310 deferred.push(format!("# {}: {} — needs arguments", f.name, base));
311 continue;
312 }
313 let note = match &f.deprecated {
314 Some(reason) if reason.is_empty() => {
318 deprecated.push(f.path.clone());
319 " # deprecated".to_string()
320 }
321 Some(reason) => {
322 deprecated.push(f.path.clone());
323 format!(" # deprecated: {reason}")
324 }
325 None => String::new(),
326 };
327 if self.is_leaf(base) {
328 lines.push(format!("{}{note}", f.name));
329 } else if depth > 1 || f.name.eq_ignore_ascii_case("errors") {
330 let inner = self.selection(base, depth.saturating_sub(1).max(1), deprecated);
333 lines.push(format!("{}{note} {{", f.name));
334 lines.extend(inner.into_iter().map(|l| format!(" {l}")));
335 lines.push("}".to_string());
336 } else {
337 deferred.push(format!("# {}: {} — add fields you need", f.name, base));
338 }
339 }
340 lines.extend(deferred);
341 if lines.is_empty() {
342 lines.push("__typename".to_string());
345 }
346 lines
347 }
348
349 fn inline_fragments(
351 &self,
352 rec: &SchemaRecord,
353 depth: usize,
354 deprecated: &mut Vec<String>,
355 ) -> Vec<String> {
356 const MAX_MEMBERS: usize = 6;
359
360 if rec.possible_types.is_empty() {
361 return vec![
362 "__typename".to_string(),
363 "# add inline fragments: ... on ConcreteType { … }".to_string(),
364 ];
365 }
366 let mut lines = vec!["__typename".to_string()];
367 for member in rec.possible_types.iter().take(MAX_MEMBERS) {
368 let inner = self.selection(member, depth, deprecated);
369 if inner.is_empty() {
370 continue;
371 }
372 lines.push(format!("... on {member} {{"));
373 lines.extend(inner.into_iter().map(|l| format!(" {l}")));
374 lines.push("}".to_string());
375 }
376 if rec.possible_types.len() > MAX_MEMBERS {
377 lines.push(format!(
378 "# {} more: {}",
379 rec.possible_types.len() - MAX_MEMBERS,
380 rec.possible_types[MAX_MEMBERS..].join(", ")
381 ));
382 }
383 lines
384 }
385}
386
387struct Variables {
389 entries: Vec<(usize, String, String, String)>,
391}
392
393impl Variables {
394 fn collect(chain: &[&SchemaRecord]) -> (Self, Vec<String>) {
397 let mut entries: Vec<(usize, String, String, String)> = Vec::new();
398 let mut optional = Vec::new();
399 for (depth, field) in chain.iter().enumerate() {
400 for arg in &field.args {
401 let Arg {
402 name,
403 type_ref,
404 default,
405 } = split_arg(arg);
406 if !type_ref.ends_with('!') || default.is_some() {
409 optional.push(format!("{}({})", field.name, arg.trim()));
410 continue;
411 }
412 let taken = entries.iter().any(|(_, _, var, _)| var == name);
414 let var = if taken {
415 format!("{}{}", field.name, pascal_case(name))
416 } else {
417 name.to_string()
418 };
419 entries.push((depth, name.to_string(), var, type_ref.to_string()));
420 }
421 }
422 (Self { entries }, optional)
423 }
424
425 fn signature(&self) -> String {
427 if self.entries.is_empty() {
428 return String::new();
429 }
430 let inner: Vec<String> = self
431 .entries
432 .iter()
433 .map(|(_, _, var, ty)| format!("${var}: {ty}"))
434 .collect();
435 format!("({})", inner.join(", "))
436 }
437
438 fn rendered_for(&self, depth: usize) -> String {
440 let inner: Vec<String> = self
441 .entries
442 .iter()
443 .filter(|(d, _, _, _)| *d == depth)
444 .map(|(_, name, var, _)| format!("{name}: ${var}"))
445 .collect();
446 if inner.is_empty() {
447 String::new()
448 } else {
449 format!("({})", inner.join(", "))
450 }
451 }
452
453 fn placeholders(&self) -> Value {
456 let mut map = Map::new();
457 for (_, _, var, ty) in &self.entries {
458 map.insert(var.clone(), Value::String(format!("<{ty}>")));
459 }
460 Value::Object(map)
461 }
462}
463
464fn required_args(r: &SchemaRecord) -> usize {
466 r.args
467 .iter()
468 .map(|a| split_arg(a))
469 .filter(|a| a.type_ref.ends_with('!') && a.default.is_none())
470 .count()
471}
472
473fn base_of(type_ref: &str) -> &str {
476 type_ref.trim_matches(|c| matches!(c, '[' | ']' | '!' | ' '))
477}
478
479struct Arg<'a> {
481 name: &'a str,
482 type_ref: &'a str,
483 default: Option<&'a str>,
484}
485
486fn split_arg(arg: &str) -> Arg<'_> {
489 let (name, rest) = arg.split_once(':').unwrap_or((arg, ""));
490 let (type_ref, default) = match rest.split_once('=') {
491 Some((t, d)) => (t, Some(d.trim())),
492 None => (rest, None),
493 };
494 Arg {
495 name: name.trim(),
496 type_ref: type_ref.trim(),
497 default,
498 }
499}
500
501fn pascal_case(name: &str) -> String {
503 let mut chars = name.chars();
504 match chars.next() {
505 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
506 None => String::new(),
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 fn rec(
515 path: &str,
516 name: &str,
517 kind: Kind,
518 parent: Option<&str>,
519 type_ref: Option<&str>,
520 args: &[&str],
521 ) -> SchemaRecord {
522 SchemaRecord {
523 path: path.into(),
524 name: name.into(),
525 kind,
526 parent: parent.map(Into::into),
527 type_ref: type_ref.map(Into::into),
528 args: args.iter().map(|a| a.to_string()).collect(),
529 description: None,
530 deprecated: None,
531 directives: vec![],
532 possible_types: vec![],
533 }
534 }
535
536 fn schema() -> Vec<SchemaRecord> {
539 vec![
540 rec("Query", "Query", Kind::Object, None, None, &[]),
541 rec("User", "User", Kind::Object, None, None, &[]),
542 rec("Post", "Post", Kind::Object, None, None, &[]),
543 rec("Role", "Role", Kind::Enum, None, None, &[]),
544 rec("Payload", "Payload", Kind::Object, None, None, &[]),
545 rec("UserError", "UserError", Kind::Object, None, None, &[]),
546 rec("Input", "Input", Kind::InputObject, None, None, &[]),
547 rec(
548 "Query.user",
549 "user",
550 Kind::Query,
551 Some("Query"),
552 Some("User"),
553 &["id: ID!"],
554 ),
555 rec(
556 "Query.count",
557 "count",
558 Kind::Query,
559 Some("Query"),
560 Some("Int!"),
561 &[],
562 ),
563 rec("User.id", "id", Kind::Field, Some("User"), Some("ID!"), &[]),
564 rec(
565 "User.name",
566 "name",
567 Kind::Field,
568 Some("User"),
569 Some("String"),
570 &[],
571 ),
572 rec(
573 "User.role",
574 "role",
575 Kind::Field,
576 Some("User"),
577 Some("Role!"),
578 &[],
579 ),
580 rec(
581 "User.posts",
582 "posts",
583 Kind::Field,
584 Some("User"),
585 Some("[Post!]!"),
586 &[],
587 ),
588 rec(
589 "User.avatar",
590 "avatar",
591 Kind::Field,
592 Some("User"),
593 Some("String"),
594 &["size: Int!"],
595 ),
596 rec(
597 "Mutation.save",
598 "save",
599 Kind::Mutation,
600 Some("Mutation"),
601 Some("Payload!"),
602 &["input: Input!", "dryRun: Boolean"],
603 ),
604 rec(
605 "Payload.ok",
606 "ok",
607 Kind::Field,
608 Some("Payload"),
609 Some("Boolean!"),
610 &[],
611 ),
612 rec(
613 "Payload.errors",
614 "errors",
615 Kind::Field,
616 Some("Payload"),
617 Some("[UserError!]!"),
618 &[],
619 ),
620 rec(
621 "UserError.message",
622 "message",
623 Kind::Field,
624 Some("UserError"),
625 Some("String!"),
626 &[],
627 ),
628 rec(
629 "Role.ADMIN",
630 "ADMIN",
631 Kind::EnumValue,
632 Some("Role"),
633 None,
634 &[],
635 ),
636 ]
637 }
638
639 fn build_for(path: &str) -> Example {
640 let records = schema();
641 let target = records.iter().find(|r| r.path == path).unwrap();
642 build(target, &records, 1).unwrap()
643 }
644
645 #[test]
646 fn root_field_becomes_a_parameterized_query() {
647 let ex = build_for("Query.user");
648 assert_eq!(
649 ex.operation,
650 "query User($id: ID!) {\n \
651 user(id: $id) {\n \
652 id\n \
653 name\n \
654 role\n \
655 # posts: Post — add fields you need\n \
656 # avatar: String — needs arguments\n \
657 }\n\
658 }\n"
659 );
660 assert_eq!(ex.variables, serde_json::json!({ "id": "<ID!>" }));
661 }
662
663 #[test]
664 fn a_scalar_return_gets_no_selection_set() {
665 let ex = build_for("Query.count");
666 assert_eq!(ex.operation, "query Count {\n count\n}\n");
667 assert_eq!(ex.variables, serde_json::json!({}));
668 }
669
670 #[test]
671 fn mutation_expands_a_real_errors_block() {
672 let ex = build_for("Mutation.save");
673 assert!(
674 ex.operation
675 .starts_with("mutation Save($input: Input!) {\n save(input: $input) {"),
676 "{}",
677 ex.operation
678 );
679 assert_eq!(ex.optional, ["save(dryRun: Boolean)"]);
681 assert!(
682 ex.operation.contains("errors {\n message\n }"),
683 "{}",
684 ex.operation
685 );
686 assert_eq!(ex.variables, serde_json::json!({ "input": "<Input!>" }));
689 }
690
691 #[test]
692 fn nested_field_is_wrapped_in_a_root_that_returns_its_type() {
693 let ex = build_for("User.posts");
694 assert_eq!(
698 ex.operation,
699 "query Posts($id: ID!) {\n \
700 user(id: $id) {\n \
701 posts {\n \
702 __typename\n \
703 }\n \
704 }\n\
705 }\n"
706 );
707 }
708
709 #[test]
710 fn an_unreachable_field_is_an_error_not_a_guess() {
711 let mut records = schema();
712 let target = records
714 .iter()
715 .position(|r| r.path == "UserError.message")
716 .unwrap();
717 let target = records.remove(target);
718 let err = build(&target, &records, 1).unwrap_err().to_string();
719 assert!(err.contains("no root field returns UserError"), "{err}");
720 }
721
722 #[test]
723 fn ambiguous_roots_are_reported_rather_than_hidden() {
724 let mut records = schema();
725 records.push(rec(
726 "Query.viewer",
727 "viewer",
728 Kind::Query,
729 Some("Query"),
730 Some("User"),
731 &[],
732 ));
733 let target = records.iter().find(|r| r.path == "User.name").unwrap();
734 let ex = build(target, &records, 1).unwrap();
735 assert_eq!(ex.alternatives.len(), 1);
737 }
738
739 #[test]
740 fn a_defaulted_argument_is_omitted_even_when_non_null() {
741 let records = vec![
742 rec("Query", "Query", Kind::Object, None, None, &[]),
743 rec(
744 "Query.feed",
745 "feed",
746 Kind::Query,
747 Some("Query"),
748 Some("Int!"),
749 &["first: Int! = 10", "after: String"],
752 ),
753 ];
754 let target = records.iter().find(|r| r.path == "Query.feed").unwrap();
755 let ex = build(target, &records, 1).unwrap();
756 assert_eq!(ex.operation, "query Feed {\n feed\n}\n");
757 assert_eq!(ex.variables, serde_json::json!({}));
758 assert_eq!(
759 ex.optional,
760 ["feed(first: Int! = 10)", "feed(after: String)"]
761 );
762 }
763
764 #[test]
765 fn a_self_referential_input_expands_once() {
766 let records = vec![
767 rec("Query", "Query", Kind::Object, None, None, &[]),
768 rec("Filter", "Filter", Kind::InputObject, None, None, &[]),
769 rec(
770 "Filter.and",
771 "and",
772 Kind::InputField,
773 Some("Filter"),
774 Some("[Filter!]"),
776 &[],
777 ),
778 rec(
779 "Filter.eq",
780 "eq",
781 Kind::InputField,
782 Some("Filter"),
783 Some("String"),
784 &[],
785 ),
786 rec(
787 "Query.search",
788 "search",
789 Kind::Query,
790 Some("Query"),
791 Some("Int!"),
792 &["filter: Filter!"],
793 ),
794 ];
795 let target = records.iter().find(|r| r.path == "Query.search").unwrap();
796 let ex = build(target, &records, 1).unwrap();
797 assert_eq!(
798 ex.input_types,
799 vec![vec![
800 "Filter {".to_string(),
801 " and: [Filter!]".to_string(),
802 " eq: String".to_string(),
803 "}".to_string(),
804 ]]
805 );
806 }
807
808 #[test]
810 fn every_drafted_operation_is_valid_graphql() {
811 for path in [
812 "Query.user",
813 "Query.count",
814 "Mutation.save",
815 "User.posts",
816 "User.name",
817 ] {
818 let ex = build_for(path);
819 graphql_parser::parse_query::<String>(&ex.operation).unwrap_or_else(|e| {
820 panic!("{path} drafted invalid GraphQL: {e}\n{}", ex.operation)
821 });
822 }
823 }
824
825 #[test]
826 fn colliding_argument_names_are_disambiguated() {
827 let records = vec![
828 rec("Query", "Query", Kind::Object, None, None, &[]),
829 rec("Role", "Role", Kind::Enum, None, None, &[]),
830 rec(
831 "Role.ADMIN",
832 "ADMIN",
833 Kind::EnumValue,
834 Some("Role"),
835 None,
836 &[],
837 ),
838 rec("Thing", "Thing", Kind::Object, None, None, &[]),
839 rec(
840 "Query.thing",
841 "thing",
842 Kind::Query,
843 Some("Query"),
844 Some("Thing"),
845 &["id: ID!"],
846 ),
847 rec(
848 "Thing.child",
849 "child",
850 Kind::Field,
851 Some("Thing"),
852 Some("String"),
853 &["id: ID!", "role: Role!"],
854 ),
855 ];
856 let target = records.iter().find(|r| r.path == "Thing.child").unwrap();
857 let ex = build(target, &records, 1).unwrap();
858 assert!(
860 ex.operation.contains("child(id: $childId, role: $role)"),
861 "{}",
862 ex.operation
863 );
864 assert_eq!(
865 ex.variables,
866 serde_json::json!({ "id": "<ID!>", "childId": "<ID!>", "role": "<Role!>" })
867 );
868 }
869}