Browse Source

Add asynchronous hooks and rename the query projection API

* IQueryProjector replaces IQueryableMapper, the hooks can await other queries to join them
* A projection must return one row per entity, the total count and the paging come before it
* Pass the ambient cancellation token and detect the missing entity for value type DTOs
pull/26016/head
maliming 2 weeks ago
parent
commit
3da29b2b35
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 87
      framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs
  2. 3
      framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ApplicationService.cs
  3. 4
      framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs
  4. 4
      framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs
  5. 18
      framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs
  6. 8
      framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs
  7. 35
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyProjectingAppService.cs
  8. 46
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAsyncProjectionAppService.cs
  9. 15
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailAppService.cs
  10. 9
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailDto.cs
  11. 18
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailProjector.cs
  12. 12
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs
  13. 2
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs
  14. 13
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructAppService.cs
  15. 10
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructDto.cs
  16. 16
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructProjector.cs
  17. 5
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs
  18. 19
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/IBookNameSuffixProvider.cs
  19. 86
      framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs
  20. 2
      framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/AbpEntityFrameworkCoreTestModule.cs
  21. 2
      framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs
  22. 2
      framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs
  23. 51
      framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityAppService.cs
  24. 11
      framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs
  25. 90
      framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs
  26. 40
      framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/SqlCommandCapture.cs
  27. 12
      framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs
  28. 2
      framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs
  29. 2
      framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs

