Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -26,6 +26,15 @@ public IQueryable<T> GetQuery<T>(IQueryable<T> query, ISpecification<T> specific
}


// We'll never reach this point for our specifications.
// This is just to cover the case where users have custom ISpecification<T> implementation but use our evaluator.
// We'll fall back to LINQ for this case.

foreach (var searchGroup in specification.SearchCriterias.GroupBy(x => x.SearchGroup))
{
query = query.ApplyLikesAsOrGroup(searchGroup);
}

return query;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
using System.Diagnostics;
using System.Reflection;

#if NET9_0_OR_GREATER
using System.Runtime.CompilerServices;
#endif

namespace Ardalis.Specification.EntityFrameworkCore;

public static class SearchExtension
Expand Down Expand Up @@ -39,6 +43,9 @@ public static IQueryable<T> ApplySingleLike<T>(this IQueryable<T> source, Search
return source.Where(Expression.Lambda<Func<T, bool>>(likeExpr, param));
}

#if NET9_0_OR_GREATER
[OverloadResolutionPriority(1)]
#endif
public static IQueryable<T> ApplyLikesAsOrGroup<T>(this IQueryable<T> source, ReadOnlySpan<SearchExpressionInfo<T>> searchExpressions)
{
Debug.Assert(_likeMethodInfo is not null);
Expand All @@ -49,37 +56,64 @@ public static IQueryable<T> ApplyLikesAsOrGroup<T>(this IQueryable<T> source, Re

foreach (var searchExpression in searchExpressions)
{
mainParam ??= searchExpression.Selector.Parameters[0];

var selectorExpr = searchExpression.Selector.Body;
if (mainParam != searchExpression.Selector.Parameters[0])
{
visitor ??= new ParameterReplacerVisitor(searchExpression.Selector.Parameters[0], mainParam);

// If there are more than 2 search items, we want to avoid creating a new visitor instance (saving 32 bytes per instance).
// We're in a sequential loop, no concurrency issues.
visitor.Update(searchExpression.Selector.Parameters[0], mainParam);
selectorExpr = visitor.Visit(selectorExpr);
}

var patternExpr = StringAsExpression(searchExpression.SearchTerm);

var likeExpr = Expression.Call(
null,
_likeMethodInfo,
_functions,
selectorExpr,
patternExpr);

combinedExpr = combinedExpr is null
? likeExpr
: Expression.OrElse(combinedExpr, likeExpr);
ApplyLikeAsOrGroup(ref combinedExpr, ref mainParam, ref visitor, searchExpression);
}

return combinedExpr is null || mainParam is null
? source
: source.Where(Expression.Lambda<Func<T, bool>>(combinedExpr, mainParam));
}

public static IQueryable<T> ApplyLikesAsOrGroup<T>(this IQueryable<T> source, IEnumerable<SearchExpressionInfo<T>> searchExpressions)
{
Debug.Assert(_likeMethodInfo is not null);

Expression? combinedExpr = null;
ParameterExpression? mainParam = null;
ParameterReplacerVisitor? visitor = null;

foreach (var searchExpression in searchExpressions)
{
ApplyLikeAsOrGroup(ref combinedExpr, ref mainParam, ref visitor, searchExpression);
}

return combinedExpr is null || mainParam is null
? source
: source.Where(Expression.Lambda<Func<T, bool>>(combinedExpr, mainParam));
}

private static void ApplyLikeAsOrGroup<T>(
ref Expression? combinedExpr,
ref ParameterExpression? mainParam,
ref ParameterReplacerVisitor? visitor,
SearchExpressionInfo<T> searchExpression)
{
mainParam ??= searchExpression.Selector.Parameters[0];

var selectorExpr = searchExpression.Selector.Body;
if (mainParam != searchExpression.Selector.Parameters[0])
{
visitor ??= new ParameterReplacerVisitor(searchExpression.Selector.Parameters[0], mainParam);

// If there are more than 2 search items, we want to avoid creating a new visitor instance (saving 32 bytes per instance).
// We're in a sequential loop, no concurrency issues.
visitor.Update(searchExpression.Selector.Parameters[0], mainParam);
selectorExpr = visitor.Visit(selectorExpr);
}

var patternExpr = StringAsExpression(searchExpression.SearchTerm);

var likeExpr = Expression.Call(
null,
_likeMethodInfo,
_functions,
selectorExpr,
patternExpr);

combinedExpr = combinedExpr is null
? likeExpr
: Expression.OrElse(combinedExpr, likeExpr);
}
}

