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..a2d2db5acc 100644 --- a/docs/en/framework/architecture/domain-driven-design/application-services.md +++ b/docs/en/framework/architecture/domain-driven-design/application-services.md @@ -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. +* `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 @@ -456,6 +457,103 @@ 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 `IQueryProjector` interface to define a projection: + +````csharp +using System.Linq; +using Volo.Abp.ObjectMapping; + +namespace MyProject.Books; + +public class BookProjector : IQueryProjector +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(book => new BookDto + { + Id = book.Id, + Name = book.Name + }); + } +} +```` + +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. + +> 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 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 books + 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 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. It replaces the way the DTOs are read: + +* `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. + +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`: + +````csharp +public class BookAppService : CrudAppService +{ + protected override IQueryProjector? GetOutputDtoQueryProjector => null; + + protected override IQueryProjector? GetListOutputDtoQueryProjector => 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 3791c4202f..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 @@ -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; @@ -44,6 +46,20 @@ public abstract class AbstractKeyReadOnlyAppService + /// Used by the to project the query to the . + /// The and the are not used while the query is projected. + /// + protected virtual IQueryProjector? GetOutputDtoQueryProjector + => LazyServiceProvider.LazyGetService>(); + + /// + /// Used by the to project the query to the . + /// The is not used while the query is projected. + /// + protected virtual IQueryProjector? GetListOutputDtoQueryProjector + => LazyServiceProvider.LazyGetService>(); + protected AbstractKeyReadOnlyAppService(IReadOnlyRepository repository) { ReadOnlyRepository = repository; @@ -53,6 +69,19 @@ public abstract class AbstractKeyReadOnlyAppService(id); + } + + return dtos[0]; + } + var entity = await GetEntityByIdAsync(id); return await MapToGetOutputDtoAsync(entity); @@ -65,7 +94,6 @@ public abstract class AbstractKeyReadOnlyAppService(); var entityDtos = new List(); if (totalCount > 0) @@ -73,8 +101,16 @@ public abstract class AbstractKeyReadOnlyAppService( @@ -85,6 +121,57 @@ public abstract class AbstractKeyReadOnlyAppService GetEntityByIdAsync(TKey id); + private CancellationToken GetCancellationToken() + { + return LazyServiceProvider + .LazyGetService(NullCancellationTokenProvider.Instance) + .FallbackToProvider(); + } + + /// + /// Should create a query that selects the entity with the given . + /// It returns null by default, then the entity is not projected. + /// + /// The id of the entity. + 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/CrudAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs index 672d85a2be..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,6 +84,13 @@ public abstract class CrudAppService?> CreateEntityQueryOrNullAsync(TKey id) + { + var query = await Repository.GetQueryableAsync(); + + return query.Where(e => e.Id!.Equals(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 867ca35ad4..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,6 +47,13 @@ public abstract class ReadOnlyAppService?> CreateEntityQueryOrNullAsync(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()) 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..2f1b8830ac 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; @@ -18,6 +19,14 @@ public class AbpObjectMappingModule : AbpModule typeof(IObjectMapper<,>) ).ConvertAll(t => new ServiceIdentifier(t)) ); + + //Register types for IQueryProjector if implements + 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/IQueryProjector.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs new file mode 100644 index 0000000000..89652e5f40 --- /dev/null +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs @@ -0,0 +1,24 @@ +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. +/// 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 +public interface IQueryProjector : ITransientDependency +{ + /// + /// 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/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/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/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/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/BookCustomizedAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs new file mode 100644 index 0000000000..f5df26baaa --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +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 }); + } + + 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/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/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..c398f9e25f --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs @@ -0,0 +1,48 @@ +using Volo.Abp.DependencyInjection; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookObjectMapper : + IObjectMapper, + 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; + } + + 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/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/BookProjector.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs new file mode 100644 index 0000000000..fa292161fc --- /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 : IQueryProjector +{ + 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/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 new file mode 100644 index 0000000000..928c8fee66 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs @@ -0,0 +1,33 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.ObjectMapping; + +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; + + public BookWithoutProjectionAppService(IRepository repository) + : base(repository) + { + + } + + protected override Task 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/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 new file mode 100644 index 0000000000..425021fb03 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs @@ -0,0 +1,196 @@ +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_The_Projector_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_Projector_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_Entity_Based_Overrides_If_The_Projection_Was_Disabled() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookWithoutProjectionAppService.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookWithoutProjectionAppService.Marker); + } + + [Fact] + public async Task Should_Not_Use_The_Entity_Based_Overrides_While_Projecting() + { + var appService = GetRequiredService(); + + var dto = await appService.GetAsync(_bookId); + + 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] + 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); + } + + [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); + } + + [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())); + } +} 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/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..5e97ceca51 --- /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 : IQueryProjector +{ + 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..d9abce7cd8 --- /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 : IQueryProjector +{ + 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/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..dac278c7b6 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs @@ -0,0 +1,12 @@ +#nullable enable +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 new file mode 100644 index 0000000000..d4410c15e7 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +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.Application; +using Volo.Abp.Threading; +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); + } + + [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"); + }); + } + + [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 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\""); + } + + [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(); + + //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)); + } + } + + [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_Apply_The_Data_Filters_Of_The_Joined_Aggregate() + { + var cityRepository = GetRequiredService>(); + + await WithUnitOfWorkAsync(async () => + { + 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); + } +} 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 new file mode 100644 index 0000000000..6715fdfa74 --- /dev/null +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs @@ -0,0 +1,39 @@ +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 queryProjector = ServiceProvider.GetRequiredService>(); + + var entities = new List + { + new MyEntity { Id = Guid.NewGuid(), Number = 42 } + }.AsQueryable(); + + 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 new file mode 100644 index 0000000000..980e40a94b --- /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(RequiredMappingStrategy = RequiredMappingStrategy.Target)] +public partial class MyEntityQueryProjector : IQueryProjector +{ + 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..eac0c6fd3a --- /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 : IQueryProjector +{ + 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"); + } +}