87
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<TEntity, TGetOutputDto, TGet
protected virtual string? GetListPolicyName { get; set; }
/// <summary>
/// Used by the <see cref="GetAsync"/> to project the query to the <typeparamref name="TGetOutputDto"/>.
/// It returns the registered mapper or null by default.
/// The <see cref="GetEntityByIdAsync"/> and the <see cref="MapToGetOutputDtoAsync"/> are not used when it returns a mapper.
/// Used by the <see cref="CreateGetOutputDtoQueryOrNullAsync"/> to project the query to the <typeparamref name="TGetOutputDto"/>.
/// The <see cref="GetEntityByIdAsync"/> and the <see cref="MapToGetOutputDtoAsync"/> are not used while the query is projected.
/// </summary>
protected virtual IQueryableMapper<TEntity, TGetOutputDto>? GetQueryableMapper
=> LazyServiceProvider.LazyGetService<IQueryableMapper<TEntity, TGetOutputDto>>();
protected virtual IQueryProjector<TEntity, TGetOutputDto>? GetOutputDtoQueryProjector
=> LazyServiceProvider.LazyGetService<IQueryProjector<TEntity, TGetOutputDto>>();
/// <summary>
/// Used by the <see cref="GetListAsync"/> to project the query to the <typeparamref name="TGetListOutputDto"/>.
/// It returns the registered mapper or null by default.
/// The <see cref="MapToGetListOutputDtosAsync"/> is not used when it returns a mapper.
/// Used by the <see cref="CreateGetListOutputDtoQueryOrNullAsync"/> to project the query to the <typeparamref name="TGetListOutputDto"/>.
/// The <see cref="MapToGetListOutputDtosAsync"/> is not used while the query is projected.
/// </summary>
protected virtual IQueryableMapper<TEntity, TGetListOutputDto>? GetListQueryableMapper
=> LazyServiceProvider.LazyGetService<IQueryableMapper<TEntity, TGetListOutputDto>>();
protected virtual IQueryProjector<TEntity, TGetListOutputDto>? GetListOutputDtoQueryProjector
=> LazyServiceProvider.LazyGetService<IQueryProjector<TEntity, TGetListOutputDto>>();
protected AbstractKeyReadOnlyAppService(IReadOnlyRepository<TEntity> repository)
{
@ -69,15 +69,17 @@ public abstract class AbstractKeyReadOnlyAppService<TEntity, TGetOutputDto, TGet
{
await CheckGetPolicyAsync();
var projectionMapper = GetQueryableMapper;
if (projectionMapper != null)
var dtoQuery = await CreateGetOutputDtoQueryOrNullAsync(id);
if (dtoQuery != null)
{
var query = await CreateEntityQueryAsync(id);
if (query != null)
//TGetOutputDto has no class constraint, so a default value can not be used to detect the missing entity
var dtos = await AsyncExecuter.ToListAsync(dtoQuery.Take(1), GetCancellationToken());
if (dtos.Count == 0)
{
return await AsyncExecuter.FirstOrDefaultAsync(projectionMapper.ProjectTo(query))
?? throw new EntityNotFoundException<TEntity>(id);
throw new EntityNotFoundException<TEntity>(id);
}
return dtos[0];
}
var entity = await GetEntityByIdAsync(id);
@ -90,7 +92,7 @@ public abstract class AbstractKeyReadOnlyAppService<TEntity, TGetOutputDto, TGet
await CheckGetListPolicyAsync();
var query = await CreateFilteredQueryAsync(input);
var totalCount = await AsyncExecuter.CountAsync(query);
var totalCount = await AsyncExecuter.CountAsync(query, GetCancellationToken());
var entityDtos = new List<TGetListOutputDto>();
@ -99,14 +101,14 @@ public abstract class AbstractKeyReadOnlyAppService<TEntity, TGetOutputDto, TGet
query = ApplySorting(query, input);
query = ApplyPaging(query, input);
var projectionMapper = GetListQueryableMapper;
if (projectionMapper != null)
var dtoQuery = await CreateGetListOutputDtoQueryOrNullAsync(query);
if (dtoQuery != null)
{
entityDtos = await AsyncExecuter.ToListAsync(projectionMapper.ProjectTo(query));
entityDtos = await AsyncExecuter.ToListAsync(dtoQuery, GetCancellationToken());
}
else
{
var entities = await AsyncExecuter.ToListAsync(query);
var entities = await AsyncExecuter.ToListAsync(query, GetCancellationToken());
entityDtos = await MapToGetListOutputDtosAsync(entities);
}
}
@ -119,16 +121,55 @@ public abstract class AbstractKeyReadOnlyAppService<TEntity, TGetOutputDto, TGet
protected abstract Task<TEntity> GetEntityByIdAsync(TKey id);
protected virtual CancellationToken GetCancellationToken(CancellationToken preferredValue = default)
{
return CancellationTokenProvider.FallbackToProvider(preferredValue);
}
/// <summary>
/// Should create a query that selects the entity with the given <paramref name="id"/>.
/// It returns null by default, then the <see cref="GetEntityByIdAsync"/> is used instead of the projection.
/// It returns null by default, then the entity is not projected.
/// </summary>
/// <param name="id">The id of the entity.</param>
protected virtual Task<IQueryable<TEntity>?> CreateEntityQueryAsync(TKey id)
protected virtual Task<IQueryable<TEntity>?> CreateEntityQueryOrNullAsync(TKey id)
{
return Task.FromResult<IQueryable<TEntity>?>(null);
}
/// <summary>
/// Projects the query of the entity with the given <paramref name="id"/> to the <typeparamref name="TGetOutputDto"/>.
/// It uses the <see cref="GetOutputDtoQueryProjector"/> and the <see cref="CreateEntityQueryOrNullAsync"/> by default,
/// and the <see cref="GetEntityByIdAsync"/> is used when it returns null.
/// Override it to await other queries, like the query of another aggregate root to join.
/// </summary>
/// <param name="id">The id of the entity.</param>
protected virtual async Task<IQueryable<TGetOutputDto>?> CreateGetOutputDtoQueryOrNullAsync(TKey id)
{
var queryProjector = GetOutputDtoQueryProjector;
if (queryProjector == null)
{
return null;
}
var query = await CreateEntityQueryOrNullAsync(id);
return query == null ? null : queryProjector.ProjectTo(query);
}
/// <summary>
/// Projects the given entity query to the <typeparamref name="TGetListOutputDto"/>.
/// It uses the <see cref="GetListOutputDtoQueryProjector"/> by default,
/// and the <see cref="MapToGetListOutputDtosAsync"/> 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.
/// </summary>
/// <param name="query">The sorted and paged entity query.</param>
protected virtual Task<IQueryable<TGetListOutputDto>?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable<TEntity> query)
{
return Task.FromResult(GetListOutputDtoQueryProjector?.ProjectTo(query));
}
protected virtual async Task CheckGetPolicyAsync()
{
await CheckPolicyAsync(GetPolicyName);

3
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<IAsyncQueryableExecuter>();
protected ICancellationTokenProvider CancellationTokenProvider => LazyServiceProvider.LazyGetService<ICancellationTokenProvider>(NullCancellationTokenProvider.Instance);
protected Type? ObjectMapperContext { get; set; }
protected IObjectMapper ObjectMapper => LazyServiceProvider.LazyGetService<IObjectMapper>(provider =>
ObjectMapperContext == null

4
framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs

@ -84,11 +84,11 @@ public abstract class CrudAppService<TEntity, TGetOutputDto, TGetListOutputDto,
return await Repository.GetAsync(id);
}
protected override async Task<IQueryable<TEntity>?> CreateEntityQueryAsync(TKey id)
protected override async Task<IQueryable<TEntity>?> CreateEntityQueryOrNullAsync(TKey id)
{
var query = await Repository.GetQueryableAsync();
return query.Where(EntityHelper.CreateEqualityExpressionForId<TEntity, TKey>(id));
return query.Where(e => e.Id!.Equals(id));
}
protected override void MapToEntity(TUpdateInput updateInput, TEntity entity)

4
framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs

@ -47,11 +47,11 @@ public abstract class ReadOnlyAppService<TEntity, TGetOutputDto, TGetListOutputD
return await Repository.GetAsync(id);
}
protected override async Task<IQueryable<TEntity>?> CreateEntityQueryAsync(TKey id)
protected override async Task<IQueryable<TEntity>?> CreateEntityQueryOrNullAsync(TKey id)
{
var query = await Repository.GetQueryableAsync();
return query.Where(EntityHelper.CreateEqualityExpressionForId<TEntity, TKey>(id));
return query.Where(e => e.Id!.Equals(id));
}
protected override IQueryable<TEntity> ApplyDefaultSorting(IQueryable<TEntity> query)

