From 39e082bb2fb1429b0a7d54c67b9613b7174e9a83 Mon Sep 17 00:00:00 2001 From: amdavie Date: Sat, 8 Apr 2023 15:22:00 -0600 Subject: [PATCH] Implement SelectMany support (#320) * Added SelectMany support * Renamed ISpecification SelectManyExpression to SelectorMany to match existing Selector * Added repository integration tests for SelectMany spec --- .../Evaluators/SpecificationEvaluator.cs | 7 +++-- .../RepositoryOfT_ListAsync.cs | 10 +++++++ .../Evaluators/SpecificationEvaluator.cs | 7 +++-- .../RepositoryOfT_ListAsync.cs | 10 +++++++ .../Builder/SpecificationBuilderExtensions.cs | 13 +++++++++ .../InMemorySpecificationEvaluator.cs | 8 ++++-- .../ConcurrentSelectorsException.cs | 19 +++++++++++++ .../Exceptions/SelectorNotFoundException.cs | 4 +-- .../Ardalis.Specification/ISpecification.cs | 7 ++++- .../Ardalis.Specification/Specification.cs | 3 ++ ...ecificationBuilderExtensions_SelectMany.cs | 25 +++++++++++++++++ .../ConcurrentSelectorsExceptionTests.cs | 28 +++++++++++++++++++ .../SelectorNotFoundExceptionTests.cs | 4 +-- .../Fixture/Entities/Seeds/ProductSeed.cs | 9 +++--- .../Specs/StoreProductNamesEmptySpec.cs | 12 ++++++++ .../Fixture/Specs/StoreProductNamesSpec.cs | 13 +++++++++ 16 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 Specification/src/Ardalis.Specification/Exceptions/ConcurrentSelectorsException.cs create mode 100644 Specification/tests/Ardalis.Specification.UnitTests/BuilderTests/SpecificationBuilderExtensions_SelectMany.cs create mode 100644 Specification/tests/Ardalis.Specification.UnitTests/ExceptionTests/ConcurrentSelectorsExceptionTests.cs create mode 100644 Specification/tests/Ardalis.Specification.UnitTests/Fixture/Specs/StoreProductNamesEmptySpec.cs create mode 100644 Specification/tests/Ardalis.Specification.UnitTests/Fixture/Specs/StoreProductNamesSpec.cs diff --git a/Specification.EntityFramework6/src/Ardalis.Specification.EntityFramework6/Evaluators/SpecificationEvaluator.cs b/Specification.EntityFramework6/src/Ardalis.Specification.EntityFramework6/Evaluators/SpecificationEvaluator.cs index 4e2ba08b..b710d966 100644 --- a/Specification.EntityFramework6/src/Ardalis.Specification.EntityFramework6/Evaluators/SpecificationEvaluator.cs +++ b/Specification.EntityFramework6/src/Ardalis.Specification.EntityFramework6/Evaluators/SpecificationEvaluator.cs @@ -33,11 +33,14 @@ public SpecificationEvaluator(IEnumerable evaluators) public virtual IQueryable GetQuery(IQueryable query, ISpecification specification) where T : class { if (specification is null) throw new ArgumentNullException("Specification is required"); - if (specification.Selector is null) throw new SelectorNotFoundException(); + if (specification.Selector is null && specification.SelectorMany is null) throw new SelectorNotFoundException(); + if (specification.Selector != null && specification.SelectorMany != null) throw new ConcurrentSelectorsException(); query = GetQuery(query, (ISpecification)specification); - return query.Select(specification.Selector); + return specification.Selector != null + ? query.Select(specification.Selector) + : query.SelectMany(specification.SelectorMany); } /// diff --git a/Specification.EntityFramework6/tests/Ardalis.Specification.EntityFramework6.IntegrationTests/RepositoryOfT_ListAsync.cs b/Specification.EntityFramework6/tests/Ardalis.Specification.EntityFramework6.IntegrationTests/RepositoryOfT_ListAsync.cs index 57e56443..97ff4d67 100644 --- a/Specification.EntityFramework6/tests/Ardalis.Specification.EntityFramework6.IntegrationTests/RepositoryOfT_ListAsync.cs +++ b/Specification.EntityFramework6/tests/Ardalis.Specification.EntityFramework6.IntegrationTests/RepositoryOfT_ListAsync.cs @@ -170,5 +170,15 @@ public async Task ReturnsStoreContainingCity1_GivenStoreIncludeProductsSpec() result[0].Id.Should().Be(StoreSeed.VALID_Search_ID); result[0].City.Should().Contain(StoreSeed.VALID_Search_City_Key); } + + [Fact] + public virtual async Task ReturnsAllProducts_GivenStoreSelectManyProductsSpec() + { + var result = await storeRepository.ListAsync(new StoreProductNamesSpec()); + + result.Should().NotBeNull(); + result.Should().HaveCount(ProductSeed.TOTAL_PRODUCT_COUNT); + result.OrderBy(x => x).First().Should().Be(ProductSeed.VALID_PRODUCT_NAME); + } } } diff --git a/Specification.EntityFrameworkCore/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs b/Specification.EntityFrameworkCore/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs index 95bb74ee..c540ecef 100644 --- a/Specification.EntityFrameworkCore/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs +++ b/Specification.EntityFrameworkCore/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs @@ -47,11 +47,14 @@ public SpecificationEvaluator(IEnumerable evaluators) public virtual IQueryable GetQuery(IQueryable query, ISpecification specification) where T : class { if (specification is null) throw new ArgumentNullException("Specification is required"); - if (specification.Selector is null) throw new SelectorNotFoundException(); + if (specification.Selector is null && specification.SelectorMany is null) throw new SelectorNotFoundException(); + if (specification.Selector != null && specification.SelectorMany != null) throw new ConcurrentSelectorsException(); query = GetQuery(query, (ISpecification)specification); - return query.Select(specification.Selector); + return specification.Selector is not null + ? query.Select(specification.Selector) + : query.SelectMany(specification.SelectorMany!); } /// diff --git a/Specification.EntityFrameworkCore/tests/Ardalis.Specification.EntityFrameworkCore.IntegrationTests/RepositoryOfT_ListAsync.cs b/Specification.EntityFrameworkCore/tests/Ardalis.Specification.EntityFrameworkCore.IntegrationTests/RepositoryOfT_ListAsync.cs index 2abf1058..ee00cc82 100644 --- a/Specification.EntityFrameworkCore/tests/Ardalis.Specification.EntityFrameworkCore.IntegrationTests/RepositoryOfT_ListAsync.cs +++ b/Specification.EntityFrameworkCore/tests/Ardalis.Specification.EntityFrameworkCore.IntegrationTests/RepositoryOfT_ListAsync.cs @@ -196,5 +196,15 @@ public virtual async Task ReturnsStoreContainingCity1_GivenStoreIncludeProductsS result[0].Id.Should().Be(StoreSeed.VALID_Search_ID); result[0].City.Should().Contain(StoreSeed.VALID_Search_City_Key); } + + [Fact] + public virtual async Task ReturnsAllProducts_GivenStoreSelectManyProductsSpec() + { + var result = await storeRepository.ListAsync(new StoreProductNamesSpec()); + + result.Should().NotBeNull(); + result.Should().HaveCount(ProductSeed.TOTAL_PRODUCT_COUNT); + result.OrderBy(x => x).First().Should().Be(ProductSeed.VALID_PRODUCT_NAME); + } } } diff --git a/Specification/src/Ardalis.Specification/Builder/SpecificationBuilderExtensions.cs b/Specification/src/Ardalis.Specification/Builder/SpecificationBuilderExtensions.cs index 9b07123b..0e44907a 100644 --- a/Specification/src/Ardalis.Specification/Builder/SpecificationBuilderExtensions.cs +++ b/Specification/src/Ardalis.Specification/Builder/SpecificationBuilderExtensions.cs @@ -291,6 +291,19 @@ public static ISpecificationBuilder Select( return specificationBuilder; } + /// + /// Specify a transform function to apply to the element + /// to produce a flattened sequence of elements. + /// + public static ISpecificationBuilder SelectMany( + this ISpecificationBuilder specificationBuilder, + Expression>> selector) + { + specificationBuilder.Specification.SelectorMany = selector; + + return specificationBuilder; + } + /// /// Specify a transform function to apply to the result of the query /// and returns the same type diff --git a/Specification/src/Ardalis.Specification/Evaluators/InMemorySpecificationEvaluator.cs b/Specification/src/Ardalis.Specification/Evaluators/InMemorySpecificationEvaluator.cs index 502f89ad..8dccb89b 100644 --- a/Specification/src/Ardalis.Specification/Evaluators/InMemorySpecificationEvaluator.cs +++ b/Specification/src/Ardalis.Specification/Evaluators/InMemorySpecificationEvaluator.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -30,11 +31,14 @@ public InMemorySpecificationEvaluator(IEnumerable evaluators public virtual IEnumerable Evaluate(IEnumerable source, ISpecification specification) { - _ = specification.Selector ?? throw new SelectorNotFoundException(); + if (specification.Selector is null && specification.SelectorMany is null) throw new SelectorNotFoundException(); + if (specification.Selector != null && specification.SelectorMany != null) throw new ConcurrentSelectorsException(); var baseQuery = Evaluate(source, (ISpecification)specification); - var resultQuery = baseQuery.Select(specification.Selector.Compile()); + var resultQuery = specification.Selector != null + ? baseQuery.Select(specification.Selector.Compile()) + : baseQuery.SelectMany(specification.SelectorMany!.Compile()); return specification.PostProcessingAction == null ? resultQuery diff --git a/Specification/src/Ardalis.Specification/Exceptions/ConcurrentSelectorsException.cs b/Specification/src/Ardalis.Specification/Exceptions/ConcurrentSelectorsException.cs new file mode 100644 index 00000000..a145c7aa --- /dev/null +++ b/Specification/src/Ardalis.Specification/Exceptions/ConcurrentSelectorsException.cs @@ -0,0 +1,19 @@ +using System; + +namespace Ardalis.Specification +{ + public class ConcurrentSelectorsException : Exception + { + private const string message = "Concurrent specification selector transforms defined. Ensure only one of the Select() or SelectMany() transforms is used in the same specification!"; + + public ConcurrentSelectorsException() + : base(message) + { + } + + public ConcurrentSelectorsException(Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/Specification/src/Ardalis.Specification/Exceptions/SelectorNotFoundException.cs b/Specification/src/Ardalis.Specification/Exceptions/SelectorNotFoundException.cs index 388a7d79..e8baf32e 100644 --- a/Specification/src/Ardalis.Specification/Exceptions/SelectorNotFoundException.cs +++ b/Specification/src/Ardalis.Specification/Exceptions/SelectorNotFoundException.cs @@ -1,12 +1,10 @@ using System; -using System.Collections.Generic; -using System.Text; namespace Ardalis.Specification { public class SelectorNotFoundException : Exception { - private const string message = "The specification must have Selector defined."; + private const string message = "The specification must have a selector transform defined. Ensure either Select() or SelectMany() is used in the specification!"; public SelectorNotFoundException() : base(message) diff --git a/Specification/src/Ardalis.Specification/ISpecification.cs b/Specification/src/Ardalis.Specification/ISpecification.cs index 0dc50850..bfbae635 100644 --- a/Specification/src/Ardalis.Specification/ISpecification.cs +++ b/Specification/src/Ardalis.Specification/ISpecification.cs @@ -15,10 +15,15 @@ public interface ISpecification : ISpecification ISpecificationBuilder Query { get; } /// - /// The transform function to apply to the element. + /// The Select transform function to apply to the element. /// Expression>? Selector { get; } + /// + /// The SelectMany transform function to apply to the element. + /// + Expression>>? SelectorMany { get; } + /// /// The transform function to apply to the result of the query encapsulated by the . /// diff --git a/Specification/src/Ardalis.Specification/Specification.cs b/Specification/src/Ardalis.Specification/Specification.cs index 968960e2..9e3bb4df 100644 --- a/Specification/src/Ardalis.Specification/Specification.cs +++ b/Specification/src/Ardalis.Specification/Specification.cs @@ -28,6 +28,9 @@ protected Specification(IInMemorySpecificationEvaluator inMemorySpecificationEva /// public Expression>? Selector { get; internal set; } + /// + public Expression>>? SelectorMany { get; internal set; } + /// public new Func, IEnumerable>? PostProcessingAction { get; internal set; } = null; } diff --git a/Specification/tests/Ardalis.Specification.UnitTests/BuilderTests/SpecificationBuilderExtensions_SelectMany.cs b/Specification/tests/Ardalis.Specification.UnitTests/BuilderTests/SpecificationBuilderExtensions_SelectMany.cs new file mode 100644 index 00000000..f3c714f3 --- /dev/null +++ b/Specification/tests/Ardalis.Specification.UnitTests/BuilderTests/SpecificationBuilderExtensions_SelectMany.cs @@ -0,0 +1,25 @@ +using Ardalis.Specification.UnitTests.Fixture.Specs; +using FluentAssertions; +using Xunit; + +namespace Ardalis.Specification.UnitTests +{ + public class SpecificationBuilderExtensions_SelectMany + { + [Fact] + public void SetsNothing_GivenNoSelectManyExpression() + { + var spec = new StoreProductNamesEmptySpec(); + + spec.SelectorMany.Should().BeNull(); + } + + [Fact] + public void SetsSelectorMany_GivenSelectManyExpression() + { + var spec = new StoreProductNamesSpec(); + + spec.SelectorMany.Should().NotBeNull(); + } + } +} diff --git a/Specification/tests/Ardalis.Specification.UnitTests/ExceptionTests/ConcurrentSelectorsExceptionTests.cs b/Specification/tests/Ardalis.Specification.UnitTests/ExceptionTests/ConcurrentSelectorsExceptionTests.cs new file mode 100644 index 00000000..86fa0067 --- /dev/null +++ b/Specification/tests/Ardalis.Specification.UnitTests/ExceptionTests/ConcurrentSelectorsExceptionTests.cs @@ -0,0 +1,28 @@ +using System; +using FluentAssertions; +using Xunit; + +namespace Ardalis.Specification.UnitTests +{ + public class ConcurrentSelectorsExceptionTests + { + private const string defaultMessage = "Concurrent specification selector transforms defined. Ensure only one of the Select() or SelectMany() transforms is used in the same specification!"; + + [Fact] + public void ThrowWithDefaultConstructor() + { + Action action = () => throw new ConcurrentSelectorsException(); + + action.Should().Throw().WithMessage(defaultMessage); + } + + [Fact] + public void ThrowWithInnerException() + { + Exception inner = new Exception("test"); + Action action = () => throw new ConcurrentSelectorsException(inner); + + action.Should().Throw().WithMessage(defaultMessage).WithInnerException().WithMessage("test"); + } + } +} diff --git a/Specification/tests/Ardalis.Specification.UnitTests/ExceptionTests/SelectorNotFoundExceptionTests.cs b/Specification/tests/Ardalis.Specification.UnitTests/ExceptionTests/SelectorNotFoundExceptionTests.cs index cbe92f96..4b54d141 100644 --- a/Specification/tests/Ardalis.Specification.UnitTests/ExceptionTests/SelectorNotFoundExceptionTests.cs +++ b/Specification/tests/Ardalis.Specification.UnitTests/ExceptionTests/SelectorNotFoundExceptionTests.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.Text; using FluentAssertions; using Xunit; @@ -8,7 +6,7 @@ namespace Ardalis.Specification.UnitTests { public class SelectorNotFoundExceptionTests { - private const string defaultMessage = "The specification must have Selector defined."; + private const string defaultMessage = "The specification must have a selector transform defined. Ensure either Select() or SelectMany() is used in the specification!"; [Fact] public void ThrowWithDefaultConstructor() diff --git a/Specification/tests/Ardalis.Specification.UnitTests/Fixture/Entities/Seeds/ProductSeed.cs b/Specification/tests/Ardalis.Specification.UnitTests/Fixture/Entities/Seeds/ProductSeed.cs index 27e8fe6a..ffef31c1 100644 --- a/Specification/tests/Ardalis.Specification.UnitTests/Fixture/Entities/Seeds/ProductSeed.cs +++ b/Specification/tests/Ardalis.Specification.UnitTests/Fixture/Entities/Seeds/ProductSeed.cs @@ -1,16 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Text; +using System.Collections.Generic; namespace Ardalis.Specification.UnitTests.Fixture.Entities.Seeds { public class ProductSeed { + public const int TOTAL_PRODUCT_COUNT = 100; + public const string VALID_PRODUCT_NAME = "Product 1"; + public static List Get() { var products = new List(); - for (int i = 1; i < 100; i = i + 2) + for (int i = 1; i < TOTAL_PRODUCT_COUNT; i = i + 2) { products.Add(new Product() { diff --git a/Specification/tests/Ardalis.Specification.UnitTests/Fixture/Specs/StoreProductNamesEmptySpec.cs b/Specification/tests/Ardalis.Specification.UnitTests/Fixture/Specs/StoreProductNamesEmptySpec.cs new file mode 100644 index 00000000..56d82fa2 --- /dev/null +++ b/Specification/tests/Ardalis.Specification.UnitTests/Fixture/Specs/StoreProductNamesEmptySpec.cs @@ -0,0 +1,12 @@ +using Ardalis.Specification.UnitTests.Fixture.Entities; + +namespace Ardalis.Specification.UnitTests.Fixture.Specs +{ + public class StoreProductNamesEmptySpec : Specification + { + public StoreProductNamesEmptySpec() + { + + } + } +} diff --git a/Specification/tests/Ardalis.Specification.UnitTests/Fixture/Specs/StoreProductNamesSpec.cs b/Specification/tests/Ardalis.Specification.UnitTests/Fixture/Specs/StoreProductNamesSpec.cs new file mode 100644 index 00000000..401c35bd --- /dev/null +++ b/Specification/tests/Ardalis.Specification.UnitTests/Fixture/Specs/StoreProductNamesSpec.cs @@ -0,0 +1,13 @@ +using System.Linq; +using Ardalis.Specification.UnitTests.Fixture.Entities; + +namespace Ardalis.Specification.UnitTests.Fixture.Specs +{ + public class StoreProductNamesSpec : Specification + { + public StoreProductNamesSpec() + { + Query.SelectMany(s => s.Products.Select(p => p.Name)); + } + } +}