Skip to content

Add support for StartsWith/EndsWith, extended support for Contains #402

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

Merged
merged 23 commits into from
Aug 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
f4641c3
Update RedisObjectHandler.cs
zulander1 Feb 5, 2023
a152ee2
Merge pull request #1 from zulander1/type-cast-issue
zulander1 Feb 5, 2023
dd4c614
Merge remote-tracking branch 'upstream/main'
slorello89 Feb 8, 2023
737c03c
Merge branch 'redis:main' into main
zulander1 Mar 25, 2023
8c74965
Fixed naming, added Async sufix
zulander1 Mar 25, 2023
c226f2c
Added When in the InsertAsync
zulander1 Mar 25, 2023
542ffb5
fixxes #116
zulander1 Jul 31, 2023
86fddcd
undid program.cs
zulander1 Jul 31, 2023
ffd5f82
Fixed for hash
zulander1 Jul 31, 2023
b646396
fixed documentation
zulander1 Jul 31, 2023
99bb64d
Merge branch 'redis:main' into async_for_bulk_operation
zulander1 Jul 31, 2023
55ccd0b
added StartsWith, EndsWith
zulander1 Jul 31, 2023
4a3b646
Merge branch 'async_for_bulk_operation' of https://github.com/zulande…
zulander1 Jul 31, 2023
29d6aa7
Merge branch 'async_for_bulk_operation' of https://github.com/zulande…
zulander1 Jul 31, 2023
d3fe9fb
Merge branch 'async_for_bulk_operation' of https://github.com/zulande…
zulander1 Jul 31, 2023
cf8bed7
fixed tests
zulander1 Jul 31, 2023
486a0ae
Added ConstainsFuzzy1 and ConstainsFuzzy2 for LD search
zulander1 Aug 2, 2023
7dd2cc2
Removing breaking changes, adding implementation for shadow methods, …
slorello89 Aug 10, 2023
e2539d5
restoring program.cs
slorello89 Aug 10, 2023
0690d25
moving extensions to the top level
slorello89 Aug 10, 2023
046fcbf
removing unnecessary method
slorello89 Aug 10, 2023
c60f776
removing extenous stylistic things
slorello89 Aug 10, 2023
1b21c1b
adding some more comments to extensions
slorello89 Aug 10, 2023
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
138 changes: 136 additions & 2 deletions src/Redis.OM/Common/ExpressionParserUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ internal static string TranslateMethodExpressions(MethodCallExpression exp)
return exp.Method.Name switch
{
"Contains" => TranslateContainsStandardQuerySyntax(exp),
nameof(StringExtension.FuzzyMatch) => TranslateFuzzyMatch(exp),
nameof(StringExtension.MatchContains) => TranslateMatchContains(exp),
nameof(StringExtension.MatchStartsWith) => TranslateMatchStartsWith(exp),
nameof(StringExtension.MatchEndsWith) => TranslateMatchEndsWith(exp),
nameof(string.StartsWith) => TranslateStartsWith(exp),
nameof(string.EndsWith) => TranslateEndsWith(exp),
"Any" => TranslateAnyForEmbeddedObjects(exp),
_ => throw new ArgumentException($"Unrecognized method for query translation:{exp.Method.Name}")
};
Expand Down Expand Up @@ -500,6 +506,7 @@ private static string ParseFormatMethod(MethodCallExpression exp)
formatString = constantFormattedExpression.Value.ToString();
args.Add($"\"{Regex.Replace(formatString, pattern, "%s")}\"");
break;

