1use std::collections::HashMap;
22
23use anyhow::{bail, Result};
24use serde_json::{Map, Value};
25
26use crate::model::{Kind, SchemaRecord};
27
28#[derive(Debug)]
30pub struct Example {
31 pub operation: String,
33 pub variables: Value,
35 pub via: Option<String>,
37 pub alternatives: Vec<String>,
40}
41
42pub fn build(target: &SchemaRecord, records: &[SchemaRecord]) -> Result<Example> {
44 let schema = Schema::index(records);
45
46 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 let vars = Variables::collect(&chain);
83
84 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 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
126struct 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 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 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 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 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 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 lines.push("__typename".to_string());
226 lines.push("# add inline fragments: ... on ConcreteType { … }".to_string());
227 }
228 lines
229 }
230
231 fn placeholder(&self, type_ref: &str) -> Value {
233 let type_ref = type_ref.trim();
234 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 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
263struct Variables {
265 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 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 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 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
325fn required_args(r: &SchemaRecord) -> usize {
327 r.args
328 .iter()
329 .filter(|a| split_arg(a).1.ends_with('!'))
330 .count()
331}
332
333fn 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
341fn 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 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 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 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 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 assert_eq!(ex.alternatives.len(), 1);
572 }
573
574 #[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 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}