diff --git a/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/SearchEvaluator.cs b/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/SearchEvaluator.cs index 6bd623b9..36344bb9 100644 --- a/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/SearchEvaluator.cs +++ b/src/Ardalis.Specification.EntityFrameworkCore/Evaluators/SearchEvaluator.cs @@ -11,20 +11,23 @@ private SearchEvaluator() { } public IQueryable GetQuery(IQueryable query, ISpecification specification) where T : class { - if (specification.SearchCriterias is List> { Count: > 0 } list) + if (specification is Specification spec) { - // Specs with a single Like are the most common. We can optimize for this case to avoid all the additional overhead. - if (list.Count == 1) + if (spec.OneOrManySearchExpressions.IsEmpty) return query; + + if (spec.OneOrManySearchExpressions.SingleOrDefault is { } searchExpression) { - return query.ApplySingleLike(list[0]); + return query.ApplySingleLike(searchExpression); } - else + + if (spec.OneOrManySearchExpressions.Values is List> list) { var span = CollectionsMarshal.AsSpan(list); return ApplyLike(query, span); } } + return query; } diff --git a/src/Ardalis.Specification/Evaluators/SearchMemoryEvaluator.cs b/src/Ardalis.Specification/Evaluators/SearchMemoryEvaluator.cs index 64e58553..5cdcddad 100644 --- a/src/Ardalis.Specification/Evaluators/SearchMemoryEvaluator.cs +++ b/src/Ardalis.Specification/Evaluators/SearchMemoryEvaluator.cs @@ -9,15 +9,80 @@ private SearchMemoryEvaluator() { } public IEnumerable Evaluate(IEnumerable query, ISpecification specification) { - if (specification.SearchCriterias is List> { Count: > 0 } list) + if (specification is Specification spec) { - // The search expressions are already sorted by SearchGroup. - return new SpecLikeIterator(query, list); + if (spec.OneOrManySearchExpressions.IsEmpty) return query; + + if (spec.OneOrManySearchExpressions.SingleOrDefault is { } searchExpression) + { + return new SpecSingleLikeIterator(query, searchExpression); + } + + if (spec.OneOrManySearchExpressions.Values is List> list) + { + // The search expressions are already sorted by SearchGroup. + return new SpecLikeIterator(query, list); + } } return query; } + private sealed class SpecSingleLikeIterator : Iterator + { + private readonly IEnumerable _source; + private readonly SearchExpressionInfo _searchExpression; + + private IEnumerator? _enumerator; + + public SpecSingleLikeIterator(IEnumerable source, SearchExpressionInfo searchExpression) + { + _source = source; + _searchExpression = searchExpression; + } + + public override Iterator Clone() + => new SpecSingleLikeIterator(_source, _searchExpression); + + public override void Dispose() + { + if (_enumerator is not null) + { + _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 searchExpression = _searchExpression; + while (_enumerator!.MoveNext()) + { + TSource sourceItem = _enumerator.Current; + if (searchExpression.SelectorFunc(sourceItem)?.Like(searchExpression.SearchTerm) ?? false) + { + _current = sourceItem; + return true; + } + } + + Dispose(); + break; + } + + return false; + } + } + private sealed class SpecLikeIterator : Iterator { private readonly IEnumerable _source; diff --git a/src/Ardalis.Specification/Expressions/SearchExpressionInfo.cs b/src/Ardalis.Specification/Expressions/SearchExpressionInfo.cs index f83392d3..3cb6b9fb 100644 --- a/src/Ardalis.Specification/Expressions/SearchExpressionInfo.cs +++ b/src/Ardalis.Specification/Expressions/SearchExpressionInfo.cs @@ -46,3 +46,17 @@ public SearchExpressionInfo(Expression> selector, string search /// public Func SelectorFunc => _selectorFunc ??= Selector.Compile(); } + +internal sealed class SearchExpressionComparer : IComparer> +{ + public static readonly SearchExpressionComparer Default = new(); + private SearchExpressionComparer() { } + + public int Compare(SearchExpressionInfo? x, SearchExpressionInfo? y) + { + if (ReferenceEquals(x, y)) return 0; + if (x is null) return -1; + if (y is null) return 1; + return x.SearchGroup.CompareTo(y.SearchGroup); + } +} diff --git a/src/Ardalis.Specification/Internals/OneOrMany.cs b/src/Ardalis.Specification/Internals/OneOrMany.cs index b6a3d98f..639a1d2f 100644 --- a/src/Ardalis.Specification/Internals/OneOrMany.cs +++ b/src/Ardalis.Specification/Internals/OneOrMany.cs @@ -43,7 +43,7 @@ public void AddSorted(T item, IComparer comparer) if (_value is List list) { - var index = list.FindIndex(x => comparer.Compare(item, x) <= 0); + var index = list.FindIndex(x => comparer.Compare(item, x) < 0); if (index == -1) { list.Add(item); @@ -57,7 +57,7 @@ public void AddSorted(T item, IComparer comparer) if (_value is T singleValue) { - if (comparer.Compare(item, singleValue) <= 0) + if (comparer.Compare(item, singleValue) < 0) { _value = new List(DEFAULT_CAPACITY) { item, singleValue }; } diff --git a/src/Ardalis.Specification/Specification.cs b/src/Ardalis.Specification/Specification.cs index a87cbb4e..5ff106af 100644 --- a/src/Ardalis.Specification/Specification.cs +++ b/src/Ardalis.Specification/Specification.cs @@ -39,7 +39,7 @@ public class Specification : ISpecification // The state is null initially, but we're spending 8 bytes per reference (on x64). // This will be reconsidered for version 10 where we may store the whole state as a single array of structs. private OneOrMany> _whereExpressions = new(); - private List>? _searchExpressions; + private OneOrMany> _searchExpressions = new(); private OneOrMany> _orderExpressions = new(); private OneOrMany _includeExpressions = new(); private OneOrMany _includeStrings = new(); @@ -94,27 +94,7 @@ public class Specification : ISpecification internal void Add(OrderExpressionInfo orderExpression) => _orderExpressions.Add(orderExpression); internal void Add(IncludeExpressionInfo includeExpression) => _includeExpressions.Add(includeExpression); internal void Add(string includeString) => _includeStrings.Add(includeString); - internal void Add(SearchExpressionInfo searchExpression) - { - if (_searchExpressions is null) - { - _searchExpressions = new(DEFAULT_CAPACITY_SEARCH) { searchExpression }; - return; - } - - // We'll keep the search expressions sorted by the search group. - // We could keep the state as SortedList instead of List, but it has additional 56 bytes overhead and it's not worth it for our use-case. - // Having multiple search groups is not a common scenario, and usually there may be just few search expressions. - var index = _searchExpressions.FindIndex(x => x.SearchGroup > searchExpression.SearchGroup); - if (index == -1) - { - _searchExpressions.Add(searchExpression); - } - else - { - _searchExpressions.Insert(index, searchExpression); - } - } + internal void Add(SearchExpressionInfo searchExpression) => _searchExpressions.AddSorted(searchExpression, SearchExpressionComparer.Default); internal void AddQueryTag(string queryTag) => _queryTags.Add(queryTag); /// @@ -124,7 +104,7 @@ internal void Add(SearchExpressionInfo searchExpression) public IEnumerable> WhereExpressions => _whereExpressions.Values; /// - public IEnumerable> SearchCriterias => _searchExpressions ?? Enumerable.Empty>(); + public IEnumerable> SearchCriterias => _searchExpressions.Values; /// public IEnumerable> OrderExpressions => _orderExpressions.Values; @@ -139,6 +119,7 @@ internal void Add(SearchExpressionInfo searchExpression) public IEnumerable QueryTags => _queryTags.Values; internal OneOrMany> OneOrManyWhereExpressions => _whereExpressions; + internal OneOrMany> OneOrManySearchExpressions => _searchExpressions; internal OneOrMany> OneOrManyOrderExpressions => _orderExpressions; internal OneOrMany OneOrManyIncludeExpressions => _includeExpressions; internal OneOrMany OneOrManyIncludeStrings => _includeStrings; @@ -194,9 +175,9 @@ void ISpecification.CopyTo(Specification otherSpec) otherSpec._orderExpressions = _orderExpressions.Clone(); } - if (_searchExpressions is not null) + if (!_searchExpressions.IsEmpty) { - otherSpec._searchExpressions = _searchExpressions.ToList(); + otherSpec._searchExpressions = _searchExpressions.Clone(); } if (!_queryTags.IsEmpty) diff --git a/src/Ardalis.Specification/Validators/SearchValidator.cs b/src/Ardalis.Specification/Validators/SearchValidator.cs index 54a4ed87..6b5a9f6e 100644 --- a/src/Ardalis.Specification/Validators/SearchValidator.cs +++ b/src/Ardalis.Specification/Validators/SearchValidator.cs @@ -7,10 +7,20 @@ private SearchValidator() { } public bool IsValid(T entity, ISpecification specification) { - if (specification.SearchCriterias is List> { Count: > 0 } list) + if (specification is Specification spec) { - // The search expressions are already sorted by SearchGroup. - return IsValid(entity, list); + if (spec.OneOrManySearchExpressions.IsEmpty) return true; + + if (spec.OneOrManySearchExpressions.SingleOrDefault is { } searchExpression) + { + return searchExpression.SelectorFunc(entity)?.Like(searchExpression.SearchTerm) ?? false; + } + + if (spec.OneOrManySearchExpressions.Values is List> list) + { + // The search expressions are already sorted by SearchGroup. + return IsValid(entity, list); + } } return true; diff --git a/tests/Ardalis.Specification.Tests/Evaluators/SearchMemoryEvaluatorTests.cs b/tests/Ardalis.Specification.Tests/Evaluators/SearchMemoryEvaluatorTests.cs index 51c1eca8..2e270636 100644 --- a/tests/Ardalis.Specification.Tests/Evaluators/SearchMemoryEvaluatorTests.cs +++ b/tests/Ardalis.Specification.Tests/Evaluators/SearchMemoryEvaluatorTests.cs @@ -6,6 +6,34 @@ public class SearchMemoryEvaluatorTests public record Customer(int Id, string FirstName, string? LastName); + [Fact] + public void Filters_GivenSingleSearch() + { + List input = + [ + new(1, "axxa", "axya"), + new(2, "aaaa", "aaaa"), + new(3, "aaaa", "axya"), + new(4, "aaaa", null) + ]; + + List expected = + [ + new(1, "axxa", "axya"), + ]; + + var spec = new Specification(); + spec.Query + .Search(x => x.FirstName, "%xx%"); + + // 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] public void Filters_GivenSearchInSameGroup() { diff --git a/tests/Ardalis.Specification.Tests/Validators/SearchValidatorTests.cs b/tests/Ardalis.Specification.Tests/Validators/SearchValidatorTests.cs index 37a692f0..21c7ee8e 100644 --- a/tests/Ardalis.Specification.Tests/Validators/SearchValidatorTests.cs +++ b/tests/Ardalis.Specification.Tests/Validators/SearchValidatorTests.cs @@ -62,6 +62,21 @@ public void ReturnsFalse_GivenSpecWithSingleSearch_WithInvalidEntity() result.Should().BeFalse(); } + [Fact] + public void ReturnsFalse_GivenSpecWithSingleSearch_WithNullProperty() + { + var customer = new Customer(1, "FirstName1", null); + + var term = "irst"; + var spec = new Specification(); + spec.Query + .Search(x => x.LastName, $"%{term}%"); + + var result = _validator.IsValid(customer, spec); + + result.Should().BeFalse(); + } + [Fact] public void ReturnsTrue_GivenSpecWithMultipleSearchSameGroup_WithValidEntity() {