1use std::collections::HashMap;
28
29use anyhow::{bail, Result};
30use serde_json::{Map, Value};
31
32use crate::model::{Kind, SchemaRecord};
33
34#[derive(Debug)]
36pub struct Example {
37 pub operation: String,
39 pub variables: Value,
41 pub optional: Vec<String>,
44 pub input_types: Vec<Vec<String>>,
48 pub via: Option<String>,
50 pub alternatives: Vec<String>,
53}
54
55pub fn build(target: &SchemaRecord, records: &[SchemaRecord]) -> Result<Example> {
57 let schema = Schema::index(records);
58
59 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 let (vars, optional) = Variables::collect(&chain);
96
97 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 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
141struct 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 fn input_types(&self, chain: &[&SchemaRecord]) -> Vec<Vec<String>> {
185 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 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 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 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 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 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 lines.push("__typename".to_string());
293 lines.push("# add inline fragments: ... on ConcreteType { … }".to_string());
294 }
295 lines
296 }
297}
298
299struct Variables {
301 entries: Vec<(usize, String, String, String)>,
303}
304
305impl Variables {
306 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 if !type_ref.ends_with('!') || default.is_some() {
321 optional.push(format!("{}({})", field.name, arg.trim()));
322 continue;
323 }
324 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 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 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 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
376fn 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
385fn base_of(type_ref: &str) -> &str {
388 type_ref.trim_matches(|c| matches!(c, '[' | ']' | '!' | ' '))
389}
390
391struct Arg<'a> {
393 name: &'a str,
394 type_ref: &'a str,
395 default: Option<&'a str>,
396}
397
398fn 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
413fn 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 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 assert_eq!(ex.optional, ["save(dryRun: Boolean)"]);
592 assert!(
593 ex.operation.contains("errors {\n message\n }"),
594 "{}",
595 ex.operation
596 );
597 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 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 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 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 &["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 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 #[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 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}