diff --git a/.gitignore b/.gitignore index aded442..6cb682c 100644 --- a/.gitignore +++ b/.gitignore @@ -386,3 +386,4 @@ FodyWeavers.xsd # Developer config files **/appsettings.Development*.json +*V11.csproj diff --git a/README.md b/README.md index ba31398..fb7efb3 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,13 @@ A .NET library for building query specifications. - [Pozitron.QuerySpecification.EntityFrameworkCore](https://www.nuget.org/packages/Pozitron.QuerySpecification.EntityFrameworkCore) An `EntityFramework Core` plugin to the base package. It contains EF specific evaluators. -## Usage +## Getting Started -Create your specification classes by inheriting from the `Specification` class, and use the builder `Query` to build your queries in the constructor. +An extended list of features (in-memory collection evaluations, validations, repositories, extensions, etc.) will be soon available in the Wiki. Below are listed some basic and common usages. + +### Creating and consuming specifications + +Create your specifications by inheriting from the `Specification` class, and use the `Query` builder in the constructor to define your conditions. ```csharp public class CustomerSpec : Specification @@ -24,7 +28,7 @@ public class CustomerSpec : Specification public CustomerSpec(int age, string nameTerm) { Query - .Where(x => x.Age > age) + .Where(x => x.Age >= age) .Like(x => x.Name, $"%{nameTerm}%") .Include(x => x.Addresses) .ThenInclude(x => x.Contact) @@ -37,10 +41,10 @@ public class CustomerSpec : Specification } ``` -Apply the specification to `DbSet` or any `IQueryable` source. +Apply the specification to `DbSet` or `IQueryable` source. ```csharp -var spec = new CustomerSpec(30, "John"); +var spec = new CustomerSpec(30, "Customer"); List result = await _context .Customers @@ -58,7 +62,7 @@ public class CustomerDtoSpec : Specification public CustomerDtoSpec(int age, string nameTerm) { Query - .Where(x => x.Age > age) + .Where(x => x.Age >= age) .Like(x => x.Name, $"%{nameTerm}%") .OrderBy(x => x.Name) .Select(x => new CustomerDto(x.Id, x.Name)); @@ -66,10 +70,10 @@ public class CustomerDtoSpec : Specification } ``` -Apply the specification to `DbSet` or any `IQueryable` source. +Apply the specification to `DbSet` or `IQueryable` source. ```csharp -var spec = new CustomerSpec(30, "John"); +var spec = new CustomerDtoSpec(30, "Customer"); List result = await _context .Customers @@ -77,5 +81,78 @@ List result = await _context .ToListAsync(); ``` +### Pagination + +The library defines a convenient `ToPagedResult` extension method that returns a detailed paginated result. + +```csharp +var spec = new CustomerDtoSpec(1, "Customer"); +var pagingFilter = new PagingFilter +{ + Page = 1, + PageSize = 2 +}; + +PagedResult result = await _context + .Customers + .WithSpecification(spec) + .ToPagedResultAsync(pagingFilter); +``` + +The `PagedResult` is serializable and contains a detailed pagination information and the data. + +```json +{ + "Pagination": { + "TotalItems": 100, + "TotalPages": 50, + "PageSize": 2, + "Page": 1, + "StartItem": 1, + "EndItem": 2, + "HasPrevious": false, + "HasNext": true + }, + "Data": [ + { + "Id": 1, + "Name": "Customer 1" + }, + { + "Id": 2, + "Name": "Customer 2" + } + ] +} +``` + +## Benchmarks + +In version 11, we refactored and rebuilt the internals from the ground up. The new version reduces the memory footprint drastically. The overhead of the library is now negligible and statistically insignificant. Here are the benchmark results of `ToQueryString()` for various queries. Refer to the [Benchmarks](https://github.com/fiseni/QuerySpecification/tree/main/tests/QuerySpecification.Benchmarks/Benchmarks) project for more benchmarks. + +Type: +- 0 -> Empty +- 1 -> Single Where clause +- 2 -> Where and OrderBy +- 3 -> Where, Order chain, Include chain, Flag (AsNoTracking) +- 4 -> Where, Order chain, Include chain, Like, Skip, Take, Flag (AsNoTracking) + +| Method | Type | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +|------- |----- |----------:|---------:|---------:|------:|--------:|-------:|----------:|------------:| +| EFCore | 0 | 81.55 us | 0.686 us | 0.608 us | 1.00 | 10.0098 | 0.9766 | 82.54 KB | 1.00 | +| Spec | 0 | 78.18 us | 0.472 us | 0.441 us | 0.96 | 10.0098 | 0.9766 | 82.53 KB | 1.00 | +| | | | | | | | | | | +| EFCore | 1 | 92.62 us | 0.350 us | 0.310 us | 1.00 | 10.2539 | 0.9766 | 84.77 KB | 1.00 | +| Spec | 1 | 92.92 us | 0.252 us | 0.236 us | 1.00 | 10.2539 | 0.9766 | 84.84 KB | 1.00 | +| | | | | | | | | | | +| EFCore | 2 | 95.48 us | 0.654 us | 0.580 us | 1.00 | 10.2539 | 0.9766 | 86.03 KB | 1.00 | +| Spec | 2 | 98.36 us | 0.775 us | 0.687 us | 1.03 | 10.2539 | 0.4883 | 86.12 KB | 1.00 | +| | | | | | | | | | | +| EFCore | 3 | 106.62 us | 0.684 us | 0.606 us | 1.00 | 10.7422 | 0.4883 | 90.35 KB | 1.00 | +| Spec | 3 | 109.56 us | 0.700 us | 0.655 us | 1.03 | 10.7422 | 0.4883 | 90.64 KB | 1.00 | +| | | | | | | | | | | +| EFCore | 4 | 147.47 us | 0.619 us | 0.483 us | 1.00 | 13.1836 | 0.9766 | 110.78 KB | 1.00 | +| Spec | 4 | 150.82 us | 0.538 us | 0.449 us | 1.02 | 13.1836 | 0.9766 | 111.32 KB | 1.00 | + ## Give a Star! :star: If you like or are using this project please give it a star. Thanks! diff --git a/exclusion.dic b/exclusion.dic index c1d5688..358812a 100644 --- a/exclusion.dic +++ b/exclusion.dic @@ -22,3 +22,4 @@ aaab aaaab aaaaab axza +Compilable diff --git a/readme-nuget.md b/readme-nuget.md index d87a650..20d3897 100644 --- a/readme-nuget.md +++ b/readme-nuget.md @@ -4,9 +4,13 @@ A .NET library for building query specifications. - [Pozitron.QuerySpecification.EntityFrameworkCore](https://www.nuget.org/packages/Pozitron.QuerySpecification.EntityFrameworkCore) An `EntityFramework Core` plugin to the base package. It contains EF specific evaluators. -## Usage +## Getting Started -Create your specification classes by inheriting from the `Specification` class, and use the builder `Query` to build your queries in the constructor. +An extended list of features (in-memory collection evaluations, validations, repositories, extensions, etc.) will be soon available in the Wiki. Below are listed some basic and common usages. + +### Creating and consuming specifications + +Create your specifications by inheriting from the `Specification` class, and use the `Query` builder in the constructor to define your conditions. ```csharp public class CustomerSpec : Specification @@ -14,7 +18,7 @@ public class CustomerSpec : Specification public CustomerSpec(int age, string nameTerm) { Query - .Where(x => x.Age > age) + .Where(x => x.Age >= age) .Like(x => x.Name, $"%{nameTerm}%") .Include(x => x.Addresses) .ThenInclude(x => x.Contact) @@ -27,10 +31,10 @@ public class CustomerSpec : Specification } ``` -Apply the specification to `DbSet` or to any `IQueryable` source. +Apply the specification to `DbSet` or `IQueryable` source. ```csharp -var spec = new CustomerSpec(30, "John"); +var spec = new CustomerSpec(30, "Customer"); List result = await _context .Customers @@ -48,7 +52,7 @@ public class CustomerDtoSpec : Specification public CustomerDtoSpec(int age, string nameTerm) { Query - .Where(x => x.Age > age) + .Where(x => x.Age >= age) .Like(x => x.Name, $"%{nameTerm}%") .OrderBy(x => x.Name) .Select(x => new CustomerDto(x.Id, x.Name)); @@ -56,10 +60,10 @@ public class CustomerDtoSpec : Specification } ``` -Apply the specification to `DbSet` or any `IQueryable` source. +Apply the specification to `DbSet` or `IQueryable` source. ```csharp -var spec = new CustomerSpec(30, "John"); +var spec = new CustomerDtoSpec(30, "Customer"); List result = await _context .Customers @@ -67,5 +71,78 @@ List result = await _context .ToListAsync(); ``` +### Pagination + +The library defines a convenient `ToPagedResult` extension method that returns a detailed paginated result. + +```csharp +var spec = new CustomerDtoSpec(1, "Customer"); +var pagingFilter = new PagingFilter +{ + Page = 1, + PageSize = 2 +}; + +PagedResult result = await _context + .Customers + .WithSpecification(spec) + .ToPagedResultAsync(pagingFilter); +``` + +The `PagedResult` is serializable and contains a detailed pagination information and the data. + +```json +{ + "Pagination": { + "TotalItems": 100, + "TotalPages": 50, + "PageSize": 2, + "Page": 1, + "StartItem": 1, + "EndItem": 2, + "HasPrevious": false, + "HasNext": true + }, + "Data": [ + { + "Id": 1, + "Name": "Customer 1" + }, + { + "Id": 2, + "Name": "Customer 2" + } + ] +} +``` + +## Benchmarks + +In version 11, we refactored and rebuilt the internals from the ground up. The new version reduces the memory footprint drastically. The overhead of the library is now negligible and statistically insignificant. Here are the benchmark results of `ToQueryString()` for various queries. Refer to the [Benchmarks](https://github.com/fiseni/QuerySpecification/tree/main/tests/QuerySpecification.Benchmarks/Benchmarks) project for more benchmarks. + +Type: +- 0 -> Empty +- 1 -> Single Where clause +- 2 -> Where and OrderBy +- 3 -> Where, Order chain, Include chain, Flag (AsNoTracking) +- 4 -> Where, Order chain, Include chain, Like, Skip, Take, Flag (AsNoTracking) + +| Method | Type | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +|------- |----- |----------:|---------:|---------:|------:|--------:|-------:|----------:|------------:| +| EFCore | 0 | 81.55 us | 0.686 us | 0.608 us | 1.00 | 10.0098 | 0.9766 | 82.54 KB | 1.00 | +| Spec | 0 | 78.18 us | 0.472 us | 0.441 us | 0.96 | 10.0098 | 0.9766 | 82.53 KB | 1.00 | +| | | | | | | | | | | +| EFCore | 1 | 92.62 us | 0.350 us | 0.310 us | 1.00 | 10.2539 | 0.9766 | 84.77 KB | 1.00 | +| Spec | 1 | 92.92 us | 0.252 us | 0.236 us | 1.00 | 10.2539 | 0.9766 | 84.84 KB | 1.00 | +| | | | | | | | | | | +| EFCore | 2 | 95.48 us | 0.654 us | 0.580 us | 1.00 | 10.2539 | 0.9766 | 86.03 KB | 1.00 | +| Spec | 2 | 98.36 us | 0.775 us | 0.687 us | 1.03 | 10.2539 | 0.4883 | 86.12 KB | 1.00 | +| | | | | | | | | | | +| EFCore | 3 | 106.62 us | 0.684 us | 0.606 us | 1.00 | 10.7422 | 0.4883 | 90.35 KB | 1.00 | +| Spec | 3 | 109.56 us | 0.700 us | 0.655 us | 1.03 | 10.7422 | 0.4883 | 90.64 KB | 1.00 | +| | | | | | | | | | | +| EFCore | 4 | 147.47 us | 0.619 us | 0.483 us | 1.00 | 13.1836 | 0.9766 | 110.78 KB | 1.00 | +| Spec | 4 | 150.82 us | 0.538 us | 0.449 us | 1.02 | 13.1836 | 0.9766 | 111.32 KB | 1.00 | + ## Give a Star! :star: If you like or are using this project please give it a star. Thanks! diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsNoTrackingEvaluator.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsNoTrackingEvaluator.cs index bccae09..8dc0554 100644 --- a/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsNoTrackingEvaluator.cs +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsNoTrackingEvaluator.cs @@ -1,6 +1,6 @@ namespace Pozitron.QuerySpecification; -public class AsNoTrackingEvaluator : IEvaluator +public sealed class AsNoTrackingEvaluator : IEvaluator { private AsNoTrackingEvaluator() { } public static AsNoTrackingEvaluator Instance = new(); diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsNoTrackingWithIdentityResolutionEvaluator.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsNoTrackingWithIdentityResolutionEvaluator.cs index eed091a..1a384ad 100644 --- a/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsNoTrackingWithIdentityResolutionEvaluator.cs +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsNoTrackingWithIdentityResolutionEvaluator.cs @@ -1,6 +1,6 @@ namespace Pozitron.QuerySpecification; -public class AsNoTrackingWithIdentityResolutionEvaluator : IEvaluator +public sealed class AsNoTrackingWithIdentityResolutionEvaluator : IEvaluator { private AsNoTrackingWithIdentityResolutionEvaluator() { } public static AsNoTrackingWithIdentityResolutionEvaluator Instance = new(); diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsSplitQueryEvaluator.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsSplitQueryEvaluator.cs index 8741817..34875ce 100644 --- a/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsSplitQueryEvaluator.cs +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/AsSplitQueryEvaluator.cs @@ -1,6 +1,6 @@ namespace Pozitron.QuerySpecification; -public class AsSplitQueryEvaluator : IEvaluator +public sealed class AsSplitQueryEvaluator : IEvaluator { private AsSplitQueryEvaluator() { } public static AsSplitQueryEvaluator Instance = new(); diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/IgnoreQueryFiltersEvaluator.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/IgnoreQueryFiltersEvaluator.cs index 9fc8e4e..454b6d6 100644 --- a/src/QuerySpecification.EntityFrameworkCore/Evaluators/IgnoreQueryFiltersEvaluator.cs +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/IgnoreQueryFiltersEvaluator.cs @@ -1,6 +1,6 @@ namespace Pozitron.QuerySpecification; -public class IgnoreQueryFiltersEvaluator : IEvaluator +public sealed class IgnoreQueryFiltersEvaluator : IEvaluator { private IgnoreQueryFiltersEvaluator() { } public static IgnoreQueryFiltersEvaluator Instance = new(); diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/IncludeEvaluator.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/IncludeEvaluator.cs index 65314f4..c6ca967 100644 --- a/src/QuerySpecification.EntityFrameworkCore/Evaluators/IncludeEvaluator.cs +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/IncludeEvaluator.cs @@ -1,10 +1,12 @@ using Microsoft.EntityFrameworkCore.Query; +using System.Collections; +using System.Collections.Concurrent; using System.Diagnostics; using System.Reflection; namespace Pozitron.QuerySpecification; -public class IncludeEvaluator : IEvaluator +public sealed class IncludeEvaluator : IEvaluator { private static readonly MethodInfo _includeMethodInfo = typeof(EntityFrameworkQueryableExtensions) .GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.Include)) @@ -28,63 +30,79 @@ private static readonly MethodInfo _thenIncludeAfterEnumerableMethodInfo && mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IIncludableQueryable<,>) && mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>)); + private readonly record struct CacheKey(Type EntityType, Type PropertyType, Type? PreviousReturnType); + private static readonly ConcurrentDictionary> _cache = new(); + private IncludeEvaluator() { } public static IncludeEvaluator Instance = new(); public IQueryable Evaluate(IQueryable source, Specification specification) where T : class { - foreach (var includeString in specification.IncludeStrings) - { - source = source.Include(includeString); - } - - foreach (var includeExpression in specification.IncludeExpressions) + Type? previousReturnType = null; + foreach (var item in specification.Items) { - if (includeExpression.Type == IncludeTypeEnum.Include) + if (item.Type == ItemType.Include && item.Reference is LambdaExpression expr) { - source = BuildInclude(source, includeExpression); - } - else if (includeExpression.Type == IncludeTypeEnum.ThenInclude) - { - source = BuildThenInclude(source, includeExpression); + if (item.Bag == (int)IncludeType.Include) + { + var key = new CacheKey(typeof(T), expr.ReturnType, null); + previousReturnType = expr.ReturnType; + var include = _cache.GetOrAdd(key, CreateIncludeDelegate); + source = (IQueryable)include(source, expr); + } + else if (item.Bag == (int)IncludeType.ThenInclude) + { + var key = new CacheKey(typeof(T), expr.ReturnType, previousReturnType); + previousReturnType = expr.ReturnType; + var include = _cache.GetOrAdd(key, CreateThenIncludeDelegate); + source = (IQueryable)include(source, expr); + } } } return source; } - private static IQueryable BuildInclude(IQueryable source, IncludeExpression includeExpression) + private static Func CreateIncludeDelegate(CacheKey cacheKey) { - Debug.Assert(includeExpression is not null); + var includeMethod = _includeMethodInfo.MakeGenericMethod(cacheKey.EntityType, cacheKey.PropertyType); + var sourceParameter = Expression.Parameter(typeof(IQueryable)); + var selectorParameter = Expression.Parameter(typeof(LambdaExpression)); - var result = _includeMethodInfo - .MakeGenericMethod(includeExpression.EntityType, includeExpression.PropertyType) - .Invoke(null, [source, includeExpression.LambdaExpression]); + var call = Expression.Call( + includeMethod, + Expression.Convert(sourceParameter, typeof(IQueryable<>).MakeGenericType(cacheKey.EntityType)), + Expression.Convert(selectorParameter, typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(cacheKey.EntityType, cacheKey.PropertyType)))); - Debug.Assert(result is not null); - - return (IQueryable)result; + var lambda = Expression.Lambda>(call, sourceParameter, selectorParameter); + return lambda.Compile(); } - private static IQueryable BuildThenInclude(IQueryable source, IncludeExpression includeExpression) + private static Func CreateThenIncludeDelegate(CacheKey cacheKey) { - Debug.Assert(includeExpression is not null); - Debug.Assert(includeExpression.PreviousPropertyType is not null); + Debug.Assert(cacheKey.PreviousReturnType is not null); + + var thenIncludeInfo = IsGenericEnumerable(cacheKey.PreviousReturnType, out var previousPropertyType) + ? _thenIncludeAfterEnumerableMethodInfo + : _thenIncludeAfterReferenceMethodInfo; - var result = (IsGenericEnumerable(includeExpression.PreviousPropertyType, out var previousPropertyType) - ? _thenIncludeAfterEnumerableMethodInfo - : _thenIncludeAfterReferenceMethodInfo) - .MakeGenericMethod(includeExpression.EntityType, previousPropertyType, includeExpression.PropertyType) - .Invoke(null, [source, includeExpression.LambdaExpression]); + var thenIncludeMethod = thenIncludeInfo.MakeGenericMethod(cacheKey.EntityType, previousPropertyType, cacheKey.PropertyType); + var sourceParameter = Expression.Parameter(typeof(IQueryable)); + var selectorParameter = Expression.Parameter(typeof(LambdaExpression)); - Debug.Assert(result is not null); + var call = Expression.Call( + thenIncludeMethod, + // We must pass cacheKey.PreviousReturnType. It must be exact type, not the generic type argument + Expression.Convert(sourceParameter, typeof(IIncludableQueryable<,>).MakeGenericType(cacheKey.EntityType, cacheKey.PreviousReturnType)), + Expression.Convert(selectorParameter, typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(previousPropertyType, cacheKey.PropertyType)))); - return (IQueryable)result; + var lambda = Expression.Lambda>(call, sourceParameter, selectorParameter); + return lambda.Compile(); } private static bool IsGenericEnumerable(Type type, out Type propertyType) { - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + if (type.IsGenericType && typeof(IEnumerable).IsAssignableFrom(type)) { propertyType = type.GenericTypeArguments[0]; return true; diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/IncludeStringEvaluator.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/IncludeStringEvaluator.cs new file mode 100644 index 0000000..a185481 --- /dev/null +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/IncludeStringEvaluator.cs @@ -0,0 +1,20 @@ +namespace Pozitron.QuerySpecification; + +public sealed class IncludeStringEvaluator : IEvaluator +{ + private IncludeStringEvaluator() { } + public static IncludeStringEvaluator Instance = new(); + + public IQueryable Evaluate(IQueryable source, Specification specification) where T : class + { + foreach (var item in specification.Items) + { + if (item.Type == ItemType.IncludeString && item.Reference is string includeString) + { + source = source.Include(includeString); + } + } + + return source; + } +} diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/LikeEvaluator.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/LikeEvaluator.cs index 773ca0b..b0f3285 100644 --- a/src/QuerySpecification.EntityFrameworkCore/Evaluators/LikeEvaluator.cs +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/LikeEvaluator.cs @@ -1,17 +1,106 @@ -namespace Pozitron.QuerySpecification; +using System.Buffers; -public class LikeEvaluator : IEvaluator +namespace Pozitron.QuerySpecification; + +/* + public IQueryable Evaluate(IQueryable source, Specification specification) where T : class + { + foreach (var likeGroup in specification.LikeExpressions.GroupBy(x => x.Group)) + { + source = source.Like(likeGroup); + } + return source; + } + This was the previous implementation. We're trying to avoid allocations of LikeExpressions and GroupBy. + The new implementation preserves the behavior and has zero allocations. + Instead of GroupBy, we have a single array, sorted by group, and we slice it to get the groups. +*/ + +public sealed class LikeEvaluator : IEvaluator { private LikeEvaluator() { } public static LikeEvaluator Instance = new(); public IQueryable Evaluate(IQueryable source, Specification specification) where T : class { - foreach (var likeGroup in specification.LikeExpressions.GroupBy(x => x.Group)) + var likeCount = GetLikeCount(specification); + if (likeCount == 0) return source; + + if (likeCount == 1) { - source = source.Like(likeGroup); + // Specs with a single Like are the most common. We can optimize for this case to avoid all the additional overhead. + var items = specification.Items; + for (var i = 0; i < items.Length; i++) + { + if (items[i].Type == ItemType.Like) + { + return source.ApplyLikesAsOrGroup(items.Slice(i, 1)); + } + } + } + + SpecItem[] array = ArrayPool.Shared.Rent(likeCount); + + try + { + var span = array.AsSpan()[..likeCount]; + FillSorted(specification, span); + source = ApplyLike(source, span); + } + finally + { + ArrayPool.Shared.Return(array); } return source; } + + private static IQueryable ApplyLike(IQueryable source, ReadOnlySpan span) where T : class + { + var groupStart = 0; + for (var i = 1; i <= span.Length; i++) + { + // If we reached the end of the span or the group has changed, we slice and process the group. + if (i == span.Length || span[i].Bag != span[groupStart].Bag) + { + source = source.ApplyLikesAsOrGroup(span[groupStart..i]); + groupStart = i; + } + } + return source; + } + + private static int GetLikeCount(Specification specification) + { + var count = 0; + foreach (var item in specification.Items) + { + if (item.Type == ItemType.Like) + count++; + } + return count; + } + + private static void FillSorted(Specification specification, Span span) + { + var i = 0; + foreach (var item in specification.Items) + { + if (item.Type == ItemType.Like) + { + // Find the correct insertion point + var j = i; + while (j > 0 && span[j - 1].Bag > item.Bag) + { + span[j] = span[j - 1]; + j--; + } + + // Insert the current item in the sorted position + span[j] = item; + i++; + } + } + } } + diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/LikeExtension.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/LikeExtension.cs new file mode 100644 index 0000000..f555b1a --- /dev/null +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/LikeExtension.cs @@ -0,0 +1,82 @@ +using System.Data; +using System.Diagnostics; +using System.Reflection; + +namespace Pozitron.QuerySpecification; + +internal static class LikeExtension +{ + private static readonly MethodInfo _likeMethodInfo = typeof(DbFunctionsExtensions) + .GetMethod(nameof(DbFunctionsExtensions.Like), [typeof(DbFunctions), typeof(string), typeof(string)])!; + + private static readonly MemberExpression _functions = Expression.Property(null, typeof(EF).GetProperty(nameof(EF.Functions))!); + + // It's required so EF can generate parameterized query. + // In the past I've been creating closures for this, e.g. var patternAsExpression = ((Expression>)(() => pattern)).Body; + // But, that allocates 168 bytes. So, this is more efficient way. + private static MemberExpression StringAsExpression(string value) => Expression.Property( + Expression.Constant(new StringVar(value)), + typeof(StringVar).GetProperty(nameof(StringVar.Format))!); + + // We'll name the property Format just so we match the produced SQL query parameter name (in case of interpolated strings). + private record StringVar(string Format); + + public static IQueryable ApplyLikesAsOrGroup(this IQueryable source, ReadOnlySpan likeItems) + { + Debug.Assert(_likeMethodInfo is not null); + + Expression? combinedExpr = null; + ParameterExpression? mainParam = null; + ParameterReplacerVisitor? visitor = null; + + foreach (var item in likeItems) + { + if (item.Reference is not SpecLike specLike) continue; + + mainParam ??= specLike.KeySelector.Parameters[0]; + + var selectorExpr = specLike.KeySelector.Body; + if (mainParam != specLike.KeySelector.Parameters[0]) + { + visitor ??= new ParameterReplacerVisitor(specLike.KeySelector.Parameters[0], mainParam); + + // If there are more than 2 likes, we want to avoid creating a new visitor instance (saving 32 bytes per instance). + // We're in a sequential loop, no concurrency issues. + visitor.Update(specLike.KeySelector.Parameters[0], mainParam); + selectorExpr = visitor.Visit(selectorExpr); + } + + var patternExpr = StringAsExpression(specLike.Pattern); + + var likeExpr = Expression.Call( + null, + _likeMethodInfo, + _functions, + selectorExpr, + patternExpr); + + combinedExpr = combinedExpr is null + ? likeExpr + : Expression.OrElse(combinedExpr, likeExpr); + } + + return combinedExpr is null || mainParam is null + ? source + : source.Where(Expression.Lambda>(combinedExpr, mainParam)); + } +} + +internal sealed class ParameterReplacerVisitor : ExpressionVisitor +{ + private ParameterExpression _oldParameter; + private Expression _newExpression; + + internal ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression) => + (_oldParameter, _newExpression) = (oldParameter, newExpression); + + internal void Update(ParameterExpression oldParameter, Expression newExpression) => + (_oldParameter, _newExpression) = (oldParameter, newExpression); + + protected override Expression VisitParameter(ParameterExpression node) => + node == _oldParameter ? _newExpression : node; +} diff --git a/src/QuerySpecification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs b/src/QuerySpecification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs index c3e43b1..758738a 100644 --- a/src/QuerySpecification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs +++ b/src/QuerySpecification.EntityFrameworkCore/Evaluators/SpecificationEvaluator.cs @@ -12,6 +12,7 @@ public SpecificationEvaluator() [ WhereEvaluator.Instance, LikeEvaluator.Instance, + IncludeStringEvaluator.Instance, IncludeEvaluator.Instance, OrderEvaluator.Instance, AsNoTrackingEvaluator.Instance, @@ -32,14 +33,20 @@ public virtual IQueryable Evaluate( bool ignorePaging = false) where T : class { ArgumentNullException.ThrowIfNull(specification); - if (specification.Selector is null && specification.SelectorMany is null) throw new SelectorNotFoundException(); - if (specification.Selector is not null && specification.SelectorMany is not null) throw new ConcurrentSelectorsException(); + + var selector = specification.Selector; + var selectorMany = specification.SelectorMany; + + if (selector is null && selectorMany is null) + { + throw new SelectorNotFoundException(); + } source = Evaluate(source, (Specification)specification, true); - var resultQuery = specification.Selector is not null - ? source.Select(specification.Selector) - : source.SelectMany(specification.SelectorMany!); + var resultQuery = selector is not null + ? source.Select(selector) + : source.SelectMany(selectorMany!); return ignorePaging ? resultQuery @@ -52,6 +59,7 @@ public virtual IQueryable Evaluate( bool ignorePaging = false) where T : class { ArgumentNullException.ThrowIfNull(specification); + if (specification.IsEmpty) return source; foreach (var evaluator in Evaluators) { diff --git a/src/QuerySpecification.EntityFrameworkCore/Extensions/LikeExtension.cs b/src/QuerySpecification.EntityFrameworkCore/Extensions/LikeExtension.cs deleted file mode 100644 index 06bade7..0000000 --- a/src/QuerySpecification.EntityFrameworkCore/Extensions/LikeExtension.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Data; -using System.Diagnostics; -using System.Reflection; - -namespace Pozitron.QuerySpecification; - -internal static class LikeExtension -{ - private static readonly MethodInfo _likeMethodInfo = typeof(DbFunctionsExtensions) - .GetMethod(nameof(DbFunctionsExtensions.Like), [typeof(DbFunctions), typeof(string), typeof(string)])!; - - private static readonly PropertyInfo _functionsProp = typeof(EF).GetProperty(nameof(EF.Functions))!; - private static readonly MemberExpression _functions = Expression.Property(null, _functionsProp); - - public static IQueryable Like(this IQueryable source, IEnumerable> likeExpressions) - { - Debug.Assert(_likeMethodInfo is not null); - Debug.Assert(_functionsProp is not null); - - Expression? expr = null; - var parameter = Expression.Parameter(typeof(T), "x"); - - foreach (var likeExpression in likeExpressions) - { - var propertySelector = ParameterReplacerVisitor.Replace( - likeExpression.KeySelector, - likeExpression.KeySelector.Parameters[0], - parameter) as LambdaExpression; - - Debug.Assert(propertySelector is not null); - - var patternAsExpression = ((Expression>)(() => likeExpression.Pattern)).Body; - - var efLikeExpression = Expression.Call( - null, - _likeMethodInfo, - _functions, - propertySelector.Body, - patternAsExpression); - - expr = expr is null - ? efLikeExpression - : Expression.OrElse(expr, efLikeExpression); - } - - return expr is null - ? source - : source.Where(Expression.Lambda>(expr, parameter)); - } -} diff --git a/src/QuerySpecification.EntityFrameworkCore/Extensions/ParameterReplacerVisitor.cs b/src/QuerySpecification.EntityFrameworkCore/Extensions/ParameterReplacerVisitor.cs deleted file mode 100644 index e6d166d..0000000 --- a/src/QuerySpecification.EntityFrameworkCore/Extensions/ParameterReplacerVisitor.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Pozitron.QuerySpecification; - -internal class ParameterReplacerVisitor : ExpressionVisitor -{ - private readonly ParameterExpression _oldParameter; - private readonly Expression _newExpression; - - private ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression) - { - _oldParameter = oldParameter; - _newExpression = newExpression; - } - protected override Expression VisitParameter(ParameterExpression node) - => node == _oldParameter ? _newExpression : node; - - internal static Expression Replace(Expression expression, ParameterExpression oldParameter, Expression newExpression) - => new ParameterReplacerVisitor(oldParameter, newExpression).Visit(expression); -} diff --git a/src/QuerySpecification.EntityFrameworkCore/QuerySpecification.EntityFrameworkCore.csproj b/src/QuerySpecification.EntityFrameworkCore/QuerySpecification.EntityFrameworkCore.csproj index afef50b..a463838 100644 --- a/src/QuerySpecification.EntityFrameworkCore/QuerySpecification.EntityFrameworkCore.csproj +++ b/src/QuerySpecification.EntityFrameworkCore/QuerySpecification.EntityFrameworkCore.csproj @@ -7,42 +7,14 @@ EntityFrameworkCore plugin to Pozitron.QuerySpecification containing EF evaluators. EntityFrameworkCore plugin to Pozitron.QuerySpecification containing EF evaluators. - 10.2.1 + 11.0.0-beta1 fiseni pozitron query specification efcore - v10.2.1 - - Bump EntityFrameworkCore version to 8.0.10 - - v10.2.0 - - Added ability to override the specification validator and in-memory evaluator. - - Performance improvements for in-memory Like. - - Refactored repositories - - Removed projection methods from RepositoryBase and IReadRepositoryBase - - Added IProjectionRepository contract defining the ProjectTo APIs. - - Added RepositoryWithMapper. It inherits RepositoryBase and implements IProjectionRepository. - - v10.1.0 - - Publish a separate symbol package (snupkg). - - Added ToPagedResult extensions. - - Consolidated method and parameter names for evaluator APIs. - - IEvaluator.GetQuery renamed to IEvaluator.Evaluate - - Refactored pagination infrastructure - - Removed PaginationEvaluator - - Apply pagination at the end of the query (fixed SelectMany issues). - - PagedResponse renamed to PagedResult - - Pagination.Default renamed to Pagination.Empty - - v10.0.0 - - Dropped support for old TFMs. Support only .NET 8. - - Dropped support for old plugin packages. Support only EntityFrameworkCore 8. - - Redesigned the infrastructure and refactored the internals. - - Removed all specification interfaces. - - Minimized the memory footprint. - - Removed obsolete features. - - Improved query-building capabilities. - - Added full support for pagination. - - Added support for paginated responses. - - Added arbitrary projection capabilities in repositories. + v11.0.0-beta1 + - Refactored and rebuilt the internals from the ground up. + - Reduced the memory footprint drastically. + - Negligible to no overhead for specification evaluations. + - Better support for extending specifications. diff --git a/src/QuerySpecification/Builders/IncludableBuilderExtensions.cs b/src/QuerySpecification/Builders/IncludableBuilderExtensions.cs index eed6a7a..b7b6ab5 100644 --- a/src/QuerySpecification/Builders/IncludableBuilderExtensions.cs +++ b/src/QuerySpecification/Builders/IncludableBuilderExtensions.cs @@ -14,13 +14,16 @@ public static IIncludableSpecificationBuilder ThenI bool condition) where TEntity : class { - if (condition && !previousBuilder.IsChainDiscarded) + if (condition && !Specification.IsChainDiscarded) { - var expr = new IncludeExpression(thenIncludeExpression, typeof(TEntity), typeof(TProperty), typeof(TPreviousProperty)); - previousBuilder.Specification.Add(expr); + previousBuilder.Specification.AddInternal(ItemType.Include, thenIncludeExpression, (int)IncludeType.ThenInclude); + } + else + { + Specification.IsChainDiscarded = true; } - var includeBuilder = new IncludableSpecificationBuilder(previousBuilder.Specification, !condition || previousBuilder.IsChainDiscarded); + var includeBuilder = new IncludableSpecificationBuilder(previousBuilder.Specification); return includeBuilder; } @@ -36,13 +39,16 @@ public static IIncludableSpecificationBuilder ThenInclude.IsChainDiscarded) + { + previousBuilder.Specification.AddInternal(ItemType.Include, thenIncludeExpression, (int)IncludeType.ThenInclude); + } + else { - var expr = new IncludeExpression(thenIncludeExpression, typeof(TEntity), typeof(TProperty), typeof(TPreviousProperty)); - previousBuilder.Specification.Add(expr); + Specification.IsChainDiscarded = true; } - var includeBuilder = new IncludableSpecificationBuilder(previousBuilder.Specification, !condition || previousBuilder.IsChainDiscarded); + var includeBuilder = new IncludableSpecificationBuilder(previousBuilder.Specification); return includeBuilder; } @@ -58,13 +64,16 @@ public static IIncludableSpecificationBuilder ThenI bool condition) where TEntity : class { - if (condition && !previousBuilder.IsChainDiscarded) + if (condition && !Specification.IsChainDiscarded) { - var expr = new IncludeExpression(thenIncludeExpression, typeof(TEntity), typeof(TProperty), typeof(IEnumerable)); - previousBuilder.Specification.Add(expr); + previousBuilder.Specification.AddInternal(ItemType.Include, thenIncludeExpression, (int)IncludeType.ThenInclude); + } + else + { + Specification.IsChainDiscarded = true; } - var includeBuilder = new IncludableSpecificationBuilder(previousBuilder.Specification, !condition || previousBuilder.IsChainDiscarded); + var includeBuilder = new IncludableSpecificationBuilder(previousBuilder.Specification); return includeBuilder; } @@ -80,13 +89,16 @@ public static IIncludableSpecificationBuilder ThenInclude.IsChainDiscarded) + { + previousBuilder.Specification.AddInternal(ItemType.Include, thenIncludeExpression, (int)IncludeType.ThenInclude); + } + else { - var expr = new IncludeExpression(thenIncludeExpression, typeof(TEntity), typeof(TProperty), typeof(IEnumerable)); - previousBuilder.Specification.Add(expr); + Specification.IsChainDiscarded = true; } - var includeBuilder = new IncludableSpecificationBuilder(previousBuilder.Specification, !condition || previousBuilder.IsChainDiscarded); + var includeBuilder = new IncludableSpecificationBuilder(previousBuilder.Specification); return includeBuilder; } } diff --git a/src/QuerySpecification/Builders/IncludableSpecificationBuilder.cs b/src/QuerySpecification/Builders/IncludableSpecificationBuilder.cs index a194375..2e6c7a5 100644 --- a/src/QuerySpecification/Builders/IncludableSpecificationBuilder.cs +++ b/src/QuerySpecification/Builders/IncludableSpecificationBuilder.cs @@ -2,34 +2,22 @@ public interface IIncludableSpecificationBuilder : ISpecificationBuilder where T : class { - internal bool IsChainDiscarded { get; set; } } public interface IIncludableSpecificationBuilder : ISpecificationBuilder where T : class { - internal bool IsChainDiscarded { get; set; } } -internal class IncludableSpecificationBuilder : IIncludableSpecificationBuilder where T : class +internal class IncludableSpecificationBuilder : SpecificationBuilder, IIncludableSpecificationBuilder where T : class { - public Specification Specification { get; } - public bool IsChainDiscarded { get; set; } - - public IncludableSpecificationBuilder(Specification specification, bool isChainDiscarded) + public IncludableSpecificationBuilder(Specification specification) : base(specification) { - Specification = specification; - IsChainDiscarded = isChainDiscarded; } } -internal class IncludableSpecificationBuilder : IIncludableSpecificationBuilder where T : class +internal class IncludableSpecificationBuilder : SpecificationBuilder, IIncludableSpecificationBuilder where T : class { - public Specification Specification { get; } - public bool IsChainDiscarded { get; set; } - - public IncludableSpecificationBuilder(Specification specification, bool isChainDiscarded) + public IncludableSpecificationBuilder(Specification specification) : base(specification) { - Specification = specification; - IsChainDiscarded = isChainDiscarded; } } diff --git a/src/QuerySpecification/Builders/OrderedBuilderExtensions.cs b/src/QuerySpecification/Builders/OrderedBuilderExtensions.cs index 2725e7c..47130da 100644 --- a/src/QuerySpecification/Builders/OrderedBuilderExtensions.cs +++ b/src/QuerySpecification/Builders/OrderedBuilderExtensions.cs @@ -12,14 +12,13 @@ public static IOrderedSpecificationBuilder ThenBy( Expression> orderExpression, bool condition) { - if (condition && !orderedBuilder.IsChainDiscarded) + if (condition && !Specification.IsChainDiscarded) { - var expr = new OrderExpression(orderExpression, OrderTypeEnum.ThenBy); - orderedBuilder.Specification.Add(expr); + orderedBuilder.Specification.AddInternal(ItemType.Order, orderExpression, (int)OrderType.ThenBy); } else { - orderedBuilder.IsChainDiscarded = true; + Specification.IsChainDiscarded = true; } return orderedBuilder; @@ -35,14 +34,13 @@ public static IOrderedSpecificationBuilder ThenBy( Expression> orderExpression, bool condition) { - if (condition && !orderedBuilder.IsChainDiscarded) + if (condition && !Specification.IsChainDiscarded) { - var expr = new OrderExpression(orderExpression, OrderTypeEnum.ThenBy); - orderedBuilder.Specification.Add(expr); + orderedBuilder.Specification.AddInternal(ItemType.Order, orderExpression, (int)OrderType.ThenBy); } else { - orderedBuilder.IsChainDiscarded = true; + Specification.IsChainDiscarded = true; } return orderedBuilder; @@ -58,14 +56,13 @@ public static IOrderedSpecificationBuilder ThenByDescending> orderExpression, bool condition) { - if (condition && !orderedBuilder.IsChainDiscarded) + if (condition && !Specification.IsChainDiscarded) { - var expr = new OrderExpression(orderExpression, OrderTypeEnum.ThenByDescending); - orderedBuilder.Specification.Add(expr); + orderedBuilder.Specification.AddInternal(ItemType.Order, orderExpression, (int)OrderType.ThenByDescending); } else { - orderedBuilder.IsChainDiscarded = true; + Specification.IsChainDiscarded = true; } return orderedBuilder; @@ -81,14 +78,13 @@ public static IOrderedSpecificationBuilder ThenByDescending( Expression> orderExpression, bool condition) { - if (condition && !orderedBuilder.IsChainDiscarded) + if (condition && !Specification.IsChainDiscarded) { - var expr = new OrderExpression(orderExpression, OrderTypeEnum.ThenByDescending); - orderedBuilder.Specification.Add(expr); + orderedBuilder.Specification.AddInternal(ItemType.Order, orderExpression, (int)OrderType.ThenByDescending); } else { - orderedBuilder.IsChainDiscarded = true; + Specification.IsChainDiscarded = true; } return orderedBuilder; diff --git a/src/QuerySpecification/Builders/OrderedSpecificationBuilder.cs b/src/QuerySpecification/Builders/OrderedSpecificationBuilder.cs deleted file mode 100644 index 8ad6d57..0000000 --- a/src/QuerySpecification/Builders/OrderedSpecificationBuilder.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace Pozitron.QuerySpecification; - -public interface IOrderedSpecificationBuilder : ISpecificationBuilder -{ - internal bool IsChainDiscarded { get; set; } -} - -public interface IOrderedSpecificationBuilder : ISpecificationBuilder -{ - internal bool IsChainDiscarded { get; set; } -} - -internal class OrderedSpecificationBuilder : IOrderedSpecificationBuilder -{ - public Specification Specification { get; } - public bool IsChainDiscarded { get; set; } - - public OrderedSpecificationBuilder(Specification specification, bool isChainDiscarded) - { - Specification = specification; - IsChainDiscarded = isChainDiscarded; - } -} - -internal class OrderedSpecificationBuilder : IOrderedSpecificationBuilder -{ - public Specification Specification { get; } - public bool IsChainDiscarded { get; set; } - - public OrderedSpecificationBuilder(Specification specification, bool isChainDiscarded) - { - Specification = specification; - IsChainDiscarded = isChainDiscarded; - } -} diff --git a/src/QuerySpecification/Builders/SpecificationBuilder.cs b/src/QuerySpecification/Builders/SpecificationBuilder.cs index 781955d..2b1ad54 100644 --- a/src/QuerySpecification/Builders/SpecificationBuilder.cs +++ b/src/QuerySpecification/Builders/SpecificationBuilder.cs @@ -1,5 +1,12 @@ namespace Pozitron.QuerySpecification; +public interface IOrderedSpecificationBuilder : ISpecificationBuilder +{ +} + +public interface IOrderedSpecificationBuilder : ISpecificationBuilder +{ +} public interface ISpecificationBuilder { internal Specification Specification { get; } @@ -10,7 +17,7 @@ public interface ISpecificationBuilder internal Specification Specification { get; } } -internal class SpecificationBuilder : ISpecificationBuilder +internal class SpecificationBuilder : IOrderedSpecificationBuilder, ISpecificationBuilder { public Specification Specification { get; } @@ -20,7 +27,7 @@ public SpecificationBuilder(Specification specification) } } -internal class SpecificationBuilder : ISpecificationBuilder +internal class SpecificationBuilder : IOrderedSpecificationBuilder, ISpecificationBuilder { public Specification Specification { get; } diff --git a/src/QuerySpecification/Builders/SpecificationBuilderExtensions.cs b/src/QuerySpecification/Builders/SpecificationBuilderExtensions.cs index 6614be4..4c7d6e9 100644 --- a/src/QuerySpecification/Builders/SpecificationBuilderExtensions.cs +++ b/src/QuerySpecification/Builders/SpecificationBuilderExtensions.cs @@ -6,14 +6,14 @@ public static void Select( this ISpecificationBuilder builder, Expression> selector) { - builder.Specification.Selector = selector; + builder.Specification.AddOrUpdateInternal(ItemType.Select, selector, (int)SelectType.Select); } public static void SelectMany( this ISpecificationBuilder builder, Expression>> selector) { - builder.Specification.SelectorMany = selector; + builder.Specification.AddOrUpdateInternal(ItemType.Select, selector, (int)SelectType.SelectMany); } public static ISpecificationBuilder Where( @@ -28,8 +28,7 @@ public static ISpecificationBuilder Where( { if (condition) { - var expr = new WhereExpression(criteria); - builder.Specification.Add(expr); + builder.Specification.AddInternal(ItemType.Where, criteria); } return builder; } @@ -46,8 +45,7 @@ public static ISpecificationBuilder Where( { if (condition) { - var expr = new WhereExpression(criteria); - builder.Specification.Add(expr); + builder.Specification.AddInternal(ItemType.Where, criteria); } return builder; } @@ -64,12 +62,11 @@ public static IOrderedSpecificationBuilder OrderBy( { if (condition) { - var expr = new OrderExpression(keySelector, OrderTypeEnum.OrderBy); - builder.Specification.Add(expr); + builder.Specification.AddInternal(ItemType.Order, keySelector, (int)OrderType.OrderBy); } - var orderedSpecificationBuilder = new OrderedSpecificationBuilder(builder.Specification, !condition); - return orderedSpecificationBuilder; + Specification.IsChainDiscarded = !condition; + return (SpecificationBuilder)builder; } public static IOrderedSpecificationBuilder OrderBy( @@ -84,12 +81,11 @@ public static IOrderedSpecificationBuilder OrderBy( { if (condition) { - var expr = new OrderExpression(keySelector, OrderTypeEnum.OrderBy); - builder.Specification.Add(expr); + builder.Specification.AddInternal(ItemType.Order, keySelector, (int)OrderType.OrderBy); } - var orderedSpecificationBuilder = new OrderedSpecificationBuilder(builder.Specification, !condition); - return orderedSpecificationBuilder; + Specification.IsChainDiscarded = !condition; + return (SpecificationBuilder)builder; } public static IOrderedSpecificationBuilder OrderByDescending( @@ -104,12 +100,11 @@ public static IOrderedSpecificationBuilder OrderByDescending(keySelector, OrderTypeEnum.OrderByDescending); - builder.Specification.Add(expr); + builder.Specification.AddInternal(ItemType.Order, keySelector, (int)OrderType.OrderByDescending); } - var orderedSpecificationBuilder = new OrderedSpecificationBuilder(builder.Specification, !condition); - return orderedSpecificationBuilder; + Specification.IsChainDiscarded = !condition; + return (SpecificationBuilder)builder; } public static IOrderedSpecificationBuilder OrderByDescending( @@ -124,12 +119,11 @@ public static IOrderedSpecificationBuilder OrderByDescending( { if (condition) { - var expr = new OrderExpression(keySelector, OrderTypeEnum.OrderByDescending); - builder.Specification.Add(expr); + builder.Specification.AddInternal(ItemType.Order, keySelector, (int)OrderType.OrderByDescending); } - var orderedSpecificationBuilder = new OrderedSpecificationBuilder(builder.Specification, !condition); - return orderedSpecificationBuilder; + Specification.IsChainDiscarded = !condition; + return (SpecificationBuilder)builder; } public static IIncludableSpecificationBuilder Include( @@ -144,11 +138,11 @@ public static IIncludableSpecificationBuilder Include(builder.Specification, !condition); + Specification.IsChainDiscarded = !condition; + var includeBuilder = new IncludableSpecificationBuilder(builder.Specification); return includeBuilder; } @@ -164,11 +158,11 @@ public static IIncludableSpecificationBuilder Include(builder.Specification, !condition); + Specification.IsChainDiscarded = !condition; + var includeBuilder = new IncludableSpecificationBuilder(builder.Specification); return includeBuilder; } @@ -184,7 +178,7 @@ public static ISpecificationBuilder Include( { if (condition) { - builder.Specification.Add(includeString); + builder.Specification.AddInternal(ItemType.IncludeString, includeString); } return builder; } @@ -200,7 +194,7 @@ public static ISpecificationBuilder Include( { if (condition) { - builder.Specification.Add(includeString); + builder.Specification.AddInternal(ItemType.IncludeString, includeString); } return builder; } @@ -221,8 +215,8 @@ public static ISpecificationBuilder Like( { if (condition) { - var expr = new LikeExpression(keySelector, pattern, group); - builder.Specification.Add(expr); + var like = new SpecLike(keySelector, pattern); + builder.Specification.AddInternal(ItemType.Like, like, group); } return builder; } @@ -243,8 +237,8 @@ public static ISpecificationBuilder Like( { if (condition) { - var expr = new LikeExpression(keySelector, pattern, group); - builder.Specification.Add(expr); + var like = new SpecLike(keySelector, pattern); + builder.Specification.AddInternal(ItemType.Like, like, group); } return builder; } diff --git a/src/QuerySpecification/Evaluators/LikeExtension.cs b/src/QuerySpecification/Evaluators/LikeExtension.cs index e6e06b0..cefe63f 100644 --- a/src/QuerySpecification/Evaluators/LikeExtension.cs +++ b/src/QuerySpecification/Evaluators/LikeExtension.cs @@ -53,7 +53,7 @@ public Regex GetOrAdd(string key, Func valueFactory) // It might happen we end up with more items than max (concurrency), but we won't be too strict. // We're just trying to avoid indefinite growth. - for (int i = _dictionary.Count - _maxSize; i >= 0; i--) + for (var i = _dictionary.Count - _maxSize; i >= 0; i--) { // Avoid being smart, just remove sequentially from the start. var firstKey = _dictionary.Keys.FirstOrDefault(); @@ -75,7 +75,7 @@ public Regex GetOrAdd(string key, Func valueFactory) // It covers almost all of the scenarios, and it's faster than regex based implementations. // It may fail/throw in some very specific and edge cases, hence, wrap it in try/catch. // UPDATE: it returns incorrect results for some obvious cases. - [ExcludeFromCodeCoverage] + [ExcludeFromCodeCoverage(Justification = "Dead code. Keeping it just as a reference")] private static bool SqlLikeOption2(string str, string pattern) { var isMatch = true; diff --git a/src/QuerySpecification/Evaluators/LikeMemoryEvaluator.cs b/src/QuerySpecification/Evaluators/LikeMemoryEvaluator.cs index 573928f..dec2f85 100644 --- a/src/QuerySpecification/Evaluators/LikeMemoryEvaluator.cs +++ b/src/QuerySpecification/Evaluators/LikeMemoryEvaluator.cs @@ -1,41 +1,132 @@ -namespace Pozitron.QuerySpecification; +using System.Diagnostics; -public class LikeMemoryEvaluator : IInMemoryEvaluator +namespace Pozitron.QuerySpecification; + +/* + public IEnumerable Evaluate(IEnumerable source, Specification specification) + { + foreach (var likeGroup in specification.LikeExpressions.GroupBy(x => x.Group)) + { + source = source.Where(x => likeGroup.Any(c => c.KeySelectorFunc(x)?.Like(c.Pattern) ?? false)); + } + return source; + } + This was the previous implementation. We're trying to avoid allocations of LikeExpressions, GroupBy and LINQ. + The new implementation preserves the behavior and reduces allocations drastically. + We've implemented a custom iterator. Also, instead of GroupBy, we have a single array sorted by group, and we slice it to get the groups. + For source of 1000 items, the allocations are reduced from 257.872 bytes to only 64 bytes (the cost of the iterator instance). Refer to LikeInMemoryEvaluatorBenchmark results. + */ + +public sealed class LikeMemoryEvaluator : IInMemoryEvaluator { private LikeMemoryEvaluator() { } public static LikeMemoryEvaluator Instance = new(); public IEnumerable Evaluate(IEnumerable source, Specification specification) { - // There are benchmarks in QuerySpecification.Benchmarks project. - // This implementation was the most efficient one. + var compiledItems = specification.GetCompiledItems(); + if (compiledItems.Length == 0) return source; + + var startIndexLikeItems = Array.FindIndex(compiledItems, item => item.Type == ItemType.Like); + if (startIndexLikeItems == -1) return source; + + // The like items are contiguously placed as a last segment in the array and are already sorted by group. + return new SpecLikeIterator(source, compiledItems, startIndexLikeItems); + } + + private sealed class SpecLikeIterator : Iterator + { + private readonly IEnumerable _source; + private readonly SpecItem[] _compiledItems; + private readonly int _startIndex; + + private IEnumerator? _enumerator; + + public SpecLikeIterator(IEnumerable source, SpecItem[] compiledItems, int startIndex) + { + Debug.Assert(source != null); + Debug.Assert(compiledItems != null); + _source = source; + _compiledItems = compiledItems; + _startIndex = startIndex; + } - var groups = specification.LikeExpressions.GroupBy(x => x.Group).ToList(); + public override Iterator Clone() + => new SpecLikeIterator(_source, _compiledItems, _startIndex); - foreach (var item in source) + public override void Dispose() { - var match = true; - foreach (var group in groups) + if (_enumerator is not null) { - var matchOrGroup = false; - foreach (var like in group) - { - if (like.KeySelectorFunc(item)?.Like(like.Pattern) ?? false) + _enumerator.Dispose(); + _enumerator = null; + } + base.Dispose(); + } + + public override bool MoveNext() + { + switch (_state) + { + case 1: + _enumerator = _source.GetEnumerator(); + _state = 2; + goto case 2; + case 2: + Debug.Assert(_enumerator is not null); + var likeItems = _compiledItems.AsSpan()[_startIndex.._compiledItems.Length]; + while (_enumerator.MoveNext()) { - matchOrGroup = true; - break; + TSource sourceItem = _enumerator.Current; + if (IsValid(sourceItem, likeItems)) + { + _current = sourceItem; + return true; + } } - } - if ((match = match && matchOrGroup) is false) - { + Dispose(); break; + } + + return false; + } + + private static bool IsValid(T sourceItem, ReadOnlySpan span) + { + var valid = true; + var groupStart = 0; + + for (var i = 1; i <= span.Length; i++) + { + // If we reached the end of the span or the group has changed, we slice and process the group. + if (i == span.Length || span[i].Bag != span[groupStart].Bag) + { + var validOrGroup = IsValidInOrGroup(sourceItem, span[groupStart..i]); + if ((valid = valid && validOrGroup) is false) + { + break; + } + groupStart = i; } } - if (match) + return valid; + + static bool IsValidInOrGroup(T sourceItem, ReadOnlySpan span) { - yield return item; + var validOrGroup = false; + foreach (var specItem in span) + { + if (specItem.Reference is not SpecLikeCompiled specLike) continue; + + if (specLike.KeySelector(sourceItem)?.Like(specLike.Pattern) ?? false) + { + validOrGroup = true; + break; + } + } + return validOrGroup; } } } diff --git a/src/QuerySpecification/Evaluators/OrderEvaluator.cs b/src/QuerySpecification/Evaluators/OrderEvaluator.cs index 50a1fae..a10caaa 100644 --- a/src/QuerySpecification/Evaluators/OrderEvaluator.cs +++ b/src/QuerySpecification/Evaluators/OrderEvaluator.cs @@ -1,6 +1,6 @@ namespace Pozitron.QuerySpecification; -public class OrderEvaluator : IEvaluator, IInMemoryEvaluator +public sealed class OrderEvaluator : IEvaluator, IInMemoryEvaluator { private OrderEvaluator() { } public static OrderEvaluator Instance = new(); @@ -9,23 +9,26 @@ public IQueryable Evaluate(IQueryable source, Specification specific { IOrderedQueryable? orderedQuery = null; - foreach (var orderExpression in specification.OrderExpressions) + foreach (var item in specification.Items) { - if (orderExpression.OrderType == OrderTypeEnum.OrderBy) + if (item.Type == ItemType.Order && item.Reference is Expression> expr) { - orderedQuery = source.OrderBy(orderExpression.KeySelector); - } - else if (orderExpression.OrderType == OrderTypeEnum.OrderByDescending) - { - orderedQuery = source.OrderByDescending(orderExpression.KeySelector); - } - else if (orderExpression.OrderType == OrderTypeEnum.ThenBy) - { - orderedQuery = orderedQuery!.ThenBy(orderExpression.KeySelector); - } - else if (orderExpression.OrderType == OrderTypeEnum.ThenByDescending) - { - orderedQuery = orderedQuery!.ThenByDescending(orderExpression.KeySelector); + if (item.Bag == (int)OrderType.OrderBy) + { + orderedQuery = source.OrderBy(expr); + } + else if (item.Bag == (int)OrderType.OrderByDescending) + { + orderedQuery = source.OrderByDescending(expr); + } + else if (item.Bag == (int)OrderType.ThenBy) + { + orderedQuery = orderedQuery!.ThenBy(expr); + } + else if (item.Bag == (int)OrderType.ThenByDescending) + { + orderedQuery = orderedQuery!.ThenByDescending(expr); + } } } @@ -39,25 +42,29 @@ public IQueryable Evaluate(IQueryable source, Specification specific public IEnumerable Evaluate(IEnumerable source, Specification specification) { + var compiledItems = specification.GetCompiledItems(); IOrderedEnumerable? orderedQuery = null; - foreach (var orderExpression in specification.OrderExpressions) + foreach (var item in compiledItems) { - if (orderExpression.OrderType == OrderTypeEnum.OrderBy) - { - orderedQuery = source.OrderBy(orderExpression.KeySelectorFunc); - } - else if (orderExpression.OrderType == OrderTypeEnum.OrderByDescending) - { - orderedQuery = source.OrderByDescending(orderExpression.KeySelectorFunc); - } - else if (orderExpression.OrderType == OrderTypeEnum.ThenBy) - { - orderedQuery = orderedQuery!.ThenBy(orderExpression.KeySelectorFunc); - } - else if (orderExpression.OrderType == OrderTypeEnum.ThenByDescending) + if (item.Type == ItemType.Order && item.Reference is Func compiledExpr) { - orderedQuery = orderedQuery!.ThenByDescending(orderExpression.KeySelectorFunc); + if (item.Bag == (int)OrderType.OrderBy) + { + orderedQuery = source.OrderBy(compiledExpr); + } + else if (item.Bag == (int)OrderType.OrderByDescending) + { + orderedQuery = source.OrderByDescending(compiledExpr); + } + else if (item.Bag == (int)OrderType.ThenBy) + { + orderedQuery = orderedQuery!.ThenBy(compiledExpr); + } + else if (item.Bag == (int)OrderType.ThenByDescending) + { + orderedQuery = orderedQuery!.ThenByDescending(compiledExpr); + } } } diff --git a/src/QuerySpecification/Evaluators/PaginationExtensions.cs b/src/QuerySpecification/Evaluators/PaginationExtensions.cs index ed00f32..4b359e7 100644 --- a/src/QuerySpecification/Evaluators/PaginationExtensions.cs +++ b/src/QuerySpecification/Evaluators/PaginationExtensions.cs @@ -3,16 +3,39 @@ public static class PaginationExtensions { public static IQueryable ApplyPaging(this IQueryable source, Specification specification) - => ApplyPaging(source, specification.Skip, specification.Take); - public static IQueryable ApplyPaging(this IQueryable source, Specification specification) - => ApplyPaging(source, specification.Skip, specification.Take); + { + var paging = specification.FirstOrDefault(ItemType.Paging); + if (paging is null) return source; + + return ApplyPaging(source, paging.Skip, paging.Take); + } + + public static IEnumerable ApplyPaging(this IEnumerable source, Specification specification) + { + var paging = specification.FirstOrDefault(ItemType.Paging); + if (paging is null) return source; + + return ApplyPaging(source, paging.Skip, paging.Take); + } + public static IQueryable ApplyPaging(this IQueryable source, Pagination pagination) => ApplyPaging(source, pagination.Skip, pagination.Take); - public static IEnumerable ApplyPaging(this IEnumerable source, Specification specification) - => ApplyPaging(source, specification.Skip, specification.Take); + public static IQueryable ApplyPaging(this IQueryable source, Specification specification) + { + var paging = specification.FirstOrDefault(ItemType.Paging); + if (paging is null) return source; + + return ApplyPaging(source, paging.Skip, paging.Take); + } + public static IEnumerable ApplyPaging(this IEnumerable source, Specification specification) - => ApplyPaging(source, specification.Skip, specification.Take); + { + var paging = specification.FirstOrDefault(ItemType.Paging); + if (paging is null) return source; + + return ApplyPaging(source, paging.Skip, paging.Take); + } private static IQueryable ApplyPaging(this IQueryable source, int skip, int take) { @@ -45,3 +68,4 @@ private static IEnumerable ApplyPaging(this IEnumerable source, int ski return source; } } + diff --git a/src/QuerySpecification/Evaluators/SpecificationInMemoryEvaluator.cs b/src/QuerySpecification/Evaluators/SpecificationInMemoryEvaluator.cs index c506460..7ba633c 100644 --- a/src/QuerySpecification/Evaluators/SpecificationInMemoryEvaluator.cs +++ b/src/QuerySpecification/Evaluators/SpecificationInMemoryEvaluator.cs @@ -11,8 +11,8 @@ public SpecificationInMemoryEvaluator() Evaluators = [ WhereEvaluator.Instance, - LikeMemoryEvaluator.Instance, OrderEvaluator.Instance, + LikeMemoryEvaluator.Instance, ]; } public SpecificationInMemoryEvaluator(IEnumerable evaluators) @@ -26,14 +26,20 @@ public virtual IEnumerable Evaluate( bool ignorePaging = false) { ArgumentNullException.ThrowIfNull(specification); - if (specification.Selector is null && specification.SelectorMany is null) throw new SelectorNotFoundException(); - if (specification.Selector is not null && specification.SelectorMany is not null) throw new ConcurrentSelectorsException(); + + var selector = specification.Selector; + var selectorMany = specification.SelectorMany; + + if (selector is null && selectorMany is null) + { + throw new SelectorNotFoundException(); + } source = Evaluate(source, (Specification)specification, true); - var result = specification.Selector is not null - ? source.Select(specification.Selector.Compile()) - : source.SelectMany(specification.SelectorMany!.Compile()); + var result = selector is not null + ? source.Select(selector.Compile()) + : source.SelectMany(selectorMany!.Compile()); return ignorePaging ? result @@ -46,6 +52,7 @@ public virtual IEnumerable Evaluate( bool ignorePaging = false) { ArgumentNullException.ThrowIfNull(specification); + if (specification.IsEmpty) return source; foreach (var evaluator in Evaluators) { diff --git a/src/QuerySpecification/Evaluators/WhereEvaluator.cs b/src/QuerySpecification/Evaluators/WhereEvaluator.cs index df09fc3..04ce4e8 100644 --- a/src/QuerySpecification/Evaluators/WhereEvaluator.cs +++ b/src/QuerySpecification/Evaluators/WhereEvaluator.cs @@ -1,15 +1,18 @@ namespace Pozitron.QuerySpecification; -public class WhereEvaluator : IEvaluator, IInMemoryEvaluator +public sealed class WhereEvaluator : IEvaluator, IInMemoryEvaluator { private WhereEvaluator() { } public static WhereEvaluator Instance = new(); public IQueryable Evaluate(IQueryable source, Specification specification) where T : class { - foreach (var whereExpression in specification.WhereExpressions) + foreach (var item in specification.Items) { - source = source.Where(whereExpression.Filter); + if (item.Type == ItemType.Where && item.Reference is Expression> expr) + { + source = source.Where(expr); + } } return source; @@ -17,11 +20,17 @@ public IQueryable Evaluate(IQueryable source, Specification specific public IEnumerable Evaluate(IEnumerable source, Specification specification) { - foreach (var whereExpression in specification.WhereExpressions) + var compiledItems = specification.GetCompiledItems(); + + foreach (var item in compiledItems) { - source = source.Where(whereExpression.FilterFunc); + if (item.Type == ItemType.Where && item.Reference is Func compiledExpr) + { + source = source.Where(compiledExpr); + } } return source; } } + diff --git a/src/QuerySpecification/Expressions/IncludeExpression.cs b/src/QuerySpecification/Expressions/IncludeExpression.cs index fbee4ab..638577a 100644 --- a/src/QuerySpecification/Expressions/IncludeExpression.cs +++ b/src/QuerySpecification/Expressions/IncludeExpression.cs @@ -1,51 +1,16 @@ -namespace Pozitron.QuerySpecification; +using System.Diagnostics; -public class IncludeExpression +namespace Pozitron.QuerySpecification; + +public sealed class IncludeExpression { public LambdaExpression LambdaExpression { get; } - public Type EntityType { get; } - public Type PropertyType { get; } - public Type? PreviousPropertyType { get; } - public IncludeTypeEnum Type { get; } - - private IncludeExpression( - LambdaExpression expression, - Type entityType, - Type propertyType, - Type? previousPropertyType, - IncludeTypeEnum includeType) + public IncludeType Type { get; } + public IncludeExpression(LambdaExpression expression, IncludeType type) { - ArgumentNullException.ThrowIfNull(expression); - ArgumentNullException.ThrowIfNull(entityType); - ArgumentNullException.ThrowIfNull(propertyType); - - if (includeType == IncludeTypeEnum.ThenInclude) - { - ArgumentNullException.ThrowIfNull(previousPropertyType); - } - + Debug.Assert(expression is not null); LambdaExpression = expression; - EntityType = entityType; - PropertyType = propertyType; - PreviousPropertyType = previousPropertyType; - Type = includeType; - } - - public IncludeExpression( - LambdaExpression expression, - Type entityType, - Type propertyType) - : this(expression, entityType, propertyType, null, IncludeTypeEnum.Include) - { - } - - public IncludeExpression( - LambdaExpression expression, - Type entityType, - Type propertyType, - Type previousPropertyType) - : this(expression, entityType, propertyType, previousPropertyType, IncludeTypeEnum.ThenInclude) - { + Type = type; } } diff --git a/src/QuerySpecification/Expressions/IncludeTypeEnum.cs b/src/QuerySpecification/Expressions/IncludeType.cs similarity index 75% rename from src/QuerySpecification/Expressions/IncludeTypeEnum.cs rename to src/QuerySpecification/Expressions/IncludeType.cs index cd6c17c..5ab1cdd 100644 --- a/src/QuerySpecification/Expressions/IncludeTypeEnum.cs +++ b/src/QuerySpecification/Expressions/IncludeType.cs @@ -1,6 +1,6 @@ namespace Pozitron.QuerySpecification; -public enum IncludeTypeEnum +public enum IncludeType { Include = 1, ThenInclude = 2 diff --git a/src/QuerySpecification/Expressions/LikeExpression.cs b/src/QuerySpecification/Expressions/LikeExpression.cs index 5e1c62e..b57c41e 100644 --- a/src/QuerySpecification/Expressions/LikeExpression.cs +++ b/src/QuerySpecification/Expressions/LikeExpression.cs @@ -1,21 +1,35 @@ -namespace Pozitron.QuerySpecification; +using System.Diagnostics; -public class LikeExpression +namespace Pozitron.QuerySpecification; + +public sealed class LikeExpression { - private Func? _keySelectorFunc; public Expression> KeySelector { get; } public string Pattern { get; } public int Group { get; } public LikeExpression(Expression> keySelector, string pattern, int group = 1) { - ArgumentNullException.ThrowIfNull(keySelector); - ArgumentException.ThrowIfNullOrEmpty(pattern); - + Debug.Assert(keySelector is not null); + Debug.Assert(!string.IsNullOrEmpty(pattern)); KeySelector = keySelector; Pattern = pattern; Group = group; } +} + +public sealed class LikeExpressionCompiled +{ + public Func KeySelector { get; } + public string Pattern { get; } + public int Group { get; } - public Func KeySelectorFunc => _keySelectorFunc ??= KeySelector.Compile(); + public LikeExpressionCompiled(Func keySelector, string pattern, int group = 1) + { + Debug.Assert(keySelector is not null); + Debug.Assert(!string.IsNullOrEmpty(pattern)); + KeySelector = keySelector; + Pattern = pattern; + Group = group; + } } diff --git a/src/QuerySpecification/Expressions/OrderExpression.cs b/src/QuerySpecification/Expressions/OrderExpression.cs index 574140a..e1990f1 100644 --- a/src/QuerySpecification/Expressions/OrderExpression.cs +++ b/src/QuerySpecification/Expressions/OrderExpression.cs @@ -1,17 +1,29 @@ -namespace Pozitron.QuerySpecification; +using System.Diagnostics; -public class OrderExpression +namespace Pozitron.QuerySpecification; + +public sealed class OrderExpression { - private Func? _keySelectorFunc; public Expression> KeySelector { get; } - public OrderTypeEnum OrderType { get; } + public OrderType Type { get; } - public OrderExpression(Expression> keySelector, OrderTypeEnum orderType) + public OrderExpression(Expression> keySelector, OrderType type) { - ArgumentNullException.ThrowIfNull(keySelector); + Debug.Assert(keySelector is not null); KeySelector = keySelector; - OrderType = orderType; + Type = type; } +} + +public sealed class OrderExpressionCompiled +{ + public Func KeySelector { get; } + public OrderType Type { get; } - public Func KeySelectorFunc => _keySelectorFunc ??= KeySelector.Compile(); + public OrderExpressionCompiled(Func keySelector, OrderType type) + { + Debug.Assert(keySelector is not null); + KeySelector = keySelector; + Type = type; + } } diff --git a/src/QuerySpecification/Expressions/OrderTypeEnum.cs b/src/QuerySpecification/Expressions/OrderType.cs similarity index 83% rename from src/QuerySpecification/Expressions/OrderTypeEnum.cs rename to src/QuerySpecification/Expressions/OrderType.cs index 0a0160a..9b1df79 100644 --- a/src/QuerySpecification/Expressions/OrderTypeEnum.cs +++ b/src/QuerySpecification/Expressions/OrderType.cs @@ -1,6 +1,6 @@ namespace Pozitron.QuerySpecification; -public enum OrderTypeEnum +public enum OrderType { OrderBy = 1, OrderByDescending = 2, diff --git a/src/QuerySpecification/Expressions/SelectType.cs b/src/QuerySpecification/Expressions/SelectType.cs new file mode 100644 index 0000000..451323d --- /dev/null +++ b/src/QuerySpecification/Expressions/SelectType.cs @@ -0,0 +1,7 @@ +namespace Pozitron.QuerySpecification; + +public enum SelectType +{ + Select = 1, + SelectMany = 2 +} diff --git a/src/QuerySpecification/Expressions/WhereExpression.cs b/src/QuerySpecification/Expressions/WhereExpression.cs index 5febded..8a84050 100644 --- a/src/QuerySpecification/Expressions/WhereExpression.cs +++ b/src/QuerySpecification/Expressions/WhereExpression.cs @@ -1,15 +1,25 @@ -namespace Pozitron.QuerySpecification; +using System.Diagnostics; -public class WhereExpression +namespace Pozitron.QuerySpecification; + +public sealed class WhereExpression { - private Func? _filterFunc; public Expression> Filter { get; } public WhereExpression(Expression> filter) { - ArgumentNullException.ThrowIfNull(filter); + Debug.Assert(filter is not null); Filter = filter; } +} + +public sealed class WhereExpressionCompiled +{ + public Func Filter { get; } - public Func FilterFunc => _filterFunc ??= Filter.Compile(); + public WhereExpressionCompiled(Func filter) + { + Debug.Assert(filter is not null); + Filter = filter; + } } diff --git a/src/QuerySpecification/Internals/ItemType.cs b/src/QuerySpecification/Internals/ItemType.cs new file mode 100644 index 0000000..4cc0421 --- /dev/null +++ b/src/QuerySpecification/Internals/ItemType.cs @@ -0,0 +1,16 @@ +namespace Pozitron.QuerySpecification; + +internal static class ItemType +{ + public const int Where = -1; + public const int Order = -2; + public const int Include = -3; + public const int IncludeString = -4; + public const int Like = -5; + public const int Select = -6; + public const int Compiled = -7; + + // We can save 16 bytes (on x64) by storing both Flags and Paging in the same item. + public const int Paging = -8; // Stored in the reference + public const int Flags = -8; // Stored in the bag +} diff --git a/src/QuerySpecification/Internals/Iterator.cs b/src/QuerySpecification/Internals/Iterator.cs new file mode 100644 index 0000000..21cc57b --- /dev/null +++ b/src/QuerySpecification/Internals/Iterator.cs @@ -0,0 +1,36 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace Pozitron.QuerySpecification; + +internal abstract class Iterator : IEnumerable, IEnumerator +{ + private readonly int _threadId = Environment.CurrentManagedThreadId; + + private protected int _state; + private protected TSource _current = default!; + + public Iterator GetEnumerator() + { + var enumerator = _state == 0 && _threadId == Environment.CurrentManagedThreadId ? this : Clone(); + enumerator._state = 1; + return enumerator; + } + + public abstract Iterator Clone(); + public abstract bool MoveNext(); + + public TSource Current => _current; + object? IEnumerator.Current => Current; + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + [ExcludeFromCodeCoverage] + void IEnumerator.Reset() => throw new NotSupportedException(); + + public virtual void Dispose() + { + _current = default!; + _state = -1; + } +} diff --git a/src/QuerySpecification/Internals/SpecItem.cs b/src/QuerySpecification/Internals/SpecItem.cs new file mode 100644 index 0000000..57dc09c --- /dev/null +++ b/src/QuerySpecification/Internals/SpecItem.cs @@ -0,0 +1,8 @@ +namespace Pozitron.QuerySpecification; + +internal struct SpecItem +{ + public int Type; // 0-4 bytes + public int Bag; // 4-8 bytes + public object? Reference; // 8-16 bytes (on x64) +} diff --git a/src/QuerySpecification/Internals/SpecIterator.cs b/src/QuerySpecification/Internals/SpecIterator.cs new file mode 100644 index 0000000..9d4e3bb --- /dev/null +++ b/src/QuerySpecification/Internals/SpecIterator.cs @@ -0,0 +1,41 @@ +using System.Diagnostics; + +namespace Pozitron.QuerySpecification; + +internal sealed class SpecIterator : Iterator +{ + private readonly SpecItem[] _source; + private readonly int _type; + + public SpecIterator(SpecItem[] source, int type) + { + Debug.Assert(source != null && source.Length > 0); + _type = type; + _source = source; + } + + public override Iterator Clone() => + new SpecIterator(_source, _type); + + public override bool MoveNext() + { + var index = _state - 1; + var source = _source; + var type = _type; + + while (unchecked((uint)index < (uint)source.Length)) + { + var item = source[index]; + index = _state++; + + if (item.Type == type && item.Reference is TObject reference) + { + _current = reference; + return true; + } + } + + Dispose(); + return false; + } +} diff --git a/src/QuerySpecification/Internals/SpecLike.cs b/src/QuerySpecification/Internals/SpecLike.cs new file mode 100644 index 0000000..3de1b5c --- /dev/null +++ b/src/QuerySpecification/Internals/SpecLike.cs @@ -0,0 +1,31 @@ +using System.Diagnostics; + +namespace Pozitron.QuerySpecification; + +internal sealed class SpecLike +{ + public Expression> KeySelector { get; } + public string Pattern { get; } + + public SpecLike(Expression> keySelector, string pattern) + { + Debug.Assert(keySelector is not null); + Debug.Assert(!string.IsNullOrEmpty(pattern)); + KeySelector = keySelector; + Pattern = pattern; + } +} + +internal sealed class SpecLikeCompiled +{ + public Func KeySelector { get; } + public string Pattern { get; } + + public SpecLikeCompiled(Func keySelector, string pattern) + { + Debug.Assert(keySelector is not null); + Debug.Assert(!string.IsNullOrEmpty(pattern)); + KeySelector = keySelector; + Pattern = pattern; + } +} diff --git a/src/QuerySpecification/Internals/SpecPaging.cs b/src/QuerySpecification/Internals/SpecPaging.cs new file mode 100644 index 0000000..e7a87e8 --- /dev/null +++ b/src/QuerySpecification/Internals/SpecPaging.cs @@ -0,0 +1,7 @@ +namespace Pozitron.QuerySpecification; + +internal sealed class SpecPaging +{ + public int Take = -1; + public int Skip = -1; +} diff --git a/src/QuerySpecification/Internals/SpecSelectIterator.cs b/src/QuerySpecification/Internals/SpecSelectIterator.cs new file mode 100644 index 0000000..7f3fb90 --- /dev/null +++ b/src/QuerySpecification/Internals/SpecSelectIterator.cs @@ -0,0 +1,43 @@ +using System.Diagnostics; + +namespace Pozitron.QuerySpecification; + +internal sealed class SpecSelectIterator : Iterator +{ + private readonly SpecItem[] _source; + private readonly Func _selector; + private readonly int _type; + + public SpecSelectIterator(SpecItem[] source, int type, Func selector) + { + Debug.Assert(source != null && source.Length > 0); + Debug.Assert(selector != null); + _type = type; + _source = source; + _selector = selector; + } + + public override Iterator Clone() => + new SpecSelectIterator(_source, _type, _selector); + + public override bool MoveNext() + { + var index = _state - 1; + var source = _source; + var type = _type; + + while (unchecked((uint)index < (uint)source.Length)) + { + var item = source[index]; + index = _state++; + if (item.Type == type && item.Reference is TObject reference) + { + _current = _selector(reference, item.Bag); + return true; + } + } + + Dispose(); + return false; + } +} diff --git a/src/QuerySpecification/QuerySpecification.csproj b/src/QuerySpecification/QuerySpecification.csproj index 28f86b2..aa16445 100644 --- a/src/QuerySpecification/QuerySpecification.csproj +++ b/src/QuerySpecification/QuerySpecification.csproj @@ -7,46 +7,21 @@ Abstract package for building query specifications. Abstract package for building query specifications. - 10.2.1 + 11.0.0-beta1 fiseni pozitron query specification - v10.2.1 - - Bump EntityFrameworkCore version to 8.0.10 - - v10.2.0 - - Added ability to override the specification validator and in-memory evaluator. - - Performance improvements for in-memory Like. - - Refactored repositories - - Removed projection methods from RepositoryBase and IReadRepositoryBase - - Added IProjectionRepository contract defining the ProjectTo APIs. - - Added RepositoryWithMapper. It inherits RepositoryBase and implements IProjectionRepository. - - v10.1.0 - - Publish a separate symbol package (snupkg). - - Added ToPagedResult extensions. - - Consolidated method and parameter names for evaluator APIs. - - IEvaluator.GetQuery renamed to IEvaluator.Evaluate - - Refactored pagination infrastructure - - Removed PaginationEvaluator - - Apply pagination at the end of the query (fixed SelectMany issues). - - PagedResponse renamed to PagedResult - - Pagination.Default renamed to Pagination.Empty - - v10.0.0 - - Dropped support for old TFMs. Support only .NET 8. - - Dropped support for old plugin packages. Support only EntityFrameworkCore 8. - - Redesigned the infrastructure and refactored the internals. - - Removed all specification interfaces. - - Minimized the memory footprint. - - Removed obsolete features. - - Improved query-building capabilities. - - Added full support for pagination. - - Added support for paginated responses. - - Added arbitrary projection capabilities in repositories. + v11.0.0-beta1 + - Refactored and rebuilt the internals from the ground up. + - Reduced the memory footprint drastically. + - Negligible to no overhead for specification evaluations. + - Better support for extending specifications. + + + diff --git a/src/QuerySpecification/Specification.cs b/src/QuerySpecification/Specification.cs index aed8989..e0cbad3 100644 --- a/src/QuerySpecification/Specification.cs +++ b/src/QuerySpecification/Specification.cs @@ -1,80 +1,383 @@ -namespace Pozitron.QuerySpecification; +using System.Diagnostics.CodeAnalysis; + +namespace Pozitron.QuerySpecification; public class Specification : Specification { - public Specification() - { - Query = new SpecificationBuilder(this); - } - - public new ISpecificationBuilder Query { get; } - - public Expression>? Selector { get; internal set; } - public Expression>>? SelectorMany { get; internal set; } + public Specification() : base() { } + public Specification(int initialCapacity) : base(initialCapacity) { } public new virtual IEnumerable Evaluate(IEnumerable entities, bool ignorePaging = false) { var evaluator = Evaluator; return evaluator.Evaluate(entities, this, ignorePaging); } + + public new ISpecificationBuilder Query => new SpecificationBuilder(this); + + public Expression>? Selector => FirstOrDefault>>(ItemType.Select); + public Expression>>? SelectorMany => FirstOrDefault>>>(ItemType.Select); } -public class Specification +public partial class Specification { - // The state is null initially, but we're spending 8 bytes per reference on x64. - // I considered keeping the whole state as a Dictionary, and that reduces the footprint for empty specs. - // But, specs are never empty, and the overhead of the dictionary compensates the saved space. Also casting back and forth is a pain. - // Refer to SpecificationSizeTests for more details. - private List>? _whereExpressions; - private List>? _likeExpressions; - private List>? _orderExpressions; - private List? _includeExpressions; - private List? _includeStrings; - private Dictionary? _items; + private protected SpecItem[]? _items; + + // It is utilized only during the building stage for the builder chains. Once the state is built, we don't care about it anymore. + // The initial value is not important since the value is always initialized by the root of the chain. Therefore, we don't need ThreadLocal (it's more expensive). + // With this we're saving 8 bytes per include builder, and we don't need an order builder at all (saving 32 bytes per order builder instance). + [ThreadStatic] + internal static bool IsChainDiscarded; + + public Specification() { } + public Specification(int initialCapacity) + { + _items = new SpecItem[initialCapacity]; + } - public Specification() + public virtual IEnumerable Evaluate(IEnumerable entities, bool ignorePaging = false) { - Query = new SpecificationBuilder(this); + var evaluator = Evaluator; + return evaluator.Evaluate(entities, this, ignorePaging); + } + public virtual bool IsSatisfiedBy(T entity) + { + var validator = Validator; + return validator.IsValid(entity, this); } - public ISpecificationBuilder Query { get; } protected virtual SpecificationInMemoryEvaluator Evaluator => SpecificationInMemoryEvaluator.Default; protected virtual SpecificationValidator Validator => SpecificationValidator.Default; - public int Take { get; internal set; } = -1; - public int Skip { get; internal set; } = -1; + public ISpecificationBuilder Query => new SpecificationBuilder(this); - // Based on the alignment of 8 bytes (on x64) we can store 8 flags here - // So, we have space for 4 more flags for free. - public bool IgnoreQueryFilters { get; internal set; } = false; - public bool AsSplitQuery { get; internal set; } = false; - public bool AsNoTracking { get; internal set; } = false; - public bool AsNoTrackingWithIdentityResolution { get; internal set; } = false; + [MemberNotNullWhen(false, nameof(_items))] + public bool IsEmpty => _items is null; - internal void Add(WhereExpression whereExpression) => (_whereExpressions ??= new(2)).Add(whereExpression); - internal void Add(LikeExpression likeExpression) => (_likeExpressions ??= new(2)).Add(likeExpression); - internal void Add(OrderExpression orderExpression) => (_orderExpressions ??= new(2)).Add(orderExpression); - internal void Add(IncludeExpression includeExpression) => (_includeExpressions ??= new(2)).Add(includeExpression); - internal void Add(string includeString) => (_includeStrings ??= new(1)).Add(includeString); + public IEnumerable> WhereExpressionsCompiled => _items is null + ? Enumerable.Empty>() + : new SpecSelectIterator, WhereExpressionCompiled>(GetCompiledItems(), ItemType.Where, (x, bag) => new(x)); + public IEnumerable> OrderExpressionsCompiled => _items is null + ? Enumerable.Empty>() + : new SpecSelectIterator, OrderExpressionCompiled>(GetCompiledItems(), ItemType.Order, (x, bag) => new(x, (OrderType)bag)); - // Specs are not intended to be thread-safe, so we don't need to worry about thread-safety here. - public Dictionary Items => _items ??= []; - public IEnumerable> WhereExpressions => _whereExpressions ?? Enumerable.Empty>(); - public IEnumerable> LikeExpressions => _likeExpressions ?? Enumerable.Empty>(); - public IEnumerable> OrderExpressions => _orderExpressions ?? Enumerable.Empty>(); - public IEnumerable IncludeExpressions => _includeExpressions ?? Enumerable.Empty(); - public IEnumerable IncludeStrings => _includeStrings ?? Enumerable.Empty(); + public IEnumerable> LikeExpressionsCompiled => _items is null + ? Enumerable.Empty>() + : new SpecSelectIterator, LikeExpressionCompiled>(GetCompiledItems(), ItemType.Like, (x, bag) => new(x.KeySelector, x.Pattern, bag)); - public virtual IEnumerable Evaluate(IEnumerable entities, bool ignorePaging = false) + public IEnumerable> WhereExpressions => _items is null + ? Enumerable.Empty>() + : new SpecSelectIterator>, WhereExpression>(_items, ItemType.Where, (x, bag) => new WhereExpression(x)); + + public IEnumerable> IncludeExpressions => _items is null + ? Enumerable.Empty>() + : new SpecSelectIterator>(_items, ItemType.Include, (x, bag) => new IncludeExpression(x, (IncludeType)bag)); + + public IEnumerable> OrderExpressions => _items is null + ? Enumerable.Empty>() + : new SpecSelectIterator>, OrderExpression>(_items, ItemType.Order, (x, bag) => new OrderExpression(x, (OrderType)bag)); + + public IEnumerable> LikeExpressions => _items is null + ? Enumerable.Empty>() + : new SpecSelectIterator, LikeExpression>(_items, ItemType.Like, (x, bag) => new LikeExpression(x.KeySelector, x.Pattern, bag)); + + public IEnumerable IncludeStrings => _items is null + ? Enumerable.Empty() + : new SpecSelectIterator(_items, ItemType.IncludeString, (x, bag) => x); + + public int Take { - var evaluator = Evaluator; - return evaluator.Evaluate(entities, this, ignorePaging); + get => FirstOrDefault(ItemType.Paging)?.Take ?? -1; + set => GetOrCreate(ItemType.Paging).Take = value; + } + public int Skip + { + get => FirstOrDefault(ItemType.Paging)?.Skip ?? -1; + set => GetOrCreate(ItemType.Paging).Skip = value; + } + public bool IgnoreQueryFilters + { + get => GetFlag(SpecFlag.IgnoreQueryFilters); + set => UpdateFlag(SpecFlag.IgnoreQueryFilters, value); + } + public bool AsSplitQuery + { + get => GetFlag(SpecFlag.AsSplitQuery); + set => UpdateFlag(SpecFlag.AsSplitQuery, value); + } + public bool AsNoTracking + { + get => GetFlag(SpecFlag.AsNoTracking); + set => UpdateFlag(SpecFlag.AsNoTracking, value); + } + public bool AsNoTrackingWithIdentityResolution + { + get => GetFlag(SpecFlag.AsNoTrackingWithIdentityResolution); + set => UpdateFlag(SpecFlag.AsNoTrackingWithIdentityResolution, value); } - public virtual bool IsSatisfiedBy(T entity) + public void Add(int type, object value) { - var validator = Validator; - return validator.IsValid(entity, this); + ArgumentNullException.ThrowIfNull(value); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(type); + + AddInternal(type, value); + } + public TObject? FirstOrDefault(int type) + { + if (IsEmpty) return default; + + foreach (var item in _items) + { + if (item.Type == type && item.Reference is TObject reference) + { + return reference; + } + } + return default; + } + public TObject First(int type) + { + if (IsEmpty) throw new InvalidOperationException("Specification contains no items"); + + foreach (var item in _items) + { + if (item.Type == type && item.Reference is TObject reference) + { + return reference; + } + } + throw new InvalidOperationException("Specification contains no matching item"); + } + public IEnumerable OfType(int type) => _items is null + ? Enumerable.Empty() + : new SpecIterator(_items, type); + + internal ReadOnlySpan Items => _items ?? ReadOnlySpan.Empty; + + [MemberNotNull(nameof(_items))] + internal void AddInternal(int type, object value, int bag = 0) + { + var newItem = new SpecItem + { + Type = type, + Reference = value, + Bag = bag + }; + + if (IsEmpty) + { + // Specs with two items are very common, we'll optimize for that. + _items = new SpecItem[2]; + _items[0] = newItem; + } + else + { + var items = _items; + + // We have a special case for Paging, we're storing it in the same item with Flags. + if (type == ItemType.Paging) + { + for (var i = 0; i < items.Length; i++) + { + if (items[i].Type == ItemType.Paging) + { + _items[i].Reference = newItem.Reference; + return; + } + } + } + + for (var i = 0; i < items.Length; i++) + { + if (items[i].Type == 0) + { + items[i] = newItem; + return; + } + } + + var originalLength = items.Length; + var newArray = new SpecItem[originalLength + 4]; + Array.Copy(items, newArray, originalLength); + newArray[originalLength] = newItem; + _items = newArray; + } + } + internal void AddOrUpdateInternal(int type, object value, int bag = 0) + { + if (IsEmpty) + { + AddInternal(type, value, bag); + return; + } + var items = _items; + for (var i = 0; i < items.Length; i++) + { + if (items[i].Type == type) + { + _items[i].Reference = value; + _items[i].Bag = bag; + return; + } + } + AddInternal(type, value, bag); + } + internal SpecItem[] GetCompiledItems() + { + if (IsEmpty) return Array.Empty(); + + var compilableItemsCount = CountCompilableItems(_items); + if (compilableItemsCount == 0) return Array.Empty(); + + var compiledItems = GetCompiledItems(_items); + + // If the count of compilable items is equal to the count of compiled items, we don't need to recompile. + if (compiledItems.Length == compilableItemsCount) return compiledItems; + + compiledItems = GenerateCompiledItems(_items, compilableItemsCount); + AddOrUpdateInternal(ItemType.Compiled, compiledItems); + return compiledItems; + + static SpecItem[] GetCompiledItems(SpecItem[] items) + { + foreach (var item in items) + { + if (item.Type == ItemType.Compiled && item.Reference is SpecItem[] compiledItems) + { + return compiledItems; + } + } + return Array.Empty(); + } + + static int CountCompilableItems(SpecItem[] items) + { + var count = 0; + foreach (var item in items) + { + if (item.Type == ItemType.Where || item.Type == ItemType.Like || item.Type == ItemType.Order) + count++; + } + return count; + } + + static SpecItem[] GenerateCompiledItems(SpecItem[] items, int count) + { + var compiledItems = new SpecItem[count]; + + // We want to place the items contiguously by type. Sorting is more expensive than looping per type. + var index = 0; + foreach (var item in items) + { + if (item.Type == ItemType.Where && item.Reference is Expression> expr) + { + compiledItems[index++] = new SpecItem + { + Type = item.Type, + Reference = expr.Compile(), + Bag = item.Bag + }; + } + } + if (index == count) return compiledItems; + + foreach (var item in items) + { + if (item.Type == ItemType.Order && item.Reference is Expression> expr) + { + compiledItems[index++] = new SpecItem + { + Type = item.Type, + Reference = expr.Compile(), + Bag = item.Bag + }; + } + } + if (index == count) return compiledItems; + + var likeStartIndex = index; + foreach (var item in items) + { + if (item.Type == ItemType.Like && item.Reference is SpecLike like) + { + compiledItems[index++] = new SpecItem + { + Type = item.Type, + Reference = new SpecLikeCompiled(like.KeySelector.Compile(), like.Pattern), + Bag = item.Bag + }; + } + } + + // Sort Like items by the group, so we do it only once and not repeatedly in the Like evaluator). + compiledItems.AsSpan()[likeStartIndex..count].Sort((x, y) => x.Bag.CompareTo(y.Bag)); + + return compiledItems; + } + } + + private TObject GetOrCreate(int type) where TObject : new() + { + return FirstOrDefault(type) ?? Create(); + TObject Create() + { + var reference = new TObject(); + AddInternal(type, reference); + return reference; + } + } + private bool GetFlag(SpecFlag flag) + { + if (IsEmpty) return false; + + foreach (var item in _items) + { + if (item.Type == ItemType.Flags) + { + return ((SpecFlag)item.Bag & flag) == flag; + } + } + return false; + } + private void UpdateFlag(SpecFlag flag, bool value) + { + if (IsEmpty) + { + if (value) + { + AddInternal(ItemType.Flags, null!, (int)flag); + } + return; + } + + var items = _items; + for (var i = 0; i < items.Length; i++) + { + if (items[i].Type == ItemType.Flags) + { + var newValue = value + ? (SpecFlag)items[i].Bag | flag + : (SpecFlag)items[i].Bag & ~flag; + + _items[i].Bag = (int)newValue; + return; + } + } + + if (value) + { + AddInternal(ItemType.Flags, null!, (int)flag); + } + } + + [Flags] + private enum SpecFlag + { + IgnoreQueryFilters = 1, + AsNoTracking = 2, + AsNoTrackingWithIdentityResolution = 4, + AsSplitQuery = 8 } } diff --git a/src/QuerySpecification/Validators/LikeValidator.cs b/src/QuerySpecification/Validators/LikeValidator.cs index 532e283..387c6ae 100644 --- a/src/QuerySpecification/Validators/LikeValidator.cs +++ b/src/QuerySpecification/Validators/LikeValidator.cs @@ -1,33 +1,68 @@ namespace Pozitron.QuerySpecification; -public class LikeValidator : IValidator +/* + public bool IsValid(T entity, Specification specification) + { + foreach (var likeGroup in specification.LikeExpressions.GroupBy(x => x.Group)) + { + if (likeGroup.Any(c => c.KeySelectorFunc(entity)?.Like(c.Pattern) ?? false) == false) return false; + } + return true; + } + This was the previous implementation.We're trying to avoid allocations of LikeExpressions, GroupBy and LINQ. + Instead of GroupBy, we have a single array sorted by group, and we slice it to get the groups. + The new implementation preserves the behavior and reduces allocations drastically. + For 1000 entities, the allocations are reduced from 651.160 bytes to ZERO bytes. Refer to LikeValidatorBenchmark results. + */ + +public sealed class LikeValidator : IValidator { private LikeValidator() { } public static LikeValidator Instance = new(); public bool IsValid(T entity, Specification specification) { - // There are benchmarks in QuerySpecification.Benchmarks project. - // This implementation was the most efficient one. + var compiledItems = specification.GetCompiledItems(); + if (compiledItems.Length == 0) return true; + + var startIndexLikeItems = Array.FindIndex(compiledItems, item => item.Type == ItemType.Like); + if (startIndexLikeItems == -1) return true; - var groups = specification.LikeExpressions.GroupBy(x => x.Group); + // The like items are contiguously placed as a last segment in the array and are already sorted by group. + return IsValid(entity, compiledItems.AsSpan()[startIndexLikeItems..compiledItems.Length]); + } - foreach (var group in groups) + private static bool IsValid(T entity, ReadOnlySpan span) + { + var groupStart = 0; + for (var i = 1; i <= span.Length; i++) { - var match = false; - foreach (var like in group) + // If we reached the end of the span or the group has changed, we slice and process the group. + if (i == span.Length || span[i].Bag != span[groupStart].Bag) { - if (like.KeySelectorFunc(entity)?.Like(like.Pattern) ?? false) + if (IsValidInOrGroup(entity, span[groupStart..i]) is false) { - match = true; - break; + return false; } + groupStart = i; } - - if (match is false) - return false; } - return true; + + static bool IsValidInOrGroup(T entity, ReadOnlySpan span) + { + var validOrGroup = false; + foreach (var specItem in span) + { + if (specItem.Reference is not SpecLikeCompiled specLike) continue; + + if (specLike.KeySelector(entity)?.Like(specLike.Pattern) ?? false) + { + validOrGroup = true; + break; + } + } + return validOrGroup; + } } } diff --git a/src/QuerySpecification/Validators/SpecificationValidator.cs b/src/QuerySpecification/Validators/SpecificationValidator.cs index d01bdd5..a9fecb7 100644 --- a/src/QuerySpecification/Validators/SpecificationValidator.cs +++ b/src/QuerySpecification/Validators/SpecificationValidator.cs @@ -21,9 +21,11 @@ public SpecificationValidator(IEnumerable validators) public virtual bool IsValid(T entity, Specification specification) { - foreach (var partialValidator in Validators) + if (specification.IsEmpty) return true; + + foreach (var validator in Validators) { - if (partialValidator.IsValid(entity, specification) == false) + if (validator.IsValid(entity, specification) == false) return false; } diff --git a/src/QuerySpecification/Validators/WhereValidator.cs b/src/QuerySpecification/Validators/WhereValidator.cs index 3fb5596..fc40d75 100644 --- a/src/QuerySpecification/Validators/WhereValidator.cs +++ b/src/QuerySpecification/Validators/WhereValidator.cs @@ -1,16 +1,21 @@ namespace Pozitron.QuerySpecification; -public class WhereValidator : IValidator +public sealed class WhereValidator : IValidator { private WhereValidator() { } public static WhereValidator Instance = new(); public bool IsValid(T entity, Specification specification) { - foreach (var whereExpression in specification.WhereExpressions) + var compiledItems = specification.GetCompiledItems(); + + foreach (var item in compiledItems) { - if (whereExpression.FilterFunc(entity) == false) - return false; + if (item.Type == ItemType.Where && item.Reference is Func compiledExpr) + { + if (compiledExpr(entity) == false) + return false; + } } return true; diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark0_SpecSize.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark0_SpecSize.cs new file mode 100644 index 0000000..1a31620 --- /dev/null +++ b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark0_SpecSize.cs @@ -0,0 +1,105 @@ +using System.Linq.Expressions; + +namespace QuerySpecification.Benchmarks; + +[MemoryDiagnoser] +public class Benchmark0_SpecSize +{ + /* This benchmark is just used to measure the Specification sizes and detect eventual regressions. + * We measure with provided expressions, so it measures pure spec overhead. + * Types: + * 0 -> Empty + * 1 -> Single Where clause + * 2 -> Where and OrderBy + * 3 -> Where, Order chain, Include chain, Flag (AsNoTracking) + * 4 -> Where, Order chain, Include chain, Like, Skip, Take, Flag (AsNoTracking) + * + * Here is the comparison between version 10 and version 11. + + | Method | Type | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | + |----------- |----- |----------:|----------:|----------:|------:|--------:|-------:|-------:|----------:|------------:| + | Version_10 | 0 | 8.065 ns | 0.1938 ns | 0.1904 ns | 1.00 | 0.03 | 0.0134 | - | 112 B | 1.00 | + | Version_11 | 0 | 3.263 ns | 0.0249 ns | 0.0208 ns | 0.40 | 0.01 | 0.0029 | - | 24 B | 0.21 | + | | | | | | | | | | | | + | Version_10 | 1 | 19.677 ns | 0.1520 ns | 0.1270 ns | 1.00 | 0.01 | 0.0258 | - | 216 B | 1.00 | + | Version_11 | 1 | 9.618 ns | 0.0537 ns | 0.0449 ns | 0.49 | 0.00 | 0.0124 | - | 104 B | 0.48 | + | | | | | | | | | | | | + | Version_10 | 2 | 36.692 ns | 0.7407 ns | 0.7274 ns | 1.00 | 0.03 | 0.0430 | - | 360 B | 1.00 | + | Version_11 | 2 | 14.613 ns | 0.0780 ns | 0.0730 ns | 0.40 | 0.01 | 0.0124 | - | 104 B | 0.29 | + | | | | | | | | | | | | + | Version_10 | 3 | 64.766 ns | 1.2484 ns | 1.1678 ns | 1.00 | 0.03 | 0.0774 | 0.0001 | 648 B | 1.00 | + | Version_11 | 3 | 42.890 ns | 0.1451 ns | 0.1133 ns | 0.66 | 0.01 | 0.0258 | - | 216 B | 0.33 | + | | | | | | | | | | | | + | Version_10 | 4 | 73.354 ns | 0.5833 ns | 0.5171 ns | 1.00 | 0.01 | 0.0918 | 0.0002 | 768 B | 1.00 | + | Version_11 | 4 | 71.566 ns | 0.5052 ns | 0.4478 ns | 0.98 | 0.01 | 0.0343 | - | 288 B | 0.38 | + */ + + public static class Expressions + { + public static Expression> Criteria { get; } = x => x.Id > 0; + public static Expression> OrderBy { get; } = x => x.Id; + public static Expression> OrderThenBy { get; } = x => x.Name; + public static Expression> IncludeStoreCompany { get; } = x => x.Company; + public static Expression> IncludeCompanyCountry { get; } = x => x.Country; + public static Expression> Like { get; } = x => x.Name; + } + + [Params(0, 1, 2, 3, 4)] + public int Type { get; set; } + + [Benchmark] + public object Spec() + { + if (Type == 0) + { + var spec = new Specification(); + return spec; + } + else if (Type == 1) + { + var spec = new Specification(); + spec.Query + .Where(Expressions.Criteria); + + return spec; + } + else if (Type == 2) + { + var spec = new Specification(); + spec.Query + .Where(Expressions.Criteria) + .OrderBy(Expressions.OrderBy); + + return spec; + } + else if (Type == 3) + { + var spec = new Specification(6); + spec.Query + .Where(Expressions.Criteria) + .OrderBy(Expressions.OrderBy) + .ThenBy(Expressions.OrderThenBy) + .Include(Expressions.IncludeStoreCompany) + .ThenInclude(Expressions.IncludeCompanyCountry) + .AsNoTracking(); + + return spec; + } + else + { + var spec = new Specification(7); + spec.Query + .Where(Expressions.Criteria) + .OrderBy(Expressions.OrderBy) + .ThenBy(Expressions.OrderThenBy) + .Include(Expressions.IncludeStoreCompany) + .ThenInclude(Expressions.IncludeCompanyCountry) + .Like(Expressions.Like, "%tore%") + .Skip(1) + .Take(1) + .AsNoTracking(); + + return spec; + } + } +} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark1_IQueryable.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark1_IQueryable.cs new file mode 100644 index 0000000..707783c --- /dev/null +++ b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark1_IQueryable.cs @@ -0,0 +1,131 @@ +namespace QuerySpecification.Benchmarks; + +[MemoryDiagnoser] +public class Benchmark1_IQueryable +{ + /* This benchmark measures building the IQueryable state. It's the pure overhead of using specifications. + * Types: + * 0 -> Empty + * 1 -> Single Where clause + * 2 -> Where and OrderBy + * 3 -> Where, Order chain, Include chain, Flag (AsNoTracking) + * 4 -> Where, Order chain, Include chain, Like, Skip, Take, Flag (AsNoTracking) + */ + + private DbSet _queryable = default!; + + [GlobalSetup] + public void Setup() + { + _queryable = new BenchmarkDbContext().Stores; + } + + [Params(0, 1, 2, 3, 4)] + public int Type { get; set; } + + [Benchmark(Baseline = true)] + public object EFCore() + { + if (Type == 0) + { + return _queryable; + } + else if (Type == 1) + { + return _queryable + .Where(x => x.Id > 0); + } + else if (Type == 2) + { + return _queryable + .Where(x => x.Id > 0) + .OrderBy(x => x.Id); + } + else if (Type == 3) + { + return _queryable + .Where(x => x.Id > 0) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .AsNoTracking(); + } + else + { + var name = "%tore%"; + return _queryable + .Where(x => x.Id > 0) + .Where(x => EF.Functions.Like(x.Name, name)) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .Skip(1) + .Take(1) + .AsNoTracking(); + } + } + + [Benchmark] + public object Spec() + { + if (Type == 0) + { + var spec = new Specification(); + return _queryable + .WithSpecification(spec); + } + else if (Type == 1) + { + var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0); + + return _queryable + .WithSpecification(spec); + } + else if (Type == 2) + { + var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0) + .OrderBy(x => x.Id); + + return _queryable + .WithSpecification(spec); + } + else if (Type == 3) + { + var spec = new Specification(6); + spec.Query + .Where(x => x.Id > 0) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .AsNoTracking(); + + return _queryable + .WithSpecification(spec); + } + else + { + var name = "%tore%"; + var spec = new Specification(7); + spec.Query + .Where(x => x.Id > 0) + .Like(x => x.Name, name) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .Skip(1) + .Take(1) + .AsNoTracking(); + + return _queryable + .WithSpecification(spec); + } + } +} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark2_ToQueryString.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark2_ToQueryString.cs new file mode 100644 index 0000000..56fc517 --- /dev/null +++ b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark2_ToQueryString.cs @@ -0,0 +1,138 @@ +namespace QuerySpecification.Benchmarks; + +[MemoryDiagnoser] +public class Benchmark2_ToQueryString +{ + /* This benchmark measures building the final SQL query. + * Types: + * 0 -> Empty + * 1 -> Single Where clause + * 2 -> Where and OrderBy + * 3 -> Where, Order chain, Include chain, Flag (AsNoTracking) + * 4 -> Where, Order chain, Include chain, Like, Skip, Take, Flag (AsNoTracking) + */ + + [Params(0, 1, 2, 3, 4)] + public int Type { get; set; } + + [Benchmark(Baseline = true)] + public string EFCore() + { + using var context = new BenchmarkDbContext(); + + if (Type == 0) + { + return context.Stores + .ToQueryString(); + } + else if (Type == 1) + { + return context.Stores + .Where(x => x.Id > 0) + .ToQueryString(); + } + else if (Type == 2) + { + return context.Stores + .Where(x => x.Id > 0) + .OrderBy(x => x.Id) + .ToQueryString(); + } + else if (Type == 3) + { + return context.Stores + .Where(x => x.Id > 0) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .AsNoTracking() + .ToQueryString(); + } + else + { + var name = "%tore%"; + return context.Stores + .Where(x => x.Id > 0) + .Where(x => EF.Functions.Like(x.Name, name)) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .Skip(1) + .Take(1) + .AsNoTracking() + .ToQueryString(); + } + } + + [Benchmark] + public string Spec() + { + using var context = new BenchmarkDbContext(); + + if (Type == 0) + { + var spec = new Specification(); + + return context.Stores + .WithSpecification(spec) + .ToQueryString(); + } + else if (Type == 1) + { + var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0); + + return context.Stores + .WithSpecification(spec) + .ToQueryString(); + } + else if (Type == 2) + { + var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0) + .OrderBy(x => x.Id); + + return context.Stores + .WithSpecification(spec) + .ToQueryString(); + } + else if (Type == 3) + { + var spec = new Specification(6); + spec.Query + .Where(x => x.Id > 0) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .AsNoTracking(); + + return context.Stores + .WithSpecification(spec) + .ToQueryString(); + } + else + { + var name = "%tore%"; + var spec = new Specification(7); + spec.Query + .Where(x => x.Id > 0) + .Like(x => x.Name, name) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .Skip(1) + .Take(1) + .AsNoTracking(); + + return context.Stores + .WithSpecification(spec) + .ToQueryString(); + } + } +} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark3_DbQuery.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark3_DbQuery.cs new file mode 100644 index 0000000..c51d1e9 --- /dev/null +++ b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark3_DbQuery.cs @@ -0,0 +1,144 @@ +namespace QuerySpecification.Benchmarks; + +[MemoryDiagnoser] +public class Benchmark3_DbQuery +{ + /* This benchmark measures the end-to-end cycle, including the round trip to the database. + * Types: + * 0 -> Empty + * 1 -> Single Where clause + * 2 -> Where and OrderBy + * 3 -> Where, Order chain, Include chain, Flag (AsNoTracking) + * 4 -> Where, Order chain, Include chain, Like, Skip, Take, Flag (AsNoTracking) + */ + + [GlobalSetup] + public async Task Setup() + { + await BenchmarkDbContext.SeedAsync(); + } + + [Params(0, 1, 2, 3, 4)] + public int Type { get; set; } + + [Benchmark(Baseline = true)] + public async Task EFCore() + { + using var context = new BenchmarkDbContext(); + + if (Type == 0) + { + return await context.Stores + .FirstAsync(); + } + else if (Type == 1) + { + return await context.Stores + .Where(x => x.Id > 0) + .FirstAsync(); + } + else if (Type == 2) + { + return await context.Stores + .Where(x => x.Id > 0) + .OrderBy(x => x.Id) + .FirstAsync(); + } + else if (Type == 3) + { + return await context.Stores + .Where(x => x.Id > 0) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .AsNoTracking() + .FirstAsync(); + } + else + { + var name = "%tore%"; + return await context.Stores + .Where(x => x.Id > 0) + .Where(x => EF.Functions.Like(x.Name, name)) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .Skip(1) + .Take(1) + .AsNoTracking() + .FirstAsync(); + } + } + + [Benchmark] + public async Task Spec() + { + using var context = new BenchmarkDbContext(); + + if (Type == 0) + { + var spec = new Specification(); + + return await context.Stores + .WithSpecification(spec) + .FirstAsync(); + } + else if (Type == 1) + { + var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0); + + return await context.Stores + .WithSpecification(spec) + .FirstAsync(); + } + else if (Type == 2) + { + var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0) + .OrderBy(x => x.Id); + + return await context.Stores + .WithSpecification(spec) + .FirstAsync(); + } + else if (Type == 3) + { + var spec = new Specification(6); + spec.Query + .Where(x => x.Id > 0) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .AsNoTracking(); + + return await context.Stores + .WithSpecification(spec) + .FirstAsync(); + } + else + { + var name = "%tore%"; + var spec = new Specification(7); + spec.Query + .Where(x => x.Id > 0) + .Like(x => x.Name, name) + .OrderBy(x => x.Id) + .ThenBy(x => x.Name) + .Include(x => x.Company) + .ThenInclude(x => x.Country) + .Skip(1) + .Take(1) + .AsNoTracking(); + + return await context.Stores + .WithSpecification(spec) + .FirstAsync(); + } + } +} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark4_Like.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark4_Like.cs new file mode 100644 index 0000000..07740c2 --- /dev/null +++ b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark4_Like.cs @@ -0,0 +1,83 @@ +namespace QuerySpecification.Benchmarks; + +[MemoryDiagnoser] +public class Benchmark4_Like +{ + private DbSet _queryable = default!; + + [GlobalSetup] + public void Setup() + { + _queryable = new BenchmarkDbContext().Stores; + } + + [Params(0, 1, 2, 6)] + public int Count { get; set; } + + [Benchmark(Baseline = true)] + public object EFCore() + { + var nameSearchTerm = "%tore%"; + if (Count == 0) + { + return _queryable; + } + else if (Count == 1) + { + return _queryable + .Where(x => EF.Functions.Like(x.Name, nameSearchTerm)); + } + else if (Count == 2) + { + return _queryable + .Where(x => EF.Functions.Like(x.Name, nameSearchTerm)) + .Where(x => EF.Functions.Like(x.Name, nameSearchTerm)); + } + else + { + return _queryable + .Where(x => EF.Functions.Like(x.Name, nameSearchTerm) || EF.Functions.Like(x.Name, nameSearchTerm)) + .Where(x => EF.Functions.Like(x.Name, nameSearchTerm) || EF.Functions.Like(x.Name, nameSearchTerm)) + .Where(x => EF.Functions.Like(x.Name, nameSearchTerm) || EF.Functions.Like(x.Name, nameSearchTerm)); + } + } + + + [Benchmark] + public object Spec() + { + var nameSearchTerm = "%tore%"; + if (Count == 0) + { + var spec = new Specification(); + return _queryable.WithSpecification(spec); + } + else if (Count == 1) + { + var spec = new Specification(); + spec.Query + .Like(x => x.Name, nameSearchTerm); + return _queryable.WithSpecification(spec); + } + else if (Count == 2) + { + var spec = new Specification(); + spec.Query + .Like(x => x.Name, nameSearchTerm, 2) + .Like(x => x.Name, nameSearchTerm, 1); + return _queryable.WithSpecification(spec); + } + else + { + var spec = new Specification(6); + spec.Query + .Like(x => x.Name, nameSearchTerm, 2) + .Like(x => x.Name, nameSearchTerm, 3) + .Like(x => x.Name, nameSearchTerm, 1) + .Like(x => x.Name, nameSearchTerm, 3) + .Like(x => x.Name, nameSearchTerm, 2) + .Like(x => x.Name, nameSearchTerm, 1); + return _queryable.WithSpecification(spec); + } + } +} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark5_Include.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark5_Include.cs new file mode 100644 index 0000000..e64e480 --- /dev/null +++ b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark5_Include.cs @@ -0,0 +1,34 @@ +namespace QuerySpecification.Benchmarks; + +[MemoryDiagnoser] +public class Benchmark5_Include +{ + private DbSet _queryable = default!; + + [GlobalSetup] + public void Setup() + { + _queryable = new BenchmarkDbContext().Stores; + } + + [Benchmark(Baseline = true)] + public object EFCore() + { + var result = _queryable + .Include(x => x.Company) + .ThenInclude(x => x.Country); + + return result; + } + + [Benchmark] + public object Spec() + { + var spec = new Specification(); + spec.Query + .Include(x => x.Company) + .ThenInclude(x => x.Country); + + return _queryable.WithSpecification(spec); + } +} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark6_IncludeEvaluator.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark6_IncludeEvaluator.cs new file mode 100644 index 0000000..2c471b9 --- /dev/null +++ b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark6_IncludeEvaluator.cs @@ -0,0 +1,164 @@ +using Microsoft.EntityFrameworkCore.Query; +using System.Collections; +using System.Diagnostics; +using System.Linq.Expressions; +using System.Reflection; + +namespace QuerySpecification.Benchmarks; + +[MemoryDiagnoser] +public class Benchmark6_IncludeEvaluator +{ + /* + * This benchmark only measures applying Include to IQueryable. + * It tends to measure the pure overhead of the reflection calls. + */ + + private static readonly Expression> _includeCompany = x => x.Company; + private static readonly Expression> _includeCountry = x => x.Country; + + private DbSet _queryable = default!; + private Specification _spec = default!; + + [GlobalSetup] + public void Setup() + { + _queryable = new BenchmarkDbContext().Stores; + _spec = new Specification(); + _spec.Query + .Include(_includeCompany) + .ThenInclude(_includeCountry); + } + + [Benchmark(Baseline = true)] + public object EFCore() + { + var result = _queryable + .Include(_includeCompany) + .ThenInclude(_includeCountry); + + return result; + } + + [Benchmark] + public object Spec_MethodInvoke() + { + var evaluator = IncludeEvaluatorMethodInvoke.Instance; + var result = evaluator.Evaluate(_queryable, _spec); + + return result; + } + + [Benchmark] + public object Spec_v11() + { + var evaluator = IncludeEvaluator.Instance; + var result = evaluator.Evaluate(_queryable, _spec); + return result; + } + + private sealed class IncludeEvaluatorMethodInvoke : IEvaluator + { + private static readonly MethodInfo _includeMethodInfo = typeof(EntityFrameworkQueryableExtensions) + .GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.Include)) + .Single(mi => mi.IsPublic && mi.GetGenericArguments().Length == 2 + && mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IQueryable<>) + && mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>)); + + private static readonly MethodInfo _thenIncludeAfterReferenceMethodInfo + = typeof(EntityFrameworkQueryableExtensions) + .GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.ThenInclude)) + .Single(mi => mi.IsPublic && mi.GetGenericArguments().Length == 3 + && mi.GetParameters()[0].ParameterType.GenericTypeArguments[1].IsGenericParameter + && mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IIncludableQueryable<,>) + && mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>)); + + private static readonly MethodInfo _thenIncludeAfterEnumerableMethodInfo + = typeof(EntityFrameworkQueryableExtensions) + .GetTypeInfo().GetDeclaredMethods(nameof(EntityFrameworkQueryableExtensions.ThenInclude)) + .Single(mi => mi.IsPublic && mi.GetGenericArguments().Length == 3 + && !mi.GetParameters()[0].ParameterType.GenericTypeArguments[1].IsGenericParameter + && mi.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IIncludableQueryable<,>) + && mi.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Expression<>)); + + private IncludeEvaluatorMethodInvoke() { } + public static IncludeEvaluatorMethodInvoke Instance = new(); + + public IQueryable Evaluate(IQueryable source, Specification specification) where T : class + { + if (specification.IsEmpty) return source; + + foreach (var item in specification.Items) + { + if (item.Type == ItemType.IncludeString && item.Reference is string includeString) + { + source = source.Include(includeString); + } + } + + var isPreviousPropertyCollection = false; + + foreach (var item in specification.Items) + { + if (item.Type == ItemType.Include && item.Reference is LambdaExpression expr) + { + if (item.Bag == (int)IncludeType.Include) + { + source = BuildInclude(source, expr); + isPreviousPropertyCollection = IsCollection(expr.ReturnType); + } + else if (item.Bag == (int)IncludeType.ThenInclude) + { + source = BuildThenInclude(source, expr, isPreviousPropertyCollection); + isPreviousPropertyCollection = IsCollection(expr.ReturnType); + } + } + } + + return source; + } + + private static IQueryable BuildInclude(IQueryable source, LambdaExpression includeExpression) + + { + Debug.Assert(includeExpression is not null); + + var result = _includeMethodInfo + .MakeGenericMethod(typeof(T), includeExpression.ReturnType) + .Invoke(null, [source, includeExpression]); + + Debug.Assert(result is not null); + + return (IQueryable)result; + } + + + private static IQueryable BuildThenInclude(IQueryable source, LambdaExpression includeExpression, bool isPreviousPropertyCollection) + { + Debug.Assert(includeExpression is not null); + + var previousPropertyType = includeExpression.Parameters[0].Type; + + var mi = isPreviousPropertyCollection + ? _thenIncludeAfterEnumerableMethodInfo.MakeGenericMethod(typeof(T), previousPropertyType, includeExpression.ReturnType) + : _thenIncludeAfterReferenceMethodInfo.MakeGenericMethod(typeof(T), previousPropertyType, includeExpression.ReturnType); + + var result = mi.Invoke(null, [source, includeExpression]); + + Debug.Assert(result is not null); + + return (IQueryable)result; + } + + public static bool IsCollection(Type type) + { + // Exclude string, which implements IEnumerable but is not considered a collection + if (type == typeof(string)) + { + return false; + } + + return typeof(IEnumerable).IsAssignableFrom(type); + } + } +} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark7_LikeInMemoryEvaluator.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark7_LikeInMemoryEvaluator.cs new file mode 100644 index 0000000..aa6774b --- /dev/null +++ b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark7_LikeInMemoryEvaluator.cs @@ -0,0 +1,118 @@ +namespace QuerySpecification.Benchmarks; + +[MemoryDiagnoser] +public class Benchmark7_LikeInMemoryEvaluator +{ + private List _source = default!; + private CustomerSpec _specification = default!; + private LikeMemoryEvaluatorOriginal _evaluatorOriginal = default!; + private LikeMemoryEvaluatorV10 _evaluatorV10 = default!; + private LikeMemoryEvaluator _evaluatorV11 = default!; + + [GlobalSetup] + public void Setup() + { + _source = + [ + new(1, "axxa", "axya"), + new(2, "aaaa", "aaaa"), + new(3, "axxa", "axza"), + new(4, "aaaa", null), + new(5, "axxa", null), + .. Enumerable.Range(6, 1000).Select(x => new Customer(x, "axxa", "axya")) + ]; + + _specification = new CustomerSpec(); + _evaluatorV11 = LikeMemoryEvaluator.Instance; + + // We'll help out the old implementations by even providing ready-to-use list (usually that happens in the evaluators) + var likeExpressionsCompiled = _specification.LikeExpressionsCompiled.ToList(); + _evaluatorOriginal = new LikeMemoryEvaluatorOriginal(likeExpressionsCompiled); + _evaluatorV10 = new LikeMemoryEvaluatorV10(likeExpressionsCompiled); + } + + [Benchmark(Baseline = true)] + public int EvaluateOriginal() + { + var evaluator = _evaluatorOriginal; + var result = evaluator.Evaluate(_source, _specification); + return result.Count(); + } + + [Benchmark] + public int Evaluate_v10() + { + var evaluator = _evaluatorV10; + var result = evaluator.Evaluate(_source, _specification); + return result.Count(); + } + + [Benchmark] + public int Evaluate_v11() + { + var evaluator = _evaluatorV11; + var result = evaluator.Evaluate(_source, _specification); + return result.Count(); + } + + private record Customer(int Id, string FirstName, string? LastName); + private class CustomerSpec : Specification + { + public CustomerSpec() + { + Query + .Like(x => x.FirstName, "%xx%", 1) + .Like(x => x.LastName, "%xy%", 2) + .Like(x => x.LastName, "%xz%", 2); + } + } + + private sealed class LikeMemoryEvaluatorOriginal(List> likeExpressionsCompiled) + { + public IEnumerable Evaluate(IEnumerable source, Specification specification) + { + foreach (var likeGroup in likeExpressionsCompiled.GroupBy(x => x.Group)) + { + source = source.Where(x => likeGroup.Any(c => c.KeySelector(x)?.Like(c.Pattern) ?? false)); + } + + return source; + } + } + + private sealed class LikeMemoryEvaluatorV10(List> likeExpressionsCompiled) + { + public IEnumerable Evaluate(IEnumerable source, Specification specification) + { + var groups = likeExpressionsCompiled.GroupBy(x => x.Group).ToList(); + + foreach (var item in source) + { + var match = true; + foreach (var group in groups) + { + var matchOrGroup = false; + foreach (var like in group) + { + if (like.KeySelector(item)?.Like(like.Pattern) ?? false) + { + matchOrGroup = true; + break; + } + } + + if ((match = match && matchOrGroup) is false) + { + break; + } + } + + if (match) + { + yield return item; + } + } + } + } +} + diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark8_LikeInMemoryValidator.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark8_LikeInMemoryValidator.cs new file mode 100644 index 0000000..211ee5c --- /dev/null +++ b/tests/QuerySpecification.Benchmarks/Benchmarks/Benchmark8_LikeInMemoryValidator.cs @@ -0,0 +1,124 @@ +namespace QuerySpecification.Benchmarks; + +[MemoryDiagnoser] +public class Benchmark8_LikeInMemoryValidator +{ + private List _source = default!; + private CustomerSpec _specification = default!; + private LikeValidatorOriginal _validatorOriginal = default!; + private LikeValidatorV10 _validatorV10 = default!; + private LikeValidator _validatorV11 = default!; + + [GlobalSetup] + public void Setup() + { + _source = + [ + new(1, "axxa", "axya"), + new(2, "aaaa", "aaaa"), + new(3, "axxa", "axza"), + new(4, "aaaa", null), + new(5, "axxa", null), + .. Enumerable.Range(6, 1000).Select(x => new Customer(x, "axxa", "axya")) + ]; + + _specification = new CustomerSpec(); + _validatorV11 = LikeValidator.Instance; + + // We'll help out the old implementations by even providing ready-to-use list (usually that happens in the validators) + var likeExpressionsCompiled = _specification.LikeExpressionsCompiled.ToList(); + _validatorOriginal = new LikeValidatorOriginal(likeExpressionsCompiled); + _validatorV10 = new LikeValidatorV10(likeExpressionsCompiled); + } + + [Benchmark(Baseline = true)] + public bool ValidateOriginal() + { + var validator = _validatorOriginal; + + var result = false; + foreach (var item in _source) + { + result = validator.IsValid(item, _specification); + } + return result; + } + + [Benchmark] + public bool Validate_v10() + { + var validator = _validatorV10; + + var result = false; + foreach (var item in _source) + { + result = validator.IsValid(item, _specification); + } + return result; + } + + [Benchmark] + public bool Validate_v11() + { + var validator = _validatorV11; + + var result = false; + foreach (var item in _source) + { + result = validator.IsValid(item, _specification); + } + return result; + } + + private record Customer(int Id, string FirstName, string? LastName); + private class CustomerSpec : Specification + { + public CustomerSpec() + { + Query + .Like(x => x.FirstName, "%xx%", 1) + .Like(x => x.LastName, "%xy%", 2) + .Like(x => x.LastName, "%xz%", 2); + } + } + + private sealed class LikeValidatorOriginal(List> likeExpressionsCompiled) + { + public bool IsValid(T entity, Specification specification) + { + foreach (var likeGroup in likeExpressionsCompiled.GroupBy(x => x.Group)) + { + if (likeGroup.Any(c => c.KeySelector(entity)?.Like(c.Pattern) ?? false) == false) return false; + } + + return true; + } + } + + private sealed class LikeValidatorV10(List> likeExpressionsCompiled) + { + public bool IsValid(T entity, Specification specification) + { + var groups = likeExpressionsCompiled.GroupBy(x => x.Group); + + foreach (var group in groups) + { + var match = false; + foreach (var like in group) + { + if (like.KeySelector(entity)?.Like(like.Pattern) ?? false) + { + match = true; + break; + } + } + + if (match is false) + return false; + } + + return true; + } + } +} + diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/DbQueryBenchmark.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/DbQueryBenchmark.cs deleted file mode 100644 index dd2b3ef..0000000 --- a/tests/QuerySpecification.Benchmarks/Benchmarks/DbQueryBenchmark.cs +++ /dev/null @@ -1,54 +0,0 @@ -#pragma warning disable CA1822 // Mark members as static -namespace QuerySpecification.Benchmarks; - -// Benchmarks including roundtrip to the database. -[MemoryDiagnoser] -public class DbQueryBenchmark -{ - [GlobalSetup] - public async Task Setup() - { - await BenchmarkDbContext.SeedAsync(); - } - - [Benchmark(Baseline = true)] - public async Task EFIncludeExpression() - { - var id = 1; - using var context = new BenchmarkDbContext(); - - var result = await context - .Stores - .Where(x => x.Id == id) - .Include(x => x.Products) - .Include(x => x.Company).ThenInclude(x => x.Country) - .FirstAsync(); - - return result; - } - - [Benchmark] - public async Task SpecIncludeExpression() - { - var id = 1; - using var context = new BenchmarkDbContext(); - - var result = await context - .Stores - .WithSpecification(new StoreIncludeProductsSpec(id)) - .FirstAsync(); - - return result; - } - - private sealed class StoreIncludeProductsSpec : Specification - { - public StoreIncludeProductsSpec(int id) - { - Query - .Where(x => x.Id == id) - .Include(x => x.Products) - .Include(x => x.Company).ThenInclude(x => x.Country); - } - } -} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/ExpressionBenchmark.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/ExpressionBenchmark.cs deleted file mode 100644 index f9b87ba..0000000 --- a/tests/QuerySpecification.Benchmarks/Benchmarks/ExpressionBenchmark.cs +++ /dev/null @@ -1,49 +0,0 @@ -namespace QuerySpecification.Benchmarks; - -// Benchmarks measuring only building the IQueryable. -[MemoryDiagnoser] -public class ExpressionBenchmark -{ - private IQueryable _queryable = default!; - - [GlobalSetup] - public void Setup() - { - _queryable = new BenchmarkDbContext().Stores.AsQueryable(); - } - - [Benchmark(Baseline = true)] - public object EFIncludeExpression() - { - var id = 1; - - var result = _queryable - .Where(x => x.Id == id) - .Include(x => x.Products) - .Include(x => x.Company).ThenInclude(x => x.Country); - - return result; - } - - [Benchmark] - public object SpecIncludeExpression() - { - var id = 1; - - var result = _queryable - .WithSpecification(new StoreIncludeProductsSpec(id)); - - return result; - } - - private sealed class StoreIncludeProductsSpec : Specification - { - public StoreIncludeProductsSpec(int id) - { - Query - .Where(x => x.Id == id) - .Include(x => x.Products) - .Include(x => x.Company).ThenInclude(x => x.Country); - } - } -} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/LikeInMemoryBenchmark.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/LikeInMemoryBenchmark.cs deleted file mode 100644 index c59a4c4..0000000 --- a/tests/QuerySpecification.Benchmarks/Benchmarks/LikeInMemoryBenchmark.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.Reflection; - -namespace QuerySpecification.Benchmarks; - -// Benchmarks measuring the in-memory Like evaluator implementations. -[MemoryDiagnoser] -public class LikeInMemoryBenchmark -{ - public record Customer(int Id, string FirstName, string? LastName); - private class CustomerSpec : Specification - { - public CustomerSpec() - { - Query - .Like(x => x.FirstName, "%xx%", 1) - .Like(x => x.LastName, "%xy%", 2) - .Like(x => x.LastName, "%xz%", 2); - } - } - - private CustomerSpec _specification = default!; - private List _source = default!; - - [GlobalSetup] - public void Setup() - { - _specification = new CustomerSpec(); - _source = - [ - new(1, "axxa", "axya"), - new(2, "aaaa", "aaaa"), - new(3, "axxa", "axza"), - new(4, "aaaa", null), - new(5, "axxa", null), - .. Enumerable.Range(6, 1000).Select(x => new Customer(x, "axxa", "axya")) - ]; - } - - [Benchmark(Baseline = true)] - public List EvaluateOption1() - { - var source = _source.AsEnumerable(); - - foreach (var likeGroup in _specification.LikeExpressions.GroupBy(x => x.Group)) - { - source = source.Where(x => likeGroup.Any(c => c.KeySelectorFunc(x)?.Like(c.Pattern) ?? false)); - } - - return source.ToList(); - } - - [Benchmark] - public List EvaluateOption2() - { - var source = _source.AsEnumerable(); - - // Precompute the predicates for each group - var groupPredicates = _specification - .LikeExpressions - .GroupBy(x => x.Group) - .Select(group => new Func(x => group.Any(c => c.KeySelectorFunc(x)?.Like(c.Pattern) ?? false))) - .ToList(); - - // Apply all predicates to filter the source - var result = source.Where(x => groupPredicates.All(predicate => predicate(x))); - - return result.ToList(); - } - - [Benchmark] - public List EvaluateOption3() - { - var source = _source.AsEnumerable(); - - var result = Evaluate(_specification, source); - - static IEnumerable Evaluate(Specification spec, IEnumerable source) - { - var groups = spec.LikeExpressions.GroupBy(x => x.Group).ToList(); - - foreach (var item in source) - { - var match = true; - foreach (var group in groups) - { - var matchOrGroup = false; - foreach (var like in group) - { - if (like.KeySelectorFunc(item)?.Like(like.Pattern) ?? false) - { - matchOrGroup = true; - break; - } - } - - if ((match = match && matchOrGroup) is false) - { - break; - } - } - - if (match) - { - yield return item; - } - } - } - - return result.ToList(); - } - - [Benchmark] - public List EvaluateOption4() - { - var source = _source.AsEnumerable(); - - var result = Evaluate(_specification, source); - - static IEnumerable Evaluate(Specification spec, IEnumerable source) - { - // Precompute the predicates for each group - var groupPredicates = spec - .LikeExpressions - .GroupBy(x => x.Group) - .Select(group => new Func(x => group.Any(c => c.KeySelectorFunc(x)?.Like(c.Pattern) ?? false))) - .ToList(); - - foreach (var item in source) - { - var match = true; - foreach (var groupPredicate in groupPredicates) - { - if ((match = match && groupPredicate(item)) is false) - { - break; - } - } - - if (match) - { - yield return item; - } - } - } - - return result.ToList(); - } -} - -public static class LikeExtensions -{ - private static readonly MethodInfo _likeMethod = typeof(LikeMemoryEvaluator).Assembly - .GetType("Pozitron.QuerySpecification.LikeExtension")! - .GetMethod("Like", BindingFlags.Public | BindingFlags.Static)!; - - // I don't want to expose the internal types to Benchmark project. - // There is overhead here with reflection, but it affects all benchmarks equally. - public static bool Like(this string input, string pattern) - { - bool result = (bool)_likeMethod!.Invoke(null, [input, pattern])!; - return result; - } -} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/LikeValidatorBenchmark.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/LikeValidatorBenchmark.cs deleted file mode 100644 index d2e3198..0000000 --- a/tests/QuerySpecification.Benchmarks/Benchmarks/LikeValidatorBenchmark.cs +++ /dev/null @@ -1,95 +0,0 @@ -namespace QuerySpecification.Benchmarks; - -// Benchmarks measuring the in-memory Like evaluator implementations. -[MemoryDiagnoser] -public class LikeValidatorBenchmark -{ - public record Customer(int Id, string FirstName, string? LastName); - private class CustomerSpec : Specification - { - public CustomerSpec() - { - Query - .Like(x => x.FirstName, "%xx%", 1) - .Like(x => x.LastName, "%xy%", 2) - .Like(x => x.LastName, "%xz%", 2); - } - } - - private CustomerSpec _specification = default!; - private Customer _customer = default!; - - [GlobalSetup] - public void Setup() - { - _specification = new CustomerSpec(); - _customer = new(1, "axxa", "axza"); - } - - [Benchmark(Baseline = true)] - public bool ValidateOption1() - { - var entity = _customer; - - var groups = _specification.LikeExpressions.GroupBy(x => x.Group); - - foreach (var likeGroup in groups) - { - if (likeGroup.Any(c => c.KeySelectorFunc(entity)?.Like(c.Pattern) ?? false) == false) return false; - } - - return true; - } - - [Benchmark] - public bool ValidateOption2() - { - var entity = _customer; - - var groups = _specification.LikeExpressions.GroupBy(x => x.Group).ToList(); - - foreach (var group in groups) - { - var match = false; - foreach (var like in group) - { - if (like.KeySelectorFunc(entity)?.Like(like.Pattern) ?? false) - { - match = true; - break; - } - } - - if (match is false) - return false; - } - - return true; - } - - [Benchmark] - public bool ValidateOption3() - { - var entity = _customer; - - var groups = _specification.LikeExpressions.GroupBy(x => x.Group); - - foreach (var group in groups) - { - var match = false; - foreach (var like in group) - { - if (like.KeySelectorFunc(entity)?.Like(like.Pattern) ?? false) - { - match = true; - break; - } - } - - if (match is false) - return false; - } - - return true; - } -} diff --git a/tests/QuerySpecification.Benchmarks/Benchmarks/QueryStringBenchmark.cs b/tests/QuerySpecification.Benchmarks/Benchmarks/QueryStringBenchmark.cs deleted file mode 100644 index aaf4837..0000000 --- a/tests/QuerySpecification.Benchmarks/Benchmarks/QueryStringBenchmark.cs +++ /dev/null @@ -1,89 +0,0 @@ -#pragma warning disable CA1822 // Mark members as static -namespace QuerySpecification.Benchmarks; - -// Benchmarks excluding roundtrip to the database, just evaluating the query string. -[MemoryDiagnoser] -public class QueryStringBenchmark -{ - [Benchmark(Baseline = true)] - public string EFIncludeExpression() - { - var id = 1; - using var context = new BenchmarkDbContext(); - - var queryString = context - .Stores - .Where(x => x.Id == id) - .Include(x => x.Products) - .Include(x => x.Company).ThenInclude(x => x.Country) - .ToQueryString(); - - return queryString; - } - - [Benchmark] - public string SpecIncludeExpression() - { - var id = 1; - using var context = new BenchmarkDbContext(); - - var queryString = context - .Stores - .WithSpecification(new StoreIncludeProductsSpec(id)) - .ToQueryString(); - - return queryString; - } - - [Benchmark] - public string EFIncludeString() - { - var id = 1; - using var context = new BenchmarkDbContext(); - - var queryString = context - .Stores - .Where(x => x.Id == id) - .Include(nameof(Store.Products)) - .Include($"{nameof(Store.Company)}.{nameof(Company.Country)}") - .ToQueryString(); - - return queryString; - } - - [Benchmark] - public string SpecIncludeString() - { - var id = 1; - using var context = new BenchmarkDbContext(); - - var queryString = context - .Stores - .WithSpecification(new StoreIncludeProductsAsStringSpec(id)) - .ToQueryString(); - - return queryString; - } - - private sealed class StoreIncludeProductsSpec : Specification - { - public StoreIncludeProductsSpec(int id) - { - Query - .Where(x => x.Id == id) - .Include(x => x.Products) - .Include(x => x.Company).ThenInclude(x => x.Country); - } - } - - private sealed class StoreIncludeProductsAsStringSpec : Specification - { - public StoreIncludeProductsAsStringSpec(int id) - { - Query - .Where(x => x.Id == id) - .Include(nameof(Store.Products)) - .Include($"{nameof(Store.Company)}.{nameof(Company.Country)}"); - } - } -} diff --git a/tests/QuerySpecification.Benchmarks/Data/BenchmarkDbContext.cs b/tests/QuerySpecification.Benchmarks/Data/BenchmarkDbContext.cs index ced6443..eff32f7 100644 --- a/tests/QuerySpecification.Benchmarks/Data/BenchmarkDbContext.cs +++ b/tests/QuerySpecification.Benchmarks/Data/BenchmarkDbContext.cs @@ -14,27 +14,34 @@ public static async Task SeedAsync() if (!created) return; - var store = new Store + var company = new Company() { - Name = "Store 1", - Company = new() + Name = "Company 1", + Country = new() { - Name = "Company 1", - Country = new() - { - Name = "Country 1" - } - }, + Name = "Country 1" + } + }; + var store1 = new Store + { + Name = "Store 1", + Company = company, + Products = + [ + new() { Name = "Product 1" } + ] + }; + var store2 = new Store + { + Name = "Store 2", + Company = company, Products = [ - new() - { - Name = "Product 1" - } + new() { Name = "Product 2" } ] }; - context.Add(store); + context.AddRange(store1, store2); await context.SaveChangesAsync(); } } diff --git a/tests/QuerySpecification.Benchmarks/GloblUsings.cs b/tests/QuerySpecification.Benchmarks/GloblUsings.cs index 60395a9..76cd24e 100644 --- a/tests/QuerySpecification.Benchmarks/GloblUsings.cs +++ b/tests/QuerySpecification.Benchmarks/GloblUsings.cs @@ -1,3 +1,5 @@ global using BenchmarkDotNet.Attributes; global using Microsoft.EntityFrameworkCore; global using Pozitron.QuerySpecification; +global using BenchmarkDotNet.Running; +global using QuerySpecification.Benchmarks; diff --git a/tests/QuerySpecification.Benchmarks/Program.cs b/tests/QuerySpecification.Benchmarks/Program.cs index 3ee63c4..575e476 100644 --- a/tests/QuerySpecification.Benchmarks/Program.cs +++ b/tests/QuerySpecification.Benchmarks/Program.cs @@ -1,18 +1,2 @@ -using BenchmarkDotNet.Running; - + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); - -//var benchmark = new QueryStringBenchmark(); - -//var x1 = benchmark.EFIncludeExpression(); -//var x2 = benchmark.EFIncludeString(); -//var x3 = benchmark.SpecIncludeExpression(); -//var x4 = benchmark.SpecIncludeString(); - -//Console.WriteLine(x1); -//Console.WriteLine(); -//Console.WriteLine(x2); -//Console.WriteLine(); -//Console.WriteLine(x3); -//Console.WriteLine(); -//Console.WriteLine(x4); diff --git a/tests/QuerySpecification.Benchmarks/QuerySpecification.Benchmarks.csproj b/tests/QuerySpecification.Benchmarks/QuerySpecification.Benchmarks.csproj index f171cb0..d1acb4e 100644 --- a/tests/QuerySpecification.Benchmarks/QuerySpecification.Benchmarks.csproj +++ b/tests/QuerySpecification.Benchmarks/QuerySpecification.Benchmarks.csproj @@ -8,6 +8,10 @@ false + + 1701;1702;CA1822 + + diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/IncludeEvaluatorTests.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/IncludeEvaluatorTests.cs index 031e4e7..b114e84 100644 --- a/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/IncludeEvaluatorTests.cs +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/IncludeEvaluatorTests.cs @@ -10,7 +10,6 @@ public void QueriesMatch_GivenIncludeExpressions() { var spec = new Specification(); spec.Query - .Include(nameof(Address)) .Include(x => x.Products.Where(x => x.Id > 10)) .ThenInclude(x => x.Images) .Include(x => x.Company) @@ -21,7 +20,6 @@ public void QueriesMatch_GivenIncludeExpressions() .ToQueryString(); var expected = DbContext.Stores - .Include(nameof(Address)) .Include(x => x.Products.Where(x => x.Id > 10)) .ThenInclude(x => x.Images) .Include(x => x.Company) diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/IncludeStringEvaluatorTests.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/IncludeStringEvaluatorTests.cs new file mode 100644 index 0000000..33b4634 --- /dev/null +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/IncludeStringEvaluatorTests.cs @@ -0,0 +1,45 @@ +namespace Tests.Evaluators; + +[Collection("SharedCollection")] +public class IncludeStringEvaluatorTests(TestFactory factory) : IntegrationTest(factory) +{ + private static readonly IncludeStringEvaluator _evaluator = IncludeStringEvaluator.Instance; + + [Fact] + public void QueriesMatch_GivenIncludeString() + { + var spec = new Specification(); + spec.Query + .Include(nameof(Address)); + + var actual = _evaluator + .Evaluate(DbContext.Stores, spec) + .ToQueryString(); + + var expected = DbContext.Stores + .Include(nameof(Address)) + .ToQueryString(); + + actual.Should().Be(expected); + } + + [Fact] + public void QueriesMatch_GivenMultipleIncludeStrings() + { + var spec = new Specification(); + spec.Query + .Include(nameof(Address)) + .Include($"{nameof(Company)}.{nameof(Country)}"); + + var actual = _evaluator + .Evaluate(DbContext.Stores, spec) + .ToQueryString(); + + var expected = DbContext.Stores + .Include(nameof(Address)) + .Include($"{nameof(Company)}.{nameof(Country)}") + .ToQueryString(); + + actual.Should().Be(expected); + } +} diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/LikeEvaluatorTests.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/LikeEvaluatorTests.cs index 3544e00..fb11033 100644 --- a/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/LikeEvaluatorTests.cs +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/LikeEvaluatorTests.cs @@ -6,26 +6,65 @@ public class LikeEvaluatorTests(TestFactory factory) : IntegrationTest(factory) private static readonly LikeEvaluator _evaluator = LikeEvaluator.Instance; [Fact] - public void QueriesMatch_GivenLikeExpressions() + public void QueriesMatch_GivenNoLike() { - var storeTerm = "ab"; - var companyTerm = "ab"; - var streetTerm = "ab"; + var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0); + + var actual = _evaluator.Evaluate(DbContext.Stores, spec) + .ToQueryString(); + + var expected = DbContext.Stores + .ToQueryString(); + + actual.Should().Be(expected); + } + + [Fact] + public void QueriesMatch_GivenSingleLike() + { + var storeTerm = "ab1"; + + var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0) + .Like(x => x.Name, $"%{storeTerm}%"); + + var actual = _evaluator.Evaluate(DbContext.Stores, spec) + .ToQueryString(); + + var expected = DbContext.Stores + .Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%")) + .ToQueryString(); + + actual.Should().Be(expected); + } + + [Fact] + public void QueriesMatch_GivenMultipleLike() + { + var storeTerm = "ab1"; + var companyTerm = "ab2"; + var countryTerm = "ab3"; + var streetTerm = "ab4"; var spec = new Specification(); spec.Query + .Where(x => x.Id > 0) .Like(x => x.Name, $"%{storeTerm}%") .Like(x => x.Company.Name, $"%{companyTerm}%") + .Like(x => x.Company.Country.Name, $"%{countryTerm}%", 3) .Like(x => x.Address.Street, $"%{streetTerm}%", 2); var actual = _evaluator.Evaluate(DbContext.Stores, spec) - .ToQueryString() - .Replace("__likeExpression_Pattern_", "__Format_"); //expr parameter names are different + .ToQueryString(); var expected = DbContext.Stores .Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%") || EF.Functions.Like(x.Company.Name, $"%{companyTerm}%")) .Where(x => EF.Functions.Like(x.Address.Street, $"%{streetTerm}%")) + .Where(x => EF.Functions.Like(x.Company.Country.Name, $"%{countryTerm}%")) .ToQueryString(); actual.Should().Be(expected); diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Extensions/Extensions_Like.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/LikeExtensionTests.cs similarity index 62% rename from tests/QuerySpecification.EntityFrameworkCore.Tests/Extensions/Extensions_Like.cs rename to tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/LikeExtensionTests.cs index feb93e5..a5b13d5 100644 --- a/tests/QuerySpecification.EntityFrameworkCore.Tests/Extensions/Extensions_Like.cs +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/LikeExtensionTests.cs @@ -1,13 +1,13 @@ -namespace Tests.Extensions; +namespace Tests.Evaluators; [Collection("SharedCollection")] -public class Extensions_Like(TestFactory factory) : IntegrationTest(factory) +public class LikeExtensionTests(TestFactory factory) : IntegrationTest(factory) { [Fact] public void QueriesMatch_GivenSpecWithMultipleLike() { - var storeTerm = "ab"; - var companyTerm = "ab"; + var storeTerm = "ab1"; + var companyTerm = "ab2"; var spec = new Specification(); spec.Query @@ -15,9 +15,8 @@ public void QueriesMatch_GivenSpecWithMultipleLike() .Like(x22 => x22.Company.Name, $"%{companyTerm}%"); var actual = DbContext.Stores - .Like(spec.LikeExpressions) - .ToQueryString() - .Replace("__likeExpression_Pattern_", "__Format_"); //expr parameter names are different + .ApplyLikesAsOrGroup(spec.Items) + .ToQueryString(); var expected = DbContext.Stores .Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%") @@ -33,9 +32,8 @@ public void QueriesMatch_GivenEmptySpec() var spec = new Specification(); var actual = DbContext.Stores - .Like(spec.LikeExpressions) - .ToQueryString() - .Replace("__likeExpression_Pattern_", "__Format_"); //expr parameter names are different + .ApplyLikesAsOrGroup(spec.Items) + .ToQueryString(); var expected = DbContext.Stores .ToQueryString(); diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Extensions/ParameterReplacerVisitorTests.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/ParameterReplacerVisitorTests.cs similarity index 67% rename from tests/QuerySpecification.EntityFrameworkCore.Tests/Extensions/ParameterReplacerVisitorTests.cs rename to tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/ParameterReplacerVisitorTests.cs index eafa66b..9a8df7e 100644 --- a/tests/QuerySpecification.EntityFrameworkCore.Tests/Extensions/ParameterReplacerVisitorTests.cs +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/ParameterReplacerVisitorTests.cs @@ -1,11 +1,11 @@ using System.Linq.Expressions; -namespace Tests.Extensions; +namespace Tests.Evaluators; public class ParameterReplacerVisitorTests { [Fact] - public void Replace_ReturnsExpressionWithReplacedParameter() + public void ReturnsExpressionWithReplacedParameter() { Expression> expected = (y, z) => y == 1; @@ -13,7 +13,8 @@ public void Replace_ReturnsExpressionWithReplacedParameter() var oldParameter = expression.Parameters[0]; var newExpression = Expression.Parameter(typeof(int), "y"); - var result = ParameterReplacerVisitor.Replace(expression, oldParameter, newExpression); + var visitor = new ParameterReplacerVisitor(oldParameter, newExpression); + var result = visitor.Visit(expression); result.ToString().Should().Be(expected.ToString()); } diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/SpecificationEvaluatorTests.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/SpecificationEvaluatorTests.cs index 1010a86..871cd33 100644 --- a/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/SpecificationEvaluatorTests.cs +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Evaluators/SpecificationEvaluatorTests.cs @@ -35,18 +35,33 @@ public void ThrowsSelectorNotFoundException_GivenNoSelector() sut.Should().Throw(); } + // TODO: We should allow overwriting. Think about this. [fatii, 26/10/2024] + //[Fact] + //public void ThrowsConcurrentSelectorsException_GivenBothSelectAndSelectMany() + //{ + // var spec = new Specification(); + // spec.Query + // .Select(x => x.Name); + // spec.Query + // .SelectMany(x => x.Products.Select(x => x.Name)); + + // var sut = () => _evaluator.Evaluate(DbContext.Stores, spec); + + // sut.Should().Throw(); + //} + [Fact] - public void ThrowsConcurrentSelectorsException_GivenBothSelectAndSelectMany() + public void GivenEmptySpec() { - var spec = new Specification(); - spec.Query - .Select(x => x.Name); - spec.Query - .SelectMany(x => x.Products.Select(x => x.Name)); + var spec = new Specification(); - var sut = () => _evaluator.Evaluate(DbContext.Stores, spec); + var actual = _evaluator.Evaluate(DbContext.Stores, spec) + .ToQueryString(); - sut.Should().Throw(); + var expected = DbContext.Stores + .ToQueryString(); + + actual.Should().Be(expected); } [Fact] @@ -54,9 +69,9 @@ public void GivenFullQuery() { var id = 2; var name = "Store1"; - var storeTerm = "ab"; - var companyTerm = "ab"; - var streetTerm = "ab"; + var storeTerm = "ab1"; + var companyTerm = "ab2"; + var streetTerm = "ab3"; var spec = new Specification(); spec.Query @@ -77,8 +92,7 @@ public void GivenFullQuery() .IgnoreQueryFilters(); var actual = _evaluator.Evaluate(DbContext.Stores, spec) - .ToQueryString() - .Replace("__likeExpression_Pattern_", "__Format_"); //like parameter names are different + .ToQueryString(); // The expression in the spec are applied in a predefined order. var expected = DbContext.Stores @@ -108,9 +122,9 @@ public void GivenExpressionsInRandomOrder() { var id = 2; var name = "Store1"; - var storeTerm = "ab"; - var companyTerm = "ab"; - var streetTerm = "ab"; + var storeTerm = "ab1"; + var companyTerm = "ab2"; + var streetTerm = "ab3"; var spec = new Specification(); spec.Query @@ -131,8 +145,7 @@ public void GivenExpressionsInRandomOrder() .IgnoreQueryFilters(); var actual = _evaluator.Evaluate(DbContext.Stores, spec) - .ToQueryString() - .Replace("__likeExpression_Pattern_", "__Format_"); //like parameter names are different + .ToQueryString(); // The expression in the spec are applied in a predefined order. var expected = DbContext.Stores @@ -162,9 +175,9 @@ public void GivenFullQueryWithSelect() { var id = 2; var name = "Store1"; - var storeTerm = "ab"; - var companyTerm = "ab"; - var streetTerm = "ab"; + var storeTerm = "ab1"; + var companyTerm = "ab2"; + var streetTerm = "ab3"; var spec = new Specification(); spec.Query @@ -186,8 +199,7 @@ public void GivenFullQueryWithSelect() .Select(x => x.Name); var actual = _evaluator.Evaluate(DbContext.Stores, spec) - .ToQueryString() - .Replace("__likeExpression_Pattern_", "__Format_"); //like parameter names are different + .ToQueryString(); // The expression in the spec are applied in a predefined order. var expected = DbContext.Stores @@ -218,9 +230,9 @@ public void GivenFullQueryWithSelectMany() { var id = 2; var name = "Store1"; - var storeTerm = "ab"; - var companyTerm = "ab"; - var streetTerm = "ab"; + var storeTerm = "ab1"; + var companyTerm = "ab2"; + var streetTerm = "ab3"; var spec = new Specification(); spec.Query @@ -242,8 +254,7 @@ public void GivenFullQueryWithSelectMany() .SelectMany(x => x.Products.Select(x => x.Name)); var actual = _evaluator.Evaluate(DbContext.Stores, spec) - .ToQueryString() - .Replace("__likeExpression_Pattern_", "__Format_"); //like parameter names are different + .ToQueryString(); // The expression in the spec are applied in a predefined order. var expected = DbContext.Stores @@ -348,9 +359,9 @@ public void Constructor_SetsProvidedEvaluators() var evaluator = new SpecificationEvaluator(evaluators); - var state = EvaluatorsOf(evaluator); - state.Should().HaveSameCount(evaluators); - state.Should().Equal(evaluators); + var result = EvaluatorsOf(evaluator); + result.Should().HaveSameCount(evaluators); + result.Should().Equal(evaluators); } [Fact] @@ -358,18 +369,19 @@ public void DerivedSpecificationEvaluatorCanAlterDefaultEvaluator() { var evaluator = new SpecificationEvaluatorDerived(); - var state = EvaluatorsOf(evaluator); - state.Should().HaveCount(10); - state[0].Should().BeOfType(); - state[1].Should().BeOfType(); - state[2].Should().BeOfType(); - state[3].Should().BeOfType(); - state[4].Should().BeOfType(); - state[5].Should().BeOfType(); - state[6].Should().BeOfType(); - state[7].Should().BeOfType(); - state[8].Should().BeOfType(); - state[9].Should().BeOfType(); + var result = EvaluatorsOf(evaluator); + result.Should().HaveCount(11); + result[0].Should().BeOfType(); + result[1].Should().BeOfType(); + result[2].Should().BeOfType(); + result[3].Should().BeOfType(); + result[4].Should().BeOfType(); + result[5].Should().BeOfType(); + result[6].Should().BeOfType(); + result[7].Should().BeOfType(); + result[8].Should().BeOfType(); + result[9].Should().BeOfType(); + result[10].Should().BeOfType(); } private class SpecificationEvaluatorDerived : SpecificationEvaluator diff --git a/tests/QuerySpecification.EntityFrameworkCore.Tests/Extensions/Extensions_WithSpecification.cs b/tests/QuerySpecification.EntityFrameworkCore.Tests/Extensions/Extensions_WithSpecification.cs index a4c8b6c..74083b7 100644 --- a/tests/QuerySpecification.EntityFrameworkCore.Tests/Extensions/Extensions_WithSpecification.cs +++ b/tests/QuerySpecification.EntityFrameworkCore.Tests/Extensions/Extensions_WithSpecification.cs @@ -10,9 +10,9 @@ public void QueriesMatch_GivenFullQuery() { var id = 1; var name = "Store1"; - var storeTerm = "ab"; - var companyTerm = "ab"; - var streetTerm = "ab"; + var storeTerm = "ab1"; + var companyTerm = "ab2"; + var streetTerm = "ab3"; var spec = new Specification(); spec.Query @@ -34,8 +34,7 @@ public void QueriesMatch_GivenFullQuery() var actual = DbContext.Stores .WithSpecification(spec) - .ToQueryString() - .Replace("__likeExpression_Pattern_", "__Format_"); //like parameter names are different + .ToQueryString(); // The expression in the spec are applied in a predefined order. var expected = DbContext.Stores @@ -65,9 +64,9 @@ public void QueriesMatch_GivenFullQueryWithSelect() { var id = 1; var name = "Store1"; - var storeTerm = "ab"; - var companyTerm = "ab"; - var streetTerm = "ab"; + var storeTerm = "ab1"; + var companyTerm = "ab2"; + var streetTerm = "ab3"; var spec = new Specification(); spec.Query @@ -90,8 +89,7 @@ public void QueriesMatch_GivenFullQueryWithSelect() var actual = DbContext.Stores .WithSpecification(spec) - .ToQueryString() - .Replace("__likeExpression_Pattern_", "__Format_"); //like parameter names are different + .ToQueryString(); // The expression in the spec are applied in a predefined order. var expected = DbContext.Stores @@ -122,9 +120,9 @@ public void QueriesMatch_GivenFullQueryWithSelectMany() { var id = 1; var name = "Store1"; - var storeTerm = "ab"; - var companyTerm = "ab"; - var streetTerm = "ab"; + var storeTerm = "ab1"; + var companyTerm = "ab2"; + var streetTerm = "ab3"; var spec = new Specification(); spec.Query @@ -147,8 +145,7 @@ public void QueriesMatch_GivenFullQueryWithSelectMany() var actual = DbContext.Stores .WithSpecification(spec) - .ToQueryString() - .Replace("__likeExpression_Pattern_", "__Format_"); //like parameter names are different + .ToQueryString(); // The expression in the spec are applied in a predefined order. var expected = DbContext.Stores diff --git a/tests/QuerySpecification.Tests/Builders/IncludableBuilderExtensions_ThenInclude.cs b/tests/QuerySpecification.Tests/Builders/IncludableBuilderExtensions_ThenInclude.cs index 01c0097..c161bde 100644 --- a/tests/QuerySpecification.Tests/Builders/IncludableBuilderExtensions_ThenInclude.cs +++ b/tests/QuerySpecification.Tests/Builders/IncludableBuilderExtensions_ThenInclude.cs @@ -33,9 +33,9 @@ public void DoesNothing_GivenIncludeThenWithFalseCondition() .ThenInclude(x => x.Contacts, false); spec1.IncludeExpressions.Should().HaveCount(4); - spec1.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeTypeEnum.Include)); + spec1.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeType.Include)); spec2.IncludeExpressions.Should().HaveCount(4); - spec2.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeTypeEnum.Include)); + spec2.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeType.Include)); } [Fact] @@ -109,9 +109,9 @@ public void DoesNothing_GivenIncludeThenWithDiscardedNestedChain() .ThenInclude(x => x.Phone); spec1.IncludeExpressions.Should().HaveCount(4); - spec1.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeTypeEnum.Include)); + spec1.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeType.Include)); spec2.IncludeExpressions.Should().HaveCount(4); - spec2.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeTypeEnum.Include)); + spec2.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeType.Include)); } [Fact] @@ -131,12 +131,12 @@ public void AddsIncludeThen_GivenIncludeThen() spec1.IncludeExpressions.Should().HaveCount(2); spec1.IncludeExpressions.Last().LambdaExpression.Should().BeSameAs(expr); - spec1.IncludeExpressions.First().Type.Should().Be(IncludeTypeEnum.Include); - spec1.IncludeExpressions.Last().Type.Should().Be(IncludeTypeEnum.ThenInclude); + spec1.IncludeExpressions.First().Type.Should().Be(IncludeType.Include); + spec1.IncludeExpressions.Last().Type.Should().Be(IncludeType.ThenInclude); spec2.IncludeExpressions.Should().HaveCount(2); spec2.IncludeExpressions.Last().LambdaExpression.Should().BeSameAs(expr); - spec2.IncludeExpressions.First().Type.Should().Be(IncludeTypeEnum.Include); - spec2.IncludeExpressions.Last().Type.Should().Be(IncludeTypeEnum.ThenInclude); + spec2.IncludeExpressions.First().Type.Should().Be(IncludeType.Include); + spec2.IncludeExpressions.Last().Type.Should().Be(IncludeType.ThenInclude); } [Fact] @@ -173,10 +173,10 @@ public void AddsIncludeThen_GivenMultipleIncludeThen() .ThenInclude(x => x.Phone); spec1.IncludeExpressions.Should().HaveCount(12); - spec1.IncludeExpressions.OrderBy(x => x.Type).Take(4).Should().AllSatisfy(x => x.Type.Should().Be(IncludeTypeEnum.Include)); - spec1.IncludeExpressions.OrderBy(x => x.Type).Skip(4).Should().AllSatisfy(x => x.Type.Should().Be(IncludeTypeEnum.ThenInclude)); + spec1.IncludeExpressions.OrderBy(x => x.Type).Take(4).Should().AllSatisfy(x => x.Type.Should().Be(IncludeType.Include)); + spec1.IncludeExpressions.OrderBy(x => x.Type).Skip(4).Should().AllSatisfy(x => x.Type.Should().Be(IncludeType.ThenInclude)); spec2.IncludeExpressions.Should().HaveCount(12); - spec2.IncludeExpressions.OrderBy(x => x.Type).Take(4).Should().AllSatisfy(x => x.Type.Should().Be(IncludeTypeEnum.Include)); - spec2.IncludeExpressions.OrderBy(x => x.Type).Skip(4).Should().AllSatisfy(x => x.Type.Should().Be(IncludeTypeEnum.ThenInclude)); + spec2.IncludeExpressions.OrderBy(x => x.Type).Take(4).Should().AllSatisfy(x => x.Type.Should().Be(IncludeType.Include)); + spec2.IncludeExpressions.OrderBy(x => x.Type).Skip(4).Should().AllSatisfy(x => x.Type.Should().Be(IncludeType.ThenInclude)); } } diff --git a/tests/QuerySpecification.Tests/Builders/OrderedBuilderExtensions_ThenBy.cs b/tests/QuerySpecification.Tests/Builders/OrderedBuilderExtensions_ThenBy.cs index aa92efc..cbe4d7f 100644 --- a/tests/QuerySpecification.Tests/Builders/OrderedBuilderExtensions_ThenBy.cs +++ b/tests/QuerySpecification.Tests/Builders/OrderedBuilderExtensions_ThenBy.cs @@ -18,9 +18,9 @@ public void DoesNothing_GivenThenByWithFalseCondition() .ThenBy(x => x.LastName, false); spec1.OrderExpressions.Should().ContainSingle(); - spec1.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderBy)); + spec1.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderBy)); spec2.OrderExpressions.Should().ContainSingle(); - spec2.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderBy)); + spec2.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderBy)); } [Fact] @@ -56,9 +56,9 @@ public void DoesNothing_GivenThenByWithDiscardedNestedChain() .ThenBy(x => x.Email); spec1.OrderExpressions.Should().ContainSingle(); - spec1.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderBy)); + spec1.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderBy)); spec2.OrderExpressions.Should().ContainSingle(); - spec2.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderBy)); + spec2.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderBy)); } [Fact] @@ -78,12 +78,12 @@ public void AddsThenBy_GivenThenBy() spec1.OrderExpressions.Should().HaveCount(2); spec1.OrderExpressions.Last().KeySelector.Should().BeSameAs(expr); - spec1.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec1.OrderExpressions.Last().OrderType.Should().Be(OrderTypeEnum.ThenBy); + spec1.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec1.OrderExpressions.Last().Type.Should().Be(OrderType.ThenBy); spec2.OrderExpressions.Should().HaveCount(2); spec2.OrderExpressions.Last().KeySelector.Should().BeSameAs(expr); - spec2.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec2.OrderExpressions.Last().OrderType.Should().Be(OrderTypeEnum.ThenBy); + spec2.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec2.OrderExpressions.Last().Type.Should().Be(OrderType.ThenBy); } [Fact] @@ -102,11 +102,11 @@ public void AddsThenBy_GivenMultipleThenBy() .ThenBy(x => x.Email); spec1.OrderExpressions.Should().HaveCount(3); - spec1.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec1.OrderExpressions.Skip(1).Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.ThenBy)); + spec1.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec1.OrderExpressions.Skip(1).Should().AllSatisfy(x => x.Type.Should().Be(OrderType.ThenBy)); spec2.OrderExpressions.Should().HaveCount(3); - spec2.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec2.OrderExpressions.Skip(1).Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.ThenBy)); + spec2.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec2.OrderExpressions.Skip(1).Should().AllSatisfy(x => x.Type.Should().Be(OrderType.ThenBy)); } [Fact] @@ -125,12 +125,12 @@ public void AddsThenBy_GivenThenByThenByDescending() .ThenByDescending(x => x.Email); spec1.OrderExpressions.Should().HaveCount(3); - spec1.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec1.OrderExpressions.Skip(1).First().OrderType.Should().Be(OrderTypeEnum.ThenBy); - spec1.OrderExpressions.Last().OrderType.Should().Be(OrderTypeEnum.ThenByDescending); + spec1.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec1.OrderExpressions.Skip(1).First().Type.Should().Be(OrderType.ThenBy); + spec1.OrderExpressions.Last().Type.Should().Be(OrderType.ThenByDescending); spec2.OrderExpressions.Should().HaveCount(3); - spec2.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec2.OrderExpressions.Skip(1).First().OrderType.Should().Be(OrderTypeEnum.ThenBy); - spec2.OrderExpressions.Last().OrderType.Should().Be(OrderTypeEnum.ThenByDescending); + spec2.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec2.OrderExpressions.Skip(1).First().Type.Should().Be(OrderType.ThenBy); + spec2.OrderExpressions.Last().Type.Should().Be(OrderType.ThenByDescending); } } diff --git a/tests/QuerySpecification.Tests/Builders/OrderedBuilderExtensions_ThenByDescending.cs b/tests/QuerySpecification.Tests/Builders/OrderedBuilderExtensions_ThenByDescending.cs index 3c29473..941d271 100644 --- a/tests/QuerySpecification.Tests/Builders/OrderedBuilderExtensions_ThenByDescending.cs +++ b/tests/QuerySpecification.Tests/Builders/OrderedBuilderExtensions_ThenByDescending.cs @@ -18,9 +18,9 @@ public void DoesNothing_GivenThenByDescendingWithFalseCondition() .ThenByDescending(x => x.LastName, false); spec1.OrderExpressions.Should().ContainSingle(); - spec1.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderBy)); + spec1.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderBy)); spec2.OrderExpressions.Should().ContainSingle(); - spec2.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderBy)); + spec2.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderBy)); } [Fact] @@ -56,9 +56,9 @@ public void DoesNothing_GivenThenByDescendingWithDiscardedNestedChain() .ThenByDescending(x => x.Email); spec1.OrderExpressions.Should().ContainSingle(); - spec1.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderBy)); + spec1.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderBy)); spec2.OrderExpressions.Should().ContainSingle(); - spec2.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderBy)); + spec2.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderBy)); } [Fact] @@ -78,12 +78,12 @@ public void AddsThenByDescending_GivenThenByDescending() spec1.OrderExpressions.Should().HaveCount(2); spec1.OrderExpressions.Last().KeySelector.Should().BeSameAs(expr); - spec1.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec1.OrderExpressions.Last().OrderType.Should().Be(OrderTypeEnum.ThenByDescending); + spec1.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec1.OrderExpressions.Last().Type.Should().Be(OrderType.ThenByDescending); spec2.OrderExpressions.Should().HaveCount(2); spec2.OrderExpressions.Last().KeySelector.Should().BeSameAs(expr); - spec2.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec2.OrderExpressions.Last().OrderType.Should().Be(OrderTypeEnum.ThenByDescending); + spec2.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec2.OrderExpressions.Last().Type.Should().Be(OrderType.ThenByDescending); } [Fact] @@ -102,11 +102,11 @@ public void AddsThenByDescending_GivenMultipleThenByDescending() .ThenByDescending(x => x.Email); spec1.OrderExpressions.Should().HaveCount(3); - spec1.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec1.OrderExpressions.Skip(1).Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.ThenByDescending)); + spec1.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec1.OrderExpressions.Skip(1).Should().AllSatisfy(x => x.Type.Should().Be(OrderType.ThenByDescending)); spec2.OrderExpressions.Should().HaveCount(3); - spec2.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec2.OrderExpressions.Skip(1).Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.ThenByDescending)); + spec2.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec2.OrderExpressions.Skip(1).Should().AllSatisfy(x => x.Type.Should().Be(OrderType.ThenByDescending)); } [Fact] @@ -125,12 +125,12 @@ public void AddsThenBy_GivenThenByDescendingThenBy() .ThenBy(x => x.Email); spec1.OrderExpressions.Should().HaveCount(3); - spec1.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec1.OrderExpressions.Skip(1).First().OrderType.Should().Be(OrderTypeEnum.ThenByDescending); - spec1.OrderExpressions.Last().OrderType.Should().Be(OrderTypeEnum.ThenBy); + spec1.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec1.OrderExpressions.Skip(1).First().Type.Should().Be(OrderType.ThenByDescending); + spec1.OrderExpressions.Last().Type.Should().Be(OrderType.ThenBy); spec2.OrderExpressions.Should().HaveCount(3); - spec2.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); - spec2.OrderExpressions.Skip(1).First().OrderType.Should().Be(OrderTypeEnum.ThenByDescending); - spec2.OrderExpressions.Last().OrderType.Should().Be(OrderTypeEnum.ThenBy); + spec2.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); + spec2.OrderExpressions.Skip(1).First().Type.Should().Be(OrderType.ThenByDescending); + spec2.OrderExpressions.Last().Type.Should().Be(OrderType.ThenBy); } } diff --git a/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_Include.cs b/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_Include.cs index e1dd2dd..74b5a41 100644 --- a/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_Include.cs +++ b/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_Include.cs @@ -46,10 +46,10 @@ public void AddsInclude_GivenInclude() spec1.IncludeExpressions.Should().ContainSingle(); spec1.IncludeExpressions.First().LambdaExpression.Should().BeSameAs(expr); - spec1.IncludeExpressions.First().Type.Should().Be(IncludeTypeEnum.Include); + spec1.IncludeExpressions.First().Type.Should().Be(IncludeType.Include); spec2.IncludeExpressions.Should().ContainSingle(); spec2.IncludeExpressions.First().LambdaExpression.Should().BeSameAs(expr); - spec2.IncludeExpressions.First().Type.Should().Be(IncludeTypeEnum.Include); + spec2.IncludeExpressions.First().Type.Should().Be(IncludeType.Include); } [Fact] @@ -66,8 +66,8 @@ public void AddsInclude_GivenMultipleInclude() .Include(x => x.Contact); spec1.IncludeExpressions.Should().HaveCount(2); - spec1.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeTypeEnum.Include)); + spec1.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeType.Include)); spec2.IncludeExpressions.Should().HaveCount(2); - spec2.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeTypeEnum.Include)); + spec2.IncludeExpressions.Should().AllSatisfy(x => x.Type.Should().Be(IncludeType.Include)); } } diff --git a/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_OrderBy.cs b/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_OrderBy.cs index 7866967..377c043 100644 --- a/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_OrderBy.cs +++ b/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_OrderBy.cs @@ -43,10 +43,10 @@ public void AddsOrderBy_GivenOrderBy() spec1.OrderExpressions.Should().ContainSingle(); spec1.OrderExpressions.First().KeySelector.Should().BeSameAs(expr); - spec1.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); + spec1.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); spec2.OrderExpressions.Should().ContainSingle(); spec2.OrderExpressions.First().KeySelector.Should().BeSameAs(expr); - spec2.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderBy); + spec2.OrderExpressions.First().Type.Should().Be(OrderType.OrderBy); } [Fact] @@ -63,8 +63,8 @@ public void AddsOrderBy_GivenMultipleOrderBy() .OrderBy(x => x.LastName); spec1.OrderExpressions.Should().HaveCount(2); - spec1.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderBy)); + spec1.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderBy)); spec2.OrderExpressions.Should().HaveCount(2); - spec2.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderBy)); + spec2.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderBy)); } } diff --git a/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_OrderByDescending.cs b/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_OrderByDescending.cs index 1a40b39..347bc27 100644 --- a/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_OrderByDescending.cs +++ b/tests/QuerySpecification.Tests/Builders/SpecificationBuilderExtensions_OrderByDescending.cs @@ -43,10 +43,10 @@ public void AddsOrderByDescending_GivenOrderByDescending() spec1.OrderExpressions.Should().ContainSingle(); spec1.OrderExpressions.First().KeySelector.Should().BeSameAs(expr); - spec1.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderByDescending); + spec1.OrderExpressions.First().Type.Should().Be(OrderType.OrderByDescending); spec2.OrderExpressions.Should().ContainSingle(); spec2.OrderExpressions.First().KeySelector.Should().BeSameAs(expr); - spec2.OrderExpressions.First().OrderType.Should().Be(OrderTypeEnum.OrderByDescending); + spec2.OrderExpressions.First().Type.Should().Be(OrderType.OrderByDescending); } [Fact] @@ -63,8 +63,8 @@ public void AddsOrderByDescending_GivenMultipleOrderByDescending() .OrderByDescending(x => x.LastName); spec1.OrderExpressions.Should().HaveCount(2); - spec1.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderByDescending)); + spec1.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderByDescending)); spec2.OrderExpressions.Should().HaveCount(2); - spec2.OrderExpressions.Should().AllSatisfy(x => x.OrderType.Should().Be(OrderTypeEnum.OrderByDescending)); + spec2.OrderExpressions.Should().AllSatisfy(x => x.Type.Should().Be(OrderType.OrderByDescending)); } } diff --git a/tests/QuerySpecification.Tests/Evaluators/LikeExtension_Like.cs b/tests/QuerySpecification.Tests/Evaluators/LikeExtensionTests.cs similarity index 99% rename from tests/QuerySpecification.Tests/Evaluators/LikeExtension_Like.cs rename to tests/QuerySpecification.Tests/Evaluators/LikeExtensionTests.cs index c1fc5f2..c93e396 100644 --- a/tests/QuerySpecification.Tests/Evaluators/LikeExtension_Like.cs +++ b/tests/QuerySpecification.Tests/Evaluators/LikeExtensionTests.cs @@ -1,6 +1,6 @@ namespace Tests.Evaluators; -public class LikeExtension_Like +public class LikeExtensionTests { [Theory] [InlineData(true, "%", "")] diff --git a/tests/QuerySpecification.Tests/Evaluators/LikeMemoryEvaluatorTests.cs b/tests/QuerySpecification.Tests/Evaluators/LikeMemoryEvaluatorTests.cs index d4b4160..fe5370c 100644 --- a/tests/QuerySpecification.Tests/Evaluators/LikeMemoryEvaluatorTests.cs +++ b/tests/QuerySpecification.Tests/Evaluators/LikeMemoryEvaluatorTests.cs @@ -28,7 +28,12 @@ public void Filters_GivenLikeInSameGroup() .Like(x => x.FirstName, "%xx%") .Like(x => x.LastName, "%xy%"); - AssertForEvaluate(spec, input, expected); + // Not materializing with ToList() intentionally to test cloning in the iterator + var actual = _evaluator.Evaluate(input, spec); + + // Multiple iterations will force cloning + actual.Should().HaveSameCount(expected); + actual.Should().Equal(expected); } [Fact] @@ -52,7 +57,9 @@ public void Filters_GivenLikeInDifferentGroup() .Like(x => x.FirstName, "%xx%", 1) .Like(x => x.LastName, "%xy%", 2); - AssertForEvaluate(spec, input, expected); + var actual = _evaluator.Evaluate(input, spec).ToList(); + + actual.Should().Equal(expected); } [Fact] @@ -79,7 +86,9 @@ public void Filters_GivenLikeComplexGrouping() .Like(x => x.LastName, "%xy%", 2) .Like(x => x.LastName, "%xz%", 2); - AssertForEvaluate(spec, input, expected); + var actual = _evaluator.Evaluate(input, spec).ToList(); + + actual.Should().Equal(expected); } [Fact] @@ -100,16 +109,35 @@ public void DoesNotFilter_GivenNoLike() ]; var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0); + + var actual = _evaluator.Evaluate(input, spec); - AssertForEvaluate(spec, input, expected); + actual.Should().Equal(expected); } - private static void AssertForEvaluate(Specification spec, List input, IEnumerable expected) + [Fact] + public void DoesNotFilter_GivenEmptySpec() { + List input = + [ + new(1, "axxa", "axya"), + new(2, "aaaa", "aaaa"), + new(3, "aaaa", "axya") + ]; + + List expected = + [ + new(1, "axxa", "axya"), + new(2, "aaaa", "aaaa"), + new(3, "aaaa", "axya") + ]; + + var spec = new Specification(); + var actual = _evaluator.Evaluate(input, spec); - actual.Should().NotBeNull(); - actual.Should().HaveSameCount(expected); actual.Should().Equal(expected); } } diff --git a/tests/QuerySpecification.Tests/Evaluators/PaginationExtensionsTests.cs b/tests/QuerySpecification.Tests/Evaluators/PaginationExtensionsTests.cs index 07682a0..5eb15f0 100644 --- a/tests/QuerySpecification.Tests/Evaluators/PaginationExtensionsTests.cs +++ b/tests/QuerySpecification.Tests/Evaluators/PaginationExtensionsTests.cs @@ -16,14 +16,14 @@ public void Filters_GivenPaginatedSpec() .Take(2); var actual = input.ApplyPaging(spec); - Assert(actual, expected); + actual.Should().Equal(expected); actual = input.AsQueryable().ApplyPaging(spec); - Assert(actual, expected); + actual.Should().Equal(expected); var pagination = new Pagination(input.Count, 2, 2); actual = input.AsQueryable().ApplyPaging(pagination); - Assert(actual, expected); + actual.Should().Equal(expected); } [Fact] @@ -32,17 +32,17 @@ public void Filters_GivenPaginatedSpecWithSelect() List input = [new(1), new(2), new(3), new(4), new(5)]; List expected = [new(3), new(4)]; - var spec = new Specification(); + var spec = new Specification(); spec.Query .Skip(2) .Take(2) - .Select(x => x.Id); + .Select(x => x); var actual = input.ApplyPaging(spec); - Assert(actual, expected); + actual.Should().Equal(expected); actual = input.AsQueryable().ApplyPaging(spec); - Assert(actual, expected); + actual.Should().Equal(expected); } [Fact] @@ -54,10 +54,27 @@ public void DoesNotFilter_GivenEmptySpec() var spec = new Specification(); var actual = input.ApplyPaging(spec); - Assert(actual, expected); + actual.Should().Equal(expected); actual = input.AsQueryable().ApplyPaging(spec); - Assert(actual, expected); + actual.Should().Equal(expected); + } + + [Fact] + public void DoesNotFilter_GivenSpecWithSelectAndNoPagination() + { + List input = [new(1), new(2), new(3), new(4), new(5)]; + List expected = [new(1), new(2), new(3), new(4), new(5)]; + + var spec = new Specification(); + spec.Query + .Select(x => x); + + var actual = input.ApplyPaging(spec); + actual.Should().Equal(expected); + + actual = input.AsQueryable().ApplyPaging(spec); + actual.Should().Equal(expected); } [Fact] @@ -72,10 +89,10 @@ public void DoesNotFilter_GivenNegativeTakeSkip() .Take(-1); var actual = input.ApplyPaging(spec); - Assert(actual, expected); + actual.Should().Equal(expected); actual = input.AsQueryable().ApplyPaging(spec); - Assert(actual, expected); + actual.Should().Equal(expected); } [Fact] @@ -89,16 +106,9 @@ public void DoesNotFilter_GivenZeroSkip() .Skip(0); var actual = input.ApplyPaging(spec); - Assert(actual, expected); + actual.Should().Equal(expected); actual = input.AsQueryable().ApplyPaging(spec); - Assert(actual, expected); - } - - private static void Assert(IEnumerable actual, IEnumerable expected) - { - actual.Should().NotBeNull(); - actual.Should().HaveSameCount(expected); actual.Should().Equal(expected); } } diff --git a/tests/QuerySpecification.Tests/Evaluators/SpecificationInMemoryEvaluatorTests.cs b/tests/QuerySpecification.Tests/Evaluators/SpecificationInMemoryEvaluatorTests.cs index c4d9215..8074456 100644 --- a/tests/QuerySpecification.Tests/Evaluators/SpecificationInMemoryEvaluatorTests.cs +++ b/tests/QuerySpecification.Tests/Evaluators/SpecificationInMemoryEvaluatorTests.cs @@ -35,19 +35,20 @@ public void Evaluate_ThrowsSelectorNotFoundException_GivenNoSelector() sut.Should().Throw(); } - [Fact] - public void Evaluate_ThrowsConcurrentSelectorsException_GivenBothSelectAndSelectMany() - { - var spec = new Specification(); - spec.Query - .Select(x => x.FirstName); - spec.Query - .SelectMany(x => x.Emails); + // TODO: We should allow overwriting. Think about this. [fatii, 26/10/2024] + //[Fact] + //public void Evaluate_ThrowsConcurrentSelectorsException_GivenBothSelectAndSelectMany() + //{ + // var spec = new Specification(); + // spec.Query + // .Select(x => x.FirstName); + // spec.Query + // .SelectMany(x => x.Emails); - var sut = () => _evaluator.Evaluate([], spec); + // var sut = () => _evaluator.Evaluate([], spec); - sut.Should().Throw(); - } + // sut.Should().Throw(); + //} [Fact] public void Evaluate_Filters_GivenSpec() @@ -73,7 +74,11 @@ public void Evaluate_Filters_GivenSpec() .Skip(1) .Take(1); - AssertForEvaluate(spec, input, expected); + var actual = _evaluator.Evaluate(input, spec).ToList(); + var actualFromSpec = spec.Evaluate(input).ToList(); + + actual.Should().Equal(actualFromSpec); + actual.Should().Equal(expected); } [Fact] @@ -98,7 +103,11 @@ public void Evaluate_Filters_GivenSpecWithSelect() .Take(1) .Select(x => x.FirstName); - AssertForEvaluate(spec, input, expected); + var actual = _evaluator.Evaluate(input, spec).ToList(); + var actualFromSpec = spec.Evaluate(input).ToList(); + + actual.Should().Equal(actualFromSpec); + actual.Should().Equal(expected); } [Fact] @@ -123,7 +132,39 @@ public void Evaluate_Filters_GivenSpecWithSelectMany() .Take(2) .SelectMany(x => x.Emails); - AssertForEvaluate(spec, input, expected); + var actual = _evaluator.Evaluate(input, spec).ToList(); + var actualFromSpec = spec.Evaluate(input).ToList(); + + actual.Should().Equal(actualFromSpec); + actual.Should().Equal(expected); + } + + [Fact] + public void Evaluate_DoesNotFilter_GivenEmptySpec() + { + List input = + [ + new(1, "axxa", "axya"), + new(2, "aaaa", "axya"), + new(3, "aaaa", "axya"), + new(4, "aaaa", "axya") + ]; + + List expected = + [ + new(1, "axxa", "axya"), + new(2, "aaaa", "axya"), + new(3, "aaaa", "axya"), + new(4, "aaaa", "axya") + ]; + + var spec = new Specification(); + + var actual = _evaluator.Evaluate(input, spec).ToList(); + var actualFromSpec = spec.Evaluate(input).ToList(); + + actual.Should().Equal(actualFromSpec); + actual.Should().Equal(expected); } [Fact] @@ -137,13 +178,25 @@ public void Evaluate_DoesNotFilter_GivenSpecAndIgnorePagination() new(4, "aaaa", "axya") ]; + List expected = + [ + new(1, "axxa", "axya"), + new(2, "aaaa", "axya"), + new(3, "aaaa", "axya"), + new(4, "aaaa", "axya") + ]; + var spec = new Specification(); spec.Query .OrderBy(x => x.Id) .Skip(1) .Take(1); - AssertForEvaluate(spec, input, input, ignorePaging: true); + var actual = _evaluator.Evaluate(input, spec, ignorePaging: true).ToList(); + var actualFromSpec = spec.Evaluate(input, ignorePaging: true).ToList(); + + actual.Should().Equal(actualFromSpec); + actual.Should().Equal(expected); } [Fact] @@ -166,7 +219,11 @@ public void Evaluate_DoesNotFilter_GivenSpecWithSelectAndIgnorePagination() .Take(1) .Select(x => x.FirstName); - AssertForEvaluate(spec, input, expected, ignorePaging: true); + var actual = _evaluator.Evaluate(input, spec, ignorePaging: true).ToList(); + var actualFromSpec = spec.Evaluate(input, ignorePaging: true).ToList(); + + actual.Should().Equal(actualFromSpec); + actual.Should().Equal(expected); } [Fact] @@ -189,36 +246,10 @@ public void Evaluate_DoesNotFilter_GivenSpecWithSelectManyAndIgnorePagination() .Take(2) .SelectMany(x => x.Emails); - AssertForEvaluate(spec, input, expected, ignorePaging: true); - } - - private static void AssertForEvaluate( - Specification spec, - List input, - IEnumerable expected, - bool ignorePaging = false) - { - var actual = _evaluator.Evaluate(input, spec, ignorePaging); - var actualFromSpec = spec.Evaluate(input, ignorePaging); - - actual.Should().Equal(actualFromSpec); - actual.Should().NotBeNull(); - actual.Should().HaveSameCount(expected); - actual.Should().Equal(expected); - } - - private static void AssertForEvaluate( - Specification spec, - List input, - IEnumerable expected, - bool ignorePaging = false) - { - var actual = _evaluator.Evaluate(input, spec, ignorePaging); - var actualFromSpec = spec.Evaluate(input, ignorePaging); + var actual = _evaluator.Evaluate(input, spec, true).ToList(); + var actualFromSpec = spec.Evaluate(input, true).ToList(); actual.Should().Equal(actualFromSpec); - actual.Should().NotBeNull(); - actual.Should().HaveSameCount(expected); actual.Should().Equal(expected); } @@ -234,9 +265,9 @@ public void Constructor_SetsProvidedEvaluators() var evaluator = new SpecificationInMemoryEvaluator(evaluators); - var state = EvaluatorsOf(evaluator); - state.Should().HaveSameCount(evaluators); - state.Should().Equal(evaluators); + var result = EvaluatorsOf(evaluator); + result.Should().HaveSameCount(evaluators); + result.Should().Equal(evaluators); } [Fact] @@ -244,13 +275,13 @@ public void DerivedSpecificationEvaluatorCanAlterDefaultEvaluator() { var evaluator = new SpecificationEvaluatorDerived(); - var state = EvaluatorsOf(evaluator); - state.Should().HaveCount(5); - state[0].Should().BeOfType(); - state[1].Should().BeOfType(); - state[2].Should().BeOfType(); - state[3].Should().BeOfType(); - state[4].Should().BeOfType(); + var result = EvaluatorsOf(evaluator); + result.Should().HaveCount(5); + result[0].Should().BeOfType(); + result[1].Should().BeOfType(); + result[2].Should().BeOfType(); + result[3].Should().BeOfType(); + result[4].Should().BeOfType(); } private class SpecificationEvaluatorDerived : SpecificationInMemoryEvaluator diff --git a/tests/QuerySpecification.Tests/Expressions/IncludeExpressionTests.cs b/tests/QuerySpecification.Tests/Expressions/IncludeExpressionTests.cs deleted file mode 100644 index ef0465d..0000000 --- a/tests/QuerySpecification.Tests/Expressions/IncludeExpressionTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -namespace Tests.Expressions; - -public class IncludeExpressionTests -{ - public record Customer(int Id, Address Address); - public record Address(int Id, City City); - public record City(int Id); - - [Fact] - public void Constructor_ThrowsArgumentNullException_GivenNullForLambdaExpression() - { - var sut = () => new IncludeExpression(null!, typeof(Customer), typeof(Address)); - - sut.Should().Throw().WithParameterName("expression"); - } - - [Fact] - public void Constructor_ThrowsArgumentNullException_GivenNullForEntityType() - { - Expression> expr = x => x.Address; - var sut = () => new IncludeExpression(expr, null!, typeof(Address)); - - sut.Should().Throw().WithParameterName("entityType"); - } - - [Fact] - public void Constructor_ThrowsArgumentNullException_GivenNullForPropertyType() - { - Expression> expr = x => x.Address; - var sut = () => new IncludeExpression(expr, typeof(Customer), null!); - - sut.Should().Throw().WithParameterName("propertyType"); - } - - [Fact] - public void Constructor_ThrowsArgumentNullException_GivenNullForPreviousPropertyType() - { - Expression> expr = x => x.Address; - var sut = () => new IncludeExpression(expr, typeof(Customer), typeof(Address), null!); - - sut.Should().Throw().WithParameterName("previousPropertyType"); - } - - [Fact] - public void Constructor_GivenIncludeExpression() - { - Expression> expr = x => x.Address; - var sut = new IncludeExpression(expr, typeof(Customer), typeof(Address)); - - sut.Type.Should().Be(IncludeTypeEnum.Include); - sut.LambdaExpression.Should().Be(expr); - sut.EntityType.Should().Be(); - sut.PropertyType.Should().Be
(); - sut.PreviousPropertyType.Should().BeNull(); - } - - [Fact] - public void Constructor_GivenIncludeThenExpression() - { - Expression> expr = x => x.City; - var sut = new IncludeExpression(expr, typeof(Customer), typeof(City), typeof(Address)); - - sut.Type.Should().Be(IncludeTypeEnum.ThenInclude); - sut.LambdaExpression.Should().Be(expr); - sut.EntityType.Should().Be(); - sut.PropertyType.Should().Be(); - sut.PreviousPropertyType.Should().Be
(); - } -} diff --git a/tests/QuerySpecification.Tests/Expressions/LikeExpressionTests.cs b/tests/QuerySpecification.Tests/Expressions/LikeExpressionTests.cs deleted file mode 100644 index 7740d49..0000000 --- a/tests/QuerySpecification.Tests/Expressions/LikeExpressionTests.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace Tests.Expressions; - -public class LikeExpressionTests -{ - public record Customer(int Id, string Name); - - [Fact] - public void Constructor_ThrowsArgumentNullException_GivenNullExpression() - { - var sut = () => new LikeExpression(null!, "x"); - - sut.Should().Throw().WithParameterName("keySelector"); - } - - [Fact] - public void Constructor_ThrowsArgumentNullException_GivenNullPattern() - { - Expression> expr = x => x.Name; - var sut = () => new LikeExpression(expr, null!); - - sut.Should().Throw().WithParameterName("pattern"); - } - - [Fact] - public void Constructor_GivenValidValues() - { - Expression> expr = x => x.Name; - - var sut = new LikeExpression(expr, "x", 99); - - sut.KeySelector.Should().Be(expr); - sut.Pattern.Should().Be("x"); - sut.Group.Should().Be(99); - Accessors.FuncFieldOf(sut).Should().BeNull(); - sut.KeySelectorFunc.Should().NotBeNull(); - //sut.KeySelectorFunc.Should().BeEquivalentTo(expr.Compile()); - Accessors.FuncFieldOf(sut).Should().NotBeNull(); - } - - private class Accessors - { - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_keySelectorFunc")] - public static extern ref Func? FuncFieldOf(LikeExpression @this); - } -} diff --git a/tests/QuerySpecification.Tests/Expressions/OrderExpressionTests.cs b/tests/QuerySpecification.Tests/Expressions/OrderExpressionTests.cs deleted file mode 100644 index 54352e4..0000000 --- a/tests/QuerySpecification.Tests/Expressions/OrderExpressionTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace Tests.Expressions; - -public class OrderExpressionTests -{ - public record Customer(int Id, string Name); - - [Fact] - public void Constructor_ThrowsArgumentNullException_GivenNullExpression() - { - var sut = () => new OrderExpression(null!, OrderTypeEnum.OrderBy); - - sut.Should().Throw().WithParameterName("keySelector"); - } - - [Fact] - public void Constructor_GivenValidValues() - { - Expression> expr = x => x.Name; - - var sut = new OrderExpression(expr, OrderTypeEnum.OrderBy); - - sut.KeySelector.Should().Be(expr); - sut.OrderType.Should().Be(OrderTypeEnum.OrderBy); - Accessors.FuncFieldOf(sut).Should().BeNull(); - sut.KeySelectorFunc.Should().NotBeNull(); - //sut.KeySelectorFunc.Should().BeEquivalentTo(expr.Compile()); - Accessors.FuncFieldOf(sut).Should().NotBeNull(); - } - - private class Accessors - { - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_keySelectorFunc")] - public static extern ref Func? FuncFieldOf(OrderExpression @this); - } -} diff --git a/tests/QuerySpecification.Tests/Expressions/WhereExpressionTests.cs b/tests/QuerySpecification.Tests/Expressions/WhereExpressionTests.cs deleted file mode 100644 index 13ddb15..0000000 --- a/tests/QuerySpecification.Tests/Expressions/WhereExpressionTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Runtime.CompilerServices; - -namespace Tests.Expressions; - -public class WhereExpressionTests -{ - public record Customer(int Id); - - [Fact] - public void Constructor_ThrowsArgumentNullException_GivenNullExpression() - { - var sut = () => new WhereExpression(null!); - - sut.Should().Throw().WithParameterName("filter"); - } - - [Fact] - public void Constructor_GivenValidValues() - { - Expression> expr = x => x.Id == 1; - - var sut = new WhereExpression(expr); - - sut.Filter.Should().Be(expr); - Accessors.FuncFieldOf(sut).Should().BeNull(); - sut.FilterFunc.Should().NotBeNull(); - //sut.FilterFunc.Should().BeEquivalentTo(expr.Compile()); - Accessors.FuncFieldOf(sut).Should().NotBeNull(); - } - - private class Accessors - { - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_filterFunc")] - public static extern ref Func? FuncFieldOf(WhereExpression @this); - } -} diff --git a/tests/QuerySpecification.Tests/SpecificationInternalsTests.cs b/tests/QuerySpecification.Tests/SpecificationInternalsTests.cs new file mode 100644 index 0000000..223fa52 --- /dev/null +++ b/tests/QuerySpecification.Tests/SpecificationInternalsTests.cs @@ -0,0 +1,285 @@ +using System.Runtime.CompilerServices; + +namespace Tests; + +public class SpecificationInternalsTests +{ + private static readonly SpecItem _emptySpecItem = new SpecItem(); + + public record Customer(int Id, string Name, Address Address); + public record Address(int Id, City City); + public record City(int Id, string Name); + + [Fact] + public void AddInternal_InitializesArray_GivenFirstAddition() + { + var spec = new Specification(); + var item = new SpecItem + { + Type = -1, + Bag = 1, + Reference = new object(), + }; + + spec.AddInternal(item.Type, item.Reference, item.Bag); + + var items = Accessors.Items(spec); + items.Should().NotBeNull(); + items.Should().HaveCount(2); + items![0].Should().Be(item); + items[1].Should().Be(_emptySpecItem); + } + + [Fact] + public void AddInternal_AddsInAvailableSlot() + { + var spec = new Specification(); + var item = new SpecItem + { + Type = -1, + Bag = 1, + Reference = new object(), + }; + + spec.AddInternal(item.Type, item.Reference, item.Bag); + spec.AddInternal(item.Type, item.Reference, item.Bag); + + var items = Accessors.Items(spec); + items.Should().NotBeNull(); + items.Should().HaveCount(2); + items![0].Should().Be(item); + items[1].Should().Be(item); + } + + [Fact] + public void AddInternal_ResizesArrayByFour_GivenFullCapacity() + { + var spec = new Specification(); + var item = new SpecItem + { + Type = -1, + Bag = 1, + Reference = new object(), + }; + + spec.AddInternal(item.Type, item.Reference, item.Bag); + spec.AddInternal(item.Type, item.Reference, item.Bag); + spec.AddInternal(item.Type, item.Reference, item.Bag); + + var items = Accessors.Items(spec); + items.Should().NotBeNull(); + items.Should().HaveCount(6); + items![0].Should().Be(item); + items[1].Should().Be(item); + items[2].Should().Be(item); + items[3].Should().Be(_emptySpecItem); + items[4].Should().Be(_emptySpecItem); + items[5].Should().Be(_emptySpecItem); + } + + [Fact] + public void AddInternal_AddsPagingAndFlagsInSameSlot() + { + var spec = new Specification(); + spec.Query.AsNoTracking(); + var itemPaging = new SpecItem + { + Type = ItemType.Paging, + Bag = int.MaxValue, + Reference = new SpecPaging(), + }; + + spec.AddInternal(itemPaging.Type, itemPaging.Reference, itemPaging.Bag); + + var items = Accessors.Items(spec); + items.Should().NotBeNull(); + items.Should().HaveCount(2); + items![0].Type.Should().Be(ItemType.Paging); + items[0].Type.Should().Be(ItemType.Flags); + items[0].Reference.Should().Be(itemPaging.Reference); + items[0].Bag.Should().NotBe(itemPaging.Bag); + items[0].Bag.Should().Match(num => (num > 0) && ((num & (num - 1)) == 0), "It contains Flags enum and should be a power of 2"); + items[1].Should().Be(_emptySpecItem); + } + + [Fact] + public void GetCompiledItems_ReturnsEmptyArray_GivenEmptySpec() + { + var spec = new Specification(); + + var items = spec.GetCompiledItems(); + + items.Should().BeSameAs(Array.Empty()); + } + + [Fact] + public void GetCompiledItems_ReturnsEmptyArray_GivenNoCompilableItems() + { + var spec = new Specification(); + spec.Query + .Include(nameof(Customer.Address)) + .Include(x => x.Address) + .Take(10) + .Skip(10) + .AsNoTracking() + .AsSplitQuery(); + + var items = spec.GetCompiledItems(); + + items.Should().BeSameAs(Array.Empty()); + } + + [Fact] + public void GetCompiledItems_DoesNotRecompile_GivenNoChanges() + { + var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0) + .Like(x => x.Name, "%abc%") + .OrderBy(x => x.Id) + .ThenBy(x => x.Name); + + var items1 = spec.GetCompiledItems(); + var items2 = spec.GetCompiledItems(); + + items1.Should().BeSameAs(items2); + } + + [Fact] + public void GetCompiledItems_GeneratesCompiledItems() + { + var spec = new Specification(); + spec.Query + .Where(x => x.Id > 0) + .Like(x => x.Name, "%a1%", 2) + .Like(x => x.Name, "%a2%") + .OrderBy(x => x.Id) + .ThenBy(x => x.Name); + + var items = spec.GetCompiledItems(); + + items.Should().HaveCount(5); + items[0].Reference.Should().BeOfType>(); + items[0].Type.Should().Be(ItemType.Where); + + items[1].Reference.Should().BeOfType>(); + items[1].Type.Should().Be(ItemType.Order); + items[1].Bag.Should().Be((int)OrderType.OrderBy); + items[2].Reference.Should().BeOfType>(); + items[2].Type.Should().Be(ItemType.Order); + items[2].Bag.Should().Be((int)OrderType.ThenBy); + + // Compiled like items are placed as a last segment and ordered by group. + items[3].Reference.Should().BeOfType>(); + items[3].Type.Should().Be(ItemType.Like); + items[3].Bag.Should().Be(1); + items[4].Reference.Should().BeOfType>(); + items[4].Type.Should().Be(ItemType.Like); + items[4].Bag.Should().Be(2); + } + + [Fact] + public void UpdateFlag_AddsNewItem_GivenSingleFlagAndSetTrue() + { + var spec = new Specification(); + spec.AsNoTracking = true; + + spec.AsNoTracking.Should().BeTrue(); + var items = Accessors.Items(spec); + items.Should().HaveCount(2); + items![0].Type.Should().Be(ItemType.Flags); + items![1].Should().Be(_emptySpecItem); + } + + [Fact] + public void UpdateFlag_DoesNotAddItem_GivenSingleFlagAndSetFalse() + { + var spec = new Specification(); + spec.AsNoTracking = false; + + spec.AsNoTracking.Should().BeFalse(); + Accessors.Items(spec).Should().BeNull(); + } + + [Fact] + public void UpdateFlag_AddsNewItem_GivenFlagAndSetTrue() + { + var spec = new Specification(); + spec.Query.Where(x => x.Id > 0); + spec.AsNoTracking = true; + + spec.AsNoTracking.Should().BeTrue(); + var items = Accessors.Items(spec); + items.Should().HaveCount(2); + items![0].Type.Should().Be(ItemType.Where); + items![1].Type.Should().Be(ItemType.Flags); + } + + [Fact] + public void UpdateFlag_DoesNotAddItem_GivenFlagAndSetFalse() + { + var spec = new Specification(); + spec.Query.Where(x => x.Id > 0); + spec.AsNoTracking = false; + + spec.AsNoTracking.Should().BeFalse(); + var items = Accessors.Items(spec); + items.Should().HaveCount(2); + items![0].Type.Should().Be(ItemType.Where); + items![1].Should().Be(_emptySpecItem); + } + + [Fact] + public void UpdateFlag_UpdatesFlags_GivenFlagAndSetTrue() + { + var spec = new Specification(); + spec.Query.Where(x => x.Id > 0).AsSplitQuery(); + spec.AsNoTracking = true; + + spec.AsNoTracking.Should().BeTrue(); + var items = Accessors.Items(spec); + items.Should().HaveCount(2); + items![0].Type.Should().Be(ItemType.Where); + items![1].Type.Should().Be(ItemType.Flags); + } + + [Fact] + public void UpdateFlag_UpdatesFlags_GivenFlagAndSetFalse() + { + var spec = new Specification(); + spec.Query.Where(x => x.Id > 0).AsSplitQuery(); + spec.AsNoTracking = false; + + spec.AsNoTracking.Should().BeFalse(); + var items = Accessors.Items(spec); + items.Should().HaveCount(2); + items![0].Type.Should().Be(ItemType.Where); + items![1].Type.Should().Be(ItemType.Flags); + } + + [Fact] + public void Constructor_InitializesArray_GivenInitialCapacity() + { + var spec = new Specification(10); + Accessors.Items(spec).Should().NotBeNull(); + Accessors.Items(spec).Should().HaveCount(10); + + var specWithSelect = new Specification(10); + Accessors.Items(specWithSelect).Should().NotBeNull(); + Accessors.Items(specWithSelect).Should().HaveCount(10); + } + + [Fact] + public void ArrayIsNull_GivenEmptySpec() + { + var spec = new Specification(); + + Accessors.Items(spec).Should().BeNull(); + } + + private class Accessors + { + [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")] + public static extern ref SpecItem[]? Items(Specification @this); + } +} diff --git a/tests/QuerySpecification.Tests/SpecificationSizeTests.cs b/tests/QuerySpecification.Tests/SpecificationSizeTests.cs deleted file mode 100644 index 7455c19..0000000 --- a/tests/QuerySpecification.Tests/SpecificationSizeTests.cs +++ /dev/null @@ -1,158 +0,0 @@ -using ManagedObjectSize; -using System.Runtime.CompilerServices; -using Xunit.Abstractions; - -namespace Tests; - -public class SpecificationSizeTests -{ - private readonly ITestOutputHelper _output; - - public SpecificationSizeTests(ITestOutputHelper output) - { - _output = output; - } - - [Fact] - public void Spec_Empty() - { - var spec = new Spec(); - PrintObjectSize(spec); - - var specWithArray = new SpecWithArray(); - PrintObjectSize(specWithArray); - - var specWithDictionary = new SpecWithDictionary(); - PrintObjectSize(specWithDictionary); - } - - [Fact] - public void Spec_Where() - { - var spec = new Spec(); - spec.Where = new List(2) { new() }; - PrintObjectSize(spec); - - var specWithArray = new SpecWithArray(); - specWithArray.State = new object[1]; - specWithArray.State[0] = new List(2) { new() }; - PrintObjectSize(specWithArray); - - var specWithDictionary = new SpecWithDictionary(); - specWithDictionary.State = new Dictionary(1); - specWithDictionary.State[0] = new List(2) { new() }; - PrintObjectSize(specWithDictionary); - } - - [Fact] - public void Spec_Where_Order() - { - var spec = new Spec(); - spec.Where = new List(2) { new() }; - spec.Order = new List(2) { new() }; - PrintObjectSize(spec); - - // This is very idealized scenario. - // Since the index represents the type here, if we need Include for example then we need to initialize array of size 4. - // So, at some point we have to give up and create max size array of 7. - var specWithArray = new SpecWithArray(); - specWithArray.State = new object[2]; - specWithArray.State[0] = new List(2) { new() }; - specWithArray.State[1] = new List(2) { new() }; - PrintObjectSize(specWithArray); - - var specWithDictionary = new SpecWithDictionary(); - specWithDictionary.State = new Dictionary(2); - specWithDictionary.State[0] = new List(2) { new() }; - specWithDictionary.State[1] = new List(2) { new() }; - PrintObjectSize(specWithDictionary); - } - - [Fact] - public void Spec_Where_Order_Take() - { - var spec = new Spec(); - spec.Where = new List(2) { new() }; - spec.Order = new List(2) { new() }; - spec.Take = 10; - PrintObjectSize(spec); - - var specWithArray = new SpecWithArray(); - specWithArray.State = new object[3]; - specWithArray.State[0] = new List(2) { new() }; - specWithArray.State[1] = new List(2) { new() }; - specWithArray.State[2] = new List(2) { new SpecFlags { Take = 10 } }; - PrintObjectSize(specWithArray); - - var specWithDictionary = new SpecWithDictionary(); - specWithDictionary.State = new Dictionary(3); - specWithDictionary.State[0] = new List(2) { new() }; - specWithDictionary.State[1] = new List(2) { new() }; - specWithDictionary.State[2] = new List(2) { new SpecFlags { Take = 10 } }; - PrintObjectSize(specWithDictionary); - } - - - private void PrintObjectSize(object obj, [CallerArgumentExpression(nameof(obj))] string caller = "") - { - _output.WriteLine(""); - _output.WriteLine(caller); - _output.WriteLine($"Inclusive: {ObjectSize.GetObjectInclusiveSize(obj):N0}"); - _output.WriteLine($"Exclusive: {ObjectSize.GetObjectExclusiveSize(obj):N0}"); - } -} - -public class Spec -{ - // We always new up the Query (builder). It's an empty object but we're spending 24 bytes here on x64. - public object Query { get; set; } = new(); - - public List? Where { get; set; } = null; - public List? Order { get; set; } = null; - public List? Like { get; set; } = null; - public List? Include { get; set; } = null; - public List? IncludeString { get; set; } = null; - public Dictionary? Items { get; set; } = null; - - public int Take { get; internal set; } = -1; - public int Skip { get; internal set; } = -1; - public bool IgnoreQueryFilters { get; internal set; } = false; - public bool AsSplitQuery { get; internal set; } = false; - public bool AsNoTracking { get; internal set; } = false; - public bool AsNoTrackingWithIdentityResolution { get; internal set; } = false; - -} - -public class SpecWithArray -{ - // We always new up the Query (builder). It's an empty object but we're spending 24 bytes here on x64. - public object Query { get; set; } = new(); - - // We'll keep everything in an array. - // The index of the array will represent the type of data we hold. - // We'll group all flags into a single object. Otherwise we pay penalty of 24 bytes per object. - // So the array size will have max length of 7 - public object[]? State { get; set; } = null; -} - -public class SpecWithDictionary -{ - // We always new up the Query (builder). It's an empty object but we're spending 24 bytes here on x64. - public object Query { get; set; } = new(); - - // We'll keep everything in a dictionary. - // The int key will represent the type of data we hold. - // We'll group all flags into a single object. Otherwise we pay penalty of 24 bytes per object. - // So the dictionary will have max 7 items. - public Dictionary? State { get; set; } = null; -} - -public class SpecFlags -{ - public int Take { get; internal set; } = -1; - public int Skip { get; internal set; } = -1; - public bool IgnoreQueryFilters { get; internal set; } = false; - public bool AsSplitQuery { get; internal set; } = false; - public bool AsNoTracking { get; internal set; } = false; - public bool AsNoTrackingWithIdentityResolution { get; internal set; } = false; -} diff --git a/tests/QuerySpecification.Tests/SpecificationTests.cs b/tests/QuerySpecification.Tests/SpecificationTests.cs index b7fbcb1..5a661bb 100644 --- a/tests/QuerySpecification.Tests/SpecificationTests.cs +++ b/tests/QuerySpecification.Tests/SpecificationTests.cs @@ -1,64 +1,353 @@ -using System.Runtime.CompilerServices; +using System.Collections; +using System.Runtime.CompilerServices; namespace Tests; public class SpecificationTests { - public record Customer(int Id, string Name); + public record Customer(int Id, string Name, Address Address); + public record Address(int Id, City City); + public record City(int Id, string Name); [Fact] - public void CollectionFields_AreNull_GivenEmptySpec() + public void WhereExpressionsCompiled() { + Expression> filter = x => x.Id == 1; var spec = new Specification(); + spec.Query + .Where(filter); - Accessors.WhereExpressionsOf(spec).Should().BeNull(); - Accessors.LikeExpressionsOf(spec).Should().BeNull(); - Accessors.OrderExpressionsOf(spec).Should().BeNull(); - Accessors.IncludeExpressionsOf(spec).Should().BeNull(); - Accessors.IncludeStringsOf(spec).Should().BeNull(); - Accessors.ItemsOf(spec).Should().BeNull(); + var expressions = spec.WhereExpressionsCompiled.ToList(); + + expressions.Should().HaveCount(1); + expressions[0].Filter.Should().BeOfType>(); } [Fact] - public void CollectionsProperties_ReturnEmptyEnumerable_GivenEmptySpec() + public void OrderExpressionsCompiled() { + Expression> orderBy = x => x.Id; + Expression> orderThenBy = x => x.Name; var spec = new Specification(); + spec.Query + .OrderBy(orderBy) + .ThenBy(orderThenBy); - spec.WhereExpressions.Should().BeSameAs(Enumerable.Empty>()); - spec.LikeExpressions.Should().BeSameAs(Enumerable.Empty>()); - spec.OrderExpressions.Should().BeSameAs(Enumerable.Empty>()); - spec.IncludeExpressions.Should().BeSameAs(Enumerable.Empty()); - spec.IncludeStrings.Should().BeSameAs(Enumerable.Empty()); + var expressions = spec.OrderExpressionsCompiled.ToList(); + + expressions.Should().HaveCount(2); + expressions[0].KeySelector.Should().BeOfType>(); + expressions[0].Type.Should().Be(OrderType.OrderBy); + expressions[1].KeySelector.Should().BeOfType>(); + expressions[1].Type.Should().Be(OrderType.ThenBy); + } + + [Fact] + public void LikeExpressionsCompiled() + { + Expression> selector = x => x.Name; + var searchTerm = "%abc%"; + var group = 10; + var spec = new Specification(); + spec.Query + .Like(selector, searchTerm, group); + + var expressions = spec.LikeExpressionsCompiled.ToList(); + + expressions.Should().HaveCount(1); + expressions[0].KeySelector.Should().BeOfType>(); + expressions[0].Pattern.Should().Be(searchTerm); + expressions[0].Group.Should().Be(group); + } + + [Fact] + public void WhereExpressions() + { + Expression> filter = x => x.Id == 1; + var spec = new Specification(); + spec.Query + .Where(filter); + + var expressions = spec.WhereExpressions.ToList(); + + expressions.Should().HaveCount(1); + expressions[0].Filter.Should().BeSameAs(filter); } [Fact] - public void Items_ReturnsNewDictionaryOnFirstAccess() + public void OrderExpressions() { + Expression> orderBy = x => x.Id; + Expression> orderThenBy = x => x.Name; var spec = new Specification(); + spec.Query + .OrderBy(orderBy) + .ThenBy(orderThenBy); + + var expressions = spec.OrderExpressions.ToList(); + + expressions.Should().HaveCount(2); + expressions[0].KeySelector.Should().BeSameAs(orderBy); + expressions[0].Type.Should().Be(OrderType.OrderBy); + expressions[1].KeySelector.Should().BeSameAs(orderThenBy); + expressions[1].Type.Should().Be(OrderType.ThenBy); + } + + [Fact] + public void LikeExpressions() + { + Expression> selector = x => x.Name; + var searchTerm = "%abc%"; + var group = 10; + var spec = new Specification(); + spec.Query + .Like(selector, searchTerm, group); + + var expressions = spec.LikeExpressions.ToList(); + + expressions.Should().HaveCount(1); + expressions[0].KeySelector.Should().BeSameAs(selector); + expressions[0].Pattern.Should().Be(searchTerm); + expressions[0].Group.Should().Be(group); + } + + [Fact] + public void IncludeExpressions() + { + Expression> include = x => x.Address; + Expression> thenInclude = x => x.City; + var spec = new Specification(); + spec.Query + .Include(include) + .ThenInclude(thenInclude); + + var expressions = spec.IncludeExpressions.ToList(); + + expressions.Should().HaveCount(2); + expressions[0].LambdaExpression.Should().BeSameAs(include); + expressions[0].Type.Should().Be(IncludeType.Include); + expressions[1].LambdaExpression.Should().BeSameAs(thenInclude); + expressions[1].Type.Should().Be(IncludeType.ThenInclude); + } + + [Fact] + public void IncludeStrings() + { + var includeString = nameof(Address); + var spec = new Specification(); + spec.Query + .Include(includeString); + + var expressions = spec.IncludeStrings.ToList(); + + expressions.Should().HaveCount(1); + expressions[0].Should().Be(includeString); + } + + [Fact] + public void Add() + { + var city1 = new City(1, "City1"); + var city2 = new City(2, "City2"); + + var spec = new Specification(); + + spec.Add(10, city1); + spec.Add(11, city2); + + var result = spec.Items.ToArray(); + result.Should().HaveCount(2); + result[0].Reference.Should().BeSameAs(city1); + result[0].Type.Should().Be(10); + result[1].Reference.Should().BeSameAs(city2); + result[1].Type.Should().Be(11); + } + + [Fact] + public void Add_ThrowsArgumentNullException_GivenNullValue() + { + var spec = new Specification(); + + var action = () => spec.Add(10, null!); + + action.Should().Throw(); + } + + [Fact] + public void Add_ArgumentOutOfRangeException_GivenZeroType() + { + var spec = new Specification(); + + var action = () => spec.Add(0, new object()); + + action.Should().Throw(); + } + + [Fact] + public void Add_ArgumentOutOfRangeException_GivenNegativeType() + { + var spec = new Specification(); - Accessors.ItemsOf(spec).Should().BeNull(); - spec.Items.Should().NotBeNull(); - Accessors.ItemsOf(spec).Should().NotBeNull(); + var action = () => spec.Add(-1, new object()); + + action.Should().Throw(); + } + + [Fact] + public void FirstOrDefault() + { + var city1 = new City(1, "City1"); + var city2 = new City(2, "City2"); + + var spec = new Specification(); + spec.Query + .Where(x => x.Id == 1); + + spec.Add(10, city1); + spec.Add(10, city2); + spec.Add(11, new City(3, "City3")); + + var result = spec.FirstOrDefault(10); + + result.Should().BeSameAs(city1); + } + + [Fact] + public void FirstOrDefault_ReturnsNull_GivenNoItems() + { + var spec = new Specification(); + spec.Query + .Where(x => x.Id == 1); + + var result = spec.FirstOrDefault(10); + + result.Should().BeNull(); + } + + [Fact] + public void FirstOrDefault_ReturnsNull_GivenEmptySpec() + { + var spec = new Specification(); + + var result = spec.FirstOrDefault(10); + + result.Should().BeNull(); } - private class Accessors + [Fact] + public void First() { - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_whereExpressions")] - public static extern ref List>? WhereExpressionsOf(Specification @this); + var city1 = new City(1, "City1"); + var city2 = new City(2, "City2"); - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_likeExpressions")] - public static extern ref List>? LikeExpressionsOf(Specification @this); + var spec = new Specification(); + spec.Query + .Where(x => x.Id == 1); - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_orderExpressions")] - public static extern ref List>? OrderExpressionsOf(Specification @this); + spec.Add(10, city1); + spec.Add(10, city2); + spec.Add(11, new City(3, "City3")); - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_includeExpressions")] - public static extern ref List? IncludeExpressionsOf(Specification @this); + var result = spec.First(10); - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_includeStrings")] - public static extern ref List? IncludeStringsOf(Specification @this); + result.Should().BeSameAs(city1); + } + + [Fact] + public void First_ThrowsInvalidOperationException_GivenNoItems() + { + var spec = new Specification(); + spec.Query + .Where(x => x.Id == 1); - [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_items")] - public static extern ref Dictionary? ItemsOf(Specification @this); + var action = () => spec.First(10); + + action.Should().Throw().WithMessage("Specification contains no matching item"); + } + + [Fact] + public void First_ThrowsInvalidOperationException_GivenEmptySpec() + { + var spec = new Specification(); + + var action = () => spec.First(10); + + action.Should().Throw().WithMessage("Specification contains no items"); + } + + [Fact] + public void OfType() + { + var city1 = new City(1, "City1"); + var city2 = new City(2, "City2"); + + var spec = new Specification(); + spec.Query + .Where(x => x.Id == 1); + + spec.Add(10, city1); + spec.Add(10, city2); + spec.Add(11, new City(3, "City3")); + + var result = spec.OfType(10).ToList(); + + result.Should().HaveCount(2); + result[0].Should().BeSameAs(city1); + result[1].Should().BeSameAs(city2); + } + + [Fact] + public void OfType_IteratorClone() + { + var city1 = new City(1, "City1"); + var city2 = new City(2, "City2"); + + var spec = new Specification(); + spec.Query + .Where(x => x.Id == 1); + + spec.Add(10, city1); + spec.Add(10, city2); + spec.Add(11, new City(3, "City3")); + + var result = spec.OfType(10); + + // Not materializing the result with ToList() to test the iterator cloning. + // The following assertions will iterate multiple times and will force cloning. + result.Should().HaveCount(2); + result.First().Should().BeSameAs(city1); + result.Skip(1).First().Should().BeSameAs(city2); + + // Testing the IEnumerable APIs of Iterator. + IEnumerable enumerable = result.Cast(); + var count = 0; + foreach (var item in enumerable) count++; + count.Should().Be(2); + } + + [Fact] + public void OfType_ReturnsEmptyEnumerable_GivenEmptySpec() + { + var spec = new Specification(); + + var result = spec.OfType(10); + + result.Should().BeEmpty(); + result.Should().BeSameAs(Enumerable.Empty()); + } + + [Fact] + public void CollectionsProperties_ReturnEmptyEnumerable_GivenEmptySpec() + { + var spec = new Specification(); + + spec.WhereExpressionsCompiled.Should().BeSameAs(Enumerable.Empty>()); + spec.LikeExpressionsCompiled.Should().BeSameAs(Enumerable.Empty>()); + spec.OrderExpressionsCompiled.Should().BeSameAs(Enumerable.Empty>()); + spec.WhereExpressions.Should().BeSameAs(Enumerable.Empty>()); + spec.LikeExpressions.Should().BeSameAs(Enumerable.Empty>()); + spec.OrderExpressions.Should().BeSameAs(Enumerable.Empty>()); + spec.IncludeExpressions.Should().BeSameAs(Enumerable.Empty>()); + spec.IncludeStrings.Should().BeSameAs(Enumerable.Empty()); } } diff --git a/tests/QuerySpecification.Tests/Validators/LikeValidatorTests.cs b/tests/QuerySpecification.Tests/Validators/LikeValidatorTests.cs index 7280f2a..87bbc47 100644 --- a/tests/QuerySpecification.Tests/Validators/LikeValidatorTests.cs +++ b/tests/QuerySpecification.Tests/Validators/LikeValidatorTests.cs @@ -6,6 +6,32 @@ public class LikeValidatorTests public record Customer(int Id, string FirstName, string? LastName); + [Fact] + public void ReturnsTrue_GivenEmptySpec() + { + var customer = new Customer(1, "FirstName1", "LastName1"); + + var spec = new Specification(); + + var result = _validator.IsValid(customer, spec); + + result.Should().BeTrue(); + } + + [Fact] + public void ReturnsTrue_GivenNoLike() + { + var customer = new Customer(1, "FirstName1", "LastName1"); + + var spec = new Specification(); + spec.Query + .Where(x => x.Id == 1); + + var result = _validator.IsValid(customer, spec); + + result.Should().BeTrue(); + } + [Fact] public void ReturnsTrue_GivenSpecWithSingleLike_WithValidEntity() { diff --git a/tests/QuerySpecification.Tests/Validators/SpecificationValidatorTests.cs b/tests/QuerySpecification.Tests/Validators/SpecificationValidatorTests.cs index 58b2960..5516831 100644 --- a/tests/QuerySpecification.Tests/Validators/SpecificationValidatorTests.cs +++ b/tests/QuerySpecification.Tests/Validators/SpecificationValidatorTests.cs @@ -26,6 +26,20 @@ public void ReturnTrue_GivenAllValidatorsPass() result.Should().BeTrue(); } + [Fact] + public void ReturnTrue_GivenEmptySpec() + { + var customer = new Customer(1, "FirstName1", "LastName1"); + + var spec = new Specification(); + + var result = _validatorDefault.IsValid(customer, spec); + var resultFromSpec = spec.IsSatisfiedBy(customer); + + result.Should().Be(resultFromSpec); + result.Should().BeTrue(); + } + [Fact] public void ReturnFalse_GivenOneValidatorFails() { @@ -74,9 +88,9 @@ public void Constructor_SetsProvidedValidators() var validator = new SpecificationValidator(validators); - var state = ValidatorsOf(validator); - state.Should().HaveSameCount(validators); - state.Should().Equal(validators); + var result = ValidatorsOf(validator); + result.Should().HaveSameCount(validators); + result.Should().Equal(validators); } [Fact] @@ -84,12 +98,12 @@ public void DerivedSpecificationValidatorCanAlterDefaultValidators() { var validator = new SpecificationValidatorDerived(); - var state = ValidatorsOf(validator); - state.Should().HaveCount(4); - state[0].Should().BeOfType(); - state[1].Should().BeOfType(); - state[2].Should().BeOfType(); - state[3].Should().BeOfType(); + var result = ValidatorsOf(validator); + result.Should().HaveCount(4); + result[0].Should().BeOfType(); + result[1].Should().BeOfType(); + result[2].Should().BeOfType(); + result[3].Should().BeOfType(); } private class SpecificationValidatorDerived : SpecificationValidator diff --git a/tests/QuerySpecification.Tests/Validators/WhereValidatorTests.cs b/tests/QuerySpecification.Tests/Validators/WhereValidatorTests.cs index e862d5e..b81a4ac 100644 --- a/tests/QuerySpecification.Tests/Validators/WhereValidatorTests.cs +++ b/tests/QuerySpecification.Tests/Validators/WhereValidatorTests.cs @@ -6,6 +6,18 @@ public class WhereValidatorTests public record Customer(int Id, string Name); + [Fact] + public void ReturnsTrue_GivenEmptySpec() + { + var customer = new Customer(1, "Customer1"); + + var spec = new Specification(); + + var result = _validator.IsValid(customer, spec); + + result.Should().BeTrue(); + } + [Fact] public void ReturnsTrue_GivenSpecWithSingleWhere_WithValidEntity() {