18
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<TSource, TDestination> if implements
onServiceExposingContext.ExposedTypes.AddRange(
ReflectionHelper.GetImplementedGenericTypes(
onServiceExposingContext.ImplementationType,
typeof(IQueryableMapper<,>)
).ConvertAll(t => new ServiceIdentifier(t))
);
//Register types for IQueryProjector<TSource, TDestination> 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));
}
});
}

8
framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryableMapper.cs → 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.
/// </summary>
/// <typeparam name="TSource">Type of the source objects</typeparam>
/// <typeparam name="TDestination">Type of the destination objects</typeparam>
public interface IQueryableMapper<TSource, TDestination> : ITransientDependency
public interface IQueryProjector<TSource, TDestination> : ITransientDependency
{
/// <summary>
/// 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.
/// </summary>
/// <param name="source">The query to project</param>
IQueryable<TDestination> ProjectTo(IQueryable<TSource> source);

35
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<Book, BookDto, Guid>
{
public BookAbstractKeyProjectingAppService(IReadOnlyRepository<Book> repository)
: base(repository)
{
}
protected override async Task<Book> GetEntityByIdAsync(Guid id)
{
var query = await ReadOnlyRepository.GetQueryableAsync();
return await AsyncExecuter.FirstAsync(query, book => book.Id == id);
}
protected override async Task<IQueryable<Book>?> CreateEntityQueryOrNullAsync(Guid id)
{
var query = await ReadOnlyRepository.GetQueryableAsync();
return query.Where(book => book.Id == id);
}
protected override IQueryable<Book> ApplyDefaultSorting(IQueryable<Book> query)
{
return query.OrderBy(book => book.Id);
}
}

46
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<Book, BookDto, Guid>
{
public const string Marker = "-async";
private readonly IBookNameSuffixProvider _suffixProvider;
public BookAsyncProjectionAppService(
IRepository<Book, Guid> repository,
IBookNameSuffixProvider suffixProvider)
: base(repository)
{
_suffixProvider = suffixProvider;
}
protected override async Task<IQueryable<BookDto>?> CreateGetOutputDtoQueryOrNullAsync(Guid id)
{
var query = await Repository.GetQueryableAsync();
return await ProjectAsync(query.Where(book => book.Id == id));
}
protected override async Task<IQueryable<BookDto>?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable<Book> query)
{
return await ProjectAsync(query);
}
private async Task<IQueryable<BookDto>> ProjectAsync(IQueryable<Book> query)
{
var suffix = await _suffixProvider.GetAsync();
return query.Select(book => new BookDto
{
Id = book.Id,
Name = book.Name + suffix
});
}
}

