Skip to content

Commit

Permalink
Add SQL LIKE implementation for the in-memory evaluator. (#153)
Browse files Browse the repository at this point in the history
* Added in-memory search feature.

* Added tests for in-memory search.
  • Loading branch information
fiseni authored Dec 9, 2021
1 parent 73693ac commit 7465137
Show file tree
Hide file tree
Showing 9 changed files with 390 additions and 54 deletions.
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

0 comments on commit 7465137

Please sign in to comment.