Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<Project>

<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
Expand All @@ -10,8 +11,10 @@
<PackageVersion Include="EntityFramework" Version="6.5.1" />
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
<PackageVersion Include="System.Data.DataSetExtensions" Version="4.5.0" />
<PackageVersion Include="System.Buffers" Version="4.6.0" />
<PackageVersion Include="System.Memory" Version="4.6.0" />
</ItemGroup>

<ItemGroup Label="Test projects dependencies">
<PackageVersion Include="ManagedObjectSize.ObjectPool" Version="0.0.7-gd53ba9da59" />
<PackageVersion Include="MartinCostello.SqlLocalDb" Version="3.4.0" />
Expand Down
5 changes: 5 additions & 0 deletions src/Ardalis.Specification/Ardalis.Specification.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,9 @@
</PackageReleaseNotes>
</PropertyGroup>

<ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0' ">
<PackageReference Include="System.Buffers" />
<PackageReference Include="System.Memory" />
</ItemGroup>

</Project>
8 changes: 4 additions & 4 deletions src/Ardalis.Specification/Builders/Builder_Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public static partial class SpecificationBuilderExtensions
/// <returns>The updated specification builder.</returns>
public static ISpecificationBuilder<T, TResult> Search<T, TResult>(
this ISpecificationBuilder<T, TResult> builder,
Expression<Func<T, string>> keySelector,
Expression<Func<T, string?>> keySelector,
string pattern,
int group = 1) where T : class
=> Search(builder, keySelector, pattern, true, group);
Expand All @@ -34,7 +34,7 @@ public static ISpecificationBuilder<T, TResult> Search<T, TResult>(
/// <returns>The updated specification builder.</returns>
public static ISpecificationBuilder<T, TResult> Search<T, TResult>(
this ISpecificationBuilder<T, TResult> builder,
Expression<Func<T, string>> keySelector,
Expression<Func<T, string?>> keySelector,
string pattern,
bool condition,
int group = 1) where T : class
Expand All @@ -59,7 +59,7 @@ public static ISpecificationBuilder<T, TResult> Search<T, TResult>(
/// <returns>The updated specification builder.</returns>
public static ISpecificationBuilder<T> Search<T>(
this ISpecificationBuilder<T> builder,
Expression<Func<T, string>> keySelector,
Expression<Func<T, string?>> keySelector,
string pattern,
int group = 1) where T : class
=> Search(builder, keySelector, pattern, true, group);
Expand All @@ -76,7 +76,7 @@ public static ISpecificationBuilder<T> Search<T>(
/// <returns>The updated specification builder.</returns>
public static ISpecificationBuilder<T> Search<T>(
this ISpecificationBuilder<T> builder,
Expression<Func<T, string>> keySelector,
Expression<Func<T, string?>> keySelector,
string pattern,
bool condition,
int group = 1) where T : class
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public InMemorySpecificationEvaluator()
Evaluators.AddRange(new IInMemoryEvaluator[]
{
WhereEvaluator.Instance,
SearchEvaluator.Instance,
SearchMemoryEvaluator.Instance,
OrderEvaluator.Instance,
PaginationEvaluator.Instance
});
Expand Down
36 changes: 36 additions & 0 deletions src/Ardalis.Specification/Evaluators/Iterator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;

namespace Ardalis.Specification;

internal abstract class Iterator<TSource> : IEnumerable<TSource>, IEnumerator<TSource>
{
private readonly int _threadId = Environment.CurrentManagedThreadId;

private protected int _state;
private protected TSource _current = default!;

public Iterator<TSource> GetEnumerator()
{
var enumerator = _state == 0 && _threadId == Environment.CurrentManagedThreadId ? this : Clone();
enumerator._state = 1;
return enumerator;
}

public abstract Iterator<TSource> Clone();
public abstract bool MoveNext();

public TSource Current => _current;
object? IEnumerator.Current => Current;
IEnumerator<TSource> IEnumerable<TSource>.GetEnumerator() => GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

[ExcludeFromCodeCoverage]
void IEnumerator.Reset() => throw new NotSupportedException();

public virtual void Dispose()
{
_current = default!;
_state = -1;
}
}
17 changes: 0 additions & 17 deletions src/Ardalis.Specification/Evaluators/SearchEvaluator.cs

This file was deleted.

68 changes: 54 additions & 14 deletions src/Ardalis.Specification/Evaluators/SearchExtension.cs
Original file line number Diff line number Diff line change
@@ -1,42 +1,81 @@
using System.Text.RegularExpressions;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;

namespace Ardalis.Specification;

public static class SearchExtension
{
private static readonly RegexCache _regexCache = new();

private static Regex BuildRegex(string pattern)
{
// Escape special regex characters, excluding those handled separately
var regexPattern = Regex
.Escape(pattern)
.Replace("%", ".*") // Translate SQL LIKE wildcard '%' to regex '.*'
.Replace("_", ".") // Translate SQL LIKE wildcard '_' to regex '.'
.Replace(@"\[", "[") // Unescape '[' as it's used for character classes/ranges
.Replace(@"\^", "^"); // Unescape '^' as it can be used for negation in character classes

// Ensure the pattern matches the entire string
regexPattern = "^" + regexPattern + "$";
var regex = new Regex(regexPattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
return regex;
}

public static bool Like(this string input, string pattern)
{
try
{
return SqlLike(input, pattern);
// The pattern is dynamic and arbitrary, the consumer might even compose it by an end-user input.
// 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.
// This might improve the performance within the same closed loop for the in-memory evaluator and validator.

var regex = _regexCache.GetOrAdd(pattern, BuildRegex);
return regex.IsMatch(input);
}
catch (Exception ex)
{
throw new InvalidSearchPatternException(pattern, ex);
}
}

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

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

return regex.IsMatch(input);
// 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 - MAX_SIZE; i >= 0; i--)
{
// Avoid being smart, just remove sequentially from the start.
var firstKey = _dictionary.Keys.FirstOrDefault();
if (firstKey is not null)
{
_dictionary.TryRemove(firstKey, out _);
}

}

var newRegex = valueFactory(key);
_dictionary.TryAdd(key, newRegex);
return newRegex;
}
}

#pragma warning disable IDE0051 // Remove unused private members
// This C# implementation of SQL Like operator is based on the following SO post https://stackoverflow.com/a/8583383/10577116
// 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.
// More details in this issue https://github.com/ardalis/Specification/issues/390
[ExcludeFromCodeCoverage] // Dead code. Keeping it just as a reference
private static bool SqlLikeOption2(string str, string pattern)
{
var isMatch = true;
Expand Down Expand Up @@ -169,4 +208,5 @@ private static bool SqlLikeOption2(string str, string pattern)
}
return isMatch && endOfPattern;
}
#pragma warning restore IDE0051
}
109 changes: 109 additions & 0 deletions src/Ardalis.Specification/Evaluators/SearchMemoryEvaluator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Diagnostics;

namespace Ardalis.Specification;

public class SearchMemoryEvaluator : IInMemoryEvaluator
{
private SearchMemoryEvaluator() { }
public static SearchMemoryEvaluator Instance { get; } = new SearchMemoryEvaluator();

public IEnumerable<T> Evaluate<T>(IEnumerable<T> query, ISpecification<T> specification)
{
if (specification.SearchCriterias is List<SearchExpressionInfo<T>> { Count: > 0 } list)
{
// The search expressions are already sorted by SearchGroup.
return new SpecLikeIterator<T>(query, list);
}

return query;
}

private sealed class SpecLikeIterator<TSource> : Iterator<TSource>
{
private readonly IEnumerable<TSource> _source;
private readonly List<SearchExpressionInfo<TSource>> _searchExpressions;

private IEnumerator<TSource>? _enumerator;

public SpecLikeIterator(IEnumerable<TSource> source, List<SearchExpressionInfo<TSource>> searchExpressions)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not switching to primary constructor syntax yet?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been hesitant because of the older TFMs. We need to check whether we need any polyfills or not (I assume not).

{
_source = source;
_searchExpressions = searchExpressions;
}

public override Iterator<TSource> Clone()
=> new SpecLikeIterator<TSource>(_source, _searchExpressions);

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 searchExpressions = _searchExpressions;
while (_enumerator!.MoveNext())
{
TSource sourceItem = _enumerator.Current;
if (IsValid(sourceItem, searchExpressions))
{
_current = sourceItem;
return true;
}
}

Dispose();
break;
}

return false;
}

// This would be simpler using Span<SearchExpressionInfo<TSource>>
// but CollectionsMarshal.AsSpan is not available in .NET Standard 2.0
private static bool IsValid<T>(T sourceItem, List<SearchExpressionInfo<T>> list)
{
var groupStart = 0;
for (var i = 1; i <= list.Count; i++)
{
// If we reached the end of the list or the group has changed, we slice and process the group.
if (i == list.Count || list[i].SearchGroup != list[groupStart].SearchGroup)
{
if (IsValidInOrGroup(sourceItem, list, groupStart, i) is false)
{
return false;
}
groupStart = i;
}
}
return true;

static bool IsValidInOrGroup(T sourceItem, List<SearchExpressionInfo<T>> list, int from, int to)
{
var validOrGroup = false;
for (int i = from; i < to; i++)
{
if (list[i].SelectorFunc(sourceItem)?.Like(list[i].SearchTerm) ?? false)
{
validOrGroup = true;
break;
}
}
return validOrGroup;
}
}
}
}
10 changes: 5 additions & 5 deletions src/Ardalis.Specification/Expressions/SearchExpressionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
/// <typeparam name="T">Type of the source from which search target should be selected.</typeparam>
public class SearchExpressionInfo<T>
{
private readonly Lazy<Func<T, string>> _selectorFunc;
private readonly Lazy<Func<T, string?>> _selectorFunc;

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

_selectorFunc = new Lazy<Func<T, string>>(Selector.Compile);
_selectorFunc = new Lazy<Func<T, string?>>(Selector.Compile);
}

/// <summary>
/// The property to apply the SQL LIKE against.
/// </summary>
public Expression<Func<T, string>> Selector { get; }
public Expression<Func<T, string?>> Selector { get; }

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