15
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<Book, BookDetailDto, BookDto, Guid, PagedAndSortedResultRequestDto>
{
public BookDetailAppService(IReadOnlyRepository<Book, Guid> repository)
: base(repository)
{
}
}

9
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<Guid>
{
public string Name { get; set; } = default!;
}

18
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<Book, BookDetailDto>
{
public const string Marker = "-detail";
public IQueryable<BookDetailDto> ProjectTo(IQueryable<Book> source)
{
return source.Select(book => new BookDetailDto
{
Id = book.Id,
Name = book.Name + Marker
});
}
}

12
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<Book, BookDto>,
IObjectMapper<Book, BookLiteDto>,
IObjectMapper<BookDto, Book>,
ITransientDependency
{
public const string Marker = "-mapped";
@ -33,4 +34,15 @@ public class BookObjectMapper :
destination.Name = source.Name + Marker;
return destination;
}
Book IObjectMapper<BookDto, Book>.Map(BookDto source)
{
return new Book(source.Id, source.Name, 0);
}
Book IObjectMapper<BookDto, Book>.Map(BookDto source, Book destination)
{
destination.Name = source.Name;
return destination;
}
}

2
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<Book, BookDto>
public class BookProjector : IQueryProjector<Book, BookDto>
{
public const string Marker = "-projected";

13
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<Book, BookStructDto, Guid>
{
public BookStructAppService(IReadOnlyRepository<Book, Guid> repository)
: base(repository)
{
}
}

10
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; }
}

16
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<Book, BookStructDto>
{
public IQueryable<BookStructDto> ProjectTo(IQueryable<Book> source)
{
return source.Select(book => new BookStructDto
{
Id = book.Id,
Name = book.Name
});
}
}

5
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<Book, BookDto, Guid>
{
protected override IQueryableMapper<Book, BookDto> GetQueryableMapper => null;
protected override IQueryProjector<Book, BookDto>? GetOutputDtoQueryProjector => null;
protected override IQueryableMapper<Book, BookDto> GetListQueryableMapper => null;
protected override IQueryProjector<Book, BookDto>? GetListOutputDtoQueryProjector => null;
public BookWithoutProjectionAppService(IRepository<Book, Guid> repository)
: base(repository)

19
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<string> GetAsync();
}
public class BookNameSuffixProvider : IBookNameSuffixProvider, ITransientDependency
{
public async Task<string> GetAsync()
{
await Task.Yield();
return BookAsyncProjectionAppService.Marker;
}
}

86
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<IQueryableMapper<Book, BookDto>>()
ServiceProvider.GetService<IQueryProjector<Book, BookDto>>()
.ShouldBeOfType<BookProjector>();
}
@ -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<BookLiteAppService>();
@ -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<BookAsyncProjectionAppService>();
(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<BookAsyncProjectionAppService>();
await Should.ThrowAsync<EntityNotFoundException>(async () => await appService.GetAsync(Guid.NewGuid()));
}
[Fact]
public async Task Should_Throw_EntityNotFoundException_For_A_Value_Type_Dto()
{
var appService = GetRequiredService<BookStructAppService>();
(await appService.GetAsync(_bookId)).Name.ShouldBe("Hitchhiker's Guide");
await Should.ThrowAsync<EntityNotFoundException>(async () => await appService.GetAsync(Guid.NewGuid()));
}
[Fact]
public async Task Should_Not_Project_On_The_Create_And_Update_Paths()
{
var appService = GetRequiredService<BookAppService>();
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<BookDetailAppService>();
(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<IRepository<Book, Guid>>();
await repository.InsertAsync(new Book(Guid.NewGuid(), "A Book", 1));
await repository.InsertAsync(new Book(Guid.NewGuid(), "Z Book", 2));
var appService = GetRequiredService<BookAppService>();
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<BookAbstractKeyProjectingAppService>();
(await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookProjector.Marker);
}
}

2
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());
});
});
}

