When a query uses an inline fragment with a union type condition (e.g. ... on MyUnion), and the current runtime type is a member of that union, async-graphql fails to spread the fragment. The fields inside the fragment are silently dropped, resulting in empty data.
This affects both the dynamic schema API and macro-derived types.
# Schema
union Things = Query | Other
type Other {
value: String
}
type Query {
hello: String
}# Query
{
... on Things {
hello
}
}Expected: {"data": {"hello": "world"}}
Actual: {"data": {}}
The inline fragment ... on Things should match because the runtime type (Query) is a member of Things. graphql-js, graphql-java, and hot-chocolate all return the expected result.
use async_graphql::dynamic::*;
#[tokio::test]
async fn inline_fragment_on_union_at_query_root() {
let query = Object::new("Query").field(
Field::new("hello", TypeRef::Named("String".into()), |_| {
FieldFuture::new(async { Ok(Some(FieldValue::value("world"))) })
}),
);
let other = Object::new("Other").field(
Field::new("value", TypeRef::Named("String".into()), |_| {
FieldFuture::new(async { Ok(Some(FieldValue::value("x"))) })
}),
);
let things = Union::new("Things")
.possible_type("Query")
.possible_type("Other");
let schema = Schema::build("Query", None, None)
.register(query)
.register(other)
.register(things)
.finish()
.unwrap();
let resp = schema.execute("{ ... on Things { hello } }").await;
let data = resp.data.into_json().unwrap();
// FAILS: data is {} instead of {"hello": "world"}
assert_eq!(data, serde_json::json!({"hello": "world"}));
}In src/dynamic/resolve.rs around line 376, the type condition matching logic for inline fragments is:
let type_condition_matched = match type_condition {
None => true,
Some(type_condition) if type_condition == introspection_type_name => true,
Some(type_condition) if object.implements.contains(type_condition) => true,
_ => false,
};This handles:
- No type condition → matches
- Type condition equals the concrete type name → matches
- Type condition is an interface the object implements → matches
Missing: type condition is a union that contains the concrete type. An object doesn't "implement" a union, so object.implements.contains("Things") is false, and the fragment is rejected.
The same bug exists in src/resolver_utils/container.rs around line 362, which handles macro-derived types.
The registry already provides MetaType::is_possible_type() which checks both union membership and interface implementation:
// In src/dynamic/resolve.rs
let type_condition_matched = match type_condition {
None => true,
Some(type_condition) if type_condition == introspection_type_name => true,
Some(type_condition) if object.implements.contains(type_condition) => true,
Some(type_condition) => {
schema.0.env.registry
.types
.get(type_condition)
.is_some_and(|ty| ty.is_possible_type(&introspection_type_name))
}
};And similarly in src/resolver_utils/container.rs:
let applies_concrete_object = type_condition.is_some_and(|condition| {
introspection_type_name == condition
|| ctx.schema_env.registry.implements
.get(&*introspection_type_name)
.is_some_and(|interfaces| interfaces.contains(condition))
|| ctx.schema_env.registry.types
.get(condition)
.is_some_and(|ty| ty.is_possible_type(&introspection_type_name))
});GraphQL spec §6.3.2 — Field Collection:
If
fragmentTypeis an Object type, the fragment spreads whenobjectTypeandfragmentTypeare the same. IffragmentTypeis an Interface or Union type, the fragment spreads whenobjectTypeis a possible type offragmentType.
graphql-conformance — a project that tests open-source GraphQL implementations against a reference (graphql-js) using generated schemas and queries.