mirror of https://github.com/sean-m/McRule.git
415 lines
18 KiB
C#
415 lines
18 KiB
C#
|
|
using System.Collections;
|
|
using System.Diagnostics.Contracts;
|
|
using System.Linq.Expressions;
|
|
using System.Reflection;
|
|
using System.Text.RegularExpressions;
|
|
using static McRule.PredicateExpressionPolicyExtensions;
|
|
|
|
namespace McRule;
|
|
|
|
public static partial class PredicateExpressionPolicyExtensions {
|
|
public enum RuleOperator {
|
|
And,
|
|
Or
|
|
}
|
|
|
|
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) {
|
|
// Check that the property isn't null, otherwise we'd hit null object exceptions at runtime
|
|
var notNull = Expression.NotEqual(left, Expression.Constant(null));
|
|
|
|
return Expression.AndAlso(notNull, expression);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies negative predicate to expression in a lambda.
|
|
/// </summary>
|
|
/// <typeparam name="T"></typeparam>
|
|
/// <param name="operand"></param>
|
|
/// <returns></returns>
|
|
internal static Expression<Func<T, bool>> Negate<T>(Expression<Func<T, bool>> lambda) {
|
|
var body = lambda.Body;
|
|
var parameters = lambda.Parameters;
|
|
|
|
var negated = Expression.IsFalse(body);
|
|
return Expression.Lambda<Func<T, bool>>(
|
|
negated,
|
|
parameters);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 {
|
|
">" => Expression.GreaterThan(left, right),
|
|
">=" => Expression.GreaterThanOrEqual(left, right),
|
|
"<" => Expression.LessThan(left, right),
|
|
"<=" => Expression.LessThanOrEqual(left, right),
|
|
"<>" => Expression.NotEqual(left, right),
|
|
"!=" => Expression.NotEqual(left, right),
|
|
"!" => Expression.NotEqual(left, right),
|
|
_ => Expression.Equal(left, right)
|
|
};
|
|
|
|
// 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?)};
|
|
|
|
|
|
/// <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) {
|
|
// 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");
|
|
var opLeft = Expression.Property(parameter, property);
|
|
var opRight = Expression.Constant(value);
|
|
|
|
// Create generic method which is bound with the Call Expression below
|
|
var arrContainsRuntimeMethod = typeof(Enumerable).GetMethods()
|
|
.Where(x => x.Name == "Contains")
|
|
.Single(x => x.GetParameters().Length == 2)
|
|
.MakeGenericMethod(value.GetType());
|
|
|
|
//LambdaExpression
|
|
var containsCall = Expression.Call(arrContainsRuntimeMethod, opLeft, opRight);
|
|
|
|
var finalExpression = AddNullCheck<T>(opLeft, containsCall);
|
|
|
|
// Wrap it up in a warm lambda snuggie
|
|
return Expression.Lambda<Func<T, bool>>(finalExpression, false, parameter);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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) {
|
|
if (predicates.Count() == 0) return null;
|
|
|
|
var final = predicates.First();
|
|
foreach (var next in predicates.Skip(1))
|
|
final = PredicateBuilder.And(final, next);
|
|
|
|
return final;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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) {
|
|
if (predicates.Count() == 0) return null;
|
|
|
|
var final = predicates.First();
|
|
foreach (var next in predicates.Skip(1))
|
|
final = PredicateBuilder.Or(final, next);
|
|
|
|
return final;
|
|
}
|
|
|
|
/// <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) {
|
|
if (predicates.Count() == 0) return null;
|
|
|
|
if (op == RuleOperator.And) {
|
|
return CombineAnd(predicates);
|
|
}
|
|
return CombineOr(predicates);
|
|
}
|
|
|
|
private static CoreExtenionFunctions funcs;
|
|
|
|
/// <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) {
|
|
|
|
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.GetExpression<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;
|
|
}
|
|
|
|
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) {
|
|
|
|
#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);
|
|
}
|
|
}
|
|
|
|
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) });
|
|
|
|
|
|
// 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);
|
|
|
|
Expression filterExpression = Expression.AndAlso(notNull, strPredicate);
|
|
|
|
return Expression.Lambda<Func<T, bool>>(
|
|
filterExpression,
|
|
lambda.Parameters);
|
|
}
|
|
}
|
|
} |