diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionFieldHandler.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionFieldHandler.cs index 97b4ba117bb..3b4f636c996 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionFieldHandler.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionFieldHandler.cs @@ -9,6 +9,8 @@ namespace HotChocolate.Data.Projections.Expressions.Handlers; public class QueryableProjectionFieldHandler : QueryableProjectionHandlerBase { + private static readonly NullabilityInfoContext s_nullability = new(); + public override bool CanHandle(Selection selection) => !selection.IsLeaf && CanProjectMember(selection); @@ -74,18 +76,15 @@ public override bool TryHandleLeave( throw ThrowHelper.ProjectionVisitor_InvalidState_NoParentScope(); } - Expression nestedProperty; - if (field.Member is PropertyInfo propertyInfo) - { - nestedProperty = Expression.Property(context.GetInstance(), propertyInfo); - } - else + if (field.Member is not PropertyInfo propertyInfo) { action = SelectionVisitor.Skip; return true; } + var nestedProperty = Expression.Property(context.GetInstance(), propertyInfo); + // If the nested scope has no projectable members we keep the original value. // This happens for members like JsonDocument where selected subfields are read-only. if (!queryableScope.HasAbstractTypes() && queryableScope.Level.Peek().Count == 0) @@ -101,7 +100,7 @@ public override bool TryHandleLeave( var memberInit = queryableScope.CreateMemberInit(); - if (context.InMemory) + if (context.InMemory && ShouldApplyNullGuard(propertyInfo)) { parentScope.Level .Peek() @@ -119,5 +118,29 @@ public override bool TryHandleLeave( return true; } + private static bool ShouldApplyNullGuard(PropertyInfo property) + { + if (property.PropertyType.IsValueType) + { + return false; + } + + // Preserve the existing null-guard behavior for entity-like references. For + // value-object/complex-like references (non-null and no identity member), the guard + // generates unsupported complex-type null comparisons on EF Core 8/9. + if (s_nullability.Create(property).ReadState is not NullabilityState.NotNull) + { + return true; + } + + return HasIdentityMember(property.PropertyType); + } + + private static bool HasIdentityMember(Type type) + => type.GetProperty( + "Id", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase) + is not null; + public static QueryableProjectionFieldHandler Create(ProjectionProviderContext context) => new(); } diff --git a/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/Issue6604ReproTests.cs b/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/Issue6604ReproTests.cs new file mode 100644 index 00000000000..62a5b9a0516 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/Issue6604ReproTests.cs @@ -0,0 +1,65 @@ +using HotChocolate.Execution; +using Microsoft.EntityFrameworkCore; + +namespace HotChocolate.Data.Projections; + +public class Issue6604ReproTests +{ + private readonly SchemaCache _cache = new(); + + [Fact] + public async Task Projection_On_NonNullable_Complex_Type_Should_Not_Fail() + { + var users = + new[] + { + new User + { + Id = 1, + Username = "user-1", + EmailAddress = "user-1@example.com", + AccountStatus = new UserAccountStatus + { + IsRegistrationCompleted = true + } + } + }; + + var executor = _cache.CreateSchema( + users, + onModelCreating: modelBuilder => + modelBuilder.Entity().ComplexProperty(x => x.AccountStatus)); + + var result = await executor.ExecuteAsync( + """ + { + root { + id + username + accountStatus { + isRegistrationCompleted + } + } + } + """); + + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors ?? []); + } + + public class User + { + public int Id { get; set; } + + public string Username { get; set; } = default!; + + public string EmailAddress { get; set; } = default!; + + public UserAccountStatus AccountStatus { get; set; } = default!; + } + + public class UserAccountStatus + { + public bool IsRegistrationCompleted { get; set; } + } +}