diff --git a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs index 7bd1728467c..8c6794f93f1 100644 --- a/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs +++ b/src/HotChocolate/Core/src/Execution.Projections/SelectionExpressionBuilder.cs @@ -165,7 +165,28 @@ private void CollectTypes(Context context, Selection selection, TypeContainer pa return switchExpression; } - return BuildSelectionSetExpression(context, parent.Nodes[0]); + var singleTypeNode = parent.Nodes[0]; + + if (context.ParentType != singleTypeNode.Type) + { + var newParent = Expression.Convert(context.Parent, singleTypeNode.Type); + var newContext = context with { Parent = newParent, ParentType = singleTypeNode.Type }; + var selectionSet = BuildSelectionSetExpression(newContext, singleTypeNode); + + if (selectionSet is null) + { + return null; + } + + var castedSelectionSet = Expression.Convert(selectionSet, context.ParentType); + + return Expression.Condition( + Expression.TypeIs(context.Parent, singleTypeNode.Type), + castedSelectionSet, + Expression.Constant(null, context.ParentType)); + } + + return BuildSelectionSetExpression(context, singleTypeNode); } private static MemberInitExpression? BuildSelectionSetExpression( diff --git a/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/QueryableProjectionUnionTypeTests.cs b/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/QueryableProjectionUnionTypeTests.cs index 3176bbc40d2..9b75c565ae9 100644 --- a/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/QueryableProjectionUnionTypeTests.cs +++ b/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/QueryableProjectionUnionTypeTests.cs @@ -39,6 +39,28 @@ public class QueryableProjectionUnionTypeTests } ]; + private static readonly InspectionDefinition[] s_inspectionDefinitions = + [ + new() + { + Id = 1, + Trigger = new FieldDateTimeInspectionTrigger + { + Id = 11, + FieldModelKey = "field-1" + } + }, + new() + { + Id = 2, + Trigger = new FieldDateTimeInspectionTrigger + { + Id = 12, + FieldModelKey = "field-2" + } + } + ]; + private readonly SchemaCache _cache = new SchemaCache(); [Fact] @@ -214,6 +236,75 @@ await Snapshot .MatchAsync(); } + [Fact] + public async Task Create_Union_Single_Property() + { + // arrange + var tester = _cache.CreateSchema( + s_inspectionDefinitions, + OnModelCreatingInspection, + configure: ConfigureInspectionSchema); + + // act + var result = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + """ + { + root { + trigger { + ... on FieldDateTimeInspectionTrigger { + fieldModelKey + } + } + } + } + """) + .Build()); + + // assert + await Snapshot + .Create() + .AddResult(result) + .MatchAsync(); + } + + [Fact] + public async Task Create_Union_Single_Property_Pagination() + { + // arrange + var tester = _cache.CreateSchema( + s_inspectionDefinitions, + OnModelCreatingInspection, + usePaging: true, + configure: ConfigureInspectionSchema); + + // act + var result = await tester.ExecuteAsync( + OperationRequestBuilder.New() + .SetDocument( + """ + { + root { + nodes { + trigger { + ... on FieldDateTimeInspectionTrigger { + fieldModelKey + } + } + } + } + } + """) + .Build()); + + // assert + await Snapshot + .Create() + .AddResult(result) + .MatchAsync(); + } + private static void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity() @@ -229,6 +320,21 @@ private static void ConfigureSchema(ISchemaBuilder schemaBuilder) .AddType(new ObjectType()); } + private static void OnModelCreatingInspection(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasOne(x => x.Trigger) + .WithOne() + .HasForeignKey(x => x.TriggerId); + + modelBuilder.Entity() + .HasDiscriminator("d") + .HasValue("fieldDateTime"); + } + + private static void ConfigureInspectionSchema(ISchemaBuilder schemaBuilder) + => schemaBuilder.AddType(new ObjectType()); + public class NestedList { public int Id { get; set; } @@ -260,4 +366,24 @@ public class Bar : AbstractType { public string BarProp { get; set; } = null!; } + + public class InspectionDefinition + { + public int Id { get; set; } + + public int TriggerId { get; set; } + + public InspectionTrigger Trigger { get; set; } = null!; + } + + [UnionType] + public abstract class InspectionTrigger + { + public int Id { get; set; } + } + + public class FieldDateTimeInspectionTrigger : InspectionTrigger + { + public string FieldModelKey { get; set; } = null!; + } } diff --git a/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/__snapshots__/QueryableProjectionUnionTypeTests.Create_Union_Single_Property.snap b/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/__snapshots__/QueryableProjectionUnionTypeTests.Create_Union_Single_Property.snap new file mode 100644 index 00000000000..4a5c9e9cbcb --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/__snapshots__/QueryableProjectionUnionTypeTests.Create_Union_Single_Property.snap @@ -0,0 +1,26 @@ +Result: +--------------- +{ + "data": { + "root": [ + { + "trigger": { + "fieldModelKey": "field-1" + } + }, + { + "trigger": { + "fieldModelKey": "field-2" + } + } + ] + } +} +--------------- + +SQL: +--------------- +SELECT 1, 1, "i"."FieldModelKey" +FROM "Data" AS "d" +INNER JOIN "InspectionTrigger" AS "i" ON "d"."TriggerId" = "i"."Id" +--------------- diff --git a/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/__snapshots__/QueryableProjectionUnionTypeTests.Create_Union_Single_Property_Pagination.snap b/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/__snapshots__/QueryableProjectionUnionTypeTests.Create_Union_Single_Property_Pagination.snap new file mode 100644 index 00000000000..b86b8d70d48 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Projections.SqlServer.Tests/__snapshots__/QueryableProjectionUnionTypeTests.Create_Union_Single_Property_Pagination.snap @@ -0,0 +1,28 @@ +Result: +--------------- +{ + "data": { + "root": { + "nodes": [ + { + "trigger": { + "fieldModelKey": "field-1" + } + }, + { + "trigger": { + "fieldModelKey": "field-2" + } + } + ] + } + } +} +--------------- + +SQL: +--------------- +SELECT 1, 1, "i"."FieldModelKey" +FROM "Data" AS "d" +INNER JOIN "InspectionTrigger" AS "i" ON "d"."TriggerId" = "i"."Id" +--------------- diff --git a/src/HotChocolate/Data/test/Data.Tests/QueryContextUnionProjectionTests.cs b/src/HotChocolate/Data/test/Data.Tests/QueryContextUnionProjectionTests.cs new file mode 100644 index 00000000000..0516db94099 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Tests/QueryContextUnionProjectionTests.cs @@ -0,0 +1,261 @@ +using HotChocolate.Execution; +using HotChocolate.Execution.Processing; +using HotChocolate.Types; +using GreenDonut; +using GreenDonut.Data; +using HotChocolate.Types.Pagination; +using System.Collections.Immutable; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Data; + +public class QueryContextUnionProjectionTests +{ + [Fact] + public async Task AsSelector_With_Single_Union_Field_Projects_Data() + { + var executor = await CreateExecutorAsync(); + + var result = await executor.ExecuteAsync( + """ + { + inspectionDefinitions { + trigger { + ... on FieldDateTimeInspectionTrigger { + fieldModelKey + } + } + } + } + """); + + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors ?? []); + result.MatchSnapshot(); + } + + [Fact] + public async Task AsSelector_With_Single_Union_Field_Projects_Data_With_Paging() + { + var executor = await CreateExecutorAsync(); + + var result = await executor.ExecuteAsync( + """ + { + pagedInspectionDefinitions(first: 10) { + nodes { + trigger { + ... on FieldDateTimeInspectionTrigger { + fieldModelKey + } + } + } + } + } + """); + + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors ?? []); + result.MatchSnapshot(); + } + + [Fact] + public async Task AsSelector_With_Single_Union_Field_Projects_Data_With_Nested_Paging_And_DataLoader() + { + var executor = await CreateExecutorAsync(); + + var result = await executor.ExecuteAsync( + """ + { + pagedInspectionGroups(first: 10) { + nodes { + id + definitions(first: 10) { + nodes { + trigger { + ... on FieldDateTimeInspectionTrigger { + fieldModelKey + } + } + } + } + } + } + } + """); + + var operationResult = result.ExpectOperationResult(); + Assert.Empty(operationResult.Errors ?? []); + result.MatchSnapshot(); + } + + private static async Task CreateExecutorAsync() + => await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddTypeExtension(typeof(InspectionGroupExtensions)) + .AddType() + .AddType() + .AddDataLoader() + .AddPagingArguments() + .ModifyRequestOptions(o => o.IncludeExceptionDetails = true) + .BuildRequestExecutorAsync(); + + public class Query + { + public IQueryable GetInspectionDefinitions(ISelection selection) + => SingleData.AsQueryable() + .Select(selection.AsSelector()); + + [UsePaging] + public IQueryable GetPagedInspectionDefinitions(ISelection selection) + => SingleData.AsQueryable() + .Select(selection.AsSelector()); + + [UsePaging] + public IQueryable GetPagedInspectionGroups(ISelection selection) + => GroupData.AsQueryable() + .Select(selection.AsSelector()); + } + + public class InspectionDefinition + { + public Guid Id { get; set; } + + public int GroupId { get; set; } + + public required IInspectionTrigger Trigger { get; set; } + } + + public class InspectionGroup + { + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public IReadOnlyList Definitions { get; set; } = []; + } + + [ExtendObjectType] + public static class InspectionGroupExtensions + { + [BindMember(nameof(InspectionGroup.Definitions))] + [UsePaging] + public static async Task> GetDefinitionsAsync( + [Parent("Id")] InspectionGroup group, + PagingArguments pagingArgs, + InspectionDefinitionsByGroupDataLoader dataLoader, + ISelection selection, + CancellationToken cancellationToken) + => await dataLoader + .With(pagingArgs) + .Select(selection) + .LoadAsync(group.Id, cancellationToken) + .ToConnectionAsync(); + } + + public sealed class InspectionDefinitionsByGroupDataLoader + : StatefulBatchDataLoader> + { + public InspectionDefinitionsByGroupDataLoader( + IBatchScheduler batchScheduler, + DataLoaderOptions options) + : base(batchScheduler, options) + { + } + + protected override Task>> LoadBatchAsync( + IReadOnlyList keys, + DataLoaderFetchContext> context, + CancellationToken cancellationToken) + { + var pagingArgs = context.GetPagingArguments(); + var query = context.GetQueryContext, InspectionDefinition>(); + var pageSize = pagingArgs.First ?? pagingArgs.Last ?? int.MaxValue; + + var grouped = GroupDefinitionData + .Where(x => keys.Contains(x.GroupId)) + .GroupBy(x => x.GroupId) + .ToDictionary(x => x.Key, x => x.ToArray()); + + var map = new Dictionary>(keys.Count); + + foreach (var key in keys) + { + grouped.TryGetValue(key, out var sourceItems); + sourceItems ??= []; + + var allItems = sourceItems + .AsQueryable() + .With(query, x => x.AddAscending(y => y.Id)) + .ToArray(); + + var take = Math.Min(pageSize, allItems.Length); + var pageItems = allItems.Take(take).ToImmutableArray(); + var hasNext = allItems.Length > take; + + map[key] = new Page( + pageItems, + hasNextPage: hasNext, + hasPreviousPage: false, + createCursor: _ => string.Empty, + totalCount: allItems.Length); + } + + return Task.FromResult>>(map); + } + } + + [UnionType] + public interface IInspectionTrigger; + + [ObjectType] + public class FieldDateTimeInspectionTrigger : IInspectionTrigger + { + public required string FieldModelKey { get; set; } + } + + private static readonly InspectionDefinition[] SingleData = + [ + new() + { + Id = Guid.Parse("11111111-1111-1111-1111-111111111111"), + GroupId = 1, + Trigger = new FieldDateTimeInspectionTrigger + { + FieldModelKey = "field-1" + } + } + ]; + + private static readonly InspectionGroup[] GroupData = + [ + new() + { + Id = 1, + Name = "group-1" + } + ]; + + private static readonly InspectionDefinition[] GroupDefinitionData = + [ + new() + { + Id = Guid.Parse("21111111-1111-1111-1111-111111111111"), + GroupId = 1, + Trigger = new FieldDateTimeInspectionTrigger + { + FieldModelKey = "field-1" + } + }, + new() + { + Id = Guid.Parse("31111111-1111-1111-1111-111111111111"), + GroupId = 1, + Trigger = new FieldDateTimeInspectionTrigger + { + FieldModelKey = "field-2" + } + } + ]; +} diff --git a/src/HotChocolate/Data/test/Data.Tests/__snapshots__/QueryContextUnionProjectionTests.AsSelector_With_Single_Union_Field_Projects_Data.snap b/src/HotChocolate/Data/test/Data.Tests/__snapshots__/QueryContextUnionProjectionTests.AsSelector_With_Single_Union_Field_Projects_Data.snap new file mode 100644 index 00000000000..9655dcb7f16 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Tests/__snapshots__/QueryContextUnionProjectionTests.AsSelector_With_Single_Union_Field_Projects_Data.snap @@ -0,0 +1,11 @@ +{ + "data": { + "inspectionDefinitions": [ + { + "trigger": { + "fieldModelKey": "field-1" + } + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.Tests/__snapshots__/QueryContextUnionProjectionTests.AsSelector_With_Single_Union_Field_Projects_Data_With_Nested_Paging_And_DataLoader.snap b/src/HotChocolate/Data/test/Data.Tests/__snapshots__/QueryContextUnionProjectionTests.AsSelector_With_Single_Union_Field_Projects_Data_With_Nested_Paging_And_DataLoader.snap new file mode 100644 index 00000000000..7212ebaf8b9 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Tests/__snapshots__/QueryContextUnionProjectionTests.AsSelector_With_Single_Union_Field_Projects_Data_With_Nested_Paging_And_DataLoader.snap @@ -0,0 +1,25 @@ +{ + "data": { + "pagedInspectionGroups": { + "nodes": [ + { + "id": 1, + "definitions": { + "nodes": [ + { + "trigger": { + "fieldModelKey": "field-1" + } + }, + { + "trigger": { + "fieldModelKey": "field-2" + } + } + ] + } + } + ] + } + } +} diff --git a/src/HotChocolate/Data/test/Data.Tests/__snapshots__/QueryContextUnionProjectionTests.AsSelector_With_Single_Union_Field_Projects_Data_With_Paging.snap b/src/HotChocolate/Data/test/Data.Tests/__snapshots__/QueryContextUnionProjectionTests.AsSelector_With_Single_Union_Field_Projects_Data_With_Paging.snap new file mode 100644 index 00000000000..56ae3926240 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.Tests/__snapshots__/QueryContextUnionProjectionTests.AsSelector_With_Single_Union_Field_Projects_Data_With_Paging.snap @@ -0,0 +1,13 @@ +{ + "data": { + "pagedInspectionDefinitions": { + "nodes": [ + { + "trigger": { + "fieldModelKey": "field-1" + } + } + ] + } + } +}