diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/ConnectionTests.cs b/src/HotChocolate/Core/test/Types.Analyzers.Tests/ConnectionTests.cs new file mode 100644 index 00000000000..853d243f5d3 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/ConnectionTests.cs @@ -0,0 +1,239 @@ +namespace HotChocolate.Types; + +public class ConnectionTests +{ + [Fact] + public async Task Build_Schema_Should_Reuse_Single_Connection_When_Same_Type_Paged_Twice_In_One_Assembly() + { + // arrange + const string source = + """ + using System.Linq; + using HotChocolate.Types; + + namespace Demo.SingleAssembly; + + public sealed class Author + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + [QueryType] + public static partial class Query + { + [UsePaging(InferConnectionNameFromField = false)] + public static IQueryable GetAuthors() => default!; + + [UsePaging(InferConnectionNameFromField = false)] + public static IQueryable GetMoreAuthors() => default!; + } + """; + + // act + var schema = await GeneratorTestServer.CreateSchemaAsync( + source, + disableDefaultSecurity: false); + + // assert + schema.MatchSnapshot(); + } + + [Fact] + public async Task Build_Schema_Should_Reuse_Single_Connection_When_Same_Type_Paged_Across_Two_Assemblies() + { + // arrange + const string authorAssemblySource = + """ + namespace Demo.CrossAssembly; + + public sealed class Author + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + """; + + const string assemblyOneSource = + """ + using System.Linq; + using HotChocolate.Types; + using Demo.CrossAssembly; + + namespace Demo.CrossAssembly.One; + + [QueryType] + public static partial class AssemblyOneQuery + { + [UsePaging(InferConnectionNameFromField = false)] + public static IQueryable GetAuthors() => default!; + } + """; + + const string assemblyTwoSource = + """ + using System.Linq; + using HotChocolate.Types; + using Demo.CrossAssembly; + + namespace Demo.CrossAssembly.Two; + + [QueryType] + public static partial class AssemblyTwoQuery + { + [UsePaging(InferConnectionNameFromField = false)] + public static IQueryable GetMoreAuthors() => default!; + } + """; + + var assemblies = new GeneratorAssembly[] + { + new( + "Demo.ConnectionAssemblyAuthor", + [authorAssemblySource], + References: [], + Register: false), + new( + "Demo.ConnectionAssemblyOne", + [assemblyOneSource], + References: ["Demo.ConnectionAssemblyAuthor"]), + new( + "Demo.ConnectionAssemblyTwo", + [assemblyTwoSource], + References: ["Demo.ConnectionAssemblyAuthor"]) + }; + + // act + var schema = await GeneratorTestServer.CreateSchemaAsync( + assemblies, + disableDefaultSecurity: false); + + // assert + schema.MatchSnapshot(); + } + + [Fact] + public async Task Build_Schema_Should_Reuse_Single_Connection_When_Static_And_NonStatic_Query_Page_Same_Type_In_One_Assembly() + { + // arrange + // A reflection-based (non-static) query type and a source-generated (static partial) + // query type page the same Author in the same assembly, so both fields should resolve + // to one shared "AuthorConnection". + const string source = + """ + using System.Linq; + using HotChocolate.Types; + + namespace Demo.MixedSingleAssembly; + + public sealed class Author + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + + [QueryType] + public class RuntimeQuery + { + [UsePaging(InferConnectionNameFromField = false)] + public IQueryable GetAuthors() => default!; + } + + [QueryType] + public static partial class GeneratedQuery + { + [UsePaging(InferConnectionNameFromField = false)] + public static IQueryable GetMoreAuthors() => default!; + } + """; + + // act + var schema = await GeneratorTestServer.CreateSchemaAsync( + source, + disableDefaultSecurity: false); + + // assert + schema.MatchSnapshot(); + } + + [Fact] + public async Task Build_Schema_Should_Reuse_Single_Connection_When_Static_And_NonStatic_Query_Page_Same_Type_Across_Assemblies() + { + // arrange + // The shared Author lives in its own assembly. One assembly pages it from a + // reflection-based (non-static) query type, the other from a source-generated + // (static partial) query type, so both fields should resolve to one shared + // "AuthorConnection". + const string authorAssemblySource = + """ + namespace Demo.MixedCrossAssembly; + + public sealed class Author + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + } + """; + + const string runtimeAssemblySource = + """ + using System.Linq; + using HotChocolate.Types; + using Demo.MixedCrossAssembly; + + namespace Demo.MixedCrossAssembly.Runtime; + + [QueryType] + public class RuntimeQuery + { + [UsePaging(InferConnectionNameFromField = false)] + public IQueryable GetAuthors() => default!; + } + """; + + const string generatedAssemblySource = + """ + using System.Linq; + using HotChocolate.Types; + using Demo.MixedCrossAssembly; + + namespace Demo.MixedCrossAssembly.Generated; + + [QueryType] + public static partial class GeneratedQuery + { + [UsePaging(InferConnectionNameFromField = false)] + public static IQueryable GetMoreAuthors() => default!; + } + """; + + var assemblies = new GeneratorAssembly[] + { + new( + "Demo.MixedAssemblyAuthor", + [authorAssemblySource], + References: [], + Register: false), + new( + "Demo.MixedAssemblyRuntime", + [runtimeAssemblySource], + References: ["Demo.MixedAssemblyAuthor"]), + new( + "Demo.MixedAssemblyGenerated", + [generatedAssemblySource], + References: ["Demo.MixedAssemblyAuthor"]) + }; + + // act + var schema = await GeneratorTestServer.CreateSchemaAsync( + assemblies, + disableDefaultSecurity: false); + + // assert + schema.MatchSnapshot(); + } +} diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/GeneratorReferences.cs b/src/HotChocolate/Core/test/Types.Analyzers.Tests/GeneratorReferences.cs new file mode 100644 index 00000000000..58b0992ef62 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/GeneratorReferences.cs @@ -0,0 +1,114 @@ +using Basic.Reference.Assemblies; +using GreenDonut; +using GreenDonut.Data; +using HotChocolate.Data.Filters; +using HotChocolate.Execution; +using HotChocolate.Execution.Configuration; +using HotChocolate.Execution.Processing; +using HotChocolate.Features; +using HotChocolate.Language; +using HotChocolate.Language.Visitors; +using HotChocolate.Types.Pagination; +using Microsoft.AspNetCore.Builder; +using Microsoft.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Types; + +/// +/// Provides the metadata references that mirror the HotChocolate package graph an +/// application using the source generator would compile against. The list is shared by +/// every in-memory compilation in this test project so that the generated registration +/// code, the HotChocolate runtime, and the test process all bind to the same loaded +/// assemblies (a requirement for type identity to hold across assembly load contexts). +/// +internal static class GeneratorReferences +{ + /// + /// Gets the metadata references for an in-memory HotChocolate compilation. The list + /// combines the framework reference assemblies for the active target framework with + /// metadata references to the loaded HotChocolate assemblies. + /// + public static IReadOnlyList All { get; } = + [ +#if NET8_0 + .. Net80.References.All, +#elif NET9_0 + .. Net90.References.All, +#elif NET10_0 + .. Net100.References.All, +#endif + // HotChocolate.Primitives + MetadataReference.CreateFromFile(typeof(ITypeSystemMember).Assembly.Location), + + // HotChocolate.Execution + MetadataReference.CreateFromFile(typeof(RequestDelegate).Assembly.Location), + + // HotChocolate.Execution.Abstractions + MetadataReference.CreateFromFile(typeof(RequestContext).Assembly.Location), + + // HotChocolate.Execution.Processing + MetadataReference.CreateFromFile(typeof(HotChocolateExecutionSelectionExtensions).Assembly.Location), + + // HotChocolate.Execution.Abstractions + MetadataReference.CreateFromFile(typeof(IRequestExecutorBuilder).Assembly.Location), + + // HotChocolate.Execution.Operation.Abstractions + MetadataReference.CreateFromFile(typeof(ISelection).Assembly.Location), + + // HotChocolate.Types + MetadataReference.CreateFromFile(typeof(ObjectTypeAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(QueryTypeAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Connection).Assembly.Location), + MetadataReference.CreateFromFile(typeof(PageConnection<>).Assembly.Location), + + // HotChocolate.Types.Abstractions + MetadataReference.CreateFromFile(typeof(ISchemaDefinition).Assembly.Location), + + // HotChocolate.Features + MetadataReference.CreateFromFile(typeof(IFeatureProvider).Assembly.Location), + + // HotChocolate.Language + MetadataReference.CreateFromFile(typeof(OperationType).Assembly.Location), + + // HotChocolate.Language.Utf8 + MetadataReference.CreateFromFile(typeof(ParserOptions).Assembly.Location), + + // HotChocolate.Language.Visitors + MetadataReference.CreateFromFile(typeof(SyntaxVisitor).Assembly.Location), + + // HotChocolate.Abstractions + MetadataReference.CreateFromFile(typeof(ParentAttribute).Assembly.Location), + + // HotChocolate.AspNetCore + MetadataReference.CreateFromFile( + typeof(HotChocolateAspNetCoreServiceCollectionExtensions).Assembly.Location), + + // GreenDonut + MetadataReference.CreateFromFile(typeof(DataLoaderBase<,>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IDataLoader).Assembly.Location), + + // GreenDonut.Data + MetadataReference.CreateFromFile(typeof(PagingArguments).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IPredicateBuilder).Assembly.Location), + MetadataReference.CreateFromFile(typeof(DefaultPredicateBuilder).Assembly.Location), + + // HotChocolate.Data + MetadataReference.CreateFromFile(typeof(IFilterContext).Assembly.Location), + + // Microsoft.AspNetCore + MetadataReference.CreateFromFile(typeof(WebApplication).Assembly.Location), + + // Microsoft.Extensions.DependencyInjection.Abstractions + MetadataReference.CreateFromFile(typeof(IServiceCollection).Assembly.Location), + + // Microsoft.AspNetCore.Authorization + MetadataReference.CreateFromFile(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute).Assembly.Location), + + // HotChocolate.Authorization + MetadataReference.CreateFromFile(typeof(Authorization.AuthorizeAttribute).Assembly.Location), + + // HotChocolate.Types.OffsetPagination + MetadataReference.CreateFromFile(typeof(UseOffsetPagingAttribute).Assembly.Location) + ]; +} diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/GeneratorTestServer.cs b/src/HotChocolate/Core/test/Types.Analyzers.Tests/GeneratorTestServer.cs new file mode 100644 index 00000000000..ff791e2dbd7 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/GeneratorTestServer.cs @@ -0,0 +1,393 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.Loader; +using HotChocolate.Execution; +using HotChocolate.Execution.Configuration; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Types; + +/// +/// A reusable harness for end-to-end source-generator tests. It compiles one or more +/// in-memory assemblies, runs the HotChocolate.Types.Analyzers generator over each +/// of them, emits and loads the result into a collectible assembly load context, discovers +/// the generated registration method by reflection, and wires it onto a real +/// so a scenario can be executed or its schema +/// inspected at runtime. +/// +/// +/// Each scenario is expressed as one or more C# source strings instead of a dedicated test +/// project. The compiled assemblies reference the same loaded HotChocolate assemblies as the +/// test process (see ), so the generated registration code +/// binds to the test process's and the reflective +/// invocation composes with the rest of the GraphQL configuration. +/// +internal static class GeneratorTestServer +{ + /// + /// Compiles the supplied source(s) into a single in-memory assembly, runs the generator, + /// loads the result, and builds an with the generated + /// registration method applied. + /// + /// The C# source defining the scenario. + /// + /// An optional callback for arbitrary builder chaining (for example + /// AddMutationConventions, ModifyPagingOptions, or AddTypeExtension). + /// + /// + /// The explicit name of the generated registration method (for example AddDemo). + /// When omitted, the single generated Add{Module} method is discovered automatically. + /// + /// + /// Whether to disable the default security configuration. Defaults to . + /// + /// An optional name for the generated assembly. + public static Task CreateExecutorAsync( + [StringSyntax("csharp")] string source, + Action? configure = null, + string? registrationMethodName = null, + bool disableDefaultSecurity = true, + string? assemblyName = null) + => CreateExecutorAsync( + [source], + configure, + registrationMethodName, + disableDefaultSecurity, + assemblyName); + + /// + /// Compiles the supplied sources into a single in-memory assembly, runs the generator, + /// loads the result, and builds an with the generated + /// registration method applied. + /// + public static async Task CreateExecutorAsync( + string[] sources, + Action? configure = null, + string? registrationMethodName = null, + bool disableDefaultSecurity = true, + string? assemblyName = null) + { + var assembly = CompileAndLoad(sources, assemblyName ?? CreateAssemblyName()); + var builder = CreateBuilder(disableDefaultSecurity); + InvokeRegistration(builder, assembly, registrationMethodName); + configure?.Invoke(builder); + return await builder.BuildRequestExecutorAsync(); + } + + /// + /// Compiles the supplied source(s) into a single in-memory assembly, runs the generator, + /// loads the result, and builds the schema with the generated registration method applied. + /// + public static Task CreateSchemaAsync( + [StringSyntax("csharp")] string source, + Action? configure = null, + string? registrationMethodName = null, + bool disableDefaultSecurity = true, + string? assemblyName = null) + => CreateSchemaAsync( + [source], + configure, + registrationMethodName, + disableDefaultSecurity, + assemblyName); + + /// + /// Compiles the supplied sources into a single in-memory assembly, runs the generator, + /// loads the result, and builds the schema with the generated registration method applied. + /// + public static async Task CreateSchemaAsync( + string[] sources, + Action? configure = null, + string? registrationMethodName = null, + bool disableDefaultSecurity = true, + string? assemblyName = null) + { + var assembly = CompileAndLoad(sources, assemblyName ?? CreateAssemblyName()); + var builder = CreateBuilder(disableDefaultSecurity); + InvokeRegistration(builder, assembly, registrationMethodName); + configure?.Invoke(builder); + return await builder.BuildSchemaAsync(); + } + + /// + /// Compiles a set of in-memory assemblies that may reference one another, runs the + /// generator over each, and builds the schema with every generated registration method + /// applied to the same builder (in the declared order). Use this to replace satellite + /// test projects with in-memory compilations for cross-assembly scenarios. + /// + /// + /// The assemblies to compile, in dependency order. An assembly may reference any assembly + /// declared before it through . + /// + /// An optional callback for arbitrary builder chaining. + /// + /// Whether to disable the default security configuration. Defaults to . + /// + public static async Task CreateSchemaAsync( + IReadOnlyList assemblies, + Action? configure = null, + bool disableDefaultSecurity = true) + { + var loaded = CompileAndLoad(assemblies); + var builder = CreateBuilder(disableDefaultSecurity); + + foreach (var (spec, assembly) in loaded) + { + if (!spec.Register) + { + continue; + } + + InvokeRegistration(builder, assembly, spec.RegistrationMethodName); + } + + configure?.Invoke(builder); + return await builder.BuildSchemaAsync(); + } + + /// + /// Compiles a set of in-memory assemblies that may reference one another, runs the + /// generator over each, and builds an with every generated + /// registration method applied to the same builder (in the declared order). + /// + public static async Task CreateExecutorAsync( + IReadOnlyList assemblies, + Action? configure = null, + bool disableDefaultSecurity = true) + { + var loaded = CompileAndLoad(assemblies); + var builder = CreateBuilder(disableDefaultSecurity); + + foreach (var (spec, assembly) in loaded) + { + if (!spec.Register) + { + continue; + } + + InvokeRegistration(builder, assembly, spec.RegistrationMethodName); + } + + configure?.Invoke(builder); + return await builder.BuildRequestExecutorAsync(); + } + + /// + /// Compiles the supplied source into a single in-memory assembly, runs the generator over + /// it, and loads the emitted assembly. Use this when a test needs the loaded assembly to + /// build more than one registration path (for example the generated module and a + /// hand-rolled runtime type) from the same compiled types. + /// + public static Assembly Compile([StringSyntax("csharp")] string source, string? assemblyName = null) + => CompileAndLoad([source], assemblyName ?? CreateAssemblyName()); + + /// + /// Applies a generated registration method from the supplied assembly to the builder. When + /// is omitted, the single generated + /// Add{Module} method is discovered automatically. + /// + public static void ApplyGeneratedRegistration( + IRequestExecutorBuilder builder, + Assembly assembly, + string? registrationMethodName = null) + => InvokeRegistration(builder, assembly, registrationMethodName); + + private static IRequestExecutorBuilder CreateBuilder(bool disableDefaultSecurity) + => new ServiceCollection().AddGraphQLServer(disableDefaultSecurity: disableDefaultSecurity); + + private static void InvokeRegistration( + IRequestExecutorBuilder builder, + Assembly assembly, + string? registrationMethodName) + { + var method = FindRegistrationMethod(assembly, registrationMethodName); + method.Invoke(null, [builder]); + } + + private static MethodInfo FindRegistrationMethod(Assembly assembly, string? registrationMethodName) + { + var candidates = assembly + .GetTypes() + .Where(t => t is { IsAbstract: true, IsSealed: true } + && t.Namespace == "Microsoft.Extensions.DependencyInjection") + .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Static)) + .Where(m => + { + var parameters = m.GetParameters(); + return m.ReturnType == typeof(IRequestExecutorBuilder) + && parameters.Length == 1 + && parameters[0].ParameterType == typeof(IRequestExecutorBuilder) + && (registrationMethodName is null + ? m.Name.StartsWith("Add", StringComparison.Ordinal) + : m.Name.Equals(registrationMethodName, StringComparison.Ordinal)); + }) + .ToArray(); + + return candidates.Length switch + { + 1 => candidates[0], + 0 => throw new InvalidOperationException( + registrationMethodName is null + ? $"No generated registration method was found in assembly '{assembly.GetName().Name}'." + : $"No generated registration method named '{registrationMethodName}' was found " + + $"in assembly '{assembly.GetName().Name}'."), + _ => throw new InvalidOperationException( + "Multiple generated registration methods were found in assembly " + + $"'{assembly.GetName().Name}' ({string.Join(", ", candidates.Select(m => m.Name))}). " + + "Specify an explicit registration method name to disambiguate.") + }; + } + + private static Assembly CompileAndLoad(string[] sources, string assemblyName) + { + var compilation = CreateCompilation(sources, assemblyName, references: []); + var (stream, _) = EmitToStream(compilation); + return LoadFromStream(stream, assemblyName); + } + + private static IReadOnlyList<(GeneratorAssembly Spec, Assembly Assembly)> CompileAndLoad( + IReadOnlyList assemblies) + { + var emittedReferences = new Dictionary(StringComparer.Ordinal); + var results = new List<(GeneratorAssembly, Assembly)>(assemblies.Count); + + // All assemblies of a multi-assembly scenario share a single collectible context so + // that cross-assembly references between them resolve against the loaded members. + var context = new AssemblyLoadContext(CreateAssemblyName(), isCollectible: true); + + foreach (var spec in assemblies) + { + var dependencyReferences = new List(spec.References.Count); + foreach (var dependency in spec.References) + { + if (!emittedReferences.TryGetValue(dependency, out var reference)) + { + throw new InvalidOperationException( + $"Assembly '{spec.AssemblyName}' references '{dependency}', " + + "which has not been declared earlier in the list."); + } + + dependencyReferences.Add(reference); + } + + var compilation = CreateCompilation(spec.Sources, spec.AssemblyName, dependencyReferences); + var (stream, image) = EmitToStream(compilation); + + // Make the (post-generation) image available to assemblies declared later so that + // cross-assembly references resolve against the generated members as well. + emittedReferences[spec.AssemblyName] = MetadataReference.CreateFromImage(image); + + using (stream) + { + results.Add((spec, context.LoadFromStream(stream))); + } + } + + return results; + } + + private static CSharpCompilation CreateCompilation( + string[] sources, + string assemblyName, + IReadOnlyList references) + { + var parseOptions = CSharpParseOptions.Default; + var syntaxTrees = sources.Select(s => CSharpSyntaxTree.ParseText(s, parseOptions)).ToArray(); + + var compilation = CSharpCompilation.Create( + assemblyName: assemblyName, + syntaxTrees: syntaxTrees, + references: [.. GeneratorReferences.All, .. references], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CSharpGeneratorDriver + .Create(new Analyzers.GraphQLServerGenerator()) + .RunGenerators(compilation); + + var generatedTrees = driver + .GetRunResult() + .Results + .SelectMany(r => r.GeneratedSources) + .Select(s => CSharpSyntaxTree.ParseText(s.SourceText, parseOptions, path: s.HintName)); + + return compilation.AddSyntaxTrees(generatedTrees); + } + + private static (MemoryStream Stream, byte[] Image) EmitToStream(CSharpCompilation compilation) + { + var stream = new MemoryStream(); + var emitResult = compilation.Emit(stream); + + if (!emitResult.Success) + { + stream.Dispose(); + throw new InvalidOperationException( + "Failed to emit the in-memory assembly:" + + Environment.NewLine + + string.Join( + Environment.NewLine, + emitResult.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .OrderBy(d => d.Id) + .Select(d => d.ToString()))); + } + + stream.Position = 0; + return (stream, stream.ToArray()); + } + + private static Assembly LoadFromStream(MemoryStream stream, string assemblyName) + { + using (stream) + { + stream.Position = 0; + var context = new AssemblyLoadContext(assemblyName, isCollectible: true); + return context.LoadFromStream(stream); + } + } + + private static string CreateAssemblyName() + => $"GeneratorTestServer_{Guid.NewGuid():N}"; +} + +/// +/// Describes a single in-memory assembly in a multi-assembly source-generator scenario. +/// +/// +/// The unique name of the assembly. References to this assembly from later assemblies are +/// matched by this name. +/// +/// The C# source(s) that make up the assembly. +/// +/// The names of assemblies (declared earlier in the scenario) that this assembly references. +/// +/// +/// The explicit name of the generated registration method, or to +/// discover the single generated method automatically. +/// +/// +/// Whether the assembly's generated registration method should be applied to the builder. +/// Set to for a dependency-only assembly that contributes no GraphQL +/// types of its own (for example one that only defines shared runtime types referenced by +/// other assemblies). The assembly is still compiled and loaded so those references resolve. +/// +internal sealed record GeneratorAssembly( + string AssemblyName, + string[] Sources, + IReadOnlyList References, + string? RegistrationMethodName = null, + bool Register = true) +{ + /// + /// Creates an assembly spec from a single source string with no cross-assembly references. + /// + public GeneratorAssembly( + string assemblyName, + [StringSyntax("csharp")] string source, + string? registrationMethodName = null) + : this(assemblyName, [source], [], registrationMethodName) + { + } +} diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/GeneratorTestServerTests.cs b/src/HotChocolate/Core/test/Types.Analyzers.Tests/GeneratorTestServerTests.cs new file mode 100644 index 00000000000..297d367716d --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/GeneratorTestServerTests.cs @@ -0,0 +1,64 @@ +using HotChocolate.Execution; + +namespace HotChocolate.Types; + +public class GeneratorTestServerTests +{ + [Fact] + public async Task Subscription_With_Subscribe_With_Delivers_Message_From_Stream() + { + // arrange + const string source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using HotChocolate; + using HotChocolate.Types; + + namespace Demo; + + [QueryType] + public static partial class Query + { + public static int Product => 1; + } + + [SubscriptionType] + public static partial class Subscription + { + [Subscribe(With = nameof(SubscribeToOnProductAdded))] + public static Task OnProductAdded([EventMessage] int productId) + => Task.FromResult(productId); + + private static async IAsyncEnumerable SubscribeToOnProductAdded(int categoryId) + { + await Task.Yield(); + yield return categoryId; + } + } + """; + + var executor = await GeneratorTestServer.CreateExecutorAsync( + source, + disableDefaultSecurity: false); + + // act + await using var subscriptionResult = await executor.ExecuteAsync( + "subscription { onProductAdded(categoryId: 42) }"); + + // assert + var stream = subscriptionResult.ExpectResponseStream(); + await foreach (var result in stream.ReadResultsAsync()) + { + result.MatchInlineSnapshot( + """ + { + "data": { + "onProductAdded": 42 + } + } + """); + break; + } + } +} diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/SourceGeneratorOffsetPagingReproTests.cs b/src/HotChocolate/Core/test/Types.Analyzers.Tests/SourceGeneratorOffsetPagingReproTests.cs index 5f7c31e09be..2fbc191db1b 100644 --- a/src/HotChocolate/Core/test/Types.Analyzers.Tests/SourceGeneratorOffsetPagingReproTests.cs +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/SourceGeneratorOffsetPagingReproTests.cs @@ -1,7 +1,5 @@ using System.Reflection; -using System.Runtime.Loader; using System.Text.Json; -using Basic.Reference.Assemblies; using GreenDonut; using GreenDonut.Data; using HotChocolate.Data.Filters; @@ -10,11 +8,8 @@ using HotChocolate.Execution.Processing; using HotChocolate.Features; using HotChocolate.Language; -using HotChocolate.Types.Analyzers; using HotChocolate.Types.Pagination; using Microsoft.AspNetCore.Builder; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; using Microsoft.Extensions.DependencyInjection; namespace HotChocolate.Types; @@ -197,22 +192,8 @@ public async Task Module_AnyType_Output_Does_Not_Double_Escape_Json_Escape_Seque private static async Task BuildSchemaWithSourceGeneratorRegistrationAsync(Assembly assembly) { - var services = new ServiceCollection(); - var builder = services.AddGraphQLServer(disableDefaultSecurity: true); - - var addTypesMethod = FindRegistrationMethod( - assembly, - m => - { - var p = m.GetParameters(); - return m.Name.StartsWith("Add", StringComparison.Ordinal) - && m.Name.EndsWith("Types", StringComparison.Ordinal) - && m.ReturnType == typeof(IRequestExecutorBuilder) - && p.Length == 1 - && p[0].ParameterType == typeof(IRequestExecutorBuilder); - }); - - addTypesMethod.Invoke(null, [builder]); + var builder = new ServiceCollection().AddGraphQLServer(disableDefaultSecurity: true); + GeneratorTestServer.ApplyGeneratedRegistration(builder, assembly); return await Record.ExceptionAsync( async () => await builder.BuildSchemaAsync()); @@ -238,19 +219,7 @@ private static async Task ExecuteWithSourceGeneratorRegistratio Action? configureBuilder = null) { var builder = new ServiceCollection().AddGraphQLServer(disableDefaultSecurity: true); - - var addModuleMethod = FindRegistrationMethod( - assembly, - m => - { - var p = m.GetParameters(); - return m.Name.Equals(registrationMethodName, StringComparison.Ordinal) - && m.ReturnType == typeof(IRequestExecutorBuilder) - && p.Length == 1 - && p[0].ParameterType == typeof(IRequestExecutorBuilder); - }); - - addModuleMethod.Invoke(null, [builder]); + GeneratorTestServer.ApplyGeneratedRegistration(builder, assembly, registrationMethodName); configureBuilder?.Invoke(builder); var executor = await builder.BuildRequestExecutorAsync(); @@ -300,18 +269,6 @@ private static async Task ExecuteWithAddQueryAndMutationTypeReg return new ExecutionResult(executor.Schema.ToString(), result.ToJson()); } - private static MethodInfo FindRegistrationMethod( - Assembly assembly, - Func predicate) - { - return assembly - .GetTypes() - .Where(t => t is { IsAbstract: true, IsSealed: true } - && t.Namespace == "Microsoft.Extensions.DependencyInjection") - .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Static)) - .Single(predicate); - } - private static Assembly CompileOffsetPagingReproAssembly() { const string source = """ @@ -509,87 +466,7 @@ public record Foo(string Description); } private static Assembly CompileReproAssembly(string source, string assemblyName) - { - var parseOptions = CSharpParseOptions.Default; - var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions); - - IEnumerable references = - [ -#if NET8_0 - .. Net80.References.All, -#elif NET9_0 - .. Net90.References.All, -#elif NET10_0 - .. Net100.References.All, -#endif - MetadataReference.CreateFromFile(typeof(ITypeSystemMember).Assembly.Location), - MetadataReference.CreateFromFile(typeof(RequestDelegate).Assembly.Location), - MetadataReference.CreateFromFile(typeof(RequestContext).Assembly.Location), - MetadataReference.CreateFromFile(typeof(HotChocolateExecutionSelectionExtensions).Assembly.Location), - MetadataReference.CreateFromFile(typeof(IRequestExecutorBuilder).Assembly.Location), - MetadataReference.CreateFromFile(typeof(ISelection).Assembly.Location), - MetadataReference.CreateFromFile(typeof(QueryTypeAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Connection).Assembly.Location), - MetadataReference.CreateFromFile(typeof(PageConnection<>).Assembly.Location), - MetadataReference.CreateFromFile(typeof(ISchemaDefinition).Assembly.Location), - MetadataReference.CreateFromFile(typeof(IFeatureProvider).Assembly.Location), - MetadataReference.CreateFromFile(typeof(OperationType).Assembly.Location), - MetadataReference.CreateFromFile(typeof(ParentAttribute).Assembly.Location), - MetadataReference.CreateFromFile( - typeof(HotChocolateAspNetCoreServiceCollectionExtensions).Assembly.Location), - MetadataReference.CreateFromFile(typeof(DataLoaderBase<,>).Assembly.Location), - MetadataReference.CreateFromFile(typeof(IDataLoader).Assembly.Location), - MetadataReference.CreateFromFile(typeof(PagingArguments).Assembly.Location), - MetadataReference.CreateFromFile(typeof(IPredicateBuilder).Assembly.Location), - MetadataReference.CreateFromFile(typeof(DefaultPredicateBuilder).Assembly.Location), - MetadataReference.CreateFromFile(typeof(IFilterContext).Assembly.Location), - MetadataReference.CreateFromFile(typeof(WebApplication).Assembly.Location), - MetadataReference.CreateFromFile(typeof(IServiceCollection).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Authorization.AuthorizeAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(UseOffsetPagingAttribute).Assembly.Location) - ]; - - var compilation = CSharpCompilation.Create( - assemblyName: assemblyName, - syntaxTrees: [syntaxTree], - references: references, - options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - - var driver = CSharpGeneratorDriver - .Create(new GraphQLServerGenerator()) - .RunGenerators(compilation); - - var generatedTrees = driver - .GetRunResult() - .Results - .SelectMany(t => t.GeneratedSources) - .Select(s => CSharpSyntaxTree.ParseText( - s.SourceText, - parseOptions, - path: s.HintName)); - - var updatedCompilation = compilation.AddSyntaxTrees(generatedTrees); - - using var stream = new MemoryStream(); - var emitResult = updatedCompilation.Emit(stream); - - if (!emitResult.Success) - { - throw new InvalidOperationException( - string.Join( - Environment.NewLine, - emitResult.Diagnostics - .OrderBy(d => d.Severity) - .ThenBy(d => d.Id) - .Select(d => d.ToString()))); - } - - stream.Position = 0; - - var context = new AssemblyLoadContext(assemblyName, isCollectible: true); - return context.LoadFromStream(stream); - } + => GeneratorTestServer.Compile(source, assemblyName); private sealed record ExecutionResult(string Schema, string Result); } diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/TestHelper.cs b/src/HotChocolate/Core/test/Types.Analyzers.Tests/TestHelper.cs index 6737b92db28..a8b7641e07e 100644 --- a/src/HotChocolate/Core/test/Types.Analyzers.Tests/TestHelper.cs +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/TestHelper.cs @@ -39,84 +39,7 @@ public static Snapshot GetGeneratedSourceSnapshot( bool enableInterceptors = false, bool enableAnalyzers = false) { - IEnumerable references = - [ -#if NET8_0 - .. Net80.References.All, -#elif NET9_0 - .. Net90.References.All, -#elif NET10_0 - .. Net100.References.All, -#endif - // HotChocolate.Primitives - MetadataReference.CreateFromFile(typeof(ITypeSystemMember).Assembly.Location), - - // HotChocolate.Execution - MetadataReference.CreateFromFile(typeof(RequestDelegate).Assembly.Location), - - // HotChocolate.Execution.Abstractions - MetadataReference.CreateFromFile(typeof(RequestContext).Assembly.Location), - - // HotChocolate.Execution.Processing - MetadataReference.CreateFromFile(typeof(HotChocolateExecutionSelectionExtensions).Assembly.Location), - - // HotChocolate.Execution.Abstractions - MetadataReference.CreateFromFile(typeof(IRequestExecutorBuilder).Assembly.Location), - - // HotChocolate.Execution.Operation.Abstractions - MetadataReference.CreateFromFile(typeof(ISelection).Assembly.Location), - - // HotChocolate.Types - MetadataReference.CreateFromFile(typeof(ObjectTypeAttribute).Assembly.Location), - MetadataReference.CreateFromFile(typeof(Connection).Assembly.Location), - MetadataReference.CreateFromFile(typeof(PageConnection<>).Assembly.Location), - - // HotChocolate.Types.Abstractions - MetadataReference.CreateFromFile(typeof(ISchemaDefinition).Assembly.Location), - - // HotChocolate.Features - MetadataReference.CreateFromFile(typeof(IFeatureProvider).Assembly.Location), - - // HotChocolate.Language - MetadataReference.CreateFromFile(typeof(OperationType).Assembly.Location), - - // HotChocolate.Language.Utf8 - MetadataReference.CreateFromFile(typeof(ParserOptions).Assembly.Location), - - // HotChocolate.Language.Visitors - MetadataReference.CreateFromFile(typeof(SyntaxVisitor).Assembly.Location), - - // HotChocolate.Abstractions - MetadataReference.CreateFromFile(typeof(ParentAttribute).Assembly.Location), - - // HotChocolate.AspNetCore - MetadataReference.CreateFromFile( - typeof(HotChocolateAspNetCoreServiceCollectionExtensions).Assembly.Location), - - // GreenDonut - MetadataReference.CreateFromFile(typeof(DataLoaderBase<,>).Assembly.Location), - MetadataReference.CreateFromFile(typeof(IDataLoader).Assembly.Location), - - // GreenDonut.Data - MetadataReference.CreateFromFile(typeof(PagingArguments).Assembly.Location), - MetadataReference.CreateFromFile(typeof(IPredicateBuilder).Assembly.Location), - MetadataReference.CreateFromFile(typeof(DefaultPredicateBuilder).Assembly.Location), - - // HotChocolate.Data - MetadataReference.CreateFromFile(typeof(IFilterContext).Assembly.Location), - - // Microsoft.AspNetCore - MetadataReference.CreateFromFile(typeof(WebApplication).Assembly.Location), - - // Microsoft.Extensions.DependencyInjection.Abstractions - MetadataReference.CreateFromFile(typeof(IServiceCollection).Assembly.Location), - - // Microsoft.AspNetCore.Authorization - MetadataReference.CreateFromFile(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute).Assembly.Location), - - // HotChocolate.Authorization - MetadataReference.CreateFromFile(typeof(Authorization.AuthorizeAttribute).Assembly.Location) - ]; + var references = GeneratorReferences.All; // Create a Roslyn compilation for the syntax tree. var parseOptions = !enableInterceptors diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Same_Type_Paged_Across_Two_Assemblies.graphql b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Same_Type_Paged_Across_Two_Assemblies.graphql new file mode 100644 index 00000000000..2bffaa48b47 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Same_Type_Paged_Across_Two_Assemblies.graphql @@ -0,0 +1,103 @@ +schema { + query: Query +} + +type Query { + authors( + "Returns the first _n_ elements from the list." + first: Int + "Returns the elements in the list that come after the specified cursor." + after: String + "Returns the last _n_ elements from the list." + last: Int + "Returns the elements in the list that come before the specified cursor." + before: String + ): AuthorConnection + @listSize( + assumedSize: 50 + slicingArguments: ["first", "last"] + slicingArgumentDefaultValue: 10 + sizedFields: ["edges", "nodes"] + requireOneSlicingArgument: false + ) + @cost(weight: "10") + moreAuthors( + "Returns the first _n_ elements from the list." + first: Int + "Returns the elements in the list that come after the specified cursor." + after: String + "Returns the last _n_ elements from the list." + last: Int + "Returns the elements in the list that come before the specified cursor." + before: String + ): AuthorConnection + @listSize( + assumedSize: 50 + slicingArguments: ["first", "last"] + slicingArgumentDefaultValue: 10 + sizedFields: ["edges", "nodes"] + requireOneSlicingArgument: false + ) + @cost(weight: "10") +} + +type Author { + id: Int! + name: String +} + +"A connection to a list of items." +type AuthorConnection { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges." + edges: [AuthorEdge!] + "A flattened list of the nodes." + nodes: [Author] +} + +"An edge in a connection." +type AuthorEdge { + "A cursor for use in pagination." + cursor: String! + "The item at the end of the edge." + node: Author +} + +"Information about pagination in a connection." +type PageInfo { + "Indicates whether more edges exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more edges exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String + "When paginating forwards, the cursor to continue." + endCursor: String +} + +"The purpose of the `cost` directive is to define a `weight` for GraphQL types, fields, and arguments. Static analysis can use these weights when calculating the overall cost of a query or response." +directive @cost( + "The `weight` argument defines what value to add to the overall cost for every appearance, or possible appearance, of a type, field, argument, etc." + weight: String! +) on + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | ENUM + | INPUT_FIELD_DEFINITION + +"The purpose of the `@listSize` directive is to either inform the static analysis about the size of returned lists (if that information is statically available), or to point the analysis to where to find that information." +directive @listSize( + "The `assumedSize` argument can be used to statically define the maximum length of a list returned by a field." + assumedSize: Int + "The `slicingArguments` argument can be used to define which of the field's arguments with numeric type are slicing arguments, so that their value determines the size of the list returned by that field. It may specify a list of multiple slicing arguments." + slicingArguments: [String!] + "The `slicingArgumentDefaultValue` argument can be used to define a default value for a slicing argument, which is used if the argument is not present in a query." + slicingArgumentDefaultValue: Int + "The `sizedFields` argument can be used to define that the value of the `assumedSize` argument or of a slicing argument does not affect the size of a list returned by a field itself, but that of a list returned by one of its sub-fields." + sizedFields: [String!] + "The `requireOneSlicingArgument` argument can be used to inform the static analysis that it should expect that exactly one of the defined slicing arguments is present in a query. If that is not the case (i.e., if none or multiple slicing arguments are present), the static analysis may throw an error." + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Same_Type_Paged_Twice_In_One_Assembly.graphql b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Same_Type_Paged_Twice_In_One_Assembly.graphql new file mode 100644 index 00000000000..2bffaa48b47 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Same_Type_Paged_Twice_In_One_Assembly.graphql @@ -0,0 +1,103 @@ +schema { + query: Query +} + +type Query { + authors( + "Returns the first _n_ elements from the list." + first: Int + "Returns the elements in the list that come after the specified cursor." + after: String + "Returns the last _n_ elements from the list." + last: Int + "Returns the elements in the list that come before the specified cursor." + before: String + ): AuthorConnection + @listSize( + assumedSize: 50 + slicingArguments: ["first", "last"] + slicingArgumentDefaultValue: 10 + sizedFields: ["edges", "nodes"] + requireOneSlicingArgument: false + ) + @cost(weight: "10") + moreAuthors( + "Returns the first _n_ elements from the list." + first: Int + "Returns the elements in the list that come after the specified cursor." + after: String + "Returns the last _n_ elements from the list." + last: Int + "Returns the elements in the list that come before the specified cursor." + before: String + ): AuthorConnection + @listSize( + assumedSize: 50 + slicingArguments: ["first", "last"] + slicingArgumentDefaultValue: 10 + sizedFields: ["edges", "nodes"] + requireOneSlicingArgument: false + ) + @cost(weight: "10") +} + +type Author { + id: Int! + name: String +} + +"A connection to a list of items." +type AuthorConnection { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges." + edges: [AuthorEdge!] + "A flattened list of the nodes." + nodes: [Author] +} + +"An edge in a connection." +type AuthorEdge { + "A cursor for use in pagination." + cursor: String! + "The item at the end of the edge." + node: Author +} + +"Information about pagination in a connection." +type PageInfo { + "Indicates whether more edges exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more edges exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String + "When paginating forwards, the cursor to continue." + endCursor: String +} + +"The purpose of the `cost` directive is to define a `weight` for GraphQL types, fields, and arguments. Static analysis can use these weights when calculating the overall cost of a query or response." +directive @cost( + "The `weight` argument defines what value to add to the overall cost for every appearance, or possible appearance, of a type, field, argument, etc." + weight: String! +) on + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | ENUM + | INPUT_FIELD_DEFINITION + +"The purpose of the `@listSize` directive is to either inform the static analysis about the size of returned lists (if that information is statically available), or to point the analysis to where to find that information." +directive @listSize( + "The `assumedSize` argument can be used to statically define the maximum length of a list returned by a field." + assumedSize: Int + "The `slicingArguments` argument can be used to define which of the field's arguments with numeric type are slicing arguments, so that their value determines the size of the list returned by that field. It may specify a list of multiple slicing arguments." + slicingArguments: [String!] + "The `slicingArgumentDefaultValue` argument can be used to define a default value for a slicing argument, which is used if the argument is not present in a query." + slicingArgumentDefaultValue: Int + "The `sizedFields` argument can be used to define that the value of the `assumedSize` argument or of a slicing argument does not affect the size of a list returned by a field itself, but that of a list returned by one of its sub-fields." + sizedFields: [String!] + "The `requireOneSlicingArgument` argument can be used to inform the static analysis that it should expect that exactly one of the defined slicing arguments is present in a query. If that is not the case (i.e., if none or multiple slicing arguments are present), the static analysis may throw an error." + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Static_And_NonStatic_Query_Page_Same_Type_Across_Assemblies.graphql b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Static_And_NonStatic_Query_Page_Same_Type_Across_Assemblies.graphql new file mode 100644 index 00000000000..2bffaa48b47 --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Static_And_NonStatic_Query_Page_Same_Type_Across_Assemblies.graphql @@ -0,0 +1,103 @@ +schema { + query: Query +} + +type Query { + authors( + "Returns the first _n_ elements from the list." + first: Int + "Returns the elements in the list that come after the specified cursor." + after: String + "Returns the last _n_ elements from the list." + last: Int + "Returns the elements in the list that come before the specified cursor." + before: String + ): AuthorConnection + @listSize( + assumedSize: 50 + slicingArguments: ["first", "last"] + slicingArgumentDefaultValue: 10 + sizedFields: ["edges", "nodes"] + requireOneSlicingArgument: false + ) + @cost(weight: "10") + moreAuthors( + "Returns the first _n_ elements from the list." + first: Int + "Returns the elements in the list that come after the specified cursor." + after: String + "Returns the last _n_ elements from the list." + last: Int + "Returns the elements in the list that come before the specified cursor." + before: String + ): AuthorConnection + @listSize( + assumedSize: 50 + slicingArguments: ["first", "last"] + slicingArgumentDefaultValue: 10 + sizedFields: ["edges", "nodes"] + requireOneSlicingArgument: false + ) + @cost(weight: "10") +} + +type Author { + id: Int! + name: String +} + +"A connection to a list of items." +type AuthorConnection { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges." + edges: [AuthorEdge!] + "A flattened list of the nodes." + nodes: [Author] +} + +"An edge in a connection." +type AuthorEdge { + "A cursor for use in pagination." + cursor: String! + "The item at the end of the edge." + node: Author +} + +"Information about pagination in a connection." +type PageInfo { + "Indicates whether more edges exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more edges exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String + "When paginating forwards, the cursor to continue." + endCursor: String +} + +"The purpose of the `cost` directive is to define a `weight` for GraphQL types, fields, and arguments. Static analysis can use these weights when calculating the overall cost of a query or response." +directive @cost( + "The `weight` argument defines what value to add to the overall cost for every appearance, or possible appearance, of a type, field, argument, etc." + weight: String! +) on + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | ENUM + | INPUT_FIELD_DEFINITION + +"The purpose of the `@listSize` directive is to either inform the static analysis about the size of returned lists (if that information is statically available), or to point the analysis to where to find that information." +directive @listSize( + "The `assumedSize` argument can be used to statically define the maximum length of a list returned by a field." + assumedSize: Int + "The `slicingArguments` argument can be used to define which of the field's arguments with numeric type are slicing arguments, so that their value determines the size of the list returned by that field. It may specify a list of multiple slicing arguments." + slicingArguments: [String!] + "The `slicingArgumentDefaultValue` argument can be used to define a default value for a slicing argument, which is used if the argument is not present in a query." + slicingArgumentDefaultValue: Int + "The `sizedFields` argument can be used to define that the value of the `assumedSize` argument or of a slicing argument does not affect the size of a list returned by a field itself, but that of a list returned by one of its sub-fields." + sizedFields: [String!] + "The `requireOneSlicingArgument` argument can be used to inform the static analysis that it should expect that exactly one of the defined slicing arguments is present in a query. If that is not the case (i.e., if none or multiple slicing arguments are present), the static analysis may throw an error." + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Static_And_NonStatic_Query_Page_Same_Type_In_One_Assembly.graphql b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Static_And_NonStatic_Query_Page_Same_Type_In_One_Assembly.graphql new file mode 100644 index 00000000000..37e4f70e98f --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/ConnectionTests.Build_Schema_Should_Reuse_Single_Connection_When_Static_And_NonStatic_Query_Page_Same_Type_In_One_Assembly.graphql @@ -0,0 +1,103 @@ +schema { + query: Query +} + +type Query { + moreAuthors( + "Returns the first _n_ elements from the list." + first: Int + "Returns the elements in the list that come after the specified cursor." + after: String + "Returns the last _n_ elements from the list." + last: Int + "Returns the elements in the list that come before the specified cursor." + before: String + ): AuthorConnection + @listSize( + assumedSize: 50 + slicingArguments: ["first", "last"] + slicingArgumentDefaultValue: 10 + sizedFields: ["edges", "nodes"] + requireOneSlicingArgument: false + ) + @cost(weight: "10") + authors( + "Returns the first _n_ elements from the list." + first: Int + "Returns the elements in the list that come after the specified cursor." + after: String + "Returns the last _n_ elements from the list." + last: Int + "Returns the elements in the list that come before the specified cursor." + before: String + ): AuthorConnection + @listSize( + assumedSize: 50 + slicingArguments: ["first", "last"] + slicingArgumentDefaultValue: 10 + sizedFields: ["edges", "nodes"] + requireOneSlicingArgument: false + ) + @cost(weight: "10") +} + +type Author { + id: Int! + name: String +} + +"A connection to a list of items." +type AuthorConnection { + "Information to aid in pagination." + pageInfo: PageInfo! + "A list of edges." + edges: [AuthorEdge!] + "A flattened list of the nodes." + nodes: [Author] +} + +"An edge in a connection." +type AuthorEdge { + "A cursor for use in pagination." + cursor: String! + "The item at the end of the edge." + node: Author +} + +"Information about pagination in a connection." +type PageInfo { + "Indicates whether more edges exist following the set defined by the clients arguments." + hasNextPage: Boolean! + "Indicates whether more edges exist prior the set defined by the clients arguments." + hasPreviousPage: Boolean! + "When paginating backwards, the cursor to continue." + startCursor: String + "When paginating forwards, the cursor to continue." + endCursor: String +} + +"The purpose of the `cost` directive is to define a `weight` for GraphQL types, fields, and arguments. Static analysis can use these weights when calculating the overall cost of a query or response." +directive @cost( + "The `weight` argument defines what value to add to the overall cost for every appearance, or possible appearance, of a type, field, argument, etc." + weight: String! +) on + | SCALAR + | OBJECT + | FIELD_DEFINITION + | ARGUMENT_DEFINITION + | ENUM + | INPUT_FIELD_DEFINITION + +"The purpose of the `@listSize` directive is to either inform the static analysis about the size of returned lists (if that information is statically available), or to point the analysis to where to find that information." +directive @listSize( + "The `assumedSize` argument can be used to statically define the maximum length of a list returned by a field." + assumedSize: Int + "The `slicingArguments` argument can be used to define which of the field's arguments with numeric type are slicing arguments, so that their value determines the size of the list returned by that field. It may specify a list of multiple slicing arguments." + slicingArguments: [String!] + "The `slicingArgumentDefaultValue` argument can be used to define a default value for a slicing argument, which is used if the argument is not present in a query." + slicingArgumentDefaultValue: Int + "The `sizedFields` argument can be used to define that the value of the `assumedSize` argument or of a slicing argument does not affect the size of a list returned by a field itself, but that of a list returned by one of its sub-fields." + sizedFields: [String!] + "The `requireOneSlicingArgument` argument can be used to inform the static analysis that it should expect that exactly one of the defined slicing arguments is present in a query. If that is not the case (i.e., if none or multiple slicing arguments are present), the static analysis may throw an error." + requireOneSlicingArgument: Boolean = true +) on FIELD_DEFINITION