| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
|
@maliming |
Sorry, something went wrong.
There was a problem hiding this comment.
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:
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. |
framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs:103
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.
Sorry, something went wrong.
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 (TEntity, TDto) pair, so registering one makes every application service using that pair skip its own GetEntityByIdAsync / MapToGetOutputDtoAsync overrides. There is a test and a note in the docs for it now. Mapperly can generate the projection for you, no need to write the Select by hand. Keep the RequiredMappingStrategy you had in your sample, without it Mapperly reports a warning for every entity property the DTO doesn't have, which is the normal case here: [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. |
Sorry, something went wrong.
There was a problem hiding this comment.
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
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
public string Name { get; set; }
framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionDto.cs:8
public string Name { get; set; }
framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionDto.cs:7
public string Name { get; set; }
framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs:22
//Register types for IQueryProjectionMapper<TSource, TDestination> if implements
docs/en/framework/architecture/domain-driven-design/application-services.md:1
```json
Sorry, something went wrong.
|
Can it support asynchrony? What if I need to join another table? GetQueryableAsync is asyn Thanks @maliming |
Sorry, something went wrong.
* 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. |
Sorry, something went wrong.
* 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, ProjectTo only builds an expression, it doesn't execute anything, so there is nothing to await inside it. Getting the query of another aggregate root is a different story, GetQueryableAsync is async as you say. The application service has two async hooks for that: 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
};
}
}CreateGetOutputDtoQueryOrNullAsync does the same for GetAsync. They replace the projector for that application service, so you don't implement IQueryProjector at all in this case. 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: IQueryableMapper is now IQueryProjector, the properties are GetOutputDtoQueryProjector and GetListOutputDtoQueryProjector, and CreateEntityQueryAsync is CreateEntityQueryOrNullAsync. Thanks |
Sorry, something went wrong.
* 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 CreateGetOutputDtoQueryOrNullAsync, but if my other AppService wants to reuse the OrderDto, I need to manually query the total price. @maliming |
Sorry, something went wrong.
|
I understand that it is not in line with DDD thinking, but I cannot be completely trapped in DDD. |
Sorry, something went wrong.
* 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 Items is a collection on Order, a plain projector can calculate it inside the query: 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 Order columns are not read: 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 (Order, OrderDto) pair, so every application service returning an OrderDto uses it. There is no second query to write anywhere. 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 IOrderDtoQuery. 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 |
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Resolve #26015
Adds an optional IQueryProjector<TEntity, TDto>. When one is registered, GetAsync and GetListAsync project 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 CreateGetOutputDtoQueryOrNullAsync or CreateGetListOutputDtoQueryOrNullAsync to build the projection asynchronously, for example to join another aggregate root. Override GetOutputDtoQueryProjector or GetListOutputDtoQueryProjector and return null to opt a specific application service out.