use std::collections::HashMap;
use anyhow::{bail, Result};
use serde_json::{Map, Value};
use crate::model::{Kind, SchemaRecord};
#[derive(Debug)]
pub struct Example {
pub operation: String,
pub variables: Value,
pub optional: Vec<String>,
pub input_types: Vec<Vec<String>>,
pub deprecated: Vec<String>,
pub via: Option<String>,
pub alternatives: Vec<String>,
}
pub fn build(target: &SchemaRecord, records: &[SchemaRecord], depth: usize) -> Result<Example> {
let schema = Schema::index(records);
let (chain, via, alternatives) = match target.kind {
Kind::Query | Kind::Mutation | Kind::Subscription => (vec![target], None, Vec::new()),
Kind::Field => {
let parent = target
.parent
.as_deref()
.ok_or_else(|| anyhow::anyhow!("{} has no enclosing type", target.path))?;
let mut roots = schema.roots_returning(parent);
if roots.is_empty() {
bail!(
"{} isn't reachable in one hop — no root field returns {parent}. \
Try `gqls --returns {parent}` to see what's close.",
target.path
);
}
let chosen = roots.remove(0);
let via = Some(chosen.path.clone());
let alternatives = roots.iter().map(|r| r.path.clone()).collect();
(vec![chosen, target], via, alternatives)
}
other => bail!(
"can't draft an operation for a {} — pick a field, query, or mutation",
other.as_str()
),
};
let operation_kind = match chain[0].kind {
Kind::Mutation => "mutation",
Kind::Subscription => "subscription",
_ => "query",
};
let (vars, optional) = Variables::collect(&chain);
let leaf_type = chain
.last()
.and_then(|r| r.base_type())
.unwrap_or_default()
.to_string();
let mut deprecated = Vec::new();
if let Some(reason) = &target.deprecated {
deprecated.push(match reason.is_empty() {
true => target.path.clone(),
false => format!("{} ({reason})", target.path),
});
}
let mut body = schema.selection(&leaf_type, depth.max(1), &mut deprecated);
for (depth, field) in chain.iter().enumerate().rev() {
let args = vars.rendered_for(depth);
body = if body.is_empty() {
vec![format!("{}{}", field.name, args)]
} else {
let mut wrapped = vec![format!("{}{} {{", field.name, args)];
wrapped.extend(body.into_iter().map(|l| format!(" {l}")));
wrapped.push("}".to_string());
wrapped
};
}
let mut operation = String::new();
operation.push_str(operation_kind);
operation.push(' ');
operation.push_str(&pascal_case(&chain.last().unwrap().name));
operation.push_str(&vars.signature());
operation.push_str(" {\n");
for line in &body {
operation.push_str(" ");
operation.push_str(line);
operation.push('\n');
}
operation.push_str("}\n");
Ok(Example {
operation,
variables: vars.placeholders(),
input_types: schema.input_types(&chain),
optional,
deprecated,
via,
alternatives,
})
}
struct Schema<'a> {
kinds: HashMap<&'a str, Kind>,
types: HashMap<&'a str, &'a SchemaRecord>,
fields: HashMap<&'a str, Vec<&'a SchemaRecord>>,
roots: Vec<&'a SchemaRecord>,
}
impl<'a> Schema<'a> {
fn index(records: &'a [SchemaRecord]) -> Self {
let mut kinds = HashMap::new();
let mut types = HashMap::new();
let mut fields: HashMap<&str, Vec<&SchemaRecord>> = HashMap::new();
let mut roots = Vec::new();
for r in records {
match r.kind {
Kind::Query | Kind::Mutation | Kind::Subscription => {
roots.push(r);
if let Some(p) = r.parent.as_deref() {
fields.entry(p).or_default().push(r);
}
}
Kind::Field | Kind::InputField | Kind::EnumValue => {
if let Some(p) = r.parent.as_deref() {
fields.entry(p).or_default().push(r);
}
}
_ => {
kinds.insert(r.name.as_str(), r.kind);
types.insert(r.name.as_str(), r);
}
}
}
Self {
kinds,
types,
fields,
roots,
}
}
fn input_types(&self, chain: &[&SchemaRecord]) -> Vec<Vec<String>> {
const MAX_TYPES: usize = 12;
let mut queue: Vec<String> = chain
.iter()
.flat_map(|f| f.args.iter())
.map(|a| base_of(split_arg(a).type_ref).to_string())
.collect();
let mut seen: Vec<String> = Vec::new();
let mut out = Vec::new();
while let Some(name) = queue.pop() {
if seen.contains(&name) || out.len() >= MAX_TYPES {
continue;
}
seen.push(name.clone());
match self.kinds.get(name.as_str()) {
Some(Kind::InputObject) => {
let mut block = vec![format!("{name} {{")];
for f in self.fields.get(name.as_str()).into_iter().flatten() {
let ty = f.type_ref.as_deref().unwrap_or("");
block.push(format!(" {}: {}", f.name, ty));
queue.push(base_of(ty).to_string());
}
block.push("}".to_string());
out.push(block);
}
Some(Kind::Enum) => {
let values: Vec<&str> = self
.fields
.get(name.as_str())
.into_iter()
.flatten()
.map(|v| v.name.as_str())
.collect();
if !values.is_empty() {
out.push(vec![format!("{name} = {}", values.join(" | "))]);
}
}
_ => {}
}
}
out.sort();
out
}
fn roots_returning(&self, type_name: &str) -> Vec<&'a SchemaRecord> {
let mut hits: Vec<&SchemaRecord> = self
.roots
.iter()
.copied()
.filter(|r| {
r.base_type()
.is_some_and(|t| t.eq_ignore_ascii_case(type_name))
})
.collect();
hits.sort_by_key(|r| (required_args(r), r.path.len(), r.path.clone()));
hits
}
fn is_leaf(&self, type_name: &str) -> bool {
!matches!(
self.kinds.get(type_name),
Some(Kind::Object | Kind::Interface | Kind::Union | Kind::InputObject)
)
}
fn selection(
&self,
type_name: &str,
depth: usize,
deprecated: &mut Vec<String>,
) -> Vec<String> {
if type_name.is_empty() || self.is_leaf(type_name) || depth == 0 {
return Vec::new();
}
if let Some(rec) = self.types.get(type_name) {
if rec.kind == Kind::Union {
return self.inline_fragments(rec, depth, deprecated, &[]);
}
}
let mut lines = Vec::new();
let mut deferred = Vec::new();
for f in self.fields.get(type_name).into_iter().flatten() {
if f.kind != Kind::Field {
continue;
}
let Some(base) = f.base_type() else { continue };
if f.args.iter().any(|a| a.trim_end().ends_with('!')) {
deferred.push(format!("# {}: {} — needs arguments", f.name, base));
continue;
}
let note = match &f.deprecated {
Some(reason) if reason.is_empty() => {
deprecated.push(f.path.clone());
" # deprecated".to_string()
}
Some(reason) => {
deprecated.push(f.path.clone());
format!(" # deprecated: {reason}")
}
None => String::new(),
};
if self.is_leaf(base) {
lines.push(format!("{}{note}", f.name));
} else if depth > 1 || f.name.eq_ignore_ascii_case("errors") {
let inner = self.selection(base, depth.saturating_sub(1).max(1), deprecated);
lines.push(format!("{}{note} {{", f.name));
lines.extend(inner.into_iter().map(|l| format!(" {l}")));
lines.push("}".to_string());
} else {
deferred.push(format!("# {}: {} — add fields you need", f.name, base));
}
}
lines.extend(deferred);
if let Some(rec) = self.types.get(type_name) {
if rec.kind == Kind::Interface {
let common: Vec<&str> = self
.fields
.get(type_name)
.into_iter()
.flatten()
.map(|f| f.name.as_str())
.collect();
lines.extend(self.inline_fragments(rec, depth, deprecated, &common));
}
}
if lines.is_empty() {
lines.push("__typename".to_string());
}
lines
}
fn inline_fragments(
&self,
rec: &SchemaRecord,
depth: usize,
deprecated: &mut Vec<String>,
skip: &[&str],
) -> Vec<String> {
const MAX_MEMBERS: usize = 6;
if rec.possible_types.is_empty() {
return match rec.kind {
Kind::Union => vec![
"__typename".to_string(),
"# add inline fragments: ... on ConcreteType { … }".to_string(),
],
_ => Vec::new(),
};
}
let mut fragments = Vec::new();
for member in rec.possible_types.iter().take(MAX_MEMBERS) {
let inner: Vec<String> = self
.selection(member, depth, deprecated)
.into_iter()
.filter(|l| {
let name = l.split([' ', '{']).next().unwrap_or(l);
!skip.contains(&name) && !(l.starts_with('#') && !skip.is_empty())
})
.collect();
if inner.is_empty() {
continue; }
fragments.push(format!("... on {member} {{"));
fragments.extend(inner.into_iter().map(|l| format!(" {l}")));
fragments.push("}".to_string());
}
if fragments.is_empty() {
return Vec::new();
}
let mut lines = vec!["__typename".to_string()];
lines.append(&mut fragments);
if rec.possible_types.len() > MAX_MEMBERS {
lines.push(format!(
"# {} more: {}",
rec.possible_types.len() - MAX_MEMBERS,
rec.possible_types[MAX_MEMBERS..].join(", ")
));
}
lines
}
}
struct Variables {
entries: Vec<(usize, String, String, String)>,
}
impl Variables {
fn collect(chain: &[&SchemaRecord]) -> (Self, Vec<String>) {
let mut entries: Vec<(usize, String, String, String)> = Vec::new();
let mut optional = Vec::new();
for (depth, field) in chain.iter().enumerate() {
for arg in &field.args {
let Arg {
name,
type_ref,
default,
} = split_arg(arg);
if !type_ref.ends_with('!') || default.is_some() {
optional.push(format!("{}({})", field.name, arg.trim()));
continue;
}
let taken = entries.iter().any(|(_, _, var, _)| var == name);
let var = if taken {
format!("{}{}", field.name, pascal_case(name))
} else {
name.to_string()
};
entries.push((depth, name.to_string(), var, type_ref.to_string()));
}
}
(Self { entries }, optional)
}
fn signature(&self) -> String {
if self.entries.is_empty() {
return String::new();
}
let inner: Vec<String> = self
.entries
.iter()
.map(|(_, _, var, ty)| format!("${var}: {ty}"))
.collect();
format!("({})", inner.join(", "))
}
fn rendered_for(&self, depth: usize) -> String {
let inner: Vec<String> = self
.entries
.iter()
.filter(|(d, _, _, _)| *d == depth)
.map(|(_, name, var, _)| format!("{name}: ${var}"))
.collect();
if inner.is_empty() {
String::new()
} else {
format!("({})", inner.join(", "))
}
}
fn placeholders(&self) -> Value {
let mut map = Map::new();
for (_, _, var, ty) in &self.entries {
map.insert(var.clone(), Value::String(format!("<{ty}>")));
}
Value::Object(map)
}
}
fn required_args(r: &SchemaRecord) -> usize {
r.args
.iter()
.map(|a| split_arg(a))
.filter(|a| a.type_ref.ends_with('!') && a.default.is_none())
.count()
}
fn base_of(type_ref: &str) -> &str {
type_ref.trim_matches(|c| matches!(c, '[' | ']' | '!' | ' '))
}
struct Arg<'a> {
name: &'a str,
type_ref: &'a str,
default: Option<&'a str>,
}
fn split_arg(arg: &str) -> Arg<'_> {
let (name, rest) = arg.split_once(':').unwrap_or((arg, ""));
let (type_ref, default) = match rest.split_once('=') {
Some((t, d)) => (t, Some(d.trim())),
None => (rest, None),
};
Arg {
name: name.trim(),
type_ref: type_ref.trim(),
default,
}
}
fn pascal_case(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(
path: &str,
name: &str,
kind: Kind,
parent: Option<&str>,
type_ref: Option<&str>,
args: &[&str],
) -> SchemaRecord {
SchemaRecord {
path: path.into(),
name: name.into(),
kind,
parent: parent.map(Into::into),
type_ref: type_ref.map(Into::into),
args: args.iter().map(|a| a.to_string()).collect(),
description: None,
deprecated: None,
directives: vec![],
possible_types: vec![],
}
}
fn schema() -> Vec<SchemaRecord> {
vec![
rec("Query", "Query", Kind::Object, None, None, &[]),
rec("User", "User", Kind::Object, None, None, &[]),
rec("Post", "Post", Kind::Object, None, None, &[]),
rec("Role", "Role", Kind::Enum, None, None, &[]),
rec("Payload", "Payload", Kind::Object, None, None, &[]),
rec("UserError", "UserError", Kind::Object, None, None, &[]),
rec("Input", "Input", Kind::InputObject, None, None, &[]),
rec(
"Query.user",
"user",
Kind::Query,
Some("Query"),
Some("User"),
&["id: ID!"],
),
rec(
"Query.count",
"count",
Kind::Query,
Some("Query"),
Some("Int!"),
&[],
),
rec("User.id", "id", Kind::Field, Some("User"), Some("ID!"), &[]),
rec(
"User.name",
"name",
Kind::Field,
Some("User"),
Some("String"),
&[],
),
rec(
"User.role",
"role",
Kind::Field,
Some("User"),
Some("Role!"),
&[],
),
rec(
"User.posts",
"posts",
Kind::Field,
Some("User"),
Some("[Post!]!"),
&[],
),
rec(
"User.avatar",
"avatar",
Kind::Field,
Some("User"),
Some("String"),
&["size: Int!"],
),
rec(
"Mutation.save",
"save",
Kind::Mutation,
Some("Mutation"),
Some("Payload!"),
&["input: Input!", "dryRun: Boolean"],
),
rec(
"Payload.ok",
"ok",
Kind::Field,
Some("Payload"),
Some("Boolean!"),
&[],
),
rec(
"Payload.errors",
"errors",
Kind::Field,
Some("Payload"),
Some("[UserError!]!"),
&[],
),
rec(
"UserError.message",
"message",
Kind::Field,
Some("UserError"),
Some("String!"),
&[],
),
rec(
"Role.ADMIN",
"ADMIN",
Kind::EnumValue,
Some("Role"),
None,
&[],
),
]
}
fn build_for(path: &str) -> Example {
let records = schema();
let target = records.iter().find(|r| r.path == path).unwrap();
build(target, &records, 1).unwrap()
}
#[test]
fn root_field_becomes_a_parameterized_query() {
let ex = build_for("Query.user");
assert_eq!(
ex.operation,
"query User($id: ID!) {\n \
user(id: $id) {\n \
id\n \
name\n \
role\n \
# posts: Post — add fields you need\n \
# avatar: String — needs arguments\n \
}\n\
}\n"
);
assert_eq!(ex.variables, serde_json::json!({ "id": "<ID!>" }));
}
#[test]
fn a_scalar_return_gets_no_selection_set() {
let ex = build_for("Query.count");
assert_eq!(ex.operation, "query Count {\n count\n}\n");
assert_eq!(ex.variables, serde_json::json!({}));
}
#[test]
fn mutation_expands_a_real_errors_block() {
let ex = build_for("Mutation.save");
assert!(
ex.operation
.starts_with("mutation Save($input: Input!) {\n save(input: $input) {"),
"{}",
ex.operation
);
assert_eq!(ex.optional, ["save(dryRun: Boolean)"]);
assert!(
ex.operation.contains("errors {\n message\n }"),
"{}",
ex.operation
);
assert_eq!(ex.variables, serde_json::json!({ "input": "<Input!>" }));
}
#[test]
fn nested_field_is_wrapped_in_a_root_that_returns_its_type() {
let ex = build_for("User.posts");
assert_eq!(
ex.operation,
"query Posts($id: ID!) {\n \
user(id: $id) {\n \
posts {\n \
__typename\n \
}\n \
}\n\
}\n"
);
}
#[test]
fn an_unreachable_field_is_an_error_not_a_guess() {
let mut records = schema();
let target = records
.iter()
.position(|r| r.path == "UserError.message")
.unwrap();
let target = records.remove(target);
let err = build(&target, &records, 1).unwrap_err().to_string();
assert!(err.contains("no root field returns UserError"), "{err}");
}
#[test]
fn ambiguous_roots_are_reported_rather_than_hidden() {
let mut records = schema();
records.push(rec(
"Query.viewer",
"viewer",
Kind::Query,
Some("Query"),
Some("User"),
&[],
));
let target = records.iter().find(|r| r.path == "User.name").unwrap();
let ex = build(target, &records, 1).unwrap();
assert_eq!(ex.alternatives.len(), 1);
}
#[test]
fn a_defaulted_argument_is_omitted_even_when_non_null() {
let records = vec![
rec("Query", "Query", Kind::Object, None, None, &[]),
rec(
"Query.feed",
"feed",
Kind::Query,
Some("Query"),
Some("Int!"),
&["first: Int! = 10", "after: String"],
),
];
let target = records.iter().find(|r| r.path == "Query.feed").unwrap();
let ex = build(target, &records, 1).unwrap();
assert_eq!(ex.operation, "query Feed {\n feed\n}\n");
assert_eq!(ex.variables, serde_json::json!({}));
assert_eq!(
ex.optional,
["feed(first: Int! = 10)", "feed(after: String)"]
);
}
#[test]
fn a_self_referential_input_expands_once() {
let records = vec![
rec("Query", "Query", Kind::Object, None, None, &[]),
rec("Filter", "Filter", Kind::InputObject, None, None, &[]),
rec(
"Filter.and",
"and",
Kind::InputField,
Some("Filter"),
Some("[Filter!]"),
&[],
),
rec(
"Filter.eq",
"eq",
Kind::InputField,
Some("Filter"),
Some("String"),
&[],
),
rec(
"Query.search",
"search",
Kind::Query,
Some("Query"),
Some("Int!"),
&["filter: Filter!"],
),
];
let target = records.iter().find(|r| r.path == "Query.search").unwrap();
let ex = build(target, &records, 1).unwrap();
assert_eq!(
ex.input_types,
vec![vec![
"Filter {".to_string(),
" and: [Filter!]".to_string(),
" eq: String".to_string(),
"}".to_string(),
]]
);
}
#[test]
fn every_drafted_operation_is_valid_graphql() {
for path in [
"Query.user",
"Query.count",
"Mutation.save",
"User.posts",
"User.name",
] {
let ex = build_for(path);
graphql_parser::parse_query::<String>(&ex.operation).unwrap_or_else(|e| {
panic!("{path} drafted invalid GraphQL: {e}\n{}", ex.operation)
});
}
}
#[test]
fn colliding_argument_names_are_disambiguated() {
let records = vec![
rec("Query", "Query", Kind::Object, None, None, &[]),
rec("Role", "Role", Kind::Enum, None, None, &[]),
rec(
"Role.ADMIN",
"ADMIN",
Kind::EnumValue,
Some("Role"),
None,
&[],
),
rec("Thing", "Thing", Kind::Object, None, None, &[]),
rec(
"Query.thing",
"thing",
Kind::Query,
Some("Query"),
Some("Thing"),
&["id: ID!"],
),
rec(
"Thing.child",
"child",
Kind::Field,
Some("Thing"),
Some("String"),
&["id: ID!", "role: Role!"],
),
];
let target = records.iter().find(|r| r.path == "Thing.child").unwrap();
let ex = build(target, &records, 1).unwrap();
assert!(
ex.operation.contains("child(id: $childId, role: $role)"),
"{}",
ex.operation
);
assert_eq!(
ex.variables,
serde_json::json!({ "id": "<ID!>", "childId": "<ID!>", "role": "<Role!>" })
);
}
}