public sealed class ParameterReplacerVisitor : ExpressionVisitor
Expand Down
1 change: 1 addition & 0 deletions src/Ardalis.Specification/Ardalis.Specification.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<InternalsVisibleTo Include="Ardalis.Specification.EntityFrameworkCore" />
<InternalsVisibleTo Include="Ardalis.Specification.EntityFramework6" />
<InternalsVisibleTo Include="Ardalis.Specification.Tests" />
<InternalsVisibleTo Include="Ardalis.Specification.EntityFrameworkCore.Tests" />
</ItemGroup>

</Project>
9 changes: 9 additions & 0 deletions src/Ardalis.Specification/Evaluators/SearchMemoryEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ public IEnumerable<T> Evaluate<T>(IEnumerable<T> query, ISpecification<T> specif
return new SpecLikeIterator<T>(query, spec.OneOrManySearchExpressions.List);
}

// We'll never reach this point for our specifications.
// This is just to cover the case where users have custom ISpecification<T> implementation but use our evaluator.
// We'll fall back to LINQ for this case.

foreach (var searchGroup in specification.SearchCriterias.GroupBy(x => x.SearchGroup))
{
query = query.Where(x => searchGroup.Any(c => c.SelectorFunc(x)?.Like(c.SearchTerm) ?? false));
}

return query;
}

Expand Down
9 changes: 9 additions & 0 deletions src/Ardalis.Specification/Validators/SearchValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ public bool IsValid<T>(T entity, ISpecification<T> specification)
return IsValid(entity, spec.OneOrManySearchExpressions.List);
}

// We'll never reach this point for our specifications.
// This is just to cover the case where users have custom ISpecification<T> implementation but use our validator.
// We'll fall back to LINQ for this case.

foreach (var searchGroup in specification.SearchCriterias.GroupBy(x => x.SearchGroup))
{
if (searchGroup.Any(c => c.SelectorFunc(entity)?.Like(c.SearchTerm) ?? false) == false) return false;
}

return true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
namespace Tests.Evaluators;

// This is a special case where users have custom ISpecification<T> implementation but use our evaluator.
[Collection("SharedCollection")]
public class SearchEvaluatorCustomSpecTests(TestFactory factory) : IntegrationTest(factory)
{
private static readonly SearchEvaluator _evaluator = SearchEvaluator.Instance;

[Fact]
public void QueriesMatch_GivenNoSearch()
{
var spec = new CustomSpecification<Store>();
spec.Where.Add(new WhereExpressionInfo<Store>(x => x.Id > 0));

var actual = _evaluator.GetQuery(DbContext.Stores, spec)
.ToQueryString();

var expected = DbContext.Stores
.ToQueryString();

actual.Should().Be(expected);
}

[Fact]
public void QueriesMatch_GivenSingleSearch()
{
var storeTerm = "ab1";

var spec = new CustomSpecification<Store>();
spec.Where.Add(new WhereExpressionInfo<Store>(x => x.Id > 0));
spec.Search.Add(new SearchExpressionInfo<Store>(x => x.Name, $"%{storeTerm}%"));

var actual = _evaluator.GetQuery(DbContext.Stores, spec)
.ToQueryString();

var expected = DbContext.Stores
.Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%"))
.ToQueryString();

actual.Should().Be(expected);
}

[Fact]
public void QueriesMatch_GivenMultipleSearch()
{
var storeTerm = "ab1";
var companyTerm = "ab2";
var countryTerm = "ab3";
var streetTerm = "ab4";

var spec = new CustomSpecification<Store>();
spec.Where.Add(new WhereExpressionInfo<Store>(x => x.Id > 0));
spec.Search.Add(new SearchExpressionInfo<Store>(x => x.Name, $"%{storeTerm}%"));
spec.Search.Add(new SearchExpressionInfo<Store>(x => x.Company.Name, $"%{companyTerm}%"));
spec.Search.Add(new SearchExpressionInfo<Store>(x => x.Address.Street, $"%{streetTerm}%", 2));
spec.Search.Add(new SearchExpressionInfo<Store>(x => x.Company.Country.Name, $"%{countryTerm}%", 3));

var actual = _evaluator.GetQuery(DbContext.Stores, spec)
.ToQueryString();

var expected = DbContext.Stores
.Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%")
|| EF.Functions.Like(x.Company.Name, $"%{companyTerm}%"))
.Where(x => EF.Functions.Like(x.Address.Street, $"%{streetTerm}%"))
.Where(x => EF.Functions.Like(x.Company.Country.Name, $"%{countryTerm}%"))
.ToQueryString();

actual.Should().Be(expected);
}

