Skip to content

Commit 0e7a338

Browse files
authored
Improve search validator and in-memory evaluator. (#443)
* WIP. First draft. * Refactored SearchMemoryEvaluator. * Added allocation free SearchValidator implementation. * Improved the nullability in search expressions. * Cleanup. * Minor fixes.
1 parent c0485f8 commit 0e7a338

File tree

14 files changed

+350
-80
lines changed

14 files changed

+350
-80
lines changed

Directory.Packages.props

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<Project>
2+
23
<PropertyGroup>
34
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
45
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
@@ -10,8 +11,10 @@
1011
<PackageVersion Include="EntityFramework" Version="6.5.1" />
1112
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
1213
<PackageVersion Include="System.Data.DataSetExtensions" Version="4.5.0" />
14+
<PackageVersion Include="System.Buffers" Version="4.6.0" />
15+
<PackageVersion Include="System.Memory" Version="4.6.0" />
1316
</ItemGroup>
14-
17+
1518
<ItemGroup Label="Test projects dependencies">
1619
<PackageVersion Include="ManagedObjectSize.ObjectPool" Version="0.0.7-gd53ba9da59" />
1720
<PackageVersion Include="MartinCostello.SqlLocalDb" Version="3.4.0" />

src/Ardalis.Specification/Ardalis.Specification.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,9 @@
1717
</PackageReleaseNotes>
1818
</PropertyGroup>
1919

20+
<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0' ">
21+
<PackageReference Include="System.Buffers" />
22+
<PackageReference Include="System.Memory" />
23+
</ItemGroup>
24+
2025
</Project>

src/Ardalis.Specification/Builders/Builder_Search.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public static partial class SpecificationBuilderExtensions
1616
/// <returns>The updated specification builder.</returns>
1717
public static ISpecificationBuilder<T, TResult> Search<T, TResult>(
1818
this ISpecificationBuilder<T, TResult> builder,
19-
Expression<Func<T, string>> keySelector,
19+
Expression<Func<T, string?>> keySelector,
2020
string pattern,
2121
int group = 1) where T : class
2222
=> Search(builder, keySelector, pattern, true, group);
@@ -34,7 +34,7 @@ public static ISpecificationBuilder<T, TResult> Search<T, TResult>(
3434
/// <returns>The updated specification builder.</returns>
3535
public static ISpecificationBuilder<T, TResult> Search<T, TResult>(
3636
this ISpecificationBuilder<T, TResult> builder,
37-
Expression<Func<T, string>> keySelector,
37+
Expression<Func<T, string?>> keySelector,
3838
string pattern,
3939
bool condition,
4040
int group = 1) where T : class
@@ -59,7 +59,7 @@ public static ISpecificationBuilder<T, TResult> Search<T, TResult>(
5959
/// <returns>The updated specification builder.</returns>
6060
public static ISpecificationBuilder<T> Search<T>(
6161
this ISpecificationBuilder<T> builder,
62-
Expression<Func<T, string>> keySelector,
62+
Expression<Func<T, string?>> keySelector,
6363
string pattern,
6464
int group = 1) where T : class
6565
=> Search(builder, keySelector, pattern, true, group);
@@ -76,7 +76,7 @@ public static ISpecificationBuilder<T> Search<T>(
7676
/// <returns>The updated specification builder.</returns>
7777
public static ISpecificationBuilder<T> Search<T>(
7878
this ISpecificationBuilder<T> builder,
79-
Expression<Func<T, string>> keySelector,
79+
Expression<Func<T, string?>> keySelector,
8080
string pattern,
8181
bool condition,
8282
int group = 1) where T : class

src/Ardalis.Specification/Evaluators/InMemorySpecificationEvaluator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public InMemorySpecificationEvaluator()
1212
Evaluators.AddRange(new IInMemoryEvaluator[]
1313
{
1414
WhereEvaluator.Instance,
15-
SearchEvaluator.Instance,
15+
SearchMemoryEvaluator.Instance,
1616
OrderEvaluator.Instance,
1717
PaginationEvaluator.Instance
1818
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.Collections;
2+
using System.Diagnostics.CodeAnalysis;
3+
4+
namespace Ardalis.Specification;
5+
6+
internal abstract class Iterator<TSource> : IEnumerable<TSource>, IEnumerator<TSource>
7+
{
8+
private readonly int _threadId = Environment.CurrentManagedThreadId;
9+
10+
private protected int _state;
11+
private protected TSource _current = default!;
12+
13+
public Iterator<TSource> GetEnumerator()
14+
{
15+
var enumerator = _state == 0 && _threadId == Environment.CurrentManagedThreadId ? this : Clone();
16+
enumerator._state = 1;
17+
return enumerator;
18+
}
19+
20+
public abstract Iterator<TSource> Clone();
21+
public abstract bool MoveNext();
22+
23+
public TSource Current => _current;
24+
object? IEnumerator.Current => Current;
25+
IEnumerator<TSource> IEnumerable<TSource>.GetEnumerator() => GetEnumerator();
26+
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
27+
28+
[ExcludeFromCodeCoverage]
29+
void IEnumerator.Reset() => throw new NotSupportedException();
30+
31+
public virtual void Dispose()
32+
{
33+
_current = default!;
34+
_state = -1;
35+
}
36+
}

src/Ardalis.Specification/Evaluators/SearchEvaluator.cs

Lines changed: 0 additions & 17 deletions
This file was deleted.

src/Ardalis.Specification/Evaluators/SearchExtension.cs

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,81 @@
1-
using System.Text.RegularExpressions;
1+
using System.Collections.Concurrent;
2+
using System.Diagnostics.CodeAnalysis;
3+
using System.Text.RegularExpressions;
24

35
namespace Ardalis.Specification;
46

57
public static class SearchExtension
68
{
9+
private static readonly RegexCache _regexCache = new();
10+
11+
private static Regex BuildRegex(string pattern)
12+
{
13+
// Escape special regex characters, excluding those handled separately
14+
var regexPattern = Regex
15+
.Escape(pattern)
16+
.Replace("%", ".*") // Translate SQL LIKE wildcard '%' to regex '.*'
17+
.Replace("_", ".") // Translate SQL LIKE wildcard '_' to regex '.'
18+
.Replace(@"\[", "[") // Unescape '[' as it's used for character classes/ranges
19+
.Replace(@"\^", "^"); // Unescape '^' as it can be used for negation in character classes
20+
21+
// Ensure the pattern matches the entire string
22+
regexPattern = "^" + regexPattern + "$";
23+
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
24+
return regex;
25+
}
26+
727
public static bool Like(this string input, string pattern)
828
{
929
try
1030
{
11-
return SqlLike(input, pattern);
31+
// The pattern is dynamic and arbitrary, the consumer might even compose it by an end-user input.
32+
// We can not cache all Regex objects, but at least we can try to reuse the most "recent" ones. We'll cache 10 of them.
33+
// This might improve the performance within the same closed loop for the in-memory evaluator and validator.
34+
35+
var regex = _regexCache.GetOrAdd(pattern, BuildRegex);
36+
return regex.IsMatch(input);
1237
}
1338
catch (Exception ex)
1439
{
1540
throw new InvalidSearchPatternException(pattern, ex);
1641
}
1742
}
1843

19-
private static bool SqlLike(this string input, string pattern)
44+
private class RegexCache
2045
{
21-
// Escape special regex characters, excluding those handled separately
22-
var regexPattern = Regex.Escape(pattern)
23-
.Replace("%", ".*") // Translate SQL LIKE wildcard '%' to regex '.*'
24-
.Replace("_", ".") // Translate SQL LIKE wildcard '_' to regex '.'
25-
.Replace(@"\[", "[") // Unescape '[' as it's used for character classes/ranges
26-
.Replace(@"\^", "^"); // Unescape '^' as it can be used for negation in character classes
46+
private const int MAX_SIZE = 10;
47+
private readonly ConcurrentDictionary<string, Regex> _dictionary = new();
2748

28-
// Ensure the pattern matches the entire string
29-
regexPattern = "^" + regexPattern + "$";
30-
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase);
49+
public Regex GetOrAdd(string key, Func<string, Regex> valueFactory)
50+
{
51+
if (_dictionary.TryGetValue(key, out var regex))
52+
return regex;
3153

32-
return regex.IsMatch(input);
54+
// It might happen we end up with more items than max (concurrency), but we won't be too strict.
55+
// We're just trying to avoid indefinite growth.
56+
for (int i = _dictionary.Count - MAX_SIZE; i >= 0; i--)
57+
{
58+
// Avoid being smart, just remove sequentially from the start.
59+
var firstKey = _dictionary.Keys.FirstOrDefault();
60+
if (firstKey is not null)
61+
{
62+
_dictionary.TryRemove(firstKey, out _);
63+
}
64+
65+
}
66+
67+
var newRegex = valueFactory(key);
68+
_dictionary.TryAdd(key, newRegex);
69+
return newRegex;
70+
}
3371
}
3472

73+
#pragma warning disable IDE0051 // Remove unused private members
3574
// This C# implementation of SQL Like operator is based on the following SO post https://stackoverflow.com/a/8583383/10577116
3675
// It covers almost all of the scenarios, and it's faster than regex based implementations.
3776
// It may fail/throw in some very specific and edge cases, hence, wrap it in try/catch.
3877
// UPDATE: it returns incorrect results for some obvious cases.
39-
// More details in this issue https://github.com/ardalis/Specification/issues/390
78+
[ExcludeFromCodeCoverage] // Dead code. Keeping it just as a reference
4079
private static bool SqlLikeOption2(string str, string pattern)
4180
{
4281
var isMatch = true;
@@ -169,4 +208,5 @@ private static bool SqlLikeOption2(string str, string pattern)
169208
}
170209
return isMatch && endOfPattern;
171210
}
211+
#pragma warning restore IDE0051
172212
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using System.Diagnostics;
2+
3+
namespace Ardalis.Specification;
4+
5+
public class SearchMemoryEvaluator : IInMemoryEvaluator
6+
{
7+
private SearchMemoryEvaluator() { }
8+
public static SearchMemoryEvaluator Instance { get; } = new SearchMemoryEvaluator();
9+
10+
public IEnumerable<T> Evaluate<T>(IEnumerable<T> query, ISpecification<T> specification)
11+
{
12+
if (specification.SearchCriterias is List<SearchExpressionInfo<T>> { Count: > 0 } list)
13+
{
14+
// The search expressions are already sorted by SearchGroup.
15+
return new SpecLikeIterator<T>(query, list);
16+
}
17+
18+
return query;
19+
}
20+
21+
private sealed class SpecLikeIterator<TSource> : Iterator<TSource>
22+
{
23+
private readonly IEnumerable<TSource> _source;
24+
private readonly List<SearchExpressionInfo<TSource>> _searchExpressions;
25+
26+
private IEnumerator<TSource>? _enumerator;
27+
28+
public SpecLikeIterator(IEnumerable<TSource> source, List<SearchExpressionInfo<TSource>> searchExpressions)
29+
{
30+
_source = source;
31+
_searchExpressions = searchExpressions;
32+
}
33+
34+
public override Iterator<TSource> Clone()
35+
=> new SpecLikeIterator<TSource>(_source, _searchExpressions);
36+
37+
public override void Dispose()
38+
{
39+
if (_enumerator is not null)
40+
{
41+
_enumerator.Dispose();
42+
_enumerator = null;
43+
}
44+
base.Dispose();
45+
}
46+
47+
public override bool MoveNext()
48+
{
49+
switch (_state)
50+
{
51+
case 1:
52+
_enumerator = _source.GetEnumerator();
53+
_state = 2;
54+
goto case 2;
55+
case 2:
56+
Debug.Assert(_enumerator is not null);
57+
var searchExpressions = _searchExpressions;
58+
while (_enumerator!.MoveNext())
59+
{
60+
TSource sourceItem = _enumerator.Current;
61+
if (IsValid(sourceItem, searchExpressions))
62+
{
63+
_current = sourceItem;
64+
return true;
65+
}
66+
}
67+
68+
Dispose();
69+
break;
70+
}
71+
72+
return false;
73+
}
74+
75+
// This would be simpler using Span<SearchExpressionInfo<TSource>>
76+
// but CollectionsMarshal.AsSpan is not available in .NET Standard 2.0
77+
private static bool IsValid<T>(T sourceItem, List<SearchExpressionInfo<T>> list)
78+
{
79+
var groupStart = 0;
80+
for (var i = 1; i <= list.Count; i++)
81+
{
82+
// If we reached the end of the list or the group has changed, we slice and process the group.
83+
if (i == list.Count || list[i].SearchGroup != list[groupStart].SearchGroup)
84+
{
85+
if (IsValidInOrGroup(sourceItem, list, groupStart, i) is false)
86+
{
87+
return false;
88+
}
89+
groupStart = i;
90+
}
91+
}
92+
return true;
93+
94+
static bool IsValidInOrGroup(T sourceItem, List<SearchExpressionInfo<T>> list, int from, int to)
95+
{
96+
var validOrGroup = false;
97+
for (int i = from; i < to; i++)
98+
{
99+
if (list[i].SelectorFunc(sourceItem)?.Like(list[i].SearchTerm) ?? false)
100+
{
101+
validOrGroup = true;
102+
break;
103+
}
104+
}
105+
return validOrGroup;
106+
}
107+
}
108+
}
109+
}

src/Ardalis.Specification/Expressions/SearchExpressionInfo.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
/// <typeparam name="T">Type of the source from which search target should be selected.</typeparam>
77
public class SearchExpressionInfo<T>
88
{
9-
private readonly Lazy<Func<T, string>> _selectorFunc;
9+
private readonly Lazy<Func<T, string?>> _selectorFunc;
1010

1111
/// <summary>
1212
/// Creates instance of <see cref="SearchExpressionInfo{T}" />.
@@ -16,7 +16,7 @@ public class SearchExpressionInfo<T>
1616
/// <param name="searchGroup">The index used to group sets of Selectors and SearchTerms together.</param>
1717
/// <exception cref="ArgumentNullException">If <paramref name="selector"/> is null.</exception>
1818
/// <exception cref="ArgumentNullException">If <paramref name="searchTerm"/> is null or empty.</exception>
19-
public SearchExpressionInfo(Expression<Func<T, string>> selector, string searchTerm, int searchGroup = 1)
19+
public SearchExpressionInfo(Expression<Func<T, string?>> selector, string searchTerm, int searchGroup = 1)
2020
{
2121
_ = selector ?? throw new ArgumentNullException(nameof(selector));
2222
if (string.IsNullOrEmpty(searchTerm)) throw new ArgumentException("The search term can not be null or empty.");
@@ -25,13 +25,13 @@ public SearchExpressionInfo(Expression<Func<T, string>> selector, string searchT
2525
SearchTerm = searchTerm;
2626
SearchGroup = searchGroup;
2727

28-
_selectorFunc = new Lazy<Func<T, string>>(Selector.Compile);
28+
_selectorFunc = new Lazy<Func<T, string?>>(Selector.Compile);
2929
}
3030

3131
/// <summary>
3232
/// The property to apply the SQL LIKE against.
3333
/// </summary>
34-
public Expression<Func<T, string>> Selector { get; }
34+
public Expression<Func<T, string?>> Selector { get; }
3535

3636
/// <summary>
3737
/// The value to use for the SQL LIKE.
@@ -46,5 +46,5 @@ public SearchExpressionInfo(Expression<Func<T, string>> selector, string searchT
4646
/// <summary>
4747
/// Compiled <see cref="Selector" />.
4848
/// </summary>
49-
public Func<T, string> SelectorFunc => _selectorFunc.Value;
49+
public Func<T, string?> SelectorFunc => _selectorFunc.Value;
5050
}

0 commit comments

Comments
 (0)