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
@@ -1,4 +1,6 @@
namespace Ardalis.Specification.EntityFrameworkCore;
using System.Runtime.InteropServices;

namespace Ardalis.Specification.EntityFrameworkCore;

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

public IQueryable<T> GetQuery<T>(IQueryable<T> query, ISpecification<T> specification) where T : class
{
foreach (var searchCriteria in specification.SearchCriterias.GroupBy(x => x.SearchGroup))
if (specification.SearchCriterias is List<SearchExpressionInfo<T>> { Count: > 0 } list)
{
query = query.Search(searchCriteria);
// 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)
{
return query.ApplySingleLike(list[0]);
}
else
{
var span = CollectionsMarshal.AsSpan(list);
return ApplyLike(query, span);
}
}

return query;
}

private static IQueryable<T> ApplyLike<T>(IQueryable<T> source, ReadOnlySpan<SearchExpressionInfo<T>> span) where T : class
{
var groupStart = 0;
for (var i = 1; i <= span.Length; i++)
{
// If we reached the end of the span or the group has changed, we slice and process the group.
if (i == span.Length || span[i].SearchGroup != span[groupStart].SearchGroup)
{
source = source.ApplyLikesAsOrGroup(span[groupStart..i]);
groupStart = i;
}
}
return source;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.Data;
using System.Diagnostics;
using System.Reflection;

namespace Ardalis.Specification.EntityFrameworkCore;

public static class SearchExtension
{
private static readonly MethodInfo _likeMethodInfo = typeof(DbFunctionsExtensions)
.GetMethod(nameof(DbFunctionsExtensions.Like), [typeof(DbFunctions), typeof(string), typeof(string)])!;

private static readonly MemberExpression _functions = Expression.Property(null, typeof(EF).GetProperty(nameof(EF.Functions))!);

// It's required so EF can generate parameterized query.
// In the past I've been creating closures for this, e.g. var patternAsExpression = ((Expression<Func<string>>)(() => pattern)).Body;
// But, that allocates 168 bytes. So, this is more efficient way.
private static MemberExpression StringAsExpression(string value) => Expression.Property(
Expression.Constant(new StringVar(value)),
typeof(StringVar).GetProperty(nameof(StringVar.Format))!);

// We'll name the property Format just so we match the produced SQL query parameter name (in case of interpolated strings).
private record StringVar(string Format);

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

var param = searchExpression.Selector.Parameters[0];
var selectorExpr = searchExpression.Selector.Body;
var patternExpr = StringAsExpression(searchExpression.SearchTerm);

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

return source.Where(Expression.Lambda<Func<T, bool>>(likeExpr, param));
}

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

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

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);
}

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

public sealed class ParameterReplacerVisitor : ExpressionVisitor
{
private ParameterExpression _oldParameter;
private Expression _newExpression;

public ParameterReplacerVisitor(ParameterExpression oldParameter, Expression newExpression) =>
(_oldParameter, _newExpression) = (oldParameter, newExpression);

internal void Update(ParameterExpression oldParameter, Expression newExpression) =>
(_oldParameter, _newExpression) = (oldParameter, newExpression);

protected override Expression VisitParameter(ParameterExpression node) =>
node == _oldParameter ? _newExpression : node;
}

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
//using System.Linq.Expressions;
using System.Linq.Expressions;

//namespace Tests.Evaluators;
namespace Tests.Evaluators;

//public class ParameterReplacerVisitorTests
//{
// [Fact]
// public void ReturnsExpressionWithReplacedParameter()
// {
// Expression<Func<int, decimal, bool>> expected = (y, z) => y == 1;
public class ParameterReplacerVisitorTests
{
[Fact]
public void ReturnsExpressionWithReplacedParameter()
{
Expression<Func<int, decimal, bool>> expected = (y, z) => y == 1;

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

// var visitor = new ParameterReplacerVisitor(oldParameter, newExpression);
// var result = visitor.Visit(expression);
var visitor = new ParameterReplacerVisitor(oldParameter, newExpression);
var result = visitor.Visit(expression);

// result.ToString().Should().Be(expected.ToString());
// }
//}
result.ToString().Should().Be(expected.ToString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[Collection("SharedCollection")]
public class SearchEvaluatorTests(TestFactory factory) : IntegrationTest(factory)
{
private static readonly Ardalis.Specification.EntityFrameworkCore.SearchEvaluator _evaluator = Ardalis.Specification.EntityFrameworkCore.SearchEvaluator.Instance;
private static readonly SearchEvaluator _evaluator = SearchEvaluator.Instance;

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

var actual = _evaluator.GetQuery(DbContext.Stores, spec)
.ToQueryString()
.Replace("__criteria_SearchTerm_", "__Format_");
.ToQueryString();

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

// TODO: Fix this case. [fatii, 11/02/2025]
//[Fact]
//public void QueriesMatch_GivenMultipleSearch()
//{
// var storeTerm = "ab1";
// var companyTerm = "ab2";
// var countryTerm = "ab3";
// var streetTerm = "ab4";
[Fact]
public void QueriesMatch_GivenMultipleSearch()
{
var storeTerm = "ab1";
var companyTerm = "ab2";
var countryTerm = "ab3";
var streetTerm = "ab4";

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

// var actual = _evaluator.GetQuery(DbContext.Stores, spec)
// .ToQueryString()
// .Replace("__criteria_SearchTerm_", "__Format_");
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();
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);
//}
actual.Should().Be(expected);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Runtime.InteropServices;

namespace Tests.Evaluators;

[Collection("SharedCollection")]
public class SearchExtensionTests(TestFactory factory) : IntegrationTest(factory)
{
[Fact]
public void QueriesMatch_GivenSpecWithMultipleSearch()
{
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 list = spec.SearchCriterias as List<SearchExpressionInfo<Store>>;
var span = CollectionsMarshal.AsSpan(list);

var actual = DbContext.Stores
.ApplyLikesAsOrGroup(span)
.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_GivenEmptySpec()
{
var spec = new Specification<Store>();

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

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

var expected = DbContext.Stores
.ToQueryString();

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