2
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<EntityWithIntPk, EntityWithIntPkProjectionDto>
public class EntityWithIntPkProjector : IQueryProjector<EntityWithIntPk, EntityWithIntPkProjectionDto>
{
public IQueryable<EntityWithIntPkProjectionDto> ProjectTo(IQueryable<EntityWithIntPk> source)
{

2
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<Person, PersonProjectionDto>
public class PersonProjector : IQueryProjector<Person, PersonProjectionDto>
{
public IQueryable<PersonProjectionDto> ProjectTo(IQueryable<Person> source)
{

51
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<Person, PersonWithCityDto, Guid>
{
private readonly IReadOnlyRepository<City, Guid> _cityRepository;
public PersonWithCityAppService(
IReadOnlyRepository<Person, Guid> repository,
IReadOnlyRepository<City, Guid> cityRepository)
: base(repository)
{
_cityRepository = cityRepository;
}
protected override async Task<IQueryable<PersonWithCityDto>?> CreateGetOutputDtoQueryOrNullAsync(Guid id)
{
var people = await CreateEntityQueryOrNullAsync(id);
return people == null ? null : await JoinCitiesAsync(people);
}
protected override async Task<IQueryable<PersonWithCityDto>?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable<Person> 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<IQueryable<PersonWithCityDto>> JoinCitiesAsync(IQueryable<Person> 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
};
}
}

11
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<Guid>
{
public string Name { get; set; }
public string CityName { get; set; }
}

90
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<IReadOnlyRepository<Person, Guid>>();
var projector = GetRequiredService<IQueryableMapper<Person, PersonProjectionDto>>();
var projector = GetRequiredService<IQueryProjector<Person, PersonProjectionDto>>();
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<PersonWithCityAppService>();
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<IRepository<Person, Guid>>()
.InsertAsync(new Person(Guid.NewGuid(), "PersonWithoutCity", 30), autoSave: true);
});
var result = await GetRequiredService<PersonWithCityAppService>()
.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<string> commands;
using (SqlCommandCapture.Begin(out commands))
{
await GetRequiredService<PersonProjectionAppService>()
.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<PersonProjectionAppService>()
.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<ICancellationTokenProvider>();
var appService = GetRequiredService<PersonProjectionAppService>();
using (cancellationTokenProvider.Use(new CancellationToken(canceled: true)))
{
await Should.ThrowAsync<OperationCanceledException>(async () =>
await appService.GetAsync(TestDataBuilder.UserDouglasId));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await appService.GetListAsync(new PagedAndSortedResultRequestDto()));
}
}
[Fact]
public async Task Should_Use_The_Ambient_Cancellation_Token_Without_A_Projector()
{
var cancellationTokenProvider = GetRequiredService<ICancellationTokenProvider>();
var appService = GetRequiredService<IPeopleAppService>();
using (cancellationTokenProvider.Use(new CancellationToken(canceled: true)))
{
await Should.ThrowAsync<OperationCanceledException>(async () =>
await appService.GetListAsync(new PagedAndSortedResultRequestDto()));
}
}
}

40
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<ConcurrentQueue<string>> Commands = new();
public static IDisposable Begin(out ConcurrentQueue<string> commands)
{
commands = new ConcurrentQueue<string>();
Commands.Value = commands;
return new DisposeAction(() => Commands.Value = null);
}
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
Commands.Value?.Enqueue(command.CommandText);
return base.ReaderExecuting(command, eventData, result);
}
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
Commands.Value?.Enqueue(command.CommandText);
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}

12
framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs

@ -15,17 +15,25 @@ public class AbpMapperlyQueryProjection_Tests : AbpIntegratedTest<MapperlyTestMo
[Fact]
public void Should_Project_A_Queryable()
{
var projectionMapper = ServiceProvider.GetRequiredService<IQueryableMapper<MyEntity, MyEntityDto>>();
var queryProjector = ServiceProvider.GetRequiredService<IQueryProjector<MyEntity, MyEntityDto>>();
var entities = new List<MyEntity>
{
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<IQueryProjector<MyEntity, MyEntityDto>>().ShouldHaveSingleItem();
}
}

2
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<MyEntity, MyEntityDto>
public partial class MyEntityQueryProjector : IQueryProjector<MyEntity, MyEntityDto>
{
public partial IQueryable<MyEntityDto> ProjectTo(IQueryable<MyEntity> source);
}

2
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<Person, PersonProjectionDto>
public class PersonProjector : IQueryProjector<Person, PersonProjectionDto>
{
public IQueryable<PersonProjectionDto> ProjectTo(IQueryable<Person> source)
{

Loading…
Cancel
Save