Add IQueryable projection support to read-only application services - #26016
Conversation
|
@maliming |
There was a problem hiding this comment.
Pull request overview
This PR introduces an optional query-level DTO projection extension point for read-only application services, enabling ORMs to translate DTO projections to the underlying query (e.g., SQL) to avoid materializing full entities when a projection mapper is available.
Changes:
- Added
IQueryProjectionMapper<TSource, TDestination>(and a baseQueryProjectionMapper<,>) as a DI-resolvable abstraction to projectIQueryable<TEntity>toIQueryable<TDto>. - Updated
AbstractKeyReadOnlyAppServiceto use projection mappers inGetAsync/GetListAsyncwhen present, with a fallback to existing entity materialization +ObjectMapper. - Implemented
GetEntityByIdQueryAsyncinReadOnlyAppServiceto provide a default query-by-id for projection scenarios.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjectionMapper.cs | Introduces the projection mapper interface for IQueryable-based DTO projection. |
| framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/QueryProjectionMapper.cs | Adds a convenience base class for implementing query projections. |
| framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs | Implements query-by-id creation used by projection-enabled GetAsync. |
| framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs | Adds optional projection paths for GetAsync / GetListAsync with fallback behavior. |
Suppressed comments (1)
framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs:103
GetListAsyncresolvesListProjectionMapperbefore checkingUseListProjectionMapper, so overridingUseListProjectionMappertofalsedoes not fully disable the projection feature. Resolve the mapper only when the feature is enabled.
var projectionMapper = ListProjectionMapper;
if (UseListProjectionMapper && projectionMapper != null)
{
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* 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
* RequiredMappingStrategy.Target avoids an RMG020 warning per unmapped entity property
|
Hi @nazem0, I pushed a couple of commits to your branch instead of going back and forth in review comments. Here is what changed and why:
One thing to keep in mind with this design: the mapper is resolved from DI per Mapperly can generate the projection for you, no need to write the [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
public partial class BookProjector : IQueryableMapper<Book, BookDto>
{
public partial IQueryable<BookDto> ProjectTo(IQueryable<Book> source);
}Thanks for implementing this, it is a nice addition for read heavy endpoints. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.
Suppressed comments (6)
framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs:11
GetProjectionMapper/GetListProjectionMapperare nullable in the base type, but this override uses a non-nullable return type while returningnull, which triggers nullable reference warnings (and can fail builds when warnings are treated as errors).
protected override IQueryProjectionMapper<Book, BookDto> GetProjectionMapper => null;
protected override IQueryProjectionMapper<Book, BookDto> GetListProjectionMapper => null;
framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionDto.cs:8
Nameis declared as non-nullable but isn't initialized, which will produce CS8618 warnings under nullable reference types. Initialize it (or make it nullable) to keep the test project warning-free.
public string Name { get; set; }
framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionDto.cs:8
Nameis declared as non-nullable but isn't initialized, which will produce CS8618 warnings under nullable reference types. Initialize it (or make it nullable) to keep the test project warning-free.
public string Name { get; set; }
framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionDto.cs:7
Nameis declared as non-nullable but isn't initialized, which will produce CS8618 warnings under nullable reference types. Initialize it (or make it nullable) to keep the test project warning-free.
public string Name { get; set; }
framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs:22
- Grammar nit in this newly added comment: "if implements" reads incorrectly; it should be "if implemented".
//Register types for IQueryProjectionMapper<TSource, TDestination> if implements
docs/en/framework/architecture/domain-driven-design/application-services.md:1
- The file now starts with a BOM/zero-width character before the opening code fence (visible as an extra character before the first backticks). This can cause noisy diffs and tooling issues; remove the BOM so the first line starts with plain ```json.
```json
|
Can it support asynchrony? What if I need to join another table? GetQueryableAsync is asyn Thanks @maliming |
* IQueryableMapper pairs with IObjectMapper, CreateEntityQueryAsync with CreateFilteredQueryAsync
Thanks @maliming for taking the time to review this and for pushing the fixes directly. The changes make sense, especially moving the query creation to CreateEntityQueryAsync so it works correctly with both ReadOnlyAppService and CrudAppService, and aligning the naming with IObjectMapper. I also agree that removing the separate Use*ProjectionMapper properties makes the API cleaner. Thanks again for the improvements and the additional tests/documentation. |
* 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
|
Hi @XuJin186,
public class PersonWithCityAppService : ReadOnlyAppService<Person, PersonWithCityDto, Guid>
{
private readonly IReadOnlyRepository<City, Guid> _cityRepository;
//...
protected override async Task<IQueryable<PersonWithCityDto>?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable<Person> query)
{
var cities = await _cityRepository.GetQueryableAsync();
return from person in query
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
};
}
}
Two things to keep in mind. Both queries have to come from the same database context, otherwise they can't be executed as a single query. And the projection has to keep one row per entity: the total count and the paging are applied to the entity query before it runs, so an inner join to an optional relation drops rows from the page while the total count still counts them. That's why the sample uses a left join. @nazem0 the names changed after the comment you quoted: Thanks |
* The projection only replaces the DTO creation, the filters and the policies still apply
|
I know what you mean,Why I say this is what I think. For example, I have an order class and I have an OrderDto. There is a field in it that is the total price. I can get it through Join Items,My order AppService can definitely rewrite |
|
I understand that it is not in line with DDD thinking, but I cannot be completely trapped in DDD. |
* 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
|
Hi @XuJin186, For the total price you described you probably don't need the async hooks at all. If public class OrderProjector : IQueryProjector<Order, OrderDto>
{
public IQueryable<OrderDto> ProjectTo(IQueryable<Order> source)
{
return source.Select(order => new OrderDto
{
Id = order.Id,
Number = order.Number,
TotalPrice = order.Items.Sum(item => item.UnitPrice * item.Quantity)
});
}
}EF Core translates it to a correlated subquery, so the total is calculated on the database and the other SELECT o.Id, o.Number, (
SELECT COALESCE(SUM(o0.UnitPrice * o0.Quantity), 0)
FROM OrderItem AS o0
WHERE o.Id = o0.OrderId) AS TotalPrice
FROM Orders AS oThe projector is resolved by the The async hooks are only needed when the other side is not reachable from the entity and you have to get its query from another repository. That is per application service, but you don't have to repeat the join in each one. Put it in an application layer service and call it from the hooks: public interface IOrderDtoQuery : ITransientDependency
{
Task<IQueryable<OrderDto>> ProjectAsync(IQueryable<Order> orders);
}
public class OrderAppService : ReadOnlyAppService<Order, OrderDto, Guid>
{
private readonly IOrderDtoQuery _orderDtoQuery;
//...
protected override async Task<IQueryable<OrderDto>?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable<Order> query)
{
return await _orderDtoQuery.ProjectAsync(query);
}
}Any other application service injects the same There is no reusable async projector contract yet, and that is a fair gap to point out. A few things have to be settled before adding one: how it coexists with the synchronous projector when both are registered for the same pair, how an application service opts out of it, and how it keeps one row per entity so the paging still matches the total count. We'll track that as a separate feature request instead of growing this PR. To be clear, this is not about DDD. The framework can join whatever the query provider can translate, both queries just have to come from the same database context. Thanks |
Resolve #26015
Adds an optional
IQueryProjector<TEntity, TDto>. When one is registered,GetAsyncandGetListAsyncproject the query to the DTO instead of loading the entities and mapping them in the memory. Nothing changes when no projector is registered, so existing application services keep working as before.A projection has to return one row per entity, since the total count and the paging are applied to the entity query before it runs. Override
CreateGetOutputDtoQueryOrNullAsyncorCreateGetListOutputDtoQueryOrNullAsyncto build the projection asynchronously, for example to join another aggregate root. OverrideGetOutputDtoQueryProjectororGetListOutputDtoQueryProjectorand returnnullto opt a specific application service out.