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"