public class CustomSpecification<T> : ISpecification<T>
{
public List<WhereExpressionInfo<T>> Where { get; set; } = new();
public List<SearchExpressionInfo<T>> Search { get; set; } = new();
public IEnumerable<SearchExpressionInfo<T>> SearchCriterias => Search;
public IEnumerable<WhereExpressionInfo<T>> WhereExpressions => Where;

public ISpecificationBuilder<T> Query => throw new NotImplementedException();
public IEnumerable<OrderExpressionInfo<T>> OrderExpressions => throw new NotImplementedException();
public IEnumerable<IncludeExpressionInfo> IncludeExpressions => throw new NotImplementedException();
public IEnumerable<string> IncludeStrings => throw new NotImplementedException();
public Dictionary<string, object> Items => throw new NotImplementedException();
public int Take => throw new NotImplementedException();
public int Skip => throw new NotImplementedException();
public Func<IEnumerable<T>, IEnumerable<T>>? PostProcessingAction => throw new NotImplementedException();
public IEnumerable<string> QueryTags => throw new NotImplementedException();
public bool CacheEnabled => throw new NotImplementedException();
public string? CacheKey => throw new NotImplementedException();
public bool AsTracking => throw new NotImplementedException();
public bool AsNoTracking => throw new NotImplementedException();
public bool AsSplitQuery => throw new NotImplementedException();
public bool AsNoTrackingWithIdentityResolution => throw new NotImplementedException();
public bool IgnoreQueryFilters => throw new NotImplementedException();
public bool IgnoreAutoIncludes => throw new NotImplementedException();
public IEnumerable<T> Evaluate(IEnumerable<T> entities)
=> throw new NotImplementedException();
public bool IsSatisfiedBy(T entity)
=> throw new NotImplementedException();

void ISpecification<T>.CopyTo(Specification<T> otherSpec)
{
throw new NotImplementedException();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,46 @@
using System.Runtime.InteropServices;

namespace Tests.Evaluators;
namespace Tests.Evaluators;

[Collection("SharedCollection")]
public class SearchExtensionTests(TestFactory factory) : IntegrationTest(factory)
{
[Fact]
public void QueriesMatch_GivenSpecWithMultipleSearch()
public void QueriesMatch_GivenMultipleSearchAsSpan()
{
var storeTerm = "ab1";
var companyTerm = "ab2";

var spec = new Specification<Store>();
spec.Query
.Search(x11 => x11.Name, $"%{storeTerm}%")
.Search(x22 => x22.Company.Name, $"%{companyTerm}%");
var array = new SearchExpressionInfo<Store>[]
{
new(x => x.Name, $"%{storeTerm}%"),
new(x => x.Company.Name, $"%{companyTerm}%")
};

var actual = DbContext.Stores
.ApplyLikesAsOrGroup(array)
.ToQueryString();

var expected = DbContext.Stores
.Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%")
|| EF.Functions.Like(x.Company.Name, $"%{companyTerm}%"))
.ToQueryString();

actual.Should().Be(expected);
}

[Fact]
public void QueriesMatch_GivenMultipleSearchAsEnumerable()
{
var storeTerm = "ab1";
var companyTerm = "ab2";

var list = spec.SearchCriterias as List<SearchExpressionInfo<Store>>;
var span = CollectionsMarshal.AsSpan(list);
var array = new SearchExpressionInfo<Store>[]
{
new(x => x.Name, $"%{storeTerm}%"),
new(x => x.Company.Name, $"%{companyTerm}%")
};

var actual = DbContext.Stores
.ApplyLikesAsOrGroup(span)
.ApplyLikesAsOrGroup(array.AsEnumerable())
.ToQueryString();

var expected = DbContext.Stores
Expand All @@ -32,15 +52,31 @@ public void QueriesMatch_GivenSpecWithMultipleSearch()
}

[Fact]
public void QueriesMatch_GivenEmptySpec()
public void QueriesMatch_GivenEmptyAsSpan()
{
var spec = new Specification<Store>();

var array = Array.Empty<SearchExpressionInfo<Store>>();

var actual = DbContext.Stores
.ApplyLikesAsOrGroup(array)
.ToQueryString();

var expected = DbContext.Stores
.ToQueryString();

actual.Should().Be(expected);
}

[Fact]
public void QueriesMatch_GivenEmptyAsEnumerable()
{
var spec = new Specification<Store>();

var list = spec.SearchCriterias as List<SearchExpressionInfo<Store>>;
var span = CollectionsMarshal.AsSpan(list);
var array = Array.Empty<SearchExpressionInfo<Store>>();

var actual = DbContext.Stores
.ApplyLikesAsOrGroup(span)
.ApplyLikesAsOrGroup(array.AsEnumerable())
.ToQueryString();

var expected = DbContext.Stores
Expand Down
Loading