diff --git a/src/HotChocolate/Data/HotChocolate.Data.sln b/src/HotChocolate/Data/HotChocolate.Data.sln index 5c9d6f0d7c1..6dee40018c5 100644 --- a/src/HotChocolate/Data/HotChocolate.Data.sln +++ b/src/HotChocolate/Data/HotChocolate.Data.sln @@ -86,6 +86,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.AspNetCore", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.AspNetCore.Tests", "..\AspNetCore\test\AspNetCore.Tests\HotChocolate.AspNetCore.Tests.csproj", "{52C8DCA7-E000-41AD-B05C-3B3C08F7DC46}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.Data.AutoMapper", "src\AutoMapper\HotChocolate.Data.AutoMapper.csproj", "{0AB70663-9D52-4415-B265-0D1F001D7576}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HotChocolate.Data.AutoMapper.Tests", "test\Data.AutoMapper.Tests\HotChocolate.Data.AutoMapper.Tests.csproj", "{F793AC13-0500-492A-914D-4229F6AE0687}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -132,6 +136,8 @@ Global {5B36B9E9-BC55-4A4D-B58F-9311581C008B} = {4EE990B2-C327-46DA-8FE8-F95AC228E47F} {4D36CB01-FA11-4961-BAE4-0D1449ED7CCB} = {882EC02D-5E1D-41F5-AD9F-AA06E31D133A} {52C8DCA7-E000-41AD-B05C-3B3C08F7DC46} = {882EC02D-5E1D-41F5-AD9F-AA06E31D133A} + {0AB70663-9D52-4415-B265-0D1F001D7576} = {91887A91-7B1C-4287-A1E0-BD4E0DAF24C7} + {F793AC13-0500-492A-914D-4229F6AE0687} = {4EE990B2-C327-46DA-8FE8-F95AC228E47F} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D68A0AB9-871A-487B-8D12-1A7544D81B9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -520,5 +526,29 @@ Global {52C8DCA7-E000-41AD-B05C-3B3C08F7DC46}.Release|x64.Build.0 = Release|Any CPU {52C8DCA7-E000-41AD-B05C-3B3C08F7DC46}.Release|x86.ActiveCfg = Release|Any CPU {52C8DCA7-E000-41AD-B05C-3B3C08F7DC46}.Release|x86.Build.0 = Release|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Debug|x64.ActiveCfg = Debug|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Debug|x64.Build.0 = Debug|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Debug|x86.ActiveCfg = Debug|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Debug|x86.Build.0 = Debug|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Release|Any CPU.Build.0 = Release|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Release|x64.ActiveCfg = Release|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Release|x64.Build.0 = Release|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Release|x86.ActiveCfg = Release|Any CPU + {0AB70663-9D52-4415-B265-0D1F001D7576}.Release|x86.Build.0 = Release|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Debug|x64.ActiveCfg = Debug|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Debug|x64.Build.0 = Debug|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Debug|x86.ActiveCfg = Debug|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Debug|x86.Build.0 = Debug|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Release|Any CPU.Build.0 = Release|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Release|x64.ActiveCfg = Release|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Release|x64.Build.0 = Release|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Release|x86.ActiveCfg = Release|Any CPU + {F793AC13-0500-492A-914D-4229F6AE0687}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/HotChocolate/Data/src/AutoMapper/AutoMapperQueryableExtensions.cs b/src/HotChocolate/Data/src/AutoMapper/AutoMapperQueryableExtensions.cs new file mode 100644 index 00000000000..08ef879f0e4 --- /dev/null +++ b/src/HotChocolate/Data/src/AutoMapper/AutoMapperQueryableExtensions.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using HotChocolate.Data.Projections.Expressions; +using HotChocolate.Resolvers; +using static HotChocolate.Data.Projections.Expressions.QueryableProjectionProvider; + +namespace HotChocolate.Data; + +/// +/// Common extensions for automapper and +/// +public static class AutoMapperQueryableExtensions +{ + /// + /// Extension method to project from a queryable using the + /// to project into based on + /// the GraphQL selection. + /// + /// The Queryable that holds the selection + /// The resolver context of the resolver + /// The source type + /// The result type + /// The projected queryable + public static IQueryable ProjectTo( + this IQueryable queryable, + IResolverContext context) + { + IMapper mapper = context.Service(); + + // ensure projections are only applied once + context.LocalContextData = context.LocalContextData.SetItem(SkipProjectionKey, true); + + QueryableProjectionContext visitorContext = + new(context, context.ObjectType, context.Selection.Field.Type.UnwrapRuntimeType()); + + QueryableProjectionVisitor.Default.Visit(visitorContext); + + Expression> projection = visitorContext.Project(); + + return queryable.ProjectTo(mapper.ConfigurationProvider, projection); + } +} diff --git a/src/HotChocolate/Data/src/AutoMapper/HotChocolate.Data.AutoMapper.csproj b/src/HotChocolate/Data/src/AutoMapper/HotChocolate.Data.AutoMapper.csproj new file mode 100644 index 00000000000..0692722d95b --- /dev/null +++ b/src/HotChocolate/Data/src/AutoMapper/HotChocolate.Data.AutoMapper.csproj @@ -0,0 +1,27 @@ + + + + HotChocolate.Data.AutoMapper + HotChocolate.Data.AutoMapper + HotChocolate.Data + $(NoWarn);CA1062 + net6.0; net5.0; netstandard2.1 + net6.0 + Contains extensions for easier integration of AutoMapper into HotChocolate + + + + + + + + + + + + + + + + + diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionContextExtensions.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionContextExtensions.cs index 2e5c1c7d402..f684d011028 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionContextExtensions.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionContextExtensions.cs @@ -42,4 +42,16 @@ public static Expression> Project(this QueryableProjectionContext throw ProjectionConvention_CouldNotProject(); } + + public static Expression> Project( + this QueryableProjectionContext context) + where T : TTarget + { + if (context.TryGetQueryableScope(out QueryableProjectionScope? scope)) + { + return scope.Project(); + } + + throw ProjectionConvention_CouldNotProject(); + } } diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs index ab73ce87e88..5f27d838246 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionScopeExtensions.cs @@ -9,10 +9,27 @@ namespace HotChocolate.Data.Projections.Expressions; public static class QueryableProjectionScopeExtensions { + /// + /// Creates an expression based on the result stored on . + /// + /// The scope that contains the projection information + /// The target type + /// An expression public static Expression> Project(this QueryableProjectionScope scope) - { - return (Expression>)scope.CreateMemberInitLambda(); - } + => (Expression>)scope.CreateMemberInitLambda(); + + /// + /// Creates an expression based on the result stored on . + /// Casts the result onto in the lambda + /// + /// The scope that contains the projection information + /// The target type + /// The target result type of the expression + /// + public static Expression> Project( + this QueryableProjectionScope scope) + where T : TTarget + => (Expression>)scope.CreateMemberInitLambda(); public static Expression CreateMemberInit(this QueryableProjectionScope scope) { @@ -20,8 +37,7 @@ public static Expression CreateMemberInit(this QueryableProjectionScope scope) { Expression lastValue = Expression.Default(scope.RuntimeType); - foreach (KeyValuePair> val in - scope.GetAbstractTypes()) + foreach (KeyValuePair> val in scope.GetAbstractTypes()) { NewExpression ctor = Expression.New(val.Key); Expression memberInit = Expression.MemberInit(ctor, val.Value); @@ -46,6 +62,12 @@ public static Expression CreateMemberInitLambda(this QueryableProjectionScope sc return Expression.Lambda(scope.CreateMemberInit(), scope.Parameter); } + private static Expression CreateMemberInitLambda(this QueryableProjectionScope scope) + { + Expression converted = Expression.Convert(scope.CreateMemberInit(), typeof(T)); + return Expression.Lambda(converted, scope.Parameter); + } + public static Expression CreateSelection( this QueryableProjectionScope scope, Expression source, @@ -56,8 +78,8 @@ public static Expression CreateSelection( nameof(Enumerable.Select), new[] { - scope.RuntimeType, - scope.RuntimeType + scope.RuntimeType, + scope.RuntimeType }, source, scope.CreateMemberInitLambda()); @@ -82,7 +104,7 @@ private static Expression ToArray(QueryableProjectionScope scope, Expression sou nameof(Enumerable.ToArray), new[] { - scope.RuntimeType + scope.RuntimeType }, source); } @@ -94,7 +116,7 @@ private static Expression ToList(QueryableProjectionScope scope, Expression sour nameof(Enumerable.ToList), new[] { - scope.RuntimeType + scope.RuntimeType }, source); } @@ -109,7 +131,7 @@ private static Expression ToSet( ConstructorInfo? ctor = typedGeneric.GetConstructor(new[] { - source.Type + source.Type }); if (ctor is null) diff --git a/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionVisitor.cs b/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionVisitor.cs index 807f6580f93..2dd1fd49034 100644 --- a/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionVisitor.cs +++ b/src/HotChocolate/Data/src/Data/Projections/Expressions/QueryableProjectionVisitor.cs @@ -42,4 +42,6 @@ protected override ISelectionVisitorAction VisitObjectType( return base.VisitObjectType(field, objectType, selectionSet, context); } + + public static readonly QueryableProjectionVisitor Default = new(); } diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/AutomapperTests.cs b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/AutomapperTests.cs new file mode 100644 index 00000000000..f94905650a2 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/AutomapperTests.cs @@ -0,0 +1,531 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AutoMapper; +using HotChocolate.Execution; +using HotChocolate.Resolvers; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace HotChocolate.Data.Projections; + +public class ProjectToTests +{ + private static readonly Blog[] _blogEntries = + { + new() + { + Name = "TestA", + Url = "testa.com", + Author = + new Author() + { + Name = "Phil", + Membership = new PremiumMember { Name = "foo", Premium = "A" } + }, + TitleImage = new Image() { Url = "https://testa.com/image.png" }, + Posts = new[] + { + new Post + { + Title = "titleA", + Content = "contentA", + Author = new Author() + { + Name = "Anna", + Membership = + new StandardMember() { Name = "foo", Standard = "FLAT" } + } + }, + new Post + { + Title = "titleB", + Content = "contentB", + Author = new Author() + { + Name = "Max", + Membership = + new StandardMember() { Name = "foo", Standard = "FLAT" } + } + } + } + }, + new() + { + Name = "TestB", + Url = "testb.com", + TitleImage = new Image() { Url = "https://testb.com/image.png" }, + Author = new Author() + { + Name = "Kurt", + Membership = + new StandardMember() { Name = "foo", Standard = "FLAT" } + }, + Posts = new[] + { + new Post + { + Title = "titleC", + Content = "contentC", + Author = new Author() + { + Name = "Charles", + Membership = + new PremiumMember { Name = "foo", Premium = "FLAT" } + } + }, + new Post + { + Title = "titleD", + Content = "contentD", + Author = new Author() + { + Name = "Simone", + Membership = + new PremiumMember { Name = "foo", Premium = "FLAT" } + } + } + } + }, + }; + + [Fact] + public async Task Execute_ManyToOne() + { + // arrange + IRequestExecutor tester = await CreateSchema(); + + // act + // assert + IExecutionResult res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + @" + { + posts { + postId + title + blog { + url + } + } + }") + .Create()); + + res1.MatchSqlSnapshot(); + } + + [Fact] + public async Task Execute_ManyToOne_Deep() + { + // arrange + IRequestExecutor tester = await CreateSchema(); + + // act + // assert + IExecutionResult res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + @" + query Test { + posts { + postId + title + blog { + url + posts { + title + blog { + url + posts { + title + } + } + } + } + } + }") + .Create()); + + res1.MatchSqlSnapshot(); + } + + [Fact] + public async Task Execute_OneToOne() + { + // arrange + IRequestExecutor tester = await CreateSchema(); + + // act + // assert + IExecutionResult res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + @" + { + blogs { + url + titleImage { + url + } + } + }") + .Create()); + + res1.MatchSqlSnapshot(); + } + + [Fact] + public async Task Execute_OneToOne_Deep() + { + // arrange + IRequestExecutor tester = await CreateSchema(); + + // act + // assert + IExecutionResult res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + @" + query Test { + posts { + postId + title + blog { + url + titleImage { + url + } + } + } + }") + .Create()); + + res1.MatchSqlSnapshot(); + } + + [Fact(Skip = "Automapper does not understand abstract mappings like we need it to")] + public async Task Execute_Derived_CompleteSelectionSet() + { + // arrange + IRequestExecutor tester = await CreateSchema(); + + // act + // assert + IExecutionResult res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + @" + query Test { + members { + name + ... on PremiumMemberDto { premium } + ... on StandardMemberDto { standard } + } + }") + .Create()); + + res1.MatchSqlSnapshot(); + } + + [Fact(Skip = "Automapper does not understand abstract mappings like we need it to")] + public async Task Execute_Derived_PartialSelectionSet() + { + // arrange + IRequestExecutor tester = await CreateSchema(); + + // act + // assert + IExecutionResult res1 = await tester.ExecuteAsync( + QueryRequestBuilder.New() + .SetQuery( + @" + query Test { + members { + name + ... on PremiumMemberDto { premium } + } + }") + .Create()); + + res1.MatchSqlSnapshot(); + } + + public async ValueTask CreateSchema() + { + IServiceCollection services = new ServiceCollection(); + services.AddPooledDbContextFactory(x + => x.UseSqlite($"Data Source={Guid.NewGuid():N}.db")); + var mapperConfig = new MapperConfiguration(mc => + { + mc.AddProfile(new PostProfile()); + mc.AddProfile(new BlogProfile()); + mc.AddProfile(new ImageProfile()); + mc.AddProfile(new MembershipProfile()); + mc.AddProfile(new AuthorProfile()); + }); + + IMapper mapper = mapperConfig.CreateMapper(); + services.AddSingleton(sp => + { + // abusing the mapper factory to add to the database. You didnt see this. + BloggingContext context = + sp.GetRequiredService>().CreateDbContext(); + context.Database.EnsureCreated(); + context.Blogs.AddRange(_blogEntries); + context.SaveChanges(); + + return mapper; + }); + + return await services + .AddGraphQL() + .AddQueryType() + .AddInterfaceType() + .AddType() + .AddType() + .RegisterDbContext(DbContextKind.Pooled) + .AddProjections() + .UseSqlLogging() + .BuildRequestExecutorAsync(); + } + + public class Query + { + [UseSqlLogging] + [UseProjection] + public IQueryable GetPosts(BloggingContext dbContext, IResolverContext context) + => dbContext.Posts.ProjectTo(context); + + [UseSqlLogging] + [UseProjection] + public IQueryable GetBlogs(BloggingContext dbContext, IResolverContext context) + => dbContext.Blogs.ProjectTo(context); + + [UseSqlLogging] + [UseProjection] + public IQueryable GetAuthors(BloggingContext dbContext, IResolverContext context) + => dbContext.Authors.ProjectTo(context); + + [UseSqlLogging] + [UseProjection] + public IQueryable GetImages(BloggingContext dbContext, IResolverContext context) + => dbContext.Images.ProjectTo(context); + + [UseSqlLogging] + [UseProjection] + public IQueryable GetMembers( + BloggingContext dbContext, + IResolverContext context) + => dbContext.Memberships.ProjectTo(context); + } + + + public class BloggingContext : DbContext + { + public DbSet Blogs { get; set; } = default!; + + public DbSet Posts { get; set; } = default!; + + public DbSet Authors { get; set; } = default!; + + public DbSet Images { get; set; } = default!; + + public DbSet Memberships { get; set; } = default!; + + + public BloggingContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasDiscriminator("d") + .HasValue("premium") + .HasValue("standard"); + } + } + + public class PostDto + { + public int PostId { get; set; } + + public string? Title { get; set; } + + public string? Content { get; set; } + + public int BlogId { get; set; } + + public AuthorDto? Author { get; set; } + + public BlogDto? Blog { get; set; } + } + + public class Blog + { + public int BlogId { get; set; } + + public string? Name { get; set; } + + public string? Url { get; set; } + + public int AuthorId { get; set; } + + public Author? Author { get; set; } + + public int ImageId { get; set; } + + public Image? TitleImage { get; set; } + + public ICollection? Posts { get; set; } + } + + public class Author + { + public int AuthorId { get; set; } + + public string? Name { get; set; } + + public ICollection Posts { get; set; } + + public int MembershipId { get; set; } + public Membership? Membership { get; set; } + + public ICollection Blogs { get; set; } + } + + public class Image + { + public int ImageId { get; set; } + + public string? Url { get; set; } + + + public Post? Post { get; set; } + } + + public class ImageDto + { + public string? Url { get; set; } + + public PostDto? Post { get; set; } + } + + public class BlogDto + { + public int BlogId { get; set; } + + public string? Name { get; set; } + + public string? Url { get; set; } + + public ICollection? Posts { get; set; } + + public AuthorDto? Author { get; set; } + + public ImageDto? TitleImage { get; set; } + } + + public class AuthorDto + { + public string? Name { get; set; } + + public ICollection? Posts { get; set; } + + public ICollection? Blogs { get; set; } + } + + public class Post + { + public int? PostId { get; set; } + + public string? Title { get; set; } + + public string? Content { get; set; } + + public int? BlogId { get; set; } + + public Blog? Blog { get; set; } + + public int? AuthorId { get; set; } + + public Author? Author { get; set; } + } + + public class Membership + { + public int MembershipId { get; set; } + + public string? Name { get; set; } + } + + public class PremiumMember : Membership + { + public string? Premium { get; set; } + } + + public class StandardMember : Membership + { + public string? Standard { get; set; } + } + + public class MembershipDto + { + public string? Name { get; set; } + } + + public class PremiumMemberDto : MembershipDto + { + public string? Premium { get; set; } + } + + public class StandardMemberDto : MembershipDto + { + public string? Standard { get; set; } + } + + public class PostProfile : Profile + { + public PostProfile() + { + CreateMap().ForAllMembers(x => x.ExplicitExpansion()); + } + } + + public class BlogProfile : Profile + { + public BlogProfile() + { + CreateMap().ForAllMembers(x => x.ExplicitExpansion()); + } + } + + public class AuthorProfile : Profile + { + public AuthorProfile() + { + CreateMap().ForAllMembers(x => x.ExplicitExpansion()); + } + } + + public class ImageProfile : Profile + { + public ImageProfile() + { + CreateMap().ForAllMembers(x => x.ExplicitExpansion()); + } + } + + public class MembershipProfile : Profile + { + public MembershipProfile() + { + CreateMap(); + CreateMap(); + CreateMap().IncludeAllDerived(); + } + } +} diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/HotChocolate.Data.AutoMapper.Tests.csproj b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/HotChocolate.Data.AutoMapper.Tests.csproj new file mode 100644 index 00000000000..dc55d32a78e --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/HotChocolate.Data.AutoMapper.Tests.csproj @@ -0,0 +1,34 @@ + + + + HotChocolate.Data.AutoMapper.Tests + HotChocolate.Data.AutoMapper + net6.0; net5.0 + net6.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/RequestExecutorBuilderExtensions.cs b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/RequestExecutorBuilderExtensions.cs new file mode 100644 index 00000000000..0aeac4130dc --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/RequestExecutorBuilderExtensions.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; +using HotChocolate.Execution; +using HotChocolate.Execution.Configuration; +using HotChocolate.Types; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Data.Projections; + +public static class RequestExecutorBuilderExtensions +{ + public static IObjectFieldDescriptor UseSqlLogging(this IObjectFieldDescriptor descriptor) + { + return descriptor + .Use( + next => async context => + { + await next(context); + + if (context.Result is IQueryable queryable) + { + try + { + context.ContextData["sql"] = queryable.ToQueryString(); + context.ContextData["expression"] = queryable.Expression.Print(); + } + catch (Exception ex) + { + context.ContextData["sql"] = ex.Message; + } + } + }); + } + + public static IRequestExecutorBuilder UseSqlLogging(this IRequestExecutorBuilder builder) + { + return builder + .UseRequest( + next => async context => + { + await next(context); + if (context.Result is IReadOnlyQueryResult result && + context.ContextData.TryGetValue("sql", out object? queryString)&& + context.ContextData.TryGetValue("expression", out object? expression)) + { + context.Result = + QueryResultBuilder + .FromResult(result) + .SetContextData("sql", queryString) + .SetContextData("expression", expression) + .Create(); + } + }) + .UseDefaultPipeline(); + } +} diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/TestExtensions.cs b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/TestExtensions.cs new file mode 100644 index 00000000000..366c269a6df --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/TestExtensions.cs @@ -0,0 +1,45 @@ +using HotChocolate.Execution; +using HotChocolate.Tests; +using Snapshooter; +using Snapshooter.Xunit; + +namespace HotChocolate.Data.Projections; + +public static class TestExtensions +{ + public static void MatchSqlSnapshot( + this IExecutionResult? result, + string snapshotName = "") + { +#if NET5_0 + const string postfix = "_NET5_0"; +#elif NET6_0 + const string postfix = "_NET6_0"; +#else + const string postfix = ""; +#endif + if (result is null) + { + return; + } + + result.ToJson().MatchSnapshot(new SnapshotNameExtension(snapshotName + postfix)); + + if (result.ContextData is null) + { + return; + } + + if (result.ContextData.TryGetValue("sql", out var value)) + { + SnapshotNameExtension extension = new(snapshotName + "sql" + postfix); + value.MatchSnapshot(extension); + } + + if (result.ContextData.TryGetValue("expression", out value)) + { + SnapshotNameExtension extension = new(snapshotName + "expression" + postfix); + value.MatchSnapshot(extension); + } + } +} diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/UseSqlLoggingAttribute.cs b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/UseSqlLoggingAttribute.cs new file mode 100644 index 00000000000..134214f2cec --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/UseSqlLoggingAttribute.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using HotChocolate.Types; +using HotChocolate.Types.Descriptors; + +namespace HotChocolate.Data.Projections; + +public class UseSqlLoggingAttribute : ObjectFieldDescriptorAttribute +{ + public override void OnConfigure( + IDescriptorContext context, + IObjectFieldDescriptor descriptor, + MemberInfo member) + => descriptor.UseSqlLogging(); +} diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_Deep__NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_Deep__NET6_0.snap new file mode 100644 index 00000000000..8b36312f3aa --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_Deep__NET6_0.snap @@ -0,0 +1,74 @@ +{ + "data": { + "posts": [ + { + "postId": 1, + "title": "titleA", + "blog": { + "url": "testa.com", + "posts": [ + { + "title": "titleA", + "blog": null + }, + { + "title": "titleB", + "blog": null + } + ] + } + }, + { + "postId": 2, + "title": "titleB", + "blog": { + "url": "testa.com", + "posts": [ + { + "title": "titleA", + "blog": null + }, + { + "title": "titleB", + "blog": null + } + ] + } + }, + { + "postId": 3, + "title": "titleC", + "blog": { + "url": "testb.com", + "posts": [ + { + "title": "titleC", + "blog": null + }, + { + "title": "titleD", + "blog": null + } + ] + } + }, + { + "postId": 4, + "title": "titleD", + "blog": { + "url": "testb.com", + "posts": [ + { + "title": "titleC", + "blog": null + }, + { + "title": "titleD", + "blog": null + } + ] + } + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_Deep_expression_NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_Deep_expression_NET6_0.snap new file mode 100644 index 00000000000..321cc24c264 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_Deep_expression_NET6_0.snap @@ -0,0 +1,17 @@ +DbSet() + .Select(dtoPost => new PostDto{ + Blog = dtoPost.Blog == null ? null : new BlogDto{ + Posts = dtoPost.Blog.Posts + .Select(dtoPost => new PostDto{ + PostId = dtoPost.PostId ?? 0, + Title = dtoPost.Title + } + ) + .ToList(), + Url = dtoPost.Blog.Url + } + , + PostId = dtoPost.PostId ?? 0, + Title = dtoPost.Title + } + ) diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_Deep_sql_NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_Deep_sql_NET6_0.snap new file mode 100644 index 00000000000..7f953d094a0 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_Deep_sql_NET6_0.snap @@ -0,0 +1,5 @@ +SELECT "b"."BlogId" IS NULL, "p"."PostId", "b"."BlogId", COALESCE("p0"."PostId", 0), "p0"."Title", "p0"."PostId", "b"."Url", COALESCE("p"."PostId", 0), "p"."Title" +FROM "Posts" AS "p" +LEFT JOIN "Blogs" AS "b" ON "p"."BlogId" = "b"."BlogId" +LEFT JOIN "Posts" AS "p0" ON "b"."BlogId" = "p0"."BlogId" +ORDER BY "p"."PostId", "b"."BlogId" diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne__NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne__NET6_0.snap new file mode 100644 index 00000000000..c153f9a641a --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne__NET6_0.snap @@ -0,0 +1,34 @@ +{ + "data": { + "posts": [ + { + "postId": 1, + "title": "titleA", + "blog": { + "url": "testa.com" + } + }, + { + "postId": 2, + "title": "titleB", + "blog": { + "url": "testa.com" + } + }, + { + "postId": 3, + "title": "titleC", + "blog": { + "url": "testb.com" + } + }, + { + "postId": 4, + "title": "titleD", + "blog": { + "url": "testb.com" + } + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_expression_NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_expression_NET6_0.snap new file mode 100644 index 00000000000..fa3506567de --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_expression_NET6_0.snap @@ -0,0 +1,8 @@ +DbSet() + .Select(dtoPost => new PostDto{ + Blog = dtoPost.Blog == null ? null : new BlogDto{ Url = dtoPost.Blog.Url } + , + PostId = dtoPost.PostId ?? 0, + Title = dtoPost.Title + } + ) diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_sql_NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_sql_NET6_0.snap new file mode 100644 index 00000000000..233031cbe6b --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_ManyToOne_sql_NET6_0.snap @@ -0,0 +1,3 @@ +SELECT "b"."BlogId" IS NULL, "b"."Url", COALESCE("p"."PostId", 0), "p"."Title" +FROM "Posts" AS "p" +LEFT JOIN "Blogs" AS "b" ON "p"."BlogId" = "b"."BlogId" diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_Deep__NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_Deep__NET6_0.snap new file mode 100644 index 00000000000..e52ae91fab7 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_Deep__NET6_0.snap @@ -0,0 +1,46 @@ +{ + "data": { + "posts": [ + { + "postId": 1, + "title": "titleA", + "blog": { + "url": "testa.com", + "titleImage": { + "url": "https://testa.com/image.png" + } + } + }, + { + "postId": 2, + "title": "titleB", + "blog": { + "url": "testa.com", + "titleImage": { + "url": "https://testa.com/image.png" + } + } + }, + { + "postId": 3, + "title": "titleC", + "blog": { + "url": "testb.com", + "titleImage": { + "url": "https://testb.com/image.png" + } + } + }, + { + "postId": 4, + "title": "titleD", + "blog": { + "url": "testb.com", + "titleImage": { + "url": "https://testb.com/image.png" + } + } + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_Deep_expression_NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_Deep_expression_NET6_0.snap new file mode 100644 index 00000000000..fe0399f2ad5 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_Deep_expression_NET6_0.snap @@ -0,0 +1,12 @@ +DbSet() + .Select(dtoPost => new PostDto{ + Blog = dtoPost.Blog == null ? null : new BlogDto{ + TitleImage = dtoPost.Blog.TitleImage == null ? null : new ImageDto{ Url = dtoPost.Blog.TitleImage.Url } + , + Url = dtoPost.Blog.Url + } + , + PostId = dtoPost.PostId ?? 0, + Title = dtoPost.Title + } + ) diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_Deep_sql_NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_Deep_sql_NET6_0.snap new file mode 100644 index 00000000000..8d380acbb1d --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_Deep_sql_NET6_0.snap @@ -0,0 +1,4 @@ +SELECT "b"."BlogId" IS NULL, "i"."ImageId" IS NULL, "i"."Url", "b"."Url", COALESCE("p"."PostId", 0), "p"."Title" +FROM "Posts" AS "p" +LEFT JOIN "Blogs" AS "b" ON "p"."BlogId" = "b"."BlogId" +LEFT JOIN "Images" AS "i" ON "b"."ImageId" = "i"."ImageId" diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne__NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne__NET6_0.snap new file mode 100644 index 00000000000..40d12f013a4 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne__NET6_0.snap @@ -0,0 +1,18 @@ +{ + "data": { + "blogs": [ + { + "url": "testa.com", + "titleImage": { + "url": "https://testa.com/image.png" + } + }, + { + "url": "testb.com", + "titleImage": { + "url": "https://testb.com/image.png" + } + } + ] + } +} diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_expression_NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_expression_NET6_0.snap new file mode 100644 index 00000000000..d25a410b9c1 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_expression_NET6_0.snap @@ -0,0 +1,7 @@ +DbSet() + .Select(dtoBlog => new BlogDto{ + TitleImage = dtoBlog.TitleImage == null ? null : new ImageDto{ Url = dtoBlog.TitleImage.Url } + , + Url = dtoBlog.Url + } + ) diff --git a/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_sql_NET6_0.snap b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_sql_NET6_0.snap new file mode 100644 index 00000000000..47007bb7828 --- /dev/null +++ b/src/HotChocolate/Data/test/Data.AutoMapper.Tests/__snapshots__/ProjectToTests.Execute_OneToOne_sql_NET6_0.snap @@ -0,0 +1,3 @@ +SELECT 0, "i"."Url", "b"."Url" +FROM "Blogs" AS "b" +INNER JOIN "Images" AS "i" ON "b"."ImageId" = "i"."ImageId"