From 41beb9b7b0dee8812709c8063b23fb2ebbf6fa97 Mon Sep 17 00:00:00 2001 From: Glen Date: Wed, 10 Jun 2026 16:04:54 +0200 Subject: [PATCH 1/2] Fix UseProjection over-projecting when the entity is a record --- .../QueryableProjectionScopeExtensions.cs | 12 ++- .../IntegrationTests.cs | 75 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs index 0acca1b7a79..a1a750936dd 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs @@ -79,7 +79,17 @@ public static Expression CreateMemberInit(this QueryableProjectionScope scope) private static bool ShouldReuseExistingInstance(Type type) => type.GetConstructor(Type.EmptyTypes) is not null && type.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic) - .Any(t => t.GetParameters().Length > 0); + .Any(t => + t.GetParameters().Length > 0 + && !IsRecordCopyConstructor(t, type)); + + private static bool IsRecordCopyConstructor(ConstructorInfo constructor, Type declaringType) + { + var parameters = constructor.GetParameters(); + + return parameters.Length == 1 + && parameters[0].ParameterType == declaringType; + } public static Expression CreateMemberInitLambda(this QueryableProjectionScope scope) => Expression.Lambda(scope.CreateMemberInit(), scope.Parameter); diff --git a/src/HotChocolate/Data/test/Data.EntityFramework.Tests/IntegrationTests.cs b/src/HotChocolate/Data/test/Data.EntityFramework.Tests/IntegrationTests.cs index b196d0f8d23..3f489db9fc4 100644 --- a/src/HotChocolate/Data/test/Data.EntityFramework.Tests/IntegrationTests.cs +++ b/src/HotChocolate/Data/test/Data.EntityFramework.Tests/IntegrationTests.cs @@ -752,6 +752,58 @@ static Dictionary ReadCounts(JsonElement value) } } + [Fact] + public async Task UseProjection_Should_Project_Only_Selected_Columns_When_Entity_Is_Record() + { + // arrange + var fileName = Guid.NewGuid().ToString("N") + ".db"; + var connectionString = "Data Source=" + fileName; + var sql = new List(); + + try + { + await using (var seed = new RecordProjectionDbContext( + new DbContextOptionsBuilder() + .UseSqlite(connectionString) + .Options)) + { + await seed.Database.EnsureCreatedAsync(Xunit.TestContext.Current.CancellationToken); + seed.Products.Add( + new RecordProjectionProduct { Id = 1, Name = "Product", Description = "Description" }); + await seed.SaveChangesAsync(Xunit.TestContext.Current.CancellationToken); + } + + var executor = await new ServiceCollection() + .AddDbContext( + b => b + .UseSqlite(connectionString) + .AddInterceptors(new SqlCapturingInterceptor(sql))) + .AddGraphQL() + .AddProjections() + .AddQueryType() + .BuildRequestExecutorAsync(cancellationToken: Xunit.TestContext.Current.CancellationToken); + + // act + var result = await executor.ExecuteAsync( + "{ products { id } }", + Xunit.TestContext.Current.CancellationToken); + + // assert + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors); + string.Join("\n", sql).MatchInlineSnapshot( + """ + SELECT "p"."Id" + FROM "Products" AS "p" + """); + } + finally + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + File.Delete(fileName); + } + } + public class ConstructorInjectionQuery { [UseProjection] @@ -806,6 +858,29 @@ public class ConstructorInjectionPost public int BlogId { get; set; } } + public class RecordProjectionQuery + { + [UseProjection] + public IQueryable GetProducts(RecordProjectionDbContext context) + => context.Products; + } + + public class RecordProjectionDbContext( + DbContextOptions options) + : DbContext(options) + { + public DbSet Products => Set(); + } + + public record RecordProjectionProduct + { + public required int Id { get; init; } + + public required string Name { get; init; } + + public required string Description { get; init; } + } + public class SingleOrDefaultUser { public string Name { get; set; } = string.Empty; From b5713daf288004c33e30f704585a5eb195a50960 Mon Sep 17 00:00:00 2001 From: Glen Date: Wed, 10 Jun 2026 16:21:30 +0200 Subject: [PATCH 2/2] Update complex type projection SQL snapshot for record member-init --- ...QueryableProjectionComplexTypeTests.Create_Complex_Type.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/__snapshots__/QueryableProjectionComplexTypeTests.Create_Complex_Type.snap b/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/__snapshots__/QueryableProjectionComplexTypeTests.Create_Complex_Type.snap index 66cdf7062c8..b12e268d814 100644 --- a/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/__snapshots__/QueryableProjectionComplexTypeTests.Create_Complex_Type.snap +++ b/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/__snapshots__/QueryableProjectionComplexTypeTests.Create_Complex_Type.snap @@ -20,6 +20,6 @@ Result: SQL: --------------- -SELECT "d"."Bar_Baz" +SELECT "d"."Bar_Baz" AS "Baz" FROM "Data" AS "d" ---------------