Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SQL LIKE implementation for the in-memory evaluator. #153

Merged
merged 2 commits into from
Dec 9, 2021
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 @@ -18,6 +18,7 @@ public InMemorySpecificationEvaluator()
this.evaluators.AddRange(new IInMemoryEvaluator[]
{
WhereEvaluator.Instance,
SearchEvaluator.Instance,
OrderEvaluator.Instance,
PaginationEvaluator.Instance
});
Expand All @@ -42,11 +43,6 @@ public virtual IEnumerable<TResult> Evaluate<T, TResult>(IEnumerable<T> source,

public virtual IEnumerable<T> Evaluate<T>(IEnumerable<T> source, ISpecification<T> specification)
{
if (specification.SearchCriterias.Count() > 0)
{
throw new NotSupportedException("The specification contains Search expressions and can't be evaluated with in-memory evaluator.");
}

foreach (var evaluator in evaluators)
{
source = evaluator.Evaluate(source, specification);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace Ardalis.Specification
{
public class SearchEvaluator : IInMemoryEvaluator
{
private SearchEvaluator() { }
public static SearchEvaluator Instance { get; } = new SearchEvaluator();

public IEnumerable<T> Evaluate<T>(IEnumerable<T> query, ISpecification<T> specification)
{
foreach (var searchGroup in specification.SearchCriterias.GroupBy(x => x.SearchGroup))
{
var criterias = searchGroup.Select(x => (x.Selector, x.SearchTerm));

query = query.Where(x => criterias.Any(c => c.Selector.Compile()(x).Like(c.SearchTerm)));
}

return query;
}
}
}
158 changes: 158 additions & 0 deletions Specification/src/Ardalis.Specification/Evaluators/SearchExtension.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace Ardalis.Specification
{
public static class SearchExtension
{
public static bool Like(this string input, string pattern)
{
try
{
return SqlLike(input, pattern);
}
catch (Exception)
{
throw new InvalidSearchPatternException(pattern);
}
}

// 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.
private static bool SqlLike(string str, string pattern)
{
bool isMatch = true,
isWildCardOn = false,
isCharWildCardOn = false,
isCharSetOn = false,
isNotCharSetOn = false,
endOfPattern = false;
int lastWildCard = -1;
int patternIndex = 0;
List<char> set = new List<char>();
char p = '\0';

for (int i = 0; i < str.Length; i++)
{
char c = str[i];
endOfPattern = (patternIndex >= pattern.Length);
if (!endOfPattern)
{
p = pattern[patternIndex];

if (!isWildCardOn && p == '%')
{
lastWildCard = patternIndex;
isWildCardOn = true;
while (patternIndex < pattern.Length &&
pattern[patternIndex] == '%')
{
patternIndex++;
}
if (patternIndex >= pattern.Length) p = '\0';
else p = pattern[patternIndex];
}
else if (p == '_')
{
isCharWildCardOn = true;
patternIndex++;
}
else if (p == '[')
{
if (pattern[++patternIndex] == '^')
{
isNotCharSetOn = true;
patternIndex++;
}
else isCharSetOn = true;

set.Clear();
if (pattern[patternIndex + 1] == '-' && pattern[patternIndex + 3] == ']')
{
char start = char.ToUpper(pattern[patternIndex]);
patternIndex += 2;
char end = char.ToUpper(pattern[patternIndex]);
if (start <= end)
{
for (char ci = start; ci <= end; ci++)
{
set.Add(ci);
}
}
patternIndex++;
}

while (patternIndex < pattern.Length &&
pattern[patternIndex] != ']')
{
set.Add(pattern[patternIndex]);
patternIndex++;
}
patternIndex++;
}
}

if (isWildCardOn)
{
if (char.ToUpper(c) == char.ToUpper(p))
{
isWildCardOn = false;
patternIndex++;
}
}
else if (isCharWildCardOn)
{
isCharWildCardOn = false;
}
else if (isCharSetOn || isNotCharSetOn)
{
bool charMatch = (set.Contains(char.ToUpper(c)));
if ((isNotCharSetOn && charMatch) || (isCharSetOn && !charMatch))
{
if (lastWildCard >= 0) patternIndex = lastWildCard;
else
{
isMatch = false;
break;
}
}
isNotCharSetOn = isCharSetOn = false;
}
else
{
if (char.ToUpper(c) == char.ToUpper(p))
{
patternIndex++;
}
else
{
if (lastWildCard >= 0) patternIndex = lastWildCard;
else
{
isMatch = false;
break;
}
}
}
}
endOfPattern = (patternIndex >= pattern.Length);

if (isMatch && !endOfPattern)
{
bool isOnlyWildCards = true;
for (int i = patternIndex; i < pattern.Length; i++)
{
if (pattern[i] != '%')
{
isOnlyWildCards = false;
break;
}
}
if (isOnlyWildCards) endOfPattern = true;
}
return isMatch && endOfPattern;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace Ardalis.Specification
{
public class InvalidSearchPatternException : Exception
{
private const string message = "Invalid search pattern: ";

public InvalidSearchPatternException(string searchPattern)
: base($"{message}{searchPattern}")
{
}

public InvalidSearchPatternException(string searchPattern, Exception innerException)
: base($"{message}{searchPattern}", innerException)
{
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

namespace Ardalis.Specification.UnitTests
{
public class SpecificationEvaluatorTests
public class InMemorySpecificationEvaluatorTests
{
[Fact]
public void ReturnsStoreWithId10_GivenStoreByIdSpec()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using FluentAssertions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xunit;

namespace Ardalis.Specification.UnitTests
{
public class SearchEvaluator_Evaluate
{
public static List<Person> Data = new List<Person>
{
new Person("James"),
new Person("Robert"),
new Person("Mary"),
new Person("Linda"),
new Person("Michael"),
new Person("David"),
};

[Theory]
[InlineData("%mes", 1)]
[InlineData("%r%", 2)]
[InlineData("_inda", 1)]
[InlineData("M%", 2)]
[InlineData("[RM]%", 3)]
[InlineData("_[IA]%", 5)]
public void ReturnsFilteredList_GivenSearchExpression(string searchTerm, int expectedCount)
{
var result = SearchEvaluator.Instance.Evaluate(Data, new PersonSpecification(searchTerm));

result.Should().HaveCount(expectedCount);
}
}

public class PersonSpecification : Specification<Person>
{
public PersonSpecification(string searchTerm)
{
Query.Search(x => x.Name, searchTerm);
}
}

public class Person
{
public string Name { get; }

public Person(string name)
{
Name = name;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using FluentAssertions;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Xunit;

namespace Ardalis.Specification.UnitTests.EvaluatorTests
{
public class SearchExtension_Like
{
[Theory]
[InlineData(true, "%", "")]
[InlineData(true, "%", " ")]
[InlineData(true, "%", "asdfa asdf asdf")]
[InlineData(true, "%", "%")]
[InlineData(false, "_", "")]
[InlineData(true, "_", " ")]
[InlineData(true, "_", "4")]
[InlineData(true, "_", "C")]
[InlineData(true, "_", "c")]
[InlineData(false, "_", "CX")]
[InlineData(false, "_", "cx")]
[InlineData(true, "[A]", "a")]
[InlineData(false, "[A]", "ab")]
[InlineData(false, "[ABCD]", "")]
[InlineData(true, "[ABCD]", "A")]
[InlineData(true, "[ABCD]", "b")]
[InlineData(false, "[ABCD]", "X")]
[InlineData(false, "[ABCD]", "AB")]
[InlineData(true, "[B-D]", "C")]
[InlineData(true, "[B-D]", "D")]
[InlineData(false, "[B-D]", "A")]
[InlineData(false, "[^B-D]", "C")]
[InlineData(false, "[^B-D]", "D")]
[InlineData(true, "[^B-D)]", "A")]
[InlineData(true, "%TEST[ABCD]XXX", "lolTESTBXXX")]
[InlineData(false, "%TEST[ABCD]XXX", "lolTESTZXXX")]
[InlineData(false, "%TEST[^ABCD]XXX", "lolTESTBXXX")]
[InlineData(true, "%TEST[^ABCD]XXX", "lolTESTZXXX")]
[InlineData(true, "%TEST[B-D]XXX", "lolTESTBXXX")]
[InlineData(true, "%TEST[^B-D)]XXX", "lolTESTZXXX")]
[InlineData(true, "%Stuff.txt", "Stuff.txt")]
[InlineData(true, "%Stuff.txt", "MagicStuff.txt")]
[InlineData(false, "%Stuff.txt", "MagicStuff.txt.img")]
[InlineData(false, "%Stuff.txt", "Stuff.txt.img")]
[InlineData(false, "%Stuff.txt", "MagicStuff001.txt.img")]
[InlineData(true, "Stuff.txt%", "Stuff.txt")]
[InlineData(false, "Stuff.txt%", "MagicStuff.txt")]
[InlineData(false, "Stuff.txt%", "MagicStuff.txt.img")]
[InlineData(true, "Stuff.txt%", "Stuff.txt.img")]
[InlineData(false, "Stuff.txt%", "MagicStuff001.txt.img")]
[InlineData(true, "%Stuff.txt%", "Stuff.txt")]
[InlineData(true, "%Stuff.txt%", "MagicStuff.txt")]
[InlineData(true, "%Stuff.txt%", "MagicStuff.txt.img")]
[InlineData(true, "%Stuff.txt%", "Stuff.txt.img")]
[InlineData(false, "%Stuff.txt%", "MagicStuff001.txt.img")]
[InlineData(true, "%Stuff%.txt", "Stuff.txt")]
[InlineData(true, "%Stuff%.txt", "MagicStuff.txt")]
[InlineData(false, "%Stuff%.txt", "MagicStuff.txt.img")]
[InlineData(false, "%Stuff%.txt", "Stuff.txt.img")]
[InlineData(false, "%Stuff%.txt", "MagicStuff001.txt.img")]
[InlineData(true, "%Stuff%.txt", "MagicStuff001.txt")]
[InlineData(true, "Stuff%.txt%", "Stuff.txt")]
[InlineData(false, "Stuff%.txt%", "MagicStuff.txt")]
[InlineData(false, "Stuff%.txt%", "MagicStuff.txt.img")]
[InlineData(true, "Stuff%.txt%", "Stuff.txt.img")]
[InlineData(false, "Stuff%.txt%", "MagicStuff001.txt.img")]
[InlineData(false, "Stuff%.txt%", "MagicStuff001.txt")]
[InlineData(true, "%Stuff%.txt%", "Stuff.txt")]
[InlineData(true, "%Stuff%.txt%", "MagicStuff.txt")]
[InlineData(true, "%Stuff%.txt%", "MagicStuff.txt.img")]
[InlineData(true, "%Stuff%.txt%", "Stuff.txt.img")]
[InlineData(true, "%Stuff%.txt%", "MagicStuff001.txt.img")]
[InlineData(true, "%Stuff%.txt%", "MagicStuff001.txt")]
[InlineData(true, "_Stuff_.txt_", "1Stuff3.txt4")]
[InlineData(false, "_Stuff_.txt_", "1Stuff.txt4")]
[InlineData(false, "_Stuff_.txt_", "1Stuff3.txt")]
[InlineData(false, "_Stuff_.txt_", "Stuff3.txt4")]
public void ReturnsExpectedResult_GivenPatternAndInput(bool expectedResult, string pattern, string input)
{
var result = input.Like(pattern);

result.Should().Be(expectedResult);
}

[Theory]
[InlineData("[", "asd")]
[InlineData("[]", "asd")]
public void ShouldThrowInvalidSearchPattern_GivenInvalidPattern(string pattern, string input)
{
Action action = () => input.Like(pattern);

action.Should().Throw<InvalidSearchPatternException>().WithMessage($"Invalid search pattern: {pattern}");
}
}
}
Loading