diff --git a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs index 589e0f1b9ac..78a6d6838cb 100644 --- a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs +++ b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs @@ -168,10 +168,11 @@ private void CollectTypes(Context context, Selection selection, TypeContainer pa var typeCondition = Expression.TypeIs(context.Parent, typeNode.Type); var selectionSet = BuildSelectionSetExpression(newContext, typeNode); - if (selectionSet is null) - { - throw new InvalidOperationException(); - } + // If a type condition only selects non-bindable fields like __typename, + // BuildSelectionSetExpression returns null. Reuse the source instance + // instead so the branch remains query-parameter dependent and does not + // get parameterized as a constant by EF. + selectionSet ??= newParent; var castedSelectionSet = Expression.Convert(selectionSet, context.ParentType); switchExpression = Expression.Condition(typeCondition, castedSelectionSet, switchExpression); diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs index 3aa02986869..9f71416f121 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs @@ -41,14 +41,28 @@ public static Expression CreateMemberInit(this QueryableProjectionScope scope) if (scope.HasAbstractTypes()) { Expression lastValue = Expression.Default(scope.RuntimeType); + var sourceInstance = scope.Instance.Peek(); foreach (var val in scope.GetAbstractTypes()) { - var ctor = Expression.New(val.Key); - Expression memberInit = Expression.MemberInit(ctor, val.Value); + Expression memberInit; + + // If a type condition only selects non-bindable fields like __typename, + // creating `new TDerived()` is evaluatable and gets parameterized as a + // constant by EF. Reuse the source instance instead so the branch + // remains query-parameter dependent. + if (val.Value.Count == 0) + { + memberInit = Expression.Convert(sourceInstance, val.Key); + } + else + { + var ctor = Expression.New(val.Key); + memberInit = Expression.MemberInit(ctor, val.Value); + } lastValue = Expression.Condition( - Expression.TypeIs(scope.Instance.Peek(), val.Key), + Expression.TypeIs(sourceInstance, val.Key), Expression.Convert(memberInit, scope.RuntimeType), lastValue); } diff --git a/src/HotChocolate/Data/test/Data.EntityFramework.Tests/Issue8252ProbeTests.cs b/src/HotChocolate/Data/test/Data.EntityFramework.Tests/Issue8252ProbeTests.cs new file mode 100644 index 00000000000..905f6b2f670 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.EntityFramework.Tests/Issue8252ProbeTests.cs @@ -0,0 +1,295 @@ +using System.Text.Json; +using HotChocolate.Execution; +using HotChocolate.Execution.Processing; +using HotChocolate.Types; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Data; + +public sealed class Issue8252ProbeTests +{ + [Fact] + public async Task Union_Subset_With_OffsetPaging_Should_Project_Text_Items() + { + // arrange + var dbFile = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"issue8252-{Guid.NewGuid():N}.db"); + var connectionString = $"Data Source={dbFile}"; + + try + { + await using var services = new ServiceCollection() + .AddDbContext(b => b.UseSqlite(connectionString)) + .AddGraphQL() + .AddQueryType() + .AddFiltering() + .AddProjections() + .AddUnionType() + .AddType() + .AddType() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .Services + .BuildServiceProvider(); + + await using (var scope = services.CreateAsyncScope()) + { + await using var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(); + + context.Content.AddRange( + new TextContent + { + Discriminator = "TEXT", + Id = Guid.NewGuid(), + Text = "Hello World" + }, + new ImageContent + { + Discriminator = "IMAGE", + Id = Guid.NewGuid(), + ImageUrl = "http://someurl", + Height = 10 + }); + + await context.SaveChangesAsync(); + } + + var executor = await services + .GetRequiredService() + .GetExecutorAsync(); + + // act + var allFragments = await executor.ExecuteAsync( + """ + { + contents { + nodes { + ... on ImageContent { + imageUrl + } + ... on TextContent { + text + } + } + } + } + """); + + var textOnly = await executor.ExecuteAsync( + """ + { + contents { + nodes { + ... on TextContent { + text + } + } + } + } + """); + + var textOnlyWithTypeName = await executor.ExecuteAsync( + """ + { + contents { + nodes { + __typename + ... on TextContent { + text + } + } + } + } + """); + + // assert + var allFragmentsResult = allFragments.ExpectOperationResult(); + Assert.Empty(allFragmentsResult.Errors ?? []); + + var textOnlyResult = textOnly.ExpectOperationResult(); + var textOnlyWithTypeNameResult = textOnlyWithTypeName.ExpectOperationResult(); + + Assert.Empty(textOnlyResult.Errors ?? []); + Assert.Empty(textOnlyWithTypeNameResult.Errors ?? []); + + Assert.True(HasTextItem(textOnlyResult.ToJson()), textOnlyResult.ToJson()); + Assert.True( + HasTextItem(textOnlyWithTypeNameResult.ToJson()), + textOnlyWithTypeNameResult.ToJson()); + } + finally + { + if (File.Exists(dbFile)) + { + File.Delete(dbFile); + } + } + } + + private static bool HasTextItem(string resultJson) + { + using var document = JsonDocument.Parse(resultJson); + var items = document.RootElement.GetProperty("data") + .GetProperty("contents") + .GetProperty("nodes"); + + foreach (var item in items.EnumerateArray()) + { + if (item.ValueKind is JsonValueKind.Object + && item.TryGetProperty("text", out var text) + && text.ValueKind is JsonValueKind.String + && text.GetString() == "Hello World") + { + return true; + } + } + + return false; + } + + [Fact] + public async Task Union_Subset_With_AsSelector_Should_Project_Text_Items() + { + // arrange + var dbFile = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"issue8252-selector-{Guid.NewGuid():N}.db"); + var connectionString = $"Data Source={dbFile}"; + + try + { + await using var services = new ServiceCollection() + .AddDbContext(b => b.UseSqlite(connectionString)) + .AddGraphQL() + .AddQueryType() + .AddFiltering() + .AddUnionType() + .AddType() + .AddType() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .Services + .BuildServiceProvider(); + + await using (var scope = services.CreateAsyncScope()) + { + await using var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(); + + context.Content.AddRange( + new TextContent + { + Discriminator = "TEXT", + Id = Guid.NewGuid(), + Text = "Hello World" + }, + new ImageContent + { + Discriminator = "IMAGE", + Id = Guid.NewGuid(), + ImageUrl = "http://someurl", + Height = 10 + }); + + await context.SaveChangesAsync(); + } + + var executor = await services + .GetRequiredService() + .GetExecutorAsync(); + + // act + var textOnlyWithTypeName = await executor.ExecuteAsync( + """ + { + contents { + ... on TextContent { + text + } + } + } + """); + + // assert + var result = textOnlyWithTypeName.ExpectOperationResult(); + Assert.Empty(result.Errors ?? []); + + using var document = JsonDocument.Parse(result.ToJson()); + var contents = document.RootElement + .GetProperty("data") + .GetProperty("contents"); + + var hasText = false; + foreach (var item in contents.EnumerateArray()) + { + if (item.ValueKind is JsonValueKind.Object + && item.TryGetProperty("text", out var text) + && text.ValueKind is JsonValueKind.String + && text.GetString() == "Hello World") + { + hasText = true; + } + } + + Assert.True(hasText, result.ToJson()); + } + finally + { + if (File.Exists(dbFile)) + { + File.Delete(dbFile); + } + } + } + + public sealed class Issue8252Query + { + [UsePaging] + [UseProjection] + [UseFiltering] + public IQueryable GetContents(Issue8252Context database) + => database.Content; + } + + public sealed class Issue8252AsSelectorQuery + { + public IQueryable GetContents( + Issue8252Context database, + ISelection selection) + => database.Content.Select(selection.AsSelector()); + } + + public class PostContent + { + public Guid Id { get; set; } + + public required string Discriminator { get; set; } + } + + public sealed class TextContent : PostContent + { + public required string Text { get; set; } + } + + public sealed class ImageContent : PostContent + { + public required string ImageUrl { get; set; } + + public int Height { get; set; } + } + + public sealed class Issue8252Context(DbContextOptions options) : DbContext(options) + { + public DbSet Content => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity().HasKey(e => e.Id); + builder.Entity() + .HasDiscriminator(e => e.Discriminator) + .HasValue("TEXT") + .HasValue("IMAGE"); + } + } +} diff --git a/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Issue8252UnionProjectionTests.cs b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Issue8252UnionProjectionTests.cs new file mode 100644 index 00000000000..f9108c45121 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.PostgreSQL.Tests/Issue8252UnionProjectionTests.cs @@ -0,0 +1,231 @@ +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Squadron; + +namespace HotChocolate.Data; + +[Collection(PostgresCacheCollectionFixture.DefinitionName)] +public sealed class Issue8252UnionProjectionTests(PostgreSqlResource resource) +{ + [Fact] + public async Task Union_AllFragments_With_OffsetPaging_Should_Project() + { + // arrange + var (executor, _) = await CreateExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + contents { + items { + ... on TextContent { + text + } + ... on ImageContent { + imageUrl + } + } + } + } + """); + + // assert + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors ?? []); + + result.MatchInlineSnapshot( + """ + { + "data": { + "contents": { + "items": [ + { + "imageUrl": "https://example.com/photo.jpg" + }, + { + "text": "Hello World" + } + ] + } + } + } + """); + } + + [Fact] + public async Task Union_SingleFragment_With_OffsetPaging_Should_Project() + { + // arrange + var (executor, _) = await CreateExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + contents { + items { + ... on TextContent { + text + } + } + } + } + """); + + // assert + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors ?? []); + + result.MatchInlineSnapshot( + """ + { + "data": { + "contents": { + "items": [ + null, + { + "text": "Hello World" + } + ] + } + } + } + """); + } + + [Fact] + public async Task Union_TypenameWithFragment_With_OffsetPaging_Should_Project() + { + // arrange + var (executor, _) = await CreateExecutorAsync(); + + // act + var result = await executor.ExecuteAsync( + """ + { + contents { + items { + __typename + ... on TextContent { + text + } + } + } + } + """); + + // assert + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors ?? []); + + result.MatchInlineSnapshot( + """ + { + "data": { + "contents": { + "items": [ + { + "__typename": "ImageContent" + }, + { + "__typename": "TextContent", + "text": "Hello World" + } + ] + } + } + } + """); + } + + private async Task<(IRequestExecutor Executor, ServiceProvider Services)> CreateExecutorAsync() + { + var db = "db_" + Guid.NewGuid().ToString("N"); + var connectionString = resource.GetConnectionString(db); + + var services = new ServiceCollection() + .AddDbContext(c => c.UseNpgsql(connectionString)) + .AddGraphQLServer() + .AddQueryType() + .AddUnionType() + .AddType() + .AddType() + .AddProjections() + .AddFiltering() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .Services + .BuildServiceProvider(); + + await using var scope = services.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.EnsureCreatedAsync(); + + context.Content.AddRange( + new TextContent + { + Id = Guid.NewGuid(), + Discriminator = "TEXT", + Text = "Hello World" + }, + new ImageContent + { + Id = Guid.NewGuid(), + Discriminator = "IMAGE", + ImageUrl = "https://example.com/photo.jpg", + Height = 600 + }); + + await context.SaveChangesAsync(); + + var executor = await services + .GetRequiredService() + .GetExecutorAsync(); + + return (executor, services); + } + + public sealed class Issue8252Query + { + [UseOffsetPaging] + [UseProjection] + [UseFiltering] + public IQueryable GetContents(Issue8252Context context) + => context.Content; + } + + public class PostContent + { + public Guid Id { get; set; } + + public required string Discriminator { get; set; } + } + + public sealed class TextContent : PostContent + { + public required string Text { get; set; } + } + + public sealed class ImageContent : PostContent + { + public required string ImageUrl { get; set; } + + public int Height { get; set; } + } + + public sealed class Issue8252Context(DbContextOptions options) : DbContext(options) + { + public DbSet Content => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity().HasKey(e => e.Id); + builder.Entity() + .HasDiscriminator(e => e.Discriminator) + .HasValue("TEXT") + .HasValue("IMAGE"); + } + } +} 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 a6ec08f1e3c..36c595f7c69 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 @@ -146,15 +146,27 @@ type ProductEdge @shareable { type ProductType { id: Int! - name: String! products: [Product!]! } +type ProductTypeConnection @shareable { + edges: [ProductTypeEdge!]! + nodes: [ProductType!]! + pageInfo: ConnectionPageInfo! + totalCount: Int! @cost(weight: "10") +} + +type ProductTypeEdge @shareable { + node: ProductType! + cursor: String! +} + type Query { "Fetches an object given its ID." node("ID of the object." id: ID!): Node @lookup @shareable @cost(weight: "10") "Lookup nodes by a list of IDs." nodes("The list of node IDs." ids: [ID!]!): [Node]! @shareable @cost(weight: "10") + hiddenNameProductTypes("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String where: ProductTypeFilterInput @cost(weight: "10") order: [ProductTypeSortInput!] @cost(weight: "10")): ProductTypeConnection! @listSize(assumedSize: 50, slicingArguments: [ "first", "last" ], slicingArgumentDefaultValue: 10, sizedFields: [ "edges", "nodes" ], requireOneSlicingArgument: false) @cost(weight: "10") brands("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String where: BrandFilterInput @cost(weight: "10")): BrandConnection! @listSize(assumedSize: 50, slicingArguments: [ "first", "last" ], slicingArgumentDefaultValue: 10, sizedFields: [ "edges", "nodes" ], requireOneSlicingArgument: false) @cost(weight: "10") brandById(id: ID!): Brand @lookup @cost(weight: "10") brandByIdWithDL(id: ID!): Brand @lookup @cost(weight: "10") @@ -237,9 +249,7 @@ input ProductFilterInput { description: StringOperationFilterInput price: DecimalOperationFilterInput imageFileName: StringOperationFilterInput - typeId: IntOperationFilterInput type: ProductTypeFilterInput - brandId: IntOperationFilterInput brand: BrandFilterInput availableStock: IntOperationFilterInput restockThreshold: IntOperationFilterInput @@ -256,10 +266,13 @@ input ProductTypeFilterInput { and: [ProductTypeFilterInput!] or: [ProductTypeFilterInput!] id: IntOperationFilterInput - name: StringOperationFilterInput products: ListFilterInputTypeOfProductFilterInput } +input ProductTypeSortInput { + id: SortEnumType @cost(weight: "10") +} + input StringOperationFilterInput { and: [StringOperationFilterInput!] or: [StringOperationFilterInput!]