From 2a2943d74dcdd38940fbaddb1a40c8ba6ccbd335 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 1 Oct 2024 13:54:36 +0200 Subject: [PATCH] Added nullability awareness to projections. (#7541) (cherry picked from commit 97ebf4b6f3829f62820f346996c332656e89204b) --- src/GreenDonut/src/Core/GreenDonut.csproj | 1 + ...otChocolateExecutionSelectionExtensions.cs | 133 ++++++++++- .../src/Execution/Processing/Operation.cs | 19 ++ ...tChocolateExecutionDataLoaderExtensions.cs | 108 ++------- .../Projections/SelectionExpressionBuilder.cs | 53 ++++- .../Types/Execution/Processing/IOperation.cs | 6 +- ...aLoaderTests.Branches_Are_Merged_NET7_0.md | 2 +- ...Tests.Brand_Details_Country_Name_NET7_0.md | 2 +- ...leDataLoaderTests.Force_A_Branch_NET7_0.md | 4 +- ...ct_With_Name_And_Brand_With_Name_NET7_0.md | 2 +- .../PagingHelperIntegrationTests.cs | 219 +++++++++++++++++- .../TestContext/FooBarContext.cs | 51 ++++ ....Ensure_Nullable_Connections_Dont_Throw.md | 57 +++++ ...nsure_Nullable_Connections_Dont_Throw_2.md | 59 +++++ ...ullable_Connections_Dont_Throw_2_NET6_0.md | 59 +++++ ...ullable_Connections_Dont_Throw_2_NET7_0.md | 59 +++++ ..._Nullable_Connections_Dont_Throw_NET6_0.md | 57 +++++ ..._Nullable_Connections_Dont_Throw_NET7_0.md | 57 +++++ 18 files changed, 838 insertions(+), 110 deletions(-) create mode 100644 src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/TestContext/FooBarContext.cs create mode 100644 src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw.md create mode 100644 src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2.md create mode 100644 src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2_NET6_0.md create mode 100644 src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2_NET7_0.md create mode 100644 src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_NET6_0.md create mode 100644 src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_NET7_0.md diff --git a/src/GreenDonut/src/Core/GreenDonut.csproj b/src/GreenDonut/src/Core/GreenDonut.csproj index e3b8389ee65..a638c155bbf 100644 --- a/src/GreenDonut/src/Core/GreenDonut.csproj +++ b/src/GreenDonut/src/Core/GreenDonut.csproj @@ -8,6 +8,7 @@ + diff --git a/src/HotChocolate/Core/src/Execution/Extensions/HotChocolateExecutionSelectionExtensions.cs b/src/HotChocolate/Core/src/Execution/Extensions/HotChocolateExecutionSelectionExtensions.cs index 6e2d5d817ee..fb61b730ef6 100644 --- a/src/HotChocolate/Core/src/Execution/Extensions/HotChocolateExecutionSelectionExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/Extensions/HotChocolateExecutionSelectionExtensions.cs @@ -1,9 +1,15 @@ #if NET6_0_OR_GREATER +using System.Buffers; using System.Buffers.Text; +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Text; using System.Runtime.CompilerServices; +using GreenDonut.Projections; using HotChocolate.Execution.Projections; +using HotChocolate.Types; +using HotChocolate.Types.Descriptors.Definitions; +using HotChocolate.Utilities; // ReSharper disable once CheckNamespace namespace HotChocolate.Execution.Processing; @@ -27,17 +33,136 @@ public static class HotChocolateExecutionSelectionExtensions /// /// Returns a selector expression that can be used for data projections. /// +#if NET8_0_OR_GREATER + [Experimental(Experiments.Projections)] +#endif public static Expression> AsSelector( this ISelection selection) - => GetOrCreateExpression(selection); + { + // we first check if we already have an expression for this selection, + // this would be the cheapest way to get the expression. + if(TryGetExpression(selection, out var expression)) + { + return expression; + } + + // if we do not have an expression we need to create one. + // we first check what kind of field selection we have, + // connection, collection or single field. + var flags = ((ObjectField)selection.Field).Flags; + + if ((flags & FieldFlags.Connection) == FieldFlags.Connection) + { + var builder = new DefaultSelectorBuilder(); + var buffer = ArrayPool.Shared.Rent(16); + var count = GetConnectionSelections(selection, buffer); + for (var i = 0; i < count; i++) + { + builder.Add(GetOrCreateExpression(buffer[i])); + } + ArrayPool.Shared.Return(buffer); + return GetOrCreateExpression(selection, builder); + } + + if ((flags & FieldFlags.CollectionSegment) == FieldFlags.CollectionSegment) + { + var builder = new DefaultSelectorBuilder(); + var buffer = ArrayPool.Shared.Rent(16); + var count = GetCollectionSelections(selection, buffer); + for (var i = 0; i < count; i++) + { + builder.Add(GetOrCreateExpression(buffer[i])); + } + ArrayPool.Shared.Return(buffer); + return GetOrCreateExpression(selection, builder); + } + + return GetOrCreateExpression(selection); + } private static Expression> GetOrCreateExpression( ISelection selection) - { - return selection.DeclaringOperation.GetOrAddState( + => selection.DeclaringOperation.GetOrAddState( CreateExpressionKey(selection.Id), static (_, ctx) => ctx._builder.BuildExpression(ctx.selection), (_builder, selection)); + +#if NET8_0_OR_GREATER + [Experimental(Experiments.Projections)] +#endif + private static Expression> GetOrCreateExpression( + ISelection selection, + ISelectorBuilder builder) + => selection.DeclaringOperation.GetOrAddState( + CreateExpressionKey(selection.Id), + static (_, ctx) => ctx.builder.TryCompile()!, + (builder, selection)); + + private static bool TryGetExpression( + ISelection selection, + [NotNullWhen(true)] out Expression>? expression) + => selection.DeclaringOperation.TryGetState(CreateExpressionKey(selection.Id), out expression); + + private static int GetConnectionSelections(ISelection selection, Span buffer) + { + var pageType = (ObjectType)selection.Field.Type.NamedType(); + var connectionSelections = selection.DeclaringOperation.GetSelectionSet(selection, pageType); + var count = 0; + + foreach (var connectionChild in connectionSelections.Selections) + { + if (connectionChild.Field.Name.EqualsOrdinal("nodes")) + { + if (buffer.Length == count) + { + throw new InvalidOperationException("To many alias selections of nodes and edges."); + } + + buffer[count++] = connectionChild; + } + else if (connectionChild.Field.Name.EqualsOrdinal("edges")) + { + var edgeType = (ObjectType)connectionChild.Field.Type.NamedType(); + var edgeSelections = connectionChild.DeclaringOperation.GetSelectionSet(connectionChild, edgeType); + + foreach (var edgeChild in edgeSelections.Selections) + { + if (edgeChild.Field.Name.EqualsOrdinal("node")) + { + if (buffer.Length == count) + { + throw new InvalidOperationException("To many alias selections of nodes and edges."); + } + + buffer[count++] = edgeChild; + } + } + } + } + + return count; + } + + private static int GetCollectionSelections(ISelection selection, Span buffer) + { + var pageType = (ObjectType)selection.Field.Type.NamedType(); + var connectionSelections = selection.DeclaringOperation.GetSelectionSet(selection, pageType); + var count = 0; + + foreach (var connectionChild in connectionSelections.Selections) + { + if (connectionChild.Field.Name.EqualsOrdinal("items")) + { + if (buffer.Length == count) + { + throw new InvalidOperationException("To many alias selections of items."); + } + + buffer[count++] = connectionChild; + } + } + + return count; } private static string CreateExpressionKey(int key) @@ -63,7 +188,7 @@ private static int EstimateIntLength(int value) } // if the number is negative we need one more digit for the sign - var length = (value < 0) ? 1 : 0; + var length = value < 0 ? 1 : 0; // we add the number of digits the number has to the length of the number. length += (int)Math.Floor(Math.Log10(Math.Abs(value)) + 1); diff --git a/src/HotChocolate/Core/src/Execution/Processing/Operation.cs b/src/HotChocolate/Core/src/Execution/Processing/Operation.cs index 87ea5873070..6aa317beb2b 100644 --- a/src/HotChocolate/Core/src/Execution/Processing/Operation.cs +++ b/src/HotChocolate/Core/src/Execution/Processing/Operation.cs @@ -117,6 +117,25 @@ public long CreateIncludeFlags(IVariableValueCollection variables) return context; } + public bool TryGetState(out TState? state) + { + var key = typeof(TState).FullName ?? throw new InvalidOperationException(); + return TryGetState(key, out state); + } + + public bool TryGetState(string key, out TState? state) + { + if(_contextData.TryGetValue(key, out var value) + && value is TState casted) + { + state = casted; + return true; + } + + state = default; + return false; + } + public TState GetOrAddState(Func createState) => GetOrAddState(_ => createState(), null); diff --git a/src/HotChocolate/Core/src/Execution/Projections/HotChocolateExecutionDataLoaderExtensions.cs b/src/HotChocolate/Core/src/Execution/Projections/HotChocolateExecutionDataLoaderExtensions.cs index 7506a06ea29..9bac2da93ff 100644 --- a/src/HotChocolate/Core/src/Execution/Projections/HotChocolateExecutionDataLoaderExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/Projections/HotChocolateExecutionDataLoaderExtensions.cs @@ -3,12 +3,8 @@ using System.Buffers; using System.Diagnostics.CodeAnalysis; -using HotChocolate.Execution; using HotChocolate.Execution.Processing; using HotChocolate.Pagination; -using HotChocolate.Types; -using HotChocolate.Types.Descriptors.Definitions; -using HotChocolate.Utilities; // ReSharper disable once CheckNamespace namespace GreenDonut.Projections; @@ -44,6 +40,16 @@ public static ISelectionDataLoader Select( ISelection selection) where TKey : notnull { + if (dataLoader == null) + { + throw new ArgumentNullException(nameof(dataLoader)); + } + + if (selection == null) + { + throw new ArgumentNullException(nameof(selection)); + } + var expression = selection.AsSelector(); return dataLoader.Select(expression); } @@ -71,99 +77,19 @@ public static IPagingDataLoader> Select( ISelection selection) where TKey : notnull { - var flags = ((ObjectField)selection.Field).Flags; - - if ((flags & FieldFlags.Connection) == FieldFlags.Connection) + if (dataLoader == null) { - var buffer = ArrayPool.Shared.Rent(16); - var count = GetConnectionSelections(selection, buffer); - for (var i = 0; i < count; i++) - { - var expression = buffer[i].AsSelector(); - dataLoader.Select(expression); - } - ArrayPool.Shared.Return(buffer); - } - else if ((flags & FieldFlags.CollectionSegment) == FieldFlags.CollectionSegment) - { - var buffer = ArrayPool.Shared.Rent(16); - var count = GetCollectionSelections(selection, buffer); - for (var i = 0; i < count; i++) - { - var expression = buffer[i].AsSelector(); - dataLoader.Select(expression); - } - ArrayPool.Shared.Return(buffer); - } - else - { - var expression = selection.AsSelector(); - dataLoader.Select(expression); + throw new ArgumentNullException(nameof(dataLoader)); } - return dataLoader; - } - - private static int GetConnectionSelections(ISelection selection, Span buffer) - { - var pageType = (ObjectType)selection.Field.Type.NamedType(); - var connectionSelections = selection.DeclaringOperation.GetSelectionSet(selection, pageType); - var count = 0; - - foreach (var connectionChild in connectionSelections.Selections) - { - if (connectionChild.Field.Name.EqualsOrdinal("nodes")) - { - if (buffer.Length == count) - { - throw new InvalidOperationException("To many alias selections of nodes and edges."); - } - - buffer[count++] = connectionChild; - } - else if (connectionChild.Field.Name.EqualsOrdinal("edges")) - { - var edgeType = (ObjectType)selection.Field.Type.NamedType(); - var edgeSelections = selection.DeclaringOperation.GetSelectionSet(connectionChild, edgeType); - - foreach (var edgeChild in edgeSelections.Selections) - { - if (edgeChild.Field.Name.EqualsOrdinal("node")) - { - if (buffer.Length == count) - { - throw new InvalidOperationException("To many alias selections of nodes and edges."); - } - - buffer[count++] = edgeChild; - } - } - } - } - - return count; - } - - private static int GetCollectionSelections(ISelection selection, Span buffer) - { - var pageType = (ObjectType)selection.Field.Type.NamedType(); - var connectionSelections = selection.DeclaringOperation.GetSelectionSet(selection, pageType); - var count = 0; - - foreach (var connectionChild in connectionSelections.Selections) + if (selection == null) { - if (connectionChild.Field.Name.EqualsOrdinal("items")) - { - if (buffer.Length == count) - { - throw new InvalidOperationException("To many alias selections of items."); - } - - buffer[count++] = connectionChild; - } + throw new ArgumentNullException(nameof(selection)); } - return count; + var expression = selection.AsSelector(); + dataLoader.Select(expression); + return dataLoader; } } #endif diff --git a/src/HotChocolate/Core/src/Execution/Projections/SelectionExpressionBuilder.cs b/src/HotChocolate/Core/src/Execution/Projections/SelectionExpressionBuilder.cs index 06b246a918b..c7fe259fed3 100644 --- a/src/HotChocolate/Core/src/Execution/Projections/SelectionExpressionBuilder.cs +++ b/src/HotChocolate/Core/src/Execution/Projections/SelectionExpressionBuilder.cs @@ -2,6 +2,7 @@ using System.Collections.Immutable; using System.Linq.Expressions; using System.Reflection; +using System.Runtime.CompilerServices; using HotChocolate.Execution.Processing; using HotChocolate.Features; using HotChocolate.Types; @@ -159,7 +160,19 @@ private void CollectSelections( if (node.Nodes.Count == 0) { - return Expression.Bind(node.Property, propertyAccessor); + if (IsNullableType(node.Property)) + { + var nullCheck = Expression.Condition( + Expression.Equal(propertyAccessor, Expression.Constant(null)), + Expression.Constant(null, node.Property.PropertyType), + propertyAccessor); + + return Expression.Bind(node.Property, nullCheck); + } + else + { + return Expression.Bind(node.Property, propertyAccessor); + } } if(node.IsArrayOrCollection) @@ -168,9 +181,43 @@ private void CollectSelections( } var newContext = context with { Parent = propertyAccessor, ParentType = node.Property.PropertyType }; - var requirementsExpression = BuildExpression(node.Nodes, newContext); - return requirementsExpression is null ? null : Expression.Bind(node.Property, requirementsExpression); + var nestedExpression = BuildExpression(node.Nodes, newContext); + + if (IsNullableType(node.Property)) + { + var nullCheck = Expression.Condition( + Expression.Equal(propertyAccessor, Expression.Constant(null)), + Expression.Constant(null, node.Property.PropertyType), + nestedExpression ?? (Expression)Expression.Constant(null, node.Property.PropertyType)); + + return Expression.Bind(node.Property, nullCheck); + } + + return nestedExpression is null ? null : Expression.Bind(node.Property, nestedExpression); + } + + #if NET8_0_OR_GREATER + private static bool IsNullableType(PropertyInfo propertyInfo) + { + if (propertyInfo.PropertyType.IsValueType) + { + return Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null; + } + + var nullableAttribute = propertyInfo.GetCustomAttribute(); + + if (nullableAttribute != null) + { + return nullableAttribute.NullableFlags[0] == 2; + } + + return false; } + #else + private static bool IsNullableType(PropertyInfo propertyInfo) + => !propertyInfo.PropertyType.IsValueType + || Nullable.GetUnderlyingType(propertyInfo.PropertyType) != null; + #endif private MemberInitExpression? BuildExpression( IReadOnlyList properties, diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/IOperation.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/IOperation.cs index 2e9d3f2c34c..0d4dfd76ddd 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/IOperation.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/IOperation.cs @@ -108,6 +108,10 @@ public interface IOperation : IHasReadOnlyContextData, IEnumerable long CreateIncludeFlags(IVariableValueCollection variables); + bool TryGetState(out TState? state); + + bool TryGetState(string key, out TState? state); + /// /// Gets or adds state to this operation. /// @@ -118,7 +122,7 @@ public interface IOperation : IHasReadOnlyContextData, IEnumerable /// - /// Returns the state. + /// Returns the state. /// TState GetOrAddState( Func createState); diff --git a/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Branches_Are_Merged_NET7_0.md b/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Branches_Are_Merged_NET7_0.md index 185b19dd69f..3499d4a5fe9 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Branches_Are_Merged_NET7_0.md +++ b/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Branches_Are_Merged_NET7_0.md @@ -3,7 +3,7 @@ ## SQL ```text -SELECT p."Name", b."Name", p."Id" +SELECT p."Name", FALSE, b."Name", p."Id" FROM "Products" AS p INNER JOIN "Brands" AS b ON p."BrandId" = b."Id" WHERE p."Id" IN (1, 2) diff --git a/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Brand_Details_Country_Name_NET7_0.md b/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Brand_Details_Country_Name_NET7_0.md index a5a7f99d878..0c5cbe5f31d 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Brand_Details_Country_Name_NET7_0.md +++ b/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Brand_Details_Country_Name_NET7_0.md @@ -3,7 +3,7 @@ ## SQL ```text -SELECT b."Name", b."Details_Country_Name" AS "Name", b."Id" +SELECT b."Name", FALSE, b."Details_Country_Name", b."Id" FROM "Brands" AS b WHERE b."Id" = 1 ``` diff --git a/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Force_A_Branch_NET7_0.md b/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Force_A_Branch_NET7_0.md index 293c0b0bf66..0e861a7e423 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Force_A_Branch_NET7_0.md +++ b/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Force_A_Branch_NET7_0.md @@ -3,11 +3,11 @@ ## SQL ```text -SELECT p."Name", b."Name", p."Id" +SELECT p."Name", FALSE, b."Name", p."Id" FROM "Products" AS p INNER JOIN "Brands" AS b ON p."BrandId" = b."Id" WHERE p."Id" = 1 -SELECT p."Id", b."Id" +SELECT p."Id", FALSE, b."Id" FROM "Products" AS p INNER JOIN "Brands" AS b ON p."BrandId" = b."Id" WHERE p."Id" = 1 diff --git a/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Product_With_Name_And_Brand_With_Name_NET7_0.md b/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Product_With_Name_And_Brand_With_Name_NET7_0.md index b4823d43c47..38e82a2302c 100644 --- a/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Product_With_Name_And_Brand_With_Name_NET7_0.md +++ b/src/HotChocolate/Core/test/Execution.Tests/Projections/__snapshots__/ProjectableDataLoaderTests.Product_With_Name_And_Brand_With_Name_NET7_0.md @@ -3,7 +3,7 @@ ## SQL ```text -SELECT p."Name", b."Name", p."Id" +SELECT p."Name", FALSE, b."Name", p."Id" FROM "Products" AS p INNER JOIN "Brands" AS b ON p."BrandId" = b."Id" WHERE p."Id" = 1 diff --git a/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/PagingHelperIntegrationTests.cs b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/PagingHelperIntegrationTests.cs index 74a50af3bed..5712d7ace85 100644 --- a/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/PagingHelperIntegrationTests.cs +++ b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/PagingHelperIntegrationTests.cs @@ -673,6 +673,7 @@ public async Task BatchPaging_First_5() }, name: page.Key.ToString()); } + snapshot.MatchMarkdownSnapshot(); } @@ -706,6 +707,7 @@ public async Task BatchPaging_Last_5() }, name: page.Key.ToString()); } + snapshot.MatchMarkdownSnapshot(); } @@ -787,6 +789,140 @@ await Snapshot.Create() .MatchMarkdownAsync(); } + [Fact] + public async Task Ensure_Nullable_Connections_Dont_Throw() + { + // Arrange + var connectionString = CreateConnectionString(); + await SeedFooAsync(connectionString); + + // Act + var result = await new ServiceCollection() + .AddScoped(_ => new FooBarContext(connectionString)) + .AddGraphQL() + .AddQueryType() + .AddPagingArguments() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .ExecuteRequestAsync( + """ + { + foos(first: 10) { + edges { + cursor + } + nodes { + id + name + bar { + id + description + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + """); + + // Assert + string? sql = null; + string? expression = null; + var operationResult = result.ExpectOperationResult(); + if (operationResult.Extensions?.TryGetValue("sql", out var value) ?? false) + { + sql = value!.ToString(); + } + + if (operationResult.Extensions?.TryGetValue("expression", out value) ?? false) + { + expression = value!.ToString(); + } + +#if NET8_0_OR_GREATER + await Snapshot.Create() +#elif NET7_0 + await Snapshot.Create(postFix: "NET7_0") +#elif NET6_0 + await Snapshot.Create(postFix: "NET6_0") +#endif + .Add(sql, "SQL") + .Add(expression, "Expression") + .Add(operationResult.WithExtensions(ImmutableDictionary.Empty), "Result") + .MatchMarkdownAsync(); + } + + [Fact] + public async Task Ensure_Nullable_Connections_Dont_Throw_2() + { + // Arrange + var connectionString = CreateConnectionString(); + await SeedFooAsync(connectionString); + + // Act + var result = await new ServiceCollection() + .AddScoped(_ => new FooBarContext(connectionString)) + .AddGraphQL() + .AddQueryType() + .AddPagingArguments() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .ExecuteRequestAsync( + """ + { + foos(first: 10) { + edges { + cursor + } + nodes { + id + name + bar { + id + description + someField1 + someField2 + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } + """); + + // Assert + string? sql = null; + string? expression = null; + var operationResult = result.ExpectOperationResult(); + if (operationResult.Extensions?.TryGetValue("sql", out var value) ?? false) + { + sql = value!.ToString(); + } + + if (operationResult.Extensions?.TryGetValue("expression", out value) ?? false) + { + expression = value!.ToString(); + } + +#if NET8_0_OR_GREATER + await Snapshot.Create() +#elif NET7_0 + await Snapshot.Create(postFix: "NET7_0") +#elif NET6_0 + await Snapshot.Create(postFix: "NET6_0") +#endif + .Add(sql, "SQL") + .Add(expression, "Expression") + .Add(operationResult.WithExtensions(ImmutableDictionary.Empty), "Result") + .MatchMarkdownAsync(); + } + private static async Task SeedAsync(string connectionString) { await using var context = new CatalogContext(connectionString); @@ -807,12 +943,7 @@ private static async Task SeedAsync(string connectionString) for (var j = 0; j < 100; j++) { - var product = new Product - { - Name = $"Product {i}-{j}", - Type = type, - Brand = brand, - }; + var product = new Product { Name = $"Product {i}-{j}", Type = type, Brand = brand, }; context.Products.Add(product); } } @@ -820,6 +951,48 @@ private static async Task SeedAsync(string connectionString) await context.SaveChangesAsync(); } + private static async Task SeedFooAsync(string connectionString) + { + await using var context = new FooBarContext(connectionString); + await context.Database.EnsureCreatedAsync(); + + context.Bars.Add( + new Bar + { + Id = 1, + Description = "Bar 1", + SomeField1 = "abc", + SomeField2 = null + }); + + context.Bars.Add( + new Bar + { + Id = 2, + Description = "Bar 2", + SomeField1 = "def", + SomeField2 = "ghi" + }); + + context.Foos.Add( + new Foo + { + Id = 1, + Name = "Foo 1", + BarId = null + }); + + context.Foos.Add( + new Foo + { + Id = 2, + Name = "Foo 2", + BarId = 1 + }); + + await context.SaveChangesAsync(); + } + public class Query { [UsePaging] @@ -910,6 +1083,40 @@ public async Task> GetBrandsAsync( .ToConnectionAsync((brand, cursor) => new BrandEdge2(brand, cursor)); } + public class QueryNullable + { + [UsePaging] + public async Task> GetFoosAsync( + FooBarContext context, + PagingArguments arguments, + ISelection selection, + IResolverContext rc, + CancellationToken ct) + { + var sql = context.Foos + .OrderBy(t => t.Name) + .ThenBy(t => t.Id) + .Select(selection.AsSelector()) + .ToQueryString(); + + var expression = context.Foos + .OrderBy(t => t.Name) + .ThenBy(t => t.Id) + .Select(selection.AsSelector()) + .Expression.ToString(); + + ((IMiddlewareContext)rc).OperationResult.SetExtension("sql", sql); + ((IMiddlewareContext)rc).OperationResult.SetExtension("expression", expression); + + return await context.Foos + .OrderBy(t => t.Name) + .ThenBy(t => t.Id) + .Select(selection.AsSelector()) + .ToPageAsync(arguments, cancellationToken: ct) + .ToConnectionAsync(); + } + } + [ExtendObjectType("BrandConnectionEdge")] public class BrandConnectionEdgeExtensions { diff --git a/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/TestContext/FooBarContext.cs b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/TestContext/FooBarContext.cs new file mode 100644 index 00000000000..381a2316b80 --- /dev/null +++ b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/TestContext/FooBarContext.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; + +namespace HotChocolate.Data.TestContext; + +public class FooBarContext(string connectionString) : DbContext +{ + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseNpgsql(connectionString); + } + + public DbSet Foos { get; set; } = default!; + + public DbSet Bars { get; set; } = default!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasOne(e => e.Bar) + .WithMany() + .HasForeignKey("BarId"); + }); + + modelBuilder.Entity(e => e.HasKey(e => e.Id)); + } +} + +public class Foo +{ + public int Id { get; set; } + + [MaxLength(100)] public string Name { get; set; } = default!; + + public int? BarId { get; set; } + + public Bar? Bar { get; set; } +} + +public class Bar +{ + public int Id { get; set; } + + [MaxLength(100)] public string? Description { get; set; } + + [MaxLength(100)] public string SomeField1 { get; set; } = default!; + + [MaxLength(100)] public string? SomeField2 { get; set; } +} diff --git a/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw.md b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw.md new file mode 100644 index 00000000000..47144e676f5 --- /dev/null +++ b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw.md @@ -0,0 +1,57 @@ +# Ensure_Nullable_Connections_Dont_Throw + +## SQL + +```text +SELECT f."Id", f."Name", b."Id" IS NULL, b."Id", b."Description" +FROM "Foos" AS f +LEFT JOIN "Bars" AS b ON f."BarId" = b."Id" +ORDER BY f."Name", f."Id" +``` + +## Expression + +```text +[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderBy(t => t.Name).ThenBy(t => t.Id).Select(root => new Foo() {Id = root.Id, Name = root.Name, Bar = IIF((root.Bar == null), null, new Bar() {Id = root.Bar.Id, Description = root.Bar.Description})}) +``` + +## Result + +```json +{ + "data": { + "foos": { + "edges": [ + { + "cursor": "Rm9vIDE6MQ==" + }, + { + "cursor": "Rm9vIDI6Mg==" + } + ], + "nodes": [ + { + "id": 1, + "name": "Foo 1", + "bar": null + }, + { + "id": 2, + "name": "Foo 2", + "bar": { + "id": 1, + "description": "Bar 1" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "Rm9vIDE6MQ==", + "endCursor": "Rm9vIDI6Mg==" + } + } + } +} +``` + diff --git a/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2.md b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2.md new file mode 100644 index 00000000000..9d5412fef69 --- /dev/null +++ b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2.md @@ -0,0 +1,59 @@ +# Ensure_Nullable_Connections_Dont_Throw_2 + +## SQL + +```text +SELECT f."Id", f."Name", b."Id" IS NULL, b."Id", b."Description", b."SomeField1", b."SomeField2" +FROM "Foos" AS f +LEFT JOIN "Bars" AS b ON f."BarId" = b."Id" +ORDER BY f."Name", f."Id" +``` + +## Expression + +```text +[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderBy(t => t.Name).ThenBy(t => t.Id).Select(root => new Foo() {Id = root.Id, Name = root.Name, Bar = IIF((root.Bar == null), null, new Bar() {Id = root.Bar.Id, Description = root.Bar.Description, SomeField1 = root.Bar.SomeField1, SomeField2 = root.Bar.SomeField2})}) +``` + +## Result + +```json +{ + "data": { + "foos": { + "edges": [ + { + "cursor": "Rm9vIDE6MQ==" + }, + { + "cursor": "Rm9vIDI6Mg==" + } + ], + "nodes": [ + { + "id": 1, + "name": "Foo 1", + "bar": null + }, + { + "id": 2, + "name": "Foo 2", + "bar": { + "id": 1, + "description": "Bar 1", + "someField1": "abc", + "someField2": null + } + } + ], + "pageInfo": { + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "Rm9vIDE6MQ==", + "endCursor": "Rm9vIDI6Mg==" + } + } + } +} +``` + diff --git a/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2_NET6_0.md b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2_NET6_0.md new file mode 100644 index 00000000000..60c823bf34f --- /dev/null +++ b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2_NET6_0.md @@ -0,0 +1,59 @@ +# Ensure_Nullable_Connections_Dont_Throw_2 + +## SQL + +```text +SELECT f."Id", f."Name", b."Id" IS NULL, b."Id", b."Description", b."SomeField1", b."SomeField2" +FROM "Foos" AS f +LEFT JOIN "Bars" AS b ON f."BarId" = b."Id" +ORDER BY f."Name", f."Id" +``` + +## Expression + +```text +[Microsoft.EntityFrameworkCore.Query.QueryRootExpression].OrderBy(t => t.Name).ThenBy(t => t.Id).Select(root => new Foo() {Id = root.Id, Name = IIF((root.Name == null), null, root.Name), Bar = IIF((root.Bar == null), null, new Bar() {Id = root.Bar.Id, Description = IIF((root.Bar.Description == null), null, root.Bar.Description), SomeField1 = IIF((root.Bar.SomeField1 == null), null, root.Bar.SomeField1), SomeField2 = IIF((root.Bar.SomeField2 == null), null, root.Bar.SomeField2)})}) +``` + +## Result + +```json +{ + "data": { + "foos": { + "edges": [ + { + "cursor": "Rm9vIDE6MQ==" + }, + { + "cursor": "Rm9vIDI6Mg==" + } + ], + "nodes": [ + { + "id": 1, + "name": "Foo 1", + "bar": null + }, + { + "id": 2, + "name": "Foo 2", + "bar": { + "id": 1, + "description": "Bar 1", + "someField1": "abc", + "someField2": null + } + } + ], + "pageInfo": { + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "Rm9vIDE6MQ==", + "endCursor": "Rm9vIDI6Mg==" + } + } + } +} +``` + diff --git a/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2_NET7_0.md b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2_NET7_0.md new file mode 100644 index 00000000000..25de998bc69 --- /dev/null +++ b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_2_NET7_0.md @@ -0,0 +1,59 @@ +# Ensure_Nullable_Connections_Dont_Throw_2 + +## SQL + +```text +SELECT f."Id", f."Name", b."Id" IS NULL, b."Id", b."Description", b."SomeField1", b."SomeField2" +FROM "Foos" AS f +LEFT JOIN "Bars" AS b ON f."BarId" = b."Id" +ORDER BY f."Name", f."Id" +``` + +## Expression + +```text +[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderBy(t => t.Name).ThenBy(t => t.Id).Select(root => new Foo() {Id = root.Id, Name = IIF((root.Name == null), null, root.Name), Bar = IIF((root.Bar == null), null, new Bar() {Id = root.Bar.Id, Description = IIF((root.Bar.Description == null), null, root.Bar.Description), SomeField1 = IIF((root.Bar.SomeField1 == null), null, root.Bar.SomeField1), SomeField2 = IIF((root.Bar.SomeField2 == null), null, root.Bar.SomeField2)})}) +``` + +## Result + +```json +{ + "data": { + "foos": { + "edges": [ + { + "cursor": "Rm9vIDE6MQ==" + }, + { + "cursor": "Rm9vIDI6Mg==" + } + ], + "nodes": [ + { + "id": 1, + "name": "Foo 1", + "bar": null + }, + { + "id": 2, + "name": "Foo 2", + "bar": { + "id": 1, + "description": "Bar 1", + "someField1": "abc", + "someField2": null + } + } + ], + "pageInfo": { + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "Rm9vIDE6MQ==", + "endCursor": "Rm9vIDI6Mg==" + } + } + } +} +``` + diff --git a/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_NET6_0.md b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_NET6_0.md new file mode 100644 index 00000000000..d51b3de6448 --- /dev/null +++ b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_NET6_0.md @@ -0,0 +1,57 @@ +# Ensure_Nullable_Connections_Dont_Throw + +## SQL + +```text +SELECT f."Id", f."Name", b."Id" IS NULL, b."Id", b."Description" +FROM "Foos" AS f +LEFT JOIN "Bars" AS b ON f."BarId" = b."Id" +ORDER BY f."Name", f."Id" +``` + +## Expression + +```text +[Microsoft.EntityFrameworkCore.Query.QueryRootExpression].OrderBy(t => t.Name).ThenBy(t => t.Id).Select(root => new Foo() {Id = root.Id, Name = IIF((root.Name == null), null, root.Name), Bar = IIF((root.Bar == null), null, new Bar() {Id = root.Bar.Id, Description = IIF((root.Bar.Description == null), null, root.Bar.Description)})}) +``` + +## Result + +```json +{ + "data": { + "foos": { + "edges": [ + { + "cursor": "Rm9vIDE6MQ==" + }, + { + "cursor": "Rm9vIDI6Mg==" + } + ], + "nodes": [ + { + "id": 1, + "name": "Foo 1", + "bar": null + }, + { + "id": 2, + "name": "Foo 2", + "bar": { + "id": 1, + "description": "Bar 1" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "Rm9vIDE6MQ==", + "endCursor": "Rm9vIDI6Mg==" + } + } + } +} +``` + diff --git a/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_NET7_0.md b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_NET7_0.md new file mode 100644 index 00000000000..f67d40e5ee1 --- /dev/null +++ b/src/HotChocolate/Pagination/test/Pagination.EntityFramework.Tests/__snapshots__/IntegrationPagingHelperTests.Ensure_Nullable_Connections_Dont_Throw_NET7_0.md @@ -0,0 +1,57 @@ +# Ensure_Nullable_Connections_Dont_Throw + +## SQL + +```text +SELECT f."Id", f."Name", b."Id" IS NULL, b."Id", b."Description" +FROM "Foos" AS f +LEFT JOIN "Bars" AS b ON f."BarId" = b."Id" +ORDER BY f."Name", f."Id" +``` + +## Expression + +```text +[Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression].OrderBy(t => t.Name).ThenBy(t => t.Id).Select(root => new Foo() {Id = root.Id, Name = IIF((root.Name == null), null, root.Name), Bar = IIF((root.Bar == null), null, new Bar() {Id = root.Bar.Id, Description = IIF((root.Bar.Description == null), null, root.Bar.Description)})}) +``` + +## Result + +```json +{ + "data": { + "foos": { + "edges": [ + { + "cursor": "Rm9vIDE6MQ==" + }, + { + "cursor": "Rm9vIDI6Mg==" + } + ], + "nodes": [ + { + "id": 1, + "name": "Foo 1", + "bar": null + }, + { + "id": 2, + "name": "Foo 2", + "bar": { + "id": 1, + "description": "Bar 1" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "Rm9vIDE6MQ==", + "endCursor": "Rm9vIDI6Mg==" + } + } + } +} +``` +