Skip to content

Commit aa4dd63

Browse files
authored
Refactor the state of search expressions as OneOrMany. (#511)
* Refactor the state of search expressions as OneOrMany. * Fix typo.
1 parent 50ec01a commit aa4dd63

File tree

8 files changed

+154
-38
lines changed

8 files changed

+154
-38
lines changed

src/Ardalis.Specification.EntityFrameworkCore/Evaluators/SearchEvaluator.cs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,23 @@ private SearchEvaluator() { }
1111

1212
public IQueryable<T> GetQuery<T>(IQueryable<T> query, ISpecification<T> specification) where T : class
1313
{
14-
if (specification.SearchCriterias is List<SearchExpressionInfo<T>> { Count: > 0 } list)
14+
if (specification is Specification<T> spec)
1515
{
16-
// Specs with a single Like are the most common. We can optimize for this case to avoid all the additional overhead.
17-
if (list.Count == 1)
16+
if (spec.OneOrManySearchExpressions.IsEmpty) return query;
17+
18+
if (spec.OneOrManySearchExpressions.SingleOrDefault is { } searchExpression)
1819
{
19-
return query.ApplySingleLike(list[0]);
20+
return query.ApplySingleLike(searchExpression);
2021
}
21-
else
22+
23+
if (spec.OneOrManySearchExpressions.Values is List<SearchExpressionInfo<T>> list)
2224
{
2325
var span = CollectionsMarshal.AsSpan(list);
2426
return ApplyLike(query, span);
2527
}
2628
}
2729

30+
2831
return query;
2932
}
3033

src/Ardalis.Specification/Evaluators/SearchMemoryEvaluator.cs

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,80 @@ private SearchMemoryEvaluator() { }
99

1010
public IEnumerable<T> Evaluate<T>(IEnumerable<T> query, ISpecification<T> specification)
1111
{
12-
if (specification.SearchCriterias is List<SearchExpressionInfo<T>> { Count: > 0 } list)
12+
if (specification is Specification<T> spec)
1313
{
14-
// The search expressions are already sorted by SearchGroup.
15-
return new SpecLikeIterator<T>(query, list);
14+
if (spec.OneOrManySearchExpressions.IsEmpty) return query;
15+
16+
if (spec.OneOrManySearchExpressions.SingleOrDefault is { } searchExpression)
17+
{
18+
return new SpecSingleLikeIterator<T>(query, searchExpression);
19+
}
20+
21+
if (spec.OneOrManySearchExpressions.Values is List<SearchExpressionInfo<T>> list)
22+
{
23+
// The search expressions are already sorted by SearchGroup.
24+
return new SpecLikeIterator<T>(query, list);
25+
}
1626
}
1727

1828
return query;
1929
}
2030

31+
private sealed class SpecSingleLikeIterator<TSource> : Iterator<TSource>
32+
{
33+
private readonly IEnumerable<TSource> _source;
34+
private readonly SearchExpressionInfo<TSource> _searchExpression;
35+
36+
private IEnumerator<TSource>? _enumerator;
37+
38+
public SpecSingleLikeIterator(IEnumerable<TSource> source, SearchExpressionInfo<TSource> searchExpression)
39+
{
40+
_source = source;
41+
_searchExpression = searchExpression;
42+
}
43+
44+
public override Iterator<TSource> Clone()
45+
=> new SpecSingleLikeIterator<TSource>(_source, _searchExpression);
46+
47+
public override void Dispose()
48+
{
49+
if (_enumerator is not null)
50+
{
51+
_enumerator.Dispose();
52+
_enumerator = null;
53+
}
54+
base.Dispose();
55+
}
56+
57+
public override bool MoveNext()
58+
{
59+
switch (_state)
60+
{
61+
case 1:
62+
_enumerator = _source.GetEnumerator();
63+
_state = 2;
64+
goto case 2;
65+
case 2:
66+
Debug.Assert(_enumerator is not null);
67+
var searchExpression = _searchExpression;
68+
while (_enumerator!.MoveNext())
69+
{
70+
TSource sourceItem = _enumerator.Current;
71+
if (searchExpression.SelectorFunc(sourceItem)?.Like(searchExpression.SearchTerm) ?? false)
72+
{
73+
_current = sourceItem;
74+
return true;
75+
}
76+
}
77+
78+
Dispose();
79+
break;
80+
}
81+
82+
return false;
83+
}
84+
}
85+
2186
private sealed class SpecLikeIterator<TSource> : Iterator<TSource>
2287
{
2388
private readonly IEnumerable<TSource> _source;

src/Ardalis.Specification/Expressions/SearchExpressionInfo.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,17 @@ public SearchExpressionInfo(Expression<Func<T, string?>> selector, string search
4646
/// </summary>
4747
public Func<T, string?> SelectorFunc => _selectorFunc ??= Selector.Compile();
4848
}
49+
50+
internal sealed class SearchExpressionComparer<T> : IComparer<SearchExpressionInfo<T>>
51+
{
52+
public static readonly SearchExpressionComparer<T> Default = new();
53+
private SearchExpressionComparer() { }
54+
55+
public int Compare(SearchExpressionInfo<T>? x, SearchExpressionInfo<T>? y)
56+
{
57+
if (ReferenceEquals(x, y)) return 0;
58+
if (x is null) return -1;
59+
if (y is null) return 1;
60+
return x.SearchGroup.CompareTo(y.SearchGroup);
61+
}
62+
}

src/Ardalis.Specification/Internals/OneOrMany.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public void AddSorted(T item, IComparer<T> comparer)
4343

4444
if (_value is List<T> list)
4545
{
46-
var index = list.FindIndex(x => comparer.Compare(item, x) <= 0);
46+
var index = list.FindIndex(x => comparer.Compare(item, x) < 0);
4747
if (index == -1)
4848
{
4949
list.Add(item);
@@ -57,7 +57,7 @@ public void AddSorted(T item, IComparer<T> comparer)
5757

5858
if (_value is T singleValue)
5959
{
60-
if (comparer.Compare(item, singleValue) <= 0)
60+
if (comparer.Compare(item, singleValue) < 0)
6161
{
6262
_value = new List<T>(DEFAULT_CAPACITY) { item, singleValue };
6363
}

src/Ardalis.Specification/Specification.cs

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public class Specification<T> : ISpecification<T>
3939
// The state is null initially, but we're spending 8 bytes per reference (on x64).
4040
// This will be reconsidered for version 10 where we may store the whole state as a single array of structs.
4141
private OneOrMany<WhereExpressionInfo<T>> _whereExpressions = new();
42-
private List<SearchExpressionInfo<T>>? _searchExpressions;
42+
private OneOrMany<SearchExpressionInfo<T>> _searchExpressions = new();
4343
private OneOrMany<OrderExpressionInfo<T>> _orderExpressions = new();
4444
private OneOrMany<IncludeExpressionInfo> _includeExpressions = new();
4545
private OneOrMany<string> _includeStrings = new();
@@ -94,27 +94,7 @@ public class Specification<T> : ISpecification<T>
9494
internal void Add(OrderExpressionInfo<T> orderExpression) => _orderExpressions.Add(orderExpression);
9595
internal void Add(IncludeExpressionInfo includeExpression) => _includeExpressions.Add(includeExpression);
9696
internal void Add(string includeString) => _includeStrings.Add(includeString);
97-
internal void Add(SearchExpressionInfo<T> searchExpression)
98-
{
99-
if (_searchExpressions is null)
100-
{
101-
_searchExpressions = new(DEFAULT_CAPACITY_SEARCH) { searchExpression };
102-
return;
103-
}
104-
105-
// We'll keep the search expressions sorted by the search group.
106-
// 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.
107-
// Having multiple search groups is not a common scenario, and usually there may be just few search expressions.
108-
var index = _searchExpressions.FindIndex(x => x.SearchGroup > searchExpression.SearchGroup);
109-
if (index == -1)
110-
{
111-
_searchExpressions.Add(searchExpression);
112-
}
113-
else
114-
{
115-
_searchExpressions.Insert(index, searchExpression);
116-
}
117-
}
97+
internal void Add(SearchExpressionInfo<T> searchExpression) => _searchExpressions.AddSorted(searchExpression, SearchExpressionComparer<T>.Default);
11898
internal void AddQueryTag(string queryTag) => _queryTags.Add(queryTag);
11999

120100
/// <inheritdoc/>
@@ -124,7 +104,7 @@ internal void Add(SearchExpressionInfo<T> searchExpression)
124104
public IEnumerable<WhereExpressionInfo<T>> WhereExpressions => _whereExpressions.Values;
125105

126106
/// <inheritdoc/>
127-
public IEnumerable<SearchExpressionInfo<T>> SearchCriterias => _searchExpressions ?? Enumerable.Empty<SearchExpressionInfo<T>>();
107+
public IEnumerable<SearchExpressionInfo<T>> SearchCriterias => _searchExpressions.Values;
128108

129109
/// <inheritdoc/>
130110
public IEnumerable<OrderExpressionInfo<T>> OrderExpressions => _orderExpressions.Values;
@@ -139,6 +119,7 @@ internal void Add(SearchExpressionInfo<T> searchExpression)
139119
public IEnumerable<string> QueryTags => _queryTags.Values;
140120

141121
internal OneOrMany<WhereExpressionInfo<T>> OneOrManyWhereExpressions => _whereExpressions;
122+
internal OneOrMany<SearchExpressionInfo<T>> OneOrManySearchExpressions => _searchExpressions;
142123
internal OneOrMany<OrderExpressionInfo<T>> OneOrManyOrderExpressions => _orderExpressions;
143124
internal OneOrMany<IncludeExpressionInfo> OneOrManyIncludeExpressions => _includeExpressions;
144125
internal OneOrMany<string> OneOrManyIncludeStrings => _includeStrings;
@@ -194,9 +175,9 @@ void ISpecification<T>.CopyTo(Specification<T> otherSpec)
194175
otherSpec._orderExpressions = _orderExpressions.Clone();
195176
}
196177

197-
if (_searchExpressions is not null)
178+
if (!_searchExpressions.IsEmpty)
198179
{
199-
otherSpec._searchExpressions = _searchExpressions.ToList();
180+
otherSpec._searchExpressions = _searchExpressions.Clone();
200181
}
201182

202183
if (!_queryTags.IsEmpty)

src/Ardalis.Specification/Validators/SearchValidator.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,20 @@ private SearchValidator() { }
77

88
public bool IsValid<T>(T entity, ISpecification<T> specification)
99
{
10-
if (specification.SearchCriterias is List<SearchExpressionInfo<T>> { Count: > 0 } list)
10+
if (specification is Specification<T> spec)
1111
{
12-
// The search expressions are already sorted by SearchGroup.
13-
return IsValid<T>(entity, list);
12+
if (spec.OneOrManySearchExpressions.IsEmpty) return true;
13+
14+
if (spec.OneOrManySearchExpressions.SingleOrDefault is { } searchExpression)
15+
{
16+
return searchExpression.SelectorFunc(entity)?.Like(searchExpression.SearchTerm) ?? false;
17+
}
18+
19+
if (spec.OneOrManySearchExpressions.Values is List<SearchExpressionInfo<T>> list)
20+
{
21+
// The search expressions are already sorted by SearchGroup.
22+
return IsValid<T>(entity, list);
23+
}
1424
}
1525

1626
return true;

tests/Ardalis.Specification.Tests/Evaluators/SearchMemoryEvaluatorTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,34 @@ public class SearchMemoryEvaluatorTests
66

77
public record Customer(int Id, string FirstName, string? LastName);
88

9+
[Fact]
10+
public void Filters_GivenSingleSearch()
11+
{
12+
List<Customer> input =
13+
[
14+
new(1, "axxa", "axya"),
15+
new(2, "aaaa", "aaaa"),
16+
new(3, "aaaa", "axya"),
17+
new(4, "aaaa", null)
18+
];
19+
20+
List<Customer> expected =
21+
[
22+
new(1, "axxa", "axya"),
23+
];
24+
25+
var spec = new Specification<Customer>();
26+
spec.Query
27+
.Search(x => x.FirstName, "%xx%");
28+
29+
// Not materializing with ToList() intentionally to test cloning in the iterator
30+
var actual = _evaluator.Evaluate(input, spec);
31+
32+
// Multiple iterations will force cloning
33+
actual.Should().HaveSameCount(expected);
34+
actual.Should().Equal(expected);
35+
}
36+
937
[Fact]
1038
public void Filters_GivenSearchInSameGroup()
1139
{

tests/Ardalis.Specification.Tests/Validators/SearchValidatorTests.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ public void ReturnsFalse_GivenSpecWithSingleSearch_WithInvalidEntity()
6262
result.Should().BeFalse();
6363
}
6464

65+
[Fact]
66+
public void ReturnsFalse_GivenSpecWithSingleSearch_WithNullProperty()
67+
{
68+
var customer = new Customer(1, "FirstName1", null);
69+
70+
var term = "irst";
71+
var spec = new Specification<Customer>();
72+
spec.Query
73+
.Search(x => x.LastName, $"%{term}%");
74+
75+
var result = _validator.IsValid(customer, spec);
76+
77+
result.Should().BeFalse();
78+
}
79+
6580
[Fact]
6681
public void ReturnsTrue_GivenSpecWithMultipleSearchSameGroup_WithValidEntity()
6782
{

0 commit comments

Comments
 (0)