Skip to content
Open
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
34 changes: 30 additions & 4 deletions src/Redis.OM/Common/ExpressionParserUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -822,28 +822,54 @@ private static string TranslateMatchStartsWith(MethodCallExpression exp)
{
var source = GetOperandString(exp.Arguments[0]);
var prefix = GetOperandString(exp.Arguments[1]);
return $"({source}:{prefix}*)";

if (exp.Arguments[0].Type == typeof(string))
{
return $"({source}:{prefix}*)";
}

// IEnumerable<string>
return $"({source}:{{{prefix}*}})";
}

private static string TranslateMatchEndsWith(MethodCallExpression exp)
{
var source = GetOperandString(exp.Arguments[0]);
var suffix = GetOperandString(exp.Arguments[1]);
return $"({source}:*{suffix})";

if (exp.Arguments[0].Type == typeof(string))
{
return $"({source}:*{suffix})";
}

// IEnumerable<string>
return $"({source}:{{*{suffix}}})";
}

private static string TranslateMatchContains(MethodCallExpression exp)
{
var source = GetOperandString(exp.Arguments[0]);
var infix = GetOperandString(exp.Arguments[1]);
return $"({source}:*{infix}*)";
if (exp.Arguments[0].Type == typeof(string))
{
return $"({source}:*{infix}*)";
}

// IEnumerable<string>
return $"({source}:{{*{infix}*}})";
}

private static string TranslateMatchPattern(MethodCallExpression exp)
{
var source = GetOperandString(exp.Arguments[0]);
var pattern = GetOperandString(exp.Arguments[1]);
return $"({source}:{pattern})";
if (exp.Arguments[0].Type == typeof(string))
{
return $"({source}:{pattern})";
}

// IEnumerable<string>
return $"({source}:{{{pattern}}})";
}

private static string TranslateFuzzyMatch(MethodCallExpression exp)
Expand Down
153 changes: 148 additions & 5 deletions src/Redis.OM/Extensions/StringExtension.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.RegularExpressions;

