diff --git a/src/Redis.OM/Common/ExpressionParserUtilities.cs b/src/Redis.OM/Common/ExpressionParserUtilities.cs index 766252c..3f5c9a5 100644 --- a/src/Redis.OM/Common/ExpressionParserUtilities.cs +++ b/src/Redis.OM/Common/ExpressionParserUtilities.cs @@ -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 + 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 + 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 + 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 + return $"({source}:{{{pattern}}})"; } private static string TranslateFuzzyMatch(MethodCallExpression exp) diff --git a/src/Redis.OM/Extensions/StringExtension.cs b/src/Redis.OM/Extensions/StringExtension.cs index 442462e..e1d2240 100644 --- a/src/Redis.OM/Extensions/StringExtension.cs +++ b/src/Redis.OM/Extensions/StringExtension.cs @@ -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 { @@ -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)); } /// @@ -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)); } /// @@ -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); } /// @@ -94,8 +94,151 @@ public static bool MatchContains(this string source, string infix) /// provided here for completeness. 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)); + } + + /// + /// Checks the source array string to see if any tokens within the source string start with the prefix. + /// + /// The array string to check. + /// The prefix to look for within the each string in array. + /// Whether any token within the source string starts with the prefix. + /// This is meant to be a shadow method that runs within an expression, a working implementation is + /// provided here for completeness. + public static bool MatchStartsWith(this IEnumerable 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; + } + + /// + /// Checks the source array string to see if any tokens within the source string ends with the suffix. + /// + /// The array string to check. + /// The suffix to look for within the each string in array. + /// Whether any token within the source string ends with the suffix. + /// This is meant to be a shadow method that runs within an expression, a working implementation is + /// provided here for completeness. + public static bool MatchEndsWith(this IEnumerable 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; + } + + /// + /// Checks the source array string to see if any tokens within the source contains the infix. + /// + /// The array string to check. + /// The infix to look for within the each string in array. + /// Whether any token within the source string contains the infix. + /// This is meant to be a shadow method that runs within an expression, a working implementation is + /// provided here for completeness. + public static bool MatchContains(this IEnumerable 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; + } + + /// + /// Checks the source array string to see if any tokens within the source matches the pattern. + /// + /// The array string to check. + /// The pattern to look for within the each string in array. + /// Whether any token within the source string matches the pattern. + /// This is meant to be a shadow method that runs within an expression, a working implementation is + /// provided here for completeness. + public static bool MatchPattern(this IEnumerable 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 + */ + + /// + /// Converts a wildcard pattern to a regex pattern. + /// + /// The wildcard pattern like '*foo?bar*'. Use '\' to escape. + /// Return regex pattern and support escaping. + 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(); } /// diff --git a/test/Redis.OM.Unit.Tests/RediSearchTests/SearchTests.cs b/test/Redis.OM.Unit.Tests/RediSearchTests/SearchTests.cs index fb65b51..3a3d5fd 100644 --- a/test/Redis.OM.Unit.Tests/RediSearchTests/SearchTests.cs +++ b/test/Redis.OM.Unit.Tests/RediSearchTests/SearchTests.cs @@ -429,7 +429,7 @@ public void TestFuzzy() } [Fact] - public void TestMatchStartsWith() + public void TestMatchStartsWithOfString() { _substitute.ClearSubstitute(); _substitute.Execute(Arg.Any(), Arg.Any()).Returns(_mockReply); @@ -446,7 +446,7 @@ public void TestMatchStartsWith() } [Fact] - public void TestMatchEndsWith() + public void TestMatchEndsWithOfString() { _substitute.ClearSubstitute(); _substitute.Execute(Arg.Any(), Arg.Any()).Returns(_mockReply); @@ -463,7 +463,7 @@ public void TestMatchEndsWith() } [Fact] - public void TestMatchContains() + public void TestMatchContainsOfString() { _substitute.ClearSubstitute(); _substitute.Execute(Arg.Any(), Arg.Any()).Returns(_mockReply); @@ -497,7 +497,7 @@ public void TestMultipleMatches() } [Fact] - public void TestMatchPattern() + public void TestMatchPatternOfString() { _substitute.ClearSubstitute(); _substitute.Execute(Arg.Any(), Arg.Any()).Returns(_mockReply); @@ -514,6 +514,77 @@ public void TestMatchPattern() "100"); } + [Fact] + public void TestMatchStartsWithOfStringArray() + { + _substitute.ClearSubstitute(); + _substitute.Execute(Arg.Any(), Arg.Any()).Returns(_mockReply); + + var collection = new RedisCollection(_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(), Arg.Any()).Returns(_mockReply); + + var collection = new RedisCollection(_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(), Arg.Any()).Returns(_mockReply); + + var collection = new RedisCollection(_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(), Arg.Any()).Returns(_mockReply); + } + + [Fact] + public void TestMatchPatternOfStringArray() + { + _substitute.ClearSubstitute(); + _substitute.Execute(Arg.Any(), Arg.Any()).Returns(_mockReply); + + var collection = new RedisCollection(_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() {