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 d2cba1c4e6..e4980074ba 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; @@ -45,20 +47,18 @@ public abstract class AbstractKeyReadOnlyAppService - /// Used by the to project the query to the . - /// It returns the registered mapper or null by default. - /// The and the are not used when it returns a mapper. + /// Used by the to project the query to the . + /// The and the are not used while the query is projected. /// - protected virtual IQueryableMapper? GetQueryableMapper - => LazyServiceProvider.LazyGetService>(); + protected virtual IQueryProjector? GetOutputDtoQueryProjector + => LazyServiceProvider.LazyGetService>(); /// - /// Used by the to project the query to the . - /// It returns the registered mapper or null by default. - /// The is not used when it returns a mapper. + /// Used by the to project the query to the . + /// The is not used while the query is projected. /// - protected virtual IQueryableMapper? GetListQueryableMapper - => LazyServiceProvider.LazyGetService>(); + protected virtual IQueryProjector? GetListOutputDtoQueryProjector + => LazyServiceProvider.LazyGetService>(); protected AbstractKeyReadOnlyAppService(IReadOnlyRepository repository) { @@ -69,15 +69,17 @@ public abstract class AbstractKeyReadOnlyAppService(id); + throw new EntityNotFoundException(id); } + + return dtos[0]; } var entity = await GetEntityByIdAsync(id); @@ -90,7 +92,7 @@ public abstract class AbstractKeyReadOnlyAppService(); @@ -99,14 +101,14 @@ public abstract class AbstractKeyReadOnlyAppService GetEntityByIdAsync(TKey id); + protected virtual CancellationToken GetCancellationToken(CancellationToken preferredValue = default) + { + return CancellationTokenProvider.FallbackToProvider(preferredValue); + } + /// /// Should create a query that selects the entity with the given . - /// It returns null by default, then the is used instead of the projection. + /// It returns null by default, then the entity is not projected. /// /// The id of the entity. - protected virtual Task?> CreateEntityQueryAsync(TKey id) + 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/ApplicationService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs index 4eb285df04..01e9e81354 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs @@ -19,6 +19,7 @@ using Volo.Abp.Localization; using Volo.Abp.MultiTenancy; using Volo.Abp.ObjectMapping; using Volo.Abp.Settings; +using Volo.Abp.Threading; using Volo.Abp.Timing; using Volo.Abp.Uow; using Volo.Abp.Users; @@ -48,6 +49,8 @@ public abstract class ApplicationService : protected IAsyncQueryableExecuter AsyncExecuter => LazyServiceProvider.LazyGetRequiredService(); + protected ICancellationTokenProvider CancellationTokenProvider => LazyServiceProvider.LazyGetService(NullCancellationTokenProvider.Instance); + protected Type? ObjectMapperContext { get; set; } protected IObjectMapper ObjectMapper => LazyServiceProvider.LazyGetService(provider => ObjectMapperContext == null 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 196a4a0e6d..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,11 +84,11 @@ public abstract class CrudAppService?> CreateEntityQueryAsync(TKey id) + protected override async Task?> CreateEntityQueryOrNullAsync(TKey id) { var query = await Repository.GetQueryableAsync(); - return query.Where(EntityHelper.CreateEqualityExpressionForId(id)); + return query.Where(e => e.Id!.Equals(id)); } protected override void MapToEntity(TUpdateInput updateInput, TEntity entity) 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 efcb456212..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,11 +47,11 @@ public abstract class ReadOnlyAppService?> CreateEntityQueryAsync(TKey id) + protected override async Task?> CreateEntityQueryOrNullAsync(TKey id) { var query = await Repository.GetQueryableAsync(); - return query.Where(EntityHelper.CreateEqualityExpressionForId(id)); + return query.Where(e => e.Id!.Equals(id)); } protected override IQueryable ApplyDefaultSorting(IQueryable query) 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 f0c412fd48..87e0c43b56 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; @@ -19,13 +20,14 @@ public class AbpObjectMappingModule : AbpModule ).ConvertAll(t => new ServiceIdentifier(t)) ); - //Register types for IQueryableMapper if implements - onServiceExposingContext.ExposedTypes.AddRange( - ReflectionHelper.GetImplementedGenericTypes( - onServiceExposingContext.ImplementationType, - typeof(IQueryableMapper<,>) - ).ConvertAll(t => new ServiceIdentifier(t)) - ); + //Register types for IQueryProjector if implements + //The class name convention may have already exposed them, so they are not added twice + 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/IQueryableMapper.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs similarity index 62% rename from framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryableMapper.cs rename to framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs index 38cd45bef7..1abf5667e6 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryableMapper.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs @@ -7,14 +7,16 @@ 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, the last registered one is used otherwise. /// /// Type of the source objects /// Type of the destination objects -public interface IQueryableMapper : ITransientDependency +public interface IQueryProjector : ITransientDependency { /// - /// Projects the given query. The returned query must be built on top of it, otherwise the - /// query provider can not translate the projection. + /// 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/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/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/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/BookObjectMapper.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs index f5668b9c29..c398f9e25f 100644 --- 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 @@ -6,6 +6,7 @@ namespace Volo.Abp.Application.Services.QueryProjection; public class BookObjectMapper : IObjectMapper, IObjectMapper, + IObjectMapper, ITransientDependency { public const string Marker = "-mapped"; @@ -33,4 +34,15 @@ public class BookObjectMapper : 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/BookProjector.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs index f72dc2ada0..fa292161fc 100644 --- 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 @@ -3,7 +3,7 @@ using Volo.Abp.ObjectMapping; namespace Volo.Abp.Application.Services.QueryProjection; -public class BookProjector : IQueryableMapper +public class BookProjector : IQueryProjector { public const string Marker = "-projected"; 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 index d6837edc6c..9ad4680d45 100644 --- 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 @@ -1,3 +1,4 @@ +#nullable enable using System; using Volo.Abp.Domain.Repositories; using Volo.Abp.ObjectMapping; @@ -6,9 +7,9 @@ namespace Volo.Abp.Application.Services.QueryProjection; public class BookWithoutProjectionAppService : CrudAppService { - protected override IQueryableMapper GetQueryableMapper => null; + protected override IQueryProjector? GetOutputDtoQueryProjector => null; - protected override IQueryableMapper GetListQueryableMapper => null; + protected override IQueryProjector? GetListOutputDtoQueryProjector => null; public BookWithoutProjectionAppService(IRepository repository) : base(repository) 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 index 348cad0728..228e4591c3 100644 --- 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 @@ -21,9 +21,9 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase } [Fact] - public void Should_Resolve_Projection_Mapper_Independent_From_The_Class_Name() + public void Should_Resolve_The_Projector_Independent_From_The_Class_Name() { - ServiceProvider.GetService>() + ServiceProvider.GetService>() .ShouldBeOfType(); } @@ -56,7 +56,7 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase } [Fact] - public async Task Should_Use_The_Object_Mapper_If_No_Projection_Mapper_Was_Registered() + public async Task Should_Use_The_Object_Mapper_If_No_Projector_Was_Registered() { var appService = GetRequiredService(); @@ -98,4 +98,84 @@ public class QueryProjection_Tests : AbpDddApplicationTestBase (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); + } } 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/EntityWithIntPkProjector.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs index 81f19b495d..5e97ceca51 100644 --- 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 @@ -4,7 +4,7 @@ using Volo.Abp.TestApp.Domain; namespace Volo.Abp.EntityFrameworkCore.Applications; -public class EntityWithIntPkProjector : IQueryableMapper +public class EntityWithIntPkProjector : IQueryProjector { public IQueryable ProjectTo(IQueryable source) { 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 index 2ede252731..d9abce7cd8 100644 --- 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 @@ -4,7 +4,7 @@ using Volo.Abp.TestApp.Domain; namespace Volo.Abp.EntityFrameworkCore.Applications; -public class PersonProjector : IQueryableMapper +public class PersonProjector : IQueryProjector { public IQueryable ProjectTo(IQueryable source) { 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..db62825b8e --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs @@ -0,0 +1,11 @@ +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 index bcc96c4730..e2d2f45d7c 100644 --- 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 @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Shouldly; @@ -8,6 +10,8 @@ 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; @@ -73,7 +77,7 @@ public class QueryProjection_Tests : EntityFrameworkCoreTestBase await WithUnitOfWorkAsync(async () => { var repository = GetRequiredService>(); - var projector = GetRequiredService>(); + var projector = GetRequiredService>(); var sql = projector.ProjectTo(await repository.GetQueryableAsync()).ToQueryString(); @@ -85,4 +89,88 @@ public class QueryProjection_Tests : EntityFrameworkCoreTestBase 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 select = commands.Last(x => x.Contains("FROM \"People\"") && !x.Contains("COUNT")); + + 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(); + + using (cancellationTokenProvider.Use(new CancellationToken(canceled: true))) + { + await Should.ThrowAsync(async () => + await appService.GetAsync(TestDataBuilder.UserDouglasId)); + + await Should.ThrowAsync(async () => + await appService.GetListAsync(new PagedAndSortedResultRequestDto())); + } + } + + [Fact] + public async Task Should_Use_The_Ambient_Cancellation_Token_Without_A_Projector() + { + var cancellationTokenProvider = GetRequiredService(); + var appService = GetRequiredService(); + + using (cancellationTokenProvider.Use(new CancellationToken(canceled: true))) + { + await Should.ThrowAsync(async () => + await appService.GetListAsync(new PagedAndSortedResultRequestDto())); + } + } } 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 index ca2ac86d3a..6715fdfa74 100644 --- 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 @@ -15,17 +15,25 @@ public class AbpMapperlyQueryProjection_Tests : AbpIntegratedTest>(); + var queryProjector = ServiceProvider.GetRequiredService>(); var entities = new List { new MyEntity { Id = Guid.NewGuid(), Number = 42 } }.AsQueryable(); - var dtos = projectionMapper.ProjectTo(entities).ToList(); + 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 index d7ad0fb260..980e40a94b 100644 --- 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 @@ -5,7 +5,7 @@ using Volo.Abp.ObjectMapping; namespace Volo.Abp.Mapperly.SampleClasses; [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] -public partial class MyEntityQueryProjector : IQueryableMapper +public partial class MyEntityQueryProjector : IQueryProjector { public partial IQueryable ProjectTo(IQueryable source); } 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 index 341ef77d91..eac0c6fd3a 100644 --- 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 @@ -4,7 +4,7 @@ using Volo.Abp.TestApp.Domain; namespace Volo.Abp.MongoDB.Applications; -public class PersonProjector : IQueryableMapper +public class PersonProjector : IQueryProjector { public IQueryable ProjectTo(IQueryable source) {