Needs refactoring of duplicated code. Tests pass.

efMatching
Sean McArde 2023-09-02 09:42:42 -07:00
parent 99dcf3f3df
commit 2446257d6d
4 changed files with 569 additions and 303 deletions

View File

@ -19,11 +19,11 @@ namespace McRule.Tests {
public class People
{
public readonly string name;
public readonly string kind;
public readonly int? number;
public readonly bool stillWithUs;
public readonly string[] tags;
public string name { get; set; }
public string kind { get; set; }
public int? number { get; set; }
public bool stillWithUs { get; set; }
public string[] tags { get; set; }
public People(string name, string kind, int? number, bool stillWithUs, string[] tags = null)
{
@ -122,13 +122,11 @@ namespace McRule.Tests {
};
[SetUp]
public void Setup() {
PredicateExpressionPolicyExtensions.Init();
}
public void Setup() { }
[Test]
public void NegativeStringMatch() {
var filter = notSean.GetExpression<People>()?.Compile();
var filter = notSean.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
Assert.Null(folks.FirstOrDefault(x => x.name == "Sean"));
@ -138,7 +136,7 @@ namespace McRule.Tests {
public void EndsWith() {
// Filter should match on people who's name ends in 'ean',
// and case insensitive ends with 'EAN'.
var filter = eans.GetExpression<People>()?.Compile();
var filter = eans.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
Assert.NotNull(folks);
@ -153,7 +151,7 @@ namespace McRule.Tests {
youngens, vikings
},
RuleOperator = PredicateExpressionPolicyExtensions.RuleOperator.And
}?.GetExpression<People>()?.Compile();
}?.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
@ -164,11 +162,11 @@ namespace McRule.Tests {
// Process both expressions separately to verify they
// have different results.
filter = youngens.GetExpression<People>()?.Compile();
filter = youngens.GetPredicateExpression<People>()?.Compile();
folks = things.Where(filter);
Assert.IsTrue(folks.Count() > 1);
filter = vikings.GetExpression<People>()?.Compile();
filter = vikings.GetPredicateExpression<People>()?.Compile();
folks = things.Where(filter);
Assert.IsTrue(folks.Count() > 1);
@ -178,7 +176,7 @@ namespace McRule.Tests {
youngens, vikings
},
RuleOperator = PredicateExpressionPolicyExtensions.RuleOperator.Or
}?.GetExpression<People>()?.Compile();
}?.GetPredicateExpression<People>()?.Compile();
folks = things.Where(filter);
Assert.IsTrue(folks.Count() > 1);
@ -198,7 +196,7 @@ namespace McRule.Tests {
[Test]
public void FilterListOfObjectsByMemberCollectionContents() {
var filter = muggles.GetExpression<People>()?.Compile();
var filter = muggles.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
Assert.NotNull(folks);
@ -207,7 +205,7 @@ namespace McRule.Tests {
[Test]
public void BoolConditional() {
var filter = notQuiteDead.GetExpression<People>()?.Compile();
var filter = notQuiteDead.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
Assert.NotNull(folks);
@ -217,14 +215,14 @@ namespace McRule.Tests {
[Test]
public void NullFilterWhenNoMatchingTypes() {
// Shouldn't have any filters in the policy for string objects.
var filter = notQuiteDead.GetExpression<string>()?.Compile();
var filter = notQuiteDead.GetPredicateExpression<string>()?.Compile();
Assert.Null(filter);
}
[Test]
public void TestPolicyWithOrConditional() {
var filter = deadOrViking.GetExpression<People>()?.Compile();
var filter = deadOrViking.GetPredicateExpression<People>()?.Compile();
var folks = things.Where(filter);
Assert.NotNull(folks);
@ -238,7 +236,9 @@ namespace McRule.Tests {
[Test]
public void PolicyEFExpressionShouldNotEmitComparisonTypeStringMatches() {
var filter = eans.GetPredicateExpression<People>();
var efFilter = eans.GetEFPredicateExpression<People>();
var efGenerator = PredicateExpressionPolicyExtensions.GetEfExpressionGenerator();
var efFilter = eans.GetPredicateExpression<People>(efGenerator);
Assert.NotNull(filter);
Assert.NotNull(efFilter.ToString(), filter.ToString());

View File

@ -6,12 +6,14 @@ using System.Text;
namespace McRule {
public interface IExpressionRule {
string TargetType { get; set; }
Expression<Func<T, bool>>? GetExpression<T>();
Expression<Func<T, bool>>? GetExpression<T>(ExpressionOptions options);
Expression<Func<T, bool>>? GetPredicateExpression<T>();
Expression<Func<T, bool>>? GetPredicateExpression<T>(ExpressionGenerator generator);
}
public interface ExpressionOptions {
public bool SupportEF { get; }
public bool NoCache { get; }
public interface ExpressionGenerator
{
Expression<Func<T, bool>>? GetPredicateExpression<T>(ExpressionRuleCollection policy);
Expression<Func<T, bool>> GetPredicateExpressionForType<T>(string property, string value);
}
}

View File

@ -13,20 +13,18 @@ namespace McRule {
public ExpressionRuleCollection() { }
public Expression<Func<T, bool>>? GetExpression<T>() {
var expressions = Rules.Select(x => x.GetExpression<T>());
public Expression<Func<T, bool>>? GetPredicateExpression<T>() {
var expressions = Rules.Select(x => x.GetPredicateExpression<T>());
return (RuleOperator == PredicateExpressionPolicyExtensions.RuleOperator.Or)
? PredicateExpressionPolicyExtensions.CombineOr<T>(expressions)
: PredicateExpressionPolicyExtensions.CombineAnd<T>(expressions);
}
public Expression<Func<T, bool>>? GetExpression<T>(ExpressionOptions options) {
var expressions = Rules.Select(x => x.GetExpression<T>(options));
return (RuleOperator == PredicateExpressionPolicyExtensions.RuleOperator.Or)
? PredicateExpressionPolicyExtensions.CombineOr<T>(expressions)
: PredicateExpressionPolicyExtensions.CombineAnd<T>(expressions);
public Expression<Func<T, bool>>? GetPredicateExpression<T>(ExpressionGenerator generator)
{
var expressions = generator.GetPredicateExpression<T>(this);
return expressions;
}
}
@ -56,7 +54,7 @@ namespace McRule {
/// <summary>
/// Returns an expression tree targeting an object type based on policy parameters.
/// </summary>
public Expression<Func<T, bool>>? GetExpression<T>() {
public Expression<Func<T, bool>>? GetPredicateExpression<T>() {
if (!(typeof(T).Name.Equals(this.TargetType, StringComparison.CurrentCultureIgnoreCase))) return null;
if (cachedExpression == null) {
@ -69,14 +67,10 @@ namespace McRule {
/// <summary>
/// Returns an expression tree targeting an object type based on policy parameters.
/// </summary>
public Expression<Func<T, bool>>? GetExpression<T>(ExpressionOptions options) {
public Expression<Func<T, bool>>? GetPredicateExpression<T>(ExpressionGenerator generator) {
if (!(typeof(T).Name.Equals(this.TargetType, StringComparison.CurrentCultureIgnoreCase))) return null;
if (cachedExpression == null && !options.NoCache) {
cachedExpression = PredicateExpressionPolicyExtensions.GetPredicateExpressionForType<T>(this.Property, this.Value);
}
return PredicateExpressionPolicyExtensions.GetPredicateExpressionForType<T>(this.Property, this.Value);
return generator.GetPredicateExpressionForType<T>(this.Property, this.Value);
}
public override string ToString() {
@ -84,7 +78,7 @@ namespace McRule {
}
public string GetFilterString<T>() {
return this.GetExpression<T>()?.ToString() ?? String.Empty;
return this.GetPredicateExpression<T>()?.ToString() ?? String.Empty;
}
}
}

View File

@ -8,26 +8,26 @@ using static McRule.PredicateExpressionPolicyExtensions;
namespace McRule;
public static partial class PredicateExpressionPolicyExtensions {
public enum RuleOperator {
public static partial class PredicateExpressionPolicyExtensions
{
public enum RuleOperator
{
And,
Or
}
public static ExpressionRule ToFilterRule(this (string, string, string) tuple) {
public static ExpressionRule ToFilterRule(this (string, string, string) tuple)
{
return new ExpressionRule(tuple);
}
internal delegate Expression<Func<T, bool>> AddStringPropertyExpression<T>(
Expression<Func<T, string>> lambda, string filter, string filterType, bool ignoreCase = false);
/// <summary>
/// Prepend the given predicate with a short circuiting null check.
/// </summary>
internal static Expression AddNullCheck<T>(
Expression left,
Expression expression) {
Expression left,
Expression expression)
{
// Check that the property isn't null, otherwise we'd hit null object exceptions at runtime
var notNull = Expression.NotEqual(left, Expression.Constant(null));
@ -40,7 +40,8 @@ public static partial class PredicateExpressionPolicyExtensions {
/// <typeparam name="T"></typeparam>
/// <param name="operand"></param>
/// <returns></returns>
internal static Expression<Func<T, bool>> Negate<T>(Expression<Func<T, bool>> lambda) {
internal static Expression<Func<T, bool>> Negate<T>(Expression<Func<T, bool>> lambda)
{
var body = lambda.Body;
var parameters = lambda.Parameters;
@ -54,7 +55,8 @@ public static partial class PredicateExpressionPolicyExtensions {
/// Return a binary expression based on the given filter string. Default to a
/// standard Equals comparison.
/// </summary>
private static Expression GetComparer(string op, Expression left, Expression right) => op switch {
internal static Expression GetComparer(string op, Expression left, Expression right) => op switch
{
">" => Expression.GreaterThan(left, right),
">=" => Expression.GreaterThanOrEqual(left, right),
"<" => Expression.LessThan(left, right),
@ -66,115 +68,16 @@ public static partial class PredicateExpressionPolicyExtensions {
};
// Used to test for numerical integer types when casting a float to integer.
private static Type[] intTypes = { typeof(Int16), typeof(Int32), typeof(Int64),
typeof(UInt16), typeof(UInt32), typeof(UInt64),
typeof(Int16?), typeof(Int32?), typeof(Int64?),
typeof(UInt16?), typeof(UInt32?), typeof(UInt64?)};
internal static Type[] intTypes =
{
typeof(Int16), typeof(Int32), typeof(Int64),
typeof(UInt16), typeof(UInt32), typeof(UInt64),
typeof(Int16?), typeof(Int32?), typeof(Int64?),
typeof(UInt16?), typeof(UInt32?), typeof(UInt64?)
};
/// <summary>
/// Dynamically build an expression suitable for filtering in a Where clause
/// </summary>
public static Expression<Func<T, bool>> GetPredicateExpressionForType<T>(string property, string value) {
var parameter = Expression.Parameter(typeof(T), "x");
var opLeft = Expression.Property(parameter, property);
var opRight = Expression.Constant(value);
Expression? comparison = null;
// For IComparable types on the left hand side, attempt to parse the right hand side
// into the same type and use <,>,<=,>=,= prefixes to infer BinaryExpression type.
// Should work with numerical or datetime values provided they parse correctly.
// Note, a float on the right hand side will not parse into an integer type implicitly
// so it is parsed into a decimal value first and then rounded to the nearest integral.
var lType = opLeft.Type;
var isNullable = false;
Type? hasComparable = lType.GetInterface("IComparable");
Type? hasCollection = lType.GetInterface("ICollection");
if (hasComparable == null && opLeft.Type.IsValueType) {
lType = Nullable.GetUnderlyingType(opLeft.Type);
// Nullable.GetUnderlyingType only returns a non-null value if the
// supplied type was indeed a nullable type.
if (lType != null)
isNullable = true;
hasComparable = lType.GetInterface("IComparable");
}
// For string comparisons using wildcards, trim the wildcard characters and pass to the comparison method
if (lType == typeof(string))
{
Expression<Func<T, bool>> result;
// Grab the object property for use in the inner expression body
var strParam = Expression.Lambda<Func<T, string>>(opLeft, parameter);
// If a string match begins with !, we negate the result.
var negateResult = false;
if (value.StartsWith("!")) {
negateResult = true;
value = value.TrimStart('!');
}
// String comparisons which are prefixed with '~' will be evaluated ignoring case.
// Note: when expression trees are used outside .net, such as with EF to SQL Server,
// default case sensitivity for that environment may apply implicitly and counter to
// filter policy intent.
bool ignoreCase = false;
if (value.StartsWith('~')) {
ignoreCase = true;
value = value.TrimStart('~');
}
if (value.StartsWith("*") && value.EndsWith("*")) {
result = funcs.AddStringPropertyExpression<T>(strParam, value.Trim('*'), "Contains", ignoreCase);
} else if (value.StartsWith("*")) {
result = funcs.AddStringPropertyExpression<T>(strParam, value.TrimStart('*'), "EndsWith", ignoreCase);
} else if (value.EndsWith("*")) {
result = funcs.AddStringPropertyExpression<T>(strParam, value.TrimEnd('*'), "StartsWith", ignoreCase);
} else {
result = funcs.AddStringPropertyExpression<T>(strParam, value, "Equals", ignoreCase);
}
if (negateResult) {
result = Negate<T>(result);
}
return result;
}
else if (hasComparable == typeof(IComparable))
{
var operatorPrefix = Regex.Match(value.Trim(), @"^[!<>=]+");
var operand = (operatorPrefix.Success ? value.Replace(operatorPrefix.Value, "") : value).Trim();
if (!String.IsNullOrEmpty(operand)) {
var parseMethod = lType.GetMethods().FirstOrDefault(x => x.Name == "Parse");
if (intTypes.Contains(opLeft.Type)) {
operand = operand.Contains(".") ? Math.Round(decimal.Parse(operand)).ToString() : operand;
}
var opRightNumerical = parseMethod?.Invoke(null, new string[] { operand });
opRight = Expression.Constant(opRightNumerical);
Expression opLeftFinal = isNullable ? Expression.Convert(opLeft, lType) : opLeft;
comparison = GetComparer(operatorPrefix.Value.Trim(), opLeftFinal, opRight);
}
} else if (hasCollection == typeof(ICollection)) {
return GetArrayContainsExpression<T>(property, value);
} else {
comparison = Expression.Equal(opLeft, opRight);
}
// If comparison is null that means we haven't been able to infer a good comparison
// expression for it so just defer to a false literal.
Expression<Func<T, bool>> falsePredicate = x => false;
comparison = comparison == null ? falsePredicate : comparison;
if (isNullable) {
comparison = AddNullCheck<T>(opLeft, comparison);
}
return Expression.Lambda<Func<T, bool>>(comparison ?? Expression.Equal(opLeft, opRight), parameter);
}
static Expression<Func<T, bool>> GetArrayContainsExpression<T>(string property, object value) {
internal static Expression<Func<T, bool>> GetArrayContainsExpression<T>(string property, object value)
{
// Bind to the property by name and make the constant value
// we'll be passing into the Contains() call
var parameter = Expression.Parameter(typeof(T), "x");
@ -200,7 +103,8 @@ public static partial class PredicateExpressionPolicyExtensions {
/// Combine a list of expressions exclusively with AndAlso predicate from
/// PredicateBuilder. This operator short circuits.
/// </summary>
public static Expression<Func<T, bool>>? CombineAnd<T>(IEnumerable<Expression<Func<T, bool>>> predicates) {
public static Expression<Func<T, bool>>? CombineAnd<T>(IEnumerable<Expression<Func<T, bool>>> predicates)
{
if (predicates.Count() == 0) return null;
var final = predicates.First();
@ -214,7 +118,8 @@ public static partial class PredicateExpressionPolicyExtensions {
/// Combine a list of expressions inclusively with an Or predicate
/// from PredicateBuilder.
/// </summary>
public static Expression<Func<T, bool>>? CombineOr<T>(IEnumerable<Expression<Func<T, bool>>> predicates) {
public static Expression<Func<T, bool>>? CombineOr<T>(IEnumerable<Expression<Func<T, bool>>> predicates)
{
if (predicates.Count() == 0) return null;
var final = predicates.First();
@ -227,24 +132,505 @@ public static partial class PredicateExpressionPolicyExtensions {
/// <summary>
/// Combine a list of expressions based on the given operator enum.
/// </summary>
public static Expression<Func<T, bool>>? CombinePredicates<T>(IEnumerable<Expression<Func<T, bool>>> predicates, PredicateExpressionPolicyExtensions.RuleOperator op) {
public static Expression<Func<T, bool>>? CombinePredicates<T>(IEnumerable<Expression<Func<T, bool>>> predicates,
PredicateExpressionPolicyExtensions.RuleOperator op)
{
if (predicates.Count() == 0) return null;
if (op == RuleOperator.And) {
if (op == RuleOperator.And)
{
return CombineAnd(predicates);
}
return CombineOr(predicates);
}
private static CoreExtenionFunctions funcs;
/// <summary>
/// Builds expressions using string member functions StartsWith, EndsWith or Contains as the comparator.
/// </summary>
public static Expression<Func<T, bool>> AddStringPropertyExpression<T>(
Expression<Func<T, string>> lambda, string filter, string filterType, bool ignoreCase = false) {
#if DEBUG
if (!(filterType == "StartsWith" || filterType == "EndsWith" || filterType == "Contains" || filterType == "Equals"))
{
throw new Exception($"filterType must equal StartsWith, EndsWith or Contains. Passed: {filterType}");
}
#endif
// Check that the property isn't null, otherwise we'd hit null object exceptions at runtime
var notNull = Expression.NotEqual(lambda.Body, Expression.Constant(null));
// Setup calls to: StartsWith, EndsWith, Contains, or Equals,
// conditionally using character case neutral comparision.
List<Expression> expressionArgs = new List<Expression>() { Expression.Constant(filter) };
if (ignoreCase) {
expressionArgs.Add(Expression.Constant(StringComparison.CurrentCultureIgnoreCase));
} else {
expressionArgs.Add(Expression.Constant(StringComparison.CurrentCulture));
}
MethodInfo methodInfo = typeof(string).GetMethod(filterType, new[] { typeof(string), typeof(StringComparison) });
var strPredicate = Expression.Call(lambda.Body, methodInfo, expressionArgs);
Expression filterExpression = Expression.AndAlso(notNull, strPredicate);
return Expression.Lambda<Func<T, bool>>(
filterExpression,
lambda.Parameters);
}
/// <summary>
/// Dynamically build an expression suitable for filtering in a Where clause
/// </summary>
public static Expression<Func<T, bool>> GetPredicateExpressionForType<T>(string property, string value)
{
var parameter = Expression.Parameter(typeof(T), "x");
var opLeft = Expression.Property(parameter, property);
var opRight = Expression.Constant(value);
Expression? comparison = null;
// For IComparable types on the left hand side, attempt to parse the right hand side
// into the same type and use <,>,<=,>=,= prefixes to infer BinaryExpression type.
// Should work with numerical or datetime values provided they parse correctly.
// Note, a float on the right hand side will not parse into an integer type implicitly
// so it is parsed into a decimal value first and then rounded to the nearest integral.
var lType = opLeft.Type;
var isNullable = false;
Type? hasComparable = lType.GetInterface("IComparable");
Type? hasCollection = lType.GetInterface("ICollection");
if (hasComparable == null && opLeft.Type.IsValueType)
{
lType = Nullable.GetUnderlyingType(opLeft.Type);
// Nullable.GetUnderlyingType only returns a non-null value if the
// supplied type was indeed a nullable type.
if (lType != null)
isNullable = true;
hasComparable = lType.GetInterface("IComparable");
}
// For string comparisons using wildcards, trim the wildcard characters and pass to the comparison method
if (lType == typeof(string))
{
Expression<Func<T, bool>> result;
// Grab the object property for use in the inner expression body
var strParam = Expression.Lambda<Func<T, string>>(opLeft, parameter);
// If a string match begins with !, we negate the result.
var negateResult = false;
if (value.StartsWith("!"))
{
negateResult = true;
value = value.TrimStart('!');
}
// String comparisons which are prefixed with '~' will be evaluated ignoring case.
// Note: when expression trees are used outside .net, such as with EF to SQL Server,
// default case sensitivity for that environment may apply implicitly and counter to
// filter policy intent.
bool ignoreCase = false;
if (value.StartsWith('~'))
{
ignoreCase = true;
value = value.TrimStart('~');
}
if (value.StartsWith("*") && value.EndsWith("*"))
{
result = AddStringPropertyExpression<T>(strParam, value.Trim('*'), "Contains", ignoreCase);
}
else if (value.StartsWith("*"))
{
result = AddStringPropertyExpression<T>(strParam, value.TrimStart('*'), "EndsWith", ignoreCase);
}
else if (value.EndsWith("*"))
{
result = AddStringPropertyExpression<T>(strParam, value.TrimEnd('*'), "StartsWith", ignoreCase);
}
else
{
result = AddStringPropertyExpression<T>(strParam, value, "Equals", ignoreCase);
}
if (negateResult)
{
result = Negate<T>(result);
}
return result;
}
else if (hasComparable == typeof(IComparable))
{
var operatorPrefix = Regex.Match(value.Trim(), @"^[!<>=]+");
var operand = (operatorPrefix.Success ? value.Replace(operatorPrefix.Value, "") : value).Trim();
if (!String.IsNullOrEmpty(operand))
{
var parseMethod = lType.GetMethods().FirstOrDefault(x => x.Name == "Parse");
if (intTypes.Contains(opLeft.Type))
{
operand = operand.Contains(".") ? Math.Round(decimal.Parse(operand)).ToString() : operand;
}
var opRightNumerical = parseMethod?.Invoke(null, new string[] { operand });
opRight = Expression.Constant(opRightNumerical);
Expression opLeftFinal = isNullable ? Expression.Convert(opLeft, lType) : opLeft;
comparison = GetComparer(operatorPrefix.Value.Trim(), opLeftFinal, opRight);
}
}
else if (hasCollection == typeof(ICollection))
{
return GetArrayContainsExpression<T>(property, value);
}
else
{
comparison = Expression.Equal(opLeft, opRight);
}
// If comparison is null that means we haven't been able to infer a good comparison
// expression for it so just defer to a false literal.
Expression<Func<T, bool>> falsePredicate = x => false;
comparison = comparison == null ? falsePredicate : comparison;
if (isNullable)
{
comparison = AddNullCheck<T>(opLeft, comparison);
}
return Expression.Lambda<Func<T, bool>>(comparison ?? Expression.Equal(opLeft, opRight), parameter);
}
public static ExpressionGenerator GetCoreExpressionGenerator() => new PolicyToExpressionGenerator();
public static ExpressionGenerator GetEfExpressionGenerator() => new PolicyToEFExpressionGenerator();
}
public class PolicyToExpressionGenerator : ExpressionGenerator
{
/// <summary>
/// Builds expressions using string member functions StartsWith, EndsWith or Contains as the comparator.
/// </summary>
public static Expression<Func<T, bool>> AddStringPropertyExpression<T>(
Expression<Func<T, string>> lambda, string filter, string filterType, bool ignoreCase = false) {
#if DEBUG
if (!(filterType == "StartsWith" || filterType == "EndsWith" || filterType == "Contains" || filterType == "Equals"))
{
throw new Exception($"filterType must equal StartsWith, EndsWith or Contains. Passed: {filterType}");
}
#endif
// Check that the property isn't null, otherwise we'd hit null object exceptions at runtime
var notNull = Expression.NotEqual(lambda.Body, Expression.Constant(null));
// Setup calls to: StartsWith, EndsWith, Contains, or Equals,
// conditionally using character case neutral comparision.
List<Expression> expressionArgs = new List<Expression>() { Expression.Constant(filter) };
if (ignoreCase) {
expressionArgs.Add(Expression.Constant(StringComparison.CurrentCultureIgnoreCase));
} else {
expressionArgs.Add(Expression.Constant(StringComparison.CurrentCulture));
}
MethodInfo methodInfo = typeof(string).GetMethod(filterType, new[] { typeof(string), typeof(StringComparison) });
var strPredicate = Expression.Call(lambda.Body, methodInfo, expressionArgs);
Expression filterExpression = Expression.AndAlso(notNull, strPredicate);
return Expression.Lambda<Func<T, bool>>(
filterExpression,
lambda.Parameters);
}
/// <summary>
/// Dynamically build an expression suitable for filtering in a Where clause
/// </summary>
public Expression<Func<T, bool>> GetPredicateExpressionForType<T>(string property, string value)
{
var parameter = Expression.Parameter(typeof(T), "x");
var opLeft = Expression.Property(parameter, property);
var opRight = Expression.Constant(value);
Expression? comparison = null;
// For IComparable types on the left hand side, attempt to parse the right hand side
// into the same type and use <,>,<=,>=,= prefixes to infer BinaryExpression type.
// Should work with numerical or datetime values provided they parse correctly.
// Note, a float on the right hand side will not parse into an integer type implicitly
// so it is parsed into a decimal value first and then rounded to the nearest integral.
var lType = opLeft.Type;
var isNullable = false;
Type? hasComparable = lType.GetInterface("IComparable");
Type? hasCollection = lType.GetInterface("ICollection");
if (hasComparable == null && opLeft.Type.IsValueType)
{
lType = Nullable.GetUnderlyingType(opLeft.Type);
// Nullable.GetUnderlyingType only returns a non-null value if the
// supplied type was indeed a nullable type.
if (lType != null)
isNullable = true;
hasComparable = lType.GetInterface("IComparable");
}
// For string comparisons using wildcards, trim the wildcard characters and pass to the comparison method
if (lType == typeof(string))
{
Expression<Func<T, bool>> result;
// Grab the object property for use in the inner expression body
var strParam = Expression.Lambda<Func<T, string>>(opLeft, parameter);
// If a string match begins with !, we negate the result.
var negateResult = false;
if (value.StartsWith("!"))
{
negateResult = true;
value = value.TrimStart('!');
}
// String comparisons which are prefixed with '~' will be evaluated ignoring case.
// Note: when expression trees are used outside .net, such as with EF to SQL Server,
// default case sensitivity for that environment may apply implicitly and counter to
// filter policy intent.
bool ignoreCase = false;
if (value.StartsWith('~'))
{
ignoreCase = true;
value = value.TrimStart('~');
}
if (value.StartsWith("*") && value.EndsWith("*"))
{
result = AddStringPropertyExpression<T>(strParam, value.Trim('*'), "Contains", ignoreCase);
}
else if (value.StartsWith("*"))
{
result = AddStringPropertyExpression<T>(strParam, value.TrimStart('*'), "EndsWith", ignoreCase);
}
else if (value.EndsWith("*"))
{
result = AddStringPropertyExpression<T>(strParam, value.TrimEnd('*'), "StartsWith", ignoreCase);
}
else
{
result = AddStringPropertyExpression<T>(strParam, value, "Equals", ignoreCase);
}
if (negateResult)
{
result = Negate<T>(result);
}
return result;
}
else if (hasComparable == typeof(IComparable))
{
var operatorPrefix = Regex.Match(value.Trim(), @"^[!<>=]+");
var operand = (operatorPrefix.Success ? value.Replace(operatorPrefix.Value, "") : value).Trim();
if (!String.IsNullOrEmpty(operand))
{
var parseMethod = lType.GetMethods().FirstOrDefault(x => x.Name == "Parse");
if (intTypes.Contains(opLeft.Type))
{
operand = operand.Contains(".") ? Math.Round(decimal.Parse(operand)).ToString() : operand;
}
var opRightNumerical = parseMethod?.Invoke(null, new string[] { operand });
opRight = Expression.Constant(opRightNumerical);
Expression opLeftFinal = isNullable ? Expression.Convert(opLeft, lType) : opLeft;
comparison = GetComparer(operatorPrefix.Value.Trim(), opLeftFinal, opRight);
}
}
else if (hasCollection == typeof(ICollection))
{
return GetArrayContainsExpression<T>(property, value);
}
else
{
comparison = Expression.Equal(opLeft, opRight);
}
// If comparison is null that means we haven't been able to infer a good comparison
// expression for it so just defer to a false literal.
Expression<Func<T, bool>> falsePredicate = x => false;
comparison = comparison == null ? falsePredicate : comparison;
if (isNullable)
{
comparison = AddNullCheck<T>(opLeft, comparison);
}
return Expression.Lambda<Func<T, bool>>(comparison ?? Expression.Equal(opLeft, opRight), parameter);
}
/// <summary>
/// Generate an expression tree targeting an object type based on a given policy.
/// </summary>
public static Expression<Func<T, bool>>? GetPredicateExpression<T>(this ExpressionRuleCollection policy) {
public Expression<Func<T, bool>>? GetPredicateExpression<T>(ExpressionRuleCollection policy)
{
CoreExtenionFunctions stdFuncs = new CoreExtensions();
if (funcs == null) funcs = stdFuncs;
var predicates = new List<Expression<Func<T, bool>>>();
var typeName = typeof(T).Name;
foreach (var rule in policy.Rules.Where(x => x.TargetType != null))
{
if (!(typeof(T).Name.Equals(rule.TargetType, StringComparison.CurrentCultureIgnoreCase)))
{
continue;
}
var expression = rule.GetPredicateExpression<T>();
if (expression != null) predicates.Add(expression);
}
var expressions = CombinePredicates<T>(predicates, policy.RuleOperator);
if (expressions == null)
{
System.Diagnostics.Debug.WriteLine(
$"No predicates available for type: <{typeof(T).Name}> in policy: {policy.Id}");
return PredicateBuilder.False<T>();
}
return expressions;
}
}
public class PolicyToEFExpressionGenerator : ExpressionGenerator
{
/// <summary>
/// Dynamically build an expression suitable for filtering in a Where clause
/// </summary>
public Expression<Func<T, bool>> GetPredicateExpressionForType<T>(string property, string value)
{
var parameter = Expression.Parameter(typeof(T), "x");
var opLeft = Expression.Property(parameter, property);
var opRight = Expression.Constant(value);
Expression? comparison = null;
// For IComparable types on the left hand side, attempt to parse the right hand side
// into the same type and use <,>,<=,>=,= prefixes to infer BinaryExpression type.
// Should work with numerical or datetime values provided they parse correctly.
// Note, a float on the right hand side will not parse into an integer type implicitly
// so it is parsed into a decimal value first and then rounded to the nearest integral.
var lType = opLeft.Type;
var isNullable = false;
Type? hasComparable = lType.GetInterface("IComparable");
Type? hasCollection = lType.GetInterface("ICollection");
if (hasComparable == null && opLeft.Type.IsValueType)
{
lType = Nullable.GetUnderlyingType(opLeft.Type);
// Nullable.GetUnderlyingType only returns a non-null value if the
// supplied type was indeed a nullable type.
if (lType != null)
isNullable = true;
hasComparable = lType.GetInterface("IComparable");
}
// For string comparisons using wildcards, trim the wildcard characters and pass to the comparison method
if (lType == typeof(string))
{
Expression<Func<T, bool>> result;
// Grab the object property for use in the inner expression body
var strParam = Expression.Lambda<Func<T, string>>(opLeft, parameter);
// If a string match begins with !, we negate the result.
var negateResult = false;
if (value.StartsWith("!"))
{
negateResult = true;
value = value.TrimStart('!');
}
// String comparisons which are prefixed with '~' will be evaluated ignoring case.
// Note: when expression trees are used outside .net, such as with EF to SQL Server,
// default case sensitivity for that environment may apply implicitly and counter to
// filter policy intent.
bool ignoreCase = false;
if (value.StartsWith('~'))
{
ignoreCase = true;
value = value.TrimStart('~');
}
if (value.StartsWith("*") && value.EndsWith("*"))
{
result = this.AddStringPropertyExpression<T>(strParam, value.Trim('*'), "Contains", ignoreCase);
}
else if (value.StartsWith("*"))
{
result = this.AddStringPropertyExpression<T>(strParam, value.TrimStart('*'), "EndsWith", ignoreCase);
}
else if (value.EndsWith("*"))
{
result = this.AddStringPropertyExpression<T>(strParam, value.TrimEnd('*'), "StartsWith", ignoreCase);
}
else
{
result = this.AddStringPropertyExpression<T>(strParam, value, "Equals", ignoreCase);
}
if (negateResult)
{
result = Negate<T>(result);
}
return result;
}
else if (hasComparable == typeof(IComparable))
{
var operatorPrefix = Regex.Match(value.Trim(), @"^[!<>=]+");
var operand = (operatorPrefix.Success ? value.Replace(operatorPrefix.Value, "") : value).Trim();
if (!String.IsNullOrEmpty(operand))
{
var parseMethod = lType.GetMethods().FirstOrDefault(x => x.Name == "Parse");
if (intTypes.Contains(opLeft.Type))
{
operand = operand.Contains(".") ? Math.Round(decimal.Parse(operand)).ToString() : operand;
}
var opRightNumerical = parseMethod?.Invoke(null, new string[] { operand });
opRight = Expression.Constant(opRightNumerical);
Expression opLeftFinal = isNullable ? Expression.Convert(opLeft, lType) : opLeft;
comparison = GetComparer(operatorPrefix.Value.Trim(), opLeftFinal, opRight);
}
}
else if (hasCollection == typeof(ICollection))
{
return GetArrayContainsExpression<T>(property, value);
}
else
{
comparison = Expression.Equal(opLeft, opRight);
}
// If comparison is null that means we haven't been able to infer a good comparison
// expression for it so just defer to a false literal.
Expression<Func<T, bool>> falsePredicate = x => false;
comparison = comparison == null ? falsePredicate : comparison;
if (isNullable)
{
comparison = AddNullCheck<T>(opLeft, comparison);
}
return Expression.Lambda<Func<T, bool>>(comparison ?? Expression.Equal(opLeft, opRight), parameter);
}
/// <summary>
/// Generate an expression tree targeting an object type based on a given policy.
/// </summary>
public Expression<Func<T, bool>>? GetPredicateExpression<T>(ExpressionRuleCollection policy) {
Expression<Func<T, bool>>? expressions = PredicateBuilder.False<T>();
var predicates = new List<Expression<Func<T, bool>>>();
var typeName = typeof(T).Name;
@ -252,103 +638,26 @@ public static partial class PredicateExpressionPolicyExtensions {
if (!(typeof(T).Name.Equals(rule.TargetType, StringComparison.CurrentCultureIgnoreCase))) {
continue;
}
var expression = rule.GetExpression<T>();
var expression = rule.GetPredicateExpression<T>(this);
if (expression != null) predicates.Add(expression);
}
var expressions = CombinePredicates<T>(predicates, policy.RuleOperator);
expressions = CombinePredicates<T>(predicates, policy.RuleOperator);
if (expressions == null) {
System.Diagnostics.Debug.WriteLine($"No predicates available for type: <{typeof(T).Name}> in policy: {policy.Id}");
return PredicateBuilder.False<T>();
}
return expressions;
}
private class EfExpressionOptions : ExpressionOptions {
public bool SupportEF => true;
public bool NoCache => true;
}
private static ExpressionOptions efExpressionOptions = new EfExpressionOptions();
/// <summary>
/// Generate an expression tree targeting an object type based on a given policy.
/// </summary>
public static Expression<Func<T, bool>>? GetEFPredicateExpression<T>(this ExpressionRuleCollection policy) {
CoreExtenionFunctions stdFuncs = new EFExtensions();
CoreExtenionFunctions prevFuncs = null;
if (funcs == null) {
System.Diagnostics.Trace.WriteLine($"Extension functions were not initialized, using standard functions and best effort EF support.");
funcs = stdFuncs;
} else {
// TODO don't do this at all, just instance the damn thing. EF safe-ish stuff should just be in a different namespace.
prevFuncs = funcs;
}
Expression<Func<T, bool>>? expressions = PredicateBuilder.False<T>();
try {
var predicates = new List<Expression<Func<T, bool>>>();
var typeName = typeof(T).Name;
foreach (var rule in policy.Rules.Where(x => x.TargetType != null)) {
if (!(typeof(T).Name.Equals(rule.TargetType, StringComparison.CurrentCultureIgnoreCase))) {
continue;
}
var expression = rule.GetExpression<T>(efExpressionOptions);
if (expression != null) predicates.Add(expression);
}
expressions = CombinePredicates<T>(predicates, policy.RuleOperator);
if (expressions == null) {
System.Diagnostics.Debug.WriteLine($"No predicates available for type: <{typeof(T).Name}> in policy: {policy.Id}");
return PredicateBuilder.False<T>();
}
}
finally {
if (prevFuncs != null) { funcs = prevFuncs; }
}
return expressions;
}
public static void Init() {
CoreExtenionFunctions stdFuncs = new CoreExtensions();
if (funcs == null) funcs = stdFuncs;
}
public static void SetExtensionFunctions(CoreExtenionFunctions functions) {
funcs = functions;
}
/*
* TODO This is a terrible pile of hacks and I should just refactor the whole dang thing because
* nobody is even using this yet so the API doesn't need to be stable...I just don't know really
* what the API should look like. Extension methods are nice to use, the shorthand they provide
* is pretty slick. I'm just not sure how to handle EF vs non-EF expressions. Perhaps different
* namespace? External library, there's one of those in the solution know but don't know if that
* will last.
*
* The two features that conflict are case-sensitive vs insensitive matches. SQL defaults to the
* collation because string startswith, endswith and contains are all mapped to the Like function
* by EF then translated to LIKE SQL, at that point it's up to the DB. If done externally in a
* libary that depends on EF Core, it could map to the Like function and it's variants which
* will use ILIKE to force case-insensitive comparisions if desired. But, then the libary can't
* just be .netstandard 2.1.
*
* For right now delegates will have to do.
*
* Good APIs are hard.
* */
internal class CoreExtensions : CoreExtenionFunctions {
/// <summary>
/// Builds expressions using string member functions StartsWith, EndsWith or Contains as the comparator.
/// </summary>
public Expression<Func<T, bool>> AddStringPropertyExpression<T>(
Expression<Func<T, string>> lambda, string filter, string filterType, bool ignoreCase = false) {
/// <summary>
/// Builds expressions using string member functions StartsWith, EndsWith or Contains as the comparator.
/// </summary>
public Expression<Func<T, bool>> AddStringPropertyExpression<T>(
Expression<Func<T, string>> lambda, string filter, string filterType, bool ignoreCase = false) {
#if DEBUG
if (!(filterType == "StartsWith" || filterType == "EndsWith" || filterType == "Contains" || filterType == "Equals"))
@ -356,60 +665,21 @@ public static partial class PredicateExpressionPolicyExtensions {
throw new Exception($"filterType must equal StartsWith, EndsWith or Contains. Passed: {filterType}");
}
#endif
// Check that the property isn't null, otherwise we'd hit null object exceptions at runtime
var notNull = Expression.NotEqual(lambda.Body, Expression.Constant(null));
// Setup calls to: StartsWith, EndsWith, Contains, or Equals,
// conditionally using character case neutral comparision.
List<Expression> expressionArgs = new List<Expression>() { Expression.Constant(filter) };
if (ignoreCase) {
expressionArgs.Add(Expression.Constant(StringComparison.CurrentCultureIgnoreCase));
} else {
expressionArgs.Add(Expression.Constant(StringComparison.CurrentCulture));
}
MethodInfo methodInfo = typeof(string).GetMethod(filterType, new[] { typeof(string), typeof(StringComparison) });
var strPredicate = Expression.Call(lambda.Body, methodInfo, expressionArgs);
Expression filterExpression = Expression.AndAlso(notNull, strPredicate);
return Expression.Lambda<Func<T, bool>>(
filterExpression,
lambda.Parameters);
}
}
internal class EFExtensions : CoreExtenionFunctions {
/// <summary>
/// Builds expressions using string member functions StartsWith, EndsWith or Contains as the comparator.
/// </summary>
public Expression<Func<T, bool>> AddStringPropertyExpression<T>(
Expression<Func<T, string>> lambda, string filter, string filterType, bool ignoreCase = false) {
#if DEBUG
if (!(filterType == "StartsWith" || filterType == "EndsWith" || filterType == "Contains" || filterType == "Equals"))
{
throw new Exception($"filterType must equal StartsWith, EndsWith or Contains. Passed: {filterType}");
}
#endif
// Check that the property isn't null, otherwise we'd hit null object exceptions at runtime
var notNull = Expression.NotEqual(lambda.Body, Expression.Constant(null));
MethodInfo methodInfo = typeof(string).GetMethod(filterType, new[] { typeof(string) });
// Check that the property isn't null, otherwise we'd hit null object exceptions at runtime
var notNull = Expression.NotEqual(lambda.Body, Expression.Constant(null));
MethodInfo methodInfo = typeof(string).GetMethod(filterType, new[] { typeof(string) });
// Setup calls to: StartsWith, EndsWith, Contains, or Equals,
// conditionally using character case neutral comparision.
List<Expression> expressionArgs = new List<Expression>() { Expression.Constant(filter) };
// Setup calls to: StartsWith, EndsWith, Contains, or Equals,
// conditionally using character case neutral comparision.
List<Expression> expressionArgs = new List<Expression>() { Expression.Constant(filter) };
var strPredicate = Expression.Call(lambda.Body, methodInfo, expressionArgs);
var strPredicate = Expression.Call(lambda.Body, methodInfo, expressionArgs);
Expression filterExpression = Expression.AndAlso(notNull, strPredicate);
Expression filterExpression = Expression.AndAlso(notNull, strPredicate);
return Expression.Lambda<Func<T, bool>>(
filterExpression,
lambda.Parameters);
}
return Expression.Lambda<Func<T, bool>>(
filterExpression,
lambda.Parameters);
}
}