namespace Redis.OM
{
Expand Down Expand Up @@ -53,7 +53,7 @@ public static bool FuzzyMatch(this string source, string term, byte distanceThre
public static bool MatchStartsWith(this string source, string prefix)
{
var terms = source.Split(SplitChars);
return terms.Any(t => t.StartsWith(prefix));
return terms.Any(t => t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}

/// <summary>
Expand All @@ -67,7 +67,7 @@ public static bool MatchStartsWith(this string source, string prefix)
public static bool MatchEndsWith(this string source, string suffix)
{
var terms = source.Split(SplitChars);
return terms.Any(t => t.EndsWith(suffix));
return terms.Any(t => t.EndsWith(suffix, StringComparison.OrdinalIgnoreCase));
}

/// <summary>
Expand All @@ -81,7 +81,7 @@ public static bool MatchEndsWith(this string source, string suffix)
public static bool MatchContains(this string source, string infix)
{
var terms = source.Split(SplitChars);
return terms.Any(t => t.EndsWith(infix));
return terms.Any(t => t.IndexOf(infix, StringComparison.OrdinalIgnoreCase) >= 0);
}

/// <summary>
Expand All @@ -94,8 +94,151 @@ public static bool MatchContains(this string source, string infix)
/// provided here for completeness.</remarks>
public static bool MatchPattern(this string source, string pattern)
{
var regex = new Regex(WildcardToRegex(pattern), RegexOptions.IgnoreCase | RegexOptions.Compiled);
var terms = source.Split(SplitChars);
return terms.Any(t => t.EndsWith(pattern));
return terms.Any(t => regex.IsMatch(t));
}

/// <summary>
/// Checks the source array string to see if any tokens within the source string start with the prefix.
/// </summary>
/// <param name="source">The array string to check.</param>
/// <param name="prefix">The prefix to look for within the each string in array.</param>
/// <returns>Whether any token within the source string starts with the prefix.</returns>
/// <remarks>This is meant to be a shadow method that runs within an expression, a working implementation is
/// provided here for completeness.</remarks>
public static bool MatchStartsWith(this IEnumerable<string> source, string prefix)
{
foreach (var str in source)
{
var terms = str.Split(SplitChars);
if (terms.Any(t => t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}

return false;
}

/// <summary>
/// Checks the source array string to see if any tokens within the source string ends with the suffix.
/// </summary>
/// <param name="source">The array string to check.</param>
/// <param name="suffix">The suffix to look for within the each string in array.</param>
/// <returns>Whether any token within the source string ends with the suffix.</returns>
/// <remarks>This is meant to be a shadow method that runs within an expression, a working implementation is
/// provided here for completeness.</remarks>
public static bool MatchEndsWith(this IEnumerable<string> source, string suffix)
{
foreach (var str in source)
{
var terms = str.Split(SplitChars);
if (terms.Any(t => t.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}

return false;
}

/// <summary>
/// Checks the source array string to see if any tokens within the source contains the infix.
/// </summary>
/// <param name="source">The array string to check.</param>
/// <param name="infix">The infix to look for within the each string in array.</param>
/// <returns>Whether any token within the source string contains the infix.</returns>
/// <remarks>This is meant to be a shadow method that runs within an expression, a working implementation is
/// provided here for completeness.</remarks>
public static bool MatchContains(this IEnumerable<string> source, string infix)
{
foreach (var str in source)
{
var terms = str.Split(SplitChars);
if (terms.Any(t => t.IndexOf(infix, StringComparison.OrdinalIgnoreCase) >= 0))
{
return true;
}
}

return false;
}

/// <summary>
/// Checks the source array string to see if any tokens within the source matches the pattern.
/// </summary>
/// <param name="source">The array string to check.</param>
/// <param name="pattern">The pattern to look for within the each string in array.</param>
/// <returns>Whether any token within the source string matches the pattern.</returns>
/// <remarks>This is meant to be a shadow method that runs within an expression, a working implementation is
/// provided here for completeness.</remarks>
public static bool MatchPattern(this IEnumerable<string> source, string pattern)
{
var regex = new Regex(WildcardToRegex(pattern), RegexOptions.IgnoreCase | RegexOptions.Compiled);
foreach (var str in source)
{
var terms = str.Split(SplitChars);
if (terms.Any(t => regex.IsMatch(t)))
{
return true;
}
}

return false;
}

/*
* See: https://redis.io/docs/latest/develop/ai/search-and-query/advanced-concepts/query_syntax/#wildcard-matching
? - for any single character
* - for any character repeating zero or more times
\ - for escaping; other special characters are ignored
*/

/// <summary>
/// Converts a wildcard pattern to a regex pattern.
/// </summary>
/// <param name="wildcardPattern">The wildcard pattern like '*foo?bar*'. Use '\' to escape.</param>
/// <returns>Return regex pattern and support escaping.</returns>
private static string WildcardToRegex(string wildcardPattern)
{
var regexPattern = new StringBuilder();
regexPattern.Append("^");

int i = 0;
while (i < wildcardPattern.Length)
{
char current = wildcardPattern[i];

if (current == '\\' && i + 1 < wildcardPattern.Length)
{
// Escape the next character
char nextChar = wildcardPattern[i + 1];
regexPattern.Append(Regex.Escape(nextChar.ToString()));
i += 2;
}
else if (current == '*')
{
// Match any character zero or more times
regexPattern.Append(".*");
i++;
}
else if (current == '?')
{
// Match any single character
regexPattern.Append(".");
i++;
}
else
{
// Escape special regex characters
regexPattern.Append(Regex.Escape(current.ToString()));
i++;
}
}

regexPattern.Append("$");
return regexPattern.ToString();
}

/// <summary>
Expand Down
79 changes: 75 additions & 4 deletions test/Redis.OM.Unit.Tests/RediSearchTests/SearchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ public void TestFuzzy()
}

[Fact]
public void TestMatchStartsWith()
public void TestMatchStartsWithOfString()
{
_substitute.ClearSubstitute();
_substitute.Execute(Arg.Any<string>(), Arg.Any<object[]>()).Returns(_mockReply);
Expand All @@ -446,7 +446,7 @@ public void TestMatchStartsWith()
}

[Fact]
public void TestMatchEndsWith()
public void TestMatchEndsWithOfString()
{
_substitute.ClearSubstitute();
_substitute.Execute(Arg.Any<string>(), Arg.Any<object[]>()).Returns(_mockReply);
Expand All @@ -463,7 +463,7 @@ public void TestMatchEndsWith()
}

[Fact]
public void TestMatchContains()
public void TestMatchContainsOfString()
{
_substitute.ClearSubstitute();
_substitute.Execute(Arg.Any<string>(), Arg.Any<object[]>()).Returns(_mockReply);
Expand Down Expand Up @@ -497,7 +497,7 @@ public void TestMultipleMatches()
}

[Fact]
public void TestMatchPattern()
public void TestMatchPatternOfString()
{
_substitute.ClearSubstitute();
_substitute.Execute(Arg.Any<string>(), Arg.Any<object[]>()).Returns(_mockReply);
Expand All @@ -514,6 +514,77 @@ public void TestMatchPattern()
"100");
}

[Fact]
public void TestMatchStartsWithOfStringArray()
{
_substitute.ClearSubstitute();
_substitute.Execute(Arg.Any<string>(), Arg.Any<object[]>()).Returns(_mockReply);

var collection = new RedisCollection<Person>(_substitute);
_ = collection.Where(x => x.NickNames.MatchStartsWith("Ste")).ToList();
_substitute.Execute(
"FT.SEARCH",
"person-idx",
"(@NickNames:{Ste*})",
"LIMIT",
"0",
"100");
}

[Fact]
public void TestMatchEndsWithOfStringArray()
{
_substitute.ClearSubstitute();
_substitute.Execute(Arg.Any<string>(), Arg.Any<object[]>()).Returns(_mockReply);

var collection = new RedisCollection<Person>(_substitute);
_ = collection.Where(x => x.NickNames.MatchEndsWith("Ste")).ToList();
_substitute.Received().Execute(
"FT.SEARCH",
"person-idx",
"(@NickNames:{*Ste})",
"LIMIT",
"0",
"100");
}

[Fact]
public void TestMatchContainsOfStringArray()
{
_substitute.ClearSubstitute();
_substitute.Execute(Arg.Any<string>(), Arg.Any<object[]>()).Returns(_mockReply);

var collection = new RedisCollection<Person>(_substitute);
_ = collection.Where(x => x.NickNames.MatchContains("Ste")).ToList();
_substitute.Received().Execute(
"FT.SEARCH",
"person-idx",
"(@NickNames:{*Ste*})",
"LIMIT",
"0",
"100");
_substitute.ClearSubstitute();
_substitute.Execute(Arg.Any<string>(), Arg.Any<object[]>()).Returns(_mockReply);
}

[Fact]
public void TestMatchPatternOfStringArray()
{
_substitute.ClearSubstitute();
_substitute.Execute(Arg.Any<string>(), Arg.Any<object[]>()).Returns(_mockReply);

var collection = new RedisCollection<Person>(_substitute);
var ddfgdf = collection.Where(x => x.NickNames.MatchPattern("Ste* Lo*")).ToList();

_substitute.Received().Execute(
"FT.SEARCH",
"person-idx",
"(@NickNames:{Ste* Lo*})",
"LIMIT",
"0",
"100");
}

[Fact]
public void TestTagContains()
{
Expand Down