Skip to content

Commit 35f3758

Browse files
authored
Optimized EF Search evaluator. (#444)
1 parent 0e7a338 commit 35f3758

File tree

7 files changed

+221
-124
lines changed

7 files changed

+221
-124
lines changed
Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
namespace Ardalis.Specification.EntityFrameworkCore;
1+
using System.Runtime.InteropServices;
2+
3+
namespace Ardalis.Specification.EntityFrameworkCore;
24

35
public class SearchEvaluator : IEvaluator
46
{
@@ -9,11 +11,35 @@ private SearchEvaluator() { }
911

1012
public IQueryable<T> GetQuery<T>(IQueryable<T> query, ISpecification<T> specification) where T : class
1113
{
12-
foreach (var searchCriteria in specification.SearchCriterias.GroupBy(x => x.SearchGroup))
14+
if (specification.SearchCriterias is List<SearchExpressionInfo<T>> { Count: > 0 } list)
1315
{
14-
query = query.Search(searchCriteria);
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)
18+
{
19+
return query.ApplySingleLike(list[0]);
20+
}
21+
else
22+
{
23+
var span = CollectionsMarshal.AsSpan(list);
24+
return ApplyLike(query, span);
25+
}
1526
}
1627

1728
return query;
1829
}
30+
31+
private static IQueryable<T> ApplyLike<T>(IQueryable<T> source, ReadOnlySpan<SearchExpressionInfo<T>> span) where T : class
32+
{
33+
var groupStart = 0;
34+
for (var i = 1; i <= span.Length; i++)
35+
{
36+
// If we reached the end of the span or the group has changed, we slice and process the group.
37+
if (i == span.Length || span[i].SearchGroup != span[groupStart].SearchGroup)
38+
{
39+
source = source.ApplyLikesAsOrGroup(span[groupStart..i]);
40+
groupStart = i;
41+
}
42+
}
43+
return source;
44+
}
1945
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using System.Data;
2+
using System.Diagnostics;
3+
using System.Reflection;
4+
5+
namespace Ardalis.Specification.EntityFrameworkCore;
6+
7+
public static class SearchExtension
8+
{
9+
private static readonly MethodInfo _likeMethodInfo = typeof(DbFunctionsExtensions)
10+
.GetMethod(nameof(DbFunctionsExtensions.Like), [typeof(DbFunctions), typeof(string), typeof(string)])!;
11+
12+
private static readonly MemberExpression _functions = Expression.Property(null, typeof(EF).GetProperty(nameof(EF.Functions))!);
13+
14+
// It's required so EF can generate parameterized query.
15+
// In the past I've been creating closures for this, e.g. var patternAsExpression = ((Expression<Func<string>>)(() => pattern)).Body;
16+
// But, that allocates 168 bytes. So, this is more efficient way.
17+
private static MemberExpression StringAsExpression(string value) => Expression.Property(
18+
Expression.Constant(new StringVar(value)),
19+
typeof(StringVar).GetProperty(nameof(StringVar.Format))!);
20+
21+
// We'll name the property Format just so we match the produced SQL query parameter name (in case of interpolated strings).
22+
private record StringVar(string Format);
23+
24+
public static IQueryable<T> ApplySingleLike<T>(this IQueryable<T> source, SearchExpressionInfo<T> searchExpression)
25+
{
26+
Debug.Assert(_likeMethodInfo is not null);
27+
28+
var param = searchExpression.Selector.Parameters[0];
29+
var selectorExpr = searchExpression.Selector.Body;
30+
var patternExpr = StringAsExpression(searchExpression.SearchTerm);
31+
32+
var likeExpr = Expression.Call(
33+
null,
34+
_likeMethodInfo,
35+
_functions,
36+
selectorExpr,
37+
patternExpr);
38+
39+
return source.Where(Expression.Lambda<Func<T, bool>>(likeExpr, param));
40+
}
41+
42+
public static IQueryable<T> ApplyLikesAsOrGroup<T>(this IQueryable<T> source, ReadOnlySpan<SearchExpressionInfo<T>> searchExpressions)
43+
{
44+
Debug.Assert(_likeMethodInfo is not null);
45+
46+
Expression? combinedExpr = null;
47+
ParameterExpression? mainParam = null;
48+
ParameterReplacerVisitor? visitor = null;
49+
50+
foreach (var searchExpression in searchExpressions)
51+
{
52+
mainParam ??= searchExpression.Selector.Parameters[0];
53+
54+
var selectorExpr = searchExpression.Selector.Body;
55+
if (mainParam != searchExpression.Selector.Parameters[0])
56+
{
57+
visitor ??= new ParameterReplacerVisitor(searchExpression.Selector.Parameters[0], mainParam);
58+
59+
// If there are more than 2 search items, we want to avoid creating a new visitor instance (saving 32 bytes per instance).
60+
// We're in a sequential loop, no concurrency issues.
61+
visitor.Update(searchExpression.Selector.Parameters[0], mainParam);
62+
selectorExpr = visitor.Visit(selectorExpr);
63+
}
64+
65+
var patternExpr = StringAsExpression(searchExpression.SearchTerm);
66+
67+
var likeExpr = Expression.Call(
68+
null,
69+
_likeMethodInfo,
70+
_functions,
71+
selectorExpr,
72+
patternExpr);
73+
74+
combinedExpr = combinedExpr is null
75+
? likeExpr
76+
: Expression.OrElse(combinedExpr, likeExpr);
77+
}
78+
79+
return combinedExpr is null || mainParam is null
80+
? source
81+
: source.Where(Expression.Lambda<Func<T, bool>>(combinedExpr, mainParam));
82+
}
83+
}
84+
85+
public sealed class ParameterReplacerVisitor : ExpressionVisitor
86+
{
87+
private ParameterExpression _oldParameter;
88+
private Expression _newExpression;
89+
90+
public ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression) =>
91+
(_oldParameter, _newExpression) = (oldParameter, newExpression);
92+
93+
internal void Update(ParameterExpression oldParameter, Expression newExpression) =>
94+
(_oldParameter, _newExpression) = (oldParameter, newExpression);
95+
96+
protected override Expression VisitParameter(ParameterExpression node) =>
97+
node == _oldParameter ? _newExpression : node;
98+
}
99+

