diff --git a/src/QuerySpecification.EntityFrameworkCore/RepositoryBase.cs b/src/QuerySpecification.EntityFrameworkCore/RepositoryBase.cs index 336a501..b13c46b 100644 --- a/src/QuerySpecification.EntityFrameworkCore/RepositoryBase.cs +++ b/src/QuerySpecification.EntityFrameworkCore/RepositoryBase.cs @@ -141,6 +141,24 @@ public virtual async Task> ToListAsync(Specification + public async Task> ToDictionaryAsync(Func keySelector, CancellationToken cancellationToken = default) where TKey : notnull + { + return await _dbContext.Set().ToDictionaryAsync(keySelector, cancellationToken); + } + + /// + public async Task> ToDictionaryAsync(Specification specification, Func keySelector, CancellationToken cancellationToken = default) where TKey : notnull + { + return await GenerateQuery(specification).ToDictionaryAsync(keySelector, cancellationToken); + } + + /// + public async Task> ToDictionaryAsync(Specification specification, Func keySelector, CancellationToken cancellationToken = default) where TKey : notnull + { + return await GenerateQuery(specification).ToDictionaryAsync(keySelector, cancellationToken); + } + /// public virtual async Task CountAsync(CancellationToken cancellationToken = default) { diff --git a/src/QuerySpecification.EntityFrameworkCore/RepositoryWithMapper.cs b/src/QuerySpecification.EntityFrameworkCore/RepositoryWithMapper.cs index 8f17500..c16fb9f 100644 --- a/src/QuerySpecification.EntityFrameworkCore/RepositoryWithMapper.cs +++ b/src/QuerySpecification.EntityFrameworkCore/RepositoryWithMapper.cs @@ -104,4 +104,14 @@ public virtual async Task> ProjectToListAsync(Spec return new PagedResult(data, pagination); } + + /// + public virtual async Task> ProjectToDictionaryAsync(Specification specification, Func keySelector, CancellationToken cancellationToken = default) where TKey : notnull + { + var query = GenerateQuery(specification).AsNoTracking(); + + var projectedQuery = Map(query); + + return await projectedQuery.ToDictionaryAsync(keySelector, cancellationToken); + } } diff --git a/src/QuerySpecification/IProjectionRepository.cs b/src/QuerySpecification/IProjectionRepository.cs index da64eb6..16af7d0 100644 --- a/src/QuerySpecification/IProjectionRepository.cs +++ b/src/QuerySpecification/IProjectionRepository.cs @@ -48,4 +48,16 @@ public interface IProjectionRepository where T : class /// The cancellation token. /// A task that represents the asynchronous operation. The task result contains the paged list of projected results. Task> ProjectToListAsync(Specification specification, IPagingFilter filter, CancellationToken cancellationToken = default); + + /// + /// Asynchronously creates a dictionary from the projected elements that satisfy the specification, using the provided key selector function. + /// It ignores the selector in the specification, and projects the entities to the result type using the Map method. + /// + /// The type of the keys in the resulting dictionary. Must be non-nullable. + /// The type of the projected element for each entity. + /// The specification to evaluate. + /// A function to extract a key from each projected element. + /// The cancellation token. + /// A task that represents the asynchronous operation. The task result contains a dictionary mapping each key to its projected element. + Task> ProjectToDictionaryAsync(Specification specification, Func keySelector, CancellationToken cancellationToken = default) where TKey : notnull; } diff --git a/src/QuerySpecification/IReadRepositoryBase.cs b/src/QuerySpecification/IReadRepositoryBase.cs index 3125e17..25f95aa 100644 --- a/src/QuerySpecification/IReadRepositoryBase.cs +++ b/src/QuerySpecification/IReadRepositoryBase.cs @@ -92,6 +92,37 @@ public interface IReadRepositoryBase where T : class /// A task that represents the asynchronous operation. The task result contains the list of entities that match the specification and are projected to a result. Task> ToListAsync(Specification specification, CancellationToken cancellationToken = default); + /// + /// Asynchronously creates a dictionary from the entities, using the specified key selector function. + /// + /// The type of the keys in the resulting dictionary. Must be a non-nullable type. + /// A function to extract a key from each element. + /// The cancellation token. + /// A task that represents the asynchronous operation. The task result contains a dictionary mapping each key to its corresponding entity. + Task> ToDictionaryAsync(Func keySelector, CancellationToken cancellationToken = default) where TKey : notnull; + + /// + /// Asynchronously creates a dictionary from the entities that satisfy the specification, using the provided key selector function. + /// + /// The type of the keys in the resulting dictionary. Must be non-nullable. + /// The specification to evaluate. + /// A function to extract a key from each element. + /// The cancellation token. + /// A task that represents the asynchronous operation. The task result contains a dictionary mapping each key to its corresponding entity. + Task> ToDictionaryAsync(Specification specification, Func keySelector, CancellationToken cancellationToken = default) where TKey : notnull; + + /// + /// Asynchronously creates a dictionary from the projected elements that satisfy the specification, using the provided key selector function. + /// + /// The type of the keys in the resulting dictionary. Must be non-nullable. + /// The type of the projected element for each entity. + /// The specification to evaluate. + /// A function to extract a key from each projected element. + /// The cancellation token. + /// A task that represents the asynchronous operation. The task result contains a dictionary mapping each key to its projected element. + Task> ToDictionaryAsync(Specification specification, Func keySelector, CancellationToken cancellationToken = default) where TKey : notnull; + + /// /// Gets the count of all entities. /// diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Repositories/Repository_ProjectToTests.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Repositories/Repository_ProjectToTests.cs index dd54c96..5f5133b 100644 --- a/tests/QuerySpecification.EntityFrameworkCore.Tests/Repositories/Repository_ProjectToTests.cs +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Repositories/Repository_ProjectToTests.cs @@ -215,4 +215,54 @@ await SeedRangeAsync( spec.Skip.Should().Be(1); spec.Take.Should().Be(2); } + + [Fact] + public async Task ProjectToDictionaryAsync_ReturnsProjectedItems_GivenSpec() + { + var expected = new Dictionary + { + [1] = new(1, "b"), + [2] = new(2, "b"), + [3] = new(3, "b"), + }; + await SeedRangeAsync( + [ + new() { No = 9, Name = "a" }, + new() { No = 9, Name = "c" }, + new() { No = 1, Name = "b" }, + new() { No = 2, Name = "b" }, + new() { No = 3, Name = "b" }, + new() { No = 9, Name = "d" }, + ]); + + var repo = new Repository(DbContext); + var spec = new Specification(); + spec.Query + .Where(x => x.Name == "b"); + + var result = await repo.ProjectToDictionaryAsync(spec, x => x.No); + + result.Should().HaveSameCount(expected); + result.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task ProjectToDictionaryAsync_ReturnsEmptyDictionary_GivenSpecWithNoMatch() + { + await SeedRangeAsync( + [ + new() { No = 9, Name = "a" }, + new() { No = 9, Name = "c" }, + new() { No = 9, Name = "d" }, + ]); + + var repo = new Repository(DbContext); + var spec = new Specification(); + spec.Query + .Where(x => x.Name == "x"); + + var result = await repo.ProjectToDictionaryAsync(spec, x => x.No); + + result.Should().BeEmpty(); + } } diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Repositories/Repository_ToDictionaryTests.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Repositories/Repository_ToDictionaryTests.cs new file mode 100644 index 0000000..f4fa956 --- /dev/null +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Repositories/Repository_ToDictionaryTests.cs @@ -0,0 +1,83 @@ +namespace Tests.Repositories; + +[Collection("SharedCollection")] +public class Repository_ToDictionaryTests(TestFactory factory) : IntegrationTest(factory) +{ + public record CountryDto(int Id, string? Name); + + [Fact] + public async Task ToDictionaryAsync_ReturnsAllItems() + { + var expected = new List + { + new() { Name = "a" }, + new() { Name = "b" }, + new() { Name = "c" }, + }; + await SeedRangeAsync(expected); + + var repo = new Repository(DbContext); + + var result = await repo.ToDictionaryAsync(x => x.Id); + + result.Should().HaveSameCount(expected); + result.Values.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task ToDictionaryAsync_ReturnsFilteredItems_GivenSpec() + { + var expected = new List + { + new() { Name = "b" }, + new() { Name = "b" }, + new() { Name = "b" }, + }; + await SeedRangeAsync( + [ + new() { Name = "a" }, + new() { Name = "c" }, + .. expected, + new() { Name = "d" }, + ]); + + var repo = new Repository(DbContext); + var spec = new Specification(); + spec.Query + .Where(x => x.Name == "b"); + + var result = await repo.ToDictionaryAsync(spec, x => x.Id); + + result.Should().HaveSameCount(expected); + result.Values.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task ToDictionaryAsync_ReturnsFilteredItems_GivenProjectionSpec() + { + var seeded = new List + { + new() { Name = "b" }, + new() { Name = "b" }, + new() { Name = "b" }, + }; + await SeedRangeAsync( + [ + new() { Name = "a" }, + new() { Name = "c" }, + .. seeded, + new() { Name = "d" }, + ]); + + var repo = new Repository(DbContext); + var spec = new Specification(); + spec.Query + .Where(x => x.Name == "b") + .Select(x => new CountryDto(x.Id, x.Name)); + + var result = await repo.ToDictionaryAsync(spec, x => x.Id); + + result.Should().HaveCount(seeded.Count); + result.Values.Should().AllSatisfy(dto => dto.Name.Should().Be("b")); + } +}