Skip to content

Commit 7288e5a

Browse files
authored
Merge pull request #26016 from nazem0/dev
Add IQueryable projection support to read-only application services
2 parents cfa8575 + c0b78d8 commit 7288e5a

46 files changed

Lines changed: 1507 additions & 4 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
* `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.
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,103 @@ 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 `IQueryProjector<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 : IQueryProjector<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+
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<TSource, TDestination>`.
486+
487+
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.
488+
489+
> 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.
490+
491+
The projector is synchronous, so it can not obtain the query of another aggregate root, which is only
492+
available through the asynchronous `GetQueryableAsync`. Override `CreateGetOutputDtoQueryOrNullAsync` or
493+
`CreateGetListOutputDtoQueryOrNullAsync` for that. They replace the projector for that application service:
494+
495+
````csharp
496+
public class BookAppService : ReadOnlyAppService<Book, BookDto, Guid>
497+
{
498+
private readonly IBookDtoQuery _bookDtoQuery;
499+
500+
//...
501+
502+
protected override async Task<IQueryable<BookDto>?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable<Book> query)
503+
{
504+
return await _bookDtoQuery.ProjectAsync(query);
505+
}
506+
}
507+
508+
//The projection is a class of its own, so the other application services returning a BookDto reuse it
509+
public class BookDtoQuery : IBookDtoQuery, ITransientDependency
510+
{
511+
private readonly IReadOnlyRepository<Author, Guid> _authorRepository;
512+
513+
//...
514+
515+
public async Task<IQueryable<BookDto>> ProjectAsync(IQueryable<Book> books)
516+
{
517+
var authors = await _authorRepository.GetQueryableAsync();
518+
519+
return from book in books
520+
join author in authors on book.AuthorId equals author.Id into bookAuthors
521+
from bookAuthor in bookAuthors.DefaultIfEmpty()
522+
select new BookDto
523+
{
524+
Id = book.Id,
525+
Name = book.Name,
526+
AuthorName = bookAuthor != null ? bookAuthor.Name : null
527+
};
528+
}
529+
}
530+
````
531+
532+
Both queries must come from the same database context, otherwise they can not be executed as a single query,
533+
and the provider has to be able to translate the join. The one row per entity rule above applies here too,
534+
that's why the example uses a left join. A joined column can not be used for the sorting, and the paging is
535+
based on the entity query, since both are applied before this method is called.
536+
537+
A projector is resolved by the `(entity, DTO)` type pair, just like an `IObjectMapper<TSource, TDestination>`, so registering one enables the projection for every application service using that pair. It replaces the way the DTOs are read:
538+
539+
* `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore.
540+
* `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.
541+
542+
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).
543+
544+
> If an application service needs to keep using the entity based extension points, override the `GetOutputDtoQueryProjector` or `GetListOutputDtoQueryProjector` property and return `null`:
545+
546+
````csharp
547+
public class BookAppService : CrudAppService<Book, BookDto, Guid>
548+
{
549+
protected override IQueryProjector<Book, BookDto>? GetOutputDtoQueryProjector => null;
550+
551+
protected override IQueryProjector<Book, BookDto>? GetListOutputDtoQueryProjector => null;
552+
553+
//...
554+
}
555+
````
556+
459557
## Miscellaneous
460558

461559
### Working with Streams

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

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
using System.Collections.Generic;
33
using System.Linq;
44
using System.Linq.Dynamic.Core;
5+
using System.Threading;
56
using System.Threading.Tasks;
67
using Volo.Abp.Application.Dtos;
78
using Volo.Abp.Auditing;
89
using Volo.Abp.Domain.Entities;
910
using Volo.Abp.Domain.Repositories;
1011
using Volo.Abp.ObjectMapping;
12+
using Volo.Abp.Threading;
1113

1214
namespace Volo.Abp.Application.Services;
1315

@@ -44,6 +46,20 @@ public abstract class AbstractKeyReadOnlyAppService<TEntity, TGetOutputDto, TGet
4446

4547
protected virtual string? GetListPolicyName { get; set; }
4648

49+
/// <summary>
50+
/// Used by the <see cref="CreateGetOutputDtoQueryOrNullAsync"/> to project the query to the <typeparamref name="TGetOutputDto"/>.
51+
/// The <see cref="GetEntityByIdAsync"/> and the <see cref="MapToGetOutputDtoAsync"/> are not used while the query is projected.
52+
/// </summary>
53+
protected virtual IQueryProjector<TEntity, TGetOutputDto>? GetOutputDtoQueryProjector
54+
=> LazyServiceProvider.LazyGetService<IQueryProjector<TEntity, TGetOutputDto>>();
55+
56+
/// <summary>
57+
/// Used by the <see cref="CreateGetListOutputDtoQueryOrNullAsync"/> to project the query to the <typeparamref name="TGetListOutputDto"/>.
58+
/// The <see cref="MapToGetListOutputDtosAsync"/> is not used while the query is projected.
59+
/// </summary>
60+
protected virtual IQueryProjector<TEntity, TGetListOutputDto>? GetListOutputDtoQueryProjector
61+
=> LazyServiceProvider.LazyGetService<IQueryProjector<TEntity, TGetListOutputDto>>();
62+
4763
protected AbstractKeyReadOnlyAppService(IReadOnlyRepository<TEntity> repository)
4864
{
4965
ReadOnlyRepository = repository;
@@ -53,6 +69,19 @@ public virtual async Task<TGetOutputDto> GetAsync(TKey id)
5369
{
5470
await CheckGetPolicyAsync();
5571

72+
var dtoQuery = await CreateGetOutputDtoQueryOrNullAsync(id);
73+
if (dtoQuery != null)
74+
{
75+
//TGetOutputDto has no class constraint, so a default value can not be used to detect the missing entity
76+
var dtos = await AsyncExecuter.ToListAsync(dtoQuery.Take(1), GetCancellationToken());
77+
if (dtos.Count == 0)
78+
{
79+
throw new EntityNotFoundException<TEntity>(id);
80+
}
81+
82+
return dtos[0];
83+
}
84+
5685
var entity = await GetEntityByIdAsync(id);
5786

5887
return await MapToGetOutputDtoAsync(entity);
@@ -65,16 +94,23 @@ public virtual async Task<PagedResultDto<TGetListOutputDto>> GetListAsync(TGetLi
6594
var query = await CreateFilteredQueryAsync(input);
6695
var totalCount = await AsyncExecuter.CountAsync(query);
6796

68-
var entities = new List<TEntity>();
6997
var entityDtos = new List<TGetListOutputDto>();
7098

7199
if (totalCount > 0)
72100
{
73101
query = ApplySorting(query, input);
74102
query = ApplyPaging(query, input);
75103

76-
entities = await AsyncExecuter.ToListAsync(query);
77-
entityDtos = await MapToGetListOutputDtosAsync(entities);
104+
var dtoQuery = await CreateGetListOutputDtoQueryOrNullAsync(query);
105+
if (dtoQuery != null)
106+
{
107+
entityDtos = await AsyncExecuter.ToListAsync(dtoQuery);
108+
}
109+
else
110+
{
111+
var entities = await AsyncExecuter.ToListAsync(query);
112+
entityDtos = await MapToGetListOutputDtosAsync(entities);
113+
}
78114
}
79115

80116
return new PagedResultDto<TGetListOutputDto>(
@@ -85,6 +121,57 @@ public virtual async Task<PagedResultDto<TGetListOutputDto>> GetListAsync(TGetLi
85121

86122
protected abstract Task<TEntity> GetEntityByIdAsync(TKey id);
87123

124+
private CancellationToken GetCancellationToken()
125+
{
126+
return LazyServiceProvider
127+
.LazyGetService<ICancellationTokenProvider>(NullCancellationTokenProvider.Instance)
128+
.FallbackToProvider();
129+
}
130+
131+
/// <summary>
132+
/// Should create a query that selects the entity with the given <paramref name="id"/>.
133+
/// It returns null by default, then the entity is not projected.
134+
/// </summary>
135+
/// <param name="id">The id of the entity.</param>
136+
protected virtual Task<IQueryable<TEntity>?> CreateEntityQueryOrNullAsync(TKey id)
137+
{
138+
return Task.FromResult<IQueryable<TEntity>?>(null);
139+
}
140+
141+
/// <summary>
142+
/// Projects the query of the entity with the given <paramref name="id"/> to the <typeparamref name="TGetOutputDto"/>.
143+
/// It uses the <see cref="GetOutputDtoQueryProjector"/> and the <see cref="CreateEntityQueryOrNullAsync"/> by default,
144+
/// and the <see cref="GetEntityByIdAsync"/> is used when it returns null.
145+
/// Override it to await other queries, like the query of another aggregate root to join.
146+
/// </summary>
147+
/// <param name="id">The id of the entity.</param>
148+
protected virtual async Task<IQueryable<TGetOutputDto>?> CreateGetOutputDtoQueryOrNullAsync(TKey id)
149+
{
150+
var queryProjector = GetOutputDtoQueryProjector;
151+
if (queryProjector == null)
152+
{
153+
return null;
154+
}
155+
156+
var query = await CreateEntityQueryOrNullAsync(id);
157+
158+
return query == null ? null : queryProjector.ProjectTo(query);
159+
}
160+
161+
/// <summary>
162+
/// Projects the given entity query to the <typeparamref name="TGetListOutputDto"/>.
163+
/// It uses the <see cref="GetListOutputDtoQueryProjector"/> by default,
164+
/// and the <see cref="MapToGetListOutputDtosAsync"/> is used when it returns null.
165+
/// Override it to await other queries, like the query of another aggregate root to join.
166+
/// The projection must return one row per entity: the total count is already calculated and the paging is
167+
/// already applied, so adding or removing rows makes the page inconsistent with the total count.
168+
/// </summary>
169+
/// <param name="query">The sorted and paged entity query.</param>
170+
protected virtual Task<IQueryable<TGetListOutputDto>?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable<TEntity> query)
171+
{
172+
return Task.FromResult(GetListOutputDtoQueryProjector?.ProjectTo(query));
173+
}
174+
88175
protected virtual async Task CheckGetPolicyAsync()
89176
{
90177
await CheckPolicyAsync(GetPolicyName);

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>?> CreateEntityQueryOrNullAsync(TKey id)
88+
{
89+
var query = await Repository.GetQueryableAsync();
90+
91+
return query.Where(e => e.Id!.Equals(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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ protected override async Task<TEntity> GetEntityByIdAsync(TKey id)
4747
return await Repository.GetAsync(id);
4848
}
4949

50+
protected override async Task<IQueryable<TEntity>?> CreateEntityQueryOrNullAsync(TKey id)
51+
{
52+
var query = await Repository.GetQueryableAsync();
53+
54+
return query.Where(e => e.Id!.Equals(id));
55+
}
56+
5057
protected override IQueryable<TEntity> ApplyDefaultSorting(IQueryable<TEntity> query)
5158
{
5259
if (typeof(TEntity).IsAssignableTo<ICreationAuditedObject>())

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Microsoft.Extensions.DependencyInjection;
1+
using System.Collections.Generic;
2+
using Microsoft.Extensions.DependencyInjection;
23
using Volo.Abp.DependencyInjection;
34
using Volo.Abp.Modularity;
45
using Volo.Abp.Reflection;
@@ -18,6 +19,14 @@ public override void PreConfigureServices(ServiceConfigurationContext context)
1819
typeof(IObjectMapper<,>)
1920
).ConvertAll(t => new ServiceIdentifier(t))
2021
);
22+
23+
//Register types for IQueryProjector<TSource, TDestination> if implements
24+
foreach (var serviceType in ReflectionHelper.GetImplementedGenericTypes(
25+
onServiceExposingContext.ImplementationType,
26+
typeof(IQueryProjector<,>)))
27+
{
28+
onServiceExposingContext.ExposedTypes.AddIfNotContains(new ServiceIdentifier(serviceType));
29+
}
2130
});
2231
}
2332

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.Linq;
2+
using Volo.Abp.DependencyInjection;
3+
4+
namespace Volo.Abp.ObjectMapping;
5+
6+
/// <summary>
7+
/// Maps a query to another.
8+
/// Implement this interface to project a query on the data store side, instead of loading the
9+
/// source objects into the memory and mapping them one by one.
10+
/// Implement it once for a source and destination pair. Use the ReplaceServices option of the
11+
/// DependencyAttribute to replace an existing implementation.
12+
/// </summary>
13+
/// <typeparam name="TSource">Type of the source objects</typeparam>
14+
/// <typeparam name="TDestination">Type of the destination objects</typeparam>
15+
public interface IQueryProjector<TSource, TDestination> : ITransientDependency
16+
{
17+
/// <summary>
18+
/// Projects the given query. The returned query must be built on top of it and must keep its order,
19+
/// with a single destination object for each source object, using expressions the query provider can
20+
/// translate. The caller may have already sorted, paged or counted the source query.
21+
/// </summary>
22+
/// <param name="source">The query to project</param>
23+
IQueryable<TDestination> ProjectTo(IQueryable<TSource> source);
24+
}
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+
}

0 commit comments

Comments
 (0)