From b3aa2aa192cb60b5fe139a7f9d90c8ae38c2c488 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Tue, 24 Feb 2026 22:54:48 +0000 Subject: [PATCH 1/4] Fix filter field type inference for array length expressions --- .../Filters/FilterInputTypeDescriptor`1.cs | 11 +++ .../Data.Filters.Tests/FilterInputTypeTest.cs | 41 ++++++++ .../ArrayLengthFilterTests.cs | 93 +++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ArrayLengthFilterTests.cs diff --git a/src/HotChocolate/Data/src/Data/Filters/FilterInputTypeDescriptor`1.cs b/src/HotChocolate/Data/src/Data/Filters/FilterInputTypeDescriptor`1.cs index 90d20284987..204609a6e1d 100644 --- a/src/HotChocolate/Data/src/Data/Filters/FilterInputTypeDescriptor`1.cs +++ b/src/HotChocolate/Data/src/Data/Filters/FilterInputTypeDescriptor`1.cs @@ -98,6 +98,17 @@ protected override void OnCompleteFields( /// public IFilterFieldDescriptor Field(Expression> propertyOrMember) { + if (propertyOrMember.Body is UnaryExpression { NodeType: ExpressionType.ArrayLength }) + { + var arrayLengthFieldDescriptor = + FilterFieldDescriptor.New( + Context, + Configuration.Scope, + propertyOrMember); + Fields.Add(arrayLengthFieldDescriptor); + return arrayLengthFieldDescriptor; + } + switch (propertyOrMember.TryExtractMember()) { case PropertyInfo m: diff --git a/src/HotChocolate/Data/test/Data.Filters.Tests/FilterInputTypeTest.cs b/src/HotChocolate/Data/test/Data.Filters.Tests/FilterInputTypeTest.cs index 106e7a1212f..7320f3e1139 100644 --- a/src/HotChocolate/Data/test/Data.Filters.Tests/FilterInputTypeTest.cs +++ b/src/HotChocolate/Data/test/Data.Filters.Tests/FilterInputTypeTest.cs @@ -377,6 +377,32 @@ public void FilterInputType_Should_InfereType_When_ItIsAInterface() schema.MatchSnapshot(); } + [Fact] + public void FilterInputType_Field_ArrayLengthExpression_Infers_IntOperationType() + { + // arrange + var schema = SchemaBuilder.New() + .AddFiltering() + .AddQueryType( + d => d + .Name("Query") + .Field("cardReaders") + .Resolve(new List()) + .UseFiltering()) + .Create(); + + // act + Assert.True( + schema.Types.TryGetType( + "CardReaderFilterInput", + out var filterType)); + + // assert + Assert.NotNull(filterType); + var lengthField = Assert.IsType(filterType.Fields["cardReaderUidLength"]); + Assert.IsType(lengthField.Type); + } + [Fact] public void FilterInputType_WithGlobalObjectIdentification_AppliesGlobalIdFormatter() { @@ -566,6 +592,11 @@ public class IgnoreTest public string Name { get; set; } = null!; } + public class CardReader + { + public byte[] CardReaderUid { get; set; } = []; + } + public class IgnoreTestFilterInputType : FilterInputType { @@ -583,6 +614,16 @@ protected override void Configure(IFilterInputTypeDescriptor descriptor) } } + public class CardReaderFilterInputType : FilterInputType + { + protected override void Configure(IFilterInputTypeDescriptor descriptor) + { + descriptor.BindFieldsExplicitly(); + descriptor.Name("CardReaderFilterInput"); + descriptor.Field(x => x.CardReaderUid.Length).Name("cardReaderUidLength"); + } + } + public class UserQueryType : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ArrayLengthFilterTests.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ArrayLengthFilterTests.cs new file mode 100644 index 00000000000..87dc91bc828 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ArrayLengthFilterTests.cs @@ -0,0 +1,93 @@ +using HotChocolate.Data.Filters; +using HotChocolate.Execution; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Squadron; + +namespace HotChocolate.Data; + +[Collection(PostgresCacheCollectionFixture.DefinitionName)] +public sealed class ArrayLengthFilterTests(PostgreSqlResource resource) +{ + [Fact] + public async Task Filter_ArrayLengthExpression_Should_Work_Against_PostgreSql() + { + // arrange + var db = "db_" + Guid.NewGuid().ToString("N"); + var connectionString = resource.GetConnectionString(db); + + await using var services = new ServiceCollection() + .AddDbContext(c => c.UseNpgsql(connectionString)) + .AddGraphQLServer() + .AddQueryType() + .AddType() + .AddFiltering() + .Services + .BuildServiceProvider(); + + await using var scope = services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(); + context.CardReaders.AddRange( + new CardReader { Id = 1, CardReaderUid = [1, 2, 3] }, + new CardReader { Id = 2, CardReaderUid = [7] }); + await context.SaveChangesAsync(); + + var executor = await services + .GetRequiredService() + .GetExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + cardReaders(where: { cardReaderUidLength: { eq: 3 } }) { + id + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "cardReaders": [ + { + "id": 1 + } + ] + } + } + """); + } + + public sealed class Query + { + [UseFiltering(typeof(CardReaderFilterInputType))] + public IQueryable GetCardReaders([Service] CardReaderContext context) + => context.CardReaders; + } + + public sealed class CardReader + { + public int Id { get; set; } + + public byte[] CardReaderUid { get; set; } = []; + } + + public sealed class CardReaderFilterInputType : FilterInputType + { + protected override void Configure(IFilterInputTypeDescriptor descriptor) + { + descriptor.BindFieldsExplicitly(); + descriptor.Field(x => x.CardReaderUid.Length).Name("cardReaderUidLength"); + } + } + + public sealed class CardReaderContext(DbContextOptions options) + : DbContext(options) + { + public DbSet CardReaders => Set(); + } +} From 3f8e19f3c19a8bb8c372aa5fe7c32a1f8b0903be Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Fri, 27 Feb 2026 13:09:50 +0000 Subject: [PATCH 2/4] more work --- .../Types/Descriptors/ObjectTypeDescriptor.cs | 11 ++ .../Descriptors/ObjectTypeDescriptorTests.cs | 24 +++ .../QueryableProjectionScalarHandler.cs | 144 ++++++++++++++++-- .../Data/Sorting/SortInputTypeDescriptor`1.cs | 11 ++ .../IntegrationTests.cs | 62 ++++++++ .../Data.Sorting.Tests/SortInputTypeTests.cs | 35 +++++ 6 files changed, 276 insertions(+), 11 deletions(-) diff --git a/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectTypeDescriptor.cs b/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectTypeDescriptor.cs index 3e71306798e..89d70140095 100644 --- a/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectTypeDescriptor.cs +++ b/src/HotChocolate/Core/src/Types/Types/Descriptors/ObjectTypeDescriptor.cs @@ -322,6 +322,17 @@ public IObjectFieldDescriptor Field( { ArgumentNullException.ThrowIfNull(propertyOrMethod); + if (propertyOrMethod.Body is UnaryExpression { NodeType: ExpressionType.ArrayLength }) + { + var fieldDescriptor = ObjectFieldDescriptor.New( + Context, + propertyOrMethod, + Configuration.RuntimeType, + typeof(TResolver)); + _fields.Add(fieldDescriptor); + return fieldDescriptor; + } + var member = propertyOrMethod.TryExtractMember(); if (member is PropertyInfo or MethodInfo) diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/ObjectTypeDescriptorTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/ObjectTypeDescriptorTests.cs index 4dec3f96dc0..726962151e9 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/ObjectTypeDescriptorTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Descriptors/ObjectTypeDescriptorTests.cs @@ -180,6 +180,25 @@ public async Task UseMiddleware() result.ToJson().MatchSnapshot(); } + [Fact] + public void Field_ArrayLengthExpression_Uses_ExpressionConfiguration() + { + // arrange + var descriptor = new ObjectTypeDescriptor(Context); + + // act + IObjectTypeDescriptor desc = descriptor; + desc.BindFieldsExplicitly(); + desc.Field(t => t.Buffer.Length).Name("bufferLength"); + + var field = descriptor.CreateConfiguration().Fields.Single(t => t.Name == "bufferLength"); + + // assert + Assert.Null(field.Member); + Assert.NotNull(field.Expression); + Assert.Equal(typeof(int), field.ResultType); + } + public class Foo : FooBase { public required string A { get; set; } @@ -199,6 +218,11 @@ public class FooBase public virtual required string B { get; set; } } + public class ArrayHolder + { + public byte[] Buffer { get; set; } = []; + } + public class BarType : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionScalarHandler.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionScalarHandler.cs index e16fef75060..11e5bde32e6 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionScalarHandler.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/Handlers/QueryableProjectionScalarHandler.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Linq.Expressions; using System.Reflection; using HotChocolate.Execution.Processing; @@ -9,14 +10,17 @@ public class QueryableProjectionScalarHandler : QueryableProjectionHandlerBase { public override bool CanHandle(Selection selection) - => selection.Field.Member is not null && selection.IsLeaf; + => selection.IsLeaf + && (selection.Field.Member is not null + || selection.Field.ResolverExpression is LambdaExpression); public override bool TryHandleEnter( QueryableProjectionContext context, Selection selection, [NotNullWhen(true)] out ISelectionVisitorAction? action) { - if (selection.Field.Member is PropertyInfo { CanWrite: true }) + if (selection.Field.Member is PropertyInfo { CanWrite: true } + || selection.Field.ResolverExpression is LambdaExpression) { action = SelectionVisitor.SkipAndLeave; return true; @@ -34,24 +38,142 @@ public override bool TryHandleLeave( var field = selection.Field; if (context.Scopes.Count > 0 - && context.Scopes.Peek() is QueryableProjectionScope closure - && field.Member is PropertyInfo member) + && context.Scopes.Peek() is QueryableProjectionScope closure) { var instance = closure.Instance.Peek(); - closure.Level.Peek() - .Enqueue( - Expression.Bind( - member, - Expression.Property(instance, member))); + if (field.Member is PropertyInfo member) + { + EnqueueBinding( + closure, + member, + Expression.Property(instance, member)); - action = SelectionVisitor.Continue; - return true; + action = SelectionVisitor.Continue; + return true; + } + + if (field.Member is null + && field.ResolverExpression is LambdaExpression expression + && expression.Parameters.Count == 1 + && expression.Parameters[0].Type.IsAssignableFrom(instance.Type)) + { + var properties = TopLevelPropertyExtractor.Extract(expression); + + foreach (var property in properties) + { + if (!property.CanWrite + || !(property.DeclaringType?.IsAssignableFrom(instance.Type) ?? false)) + { + continue; + } + + EnqueueBinding( + closure, + property, + Expression.Property(instance, property)); + } + + action = SelectionVisitor.Continue; + return true; + } } action = SelectionVisitor.Skip; return true; } + private static void EnqueueBinding( + QueryableProjectionScope scope, + PropertyInfo member, + Expression value) + { + if (scope.Level.Peek().Any(t => t.Member == member)) + { + return; + } + + scope.Level.Peek().Enqueue(Expression.Bind(member, value)); + } + + private sealed class TopLevelPropertyExtractor(ParameterExpression parameter) : ExpressionVisitor + { + private readonly ParameterExpression _parameter = parameter; + private readonly HashSet _seen = []; + private readonly List _properties = []; + + public static IReadOnlyList Extract(LambdaExpression expression) + { + var visitor = new TopLevelPropertyExtractor(expression.Parameters[0]); + visitor.Visit(expression.Body); + return visitor._properties; + } + + protected override Expression VisitExtension(Expression node) => node.CanReduce ? base.VisitExtension(node) : node; + + protected override Expression VisitMember(MemberExpression node) + { + if (TryGetTopLevelProperty(node, _parameter, out var property) + && _seen.Add(property)) + { + _properties.Add(property); + } + + return base.VisitMember(node); + } + + private static bool TryGetTopLevelProperty( + Expression expression, + ParameterExpression parameter, + [NotNullWhen(true)] out PropertyInfo? property) + { + Expression? current = expression; + + while (current is not null) + { + current = UnwrapConvert(current); + + if (current is not MemberExpression memberExpression) + { + break; + } + + var parent = UnwrapConvert(memberExpression.Expression); + + if (parent == parameter) + { + property = memberExpression.Member as PropertyInfo; + return property is not null; + } + + if (parent is null) + { + break; + } + + current = parent; + } + + property = null; + return false; + } + + private static Expression? UnwrapConvert(Expression? expression) + { + while (expression is UnaryExpression + { + NodeType: + ExpressionType.Convert + or ExpressionType.ConvertChecked + or ExpressionType.TypeAs + } unary) + { + expression = unary.Operand; + } + + return expression; + } + } + public static QueryableProjectionScalarHandler Create(ProjectionProviderContext context) => new(); } diff --git a/src/HotChocolate/Data/src/Data/Sorting/SortInputTypeDescriptor`1.cs b/src/HotChocolate/Data/src/Data/Sorting/SortInputTypeDescriptor`1.cs index c47e7f8741c..8a6daa4fadf 100644 --- a/src/HotChocolate/Data/src/Data/Sorting/SortInputTypeDescriptor`1.cs +++ b/src/HotChocolate/Data/src/Data/Sorting/SortInputTypeDescriptor`1.cs @@ -99,6 +99,17 @@ protected override void OnCompleteFields( /// public ISortFieldDescriptor Field(Expression> propertyOrMember) { + if (propertyOrMember.Body is UnaryExpression { NodeType: ExpressionType.ArrayLength }) + { + var arrayLengthFieldDescriptor = + SortFieldDescriptor.New( + Context, + Configuration.Scope, + propertyOrMember); + Fields.Add(arrayLengthFieldDescriptor); + return arrayLengthFieldDescriptor; + } + switch (propertyOrMember.TryExtractMember()) { case PropertyInfo m: diff --git a/src/HotChocolate/Data/test/Data.Projections.Tests/IntegrationTests.cs b/src/HotChocolate/Data/test/Data.Projections.Tests/IntegrationTests.cs index acec2265067..486b41110a5 100644 --- a/src/HotChocolate/Data/test/Data.Projections.Tests/IntegrationTests.cs +++ b/src/HotChocolate/Data/test/Data.Projections.Tests/IntegrationTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HotChocolate.Execution; using HotChocolate.Types; using HotChocolate.Types.Relay; @@ -111,6 +112,43 @@ public async Task Projection_Should_NotBreakProjections_When_ExtensionsObjectReq result.MatchSnapshot(); } + [Fact] + public async Task Projection_Should_Project_ArrayLength_Expression_Fields() + { + // arrange + var executor = await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddType() + .AddProjections() + .BuildRequestExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + cardReaders { + cardReaderUidLength + } + } + """); + + using var document = JsonDocument.Parse(result.ToJson()); + var readers = document.RootElement + .GetProperty("data") + .GetProperty("cardReaders"); + + // assert + Assert.Equal(2, readers.GetArrayLength()); + + var enumerator = readers.EnumerateArray(); + Assert.True(enumerator.MoveNext()); + Assert.Equal(3, enumerator.Current.GetProperty("cardReaderUidLength").GetInt32()); + Assert.True(enumerator.MoveNext()); + Assert.Equal(1, enumerator.Current.GetProperty("cardReaderUidLength").GetInt32()); + Assert.False(enumerator.MoveNext()); + } + [Fact] public async Task Node_Resolver_With_SingleOrDefault_Schema() { @@ -470,6 +508,30 @@ public IQueryable Foos => new Foo[] { new() { Bar = "A" }, new() { Bar = "B" } }.AsQueryable(); } +public class QueryWithExpressionProjection +{ + [UseProjection] + public IQueryable CardReaders + => new[] + { + new CardReader { CardReaderUid = [1, 2, 3] }, + new CardReader { CardReaderUid = [1] } + }.AsQueryable(); +} + +public class CardReaderType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(x => x.CardReaderUid.Length).Name("cardReaderUidLength"); + } +} + +public class CardReader +{ + public byte[] CardReaderUid { get; set; } = []; +} + public class Mutation { [UseMutationConvention] diff --git a/src/HotChocolate/Data/test/Data.Sorting.Tests/SortInputTypeTests.cs b/src/HotChocolate/Data/test/Data.Sorting.Tests/SortInputTypeTests.cs index f013f02ec8d..68335c49fba 100644 --- a/src/HotChocolate/Data/test/Data.Sorting.Tests/SortInputTypeTests.cs +++ b/src/HotChocolate/Data/test/Data.Sorting.Tests/SortInputTypeTests.cs @@ -149,6 +149,26 @@ public void SortInput_AddName() schema.MatchSnapshot(); } + [Fact] + public void SortInputType_Field_ArrayLengthExpression_Infers_IntRuntimeType() + { + // arrange + var schema = CreateSchema( + s => s + .AddType()); + + // act + Assert.True( + schema.Types.TryGetType( + "CardReaderSortInput", + out var sortType)); + + // assert + Assert.NotNull(sortType); + var lengthField = Assert.IsType(sortType.Fields["cardReaderUidLength"]); + Assert.Equal(typeof(int), lengthField.RuntimeType?.Source); + } + [Fact] public void SortInputType_Should_ThrowException_WhenNoConventionIsRegistered() { @@ -374,6 +394,21 @@ protected override void Configure(ISortInputTypeDescriptor descriptor) } } + public class CardReader + { + public byte[] CardReaderUid { get; set; } = []; + } + + public class CardReaderSortInputType : SortInputType + { + protected override void Configure(ISortInputTypeDescriptor descriptor) + { + descriptor.BindFieldsExplicitly(); + descriptor.Name("CardReaderSortInput"); + descriptor.Field(x => x.CardReaderUid.Length).Name("cardReaderUidLength"); + } + } + public class UserQueryType : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) From 41832d98c4b5ee5aa5ec429c9ced1895306d3457 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Fri, 27 Feb 2026 13:24:21 +0000 Subject: [PATCH 3/4] polish and tests --- .../ArrayLengthProjectionTests.cs | 95 +++++++++++++++++ .../ComputedExpressionProjectionTests.cs | 100 ++++++++++++++++++ .../IntegrationTests.cs | 66 ++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ArrayLengthProjectionTests.cs create mode 100644 src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ComputedExpressionProjectionTests.cs diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ArrayLengthProjectionTests.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ArrayLengthProjectionTests.cs new file mode 100644 index 00000000000..d466fca0c7c --- /dev/null +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ArrayLengthProjectionTests.cs @@ -0,0 +1,95 @@ +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Squadron; + +namespace HotChocolate.Data; + +[Collection(PostgresCacheCollectionFixture.DefinitionName)] +public sealed class ArrayLengthProjectionTests(PostgreSqlResource resource) +{ + [Fact] + public async Task Projection_ArrayLengthExpression_Should_Work_Against_PostgreSql() + { + // arrange + var db = "db_" + Guid.NewGuid().ToString("N"); + var connectionString = resource.GetConnectionString(db); + + await using var services = new ServiceCollection() + .AddDbContext(c => c.UseNpgsql(connectionString)) + .AddGraphQLServer() + .AddQueryType() + .AddType() + .AddProjections() + .Services + .BuildServiceProvider(); + + await using var scope = services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(); + context.CardReaders.AddRange( + new CardReader { Id = 1, CardReaderUid = [1, 2, 3] }, + new CardReader { Id = 2, CardReaderUid = [7] }); + await context.SaveChangesAsync(); + + var executor = await services + .GetRequiredService() + .GetExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + cardReaders { + cardReaderUidLength + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "cardReaders": [ + { + "cardReaderUidLength": 3 + }, + { + "cardReaderUidLength": 1 + } + ] + } + } + """); + } + + public sealed class Query + { + [UseProjection] + public IQueryable GetCardReaders([Service] CardReaderContext context) + => context.CardReaders.OrderBy(x => x.Id); + } + + public sealed class CardReaderType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(x => x.CardReaderUid.Length).Name("cardReaderUidLength"); + } + } + + public sealed class CardReader + { + public int Id { get; set; } + + public byte[] CardReaderUid { get; set; } = []; + } + + public sealed class CardReaderContext(DbContextOptions options) + : DbContext(options) + { + public DbSet CardReaders => Set(); + } +} diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ComputedExpressionProjectionTests.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ComputedExpressionProjectionTests.cs new file mode 100644 index 00000000000..fa40d067999 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/ComputedExpressionProjectionTests.cs @@ -0,0 +1,100 @@ +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Squadron; + +namespace HotChocolate.Data; + +[Collection(PostgresCacheCollectionFixture.DefinitionName)] +public sealed class ComputedExpressionProjectionTests(PostgreSqlResource resource) +{ + [Fact] + public async Task Projection_ComputedExpression_Field_Dependencies_Should_Work_Against_PostgreSql() + { + // arrange + var db = "db_" + Guid.NewGuid().ToString("N"); + var connectionString = resource.GetConnectionString(db); + + await using var services = new ServiceCollection() + .AddDbContext(c => c.UseNpgsql(connectionString)) + .AddGraphQLServer() + .AddQueryType() + .AddType() + .AddProjections() + .Services + .BuildServiceProvider(); + + await using var scope = services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(); + context.People.AddRange( + new ExpressionPerson { Id = 1, FirstName = "Jane", LastName = "Doe" }, + new ExpressionPerson { Id = 2, FirstName = "John", LastName = "Smith" }); + await context.SaveChangesAsync(); + + var executor = await services + .GetRequiredService() + .GetExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + people { + firstName + fullName + } + } + """); + + // assert + result.MatchInlineSnapshot( + """ + { + "data": { + "people": [ + { + "firstName": "Jane", + "fullName": "Jane Doe" + }, + { + "firstName": "John", + "fullName": "John Smith" + } + ] + } + } + """); + } + + public sealed class Query + { + [UseProjection] + public IQueryable GetPeople([Service] ExpressionPersonContext context) + => context.People.OrderBy(x => x.Id); + } + + public sealed class ExpressionPersonType : ObjectType + { + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(x => x.FirstName + " " + x.LastName).Name("fullName"); + } + } + + public sealed class ExpressionPerson + { + public int Id { get; set; } + + public string FirstName { get; set; } = null!; + + public string LastName { get; set; } = null!; + } + + public sealed class ExpressionPersonContext(DbContextOptions options) + : DbContext(options) + { + public DbSet People => Set(); + } +} diff --git a/src/HotChocolate/Data/test/Data.Projections.Tests/IntegrationTests.cs b/src/HotChocolate/Data/test/Data.Projections.Tests/IntegrationTests.cs index 486b41110a5..02ed2d920a8 100644 --- a/src/HotChocolate/Data/test/Data.Projections.Tests/IntegrationTests.cs +++ b/src/HotChocolate/Data/test/Data.Projections.Tests/IntegrationTests.cs @@ -149,6 +149,46 @@ public async Task Projection_Should_Project_ArrayLength_Expression_Fields() Assert.False(enumerator.MoveNext()); } + [Fact] + public async Task Projection_Should_Project_ComputedExpression_Field_Dependencies() + { + // arrange + var executor = await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddType() + .AddProjections() + .BuildRequestExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + people { + firstName + fullName + } + } + """); + + using var document = JsonDocument.Parse(result.ToJson()); + var people = document.RootElement + .GetProperty("data") + .GetProperty("people"); + + // assert + Assert.Equal(2, people.GetArrayLength()); + + var enumerator = people.EnumerateArray(); + Assert.True(enumerator.MoveNext()); + Assert.Equal("Jane", enumerator.Current.GetProperty("firstName").GetString()); + Assert.Equal("Jane Doe", enumerator.Current.GetProperty("fullName").GetString()); + Assert.True(enumerator.MoveNext()); + Assert.Equal("John", enumerator.Current.GetProperty("firstName").GetString()); + Assert.Equal("John Smith", enumerator.Current.GetProperty("fullName").GetString()); + Assert.False(enumerator.MoveNext()); + } + [Fact] public async Task Node_Resolver_With_SingleOrDefault_Schema() { @@ -519,6 +559,17 @@ public IQueryable CardReaders }.AsQueryable(); } +public class QueryWithComputedExpressionProjection +{ + [UseProjection] + public IQueryable People + => new[] + { + new ExpressionPerson { FirstName = "Jane", LastName = "Doe" }, + new ExpressionPerson { FirstName = "John", LastName = "Smith" } + }.AsQueryable(); +} + public class CardReaderType : ObjectType { protected override void Configure(IObjectTypeDescriptor descriptor) @@ -527,11 +578,26 @@ protected override void Configure(IObjectTypeDescriptor descriptor) } } +public class ExpressionPersonType : ObjectType +{ + protected override void Configure(IObjectTypeDescriptor descriptor) + { + descriptor.Field(x => x.FirstName + " " + x.LastName).Name("fullName"); + } +} + public class CardReader { public byte[] CardReaderUid { get; set; } = []; } +public class ExpressionPerson +{ + public string FirstName { get; set; } = null!; + + public string LastName { get; set; } = null!; +} + public class Mutation { [UseMutationConvention] From 516458d89b71cabaeb1010c6ceb810c030134b6b Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Fri, 27 Feb 2026 17:37:06 +0000 Subject: [PATCH 4/4] Fix flacky tests --- .../Data.PostgreSQL.Tests/IntegrationTests.cs | 115 +++++++++++++++++- .../IntegrationTests.CreateSchema.graphql | 22 ++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs index 885f6bbcde4..468743f4f85 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/IntegrationTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Squadron; +using System.Text.RegularExpressions; namespace HotChocolate.Data; @@ -586,18 +587,128 @@ private static void MatchSnapshot( TestQueryInterceptor queryInterceptor) { var snapshot = Snapshot.Create(postFix: TestEnvironment.TargetFramework); + var queries = NormalizeBrandLookupBatching(queryInterceptor.Queries); snapshot.Add(result.ToJson(), "Result", MarkdownLanguages.Json); - for (var i = 0; i < queryInterceptor.Queries.Count; i++) + for (var i = 0; i < queries.Count; i++) { - var sql = queryInterceptor.Queries[i]; + var sql = queries[i]; snapshot.Add(sql, $"Query {i + 1}", MarkdownLanguages.Sql); } snapshot.MatchMarkdown(); } + private static IReadOnlyList NormalizeBrandLookupBatching(IReadOnlyList queries) + { + var indices = new List(); + var ids = new HashSet(); + string? body = null; + + for (var i = 0; i < queries.Count; i++) + { + var query = queries[i]; + if (!IsBrandLookupQuery(query, out var currentIds, out var currentBody)) + { + continue; + } + + if (body is not null && !string.Equals(body, currentBody, StringComparison.Ordinal)) + { + return queries; + } + + body = currentBody; + indices.Add(i); + + foreach (var id in currentIds) + { + ids.Add(id); + } + } + + if (indices.Count <= 1 || body is null || ids.Count == 0) + { + return queries; + } + + var orderedIds = ids.OrderBy(t => t).Select(t => $"'{t}'"); + var merged = Regex.Replace( + queries[indices[0]], + @"\{[^}]*\}", + "{ " + string.Join(", ", orderedIds) + " }", + RegexOptions.CultureInvariant); + + var normalized = new List(queries.Count - indices.Count + 1); + var first = indices[0]; + var indexSet = indices.ToHashSet(); + + for (var i = 0; i < queries.Count; i++) + { + if (i == first) + { + normalized.Add(merged); + } + + if (!indexSet.Contains(i)) + { + normalized.Add(queries[i]); + } + } + + return normalized; + } + + private static bool IsBrandLookupQuery( + string query, + out IReadOnlyList ids, + out string body) + { + ids = []; + body = query; + + var lines = query + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (lines.Length < 4) + { + return false; + } + + if (!lines[0].StartsWith("-- @", StringComparison.Ordinal) + || !query.Contains("SELECT b.\"Name\", b.\"Id\"", StringComparison.Ordinal) + || !query.Contains("FROM \"Brands\" AS b", StringComparison.Ordinal) + || !query.Contains("WHERE b.\"Id\" = ANY (", StringComparison.Ordinal)) + { + return false; + } + + var matches = Regex.Matches(lines[0], @"'(?\d+)'", RegexOptions.CultureInvariant); + if (matches.Count == 0) + { + return false; + } + + var parsed = new List(matches.Count); + foreach (Match match in matches) + { + if (int.TryParse(match.Groups["id"].Value, out var id)) + { + parsed.Add(id); + } + } + + if (parsed.Count == 0) + { + return false; + } + + ids = parsed; + body = string.Join('\n', lines.Skip(1)); + return true; + } + private class DataLoaderSecondLevelCache : IPromiseCacheInterceptor { private readonly IMemoryCache _memoryCache; diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.CreateSchema.graphql b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.CreateSchema.graphql index 8dda7bc419f..ea65b2c76c8 100644 --- a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.CreateSchema.graphql +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/__snapshots__/IntegrationTests.CreateSchema.graphql @@ -44,6 +44,12 @@ type BrandProductsEdge @shareable { cursor: String! } +type CardReader { + cardReaderUidLength: Int! @cost(weight: "10") + id: Int! + cardReaderUid: [UnsignedByte!]! +} + """ Represents the connection page info. This class provides additional information about pagination in a connection. @@ -65,6 +71,13 @@ type ConnectionPageInfo { endCursor: String } +type ExpressionPerson { + fullName: String @cost(weight: "10") + id: Int! + firstName: String! + lastName: String! +} + "A cursor that points to a specific page." type PageCursor @shareable { "The page number." @@ -158,6 +171,12 @@ input BrandSortInput { name: SortEnumType @cost(weight: "10") } +input CardReaderFilterInput { + and: [CardReaderFilterInput!] + or: [CardReaderFilterInput!] + cardReaderUidLength: IntOperationFilterInput +} + input DecimalOperationFilterInput { eq: Decimal @cost(weight: "10") neq: Decimal @cost(weight: "10") @@ -321,3 +340,6 @@ directive @specifiedBy("The specifiedBy URL points to a human-readable specifica "The `Decimal` scalar type represents a decimal floating-point number with high precision." scalar Decimal @serializeAs(type: FLOAT) @specifiedBy(url: "https://scalars.graphql.org/chillicream/decimal.html") + +"The `UnsignedByte` scalar type represents an unsigned 8-bit integer." +scalar UnsignedByte @serializeAs(type: INT) @specifiedBy(url: "https://scalars.graphql.org/chillicream/unsigned-byte.html")