From d8e65b33375f061d24b05c52875d500973137ed1 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 12:52:20 +0800 Subject: [PATCH 01/24] Add semantic introspection --- .work/review/review.md | 48 + .../ISchemaSearchProvider.cs | 61 + .../SchemaCoordinatePath.cs | 52 + .../Types.Abstractions/SchemaSearchResult.cs | 46 + .../IntrospectionTypeReferences.cs | 9 + .../Core/src/Types/IReadOnlySchemaOptions.cs | 8 + .../Core/src/Types/SchemaBuilder.Setup.cs | 4 + .../Core/src/Types/SchemaOptions.cs | 4 + .../Introspection/IntrospectionFields.cs | 174 +++ .../IntrospectionTypeInterceptor.cs | 6 + .../Introspection/Search/BM25Document.cs | 9 + .../Types/Introspection/Search/BM25Index.cs | 194 +++ .../Search/BM25SearchProvider.cs | 290 ++++ .../Introspection/Search/BM25Tokenizer.cs | 113 ++ .../Introspection/Search/SchemaIndexer.cs | 142 ++ .../Types/Introspection/SearchResultInfo.cs | 35 + .../Types/Introspection/__SchemaDefinition.cs | 55 + .../Types/Introspection/__SearchResult.cs | 80 + .../Validation/Rules/IntrospectionVisitor.cs | 5 +- ...sts.Ensure_Benchmark_Query_LargeQuery.snap | 170 +- .../SemanticIntrospectionTests.cs | 1361 +++++++++++++++++ ...ectionTests.DefaultValueIsInputObject.snap | 155 ++ ...eIntrospection_AllDirectives_Internal.snap | 28 + ...iveIntrospection_AllDirectives_Public.snap | 28 + ...Introspection_SomeDirectives_Internal.snap | 28 + ...sts.ExecuteGraphiQLIntrospectionQuery.snap | 155 ++ ...cuteGraphiQLIntrospectionQuery_ToJson.snap | 155 ++ ...sts.Register_ClrType_InferSchemaTypes.snap | 36 + ...sts.Register_SchemaType_ClrTypeExists.snap | 36 + ...sts.Register_ClrType_InferSchemaTypes.snap | 4 +- ...sts.Register_SchemaType_ClrTypeExists.snap | 4 +- .../Introspection/Search/BM25IndexTests.cs | 209 +++ .../Search/BM25SearchProviderTests.cs | 361 +++++ .../Search/BM25TokenizerTests.cs | 134 ++ .../Search/SchemaIndexerTests.cs | 266 ++++ ...ary_String_Object_Output_Field_Builds.snap | 6 + ....Allow_PascalCasedArguments_Schema.graphql | 6 + ...rstTests.DescriptionsAreCorrectlyRead.snap | 150 ++ ...rrectly_Exposed_Through_Introspection.snap | 150 ++ .../IntrospectionRuleTests.cs | 1 + .../Completion/CompositeSchemaBuilder.cs | 122 +- .../Completion/SemanticIntrospectionSchema.cs | 29 + .../FusionSchemaOptions.cs | 3 + .../IFusionSchemaOptions.cs | 6 + .../Execution/FusionOptions.cs | 18 +- .../Execution/FusionRequestExecutorManager.cs | 25 +- .../Execution/Introspection/MemHelper.cs | 11 + .../Execution/Introspection/Query.cs | 126 ++ .../Introspection/SchemaCoordinateResolver.cs | 102 ++ .../Introspection/SearchResultData.cs | 37 + .../Execution/Introspection/__SearchResult.cs | 69 + .../Nodes/IntrospectionExecutionNode.cs | 55 +- .../HotChocolate.Fusion.Execution.csproj | 15 + .../Text/Json/SourceResultElementBuilder.cs | 20 + .../Types/IntrospectionFieldNames.cs | 20 + 55 files changed, 5391 insertions(+), 45 deletions(-) create mode 100644 .work/review/review.md create mode 100644 src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs create mode 100644 src/HotChocolate/Core/src/Types.Abstractions/SchemaCoordinatePath.cs create mode 100644 src/HotChocolate/Core/src/Types.Abstractions/SchemaSearchResult.cs create mode 100644 src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Document.cs create mode 100644 src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Index.cs create mode 100644 src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs create mode 100644 src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Tokenizer.cs create mode 100644 src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs create mode 100644 src/HotChocolate/Core/src/Types/Types/Introspection/SearchResultInfo.cs create mode 100644 src/HotChocolate/Core/src/Types/Types/Introspection/__SchemaDefinition.cs create mode 100644 src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs create mode 100644 src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs create mode 100644 src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25IndexTests.cs create mode 100644 src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs create mode 100644 src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25TokenizerTests.cs create mode 100644 src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/SemanticIntrospectionSchema.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaCoordinateResolver.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SearchResultData.cs create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs diff --git a/.work/review/review.md b/.work/review/review.md new file mode 100644 index 00000000000..b96f6ac3908 --- /dev/null +++ b/.work/review/review.md @@ -0,0 +1,48 @@ +# Review Verdict: **Request Changes** + +The current changes introduce valuable semantic introspection functionality, but there are shipping blockers and a few important consistency risks. + +## Critical + +### 1. Sync-over-async in Fusion introspection resolvers +- **Files:** `src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs` +- **Evidence:** Calls to `SearchAsync(...).AsTask().GetAwaiter().GetResult()` and `GetPathsToRootAsync(...).AsTask().GetAwaiter().GetResult()` +- **Why it matters:** Blocking async calls inside request execution risks thread-pool starvation/deadlock patterns and increases tail latency under load. +- **Fix:** Move resolver path to async end-to-end (preferred), or provide a truly synchronous provider path. Avoid `GetResult()` in execution flow. + +## Major + +### 2. Semantic introspection enabled by default changes schema surface +- **File:** `src/HotChocolate/Core/src/Types/SchemaOptions.cs` +- **Evidence:** `EnableSemanticIntrospection` defaults to `true`. +- **Why it matters:** Existing schemas gain new introspection fields/types by default, which can break strict schema-shape checks and downstream tooling expectations. +- **Fix:** Consider defaulting to opt-in (`false`) or gate with explicit versioned rollout/clear upgrade note. + +### 3. Inconsistent `ISchemaSearchProvider` registration between Core and Fusion +- **Files:** `src/HotChocolate/Core/src/Types/SchemaBuilder.Setup.cs`, `src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs` +- **Evidence:** Core registers provider unconditionally; Fusion registers only when semantic introspection is enabled. +- **Why it matters:** Divergent lifecycle/behavior across stacks makes feature behavior and diagnostics inconsistent. +- **Fix:** Align registration strategy (prefer conditional registration in both paths). + +## Minor + +### 4. Missing cancellation propagation in Fusion search calls +- **File:** `src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs` +- **Evidence:** `SearchAsync`/`GetPathsToRootAsync` are invoked without a request cancellation token. +- **Why it matters:** Cancelled requests may continue expensive introspection work unnecessarily. +- **Fix:** Thread request cancellation through field context and pass token to provider APIs. + +### 5. Duplicate coordinate-resolution logic in Core and Fusion +- **Files:** `src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs`, `src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaCoordinateResolver.cs` +- **Why it matters:** Logic drift risk and higher maintenance overhead. +- **Fix:** Extract a shared resolver helper/API and reuse in both implementations. + +### 6. First-query latency risk from lazy BM25 index build +- **File:** `src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs` +- **Why it matters:** Large schemas can pay indexing cost on first `__search` call. +- **Fix:** Add optional prewarm/eager build path or document/startup-warm strategy. + +## Notes + +- Pattern and validation wiring look largely correct (new fields marked as introspection and recognized by validation logic). +- Snapshot changes are consistent with the new schema surface. diff --git a/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs b/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs new file mode 100644 index 00000000000..39ca7c9df83 --- /dev/null +++ b/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs @@ -0,0 +1,61 @@ +namespace HotChocolate; + +/// +/// Provides semantic search capabilities over a GraphQL schema, +/// allowing consumers to find schema elements by natural language queries +/// and to discover paths from a given schema coordinate back to a root type. +/// +public interface ISchemaSearchProvider +{ + /// + /// Searches the schema for elements matching the specified query. + /// + /// + /// The search query string. + /// + /// + /// The maximum number of results to return. + /// + /// + /// An opaque cursor for forward pagination. + /// Pass null to start from the beginning. + /// + /// + /// The minimum relevance score in the range [0.0, 1.0]. + /// Results with a score below this threshold are excluded. + /// Pass null to include all results. + /// + /// + /// The cancellation token. + /// + /// + /// A list of ordered by relevance. + /// + ValueTask> SearchAsync( + string query, + int first, + string? after, + float? minScore, + CancellationToken cancellationToken = default); + + /// + /// Gets the paths from the specified schema coordinate to a root type. + /// + /// + /// The schema coordinate from which to trace paths to a root type. + /// + /// + /// The maximum number of paths to return. + /// + /// + /// The cancellation token. + /// + /// + /// A list of instances, + /// each representing an ordered path from the coordinate to a root type. + /// + ValueTask> GetPathsToRootAsync( + SchemaCoordinate coordinate, + int maxPaths, + CancellationToken cancellationToken = default); +} diff --git a/src/HotChocolate/Core/src/Types.Abstractions/SchemaCoordinatePath.cs b/src/HotChocolate/Core/src/Types.Abstractions/SchemaCoordinatePath.cs new file mode 100644 index 00000000000..a8916a9bc90 --- /dev/null +++ b/src/HotChocolate/Core/src/Types.Abstractions/SchemaCoordinatePath.cs @@ -0,0 +1,52 @@ +using System.Collections; + +namespace HotChocolate; + +/// +/// Represents an ordered path of values +/// from a schema element to a root type. +/// +public sealed class SchemaCoordinatePath : IReadOnlyList +{ + private readonly SchemaCoordinate[] _segments; + + /// + /// Initializes a new instance of . + /// + /// + /// The ordered sequence of schema coordinates that form the path. + /// + /// + /// is empty. + /// + public SchemaCoordinatePath(ReadOnlySpan segments) + { + if (segments.Length == 0) + { + throw new ArgumentOutOfRangeException( + nameof(segments), + segments.Length, + "A schema coordinate path must contain at least one segment."); + } + + _segments = segments.ToArray(); + } + + /// + public int Count => _segments.Length; + + /// + public SchemaCoordinate this[int index] => _segments[index]; + + /// + public IEnumerator GetEnumerator() + { + foreach (var segment in _segments) + { + yield return segment; + } + } + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/HotChocolate/Core/src/Types.Abstractions/SchemaSearchResult.cs b/src/HotChocolate/Core/src/Types.Abstractions/SchemaSearchResult.cs new file mode 100644 index 00000000000..f4a1516868d --- /dev/null +++ b/src/HotChocolate/Core/src/Types.Abstractions/SchemaSearchResult.cs @@ -0,0 +1,46 @@ +namespace HotChocolate; + +/// +/// Represents a single result from a schema search operation. +/// +public readonly record struct SchemaSearchResult +{ + /// + /// Initializes a new instance of . + /// + /// + /// The schema coordinate of the matched element. + /// + /// + /// The relevance score of the match in the range [0.0, 1.0], + /// or null if scoring is not supported. + /// + /// + /// An opaque cursor that can be used for pagination. + /// + public SchemaSearchResult( + SchemaCoordinate coordinate, + float? score, + string cursor) + { + Coordinate = coordinate; + Score = score; + Cursor = cursor ?? throw new ArgumentNullException(nameof(cursor)); + } + + /// + /// Gets the schema coordinate of the matched element. + /// + public SchemaCoordinate Coordinate { get; } + + /// + /// Gets the relevance score of the match in the range [0.0, 1.0], + /// or null if scoring is not supported by the provider. + /// + public float? Score { get; } + + /// + /// Gets the opaque cursor that can be used for pagination. + /// + public string Cursor { get; } +} diff --git a/src/HotChocolate/Core/src/Types/Configuration/IntrospectionTypeReferences.cs b/src/HotChocolate/Core/src/Types/Configuration/IntrospectionTypeReferences.cs index ee35fe33c1b..cfe799e7fad 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/IntrospectionTypeReferences.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/IntrospectionTypeReferences.cs @@ -1,3 +1,4 @@ +using HotChocolate.Types; using HotChocolate.Types.Descriptors; using HotChocolate.Types.Introspection; @@ -30,6 +31,14 @@ internal static void Enqueue( EnqueueTypeRef(backlog, context.TypeInspector.GetTypeRef(typeof(__OptInFeatureStability)), nextIndex++); } + if (context.Options.EnableSemanticIntrospection) + { + EnqueueTypeRef(backlog, context.TypeInspector.GetTypeRef(typeof(__SearchResult)), nextIndex++); + EnqueueTypeRef(backlog, context.TypeInspector.GetTypeRef(typeof(__SchemaDefinition)), nextIndex++); + EnqueueTypeRef(backlog, context.TypeInspector.GetTypeRef(typeof(IntType)), nextIndex++); + EnqueueTypeRef(backlog, context.TypeInspector.GetTypeRef(typeof(FloatType)), nextIndex++); + } + static void EnqueueTypeRef( PriorityQueue backlog, TypeReference typeRef, diff --git a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs index 2729cbe797b..8b998e03460 100644 --- a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs @@ -179,6 +179,14 @@ public interface IReadOnlySchemaOptions /// bool EnableOptInFeatures { get; } + /// + /// Enables semantic introspection, including the __search and __definitions + /// introspection fields for AI-driven schema discovery. + /// When core introspection is disabled, semantic introspection is also disabled + /// regardless of this setting. + /// + bool EnableSemanticIntrospection { get; } + /// /// Specifies the default dependency injection scope for query fields. /// diff --git a/src/HotChocolate/Core/src/Types/SchemaBuilder.Setup.cs b/src/HotChocolate/Core/src/Types/SchemaBuilder.Setup.cs index 9df8a4d3525..a2087aefa36 100644 --- a/src/HotChocolate/Core/src/Types/SchemaBuilder.Setup.cs +++ b/src/HotChocolate/Core/src/Types/SchemaBuilder.Setup.cs @@ -9,6 +9,7 @@ using HotChocolate.Types.Factories; using HotChocolate.Types.Helpers; using HotChocolate.Types.Interceptors; +using HotChocolate.Types.Introspection; using HotChocolate.Types.Pagination; using HotChocolate.Types.Relay; using HotChocolate.Utilities; @@ -598,5 +599,8 @@ internal static void AddCoreSchemaServices(IServiceCollection services, LazySche lazy.OnSchemaCreated(accessor.OnSchemaCreated); return accessor; }); + + services.TryAddSingleton( + static sp => new BM25SearchProvider(sp.GetRequiredService())); } } diff --git a/src/HotChocolate/Core/src/Types/SchemaOptions.cs b/src/HotChocolate/Core/src/Types/SchemaOptions.cs index 54d52b9e17d..5facbbec106 100644 --- a/src/HotChocolate/Core/src/Types/SchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/SchemaOptions.cs @@ -116,6 +116,9 @@ public FieldBindingFlags DefaultFieldBindingFlags /// public bool EnableOptInFeatures { get; set; } + /// + public bool EnableSemanticIntrospection { get; set; } = true; + /// public DependencyInjectionScope DefaultQueryDependencyInjectionScope { get; set; } = DependencyInjectionScope.Resolver; @@ -226,6 +229,7 @@ public static SchemaOptions FromOptions(IReadOnlySchemaOptions options) StripLeadingIFromInterface = options.StripLeadingIFromInterface, EnableTag = options.EnableTag, EnableOptInFeatures = options.EnableOptInFeatures, + EnableSemanticIntrospection = options.EnableSemanticIntrospection, DefaultQueryDependencyInjectionScope = options.DefaultQueryDependencyInjectionScope, DefaultMutationDependencyInjectionScope = options.DefaultMutationDependencyInjectionScope, LazyInitialization = options.LazyInitialization, diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs index 308f64e5d5e..a6afb3cdee5 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs @@ -66,6 +66,180 @@ internal static ObjectFieldConfiguration CreateTypeNameField(IDescriptorContext return CreateConfiguration(descriptor); } + internal static ObjectFieldConfiguration CreateSearchField(IDescriptorContext context) + { + var descriptor = ObjectFieldDescriptor.New(context, IntrospectionFieldNames.Search); + + descriptor + .Argument("query", a => a.Type>()) + .Argument("first", a => a.Type>().DefaultValue(10)) + .Argument("after", a => a.Type()) + .Argument("min_score", a => a.Type()) + .Type>>>() + .Resolve(Resolve); + + var configuration = descriptor.Configuration; + configuration.Flags |= CoreFieldFlags.Introspection; + + static async ValueTask Resolve(IResolverContext ctx) + { + var provider = ctx.Schema.Services.GetService(); + + if (provider is null) + { + return Array.Empty(); + } + + var query = ctx.ArgumentValue("query"); + var first = ctx.ArgumentValue("first"); + var after = ctx.ArgumentOptional("after"); + var minScore = ctx.ArgumentOptional("min_score"); + + var results = await provider.SearchAsync( + query, + first, + after.HasValue ? after.Value : null, + minScore.HasValue ? minScore.Value : null, + ctx.RequestAborted); + + var searchResults = new List(results.Count); + + foreach (var result in results) + { + var definition = ResolveCoordinate(ctx.Schema, result.Coordinate); + + if (definition is null) + { + continue; + } + + var paths = await provider.GetPathsToRootAsync( + result.Coordinate, + maxPaths: 5, + ctx.RequestAborted); + + var pathStrings = new List(paths.Count); + + foreach (var path in paths) + { + pathStrings.Add(string.Join(" > ", path.Select(c => c.ToString()))); + } + + searchResults.Add(new SearchResultInfo + { + Coordinate = result.Coordinate, + Definition = definition, + PathsToRoot = pathStrings, + Score = result.Score, + Cursor = result.Cursor + }); + } + + return searchResults; + } + + return CreateConfiguration(descriptor); + } + + internal static ObjectFieldConfiguration CreateDefinitionsField(IDescriptorContext context) + { + var descriptor = ObjectFieldDescriptor.New(context, IntrospectionFieldNames.Definitions); + + descriptor + .Argument("coordinates", a => a.Type>>>()) + .Type>>>() + .Resolve(Resolve); + + var configuration = descriptor.Configuration; + configuration.Flags |= CoreFieldFlags.Introspection; + + static ValueTask Resolve(IResolverContext ctx) + { + var coordinates = ctx.ArgumentValue("coordinates"); + var definitions = new List(coordinates.Length); + + foreach (var coordinateString in coordinates) + { + if (!SchemaCoordinate.TryParse(coordinateString, out var coordinate)) + { + continue; + } + + var definition = ResolveCoordinate(ctx.Schema, coordinate.Value); + + if (definition is not null) + { + definitions.Add(definition); + } + } + + return new ValueTask(definitions); + } + + return CreateConfiguration(descriptor); + } + + private static object? ResolveCoordinate(Schema schema, SchemaCoordinate coordinate) + { + if (coordinate.OfDirective) + { + if (!schema.DirectiveTypes.TryGetDirective(coordinate.Name, out var directive)) + { + return null; + } + + if (coordinate.ArgumentName is not null) + { + return directive.Arguments.TryGetField(coordinate.ArgumentName, out var arg) + ? arg + : null; + } + + return directive; + } + + if (!schema.Types.TryGetType(coordinate.Name, out var type)) + { + return null; + } + + if (coordinate.MemberName is null) + { + return type; + } + + switch (type) + { + case IComplexTypeDefinition complexType: + if (!complexType.Fields.TryGetField(coordinate.MemberName, out var field)) + { + return null; + } + + if (coordinate.ArgumentName is not null) + { + return field.Arguments.TryGetField(coordinate.ArgumentName, out var fieldArg) + ? fieldArg + : null; + } + + return field; + + case IEnumTypeDefinition enumType: + return enumType.Values.TryGetValue(coordinate.MemberName, out var enumValue) + ? enumValue + : null; + + case IInputObjectTypeDefinition inputType: + return inputType.Fields.TryGetField(coordinate.MemberName, out var inputField) + ? inputField + : null; + + default: + return null; + } + } + private static ObjectFieldConfiguration CreateConfiguration(ObjectFieldDescriptor descriptor) { var configuration = descriptor.CreateConfiguration(); diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionTypeInterceptor.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionTypeInterceptor.cs index 0ec7c33d8af..5153a31a8f6 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionTypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionTypeInterceptor.cs @@ -53,6 +53,12 @@ public override void OnBeforeCompleteTypes() _queryTypeConfiguration.Fields.Insert(position++, CreateSchemaField(_context)); _queryTypeConfiguration.Fields.Insert(position++, CreateTypeField(_context)); _queryTypeConfiguration.Fields.Insert(position, CreateTypeNameField(_context)); + + if (_context.Options.EnableSemanticIntrospection) + { + _queryTypeConfiguration.Fields.Add(CreateSearchField(_context)); + _queryTypeConfiguration.Fields.Add(CreateDefinitionsField(_context)); + } } foreach (var typeDef in _objectTypeConfigurations) diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Document.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Document.cs new file mode 100644 index 00000000000..962f1104e36 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Document.cs @@ -0,0 +1,9 @@ +namespace HotChocolate.Types.Introspection; + +/// +/// Represents a single document in the BM25 search index, +/// mapping a schema coordinate to its searchable text content. +/// +internal readonly record struct BM25Document( + SchemaCoordinate Coordinate, + string Text); diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Index.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Index.cs new file mode 100644 index 00000000000..3674ab48859 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Index.cs @@ -0,0 +1,194 @@ +using System.Buffers; +using System.Collections.Frozen; + +namespace HotChocolate.Types.Introspection; + +/// +/// An inverted index that supports BM25 scoring for schema search. +/// This class is immutable after construction via . +/// +internal sealed class BM25Index +{ + private const float K1 = 1.2f; + private const float B = 0.75f; + + private readonly FrozenDictionary _invertedIndex; + private readonly float[] _documentLengths; + private readonly float _averageDocumentLength; + private readonly int _documentCount; + private readonly SchemaCoordinate[] _coordinates; + + private BM25Index( + FrozenDictionary invertedIndex, + float[] documentLengths, + float averageDocumentLength, + int documentCount, + SchemaCoordinate[] coordinates) + { + _invertedIndex = invertedIndex; + _documentLengths = documentLengths; + _averageDocumentLength = averageDocumentLength; + _documentCount = documentCount; + _coordinates = coordinates; + } + + /// + /// Gets the total number of documents in the index. + /// + public int DocumentCount => _documentCount; + + /// + /// Gets the schema coordinate for the specified document ID. + /// + /// + /// The document ID. + /// + /// + /// The schema coordinate associated with the document. + /// + public SchemaCoordinate GetCoordinate(int documentId) => _coordinates[documentId]; + + /// + /// Builds a BM25 index from the specified documents. + /// + /// + /// The documents to index. + /// + /// + /// A new instance. + /// + public static BM25Index Build(IReadOnlyList documents) + { + ArgumentNullException.ThrowIfNull(documents); + + var count = documents.Count; + var coordinates = new SchemaCoordinate[count]; + var documentLengths = new float[count]; + var builder = new Dictionary>(StringComparer.Ordinal); + var totalLength = 0f; + + for (var documentId = 0; documentId < count; documentId++) + { + var doc = documents[documentId]; + coordinates[documentId] = doc.Coordinate; + + var tokens = BM25Tokenizer.Tokenize(doc.Text); + documentLengths[documentId] = tokens.Length; + totalLength += tokens.Length; + + // Count term frequencies for this document. + var termFrequencies = new Dictionary(StringComparer.Ordinal); + + foreach (var token in tokens) + { + if (!termFrequencies.TryGetValue(token, out var frequency)) + { + frequency = 0; + } + + termFrequencies[token] = frequency + 1; + } + + // Add to the inverted index. + foreach (var (term, frequency) in termFrequencies) + { + if (!builder.TryGetValue(term, out var postings)) + { + postings = []; + builder[term] = postings; + } + + postings.Add(new TermPosting(documentId, frequency)); + } + } + + var averageDocumentLength = count > 0 ? totalLength / count : 0f; + + return new BM25Index( + builder.ToFrozenDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToArray(), + StringComparer.Ordinal), + documentLengths, + averageDocumentLength, + count, + coordinates); + } + + /// + /// Searches the index with the specified query tokens and returns + /// scored document IDs sorted by score descending. + /// + /// + /// The tokenized query terms. + /// + /// + /// A list of document ID and raw BM25 score pairs, sorted by score descending. + /// + public IReadOnlyList Search(string[] queryTokens) + { + if (queryTokens.Length == 0 || _documentCount == 0) + { + return []; + } + + // Use rented array for accumulating scores to avoid allocations on large schemas. + var scores = ArrayPool.Shared.Rent(_documentCount); + + try + { + Array.Clear(scores, 0, _documentCount); + + foreach (var token in queryTokens) + { + if (!_invertedIndex.TryGetValue(token, out var postings)) + { + continue; + } + + // IDF = ln((N - df + 0.5) / (df + 0.5) + 1) + var df = postings.Length; + var idf = MathF.Log(((_documentCount - df + 0.5f) / (df + 0.5f)) + 1f); + + foreach (var posting in postings) + { + var tf = posting.TermFrequency; + var documentLength = _documentLengths[posting.DocumentId]; + var numerator = tf * (K1 + 1f); + var denominator = tf + K1 * (1f - B + B * (documentLength / _averageDocumentLength)); + scores[posting.DocumentId] += idf * (numerator / denominator); + } + } + + // Collect non-zero scores. + var results = new List(); + + for (var i = 0; i < _documentCount; i++) + { + if (scores[i] > 0f) + { + results.Add(new ScoredDocument(i, scores[i])); + } + } + + // Sort by score descending. + results.Sort(static (a, b) => b.Score.CompareTo(a.Score)); + + return results; + } + finally + { + ArrayPool.Shared.Return(scores); + } + } + + /// + /// Represents a posting in the inverted index: a document ID and its term frequency. + /// + internal readonly record struct TermPosting(int DocumentId, int TermFrequency); + + /// + /// Represents a scored document from a search operation. + /// + internal readonly record struct ScoredDocument(int DocumentId, float Score); +} diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs new file mode 100644 index 00000000000..0a79b110881 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs @@ -0,0 +1,290 @@ +using System.Collections.Frozen; +using System.Runtime.InteropServices; +using static HotChocolate.Types.Introspection.SchemaIndexer; + +namespace HotChocolate.Types.Introspection; + +/// +/// The default implementation that uses BM25 scoring +/// to search schema elements by natural language queries. +/// +internal sealed class BM25SearchProvider : ISchemaSearchProvider +{ + private readonly ISchemaDefinition _schema; + private volatile SearchData? _searchData; + private readonly object _syncRoot = new(); + + /// + /// Initializes a new instance of . + /// + /// + /// The schema definition to search. + /// + public BM25SearchProvider(ISchemaDefinition schema) + { + ArgumentNullException.ThrowIfNull(schema); + _schema = schema; + } + + /// + public ValueTask> SearchAsync( + string query, + int first, + string? after, + float? minScore, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + + if (first <= 0) + { + return new ValueTask>( + Array.Empty()); + } + + var data = EnsureIndex(); + var queryTokens = BM25Tokenizer.Tokenize(query); + var rawResults = data.Index.Search(queryTokens); + + if (rawResults.Count == 0) + { + return new ValueTask>( + Array.Empty()); + } + + // Determine the maximum raw score for normalization. + var maxRawScore = rawResults[0].Score; // Results are sorted descending. + + // Decode the cursor to determine the starting offset. + var offset = 0; + + if (after is not null) + { + offset = DecodeCursor(after); + } + + var results = new List(Math.Min(first, rawResults.Count)); + + for (var i = offset; i < rawResults.Count && results.Count < first; i++) + { + var scored = rawResults[i]; + var normalizedScore = maxRawScore > 0f ? scored.Score / maxRawScore : 0f; + + if (minScore.HasValue && normalizedScore < minScore.Value) + { + // Results are sorted by score descending, so all subsequent + // results will also be below the threshold. + break; + } + + results.Add(new SchemaSearchResult( + data.Index.GetCoordinate(scored.DocumentId), + normalizedScore, + EncodeCursor(i + 1))); + } + + return new ValueTask>(results); + } + + /// + public ValueTask> GetPathsToRootAsync( + SchemaCoordinate coordinate, + int maxPaths, + CancellationToken cancellationToken = default) + { + if (maxPaths <= 0) + { + return new ValueTask>( + Array.Empty()); + } + + var data = EnsureIndex(); + + // Determine the type name to start BFS from. + // If the coordinate has a member name, it's a field/value on a type; + // the starting type is the coordinate's Name. + // If it's a type coordinate, we start from that type directly. + var startTypeName = coordinate.Name; + + var paths = FindPathsToRoot(data, startTypeName, maxPaths); + + // Build SchemaCoordinatePath instances. + // Each path is from the target coordinate back to a root type field. + var result = new List(paths.Count); + + foreach (var path in paths) + { + var segments = new List(); + + // If the original coordinate has a member (it's a field/value), + // include it as the first segment. + if (coordinate.MemberName is not null) + { + segments.Add(coordinate); + } + + // Add the type-level coordinate for the starting type. + segments.Add(new SchemaCoordinate(startTypeName)); + + // Add intermediate hops (type.field coordinates leading to root). + foreach (var (typeName, fieldName) in path) + { + segments.Add(new SchemaCoordinate(typeName, fieldName)); + segments.Add(new SchemaCoordinate(typeName)); + } + + result.Add(new SchemaCoordinatePath(CollectionsMarshal.AsSpan(segments))); + } + + // Sort by path length (shortest first). + result.Sort(static (a, b) => a.Count.CompareTo(b.Count)); + + // Limit to maxPaths. + if (result.Count > maxPaths) + { + result.RemoveRange(maxPaths, result.Count - maxPaths); + } + + return new ValueTask>(result); + } + + private static List> FindPathsToRoot( + SearchData data, + string startTypeName, + int maxPaths) + { + var rootTypeNames = data.RootTypeNames; + var reverseMap = data.ReverseMap; + var paths = new List>(); + + // If the start type is already a root type, return a single empty path. + if (rootTypeNames.Contains(startTypeName)) + { + paths.Add([]); + return paths; + } + + // BFS: each queue entry is (currentTypeName, pathSoFar). + var queue = new Queue<(string TypeName, List Path)>(); + queue.Enqueue((startTypeName, [])); + + var visited = new HashSet(StringComparer.Ordinal) { startTypeName }; + + while (queue.Count > 0 && paths.Count < maxPaths) + { + var (currentType, currentPath) = queue.Dequeue(); + + if (!reverseMap.TryGetValue(currentType, out var references)) + { + continue; + } + + foreach (var reference in references) + { + if (!visited.Add(reference.TypeName)) + { + continue; + } + + var newPath = new List(currentPath) { reference }; + + if (rootTypeNames.Contains(reference.TypeName)) + { + paths.Add(newPath); + + if (paths.Count >= maxPaths) + { + break; + } + } + else + { + queue.Enqueue((reference.TypeName, newPath)); + } + } + } + + return paths; + } + + private SearchData EnsureIndex() + { + if (_searchData is not null) + { + return _searchData; + } + + lock (_syncRoot) + { + if (_searchData is not null) + { + return _searchData; + } + + var (documents, reverseMap) = Index(_schema); + var index = BM25Index.Build(documents); + + var rootTypeNames = new HashSet(StringComparer.Ordinal) + { + _schema.QueryType.Name + }; + + if (_schema.MutationType is not null) + { + rootTypeNames.Add(_schema.MutationType.Name); + } + + if (_schema.SubscriptionType is not null) + { + rootTypeNames.Add(_schema.SubscriptionType.Name); + } + + _searchData = new SearchData( + index, + reverseMap.ToFrozenDictionary( + kvp => kvp.Key, + kvp => kvp.Value.ToArray(), + StringComparer.Ordinal), + rootTypeNames.ToFrozenSet(StringComparer.Ordinal)); + + return _searchData; + } + } + + private static string EncodeCursor(int offset) + => Convert.ToBase64String(BitConverter.GetBytes(offset)); + + private static int DecodeCursor(string cursor) + { + try + { + var bytes = Convert.FromBase64String(cursor); + + if (bytes.Length >= 4) + { + return BitConverter.ToInt32(bytes, 0); + } + } + catch (FormatException) + { + // Invalid cursor format; start from the beginning. + } + + return 0; + } + + /// + /// Holds the lazily-built search data structures. + /// + private sealed class SearchData( + BM25Index index, + FrozenDictionary reverseMap, + FrozenSet rootTypeNames) + { + public BM25Index Index { get; } = index; + + public FrozenDictionary ReverseMap { get; } = reverseMap; + + public FrozenSet RootTypeNames { get; } = rootTypeNames; + } +} diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Tokenizer.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Tokenizer.cs new file mode 100644 index 00000000000..ab37ac23375 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Tokenizer.cs @@ -0,0 +1,113 @@ +using System.Runtime.CompilerServices; + +namespace HotChocolate.Types.Introspection; + +/// +/// Provides text tokenization for the BM25 search index. +/// Handles camelCase/PascalCase splitting and non-alphanumeric boundary splitting. +/// +internal static class BM25Tokenizer +{ + /// + /// Tokenizes the specified text into an array of lowercase tokens. + /// + /// + /// The text to tokenize. + /// + /// + /// An array of lowercase tokens extracted from the text. + /// + public static string[] Tokenize(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return []; + } + + var tokens = new List(); + var start = 0; + + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + + if (!char.IsLetterOrDigit(c)) + { + // Non-alphanumeric boundary: emit token if accumulated. + if (i > start) + { + AddCamelCaseTokens(tokens, text, start, i); + } + + start = i + 1; + continue; + } + + // Detect camelCase boundary: lowercase followed by uppercase. + if (i > start && char.IsUpper(c) && char.IsLower(text[i - 1])) + { + AddCamelCaseTokens(tokens, text, start, i); + start = i; + continue; + } + + // Detect PascalCase boundary: uppercase followed by uppercase then lowercase + // e.g., "XMLParser" -> "XML", "Parser" + if (i > start + 1 + && char.IsUpper(c) + && char.IsUpper(text[i - 1]) + && i + 1 < text.Length + && char.IsLower(text[i + 1])) + { + // Emit everything before the current uppercase as one token. + // But only if the segment from start..i-1 hasn't already been split. + // The segment start..(i-1) is the uppercase run, and 'i' starts a new word. + if (i - 1 > start) + { + EmitToken(tokens, text, start, i); + } + + start = i; + } + } + + // Emit remaining token. + if (start < text.Length) + { + AddCamelCaseTokens(tokens, text, start, text.Length); + } + + return tokens.ToArray(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void AddCamelCaseTokens( + List tokens, + string text, + int start, + int end) + { + // For simple segments that have no further camelCase boundaries, + // just emit the whole segment. + EmitToken(tokens, text, start, end); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void EmitToken( + List tokens, + string text, + int start, + int end) + { + var length = end - start; + + // Filter out single-character tokens that are not meaningful. + if (length <= 1) + { + return; + } + + var token = text.Substring(start, length).ToLowerInvariant(); + tokens.Add(token); + } +} diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs new file mode 100644 index 00000000000..d8cf4dc448c --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs @@ -0,0 +1,142 @@ +namespace HotChocolate.Types.Introspection; + +/// +/// Walks an and produces entries +/// for all indexable schema elements, along with a reverse adjacency map for path-to-root traversal. +/// +internal static class SchemaIndexer +{ + /// + /// Indexes the specified schema definition, producing documents for the BM25 index + /// and a reverse adjacency map for path-to-root queries. + /// + /// + /// The schema definition to index. + /// + /// + /// A tuple containing the list of documents and the reverse adjacency map. + /// The reverse adjacency map maps a type name to the list of (declaringTypeName, fieldName) pairs + /// where a field on the declaring type returns a value of that type. + /// + public static (List Documents, Dictionary> ReverseMap) Index( + ISchemaDefinition schema) + { + var documents = new List(); + var reverseMap = new Dictionary>(StringComparer.Ordinal); + + foreach (var type in schema.Types) + { + // Skip introspection types (names starting with "__"). + if (type.IsIntrospectionType) + { + continue; + } + + // Index the type itself. + documents.Add(new BM25Document( + new SchemaCoordinate(type.Name), + BuildText(type.Name, type.Description))); + + switch (type) + { + case IComplexTypeDefinition complexType: + IndexComplexTypeFields(complexType, documents, reverseMap); + break; + + case IEnumTypeDefinition enumType: + IndexEnumValues(enumType, documents); + break; + + case IInputObjectTypeDefinition inputObjectType: + IndexInputObjectFields(inputObjectType, documents); + break; + } + } + + // Index directive definitions. + foreach (var directive in schema.DirectiveDefinitions) + { + // Skip introspection directives. + if (directive.Name.StartsWith("__", StringComparison.Ordinal)) + { + continue; + } + + documents.Add(new BM25Document( + new SchemaCoordinate(directive.Name, ofDirective: true), + BuildText(directive.Name, directive.Description))); + } + + return (documents, reverseMap); + } + + private static void IndexComplexTypeFields( + IComplexTypeDefinition complexType, + List documents, + Dictionary> reverseMap) + { + foreach (var field in complexType.Fields) + { + // Skip introspection fields. + if (field.IsIntrospectionField) + { + continue; + } + + documents.Add(new BM25Document( + new SchemaCoordinate(complexType.Name, field.Name), + BuildText(field.Name, field.Description))); + + // Build reverse adjacency: the field's return type points back to this type. + var returnType = field.Type.NamedType(); + + if (!reverseMap.TryGetValue(returnType.Name, out var references)) + { + references = []; + reverseMap[returnType.Name] = references; + } + + references.Add(new TypeFieldReference(complexType.Name, field.Name)); + } + } + + private static void IndexEnumValues( + IEnumTypeDefinition enumType, + List documents) + { + foreach (var value in enumType.Values) + { + documents.Add(new BM25Document( + new SchemaCoordinate(enumType.Name, value.Name), + BuildText(value.Name, value.Description))); + } + } + + private static void IndexInputObjectFields( + IInputObjectTypeDefinition inputObjectType, + List documents) + { + foreach (var field in inputObjectType.Fields) + { + documents.Add(new BM25Document( + new SchemaCoordinate(inputObjectType.Name, field.Name), + BuildText(field.Name, field.Description))); + } + } + + private static string BuildText(string name, string? description) + { + if (string.IsNullOrEmpty(description)) + { + return name; + } + + return string.Concat(name, " ", description); + } + + /// + /// Represents a reference from a type's field back to that type, + /// used in the reverse adjacency map for path-to-root traversal. + /// + internal readonly record struct TypeFieldReference(string TypeName, string FieldName); +} diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/SearchResultInfo.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/SearchResultInfo.cs new file mode 100644 index 00000000000..f01f2889dc6 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/SearchResultInfo.cs @@ -0,0 +1,35 @@ +namespace HotChocolate.Types.Introspection; + +/// +/// Represents the backing data for a __SearchResult introspection type instance. +/// +internal sealed class SearchResultInfo +{ + /// + /// Gets the schema coordinate of the matched element. + /// + public required SchemaCoordinate Coordinate { get; init; } + + /// + /// Gets the resolved type system definition (e.g. , + /// , , + /// , or ). + /// + public required object Definition { get; init; } + + /// + /// Gets the paths from the matched element to a root type, + /// each serialized as a string of schema coordinates. + /// + public required IReadOnlyList PathsToRoot { get; init; } + + /// + /// Gets the relevance score of the match, or null if scoring is not supported. + /// + public float? Score { get; init; } + + /// + /// Gets the opaque cursor for pagination. + /// + public required string Cursor { get; init; } +} diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/__SchemaDefinition.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/__SchemaDefinition.cs new file mode 100644 index 00000000000..f28aa0c8e7b --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/__SchemaDefinition.cs @@ -0,0 +1,55 @@ +#pragma warning disable IDE1006 // Naming Styles +using HotChocolate.Configuration; +using HotChocolate.Resolvers; +using HotChocolate.Types.Descriptors.Configurations; +using static HotChocolate.Types.Descriptors.TypeReference; + +namespace HotChocolate.Types.Introspection; + +[Introspection] +// ReSharper disable once InconsistentNaming +internal sealed class __SchemaDefinition : UnionType +{ + protected override UnionTypeConfiguration CreateConfiguration(ITypeDiscoveryContext context) + => new(Names.__SchemaDefinition) + { + ResolveAbstractType = ResolveType, + Types = + { + Create(nameof(__Type)), + Create(nameof(__Field)), + Create(nameof(__InputValue)), + Create(nameof(__EnumValue)), + Create(nameof(__Directive)) + } + }; + + private static ObjectType? ResolveType(IResolverContext context, object resolverResult) + { + var typeName = resolverResult switch + { + EnumValue => __EnumValue.Names.__EnumValue, + DirectiveType => __Directive.Names.__Directive, + IOutputFieldDefinition => __Field.Names.__Field, + IInputValueDefinition => __InputValue.Names.__InputValue, + IType => __Type.Names.__Type, + _ => null + }; + + if (typeName is not null + && context.Schema.Types.TryGetType(typeName, out var type) + && type is ObjectType objectType) + { + return objectType; + } + + return null; + } + + public static class Names + { + // ReSharper disable once InconsistentNaming + public const string __SchemaDefinition = "__SchemaDefinition"; + } +} +#pragma warning restore IDE1006 // Naming Styles diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs new file mode 100644 index 00000000000..86d1a2bb556 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs @@ -0,0 +1,80 @@ +#pragma warning disable IDE1006 // Naming Styles +using HotChocolate.Configuration; +using HotChocolate.Resolvers; +using HotChocolate.Types.Descriptors.Configurations; +using static HotChocolate.Types.Descriptors.TypeReference; + +namespace HotChocolate.Types.Introspection; + +[Introspection] +// ReSharper disable once InconsistentNaming +internal sealed class __SearchResult : ObjectType +{ + protected override ObjectTypeConfiguration CreateConfiguration(ITypeDiscoveryContext context) + { + var nonNullStringType = Parse($"{ScalarNames.String}!"); + var floatType = Create(ScalarNames.Float); + var nonNullSchemaDefinitionType = Parse($"{nameof(__SchemaDefinition)}!"); + var nonNullStringListType = Parse($"[{ScalarNames.String}!]!"); + + return new ObjectTypeConfiguration( + Names.__SearchResult, + description: "A search result representing a matched schema element.", + typeof(SearchResultInfo)) + { + Fields = + { + new(Names.Cursor, + "An opaque cursor for pagination.", + nonNullStringType, + pureResolver: Resolvers.Cursor), + new(Names.Coordinate, + "The schema coordinate of the matched element.", + nonNullStringType, + pureResolver: Resolvers.Coordinate), + new(Names.Definition, + "The matched schema definition.", + nonNullSchemaDefinitionType, + pureResolver: Resolvers.Definition), + new(Names.PathsToRoot, + "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + nonNullStringListType, + pureResolver: Resolvers.PathsToRoot), + new(Names.Score, + "The relevance score of the match, or null if scoring is not supported.", + floatType, + pureResolver: Resolvers.Score) + } + }; + } + + private static class Resolvers + { + public static object Cursor(IResolverContext context) + => context.Parent().Cursor; + + public static object Coordinate(IResolverContext context) + => context.Parent().Coordinate.ToString(); + + public static object Definition(IResolverContext context) + => context.Parent().Definition; + + public static object PathsToRoot(IResolverContext context) + => context.Parent().PathsToRoot; + + public static object? Score(IResolverContext context) + => context.Parent().Score; + } + + public static class Names + { + // ReSharper disable once InconsistentNaming + public const string __SearchResult = "__SearchResult"; + public const string Cursor = "cursor"; + public const string Coordinate = "coordinate"; + public const string Definition = "definition"; + public const string PathsToRoot = "pathsToRoot"; + public const string Score = "score"; + } +} +#pragma warning restore IDE1006 // Naming Styles diff --git a/src/HotChocolate/Core/src/Validation/Rules/IntrospectionVisitor.cs b/src/HotChocolate/Core/src/Validation/Rules/IntrospectionVisitor.cs index 217ac4a3e2a..9bcc4f11988 100644 --- a/src/HotChocolate/Core/src/Validation/Rules/IntrospectionVisitor.cs +++ b/src/HotChocolate/Core/src/Validation/Rules/IntrospectionVisitor.cs @@ -36,8 +36,9 @@ protected override ISyntaxVisitorAction Enter( { var namedType = type.NamedType(); if (context.Schema.QueryType == namedType - && (IntrospectionFieldNames.Schema.Equals(node.Name.Value, StringComparison.Ordinal) - || IntrospectionFieldNames.Type.Equals(node.Name.Value, StringComparison.Ordinal))) + && namedType is IComplexTypeDefinition complexType + && complexType.Fields.TryGetField(node.Name.Value, out var field) + && field.IsIntrospectionField) { context.ReportError( context.IntrospectionNotAllowed( diff --git a/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/__snapshots__/StarWarsCodeFirstTests.Ensure_Benchmark_Query_LargeQuery.snap b/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/__snapshots__/StarWarsCodeFirstTests.Ensure_Benchmark_Query_LargeQuery.snap index 00dd486cac6..18aff3c18c5 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/__snapshots__/StarWarsCodeFirstTests.Ensure_Benchmark_Query_LargeQuery.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/__snapshots__/StarWarsCodeFirstTests.Ensure_Benchmark_Query_LargeQuery.snap @@ -5894,6 +5894,156 @@ ], "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "__SearchResult", + "description": "A search result representing a matched schema element.", + "fields": [ + { + "name": "cursor", + "description": "An opaque cursor for pagination.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coordinate", + "description": "The schema coordinate of the matched element.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "definition", + "description": "The matched schema definition.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "__SchemaDefinition", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pathsToRoot", + "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "score", + "description": "The relevance score of the match, or null if scoring is not supported.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "__SchemaDefinition", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + ] + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Query", @@ -6982,26 +7132,6 @@ "enumValues": null, "possibleTypes": null }, - { - "kind": "SCALAR", - "name": "Int", - "description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "SCALAR", - "name": "Float", - "description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", - "fields": null, - "inputFields": null, - "interfaces": null, - "enumValues": null, - "possibleTypes": null - }, { "kind": "ENUM", "name": "Unit", diff --git a/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs new file mode 100644 index 00000000000..05d17c9e7d9 --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs @@ -0,0 +1,1361 @@ +using HotChocolate.Types; + +namespace HotChocolate.Execution; + +public sealed class SemanticIntrospectionTests +{ + [Fact] + public async Task Search_Should_ReturnResults_When_QueryMatchesFieldName() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "user") { + coordinate + score + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "User", + "score": 1 + }, + { + "coordinate": "Query.userByEmail", + "score": 0.9113392233848572 + }, + { + "coordinate": "User.name", + "score": 0.732876718044281 + }, + { + "coordinate": "User.email", + "score": 0.732876718044281 + }, + { + "coordinate": "User.age", + "score": 0.699621856212616 + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ReturnResults_When_QueryMatchesDescription() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "email address") { + coordinate + score + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "User.email", + "score": 1 + }, + { + "coordinate": "Query.userByEmail", + "score": 0.9289592504501343 + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_RespectFirstArgument() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "product", first: 2) { + coordinate + score + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Product", + "score": 1 + }, + { + "coordinate": "Product.name", + "score": 0.827045202255249 + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_FilterByMinScore() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "product", first: 100, min_score: 0.9) { + coordinate + score + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Product", + "score": 1 + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ReturnCursors_And_SupportPagination() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // Get first page. + var firstResult = await executor.ExecuteAsync( + """ + { + __search(query: "product", first: 1) { + cursor + coordinate + } + } + """); + + var firstJson = firstResult.ToJson(); + var cursorStart = firstJson.IndexOf("\"cursor\": \"", StringComparison.Ordinal) + 11; + var cursorEnd = firstJson.IndexOf("\"", cursorStart, StringComparison.Ordinal); + var cursor = firstJson[cursorStart..cursorEnd]; + + // act - Get second page. + var secondResult = await executor.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + """ + query($after: String) { + __search(query: "product", first: 1, after: $after) { + cursor + coordinate + } + } + """) + .SetVariableValues(new Dictionary { { "after", cursor } }) + .Build()); + + // assert + firstResult.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "cursor": "AQAAAA==", + "coordinate": "Product" + } + ] + } + } + """); + + secondResult.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "cursor": "AgAAAA==", + "coordinate": "Product.name" + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ReturnEmptyList_When_NoMatches() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "xyznonexistent") { + coordinate + score + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [] + } + } + """); + } + + [Fact] + public async Task Search_Should_IncludePathsToRoot() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "product name") { + coordinate + pathsToRoot + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Product.name", + "pathsToRoot": [ + "Product.name > Product > Query.productSearch > Query" + ] + }, + { + "coordinate": "Query.productSearch", + "pathsToRoot": [ + "Query.productSearch > Query" + ] + }, + { + "coordinate": "User.name", + "pathsToRoot": [ + "User.name > User > Query.userByEmail > Query" + ] + }, + { + "coordinate": "Product", + "pathsToRoot": [ + "Product > Query.productSearch > Query" + ] + }, + { + "coordinate": "Product.category", + "pathsToRoot": [ + "Product.category > Product > Query.productSearch > Query" + ] + }, + { + "coordinate": "Product.price", + "pathsToRoot": [ + "Product.price > Product > Query.productSearch > Query" + ] + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ReturnScoresInDescendingOrder() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "product", first: 100) { + coordinate + score + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Product", + "score": 1 + }, + { + "coordinate": "Product.name", + "score": 0.827045202255249 + }, + { + "coordinate": "Product.category", + "score": 0.827045202255249 + }, + { + "coordinate": "Product.price", + "score": 0.7444983124732971 + }, + { + "coordinate": "Query.productSearch", + "score": 0.6475508809089661 + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ResolveDefinition_AsField() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "userByEmail") { + coordinate + definition { + ... on __Field { + name + description + args { + name + } + } + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Query.userByEmail", + "definition": { + "name": "userByEmail", + "description": "Retrieve a user by their email address", + "args": [ + { + "name": "email" + } + ] + } + }, + { + "coordinate": "User.email", + "definition": { + "name": "email", + "description": "The email address of the user", + "args": [] + } + }, + { + "coordinate": "User", + "definition": {} + }, + { + "coordinate": "Query.orderById", + "definition": { + "name": "orderById", + "description": "Retrieve an order by its unique identifier", + "args": [ + { + "name": "id" + } + ] + } + }, + { + "coordinate": "User.name", + "definition": { + "name": "name", + "description": "The full name of the user", + "args": [] + } + }, + { + "coordinate": "User.age", + "definition": { + "name": "age", + "description": "The age of the user in years", + "args": [] + } + }, + { + "coordinate": "Query.productSearch", + "definition": { + "name": "productSearch", + "description": "Search for products by name or category", + "args": [ + { + "name": "term" + } + ] + } + }, + { + "coordinate": "@specifiedBy", + "definition": {} + }, + { + "coordinate": "Float", + "definition": {} + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ResolveDefinition_AsType() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "Product") { + coordinate + definition { + ... on __Type { + name + kind + fields { + name + } + } + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Product", + "definition": { + "name": "Product", + "kind": "OBJECT", + "fields": [ + { + "name": "name" + }, + { + "name": "price" + }, + { + "name": "category" + } + ] + } + }, + { + "coordinate": "Product.name", + "definition": {} + }, + { + "coordinate": "Product.category", + "definition": {} + }, + { + "coordinate": "Product.price", + "definition": {} + }, + { + "coordinate": "Query.productSearch", + "definition": {} + } + ] + } + } + """); + } + + [Fact] + public async Task Definitions_Should_ResolveTypeByCoordinate() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __definitions(coordinates: ["User"]) { + ... on __Type { + name + kind + fields { + name + } + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__definitions": [ + { + "name": "User", + "kind": "OBJECT", + "fields": [ + { + "name": "name" + }, + { + "name": "email" + }, + { + "name": "age" + } + ] + } + ] + } + } + """); + } + + [Fact] + public async Task Definitions_Should_ResolveFieldByCoordinate() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __definitions(coordinates: ["Query.userByEmail"]) { + ... on __Field { + name + description + args { + name + type { + name + kind + } + } + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__definitions": [ + { + "name": "userByEmail", + "description": "Retrieve a user by their email address", + "args": [ + { + "name": "email", + "type": { + "name": null, + "kind": "NON_NULL" + } + } + ] + } + ] + } + } + """); + } + + [Fact] + public async Task Definitions_Should_ResolveMultipleCoordinates() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __definitions(coordinates: ["User", "Product", "Query.orderById"]) { + ... on __Type { + typeName: name + kind + } + ... on __Field { + fieldName: name + description + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__definitions": [ + { + "typeName": "User", + "kind": "OBJECT" + }, + { + "typeName": "Product", + "kind": "OBJECT" + }, + { + "fieldName": "orderById", + "description": "Retrieve an order by its unique identifier" + } + ] + } + } + """); + } + + [Fact] + public async Task Definitions_Should_SkipInvalidCoordinates() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __definitions(coordinates: ["User", "NonExistentType", "Query.orderById"]) { + ... on __Type { + typeName: name + } + ... on __Field { + fieldName: name + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__definitions": [ + { + "typeName": "User" + }, + { + "fieldName": "orderById" + } + ] + } + } + """); + } + + [Fact] + public async Task Definitions_Should_ResolveEnumValueByCoordinate() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __definitions(coordinates: ["OrderStatus.PENDING"]) { + ... on __EnumValue { + name + description + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__definitions": [ + { + "name": "PENDING", + "description": "Order is pending processing" + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_FindUserField_When_AskedNaturalQuestion() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "How do I look up a user by their email address?") { + coordinate + score + definition { + ... on __Field { + fieldName: name + description + } + ... on __Type { + typeName: name + kind + } + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Query.userByEmail", + "score": 1, + "definition": { + "fieldName": "userByEmail", + "description": "Retrieve a user by their email address" + } + }, + { + "coordinate": "User.email", + "score": 0.6059101819992065, + "definition": { + "fieldName": "email", + "description": "The email address of the user" + } + }, + { + "coordinate": "User", + "score": 0.19035416841506958, + "definition": { + "typeName": "User", + "kind": "OBJECT" + } + }, + { + "coordinate": "Query.orderById", + "score": 0.1684975028038025, + "definition": { + "fieldName": "orderById", + "description": "Retrieve an order by its unique identifier" + } + }, + { + "coordinate": "User.name", + "score": 0.13950614631175995, + "definition": { + "fieldName": "name", + "description": "The full name of the user" + } + }, + { + "coordinate": "User.age", + "score": 0.13317593932151794, + "definition": { + "fieldName": "age", + "description": "The age of the user in years" + } + }, + { + "coordinate": "Query.productSearch", + "score": 0.12739528715610504, + "definition": { + "fieldName": "productSearch", + "description": "Search for products by name or category" + } + }, + { + "coordinate": "@specifiedBy", + "score": 0.11778272688388824, + "definition": {} + }, + { + "coordinate": "Float", + "score": 0.07715816795825958, + "definition": { + "typeName": "Float", + "kind": "SCALAR" + } + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_FindProducts_When_AskedAboutShopping() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "I want to search for products to buy") { + coordinate + score + definition { + ... on __Field { + fieldName: name + description + } + ... on __Type { + typeName: name + kind + } + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Query.productSearch", + "score": 1, + "definition": { + "fieldName": "productSearch", + "description": "Search for products by name or category" + } + }, + { + "coordinate": "ID", + "score": 0.4061276316642761, + "definition": { + "typeName": "ID", + "kind": "SCALAR" + } + }, + { + "coordinate": "@specifiedBy", + "score": 0.354110985994339, + "definition": {} + }, + { + "coordinate": "@skip", + "score": 0.2957645654678345, + "definition": {} + }, + { + "coordinate": "@include", + "score": 0.286235511302948, + "definition": {} + }, + { + "coordinate": "Product", + "score": 0.2594583332538605, + "definition": { + "typeName": "Product", + "kind": "OBJECT" + } + }, + { + "coordinate": "@deprecated", + "score": 0.19725997745990753, + "definition": {} + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_FindOrders_When_AskedAboutOrderTracking() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "How can I track the status of my order?") { + coordinate + score + definition { + ... on __Field { + fieldName: name + description + } + ... on __Type { + typeName: name + kind + } + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "OrderStatus", + "score": 1, + "definition": { + "typeName": "OrderStatus", + "kind": "ENUM" + } + }, + { + "coordinate": "Order.status", + "score": 0.8016138672828674, + "definition": { + "fieldName": "status", + "description": "The current order status" + } + }, + { + "coordinate": "User.name", + "score": 0.28660982847213745, + "definition": { + "fieldName": "name", + "description": "The full name of the user" + } + }, + { + "coordinate": "User.email", + "score": 0.28660982847213745, + "definition": { + "fieldName": "email", + "description": "The email address of the user" + } + }, + { + "coordinate": "User", + "score": 0.2793460786342621, + "definition": { + "typeName": "User", + "kind": "OBJECT" + } + }, + { + "coordinate": "User.age", + "score": 0.27486422657966614, + "definition": { + "fieldName": "age", + "description": "The age of the user in years" + } + }, + { + "coordinate": "Order.id", + "score": 0.25921085476875305, + "definition": { + "fieldName": "id", + "description": "The unique order identifier" + } + }, + { + "coordinate": "Order.total", + "score": 0.25921085476875305, + "definition": { + "fieldName": "total", + "description": "The order total amount" + } + }, + { + "coordinate": "Order", + "score": 0.25802066922187805, + "definition": { + "typeName": "Order", + "kind": "OBJECT" + } + }, + { + "coordinate": "Query.orderById", + "score": 0.2061748057603836, + "definition": { + "fieldName": "orderById", + "description": "Retrieve an order by its unique identifier" + } + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_FindFields_When_AskedAboutPricing() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "What pricing information is available?") { + coordinate + score + definition { + ... on __Field { + fieldName: name + description + } + ... on __Type { + typeName: name + kind + } + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + PLACEHOLDER + """); + } + + [Fact] + public async Task Search_Should_NotExist_When_SemanticIntrospectionDisabled() + { + // arrange + var executor = CreateSchemaWithSemanticIntrospectionDisabled().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "user") { + coordinate + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "The field `__search` does not exist on the type `Query`.", + "locations": [ + { + "line": 2, + "column": 5 + } + ], + "extensions": { + "type": "Query", + "field": "__search", + "responseName": "__search", + "specifiedBy": "https://spec.graphql.org/September2025/#sec-Field-Selections" + } + } + ] + } + """); + } + + [Fact] + public async Task Definitions_Should_NotExist_When_SemanticIntrospectionDisabled() + { + // arrange + var executor = CreateSchemaWithSemanticIntrospectionDisabled().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __definitions(coordinates: ["User"]) { + ... on __Type { + name + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "The field `__definitions` does not exist on the type `Query`.", + "locations": [ + { + "line": 2, + "column": 5 + } + ], + "extensions": { + "type": "Query", + "field": "__definitions", + "responseName": "__definitions", + "specifiedBy": "https://spec.graphql.org/September2025/#sec-Field-Selections" + } + } + ] + } + """); + } + + private static Schema CreateSchema() + { + return SchemaBuilder.New() + .AddQueryType() + .AddType() + .AddType() + .AddType() + .AddType() + .Use(next => next) + .Create(); + } + + private static Schema CreateSchemaWithSemanticIntrospectionDisabled() + { + return SchemaBuilder.New() + .AddQueryType() + .AddType() + .AddType() + .AddType() + .AddType() + .ModifyOptions(o => o.EnableSemanticIntrospection = false) + .Use(next => next) + .Create(); + } + + // -- Test schema types -- + + private sealed class QueryType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name(OperationTypeNames.Query); + + descriptor.Field("userByEmail") + .Description("Retrieve a user by their email address") + .Argument("email", a => a.Type>()) + .Type() + .Resolve(new User("Alice", "alice@example.com", 30)); + + descriptor.Field("users") + .Description("List all users with optional filtering") + .Type>() + .Resolve(Array.Empty()); + + descriptor.Field("productSearch") + .Description("Search for products by name or category") + .Argument("term", a => a.Type>()) + .Type>() + .Resolve(Array.Empty()); + + descriptor.Field("orderById") + .Description("Retrieve an order by its unique identifier") + .Argument("id", a => a.Type>()) + .Type() + .Resolve(new Order("ORD-001", 99.99m, "PENDING")); + } + } + + private record User(string Name, string Email, int Age); + + private sealed class UserType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("User"); + descriptor.Description("A registered user of the system"); + + descriptor.Field(u => u.Name) + .Description("The full name of the user") + .Type>(); + + descriptor.Field(u => u.Email) + .Description("The email address of the user") + .Type>(); + + descriptor.Field(u => u.Age) + .Description("The age of the user in years") + .Type>(); + } + } + + private record Product(string Name, decimal Price, string Category); + + private sealed class ProductType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("Product"); + descriptor.Description("A product available for purchase"); + + descriptor.Field(p => p.Name) + .Description("The product name") + .Type>(); + + descriptor.Field(p => p.Price) + .Description("The product price in dollars") + .Type>(); + + descriptor.Field(p => p.Category) + .Description("The product category") + .Type>(); + } + } + + private record Order(string Id, decimal Total, string Status); + + private sealed class OrderType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("Order"); + descriptor.Description("A customer order"); + + descriptor.Field(o => o.Id) + .Description("The unique order identifier") + .Type>(); + + descriptor.Field(o => o.Total) + .Description("The order total amount") + .Type>(); + + descriptor.Field("status") + .Description("The current order status") + .Type() + .Resolve("PENDING"); + } + } + + private enum OrderStatus + { + Pending, + Shipped, + Delivered, + Cancelled + } + + private sealed class OrderStatusType : EnumType + { + protected override void Configure(IEnumTypeDescriptor descriptor) + { + descriptor.Name("OrderStatus"); + descriptor.Description("The status of an order"); + + descriptor.Value(OrderStatus.Pending) + .Name("PENDING") + .Description("Order is pending processing"); + + descriptor.Value(OrderStatus.Shipped) + .Name("SHIPPED") + .Description("Order has been shipped"); + + descriptor.Value(OrderStatus.Delivered) + .Name("DELIVERED") + .Description("Order has been delivered"); + + descriptor.Value(OrderStatus.Cancelled) + .Name("CANCELLED") + .Description("Order has been cancelled"); + } + } +} diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DefaultValueIsInputObject.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DefaultValueIsInputObject.snap index 0b5a37495d8..6735ca53424 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DefaultValueIsInputObject.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DefaultValueIsInputObject.snap @@ -1002,6 +1002,161 @@ ], "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "__SearchResult", + "description": "A search result representing a matched schema element.", + "specifiedByURL": null, + "fields": [ + { + "name": "cursor", + "description": "An opaque cursor for pagination.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coordinate", + "description": "The schema coordinate of the matched element.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "definition", + "description": "The matched schema definition.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "__SchemaDefinition", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pathsToRoot", + "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "score", + "description": "The relevance score of the match, or null if scoring is not supported.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "__SchemaDefinition", + "description": null, + "specifiedByURL": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + ] + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", + "specifiedByURL": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + "specifiedByURL": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Bar", diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_AllDirectives_Internal.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_AllDirectives_Internal.snap index fa795ba9b04..d03cf355ae8 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_AllDirectives_Internal.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_AllDirectives_Internal.snap @@ -181,6 +181,34 @@ } ] }, + { + "fields": [ + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + } + ] + }, + { + "fields": null + }, + { + "fields": null + }, + { + "fields": null + }, { "fields": [ { diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_AllDirectives_Public.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_AllDirectives_Public.snap index eaf6495417c..b3c26b368b0 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_AllDirectives_Public.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_AllDirectives_Public.snap @@ -181,6 +181,34 @@ } ] }, + { + "fields": [ + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + } + ] + }, + { + "fields": null + }, + { + "fields": null + }, + { + "fields": null + }, { "fields": [ { diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_SomeDirectives_Internal.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_SomeDirectives_Internal.snap index 47cb80e5baa..9766b660670 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_SomeDirectives_Internal.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DirectiveIntrospection_SomeDirectives_Internal.snap @@ -181,6 +181,34 @@ } ] }, + { + "fields": [ + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + }, + { + "appliedDirectives": [] + } + ] + }, + { + "fields": null + }, + { + "fields": null + }, + { + "fields": null + }, { "fields": [ { diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery.snap index d4f5c952ba7..2277696e8a3 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery.snap @@ -1002,6 +1002,161 @@ ], "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "__SearchResult", + "description": "A search result representing a matched schema element.", + "specifiedByURL": null, + "fields": [ + { + "name": "cursor", + "description": "An opaque cursor for pagination.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coordinate", + "description": "The schema coordinate of the matched element.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "definition", + "description": "The matched schema definition.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "__SchemaDefinition", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pathsToRoot", + "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "score", + "description": "The relevance score of the match, or null if scoring is not supported.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "__SchemaDefinition", + "description": null, + "specifiedByURL": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + ] + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", + "specifiedByURL": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + "specifiedByURL": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Query", diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery_ToJson.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery_ToJson.snap index d4f5c952ba7..2277696e8a3 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery_ToJson.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery_ToJson.snap @@ -1002,6 +1002,161 @@ ], "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "__SearchResult", + "description": "A search result representing a matched schema element.", + "specifiedByURL": null, + "fields": [ + { + "name": "cursor", + "description": "An opaque cursor for pagination.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coordinate", + "description": "The schema coordinate of the matched element.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "definition", + "description": "The matched schema definition.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "__SchemaDefinition", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pathsToRoot", + "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "score", + "description": "The relevance score of the match, or null if scoring is not supported.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "__SchemaDefinition", + "description": null, + "specifiedByURL": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + ] + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", + "specifiedByURL": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + "specifiedByURL": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Query", diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_ClrType_InferSchemaTypes.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_ClrType_InferSchemaTypes.snap index 6afcbcd9ee4..37fccfc1223 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_ClrType_InferSchemaTypes.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_ClrType_InferSchemaTypes.snap @@ -72,6 +72,39 @@ "EnumType" ] }, + { + "type": "HotChocolate.Types.Introspection.__SearchResult", + "runtimeType": "HotChocolate.Types.Introspection.SearchResultInfo", + "references": [ + "HotChocolate.Types.Introspection.__SearchResult (Output)", + "__SearchResult (Output)", + "ObjectType (Output)" + ] + }, + { + "type": "HotChocolate.Types.Introspection.__SchemaDefinition", + "runtimeType": "System.Object", + "references": [ + "HotChocolate.Types.Introspection.__SchemaDefinition (Output)", + "__SchemaDefinition (Output)" + ] + }, + { + "type": "HotChocolate.Types.IntType", + "runtimeType": "System.Int32", + "references": [ + "HotChocolate.Types.IntType", + "IntType" + ] + }, + { + "type": "HotChocolate.Types.FloatType", + "runtimeType": "System.Double", + "references": [ + "HotChocolate.Types.FloatType", + "FloatType" + ] + }, { "type": "HotChocolate.Types.SkipDirectiveType", "runtimeType": "System.Object", @@ -157,6 +190,9 @@ "ISchemaDefinition (Output)": "HotChocolate.Types.Introspection.__Schema (Output)", "IType (Output)": "HotChocolate.Types.Introspection.__Type (Output)", "TypeKind!": "HotChocolate.Types.Introspection.__TypeKind", + "SearchResultInfo (Output)": "HotChocolate.Types.Introspection.__SearchResult (Output)", + "Int32!": "HotChocolate.Types.IntType", + "Double!": "HotChocolate.Types.FloatType", "DeprecatedDirective": "HotChocolate.Types.DeprecatedDirectiveType", "String": "HotChocolate.Types.StringType", "Boolean!": "HotChocolate.Types.BooleanType", diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_SchemaType_ClrTypeExists.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_SchemaType_ClrTypeExists.snap index c2fe8f54ab4..016c8a61292 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_SchemaType_ClrTypeExists.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_SchemaType_ClrTypeExists.snap @@ -72,6 +72,39 @@ "EnumType" ] }, + { + "type": "HotChocolate.Types.Introspection.__SearchResult", + "runtimeType": "HotChocolate.Types.Introspection.SearchResultInfo", + "references": [ + "HotChocolate.Types.Introspection.__SearchResult (Output)", + "__SearchResult (Output)", + "ObjectType (Output)" + ] + }, + { + "type": "HotChocolate.Types.Introspection.__SchemaDefinition", + "runtimeType": "System.Object", + "references": [ + "HotChocolate.Types.Introspection.__SchemaDefinition (Output)", + "__SchemaDefinition (Output)" + ] + }, + { + "type": "HotChocolate.Types.IntType", + "runtimeType": "System.Int32", + "references": [ + "HotChocolate.Types.IntType", + "IntType" + ] + }, + { + "type": "HotChocolate.Types.FloatType", + "runtimeType": "System.Double", + "references": [ + "HotChocolate.Types.FloatType", + "FloatType" + ] + }, { "type": "HotChocolate.Types.SkipDirectiveType", "runtimeType": "System.Object", @@ -159,6 +192,9 @@ "ISchemaDefinition (Output)": "HotChocolate.Types.Introspection.__Schema (Output)", "IType (Output)": "HotChocolate.Types.Introspection.__Type (Output)", "TypeKind!": "HotChocolate.Types.Introspection.__TypeKind", + "SearchResultInfo (Output)": "HotChocolate.Types.Introspection.__SearchResult (Output)", + "Int32!": "HotChocolate.Types.IntType", + "Double!": "HotChocolate.Types.FloatType", "DeprecatedDirective": "HotChocolate.Types.DeprecatedDirectiveType", "Foo (Output)": "HotChocolate.Configuration.TypeDiscovererTests+FooType (Output)", "String": "HotChocolate.Types.StringType", diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeInitializerTests.Register_ClrType_InferSchemaTypes.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeInitializerTests.Register_ClrType_InferSchemaTypes.snap index dd3809a7703..ce34297b0e0 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeInitializerTests.Register_ClrType_InferSchemaTypes.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeInitializerTests.Register_ClrType_InferSchemaTypes.snap @@ -3,7 +3,9 @@ "__schema": "__Schema!", "__type": "__Type", "__typename": "String!", - "bar": "Bar!" + "bar": "Bar!", + "__search": "[__SearchResult!]!", + "__definitions": "[__SchemaDefinition!]!" }, "barType": { "__typename": "String!", diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeInitializerTests.Register_SchemaType_ClrTypeExists.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeInitializerTests.Register_SchemaType_ClrTypeExists.snap index dd3809a7703..ce34297b0e0 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeInitializerTests.Register_SchemaType_ClrTypeExists.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeInitializerTests.Register_SchemaType_ClrTypeExists.snap @@ -3,7 +3,9 @@ "__schema": "__Schema!", "__type": "__Type", "__typename": "String!", - "bar": "Bar!" + "bar": "Bar!", + "__search": "[__SearchResult!]!", + "__definitions": "[__SchemaDefinition!]!" }, "barType": { "__typename": "String!", diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25IndexTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25IndexTests.cs new file mode 100644 index 00000000000..090b141098b --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25IndexTests.cs @@ -0,0 +1,209 @@ +namespace HotChocolate.Types.Introspection; + +public class BM25IndexTests +{ + [Fact] + public void Build_Should_CreateIndex_When_DocumentsProvided() + { + // arrange + var documents = new List + { + new(new SchemaCoordinate("Product"), "Product A physical item for sale"), + new(new SchemaCoordinate("Order"), "Order A customer purchase"), + new(new SchemaCoordinate("Customer"), "Customer A person who buys") + }; + + // act + var index = BM25Index.Build(documents); + + // assert + Assert.Equal(3, index.DocumentCount); + } + + [Fact] + public void Build_Should_CreateEmptyIndex_When_NoDocuments() + { + // act + var index = BM25Index.Build([]); + + // assert + Assert.Equal(0, index.DocumentCount); + } + + [Fact] + public void GetCoordinate_Should_ReturnCorrectCoordinate_When_ValidDocId() + { + // arrange + var coordinate = new SchemaCoordinate("Product"); + var documents = new List + { + new(coordinate, "Product") + }; + var index = BM25Index.Build(documents); + + // act + var result = index.GetCoordinate(0); + + // assert + Assert.Equal(coordinate, result); + } + + [Fact] + public void Search_Should_ReturnEmpty_When_NoTokensProvided() + { + // arrange + var documents = new List + { + new(new SchemaCoordinate("Product"), "Product item") + }; + var index = BM25Index.Build(documents); + + // act + var results = index.Search([]); + + // assert + Assert.Empty(results); + } + + [Fact] + public void Search_Should_ReturnEmpty_When_IndexIsEmpty() + { + // arrange + var index = BM25Index.Build([]); + + // act + var results = index.Search(["product"]); + + // assert + Assert.Empty(results); + } + + [Fact] + public void Search_Should_ReturnEmpty_When_NoMatchingTokens() + { + // arrange + var documents = new List + { + new(new SchemaCoordinate("Product"), "Product item") + }; + var index = BM25Index.Build(documents); + + // act + var results = index.Search(["nonexistent"]); + + // assert + Assert.Empty(results); + } + + [Fact] + public void Search_Should_ReturnMatchingDocument_When_TokenMatches() + { + // arrange + var documents = new List + { + new(new SchemaCoordinate("Product"), "Product item for sale"), + new(new SchemaCoordinate("Order"), "Order purchase record") + }; + var index = BM25Index.Build(documents); + + // act + var results = index.Search(["product"]); + + // assert + Assert.Single(results); + Assert.Equal(0, results[0].DocumentId); + Assert.True(results[0].Score > 0f); + } + + [Fact] + public void Search_Should_RankByRelevance_When_MultipleMatches() + { + // arrange + var documents = new List + { + new(new SchemaCoordinate("Product"), "Product item for sale"), + new(new SchemaCoordinate("ProductReview"), "Product review feedback on product"), + new(new SchemaCoordinate("Order"), "Order purchase record") + }; + var index = BM25Index.Build(documents); + + // act + var results = index.Search(["product"]); + + // assert + Assert.Equal(2, results.Count); + // The document with higher term frequency for "product" should score higher. + Assert.True(results[0].Score >= results[1].Score); + } + + [Fact] + public void Search_Should_SortDescending_When_MultipleMatches() + { + // arrange + var documents = new List + { + new(new SchemaCoordinate("A"), "common word here"), + new(new SchemaCoordinate("B"), "rare unique specialized term"), + new(new SchemaCoordinate("C"), "common word common word") + }; + var index = BM25Index.Build(documents); + + // act + var results = index.Search(["common", "word"]); + + // assert + Assert.True(results.Count >= 1); + + for (var i = 1; i < results.Count; i++) + { + Assert.True(results[i - 1].Score >= results[i].Score); + } + } + + [Fact] + public void Search_Should_ScoreHigher_When_TermIsRare() + { + // arrange - "rare" appears in only 1 doc, "common" appears in all + var documents = new List + { + new(new SchemaCoordinate("A"), "common shared"), + new(new SchemaCoordinate("B"), "common shared"), + new(new SchemaCoordinate("C"), "common rare unique") + }; + var index = BM25Index.Build(documents); + + // act + var rareResults = index.Search(["rare"]); + var commonResults = index.Search(["common"]); + + // assert + // "rare" has higher IDF, so a single match for "rare" should have higher + // score than a single match for "common" (all else being equal). + Assert.Single(rareResults); + Assert.Equal(3, commonResults.Count); + + // The rare term match should score higher than the best common match. + Assert.True(rareResults[0].Score > commonResults[0].Score); + } + + [Fact] + public void Search_Should_HandleMultipleQueryTokens() + { + // arrange + var documents = new List + { + new(new SchemaCoordinate("ProductReview"), "product review feedback"), + new(new SchemaCoordinate("Product"), "product item"), + new(new SchemaCoordinate("Review"), "review feedback rating") + }; + var index = BM25Index.Build(documents); + + // act + var results = index.Search(["product", "review"]); + + // assert + // The document matching both tokens should score highest. + Assert.True(results.Count >= 1); + Assert.Equal(0, results[0].DocumentId); // ProductReview matches both terms. + } +} diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs new file mode 100644 index 00000000000..f7695020603 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs @@ -0,0 +1,361 @@ +namespace HotChocolate.Types.Introspection; + +public class BM25SearchProviderTests +{ + [Fact] + public async Task SearchAsync_Should_ReturnResults_When_QueryMatchesField() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var results = await provider.SearchAsync("product", first: 10, after: null, minScore: null); + + // assert + Assert.NotEmpty(results); + Assert.All(results, r => + { + Assert.True(r.Score >= 0f && r.Score <= 1f); + Assert.NotNull(r.Cursor); + }); + } + + [Fact] + public async Task SearchAsync_Should_ReturnEmpty_When_NoMatch() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var results = await provider.SearchAsync("xyznonexistent", first: 10, after: null, minScore: null); + + // assert + Assert.Empty(results); + } + + [Fact] + public async Task SearchAsync_Should_ReturnEmpty_When_FirstIsZero() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var results = await provider.SearchAsync("product", first: 0, after: null, minScore: null); + + // assert + Assert.Empty(results); + } + + [Fact] + public async Task SearchAsync_Should_ReturnEmpty_When_FirstIsNegative() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var results = await provider.SearchAsync("product", first: -1, after: null, minScore: null); + + // assert + Assert.Empty(results); + } + + [Fact] + public async Task SearchAsync_Should_LimitResults_When_FirstIsSmall() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var results = await provider.SearchAsync("product", first: 1, after: null, minScore: null); + + // assert + Assert.Single(results); + } + + [Fact] + public async Task SearchAsync_Should_NormalizeScores_When_ResultsReturned() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var results = await provider.SearchAsync("product", first: 10, after: null, minScore: null); + + // assert + Assert.NotEmpty(results); + // The first result should have the highest score, normalized to 1.0. + Assert.Equal(1.0f, results[0].Score); + + // All scores should be in [0, 1]. + Assert.All(results, r => Assert.InRange(r.Score!.Value, 0f, 1f)); + } + + [Fact] + public async Task SearchAsync_Should_FilterByMinScore_When_MinScoreProvided() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var results = await provider.SearchAsync("product", first: 100, after: null, minScore: 0.5f); + + // assert + Assert.All(results, r => Assert.True(r.Score >= 0.5f)); + } + + [Fact] + public async Task SearchAsync_Should_Paginate_When_AfterCursorProvided() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // Get first page. + var firstPage = await provider.SearchAsync("product", first: 1, after: null, minScore: null); + + // act - Get second page using cursor. + var secondPage = await provider.SearchAsync( + "product", first: 1, after: firstPage[0].Cursor, minScore: null); + + // assert + if (secondPage.Count > 0) + { + // The second page result should be different from the first. + Assert.NotEqual(firstPage[0].Coordinate, secondPage[0].Coordinate); + } + } + + [Fact] + public async Task SearchAsync_Should_SortByScoreDescending() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var results = await provider.SearchAsync("product", first: 100, after: null, minScore: null); + + // assert + for (var i = 1; i < results.Count; i++) + { + Assert.True(results[i - 1].Score >= results[i].Score); + } + } + + [Fact] + public async Task SearchAsync_Should_BeThreadSafe_When_CalledConcurrently() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act - Trigger concurrent index builds. + var tasks = Enumerable.Range(0, 10) + .Select(_ => provider.SearchAsync("product", first: 10, after: null, minScore: null).AsTask()) + .ToArray(); + + var allResults = await Task.WhenAll(tasks); + + // assert - All should return the same results. + for (var i = 1; i < allResults.Length; i++) + { + Assert.Equal(allResults[0].Count, allResults[i].Count); + } + } + + [Fact] + public async Task GetPathsToRootAsync_Should_ReturnEmptyPath_When_CoordinateIsRootType() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var paths = await provider.GetPathsToRootAsync( + new SchemaCoordinate("Query"), maxPaths: 5); + + // assert + Assert.NotEmpty(paths); + // Path from root to itself should be short (just the type coordinate). + Assert.True(paths[0].Count <= 1); + } + + [Fact] + public async Task GetPathsToRootAsync_Should_ReturnPath_When_TypeIsReachableFromRoot() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var paths = await provider.GetPathsToRootAsync( + new SchemaCoordinate("Product"), maxPaths: 5); + + // assert + Assert.NotEmpty(paths); + // The path should contain at least the Product type. + Assert.True(paths[0].Count >= 1); + } + + [Fact] + public async Task GetPathsToRootAsync_Should_IncludeFieldCoordinate_When_CoordinateHasMember() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var paths = await provider.GetPathsToRootAsync( + new SchemaCoordinate("Product", "name"), maxPaths: 5); + + // assert + Assert.NotEmpty(paths); + // The path should start with the field coordinate. + Assert.Equal(new SchemaCoordinate("Product", "name"), paths[0][0]); + } + + [Fact] + public async Task GetPathsToRootAsync_Should_ReturnEmpty_When_MaxPathsIsZero() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var paths = await provider.GetPathsToRootAsync( + new SchemaCoordinate("Product"), maxPaths: 0); + + // assert + Assert.Empty(paths); + } + + [Fact] + public async Task GetPathsToRootAsync_Should_ReturnSortedByLength() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var paths = await provider.GetPathsToRootAsync( + new SchemaCoordinate("Product"), maxPaths: 10); + + // assert + for (var i = 1; i < paths.Count; i++) + { + Assert.True(paths[i - 1].Count <= paths[i].Count); + } + } + + [Fact] + public async Task GetPathsToRootAsync_Should_LimitResults_When_MaxPathsIsSmall() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act + var paths = await provider.GetPathsToRootAsync( + new SchemaCoordinate("Product"), maxPaths: 1); + + // assert + Assert.True(paths.Count <= 1); + } + + [Fact] + public async Task SearchAsync_Should_ThrowArgumentNullException_When_QueryIsNull() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act & assert + await Assert.ThrowsAsync( + () => provider.SearchAsync(null!, first: 10, after: null, minScore: null).AsTask()); + } + + [Fact] + public void Constructor_Should_ThrowArgumentNullException_When_SchemaIsNull() + { + // act & assert + Assert.Throws(() => new BM25SearchProvider(null!)); + } + + private static Schema CreateTestSchema() + { + return SchemaBuilder.New() + .AddQueryType(d => d + .Name("Query") + .Field("product") + .Description("Gets a product") + .Type() + .Resolve(new Product("Test", 9.99m)) + .Argument("id", a => a.Type>())) + .AddType() + .AddType() + .AddType() + .ModifyOptions(o => + { + o.StrictValidation = false; + o.EnableSemanticIntrospection = false; + }) + .Create(); + } + + private record Product(string Name, decimal Price); + + private sealed class ProductType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("Product"); + descriptor.Description("A product in the store"); + descriptor.Field(p => p.Name) + .Description("The product name") + .Type>(); + descriptor.Field(p => p.Price) + .Description("The product price") + .Type>(); + descriptor.Field("category") + .Type() + .Resolve(new Category("Electronics")); + } + } + + private record Category(string Name); + + private sealed class CategoryType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("Category"); + descriptor.Description("A product category"); + descriptor.Field(c => c.Name) + .Description("The category name") + .Type>(); + } + } + + private enum ProductStatus + { + Active, + Discontinued + } + + private sealed class ProductStatusType : EnumType + { + protected override void Configure(IEnumTypeDescriptor descriptor) + { + descriptor.Name("ProductStatus"); + descriptor.Value(ProductStatus.Active).Name("ACTIVE"); + descriptor.Value(ProductStatus.Discontinued).Name("DISCONTINUED"); + } + } +} diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25TokenizerTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25TokenizerTests.cs new file mode 100644 index 00000000000..c1304cd368a --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25TokenizerTests.cs @@ -0,0 +1,134 @@ +namespace HotChocolate.Types.Introspection; + +public class BM25TokenizerTests +{ + [Fact] + public void Tokenize_Should_ReturnEmpty_When_InputIsNull() + { + // act + var tokens = BM25Tokenizer.Tokenize(null!); + + // assert + Assert.Empty(tokens); + } + + [Fact] + public void Tokenize_Should_ReturnEmpty_When_InputIsEmpty() + { + // act + var tokens = BM25Tokenizer.Tokenize(string.Empty); + + // assert + Assert.Empty(tokens); + } + + [Fact] + public void Tokenize_Should_ReturnEmpty_When_InputIsWhitespace() + { + // act + var tokens = BM25Tokenizer.Tokenize(" "); + + // assert + Assert.Empty(tokens); + } + + [Fact] + public void Tokenize_Should_ReturnLowercaseToken_When_SimpleWord() + { + // act + var tokens = BM25Tokenizer.Tokenize("Product"); + + // assert + Assert.Equal(["product"], tokens); + } + + [Fact] + public void Tokenize_Should_SplitCamelCase_When_CamelCaseInput() + { + // act + var tokens = BM25Tokenizer.Tokenize("productName"); + + // assert + Assert.Equal(["product", "name"], tokens); + } + + [Fact] + public void Tokenize_Should_SplitPascalCase_When_PascalCaseInput() + { + // act + var tokens = BM25Tokenizer.Tokenize("ProductName"); + + // assert + Assert.Equal(["product", "name"], tokens); + } + + [Fact] + public void Tokenize_Should_SplitOnNonAlphanumeric_When_SpaceSeparated() + { + // act + var tokens = BM25Tokenizer.Tokenize("product name description"); + + // assert + Assert.Equal(["product", "name", "description"], tokens); + } + + [Fact] + public void Tokenize_Should_FilterSingleCharTokens() + { + // act + var tokens = BM25Tokenizer.Tokenize("a product b"); + + // assert + Assert.Equal(["product"], tokens); + } + + [Fact] + public void Tokenize_Should_HandleMixedSeparators() + { + // act + var tokens = BM25Tokenizer.Tokenize("get_product-info"); + + // assert + Assert.Equal(["get", "product", "info"], tokens); + } + + [Fact] + public void Tokenize_Should_SplitAcronymBoundary_When_XMLParser() + { + // act + var tokens = BM25Tokenizer.Tokenize("XMLParser"); + + // assert + Assert.Equal(["xml", "parser"], tokens); + } + + [Fact] + public void Tokenize_Should_HandleAllUppercase() + { + // act + var tokens = BM25Tokenizer.Tokenize("ID"); + + // assert + Assert.Equal(["id"], tokens); + } + + [Fact] + public void Tokenize_Should_HandleMultipleCamelCaseWords() + { + // act + var tokens = BM25Tokenizer.Tokenize("getProductNameById"); + + // assert + Assert.Equal(["get", "product", "name", "by", "id"], tokens); + } + + [Fact] + public void Tokenize_Should_HandleDescriptionText() + { + // act + var tokens = BM25Tokenizer.Tokenize("The product name field"); + + // assert + Assert.Equal(["the", "product", "name", "field"], tokens); + } +} diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs new file mode 100644 index 00000000000..9f7bbf003af --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs @@ -0,0 +1,266 @@ +namespace HotChocolate.Types.Introspection; + +public class SchemaIndexerTests +{ + [Fact] + public void Index_Should_IndexTypeNames() + { + // arrange + var schema = CreateSchema(d => d + .Name("Query") + .Field("hello") + .Type() + .Resolve("world")); + + // act + var (documents, _) = SchemaIndexer.Index(schema); + + // assert + Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("Query")); + } + + [Fact] + public void Index_Should_IndexFieldNames() + { + // arrange + var schema = CreateSchema(d => d + .Name("Query") + .Field("productName") + .Type() + .Resolve("test")); + + // act + var (documents, _) = SchemaIndexer.Index(schema); + + // assert + Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("Query", "productName")); + } + + [Fact] + public void Index_Should_SkipIntrospectionTypes() + { + // arrange + var schema = CreateSchema(d => d + .Name("Query") + .Field("hello") + .Type() + .Resolve("world")); + + // act + var (documents, _) = SchemaIndexer.Index(schema); + + // assert + Assert.DoesNotContain(documents, + d => d.Coordinate.Name.StartsWith("__")); + } + + [Fact] + public void Index_Should_IndexEnumValues() + { + // arrange + var schema = SchemaBuilder.New() + .AddQueryType(d => d + .Name("Query") + .Field("status") + .Type() + .Resolve(Status.Active)) + .ModifyOptions(o => o.EnableSemanticIntrospection = false) + .Create(); + + // act + var (documents, _) = SchemaIndexer.Index(schema); + + // assert + Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("Status", "ACTIVE")); + Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("Status", "INACTIVE")); + } + + [Fact] + public void Index_Should_IndexInputObjectFields() + { + // arrange + var schema = SchemaBuilder.New() + .AddQueryType(d => d + .Name("Query") + .Field("search") + .Argument("input", a => a.Type()) + .Type() + .Resolve("result")) + .ModifyOptions(o => o.EnableSemanticIntrospection = false) + .Create(); + + // act + var (documents, _) = SchemaIndexer.Index(schema); + + // assert + Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("ProductFilterInput")); + Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("ProductFilterInput", "name")); + } + + [Fact] + public void Index_Should_IndexDirectiveDefinitions() + { + // arrange + var schema = SchemaBuilder.New() + .AddQueryType(d => d + .Name("Query") + .Field("hello") + .Type() + .Resolve("world")) + .AddDirectiveType() + .ModifyOptions(o => o.EnableSemanticIntrospection = false) + .Create(); + + // act + var (documents, _) = SchemaIndexer.Index(schema); + + // assert + Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("cached", ofDirective: true)); + } + + [Fact] + public void Index_Should_BuildReverseAdjacencyMap() + { + // arrange + var schema = SchemaBuilder.New() + .AddQueryType(d => d + .Name("Query") + .Field("product") + .Type() + .Resolve(new Product("Test", 9.99m))) + .ModifyOptions(o => o.EnableSemanticIntrospection = false) + .Create(); + + // act + var (_, reverseMap) = SchemaIndexer.Index(schema); + + // assert + // Product is returned by Query.product, so reverse map should map Product -> Query + Assert.True(reverseMap.ContainsKey("Product")); + + var references = reverseMap["Product"]; + Assert.Contains(references, r => r.TypeName == "Query" && r.FieldName == "product"); + } + + [Fact] + public void Index_Should_IncludeDescriptionInText() + { + // arrange + var schema = CreateSchema(d => d + .Name("Query") + .Field("product") + .Description("Gets a product by ID") + .Type() + .Resolve("test")); + + // act + var (documents, _) = SchemaIndexer.Index(schema); + + // assert + var fieldDoc = documents.First(d => d.Coordinate == new SchemaCoordinate("Query", "product")); + Assert.Contains("product", fieldDoc.Text); + Assert.Contains("Gets a product by ID", fieldDoc.Text); + } + + [Fact] + public void Index_Should_IndexInterfaceTypeFields() + { + // arrange + var schema = SchemaBuilder.New() + .AddQueryType(d => d + .Name("Query") + .Field("node") + .Type() + .Resolve(new NodeImpl { Id = "1" })) + .AddType() + .AddType() + .ModifyOptions(o => o.EnableSemanticIntrospection = false) + .Create(); + + // act + var (documents, _) = SchemaIndexer.Index(schema); + + // assert + Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("Node", "id")); + } + + private static Schema CreateSchema(Action configure) + { + return SchemaBuilder.New() + .AddQueryType(configure) + .ModifyOptions(o => o.EnableSemanticIntrospection = false) + .Create(); + } + + // -- Test helper types -- + + private enum Status + { + Active, + Inactive + } + + private sealed class StatusType : EnumType + { + protected override void Configure(IEnumTypeDescriptor descriptor) + { + descriptor.Value(Status.Active).Name("ACTIVE"); + descriptor.Value(Status.Inactive).Name("INACTIVE"); + } + } + + private sealed class ProductFilterInputType : InputObjectType + { + protected override void Configure(IInputObjectTypeDescriptor descriptor) + { + descriptor.Name("ProductFilterInput"); + descriptor.Field("name").Type(); + descriptor.Field("minPrice").Type(); + } + } + + private sealed class CachedDirectiveType : DirectiveType + { + protected override void Configure(IDirectiveTypeDescriptor descriptor) + { + descriptor.Name("cached"); + descriptor.Location(DirectiveLocation.Field); + } + } + + private record Product(string Name, decimal Price); + + private sealed class ProductType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("Product"); + descriptor.Field(p => p.Name).Type(); + descriptor.Field(p => p.Price).Type(); + } + } + + private sealed class NodeImpl + { + public string Id { get; set; } = default!; + } + + private sealed class NodeType : InterfaceType + { + protected override void Configure(IInterfaceTypeDescriptor descriptor) + { + descriptor.Name("Node"); + descriptor.Field("id").Type>(); + } + } + + private sealed class NodeImplType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Name("NodeImpl"); + descriptor.Implements(); + descriptor.Field(n => n.Id).Type>(); + } + } +} diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/Issue6970ReproTests.Schema_With_IDictionary_String_Object_Output_Field_Builds.snap b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/Issue6970ReproTests.Schema_With_IDictionary_String_Object_Output_Field_Builds.snap index 6a3aeea4f77..31de6aeeea7 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/Issue6970ReproTests.Schema_With_IDictionary_String_Object_Output_Field_Builds.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/Issue6970ReproTests.Schema_With_IDictionary_String_Object_Output_Field_Builds.snap @@ -10,6 +10,12 @@ type Query { item: PageVOAllergyIntolerance! } +"The purpose of the `cost` directive is to define a `weight` for GraphQL types, fields, and arguments. Static analysis can use these weights when calculating the overall cost of a query or response." +directive @cost( + "The `weight` argument defines what value to add to the overall cost for every appearance, or possible appearance, of a type, field, argument, etc." + weight: String! +) on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM | INPUT_FIELD_DEFINITION + "The `@specifiedBy` directive is used within the type system definition language to provide a URL for specifying the behavior of custom scalar definitions." directive @specifiedBy( "The specifiedBy URL points to a human-readable specification. This field will only read a result for scalar types." diff --git a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/CodeFirstTests.Allow_PascalCasedArguments_Schema.graphql b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/CodeFirstTests.Allow_PascalCasedArguments_Schema.graphql index c71506fb370..f932fa85f71 100644 --- a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/CodeFirstTests.Allow_PascalCasedArguments_Schema.graphql +++ b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/CodeFirstTests.Allow_PascalCasedArguments_Schema.graphql @@ -5,3 +5,9 @@ schema { type PascalCaseQuery { testResolver(testArgument: String!): String! } + +"The purpose of the `cost` directive is to define a `weight` for GraphQL types, fields, and arguments. Static analysis can use these weights when calculating the overall cost of a query or response." +directive @cost( + "The `weight` argument defines what value to add to the overall cost for every appearance, or possible appearance, of a type, field, argument, etc." + weight: String! +) on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM | INPUT_FIELD_DEFINITION diff --git a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.DescriptionsAreCorrectlyRead.snap b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.DescriptionsAreCorrectlyRead.snap index 9571da418cf..a1019f15196 100644 --- a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.DescriptionsAreCorrectlyRead.snap +++ b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.DescriptionsAreCorrectlyRead.snap @@ -989,6 +989,156 @@ ], "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "__SearchResult", + "description": "A search result representing a matched schema element.", + "fields": [ + { + "name": "coordinate", + "description": "The schema coordinate of the matched element.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cursor", + "description": "An opaque cursor for pagination.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "definition", + "description": "The matched schema definition.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "__SchemaDefinition", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pathsToRoot", + "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "score", + "description": "The relevance score of the match, or null if scoring is not supported.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "__SchemaDefinition", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + ] + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Query", diff --git a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.Interfaces_Impl_Interfaces_Are_Correctly_Exposed_Through_Introspection.snap b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.Interfaces_Impl_Interfaces_Are_Correctly_Exposed_Through_Introspection.snap index 183cb8c1d67..a8f5387a74a 100644 --- a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.Interfaces_Impl_Interfaces_Are_Correctly_Exposed_Through_Introspection.snap +++ b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.Interfaces_Impl_Interfaces_Are_Correctly_Exposed_Through_Introspection.snap @@ -989,6 +989,156 @@ ], "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "__SearchResult", + "description": "A search result representing a matched schema element.", + "fields": [ + { + "name": "cursor", + "description": "An opaque cursor for pagination.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coordinate", + "description": "The schema coordinate of the matched element.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "definition", + "description": "The matched schema definition.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "__SchemaDefinition", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pathsToRoot", + "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "score", + "description": "The relevance score of the match, or null if scoring is not supported.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "__SchemaDefinition", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + ] + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Query", diff --git a/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs b/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs index 2a022336c3c..465be9e6736 100644 --- a/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs +++ b/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs @@ -102,6 +102,7 @@ private static Schema CreateSchema() => SchemaBuilder.New() .AddDocumentFromString( FileResource.Open("IntrospectionSchema.graphql")) + .ModifyOptions(o => o.EnableSemanticIntrospection = false) .Use(_ => _ => default) .Create(); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs index 2db769da056..9618e26ea61 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaBuilder.cs @@ -71,7 +71,12 @@ private static CompositeSchemaBuilderContext CreateTypes( } } - foreach (var definition in IntrospectionSchema.Document.Definitions.Concat(schemaDocument.Definitions)) + var introspectionDefinitions = options.EnableSemanticIntrospection + ? IntrospectionSchema.Document.Definitions + .Concat(SemanticIntrospectionSchema.Document.Definitions) + : IntrospectionSchema.Document.Definitions.AsEnumerable(); + + foreach (var definition in introspectionDefinitions.Concat(schemaDocument.Definitions)) { if (definition is IHasName namedSyntaxNode && (FusionBuiltIns.IsBuiltInType(namedSyntaxNode.Name.Value) @@ -85,7 +90,8 @@ private static CompositeSchemaBuilderContext CreateTypes( case ObjectTypeDefinitionNode objectType: var type = CreateObjectType( objectType, - objectType.Name.Value.Equals(queryType, StringComparison.Ordinal)); + objectType.Name.Value.Equals(queryType, StringComparison.Ordinal), + options.EnableSemanticIntrospection); types.Add(type); typeDefinitions.Add(objectType.Name.Value, objectType); break; @@ -164,7 +170,8 @@ private static CompositeTypeInterceptor CreateTypeInterceptor(IServiceProvider s private static FusionObjectTypeDefinition CreateObjectType( ObjectTypeDefinitionNode definition, - bool isQuery) + bool isQuery, + bool enableSemanticIntrospection) { var isInaccessible = InaccessibleDirectiveParser.Parse(definition.Directives); @@ -172,7 +179,7 @@ private static FusionObjectTypeDefinition CreateObjectType( definition.Name.Value, definition.Description?.Value, isInaccessible, - CreateOutputFields(definition.Fields, isQuery)); + CreateOutputFields(definition.Fields, isQuery, enableSemanticIntrospection)); } private static FusionInterfaceTypeDefinition CreateInterfaceType( @@ -184,7 +191,7 @@ private static FusionInterfaceTypeDefinition CreateInterfaceType( definition.Name.Value, definition.Description?.Value, isInaccessible, - CreateOutputFields(definition.Fields, false)); + CreateOutputFields(definition.Fields, isQuery: false, enableSemanticIntrospection: false)); } private static FusionUnionTypeDefinition CreateUnionType( @@ -246,14 +253,23 @@ private static FusionDirectiveDefinition CreateDirectiveType( private static FusionOutputFieldDefinitionCollection CreateOutputFields( IReadOnlyList fields, - bool isQuery) + bool isQuery, + bool enableSemanticIntrospection) { - var size = isQuery ? fields.Count + 3 : fields.Count; + var introspectionFieldCount = isQuery ? 3 : 0; + if (isQuery && enableSemanticIntrospection) + { + introspectionFieldCount += 2; // __search and __definitions + } + + var size = fields.Count + introspectionFieldCount; var sourceFields = new FusionOutputFieldDefinition[size]; if (isQuery) { - sourceFields[0] = new FusionOutputFieldDefinition( + var fieldIndex = 0; + + sourceFields[fieldIndex++] = new FusionOutputFieldDefinition( IntrospectionFieldNames.Schema, null, isDeprecated: false, @@ -261,7 +277,7 @@ private static FusionOutputFieldDefinitionCollection CreateOutputFields( isInaccessible: false, arguments: FusionInputFieldDefinitionCollection.Empty); - sourceFields[1] = new FusionOutputFieldDefinition( + sourceFields[fieldIndex++] = new FusionOutputFieldDefinition( IntrospectionFieldNames.Type, null, isDeprecated: false, @@ -279,7 +295,7 @@ private static FusionOutputFieldDefinitionCollection CreateOutputFields( isInaccessible: false) ])); - sourceFields[2] = new FusionOutputFieldDefinition( + sourceFields[fieldIndex++] = new FusionOutputFieldDefinition( IntrospectionFieldNames.TypeName, null, isDeprecated: false, @@ -287,13 +303,76 @@ private static FusionOutputFieldDefinitionCollection CreateOutputFields( isInaccessible: false, arguments: FusionInputFieldDefinitionCollection.Empty); + if (enableSemanticIntrospection) + { + sourceFields[fieldIndex++] = new FusionOutputFieldDefinition( + IntrospectionFieldNames.Search, + null, + isDeprecated: false, + deprecationReason: null, + isInaccessible: false, + arguments: new FusionInputFieldDefinitionCollection( + [ + new FusionInputFieldDefinition( + 0, + "query", + null, + null, + isDeprecated: false, + deprecationReason: null, + isInaccessible: false), + new FusionInputFieldDefinition( + 1, + "first", + null, + new IntValueNode(10), + isDeprecated: false, + deprecationReason: null, + isInaccessible: false), + new FusionInputFieldDefinition( + 2, + "after", + null, + null, + isDeprecated: false, + deprecationReason: null, + isInaccessible: false), + new FusionInputFieldDefinition( + 3, + "min_score", + null, + null, + isDeprecated: false, + deprecationReason: null, + isInaccessible: false) + ])); + + sourceFields[fieldIndex++] = new FusionOutputFieldDefinition( + IntrospectionFieldNames.Definitions, + null, + isDeprecated: false, + deprecationReason: null, + isInaccessible: false, + arguments: new FusionInputFieldDefinitionCollection( + [ + new FusionInputFieldDefinition( + 0, + "coordinates", + null, + null, + isDeprecated: false, + deprecationReason: null, + isInaccessible: false) + ])); + } + for (var i = 0; i < fields.Count; i++) { var field = fields[i]; var isDeprecated = DeprecatedDirectiveParser.TryParse(field.Directives, out var deprecated); var isInaccessible = InaccessibleDirectiveParser.Parse(field.Directives); - sourceFields[i + 3] = new FusionOutputFieldDefinition( + sourceFields[fieldIndex + i] = new FusionOutputFieldDefinition( field.Name.Value, field.Description?.Value, isDeprecated, @@ -420,6 +499,7 @@ private static FusionSchemaDefinition CompleteTypes( CompleteObjectType( objectType, context.GetTypeDefinition(objectType.Name), + options, context); break; @@ -506,6 +586,7 @@ context.SubscriptionType is not null private static void CompleteObjectType( FusionObjectTypeDefinition type, ObjectTypeDefinitionNode typeDef, + FusionSchemaOptions options, CompositeSchemaBuilderContext context) { var operationType = GetOperationType(typeDef.Name.Value, context); @@ -542,6 +623,25 @@ private static void CompleteObjectType( type.Fields[IntrospectionFieldNames.TypeName], Utf8GraphQLParser.Syntax.ParseFieldDefinition("__typename: String!"), context); + + if (options.EnableSemanticIntrospection) + { + CompleteOutputField( + type, + operationType, + type.Fields[IntrospectionFieldNames.Search], + Utf8GraphQLParser.Syntax.ParseFieldDefinition( + "__search(query: String!, first: Int! = 10, after: String, min_score: Float): [__SearchResult!]!"), + context); + + CompleteOutputField( + type, + operationType, + type.Fields[IntrospectionFieldNames.Definitions], + Utf8GraphQLParser.Syntax.ParseFieldDefinition( + "__definitions(coordinates: [String!]!): [__SchemaDefinition!]!"), + context); + } } var directives = CompletionTools.CreateDirectiveCollection(typeDef.Directives, context); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/SemanticIntrospectionSchema.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/SemanticIntrospectionSchema.cs new file mode 100644 index 00000000000..b6534f83591 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/SemanticIntrospectionSchema.cs @@ -0,0 +1,29 @@ +using HotChocolate.Language; + +namespace HotChocolate.Fusion.Types.Completion; + +internal static class SemanticIntrospectionSchema +{ + private static DocumentNode? s_document; + + public static ReadOnlySpan SourceText => + """ + type __SearchResult { + cursor: String! + coordinate: String! + definition: __SchemaDefinition! + pathsToRoot: [String!]! + score: Float + } + + union __SchemaDefinition = __Type | __Field | __InputValue | __EnumValue | __Directive + """u8; + + public static DocumentNode Document + { + get + { + return s_document ??= Utf8GraphQLParser.Parse(SourceText); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaOptions.cs index 3641df666ed..2fd15320f7b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/FusionSchemaOptions.cs @@ -4,6 +4,8 @@ internal struct FusionSchemaOptions : IFusionSchemaOptions { public bool ApplySerializeAsToScalars { get; private set; } + public bool EnableSemanticIntrospection { get; private set; } + public static FusionSchemaOptions From(IFusionSchemaOptions? options) { var copy = new FusionSchemaOptions(); @@ -11,6 +13,7 @@ public static FusionSchemaOptions From(IFusionSchemaOptions? options) if (options is not null) { copy.ApplySerializeAsToScalars = options.ApplySerializeAsToScalars; + copy.EnableSemanticIntrospection = options.EnableSemanticIntrospection; } return copy; diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/IFusionSchemaOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/IFusionSchemaOptions.cs index e7a425511e9..fbc4d6af671 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/IFusionSchemaOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/IFusionSchemaOptions.cs @@ -9,4 +9,10 @@ public interface IFusionSchemaOptions /// Applies the @serializeAs directive to scalar types that specify a serialization format. /// bool ApplySerializeAsToScalars { get; } + + /// + /// Enables the __search and __definitions introspection fields + /// for semantic schema discovery. + /// + bool EnableSemanticIntrospection { get; } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs index 7eaca8831dd..72c9e7f00cf 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionOptions.cs @@ -167,6 +167,21 @@ public bool ApplySerializeAsToScalars } } + /// + /// Enables the __search and __definitions introspection fields + /// for semantic schema discovery. + /// + public bool EnableSemanticIntrospection + { + get; + set + { + ExpectMutableOptions(); + + field = value; + } + } + /// /// Clones the options into a new mutable instance. /// @@ -185,7 +200,8 @@ public FusionOptions Clone() DefaultErrorHandlingMode = DefaultErrorHandlingMode, LazyInitialization = LazyInitialization, NodeIdSerializerFormat = NodeIdSerializerFormat, - ApplySerializeAsToScalars = ApplySerializeAsToScalars + ApplySerializeAsToScalars = ApplySerializeAsToScalars, + EnableSemanticIntrospection = EnableSemanticIntrospection }; } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs index 42e039b0a76..777c3069448 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs @@ -436,7 +436,7 @@ private FeatureCollection CreateSchemaFeatures( features.Set(requestOptions.PersistedOperations); features.Set(parserOptions); features.Set(clientConfigurations); - features.Set(CreateTypeResolverInterceptors()); + features.Set(CreateTypeResolverInterceptors(options)); features.Set(new SchemaCancellationFeature()); foreach (var configure in setup.SchemaFeaturesModifiers) @@ -447,10 +447,12 @@ private FeatureCollection CreateSchemaFeatures( return features; } - private static Dictionary CreateTypeResolverInterceptors() - => new() + private static Dictionary CreateTypeResolverInterceptors( + FusionOptions options) + { + var interceptors = new Dictionary { - { nameof(Query), new Query() }, + { nameof(Query), new Query(options.EnableSemanticIntrospection) }, { nameof(__Directive), new __Directive() }, { nameof(__EnumValue), new __EnumValue() }, { nameof(__Field), new __Field() }, @@ -459,6 +461,14 @@ private static Dictionary CreateTypeResolverIn { nameof(__Type), new __Type() } }; + if (options.EnableSemanticIntrospection) + { + interceptors.Add(nameof(__SearchResult), new __SearchResult()); + } + + return interceptors; + } + private ServiceProvider CreateSchemaServices( FusionGatewaySetup setup, FusionOptions options, @@ -511,6 +521,13 @@ private void AddCoreServices( services.AddSingleton(requestOptions); services.AddSingleton(requestOptions.PersistedOperations); + if (options.EnableSemanticIntrospection) + { + services.TryAddSingleton( + static sp => new HotChocolate.Types.Introspection.BM25SearchProvider( + sp.GetRequiredService())); + } + services.AddSingleton>( static _ => new DefaultObjectPool( new RequestContextPooledObjectPolicy())); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs index f7401d52376..0e0a360798d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs @@ -1,3 +1,5 @@ +using System.Buffers; +using System.Globalization; using HotChocolate.Fusion.Execution.Nodes; namespace HotChocolate.Fusion.Execution.Introspection; @@ -22,4 +24,13 @@ public static void WriteValue(this FieldContext context, bool b) public static void WriteValue(this FieldContext context, ReadOnlySpan value) => context.FieldResult.SetStringValue(value); + + public static void WriteFloatValue(this FieldContext context, float value) + { + Span buffer = stackalloc byte[32]; + if (value.TryFormat(buffer, out var written, default, CultureInfo.InvariantCulture)) + { + context.FieldResult.SetNumberValue(buffer[..written]); + } + } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs index 5a2f8b15772..f2522ea48bb 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs @@ -1,11 +1,21 @@ +using System.Diagnostics; using HotChocolate.Features; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Language; +using HotChocolate.Types; +using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Fusion.Execution.Introspection; internal class Query : ITypeResolverInterceptor { + private readonly bool _enableSemanticIntrospection; + + public Query(bool enableSemanticIntrospection = false) + { + _enableSemanticIntrospection = enableSemanticIntrospection; + } + public void OnApplyResolver(string fieldName, IFeatureCollection features) { switch (fieldName) @@ -17,6 +27,14 @@ public void OnApplyResolver(string fieldName, IFeatureCollection features) case "__type": features.Set(new ResolveFieldValue(Type)); break; + + case "__search" when _enableSemanticIntrospection: + features.Set(new ResolveFieldValue(Search)); + break; + + case "__definitions" when _enableSemanticIntrospection: + features.Set(new ResolveFieldValue(Definitions)); + break; } } @@ -35,4 +53,112 @@ public static void Type(FieldContext context) context.AddRuntimeResult(type); } } + + public static void Search(FieldContext context) + { + var provider = context.Schema.Services.GetService(); + + if (provider is null) + { + context.FieldResult.CreateListValue(0); + return; + } + + var query = context.ArgumentValue("query").Value; + var first = int.Parse(context.ArgumentValue("first").Value); + + string? after = null; + var afterNode = context.ArgumentValue("after"); + if (afterNode is StringValueNode afterString) + { + after = afterString.Value; + } + + float? minScore = null; + var minScoreNode = context.ArgumentValue("min_score"); + if (minScoreNode is FloatValueNode floatNode) + { + minScore = float.Parse(floatNode.Value, System.Globalization.CultureInfo.InvariantCulture); + } + else if (minScoreNode is IntValueNode intNode) + { + minScore = float.Parse(intNode.Value, System.Globalization.CultureInfo.InvariantCulture); + } + + var results = provider.SearchAsync(query, first, after, minScore).AsTask().GetAwaiter().GetResult(); + + var searchResults = new List(results.Count); + + foreach (var result in results) + { + var definition = SchemaCoordinateResolver.Resolve(context.Schema, result.Coordinate); + + if (definition is null) + { + continue; + } + + var paths = provider.GetPathsToRootAsync(result.Coordinate, maxPaths: 5).AsTask().GetAwaiter().GetResult(); + + var pathStrings = new List(paths.Count); + + foreach (var path in paths) + { + pathStrings.Add(string.Join(" > ", path.Select(c => c.ToString()))); + } + + searchResults.Add(new SearchResultData + { + Coordinate = result.Coordinate, + Definition = definition, + PathsToRoot = pathStrings, + Score = result.Score, + Cursor = result.Cursor + }); + } + + var list = context.FieldResult.CreateListValue(searchResults.Count); + + var i = 0; + foreach (var element in list.EnumerateArray()) + { + context.AddRuntimeResult(searchResults[i++]); + element.CreateObjectValue(context.Selection, context.IncludeFlags); + } + } + + public static void Definitions(FieldContext context) + { + var coordinatesNode = context.ArgumentValue("coordinates"); + var definitions = new List(coordinatesNode.Items.Count); + + foreach (var item in coordinatesNode.Items) + { + if (item is not StringValueNode coordinateString) + { + continue; + } + + if (!SchemaCoordinate.TryParse(coordinateString.Value, out var coordinate)) + { + continue; + } + + var definition = SchemaCoordinateResolver.Resolve(context.Schema, coordinate.Value); + + if (definition is not null) + { + definitions.Add(definition); + } + } + + var list = context.FieldResult.CreateListValue(definitions.Count); + + var i = 0; + foreach (var element in list.EnumerateArray()) + { + context.AddRuntimeResult(definitions[i++]); + element.CreateObjectValue(context.Selection, context.IncludeFlags); + } + } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaCoordinateResolver.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaCoordinateResolver.cs new file mode 100644 index 00000000000..d4938b5dcbe --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaCoordinateResolver.cs @@ -0,0 +1,102 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Execution.Introspection; + +/// +/// Resolves a to the corresponding +/// type system definition from a schema. +/// +internal static class SchemaCoordinateResolver +{ + /// + /// Resolves the specified schema coordinate to a type system definition. + /// + /// The schema to resolve against. + /// The schema coordinate to resolve. + /// + /// The resolved definition, or null if the coordinate + /// does not match any element in the schema. + /// + public static object? Resolve(ISchemaDefinition schema, SchemaCoordinate coordinate) + { + if (coordinate.OfDirective) + { + if (!schema.DirectiveDefinitions.TryGetDirective(coordinate.Name, out var directive)) + { + return null; + } + + if (coordinate.ArgumentName is not null) + { + return directive.Arguments.TryGetField(coordinate.ArgumentName, out var arg) + ? arg + : null; + } + + return directive; + } + + if (!schema.Types.TryGetType(coordinate.Name, out var type)) + { + return null; + } + + if (coordinate.MemberName is null) + { + return type; + } + + switch (type) + { + case IComplexTypeDefinition complexType: + if (!complexType.Fields.TryGetField(coordinate.MemberName, out var field)) + { + return null; + } + + if (coordinate.ArgumentName is not null) + { + return field.Arguments.TryGetField(coordinate.ArgumentName, out var fieldArg) + ? fieldArg + : null; + } + + return field; + + case IEnumTypeDefinition enumType: + return enumType.Values.TryGetValue(coordinate.MemberName, out var enumValue) + ? enumValue + : null; + + case IInputObjectTypeDefinition inputType: + return inputType.Fields.TryGetField(coordinate.MemberName, out var inputField) + ? inputField + : null; + + default: + return null; + } + } + + /// + /// Gets the introspection type name for a resolved definition object + /// within the __SchemaDefinition union. + /// + /// The resolved definition object. + /// + /// The introspection type name, or null if the definition + /// is not a recognized member of the __SchemaDefinition union. + /// + public static string? GetTypeName(object definition) + { + return definition switch + { + ITypeDefinition => "__Type", + IOutputFieldDefinition => "__Field", + IInputValueDefinition => "__InputValue", + IEnumValue => "__EnumValue", + IDirectiveDefinition => "__Directive", + _ => null + }; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SearchResultData.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SearchResultData.cs new file mode 100644 index 00000000000..d8c2f2f9e61 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SearchResultData.cs @@ -0,0 +1,37 @@ +namespace HotChocolate.Fusion.Execution.Introspection; + +/// +/// Represents the backing data for a __SearchResult introspection type instance. +/// +internal sealed class SearchResultData +{ + /// + /// Gets the schema coordinate of the matched element. + /// + public required SchemaCoordinate Coordinate { get; init; } + + /// + /// Gets the resolved type system definition (e.g. , + /// , + /// , + /// , + /// or ). + /// + public required object Definition { get; init; } + + /// + /// Gets the paths from the matched element to a root type, + /// each serialized as a string of schema coordinates. + /// + public required IReadOnlyList PathsToRoot { get; init; } + + /// + /// Gets the relevance score of the match, or null if scoring is not supported. + /// + public float? Score { get; init; } + + /// + /// Gets the opaque cursor for pagination. + /// + public required string Cursor { get; init; } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs new file mode 100644 index 00000000000..e6211fff091 --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs @@ -0,0 +1,69 @@ +using HotChocolate.Features; +using HotChocolate.Fusion.Execution.Nodes; + +namespace HotChocolate.Fusion.Execution.Introspection; + +// ReSharper disable once InconsistentNaming +internal sealed class __SearchResult : ITypeResolverInterceptor +{ + public void OnApplyResolver(string fieldName, IFeatureCollection features) + { + switch (fieldName) + { + case "cursor": + features.Set(new ResolveFieldValue(Cursor)); + break; + + case "coordinate": + features.Set(new ResolveFieldValue(Coordinate)); + break; + + case "definition": + features.Set(new ResolveFieldValue(Definition)); + break; + + case "pathsToRoot": + features.Set(new ResolveFieldValue(PathsToRoot)); + break; + + case "score": + features.Set(new ResolveFieldValue(Score)); + break; + } + } + + public static void Cursor(FieldContext context) + => context.WriteValue(context.Parent().Cursor); + + public static void Coordinate(FieldContext context) + => context.WriteValue(context.Parent().Coordinate.ToString()); + + public static void Definition(FieldContext context) + { + var data = context.Parent(); + context.FieldResult.CreateObjectValue(context.Selection, context.IncludeFlags); + context.AddRuntimeResult(data.Definition); + } + + public static void PathsToRoot(FieldContext context) + { + var data = context.Parent(); + var paths = data.PathsToRoot; + var list = context.FieldResult.CreateListValue(paths.Count); + + var index = 0; + foreach (var element in list.EnumerateArray()) + { + element.SetStringValue(paths[index++]); + } + } + + public static void Score(FieldContext context) + { + var data = context.Parent(); + if (data.Score.HasValue) + { + context.WriteFloatValue(data.Score.Value); + } + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs index 4ce909b1878..9d89394bb1d 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using HotChocolate.Fusion.Execution.Introspection; using HotChocolate.Fusion.Text.Json; using HotChocolate.Language; using HotChocolate.Types; @@ -100,9 +101,15 @@ private static void ExecuteSelections( if (!selection.IsLeaf) { - if (result.ValueKind is JsonValueKind.Object && selection.Type.IsObjectType()) + var namedType = selection.Type.NamedType(); + + if (result.ValueKind is JsonValueKind.Object + && (namedType.IsObjectType() || namedType.IsAbstractType())) { - var objectType = selection.Type.NamedType(); + var objectType = ResolveObjectType( + namedType, + fieldContext.RuntimeResults[0], + context.Schema); var selectionSet = operation.GetSelectionSet(selection, objectType); var j = 0; @@ -121,10 +128,18 @@ private static void ExecuteSelections( } else if (result.ValueKind is JsonValueKind.Array && selection.Type.IsListType() - && selection.Type.NamedType().IsObjectType()) + && (namedType.IsObjectType() || namedType.IsAbstractType())) { - var objectType = selection.Type.NamedType(); - var selectionSet = operation.GetSelectionSet(selection, objectType); + var isAbstract = namedType.IsAbstractType(); + + // For non-abstract list types, resolve the selection set once. + SelectionSet? staticSelectionSet = null; + if (!isAbstract) + { + var objectType = namedType as IObjectTypeDefinition + ?? selection.Type.NamedType(); + staticSelectionSet = operation.GetSelectionSet(selection, objectType); + } var i = 0; foreach (var element in result.EnumerateArray()) @@ -136,6 +151,11 @@ private static void ExecuteSelections( continue; } + var selectionSet = staticSelectionSet + ?? operation.GetSelectionSet( + selection, + ResolveObjectType(namedType, runtimeResult, context.Schema)); + var k = 0; for (var j = 0; j < selectionSet.Selections.Length; j++) { @@ -154,4 +174,29 @@ private static void ExecuteSelections( } } } + + private static IObjectTypeDefinition ResolveObjectType( + IType namedType, + object? runtimeResult, + ISchemaDefinition schema) + { + if (namedType is IObjectTypeDefinition objectType) + { + return objectType; + } + + // For abstract types, determine the concrete type from the runtime result. + var typeName = SchemaCoordinateResolver.GetTypeName(runtimeResult!); + + if (typeName is not null + && schema.Types.TryGetType(typeName, out var resolvedType) + && resolvedType is IObjectTypeDefinition resolvedObjectType) + { + return resolvedObjectType; + } + + throw new InvalidOperationException( + $"Cannot determine the concrete object type for abstract type '{namedType}'" + + $" from runtime result of type '{runtimeResult?.GetType().Name}'."); + } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj b/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj index 4325f6d0a14..cac2e13eadf 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/HotChocolate.Fusion.Execution.csproj @@ -41,6 +41,21 @@ + + Execution\Introspection\Search\BM25Document.cs + + + Execution\Introspection\Search\BM25Index.cs + + + Execution\Introspection\Search\BM25SearchProvider.cs + + + Execution\Introspection\Search\BM25Tokenizer.cs + + + Execution\Introspection\Search\SchemaIndexer.cs + Transport\ContentType.cs diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultElementBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultElementBuilder.cs index 85e4f0d71f6..1599387dec8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultElementBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultElementBuilder.cs @@ -96,6 +96,26 @@ is ElementTokenType.None or ElementTokenType.Null public void SetStringValue(string value) => SetStringValue(s_utf8Encoding.GetBytes(value)); + public void SetNumberValue(ReadOnlySpan value) + { + AssertValidInstance(); + + Debug.Assert(_builder._metaDb.GetElementTokenType(_index) + is ElementTokenType.None or ElementTokenType.Null + or ElementTokenType.Number); + + var writer = _builder._data; + var writeIndex = _builder._data.Length; + + var target = writer.GetSpan(value.Length); + value.CopyTo(target); + writer.Advance(value.Length); + + _builder._metaDb.SetLocation(_index, writeIndex); + _builder._metaDb.SetSizeOrLength(_index, value.Length); + _builder._metaDb.SetElementTokenType(_index, ElementTokenType.Number); + } + public void SetBooleanValue(bool value) { AssertValidInstance(); diff --git a/src/HotChocolate/Primitives/src/Primitives/Types/IntrospectionFieldNames.cs b/src/HotChocolate/Primitives/src/Primitives/Types/IntrospectionFieldNames.cs index 315832fe9a6..e07079cf4da 100644 --- a/src/HotChocolate/Primitives/src/Primitives/Types/IntrospectionFieldNames.cs +++ b/src/HotChocolate/Primitives/src/Primitives/Types/IntrospectionFieldNames.cs @@ -34,4 +34,24 @@ public static class IntrospectionFieldNames /// Gets the field name of the __type introspection field as a span of utf-8 bytes. /// public static ReadOnlySpan TypeSpan => "__type"u8; + + /// + /// Gets the field name of the __search introspection field. + /// + public const string Search = "__search"; + + /// + /// Gets the field name of the __search introspection field as a span of utf-8 bytes. + /// + public static ReadOnlySpan SearchSpan => "__search"u8; + + /// + /// Gets the field name of the __definitions introspection field. + /// + public const string Definitions = "__definitions"; + + /// + /// Gets the field name of the __definitions introspection field as a span of utf-8 bytes. + /// + public static ReadOnlySpan DefinitionsSpan => "__definitions"u8; } From 8495166fa56112acc1bbe33c7e81c9a7d0825102 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 13:07:12 +0800 Subject: [PATCH 02/24] Added cancellation token to search --- .../src/Types/Types/Introspection/Search/BM25Index.cs | 9 ++++++++- .../Types/Introspection/Search/BM25SearchProvider.cs | 9 ++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Index.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Index.cs index 3674ab48859..0c1d714a702 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Index.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Index.cs @@ -122,10 +122,15 @@ public static BM25Index Build(IReadOnlyList documents) /// /// The tokenized query terms. /// + /// + /// The cancellation token. + /// /// /// A list of document ID and raw BM25 score pairs, sorted by score descending. /// - public IReadOnlyList Search(string[] queryTokens) + public IReadOnlyList Search( + string[] queryTokens, + CancellationToken cancellationToken = default) { if (queryTokens.Length == 0 || _documentCount == 0) { @@ -141,6 +146,8 @@ public IReadOnlyList Search(string[] queryTokens) foreach (var token in queryTokens) { + cancellationToken.ThrowIfCancellationRequested(); + if (!_invertedIndex.TryGetValue(token, out var postings)) { continue; diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs index 0a79b110881..dd8740ac250 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs @@ -44,7 +44,7 @@ public ValueTask> SearchAsync( var data = EnsureIndex(); var queryTokens = BM25Tokenizer.Tokenize(query); - var rawResults = data.Index.Search(queryTokens); + var rawResults = data.Index.Search(queryTokens, cancellationToken); if (rawResults.Count == 0) { @@ -106,7 +106,7 @@ public ValueTask> GetPathsToRootAsync( // If it's a type coordinate, we start from that type directly. var startTypeName = coordinate.Name; - var paths = FindPathsToRoot(data, startTypeName, maxPaths); + var paths = FindPathsToRoot(data, startTypeName, maxPaths, cancellationToken); // Build SchemaCoordinatePath instances. // Each path is from the target coordinate back to a root type field. @@ -151,7 +151,8 @@ public ValueTask> GetPathsToRootAsync( private static List> FindPathsToRoot( SearchData data, string startTypeName, - int maxPaths) + int maxPaths, + CancellationToken cancellationToken) { var rootTypeNames = data.RootTypeNames; var reverseMap = data.ReverseMap; @@ -172,6 +173,8 @@ private static List> FindPathsToRoot( while (queue.Count > 0 && paths.Count < maxPaths) { + cancellationToken.ThrowIfCancellationRequested(); + var (currentType, currentPath) = queue.Dequeue(); if (!reverseMap.TryGetValue(currentType, out var references)) From 29ae08ac88d87c67d1f1bf27b1bc34fe22f2640b Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 14:36:49 +0800 Subject: [PATCH 03/24] refinements --- .work/review/review.md | 48 --------------- .../ISchemaSearchProvider.cs | 6 ++ .../InvalidSearchCursorException.cs | 26 +++++++++ .../SearchQueryTooLargeException.cs | 26 +++++++++ .../Core/src/Types/SchemaBuilder.Setup.cs | 6 +- .../Introspection/IntrospectionFields.cs | 58 +++++++++++++++++-- .../Search/BM25SearchProvider.cs | 50 ++++++++++------ .../SemanticIntrospectionTests.cs | 34 ----------- 8 files changed, 146 insertions(+), 108 deletions(-) delete mode 100644 .work/review/review.md create mode 100644 src/HotChocolate/Core/src/Types.Abstractions/InvalidSearchCursorException.cs create mode 100644 src/HotChocolate/Core/src/Types.Abstractions/SearchQueryTooLargeException.cs diff --git a/.work/review/review.md b/.work/review/review.md deleted file mode 100644 index b96f6ac3908..00000000000 --- a/.work/review/review.md +++ /dev/null @@ -1,48 +0,0 @@ -# Review Verdict: **Request Changes** - -The current changes introduce valuable semantic introspection functionality, but there are shipping blockers and a few important consistency risks. - -## Critical - -### 1. Sync-over-async in Fusion introspection resolvers -- **Files:** `src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs` -- **Evidence:** Calls to `SearchAsync(...).AsTask().GetAwaiter().GetResult()` and `GetPathsToRootAsync(...).AsTask().GetAwaiter().GetResult()` -- **Why it matters:** Blocking async calls inside request execution risks thread-pool starvation/deadlock patterns and increases tail latency under load. -- **Fix:** Move resolver path to async end-to-end (preferred), or provide a truly synchronous provider path. Avoid `GetResult()` in execution flow. - -## Major - -### 2. Semantic introspection enabled by default changes schema surface -- **File:** `src/HotChocolate/Core/src/Types/SchemaOptions.cs` -- **Evidence:** `EnableSemanticIntrospection` defaults to `true`. -- **Why it matters:** Existing schemas gain new introspection fields/types by default, which can break strict schema-shape checks and downstream tooling expectations. -- **Fix:** Consider defaulting to opt-in (`false`) or gate with explicit versioned rollout/clear upgrade note. - -### 3. Inconsistent `ISchemaSearchProvider` registration between Core and Fusion -- **Files:** `src/HotChocolate/Core/src/Types/SchemaBuilder.Setup.cs`, `src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs` -- **Evidence:** Core registers provider unconditionally; Fusion registers only when semantic introspection is enabled. -- **Why it matters:** Divergent lifecycle/behavior across stacks makes feature behavior and diagnostics inconsistent. -- **Fix:** Align registration strategy (prefer conditional registration in both paths). - -## Minor - -### 4. Missing cancellation propagation in Fusion search calls -- **File:** `src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs` -- **Evidence:** `SearchAsync`/`GetPathsToRootAsync` are invoked without a request cancellation token. -- **Why it matters:** Cancelled requests may continue expensive introspection work unnecessarily. -- **Fix:** Thread request cancellation through field context and pass token to provider APIs. - -### 5. Duplicate coordinate-resolution logic in Core and Fusion -- **Files:** `src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs`, `src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaCoordinateResolver.cs` -- **Why it matters:** Logic drift risk and higher maintenance overhead. -- **Fix:** Extract a shared resolver helper/API and reuse in both implementations. - -### 6. First-query latency risk from lazy BM25 index build -- **File:** `src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs` -- **Why it matters:** Large schemas can pay indexing cost on first `__search` call. -- **Fix:** Add optional prewarm/eager build path or document/startup-warm strategy. - -## Notes - -- Pattern and validation wiring look largely correct (new fields marked as introspection and recognized by validation logic). -- Snapshot changes are consistent with the new schema surface. diff --git a/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs b/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs index 39ca7c9df83..c46b2f35133 100644 --- a/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs +++ b/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs @@ -31,6 +31,12 @@ public interface ISchemaSearchProvider /// /// A list of ordered by relevance. /// + /// + /// Thrown when the cursor is malformed or out of range. + /// + /// + /// Thrown when the exceeds the maximum allowed length. + /// ValueTask> SearchAsync( string query, int first, diff --git a/src/HotChocolate/Core/src/Types.Abstractions/InvalidSearchCursorException.cs b/src/HotChocolate/Core/src/Types.Abstractions/InvalidSearchCursorException.cs new file mode 100644 index 00000000000..3ba052404be --- /dev/null +++ b/src/HotChocolate/Core/src/Types.Abstractions/InvalidSearchCursorException.cs @@ -0,0 +1,26 @@ +namespace HotChocolate; + +/// +/// The exception that is thrown by an +/// when a search cursor is invalid or cannot be decoded. +/// +public sealed class InvalidSearchCursorException : Exception +{ + /// + /// Initializes a new instance of . + /// + public InvalidSearchCursorException() + : base("The specified search cursor is invalid.") + { + } + + /// + /// Initializes a new instance of + /// with a custom message. + /// + /// The error message. + public InvalidSearchCursorException(string message) + : base(message) + { + } +} diff --git a/src/HotChocolate/Core/src/Types.Abstractions/SearchQueryTooLargeException.cs b/src/HotChocolate/Core/src/Types.Abstractions/SearchQueryTooLargeException.cs new file mode 100644 index 00000000000..9d38697411d --- /dev/null +++ b/src/HotChocolate/Core/src/Types.Abstractions/SearchQueryTooLargeException.cs @@ -0,0 +1,26 @@ +namespace HotChocolate; + +/// +/// The exception that is thrown by an +/// when a search query exceeds the maximum allowed length. +/// +public sealed class SearchQueryTooLargeException : Exception +{ + /// + /// Initializes a new instance of . + /// + public SearchQueryTooLargeException() + : base("The search query exceeds the maximum allowed length.") + { + } + + /// + /// Initializes a new instance of + /// with a custom message. + /// + /// The error message. + public SearchQueryTooLargeException(string message) + : base(message) + { + } +} diff --git a/src/HotChocolate/Core/src/Types/SchemaBuilder.Setup.cs b/src/HotChocolate/Core/src/Types/SchemaBuilder.Setup.cs index a2087aefa36..70b2d2b68b8 100644 --- a/src/HotChocolate/Core/src/Types/SchemaBuilder.Setup.cs +++ b/src/HotChocolate/Core/src/Types/SchemaBuilder.Setup.cs @@ -601,6 +601,10 @@ internal static void AddCoreSchemaServices(IServiceCollection services, LazySche }); services.TryAddSingleton( - static sp => new BM25SearchProvider(sp.GetRequiredService())); + static sp => + { + var schema = sp.GetRequiredService(); + return new BM25SearchProvider(schema); + }); } } diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs index a6afb3cdee5..26feb52de89 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs @@ -7,6 +7,8 @@ namespace HotChocolate.Types.Introspection; internal static class IntrospectionFields { + private const int MaxFirstLimit = 150; + private static readonly PureFieldDelegate s_typeNameResolver = ctx => ctx.ObjectType.Name; @@ -95,12 +97,47 @@ internal static ObjectFieldConfiguration CreateSearchField(IDescriptorContext co var after = ctx.ArgumentOptional("after"); var minScore = ctx.ArgumentOptional("min_score"); - var results = await provider.SearchAsync( - query, - first, - after.HasValue ? after.Value : null, - minScore.HasValue ? minScore.Value : null, - ctx.RequestAborted); + if (first <= 0) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage("The `first` argument must be greater than zero.") + .Build()); + } + + if (first > MaxFirstLimit) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage($"The `first` argument must not exceed {MaxFirstLimit}.") + .Build()); + } + + IReadOnlyList results; + + try + { + results = await provider.SearchAsync( + query, + first, + after.HasValue ? after.Value : null, + minScore.HasValue ? minScore.Value : null, + ctx.RequestAborted); + } + catch (InvalidSearchCursorException) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage("The value of `after` is not a valid cursor.") + .Build()); + } + catch (SearchQueryTooLargeException) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage("The search query exceeds the maximum allowed length.") + .Build()); + } var searchResults = new List(results.Count); @@ -156,6 +193,15 @@ internal static ObjectFieldConfiguration CreateDefinitionsField(IDescriptorConte static ValueTask Resolve(IResolverContext ctx) { var coordinates = ctx.ArgumentValue("coordinates"); + + if (coordinates.Length > MaxFirstLimit) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage($"The `coordinates` argument must not exceed {MaxFirstLimit} items.") + .Build()); + } + var definitions = new List(coordinates.Length); foreach (var coordinateString in coordinates) diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs index dd8740ac250..a04d0ad0b90 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs @@ -10,6 +10,8 @@ namespace HotChocolate.Types.Introspection; /// internal sealed class BM25SearchProvider : ISchemaSearchProvider { + private const int MaxQueryLength = 1024; + private readonly ISchemaDefinition _schema; private volatile SearchData? _searchData; private readonly object _syncRoot = new(); @@ -35,11 +37,16 @@ public ValueTask> SearchAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(query); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(first); + + if (query.Length > MaxQueryLength) + { + throw new SearchQueryTooLargeException(); + } - if (first <= 0) + if (after is { Length: 0 }) { - return new ValueTask>( - Array.Empty()); + throw new ArgumentException("The cursor must not be empty.", nameof(after)); } var data = EnsureIndex(); @@ -48,20 +55,16 @@ public ValueTask> SearchAsync( if (rawResults.Count == 0) { - return new ValueTask>( - Array.Empty()); + return ValueTask.FromResult>(Array.Empty()); } // Determine the maximum raw score for normalization. var maxRawScore = rawResults[0].Score; // Results are sorted descending. // Decode the cursor to determine the starting offset. - var offset = 0; - - if (after is not null) - { - offset = DecodeCursor(after); - } + var offset = after is not null + ? DecodeCursor(after, rawResults.Count) + : 0; var results = new List(Math.Min(first, rawResults.Count)); @@ -83,7 +86,7 @@ public ValueTask> SearchAsync( EncodeCursor(i + 1))); } - return new ValueTask>(results); + return ValueTask.FromResult>(results); } /// @@ -94,7 +97,7 @@ public ValueTask> GetPathsToRootAsync( { if (maxPaths <= 0) { - return new ValueTask>( + return ValueTask.FromResult>( Array.Empty()); } @@ -145,7 +148,7 @@ public ValueTask> GetPathsToRootAsync( result.RemoveRange(maxPaths, result.Count - maxPaths); } - return new ValueTask>(result); + return ValueTask.FromResult>(result); } private static List> FindPathsToRoot( @@ -257,23 +260,32 @@ private SearchData EnsureIndex() private static string EncodeCursor(int offset) => Convert.ToBase64String(BitConverter.GetBytes(offset)); - private static int DecodeCursor(string cursor) + private static int DecodeCursor(string cursor, int resultCount) { + int offset; + try { var bytes = Convert.FromBase64String(cursor); - if (bytes.Length >= 4) + if (bytes.Length < 4) { - return BitConverter.ToInt32(bytes, 0); + throw new InvalidSearchCursorException(); } + + offset = BitConverter.ToInt32(bytes, 0); } catch (FormatException) { - // Invalid cursor format; start from the beginning. + throw new InvalidSearchCursorException(); + } + + if (offset < 0 || offset > resultCount) + { + throw new InvalidSearchCursorException(); } - return 0; + return offset; } /// diff --git a/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs index 05d17c9e7d9..b80fef76144 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs @@ -1080,40 +1080,6 @@ ... on __Type { """); } - [Fact] - public async Task Search_Should_FindFields_When_AskedAboutPricing() - { - // arrange - var executor = CreateSchema().MakeExecutable(); - - // act - var result = await executor.ExecuteAsync( - """ - { - __search(query: "What pricing information is available?") { - coordinate - score - definition { - ... on __Field { - fieldName: name - description - } - ... on __Type { - typeName: name - kind - } - } - } - } - """); - - // assert - result.MatchInlineSnapshot( - """ - PLACEHOLDER - """); - } - [Fact] public async Task Search_Should_NotExist_When_SemanticIntrospectionDisabled() { From 9fd85854d8a7954c680a48bba1e7835b1f869cdc Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 15:00:30 +0800 Subject: [PATCH 04/24] Fail if we cannot format --- .../Fusion.Execution/Execution/Introspection/MemHelper.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs index 0e0a360798d..49577404dbe 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs @@ -1,4 +1,3 @@ -using System.Buffers; using System.Globalization; using HotChocolate.Fusion.Execution.Nodes; @@ -28,9 +27,12 @@ public static void WriteValue(this FieldContext context, ReadOnlySpan valu public static void WriteFloatValue(this FieldContext context, float value) { Span buffer = stackalloc byte[32]; - if (value.TryFormat(buffer, out var written, default, CultureInfo.InvariantCulture)) + + if (!value.TryFormat(buffer, out var written, default, CultureInfo.InvariantCulture)) { - context.FieldResult.SetNumberValue(buffer[..written]); + throw new InvalidOperationException($"Failed to format float value '{value}'."); } + + context.FieldResult.SetNumberValue(buffer[..written]); } } From b76c1dedee8110e54b939b4772498d5acf3cb3cd Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 15:01:19 +0800 Subject: [PATCH 05/24] add tests to fusion --- .../SemanticIntrospectionTests.cs | 871 ++++++++++++++++++ 1 file changed, 871 insertions(+) create mode 100644 src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs new file mode 100644 index 00000000000..82b73005097 --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs @@ -0,0 +1,871 @@ +using HotChocolate.AspNetCore; +using HotChocolate.Transport; +using HotChocolate.Transport.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace HotChocolate.Fusion; + +public class SemanticIntrospectionTests : FusionTestBase +{ + [Fact] + public async Task Search_Should_ReturnResults_When_QueryMatchesFieldName() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "user") { + coordinate + score + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "User", + "score": 1 + }, + { + "coordinate": "Query.userByEmail", + "score": 0.8877184 + }, + { + "coordinate": "User.email", + "score": 0.69699603 + }, + { + "coordinate": "User.name", + "score": 0.69699603 + }, + { + "coordinate": "User.age", + "score": 0.65830594 + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ReturnResults_When_QueryMatchesDescription() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "email address") { + coordinate + score + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "User.email", + "score": 1 + }, + { + "coordinate": "Query.userByEmail", + "score": 0.9123855 + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_RespectFirstArgument() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "product", first: 2) { + coordinate + score + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Product", + "score": 1 + }, + { + "coordinate": "Product.category", + "score": 0.81051797 + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_FilterByMinScore() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "product", first: 100, min_score: 0.9) { + coordinate + score + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Product", + "score": 1 + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ReturnCursors_And_SupportPagination() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // Get first page. + using var firstResult = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "product", first: 1) { + cursor + coordinate + } + } + """), + new Uri("http://localhost:5000/graphql")); + + using var firstResponse = await firstResult.ReadAsResultAsync(); + var firstJson = firstResponse.Data.ToString()!; + var cursorStart = firstJson.IndexOf("\"cursor\":\"", StringComparison.Ordinal) + 10; + var cursorEnd = firstJson.IndexOf("\"", cursorStart, StringComparison.Ordinal); + var cursor = firstJson[cursorStart..cursorEnd]; + + // act - Get second page. + using var secondResult = await client.PostAsync( + new OperationRequest( + query: """ + query($after: String) { + __search(query: "product", first: 1, after: $after) { + cursor + coordinate + } + } + """, + variables: new Dictionary { { "after", cursor } }), + new Uri("http://localhost:5000/graphql")); + + // assert + firstResponse.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "cursor": "AQAAAA==", + "coordinate": "Product" + } + ] + } + } + """); + + using var secondResponse = await secondResult.ReadAsResultAsync(); + secondResponse.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "cursor": "AgAAAA==", + "coordinate": "Product.category" + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ReturnEmptyList_When_NoMatches() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "xyznonexistent") { + coordinate + score + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__search": [] + } + } + """); + } + + [Fact] + public async Task Search_Should_IncludePathsToRoot() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "product name") { + coordinate + pathsToRoot + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Product.name", + "pathsToRoot": [ + "Product.name > Product > Query.productSearch > Query" + ] + }, + { + "coordinate": "Query.productSearch", + "pathsToRoot": [ + "Query.productSearch > Query" + ] + }, + { + "coordinate": "User.name", + "pathsToRoot": [ + "User.name > User > Query.userByEmail > Query" + ] + }, + { + "coordinate": "Product", + "pathsToRoot": [ + "Product > Query.productSearch > Query" + ] + }, + { + "coordinate": "Product.category", + "pathsToRoot": [ + "Product.category > Product > Query.productSearch > Query" + ] + }, + { + "coordinate": "Product.price", + "pathsToRoot": [ + "Product.price > Product > Query.productSearch > Query" + ] + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ReturnScoresInDescendingOrder() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "product", first: 100) { + coordinate + score + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Product", + "score": 1 + }, + { + "coordinate": "Product.category", + "score": 0.81051797 + }, + { + "coordinate": "Product.name", + "score": 0.81051797 + }, + { + "coordinate": "Product.price", + "score": 0.70929694 + }, + { + "coordinate": "Query.productSearch", + "score": 0.5973899 + } + ] + } + } + """); + } + + [Fact] + public async Task Search_Should_ResolveDefinition_AsField() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "userByEmail") { + coordinate + definition { + ... on __Field { + name + description + args { + name + } + } + } + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__search": null + } + } + """); + } + + [Fact] + public async Task Search_Should_ResolveDefinition_AsType() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "Product") { + coordinate + definition { + ... on __Type { + name + kind + fields { + name + } + } + } + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__search": null + } + } + """); + } + + [Fact] + public async Task Definitions_Should_ResolveTypeByCoordinate() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __definitions(coordinates: ["User"]) { + ... on __Type { + name + kind + fields { + name + } + } + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__definitions": null + } + } + """); + } + + [Fact] + public async Task Definitions_Should_ResolveFieldByCoordinate() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __definitions(coordinates: ["Query.userByEmail"]) { + ... on __Field { + name + description + args { + name + type { + name + kind + } + } + } + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__definitions": null + } + } + """); + } + + [Fact] + public async Task Definitions_Should_ResolveMultipleCoordinates() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __definitions(coordinates: ["User", "Product", "Query.orderById"]) { + ... on __Type { + typeName: name + kind + } + ... on __Field { + fieldName: name + description + } + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__definitions": null + } + } + """); + } + + [Fact] + public async Task Definitions_Should_SkipInvalidCoordinates() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __definitions(coordinates: ["User", "NonExistentType", "Query.orderById"]) { + ... on __Type { + typeName: name + } + ... on __Field { + fieldName: name + } + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__definitions": null + } + } + """); + } + + [Fact] + public async Task Definitions_Should_ResolveEnumValueByCoordinate() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __definitions(coordinates: ["OrderStatus.PENDING"]) { + ... on __EnumValue { + name + description + } + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__definitions": null + } + } + """); + } + + [Fact] + public async Task Search_Should_NotExist_When_SemanticIntrospectionDisabled() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server, enableSemanticIntrospection: false); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "user") { + coordinate + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "The field `__search` does not exist on the type `Query`.", + "locations": [ + { + "line": 2, + "column": 5 + } + ], + "extensions": { + "type": "Query", + "field": "__search", + "responseName": "__search", + "specifiedBy": "https://spec.graphql.org/September2025/#sec-Field-Selections" + } + } + ] + } + """); + } + + [Fact] + public async Task Definitions_Should_NotExist_When_SemanticIntrospectionDisabled() + { + // arrange + using var server = CreateSourceSchema("A", _sourceSchema); + + using var gateway = await CreateGatewayAsync(server, enableSemanticIntrospection: false); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __definitions(coordinates: ["User"]) { + ... on __Type { + name + } + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "The field `__definitions` does not exist on the type `Query`.", + "locations": [ + { + "line": 2, + "column": 5 + } + ], + "extensions": { + "type": "Query", + "field": "__definitions", + "responseName": "__definitions", + "specifiedBy": "https://spec.graphql.org/September2025/#sec-Field-Selections" + } + } + ] + } + """); + } + + private Task CreateGatewayAsync( + Microsoft.AspNetCore.TestHost.TestServer server, + bool enableSemanticIntrospection = true) + { + return CreateCompositeSchemaAsync( + [("A", server)], + configureGatewayBuilder: b => + { + b.ModifyOptions(o => o.EnableSemanticIntrospection = enableSemanticIntrospection); + + b.ConfigureSchemaServices((_, s) => + { + s.RemoveAll(); + s.AddSingleton(); + }); + }); + } + + private const string _sourceSchema = + """ + "A registered user of the system" + type User { + "The full name of the user" + name: String! + "The email address of the user" + email: String! + "The age of the user in years" + age: Int! + } + + "A product available for purchase" + type Product { + "The product name" + name: String! + "The product price in dollars" + price: Float! + "The product category" + category: String! + } + + "A customer order" + type Order { + "The unique order identifier" + id: ID! + "The order total amount" + total: Float! + "The current order status" + status: OrderStatus + } + + "The status of an order" + enum OrderStatus { + "Order is pending processing" + PENDING + "Order has been shipped" + SHIPPED + "Order has been delivered" + DELIVERED + "Order has been cancelled" + CANCELLED + } + + type Query { + "Retrieve a user by their email address" + userByEmail(email: String!): User + "Search for products by name or category" + productSearch(term: String!): [Product] + "Retrieve an order by its unique identifier" + orderById(id: ID!): Order + } + """; +} From 71d4c4f7cc1266d2793d6a48ef81665d16ddf4c1 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 15:02:55 +0800 Subject: [PATCH 06/24] refinements --- .../Introspection/Search/SchemaIndexer.cs | 17 +++++++----- .../Search/BM25SearchProviderTests.cs | 20 ++++++-------- .../Search/SchemaIndexerTests.cs | 26 ++++++++++++------- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs index d8cf4dc448c..bf9ce8c538c 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs @@ -14,12 +14,9 @@ internal static class SchemaIndexer /// The schema definition to index. /// /// - /// A tuple containing the list of documents and the reverse adjacency map. - /// The reverse adjacency map maps a type name to the list of (declaringTypeName, fieldName) pairs - /// where a field on the declaring type returns a value of that type. + /// A containing the indexed documents and reverse adjacency map. /// - public static (List Documents, Dictionary> ReverseMap) Index( - ISchemaDefinition schema) + public static SchemaIndexResult Index(ISchemaDefinition schema) { var documents = new List(); var reverseMap = new Dictionary>(StringComparer.Ordinal); @@ -67,7 +64,7 @@ public static (List Documents, Dictionary internal readonly record struct TypeFieldReference(string TypeName, string FieldName); + + /// + /// The result of indexing a schema, containing the indexed documents + /// and a reverse adjacency map for path-to-root traversal. + /// + internal readonly record struct SchemaIndexResult( + List Documents, + Dictionary> ReverseMap); } diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs index f7695020603..e559e8dd3ca 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs @@ -36,31 +36,27 @@ public async Task SearchAsync_Should_ReturnEmpty_When_NoMatch() } [Fact] - public async Task SearchAsync_Should_ReturnEmpty_When_FirstIsZero() + public async Task SearchAsync_Should_Throw_When_FirstIsZero() { // arrange var schema = CreateTestSchema(); var provider = new BM25SearchProvider(schema); - // act - var results = await provider.SearchAsync("product", first: 0, after: null, minScore: null); - - // assert - Assert.Empty(results); + // act & assert + await Assert.ThrowsAsync( + () => provider.SearchAsync("product", first: 0, after: null, minScore: null).AsTask()); } [Fact] - public async Task SearchAsync_Should_ReturnEmpty_When_FirstIsNegative() + public async Task SearchAsync_Should_Throw_When_FirstIsNegative() { // arrange var schema = CreateTestSchema(); var provider = new BM25SearchProvider(schema); - // act - var results = await provider.SearchAsync("product", first: -1, after: null, minScore: null); - - // assert - Assert.Empty(results); + // act & assert + await Assert.ThrowsAsync( + () => provider.SearchAsync("product", first: -1, after: null, minScore: null).AsTask()); } [Fact] diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs index 9f7bbf003af..f33c6071633 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs @@ -13,7 +13,8 @@ public void Index_Should_IndexTypeNames() .Resolve("world")); // act - var (documents, _) = SchemaIndexer.Index(schema); + var result = SchemaIndexer.Index(schema); + var documents = result.Documents; // assert Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("Query")); @@ -30,7 +31,8 @@ public void Index_Should_IndexFieldNames() .Resolve("test")); // act - var (documents, _) = SchemaIndexer.Index(schema); + var result = SchemaIndexer.Index(schema); + var documents = result.Documents; // assert Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("Query", "productName")); @@ -47,7 +49,8 @@ public void Index_Should_SkipIntrospectionTypes() .Resolve("world")); // act - var (documents, _) = SchemaIndexer.Index(schema); + var result = SchemaIndexer.Index(schema); + var documents = result.Documents; // assert Assert.DoesNotContain(documents, @@ -68,7 +71,8 @@ public void Index_Should_IndexEnumValues() .Create(); // act - var (documents, _) = SchemaIndexer.Index(schema); + var result = SchemaIndexer.Index(schema); + var documents = result.Documents; // assert Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("Status", "ACTIVE")); @@ -90,7 +94,8 @@ public void Index_Should_IndexInputObjectFields() .Create(); // act - var (documents, _) = SchemaIndexer.Index(schema); + var result = SchemaIndexer.Index(schema); + var documents = result.Documents; // assert Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("ProductFilterInput")); @@ -112,7 +117,8 @@ public void Index_Should_IndexDirectiveDefinitions() .Create(); // act - var (documents, _) = SchemaIndexer.Index(schema); + var result = SchemaIndexer.Index(schema); + var documents = result.Documents; // assert Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("cached", ofDirective: true)); @@ -132,7 +138,7 @@ public void Index_Should_BuildReverseAdjacencyMap() .Create(); // act - var (_, reverseMap) = SchemaIndexer.Index(schema); + var reverseMap = SchemaIndexer.Index(schema).ReverseMap; // assert // Product is returned by Query.product, so reverse map should map Product -> Query @@ -154,7 +160,8 @@ public void Index_Should_IncludeDescriptionInText() .Resolve("test")); // act - var (documents, _) = SchemaIndexer.Index(schema); + var result = SchemaIndexer.Index(schema); + var documents = result.Documents; // assert var fieldDoc = documents.First(d => d.Coordinate == new SchemaCoordinate("Query", "product")); @@ -178,7 +185,8 @@ public void Index_Should_IndexInterfaceTypeFields() .Create(); // act - var (documents, _) = SchemaIndexer.Index(schema); + var result = SchemaIndexer.Index(schema); + var documents = result.Documents; // assert Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("Node", "id")); From c08614ecd7f6761175498b5de161e3f8d4a9e2c9 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 18:39:48 +0800 Subject: [PATCH 07/24] more stuff --- .../SchemaCoordinatePath.cs | 30 +++++++++++++++++++ .../Introspection/IntrospectionFields.cs | 10 ++++--- .../Types/Introspection/__SearchResult.cs | 6 ++-- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/HotChocolate/Core/src/Types.Abstractions/SchemaCoordinatePath.cs b/src/HotChocolate/Core/src/Types.Abstractions/SchemaCoordinatePath.cs index a8916a9bc90..fb3463826b5 100644 --- a/src/HotChocolate/Core/src/Types.Abstractions/SchemaCoordinatePath.cs +++ b/src/HotChocolate/Core/src/Types.Abstractions/SchemaCoordinatePath.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Collections.Immutable; namespace HotChocolate; @@ -9,6 +10,7 @@ namespace HotChocolate; public sealed class SchemaCoordinatePath : IReadOnlyList { private readonly SchemaCoordinate[] _segments; + private ImmutableArray? _stringSegments; /// /// Initializes a new instance of . @@ -38,6 +40,34 @@ public SchemaCoordinatePath(ReadOnlySpan segments) /// public SchemaCoordinate this[int index] => _segments[index]; + /// + /// Returns the path segments as an immutable array of their string representations. + /// The result is cached for subsequent calls. + /// + /// + /// An of schema coordinate strings, + /// ordered from the matched element to the root type. + /// + public ImmutableArray ToStringArray() + { + if (_stringSegments is not null) + { + return _stringSegments.Value; + } + + lock (_segments) + { + if (_stringSegments is not null) + { + return _stringSegments.Value; + } + + var strings = _segments.Select(s => s.ToString()).ToImmutableArray(); + _stringSegments = strings; + return strings; + } + } + /// public IEnumerator GetEnumerator() { diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs index 26feb52de89..639c7f634c0 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using System.Runtime.InteropServices; using HotChocolate.Properties; using HotChocolate.Resolvers; using HotChocolate.Types.Descriptors; @@ -155,18 +157,18 @@ internal static ObjectFieldConfiguration CreateSearchField(IDescriptorContext co maxPaths: 5, ctx.RequestAborted); - var pathStrings = new List(paths.Count); + var pathsToRoot = new ImmutableArray[paths.Count]; - foreach (var path in paths) + for (var i = 0; i < paths.Count; i++) { - pathStrings.Add(string.Join(" > ", path.Select(c => c.ToString()))); + pathsToRoot[i] = paths[i].ToStringArray(); } searchResults.Add(new SearchResultInfo { Coordinate = result.Coordinate, Definition = definition, - PathsToRoot = pathStrings, + PathsToRoot = ImmutableCollectionsMarshal.AsImmutableArray(pathsToRoot), Score = result.Score, Cursor = result.Cursor }); diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs index 86d1a2bb556..40fe0aa3e52 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs @@ -15,7 +15,7 @@ protected override ObjectTypeConfiguration CreateConfiguration(ITypeDiscoveryCon var nonNullStringType = Parse($"{ScalarNames.String}!"); var floatType = Create(ScalarNames.Float); var nonNullSchemaDefinitionType = Parse($"{nameof(__SchemaDefinition)}!"); - var nonNullStringListType = Parse($"[{ScalarNames.String}!]!"); + var nonNullStringListListType = Parse($"[[{ScalarNames.String}!]!]!"); return new ObjectTypeConfiguration( Names.__SearchResult, @@ -37,8 +37,8 @@ protected override ObjectTypeConfiguration CreateConfiguration(ITypeDiscoveryCon nonNullSchemaDefinitionType, pureResolver: Resolvers.Definition), new(Names.PathsToRoot, - "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", - nonNullStringListType, + "Paths from this element to a root type, each as a list of schema coordinates.", + nonNullStringListListType, pureResolver: Resolvers.PathsToRoot), new(Names.Score, "The relevance score of the match, or null if scoring is not supported.", From 026fc770574d3f8f54373b60eb21c4e4ee3ddd47 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 19:19:47 +0800 Subject: [PATCH 08/24] refactoring --- .../Extensions/SchemaDefinitionExtensions.cs | 117 ++++++++++++++++++ .../Introspection/IntrospectionFields.cs | 108 +--------------- .../Types/Introspection/SearchResultInfo.cs | 35 ------ .../Types/Introspection/__SearchResult.cs | 43 +++++-- ...ectionTests.DefaultValueIsInputObject.snap | 15 ++- ...sts.ExecuteGraphiQLIntrospectionQuery.snap | 15 ++- ...cuteGraphiQLIntrospectionQuery_ToJson.snap | 15 ++- 7 files changed, 188 insertions(+), 160 deletions(-) create mode 100644 src/HotChocolate/Core/src/Types.Abstractions/Extensions/SchemaDefinitionExtensions.cs delete mode 100644 src/HotChocolate/Core/src/Types/Types/Introspection/SearchResultInfo.cs diff --git a/src/HotChocolate/Core/src/Types.Abstractions/Extensions/SchemaDefinitionExtensions.cs b/src/HotChocolate/Core/src/Types.Abstractions/Extensions/SchemaDefinitionExtensions.cs new file mode 100644 index 00000000000..4a205fdaa8d --- /dev/null +++ b/src/HotChocolate/Core/src/Types.Abstractions/Extensions/SchemaDefinitionExtensions.cs @@ -0,0 +1,117 @@ +using System.Diagnostics.CodeAnalysis; +using HotChocolate.Types; + +namespace HotChocolate; + +/// +/// Provides extension methods for . +/// +public static class SchemaDefinitionExtensions +{ + /// + /// Tries to resolve a by its . + /// + /// + /// The schema definition to resolve against. + /// + /// + /// The schema coordinate to resolve. + /// + /// + /// The resolved type system definition. + /// + /// + /// true if a type system definition was found with the given + /// ; otherwise, false. + /// + public static bool TryGetMember( + this ISchemaDefinition schema, + SchemaCoordinate coordinate, + [NotNullWhen(true)] out ITypeSystemMember? member) + { + if (coordinate.OfDirective) + { + if (!schema.DirectiveDefinitions.TryGetDirective(coordinate.Name, out var directive)) + { + member = null; + return false; + } + + if (coordinate.ArgumentName is not null) + { + if (directive.Arguments.TryGetField(coordinate.ArgumentName, out var arg)) + { + member = arg; + return true; + } + + member = null; + return false; + } + + member = directive; + return true; + } + + if (!schema.Types.TryGetType(coordinate.Name, out var type)) + { + member = null; + return false; + } + + if (coordinate.MemberName is null) + { + member = type; + return true; + } + + switch (type) + { + case IComplexTypeDefinition complexType: + if (!complexType.Fields.TryGetField(coordinate.MemberName, out var field)) + { + member = null; + return false; + } + + if (coordinate.ArgumentName is not null) + { + if (field.Arguments.TryGetField(coordinate.ArgumentName, out var fieldArg)) + { + member = fieldArg; + return true; + } + + member = null; + return false; + } + + member = field; + return true; + + case IEnumTypeDefinition enumType: + if (enumType.Values.TryGetValue(coordinate.MemberName, out var enumValue)) + { + member = enumValue; + return true; + } + + member = null; + return false; + + case IInputObjectTypeDefinition inputType: + if (inputType.Fields.TryGetField(coordinate.MemberName, out var inputField)) + { + member = inputField; + return true; + } + + member = null; + return false; + + default: + member = null; + return false; + } + } +} diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs index 639c7f634c0..0396bd4ceab 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs @@ -1,5 +1,3 @@ -using System.Collections.Immutable; -using System.Runtime.InteropServices; using HotChocolate.Properties; using HotChocolate.Resolvers; using HotChocolate.Types.Descriptors; @@ -91,7 +89,7 @@ internal static ObjectFieldConfiguration CreateSearchField(IDescriptorContext co if (provider is null) { - return Array.Empty(); + return Array.Empty(); } var query = ctx.ArgumentValue("query"); @@ -115,11 +113,9 @@ internal static ObjectFieldConfiguration CreateSearchField(IDescriptorContext co .Build()); } - IReadOnlyList results; - try { - results = await provider.SearchAsync( + return await provider.SearchAsync( query, first, after.HasValue ? after.Value : null, @@ -140,41 +136,6 @@ internal static ObjectFieldConfiguration CreateSearchField(IDescriptorContext co .SetMessage("The search query exceeds the maximum allowed length.") .Build()); } - - var searchResults = new List(results.Count); - - foreach (var result in results) - { - var definition = ResolveCoordinate(ctx.Schema, result.Coordinate); - - if (definition is null) - { - continue; - } - - var paths = await provider.GetPathsToRootAsync( - result.Coordinate, - maxPaths: 5, - ctx.RequestAborted); - - var pathsToRoot = new ImmutableArray[paths.Count]; - - for (var i = 0; i < paths.Count; i++) - { - pathsToRoot[i] = paths[i].ToStringArray(); - } - - searchResults.Add(new SearchResultInfo - { - Coordinate = result.Coordinate, - Definition = definition, - PathsToRoot = ImmutableCollectionsMarshal.AsImmutableArray(pathsToRoot), - Score = result.Score, - Cursor = result.Cursor - }); - } - - return searchResults; } return CreateConfiguration(descriptor); @@ -213,9 +174,7 @@ internal static ObjectFieldConfiguration CreateDefinitionsField(IDescriptorConte continue; } - var definition = ResolveCoordinate(ctx.Schema, coordinate.Value); - - if (definition is not null) + if (ctx.Schema.TryGetMember(coordinate.Value, out var definition)) { definitions.Add(definition); } @@ -227,67 +186,6 @@ internal static ObjectFieldConfiguration CreateDefinitionsField(IDescriptorConte return CreateConfiguration(descriptor); } - private static object? ResolveCoordinate(Schema schema, SchemaCoordinate coordinate) - { - if (coordinate.OfDirective) - { - if (!schema.DirectiveTypes.TryGetDirective(coordinate.Name, out var directive)) - { - return null; - } - - if (coordinate.ArgumentName is not null) - { - return directive.Arguments.TryGetField(coordinate.ArgumentName, out var arg) - ? arg - : null; - } - - return directive; - } - - if (!schema.Types.TryGetType(coordinate.Name, out var type)) - { - return null; - } - - if (coordinate.MemberName is null) - { - return type; - } - - switch (type) - { - case IComplexTypeDefinition complexType: - if (!complexType.Fields.TryGetField(coordinate.MemberName, out var field)) - { - return null; - } - - if (coordinate.ArgumentName is not null) - { - return field.Arguments.TryGetField(coordinate.ArgumentName, out var fieldArg) - ? fieldArg - : null; - } - - return field; - - case IEnumTypeDefinition enumType: - return enumType.Values.TryGetValue(coordinate.MemberName, out var enumValue) - ? enumValue - : null; - - case IInputObjectTypeDefinition inputType: - return inputType.Fields.TryGetField(coordinate.MemberName, out var inputField) - ? inputField - : null; - - default: - return null; - } - } - private static ObjectFieldConfiguration CreateConfiguration(ObjectFieldDescriptor descriptor) { var configuration = descriptor.CreateConfiguration(); diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/SearchResultInfo.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/SearchResultInfo.cs deleted file mode 100644 index f01f2889dc6..00000000000 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/SearchResultInfo.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace HotChocolate.Types.Introspection; - -/// -/// Represents the backing data for a __SearchResult introspection type instance. -/// -internal sealed class SearchResultInfo -{ - /// - /// Gets the schema coordinate of the matched element. - /// - public required SchemaCoordinate Coordinate { get; init; } - - /// - /// Gets the resolved type system definition (e.g. , - /// , , - /// , or ). - /// - public required object Definition { get; init; } - - /// - /// Gets the paths from the matched element to a root type, - /// each serialized as a string of schema coordinates. - /// - public required IReadOnlyList PathsToRoot { get; init; } - - /// - /// Gets the relevance score of the match, or null if scoring is not supported. - /// - public float? Score { get; init; } - - /// - /// Gets the opaque cursor for pagination. - /// - public required string Cursor { get; init; } -} diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs index 40fe0aa3e52..02ded804fa7 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs @@ -20,7 +20,7 @@ protected override ObjectTypeConfiguration CreateConfiguration(ITypeDiscoveryCon return new ObjectTypeConfiguration( Names.__SearchResult, description: "A search result representing a matched schema element.", - typeof(SearchResultInfo)) + typeof(SchemaSearchResult)) { Fields = { @@ -39,7 +39,7 @@ protected override ObjectTypeConfiguration CreateConfiguration(ITypeDiscoveryCon new(Names.PathsToRoot, "Paths from this element to a root type, each as a list of schema coordinates.", nonNullStringListListType, - pureResolver: Resolvers.PathsToRoot), + resolver: Resolvers.PathsToRootAsync), new(Names.Score, "The relevance score of the match, or null if scoring is not supported.", floatType, @@ -51,19 +51,46 @@ protected override ObjectTypeConfiguration CreateConfiguration(ITypeDiscoveryCon private static class Resolvers { public static object Cursor(IResolverContext context) - => context.Parent().Cursor; + => context.Parent().Cursor; public static object Coordinate(IResolverContext context) - => context.Parent().Coordinate.ToString(); + => context.Parent().Coordinate.ToString(); public static object Definition(IResolverContext context) - => context.Parent().Definition; + { + var result = context.Parent(); + + if (!context.Schema.TryGetMember(result.Coordinate, out var member)) + { + throw new InvalidOperationException( + $"Failed to resolve schema coordinate '{result.Coordinate}'."); + } + + return member; + } + + public static async ValueTask PathsToRootAsync(IResolverContext context) + { + var result = context.Parent(); + var provider = context.Schema.Services.GetRequiredService(); + var paths = await provider.GetPathsToRootAsync( + result.Coordinate, + maxPaths: 5, + context.RequestAborted) + .ConfigureAwait(false); + + var pathsToRoot = new IReadOnlyList[paths.Count]; + + for (var i = 0; i < paths.Count; i++) + { + pathsToRoot[i] = paths[i].ToStringArray(); + } - public static object PathsToRoot(IResolverContext context) - => context.Parent().PathsToRoot; + return pathsToRoot; + } public static object? Score(IResolverContext context) - => context.Parent().Score; + => context.Parent().Score; } public static class Names diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DefaultValueIsInputObject.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DefaultValueIsInputObject.snap index 6735ca53424..a56fe7a6df1 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DefaultValueIsInputObject.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.DefaultValueIsInputObject.snap @@ -1058,7 +1058,7 @@ }, { "name": "pathsToRoot", - "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "description": "Paths from this element to a root type, each as a list of schema coordinates.", "args": [], "type": { "kind": "NON_NULL", @@ -1070,9 +1070,16 @@ "kind": "NON_NULL", "name": null, "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } } } } diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery.snap index 2277696e8a3..533f13260d2 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery.snap @@ -1058,7 +1058,7 @@ }, { "name": "pathsToRoot", - "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "description": "Paths from this element to a root type, each as a list of schema coordinates.", "args": [], "type": { "kind": "NON_NULL", @@ -1070,9 +1070,16 @@ "kind": "NON_NULL", "name": null, "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } } } } diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery_ToJson.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery_ToJson.snap index 2277696e8a3..533f13260d2 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery_ToJson.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/IntrospectionTests.ExecuteGraphiQLIntrospectionQuery_ToJson.snap @@ -1058,7 +1058,7 @@ }, { "name": "pathsToRoot", - "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "description": "Paths from this element to a root type, each as a list of schema coordinates.", "args": [], "type": { "kind": "NON_NULL", @@ -1070,9 +1070,16 @@ "kind": "NON_NULL", "name": null, "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } } } } From 5f33ad48758ecc08cd5277b6757fbf5b6516fc78 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 20:52:00 +0800 Subject: [PATCH 09/24] polish --- .../ISchemaSearchProvider.cs | 8 +- .../Search/BM25SearchProvider.cs | 16 +-- .../Types/Introspection/__SearchResult.cs | 1 - .../Search/BM25SearchProviderTests.cs | 38 +------ .../Completion/SemanticIntrospectionSchema.cs | 2 +- .../Execution/Introspection/Query.cs | 84 ++++++++------- .../Introspection/SchemaCoordinateResolver.cs | 102 ------------------ .../Introspection/SearchResultData.cs | 37 ------- .../Execution/Introspection/__SearchResult.cs | 51 ++++++--- .../Execution/Nodes/FieldContext.cs | 1 + .../Nodes/IntrospectionExecutionNode.cs | 57 +++++++--- .../Execution/Nodes/ResolveFieldValue.cs | 3 + .../Execution/Nodes/ReusableFieldContext.cs | 5 +- .../Execution/Nodes/Selection.cs | 2 + 14 files changed, 147 insertions(+), 260 deletions(-) delete mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaCoordinateResolver.cs delete mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SearchResultData.cs diff --git a/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs b/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs index c46b2f35133..a7fe3908754 100644 --- a/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs +++ b/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs @@ -46,22 +46,20 @@ ValueTask> SearchAsync( /// /// Gets the paths from the specified schema coordinate to a root type. + /// The implementation determines how many paths to return. /// /// /// The schema coordinate from which to trace paths to a root type. /// - /// - /// The maximum number of paths to return. - /// /// /// The cancellation token. /// /// /// A list of instances, - /// each representing an ordered path from the coordinate to a root type. + /// each representing an ordered path from the coordinate to a root type, + /// ordered by path length (shortest first). /// ValueTask> GetPathsToRootAsync( SchemaCoordinate coordinate, - int maxPaths, CancellationToken cancellationToken = default); } diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs index a04d0ad0b90..6ea174bc136 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs @@ -11,6 +11,7 @@ namespace HotChocolate.Types.Introspection; internal sealed class BM25SearchProvider : ISchemaSearchProvider { private const int MaxQueryLength = 1024; + private const int MaxPaths = 5; private readonly ISchemaDefinition _schema; private volatile SearchData? _searchData; @@ -92,15 +93,8 @@ public ValueTask> SearchAsync( /// public ValueTask> GetPathsToRootAsync( SchemaCoordinate coordinate, - int maxPaths, CancellationToken cancellationToken = default) { - if (maxPaths <= 0) - { - return ValueTask.FromResult>( - Array.Empty()); - } - var data = EnsureIndex(); // Determine the type name to start BFS from. @@ -109,7 +103,7 @@ public ValueTask> GetPathsToRootAsync( // If it's a type coordinate, we start from that type directly. var startTypeName = coordinate.Name; - var paths = FindPathsToRoot(data, startTypeName, maxPaths, cancellationToken); + var paths = FindPathsToRoot(data, startTypeName, MaxPaths, cancellationToken); // Build SchemaCoordinatePath instances. // Each path is from the target coordinate back to a root type field. @@ -142,10 +136,10 @@ public ValueTask> GetPathsToRootAsync( // Sort by path length (shortest first). result.Sort(static (a, b) => a.Count.CompareTo(b.Count)); - // Limit to maxPaths. - if (result.Count > maxPaths) + // Limit to MaxPaths. + if (result.Count > MaxPaths) { - result.RemoveRange(maxPaths, result.Count - maxPaths); + result.RemoveRange(MaxPaths, result.Count - MaxPaths); } return ValueTask.FromResult>(result); diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs index 02ded804fa7..e542f34eacf 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs @@ -75,7 +75,6 @@ public static object Definition(IResolverContext context) var provider = context.Schema.Services.GetRequiredService(); var paths = await provider.GetPathsToRootAsync( result.Coordinate, - maxPaths: 5, context.RequestAborted) .ConfigureAwait(false); diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs index e559e8dd3ca..0bfcd93220b 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs @@ -175,7 +175,7 @@ public async Task GetPathsToRootAsync_Should_ReturnEmptyPath_When_CoordinateIsRo // act var paths = await provider.GetPathsToRootAsync( - new SchemaCoordinate("Query"), maxPaths: 5); + new SchemaCoordinate("Query")); // assert Assert.NotEmpty(paths); @@ -192,7 +192,7 @@ public async Task GetPathsToRootAsync_Should_ReturnPath_When_TypeIsReachableFrom // act var paths = await provider.GetPathsToRootAsync( - new SchemaCoordinate("Product"), maxPaths: 5); + new SchemaCoordinate("Product")); // assert Assert.NotEmpty(paths); @@ -209,7 +209,7 @@ public async Task GetPathsToRootAsync_Should_IncludeFieldCoordinate_When_Coordin // act var paths = await provider.GetPathsToRootAsync( - new SchemaCoordinate("Product", "name"), maxPaths: 5); + new SchemaCoordinate("Product", "name")); // assert Assert.NotEmpty(paths); @@ -217,21 +217,6 @@ public async Task GetPathsToRootAsync_Should_IncludeFieldCoordinate_When_Coordin Assert.Equal(new SchemaCoordinate("Product", "name"), paths[0][0]); } - [Fact] - public async Task GetPathsToRootAsync_Should_ReturnEmpty_When_MaxPathsIsZero() - { - // arrange - var schema = CreateTestSchema(); - var provider = new BM25SearchProvider(schema); - - // act - var paths = await provider.GetPathsToRootAsync( - new SchemaCoordinate("Product"), maxPaths: 0); - - // assert - Assert.Empty(paths); - } - [Fact] public async Task GetPathsToRootAsync_Should_ReturnSortedByLength() { @@ -241,7 +226,7 @@ public async Task GetPathsToRootAsync_Should_ReturnSortedByLength() // act var paths = await provider.GetPathsToRootAsync( - new SchemaCoordinate("Product"), maxPaths: 10); + new SchemaCoordinate("Product")); // assert for (var i = 1; i < paths.Count; i++) @@ -250,21 +235,6 @@ public async Task GetPathsToRootAsync_Should_ReturnSortedByLength() } } - [Fact] - public async Task GetPathsToRootAsync_Should_LimitResults_When_MaxPathsIsSmall() - { - // arrange - var schema = CreateTestSchema(); - var provider = new BM25SearchProvider(schema); - - // act - var paths = await provider.GetPathsToRootAsync( - new SchemaCoordinate("Product"), maxPaths: 1); - - // assert - Assert.True(paths.Count <= 1); - } - [Fact] public async Task SearchAsync_Should_ThrowArgumentNullException_When_QueryIsNull() { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/SemanticIntrospectionSchema.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/SemanticIntrospectionSchema.cs index b6534f83591..0a5f8309f20 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/SemanticIntrospectionSchema.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/SemanticIntrospectionSchema.cs @@ -12,7 +12,7 @@ type __SearchResult { cursor: String! coordinate: String! definition: __SchemaDefinition! - pathsToRoot: [String!]! + pathsToRoot: [[String!]!]! score: Float } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs index f2522ea48bb..0903ec869c9 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs @@ -1,8 +1,6 @@ -using System.Diagnostics; using HotChocolate.Features; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Language; -using HotChocolate.Types; using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Fusion.Execution.Introspection; @@ -29,7 +27,7 @@ public void OnApplyResolver(string fieldName, IFeatureCollection features) break; case "__search" when _enableSemanticIntrospection: - features.Set(new ResolveFieldValue(Search)); + features.Set(new AsyncResolveFieldValue(SearchAsync)); break; case "__definitions" when _enableSemanticIntrospection: @@ -54,7 +52,9 @@ public static void Type(FieldContext context) } } - public static void Search(FieldContext context) + private const int MaxFirstLimit = 150; + + public static async ValueTask SearchAsync(FieldContext context) { var provider = context.Schema.Services.GetService(); @@ -67,8 +67,25 @@ public static void Search(FieldContext context) var query = context.ArgumentValue("query").Value; var first = int.Parse(context.ArgumentValue("first").Value); + if (first <= 0) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage("The `first` argument must be greater than zero.") + .Build()); + } + + if (first > MaxFirstLimit) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage($"The `first` argument must not exceed {MaxFirstLimit}.") + .Build()); + } + string? after = null; var afterNode = context.ArgumentValue("after"); + if (afterNode is StringValueNode afterString) { after = afterString.Value; @@ -76,6 +93,7 @@ public static void Search(FieldContext context) float? minScore = null; var minScoreNode = context.ArgumentValue("min_score"); + if (minScoreNode is FloatValueNode floatNode) { minScore = float.Parse(floatNode.Value, System.Globalization.CultureInfo.InvariantCulture); @@ -85,44 +103,38 @@ public static void Search(FieldContext context) minScore = float.Parse(intNode.Value, System.Globalization.CultureInfo.InvariantCulture); } - var results = provider.SearchAsync(query, first, after, minScore).AsTask().GetAwaiter().GetResult(); - - var searchResults = new List(results.Count); + IReadOnlyList results; - foreach (var result in results) + try { - var definition = SchemaCoordinateResolver.Resolve(context.Schema, result.Coordinate); - - if (definition is null) - { - continue; - } - - var paths = provider.GetPathsToRootAsync(result.Coordinate, maxPaths: 5).AsTask().GetAwaiter().GetResult(); - - var pathStrings = new List(paths.Count); - - foreach (var path in paths) - { - pathStrings.Add(string.Join(" > ", path.Select(c => c.ToString()))); - } - - searchResults.Add(new SearchResultData - { - Coordinate = result.Coordinate, - Definition = definition, - PathsToRoot = pathStrings, - Score = result.Score, - Cursor = result.Cursor - }); + results = await provider.SearchAsync( + query, + first, + after, + minScore, + context.RequestAborted).ConfigureAwait(false); + } + catch (InvalidSearchCursorException) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage("The value of `after` is not a valid cursor.") + .Build()); + } + catch (SearchQueryTooLargeException) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage("The search query exceeds the maximum allowed length.") + .Build()); } - var list = context.FieldResult.CreateListValue(searchResults.Count); + var list = context.FieldResult.CreateListValue(results.Count); var i = 0; foreach (var element in list.EnumerateArray()) { - context.AddRuntimeResult(searchResults[i++]); + context.AddRuntimeResult(results[i++]); element.CreateObjectValue(context.Selection, context.IncludeFlags); } } @@ -144,9 +156,7 @@ public static void Definitions(FieldContext context) continue; } - var definition = SchemaCoordinateResolver.Resolve(context.Schema, coordinate.Value); - - if (definition is not null) + if (context.Schema.TryGetMember(coordinate.Value, out var definition)) { definitions.Add(definition); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaCoordinateResolver.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaCoordinateResolver.cs deleted file mode 100644 index d4938b5dcbe..00000000000 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaCoordinateResolver.cs +++ /dev/null @@ -1,102 +0,0 @@ -using HotChocolate.Types; - -namespace HotChocolate.Fusion.Execution.Introspection; - -/// -/// Resolves a to the corresponding -/// type system definition from a schema. -/// -internal static class SchemaCoordinateResolver -{ - /// - /// Resolves the specified schema coordinate to a type system definition. - /// - /// The schema to resolve against. - /// The schema coordinate to resolve. - /// - /// The resolved definition, or null if the coordinate - /// does not match any element in the schema. - /// - public static object? Resolve(ISchemaDefinition schema, SchemaCoordinate coordinate) - { - if (coordinate.OfDirective) - { - if (!schema.DirectiveDefinitions.TryGetDirective(coordinate.Name, out var directive)) - { - return null; - } - - if (coordinate.ArgumentName is not null) - { - return directive.Arguments.TryGetField(coordinate.ArgumentName, out var arg) - ? arg - : null; - } - - return directive; - } - - if (!schema.Types.TryGetType(coordinate.Name, out var type)) - { - return null; - } - - if (coordinate.MemberName is null) - { - return type; - } - - switch (type) - { - case IComplexTypeDefinition complexType: - if (!complexType.Fields.TryGetField(coordinate.MemberName, out var field)) - { - return null; - } - - if (coordinate.ArgumentName is not null) - { - return field.Arguments.TryGetField(coordinate.ArgumentName, out var fieldArg) - ? fieldArg - : null; - } - - return field; - - case IEnumTypeDefinition enumType: - return enumType.Values.TryGetValue(coordinate.MemberName, out var enumValue) - ? enumValue - : null; - - case IInputObjectTypeDefinition inputType: - return inputType.Fields.TryGetField(coordinate.MemberName, out var inputField) - ? inputField - : null; - - default: - return null; - } - } - - /// - /// Gets the introspection type name for a resolved definition object - /// within the __SchemaDefinition union. - /// - /// The resolved definition object. - /// - /// The introspection type name, or null if the definition - /// is not a recognized member of the __SchemaDefinition union. - /// - public static string? GetTypeName(object definition) - { - return definition switch - { - ITypeDefinition => "__Type", - IOutputFieldDefinition => "__Field", - IInputValueDefinition => "__InputValue", - IEnumValue => "__EnumValue", - IDirectiveDefinition => "__Directive", - _ => null - }; - } -} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SearchResultData.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SearchResultData.cs deleted file mode 100644 index d8c2f2f9e61..00000000000 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SearchResultData.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace HotChocolate.Fusion.Execution.Introspection; - -/// -/// Represents the backing data for a __SearchResult introspection type instance. -/// -internal sealed class SearchResultData -{ - /// - /// Gets the schema coordinate of the matched element. - /// - public required SchemaCoordinate Coordinate { get; init; } - - /// - /// Gets the resolved type system definition (e.g. , - /// , - /// , - /// , - /// or ). - /// - public required object Definition { get; init; } - - /// - /// Gets the paths from the matched element to a root type, - /// each serialized as a string of schema coordinates. - /// - public required IReadOnlyList PathsToRoot { get; init; } - - /// - /// Gets the relevance score of the match, or null if scoring is not supported. - /// - public float? Score { get; init; } - - /// - /// Gets the opaque cursor for pagination. - /// - public required string Cursor { get; init; } -} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs index e6211fff091..9854bf2a9c0 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs @@ -1,5 +1,6 @@ using HotChocolate.Features; using HotChocolate.Fusion.Execution.Nodes; +using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Fusion.Execution.Introspection; @@ -23,7 +24,7 @@ public void OnApplyResolver(string fieldName, IFeatureCollection features) break; case "pathsToRoot": - features.Set(new ResolveFieldValue(PathsToRoot)); + features.Set(new AsyncResolveFieldValue(PathsToRootAsync)); break; case "score": @@ -33,37 +34,57 @@ public void OnApplyResolver(string fieldName, IFeatureCollection features) } public static void Cursor(FieldContext context) - => context.WriteValue(context.Parent().Cursor); + => context.WriteValue(context.Parent().Cursor); public static void Coordinate(FieldContext context) - => context.WriteValue(context.Parent().Coordinate.ToString()); + => context.WriteValue(context.Parent().Coordinate.ToString()); public static void Definition(FieldContext context) { - var data = context.Parent(); + var result = context.Parent(); + + if (!context.Schema.TryGetMember(result.Coordinate, out var member)) + { + throw new InvalidOperationException( + $"Failed to resolve schema coordinate '{result.Coordinate}'."); + } + context.FieldResult.CreateObjectValue(context.Selection, context.IncludeFlags); - context.AddRuntimeResult(data.Definition); + context.AddRuntimeResult(member); } - public static void PathsToRoot(FieldContext context) + public static async ValueTask PathsToRootAsync(FieldContext context) { - var data = context.Parent(); - var paths = data.PathsToRoot; - var list = context.FieldResult.CreateListValue(paths.Count); + var result = context.Parent(); + var provider = context.Schema.Services.GetRequiredService(); + var paths = await provider.GetPathsToRootAsync( + result.Coordinate, + context.RequestAborted) + .ConfigureAwait(false); - var index = 0; - foreach (var element in list.EnumerateArray()) + var outerList = context.FieldResult.CreateListValue(paths.Count); + + var outerIndex = 0; + foreach (var outerElement in outerList.EnumerateArray()) { - element.SetStringValue(paths[index++]); + var path = paths[outerIndex++].ToStringArray(); + var innerList = outerElement.CreateListValue(path.Length); + + var innerIndex = 0; + foreach (var innerElement in innerList.EnumerateArray()) + { + innerElement.SetStringValue(path[innerIndex++]); + } } } public static void Score(FieldContext context) { - var data = context.Parent(); - if (data.Score.HasValue) + var result = context.Parent(); + + if (result.Score.HasValue) { - context.WriteFloatValue(data.Score.Value); + context.WriteFloatValue(result.Score.Value); } } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldContext.cs index 050687a636e..bdf2150df48 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/FieldContext.cs @@ -13,5 +13,6 @@ internal abstract class FieldContext public abstract ulong IncludeFlags { get; } public abstract T Parent(); public abstract T ArgumentValue(string name) where T : IValueNode; + public abstract CancellationToken RequestAborted { get; } public abstract void AddRuntimeResult(T result); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs index 9d89394bb1d..7b4cbceb9aa 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using HotChocolate.Fusion.Execution.Introspection; using HotChocolate.Fusion.Text.Json; using HotChocolate.Language; using HotChocolate.Types; @@ -50,7 +49,7 @@ public IntrospectionExecutionNode( /// public ReadOnlySpan Selections => _selections; - protected override ValueTask OnExecuteAsync( + protected override async ValueTask OnExecuteAsync( OperationPlanContext context, CancellationToken cancellationToken = default) { @@ -61,7 +60,7 @@ protected override ValueTask OnExecuteAsync( foreach (var selection in _selections) { - if (selection.Resolver is null + if ((selection.Resolver is null && selection.AsyncResolver is null) || !selection.Field.IsIntrospectionField || !selection.IsIncluded(context.IncludeFlags)) { @@ -72,32 +71,49 @@ protected override ValueTask OnExecuteAsync( backlog.Push((null, selection, property)); } - ExecuteSelections(context, backlog); + await ExecuteSelectionsAsync(context, backlog, cancellationToken).ConfigureAwait(false); context.AddPartialResults(resultBuilder.Build(), _resultSelectionSet); - return new ValueTask(ExecutionStatus.Success); + return ExecutionStatus.Success; } protected override IDisposable CreateScope(OperationPlanContext context) => context.DiagnosticEvents.ExecuteIntrospectionNode(context, this); - private static void ExecuteSelections( + private static async ValueTask ExecuteSelectionsAsync( OperationPlanContext context, - Stack<(object? Parent, Selection Selection, SourceResultElementBuilder Result)> backlog) + Stack<(object? Parent, Selection Selection, SourceResultElementBuilder Result)> backlog, + CancellationToken cancellationToken) { var operation = context.OperationPlan.Operation; var fieldContext = new ReusableFieldContext( context.Schema, context.Variables, context.IncludeFlags, - context.CreateRentedBuffer()); + context.CreateRentedBuffer(), + cancellationToken); while (backlog.TryPop(out var current)) { + cancellationToken.ThrowIfCancellationRequested(); + var (parent, selection, result) = current; fieldContext.Initialize(parent, selection, result); - selection.Resolver?.Invoke(fieldContext); + if (selection.AsyncResolver is { } asyncResolver) + { + await asyncResolver.Invoke(fieldContext).ConfigureAwait(false); + } + else if (selection.Resolver is { } resolver) + { + resolver.Invoke(fieldContext); + } + else + { + throw new InvalidOperationException( + $"No resolver found for selection '{selection.ResponseName}' " + + $"on field '{selection.Field.Name}'."); + } if (!selection.IsLeaf) { @@ -185,18 +201,27 @@ private static IObjectTypeDefinition ResolveObjectType( return objectType; } - // For abstract types, determine the concrete type from the runtime result. - var typeName = SchemaCoordinateResolver.GetTypeName(runtimeResult!); - - if (typeName is not null - && schema.Types.TryGetType(typeName, out var resolvedType) + // For abstract types (unions), determine the concrete introspection type + // from the runtime result. + var typeName = runtimeResult switch + { + ITypeDefinition => "__Type", + IOutputFieldDefinition => "__Field", + IInputValueDefinition => "__InputValue", + IEnumValue => "__EnumValue", + IDirectiveDefinition => "__Directive", + _ => throw new InvalidOperationException( + $"Cannot determine the concrete object type for abstract type '{namedType}'" + + $" from runtime result of type '{runtimeResult?.GetType().Name}'.") + }; + + if (schema.Types.TryGetType(typeName, out var resolvedType) && resolvedType is IObjectTypeDefinition resolvedObjectType) { return resolvedObjectType; } throw new InvalidOperationException( - $"Cannot determine the concrete object type for abstract type '{namedType}'" - + $" from runtime result of type '{runtimeResult?.GetType().Name}'."); + $"Introspection type '{typeName}' not found in schema."); } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResolveFieldValue.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResolveFieldValue.cs index 91966c93515..17f80794e01 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResolveFieldValue.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ResolveFieldValue.cs @@ -2,3 +2,6 @@ namespace HotChocolate.Fusion.Execution.Nodes; internal delegate void ResolveFieldValue( FieldContext context); + +internal delegate ValueTask AsyncResolveFieldValue( + FieldContext context); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ReusableFieldContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ReusableFieldContext.cs index 4dfc1c8553b..9ba0e91a1bc 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ReusableFieldContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ReusableFieldContext.cs @@ -10,7 +10,8 @@ internal sealed class ReusableFieldContext( ISchemaDefinition schema, IVariableValueCollection variableValues, ulong includeFlags, - PooledArrayWriter memory) + PooledArrayWriter memory, + CancellationToken cancellationToken) : FieldContext { private readonly Dictionary _arguments = []; @@ -31,6 +32,8 @@ internal sealed class ReusableFieldContext( public override ulong IncludeFlags => includeFlags; + public override CancellationToken RequestAborted => cancellationToken; + public override T Parent() => (T)_parent!; public override T ArgumentValue(string name) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs index 728dc015d4b..3b2e19b56b5 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/Selection.cs @@ -85,6 +85,8 @@ public Selection( internal ResolveFieldValue? Resolver => Field.Features.Get(); + internal AsyncResolveFieldValue? AsyncResolver => Field.Features.Get(); + IEnumerable ISelection.GetSyntaxNodes() { for (var i = 0; i < SyntaxNodes.Length; i++) From a1ca9247f796f08e8ccef69b2088c8701c6d4ed9 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Sun, 12 Apr 2026 22:14:51 +0800 Subject: [PATCH 10/24] more stuff --- .../Search/BM25SearchProvider.cs | 83 ++-- .../Introspection/Search/SchemaIndexer.cs | 29 +- .../SemanticIntrospectionTests.cs | 426 ++++++++++++++---- .../Search/BM25SearchProviderTests.cs | 9 +- .../Search/SchemaIndexerTests.cs | 8 +- .../SemanticIntrospectionTests.cs | 39 +- 6 files changed, 416 insertions(+), 178 deletions(-) diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs index 6ea174bc136..f35577fe9e2 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs @@ -96,74 +96,41 @@ public ValueTask> GetPathsToRootAsync( CancellationToken cancellationToken = default) { var data = EnsureIndex(); - - // Determine the type name to start BFS from. - // If the coordinate has a member name, it's a field/value on a type; - // the starting type is the coordinate's Name. - // If it's a type coordinate, we start from that type directly. - var startTypeName = coordinate.Name; - - var paths = FindPathsToRoot(data, startTypeName, MaxPaths, cancellationToken); - - // Build SchemaCoordinatePath instances. - // Each path is from the target coordinate back to a root type field. - var result = new List(paths.Count); - - foreach (var path in paths) - { - var segments = new List(); - - // If the original coordinate has a member (it's a field/value), - // include it as the first segment. - if (coordinate.MemberName is not null) - { - segments.Add(coordinate); - } - - // Add the type-level coordinate for the starting type. - segments.Add(new SchemaCoordinate(startTypeName)); - - // Add intermediate hops (type.field coordinates leading to root). - foreach (var (typeName, fieldName) in path) - { - segments.Add(new SchemaCoordinate(typeName, fieldName)); - segments.Add(new SchemaCoordinate(typeName)); - } - - result.Add(new SchemaCoordinatePath(CollectionsMarshal.AsSpan(segments))); - } + var result = FindPathsToRoot(data, coordinate, MaxPaths, cancellationToken); // Sort by path length (shortest first). result.Sort(static (a, b) => a.Count.CompareTo(b.Count)); - // Limit to MaxPaths. - if (result.Count > MaxPaths) - { - result.RemoveRange(MaxPaths, result.Count - MaxPaths); - } - return ValueTask.FromResult>(result); } - private static List> FindPathsToRoot( + private static List FindPathsToRoot( SearchData data, - string startTypeName, + SchemaCoordinate coordinate, int maxPaths, CancellationToken cancellationToken) { var rootTypeNames = data.RootTypeNames; var reverseMap = data.ReverseMap; - var paths = new List>(); + var startTypeName = coordinate.Name; + var paths = new List(); - // If the start type is already a root type, return a single empty path. + // If the start type is already a root type, the path is just the coordinate itself + // for field coordinates, or empty for type coordinates. if (rootTypeNames.Contains(startTypeName)) { - paths.Add([]); + if (coordinate.MemberName is not null) + { + paths.Add(new SchemaCoordinatePath([coordinate])); + } + return paths; } // BFS: each queue entry is (currentTypeName, pathSoFar). - var queue = new Queue<(string TypeName, List Path)>(); + // The path accumulates field hops only; the target coordinate is appended + // when finalizing a completed path. + var queue = new Queue<(string TypeName, List Path)>(); queue.Enqueue((startTypeName, [])); var visited = new HashSet(StringComparer.Ordinal) { startTypeName }; @@ -181,16 +148,22 @@ private static List> FindPathsToRoot( foreach (var reference in references) { - if (!visited.Add(reference.TypeName)) + if (!visited.Add(reference.Name)) { continue; } - var newPath = new List(currentPath) { reference }; + var newPath = new List(currentPath.Count + 1) { reference }; + newPath.AddRange(currentPath); - if (rootTypeNames.Contains(reference.TypeName)) + if (rootTypeNames.Contains(reference.Name)) { - paths.Add(newPath); + if (coordinate.MemberName is not null) + { + newPath.Add(coordinate); + } + + paths.Add(new SchemaCoordinatePath(CollectionsMarshal.AsSpan(newPath))); if (paths.Count >= maxPaths) { @@ -199,7 +172,7 @@ private static List> FindPathsToRoot( } else { - queue.Enqueue((reference.TypeName, newPath)); + queue.Enqueue((reference.Name, newPath)); } } } @@ -287,12 +260,12 @@ private static int DecodeCursor(string cursor, int resultCount) /// private sealed class SearchData( BM25Index index, - FrozenDictionary reverseMap, + FrozenDictionary reverseMap, FrozenSet rootTypeNames) { public BM25Index Index { get; } = index; - public FrozenDictionary ReverseMap { get; } = reverseMap; + public FrozenDictionary ReverseMap { get; } = reverseMap; public FrozenSet RootTypeNames { get; } = rootTypeNames; } diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs index bf9ce8c538c..cb6983d3806 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs @@ -19,7 +19,7 @@ internal static class SchemaIndexer public static SchemaIndexResult Index(ISchemaDefinition schema) { var documents = new List(); - var reverseMap = new Dictionary>(StringComparer.Ordinal); + var reverseMap = new Dictionary>(StringComparer.Ordinal); foreach (var type in schema.Types) { @@ -50,19 +50,8 @@ public static SchemaIndexResult Index(ISchemaDefinition schema) } } - // Index directive definitions. - foreach (var directive in schema.DirectiveDefinitions) - { - // Skip introspection directives. - if (directive.Name.StartsWith("__", StringComparison.Ordinal)) - { - continue; - } - - documents.Add(new BM25Document( - new SchemaCoordinate(directive.Name, ofDirective: true), - BuildText(directive.Name, directive.Description))); - } + // Directives are not indexed for search — they have no fetch path. + // They remain accessible via __definitions coordinate lookup. return new SchemaIndexResult(documents, reverseMap); } @@ -70,7 +59,7 @@ public static SchemaIndexResult Index(ISchemaDefinition schema) private static void IndexComplexTypeFields( IComplexTypeDefinition complexType, List documents, - Dictionary> reverseMap) + Dictionary> reverseMap) { foreach (var field in complexType.Fields) { @@ -93,7 +82,7 @@ private static void IndexComplexTypeFields( reverseMap[returnType.Name] = references; } - references.Add(new TypeFieldReference(complexType.Name, field.Name)); + references.Add(new SchemaCoordinate(complexType.Name, field.Name)); } } @@ -131,17 +120,11 @@ private static string BuildText(string name, string? description) return string.Concat(name, " ", description); } - /// - /// Represents a reference from a type's field back to that type, - /// used in the reverse adjacency map for path-to-root traversal. - /// - internal readonly record struct TypeFieldReference(string TypeName, string FieldName); - /// /// The result of indexing a schema, containing the indexed documents /// and a reverse adjacency map for path-to-root traversal. /// internal readonly record struct SchemaIndexResult( List Documents, - Dictionary> ReverseMap); + Dictionary> ReverseMap); } diff --git a/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs index b80fef76144..2f1f6a669bb 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs @@ -33,19 +33,19 @@ public async Task Search_Should_ReturnResults_When_QueryMatchesFieldName() }, { "coordinate": "Query.userByEmail", - "score": 0.9113392233848572 + "score": 0.8974776268005371 }, { "coordinate": "User.name", - "score": 0.732876718044281 + "score": 0.71161288022995 }, { "coordinate": "User.email", - "score": 0.732876718044281 + "score": 0.71161288022995 }, { "coordinate": "User.age", - "score": 0.699621856212616 + "score": 0.6750305891036987 } ] } @@ -82,7 +82,7 @@ public async Task Search_Should_ReturnResults_When_QueryMatchesDescription() }, { "coordinate": "Query.userByEmail", - "score": 0.9289592504501343 + "score": 0.9191438555717468 } ] } @@ -119,7 +119,7 @@ public async Task Search_Should_RespectFirstArgument() }, { "coordinate": "Product.name", - "score": 0.827045202255249 + "score": 0.8174113631248474 } ] } @@ -281,37 +281,53 @@ public async Task Search_Should_IncludePathsToRoot() { "coordinate": "Product.name", "pathsToRoot": [ - "Product.name > Product > Query.productSearch > Query" + [ + "Query.productSearch", + "Product.name" + ] ] }, { "coordinate": "Query.productSearch", "pathsToRoot": [ - "Query.productSearch > Query" + [ + "Query.productSearch" + ] ] }, { "coordinate": "User.name", "pathsToRoot": [ - "User.name > User > Query.userByEmail > Query" + [ + "Query.userByEmail", + "User.name" + ] ] }, { "coordinate": "Product", "pathsToRoot": [ - "Product > Query.productSearch > Query" + [ + "Query.productSearch" + ] ] }, { "coordinate": "Product.category", "pathsToRoot": [ - "Product.category > Product > Query.productSearch > Query" + [ + "Query.productSearch", + "Product.category" + ] ] }, { "coordinate": "Product.price", "pathsToRoot": [ - "Product.price > Product > Query.productSearch > Query" + [ + "Query.productSearch", + "Product.price" + ] ] } ] @@ -349,19 +365,19 @@ public async Task Search_Should_ReturnScoresInDescendingOrder() }, { "coordinate": "Product.name", - "score": 0.827045202255249 + "score": 0.8174113631248474 }, { "coordinate": "Product.category", - "score": 0.827045202255249 + "score": 0.8174113631248474 }, { "coordinate": "Product.price", - "score": 0.7444983124732971 + "score": 0.7237380146980286 }, { "coordinate": "Query.productSearch", - "score": 0.6475508809089661 + "score": 0.6175786256790161 } ] } @@ -436,6 +452,18 @@ ... on __Field { ] } }, + { + "coordinate": "Query.productSearch", + "definition": { + "name": "productSearch", + "description": "Search for products by name or category", + "args": [ + { + "name": "term" + } + ] + } + }, { "coordinate": "User.name", "definition": { @@ -452,22 +480,6 @@ ... on __Field { "args": [] } }, - { - "coordinate": "Query.productSearch", - "definition": { - "name": "productSearch", - "description": "Search for products by name or category", - "args": [ - { - "name": "term" - } - ] - } - }, - { - "coordinate": "@specifiedBy", - "definition": {} - }, { "coordinate": "Float", "definition": {} @@ -813,7 +825,7 @@ ... on __Type { }, { "coordinate": "User.email", - "score": 0.6059101819992065, + "score": 0.5982846021652222, "definition": { "fieldName": "email", "description": "The email address of the user" @@ -821,7 +833,7 @@ ... on __Type { }, { "coordinate": "User", - "score": 0.19035416841506958, + "score": 0.1880880743265152, "definition": { "typeName": "User", "kind": "OBJECT" @@ -829,15 +841,23 @@ ... on __Type { }, { "coordinate": "Query.orderById", - "score": 0.1684975028038025, + "score": 0.18292827904224396, "definition": { "fieldName": "orderById", "description": "Retrieve an order by its unique identifier" } }, + { + "coordinate": "Query.productSearch", + "score": 0.1353328675031662, + "definition": { + "fieldName": "productSearch", + "description": "Search for products by name or category" + } + }, { "coordinate": "User.name", - "score": 0.13950614631175995, + "score": 0.13384589552879333, "definition": { "fieldName": "name", "description": "The full name of the user" @@ -845,28 +865,15 @@ ... on __Type { }, { "coordinate": "User.age", - "score": 0.13317593932151794, + "score": 0.1269652098417282, "definition": { "fieldName": "age", "description": "The age of the user in years" } }, - { - "coordinate": "Query.productSearch", - "score": 0.12739528715610504, - "definition": { - "fieldName": "productSearch", - "description": "Search for products by name or category" - } - }, - { - "coordinate": "@specifiedBy", - "score": 0.11778272688388824, - "definition": {} - }, { "coordinate": "Float", - "score": 0.07715816795825958, + "score": 0.07807315140962601, "definition": { "typeName": "Float", "kind": "SCALAR" @@ -921,39 +928,19 @@ ... on __Type { }, { "coordinate": "ID", - "score": 0.4061276316642761, + "score": 0.5671203136444092, "definition": { "typeName": "ID", "kind": "SCALAR" } }, - { - "coordinate": "@specifiedBy", - "score": 0.354110985994339, - "definition": {} - }, - { - "coordinate": "@skip", - "score": 0.2957645654678345, - "definition": {} - }, - { - "coordinate": "@include", - "score": 0.286235511302948, - "definition": {} - }, { "coordinate": "Product", - "score": 0.2594583332538605, + "score": 0.2852042019367218, "definition": { "typeName": "Product", "kind": "OBJECT" } - }, - { - "coordinate": "@deprecated", - "score": 0.19725997745990753, - "definition": {} } ] } @@ -1004,7 +991,7 @@ ... on __Type { }, { "coordinate": "Order.status", - "score": 0.8016138672828674, + "score": 0.789767861366272, "definition": { "fieldName": "status", "description": "The current order status" @@ -1012,7 +999,7 @@ ... on __Type { }, { "coordinate": "User.name", - "score": 0.28660982847213745, + "score": 0.32556161284446716, "definition": { "fieldName": "name", "description": "The full name of the user" @@ -1020,7 +1007,7 @@ ... on __Type { }, { "coordinate": "User.email", - "score": 0.28660982847213745, + "score": 0.32556161284446716, "definition": { "fieldName": "email", "description": "The email address of the user" @@ -1028,7 +1015,7 @@ ... on __Type { }, { "coordinate": "User", - "score": 0.2793460786342621, + "score": 0.31599652767181396, "definition": { "typeName": "User", "kind": "OBJECT" @@ -1036,7 +1023,7 @@ ... on __Type { }, { "coordinate": "User.age", - "score": 0.27486422657966614, + "score": 0.3104621171951294, "definition": { "fieldName": "age", "description": "The age of the user in years" @@ -1044,7 +1031,7 @@ ... on __Type { }, { "coordinate": "Order.id", - "score": 0.25921085476875305, + "score": 0.25484970211982727, "definition": { "fieldName": "id", "description": "The unique order identifier" @@ -1052,7 +1039,7 @@ ... on __Type { }, { "coordinate": "Order.total", - "score": 0.25921085476875305, + "score": 0.25484970211982727, "definition": { "fieldName": "total", "description": "The order total amount" @@ -1060,18 +1047,18 @@ ... on __Type { }, { "coordinate": "Order", - "score": 0.25802066922187805, + "score": 0.24078181385993958, "definition": { "typeName": "Order", "kind": "OBJECT" } }, { - "coordinate": "Query.orderById", - "score": 0.2061748057603836, + "coordinate": "String", + "score": 0.2084837555885315, "definition": { - "fieldName": "orderById", - "description": "Retrieve an order by its unique identifier" + "typeName": "String", + "kind": "SCALAR" } } ] @@ -1164,6 +1151,275 @@ ... on __Type { """); } + [Fact] + public async Task PathsToRoot_Should_BeCorrect_When_CoordinateIsFieldOnRootType() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "orderById", first: 1) { + coordinate + pathsToRoot + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Query.orderById", + "pathsToRoot": [ + [ + "Query.orderById" + ] + ] + } + ] + } + } + """); + } + + [Fact] + public async Task PathsToRoot_Should_BeCorrect_When_CoordinateIsFieldOnNestedType() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "email address", first: 1) { + coordinate + pathsToRoot + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "User.email", + "pathsToRoot": [ + [ + "Query.userByEmail", + "User.email" + ] + ] + } + ] + } + } + """); + } + + [Fact] + public async Task PathsToRoot_Should_BeCorrect_When_CoordinateIsObjectType() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "Product", first: 1) { + coordinate + pathsToRoot + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Product", + "pathsToRoot": [ + [ + "Query.productSearch" + ] + ] + } + ] + } + } + """); + } + + [Fact] + public async Task PathsToRoot_Should_BeCorrect_When_CoordinateIsScalarType() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "Decimal", first: 1) { + coordinate + pathsToRoot + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Decimal", + "pathsToRoot": [ + [ + "Query.productSearch", + "Product.price" + ] + ] + } + ] + } + } + """); + } + + [Fact] + public async Task PathsToRoot_Should_BeCorrect_When_CoordinateIsEnumType() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "OrderStatus", first: 2) { + coordinate + pathsToRoot + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Order.status", + "pathsToRoot": [ + [ + "Query.orderById", + "Order.status" + ] + ] + }, + { + "coordinate": "OrderStatus", + "pathsToRoot": [ + [ + "Query.orderById", + "Order.status" + ] + ] + } + ] + } + } + """); + } + + [Fact] + public async Task PathsToRoot_Should_BeCorrect_When_CoordinateIsEnumValue() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "PENDING", first: 1) { + coordinate + pathsToRoot + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "OrderStatus.PENDING", + "pathsToRoot": [ + [ + "Query.orderById", + "Order.status", + "OrderStatus.PENDING" + ] + ] + } + ] + } + } + """); + } + + [Fact] + public async Task PathsToRoot_Should_BeEmpty_When_CoordinateIsRootType() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "Query", first: 1) { + coordinate + pathsToRoot + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "__search": [ + { + "coordinate": "Query", + "pathsToRoot": [] + } + ] + } + } + """); + } + private static Schema CreateSchema() { return SchemaBuilder.New() diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs index 0bfcd93220b..d17220e6fe0 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs @@ -178,9 +178,8 @@ public async Task GetPathsToRootAsync_Should_ReturnEmptyPath_When_CoordinateIsRo new SchemaCoordinate("Query")); // assert - Assert.NotEmpty(paths); - // Path from root to itself should be short (just the type coordinate). - Assert.True(paths[0].Count <= 1); + // A root type is already at root — no field path needed. + Assert.Empty(paths); } [Fact] @@ -213,8 +212,8 @@ public async Task GetPathsToRootAsync_Should_IncludeFieldCoordinate_When_Coordin // assert Assert.NotEmpty(paths); - // The path should start with the field coordinate. - Assert.Equal(new SchemaCoordinate("Product", "name"), paths[0][0]); + // The path should end with the target field coordinate. + Assert.Equal(new SchemaCoordinate("Product", "name"), paths[0][^1]); } [Fact] diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs index f33c6071633..0a2f4a30bf8 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs @@ -103,7 +103,7 @@ public void Index_Should_IndexInputObjectFields() } [Fact] - public void Index_Should_IndexDirectiveDefinitions() + public void Index_Should_NotIndexDirectiveDefinitions() { // arrange var schema = SchemaBuilder.New() @@ -120,8 +120,8 @@ public void Index_Should_IndexDirectiveDefinitions() var result = SchemaIndexer.Index(schema); var documents = result.Documents; - // assert - Assert.Contains(documents, d => d.Coordinate == new SchemaCoordinate("cached", ofDirective: true)); + // assert — directives have no fetch path and are excluded from search. + Assert.DoesNotContain(documents, d => d.Coordinate.OfDirective); } [Fact] @@ -145,7 +145,7 @@ public void Index_Should_BuildReverseAdjacencyMap() Assert.True(reverseMap.ContainsKey("Product")); var references = reverseMap["Product"]; - Assert.Contains(references, r => r.TypeName == "Query" && r.FieldName == "product"); + Assert.Contains(references, r => r.Name == "Query" && r.MemberName == "product"); } [Fact] diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs index 82b73005097..865fabb28c9 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs @@ -327,37 +327,64 @@ public async Task Search_Should_IncludePathsToRoot() { "coordinate": "Product.name", "pathsToRoot": [ - "Product.name > Product > Query.productSearch > Query" + [ + "Product.name", + "Product", + "Query.productSearch", + "Query" + ] ] }, { "coordinate": "Query.productSearch", "pathsToRoot": [ - "Query.productSearch > Query" + [ + "Query.productSearch", + "Query" + ] ] }, { "coordinate": "User.name", "pathsToRoot": [ - "User.name > User > Query.userByEmail > Query" + [ + "User.name", + "User", + "Query.userByEmail", + "Query" + ] ] }, { "coordinate": "Product", "pathsToRoot": [ - "Product > Query.productSearch > Query" + [ + "Product", + "Query.productSearch", + "Query" + ] ] }, { "coordinate": "Product.category", "pathsToRoot": [ - "Product.category > Product > Query.productSearch > Query" + [ + "Product.category", + "Product", + "Query.productSearch", + "Query" + ] ] }, { "coordinate": "Product.price", "pathsToRoot": [ - "Product.price > Product > Query.productSearch > Query" + [ + "Product.price", + "Product", + "Query.productSearch", + "Query" + ] ] } ] From ac27b73dab5acae7a162072a055625b2f8325ea3 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 06:12:54 +0800 Subject: [PATCH 11/24] fixes --- .../Completion/CompositeSchemaContext.cs | 18 ++++++++- .../SchemaDefinitionTypeResolver.cs | 37 +++++++++++++++++++ .../Text/Json/SourceResultElementBuilder.cs | 19 ++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaDefinitionTypeResolver.cs diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaContext.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaContext.cs index e700530c150..097a050f25b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaContext.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/CompositeSchemaContext.cs @@ -166,7 +166,7 @@ private IType CreateType(ITypeNode typeNode, string typeName) private FusionScalarTypeDefinition CreateSpecScalar(string name) { - var type = new FusionScalarTypeDefinition(name, null, isInaccessible: false); + var type = new FusionScalarTypeDefinition(name, GetSpecScalarDescription(name), isInaccessible: false); var typeDef = new ScalarTypeDefinitionNode(null, new NameNode(name), null, []); type.Complete(new CompositeScalarTypeCompletionContext( default, @@ -181,6 +181,22 @@ private FusionScalarTypeDefinition CreateSpecScalar(string name) return type; } + private static string? GetSpecScalarDescription(string name) + => name switch + { + SpecScalarNames.String.Name => + "The `String` scalar type represents textual data, represented as a sequence of Unicode code points.", + SpecScalarNames.Int.Name => + "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", + SpecScalarNames.Float.Name => + "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + SpecScalarNames.Boolean.Name => + "The `Boolean` scalar type represents `true` or `false`.", + SpecScalarNames.ID.Name => + "The `ID` scalar type represents a unique identifier, often used to refetch an object or as the key for a cache.", + _ => null + }; + private static ScalarSerializationType GetSpecScalarSerializationType(string name) => name switch { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaDefinitionTypeResolver.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaDefinitionTypeResolver.cs new file mode 100644 index 00000000000..a7cf6e6db8d --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/SchemaDefinitionTypeResolver.cs @@ -0,0 +1,37 @@ +using HotChocolate.Types; + +namespace HotChocolate.Fusion.Execution.Introspection; + +internal static class SchemaDefinitionTypeResolver +{ + public static IObjectTypeDefinition ResolveObjectType( + ISchemaDefinition schema, + object? runtimeValue) + { + var typeName = ResolveTypeName(runtimeValue); + + if (schema.Types.TryGetType(typeName, out var type) + && type is IObjectTypeDefinition resolvedType) + { + return resolvedType; + } + + throw new InvalidOperationException( + $"The schema does not declare an object type named '{typeName}'."); + } + + public static string ResolveTypeName(object? runtimeValue) + { + return runtimeValue switch + { + ITypeDefinition => "__Type", + IOutputFieldDefinition => "__Field", + IInputValueDefinition => "__InputValue", + IEnumValue => "__EnumValue", + IDirectiveDefinition => "__Directive", + _ => throw new InvalidOperationException( + "Cannot resolve a concrete introspection __typename for runtime value of type " + + $"'{runtimeValue?.GetType().FullName ?? "null"}'.") + }; + } +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultElementBuilder.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultElementBuilder.cs index 1599387dec8..1d7ac34dc69 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultElementBuilder.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultElementBuilder.cs @@ -3,6 +3,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Types; namespace HotChocolate.Fusion.Text.Json; @@ -53,6 +54,24 @@ is ElementTokenType.None or ElementTokenType.Null return element; } + public SourceResultElementBuilder CreateObjectValue( + Selection parent, + IObjectTypeDefinition typeContext, + ulong includeFlags) + { + AssertValidInstance(); + + Debug.Assert(_builder._metaDb.GetElementTokenType(_index) + is ElementTokenType.None or ElementTokenType.Null + or ElementTokenType.Reference); + + var selectionSet = parent.DeclaringSelectionSet.DeclaringOperation.GetSelectionSet(parent, typeContext); + var objectIndex = _builder.CreateObjectValue(selectionSet.Selections, includeFlags); + var element = new SourceResultElementBuilder(_builder, objectIndex); + _builder.AssignReference(this, element); + return element; + } + public SourceResultElementBuilder CreateListValue(int length) { AssertValidInstance(); From cd11ab2a25a99e3c6bf4f6392e6f41605045cc51 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 06:26:49 +0800 Subject: [PATCH 12/24] made link order stable --- .../ApolloFederation/FederationTypeInterceptor.cs | 4 ++-- .../Types/Directives/LinkDescriptorExtensions.cs | 2 +- .../Types/Directives/LinkDirective.cs | 13 ++++++++++--- .../CertificationTests.Schema_Snapshot.graphql | 2 +- .../CertificationTests.Subgraph_SDL.snap | 2 +- .../CertificationTests.Schema_Snapshot.graphql | 2 +- .../CertificationTests.Subgraph_SDL.snap | 2 +- ...eTests.ExportDirectiveUsingNameCodeFirst.graphql | 2 +- ...eTests.ExportDirectiveUsingTypeCodeFirst.graphql | 2 +- ...notateExternalToTypeFieldAnnotationBased.graphql | 2 +- ...Directive_GetsAddedCorrectly_Annotations.graphql | 2 +- ...Directives_GetAddedCorrectly_Annotations.graphql | 2 +- ...cyDirectives_GetAddedCorrectly_CodeFirst.graphql | 2 +- ...veTests.AnnotateProvidesToFieldCodeFirst.graphql | 2 +- ...veTests.AnnotateProvidesToFieldCodeFirst.graphql | 2 +- ...Directive_GetsAddedCorrectly_Annotations.graphql | 2 +- ...Directives_GetAddedCorrectly_Annotations.graphql | 2 +- ...esDirectives_GetAddedCorrectly_CodeFirst.graphql | 2 +- 18 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/HotChocolate/ApolloFederation/src/ApolloFederation/FederationTypeInterceptor.cs b/src/HotChocolate/ApolloFederation/src/ApolloFederation/FederationTypeInterceptor.cs index 5f5d8605068..3acd3733273 100644 --- a/src/HotChocolate/ApolloFederation/src/ApolloFederation/FederationTypeInterceptor.cs +++ b/src/HotChocolate/ApolloFederation/src/ApolloFederation/FederationTypeInterceptor.cs @@ -274,7 +274,7 @@ private void RegisterImports() _schemaTypeCfg .GetLegacyConfiguration() .AddDirective( - new LinkDirective(version.ToUrl(), federationTypes), + new LinkDirective(version.ToUrl(), federationTypes.Order().ToArray()), _typeInspector); foreach (var import in _imports) @@ -287,7 +287,7 @@ private void RegisterImports() _schemaTypeCfg .GetLegacyConfiguration() .AddDirective( - new LinkDirective(import.Key, import.Value), + new LinkDirective(import.Key, import.Value.Order().ToArray()), _typeInspector); } } diff --git a/src/HotChocolate/ApolloFederation/src/ApolloFederation/Types/Directives/LinkDescriptorExtensions.cs b/src/HotChocolate/ApolloFederation/src/ApolloFederation/Types/Directives/LinkDescriptorExtensions.cs index f5dac954810..521d759e4c2 100644 --- a/src/HotChocolate/ApolloFederation/src/ApolloFederation/Types/Directives/LinkDescriptorExtensions.cs +++ b/src/HotChocolate/ApolloFederation/src/ApolloFederation/Types/Directives/LinkDescriptorExtensions.cs @@ -114,7 +114,7 @@ public static IRequestExecutorBuilder AddLink( builder.ConfigureSchema( sb => sb.AddSchemaConfiguration( - d => d.Directive(new LinkDirective(url, imports?.ToHashSet())))); + d => d.Directive(new LinkDirective(url, imports?.Distinct().Order().ToArray())))); return builder; } diff --git a/src/HotChocolate/ApolloFederation/src/ApolloFederation/Types/Directives/LinkDirective.cs b/src/HotChocolate/ApolloFederation/src/ApolloFederation/Types/Directives/LinkDirective.cs index 435b578d8ad..2a1e26a7240 100644 --- a/src/HotChocolate/ApolloFederation/src/ApolloFederation/Types/Directives/LinkDirective.cs +++ b/src/HotChocolate/ApolloFederation/src/ApolloFederation/Types/Directives/LinkDirective.cs @@ -19,10 +19,17 @@ public sealed class LinkDirective /// /// Optional list of imported elements. /// - public LinkDirective(Uri url, IReadOnlySet? import) + public LinkDirective(Uri url, IReadOnlyList? import) { Url = url; - Import = import; + var imports = import?.ToArray(); + + if (imports is not null) + { + Array.Sort(imports, StringComparer.Ordinal); + } + + Import = imports; } /// @@ -36,5 +43,5 @@ public LinkDirective(Uri url, IReadOnlySet? import) /// Gets optional list of imported element names. /// [GraphQLDescription(FederationResources.LinkDirective_Import_Description)] - public IReadOnlySet? Import { get; } + public IReadOnlyList? Import { get; } } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/AnnotationBased/__snapshots__/CertificationTests.Schema_Snapshot.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/AnnotationBased/__snapshots__/CertificationTests.Schema_Snapshot.graphql index 98218c6e53d..88ae4f1bbf6 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/AnnotationBased/__snapshots__/CertificationTests.Schema_Snapshot.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/AnnotationBased/__snapshots__/CertificationTests.Schema_Snapshot.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@tag", "@key", "@provides", "@external", "FieldSet"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@external", "@key", "@provides", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/AnnotationBased/__snapshots__/CertificationTests.Subgraph_SDL.snap b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/AnnotationBased/__snapshots__/CertificationTests.Subgraph_SDL.snap index 98218c6e53d..88ae4f1bbf6 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/AnnotationBased/__snapshots__/CertificationTests.Subgraph_SDL.snap +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/AnnotationBased/__snapshots__/CertificationTests.Subgraph_SDL.snap @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@tag", "@key", "@provides", "@external", "FieldSet"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@external", "@key", "@provides", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/CodeFirst/__snapshots__/CertificationTests.Schema_Snapshot.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/CodeFirst/__snapshots__/CertificationTests.Schema_Snapshot.graphql index 9554e703b14..e84aafc4808 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/CodeFirst/__snapshots__/CertificationTests.Schema_Snapshot.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/CodeFirst/__snapshots__/CertificationTests.Schema_Snapshot.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@provides", "@external", "@tag", "FieldSet"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@external", "@key", "@provides", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/CodeFirst/__snapshots__/CertificationTests.Subgraph_SDL.snap b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/CodeFirst/__snapshots__/CertificationTests.Subgraph_SDL.snap index 9554e703b14..e84aafc4808 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/CodeFirst/__snapshots__/CertificationTests.Subgraph_SDL.snap +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/CertificationSchema/CodeFirst/__snapshots__/CertificationTests.Subgraph_SDL.snap @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@provides", "@external", "@tag", "FieldSet"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@external", "@key", "@provides", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ComposeDirectiveTests.ExportDirectiveUsingNameCodeFirst.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ComposeDirectiveTests.ExportDirectiveUsingNameCodeFirst.graphql index 3de89f5a26f..00b5c26469a 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ComposeDirectiveTests.ExportDirectiveUsingNameCodeFirst.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ComposeDirectiveTests.ExportDirectiveUsingNameCodeFirst.graphql @@ -1,6 +1,6 @@ schema @composeDirective(name: "@custom") - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@tag", "FieldSet", "@composeDirective"]) + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@composeDirective", "@key", "@tag", "FieldSet"]) @link(url: "https://specs.custom.dev/custom/v1.0", import: ["@custom"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ComposeDirectiveTests.ExportDirectiveUsingTypeCodeFirst.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ComposeDirectiveTests.ExportDirectiveUsingTypeCodeFirst.graphql index 3de89f5a26f..00b5c26469a 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ComposeDirectiveTests.ExportDirectiveUsingTypeCodeFirst.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ComposeDirectiveTests.ExportDirectiveUsingTypeCodeFirst.graphql @@ -1,6 +1,6 @@ schema @composeDirective(name: "@custom") - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@tag", "FieldSet", "@composeDirective"]) + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@composeDirective", "@key", "@tag", "FieldSet"]) @link(url: "https://specs.custom.dev/custom/v1.0", import: ["@custom"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ExternalDirectiveTests.AnnotateExternalToTypeFieldAnnotationBased.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ExternalDirectiveTests.AnnotateExternalToTypeFieldAnnotationBased.graphql index a0cfbace83c..734d8ba22b6 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ExternalDirectiveTests.AnnotateExternalToTypeFieldAnnotationBased.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ExternalDirectiveTests.AnnotateExternalToTypeFieldAnnotationBased.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@external", "@tag", "FieldSet"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@external", "@key", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirective_GetsAddedCorrectly_Annotations.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirective_GetsAddedCorrectly_Annotations.graphql index 92791788b60..3f8a78f1165 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirective_GetsAddedCorrectly_Annotations.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirective_GetsAddedCorrectly_Annotations.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@tag", "FieldSet", "@policy"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@policy", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirectives_GetAddedCorrectly_Annotations.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirectives_GetAddedCorrectly_Annotations.graphql index 92791788b60..3f8a78f1165 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirectives_GetAddedCorrectly_Annotations.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirectives_GetAddedCorrectly_Annotations.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@tag", "FieldSet", "@policy"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@policy", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirectives_GetAddedCorrectly_CodeFirst.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirectives_GetAddedCorrectly_CodeFirst.graphql index ca6f74da552..7f71ac90582 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirectives_GetAddedCorrectly_CodeFirst.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/PolicyDirectiveTests.PolicyDirectives_GetAddedCorrectly_CodeFirst.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@policy", "@key", "@tag", "FieldSet"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@policy", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ProvidesDirectiveTests.AnnotateProvidesToFieldCodeFirst.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ProvidesDirectiveTests.AnnotateProvidesToFieldCodeFirst.graphql index c3530839418..692c0c69203 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ProvidesDirectiveTests.AnnotateProvidesToFieldCodeFirst.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/ProvidesDirectiveTests.AnnotateProvidesToFieldCodeFirst.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@provides", "@key", "@tag", "FieldSet"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@provides", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresDirectiveTests.AnnotateProvidesToFieldCodeFirst.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresDirectiveTests.AnnotateProvidesToFieldCodeFirst.graphql index 1ddf35fad5a..1a007c9883d 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresDirectiveTests.AnnotateProvidesToFieldCodeFirst.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresDirectiveTests.AnnotateProvidesToFieldCodeFirst.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@requires", "@key", "@tag", "FieldSet"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@requires", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirective_GetsAddedCorrectly_Annotations.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirective_GetsAddedCorrectly_Annotations.graphql index fe1d594bfb7..a988b60ba84 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirective_GetsAddedCorrectly_Annotations.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirective_GetsAddedCorrectly_Annotations.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@tag", "FieldSet", "@requiresScopes"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@requiresScopes", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirectives_GetAddedCorrectly_Annotations.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirectives_GetAddedCorrectly_Annotations.graphql index fe1d594bfb7..a988b60ba84 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirectives_GetAddedCorrectly_Annotations.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirectives_GetAddedCorrectly_Annotations.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@tag", "FieldSet", "@requiresScopes"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@requiresScopes", "@tag", "FieldSet"]) { query: Query } diff --git a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirectives_GetAddedCorrectly_CodeFirst.graphql b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirectives_GetAddedCorrectly_CodeFirst.graphql index d79fb9ec049..504689e32db 100644 --- a/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirectives_GetAddedCorrectly_CodeFirst.graphql +++ b/src/HotChocolate/ApolloFederation/test/ApolloFederation.Tests/Directives/__snapshots__/RequiresScopesDirectiveTests.RequiresScopesDirectives_GetAddedCorrectly_CodeFirst.graphql @@ -1,5 +1,5 @@ schema - @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@requiresScopes", "@key", "@tag", "FieldSet"]) { + @link(url: "https://specs.apollo.dev/federation/v2.6", import: ["@key", "@requiresScopes", "@tag", "FieldSet"]) { query: Query } From 7ef820eb93950aa028c84819fe76be477d0324b2 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 06:27:58 +0800 Subject: [PATCH 13/24] smaller bunch of fixes --- .../Extensions/SchemaDefinitionExtensions.cs | 28 +++++++++++++++++++ .../Execution/Introspection/MemHelper.cs | 4 ++- .../Execution/Introspection/Query.cs | 6 ++-- .../Execution/Introspection/__SearchResult.cs | 10 ++----- .../Nodes/IntrospectionExecutionNode.cs | 24 ++-------------- .../Execution/Nodes/TypeNameField.cs | 17 +++++++++-- 6 files changed, 55 insertions(+), 34 deletions(-) diff --git a/src/HotChocolate/Core/src/Types.Abstractions/Extensions/SchemaDefinitionExtensions.cs b/src/HotChocolate/Core/src/Types.Abstractions/Extensions/SchemaDefinitionExtensions.cs index 4a205fdaa8d..a7191410fdb 100644 --- a/src/HotChocolate/Core/src/Types.Abstractions/Extensions/SchemaDefinitionExtensions.cs +++ b/src/HotChocolate/Core/src/Types.Abstractions/Extensions/SchemaDefinitionExtensions.cs @@ -8,6 +8,34 @@ namespace HotChocolate; /// public static class SchemaDefinitionExtensions { + /// + /// Resolves a by its . + /// + /// + /// The schema definition to resolve against. + /// + /// + /// The schema coordinate to resolve. + /// + /// + /// The resolved type system member. + /// + /// + /// Thrown when no type system member exists for the given . + /// + public static ITypeSystemMember GetMember( + this ISchemaDefinition schema, + SchemaCoordinate coordinate) + { + if (!schema.TryGetMember(coordinate, out var member)) + { + throw new InvalidOperationException( + $"Failed to resolve schema coordinate '{coordinate}'."); + } + + return member; + } + /// /// Tries to resolve a by its . /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs index 49577404dbe..ca6cd4622ea 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/MemHelper.cs @@ -27,8 +27,10 @@ public static void WriteValue(this FieldContext context, ReadOnlySpan valu public static void WriteFloatValue(this FieldContext context, float value) { Span buffer = stackalloc byte[32]; + const string format = "R"; + var doubleValue = (double)value; - if (!value.TryFormat(buffer, out var written, default, CultureInfo.InvariantCulture)) + if (!doubleValue.TryFormat(buffer, out var written, format, CultureInfo.InvariantCulture)) { throw new InvalidOperationException($"Failed to format float value '{value}'."); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs index 0903ec869c9..8d8b695405b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs @@ -167,8 +167,10 @@ public static void Definitions(FieldContext context) var i = 0; foreach (var element in list.EnumerateArray()) { - context.AddRuntimeResult(definitions[i++]); - element.CreateObjectValue(context.Selection, context.IncludeFlags); + var definition = definitions[i++]; + var objectType = SchemaDefinitionTypeResolver.ResolveObjectType(context.Schema, definition); + context.AddRuntimeResult(definition); + element.CreateObjectValue(context.Selection, objectType, context.IncludeFlags); } } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs index 9854bf2a9c0..f12b7714c6b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs @@ -43,13 +43,9 @@ public static void Definition(FieldContext context) { var result = context.Parent(); - if (!context.Schema.TryGetMember(result.Coordinate, out var member)) - { - throw new InvalidOperationException( - $"Failed to resolve schema coordinate '{result.Coordinate}'."); - } - - context.FieldResult.CreateObjectValue(context.Selection, context.IncludeFlags); + var member = context.Schema.GetMember(result.Coordinate); + var objectType = SchemaDefinitionTypeResolver.ResolveObjectType(context.Schema, member); + context.FieldResult.CreateObjectValue(context.Selection, objectType, context.IncludeFlags); context.AddRuntimeResult(member); } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs index 7b4cbceb9aa..2e75260060c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using HotChocolate.Fusion.Execution.Introspection; using HotChocolate.Fusion.Text.Json; using HotChocolate.Language; using HotChocolate.Types; @@ -201,27 +202,6 @@ private static IObjectTypeDefinition ResolveObjectType( return objectType; } - // For abstract types (unions), determine the concrete introspection type - // from the runtime result. - var typeName = runtimeResult switch - { - ITypeDefinition => "__Type", - IOutputFieldDefinition => "__Field", - IInputValueDefinition => "__InputValue", - IEnumValue => "__EnumValue", - IDirectiveDefinition => "__Directive", - _ => throw new InvalidOperationException( - $"Cannot determine the concrete object type for abstract type '{namedType}'" - + $" from runtime result of type '{runtimeResult?.GetType().Name}'.") - }; - - if (schema.Types.TryGetType(typeName, out var resolvedType) - && resolvedType is IObjectTypeDefinition resolvedObjectType) - { - return resolvedObjectType; - } - - throw new InvalidOperationException( - $"Introspection type '{typeName}' not found in schema."); + return SchemaDefinitionTypeResolver.ResolveObjectType(schema, runtimeResult); } } diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/TypeNameField.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/TypeNameField.cs index e812cf12e7a..1d6d336d165 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/TypeNameField.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/TypeNameField.cs @@ -13,11 +13,24 @@ public TypeNameField(IOutputType nonNullStringType) ArgumentNullException.ThrowIfNull(nonNullStringType); Type = nonNullStringType; var features = new FeatureCollection(); - features.Set(new ResolveFieldValue( - ctx => ctx.WriteValue(ctx.Selection.DeclaringSelectionSet.Type.Name))); + features.Set(new ResolveFieldValue(ResolveTypeName)); Features = features.ToReadOnly(); } + private static void ResolveTypeName(FieldContext context) + { + var type = context.Selection.DeclaringSelectionSet.Type; + + if (!type.IsAbstractType()) + { + context.WriteValue(type.Name); + return; + } + + var typeName = SchemaDefinitionTypeResolver.ResolveTypeName(context.Parent()); + context.WriteValue(typeName); + } + public string Name => IntrospectionFieldNames.TypeName; public string? Description => null; From ebf1de508bbc8c9d8852f6fcca664772a0ee4452 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 06:44:06 +0800 Subject: [PATCH 14/24] updated the source result documents --- .../Text/Json/SourceResultDocument.DbRow.cs | 10 +- .../Text/Json/SourceResultDocument.MetaDb.cs | 116 ++++++++++-------- .../Text/Json/SourceResultDocument.Parse.cs | 94 +++++++++----- 3 files changed, 141 insertions(+), 79 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.DbRow.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.DbRow.cs index f459f3d23c4..710e49b6456 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.DbRow.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.DbRow.cs @@ -24,12 +24,18 @@ internal readonly struct DbRow // exceed that range. private readonly int _numberOfRowsAndTypeUnion; - internal DbRow(JsonTokenType jsonTokenType, int location, int sizeOrLength, bool hasComplexChildren) + internal DbRow( + JsonTokenType jsonTokenType, + int location, + int sizeOrLength, + int numberOfRows, + bool hasComplexChildren) { Debug.Assert(jsonTokenType is > JsonTokenType.None and <= JsonTokenType.Null); Debug.Assert((byte)jsonTokenType < 1 << 4); Debug.Assert(location >= 0); Debug.Assert(sizeOrLength >= UnknownSize); + Debug.Assert(numberOfRows is >= 1 and <= 0x0FFFFFFF); Debug.Assert(Unsafe.SizeOf() == Size); _location = location; @@ -38,7 +44,7 @@ internal DbRow(JsonTokenType jsonTokenType, int location, int sizeOrLength, bool ? sizeOrLength | int.MinValue // Clear sign bit : sizeOrLength & int.MaxValue; - _numberOfRowsAndTypeUnion = ((int)jsonTokenType << 28) | 1; + _numberOfRowsAndTypeUnion = ((int)jsonTokenType << 28) | numberOfRows; } /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.MetaDb.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.MetaDb.cs index 08fc5aadc6e..18f6ad2ab89 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.MetaDb.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.MetaDb.cs @@ -12,9 +12,6 @@ public sealed partial class SourceResultDocument { internal struct MetaDb : IDisposable { - private const int SizeOrLengthOffset = 4; - private const int NumberOfRowsOffset = 8; - private static readonly ArrayPool s_arrayPool = ArrayPool.Shared; private byte[][] _chunks; @@ -52,12 +49,77 @@ internal static MetaDb CreateForEstimatedRows(int estimatedRows) }; } + /// + /// The cursor that the next or + /// call will write to (after handling chunk-boundary advance). + /// + public readonly Cursor NextCursor + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get + { + var cursor = _cursor; + if (cursor.Row >= Cursor.RowsPerChunk) + { + return Cursor.FromByteOffset(cursor.Chunk + 1, 0); + } + return cursor; + } + } + + /// + /// Allocates the next row slot without writing any data to it. Use + /// later to populate it once all field values + /// are known. The reserved cursor must not be read from before it is + /// written. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Cursor Reserve() + { + var (cursor, _, _) = AcquireSlot(); + return cursor; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal Cursor Append( JsonTokenType tokenType, int startLocation = DbRow.NoLocation, int length = DbRow.UnknownSize, + int numberOfRows = 1, + bool hasComplexChildren = false) + { + var (cursor, chunk, byteOffset) = AcquireSlot(); + + var row = new DbRow(tokenType, startLocation, length, numberOfRows, hasComplexChildren); + ref var dest = ref MemoryMarshal.GetArrayDataReference(chunk); + Unsafe.WriteUnaligned(ref Unsafe.Add(ref dest, byteOffset), row); + + return cursor; + } + + /// + /// Overwrites the row at with a freshly + /// constructed , in a single write. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal readonly void Replace( + Cursor cursor, + JsonTokenType tokenType, + int location, + int sizeOrLength, + int numberOfRows, bool hasComplexChildren = false) + { + AssertValidCursor(cursor); + + var row = new DbRow(tokenType, location, sizeOrLength, numberOfRows, hasComplexChildren); + var chunk = _chunks[cursor.Chunk]; + ref var dest = ref MemoryMarshal.GetArrayDataReference(chunk); + Unsafe.WriteUnaligned(ref Unsafe.Add(ref dest, cursor.ByteOffset), row); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private (Cursor cursor, byte[] chunk, int byteOffset) AcquireSlot() { var log = Log; var cursor = _cursor; @@ -106,51 +168,8 @@ internal Cursor Append( log.ChunkAllocated(1, chunkIndex); } - var byteOffset = cursor.ByteOffset; - var row = new DbRow(tokenType, startLocation, length, hasComplexChildren); - ref var dest = ref MemoryMarshal.GetArrayDataReference(chunk); - Unsafe.WriteUnaligned(ref Unsafe.Add(ref dest, byteOffset), row); - _cursor++; - return cursor; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal void SetLength(Cursor cursor, int length) - { - AssertValidCursor(cursor); - Debug.Assert(length >= 0); - - var offset = cursor.ByteOffset + SizeOrLengthOffset; - var destination = _chunks[cursor.Chunk].AsSpan(offset); - - MemoryMarshal.Write(destination, length); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal void SetNumberOfRows(Cursor cursor, int numberOfRows) - { - AssertValidCursor(cursor); - Debug.Assert(numberOfRows is >= 1 and <= 0x0FFFFFFF); - - var offset = cursor.ByteOffset + NumberOfRowsOffset; - var dataPos = _chunks[cursor.Chunk].AsSpan(offset); - var current = MemoryMarshal.Read(dataPos); - - var value = (current & unchecked((int)0xF0000000)) | numberOfRows; - MemoryMarshal.Write(dataPos, value); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal void SetHasComplexChildren(Cursor cursor) - { - AssertValidCursor(cursor); - - var offset = cursor.ByteOffset + SizeOrLengthOffset; - var dataPos = _chunks[cursor.Chunk].AsSpan(offset); - - var current = MemoryMarshal.Read(dataPos); - MemoryMarshal.Write(dataPos, current | unchecked((int)0x80000000)); + return (cursor, chunk, cursor.ByteOffset); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -168,7 +187,8 @@ internal readonly JsonTokenType GetJsonTokenType(Cursor cursor) { AssertValidCursor(cursor); - var offset = cursor.ByteOffset + NumberOfRowsOffset; + // _numberOfRowsAndTypeUnion is the third int in the row. + var offset = cursor.ByteOffset + 8; var dataPos = _chunks[cursor.Chunk].AsSpan(offset); var union = MemoryMarshal.Read(dataPos); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.Parse.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.Parse.cs index 584e4f3530c..e9a2e5cb2e8 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.Parse.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Text/Json/SourceResultDocument.Parse.cs @@ -158,8 +158,10 @@ internal static SourceResultDocument ParseMultipleSegments( private static void ParseJson(ref Utf8JsonReader reader, ref MetaDb metaDb, bool skipInitialRead = false) { Span containerStart = stackalloc Cursor[64]; + Span containerStartLocation = stackalloc int[64]; Span containerChildCount = stackalloc int[64]; var containerIsArrayBits = 0UL; + var containerHasComplexBits = 0UL; var stackIndex = 0; while (skipInitialRead || reader.Read()) @@ -192,15 +194,31 @@ private static void ParseJson(ref Utf8JsonReader reader, ref MetaDb metaDb, bool switch (tokenType) { case JsonTokenType.StartObject: - containerStart[stackIndex] = metaDb.Append(tokenType, location); + // The new container is a complex child of its parent. + if (stackIndex > 0) + { + containerHasComplexBits |= 1UL << (stackIndex - 1); + } + + containerStart[stackIndex] = metaDb.Reserve(); + containerStartLocation[stackIndex] = location; containerChildCount[stackIndex] = 0; containerIsArrayBits &= ~(1UL << stackIndex); + containerHasComplexBits &= ~(1UL << stackIndex); stackIndex++; break; case JsonTokenType.EndObject: --stackIndex; - CloseObject(ref metaDb, containerStart[stackIndex], location, containerChildCount[stackIndex]); + CloseContainer( + ref metaDb, + JsonTokenType.StartObject, + JsonTokenType.EndObject, + containerStart[stackIndex], + containerStartLocation[stackIndex], + location, + containerChildCount[stackIndex], + (containerHasComplexBits & (1UL << stackIndex)) != 0); if (stackIndex == 0) { @@ -209,15 +227,30 @@ private static void ParseJson(ref Utf8JsonReader reader, ref MetaDb metaDb, bool break; case JsonTokenType.StartArray: - containerStart[stackIndex] = metaDb.Append(tokenType, location); + if (stackIndex > 0) + { + containerHasComplexBits |= 1UL << (stackIndex - 1); + } + + containerStart[stackIndex] = metaDb.Reserve(); + containerStartLocation[stackIndex] = location; containerChildCount[stackIndex] = 0; containerIsArrayBits |= 1UL << stackIndex; + containerHasComplexBits &= ~(1UL << stackIndex); stackIndex++; break; case JsonTokenType.EndArray: --stackIndex; - CloseArray(ref metaDb, containerStart[stackIndex], location, containerChildCount[stackIndex]); + CloseContainer( + ref metaDb, + JsonTokenType.StartArray, + JsonTokenType.EndArray, + containerStart[stackIndex], + containerStartLocation[stackIndex], + location, + containerChildCount[stackIndex], + (containerHasComplexBits & (1UL << stackIndex)) != 0); break; case JsonTokenType.PropertyName: @@ -253,31 +286,30 @@ private static void ParseJson(ref Utf8JsonReader reader, ref MetaDb metaDb, bool } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void CloseObject(ref MetaDb metaDb, Cursor startId, int location, int properties) - { - Debug.Assert(metaDb.Get(startId).TokenType == JsonTokenType.StartObject); - - var endId = metaDb.Append(JsonTokenType.EndObject, location); - var rows = CursorDistance(startId, endId); - - metaDb.SetLength(startId, properties); - metaDb.SetNumberOfRows(startId, rows); - metaDb.SetLength(endId, properties); - metaDb.SetNumberOfRows(endId, rows); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static void CloseArray(ref MetaDb metaDb, Cursor startCursor, int location, int elements) + private static void CloseContainer( + ref MetaDb metaDb, + JsonTokenType startTokenType, + JsonTokenType endTokenType, + Cursor startCursor, + int startLocation, + int endLocation, + int sizeOrLength, + bool hasComplexChildren) { - Debug.Assert(metaDb.Get(startCursor).TokenType == JsonTokenType.StartArray); - - var endId = metaDb.Append(JsonTokenType.EndArray, location); - var rows = CursorDistance(startCursor, endId); + // NextCursor is where the end row will land, so the inclusive + // distance from the start cursor is the total row count. + var endCursor = metaDb.NextCursor; + var rows = CursorDistance(startCursor, endCursor); + + metaDb.Replace( + startCursor, + startTokenType, + startLocation, + sizeOrLength, + rows, + hasComplexChildren); - metaDb.SetLength(startCursor, elements); - metaDb.SetNumberOfRows(startCursor, rows); - metaDb.SetLength(endId, elements); - metaDb.SetNumberOfRows(endId, rows); + metaDb.Append(endTokenType, endLocation, sizeOrLength, rows); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -296,7 +328,11 @@ private static void AppendStringToken( var adjustedLocation = startLocation + 1; var adjustedLength = tokenLength - 2; - metaDb.Append(tokenType, adjustedLocation, adjustedLength, ContainsEscapeSequences(reader)); + metaDb.Append( + tokenType, + adjustedLocation, + adjustedLength, + hasComplexChildren: ContainsEscapeSequences(reader)); } [MethodImpl(MethodImplOptions.AggressiveInlining)] @@ -309,7 +345,7 @@ private static void AppendNumberToken( JsonTokenType.Number, startLocation, tokenLength, - ContainsScientificNotation(reader.ValueSpan)); + hasComplexChildren: ContainsScientificNotation(reader.ValueSpan)); [MethodImpl(MethodImplOptions.AggressiveInlining)] private static bool ContainsEscapeSequences(Utf8JsonReader reader) From 78d4524ac5ef3ef008bf617d1bafbe45e9433dd5 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 07:35:55 +0800 Subject: [PATCH 15/24] Fix cost interceptor --- .../src/CostAnalysis/CostTypeInterceptor.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostTypeInterceptor.cs b/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostTypeInterceptor.cs index 6cd817aebbe..f5c8439c716 100644 --- a/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostTypeInterceptor.cs +++ b/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostTypeInterceptor.cs @@ -45,10 +45,20 @@ internal override void InitializeContext( public override void OnAfterCompleteName(ITypeCompletionContext completionContext, TypeSystemConfiguration configuration) { + if (completionContext.IsIntrospectionType) + { + return; + } + if (configuration is ObjectTypeConfiguration objectTypeDef) { foreach (var fieldDef in objectTypeDef.Fields) { + if (fieldDef.IsIntrospectionField) + { + continue; + } + if (fieldDef.Features.TryGet(out PagingOptions? options) && !fieldDef.HasListSizeDirective() && ((fieldDef.Flags & CoreFieldFlags.Connection) == CoreFieldFlags.Connection @@ -151,10 +161,20 @@ public override void OnAfterCompleteName(ITypeCompletionContext completionContex public override void OnBeforeCompleteType(ITypeCompletionContext completionContext, TypeSystemConfiguration configuration) { + if (completionContext.IsIntrospectionType) + { + return; + } + if (configuration is ObjectTypeConfiguration objectTypeDef) { foreach (var fieldDef in objectTypeDef.Fields) { + if (fieldDef.IsIntrospectionField) + { + continue; + } + if ((fieldDef.PureResolver is null || (fieldDef.Flags & CoreFieldFlags.TotalCount) == CoreFieldFlags.TotalCount) && _options.DefaultResolverCost.HasValue From ebc3fd4959df527a8c94da20a1f0d5c6c7123421 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 07:36:10 +0800 Subject: [PATCH 16/24] fix value completion --- .../Execution/Results/ValueCompletion.cs | 4 +- .../SemanticIntrospectionTests.cs | 268 ++++++++++++++---- 2 files changed, 216 insertions(+), 56 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs index 622db8ec3ac..eca12b91b20 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/ValueCompletion.cs @@ -738,7 +738,7 @@ public static IType ElementType(this IType type) => type switch { ListType listType => listType.ElementType, - NonNullType nonNullType => nonNullType.NullableType, - _ => type + NonNullType { NullableType: ListType listType } => listType.ElementType, + _ => throw new ArgumentException($"The type '{type}' is not a list type.", nameof(type)) }; } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs index 865fabb28c9..102c2eb5996 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs @@ -12,7 +12,7 @@ public class SemanticIntrospectionTests : FusionTestBase public async Task Search_Should_ReturnResults_When_QueryMatchesFieldName() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -43,19 +43,19 @@ public async Task Search_Should_ReturnResults_When_QueryMatchesFieldName() }, { "coordinate": "Query.userByEmail", - "score": 0.8877184 + "score": 0.8974776268005371 }, { "coordinate": "User.email", - "score": 0.69699603 + "score": 0.71161288022995 }, { "coordinate": "User.name", - "score": 0.69699603 + "score": 0.71161288022995 }, { "coordinate": "User.age", - "score": 0.65830594 + "score": 0.6750305891036987 } ] } @@ -67,7 +67,7 @@ public async Task Search_Should_ReturnResults_When_QueryMatchesFieldName() public async Task Search_Should_ReturnResults_When_QueryMatchesDescription() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -98,7 +98,7 @@ public async Task Search_Should_ReturnResults_When_QueryMatchesDescription() }, { "coordinate": "Query.userByEmail", - "score": 0.9123855 + "score": 0.9191438555717468 } ] } @@ -110,7 +110,7 @@ public async Task Search_Should_ReturnResults_When_QueryMatchesDescription() public async Task Search_Should_RespectFirstArgument() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -141,7 +141,7 @@ public async Task Search_Should_RespectFirstArgument() }, { "coordinate": "Product.category", - "score": 0.81051797 + "score": 0.8174113631248474 } ] } @@ -153,7 +153,7 @@ public async Task Search_Should_RespectFirstArgument() public async Task Search_Should_FilterByMinScore() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -192,7 +192,7 @@ public async Task Search_Should_FilterByMinScore() public async Task Search_Should_ReturnCursors_And_SupportPagination() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -265,7 +265,7 @@ public async Task Search_Should_ReturnCursors_And_SupportPagination() public async Task Search_Should_ReturnEmptyList_When_NoMatches() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -299,7 +299,7 @@ public async Task Search_Should_ReturnEmptyList_When_NoMatches() public async Task Search_Should_IncludePathsToRoot() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -328,10 +328,8 @@ public async Task Search_Should_IncludePathsToRoot() "coordinate": "Product.name", "pathsToRoot": [ [ - "Product.name", - "Product", "Query.productSearch", - "Query" + "Product.name" ] ] }, @@ -339,8 +337,7 @@ public async Task Search_Should_IncludePathsToRoot() "coordinate": "Query.productSearch", "pathsToRoot": [ [ - "Query.productSearch", - "Query" + "Query.productSearch" ] ] }, @@ -348,10 +345,8 @@ public async Task Search_Should_IncludePathsToRoot() "coordinate": "User.name", "pathsToRoot": [ [ - "User.name", - "User", "Query.userByEmail", - "Query" + "User.name" ] ] }, @@ -359,9 +354,7 @@ public async Task Search_Should_IncludePathsToRoot() "coordinate": "Product", "pathsToRoot": [ [ - "Product", - "Query.productSearch", - "Query" + "Query.productSearch" ] ] }, @@ -369,10 +362,8 @@ public async Task Search_Should_IncludePathsToRoot() "coordinate": "Product.category", "pathsToRoot": [ [ - "Product.category", - "Product", "Query.productSearch", - "Query" + "Product.category" ] ] }, @@ -380,10 +371,8 @@ public async Task Search_Should_IncludePathsToRoot() "coordinate": "Product.price", "pathsToRoot": [ [ - "Product.price", - "Product", "Query.productSearch", - "Query" + "Product.price" ] ] } @@ -397,7 +386,7 @@ public async Task Search_Should_IncludePathsToRoot() public async Task Search_Should_ReturnScoresInDescendingOrder() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -428,19 +417,19 @@ public async Task Search_Should_ReturnScoresInDescendingOrder() }, { "coordinate": "Product.category", - "score": 0.81051797 + "score": 0.8174113631248474 }, { "coordinate": "Product.name", - "score": 0.81051797 + "score": 0.8174113631248474 }, { "coordinate": "Product.price", - "score": 0.70929694 + "score": 0.7237380146980286 }, { "coordinate": "Query.productSearch", - "score": 0.5973899 + "score": 0.6175786256790161 } ] } @@ -452,7 +441,7 @@ public async Task Search_Should_ReturnScoresInDescendingOrder() public async Task Search_Should_ResolveDefinition_AsField() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -484,7 +473,76 @@ ... on __Field { """ { "data": { - "__search": null + "__search": [ + { + "coordinate": "Query.userByEmail", + "definition": { + "name": "userByEmail", + "description": "Retrieve a user by their email address", + "args": [ + { + "name": "email" + } + ] + } + }, + { + "coordinate": "User.email", + "definition": { + "name": "email", + "description": "The email address of the user", + "args": [] + } + }, + { + "coordinate": "User", + "definition": {} + }, + { + "coordinate": "Query.orderById", + "definition": { + "name": "orderById", + "description": "Retrieve an order by its unique identifier", + "args": [ + { + "name": "id" + } + ] + } + }, + { + "coordinate": "Query.productSearch", + "definition": { + "name": "productSearch", + "description": "Search for products by name or category", + "args": [ + { + "name": "term" + } + ] + } + }, + { + "coordinate": "User.name", + "definition": { + "name": "name", + "description": "The full name of the user", + "args": [] + } + }, + { + "coordinate": "User.age", + "definition": { + "name": "age", + "description": "The age of the user in years", + "args": [] + } + }, + { + "coordinate": "Float", + "definition": {} + } + ] } } """); @@ -494,7 +552,7 @@ ... on __Field { public async Task Search_Should_ResolveDefinition_AsType() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -514,6 +572,7 @@ ... on __Type { name } } + __typename } } } @@ -526,7 +585,51 @@ ... on __Type { """ { "data": { - "__search": null + "__search": [ + { + "coordinate": "Product", + "definition": { + "__typename": "__Type", + "name": "Product", + "kind": "OBJECT", + "fields": [ + { + "name": "category" + }, + { + "name": "name" + }, + { + "name": "price" + } + ] + } + }, + { + "coordinate": "Product.category", + "definition": { + "__typename": "__Field" + } + }, + { + "coordinate": "Product.name", + "definition": { + "__typename": "__Field" + } + }, + { + "coordinate": "Product.price", + "definition": { + "__typename": "__Field" + } + }, + { + "coordinate": "Query.productSearch", + "definition": { + "__typename": "__Field" + } + } + ] } } """); @@ -536,7 +639,7 @@ ... on __Type { public async Task Definitions_Should_ResolveTypeByCoordinate() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -565,7 +668,23 @@ ... on __Type { """ { "data": { - "__definitions": null + "__definitions": [ + { + "name": "User", + "kind": "OBJECT", + "fields": [ + { + "name": "age" + }, + { + "name": "email" + }, + { + "name": "name" + } + ] + } + ] } } """); @@ -575,7 +694,7 @@ ... on __Type { public async Task Definitions_Should_ResolveFieldByCoordinate() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -608,7 +727,21 @@ ... on __Field { """ { "data": { - "__definitions": null + "__definitions": [ + { + "name": "userByEmail", + "description": "Retrieve a user by their email address", + "args": [ + { + "name": "email", + "type": { + "name": null, + "kind": "NON_NULL" + } + } + ] + } + ] } } """); @@ -618,7 +751,7 @@ ... on __Field { public async Task Definitions_Should_ResolveMultipleCoordinates() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -648,7 +781,20 @@ ... on __Field { """ { "data": { - "__definitions": null + "__definitions": [ + { + "typeName": "User", + "kind": "OBJECT" + }, + { + "typeName": "Product", + "kind": "OBJECT" + }, + { + "fieldName": "orderById", + "description": "Retrieve an order by its unique identifier" + } + ] } } """); @@ -658,7 +804,7 @@ ... on __Field { public async Task Definitions_Should_SkipInvalidCoordinates() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -686,7 +832,14 @@ ... on __Field { """ { "data": { - "__definitions": null + "__definitions": [ + { + "typeName": "User" + }, + { + "fieldName": "orderById" + } + ] } } """); @@ -696,7 +849,7 @@ ... on __Field { public async Task Definitions_Should_ResolveEnumValueByCoordinate() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -722,7 +875,12 @@ ... on __EnumValue { """ { "data": { - "__definitions": null + "__definitions": [ + { + "name": "PENDING", + "description": "Order is pending processing" + } + ] } } """); @@ -732,7 +890,7 @@ ... on __EnumValue { public async Task Search_Should_NotExist_When_SemanticIntrospectionDisabled() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server, enableSemanticIntrospection: false); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -779,7 +937,7 @@ public async Task Search_Should_NotExist_When_SemanticIntrospectionDisabled() public async Task Definitions_Should_NotExist_When_SemanticIntrospectionDisabled() { // arrange - using var server = CreateSourceSchema("A", _sourceSchema); + using var server = CreateSourceSchema("A", SourceSchema); using var gateway = await CreateGatewayAsync(server, enableSemanticIntrospection: false); using var client = GraphQLHttpClient.Create(gateway.CreateClient()); @@ -842,7 +1000,7 @@ private Task CreateGatewayAsync( }); } - private const string _sourceSchema = + private const string SourceSchema = """ "A registered user of the system" type User { @@ -859,7 +1017,7 @@ type Product { "The product name" name: String! "The product price in dollars" - price: Float! + price: Decimal! "The product category" category: String! } @@ -869,7 +1027,7 @@ type Order { "The unique order identifier" id: ID! "The order total amount" - total: Float! + total: Decimal! "The current order status" status: OrderStatus } @@ -889,6 +1047,8 @@ enum OrderStatus { type Query { "Retrieve a user by their email address" userByEmail(email: String!): User + "List all users with optional filtering" + users: [User] "Search for products by name or category" productSearch(term: String!): [Product] "Retrieve an order by its unique identifier" From b832e3f6a9d42937fddfd5c8de64bf9a2179b9a9 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 07:59:21 +0800 Subject: [PATCH 17/24] fix assertion --- .../test/Data.Sorting.Tests/Convention/SortConventionTests.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/HotChocolate/Data/test/Data.Sorting.Tests/Convention/SortConventionTests.cs b/src/HotChocolate/Data/test/Data.Sorting.Tests/Convention/SortConventionTests.cs index a8c398fa8fe..95fa5902ca5 100644 --- a/src/HotChocolate/Data/test/Data.Sorting.Tests/Convention/SortConventionTests.cs +++ b/src/HotChocolate/Data/test/Data.Sorting.Tests/Convention/SortConventionTests.cs @@ -370,9 +370,7 @@ public void GetTypeName_TypeNameEndingWithSortInputType_RemovesTypeSuffix() var schema = CreateSchemaWith(sortInputType, convention); // assert - Assert.Equal( - "SortInput", - schema.Types.First(t => t.IsInputType() && !t.IsIntrospectionType()).Name); + Assert.True(schema.Types.TryGetType("SortInput", out _)); } protected Schema CreateSchemaWithTypes( From cd1ff6af4c210df880c1756158a40cbaeb579196 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 08:07:27 +0800 Subject: [PATCH 18/24] update tests --- ...sts.Ensure_Benchmark_Query_LargeQuery.snap | 6 ++-- ...sts.Register_ClrType_InferSchemaTypes.snap | 6 ++-- ...sts.Register_SchemaType_ClrTypeExists.snap | 6 ++-- ...erTests.Upgrade_Type_From_GenericType.snap | 36 +++++++++++++++++++ .../Types/Scalars/ScalarBindingTests.cs | 18 +++++----- ...ary_String_Object_Output_Field_Builds.snap | 6 ---- ...ding_Behavior_Is_Respected_On_Scalars.snap | 14 ++++++-- ...ding_Behavior_Is_Respected_On_Scalars.snap | 2 +- ....Allow_PascalCasedArguments_Schema.graphql | 6 ---- ...rstTests.DescriptionsAreCorrectlyRead.snap | 6 ++-- ...rrectly_Exposed_Through_Introspection.snap | 6 ++-- 11 files changed, 73 insertions(+), 39 deletions(-) diff --git a/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/__snapshots__/StarWarsCodeFirstTests.Ensure_Benchmark_Query_LargeQuery.snap b/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/__snapshots__/StarWarsCodeFirstTests.Ensure_Benchmark_Query_LargeQuery.snap index 18aff3c18c5..3a59d69225b 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/__snapshots__/StarWarsCodeFirstTests.Ensure_Benchmark_Query_LargeQuery.snap +++ b/src/HotChocolate/Core/test/Execution.Tests/Integration/StarWarsCodeFirst/__snapshots__/StarWarsCodeFirstTests.Ensure_Benchmark_Query_LargeQuery.snap @@ -5949,7 +5949,7 @@ }, { "name": "pathsToRoot", - "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "description": "Paths from this element to a root type, each as a list of schema coordinates.", "args": [], "type": { "kind": "NON_NULL", @@ -5961,8 +5961,8 @@ "kind": "NON_NULL", "name": null, "ofType": { - "kind": "SCALAR", - "name": "String" + "kind": "LIST", + "name": null } } } diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_ClrType_InferSchemaTypes.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_ClrType_InferSchemaTypes.snap index 37fccfc1223..495cfe451ef 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_ClrType_InferSchemaTypes.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_ClrType_InferSchemaTypes.snap @@ -74,11 +74,11 @@ }, { "type": "HotChocolate.Types.Introspection.__SearchResult", - "runtimeType": "HotChocolate.Types.Introspection.SearchResultInfo", + "runtimeType": "HotChocolate.SchemaSearchResult", "references": [ "HotChocolate.Types.Introspection.__SearchResult (Output)", "__SearchResult (Output)", - "ObjectType (Output)" + "ObjectType (Output)" ] }, { @@ -190,7 +190,7 @@ "ISchemaDefinition (Output)": "HotChocolate.Types.Introspection.__Schema (Output)", "IType (Output)": "HotChocolate.Types.Introspection.__Type (Output)", "TypeKind!": "HotChocolate.Types.Introspection.__TypeKind", - "SearchResultInfo (Output)": "HotChocolate.Types.Introspection.__SearchResult (Output)", + "SchemaSearchResult! (Output)": "HotChocolate.Types.Introspection.__SearchResult (Output)", "Int32!": "HotChocolate.Types.IntType", "Double!": "HotChocolate.Types.FloatType", "DeprecatedDirective": "HotChocolate.Types.DeprecatedDirectiveType", diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_SchemaType_ClrTypeExists.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_SchemaType_ClrTypeExists.snap index 016c8a61292..6cbc4b0ce4e 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_SchemaType_ClrTypeExists.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Register_SchemaType_ClrTypeExists.snap @@ -74,11 +74,11 @@ }, { "type": "HotChocolate.Types.Introspection.__SearchResult", - "runtimeType": "HotChocolate.Types.Introspection.SearchResultInfo", + "runtimeType": "HotChocolate.SchemaSearchResult", "references": [ "HotChocolate.Types.Introspection.__SearchResult (Output)", "__SearchResult (Output)", - "ObjectType (Output)" + "ObjectType (Output)" ] }, { @@ -192,7 +192,7 @@ "ISchemaDefinition (Output)": "HotChocolate.Types.Introspection.__Schema (Output)", "IType (Output)": "HotChocolate.Types.Introspection.__Type (Output)", "TypeKind!": "HotChocolate.Types.Introspection.__TypeKind", - "SearchResultInfo (Output)": "HotChocolate.Types.Introspection.__SearchResult (Output)", + "SchemaSearchResult! (Output)": "HotChocolate.Types.Introspection.__SearchResult (Output)", "Int32!": "HotChocolate.Types.IntType", "Double!": "HotChocolate.Types.FloatType", "DeprecatedDirective": "HotChocolate.Types.DeprecatedDirectiveType", diff --git a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Upgrade_Type_From_GenericType.snap b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Upgrade_Type_From_GenericType.snap index f8ac6d32db3..0c9cbbabca0 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Upgrade_Type_From_GenericType.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Configuration/__snapshots__/TypeDiscovererTests.Upgrade_Type_From_GenericType.snap @@ -72,6 +72,39 @@ "EnumType" ] }, + { + "type": "HotChocolate.Types.Introspection.__SearchResult", + "runtimeType": "HotChocolate.SchemaSearchResult", + "references": [ + "HotChocolate.Types.Introspection.__SearchResult (Output)", + "__SearchResult (Output)", + "ObjectType (Output)" + ] + }, + { + "type": "HotChocolate.Types.Introspection.__SchemaDefinition", + "runtimeType": "System.Object", + "references": [ + "HotChocolate.Types.Introspection.__SchemaDefinition (Output)", + "__SchemaDefinition (Output)" + ] + }, + { + "type": "HotChocolate.Types.IntType", + "runtimeType": "System.Int32", + "references": [ + "HotChocolate.Types.IntType", + "IntType" + ] + }, + { + "type": "HotChocolate.Types.FloatType", + "runtimeType": "System.Double", + "references": [ + "HotChocolate.Types.FloatType", + "FloatType" + ] + }, { "type": "HotChocolate.Types.SkipDirectiveType", "runtimeType": "System.Object", @@ -159,6 +192,9 @@ "ISchemaDefinition (Output)": "HotChocolate.Types.Introspection.__Schema (Output)", "IType (Output)": "HotChocolate.Types.Introspection.__Type (Output)", "TypeKind!": "HotChocolate.Types.Introspection.__TypeKind", + "SchemaSearchResult! (Output)": "HotChocolate.Types.Introspection.__SearchResult (Output)", + "Int32!": "HotChocolate.Types.IntType", + "Double!": "HotChocolate.Types.FloatType", "DeprecatedDirective": "HotChocolate.Types.DeprecatedDirectiveType", "Foo (Output)": "ObjectType (Output)", "String": "HotChocolate.Types.StringType", diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/ScalarBindingTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/ScalarBindingTests.cs index 32bce68562c..649053809b2 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/ScalarBindingTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/ScalarBindingTests.cs @@ -35,12 +35,12 @@ public void Ensure_That_Implicit_Binding_Behavior_Is_Respected_On_Scalars() public class QueryA { - public Bar? Bar([GraphQLType(typeof(ExplicitBindingScalar))] int id) => new Bar(); + public Bar? Bar([GraphQLType(typeof(ExplicitBindingScalar))] decimal id) => new Bar(); } public class QueryB { - public Bar? Bar([GraphQLType(typeof(ImplicitBindingScalar))] int id) => new Bar(); + public Bar? Bar([GraphQLType(typeof(ImplicitBindingScalar))] decimal id) => new Bar(); } public class Bar @@ -50,10 +50,10 @@ public class Bar public class Baz { - public int Text { get; set; } + public decimal Text { get; set; } } - public class ImplicitBindingScalar : ScalarType + public class ImplicitBindingScalar : ScalarType { public ImplicitBindingScalar() : base("FOO", BindingBehavior.Implicit) @@ -72,18 +72,18 @@ public override object CoerceInputValue(JsonElement inputValue, IFeatureProvider throw new NotImplementedException(); } - protected override void OnCoerceOutputValue(int runtimeValue, ResultElement resultValue) + protected override void OnCoerceOutputValue(decimal runtimeValue, ResultElement resultValue) { throw new NotImplementedException(); } - protected override IValueNode OnValueToLiteral(int runtimeValue) + protected override IValueNode OnValueToLiteral(decimal runtimeValue) { throw new NotImplementedException(); } } - public class ExplicitBindingScalar : ScalarType + public class ExplicitBindingScalar : ScalarType { public ExplicitBindingScalar() : base("FOO", BindingBehavior.Explicit) @@ -102,12 +102,12 @@ public override object CoerceInputValue(JsonElement inputValue, IFeatureProvider throw new NotImplementedException(); } - protected override void OnCoerceOutputValue(int runtimeValue, ResultElement resultValue) + protected override void OnCoerceOutputValue(decimal runtimeValue, ResultElement resultValue) { throw new NotImplementedException(); } - protected override IValueNode OnValueToLiteral(int runtimeValue) + protected override IValueNode OnValueToLiteral(decimal runtimeValue) { throw new NotImplementedException(); } diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/Issue6970ReproTests.Schema_With_IDictionary_String_Object_Output_Field_Builds.snap b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/Issue6970ReproTests.Schema_With_IDictionary_String_Object_Output_Field_Builds.snap index 31de6aeeea7..6a3aeea4f77 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/Issue6970ReproTests.Schema_With_IDictionary_String_Object_Output_Field_Builds.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/Issue6970ReproTests.Schema_With_IDictionary_String_Object_Output_Field_Builds.snap @@ -10,12 +10,6 @@ type Query { item: PageVOAllergyIntolerance! } -"The purpose of the `cost` directive is to define a `weight` for GraphQL types, fields, and arguments. Static analysis can use these weights when calculating the overall cost of a query or response." -directive @cost( - "The `weight` argument defines what value to add to the overall cost for every appearance, or possible appearance, of a type, field, argument, etc." - weight: String! -) on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM | INPUT_FIELD_DEFINITION - "The `@specifiedBy` directive is used within the type system definition language to provide a URL for specifying the behavior of custom scalar definitions." directive @specifiedBy( "The specifiedBy URL points to a human-readable specification. This field will only read a result for scalar types." diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/ScalarBindingTests.Ensure_That_Explicit_Binding_Behavior_Is_Respected_On_Scalars.snap b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/ScalarBindingTests.Ensure_That_Explicit_Binding_Behavior_Is_Respected_On_Scalars.snap index 7171dcb02dd..03208199dcb 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/ScalarBindingTests.Ensure_That_Explicit_Binding_Behavior_Is_Respected_On_Scalars.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/ScalarBindingTests.Ensure_That_Explicit_Binding_Behavior_Is_Respected_On_Scalars.snap @@ -1,4 +1,4 @@ -schema { +schema { query: QueryA } @@ -7,11 +7,21 @@ type Bar { } type Baz { - text: Int! + text: Decimal! } type QueryA { bar(id: FOO): Bar } +"The `@specifiedBy` directive is used within the type system definition language to provide a URL for specifying the behavior of custom scalar definitions." +directive @specifiedBy( + "The specifiedBy URL points to a human-readable specification. This field will only read a result for scalar types." + url: String! +) on SCALAR + +"The `Decimal` scalar type represents a decimal floating-point number with high precision." +scalar Decimal + @specifiedBy(url: "https://scalars.graphql.org/chillicream/decimal.html") + scalar FOO diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/ScalarBindingTests.Ensure_That_Implicit_Binding_Behavior_Is_Respected_On_Scalars.snap b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/ScalarBindingTests.Ensure_That_Implicit_Binding_Behavior_Is_Respected_On_Scalars.snap index 83ca8efbe4f..df85212c024 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/ScalarBindingTests.Ensure_That_Implicit_Binding_Behavior_Is_Respected_On_Scalars.snap +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/__snapshots__/ScalarBindingTests.Ensure_That_Implicit_Binding_Behavior_Is_Respected_On_Scalars.snap @@ -1,4 +1,4 @@ -schema { +schema { query: QueryB } diff --git a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/CodeFirstTests.Allow_PascalCasedArguments_Schema.graphql b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/CodeFirstTests.Allow_PascalCasedArguments_Schema.graphql index f932fa85f71..c71506fb370 100644 --- a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/CodeFirstTests.Allow_PascalCasedArguments_Schema.graphql +++ b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/CodeFirstTests.Allow_PascalCasedArguments_Schema.graphql @@ -5,9 +5,3 @@ schema { type PascalCaseQuery { testResolver(testArgument: String!): String! } - -"The purpose of the `cost` directive is to define a `weight` for GraphQL types, fields, and arguments. Static analysis can use these weights when calculating the overall cost of a query or response." -directive @cost( - "The `weight` argument defines what value to add to the overall cost for every appearance, or possible appearance, of a type, field, argument, etc." - weight: String! -) on SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | ENUM | INPUT_FIELD_DEFINITION diff --git a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.DescriptionsAreCorrectlyRead.snap b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.DescriptionsAreCorrectlyRead.snap index a1019f15196..206a39a5b48 100644 --- a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.DescriptionsAreCorrectlyRead.snap +++ b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.DescriptionsAreCorrectlyRead.snap @@ -1044,7 +1044,7 @@ }, { "name": "pathsToRoot", - "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "description": "Paths from this element to a root type, each as a list of schema coordinates.", "args": [], "type": { "kind": "NON_NULL", @@ -1056,8 +1056,8 @@ "kind": "NON_NULL", "name": null, "ofType": { - "kind": "SCALAR", - "name": "String" + "kind": "LIST", + "name": null } } } diff --git a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.Interfaces_Impl_Interfaces_Are_Correctly_Exposed_Through_Introspection.snap b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.Interfaces_Impl_Interfaces_Are_Correctly_Exposed_Through_Introspection.snap index a8f5387a74a..1bc480f6cfb 100644 --- a/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.Interfaces_Impl_Interfaces_Are_Correctly_Exposed_Through_Introspection.snap +++ b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.Interfaces_Impl_Interfaces_Are_Correctly_Exposed_Through_Introspection.snap @@ -1044,7 +1044,7 @@ }, { "name": "pathsToRoot", - "description": "Paths from this element to a root type, each as a dot-separated string of schema coordinates.", + "description": "Paths from this element to a root type, each as a list of schema coordinates.", "args": [], "type": { "kind": "NON_NULL", @@ -1056,8 +1056,8 @@ "kind": "NON_NULL", "name": null, "ofType": { - "kind": "SCALAR", - "name": "String" + "kind": "LIST", + "name": null } } } From dbb8aac2872cc6f47f5bbc63071b54cf93c9d59e Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 08:32:21 +0800 Subject: [PATCH 19/24] Fix introspection client --- .../Primitives/src/Primitives/Types/BuiltInTypes.cs | 2 ++ .../Primitives/src/Primitives/Types/IntrospectionTypeNames.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/HotChocolate/Primitives/src/Primitives/Types/BuiltInTypes.cs b/src/HotChocolate/Primitives/src/Primitives/Types/BuiltInTypes.cs index 16d5e401aca..fddf79e8f02 100644 --- a/src/HotChocolate/Primitives/src/Primitives/Types/BuiltInTypes.cs +++ b/src/HotChocolate/Primitives/src/Primitives/Types/BuiltInTypes.cs @@ -12,6 +12,8 @@ public static class BuiltInTypes IntrospectionTypeNames.__Field, IntrospectionTypeNames.__InputValue, IntrospectionTypeNames.__Schema, + IntrospectionTypeNames.__SchemaDefinition, + IntrospectionTypeNames.__SearchResult, IntrospectionTypeNames.__Type, IntrospectionTypeNames.__TypeKind, SpecScalarNames.String.Name, diff --git a/src/HotChocolate/Primitives/src/Primitives/Types/IntrospectionTypeNames.cs b/src/HotChocolate/Primitives/src/Primitives/Types/IntrospectionTypeNames.cs index 037e1bff792..b8943ba19a3 100644 --- a/src/HotChocolate/Primitives/src/Primitives/Types/IntrospectionTypeNames.cs +++ b/src/HotChocolate/Primitives/src/Primitives/Types/IntrospectionTypeNames.cs @@ -10,6 +10,8 @@ public static class IntrospectionTypeNames public const string __Field = nameof(__Field); public const string __InputValue = nameof(__InputValue); public const string __Schema = nameof(__Schema); + public const string __SchemaDefinition = nameof(__SchemaDefinition); + public const string __SearchResult = nameof(__SearchResult); public const string __Type = nameof(__Type); public const string __TypeKind = nameof(__TypeKind); // ReSharper restore InconsistentNaming From 0876c591d8dc7d7a35ac339910720111362eba01 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 09:20:54 +0800 Subject: [PATCH 20/24] fix tests --- .../Execution/Introspection/__Type.cs | 2 +- ...trospectionQueries_IntrospectionQuery.yaml | 8 +- .../CodeGeneration/Utilities/SchemaHelper.cs | 1 + ...st.Execute_StarWarsIntrospection_Test.snap | 472 ++++++++++++++++-- 4 files changed, 433 insertions(+), 50 deletions(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__Type.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__Type.cs index 23f1c8456da..24e0ffac75b 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__Type.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__Type.cs @@ -55,7 +55,7 @@ public void OnApplyResolver(string fieldName, IFeatureCollection features) features.Set(new ResolveFieldValue(IsOneOf)); break; - case "specifiedBy": + case "specifiedByURL": features.Set(new ResolveFieldValue(SpecifiedBy)); break; } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_IntrospectionQuery.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_IntrospectionQuery.yaml index bb0aac8d1d0..5095fe941a6 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_IntrospectionQuery.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_IntrospectionQuery.yaml @@ -1583,7 +1583,7 @@ response: { "kind": "SCALAR", "name": "String", - "description": null, + "description": "The \u0060String\u0060 scalar type represents textual data, represented as a sequence of Unicode code points.", "specifiedByURL": null, "fields": null, "inputFields": null, @@ -1594,7 +1594,7 @@ response: { "kind": "SCALAR", "name": "Boolean", - "description": null, + "description": "The \u0060Boolean\u0060 scalar type represents \u0060true\u0060 or \u0060false\u0060.", "specifiedByURL": null, "fields": null, "inputFields": null, @@ -1605,7 +1605,7 @@ response: { "kind": "SCALAR", "name": "ID", - "description": null, + "description": "The \u0060ID\u0060 scalar type represents a unique identifier, often used to refetch an object or as the key for a cache.", "specifiedByURL": null, "fields": null, "inputFields": null, @@ -1616,7 +1616,7 @@ response: { "kind": "SCALAR", "name": "Int", - "description": null, + "description": "The \u0060Int\u0060 scalar type represents a signed 32-bit numeric non-fractional value.", "specifiedByURL": null, "fields": null, "inputFields": null, diff --git a/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Utilities/SchemaHelper.cs b/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Utilities/SchemaHelper.cs index c3067cc16ff..01a765617c6 100644 --- a/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Utilities/SchemaHelper.cs +++ b/src/StrawberryShake/CodeGeneration/src/CodeGeneration/Utilities/SchemaHelper.cs @@ -91,6 +91,7 @@ public static Schema Load( o.EnableStream = true; o.EnableTag = false; o.EnableFlagEnums = false; + o.EnableSemanticIntrospection = false; }) .SetSchema(d => d.Extend().OnBeforeCreate( c => c.Features.Set(typeInfos))) diff --git a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/__snapshots__/StarWarsIntrospectionTest.Execute_StarWarsIntrospection_Test.snap b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/__snapshots__/StarWarsIntrospectionTest.Execute_StarWarsIntrospection_Test.snap index 3ea79288b93..5a902cdb1d5 100644 --- a/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/__snapshots__/StarWarsIntrospectionTest.Execute_StarWarsIntrospection_Test.snap +++ b/src/StrawberryShake/CodeGeneration/test/CodeGeneration.CSharp.Tests/Integration/__snapshots__/StarWarsIntrospectionTest.Execute_StarWarsIntrospection_Test.snap @@ -983,6 +983,156 @@ ], "PossibleTypes": null }, + { + "Kind": "Object", + "Name": "__SearchResult", + "Description": "A search result representing a matched schema element.", + "Fields": [ + { + "Name": "cursor", + "Description": "An opaque cursor for pagination.", + "Args": [], + "Type": { + "Kind": "NonNull", + "Name": null, + "OfType": { + "Kind": "Scalar", + "Name": "String", + "OfType": null + } + }, + "IsDeprecated": false, + "DeprecationReason": null + }, + { + "Name": "coordinate", + "Description": "The schema coordinate of the matched element.", + "Args": [], + "Type": { + "Kind": "NonNull", + "Name": null, + "OfType": { + "Kind": "Scalar", + "Name": "String", + "OfType": null + } + }, + "IsDeprecated": false, + "DeprecationReason": null + }, + { + "Name": "definition", + "Description": "The matched schema definition.", + "Args": [], + "Type": { + "Kind": "NonNull", + "Name": null, + "OfType": { + "Kind": "Union", + "Name": "__SchemaDefinition", + "OfType": null + } + }, + "IsDeprecated": false, + "DeprecationReason": null + }, + { + "Name": "pathsToRoot", + "Description": "Paths from this element to a root type, each as a list of schema coordinates.", + "Args": [], + "Type": { + "Kind": "NonNull", + "Name": null, + "OfType": { + "Kind": "List", + "Name": null, + "OfType": { + "Kind": "NonNull", + "Name": null, + "OfType": { + "Kind": "List", + "Name": null + } + } + } + }, + "IsDeprecated": false, + "DeprecationReason": null + }, + { + "Name": "score", + "Description": "The relevance score of the match, or null if scoring is not supported.", + "Args": [], + "Type": { + "Kind": "Scalar", + "Name": "Float", + "OfType": null + }, + "IsDeprecated": false, + "DeprecationReason": null + } + ], + "InputFields": null, + "Interfaces": [], + "EnumValues": null, + "PossibleTypes": null + }, + { + "Kind": "Union", + "Name": "__SchemaDefinition", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": [ + { + "Kind": "Object", + "Name": "__Type", + "OfType": null + }, + { + "Kind": "Object", + "Name": "__Field", + "OfType": null + }, + { + "Kind": "Object", + "Name": "__InputValue", + "OfType": null + }, + { + "Kind": "Object", + "Name": "__EnumValue", + "OfType": null + }, + { + "Kind": "Object", + "Name": "__Directive", + "OfType": null + } + ] + }, + { + "Kind": "Scalar", + "Name": "Int", + "Description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null + }, + { + "Kind": "Scalar", + "Name": "Float", + "Description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null + }, { "Kind": "Object", "Name": "Query", @@ -1740,16 +1890,6 @@ "EnumValues": null, "PossibleTypes": null }, - { - "Kind": "Scalar", - "Name": "Int", - "Description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", - "Fields": null, - "InputFields": null, - "Interfaces": null, - "EnumValues": null, - "PossibleTypes": null - }, { "Kind": "Interface", "Name": "Character", @@ -2081,16 +2221,6 @@ "EnumValues": null, "PossibleTypes": null }, - { - "Kind": "Scalar", - "Name": "Float", - "Description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", - "Fields": null, - "InputFields": null, - "Interfaces": null, - "EnumValues": null, - "PossibleTypes": null - }, { "Kind": "Enum", "Name": "Unit", @@ -4306,6 +4436,282 @@ "PossibleTypes": null, "OfType": null }, + { + "__typename": "__Type", + "Name": "__SearchResult", + "Kind": "Object", + "Description": "A search result representing a matched schema element.", + "Fields": [ + { + "__typename": "__Field", + "Name": "cursor", + "Description": "An opaque cursor for pagination.", + "Args": [], + "Type": { + "__typename": "__Type", + "Name": null, + "Kind": "NonNull", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": { + "__typename": "__Type", + "Name": "String", + "Kind": "Scalar", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + } + }, + "IsDeprecated": false, + "DeprecationReason": null + }, + { + "__typename": "__Field", + "Name": "coordinate", + "Description": "The schema coordinate of the matched element.", + "Args": [], + "Type": { + "__typename": "__Type", + "Name": null, + "Kind": "NonNull", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": { + "__typename": "__Type", + "Name": "String", + "Kind": "Scalar", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + } + }, + "IsDeprecated": false, + "DeprecationReason": null + }, + { + "__typename": "__Field", + "Name": "definition", + "Description": "The matched schema definition.", + "Args": [], + "Type": { + "__typename": "__Type", + "Name": null, + "Kind": "NonNull", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": { + "__typename": "__Type", + "Name": "__SchemaDefinition", + "Kind": "Union", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + } + }, + "IsDeprecated": false, + "DeprecationReason": null + }, + { + "__typename": "__Field", + "Name": "pathsToRoot", + "Description": "Paths from this element to a root type, each as a list of schema coordinates.", + "Args": [], + "Type": { + "__typename": "__Type", + "Name": null, + "Kind": "NonNull", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": { + "__typename": "__Type", + "Name": null, + "Kind": "List", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": { + "__typename": "__Type", + "Name": null, + "Kind": "NonNull", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": { + "__typename": "__Type", + "Name": null, + "Kind": "List", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + } + } + } + }, + "IsDeprecated": false, + "DeprecationReason": null + }, + { + "__typename": "__Field", + "Name": "score", + "Description": "The relevance score of the match, or null if scoring is not supported.", + "Args": [], + "Type": { + "__typename": "__Type", + "Name": "Float", + "Kind": "Scalar", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + }, + "IsDeprecated": false, + "DeprecationReason": null + } + ], + "InputFields": null, + "Interfaces": [], + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + }, + { + "__typename": "__Type", + "Name": "__SchemaDefinition", + "Kind": "Union", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": [ + { + "__typename": "__Type", + "Name": "__Type", + "Kind": "Object", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + }, + { + "__typename": "__Type", + "Name": "__Field", + "Kind": "Object", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + }, + { + "__typename": "__Type", + "Name": "__InputValue", + "Kind": "Object", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + }, + { + "__typename": "__Type", + "Name": "__EnumValue", + "Kind": "Object", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + }, + { + "__typename": "__Type", + "Name": "__Directive", + "Kind": "Object", + "Description": null, + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + } + ], + "OfType": null + }, + { + "__typename": "__Type", + "Name": "Int", + "Kind": "Scalar", + "Description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + }, + { + "__typename": "__Type", + "Name": "Float", + "Kind": "Scalar", + "Description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + "Fields": null, + "InputFields": null, + "Interfaces": null, + "EnumValues": null, + "PossibleTypes": null, + "OfType": null + }, { "__typename": "__Type", "Name": "Query", @@ -5677,18 +6083,6 @@ "PossibleTypes": null, "OfType": null }, - { - "__typename": "__Type", - "Name": "Int", - "Kind": "Scalar", - "Description": "The `Int` scalar type represents a signed 32-bit numeric non-fractional value.", - "Fields": null, - "InputFields": null, - "Interfaces": null, - "EnumValues": null, - "PossibleTypes": null, - "OfType": null - }, { "__typename": "__Type", "Name": "Character", @@ -6274,18 +6668,6 @@ "PossibleTypes": null, "OfType": null }, - { - "__typename": "__Type", - "Name": "Float", - "Kind": "Scalar", - "Description": "The `Float` scalar type represents signed double-precision finite values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", - "Fields": null, - "InputFields": null, - "Interfaces": null, - "EnumValues": null, - "PossibleTypes": null, - "OfType": null - }, { "__typename": "__Type", "Name": "Unit", @@ -7089,4 +7471,4 @@ "Errors": [], "Extensions": {}, "ContextData": {} -} +} \ No newline at end of file From 9abe1e68ed95bfa2379a1518c5c5029723dbd74f Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 09:26:46 +0800 Subject: [PATCH 21/24] fix isoneof --- .../Completion/IntrospectionSchema.cs | 2 + .../__resources__/IntrospectionQuery.graphql | 1 + ...trospectionQueries_IntrospectionQuery.yaml | 40 ++++++++++++++++++- ...spectionQueries_TypeCapabilitiesQuery.yaml | 3 ++ ...Tests.Typename_On_Introspection_Types.yaml | 3 ++ ...nd_Reviews_And_Products_Introspection.yaml | 7 ++++ 6 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/IntrospectionSchema.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/IntrospectionSchema.cs index 1f1baade071..af70648fef0 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/IntrospectionSchema.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/IntrospectionSchema.cs @@ -35,6 +35,8 @@ type __Type { inputFields(includeDeprecated: Boolean! = false): [__InputValue!] # must be non-null for NON_NULL and LIST, otherwise null. ofType: __Type + # must be non-null for INPUT_OBJECT, otherwise null. + isOneOf: Boolean } enum __TypeKind { diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__resources__/IntrospectionQuery.graphql b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__resources__/IntrospectionQuery.graphql index f31394fa41a..8cd506b1ff0 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__resources__/IntrospectionQuery.graphql +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__resources__/IntrospectionQuery.graphql @@ -29,6 +29,7 @@ fragment FullType on __Type { name description specifiedByURL + isOneOf fields(includeDeprecated: true) { name description diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_IntrospectionQuery.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_IntrospectionQuery.yaml index 5095fe941a6..80d6048d632 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_IntrospectionQuery.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_IntrospectionQuery.yaml @@ -33,6 +33,7 @@ request: name description specifiedByURL + isOneOf fields(includeDeprecated: true) { name description @@ -118,6 +119,7 @@ response: "name": "__Schema", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "description", @@ -230,6 +232,7 @@ response: "name": "__Type", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "kind", @@ -445,6 +448,18 @@ response: }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "isOneOf", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -457,6 +472,7 @@ response: "name": "__TypeKind", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -517,6 +533,7 @@ response: "name": "__Field", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "name", @@ -642,6 +659,7 @@ response: "name": "__InputValue", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "name", @@ -738,6 +756,7 @@ response: "name": "__EnumValue", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "name", @@ -806,6 +825,7 @@ response: "name": "__Directive", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "name", @@ -927,6 +947,7 @@ response: "name": "__DirectiveLocation", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1053,6 +1074,7 @@ response: "name": "Query", "description": "Object type description", "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "node", @@ -1212,6 +1234,7 @@ response: "name": "Mutation", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "postReview", @@ -1249,6 +1272,7 @@ response: "name": "Subscription", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "onNewReview", @@ -1273,6 +1297,7 @@ response: "name": "Post", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "id", @@ -1324,6 +1349,7 @@ response: "name": "Review", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "id", @@ -1363,6 +1389,7 @@ response: "name": "Node", "description": null, "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "id", @@ -1402,6 +1429,7 @@ response: "name": "Votable", "description": "Interface description", "specifiedByURL": null, + "isOneOf": null, "fields": [ { "name": "id", @@ -1447,6 +1475,7 @@ response: "name": "UserCreation", "description": "Union description", "specifiedByURL": null, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1469,6 +1498,7 @@ response: "name": "PostReviewInput", "description": null, "specifiedByURL": null, + "isOneOf": true, "fields": null, "inputFields": [ { @@ -1509,6 +1539,7 @@ response: "name": "PostReviewPro", "description": null, "specifiedByURL": null, + "isOneOf": false, "fields": null, "inputFields": [ { @@ -1537,6 +1568,7 @@ response: "name": "PostsFilter", "description": "Input object type description", "specifiedByURL": null, + "isOneOf": false, "fields": null, "inputFields": [ { @@ -1561,6 +1593,7 @@ response: "name": "PostKind", "description": "Enum description", "specifiedByURL": null, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1585,6 +1618,7 @@ response: "name": "String", "description": "The \u0060String\u0060 scalar type represents textual data, represented as a sequence of Unicode code points.", "specifiedByURL": null, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1596,6 +1630,7 @@ response: "name": "Boolean", "description": "The \u0060Boolean\u0060 scalar type represents \u0060true\u0060 or \u0060false\u0060.", "specifiedByURL": null, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1607,6 +1642,7 @@ response: "name": "ID", "description": "The \u0060ID\u0060 scalar type represents a unique identifier, often used to refetch an object or as the key for a cache.", "specifiedByURL": null, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1618,6 +1654,7 @@ response: "name": "Int", "description": "The \u0060Int\u0060 scalar type represents a signed 32-bit numeric non-fractional value.", "specifiedByURL": null, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1824,6 +1861,7 @@ operationPlan: name description specifiedByURL + isOneOf fields(includeDeprecated: true) { name description @@ -2011,7 +2049,7 @@ operationPlan: } } name: IntrospectionQuery - hash: a1ed729bdd8eeff7c48797c59222c723 + hash: 100115cb7dd55b6a817c8f83458b8fc5 searchSpace: 1 expandedNodes: 1 nodes: diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_TypeCapabilitiesQuery.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_TypeCapabilitiesQuery.yaml index a1d03a63de4..3f86a084af8 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_TypeCapabilitiesQuery.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.IntrospectionQueries_TypeCapabilitiesQuery.yaml @@ -45,6 +45,9 @@ response: }, { "name": "ofType" + }, + { + "name": "isOneOf" } ] } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Typename_On_Introspection_Types.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Typename_On_Introspection_Types.yaml index 99e2624bf5a..c2613b28a48 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Typename_On_Introspection_Types.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/__snapshots__/IntrospectionTests.Typename_On_Introspection_Types.yaml @@ -97,6 +97,9 @@ response: { "__typename": "__Field" }, + { + "__typename": "__Field" + }, { "__typename": "__Field" } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/v15/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.yaml b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/v15/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.yaml index 3bef2e6b7d9..b99f30d0961 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/v15/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.yaml +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/v15/__snapshots__/DemoIntegrationTests.Authors_And_Reviews_And_Products_Introspection.yaml @@ -143,6 +143,13 @@ response: "name": "__Type", "kind": "OBJECT" } + }, + { + "name": "isOneOf", + "type": { + "name": "Boolean", + "kind": "SCALAR" + } } ] }, From 4c869fff904a1620f0b3f01ec9015b214ec467f0 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 09:51:59 +0800 Subject: [PATCH 22/24] fail for invalid coordinates --- .../Introspection/IntrospectionFields.cs | 16 +++++++++--- .../SemanticIntrospectionTests.cs | 18 ++----------- ...ions_Should_ErrorOn_UnknownCoordinate.snap | 11 ++++++++ .../Execution/Introspection/Query.cs | 22 +++++++++++++--- .../Nodes/IntrospectionExecutionNode.cs | 26 ++++++++++++++++++- .../SemanticIntrospectionTests.cs | 20 +++++++------- 6 files changed, 78 insertions(+), 35 deletions(-) create mode 100644 src/HotChocolate/Core/test/Execution.Tests/__snapshots__/SemanticIntrospectionTests.Definitions_Should_ErrorOn_UnknownCoordinate.snap diff --git a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs index 0396bd4ceab..325ad162f52 100644 --- a/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/IntrospectionFields.cs @@ -171,13 +171,23 @@ internal static ObjectFieldConfiguration CreateDefinitionsField(IDescriptorConte { if (!SchemaCoordinate.TryParse(coordinateString, out var coordinate)) { - continue; + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage( + $"The value '{coordinateString}' is not a valid schema coordinate.") + .Build()); } - if (ctx.Schema.TryGetMember(coordinate.Value, out var definition)) + if (!ctx.Schema.TryGetMember(coordinate.Value, out var definition)) { - definitions.Add(definition); + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage( + $"No schema member was found for the coordinate '{coordinate.Value}'.") + .Build()); } + + definitions.Add(definition); } return new ValueTask(definitions); diff --git a/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs index 2f1f6a669bb..94ee510b810 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs @@ -709,7 +709,7 @@ ... on __Field { } [Fact] - public async Task Definitions_Should_SkipInvalidCoordinates() + public async Task Definitions_Should_ErrorOn_UnknownCoordinate() { // arrange var executor = CreateSchema().MakeExecutable(); @@ -730,21 +730,7 @@ ... on __Field { """); // assert - result.MatchInlineSnapshot( - """ - { - "data": { - "__definitions": [ - { - "typeName": "User" - }, - { - "fieldName": "orderById" - } - ] - } - } - """); + result.MatchSnapshot(); } [Fact] diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/SemanticIntrospectionTests.Definitions_Should_ErrorOn_UnknownCoordinate.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/SemanticIntrospectionTests.Definitions_Should_ErrorOn_UnknownCoordinate.snap new file mode 100644 index 00000000000..528484d8191 --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/SemanticIntrospectionTests.Definitions_Should_ErrorOn_UnknownCoordinate.snap @@ -0,0 +1,11 @@ +{ + "errors": [ + { + "message": "No schema member was found for the coordinate 'NonExistentType'.", + "path": [ + "__definitions" + ] + } + ], + "data": null +} diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs index 8d8b695405b..f0a0d3ff126 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs @@ -148,18 +148,32 @@ public static void Definitions(FieldContext context) { if (item is not StringValueNode coordinateString) { - continue; + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage( + "Each entry in the `coordinates` argument must be a string value.") + .Build()); } if (!SchemaCoordinate.TryParse(coordinateString.Value, out var coordinate)) { - continue; + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage( + $"The value '{coordinateString.Value}' is not a valid schema coordinate.") + .Build()); } - if (context.Schema.TryGetMember(coordinate.Value, out var definition)) + if (!context.Schema.TryGetMember(coordinate.Value, out var definition)) { - definitions.Add(definition); + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage( + $"No schema member was found for the coordinate '{coordinate.Value}'.") + .Build()); } + + definitions.Add(definition); } var list = context.FieldResult.CreateListValue(definitions.Count); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs index 2e75260060c..021caebbf9f 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/IntrospectionExecutionNode.cs @@ -72,7 +72,31 @@ protected override async ValueTask OnExecuteAsync( backlog.Push((null, selection, property)); } - await ExecuteSelectionsAsync(context, backlog, cancellationToken).ConfigureAwait(false); + try + { + await ExecuteSelectionsAsync(context, backlog, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return ExecutionStatus.Failed; + } + catch (GraphQLException ex) + { + foreach (var error in ex.Errors) + { + context.AddErrors(error, _resultSelectionSet, Path.Root); + } + + return ExecutionStatus.Failed; + } + catch (Exception ex) + { + var error = ErrorBuilder.FromException(ex).Build(); + context.AddErrors(error, _resultSelectionSet, Path.Root); + + return ExecutionStatus.Failed; + } + context.AddPartialResults(resultBuilder.Build(), _resultSelectionSet); return ExecutionStatus.Success; diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs index 102c2eb5996..2730c4dc69f 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs @@ -801,7 +801,7 @@ ... on __Field { } [Fact] - public async Task Definitions_Should_SkipInvalidCoordinates() + public async Task Definitions_Should_ErrorOn_UnknownCoordinate() { // arrange using var server = CreateSourceSchema("A", SourceSchema); @@ -831,16 +831,14 @@ ... on __Field { response.MatchInlineSnapshot( """ { - "data": { - "__definitions": [ - { - "typeName": "User" - }, - { - "fieldName": "orderById" - } - ] - } + "errors": [ + { + "message": "No schema member was found for the coordinate 'NonExistentType'.", + "path": [ + "__definitions" + ] + } + ] } """); } From bd5a46dd8b46315061e4b89cd01d75c93ea57327 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 10:24:34 +0800 Subject: [PATCH 23/24] Align errors and limits --- .../OperationResultSnapshotValueFormatter.cs | 2 +- .../SemanticIntrospectionTests.cs | 102 +++++++++++++++ .../Execution/Introspection/Query.cs | 10 ++ .../Execution/Results/FetchResultStore.cs | 4 +- .../SemanticIntrospectionTests.cs | 122 ++++++++++++++++++ 5 files changed, 238 insertions(+), 2 deletions(-) diff --git a/src/CookieCrumble/src/CookieCrumble.HotChocolate/Formatters/OperationResultSnapshotValueFormatter.cs b/src/CookieCrumble/src/CookieCrumble.HotChocolate/Formatters/OperationResultSnapshotValueFormatter.cs index b3db816d674..53b88b9fa14 100644 --- a/src/CookieCrumble/src/CookieCrumble.HotChocolate/Formatters/OperationResultSnapshotValueFormatter.cs +++ b/src/CookieCrumble/src/CookieCrumble.HotChocolate/Formatters/OperationResultSnapshotValueFormatter.cs @@ -30,7 +30,7 @@ protected override void Format(IBufferWriter snapshot, OperationResult val writer.WriteNumber("variableIndex", value.VariableIndex.Value); } - if (value.Data.ValueKind is JsonValueKind.Object) + if (value.Data.ValueKind is JsonValueKind.Object or JsonValueKind.Null) { writer.WritePropertyName("data"); value.Data.WriteTo(writer); diff --git a/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs index 94ee510b810..21ccad37d8f 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs @@ -127,6 +127,108 @@ public async Task Search_Should_RespectFirstArgument() """); } + [Fact] + public async Task Search_Should_ErrorOn_FirstExceedingLimit() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "product", first: 151) { + coordinate + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "The `first` argument must not exceed 150.", + "path": [ + "__search" + ] + } + ], + "data": null + } + """); + } + + [Fact] + public async Task Search_Should_ErrorOn_FirstLessThanOrEqualToZero() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + + // act + var result = await executor.ExecuteAsync( + """ + { + __search(query: "product", first: 0) { + coordinate + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "The `first` argument must be greater than zero.", + "path": [ + "__search" + ] + } + ], + "data": null + } + """); + } + + [Fact] + public async Task Definitions_Should_ErrorOn_CoordinatesExceedingLimit() + { + // arrange + var executor = CreateSchema().MakeExecutable(); + var coordinates = string.Join(", ", Enumerable.Repeat("\"User\"", 151)); + + // act + var result = await executor.ExecuteAsync( + $$""" + { + __definitions(coordinates: [{{coordinates}}]) { + ... on __Type { + name + } + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "errors": [ + { + "message": "The `coordinates` argument must not exceed 150 items.", + "path": [ + "__definitions" + ] + } + ], + "data": null + } + """); + } + [Fact] public async Task Search_Should_FilterByMinScore() { diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs index f0a0d3ff126..de1b6c42ed6 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/Query.cs @@ -142,6 +142,16 @@ public static async ValueTask SearchAsync(FieldContext context) public static void Definitions(FieldContext context) { var coordinatesNode = context.ArgumentValue("coordinates"); + + if (coordinatesNode.Items.Count > MaxFirstLimit) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage( + $"The `coordinates` argument must not exceed {MaxFirstLimit} items.") + .Build()); + } + var definitions = new List(coordinatesNode.Items.Count); foreach (var item in coordinatesNode.Items) diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs index 055387bc0a7..7cf5dcb2563 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs @@ -314,7 +314,9 @@ public bool AddPartialResults(SourceResultDocument document, ResultSelectionSet return _valueCompletion.BuildResult( partial, - data, errorTrie: null, resultSelectionSet: resultSelectionSet); + data, + errorTrie: null, + resultSelectionSet: resultSelectionSet); } } diff --git a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs index 2730c4dc69f..4c986f0acef 100644 --- a/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs @@ -149,6 +149,127 @@ public async Task Search_Should_RespectFirstArgument() """); } + [Fact] + public async Task Search_Should_ErrorOn_FirstExceedingLimit() + { + // arrange + using var server = CreateSourceSchema("A", SourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "product", first: 151) { + coordinate + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": null, + "errors": [ + { + "message": "The `first` argument must not exceed 150.", + "path": [ + "__search" + ] + } + ] + } + """); + } + + [Fact] + public async Task Search_Should_ErrorOn_FirstLessThanOrEqualToZero() + { + // arrange + using var server = CreateSourceSchema("A", SourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + // act + using var result = await client.PostAsync( + new OperationRequest( + """ + { + __search(query: "product", first: 0) { + coordinate + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": null, + "errors": [ + { + "message": "The `first` argument must be greater than zero.", + "path": [ + "__search" + ] + } + ] + } + """); + } + + [Fact] + public async Task Definitions_Should_ErrorOn_CoordinatesExceedingLimit() + { + // arrange + using var server = CreateSourceSchema("A", SourceSchema); + + using var gateway = await CreateGatewayAsync(server); + using var client = GraphQLHttpClient.Create(gateway.CreateClient()); + + var coordinates = string.Join(", ", Enumerable.Repeat("\"User\"", 151)); + + // act + using var result = await client.PostAsync( + new OperationRequest( + $$""" + { + __definitions(coordinates: [{{coordinates}}]) { + ... on __Type { + name + } + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": null, + "errors": [ + { + "message": "The `coordinates` argument must not exceed 150 items.", + "path": [ + "__definitions" + ] + } + ] + } + """); + } + [Fact] public async Task Search_Should_FilterByMinScore() { @@ -831,6 +952,7 @@ ... on __Field { response.MatchInlineSnapshot( """ { + "data": null, "errors": [ { "message": "No schema member was found for the coordinate 'NonExistentType'.", From 0b3ba26cb830e6d3e31d39a35e12c773f1439efb Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 13 Apr 2026 10:30:15 +0800 Subject: [PATCH 24/24] Add more tests --- .../IntrospectionRuleTests.cs | 43 ++++++++++++++++++- ...ospectionNotAllowed_Definitions_Field.snap | 15 +++++++ ....IntrospectionNotAllowed_Search_Field.snap | 15 +++++++ 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 src/HotChocolate/Core/test/Validation.Tests/__snapshots__/IntrospectionRuleTests.IntrospectionNotAllowed_Definitions_Field.snap create mode 100644 src/HotChocolate/Core/test/Validation.Tests/__snapshots__/IntrospectionRuleTests.IntrospectionNotAllowed_Search_Field.snap diff --git a/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs b/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs index 465be9e6736..f4c808e3188 100644 --- a/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs +++ b/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs @@ -98,11 +98,50 @@ public void IntrospectionAllowed_Type_Field() context => context.Features.Set(new IntrospectionRequestOverrides(IsAllowed: true))); } + [Fact] + public void IntrospectionNotAllowed_Search_Field() + { + ExpectErrors( + CreateSchemaWithSemanticIntrospection(), + b => b.AddIntrospectionAllowedRule() + .ModifyOptions(o => o.DisableIntrospection = true), + """ + { + __search(query: "foo") { + coordinate + } + } + """); + } + + [Fact] + public void IntrospectionNotAllowed_Definitions_Field() + { + ExpectErrors( + CreateSchemaWithSemanticIntrospection(), + b => b.AddIntrospectionAllowedRule() + .ModifyOptions(o => o.DisableIntrospection = true), + """ + { + __definitions(coordinates: ["Foo"]) { + ... on __Type { + name + } + } + } + """); + } + private static Schema CreateSchema() => SchemaBuilder.New() - .AddDocumentFromString( - FileResource.Open("IntrospectionSchema.graphql")) + .AddDocumentFromString(FileResource.Open("IntrospectionSchema.graphql")) .ModifyOptions(o => o.EnableSemanticIntrospection = false) .Use(_ => _ => default) .Create(); + + private static Schema CreateSchemaWithSemanticIntrospection() + => SchemaBuilder.New() + .AddDocumentFromString(FileResource.Open("IntrospectionSchema.graphql")) + .Use(_ => _ => default) + .Create(); } diff --git a/src/HotChocolate/Core/test/Validation.Tests/__snapshots__/IntrospectionRuleTests.IntrospectionNotAllowed_Definitions_Field.snap b/src/HotChocolate/Core/test/Validation.Tests/__snapshots__/IntrospectionRuleTests.IntrospectionNotAllowed_Definitions_Field.snap new file mode 100644 index 00000000000..cb386299bfe --- /dev/null +++ b/src/HotChocolate/Core/test/Validation.Tests/__snapshots__/IntrospectionRuleTests.IntrospectionNotAllowed_Definitions_Field.snap @@ -0,0 +1,15 @@ +"errors": [ + { + "message": "Introspection is not allowed for the current request.", + "locations": [ + { + "line": 2, + "column": 5 + } + ], + "extensions": { + "code": "HC0046", + "field": "__definitions" + } + } +] diff --git a/src/HotChocolate/Core/test/Validation.Tests/__snapshots__/IntrospectionRuleTests.IntrospectionNotAllowed_Search_Field.snap b/src/HotChocolate/Core/test/Validation.Tests/__snapshots__/IntrospectionRuleTests.IntrospectionNotAllowed_Search_Field.snap new file mode 100644 index 00000000000..7d4a635b877 --- /dev/null +++ b/src/HotChocolate/Core/test/Validation.Tests/__snapshots__/IntrospectionRuleTests.IntrospectionNotAllowed_Search_Field.snap @@ -0,0 +1,15 @@ +"errors": [ + { + "message": "Introspection is not allowed for the current request.", + "locations": [ + { + "line": 2, + "column": 5 + } + ], + "extensions": { + "code": "HC0046", + "field": "__search" + } + } +]