From fade944e3a2e0aec287cd05b3f85504f06832f29 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Mon, 2 Oct 2023 23:01:43 +0200 Subject: [PATCH] true nullability --- .../src/Abstractions/WellKnownContextData.cs | 5 + .../src/Abstractions/WellKnownDirectives.cs | 4 + .../Pipeline/OperationResolverMiddleware.cs | 90 ++++++++++- .../src/Execution/Pipeline/PipelineTools.cs | 6 +- .../OperationCompiler.CompilerContext.cs | 14 +- .../Execution/Processing/OperationCompiler.cs | 55 ++++--- .../Configuration/AggregateTypeInterceptor.cs | 50 ++++-- .../Types/Configuration/TypeInterceptor.cs | 2 + .../Core/src/Types/IReadOnlySchemaOptions.cs | 3 + .../Core/src/Types/ReadOnlySchemaOptions.cs | 4 + .../Core/src/Types/SchemaBuilder.cs | 3 +- .../Core/src/Types/SchemaOptions.cs | 8 +- .../src/Types/Types/Directives/Directives.cs | 5 + .../Types/Directives/NullBubblingDirective.cs | 18 +++ .../Types/Types/Extensions/TypeExtensions.cs | 5 + .../MiddlewareValidationTypeInterceptor.cs | 20 +++ .../ClientControlledNullabilityTests.cs | 2 +- .../Execution.Tests/TrueNullabilityTests.cs | 147 ++++++++++++++++++ ...Nullability_And_NullBubbling_Disabled.snap | 26 ++++ ...d_NullBubbling_Disabled_With_Variable.snap | 26 ++++ ...eNullability_And_NullBubbling_Enabled.snap | 37 +++++ ...y_And_NullBubbling_Enabled_By_Default.snap | 37 +++++ ...yTests.Schema_With_TrueNullability.graphql | 18 +++ ...sts.Schema_Without_TrueNullability.graphql | 16 ++ 24 files changed, 548 insertions(+), 53 deletions(-) create mode 100644 src/HotChocolate/Core/src/Types/Types/Directives/NullBubblingDirective.cs create mode 100644 src/HotChocolate/Core/test/Execution.Tests/TrueNullabilityTests.cs create mode 100644 src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Disabled.snap create mode 100644 src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Disabled_With_Variable.snap create mode 100644 src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Enabled.snap create mode 100644 src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Enabled_By_Default.snap create mode 100644 src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Schema_With_TrueNullability.graphql create mode 100644 src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Schema_Without_TrueNullability.graphql diff --git a/src/HotChocolate/Core/src/Abstractions/WellKnownContextData.cs b/src/HotChocolate/Core/src/Abstractions/WellKnownContextData.cs index 43be07d7fa0..a5575f9bdd8 100644 --- a/src/HotChocolate/Core/src/Abstractions/WellKnownContextData.cs +++ b/src/HotChocolate/Core/src/Abstractions/WellKnownContextData.cs @@ -274,4 +274,9 @@ public static class WellKnownContextData /// The key to access the authorization allowed flag on the member context. /// public const string AllowAnonymous = "HotChocolate.Authorization.AllowAnonymous"; + + /// + /// The key to access the true nullability flag on the execution context. + /// + public const string EnableTrueNullability = "HotChocolate.Types.EnableTrueNullability"; } diff --git a/src/HotChocolate/Core/src/Abstractions/WellKnownDirectives.cs b/src/HotChocolate/Core/src/Abstractions/WellKnownDirectives.cs index e7980b8cb62..7939a6d8da4 100644 --- a/src/HotChocolate/Core/src/Abstractions/WellKnownDirectives.cs +++ b/src/HotChocolate/Core/src/Abstractions/WellKnownDirectives.cs @@ -69,4 +69,8 @@ public static class WellKnownDirectives /// The name of the @tag argument name. /// public const string Name = "name"; + + public const string NullBubbling = "nullBubbling"; + + public const string Enable = "enable"; } diff --git a/src/HotChocolate/Core/src/Execution/Pipeline/OperationResolverMiddleware.cs b/src/HotChocolate/Core/src/Execution/Pipeline/OperationResolverMiddleware.cs index 32d4b220287..d7b9799d697 100644 --- a/src/HotChocolate/Core/src/Execution/Pipeline/OperationResolverMiddleware.cs +++ b/src/HotChocolate/Core/src/Execution/Pipeline/OperationResolverMiddleware.cs @@ -5,7 +5,11 @@ using HotChocolate.Execution.Processing; using HotChocolate.Language; using HotChocolate.Types; +using HotChocolate.Utilities; using Microsoft.Extensions.ObjectPool; +using static HotChocolate.WellKnownDirectives; +using static HotChocolate.Execution.Pipeline.PipelineTools; +using static HotChocolate.WellKnownContextData; namespace HotChocolate.Execution.Pipeline; @@ -13,20 +17,26 @@ internal sealed class OperationResolverMiddleware { private readonly RequestDelegate _next; private readonly ObjectPool _operationCompilerPool; + private readonly VariableCoercionHelper _coercionHelper; private readonly IReadOnlyList? _optimizers; public OperationResolverMiddleware( RequestDelegate next, ObjectPool operationCompilerPool, - IEnumerable optimizers) + IEnumerable optimizers, + VariableCoercionHelper coercionHelper) { if (optimizers is null) { throw new ArgumentNullException(nameof(optimizers)); } - _next = next ?? throw new ArgumentNullException(nameof(next)); - _operationCompilerPool = operationCompilerPool; + _next = next ?? + throw new ArgumentNullException(nameof(next)); + _operationCompilerPool = operationCompilerPool ?? + throw new ArgumentNullException(nameof(operationCompilerPool)); + _coercionHelper = coercionHelper ?? + throw new ArgumentNullException(nameof(coercionHelper)); _optimizers = optimizers.ToArray(); } @@ -78,7 +88,8 @@ private IOperation CompileOperation( operationType, context.Document!, context.Schema, - _optimizers); + _optimizers, + IsNullBubblingEnabled(context, operationDefinition)); _operationCompilerPool.Return(compiler); return operation; } @@ -93,4 +104,73 @@ private IOperation CompileOperation( OperationType.Subscription => schema.SubscriptionType, _ => throw ThrowHelper.RootTypeNotSupported(operationType) }; -} + + private bool IsNullBubblingEnabled(IRequestContext context, OperationDefinitionNode operationDefinition) + { + if (!context.Schema.ContextData.ContainsKey(EnableTrueNullability) || + operationDefinition.Directives.Count == 0) + { + return true; + } + + var enabled = true; + + for (var i = 0; i < operationDefinition.Directives.Count; i++) + { + var directive = operationDefinition.Directives[i]; + + if (!directive.Name.Value.EqualsOrdinal(NullBubbling)) + { + continue; + } + + for (var j = 0; j < directive.Arguments.Count; j++) + { + var argument = directive.Arguments[j]; + + if (argument.Name.Value.EqualsOrdinal(Enable)) + { + if (argument.Value is BooleanValueNode b) + { + enabled = b.Value; + break; + } + + if (argument.Value is VariableNode v) + { + enabled = CoerceVariable(context, operationDefinition, v); + break; + } + + // TOOD : Move to ErrorHelper + var errorBuilder = ErrorBuilder.New(); + + if (argument.Value.Location is not null) + { + errorBuilder.AddLocation( + argument.Value.Location.Line, + argument.Value.Location.Column); + } + + errorBuilder.SetSyntaxNode(argument.Value); + errorBuilder.SetMessage("Only boolean values are allowed here."); + + throw new GraphQLException(errorBuilder.Build()); + } + } + + break; + } + + return enabled; + } + + private bool CoerceVariable( + IRequestContext context, + OperationDefinitionNode operationDefinition, + VariableNode variable) + { + var variables = CoerceVariables(context, _coercionHelper, operationDefinition.VariableDefinitions); + return variables.GetVariable(variable.Name.Value); + } +} \ No newline at end of file diff --git a/src/HotChocolate/Core/src/Execution/Pipeline/PipelineTools.cs b/src/HotChocolate/Core/src/Execution/Pipeline/PipelineTools.cs index f8f7d890cfb..3e72a233cf9 100644 --- a/src/HotChocolate/Core/src/Execution/Pipeline/PipelineTools.cs +++ b/src/HotChocolate/Core/src/Execution/Pipeline/PipelineTools.cs @@ -25,19 +25,20 @@ public static string CreateCacheId( string? operationName) => CreateCacheId(context, CreateOperationId(documentId, operationName)); - public static void CoerceVariables( + public static IVariableValueCollection CoerceVariables( IRequestContext context, VariableCoercionHelper coercionHelper, IReadOnlyList variableDefinitions) { if (context.Variables is not null) { - return; + return context.Variables; } if (variableDefinitions.Count == 0) { context.Variables = _noVariables; + return _noVariables; } else { @@ -52,6 +53,7 @@ public static void CoerceVariables( coercedValues); context.Variables = new VariableValueCollection(coercedValues); + return context.Variables; } } } diff --git a/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.CompilerContext.cs b/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.CompilerContext.cs index 8f81ca6eadb..66bbfbd898e 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.CompilerContext.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.CompilerContext.cs @@ -8,17 +8,11 @@ namespace HotChocolate.Execution.Processing; public sealed partial class OperationCompiler { - internal sealed class CompilerContext + internal sealed class CompilerContext(ISchema schema, DocumentNode document, bool disableNullBubbling) { - public CompilerContext(ISchema schema, DocumentNode document) - { - Schema = schema; - Document = document; - } - - public ISchema Schema { get; } + public ISchema Schema { get; } = schema; - public DocumentNode Document { get; } + public DocumentNode Document { get; } = document; public ObjectType Type { get; private set; } = default!; @@ -35,6 +29,8 @@ public CompilerContext(ISchema schema, DocumentNode document) public IImmutableList Optimizers { get; private set; } = ImmutableList.Empty; + + public bool EnableNullBubbling { get; } = disableNullBubbling; public void Initialize( ObjectType type, diff --git a/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs b/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs index 260834358d8..4f5f548b684 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/OperationCompiler.cs @@ -24,6 +24,7 @@ public sealed partial class OperationCompiler { private static readonly ImmutableList _emptyOptimizers = ImmutableList.Empty; + private readonly InputParser _parser; private readonly CreateFieldPipeline _createFieldPipeline; private readonly Stack _backlog = new(); @@ -65,7 +66,8 @@ public IOperation Compile( ObjectType operationType, DocumentNode document, ISchema schema, - IReadOnlyList? optimizers = null) + IReadOnlyList? optimizers = null, + bool enableNullBubbling = true) { if (string.IsNullOrEmpty(operationId)) { @@ -112,7 +114,7 @@ public IOperation Compile( var variants = GetOrCreateSelectionVariants(id); SelectionSetInfo[] infos = { new(operationDefinition.SelectionSet, 0) }; - var context = new CompilerContext(schema, document); + var context = new CompilerContext(schema, document, enableNullBubbling); context.Initialize(operationType, variants, infos, rootPath, rootOptimizers); CompileSelectionSet(context); @@ -378,19 +380,19 @@ private void CompleteSelectionSet(CompilerContext context) // For now we only allow streams on lists of composite types. if (selection.SyntaxNode.IsStreamable()) { - var streamDirective = selection.SyntaxNode.GetStreamDirectiveNode(); - var nullValue = NullValueNode.Default; - var ifValue = streamDirective?.GetIfArgumentValueOrDefault() ?? nullValue; - long ifConditionFlags = 0; - - if (ifValue.Kind is not SyntaxKind.NullValue) - { - var ifCondition = new IncludeCondition(ifValue, nullValue); - ifConditionFlags = GetSelectionIncludeCondition(ifCondition, 0); - } - - selection.MarkAsStream(ifConditionFlags); - _hasIncrementalParts = true; + var streamDirective = selection.SyntaxNode.GetStreamDirectiveNode(); + var nullValue = NullValueNode.Default; + var ifValue = streamDirective?.GetIfArgumentValueOrDefault() ?? nullValue; + long ifConditionFlags = 0; + + if (ifValue.Kind is not SyntaxKind.NullValue) + { + var ifCondition = new IncludeCondition(ifValue, nullValue); + ifConditionFlags = GetSelectionIncludeCondition(ifCondition, 0); + } + + selection.MarkAsStream(ifConditionFlags); + _hasIncrementalParts = true; } } @@ -449,21 +451,21 @@ private void ResolveFields( case SyntaxKind.Field: ResolveField( context, - (FieldNode)selection, + (FieldNode) selection, includeCondition); break; case SyntaxKind.InlineFragment: ResolveInlineFragment( context, - (InlineFragmentNode)selection, + (InlineFragmentNode) selection, includeCondition); break; case SyntaxKind.FragmentSpread: ResolveFragmentSpread( context, - (FragmentSpreadNode)selection, + (FragmentSpreadNode) selection, includeCondition); break; } @@ -481,7 +483,9 @@ private void ResolveField( if (context.Type.Fields.TryGetField(fieldName, out var field)) { - var fieldType = field.Type.RewriteNullability(selection.Required); + var fieldType = context.EnableNullBubbling + ? field.Type.RewriteNullability(selection.Required) + : field.Type.RewriteToNullableType(); if (context.Fields.TryGetValue(responseName, out var preparedSelection)) { @@ -516,7 +520,9 @@ selection.SelectionSet is not null responseName: responseName, isParallelExecutable: field.IsParallelExecutable, arguments: CoerceArgumentValues(field, selection, responseName), - includeConditions: includeCondition == 0 ? null : new[] { includeCondition }); + includeConditions: includeCondition == 0 + ? null + : new[] { includeCondition }); context.Fields.Add(responseName, preparedSelection); @@ -586,6 +592,7 @@ private void ResolveFragment( var ifValue = deferDirective?.GetIfArgumentValueOrDefault() ?? nullValue; long ifConditionFlags = 0; + if (ifValue.Kind is not SyntaxKind.NullValue) { var ifCondition = new IncludeCondition(ifValue, nullValue); @@ -637,8 +644,8 @@ private static bool DoesTypeApply(IType typeCondition, IObjectType current) => typeCondition.Kind switch { TypeKind.Object => ReferenceEquals(typeCondition, current), - TypeKind.Interface => current.IsImplementing((InterfaceType)typeCondition), - TypeKind.Union => ((UnionType)typeCondition).Types.ContainsKey(current.Name), + TypeKind.Interface => current.IsImplementing((InterfaceType) typeCondition), + TypeKind.Union => ((UnionType) typeCondition).Types.ContainsKey(current.Name), _ => false }; @@ -789,7 +796,7 @@ private CompilerContext RentContext(CompilerContext context) { if (_deferContext is null) { - return new CompilerContext(context.Schema, context.Document); + return new CompilerContext(context.Schema, context.Document, context.EnableNullBubbling); } var temp = _deferContext; @@ -862,4 +869,4 @@ public override bool Equals(object? obj) public override int GetHashCode() => HashCode.Combine(SelectionSet, Path); } -} +} \ No newline at end of file diff --git a/src/HotChocolate/Core/src/Types/Configuration/AggregateTypeInterceptor.cs b/src/HotChocolate/Core/src/Types/Configuration/AggregateTypeInterceptor.cs index a03db528339..3e0011eb866 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/AggregateTypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/AggregateTypeInterceptor.cs @@ -15,12 +15,7 @@ namespace HotChocolate.Configuration; internal sealed class AggregateTypeInterceptor : TypeInterceptor { private readonly List _typeReferences = new(); - private TypeInterceptor[] _typeInterceptors; - - public AggregateTypeInterceptor() - { - _typeInterceptors = Array.Empty(); - } + private TypeInterceptor[] _typeInterceptors = Array.Empty(); public void SetInterceptors(IReadOnlyCollection typeInterceptors) { @@ -37,12 +32,47 @@ public override void OnBeforeCreateSchema( IDescriptorContext context, ISchemaBuilder schemaBuilder) { - ref var first = ref GetReference(); - var length = _typeInterceptors.Length; + ref var start = ref GetReference(); + ref var current = ref Unsafe.Add(ref start, 0); + ref var end = ref Unsafe.Add(ref current, _typeInterceptors.Length); - for (var i = 0; i < length; i++) + // we first initialize all schema context ... + while (Unsafe.IsAddressLessThan(ref current, ref end)) + { + current.OnBeforeCreateSchema(context, schemaBuilder); + current = ref Unsafe.Add(ref current, 1); + } + + current = ref Unsafe.Add(ref start, 0); + var i = 0; + TypeInterceptor[]? temp = null; + + // next we determine the type interceptors that are enabled ... + while (Unsafe.IsAddressLessThan(ref current, ref end)) + { + if (temp is null && !current.IsEnabled(context)) + { + temp ??= new TypeInterceptor[_typeInterceptors.Length]; + ref var next = ref Unsafe.Add(ref start, 0); + while (Unsafe.IsAddressLessThan(ref next, ref current)) + { + temp[i++] = next; + next = ref Unsafe.Add(ref next, 1); + } + } + + if (temp is not null && current.IsEnabled(context)) + { + temp[i++] = current; + } + + current = ref Unsafe.Add(ref current, 1); + } + + if (temp is not null) { - Unsafe.Add(ref first, i).OnBeforeCreateSchema(context, schemaBuilder); + Array.Resize(ref temp, i); + _typeInterceptors = temp; } } diff --git a/src/HotChocolate/Core/src/Types/Configuration/TypeInterceptor.cs b/src/HotChocolate/Core/src/Types/Configuration/TypeInterceptor.cs index a511fb11ebe..a737996b541 100644 --- a/src/HotChocolate/Core/src/Types/Configuration/TypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Types/Configuration/TypeInterceptor.cs @@ -23,6 +23,8 @@ public abstract class TypeInterceptor /// A weight to order interceptors. /// internal virtual uint Position => _position; + + public virtual bool IsEnabled(IDescriptorContext context) => true; /// /// This hook is invoked before anything else any allows for additional modification diff --git a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs index 900c95eb04d..f920fb82423 100644 --- a/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/IReadOnlySchemaOptions.cs @@ -87,4 +87,7 @@ public interface IReadOnlySchemaOptions /// bool StripLeadingIFromInterface { get; } + + /// + bool EnableTrueNullability { get; } } diff --git a/src/HotChocolate/Core/src/Types/ReadOnlySchemaOptions.cs b/src/HotChocolate/Core/src/Types/ReadOnlySchemaOptions.cs index 48ff818cdc0..bf06cd35beb 100644 --- a/src/HotChocolate/Core/src/Types/ReadOnlySchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/ReadOnlySchemaOptions.cs @@ -53,6 +53,7 @@ public ReadOnlySchemaOptions(IReadOnlySchemaOptions options) EnableStream = options.EnableStream; MaxAllowedNodeBatchSize = options.MaxAllowedNodeBatchSize; StripLeadingIFromInterface = options.StripLeadingIFromInterface; + EnableTrueNullability = options.EnableTrueNullability; } /// @@ -128,4 +129,7 @@ public ReadOnlySchemaOptions(IReadOnlySchemaOptions options) /// public bool StripLeadingIFromInterface { get; } + + /// + public bool EnableTrueNullability { get; } } diff --git a/src/HotChocolate/Core/src/Types/SchemaBuilder.cs b/src/HotChocolate/Core/src/Types/SchemaBuilder.cs index 41b93fd3ead..adf143be404 100644 --- a/src/HotChocolate/Core/src/Types/SchemaBuilder.cs +++ b/src/HotChocolate/Core/src/Types/SchemaBuilder.cs @@ -35,7 +35,8 @@ public partial class SchemaBuilder : ISchemaBuilder typeof(IntrospectionTypeInterceptor), typeof(InterfaceCompletionTypeInterceptor), typeof(CostTypeInterceptor), - typeof(MiddlewareValidationTypeInterceptor) + typeof(MiddlewareValidationTypeInterceptor), + typeof(EnableTrueNullabilityTypeInterceptor) }; private SchemaOptions _options = new(); diff --git a/src/HotChocolate/Core/src/Types/SchemaOptions.cs b/src/HotChocolate/Core/src/Types/SchemaOptions.cs index 61d7bc48a42..ecb3883bd9a 100644 --- a/src/HotChocolate/Core/src/Types/SchemaOptions.cs +++ b/src/HotChocolate/Core/src/Types/SchemaOptions.cs @@ -203,6 +203,11 @@ public FieldBindingFlags DefaultFieldBindingFlags /// public bool StripLeadingIFromInterface { get; set; } = false; + /// + /// Specifies that the true nullability proto type shall be enabled. + /// + public bool EnableTrueNullability { get; set; } = false; + /// /// Creates a mutable options object from a read-only options object. /// @@ -236,7 +241,8 @@ public static SchemaOptions FromOptions(IReadOnlySchemaOptions options) EnableStream = options.EnableStream, DefaultFieldBindingFlags = options.DefaultFieldBindingFlags, MaxAllowedNodeBatchSize = options.MaxAllowedNodeBatchSize, - StripLeadingIFromInterface = options.StripLeadingIFromInterface + StripLeadingIFromInterface = options.StripLeadingIFromInterface, + EnableTrueNullability = options.EnableTrueNullability }; } } diff --git a/src/HotChocolate/Core/src/Types/Types/Directives/Directives.cs b/src/HotChocolate/Core/src/Types/Types/Directives/Directives.cs index 4741348b0c5..08fbaa72f12 100644 --- a/src/HotChocolate/Core/src/Types/Types/Directives/Directives.cs +++ b/src/HotChocolate/Core/src/Types/Types/Directives/Directives.cs @@ -40,6 +40,11 @@ internal static IReadOnlyList CreateReferences( { directiveTypes.Add(typeInspector.GetTypeRef(typeof(StreamDirectiveType))); } + + if (descriptorContext.Options.EnableTrueNullability) + { + directiveTypes.Add(typeInspector.GetTypeRef(typeof(NullBubblingDirective))); + } directiveTypes.Add(typeInspector.GetTypeRef(typeof(SkipDirectiveType))); directiveTypes.Add(typeInspector.GetTypeRef(typeof(IncludeDirectiveType))); diff --git a/src/HotChocolate/Core/src/Types/Types/Directives/NullBubblingDirective.cs b/src/HotChocolate/Core/src/Types/Types/Directives/NullBubblingDirective.cs new file mode 100644 index 00000000000..4e8fec48fc9 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Directives/NullBubblingDirective.cs @@ -0,0 +1,18 @@ +namespace HotChocolate.Types; + +[DirectiveType( + WellKnownDirectives.NullBubbling, + DirectiveLocation.Query | + DirectiveLocation.Mutation | + DirectiveLocation.Subscription)] +public class NullBubblingDirective +{ + public NullBubblingDirective(bool enable = true) + { + Enable = enable; + } + + [DefaultValue(true)] + [GraphQLName(WellKnownDirectives.Enable)] + public bool Enable { get; } +} \ No newline at end of file diff --git a/src/HotChocolate/Core/src/Types/Types/Extensions/TypeExtensions.cs b/src/HotChocolate/Core/src/Types/Types/Extensions/TypeExtensions.cs index 1b0e7f999d0..6fec956d0b8 100644 --- a/src/HotChocolate/Core/src/Types/Types/Extensions/TypeExtensions.cs +++ b/src/HotChocolate/Core/src/Types/Types/Extensions/TypeExtensions.cs @@ -763,4 +763,9 @@ public static IType RewriteNullability(this IType type, INullabilityNode? nullab throw RewriteNullability_InvalidNullabilityStructure(); } } + + public static IType RewriteToNullableType(this IType type) + => type.Kind is TypeKind.NonNull + ? type.InnerType() + : type; } \ No newline at end of file diff --git a/src/HotChocolate/Core/src/Types/Types/Interceptors/MiddlewareValidationTypeInterceptor.cs b/src/HotChocolate/Core/src/Types/Types/Interceptors/MiddlewareValidationTypeInterceptor.cs index b7c3c97c3b3..0cc4868f7b0 100644 --- a/src/HotChocolate/Core/src/Types/Types/Interceptors/MiddlewareValidationTypeInterceptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Interceptors/MiddlewareValidationTypeInterceptor.cs @@ -2,6 +2,7 @@ using System.Text; using HotChocolate.Configuration; using HotChocolate.Language; +using HotChocolate.Types.Descriptors; using HotChocolate.Types.Descriptors.Definitions; using HotChocolate.Utilities; @@ -173,3 +174,22 @@ void PrintOther() } } } + +internal sealed class EnableTrueNullabilityTypeInterceptor : TypeInterceptor +{ + public override bool IsEnabled(IDescriptorContext context) + => context.Options.EnableTrueNullability; + + public override void OnBeforeCreateSchema(IDescriptorContext context, ISchemaBuilder schemaBuilder) + { + base.OnBeforeCreateSchema(context, schemaBuilder); + } + + public override void OnAfterInitialize(ITypeDiscoveryContext discoveryContext, DefinitionBase definition) + { + if (definition is SchemaTypeDefinition schemaDef) + { + schemaDef.ContextData[WellKnownContextData.EnableTrueNullability] = true; + } + } +} \ No newline at end of file diff --git a/src/HotChocolate/Core/test/Execution.Tests/ClientControlledNullabilityTests.cs b/src/HotChocolate/Core/test/Execution.Tests/ClientControlledNullabilityTests.cs index ce9f6d4665d..a07f0385e97 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/ClientControlledNullabilityTests.cs +++ b/src/HotChocolate/Core/test/Execution.Tests/ClientControlledNullabilityTests.cs @@ -75,4 +75,4 @@ public class Person public string? Bio { get; set; } } -} +} \ No newline at end of file diff --git a/src/HotChocolate/Core/test/Execution.Tests/TrueNullabilityTests.cs b/src/HotChocolate/Core/test/Execution.Tests/TrueNullabilityTests.cs new file mode 100644 index 00000000000..512ce55b61d --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/TrueNullabilityTests.cs @@ -0,0 +1,147 @@ +#nullable enable +using CookieCrumble; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Execution; + +public class TrueNullabilityTests +{ + [Fact] + public async Task Schema_Without_TrueNullability() + { + var schema = + await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType() + .ModifyOptions(o => o.EnableTrueNullability = false) + .BuildSchemaAsync(); + + schema.MatchSnapshot(); + } + + [Fact] + public async Task Schema_With_TrueNullability() + { + var schema = + await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType() + .ModifyOptions(o => o.EnableTrueNullability = true) + .BuildSchemaAsync(); + + schema.MatchSnapshot(); + } + + [Fact] + public async Task Error_Query_With_TrueNullability_And_NullBubbling_Enabled_By_Default() + { + var response = + await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType() + .ModifyOptions(o => o.EnableTrueNullability = true) + .ExecuteRequestAsync( + """ + query { + book { + name + author { + name + } + } + } + """); + + response.MatchSnapshot(); + } + + [Fact] + public async Task Error_Query_With_TrueNullability_And_NullBubbling_Enabled() + { + var response = + await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType() + .ModifyOptions(o => o.EnableTrueNullability = true) + .ExecuteRequestAsync( + """ + query @nullBubbling { + book { + name + author { + name + } + } + } + """); + + response.MatchSnapshot(); + } + + [Fact] + public async Task Error_Query_With_TrueNullability_And_NullBubbling_Disabled() + { + var response = + await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType() + .ModifyOptions(o => o.EnableTrueNullability = true) + .ExecuteRequestAsync( + """ + query @nullBubbling(enable: false) { + book { + name + author { + name + } + } + } + """); + + response.MatchSnapshot(); + } + + [Fact] + public async Task Error_Query_With_TrueNullability_And_NullBubbling_Disabled_With_Variable() + { + var response = + await new ServiceCollection() + .AddGraphQLServer() + .AddQueryType() + .ModifyOptions(o => o.EnableTrueNullability = true) + .ExecuteRequestAsync( + QueryRequestBuilder.New() + .SetQuery( + """ + query($enable: Boolean!) @nullBubbling(enable: $enable) { + book { + name + author { + name + } + } + } + """) + .SetVariableValue("enable", false) + .Create()); + + response.MatchSnapshot(); + } + + public class Query + { + public Book? GetBook() => new(); + } + + public class Book + { + public string Name => "Some book!"; + + public Author Author => new(); + } + + public class Author + { + public string Name => throw new Exception(); + } +} \ No newline at end of file diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Disabled.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Disabled.snap new file mode 100644 index 00000000000..5f1271ea8b6 --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Disabled.snap @@ -0,0 +1,26 @@ +{ + "errors": [ + { + "message": "Unexpected Execution Error", + "locations": [ + { + "line": 5, + "column": 13 + } + ], + "path": [ + "book", + "author", + "name" + ] + } + ], + "data": { + "book": { + "name": "Some book!", + "author": { + "name": null + } + } + } +} \ No newline at end of file diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Disabled_With_Variable.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Disabled_With_Variable.snap new file mode 100644 index 00000000000..5f1271ea8b6 --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Disabled_With_Variable.snap @@ -0,0 +1,26 @@ +{ + "errors": [ + { + "message": "Unexpected Execution Error", + "locations": [ + { + "line": 5, + "column": 13 + } + ], + "path": [ + "book", + "author", + "name" + ] + } + ], + "data": { + "book": { + "name": "Some book!", + "author": { + "name": null + } + } + } +} \ No newline at end of file diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Enabled.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Enabled.snap new file mode 100644 index 00000000000..4bb9b286d1b --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Enabled.snap @@ -0,0 +1,37 @@ +{ + "errors": [ + { + "message": "Cannot return null for non-nullable field.", + "locations": [ + { + "line": 4, + "column": 9 + } + ], + "path": [ + "book", + "author" + ], + "extensions": { + "code": "HC0018" + } + }, + { + "message": "Unexpected Execution Error", + "locations": [ + { + "line": 5, + "column": 13 + } + ], + "path": [ + "book", + "author", + "name" + ] + } + ], + "data": { + "book": null + } +} \ No newline at end of file diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Enabled_By_Default.snap b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Enabled_By_Default.snap new file mode 100644 index 00000000000..4bb9b286d1b --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Error_Query_With_TrueNullability_And_NullBubbling_Enabled_By_Default.snap @@ -0,0 +1,37 @@ +{ + "errors": [ + { + "message": "Cannot return null for non-nullable field.", + "locations": [ + { + "line": 4, + "column": 9 + } + ], + "path": [ + "book", + "author" + ], + "extensions": { + "code": "HC0018" + } + }, + { + "message": "Unexpected Execution Error", + "locations": [ + { + "line": 5, + "column": 13 + } + ], + "path": [ + "book", + "author", + "name" + ] + } + ], + "data": { + "book": null + } +} \ No newline at end of file diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Schema_With_TrueNullability.graphql b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Schema_With_TrueNullability.graphql new file mode 100644 index 00000000000..b202b0bbeab --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Schema_With_TrueNullability.graphql @@ -0,0 +1,18 @@ +schema { + query: Query +} + +type Author { + name: String! +} + +type Book { + name: String! + author: Author! +} + +type Query { + book: Book +} + +directive @nullBubbling(enable: Boolean! = true) on QUERY | MUTATION | SUBSCRIPTION \ No newline at end of file diff --git a/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Schema_Without_TrueNullability.graphql b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Schema_Without_TrueNullability.graphql new file mode 100644 index 00000000000..ae51fdf5769 --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/__snapshots__/TrueNullabilityTests.Schema_Without_TrueNullability.graphql @@ -0,0 +1,16 @@ +schema { + query: Query +} + +type Author { + name: String! +} + +type Book { + name: String! + author: Author! +} + +type Query { + book: Book +} \ No newline at end of file