From a07484570f806a5d891d13ea33d616c804a72184 Mon Sep 17 00:00:00 2001 From: Mohamed Nazem <109486038+nazem0@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:07:18 +0300 Subject: [PATCH 01/17] feat: add IQueryable DTO projection support to read-only application services --- .../Services/AbstractKeyReadOnlyAppService.cs | 50 +++++++++++++++++-- .../Services/ReadOnlyAppService.cs | 11 +++- .../ObjectMapping/IQueryProjectionMapper.cs | 8 +++ .../ObjectMapping/QueryProjectionMapper.cs | 7 +++ 4 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs create mode 100644 framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs index 3791c4202f..0c53ac2705 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs @@ -44,6 +44,18 @@ public abstract class AbstractKeyReadOnlyAppService? ObjectProjectionMapper => + LazyServiceProvider.LazyGetService>(); + + protected virtual IQueryProjectionMapper? ListProjectionMapper => + LazyServiceProvider.LazyGetService>(); + + protected virtual bool UseObjectProjectionMapper => + ObjectProjectionMapper != null; + + protected virtual bool UseListProjectionMapper => + ListProjectionMapper != null; + protected AbstractKeyReadOnlyAppService(IReadOnlyRepository repository) { ReadOnlyRepository = repository; @@ -53,6 +65,19 @@ public abstract class AbstractKeyReadOnlyAppService(); var entityDtos = new List(); if (totalCount > 0) @@ -73,8 +97,19 @@ public abstract class AbstractKeyReadOnlyAppService( @@ -85,6 +120,13 @@ public abstract class AbstractKeyReadOnlyAppService GetEntityByIdAsync(TKey id); + protected virtual Task> GetEntityByIdQueryAsync(TKey id) + { + throw new NotImplementedException( + "Override this method to create the query used for getting an entity by id." + ); + } + protected virtual async Task CheckGetPolicyAsync() { await CheckPolicyAsync(GetPolicyName); @@ -227,4 +269,4 @@ public abstract class AbstractKeyReadOnlyAppService(entity); } -} +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs index 867ca35ad4..0cfbdee673 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs @@ -37,7 +37,7 @@ public abstract class ReadOnlyAppService Repository { get; } protected ReadOnlyAppService(IReadOnlyRepository repository) - : base(repository) + : base(repository) { Repository = repository; } @@ -47,6 +47,13 @@ public abstract class ReadOnlyAppService> GetEntityByIdQueryAsync(TKey id) + { + var query = await Repository.GetQueryableAsync(); + + return query.Where(e => e.Id.Equals(id)); + } + protected override IQueryable ApplyDefaultSorting(IQueryable query) { if (typeof(TEntity).IsAssignableTo()) @@ -58,4 +65,4 @@ public abstract class ReadOnlyAppService e.Id); } } -} +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs new file mode 100644 index 0000000000..b40ea1c198 --- /dev/null +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs @@ -0,0 +1,8 @@ +using System.Linq; +using Volo.Abp.DependencyInjection; +namespace Volo.Abp.ObjectMapping; + +public interface IQueryProjectionMapper : ITransientDependency +{ + IQueryable ProjectTo(IQueryable source); +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs new file mode 100644 index 0000000000..96e9c98c3a --- /dev/null +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs @@ -0,0 +1,7 @@ +using System.Linq; +namespace Volo.Abp.ObjectMapping; + +public abstract class QueryProjectionMapper : IQueryProjectionMapper +{ + public abstract IQueryable ProjectTo(IQueryable source); +} \ No newline at end of file From 9b371db770ae0b3865b2b93353e6cbfa07ee6900 Mon Sep 17 00:00:00 2001 From: Mohamed Nazem <109486038+nazem0@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:00:53 +0300 Subject: [PATCH 02/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Volo/Abp/ObjectMapping/QueryProjectionMapper.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs index 96e9c98c3a..95fd3b02dd 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs @@ -1,4 +1,5 @@ using System.Linq; + namespace Volo.Abp.ObjectMapping; public abstract class QueryProjectionMapper : IQueryProjectionMapper From 2ce16a7bb8b2ca8612c37c87d7c36f3f6aab4b1e Mon Sep 17 00:00:00 2001 From: Mohamed Nazem <109486038+nazem0@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:06:57 +0300 Subject: [PATCH 03/17] GetEntityByIdQueryAsync : null check --- .../Volo/Abp/Application/Services/ReadOnlyAppService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs index 0cfbdee673..eab432239c 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs @@ -51,7 +51,7 @@ public abstract class ReadOnlyAppService e.Id.Equals(id)); + return query.Where(e =>e.Id != null && e.Id.Equals(id)); } protected override IQueryable ApplyDefaultSorting(IQueryable query) From 98d54c33dca97ff382159d44444a229b05308b18 Mon Sep 17 00:00:00 2001 From: Mohamed Nazem <109486038+nazem0@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:08:24 +0300 Subject: [PATCH 04/17] space between namespace and usings --- .../Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs | 1 + .../Volo/Abp/ObjectMapping/QueryProjectionMapper.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs index b40ea1c198..5fe9f955b8 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs @@ -1,5 +1,6 @@ using System.Linq; using Volo.Abp.DependencyInjection; + namespace Volo.Abp.ObjectMapping; public interface IQueryProjectionMapper : ITransientDependency diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs index 95fd3b02dd..23bfb21141 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs @@ -1,5 +1,5 @@ using System.Linq; - + namespace Volo.Abp.ObjectMapping; public abstract class QueryProjectionMapper : IQueryProjectionMapper From 632addf8e84491c54cf4ca1a19cdac595403f214 Mon Sep 17 00:00:00 2001 From: Mohamed Nazem <109486038+nazem0@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:35:23 +0300 Subject: [PATCH 05/17] checking Use(Object/List)ProjectionMapper before resolving the mapper --- .../Services/AbstractKeyReadOnlyAppService.cs | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs index 0c53ac2705..f70953b2ac 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs @@ -44,11 +44,9 @@ public abstract class AbstractKeyReadOnlyAppService? ObjectProjectionMapper => - LazyServiceProvider.LazyGetService>(); + protected virtual IQueryProjectionMapper? ObjectProjectionMapper { get; } - protected virtual IQueryProjectionMapper? ListProjectionMapper => - LazyServiceProvider.LazyGetService>(); + protected virtual IQueryProjectionMapper? ListProjectionMapper { get; } protected virtual bool UseObjectProjectionMapper => ObjectProjectionMapper != null; @@ -59,20 +57,28 @@ public abstract class AbstractKeyReadOnlyAppService repository) { ReadOnlyRepository = repository; + + ObjectProjectionMapper = + LazyServiceProvider.LazyGetService< + IQueryProjectionMapper>(); + + ListProjectionMapper = + LazyServiceProvider.LazyGetService< + IQueryProjectionMapper>(); } public virtual async Task GetAsync(TKey id) { await CheckGetPolicyAsync(); - var projectionMapper = ObjectProjectionMapper; - - if (UseObjectProjectionMapper && projectionMapper != null) + if (UseObjectProjectionMapper && ObjectProjectionMapper != null) { var query = await GetEntityByIdQueryAsync(id); - var dto = - await AsyncExecuter.FirstOrDefaultAsync(projectionMapper.ProjectTo(query)) + var dto = + await AsyncExecuter.FirstOrDefaultAsync( + ObjectProjectionMapper.ProjectTo(query) + ) ?? throw new EntityNotFoundException(typeof(TEntity), id); return dto; @@ -97,12 +103,10 @@ public abstract class AbstractKeyReadOnlyAppService Date: Wed, 19 Aug 2026 18:19:20 +0300 Subject: [PATCH 06/17] revert : checking Use(Object/List)ProjectionMapper before resolving the mapper --- .../Services/AbstractKeyReadOnlyAppService.cs | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs index f70953b2ac..0c53ac2705 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs @@ -44,9 +44,11 @@ public abstract class AbstractKeyReadOnlyAppService? ObjectProjectionMapper { get; } + protected virtual IQueryProjectionMapper? ObjectProjectionMapper => + LazyServiceProvider.LazyGetService>(); - protected virtual IQueryProjectionMapper? ListProjectionMapper { get; } + protected virtual IQueryProjectionMapper? ListProjectionMapper => + LazyServiceProvider.LazyGetService>(); protected virtual bool UseObjectProjectionMapper => ObjectProjectionMapper != null; @@ -57,28 +59,20 @@ public abstract class AbstractKeyReadOnlyAppService repository) { ReadOnlyRepository = repository; - - ObjectProjectionMapper = - LazyServiceProvider.LazyGetService< - IQueryProjectionMapper>(); - - ListProjectionMapper = - LazyServiceProvider.LazyGetService< - IQueryProjectionMapper>(); } public virtual async Task GetAsync(TKey id) { await CheckGetPolicyAsync(); - if (UseObjectProjectionMapper && ObjectProjectionMapper != null) + var projectionMapper = ObjectProjectionMapper; + + if (UseObjectProjectionMapper && projectionMapper != null) { var query = await GetEntityByIdQueryAsync(id); - var dto = - await AsyncExecuter.FirstOrDefaultAsync( - ObjectProjectionMapper.ProjectTo(query) - ) + var dto = + await AsyncExecuter.FirstOrDefaultAsync(projectionMapper.ProjectTo(query)) ?? throw new EntityNotFoundException(typeof(TEntity), id); return dto; @@ -103,10 +97,12 @@ public abstract class AbstractKeyReadOnlyAppService Date: Wed, 19 Aug 2026 18:40:32 +0300 Subject: [PATCH 07/17] refactor: remove redundant query projection mapper base class --- .../Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs | 8 +++++++- .../Volo/Abp/ObjectMapping/QueryProjectionMapper.cs | 8 -------- 2 files changed, 7 insertions(+), 9 deletions(-) delete mode 100644 framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs index 5fe9f955b8..98deb9e4c4 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs @@ -6,4 +6,10 @@ namespace Volo.Abp.ObjectMapping; public interface IQueryProjectionMapper : ITransientDependency { IQueryable ProjectTo(IQueryable source); -} \ No newline at end of file +} + +//[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] +//public partial class GetSectorMapper : IQueryProjectionMapper +//{ +// public partial IQueryable ProjectTo(IQueryable source); +//} \ No newline at end of file diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs deleted file mode 100644 index 23bfb21141..0000000000 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Linq; - -namespace Volo.Abp.ObjectMapping; - -public abstract class QueryProjectionMapper : IQueryProjectionMapper -{ - public abstract IQueryable ProjectTo(IQueryable source); -} \ No newline at end of file From 029cc7de47904925754bc5807f17f3b324ace049 Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 20 Aug 2026 10:27:11 +0800 Subject: [PATCH 08/17] Fix query projection for CrudAppService and register projection mappers * Fall back to GetEntityByIdAsync when a by-id query can not be created * Expose IQueryProjectionMapper implementations like IObjectMapper does * Document query projection and cover EF Core, MongoDB and Mapperly --- .../application-services.md | 51 ++++++++- .../Services/AbstractKeyReadOnlyAppService.cs | 61 +++++----- .../Application/Services/CrudAppService.cs | 7 ++ .../Services/ReadOnlyAppService.cs | 8 +- .../ObjectMapping/AbpObjectMappingModule.cs | 8 ++ .../ObjectMapping/IQueryProjectionMapper.cs | 38 ++++--- .../Services/QueryProjection/Book.cs | 23 ++++ .../BookAbstractKeyAppService.cs | 27 +++++ .../QueryProjection/BookAppService.cs | 13 +++ .../BookCustomizedAppService.cs | 28 +++++ .../Services/QueryProjection/BookDto.cs | 9 ++ .../QueryProjection/BookLiteAppService.cs | 13 +++ .../Services/QueryProjection/BookLiteDto.cs | 9 ++ .../QueryProjection/BookObjectMapper.cs | 36 ++++++ .../Services/QueryProjection/BookProjector.cs | 18 +++ .../QueryProjection/BookReadOnlyAppService.cs | 13 +++ .../QueryProjection/BookRepository.cs | 106 ++++++++++++++++++ .../BookWithoutProjectionAppService.cs | 18 +++ .../QueryProjection/QueryProjection_Tests.cs | 103 +++++++++++++++++ .../EntityWithIntPkProjectionAppService.cs | 14 +++ .../EntityWithIntPkProjectionDto.cs | 8 ++ .../Applications/EntityWithIntPkProjector.cs | 17 +++ .../PersonProjectionAppService.cs | 15 +++ .../Applications/PersonProjectionDto.cs | 9 ++ .../Applications/PersonProjector.cs | 17 +++ .../Applications/QueryProjection_Tests.cs | 66 +++++++++++ .../AbpMapperlyQueryProjection_Tests.cs | 31 +++++ .../SampleClasses/MyEntityQueryProjector.cs | 11 ++ .../PersonProjectionAppService.cs | 15 +++ .../Applications/PersonProjectionDto.cs | 9 ++ .../MongoDB/Applications/PersonProjector.cs | 17 +++ .../Applications/QueryProjection_Tests.cs | 46 ++++++++ 32 files changed, 813 insertions(+), 51 deletions(-) create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/Book.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyAppService.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAppService.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDto.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteAppService.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteDto.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookReadOnlyAppService.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookRepository.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionAppService.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionDto.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionAppService.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionDto.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs create mode 100644 framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs create mode 100644 framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs create mode 100644 framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionAppService.cs create mode 100644 framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionDto.cs create mode 100644 framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs create mode 100644 framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/QueryProjection_Tests.cs diff --git a/docs/en/framework/architecture/domain-driven-design/application-services.md b/docs/en/framework/architecture/domain-driven-design/application-services.md index 8683241534..74ae2adf50 100644 --- a/docs/en/framework/architecture/domain-driven-design/application-services.md +++ b/docs/en/framework/architecture/domain-driven-design/application-services.md @@ -1,4 +1,4 @@ -```json +```json //[doc-seo] { "Description": "Learn how to implement application services in the ABP Framework to expose domain logic and streamline presentation layer interactions." @@ -444,6 +444,7 @@ These methods are low level methods that can control how to query entities from * `ApplyPaging` is used to make paging on the query. If your `TGetListInput` already implements `IPagedResultRequest`, you don't need to override this since the ABP automatically understands it and performs the paging. * `ApplySorting` is used to sort (order by...) the query. If your `TGetListInput` already implements the `ISortedResultRequest`, ABP automatically sorts the query. If not, it fallbacks to the `ApplyDefaultSorting` which tries to sort by creation time, if your entity implements the standard `IHasCreationTime` interface. * `GetEntityByIdAsync` is used to get an entity by id, which calls `Repository.GetAsync(id)` by default. +* `GetEntityByIdQueryOrNullAsync` is used to create a query for a single entity by id, which is only needed for the *Query Projection* explained below. It returns `null` if the application service can not create such a query, then `GetEntityByIdAsync` is used. * `DeleteByIdAsync` is used to delete an entity by id, which calls `Repository.DeleteAsync(id)` by default. #### Object to Object Mapping @@ -456,6 +457,54 @@ These methods are used to convert Entities to DTOs and vice verse. They use the * `MapToEntityAsync(TCreateInput)` is used to create an entity from `TCreateInput`. * `MapToEntityAsync(TUpdateInput, TEntity)` is used to update an existing entity from `TUpdateInput`. +#### Query Projection + +`GetAsync` and `GetListAsync` get the entities from the database, then map them to DTOs in the memory. If your DTO uses only a few properties of a large entity, you can project the query to the DTO instead, so the database returns only the columns you need. + +Implement the `IQueryProjectionMapper` interface to define a projection: + +````csharp +using System.Linq; +using Volo.Abp.ObjectMapping; + +namespace MyProject.Books; + +public class BookProjector : IQueryProjectionMapper +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(book => new BookDto + { + Id = book.Id, + Name = book.Name + }); + } +} +```` + +[Mapperly](https://mapperly.riok.app/) can generate that method for you: + +````csharp +[Mapper] +public partial class BookProjector : IQueryProjectionMapper +{ + public partial IQueryable ProjectTo(IQueryable source); +} +```` + +ABP registers the projection mappers by convention, you don't need to configure anything else. Filters (like soft delete and multi-tenancy), sorting and paging are still applied to the query before the projection. + +> The projection replaces the entity based extension points. `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync`, `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore. If an application service needs to keep using them, override the `GetProjectionMapper` or `GetListProjectionMapper` property and return `null`: + +````csharp +public class BookAppService : CrudAppService +{ + protected override IQueryProjectionMapper? GetProjectionMapper => null; + + //... +} +```` + ## Miscellaneous ### Working with Streams diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs index 0c53ac2705..72f6946930 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs @@ -44,17 +44,19 @@ public abstract class AbstractKeyReadOnlyAppService? ObjectProjectionMapper => - LazyServiceProvider.LazyGetService>(); - - protected virtual IQueryProjectionMapper? ListProjectionMapper => - LazyServiceProvider.LazyGetService>(); - - protected virtual bool UseObjectProjectionMapper => - ObjectProjectionMapper != null; + /// + /// and are not used + /// while a projection mapper is available. Override and return null to keep using them. + /// + protected virtual IQueryProjectionMapper? GetProjectionMapper + => LazyServiceProvider.LazyGetService>(); - protected virtual bool UseListProjectionMapper => - ListProjectionMapper != null; + /// + /// is not used while a projection mapper is + /// available. Override and return null to keep using it. + /// + protected virtual IQueryProjectionMapper? GetListProjectionMapper + => LazyServiceProvider.LazyGetService>(); protected AbstractKeyReadOnlyAppService(IReadOnlyRepository repository) { @@ -65,17 +67,15 @@ public abstract class AbstractKeyReadOnlyAppService(id); + } } var entity = await GetEntityByIdAsync(id); @@ -97,13 +97,10 @@ public abstract class AbstractKeyReadOnlyAppService GetEntityByIdAsync(TKey id); - protected virtual Task> GetEntityByIdQueryAsync(TKey id) + /// + /// Returns null if this application service can not create a query for a single entity. + /// is used in that case. + /// + protected virtual Task?> GetEntityByIdQueryOrNullAsync(TKey id) { - throw new NotImplementedException( - "Override this method to create the query used for getting an entity by id." - ); + return Task.FromResult?>(null); } protected virtual async Task CheckGetPolicyAsync() @@ -269,4 +268,4 @@ public abstract class AbstractKeyReadOnlyAppService(entity); } -} \ No newline at end of file +} diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs index 672d85a2be..e47514c257 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs @@ -84,6 +84,13 @@ public abstract class CrudAppService?> GetEntityByIdQueryOrNullAsync(TKey id) + { + var query = await Repository.GetQueryableAsync(); + + return query.Where(EntityHelper.CreateEqualityExpressionForId(id)); + } + protected override void MapToEntity(TUpdateInput updateInput, TEntity entity) { if (updateInput is IEntityDto entityDto) diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs index eab432239c..791085f0a4 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs @@ -37,7 +37,7 @@ public abstract class ReadOnlyAppService Repository { get; } protected ReadOnlyAppService(IReadOnlyRepository repository) - : base(repository) + : base(repository) { Repository = repository; } @@ -47,11 +47,11 @@ public abstract class ReadOnlyAppService> GetEntityByIdQueryAsync(TKey id) + protected override async Task?> GetEntityByIdQueryOrNullAsync(TKey id) { var query = await Repository.GetQueryableAsync(); - return query.Where(e =>e.Id != null && e.Id.Equals(id)); + return query.Where(EntityHelper.CreateEqualityExpressionForId(id)); } protected override IQueryable ApplyDefaultSorting(IQueryable query) @@ -65,4 +65,4 @@ public abstract class ReadOnlyAppService e.Id); } } -} \ No newline at end of file +} diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs index e572d6e155..92a67bced9 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs @@ -18,6 +18,14 @@ public class AbpObjectMappingModule : AbpModule typeof(IObjectMapper<,>) ).ConvertAll(t => new ServiceIdentifier(t)) ); + + //Register types for IQueryProjectionMapper if implements + onServiceExposingContext.ExposedTypes.AddRange( + ReflectionHelper.GetImplementedGenericTypes( + onServiceExposingContext.ImplementationType, + typeof(IQueryProjectionMapper<,>) + ).ConvertAll(t => new ServiceIdentifier(t)) + ); }); } diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs index 98deb9e4c4..ff41c67a56 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs @@ -1,15 +1,23 @@ -using System.Linq; -using Volo.Abp.DependencyInjection; - -namespace Volo.Abp.ObjectMapping; - -public interface IQueryProjectionMapper : ITransientDependency -{ - IQueryable ProjectTo(IQueryable source); -} - -//[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] -//public partial class GetSectorMapper : IQueryProjectionMapper -//{ -// public partial IQueryable ProjectTo(IQueryable source); -//} \ No newline at end of file +using System.Linq; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.ObjectMapping; + +/// +/// Projects a query of objects to a query of +/// objects. +/// Implement this interface to let the query provider translate the projection into the data +/// store's own query language, instead of loading the source objects into the memory. +/// +/// Type of the source objects +/// Type of the destination objects +public interface IQueryProjectionMapper : ITransientDependency +{ + /// + /// Projects the given query to a query of objects. + /// The returned query must be built on top of , so the query + /// provider can still translate it. + /// + /// The query to project + IQueryable ProjectTo(IQueryable source); +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/Book.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/Book.cs new file mode 100644 index 0000000000..6211160df4 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/Book.cs @@ -0,0 +1,23 @@ +using System; +using Volo.Abp.Domain.Entities; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class Book : Entity +{ + public string Name { get; set; } = default!; + + public int Price { get; set; } + + public Book() + { + + } + + public Book(Guid id, string name, int price) + : base(id) + { + Name = name; + Price = price; + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyAppService.cs new file mode 100644 index 0000000000..1110ff841e --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyAppService.cs @@ -0,0 +1,27 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookAbstractKeyAppService : AbstractKeyReadOnlyAppService +{ + public BookAbstractKeyAppService(IReadOnlyRepository repository) + : base(repository) + { + + } + + protected override async Task GetEntityByIdAsync(Guid id) + { + var query = await ReadOnlyRepository.GetQueryableAsync(); + + return await AsyncExecuter.FirstAsync(query, book => book.Id == id); + } + + protected override IQueryable ApplyDefaultSorting(IQueryable query) + { + return query.OrderBy(book => book.Id); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAppService.cs new file mode 100644 index 0000000000..c3ae530e7d --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAppService.cs @@ -0,0 +1,13 @@ +using System; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookAppService : CrudAppService +{ + public BookAppService(IRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs new file mode 100644 index 0000000000..b4c756d6b2 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookCustomizedAppService : CrudAppService +{ + public const string Marker = "-customized"; + + public BookCustomizedAppService(IRepository repository) + : base(repository) + { + + } + + protected override async Task GetEntityByIdAsync(Guid id) + { + var book = await base.GetEntityByIdAsync(id); + book.Name += Marker; + return book; + } + + protected override Task MapToGetOutputDtoAsync(Book entity) + { + return Task.FromResult(new BookDto { Id = entity.Id, Name = entity.Name + Marker }); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDto.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDto.cs new file mode 100644 index 0000000000..08a2117ec2 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDto.cs @@ -0,0 +1,9 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookDto : EntityDto +{ + public string Name { get; set; } = default!; +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteAppService.cs new file mode 100644 index 0000000000..c5b28e058b --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteAppService.cs @@ -0,0 +1,13 @@ +using System; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookLiteAppService : CrudAppService +{ + public BookLiteAppService(IRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteDto.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteDto.cs new file mode 100644 index 0000000000..7ab0408c85 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteDto.cs @@ -0,0 +1,9 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookLiteDto : EntityDto +{ + public string Name { get; set; } = default!; +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs new file mode 100644 index 0000000000..f5668b9c29 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs @@ -0,0 +1,36 @@ +using Volo.Abp.DependencyInjection; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookObjectMapper : + IObjectMapper, + IObjectMapper, + ITransientDependency +{ + public const string Marker = "-mapped"; + + public BookDto Map(Book source) + { + return new BookDto { Id = source.Id, Name = source.Name + Marker }; + } + + public BookDto Map(Book source, BookDto destination) + { + destination.Id = source.Id; + destination.Name = source.Name + Marker; + return destination; + } + + BookLiteDto IObjectMapper.Map(Book source) + { + return new BookLiteDto { Id = source.Id, Name = source.Name + Marker }; + } + + BookLiteDto IObjectMapper.Map(Book source, BookLiteDto destination) + { + destination.Id = source.Id; + destination.Name = source.Name + Marker; + return destination; + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs new file mode 100644 index 0000000000..89342525ac --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs @@ -0,0 +1,18 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookProjector : IQueryProjectionMapper +{ + public const string Marker = "-projected"; + + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(book => new BookDto + { + Id = book.Id, + Name = book.Name + Marker + }); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookReadOnlyAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookReadOnlyAppService.cs new file mode 100644 index 0000000000..9a80d741fb --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookReadOnlyAppService.cs @@ -0,0 +1,13 @@ +using System; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookReadOnlyAppService : ReadOnlyAppService +{ + public BookReadOnlyAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookRepository.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookRepository.cs new file mode 100644 index 0000000000..75da4e0da6 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookRepository.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +[ExposeServices( + typeof(IRepository), + typeof(IReadOnlyRepository), + typeof(IReadOnlyRepository))] +public class BookRepository : RepositoryBase, ISingletonDependency +{ + private readonly List _books = new(); + + public BookRepository() + : base("InMemory") + { + + } + + public override Task> GetQueryableAsync() + { + return Task.FromResult(_books.AsQueryable()); + } + + [Obsolete("Use GetQueryableAsync method.")] + protected override IQueryable GetQueryable() + { + return _books.AsQueryable(); + } + + public override Task GetAsync(Guid id, bool includeDetails = true, CancellationToken cancellationToken = default) + { + var book = _books.FirstOrDefault(x => x.Id == id); + if (book == null) + { + throw new EntityNotFoundException(typeof(Book), id); + } + + return Task.FromResult(book); + } + + public override Task FindAsync(Guid id, bool includeDetails = true, CancellationToken cancellationToken = default) + { + return Task.FromResult(_books.FirstOrDefault(x => x.Id == id)); + } + + public override Task FindAsync(Expression> predicate, bool includeDetails = true, CancellationToken cancellationToken = default) + { + return Task.FromResult(_books.AsQueryable().FirstOrDefault(predicate)); + } + + public override Task> GetListAsync(bool includeDetails = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(_books.ToList()); + } + + public override Task> GetListAsync(Expression> predicate, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(_books.AsQueryable().Where(predicate).ToList()); + } + + public override Task> GetPagedListAsync(int skipCount, int maxResultCount, string sorting, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(_books.Skip(skipCount).Take(maxResultCount).ToList()); + } + + public override Task GetCountAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult((long)_books.Count); + } + + public override Task InsertAsync(Book entity, bool autoSave = false, CancellationToken cancellationToken = default) + { + _books.Add(entity); + return Task.FromResult(entity); + } + + public override Task UpdateAsync(Book entity, bool autoSave = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(entity); + } + + public override Task DeleteAsync(Book entity, bool autoSave = false, CancellationToken cancellationToken = default) + { + _books.Remove(entity); + return Task.CompletedTask; + } + + public override Task DeleteAsync(Expression> predicate, bool autoSave = false, CancellationToken cancellationToken = default) + { + _books.RemoveAll(new Predicate(predicate.Compile())); + return Task.CompletedTask; + } + + public override Task DeleteDirectAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + return DeleteAsync(predicate); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs new file mode 100644 index 0000000000..f061781282 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs @@ -0,0 +1,18 @@ +using System; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookWithoutProjectionAppService : CrudAppService +{ + protected override IQueryProjectionMapper GetProjectionMapper => null; + + protected override IQueryProjectionMapper GetListProjectionMapper => null; + + public BookWithoutProjectionAppService(IRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs new file mode 100644 index 0000000000..3f01c930ba --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.ObjectMapping; +using Xunit; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class QueryProjection_Tests : AbpDddApplicationTestBase +{ + private readonly Guid _bookId = Guid.NewGuid(); + + public QueryProjection_Tests() + { + var repository = GetRequiredService>(); + repository.InsertAsync(new Book(_bookId, "Hitchhiker's Guide", 42)).GetAwaiter().GetResult(); + } + + [Fact] + public void Should_Resolve_Projection_Mapper_Independent_From_The_Class_Name() + { + ServiceProvider.GetService>() + .ShouldBeOfType(); + } + + [Fact] + public async Task Should_Project_On_The_Query_For_CrudAppService() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookProjector.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookProjector.Marker); + } + + [Fact] + public async Task Should_Project_On_The_Query_For_ReadOnlyAppService() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookProjector.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookProjector.Marker); + } + + [Fact] + public async Task Should_Throw_EntityNotFoundException_If_The_Projected_Entity_Does_Not_Exist() + { + var appService = GetRequiredService(); + + await Should.ThrowAsync(async () => await appService.GetAsync(Guid.NewGuid())); + } + + [Fact] + public async Task Should_Use_The_Object_Mapper_If_No_Projection_Mapper_Was_Registered() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookObjectMapper.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookObjectMapper.Marker); + } + + [Fact] + public async Task Should_Use_The_Object_Mapper_If_The_Projection_Was_Disabled() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookObjectMapper.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookObjectMapper.Marker); + } + + [Fact] + public async Task Should_Not_Use_The_Entity_Based_Overrides_While_Projecting() + { + var appService = GetRequiredService(); + + //A projection mapper is registered for , so GetEntityByIdAsync and + //MapToGetOutputDtoAsync of this application service are skipped by design. + var dto = await appService.GetAsync(_bookId); + + dto.Name.ShouldEndWith(BookProjector.Marker); + dto.Name.ShouldNotContain(BookCustomizedAppService.Marker); + } + + [Fact] + public async Task Should_Use_The_Object_Mapper_If_The_Entity_Query_Can_Not_Be_Created() + { + var appService = GetRequiredService(); + + //GetAsync has no query to project, it falls back to GetEntityByIdAsync + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookObjectMapper.Marker); + + //GetListAsync always has a query, so it is still projected + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookProjector.Marker); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionAppService.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionAppService.cs new file mode 100644 index 0000000000..cc6176cddd --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionAppService.cs @@ -0,0 +1,14 @@ +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class EntityWithIntPkProjectionAppService : ReadOnlyAppService +{ + public EntityWithIntPkProjectionAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionDto.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionDto.cs new file mode 100644 index 0000000000..f6bbebe850 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionDto.cs @@ -0,0 +1,8 @@ +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class EntityWithIntPkProjectionDto : EntityDto +{ + public string Name { get; set; } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs new file mode 100644 index 0000000000..752dbd9bc8 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs @@ -0,0 +1,17 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class EntityWithIntPkProjector : IQueryProjectionMapper +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(entity => new EntityWithIntPkProjectionDto + { + Id = entity.Id, + Name = entity.Name + }); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionAppService.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionAppService.cs new file mode 100644 index 0000000000..fa51e9407f --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionAppService.cs @@ -0,0 +1,15 @@ +using System; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class PersonProjectionAppService : ReadOnlyAppService +{ + public PersonProjectionAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionDto.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionDto.cs new file mode 100644 index 0000000000..57eef129d0 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionDto.cs @@ -0,0 +1,9 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class PersonProjectionDto : EntityDto +{ + public string Name { get; set; } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs new file mode 100644 index 0000000000..bc817e2499 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs @@ -0,0 +1,17 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class PersonProjector : IQueryProjectionMapper +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(person => new PersonProjectionDto + { + Id = person.Id, + Name = person.Name + }); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs new file mode 100644 index 0000000000..832a1502e4 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.TestApp; +using Volo.Abp.TestApp.Domain; +using Xunit; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class QueryProjection_Tests : EntityFrameworkCoreTestBase +{ + private readonly PersonProjectionAppService _personProjectionAppService; + + public QueryProjection_Tests() + { + _personProjectionAppService = GetRequiredService(); + } + + [Fact] + public async Task Should_Get_A_Projected_Entity() + { + var dto = await _personProjectionAppService.GetAsync(TestDataBuilder.UserDouglasId); + + dto.Id.ShouldBe(TestDataBuilder.UserDouglasId); + dto.Name.ShouldBe("Douglas"); + } + + [Fact] + public async Task Should_Throw_EntityNotFoundException_For_A_Missing_Entity() + { + (await Should.ThrowAsync>( + async () => await _personProjectionAppService.GetAsync(Guid.NewGuid())) + ).EntityType.ShouldBe(typeof(Person)); + } + + [Fact] + public async Task Should_Get_A_Projected_List() + { + var result = await _personProjectionAppService.GetListAsync(new PagedAndSortedResultRequestDto()); + + result.TotalCount.ShouldBeGreaterThan(0); + result.Items.Count.ShouldBe((int)result.TotalCount); + result.Items.ShouldContain(x => x.Name == "Douglas"); + } + + [Fact] + public async Task Should_Get_A_Projected_Entity_With_A_Non_Guid_Key() + { + var appService = GetRequiredService(); + var entity = await WithUnitOfWorkAsync( + async () => await GetRequiredService>().FirstAsync()); + + (await appService.GetAsync(entity.Id)).Name.ShouldBe(entity.Name); + } + + [Fact] + public async Task Should_Apply_The_Soft_Delete_Filter_While_Projecting() + { + var result = await _personProjectionAppService.GetListAsync(new PagedAndSortedResultRequestDto()); + + result.Items.ShouldNotContain(x => x.Id == TestDataBuilder.UserJohnDeletedId); + } +} diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs new file mode 100644 index 0000000000..a66baaf503 --- /dev/null +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Mapperly.SampleClasses; +using Volo.Abp.ObjectMapping; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.Mapperly; + +public class AbpMapperlyQueryProjection_Tests : AbpIntegratedTest +{ + [Fact] + public void Should_Project_A_Queryable() + { + var projectionMapper = ServiceProvider.GetRequiredService>(); + + var entities = new List + { + new MyEntity { Id = Guid.NewGuid(), Number = 42 } + }.AsQueryable(); + + var dtos = projectionMapper.ProjectTo(entities).ToList(); + + dtos.Count.ShouldBe(1); + dtos[0].Id.ShouldBe(entities.First().Id); + dtos[0].Number.ShouldBe(42); + } +} diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs new file mode 100644 index 0000000000..80d984e11b --- /dev/null +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs @@ -0,0 +1,11 @@ +using System.Linq; +using Riok.Mapperly.Abstractions; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Mapperly.SampleClasses; + +[Mapper] +public partial class MyEntityQueryProjector : IQueryProjectionMapper +{ + public partial IQueryable ProjectTo(IQueryable source); +} diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionAppService.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionAppService.cs new file mode 100644 index 0000000000..d625b518e8 --- /dev/null +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionAppService.cs @@ -0,0 +1,15 @@ +using System; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.MongoDB.Applications; + +public class PersonProjectionAppService : ReadOnlyAppService +{ + public PersonProjectionAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionDto.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionDto.cs new file mode 100644 index 0000000000..2a8e716a8c --- /dev/null +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionDto.cs @@ -0,0 +1,9 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.MongoDB.Applications; + +public class PersonProjectionDto : EntityDto +{ + public string Name { get; set; } +} diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs new file mode 100644 index 0000000000..9640f9f45f --- /dev/null +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs @@ -0,0 +1,17 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.MongoDB.Applications; + +public class PersonProjector : IQueryProjectionMapper +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(person => new PersonProjectionDto + { + Id = person.Id, + Name = person.Name + }); + } +} diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/QueryProjection_Tests.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/QueryProjection_Tests.cs new file mode 100644 index 0000000000..a7bf1367a6 --- /dev/null +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/QueryProjection_Tests.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Entities; +using Volo.Abp.TestApp; +using Volo.Abp.TestApp.Domain; +using Xunit; + +namespace Volo.Abp.MongoDB.Applications; + +[Collection(MongoTestCollection.Name)] +public class QueryProjection_Tests : MongoDbTestBase +{ + private readonly PersonProjectionAppService _personProjectionAppService; + + public QueryProjection_Tests() + { + _personProjectionAppService = GetRequiredService(); + } + + [Fact] + public async Task Should_Get_A_Projected_Entity() + { + var dto = await _personProjectionAppService.GetAsync(TestDataBuilder.UserDouglasId); + + dto.Id.ShouldBe(TestDataBuilder.UserDouglasId); + dto.Name.ShouldBe("Douglas"); + } + + [Fact] + public async Task Should_Throw_EntityNotFoundException_For_A_Missing_Entity() + { + await Should.ThrowAsync>( + async () => await _personProjectionAppService.GetAsync(Guid.NewGuid())); + } + + [Fact] + public async Task Should_Get_A_Projected_List() + { + var result = await _personProjectionAppService.GetListAsync(new PagedAndSortedResultRequestDto()); + + result.TotalCount.ShouldBeGreaterThan(0); + result.Items.ShouldContain(x => x.Name == "Douglas"); + } +} From f00d7df56cbb3c516373fa4ae615604888e67b76 Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 20 Aug 2026 10:53:53 +0800 Subject: [PATCH 09/17] Assert the projected SQL and fix the Mapperly sample in the docs * RequiredMappingStrategy.Target avoids an RMG020 warning per unmapped entity property --- .../application-services.md | 4 +++- .../Applications/QueryProjection_Tests.cs | 22 +++++++++++++++++++ .../SampleClasses/MyEntityQueryProjector.cs | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/en/framework/architecture/domain-driven-design/application-services.md b/docs/en/framework/architecture/domain-driven-design/application-services.md index 74ae2adf50..d1c0186f6d 100644 --- a/docs/en/framework/architecture/domain-driven-design/application-services.md +++ b/docs/en/framework/architecture/domain-driven-design/application-services.md @@ -485,13 +485,15 @@ public class BookProjector : IQueryProjectionMapper [Mapperly](https://mapperly.riok.app/) can generate that method for you: ````csharp -[Mapper] +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] public partial class BookProjector : IQueryProjectionMapper { public partial IQueryable ProjectTo(IQueryable source); } ```` +> `RequiredMappingStrategy.Target` tells Mapperly to only require the DTO members to be mapped. Without it, it reports a warning for every entity property that the DTO doesn't have. + ABP registers the projection mappers by convention, you don't need to configure anything else. Filters (like soft delete and multi-tenancy), sorting and paging are still applied to the query before the projection. > The projection replaces the entity based extension points. `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync`, `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore. If an application service needs to keep using them, override the `GetProjectionMapper` or `GetListProjectionMapper` property and return `null`: diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs index 832a1502e4..c833a2515a 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs @@ -1,9 +1,12 @@ using System; +using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using Shouldly; using Volo.Abp.Application.Dtos; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; +using Volo.Abp.ObjectMapping; using Volo.Abp.TestApp; using Volo.Abp.TestApp.Domain; using Xunit; @@ -63,4 +66,23 @@ public class QueryProjection_Tests : EntityFrameworkCoreTestBase result.Items.ShouldNotContain(x => x.Id == TestDataBuilder.UserJohnDeletedId); } + + [Fact] + public async Task Should_Only_Select_The_Projected_Columns() + { + await WithUnitOfWorkAsync(async () => + { + var repository = GetRequiredService>(); + var projector = GetRequiredService>(); + + var sql = projector.ProjectTo(await repository.GetQueryableAsync()).ToQueryString(); + + sql.ShouldContain("\"Name\""); + sql.ShouldNotContain("\"Birthday\""); + sql.ShouldNotContain("\"ExtraProperties\""); + + //the data filters are still a part of the query + sql.ShouldContain("Is_Deleted"); + }); + } } diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs index 80d984e11b..da57936cf6 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs @@ -4,7 +4,7 @@ using Volo.Abp.ObjectMapping; namespace Volo.Abp.Mapperly.SampleClasses; -[Mapper] +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] public partial class MyEntityQueryProjector : IQueryProjectionMapper { public partial IQueryable ProjectTo(IQueryable source); From e3127db7e01304daa0dba71e5bc55584f6c63d7f Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 20 Aug 2026 11:05:22 +0800 Subject: [PATCH 10/17] Point to the mapping libraries for the query projection sample --- .../domain-driven-design/application-services.md | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/docs/en/framework/architecture/domain-driven-design/application-services.md b/docs/en/framework/architecture/domain-driven-design/application-services.md index d1c0186f6d..2aab4cf35a 100644 --- a/docs/en/framework/architecture/domain-driven-design/application-services.md +++ b/docs/en/framework/architecture/domain-driven-design/application-services.md @@ -482,17 +482,7 @@ public class BookProjector : IQueryProjectionMapper } ```` -[Mapperly](https://mapperly.riok.app/) can generate that method for you: - -````csharp -[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] -public partial class BookProjector : IQueryProjectionMapper -{ - public partial IQueryable ProjectTo(IQueryable source); -} -```` - -> `RequiredMappingStrategy.Target` tells Mapperly to only require the DTO members to be mapped. Without it, it reports a warning for every entity property that the DTO doesn't have. +You don't have to write the `Select` by hand. Both [Mapperly](https://mapperly.riok.app/) and [AutoMapper](https://docs.automapper.org) can project an `IQueryable`, refer to their own documentation for it and to the [object to object mapping document](../../infrastructure/object-to-object-mapping.md) for their ABP integrations. ABP registers the projection mappers by convention, you don't need to configure anything else. Filters (like soft delete and multi-tenancy), sorting and paging are still applied to the query before the projection. From d6876b85717f266ee6351233fd8d98cbd8c99e6c Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 20 Aug 2026 11:26:52 +0800 Subject: [PATCH 11/17] Rename the query projection API * IQueryableMapper pairs with IObjectMapper, CreateEntityQueryAsync with CreateFilteredQueryAsync --- .../application-services.md | 10 +++--- .../Services/AbstractKeyReadOnlyAppService.cs | 31 ++++++++++--------- .../Application/Services/CrudAppService.cs | 2 +- .../Services/ReadOnlyAppService.cs | 2 +- .../ObjectMapping/AbpObjectMappingModule.cs | 4 +-- .../ObjectMapping/IQueryProjectionMapper.cs | 23 -------------- .../Abp/ObjectMapping/IQueryableMapper.cs | 21 +++++++++++++ .../Services/QueryProjection/BookProjector.cs | 2 +- .../BookWithoutProjectionAppService.cs | 4 +-- .../QueryProjection/QueryProjection_Tests.cs | 4 +-- .../Applications/EntityWithIntPkProjector.cs | 2 +- .../Applications/PersonProjector.cs | 2 +- .../Applications/QueryProjection_Tests.cs | 2 +- .../AbpMapperlyQueryProjection_Tests.cs | 2 +- .../SampleClasses/MyEntityQueryProjector.cs | 2 +- .../MongoDB/Applications/PersonProjector.cs | 2 +- 16 files changed, 57 insertions(+), 58 deletions(-) delete mode 100644 framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs create mode 100644 framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryableMapper.cs diff --git a/docs/en/framework/architecture/domain-driven-design/application-services.md b/docs/en/framework/architecture/domain-driven-design/application-services.md index 2aab4cf35a..5c548820aa 100644 --- a/docs/en/framework/architecture/domain-driven-design/application-services.md +++ b/docs/en/framework/architecture/domain-driven-design/application-services.md @@ -444,7 +444,7 @@ These methods are low level methods that can control how to query entities from * `ApplyPaging` is used to make paging on the query. If your `TGetListInput` already implements `IPagedResultRequest`, you don't need to override this since the ABP automatically understands it and performs the paging. * `ApplySorting` is used to sort (order by...) the query. If your `TGetListInput` already implements the `ISortedResultRequest`, ABP automatically sorts the query. If not, it fallbacks to the `ApplyDefaultSorting` which tries to sort by creation time, if your entity implements the standard `IHasCreationTime` interface. * `GetEntityByIdAsync` is used to get an entity by id, which calls `Repository.GetAsync(id)` by default. -* `GetEntityByIdQueryOrNullAsync` is used to create a query for a single entity by id, which is only needed for the *Query Projection* explained below. It returns `null` if the application service can not create such a query, then `GetEntityByIdAsync` is used. +* `CreateEntityQueryAsync` is used to create a query for a single entity by id, which is only needed for the *Query Projection* explained below. It returns `null` if the application service can not create such a query, then `GetEntityByIdAsync` is used. * `DeleteByIdAsync` is used to delete an entity by id, which calls `Repository.DeleteAsync(id)` by default. #### Object to Object Mapping @@ -461,7 +461,7 @@ These methods are used to convert Entities to DTOs and vice verse. They use the `GetAsync` and `GetListAsync` get the entities from the database, then map them to DTOs in the memory. If your DTO uses only a few properties of a large entity, you can project the query to the DTO instead, so the database returns only the columns you need. -Implement the `IQueryProjectionMapper` interface to define a projection: +Implement the `IQueryableMapper` interface to define a projection: ````csharp using System.Linq; @@ -469,7 +469,7 @@ using Volo.Abp.ObjectMapping; namespace MyProject.Books; -public class BookProjector : IQueryProjectionMapper +public class BookProjector : IQueryableMapper { public IQueryable ProjectTo(IQueryable source) { @@ -486,12 +486,12 @@ You don't have to write the `Select` by hand. Both [Mapperly](https://mapperly.r ABP registers the projection mappers by convention, you don't need to configure anything else. Filters (like soft delete and multi-tenancy), sorting and paging are still applied to the query before the projection. -> The projection replaces the entity based extension points. `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync`, `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore. If an application service needs to keep using them, override the `GetProjectionMapper` or `GetListProjectionMapper` property and return `null`: +> The projection replaces the entity based extension points. `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync`, `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore. If an application service needs to keep using them, override the `GetQueryableMapper` or `GetListQueryableMapper` property and return `null`: ````csharp public class BookAppService : CrudAppService { - protected override IQueryProjectionMapper? GetProjectionMapper => null; + protected override IQueryableMapper? GetQueryableMapper => null; //... } diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs index 72f6946930..d2cba1c4e6 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs @@ -45,18 +45,20 @@ public abstract class AbstractKeyReadOnlyAppService - /// and are not used - /// while a projection mapper is available. Override and return null to keep using them. + /// Used by the to project the query to the . + /// It returns the registered mapper or null by default. + /// The and the are not used when it returns a mapper. /// - protected virtual IQueryProjectionMapper? GetProjectionMapper - => LazyServiceProvider.LazyGetService>(); + protected virtual IQueryableMapper? GetQueryableMapper + => LazyServiceProvider.LazyGetService>(); /// - /// is not used while a projection mapper is - /// available. Override and return null to keep using it. + /// Used by the to project the query to the . + /// It returns the registered mapper or null by default. + /// The is not used when it returns a mapper. /// - protected virtual IQueryProjectionMapper? GetListProjectionMapper - => LazyServiceProvider.LazyGetService>(); + protected virtual IQueryableMapper? GetListQueryableMapper + => LazyServiceProvider.LazyGetService>(); protected AbstractKeyReadOnlyAppService(IReadOnlyRepository repository) { @@ -67,10 +69,10 @@ public abstract class AbstractKeyReadOnlyAppService GetEntityByIdAsync(TKey id); /// - /// Returns null if this application service can not create a query for a single entity. - /// is used in that case. + /// Should create a query that selects the entity with the given . + /// It returns null by default, then the is used instead of the projection. /// - protected virtual Task?> GetEntityByIdQueryOrNullAsync(TKey id) + /// The id of the entity. + protected virtual Task?> CreateEntityQueryAsync(TKey id) { return Task.FromResult?>(null); } diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs index e47514c257..196a4a0e6d 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs @@ -84,7 +84,7 @@ public abstract class CrudAppService?> GetEntityByIdQueryOrNullAsync(TKey id) + protected override async Task?> CreateEntityQueryAsync(TKey id) { var query = await Repository.GetQueryableAsync(); diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs index 791085f0a4..efcb456212 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs @@ -47,7 +47,7 @@ public abstract class ReadOnlyAppService?> GetEntityByIdQueryOrNullAsync(TKey id) + protected override async Task?> CreateEntityQueryAsync(TKey id) { var query = await Repository.GetQueryableAsync(); diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs index 92a67bced9..f0c412fd48 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs @@ -19,11 +19,11 @@ public class AbpObjectMappingModule : AbpModule ).ConvertAll(t => new ServiceIdentifier(t)) ); - //Register types for IQueryProjectionMapper if implements + //Register types for IQueryableMapper if implements onServiceExposingContext.ExposedTypes.AddRange( ReflectionHelper.GetImplementedGenericTypes( onServiceExposingContext.ImplementationType, - typeof(IQueryProjectionMapper<,>) + typeof(IQueryableMapper<,>) ).ConvertAll(t => new ServiceIdentifier(t)) ); }); diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs deleted file mode 100644 index ff41c67a56..0000000000 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Linq; -using Volo.Abp.DependencyInjection; - -namespace Volo.Abp.ObjectMapping; - -/// -/// Projects a query of objects to a query of -/// objects. -/// Implement this interface to let the query provider translate the projection into the data -/// store's own query language, instead of loading the source objects into the memory. -/// -/// Type of the source objects -/// Type of the destination objects -public interface IQueryProjectionMapper : ITransientDependency -{ - /// - /// Projects the given query to a query of objects. - /// The returned query must be built on top of , so the query - /// provider can still translate it. - /// - /// The query to project - IQueryable ProjectTo(IQueryable source); -} diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryableMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryableMapper.cs new file mode 100644 index 0000000000..38cd45bef7 --- /dev/null +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryableMapper.cs @@ -0,0 +1,21 @@ +using System.Linq; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.ObjectMapping; + +/// +/// Maps a query to another. +/// Implement this interface to project a query on the data store side, instead of loading the +/// source objects into the memory and mapping them one by one. +/// +/// Type of the source objects +/// Type of the destination objects +public interface IQueryableMapper : ITransientDependency +{ + /// + /// Projects the given query. The returned query must be built on top of it, otherwise the + /// query provider can not translate the projection. + /// + /// The query to project + IQueryable ProjectTo(IQueryable source); +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs index 89342525ac..f72dc2ada0 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs @@ -3,7 +3,7 @@ using Volo.Abp.ObjectMapping; namespace Volo.Abp.Application.Services.QueryProjection; -public class BookProjector : IQueryProjectionMapper +public class BookProjector : IQueryableMapper { public const string Marker = "-projected"; diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs index f061781282..d6837edc6c 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs @@ -6,9 +6,9 @@ namespace Volo.Abp.Application.Services.QueryProjection; public class BookWithoutProjectionAppService : CrudAppService { - protected override IQueryProjectionMapper GetProjectionMapper => null; + protected override IQueryableMapper GetQueryableMapper => null; - protected override IQueryProjectionMapper GetListProjectionMapper => null; + protected override IQueryableMapper GetListQueryableMapper => null; public BookWithoutProjectionAppService(IRepository repository) : base(repository) diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs index 3f01c930ba..348cad0728 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs @@ -23,7 +23,7 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase [Fact] public void Should_Resolve_Projection_Mapper_Independent_From_The_Class_Name() { - ServiceProvider.GetService>() + ServiceProvider.GetService>() .ShouldBeOfType(); } @@ -80,8 +80,6 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase { var appService = GetRequiredService(); - //A projection mapper is registered for , so GetEntityByIdAsync and - //MapToGetOutputDtoAsync of this application service are skipped by design. var dto = await appService.GetAsync(_bookId); dto.Name.ShouldEndWith(BookProjector.Marker); diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs index 752dbd9bc8..81f19b495d 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs @@ -4,7 +4,7 @@ using Volo.Abp.TestApp.Domain; namespace Volo.Abp.EntityFrameworkCore.Applications; -public class EntityWithIntPkProjector : IQueryProjectionMapper +public class EntityWithIntPkProjector : IQueryableMapper { public IQueryable ProjectTo(IQueryable source) { diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs index bc817e2499..2ede252731 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs @@ -4,7 +4,7 @@ using Volo.Abp.TestApp.Domain; namespace Volo.Abp.EntityFrameworkCore.Applications; -public class PersonProjector : IQueryProjectionMapper +public class PersonProjector : IQueryableMapper { public IQueryable ProjectTo(IQueryable source) { diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs index c833a2515a..bcc96c4730 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs @@ -73,7 +73,7 @@ public class QueryProjection_Tests : EntityFrameworkCoreTestBase await WithUnitOfWorkAsync(async () => { var repository = GetRequiredService>(); - var projector = GetRequiredService>(); + var projector = GetRequiredService>(); var sql = projector.ProjectTo(await repository.GetQueryableAsync()).ToQueryString(); diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs index a66baaf503..ca2ac86d3a 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs @@ -15,7 +15,7 @@ public class AbpMapperlyQueryProjection_Tests : AbpIntegratedTest>(); + var projectionMapper = ServiceProvider.GetRequiredService>(); var entities = new List { diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs index da57936cf6..d7ad0fb260 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs @@ -5,7 +5,7 @@ using Volo.Abp.ObjectMapping; namespace Volo.Abp.Mapperly.SampleClasses; [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] -public partial class MyEntityQueryProjector : IQueryProjectionMapper +public partial class MyEntityQueryProjector : IQueryableMapper { public partial IQueryable ProjectTo(IQueryable source); } diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs index 9640f9f45f..341ef77d91 100644 --- a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs @@ -4,7 +4,7 @@ using Volo.Abp.TestApp.Domain; namespace Volo.Abp.MongoDB.Applications; -public class PersonProjector : IQueryProjectionMapper +public class PersonProjector : IQueryableMapper { public IQueryable ProjectTo(IQueryable source) { From 3da29b2b359aa983923cb5ec214413e88bb5736f Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 20 Aug 2026 15:51:19 +0800 Subject: [PATCH 12/17] Add asynchronous hooks and rename the query projection API * IQueryProjector replaces IQueryableMapper, the hooks can await other queries to join them * A projection must return one row per entity, the total count and the paging come before it * Pass the ambient cancellation token and detect the missing entity for value type DTOs --- .../Services/AbstractKeyReadOnlyAppService.cs | 87 +++++++++++++----- .../Services/ApplicationService.cs | 3 + .../Application/Services/CrudAppService.cs | 4 +- .../Services/ReadOnlyAppService.cs | 4 +- .../ObjectMapping/AbpObjectMappingModule.cs | 18 ++-- ...IQueryableMapper.cs => IQueryProjector.cs} | 8 +- .../BookAbstractKeyProjectingAppService.cs | 35 ++++++++ .../BookAsyncProjectionAppService.cs | 46 ++++++++++ .../QueryProjection/BookDetailAppService.cs | 15 ++++ .../Services/QueryProjection/BookDetailDto.cs | 9 ++ .../QueryProjection/BookDetailProjector.cs | 18 ++++ .../QueryProjection/BookObjectMapper.cs | 12 +++ .../Services/QueryProjection/BookProjector.cs | 2 +- .../QueryProjection/BookStructAppService.cs | 13 +++ .../Services/QueryProjection/BookStructDto.cs | 10 +++ .../QueryProjection/BookStructProjector.cs | 16 ++++ .../BookWithoutProjectionAppService.cs | 5 +- .../IBookNameSuffixProvider.cs | 19 ++++ .../QueryProjection/QueryProjection_Tests.cs | 86 +++++++++++++++++- .../AbpEntityFrameworkCoreTestModule.cs | 2 + .../Applications/EntityWithIntPkProjector.cs | 2 +- .../Applications/PersonProjector.cs | 2 +- .../Applications/PersonWithCityAppService.cs | 51 +++++++++++ .../Applications/PersonWithCityDto.cs | 11 +++ .../Applications/QueryProjection_Tests.cs | 90 ++++++++++++++++++- .../Applications/SqlCommandCapture.cs | 40 +++++++++ .../AbpMapperlyQueryProjection_Tests.cs | 12 ++- .../SampleClasses/MyEntityQueryProjector.cs | 2 +- .../MongoDB/Applications/PersonProjector.cs | 2 +- 29 files changed, 573 insertions(+), 51 deletions(-) rename framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/{IQueryableMapper.cs => IQueryProjector.cs} (62%) create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyProjectingAppService.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAsyncProjectionAppService.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailAppService.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailDto.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailProjector.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructAppService.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructDto.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructProjector.cs create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/IBookNameSuffixProvider.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityAppService.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs create mode 100644 framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/SqlCommandCapture.cs diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs index d2cba1c4e6..e4980074ba 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs @@ -2,12 +2,14 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Dynamic.Core; +using System.Threading; using System.Threading.Tasks; using Volo.Abp.Application.Dtos; using Volo.Abp.Auditing; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; using Volo.Abp.ObjectMapping; +using Volo.Abp.Threading; namespace Volo.Abp.Application.Services; @@ -45,20 +47,18 @@ public abstract class AbstractKeyReadOnlyAppService - /// Used by the to project the query to the . - /// It returns the registered mapper or null by default. - /// The and the are not used when it returns a mapper. + /// Used by the to project the query to the . + /// The and the are not used while the query is projected. /// - protected virtual IQueryableMapper? GetQueryableMapper - => LazyServiceProvider.LazyGetService>(); + protected virtual IQueryProjector? GetOutputDtoQueryProjector + => LazyServiceProvider.LazyGetService>(); /// - /// Used by the to project the query to the . - /// It returns the registered mapper or null by default. - /// The is not used when it returns a mapper. + /// Used by the to project the query to the . + /// The is not used while the query is projected. /// - protected virtual IQueryableMapper? GetListQueryableMapper - => LazyServiceProvider.LazyGetService>(); + protected virtual IQueryProjector? GetListOutputDtoQueryProjector + => LazyServiceProvider.LazyGetService>(); protected AbstractKeyReadOnlyAppService(IReadOnlyRepository repository) { @@ -69,15 +69,17 @@ public abstract class AbstractKeyReadOnlyAppService(id); + throw new EntityNotFoundException(id); } + + return dtos[0]; } var entity = await GetEntityByIdAsync(id); @@ -90,7 +92,7 @@ public abstract class AbstractKeyReadOnlyAppService(); @@ -99,14 +101,14 @@ public abstract class AbstractKeyReadOnlyAppService GetEntityByIdAsync(TKey id); + protected virtual CancellationToken GetCancellationToken(CancellationToken preferredValue = default) + { + return CancellationTokenProvider.FallbackToProvider(preferredValue); + } + /// /// Should create a query that selects the entity with the given . - /// It returns null by default, then the is used instead of the projection. + /// It returns null by default, then the entity is not projected. /// /// The id of the entity. - protected virtual Task?> CreateEntityQueryAsync(TKey id) + protected virtual Task?> CreateEntityQueryOrNullAsync(TKey id) { return Task.FromResult?>(null); } + /// + /// Projects the query of the entity with the given to the . + /// It uses the and the by default, + /// and the is used when it returns null. + /// Override it to await other queries, like the query of another aggregate root to join. + /// + /// The id of the entity. + protected virtual async Task?> CreateGetOutputDtoQueryOrNullAsync(TKey id) + { + var queryProjector = GetOutputDtoQueryProjector; + if (queryProjector == null) + { + return null; + } + + var query = await CreateEntityQueryOrNullAsync(id); + + return query == null ? null : queryProjector.ProjectTo(query); + } + + /// + /// Projects the given entity query to the . + /// It uses the by default, + /// and the is used when it returns null. + /// Override it to await other queries, like the query of another aggregate root to join. + /// The projection must return one row per entity: the total count is already calculated and the paging is + /// already applied, so adding or removing rows makes the page inconsistent with the total count. + /// + /// The sorted and paged entity query. + protected virtual Task?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable query) + { + return Task.FromResult(GetListOutputDtoQueryProjector?.ProjectTo(query)); + } + protected virtual async Task CheckGetPolicyAsync() { await CheckPolicyAsync(GetPolicyName); diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs index 4eb285df04..01e9e81354 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs @@ -19,6 +19,7 @@ using Volo.Abp.Localization; using Volo.Abp.MultiTenancy; using Volo.Abp.ObjectMapping; using Volo.Abp.Settings; +using Volo.Abp.Threading; using Volo.Abp.Timing; using Volo.Abp.Uow; using Volo.Abp.Users; @@ -48,6 +49,8 @@ public abstract class ApplicationService : protected IAsyncQueryableExecuter AsyncExecuter => LazyServiceProvider.LazyGetRequiredService(); + protected ICancellationTokenProvider CancellationTokenProvider => LazyServiceProvider.LazyGetService(NullCancellationTokenProvider.Instance); + protected Type? ObjectMapperContext { get; set; } protected IObjectMapper ObjectMapper => LazyServiceProvider.LazyGetService(provider => ObjectMapperContext == null diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs index 196a4a0e6d..906b138fba 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs @@ -84,11 +84,11 @@ public abstract class CrudAppService?> CreateEntityQueryAsync(TKey id) + protected override async Task?> CreateEntityQueryOrNullAsync(TKey id) { var query = await Repository.GetQueryableAsync(); - return query.Where(EntityHelper.CreateEqualityExpressionForId(id)); + return query.Where(e => e.Id!.Equals(id)); } protected override void MapToEntity(TUpdateInput updateInput, TEntity entity) diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs index efcb456212..c36d17ffc8 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs @@ -47,11 +47,11 @@ public abstract class ReadOnlyAppService?> CreateEntityQueryAsync(TKey id) + protected override async Task?> CreateEntityQueryOrNullAsync(TKey id) { var query = await Repository.GetQueryableAsync(); - return query.Where(EntityHelper.CreateEqualityExpressionForId(id)); + return query.Where(e => e.Id!.Equals(id)); } protected override IQueryable ApplyDefaultSorting(IQueryable query) diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs index f0c412fd48..87e0c43b56 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; using Volo.Abp.DependencyInjection; using Volo.Abp.Modularity; using Volo.Abp.Reflection; @@ -19,13 +20,14 @@ public class AbpObjectMappingModule : AbpModule ).ConvertAll(t => new ServiceIdentifier(t)) ); - //Register types for IQueryableMapper if implements - onServiceExposingContext.ExposedTypes.AddRange( - ReflectionHelper.GetImplementedGenericTypes( - onServiceExposingContext.ImplementationType, - typeof(IQueryableMapper<,>) - ).ConvertAll(t => new ServiceIdentifier(t)) - ); + //Register types for IQueryProjector if implements + //The class name convention may have already exposed them, so they are not added twice + foreach (var serviceType in ReflectionHelper.GetImplementedGenericTypes( + onServiceExposingContext.ImplementationType, + typeof(IQueryProjector<,>))) + { + onServiceExposingContext.ExposedTypes.AddIfNotContains(new ServiceIdentifier(serviceType)); + } }); } diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryableMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs similarity index 62% rename from framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryableMapper.cs rename to framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs index 38cd45bef7..1abf5667e6 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryableMapper.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs @@ -7,14 +7,16 @@ namespace Volo.Abp.ObjectMapping; /// Maps a query to another. /// Implement this interface to project a query on the data store side, instead of loading the /// source objects into the memory and mapping them one by one. +/// Implement it once for a source and destination pair, the last registered one is used otherwise. /// /// Type of the source objects /// Type of the destination objects -public interface IQueryableMapper : ITransientDependency +public interface IQueryProjector : ITransientDependency { /// - /// Projects the given query. The returned query must be built on top of it, otherwise the - /// query provider can not translate the projection. + /// Projects the given query. The returned query must be built on top of it and must keep its order, + /// with a single destination object for each source object, using expressions the query provider can + /// translate. The caller may have already sorted, paged or counted the source query. /// /// The query to project IQueryable ProjectTo(IQueryable source); diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyProjectingAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyProjectingAppService.cs new file mode 100644 index 0000000000..c6bcf8dbce --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyProjectingAppService.cs @@ -0,0 +1,35 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookAbstractKeyProjectingAppService : AbstractKeyReadOnlyAppService +{ + public BookAbstractKeyProjectingAppService(IReadOnlyRepository repository) + : base(repository) + { + + } + + protected override async Task GetEntityByIdAsync(Guid id) + { + var query = await ReadOnlyRepository.GetQueryableAsync(); + + return await AsyncExecuter.FirstAsync(query, book => book.Id == id); + } + + protected override async Task?> CreateEntityQueryOrNullAsync(Guid id) + { + var query = await ReadOnlyRepository.GetQueryableAsync(); + + return query.Where(book => book.Id == id); + } + + protected override IQueryable ApplyDefaultSorting(IQueryable query) + { + return query.OrderBy(book => book.Id); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAsyncProjectionAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAsyncProjectionAppService.cs new file mode 100644 index 0000000000..21f7516116 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAsyncProjectionAppService.cs @@ -0,0 +1,46 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookAsyncProjectionAppService : CrudAppService +{ + public const string Marker = "-async"; + + private readonly IBookNameSuffixProvider _suffixProvider; + + public BookAsyncProjectionAppService( + IRepository repository, + IBookNameSuffixProvider suffixProvider) + : base(repository) + { + _suffixProvider = suffixProvider; + } + + protected override async Task?> CreateGetOutputDtoQueryOrNullAsync(Guid id) + { + var query = await Repository.GetQueryableAsync(); + + return await ProjectAsync(query.Where(book => book.Id == id)); + } + + protected override async Task?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable query) + { + return await ProjectAsync(query); + } + + private async Task> ProjectAsync(IQueryable query) + { + var suffix = await _suffixProvider.GetAsync(); + + return query.Select(book => new BookDto + { + Id = book.Id, + Name = book.Name + suffix + }); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailAppService.cs new file mode 100644 index 0000000000..ec2139a118 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailAppService.cs @@ -0,0 +1,15 @@ +using System; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookDetailAppService : + ReadOnlyAppService +{ + public BookDetailAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailDto.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailDto.cs new file mode 100644 index 0000000000..f278d6d721 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailDto.cs @@ -0,0 +1,9 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookDetailDto : EntityDto +{ + public string Name { get; set; } = default!; +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailProjector.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailProjector.cs new file mode 100644 index 0000000000..d7e3e19966 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailProjector.cs @@ -0,0 +1,18 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookDetailProjector : IQueryProjector +{ + public const string Marker = "-detail"; + + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(book => new BookDetailDto + { + Id = book.Id, + Name = book.Name + Marker + }); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs index f5668b9c29..c398f9e25f 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs @@ -6,6 +6,7 @@ namespace Volo.Abp.Application.Services.QueryProjection; public class BookObjectMapper : IObjectMapper, IObjectMapper, + IObjectMapper, ITransientDependency { public const string Marker = "-mapped"; @@ -33,4 +34,15 @@ public class BookObjectMapper : destination.Name = source.Name + Marker; return destination; } + + Book IObjectMapper.Map(BookDto source) + { + return new Book(source.Id, source.Name, 0); + } + + Book IObjectMapper.Map(BookDto source, Book destination) + { + destination.Name = source.Name; + return destination; + } } diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs index f72dc2ada0..fa292161fc 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs @@ -3,7 +3,7 @@ using Volo.Abp.ObjectMapping; namespace Volo.Abp.Application.Services.QueryProjection; -public class BookProjector : IQueryableMapper +public class BookProjector : IQueryProjector { public const string Marker = "-projected"; diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructAppService.cs new file mode 100644 index 0000000000..34b4945b85 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructAppService.cs @@ -0,0 +1,13 @@ +using System; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookStructAppService : ReadOnlyAppService +{ + public BookStructAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructDto.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructDto.cs new file mode 100644 index 0000000000..f2690bb139 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public struct BookStructDto +{ + public Guid Id { get; set; } + + public string Name { get; set; } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructProjector.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructProjector.cs new file mode 100644 index 0000000000..4ae26a4b11 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructProjector.cs @@ -0,0 +1,16 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookStructProjector : IQueryProjector +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(book => new BookStructDto + { + Id = book.Id, + Name = book.Name + }); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs index d6837edc6c..9ad4680d45 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using Volo.Abp.Domain.Repositories; using Volo.Abp.ObjectMapping; @@ -6,9 +7,9 @@ namespace Volo.Abp.Application.Services.QueryProjection; public class BookWithoutProjectionAppService : CrudAppService { - protected override IQueryableMapper GetQueryableMapper => null; + protected override IQueryProjector? GetOutputDtoQueryProjector => null; - protected override IQueryableMapper GetListQueryableMapper => null; + protected override IQueryProjector? GetListOutputDtoQueryProjector => null; public BookWithoutProjectionAppService(IRepository repository) : base(repository) diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/IBookNameSuffixProvider.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/IBookNameSuffixProvider.cs new file mode 100644 index 0000000000..747a93c15d --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/IBookNameSuffixProvider.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public interface IBookNameSuffixProvider +{ + Task GetAsync(); +} + +public class BookNameSuffixProvider : IBookNameSuffixProvider, ITransientDependency +{ + public async Task GetAsync() + { + await Task.Yield(); + + return BookAsyncProjectionAppService.Marker; + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs index 348cad0728..228e4591c3 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs @@ -21,9 +21,9 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase } [Fact] - public void Should_Resolve_Projection_Mapper_Independent_From_The_Class_Name() + public void Should_Resolve_The_Projector_Independent_From_The_Class_Name() { - ServiceProvider.GetService>() + ServiceProvider.GetService>() .ShouldBeOfType(); } @@ -56,7 +56,7 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase } [Fact] - public async Task Should_Use_The_Object_Mapper_If_No_Projection_Mapper_Was_Registered() + public async Task Should_Use_The_Object_Mapper_If_No_Projector_Was_Registered() { var appService = GetRequiredService(); @@ -98,4 +98,84 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) .Items[0].Name.ShouldEndWith(BookProjector.Marker); } + + [Fact] + public async Task Should_Use_The_Asynchronously_Created_Projection_Query() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookAsyncProjectionAppService.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookAsyncProjectionAppService.Marker); + } + + [Fact] + public async Task Should_Throw_EntityNotFoundException_From_An_Overridden_Projection_Query() + { + var appService = GetRequiredService(); + + await Should.ThrowAsync(async () => await appService.GetAsync(Guid.NewGuid())); + } + + [Fact] + public async Task Should_Throw_EntityNotFoundException_For_A_Value_Type_Dto() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldBe("Hitchhiker's Guide"); + + await Should.ThrowAsync(async () => await appService.GetAsync(Guid.NewGuid())); + } + + [Fact] + public async Task Should_Not_Project_On_The_Create_And_Update_Paths() + { + var appService = GetRequiredService(); + + var created = await appService.CreateAsync(new BookDto { Name = "New Book" }); + created.Name.ShouldEndWith(BookObjectMapper.Marker); + + var updated = await appService.UpdateAsync(created.Id, new BookDto { Name = "Updated Book" }); + updated.Name.ShouldEndWith(BookObjectMapper.Marker); + } + + [Fact] + public async Task Should_Use_A_Different_Projector_For_The_Get_And_The_List() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookDetailProjector.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookProjector.Marker); + } + + [Fact] + public async Task Should_Apply_Paging_And_Sorting_Before_Projecting() + { + var repository = GetRequiredService>(); + await repository.InsertAsync(new Book(Guid.NewGuid(), "A Book", 1)); + await repository.InsertAsync(new Book(Guid.NewGuid(), "Z Book", 2)); + + var appService = GetRequiredService(); + + var firstPage = await appService.GetListAsync( + new PagedAndSortedResultRequestDto { MaxResultCount = 1, Sorting = "Name" }); + + firstPage.TotalCount.ShouldBe(3); + firstPage.Items.Count.ShouldBe(1); + firstPage.Items[0].Name.ShouldBe("A Book" + BookProjector.Marker); + + var secondPage = await appService.GetListAsync( + new PagedAndSortedResultRequestDto { MaxResultCount = 1, SkipCount = 1, Sorting = "Name" }); + + secondPage.Items[0].Name.ShouldBe("Hitchhiker's Guide" + BookProjector.Marker); + } + + [Fact] + public async Task Should_Project_A_Single_Entity_From_An_AbstractKey_Application_Service() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookProjector.Marker); + } } diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/AbpEntityFrameworkCoreTestModule.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/AbpEntityFrameworkCoreTestModule.cs index 27992ed124..fae245bc3a 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/AbpEntityFrameworkCoreTestModule.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/AbpEntityFrameworkCoreTestModule.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Volo.Abp.Autofac; using Volo.Abp.Data; using Volo.Abp.Domain.Repositories; +using Volo.Abp.EntityFrameworkCore.Applications; using Volo.Abp.EntityFrameworkCore.Domain; using Volo.Abp.EntityFrameworkCore.Sqlite; using Volo.Abp.EntityFrameworkCore.TestApp.FifthContext; @@ -92,6 +93,7 @@ public class AbpEntityFrameworkCoreTestModule : AbpModule options.Configure(abpDbContextConfigurationContext => { abpDbContextConfigurationContext.UseSqlite().AddAbpDbContextOptionsExtension(); + abpDbContextConfigurationContext.DbContextOptions.AddInterceptors(new SqlCommandCapture()); }); }); } diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs index 81f19b495d..5e97ceca51 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs @@ -4,7 +4,7 @@ using Volo.Abp.TestApp.Domain; namespace Volo.Abp.EntityFrameworkCore.Applications; -public class EntityWithIntPkProjector : IQueryableMapper +public class EntityWithIntPkProjector : IQueryProjector { public IQueryable ProjectTo(IQueryable source) { diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs index 2ede252731..d9abce7cd8 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs @@ -4,7 +4,7 @@ using Volo.Abp.TestApp.Domain; namespace Volo.Abp.EntityFrameworkCore.Applications; -public class PersonProjector : IQueryableMapper +public class PersonProjector : IQueryProjector { public IQueryable ProjectTo(IQueryable source) { diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityAppService.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityAppService.cs new file mode 100644 index 0000000000..f8a8ba01e5 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityAppService.cs @@ -0,0 +1,51 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +//City is another aggregate root, so Person has no City navigation property to project. +public class PersonWithCityAppService : ReadOnlyAppService +{ + private readonly IReadOnlyRepository _cityRepository; + + public PersonWithCityAppService( + IReadOnlyRepository repository, + IReadOnlyRepository cityRepository) + : base(repository) + { + _cityRepository = cityRepository; + } + + protected override async Task?> CreateGetOutputDtoQueryOrNullAsync(Guid id) + { + var people = await CreateEntityQueryOrNullAsync(id); + + return people == null ? null : await JoinCitiesAsync(people); + } + + protected override async Task?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable query) + { + return await JoinCitiesAsync(query); + } + + //left join, an inner join would drop the people without a city and break the total count + private async Task> JoinCitiesAsync(IQueryable people) + { + var cities = await _cityRepository.GetQueryableAsync(); + + return from person in people + join city in cities on person.CityId equals city.Id into personCities + from personCity in personCities.DefaultIfEmpty() + select new PersonWithCityDto + { + Id = person.Id, + Name = person.Name, + CityName = personCity != null ? personCity.Name : null + }; + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs new file mode 100644 index 0000000000..db62825b8e --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs @@ -0,0 +1,11 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class PersonWithCityDto : EntityDto +{ + public string Name { get; set; } + + public string CityName { get; set; } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs index bcc96c4730..e2d2f45d7c 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Shouldly; @@ -8,6 +10,8 @@ using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; using Volo.Abp.ObjectMapping; using Volo.Abp.TestApp; +using Volo.Abp.TestApp.Application; +using Volo.Abp.Threading; using Volo.Abp.TestApp.Domain; using Xunit; @@ -73,7 +77,7 @@ public class QueryProjection_Tests : EntityFrameworkCoreTestBase await WithUnitOfWorkAsync(async () => { var repository = GetRequiredService>(); - var projector = GetRequiredService>(); + var projector = GetRequiredService>(); var sql = projector.ProjectTo(await repository.GetQueryableAsync()).ToQueryString(); @@ -85,4 +89,88 @@ public class QueryProjection_Tests : EntityFrameworkCoreTestBase sql.ShouldContain("Is_Deleted"); }); } + + [Fact] + public async Task Should_Join_Another_Aggregate_While_Projecting() + { + var appService = GetRequiredService(); + + var dto = await appService.GetAsync(TestDataBuilder.UserDouglasId); + dto.CityName.ShouldBe("London"); + + var result = await appService.GetListAsync(new PagedAndSortedResultRequestDto()); + result.Items.ShouldContain(x => x.CityName == "London"); + } + + [Fact] + public async Task Should_Keep_The_Total_Count_While_Joining_Another_Aggregate() + { + await WithUnitOfWorkAsync(async () => + { + await GetRequiredService>() + .InsertAsync(new Person(Guid.NewGuid(), "PersonWithoutCity", 30), autoSave: true); + }); + + var result = await GetRequiredService() + .GetListAsync(new PagedAndSortedResultRequestDto()); + + result.Items.Count.ShouldBe((int)result.TotalCount); + result.Items.ShouldContain(x => x.Name == "PersonWithoutCity" && x.CityName == null); + } + + [Fact] + public async Task Should_Execute_The_Projected_Query_From_The_Application_Service() + { + System.Collections.Concurrent.ConcurrentQueue commands; + using (SqlCommandCapture.Begin(out commands)) + { + await GetRequiredService() + .GetListAsync(new PagedAndSortedResultRequestDto()); + } + + //the application service must run the projection itself, not materialize the entities first + var select = commands.Last(x => x.Contains("FROM \"People\"") && !x.Contains("COUNT")); + + select.ShouldContain("\"Name\""); + select.ShouldNotContain("\"Birthday\""); + select.ShouldNotContain("\"ExtraProperties\""); + } + + [Fact] + public async Task Should_Apply_The_Multi_Tenancy_Filter_While_Projecting() + { + var result = await GetRequiredService() + .GetListAsync(new PagedAndSortedResultRequestDto()); + + result.Items.ShouldNotContain(x => x.Name.StartsWith(TestDataBuilder.TenantId1.ToString())); + } + + [Fact] + public async Task Should_Use_The_Ambient_Cancellation_Token_While_Projecting() + { + var cancellationTokenProvider = GetRequiredService(); + var appService = GetRequiredService(); + + using (cancellationTokenProvider.Use(new CancellationToken(canceled: true))) + { + await Should.ThrowAsync(async () => + await appService.GetAsync(TestDataBuilder.UserDouglasId)); + + await Should.ThrowAsync(async () => + await appService.GetListAsync(new PagedAndSortedResultRequestDto())); + } + } + + [Fact] + public async Task Should_Use_The_Ambient_Cancellation_Token_Without_A_Projector() + { + var cancellationTokenProvider = GetRequiredService(); + var appService = GetRequiredService(); + + using (cancellationTokenProvider.Use(new CancellationToken(canceled: true))) + { + await Should.ThrowAsync(async () => + await appService.GetListAsync(new PagedAndSortedResultRequestDto())); + } + } } diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/SqlCommandCapture.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/SqlCommandCapture.cs new file mode 100644 index 0000000000..7f70e562e5 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/SqlCommandCapture.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class SqlCommandCapture : DbCommandInterceptor +{ + private static readonly AsyncLocal> Commands = new(); + + public static IDisposable Begin(out ConcurrentQueue commands) + { + commands = new ConcurrentQueue(); + Commands.Value = commands; + return new DisposeAction(() => Commands.Value = null); + } + + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Commands.Value?.Enqueue(command.CommandText); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Commands.Value?.Enqueue(command.CommandText); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } +} diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs index ca2ac86d3a..6715fdfa74 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs @@ -15,17 +15,25 @@ public class AbpMapperlyQueryProjection_Tests : AbpIntegratedTest>(); + var queryProjector = ServiceProvider.GetRequiredService>(); var entities = new List { new MyEntity { Id = Guid.NewGuid(), Number = 42 } }.AsQueryable(); - var dtos = projectionMapper.ProjectTo(entities).ToList(); + var dtos = queryProjector.ProjectTo(entities).ToList(); dtos.Count.ShouldBe(1); dtos[0].Id.ShouldBe(entities.First().Id); dtos[0].Number.ShouldBe(42); } + + + [Fact] + public void Should_Register_A_Projector_Only_Once() + { + //MyEntityQueryProjector also matches the class name convention of ExposeServicesAttribute + ServiceProvider.GetServices>().ShouldHaveSingleItem(); + } } diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs index d7ad0fb260..980e40a94b 100644 --- a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs @@ -5,7 +5,7 @@ using Volo.Abp.ObjectMapping; namespace Volo.Abp.Mapperly.SampleClasses; [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] -public partial class MyEntityQueryProjector : IQueryableMapper +public partial class MyEntityQueryProjector : IQueryProjector { public partial IQueryable ProjectTo(IQueryable source); } diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs index 341ef77d91..eac0c6fd3a 100644 --- a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs @@ -4,7 +4,7 @@ using Volo.Abp.TestApp.Domain; namespace Volo.Abp.MongoDB.Applications; -public class PersonProjector : IQueryableMapper +public class PersonProjector : IQueryProjector { public IQueryable ProjectTo(IQueryable source) { From 7ac1bc668e5aedc20b5cffaedbbf1f465713bb13 Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 20 Aug 2026 15:51:19 +0800 Subject: [PATCH 13/17] Document the query projection --- .../application-services.md | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/en/framework/architecture/domain-driven-design/application-services.md b/docs/en/framework/architecture/domain-driven-design/application-services.md index 5c548820aa..0561dcbd3c 100644 --- a/docs/en/framework/architecture/domain-driven-design/application-services.md +++ b/docs/en/framework/architecture/domain-driven-design/application-services.md @@ -1,4 +1,4 @@ -```json +```json //[doc-seo] { "Description": "Learn how to implement application services in the ABP Framework to expose domain logic and streamline presentation layer interactions." @@ -444,7 +444,7 @@ These methods are low level methods that can control how to query entities from * `ApplyPaging` is used to make paging on the query. If your `TGetListInput` already implements `IPagedResultRequest`, you don't need to override this since the ABP automatically understands it and performs the paging. * `ApplySorting` is used to sort (order by...) the query. If your `TGetListInput` already implements the `ISortedResultRequest`, ABP automatically sorts the query. If not, it fallbacks to the `ApplyDefaultSorting` which tries to sort by creation time, if your entity implements the standard `IHasCreationTime` interface. * `GetEntityByIdAsync` is used to get an entity by id, which calls `Repository.GetAsync(id)` by default. -* `CreateEntityQueryAsync` is used to create a query for a single entity by id, which is only needed for the *Query Projection* explained below. It returns `null` if the application service can not create such a query, then `GetEntityByIdAsync` is used. +* `CreateEntityQueryOrNullAsync` is used to create a query for a single entity by id, which is only needed for the *Query Projection* explained below. It returns `null` if the application service can not create such a query, then `GetEntityByIdAsync` is used. * `DeleteByIdAsync` is used to delete an entity by id, which calls `Repository.DeleteAsync(id)` by default. #### Object to Object Mapping @@ -461,7 +461,7 @@ These methods are used to convert Entities to DTOs and vice verse. They use the `GetAsync` and `GetListAsync` get the entities from the database, then map them to DTOs in the memory. If your DTO uses only a few properties of a large entity, you can project the query to the DTO instead, so the database returns only the columns you need. -Implement the `IQueryableMapper` interface to define a projection: +Implement the `IQueryProjector` interface to define a projection: ````csharp using System.Linq; @@ -469,7 +469,7 @@ using Volo.Abp.ObjectMapping; namespace MyProject.Books; -public class BookProjector : IQueryableMapper +public class BookProjector : IQueryProjector { public IQueryable ProjectTo(IQueryable source) { @@ -484,14 +484,51 @@ public class BookProjector : IQueryableMapper You don't have to write the `Select` by hand. Both [Mapperly](https://mapperly.riok.app/) and [AutoMapper](https://docs.automapper.org) can project an `IQueryable`, refer to their own documentation for it and to the [object to object mapping document](../../infrastructure/object-to-object-mapping.md) for their ABP integrations. -ABP registers the projection mappers by convention, you don't need to configure anything else. Filters (like soft delete and multi-tenancy), sorting and paging are still applied to the query before the projection. +ABP registers the projectors by convention, you don't need to configure anything else. Implement a projector once for an entity and DTO pair, the last registered one is used otherwise. Filters (like soft delete and multi-tenancy), sorting and paging are still applied to the query before the projection. -> The projection replaces the entity based extension points. `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync`, `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore. If an application service needs to keep using them, override the `GetQueryableMapper` or `GetListQueryableMapper` property and return `null`: +> A projection must return one row per entity. The total count and the paging are calculated on the entity query before the projection runs, so a projection that filters out rows (an inner join to an optional relation) or multiplies them (a join to a collection) returns a page that doesn't match the reported total count. Use a left join for optional relations. + +The projector is synchronous, so it can not obtain the query of another aggregate root, which is only +available through the asynchronous `GetQueryableAsync`. Override `CreateGetOutputDtoQueryOrNullAsync` or +`CreateGetListOutputDtoQueryOrNullAsync` for that. They replace the projector for that application service: + +````csharp +public class BookAppService : ReadOnlyAppService +{ + private readonly IReadOnlyRepository _authorRepository; + + //... + + protected override async Task?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable query) + { + var authors = await _authorRepository.GetQueryableAsync(); + + return from book in query + join author in authors on book.AuthorId equals author.Id into bookAuthors + from bookAuthor in bookAuthors.DefaultIfEmpty() + select new BookDto + { + Id = book.Id, + Name = book.Name, + AuthorName = bookAuthor != null ? bookAuthor.Name : null + }; + } +} +```` + +Both queries must come from the same database context, otherwise they can not be executed as a single query, +and the provider has to be able to translate the join. The one row per entity rule above applies here too, +that's why the example uses a left join. A joined column can not be used for the sorting and the paging, +since they are already applied to the entity query before this method is called. + +> The projection replaces the entity based extension points. `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync`, `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore. Projectors are resolved by the `(entity, DTO)` type pair, so registering one enables the projection for every application service using that pair. If an application service needs to keep using the entity based extension points, override the `GetOutputDtoQueryProjector` or `GetListOutputDtoQueryProjector` property and return `null`: ````csharp public class BookAppService : CrudAppService { - protected override IQueryableMapper? GetQueryableMapper => null; + protected override IQueryProjector? GetOutputDtoQueryProjector => null; + + protected override IQueryProjector? GetListOutputDtoQueryProjector => null; //... } From c24a0e3b500173c64b20f9cd8b45132ecdae078c Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 20 Aug 2026 16:12:37 +0800 Subject: [PATCH 14/17] Keep checking the authorization policies while projecting * The projection only replaces the DTO creation, the filters and the policies still apply --- .../application-services.md | 9 +++++- .../BookPolicyCheckedAppService.cs | 29 +++++++++++++++++++ .../QueryProjection/QueryProjection_Tests.cs | 10 +++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookPolicyCheckedAppService.cs diff --git a/docs/en/framework/architecture/domain-driven-design/application-services.md b/docs/en/framework/architecture/domain-driven-design/application-services.md index 0561dcbd3c..c257a613f7 100644 --- a/docs/en/framework/architecture/domain-driven-design/application-services.md +++ b/docs/en/framework/architecture/domain-driven-design/application-services.md @@ -521,7 +521,14 @@ and the provider has to be able to translate the join. The one row per entity ru that's why the example uses a left join. A joined column can not be used for the sorting and the paging, since they are already applied to the entity query before this method is called. -> The projection replaces the entity based extension points. `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync`, `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore. Projectors are resolved by the `(entity, DTO)` type pair, so registering one enables the projection for every application service using that pair. If an application service needs to keep using the entity based extension points, override the `GetOutputDtoQueryProjector` or `GetListOutputDtoQueryProjector` property and return `null`: +A projector is resolved by the `(entity, DTO)` type pair, just like an `IObjectMapper`, so registering one enables the projection for every application service using that pair. The projection only replaces the way the DTOs are created: + +* `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync` anymore. +* `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore. + +Everything else is untouched. The authorization policies are still checked, `CreateFilteredQueryAsync`, `ApplySorting` and `ApplyPaging` are still used, the data filters (like soft delete and multi-tenancy) are still applied, and the create, update and delete methods still use the [IObjectMapper](../../infrastructure/object-to-object-mapping.md). + +> If an application service needs to keep using the entity based extension points, override the `GetOutputDtoQueryProjector` or `GetListOutputDtoQueryProjector` property and return `null`: ````csharp public class BookAppService : CrudAppService diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookPolicyCheckedAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookPolicyCheckedAppService.cs new file mode 100644 index 0000000000..ee8e73db14 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookPolicyCheckedAppService.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookPolicyCheckedException : Exception +{ + +} + +public class BookPolicyCheckedAppService : CrudAppService +{ + public BookPolicyCheckedAppService(IRepository repository) + : base(repository) + { + + } + + protected override Task CheckGetPolicyAsync() + { + throw new BookPolicyCheckedException(); + } + + protected override Task CheckGetListPolicyAsync() + { + throw new BookPolicyCheckedException(); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs index 228e4591c3..3823a07a56 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs @@ -178,4 +178,14 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookProjector.Marker); } + + [Fact] + public async Task Should_Check_The_Policies_While_Projecting() + { + var appService = GetRequiredService(); + + await Should.ThrowAsync(async () => await appService.GetAsync(_bookId)); + await Should.ThrowAsync(async () => + await appService.GetListAsync(new PagedAndSortedResultRequestDto())); + } } From be8f078ea9662630b310fa4a7da5256a149f603d Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 20 Aug 2026 17:35:04 +0800 Subject: [PATCH 15/17] Keep the ambient cancellation token out of the entity paths * Only the projected GetAsync passes it, that is the path Repository.GetAsync already covered * Assert a single data query so a materialize-then-project implementation can not pass * Opting out of the projection brings the entity based overrides back --- .../Services/AbstractKeyReadOnlyAppService.cs | 12 +++--- .../Services/ApplicationService.cs | 3 -- .../ObjectMapping/AbpObjectMappingModule.cs | 1 - .../Volo/Abp/ObjectMapping/IQueryProjector.cs | 3 +- .../BookCustomizedAppService.cs | 6 +++ .../BookWithoutProjectionAppService.cs | 14 +++++++ .../QueryProjection/QueryProjection_Tests.cs | 11 +++-- .../Applications/PersonWithCityDto.cs | 3 +- .../Applications/QueryProjection_Tests.cs | 41 ++++++++++++++----- 9 files changed, 70 insertions(+), 24 deletions(-) diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs index e4980074ba..fa8aafc76a 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs @@ -92,7 +92,7 @@ public abstract class AbstractKeyReadOnlyAppService(); @@ -104,11 +104,11 @@ public abstract class AbstractKeyReadOnlyAppService GetEntityByIdAsync(TKey id); - protected virtual CancellationToken GetCancellationToken(CancellationToken preferredValue = default) + private CancellationToken GetCancellationToken() { - return CancellationTokenProvider.FallbackToProvider(preferredValue); + return LazyServiceProvider + .LazyGetService(NullCancellationTokenProvider.Instance) + .FallbackToProvider(); } /// diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs index 01e9e81354..4eb285df04 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs @@ -19,7 +19,6 @@ using Volo.Abp.Localization; using Volo.Abp.MultiTenancy; using Volo.Abp.ObjectMapping; using Volo.Abp.Settings; -using Volo.Abp.Threading; using Volo.Abp.Timing; using Volo.Abp.Uow; using Volo.Abp.Users; @@ -49,8 +48,6 @@ public abstract class ApplicationService : protected IAsyncQueryableExecuter AsyncExecuter => LazyServiceProvider.LazyGetRequiredService(); - protected ICancellationTokenProvider CancellationTokenProvider => LazyServiceProvider.LazyGetService(NullCancellationTokenProvider.Instance); - protected Type? ObjectMapperContext { get; set; } protected IObjectMapper ObjectMapper => LazyServiceProvider.LazyGetService(provider => ObjectMapperContext == null diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs index 87e0c43b56..2f1b8830ac 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs @@ -21,7 +21,6 @@ public class AbpObjectMappingModule : AbpModule ); //Register types for IQueryProjector if implements - //The class name convention may have already exposed them, so they are not added twice foreach (var serviceType in ReflectionHelper.GetImplementedGenericTypes( onServiceExposingContext.ImplementationType, typeof(IQueryProjector<,>))) diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs index 1abf5667e6..89652e5f40 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs @@ -7,7 +7,8 @@ namespace Volo.Abp.ObjectMapping; /// Maps a query to another. /// Implement this interface to project a query on the data store side, instead of loading the /// source objects into the memory and mapping them one by one. -/// Implement it once for a source and destination pair, the last registered one is used otherwise. +/// Implement it once for a source and destination pair. Use the ReplaceServices option of the +/// DependencyAttribute to replace an existing implementation. /// /// Type of the source objects /// Type of the destination objects diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs index b4c756d6b2..f5df26baaa 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Volo.Abp.Domain.Repositories; @@ -25,4 +26,9 @@ public class BookCustomizedAppService : CrudAppService { return Task.FromResult(new BookDto { Id = entity.Id, Name = entity.Name + Marker }); } + + protected override Task> MapToGetListOutputDtosAsync(List entities) + { + return Task.FromResult(entities.ConvertAll(entity => new BookDto { Id = entity.Id, Name = entity.Name + Marker })); + } } diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs index 9ad4680d45..928c8fee66 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs @@ -1,5 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; +using System.Threading.Tasks; using Volo.Abp.Domain.Repositories; using Volo.Abp.ObjectMapping; @@ -7,6 +9,8 @@ namespace Volo.Abp.Application.Services.QueryProjection; public class BookWithoutProjectionAppService : CrudAppService { + public const string Marker = "-not-projected"; + protected override IQueryProjector? GetOutputDtoQueryProjector => null; protected override IQueryProjector? GetListOutputDtoQueryProjector => null; @@ -16,4 +20,14 @@ public class BookWithoutProjectionAppService : CrudAppService MapToGetOutputDtoAsync(Book entity) + { + return Task.FromResult(new BookDto { Id = entity.Id, Name = entity.Name + Marker }); + } + + protected override Task> MapToGetListOutputDtosAsync(List entities) + { + return Task.FromResult(entities.ConvertAll(entity => new BookDto { Id = entity.Id, Name = entity.Name + Marker })); + } } diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs index 3823a07a56..425021fb03 100644 --- a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs @@ -66,13 +66,13 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase } [Fact] - public async Task Should_Use_The_Object_Mapper_If_The_Projection_Was_Disabled() + public async Task Should_Use_The_Entity_Based_Overrides_If_The_Projection_Was_Disabled() { var appService = GetRequiredService(); - (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookObjectMapper.Marker); + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookWithoutProjectionAppService.Marker); (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) - .Items[0].Name.ShouldEndWith(BookObjectMapper.Marker); + .Items[0].Name.ShouldEndWith(BookWithoutProjectionAppService.Marker); } [Fact] @@ -84,6 +84,11 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase dto.Name.ShouldEndWith(BookProjector.Marker); dto.Name.ShouldNotContain(BookCustomizedAppService.Marker); + + var items = (await appService.GetListAsync(new PagedAndSortedResultRequestDto())).Items; + + items[0].Name.ShouldEndWith(BookProjector.Marker); + items[0].Name.ShouldNotContain(BookCustomizedAppService.Marker); } [Fact] diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs index db62825b8e..dac278c7b6 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using Volo.Abp.Application.Dtos; @@ -7,5 +8,5 @@ public class PersonWithCityDto : EntityDto { public string Name { get; set; } - public string CityName { get; set; } + public string? CityName { get; set; } } diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs index e2d2f45d7c..d4410c15e7 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs @@ -129,8 +129,9 @@ public class QueryProjection_Tests : EntityFrameworkCoreTestBase } //the application service must run the projection itself, not materialize the entities first - var select = commands.Last(x => x.Contains("FROM \"People\"") && !x.Contains("COUNT")); + var selects = commands.Where(x => x.Contains("FROM \"People\"") && !x.Contains("COUNT")).ToList(); + var select = selects.ShouldHaveSingleItem(); select.ShouldContain("\"Name\""); select.ShouldNotContain("\"Birthday\""); select.ShouldNotContain("\"ExtraProperties\""); @@ -151,26 +152,46 @@ public class QueryProjection_Tests : EntityFrameworkCoreTestBase var cancellationTokenProvider = GetRequiredService(); var appService = GetRequiredService(); + //the entity path passes it through Repository.GetAsync, so the projected path must not regress using (cancellationTokenProvider.Use(new CancellationToken(canceled: true))) { await Should.ThrowAsync(async () => await appService.GetAsync(TestDataBuilder.UserDouglasId)); + } + } - await Should.ThrowAsync(async () => - await appService.GetListAsync(new PagedAndSortedResultRequestDto())); + [Fact] + public async Task Should_Execute_The_Projected_Query_From_The_Application_Service_For_A_Single_Entity() + { + System.Collections.Concurrent.ConcurrentQueue commands; + using (SqlCommandCapture.Begin(out commands)) + { + await GetRequiredService().GetAsync(TestDataBuilder.UserDouglasId); } + + var selects = commands.Where(x => x.Contains("FROM \"People\"")).ToList(); + + var select = selects.ShouldHaveSingleItem(); + select.ShouldContain("\"Name\""); + select.ShouldNotContain("\"Birthday\""); + select.ShouldNotContain("\"ExtraProperties\""); } [Fact] - public async Task Should_Use_The_Ambient_Cancellation_Token_Without_A_Projector() + public async Task Should_Apply_The_Data_Filters_Of_The_Joined_Aggregate() { - var cancellationTokenProvider = GetRequiredService(); - var appService = GetRequiredService(); + var cityRepository = GetRequiredService>(); - using (cancellationTokenProvider.Use(new CancellationToken(canceled: true))) + await WithUnitOfWorkAsync(async () => { - await Should.ThrowAsync(async () => - await appService.GetListAsync(new PagedAndSortedResultRequestDto())); - } + var london = await cityRepository.GetAsync(TestDataBuilder.LondonCityId); + await cityRepository.DeleteAsync(london, autoSave: true); + }); + + var result = await GetRequiredService() + .GetListAsync(new PagedAndSortedResultRequestDto()); + + //the soft deleted city is filtered out, the left join still keeps the person + result.Items.ShouldContain(x => x.Name == "Douglas" && x.CityName == null); } } From dfeb830255b431744aead36626bd5d8f1a707209 Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 20 Aug 2026 17:35:04 +0800 Subject: [PATCH 16/17] Clarify the query projection contract in the documentation --- .../domain-driven-design/application-services.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/en/framework/architecture/domain-driven-design/application-services.md b/docs/en/framework/architecture/domain-driven-design/application-services.md index c257a613f7..57c104385e 100644 --- a/docs/en/framework/architecture/domain-driven-design/application-services.md +++ b/docs/en/framework/architecture/domain-driven-design/application-services.md @@ -484,7 +484,7 @@ public class BookProjector : IQueryProjector You don't have to write the `Select` by hand. Both [Mapperly](https://mapperly.riok.app/) and [AutoMapper](https://docs.automapper.org) can project an `IQueryable`, refer to their own documentation for it and to the [object to object mapping document](../../infrastructure/object-to-object-mapping.md) for their ABP integrations. -ABP registers the projectors by convention, you don't need to configure anything else. Implement a projector once for an entity and DTO pair, the last registered one is used otherwise. Filters (like soft delete and multi-tenancy), sorting and paging are still applied to the query before the projection. +ABP registers the projectors by convention, you don't need to configure anything else. Implement a projector once for an entity and DTO pair, and use the `ReplaceServices` option of the `DependencyAttribute` to replace an existing one. Filters (like soft delete and multi-tenancy), sorting and paging are still applied to the query before the projection. > A projection must return one row per entity. The total count and the paging are calculated on the entity query before the projection runs, so a projection that filters out rows (an inner join to an optional relation) or multiplies them (a join to a collection) returns a page that doesn't match the reported total count. Use a left join for optional relations. @@ -518,15 +518,15 @@ public class BookAppService : ReadOnlyAppService Both queries must come from the same database context, otherwise they can not be executed as a single query, and the provider has to be able to translate the join. The one row per entity rule above applies here too, -that's why the example uses a left join. A joined column can not be used for the sorting and the paging, -since they are already applied to the entity query before this method is called. +that's why the example uses a left join. A joined column can not be used for the sorting, and the paging is +based on the entity query, since both are applied before this method is called. -A projector is resolved by the `(entity, DTO)` type pair, just like an `IObjectMapper`, so registering one enables the projection for every application service using that pair. The projection only replaces the way the DTOs are created: +A projector is resolved by the `(entity, DTO)` type pair, just like an `IObjectMapper`, so registering one enables the projection for every application service using that pair. It replaces the way the DTOs are read: -* `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync` anymore. * `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore. +* `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync` anymore, as long as the application service can create a query for a single entity. `ReadOnlyAppService` and `CrudAppService` already do that. A class deriving from `AbstractKeyReadOnlyAppService` has to override `CreateEntityQueryOrNullAsync`, otherwise `GetAsync` keeps loading the entity and mapping it. -Everything else is untouched. The authorization policies are still checked, `CreateFilteredQueryAsync`, `ApplySorting` and `ApplyPaging` are still used, the data filters (like soft delete and multi-tenancy) are still applied, and the create, update and delete methods still use the [IObjectMapper](../../infrastructure/object-to-object-mapping.md). +The rest of the pipeline is untouched. The authorization policies are still checked, `CreateFilteredQueryAsync`, `ApplySorting` and `ApplyPaging` are still used, the data filters (like soft delete and multi-tenancy) are still applied, and the create, update and delete methods still use the [IObjectMapper](../../infrastructure/object-to-object-mapping.md). > If an application service needs to keep using the entity based extension points, override the `GetOutputDtoQueryProjector` or `GetListOutputDtoQueryProjector` property and return `null`: From c0b78d863287b3f777a3839088e277d501e5f936 Mon Sep 17 00:00:00 2001 From: maliming Date: Thu, 20 Aug 2026 19:59:38 +0800 Subject: [PATCH 17/17] Show a reusable projection in the asynchronous hook sample --- .../application-services.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/en/framework/architecture/domain-driven-design/application-services.md b/docs/en/framework/architecture/domain-driven-design/application-services.md index 57c104385e..a2d2db5acc 100644 --- a/docs/en/framework/architecture/domain-driven-design/application-services.md +++ b/docs/en/framework/architecture/domain-driven-design/application-services.md @@ -482,7 +482,7 @@ public class BookProjector : IQueryProjector } ```` -You don't have to write the `Select` by hand. Both [Mapperly](https://mapperly.riok.app/) and [AutoMapper](https://docs.automapper.org) can project an `IQueryable`, refer to their own documentation for it and to the [object to object mapping document](../../infrastructure/object-to-object-mapping.md) for their ABP integrations. +You don't have to write the `Select` by hand. Both [Mapperly](https://mapperly.riok.app/) and [AutoMapper](https://docs.automapper.org) can project an `IQueryable`, refer to their own documentation for it and to the [object to object mapping document](../../infrastructure/object-to-object-mapping.md) for their ABP integrations. Your existing maps are not used for the projection, a projector is always a class implementing `IQueryProjector`. ABP registers the projectors by convention, you don't need to configure anything else. Implement a projector once for an entity and DTO pair, and use the `ReplaceServices` option of the `DependencyAttribute` to replace an existing one. Filters (like soft delete and multi-tenancy), sorting and paging are still applied to the query before the projection. @@ -495,15 +495,28 @@ available through the asynchronous `GetQueryableAsync`. Override `CreateGetOutpu ````csharp public class BookAppService : ReadOnlyAppService { - private readonly IReadOnlyRepository _authorRepository; + private readonly IBookDtoQuery _bookDtoQuery; //... protected override async Task?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable query) + { + return await _bookDtoQuery.ProjectAsync(query); + } +} + +//The projection is a class of its own, so the other application services returning a BookDto reuse it +public class BookDtoQuery : IBookDtoQuery, ITransientDependency +{ + private readonly IReadOnlyRepository _authorRepository; + + //... + + public async Task> ProjectAsync(IQueryable books) { var authors = await _authorRepository.GetQueryableAsync(); - return from book in query + return from book in books join author in authors on book.AuthorId equals author.Id into bookAuthors from bookAuthor in bookAuthors.DefaultIfEmpty() select new BookDto