Skip to content

Commit 029cc7d

Browse files
committed
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
1 parent 331e3f0 commit 029cc7d

32 files changed

Lines changed: 813 additions & 51 deletions

File tree

docs/en/framework/architecture/domain-driven-design/application-services.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
```json
1+
```json
22
//[doc-seo]
33
{
44
"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
444444
* `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.
445445
* `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.
446446
* `GetEntityByIdAsync` is used to get an entity by id, which calls `Repository.GetAsync(id)` by default.
447+
* `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.
447448
* `DeleteByIdAsync` is used to delete an entity by id, which calls `Repository.DeleteAsync(id)` by default.
448449

449450
#### Object to Object Mapping
@@ -456,6 +457,54 @@ These methods are used to convert Entities to DTOs and vice verse. They use the
456457
* `MapToEntityAsync(TCreateInput)` is used to create an entity from `TCreateInput`.
457458
* `MapToEntityAsync(TUpdateInput, TEntity)` is used to update an existing entity from `TUpdateInput`.
458459

460+
#### Query Projection
461+
462+
`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.
463+
464+
Implement the `IQueryProjectionMapper<TEntity, TDto>` interface to define a projection:
465+
466+
````csharp
467+
using System.Linq;
468+
using Volo.Abp.ObjectMapping;
469+
470+
namespace MyProject.Books;
471+
472+
public class BookProjector : IQueryProjectionMapper<Book, BookDto>
473+
{
474+
public IQueryable<BookDto> ProjectTo(IQueryable<Book> source)
475+
{
476+
return source.Select(book => new BookDto
477+
{
478+
Id = book.Id,
479+
Name = book.Name
480+
});
481+
}
482+
}
483+
````
484+
485+
[Mapperly](https://mapperly.riok.app/) can generate that method for you:
486+
487+
````csharp
488+
[Mapper]
489+
public partial class BookProjector : IQueryProjectionMapper<Book, BookDto>
490+
{
491+
public partial IQueryable<BookDto> ProjectTo(IQueryable<Book> source);
492+
}
493+
````
494+
495+
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.
496+
497+
> 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`:
498+
499+
````csharp
500+
public class BookAppService : CrudAppService<Book, BookDto, Guid>
501+
{
502+
protected override IQueryProjectionMapper<Book, BookDto>? GetProjectionMapper => null;
503+
504+
//...
505+
}
506+
````
507+
459508
## Miscellaneous
460509

461510
### Working with Streams

framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,19 @@ public abstract class AbstractKeyReadOnlyAppService<TEntity, TGetOutputDto, TGet
4444

4545
protected virtual string? GetListPolicyName { get; set; }
4646

47-
protected virtual IQueryProjectionMapper<TEntity, TGetOutputDto>? ObjectProjectionMapper =>
48-
LazyServiceProvider.LazyGetService<IQueryProjectionMapper<TEntity, TGetOutputDto>>();
49-
50-
protected virtual IQueryProjectionMapper<TEntity, TGetListOutputDto>? ListProjectionMapper =>
51-
LazyServiceProvider.LazyGetService<IQueryProjectionMapper<TEntity, TGetListOutputDto>>();
52-
53-
protected virtual bool UseObjectProjectionMapper =>
54-
ObjectProjectionMapper != null;
47+
/// <summary>
48+
/// <see cref="GetEntityByIdAsync"/> and <see cref="MapToGetOutputDtoAsync"/> are not used
49+
/// while a projection mapper is available. Override and return null to keep using them.
50+
/// </summary>
51+
protected virtual IQueryProjectionMapper<TEntity, TGetOutputDto>? GetProjectionMapper
52+
=> LazyServiceProvider.LazyGetService<IQueryProjectionMapper<TEntity, TGetOutputDto>>();
5553

56-
protected virtual bool UseListProjectionMapper =>
57-
ListProjectionMapper != null;
54+
/// <summary>
55+
/// <see cref="MapToGetListOutputDtosAsync"/> is not used while a projection mapper is
56+
/// available. Override and return null to keep using it.
57+
/// </summary>
58+
protected virtual IQueryProjectionMapper<TEntity, TGetListOutputDto>? GetListProjectionMapper
59+
=> LazyServiceProvider.LazyGetService<IQueryProjectionMapper<TEntity, TGetListOutputDto>>();
5860

5961
protected AbstractKeyReadOnlyAppService(IReadOnlyRepository<TEntity> repository)
6062
{
@@ -65,17 +67,15 @@ public virtual async Task<TGetOutputDto> GetAsync(TKey id)
6567
{
6668
await CheckGetPolicyAsync();
6769

68-
var projectionMapper = ObjectProjectionMapper;
69-
70-
if (UseObjectProjectionMapper && projectionMapper != null)
70+
var projectionMapper = GetProjectionMapper;
71+
if (projectionMapper != null)
7172
{
72-
var query = await GetEntityByIdQueryAsync(id);
73-
74-
var dto =
75-
await AsyncExecuter.FirstOrDefaultAsync(projectionMapper.ProjectTo(query))
76-
?? throw new EntityNotFoundException(typeof(TEntity), id);
77-
78-
return dto;
73+
var query = await GetEntityByIdQueryOrNullAsync(id);
74+
if (query != null)
75+
{
76+
return await AsyncExecuter.FirstOrDefaultAsync(projectionMapper.ProjectTo(query))
77+
?? throw new EntityNotFoundException<TEntity>(id);
78+
}
7979
}
8080

8181
var entity = await GetEntityByIdAsync(id);
@@ -97,13 +97,10 @@ public virtual async Task<PagedResultDto<TGetListOutputDto>> GetListAsync(TGetLi
9797
query = ApplySorting(query, input);
9898
query = ApplyPaging(query, input);
9999

100-
var projectionMapper = ListProjectionMapper;
101-
102-
if (UseListProjectionMapper && projectionMapper != null)
100+
var projectionMapper = GetListProjectionMapper;
101+
if (projectionMapper != null)
103102
{
104-
entityDtos = await AsyncExecuter.ToListAsync(
105-
projectionMapper.ProjectTo(query)
106-
);
103+
entityDtos = await AsyncExecuter.ToListAsync(projectionMapper.ProjectTo(query));
107104
}
108105
else
109106
{
@@ -120,11 +117,13 @@ public virtual async Task<PagedResultDto<TGetListOutputDto>> GetListAsync(TGetLi
120117

121118
protected abstract Task<TEntity> GetEntityByIdAsync(TKey id);
122119

123-
protected virtual Task<IQueryable<TEntity>> GetEntityByIdQueryAsync(TKey id)
120+
/// <summary>
121+
/// Returns null if this application service can not create a query for a single entity.
122+
/// <see cref="GetEntityByIdAsync"/> is used in that case.
123+
/// </summary>
124+
protected virtual Task<IQueryable<TEntity>?> GetEntityByIdQueryOrNullAsync(TKey id)
124125
{
125-
throw new NotImplementedException(
126-
"Override this method to create the query used for getting an entity by id."
127-
);
126+
return Task.FromResult<IQueryable<TEntity>?>(null);
128127
}
129128

130129
protected virtual async Task CheckGetPolicyAsync()
@@ -269,4 +268,4 @@ protected virtual TGetListOutputDto MapToGetListOutputDto(TEntity entity)
269268
{
270269
return ObjectMapper.Map<TEntity, TGetListOutputDto>(entity);
271270
}
272-
}
271+
}

framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ protected override async Task<TEntity> GetEntityByIdAsync(TKey id)
8484
return await Repository.GetAsync(id);
8585
}
8686

87+
protected override async Task<IQueryable<TEntity>?> GetEntityByIdQueryOrNullAsync(TKey id)
88+
{
89+
var query = await Repository.GetQueryableAsync();
90+
91+
return query.Where(EntityHelper.CreateEqualityExpressionForId<TEntity, TKey>(id));
92+
}
93+
8794
protected override void MapToEntity(TUpdateInput updateInput, TEntity entity)
8895
{
8996
if (updateInput is IEntityDto<TKey> entityDto)

framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public abstract class ReadOnlyAppService<TEntity, TGetOutputDto, TGetListOutputD
3737
protected IReadOnlyRepository<TEntity, TKey> Repository { get; }
3838

3939
protected ReadOnlyAppService(IReadOnlyRepository<TEntity, TKey> repository)
40-
: base(repository)
40+
: base(repository)
4141
{
4242
Repository = repository;
4343
}
@@ -47,11 +47,11 @@ protected override async Task<TEntity> GetEntityByIdAsync(TKey id)
4747
return await Repository.GetAsync(id);
4848
}
4949

50-
protected override async Task<IQueryable<TEntity>> GetEntityByIdQueryAsync(TKey id)
50+
protected override async Task<IQueryable<TEntity>?> GetEntityByIdQueryOrNullAsync(TKey id)
5151
{
5252
var query = await Repository.GetQueryableAsync();
5353

54-
return query.Where(e =>e.Id != null && e.Id.Equals(id));
54+
return query.Where(EntityHelper.CreateEqualityExpressionForId<TEntity, TKey>(id));
5555
}
5656

5757
protected override IQueryable<TEntity> ApplyDefaultSorting(IQueryable<TEntity> query)
@@ -65,4 +65,4 @@ protected override IQueryable<TEntity> ApplyDefaultSorting(IQueryable<TEntity> q
6565
return query.OrderByDescending(e => e.Id);
6666
}
6767
}
68-
}
68+
}

framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ public override void PreConfigureServices(ServiceConfigurationContext context)
1818
typeof(IObjectMapper<,>)
1919
).ConvertAll(t => new ServiceIdentifier(t))
2020
);
21+
22+
//Register types for IQueryProjectionMapper<TSource, TDestination> if implements
23+
onServiceExposingContext.ExposedTypes.AddRange(
24+
ReflectionHelper.GetImplementedGenericTypes(
25+
onServiceExposingContext.ImplementationType,
26+
typeof(IQueryProjectionMapper<,>)
27+
).ConvertAll(t => new ServiceIdentifier(t))
28+
);
2129
});
2230
}
2331

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
1-
using System.Linq;
2-
using Volo.Abp.DependencyInjection;
3-
4-
namespace Volo.Abp.ObjectMapping;
5-
6-
public interface IQueryProjectionMapper<TSource, TDestination> : ITransientDependency
7-
{
8-
IQueryable<TDestination> ProjectTo(IQueryable<TSource> source);
9-
}
10-
11-
//[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
12-
//public partial class GetSectorMapper : IQueryProjectionMapper<Sector, GetSectorsDto>
13-
//{
14-
// public partial IQueryable<GetSectorsDto> ProjectTo(IQueryable<Sector> source);
15-
//}
1+
using System.Linq;
2+
using Volo.Abp.DependencyInjection;
3+
4+
namespace Volo.Abp.ObjectMapping;
5+
6+
/// <summary>
7+
/// Projects a query of <typeparamref name="TSource"/> objects to a query of
8+
/// <typeparamref name="TDestination"/> objects.
9+
/// Implement this interface to let the query provider translate the projection into the data
10+
/// store's own query language, instead of loading the source objects into the memory.
11+
/// </summary>
12+
/// <typeparam name="TSource">Type of the source objects</typeparam>
13+
/// <typeparam name="TDestination">Type of the destination objects</typeparam>
14+
public interface IQueryProjectionMapper<TSource, TDestination> : ITransientDependency
15+
{
16+
/// <summary>
17+
/// Projects the given query to a query of <typeparamref name="TDestination"/> objects.
18+
/// The returned query must be built on top of <paramref name="source"/>, so the query
19+
/// provider can still translate it.
20+
/// </summary>
21+
/// <param name="source">The query to project</param>
22+
IQueryable<TDestination> ProjectTo(IQueryable<TSource> source);
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using System;
2+
using Volo.Abp.Domain.Entities;
3+
4+
namespace Volo.Abp.Application.Services.QueryProjection;
5+
6+
public class Book : Entity<Guid>
7+
{
8+
public string Name { get; set; } = default!;
9+
10+
public int Price { get; set; }
11+
12+
public Book()
13+
{
14+
15+
}
16+
17+
public Book(Guid id, string name, int price)
18+
: base(id)
19+
{
20+
Name = name;
21+
Price = price;
22+
}
23+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using Volo.Abp.Domain.Repositories;
5+
6+
namespace Volo.Abp.Application.Services.QueryProjection;
7+
8+
public class BookAbstractKeyAppService : AbstractKeyReadOnlyAppService<Book, BookDto, Guid>
9+
{
10+
public BookAbstractKeyAppService(IReadOnlyRepository<Book> repository)
11+
: base(repository)
12+
{
13+
14+
}
15+
16+
protected override async Task<Book> GetEntityByIdAsync(Guid id)
17+
{
18+
var query = await ReadOnlyRepository.GetQueryableAsync();
19+
20+
return await AsyncExecuter.FirstAsync(query, book => book.Id == id);
21+
}
22+
23+
protected override IQueryable<Book> ApplyDefaultSorting(IQueryable<Book> query)
24+
{
25+
return query.OrderBy(book => book.Id);
26+
}
27+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System;
2+
using Volo.Abp.Domain.Repositories;
3+
4+
namespace Volo.Abp.Application.Services.QueryProjection;
5+
6+
public class BookAppService : CrudAppService<Book, BookDto, Guid>
7+
{
8+
public BookAppService(IRepository<Book, Guid> repository)
9+
: base(repository)
10+
{
11+
12+
}
13+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using Volo.Abp.Domain.Repositories;
4+
5+
namespace Volo.Abp.Application.Services.QueryProjection;
6+
7+
public class BookCustomizedAppService : CrudAppService<Book, BookDto, Guid>
8+
{
9+
public const string Marker = "-customized";
10+
11+
public BookCustomizedAppService(IRepository<Book, Guid> repository)
12+
: base(repository)
13+
{
14+
15+
}
16+
17+
protected override async Task<Book> GetEntityByIdAsync(Guid id)
18+
{
19+
var book = await base.GetEntityByIdAsync(id);
20+
book.Name += Marker;
21+
return book;
22+
}
23+
24+
protected override Task<BookDto> MapToGetOutputDtoAsync(Book entity)
25+
{
26+
return Task.FromResult(new BookDto { Id = entity.Id, Name = entity.Name + Marker });
27+
}
28+
}

0 commit comments

Comments
 (0)