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 via: Option<String>,
pub alternatives: Vec<String>,
}
pub fn build(target: &SchemaRecord, records: &[SchemaRecord]) -> 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 = Variables::collect(&chain);
let leaf_type = chain
.last()
.and_then(|r| r.base_type())
.unwrap_or_default()
.to_string();
let mut body = schema.selection(&leaf_type);
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(&schema),
via,
alternatives,
})
}
struct Schema<'a> {
kinds: HashMap<&'a str, Kind>,
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 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);
}
}
}
Self {
kinds,
fields,
roots,
}
}
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) -> Vec<String> {
if type_name.is_empty() || self.is_leaf(type_name) {
return Vec::new();
}
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 self.is_leaf(base) {
if f.args.iter().any(|a| a.trim_end().ends_with('!')) {
deferred.push(format!("# {}: {} — needs arguments", f.name, base));
} else {
lines.push(f.name.clone());
}
} else if f.name.eq_ignore_ascii_case("errors") {
let inner = self.selection(base);
lines.push(format!("{} {{", 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 lines.is_empty() {
lines.push("__typename".to_string());
lines.push("# add inline fragments: ... on ConcreteType { … }".to_string());
}
lines
}
fn placeholder(&self, type_ref: &str) -> Value {
let type_ref = type_ref.trim();
if !type_ref.ends_with('!') {
return Value::Null;
}
if type_ref.starts_with('[') {
return Value::Array(Vec::new());
}
let base = type_ref.trim_matches(|c| matches!(c, '[' | ']' | '!' | ' '));
match base {
"ID" | "String" => Value::String(String::new()),
"Int" => Value::Number(0.into()),
"Float" => serde_json::json!(0.0),
"Boolean" => Value::Bool(false),
_ => match self.kinds.get(base) {
Some(Kind::Enum) => self
.fields
.get(base)
.and_then(|vs| vs.first())
.map(|v| Value::String(v.name.clone()))
.unwrap_or(Value::Null),
Some(Kind::InputObject) => Value::Object(Map::new()),
_ => Value::Null,
},
}
}
}
struct Variables {
entries: Vec<(usize, String, String, String)>,
}
impl Variables {
fn collect(chain: &[&SchemaRecord]) -> Self {
let mut entries: Vec<(usize, String, String, String)> = Vec::new();
for (depth, field) in chain.iter().enumerate() {
for arg in &field.args {
let (name, type_ref) = split_arg(arg);
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 }
}
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, schema: &Schema) -> Value {
let mut map = Map::new();
for (_, _, var, ty) in &self.entries {
map.insert(var.clone(), schema.placeholder(ty));
}
Value::Object(map)
}
}
fn required_args(r: &SchemaRecord) -> usize {
r.args
.iter()
.filter(|a| split_arg(a).1.ends_with('!'))
.count()
}
fn split_arg(arg: &str) -> (&str, &str) {
let (name, rest) = arg.split_once(':').unwrap_or((arg, ""));
let type_ref = rest.split('=').next().unwrap_or(rest);
(name.trim(), type_ref.trim())
}
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![],
}
}
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).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": "" }));
}
#[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!, $dryRun: Boolean) {\n save(input: $input, dryRun: $dryRun) {"
), "{}", ex.operation);
assert!(
ex.operation.contains("errors {\n message\n }"),
"{}",
ex.operation
);
assert_eq!(
ex.variables,
serde_json::json!({ "input": {}, "dryRun": null })
);
}
#[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 \
# add inline fragments: ... on ConcreteType { … }\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).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).unwrap();
assert_eq!(ex.alternatives.len(), 1);
}
#[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 enum_placeholder_uses_a_real_value_and_args_disambiguate() {
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).unwrap();
assert!(
ex.operation.contains("child(id: $childId, role: $role)"),
"{}",
ex.operation
);
assert_eq!(
ex.variables,
serde_json::json!({ "id": "", "childId": "", "role": "ADMIN" })
);
}
}