Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,23 @@ private SearchEvaluator() { }

public IQueryable<T> GetQuery<T>(IQueryable<T> query, ISpecification<T> specification) where T : class
{
if (specification.SearchCriterias is List<SearchExpressionInfo<T>> { Count: > 0 } list)
if (specification is Specification<T> 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<SearchExpressionInfo<T>> list)
{
var span = CollectionsMarshal.AsSpan(list);
return ApplyLike(query, span);
}
}


return query;
}

Expand Down
71 changes: 68 additions & 3 deletions src/Ardalis.Specification/Evaluators/SearchMemoryEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,80 @@ private SearchMemoryEvaluator() { }

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

if (spec.OneOrManySearchExpressions.SingleOrDefault is { } searchExpression)
{
return new SpecSingleLikeIterator<T>(query, searchExpression);
}

if (spec.OneOrManySearchExpressions.Values is List<SearchExpressionInfo<T>> list)
{
// The search expressions are already sorted by SearchGroup.
return new SpecLikeIterator<T>(query, list);
}
}

return query;
}

private sealed class SpecSingleLikeIterator<TSource> : Iterator<TSource>
{
private readonly IEnumerable<TSource> _source;
private readonly SearchExpressionInfo<TSource> _searchExpression;

private IEnumerator<TSource>? _enumerator;

public SpecSingleLikeIterator(IEnumerable<TSource> source, SearchExpressionInfo<TSource> searchExpression)
{
_source = source;
_searchExpression = searchExpression;
}

public override Iterator<TSource> Clone()
=> new SpecSingleLikeIterator<TSource>(_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<TSource> : Iterator<TSource>
{
private readonly IEnumerable<TSource> _source;
Expand Down
14 changes: 14 additions & 0 deletions src/Ardalis.Specification/Expressions/SearchExpressionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,17 @@ public SearchExpressionInfo(Expression<Func<T, string?>> selector, string search
/// </summary>
public Func<T, string?> SelectorFunc => _selectorFunc ??= Selector.Compile();
}

internal sealed class SearchExpressionComparer<T> : IComparer<SearchExpressionInfo<T>>
{
public static readonly SearchExpressionComparer<T> Default = new();
private SearchExpressionComparer() { }

public int Compare(SearchExpressionInfo<T>? x, SearchExpressionInfo<T>? 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);
}
}
4 changes: 2 additions & 2 deletions src/Ardalis.Specification/Internals/OneOrMany.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public void AddSorted(T item, IComparer<T> comparer)

if (_value is List<T> 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);
Expand All @@ -57,7 +57,7 @@ public void AddSorted(T item, IComparer<T> comparer)

if (_value is T singleValue)
{
if (comparer.Compare(item, singleValue) <= 0)
if (comparer.Compare(item, singleValue) < 0)
{
_value = new List<T>(DEFAULT_CAPACITY) { item, singleValue };
}
Expand Down
31 changes: 6 additions & 25 deletions src/Ardalis.Specification/Specification.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public class Specification<T> : ISpecification<T>
// 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<WhereExpressionInfo<T>> _whereExpressions = new();
private List<SearchExpressionInfo<T>>? _searchExpressions;
private OneOrMany<SearchExpressionInfo<T>> _searchExpressions = new();
private OneOrMany<OrderExpressionInfo<T>> _orderExpressions = new();
private OneOrMany<IncludeExpressionInfo> _includeExpressions = new();
private OneOrMany<string> _includeStrings = new();
Expand Down Expand Up @@ -94,27 +94,7 @@ public class Specification<T> : ISpecification<T>
internal void Add(OrderExpressionInfo<T> orderExpression) => _orderExpressions.Add(orderExpression);
internal void Add(IncludeExpressionInfo includeExpression) => _includeExpressions.Add(includeExpression);
internal void Add(string includeString) => _includeStrings.Add(includeString);
internal void Add(SearchExpressionInfo<T> 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<T> searchExpression) => _searchExpressions.AddSorted(searchExpression, SearchExpressionComparer<T>.Default);
internal void AddQueryTag(string queryTag) => _queryTags.Add(queryTag);

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

/// <inheritdoc/>
public IEnumerable<SearchExpressionInfo<T>> SearchCriterias => _searchExpressions ?? Enumerable.Empty<SearchExpressionInfo<T>>();
public IEnumerable<SearchExpressionInfo<T>> SearchCriterias => _searchExpressions.Values;

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

internal OneOrMany<WhereExpressionInfo<T>> OneOrManyWhereExpressions => _whereExpressions;
internal OneOrMany<SearchExpressionInfo<T>> OneOrManySearchExpressions => _searchExpressions;
internal OneOrMany<OrderExpressionInfo<T>> OneOrManyOrderExpressions => _orderExpressions;
internal OneOrMany<IncludeExpressionInfo> OneOrManyIncludeExpressions => _includeExpressions;
internal OneOrMany<string> OneOrManyIncludeStrings => _includeStrings;
Expand Down Expand Up @@ -194,9 +175,9 @@ void ISpecification<T>.CopyTo(Specification<T> otherSpec)
otherSpec._orderExpressions = _orderExpressions.Clone();
}

if (_searchExpressions is not null)
if (!_searchExpressions.IsEmpty)
{
otherSpec._searchExpressions = _searchExpressions.ToList();
otherSpec._searchExpressions = _searchExpressions.Clone();
}

if (!_queryTags.IsEmpty)
Expand Down
16 changes: 13 additions & 3 deletions src/Ardalis.Specification/Validators/SearchValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,20 @@ private SearchValidator() { }

public bool IsValid<T>(T entity, ISpecification<T> specification)
{
if (specification.SearchCriterias is List<SearchExpressionInfo<T>> { Count: > 0 } list)
if (specification is Specification<T> spec)
{
// The search expressions are already sorted by SearchGroup.
return IsValid<T>(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<SearchExpressionInfo<T>> list)
{
// The search expressions are already sorted by SearchGroup.
return IsValid<T>(entity, list);
}
}

return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ public class SearchMemoryEvaluatorTests

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

[Fact]
public void Filters_GivenSingleSearch()
{
List<Customer> input =
[
new(1, "axxa", "axya"),
new(2, "aaaa", "aaaa"),
new(3, "aaaa", "axya"),
new(4, "aaaa", null)
];

List<Customer> expected =
[
new(1, "axxa", "axya"),
];

var spec = new Specification<Customer>();
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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Customer>();
spec.Query
.Search(x => x.LastName, $"%{term}%");

var result = _validator.IsValid(customer, spec);

result.Should().BeFalse();
}

[Fact]
public void ReturnsTrue_GivenSpecWithMultipleSearchSameGroup_WithValidEntity()
{
Expand Down