src/Ardalis.Specification.EntityFrameworkCore/Extensions/ParameterReplacerVisitor.cs

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

src/Ardalis.Specification.EntityFrameworkCore/Extensions/SearchExtension.cs

Lines changed: 0 additions & 57 deletions
This file was deleted.
Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
//using System.Linq.Expressions;
1+
using System.Linq.Expressions;
22

3-
//namespace Tests.Evaluators;
3+
namespace Tests.Evaluators;
44

5-
//public class ParameterReplacerVisitorTests
6-
//{
7-
// [Fact]
8-
// public void ReturnsExpressionWithReplacedParameter()
9-
// {
10-
// Expression<Func<int, decimal, bool>> expected = (y, z) => y == 1;
5+
public class ParameterReplacerVisitorTests
6+
{
7+
[Fact]
8+
public void ReturnsExpressionWithReplacedParameter()
9+
{
10+
Expression<Func<int, decimal, bool>> expected = (y, z) => y == 1;
1111

12-
// Expression<Func<int, decimal, bool>> expression = (x, z) => x == 1;
13-
// var oldParameter = expression.Parameters[0];
14-
// var newExpression = Expression.Parameter(typeof(int), "y");
12+
Expression<Func<int, decimal, bool>> expression = (x, z) => x == 1;
13+
var oldParameter = expression.Parameters[0];
14+
var newExpression = Expression.Parameter(typeof(int), "y");
1515

16-
// var visitor = new ParameterReplacerVisitor(oldParameter, newExpression);
17-
// var result = visitor.Visit(expression);
16+
var visitor = new ParameterReplacerVisitor(oldParameter, newExpression);
17+
var result = visitor.Visit(expression);
1818

19-
// result.ToString().Should().Be(expected.ToString());
20-
// }
21-
//}
19+
result.ToString().Should().Be(expected.ToString());
20+
}
21+
}

tests/Ardalis.Specification.EntityFrameworkCore.Tests/Evaluators/SearchEvaluatorTests.cs

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[Collection("SharedCollection")]
44
public class SearchEvaluatorTests(TestFactory factory) : IntegrationTest(factory)
55
{
6-
private static readonly Ardalis.Specification.EntityFrameworkCore.SearchEvaluator _evaluator = Ardalis.Specification.EntityFrameworkCore.SearchEvaluator.Instance;
6+
private static readonly SearchEvaluator _evaluator = SearchEvaluator.Instance;
77

88
[Fact]
99
public void QueriesMatch_GivenNoSearch()
@@ -32,8 +32,7 @@ public void QueriesMatch_GivenSingleSearch()
3232
.Search(x => x.Name, $"%{storeTerm}%");
3333

3434
var actual = _evaluator.GetQuery(DbContext.Stores, spec)
35-
.ToQueryString()
36-
.Replace("__criteria_SearchTerm_", "__Format_");
35+
.ToQueryString();
3736

3837
var expected = DbContext.Stores
3938
.Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%"))
@@ -42,34 +41,32 @@ public void QueriesMatch_GivenSingleSearch()
4241
actual.Should().Be(expected);
4342
}
4443