case MemberExpression { Expression: ConstantExpression constInnerExpression } member:
formatString = (string)GetValue(member.Member, constInnerExpression.Value);
args.Add($"\"{Regex.Replace(formatString, pattern, "%s")}\"");
Expand Down Expand Up @@ -637,8 +644,11 @@ private static string TranslateMethodStandardQuerySyntax(MethodCallExpression ex
{
return exp.Method.Name switch
{
nameof(StringExtension.FuzzyMatch) => TranslateFuzzyMatch(exp),
nameof(string.Format) => TranslateFormatMethodStandardQuerySyntax(exp),
nameof(string.Contains) => TranslateContainsStandardQuerySyntax(exp),
nameof(string.StartsWith) => TranslateStartsWith(exp),
nameof(string.EndsWith) => TranslateEndsWith(exp),
"Any" => TranslateAnyForEmbeddedObjects(exp),
_ => throw new InvalidOperationException($"Unable to parse method {exp.Method.Name}")
};
Expand All @@ -664,12 +674,125 @@ private static string TranslateFormatMethodStandardQuerySyntax(MethodCallExpress
return string.Format(format, args);
}

private static bool IsFullTextSearch(Expression expression)
{
if (expression is MemberExpression member)
{
return DetermineSearchAttribute(member) is SearchableAttribute;
}

return false;
}

private static string TranslateStartsWith(MethodCallExpression exp)
{
string source;
string prefix;
Expression sourceExpression;
if (exp.Arguments.Count < 2 && exp.Object is not null)
{
source = GetOperandString(exp.Object);
prefix = GetOperandString(exp.Arguments[0]);
sourceExpression = exp.Object;
}
else if (exp.Arguments.Count >= 2)
{
source = GetOperandString(exp.Arguments[0]);
prefix = GetOperandString(exp.Arguments[1]);
sourceExpression = exp.Arguments[0];
}
else
{
throw new InvalidOperationException("Could not parse out StartsWith method from provided expression");
}

if (IsFullTextSearch(sourceExpression))
{
return $"({source}:{prefix}*)";
}

return $"({source}:{{{EscapeTagField(prefix)}*}})";
}

private static string TranslateEndsWith(MethodCallExpression exp)
{
string source;
string suffix;
Expression sourceExpression;
if (exp.Arguments.Count < 2 && exp.Object is not null)
{
source = GetOperandString(exp.Object);
suffix = GetOperandString(exp.Arguments[0]);
sourceExpression = exp.Object;
}
else if (exp.Arguments.Count >= 2)
{
source = GetOperandString(exp.Arguments[0]);
suffix = GetOperandString(exp.Arguments[1]);
sourceExpression = exp.Arguments[0];
}
else
{
throw new InvalidOperationException("Could not parse out EndsWith method from provided expression");
}

if (IsFullTextSearch(sourceExpression))
{
return $"({source}:*{suffix})";
}

return $"({source}:{{*{EscapeTagField(suffix)}}})";
}

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

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

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

private static string TranslateFuzzyMatch(MethodCallExpression exp)
{
var source = GetOperandString(exp.Arguments[0]);
var term = GetOperandString(exp.Arguments[1]);
if (!int.TryParse(GetOperandString(exp.Arguments[2]), out var distanceThreshold))
{
throw new ArgumentException($"Could not parse {nameof(distanceThreshold)}");
}

return distanceThreshold switch
{
1 => $"({source}:%{term}%)",
2 => $"({source}:%%{term}%%)",
3 => $"({source}:%%%{term}%%%)",
_ => throw new ArgumentOutOfRangeException(
nameof(distanceThreshold),
distanceThreshold,
$"{nameof(distanceThreshold)} must not exceed 3")
};
}

private static string TranslateContainsStandardQuerySyntax(MethodCallExpression exp)
{
MemberExpression? expression = null;
Type type;
string memberName;
string literal;
SearchFieldAttribute? searchFieldAttribute = null;
if (exp.Arguments.LastOrDefault() is MemberExpression && exp.Arguments.FirstOrDefault() is MemberExpression)
{
var propertyExpression = (MemberExpression)exp.Arguments.Last();
Expand Down Expand Up @@ -734,10 +857,15 @@ private static string TranslateContainsStandardQuerySyntax(MethodCallExpression
if (exp.Object is MemberExpression)
{
expression = exp.Object as MemberExpression;
if (expression is not null)
{
searchFieldAttribute = DetermineSearchAttribute(expression);
}
}
else if (exp.Arguments.FirstOrDefault() is MemberExpression)
{
expression = (MemberExpression)exp.Arguments.First();
searchFieldAttribute = DetermineSearchAttribute(expression);
}

if (expression == null)
Expand All @@ -748,7 +876,13 @@ private static string TranslateContainsStandardQuerySyntax(MethodCallExpression
type = Nullable.GetUnderlyingType(expression.Type) ?? expression.Type;
memberName = GetOperandStringForMember(expression);
literal = GetOperandStringForQueryArgs(exp.Arguments.Last());
return (type == typeof(string)) ? $"({memberName}:{literal})" : $"({memberName}:{{{EscapeTagField(literal)}}})";

if (searchFieldAttribute is not null && searchFieldAttribute is SearchableAttribute)
{
return $"({memberName}:{literal})";
}

return (type == typeof(string)) ? $"({memberName}:{{*{EscapeTagField(literal)}*}})" : $"({memberName}:{{{EscapeTagField(literal)}}})";
}

private static string TranslateAnyForEmbeddedObjects(MethodCallExpression exp)
Expand Down Expand Up @@ -789,4 +923,4 @@ private static string GetConstantStringForArgs(ConstantExpression constExp)
return $"{valueAsString}";
}
}
}
}
120 changes: 120 additions & 0 deletions src/Redis.OM/Extensions/StringExtension.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;

namespace Redis.OM
{
/// <summary>
/// String Search Extensions.
/// </summary>
public static class StringExtension
{
private static readonly char[] SplitChars;

static StringExtension()
{
SplitChars = new[]
{
',', '.', '<', '>', '{', '}', '[', ']', '"', '\'', ':', ';', '!', '@', '#', '$', '%', '^', '&', '*', '(',
')', '-', '+', '=', '~',
};
}

/// <summary>
/// Checks if the string Levenshtein distance between the source and term is less than the provided distance.
/// </summary>
/// <param name="source">Source string.</param>
/// <param name="term">The string to compare the source to.</param>
/// <param name="distanceThreshold">The threshold for permissible distance (must be 3 or less).</param>
/// <returns>Whether the strings are within the provided Levenshtein distance of each other.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown if distanceThreshold is greater than 3.</exception>
/// <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 FuzzyMatch(this string source, string term, byte distanceThreshold)
{
if (distanceThreshold > 3)
{
throw new ArgumentOutOfRangeException(nameof(distanceThreshold), distanceThreshold, "Distance must be less than 3.");
}

return source.LevenshteinDistance(term) <= distanceThreshold;
}

/// <summary>
/// Checks the source string to see if any tokens within the source string start with the prefix.
/// </summary>
/// <param name="source">The string to check.</param>
/// <param name="prefix">The prefix to look for within the string.</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 string source, string prefix)
{
var terms = source.Split(SplitChars);
return terms.Any(t => t.StartsWith(prefix));
}

/// <summary>
/// Checks the source string to see if any tokens within the source string ends with the suffix.
/// </summary>
/// <param name="source">The string to check.</param>
/// <param name="suffix">The suffix to look for within the string.</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 string source, string suffix)
{
var terms = source.Split(SplitChars);
return terms.Any(t => t.EndsWith(suffix));
}

/// <summary>
/// Checks the source string to see if any tokens within the source contains the infix.
/// </summary>
/// <param name="source">The string to check.</param>
/// <param name="infix">The infix to look for within the string.</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 string source, string infix)
{
var terms = source.Split(SplitChars);
return terms.Any(t => t.EndsWith(infix));
}

/// <summary>
/// Wagner-Fischer dynamic programming string distance algorithm.
/// </summary>
/// <param name="source">The source string to check the distance from.</param>
/// <param name="term">The destination string to check the distance to.</param>
/// <returns>The Levenshtein distance.</returns>
/// <remarks>This is meant to be a shadow method that runs within an expression, a working implementation is
/// provided here for completeness.</remarks>
private static int LevenshteinDistance(this string source, string term)
{
var d = new int[source.Length, term.Length];
for (var i = 1; i < source.Length; i++)
{
d[i, 0] = i;
}

for (var j = 1; j < term.Length; j++)
{
d[0, j] = j;
}

for (var j = 1; j < term.Length; j++)
{
for (var i = 1; i < source.Length; i++)
{
var substitutionCost = source[i] == term[j] ? 0 : 1;
d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + substitutionCost);
}
}

return d[source.Length - 1, term.Length - 1];
}
}
}
Loading