-
Notifications
You must be signed in to change notification settings - Fork 249
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add SQL LIKE implementation for the in-memory evaluator. (#153)
* Added in-memory search feature. * Added tests for in-memory search.
- Loading branch information
Showing
9 changed files
with
390 additions
and
54 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
Specification/src/Ardalis.Specification/Evaluators/SearchEvaluator.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
158
Specification/src/Ardalis.Specification/Evaluators/SearchExtension.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} |
21 changes: 21 additions & 0 deletions
21
Specification/src/Ardalis.Specification/Exceptions/InvalidSearchPatternException.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
{ | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
55 changes: 55 additions & 0 deletions
55
...fication/tests/Ardalis.Specification.UnitTests/EvaluatorTests/SearchEvaluator_Evaluate.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} | ||
} |
98 changes: 98 additions & 0 deletions
98
Specification/tests/Ardalis.Specification.UnitTests/EvaluatorTests/SearchExtension_Like.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}"); | ||
} | ||
} | ||
} |
Oops, something went wrong.