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/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 } 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..a7191410fdb --- /dev/null +++ b/src/HotChocolate/Core/src/Types.Abstractions/Extensions/SchemaDefinitionExtensions.cs @@ -0,0 +1,145 @@ +using System.Diagnostics.CodeAnalysis; +using HotChocolate.Types; + +namespace HotChocolate; + +/// +/// Provides extension methods for . +/// +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 . + /// + /// + /// 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.Abstractions/ISchemaSearchProvider.cs b/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs new file mode 100644 index 00000000000..a7fe3908754 --- /dev/null +++ b/src/HotChocolate/Core/src/Types.Abstractions/ISchemaSearchProvider.cs @@ -0,0 +1,65 @@ +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. + /// + /// + /// Thrown when the cursor is malformed or out of range. + /// + /// + /// Thrown when the exceeds the maximum allowed length. + /// + 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 implementation determines how many paths to return. + /// + /// + /// The schema coordinate from which to trace paths to a root type. + /// + /// + /// The cancellation token. + /// + /// + /// A list of instances, + /// each representing an ordered path from the coordinate to a root type, + /// ordered by path length (shortest first). + /// + ValueTask> GetPathsToRootAsync( + SchemaCoordinate coordinate, + CancellationToken cancellationToken = default); +} 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/SchemaCoordinatePath.cs b/src/HotChocolate/Core/src/Types.Abstractions/SchemaCoordinatePath.cs new file mode 100644 index 00000000000..fb3463826b5 --- /dev/null +++ b/src/HotChocolate/Core/src/Types.Abstractions/SchemaCoordinatePath.cs @@ -0,0 +1,82 @@ +using System.Collections; +using System.Collections.Immutable; + +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; + private ImmutableArray? _stringSegments; + + /// + /// 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]; + + /// + /// 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() + { + 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.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/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..70b2d2b68b8 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,12 @@ internal static void AddCoreSchemaServices(IServiceCollection services, LazySche lazy.OnSchemaCreated(accessor.OnSchemaCreated); return accessor; }); + + services.TryAddSingleton( + static sp => + { + var schema = sp.GetRequiredService(); + return new BM25SearchProvider(schema); + }); } } 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..325ad162f52 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; @@ -66,6 +68,134 @@ 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"); + + 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()); + } + + try + { + return 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()); + } + } + + 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"); + + 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) + { + if (!SchemaCoordinate.TryParse(coordinateString, out var coordinate)) + { + 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)) + { + throw new GraphQLException( + ErrorBuilder.New() + .SetMessage( + $"No schema member was found for the coordinate '{coordinate.Value}'.") + .Build()); + } + + definitions.Add(definition); + } + + return new ValueTask(definitions); + } + + return CreateConfiguration(descriptor); + } + 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..0c1d714a702 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25Index.cs @@ -0,0 +1,201 @@ +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. + /// + /// + /// The cancellation token. + /// + /// + /// A list of document ID and raw BM25 score pairs, sorted by score descending. + /// + public IReadOnlyList Search( + string[] queryTokens, + CancellationToken cancellationToken = default) + { + 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) + { + cancellationToken.ThrowIfCancellationRequested(); + + 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..f35577fe9e2 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/BM25SearchProvider.cs @@ -0,0 +1,272 @@ +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 const int MaxQueryLength = 1024; + private const int MaxPaths = 5; + + 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); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(first); + + if (query.Length > MaxQueryLength) + { + throw new SearchQueryTooLargeException(); + } + + if (after is { Length: 0 }) + { + throw new ArgumentException("The cursor must not be empty.", nameof(after)); + } + + var data = EnsureIndex(); + var queryTokens = BM25Tokenizer.Tokenize(query); + var rawResults = data.Index.Search(queryTokens, cancellationToken); + + if (rawResults.Count == 0) + { + 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 = after is not null + ? DecodeCursor(after, rawResults.Count) + : 0; + + 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 ValueTask.FromResult>(results); + } + + /// + public ValueTask> GetPathsToRootAsync( + SchemaCoordinate coordinate, + CancellationToken cancellationToken = default) + { + var data = EnsureIndex(); + var result = FindPathsToRoot(data, coordinate, MaxPaths, cancellationToken); + + // Sort by path length (shortest first). + result.Sort(static (a, b) => a.Count.CompareTo(b.Count)); + + return ValueTask.FromResult>(result); + } + + private static List FindPathsToRoot( + SearchData data, + SchemaCoordinate coordinate, + int maxPaths, + CancellationToken cancellationToken) + { + var rootTypeNames = data.RootTypeNames; + var reverseMap = data.ReverseMap; + var startTypeName = coordinate.Name; + var paths = new List(); + + // 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)) + { + if (coordinate.MemberName is not null) + { + paths.Add(new SchemaCoordinatePath([coordinate])); + } + + return paths; + } + + // BFS: each queue entry is (currentTypeName, pathSoFar). + // 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 }; + + while (queue.Count > 0 && paths.Count < maxPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + var (currentType, currentPath) = queue.Dequeue(); + + if (!reverseMap.TryGetValue(currentType, out var references)) + { + continue; + } + + foreach (var reference in references) + { + if (!visited.Add(reference.Name)) + { + continue; + } + + var newPath = new List(currentPath.Count + 1) { reference }; + newPath.AddRange(currentPath); + + if (rootTypeNames.Contains(reference.Name)) + { + if (coordinate.MemberName is not null) + { + newPath.Add(coordinate); + } + + paths.Add(new SchemaCoordinatePath(CollectionsMarshal.AsSpan(newPath))); + + if (paths.Count >= maxPaths) + { + break; + } + } + else + { + queue.Enqueue((reference.Name, 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, int resultCount) + { + int offset; + + try + { + var bytes = Convert.FromBase64String(cursor); + + if (bytes.Length < 4) + { + throw new InvalidSearchCursorException(); + } + + offset = BitConverter.ToInt32(bytes, 0); + } + catch (FormatException) + { + throw new InvalidSearchCursorException(); + } + + if (offset < 0 || offset > resultCount) + { + throw new InvalidSearchCursorException(); + } + + return offset; + } + + /// + /// 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..cb6983d3806 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/Search/SchemaIndexer.cs @@ -0,0 +1,130 @@ +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 containing the indexed documents and reverse adjacency map. + /// + public static SchemaIndexResult 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; + } + } + + // Directives are not indexed for search — they have no fetch path. + // They remain accessible via __definitions coordinate lookup. + + return new SchemaIndexResult(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 SchemaCoordinate(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); + } + + /// + /// 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/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..e542f34eacf --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Introspection/__SearchResult.cs @@ -0,0 +1,106 @@ +#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 nonNullStringListListType = Parse($"[[{ScalarNames.String}!]!]!"); + + return new ObjectTypeConfiguration( + Names.__SearchResult, + description: "A search result representing a matched schema element.", + typeof(SchemaSearchResult)) + { + 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 list of schema coordinates.", + nonNullStringListListType, + resolver: Resolvers.PathsToRootAsync), + 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) + { + 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, + context.RequestAborted) + .ConfigureAwait(false); + + var pathsToRoot = new IReadOnlyList[paths.Count]; + + for (var i = 0; i < paths.Count; i++) + { + pathsToRoot[i] = paths[i].ToStringArray(); + } + + return 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..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 @@ -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 list of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "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", @@ -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..21ccad37d8f --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/SemanticIntrospectionTests.cs @@ -0,0 +1,1671 @@ +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.8974776268005371 + }, + { + "coordinate": "User.name", + "score": 0.71161288022995 + }, + { + "coordinate": "User.email", + "score": 0.71161288022995 + }, + { + "coordinate": "User.age", + "score": 0.6750305891036987 + } + ] + } + } + """); + } + + [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.9191438555717468 + } + ] + } + } + """); + } + + [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.8174113631248474 + } + ] + } + } + """); + } + + [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() + { + // 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": [ + [ + "Query.productSearch", + "Product.name" + ] + ] + }, + { + "coordinate": "Query.productSearch", + "pathsToRoot": [ + [ + "Query.productSearch" + ] + ] + }, + { + "coordinate": "User.name", + "pathsToRoot": [ + [ + "Query.userByEmail", + "User.name" + ] + ] + }, + { + "coordinate": "Product", + "pathsToRoot": [ + [ + "Query.productSearch" + ] + ] + }, + { + "coordinate": "Product.category", + "pathsToRoot": [ + [ + "Query.productSearch", + "Product.category" + ] + ] + }, + { + "coordinate": "Product.price", + "pathsToRoot": [ + [ + "Query.productSearch", + "Product.price" + ] + ] + } + ] + } + } + """); + } + + [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.8174113631248474 + }, + { + "coordinate": "Product.category", + "score": 0.8174113631248474 + }, + { + "coordinate": "Product.price", + "score": 0.7237380146980286 + }, + { + "coordinate": "Query.productSearch", + "score": 0.6175786256790161 + } + ] + } + } + """); + } + + [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": "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": {} + } + ] + } + } + """); + } + + [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_ErrorOn_UnknownCoordinate() + { + // 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.MatchSnapshot(); + } + + [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.5982846021652222, + "definition": { + "fieldName": "email", + "description": "The email address of the user" + } + }, + { + "coordinate": "User", + "score": 0.1880880743265152, + "definition": { + "typeName": "User", + "kind": "OBJECT" + } + }, + { + "coordinate": "Query.orderById", + "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.13384589552879333, + "definition": { + "fieldName": "name", + "description": "The full name of the user" + } + }, + { + "coordinate": "User.age", + "score": 0.1269652098417282, + "definition": { + "fieldName": "age", + "description": "The age of the user in years" + } + }, + { + "coordinate": "Float", + "score": 0.07807315140962601, + "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.5671203136444092, + "definition": { + "typeName": "ID", + "kind": "SCALAR" + } + }, + { + "coordinate": "Product", + "score": 0.2852042019367218, + "definition": { + "typeName": "Product", + "kind": "OBJECT" + } + } + ] + } + } + """); + } + + [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.789767861366272, + "definition": { + "fieldName": "status", + "description": "The current order status" + } + }, + { + "coordinate": "User.name", + "score": 0.32556161284446716, + "definition": { + "fieldName": "name", + "description": "The full name of the user" + } + }, + { + "coordinate": "User.email", + "score": 0.32556161284446716, + "definition": { + "fieldName": "email", + "description": "The email address of the user" + } + }, + { + "coordinate": "User", + "score": 0.31599652767181396, + "definition": { + "typeName": "User", + "kind": "OBJECT" + } + }, + { + "coordinate": "User.age", + "score": 0.3104621171951294, + "definition": { + "fieldName": "age", + "description": "The age of the user in years" + } + }, + { + "coordinate": "Order.id", + "score": 0.25484970211982727, + "definition": { + "fieldName": "id", + "description": "The unique order identifier" + } + }, + { + "coordinate": "Order.total", + "score": 0.25484970211982727, + "definition": { + "fieldName": "total", + "description": "The order total amount" + } + }, + { + "coordinate": "Order", + "score": 0.24078181385993958, + "definition": { + "typeName": "Order", + "kind": "OBJECT" + } + }, + { + "coordinate": "String", + "score": 0.2084837555885315, + "definition": { + "typeName": "String", + "kind": "SCALAR" + } + } + ] + } + } + """); + } + + [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" + } + } + ] + } + """); + } + + [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() + .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..a56fe7a6df1 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,168 @@ ], "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 list of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "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, + "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..533f13260d2 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,168 @@ ], "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 list of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "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, + "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..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 @@ -1002,6 +1002,168 @@ ], "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 list of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "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, + "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__/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/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..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 @@ -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", @@ -157,6 +190,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", "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..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 @@ -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)": "HotChocolate.Configuration.TypeDiscovererTests+FooType (Output)", "String": "HotChocolate.Types.StringType", 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/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..d17220e6fe0 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/BM25SearchProviderTests.cs @@ -0,0 +1,326 @@ +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_Throw_When_FirstIsZero() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act & assert + await Assert.ThrowsAsync( + () => provider.SearchAsync("product", first: 0, after: null, minScore: null).AsTask()); + } + + [Fact] + public async Task SearchAsync_Should_Throw_When_FirstIsNegative() + { + // arrange + var schema = CreateTestSchema(); + var provider = new BM25SearchProvider(schema); + + // act & assert + await Assert.ThrowsAsync( + () => provider.SearchAsync("product", first: -1, after: null, minScore: null).AsTask()); + } + + [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")); + + // assert + // A root type is already at root — no field path needed. + Assert.Empty(paths); + } + + [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")); + + // 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")); + + // assert + Assert.NotEmpty(paths); + // The path should end with the target field coordinate. + Assert.Equal(new SchemaCoordinate("Product", "name"), paths[0][^1]); + } + + [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")); + + // assert + for (var i = 1; i < paths.Count; i++) + { + Assert.True(paths[i - 1].Count <= paths[i].Count); + } + } + + [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..0a2f4a30bf8 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Introspection/Search/SchemaIndexerTests.cs @@ -0,0 +1,274 @@ +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 result = SchemaIndexer.Index(schema); + var documents = result.Documents; + + // 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 result = SchemaIndexer.Index(schema); + var documents = result.Documents; + + // 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 result = SchemaIndexer.Index(schema); + var documents = result.Documents; + + // 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 result = SchemaIndexer.Index(schema); + var documents = result.Documents; + + // 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 result = SchemaIndexer.Index(schema); + var documents = result.Documents; + + // 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_NotIndexDirectiveDefinitions() + { + // arrange + var schema = SchemaBuilder.New() + .AddQueryType(d => d + .Name("Query") + .Field("hello") + .Type() + .Resolve("world")) + .AddDirectiveType() + .ModifyOptions(o => o.EnableSemanticIntrospection = false) + .Create(); + + // act + var result = SchemaIndexer.Index(schema); + var documents = result.Documents; + + // assert — directives have no fetch path and are excluded from search. + Assert.DoesNotContain(documents, d => d.Coordinate.OfDirective); + } + + [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).ReverseMap; + + // 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.Name == "Query" && r.MemberName == "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 result = SchemaIndexer.Index(schema); + var documents = result.Documents; + + // 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 result = SchemaIndexer.Index(schema); + var documents = result.Documents; + + // 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/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__/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__/SchemaFirstTests.DescriptionsAreCorrectlyRead.snap b/src/HotChocolate/Core/test/Types.Tests/__snapshots__/SchemaFirstTests.DescriptionsAreCorrectlyRead.snap index 9571da418cf..206a39a5b48 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 list of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "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", 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..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 @@ -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 list of schema coordinates.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "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", diff --git a/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs b/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs index 2a022336c3c..f4c808e3188 100644 --- a/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs +++ b/src/HotChocolate/Core/test/Validation.Tests/IntrospectionRuleTests.cs @@ -98,10 +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" + } + } +] 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 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( 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/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.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/src/Fusion.Execution.Types/Completion/SemanticIntrospectionSchema.cs b/src/HotChocolate/Fusion/src/Fusion.Execution.Types/Completion/SemanticIntrospectionSchema.cs new file mode 100644 index 00000000000..0a5f8309f20 --- /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..ca6cd4622ea 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,4 @@ +using System.Globalization; using HotChocolate.Fusion.Execution.Nodes; namespace HotChocolate.Fusion.Execution.Introspection; @@ -22,4 +23,18 @@ 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]; + const string format = "R"; + var doubleValue = (double)value; + + if (!doubleValue.TryFormat(buffer, out var written, format, CultureInfo.InvariantCulture)) + { + throw new InvalidOperationException($"Failed to format float value '{value}'."); + } + + 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..de1b6c42ed6 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,19 @@ using HotChocolate.Features; using HotChocolate.Fusion.Execution.Nodes; using HotChocolate.Language; +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 +25,14 @@ public void OnApplyResolver(string fieldName, IFeatureCollection features) case "__type": features.Set(new ResolveFieldValue(Type)); break; + + case "__search" when _enableSemanticIntrospection: + features.Set(new AsyncResolveFieldValue(SearchAsync)); + break; + + case "__definitions" when _enableSemanticIntrospection: + features.Set(new ResolveFieldValue(Definitions)); + break; } } @@ -35,4 +51,150 @@ public static void Type(FieldContext context) context.AddRuntimeResult(type); } } + + private const int MaxFirstLimit = 150; + + public static async ValueTask SearchAsync(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); + + 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; + } + + 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); + } + + IReadOnlyList results; + + try + { + 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(results.Count); + + var i = 0; + foreach (var element in list.EnumerateArray()) + { + context.AddRuntimeResult(results[i++]); + element.CreateObjectValue(context.Selection, context.IncludeFlags); + } + } + + 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) + { + if (item is not StringValueNode coordinateString) + { + 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)) + { + 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)) + { + 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); + + var i = 0; + foreach (var element in list.EnumerateArray()) + { + 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/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/Execution/Introspection/__SearchResult.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs new file mode 100644 index 00000000000..f12b7714c6b --- /dev/null +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Introspection/__SearchResult.cs @@ -0,0 +1,86 @@ +using HotChocolate.Features; +using HotChocolate.Fusion.Execution.Nodes; +using Microsoft.Extensions.DependencyInjection; + +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 AsyncResolveFieldValue(PathsToRootAsync)); + 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 result = context.Parent(); + + 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); + } + + public static async ValueTask PathsToRootAsync(FieldContext context) + { + var result = context.Parent(); + var provider = context.Schema.Services.GetRequiredService(); + var paths = await provider.GetPathsToRootAsync( + result.Coordinate, + context.RequestAborted) + .ConfigureAwait(false); + + var outerList = context.FieldResult.CreateListValue(paths.Count); + + var outerIndex = 0; + foreach (var outerElement in outerList.EnumerateArray()) + { + 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 result = context.Parent(); + + if (result.Score.HasValue) + { + context.WriteFloatValue(result.Score.Value); + } + } +} 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/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 4ce909b1878..021caebbf9f 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; @@ -49,7 +50,7 @@ public IntrospectionExecutionNode( /// public ReadOnlySpan Selections => _selections; - protected override ValueTask OnExecuteAsync( + protected override async ValueTask OnExecuteAsync( OperationPlanContext context, CancellationToken cancellationToken = default) { @@ -60,7 +61,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)) { @@ -71,38 +72,85 @@ protected override ValueTask OnExecuteAsync( backlog.Push((null, selection, property)); } - ExecuteSelections(context, backlog); + 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 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) { - 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 +169,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 +192,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 +215,17 @@ private static void ExecuteSelections( } } } + + private static IObjectTypeDefinition ResolveObjectType( + IType namedType, + object? runtimeResult, + ISchemaDefinition schema) + { + if (namedType is IObjectTypeDefinition objectType) + { + return objectType; + } + + return SchemaDefinitionTypeResolver.ResolveObjectType(schema, runtimeResult); + } } 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++) 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; 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/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/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/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) 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..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(); @@ -96,6 +115,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/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs new file mode 100644 index 00000000000..4c986f0acef --- /dev/null +++ b/src/HotChocolate/Fusion/test/Fusion.AspNetCore.Tests/SemanticIntrospectionTests.cs @@ -0,0 +1,1178 @@ +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.8974776268005371 + }, + { + "coordinate": "User.email", + "score": 0.71161288022995 + }, + { + "coordinate": "User.name", + "score": 0.71161288022995 + }, + { + "coordinate": "User.age", + "score": 0.6750305891036987 + } + ] + } + } + """); + } + + [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.9191438555717468 + } + ] + } + } + """); + } + + [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.8174113631248474 + } + ] + } + } + """); + } + + [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() + { + // 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": [ + [ + "Query.productSearch", + "Product.name" + ] + ] + }, + { + "coordinate": "Query.productSearch", + "pathsToRoot": [ + [ + "Query.productSearch" + ] + ] + }, + { + "coordinate": "User.name", + "pathsToRoot": [ + [ + "Query.userByEmail", + "User.name" + ] + ] + }, + { + "coordinate": "Product", + "pathsToRoot": [ + [ + "Query.productSearch" + ] + ] + }, + { + "coordinate": "Product.category", + "pathsToRoot": [ + [ + "Query.productSearch", + "Product.category" + ] + ] + }, + { + "coordinate": "Product.price", + "pathsToRoot": [ + [ + "Query.productSearch", + "Product.price" + ] + ] + } + ] + } + } + """); + } + + [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.8174113631248474 + }, + { + "coordinate": "Product.name", + "score": 0.8174113631248474 + }, + { + "coordinate": "Product.price", + "score": 0.7237380146980286 + }, + { + "coordinate": "Query.productSearch", + "score": 0.6175786256790161 + } + ] + } + } + """); + } + + [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": [ + { + "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": {} + } + ] + } + } + """); + } + + [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 + } + } + __typename + } + } + } + """), + new Uri("http://localhost:5000/graphql")); + + // assert + using var response = await result.ReadAsResultAsync(); + response.MatchInlineSnapshot( + """ + { + "data": { + "__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" + } + } + ] + } + } + """); + } + + [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": [ + { + "name": "User", + "kind": "OBJECT", + "fields": [ + { + "name": "age" + }, + { + "name": "email" + }, + { + "name": "name" + } + ] + } + ] + } + } + """); + } + + [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": [ + { + "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 + 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": [ + { + "typeName": "User", + "kind": "OBJECT" + }, + { + "typeName": "Product", + "kind": "OBJECT" + }, + { + "fieldName": "orderById", + "description": "Retrieve an order by its unique identifier" + } + ] + } + } + """); + } + + [Fact] + public async Task Definitions_Should_ErrorOn_UnknownCoordinate() + { + // 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": null, + "errors": [ + { + "message": "No schema member was found for the coordinate 'NonExistentType'.", + "path": [ + "__definitions" + ] + } + ] + } + """); + } + + [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": [ + { + "name": "PENDING", + "description": "Order is pending processing" + } + ] + } + } + """); + } + + [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: Decimal! + "The product category" + category: String! + } + + "A customer order" + type Order { + "The unique order identifier" + id: ID! + "The order total amount" + total: Decimal! + "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 + "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" + orderById(id: ID!): Order + } + """; +} 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 bb0aac8d1d0..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, @@ -1583,8 +1616,9 @@ 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, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1594,8 +1628,9 @@ response: { "kind": "SCALAR", "name": "Boolean", - "description": null, + "description": "The \u0060Boolean\u0060 scalar type represents \u0060true\u0060 or \u0060false\u0060.", "specifiedByURL": null, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1605,8 +1640,9 @@ 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, + "isOneOf": null, "fields": null, "inputFields": null, "interfaces": null, @@ -1616,8 +1652,9 @@ response: { "kind": "SCALAR", "name": "Int", - "description": null, + "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" + } } ] }, 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/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; } 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 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