From 0aad85bfa2f89859eab8a2381c33745912db47ae Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Wed, 3 Jun 2026 14:20:57 +0000 Subject: [PATCH] Improve Schema Error Details --- .../src/Types/Configuration/TypeDiscoverer.cs | 34 +- .../Configuration/TypeInferencePathBuilder.cs | 387 ++++++++++++++++++ .../Core/src/Types/TypeErrorFields.cs | 1 + .../Configuration/TypeDiscovererTests.cs | 188 +++++++++ ...scovererTests.Cannot_Infer_Input_Type.snap | 4 +- ...When_Nested_Member_Cannot_Be_Inferred.snap | 5 + 6 files changed, 614 insertions(+), 5 deletions(-) create mode 100644 src/HotChocolate/Core/src/Types/Configuration/TypeInferencePathBuilder.cs create mode 100644 src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.DiscoverTypes_Should_Append_PathToRoot_When_Nested_Member_Cannot_Be_Inferred.snap diff --git a/src/HotChocolate/Core/src/Types/Configuration/TypeDiscoverer.cs b/src/HotChocolate/Core/src/Types/Configuration/TypeDiscoverer.cs index 7b78444c1ea..c8ce715c9bd 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/TypeDiscoverer.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/TypeDiscoverer.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text; using HotChocolate.Properties; using HotChocolate.Types; using HotChocolate.Types.Descriptors; @@ -238,22 +240,46 @@ private void CollectErrors() if (_errors.Count == 0 && _typeRegistrar.Unresolved.Count > 0) { - foreach (var unresolvedReference in _typeRegistrar.Unresolved) + var message = new StringBuilder(); + + // the unresolved references are stored in an unordered set, so we order + // them deterministically to keep the produced error sequence stable. + foreach (var unresolvedReference in + _typeRegistrar.Unresolved.OrderBy(r => r.ToString(), StringComparer.Ordinal)) { var types = _typeRegistry.Types.Where( t => t.Dependencies.Select(d => d.Type) .Any(r => r.Equals(unresolvedReference))).ToList(); + var path = TypeInferencePathBuilder.Build(_typeRegistry, unresolvedReference); + + message.Clear(); + + message.AppendFormat( + CultureInfo.InvariantCulture, + TypeResources.TypeRegistrar_TypesInconsistent, + unresolvedReference); + + if (path.HasValue) + { + message.Append(Environment.NewLine); + message.Append(Environment.NewLine); + message.Append(path.Value.Short); + } + var builder = SchemaErrorBuilder.New() - .SetMessage( - TypeResources.TypeRegistrar_TypesInconsistent, - unresolvedReference) + .SetMessage(message.ToString()) .SetExtension( TypeErrorFields.Reference, unresolvedReference) .SetCode(ErrorCodes.Schema.UnresolvedTypes); + if (path.HasValue) + { + builder.SetExtension(TypeErrorFields.Path, path.Value.Expanded); + } + if (types.Count == 1) { builder.SetTypeSystemObject(types[0].Type); diff --git a/src/HotChocolate/Core/src/Types/Configuration/TypeInferencePathBuilder.cs b/src/HotChocolate/Core/src/Types/Configuration/TypeInferencePathBuilder.cs new file mode 100644 index 00000000000..ff967201109 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Configuration/TypeInferencePathBuilder.cs @@ -0,0 +1,387 @@ +using System.Text; +using HotChocolate.Types; +using HotChocolate.Types.Descriptors; +using HotChocolate.Types.Descriptors.Configurations; + +namespace HotChocolate.Configuration; + +/// +/// Builds a developer-facing path that describes how an unresolved type reference +/// was reached during type discovery. The path is composed from runtime (CLR) member +/// names so that the originating field or argument can be located in the source code. +/// +internal static class TypeInferencePathBuilder +{ + /// + /// Builds a path from the root type to the unresolved type reference. + /// + /// + /// The type registry that holds all registered types. + /// + /// + /// The type reference that could not be inferred or resolved. + /// + /// + /// The maximum number of arrow-separated parts in the path, including the leaf + /// (and a leading "..." marker when the chain is longer). + /// + /// + /// A that can render a short or a namespace-qualified + /// form, or null when no member edge pointing at the unresolved reference can + /// be found. + /// + public static TypeInferencePath? Build( + TypeRegistry typeRegistry, + TypeReference unresolved, + int maxSegments = 5) + { + ArgumentNullException.ThrowIfNull(typeRegistry); + ArgumentNullException.ThrowIfNull(unresolved); + + // the leaf is captured as a runtime type when possible so that it can be + // rendered with or without its namespace. otherwise the reference text is used. + var leafType = unresolved is ExtendedTypeReference extendedRef + ? extendedRef.Type.Type + : null; + var leafText = leafType is null ? unresolved.ToString()! : null; + + // the first hop locates the member whose type is the unresolved reference. + if (!TryFindOwner(typeRegistry, unresolved, out var owner, out var segment)) + { + return null; + } + + // the path is built leaf-first and reversed before joining. the leaf counts + // towards the cap, as does a leading "..." marker when the chain is longer. + var segments = new List { segment }; + var visited = new HashSet { owner }; + var truncated = false; + + // the remaining hops walk towards the root by locating the member whose + // type resolves to the current owner. + while (TryFindParent(typeRegistry, owner, visited, out var parent, out var parentSegment)) + { + // adding this ancestor would fill the last slot. if yet another ancestor + // exists beyond it, that slot is reserved for the leading "..." marker. + if (segments.Count + 1 >= maxSegments - 1) + { + visited.Add(parent); + truncated = TryFindParent(typeRegistry, parent, visited, out _, out _); + + if (!truncated) + { + segments.Add(parentSegment); + } + + break; + } + + segments.Add(parentSegment); + visited.Add(parent); + owner = parent; + } + + segments.Reverse(); + + return new TypeInferencePath(leafType, leafText, segments, truncated); + } + + private static bool TryFindOwner( + TypeRegistry typeRegistry, + TypeReference unresolved, + out RegisteredType owner, + out TypeInferenceSegment segment) + { + bool Matches(TypeReference reference) => reference.Equals(unresolved); + + foreach (var registeredType in typeRegistry.Types) + { + if (TryGetMemberSegment(registeredType, Matches, out segment)) + { + owner = registeredType; + return true; + } + } + + owner = null!; + segment = default; + return false; + } + + private static bool TryFindParent( + TypeRegistry typeRegistry, + RegisteredType target, + HashSet visited, + out RegisteredType parent, + out TypeInferenceSegment segment) + { + bool Matches(TypeReference reference) + => typeRegistry.TryGetType(reference, out var rt) && ReferenceEquals(rt, target); + + foreach (var registeredType in typeRegistry.Types) + { + if (visited.Contains(registeredType)) + { + continue; + } + + if (TryGetMemberSegment(registeredType, Matches, out segment)) + { + parent = registeredType; + return true; + } + } + + parent = null!; + segment = default; + return false; + } + + private static bool TryGetMemberSegment( + RegisteredType registeredType, + Func matches, + out TypeInferenceSegment segment) + { + var owner = registeredType.RuntimeType; + + // only object, interface and input object types expose member edges with a + // populated configuration. any other kind yields no member segment. + switch (registeredType.Type) + { + case ObjectType { Configuration: { } config }: + foreach (var field in config.Fields) + { + var memberName = field.Member?.Name ?? field.Name; + if (TryMatchOutputField(owner, memberName, field, matches, out segment)) + { + return true; + } + } + break; + + case InterfaceType { Configuration: { } config }: + foreach (var field in config.Fields) + { + var memberName = field.Member?.Name ?? field.Name; + if (TryMatchOutputField(owner, memberName, field, matches, out segment)) + { + return true; + } + } + break; + + case InputObjectType { Configuration: { } config }: + foreach (var field in config.Fields) + { + if (field.Type is { } fieldType && matches(fieldType)) + { + var memberName = field.Property?.Name ?? field.Name; + segment = new TypeInferenceSegment(owner, $".{memberName}"); + return true; + } + } + break; + } + + segment = default; + return false; + } + + private static bool TryMatchOutputField( + Type owner, + string memberName, + OutputFieldConfiguration field, + Func matches, + out TypeInferenceSegment segment) + { + if (field.Type is { } fieldType && matches(fieldType)) + { + segment = new TypeInferenceSegment(owner, $".{memberName}"); + return true; + } + + if (field.HasArguments) + { + foreach (var argument in field.GetArguments()) + { + if (argument.Type is { } argType && matches(argType)) + { + var argName = argument.Parameter?.Name ?? argument.Name; + segment = new TypeInferenceSegment(owner, $".{memberName}({argName})"); + return true; + } + } + } + + segment = default; + return false; + } + + internal static string GetFriendlyTypeName(Type type, bool includeNamespace) + { + if (TryGetAlias(type, out var alias)) + { + return alias; + } + + if (type.IsArray) + { + var elementType = type.GetElementType(); + return elementType is null + ? type.Name + : $"{GetFriendlyTypeName(elementType, includeNamespace)}[]"; + } + + if (type.IsByRef) + { + var elementType = type.GetElementType(); + return elementType is null + ? type.Name + : $"{GetFriendlyTypeName(elementType, includeNamespace)}&"; + } + + if (type.IsPointer) + { + var elementType = type.GetElementType(); + return elementType is null + ? type.Name + : $"{GetFriendlyTypeName(elementType, includeNamespace)}*"; + } + + if (type.IsGenericType) + { + var name = type.Name; + var backtick = name.IndexOf('`'); + if (backtick > 0) + { + name = name[..backtick]; + } + + if (includeNamespace && !string.IsNullOrEmpty(type.Namespace)) + { + name = $"{type.Namespace}.{name}"; + } + + var arguments = type.GetGenericArguments(); + var builder = new StringBuilder(name); + builder.Append('<'); + + for (var i = 0; i < arguments.Length; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + builder.Append(GetFriendlyTypeName(arguments[i], includeNamespace)); + } + + builder.Append('>'); + return builder.ToString(); + } + + return includeNamespace && !string.IsNullOrEmpty(type.Namespace) + ? $"{type.Namespace}.{type.Name}" + : type.Name; + } + + private static bool TryGetAlias(Type type, out string alias) + { + alias = type switch + { + _ when type == typeof(byte) => "byte", + _ when type == typeof(sbyte) => "sbyte", + _ when type == typeof(short) => "short", + _ when type == typeof(ushort) => "ushort", + _ when type == typeof(int) => "int", + _ when type == typeof(uint) => "uint", + _ when type == typeof(long) => "long", + _ when type == typeof(ulong) => "ulong", + _ when type == typeof(float) => "float", + _ when type == typeof(double) => "double", + _ when type == typeof(decimal) => "decimal", + _ when type == typeof(bool) => "bool", + _ when type == typeof(char) => "char", + _ when type == typeof(string) => "string", + _ when type == typeof(object) => "object", + _ => null! + }; + + return alias is not null; + } +} + +/// +/// Represents a single member hop in a . +/// +/// +/// The runtime type that declares the member. +/// +/// +/// The member portion of the segment, for example ".Bar" or ".Baz(input)". +/// +internal readonly record struct TypeInferenceSegment(Type Owner, string MemberSuffix); + +/// +/// Represents how an unresolved type reference was reached during type discovery. +/// The path can be rendered in a short form (aliases, no namespaces) for messages +/// and an expanded form (namespace-qualified) for tooling. +/// +internal readonly record struct TypeInferencePath +{ + private const string ArrowSeparator = " -> "; + private const string TruncationMarker = "..."; + + private readonly Type? _leafType; + private readonly string? _leafText; + private readonly IReadOnlyList _segments; + private readonly bool _truncated; + + public TypeInferencePath( + Type? leafType, + string? leafText, + IReadOnlyList segments, + bool truncated) + { + _leafType = leafType; + _leafText = leafText; + _segments = segments; + _truncated = truncated; + } + + /// + /// Gets the compact path using primitive aliases and no namespaces. + /// + public string Short => Render(includeNamespace: false); + + /// + /// Gets the namespace-qualified path. Primitive aliases are preserved. + /// + public string Expanded => Render(includeNamespace: true); + + private string Render(bool includeNamespace) + { + var builder = new StringBuilder(); + + if (_truncated) + { + builder.Append(TruncationMarker); + builder.Append(ArrowSeparator); + } + + foreach (var segment in _segments) + { + builder.Append( + TypeInferencePathBuilder.GetFriendlyTypeName(segment.Owner, includeNamespace)); + builder.Append(segment.MemberSuffix); + builder.Append(ArrowSeparator); + } + + builder.Append( + _leafType is null + ? _leafText + : TypeInferencePathBuilder.GetFriendlyTypeName(_leafType, includeNamespace)); + + return builder.ToString(); + } +} diff --git a/src/HotChocolate/Core/src/Types/TypeErrorFields.cs b/src/HotChocolate/Core/src/Types/TypeErrorFields.cs index 51e06c796ec..59d38d7172e 100644 --- a/src/HotChocolate/Core/src/Types/TypeErrorFields.cs +++ b/src/HotChocolate/Core/src/Types/TypeErrorFields.cs @@ -4,4 +4,5 @@ internal static class TypeErrorFields { public const string Reference = "reference"; public const string Definition = "definition"; + public const string Path = "typePath"; } diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscovererTests.cs b/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscovererTests.cs index 2cf41120ba5..fee13d6b86f 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscovererTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/TypeDiscovererTests.cs @@ -281,6 +281,194 @@ public void Can_Infer_Generic_Record_Struct_Input_Type() Assert.Empty(errors); } + [Fact] + public void DiscoverTypes_Should_Append_PathToRoot_When_Nested_Member_Cannot_Be_Inferred() + { + // arrange + // the query reaches an un-inferable interface through two nested members. + var context = DescriptorContext.Create(); + var typeRegistry = new TypeRegistry(context.TypeInterceptor); + var typeLookup = new TypeLookup(context.TypeInspector, typeRegistry); + + var typeDiscoverer = new TypeDiscoverer( + context, + typeRegistry, + typeLookup, + new HashSet + { + _typeInspector.GetTypeRef(typeof(NestedQuery), TypeContext.Output) + }, + new AggregateTypeInterceptor()); + + // act + var errors = typeDiscoverer.DiscoverTypes(); + + // assert + var error = Assert.Single(errors); + Assert.Contains(" -> ", error.Message, StringComparison.Ordinal); + new SchemaException(errors).Message.MatchSnapshot(); + } + + [Fact] + public void DiscoverTypes_Should_Expose_TypePath_Extension_When_Member_Cannot_Be_Inferred() + { + // arrange + var context = DescriptorContext.Create(); + var typeRegistry = new TypeRegistry(context.TypeInterceptor); + var typeLookup = new TypeLookup(context.TypeInspector, typeRegistry); + + var typeDiscoverer = new TypeDiscoverer( + context, + typeRegistry, + typeLookup, + new HashSet + { + _typeInspector.GetTypeRef(typeof(NestedQuery), TypeContext.Output) + }, + new AggregateTypeInterceptor()); + + // act + var errors = typeDiscoverer.DiscoverTypes(); + + // assert + // the message keeps the short form while the extension is namespace-qualified. + var error = Assert.Single(errors); + Assert.Contains( + "NestedQuery.GetFoo -> FooWithBar.Bar -> BarWithBaz.Baz(input) -> IMyArg", + error.Message, + StringComparison.Ordinal); + Assert.Equal( + "HotChocolate.Configuration.NestedQuery.GetFoo " + + "-> HotChocolate.Configuration.FooWithBar.Bar " + + "-> HotChocolate.Configuration.BarWithBaz.Baz(input) " + + "-> HotChocolate.Configuration.IMyArg", + Assert.IsType(error.Extensions[TypeErrorFields.Path])); + } + + [Fact] + public void Build_Should_Use_Friendly_GenericName_When_Leaf_Is_ReadOnlyMemory() + { + // arrange + // ReadOnlyMemory resolves during discovery, so the path is built + // directly to demonstrate the friendly leaf name formatting. + var context = DescriptorContext.Create(); + var typeRegistry = new TypeRegistry(context.TypeInterceptor); + var typeLookup = new TypeLookup(context.TypeInspector, typeRegistry); + + var typeDiscoverer = new TypeDiscoverer( + context, + typeRegistry, + typeLookup, + new HashSet + { + _typeInspector.GetTypeRef(typeof(MemoryQuery), TypeContext.Output) + }, + new AggregateTypeInterceptor()); + typeDiscoverer.DiscoverTypes(); + + var reference = _typeInspector.GetTypeRef(typeof(ReadOnlyMemory), TypeContext.Input); + + // act + var path = TypeInferencePathBuilder.Build(typeRegistry, reference); + + // assert + // the short form uses aliases without namespaces, the expanded form qualifies them. + Assert.Equal("MemoryQuery.Baz(input) -> ReadOnlyMemory", path?.Short); + Assert.Equal( + "HotChocolate.Configuration.MemoryQuery.Baz(input) -> System.ReadOnlyMemory", + path?.Expanded); + } + + [Fact] + public void Build_Should_Truncate_With_Marker_When_Chain_Exceeds_Cap() + { + // arrange + // the chain reaches the un-inferable leaf through more hops than the cap allows. + var context = DescriptorContext.Create(); + var typeRegistry = new TypeRegistry(context.TypeInterceptor); + var typeLookup = new TypeLookup(context.TypeInspector, typeRegistry); + + var typeDiscoverer = new TypeDiscoverer( + context, + typeRegistry, + typeLookup, + new HashSet + { + _typeInspector.GetTypeRef(typeof(DeepQuery), TypeContext.Output) + }, + new AggregateTypeInterceptor()); + typeDiscoverer.DiscoverTypes(); + + var reference = _typeInspector.GetTypeRef(typeof(IMyArg), TypeContext.Input); + + // act + var path = TypeInferencePathBuilder.Build(typeRegistry, reference); + + // assert + // both renderings share the same truncation, so the part count is identical. + var shortParts = path!.Value.Short.Split(" -> "); + var expandedParts = path.Value.Expanded.Split(" -> "); + Assert.Equal(5, shortParts.Length); + Assert.Equal("...", shortParts[0]); + Assert.Equal("IMyArg", shortParts[^1]); + Assert.Equal(shortParts.Length, expandedParts.Length); + } + + public class DeepQuery + { + public DeepLevel1 GetLevel1() => throw new NotImplementedException(); + } + + public class DeepLevel1 + { + public DeepLevel2 Level2 { get; } = null!; + } + + public class DeepLevel2 + { + public DeepLevel3 Level3 { get; } = null!; + } + + public class DeepLevel3 + { + public DeepLevel4 Level4 { get; } = null!; + } + + public class DeepLevel4 + { + public DeepLevel5 Level5 { get; } = null!; + } + + public class DeepLevel5 + { + public DeepLevel6 Level6 { get; } = null!; + } + + public class DeepLevel6 + { + public string Leaf(IMyArg arg) => throw new NotImplementedException(); + } + + public class NestedQuery + { + public FooWithBar GetFoo() => throw new NotImplementedException(); + } + + public class FooWithBar + { + public BarWithBaz Bar { get; } = null!; + } + + public class BarWithBaz + { + public string Baz(IMyArg input) => throw new NotImplementedException(); + } + + public class MemoryQuery + { + public string Baz(ReadOnlyMemory input) => throw new NotImplementedException(); + } + public class FooType : ObjectType { diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Cannot_Infer_Input_Type.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Cannot_Infer_Input_Type.snap index 766d897e0f6..b119ee2074c 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Cannot_Infer_Input_Type.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Cannot_Infer_Input_Type.snap @@ -1,3 +1,5 @@ For more details look at the `Errors` property. -1. Unable to infer or resolve a schema type from the type reference `IMyArg (Input)`. (HotChocolate.Types.ObjectType) +1. Unable to infer or resolve a schema type from the type reference `IMyArg (Input)`. + +QueryWithInferError.Foo(o) -> IMyArg (HotChocolate.Types.ObjectType) diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.DiscoverTypes_Should_Append_PathToRoot_When_Nested_Member_Cannot_Be_Inferred.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.DiscoverTypes_Should_Append_PathToRoot_When_Nested_Member_Cannot_Be_Inferred.snap new file mode 100644 index 00000000000..4e68cd9a14e --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.DiscoverTypes_Should_Append_PathToRoot_When_Nested_Member_Cannot_Be_Inferred.snap @@ -0,0 +1,5 @@ +For more details look at the `Errors` property. + +1. Unable to infer or resolve a schema type from the type reference `IMyArg (Input)`. + +NestedQuery.GetFoo -> FooWithBar.Bar -> BarWithBaz.Baz(input) -> IMyArg (HotChocolate.Types.ObjectType)