45-
// TODO: Fix this case. [fatii, 11/02/2025]
46-
//[Fact]
47-
//public void QueriesMatch_GivenMultipleSearch()
48-
//{
49-
// var storeTerm = "ab1";
50-
// var companyTerm = "ab2";
51-
// var countryTerm = "ab3";
52-
// var streetTerm = "ab4";
44+
[Fact]
45+
public void QueriesMatch_GivenMultipleSearch()
46+
{
47+
var storeTerm = "ab1";
48+
var companyTerm = "ab2";
49+
var countryTerm = "ab3";
50+
var streetTerm = "ab4";
5351

54-
// var spec = new Specification<Store>();
55-
// spec.Query
56-
// .Where(x => x.Id > 0)
57-
// .Search(x => x.Name, $"%{storeTerm}%")
58-
// .Search(x => x.Company.Name, $"%{companyTerm}%")
59-
// .Search(x => x.Company.Country.Name, $"%{countryTerm}%", 3)
60-
// .Search(x => x.Address.Street, $"%{streetTerm}%", 2);
52+
var spec = new Specification<Store>();
53+
spec.Query
54+
.Where(x => x.Id > 0)
55+
.Search(x => x.Name, $"%{storeTerm}%")
56+
.Search(x => x.Company.Name, $"%{companyTerm}%")
57+
.Search(x => x.Company.Country.Name, $"%{countryTerm}%", 3)
58+
.Search(x => x.Address.Street, $"%{streetTerm}%", 2);
6159

62-
// var actual = _evaluator.GetQuery(DbContext.Stores, spec)
63-
// .ToQueryString()
64-
// .Replace("__criteria_SearchTerm_", "__Format_");
60+
var actual = _evaluator.GetQuery(DbContext.Stores, spec)
61+
.ToQueryString();
6562

66-
// var expected = DbContext.Stores
67-
// .Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%")
68-
// || EF.Functions.Like(x.Company.Name, $"%{companyTerm}%"))
69-
// .Where(x => EF.Functions.Like(x.Address.Street, $"%{streetTerm}%"))
70-
// .Where(x => EF.Functions.Like(x.Company.Country.Name, $"%{countryTerm}%"))
71-
// .ToQueryString();
63+
var expected = DbContext.Stores
64+
.Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%")
65+
|| EF.Functions.Like(x.Company.Name, $"%{companyTerm}%"))
66+
.Where(x => EF.Functions.Like(x.Address.Street, $"%{streetTerm}%"))
67+
.Where(x => EF.Functions.Like(x.Company.Country.Name, $"%{countryTerm}%"))
68+
.ToQueryString();
7269

73-
// actual.Should().Be(expected);
74-
//}
70+
actual.Should().Be(expected);
71+
}
7572
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using System.Runtime.InteropServices;
2+
3+
namespace Tests.Evaluators;
4+
5+
[Collection("SharedCollection")]
6+
public class SearchExtensionTests(TestFactory factory) : IntegrationTest(factory)
7+
{
8+
[Fact]
9+
public void QueriesMatch_GivenSpecWithMultipleSearch()
10+
{
11+
var storeTerm = "ab1";
12+
var companyTerm = "ab2";
13+
14+
var spec = new Specification<Store>();
15+
spec.Query
16+
.Search(x11 => x11.Name, $"%{storeTerm}%")
17+
.Search(x22 => x22.Company.Name, $"%{companyTerm}%");
18+
19+
var list = spec.SearchCriterias as List<SearchExpressionInfo<Store>>;
20+
var span = CollectionsMarshal.AsSpan(list);
21+
22+
var actual = DbContext.Stores
23+
.ApplyLikesAsOrGroup(span)
24+
.ToQueryString();
25+
26+
var expected = DbContext.Stores
27+
.Where(x => EF.Functions.Like(x.Name, $"%{storeTerm}%")
28+
|| EF.Functions.Like(x.Company.Name, $"%{companyTerm}%"))
29+
.ToQueryString();
30+
31+
actual.Should().Be(expected);
32+
}
33+
34+
[Fact]
35+
public void QueriesMatch_GivenEmptySpec()
36+
{
37+
var spec = new Specification<Store>();
38+
39+
var list = spec.SearchCriterias as List<SearchExpressionInfo<Store>>;
40+
var span = CollectionsMarshal.AsSpan(list);
41+
42+
var actual = DbContext.Stores
43+
.ApplyLikesAsOrGroup(span)
44+
.ToQueryString();
45+
46+
var expected = DbContext.Stores
47+
.ToQueryString();
48+
49+
actual.Should().Be(expected);
50+
}
51+
}

0 commit comments

Comments
 (0)