diff --git a/McRule.EF/CoreExtensionFunctions.cs b/McRule.EF/CoreExtensionFunctions.cs new file mode 100644 index 0000000..9edbde7 --- /dev/null +++ b/McRule.EF/CoreExtensionFunctions.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; + +namespace McRule.EF { + + internal class CoreExtensions : CoreExtenionFunctions { + public Expression> AddStringPropertyExpression(Expression> lambda, string filter, string filterType, bool ignoreCase = false) { + return AddStringPropertyExpression(lambda, filter, filterType, ignoreCase, false); + } + + /// + /// Builds expressions using string member functions StartsWith, EndsWith or Contains as the comparator. + /// + public Expression> AddStringPropertyExpression( + Expression> lambda, string filter, string filterType, bool ignoreCase = false, bool supportEF = 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("Contains", new[] { typeof(string) }); + + string filterString = filter; + switch (filterType) { + case "Contains": + filterString = filter.Trim(new[] { ' ', '*' }); + break; + case "StartsWith": + filterString = $"%{filter.Trim(new[] { ' ', '*' })}"; + break; + case "EndsWith": + filterString = $"{filter.Trim(new[] { ' ', '*' })}%"; + break; + default: + methodInfo = typeof(string).GetMethod(filterType, new[] { typeof(string) }); + break; + } + + + // Setup calls to: StartsWith, EndsWith, Contains, or Equals, + // conditionally using character case neutral comparision. + List expressionArgs = new List() { Expression.Constant(filterString) }; + + var strPredicate = Expression.Call(lambda.Body, methodInfo, expressionArgs); + + Expression filterExpression = Expression.AndAlso(notNull, strPredicate); + + return Expression.Lambda>( + filterExpression, + lambda.Parameters); + } + } +} diff --git a/McRule.EF/McRule.EF.csproj b/McRule.EF/McRule.EF.csproj new file mode 100644 index 0000000..05c4680 --- /dev/null +++ b/McRule.EF/McRule.EF.csproj @@ -0,0 +1,16 @@ + + + + net7.0 + enable + + + + + + + + + + + diff --git a/McRule.Tests/Filtering.cs b/McRule.Tests/Filtering.cs index 8826683..9521f60 100644 --- a/McRule.Tests/Filtering.cs +++ b/McRule.Tests/Filtering.cs @@ -87,7 +87,7 @@ namespace McRule.Tests { [SetUp] public void Setup() { - + PredicateExpressionPolicyExtensions.Init(); } [Test] @@ -209,18 +209,5 @@ namespace McRule.Tests { Assert.IsTrue(filter.ToString().Contains("CurrentCulture")); Assert.IsFalse(efFilter.ToString().Contains("CurrentCulture")); } - - [Test] - public void InvalidStringFilterTypeShouldThrow() { - var parameter = Expression.Parameter(typeof(People), "x"); - var opRight = Expression.Constant("foo"); - var strParam = Expression.Lambda>(opRight, parameter); - -#if DEBUG - Assert.Throws(Is.TypeOf() - .And.Message.EqualTo("filterType must equal StartsWith, EndsWith or Contains. Passed: NotAMatch"), - () => PredicateExpressionPolicyExtensions.AddStringPropertyExpression(strParam, "foo", "NotAMatch")); -#endif - } } } \ No newline at end of file diff --git a/McRule.sln b/McRule.sln index edefbfd..99dd04c 100644 --- a/McRule.sln +++ b/McRule.sln @@ -12,7 +12,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McRule.Tests", "McRule.Tests\McRule.Tests.csproj", "{15DC72B8-E535-4E1D-82FA-A78BA540F0A4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "McRule.Tests", "McRule.Tests\McRule.Tests.csproj", "{15DC72B8-E535-4E1D-82FA-A78BA540F0A4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McRule.EF", "McRule.EF\McRule.EF.csproj", "{17F3AB0B-1E2A-428D-850B-DC83D70D4F29}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -32,6 +34,10 @@ Global {15DC72B8-E535-4E1D-82FA-A78BA540F0A4}.Debug|Any CPU.Build.0 = Debug|Any CPU {15DC72B8-E535-4E1D-82FA-A78BA540F0A4}.Release|Any CPU.ActiveCfg = Release|Any CPU {15DC72B8-E535-4E1D-82FA-A78BA540F0A4}.Release|Any CPU.Build.0 = Release|Any CPU + {17F3AB0B-1E2A-428D-850B-DC83D70D4F29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17F3AB0B-1E2A-428D-850B-DC83D70D4F29}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17F3AB0B-1E2A-428D-850B-DC83D70D4F29}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17F3AB0B-1E2A-428D-850B-DC83D70D4F29}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/McRule/CoreExtensionFunctions.cs b/McRule/CoreExtensionFunctions.cs new file mode 100644 index 0000000..957250a --- /dev/null +++ b/McRule/CoreExtensionFunctions.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Text; + +namespace McRule { + public interface CoreExtenionFunctions { + Expression> AddStringPropertyExpression( + Expression> lambda, string filter, string filterType, bool ignoreCase = false); + + } +} diff --git a/McRule/ExpressionInterface.cs b/McRule/ExpressionInterface.cs index 0270b9e..cdc0249 100644 --- a/McRule/ExpressionInterface.cs +++ b/McRule/ExpressionInterface.cs @@ -12,5 +12,6 @@ namespace McRule { public interface ExpressionOptions { public bool SupportEF { get; } + public bool NoCache { get; } } } diff --git a/McRule/ExpressionRule.cs b/McRule/ExpressionRule.cs index 6ccd2d1..8d82282 100644 --- a/McRule/ExpressionRule.cs +++ b/McRule/ExpressionRule.cs @@ -71,8 +71,12 @@ namespace McRule { /// public Expression>? GetExpression(ExpressionOptions options) { if (!(typeof(T).Name.Equals(this.TargetType, StringComparison.CurrentCultureIgnoreCase))) return null; + + if (cachedExpression == null && !options.NoCache) { + cachedExpression = PredicateExpressionPolicyExtensions.GetPredicateExpressionForType(this.Property, this.Value); + } - return PredicateExpressionPolicyExtensions.GetPredicateExpressionForType(this.Property, this.Value, options.SupportEF); + return PredicateExpressionPolicyExtensions.GetPredicateExpressionForType(this.Property, this.Value); } public override string ToString() { diff --git a/McRule/PredicateExpressionPolicyExtensions.cs b/McRule/PredicateExpressionPolicyExtensions.cs index 28ba57f..b081114 100644 --- a/McRule/PredicateExpressionPolicyExtensions.cs +++ b/McRule/PredicateExpressionPolicyExtensions.cs @@ -1,12 +1,14 @@  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 class PredicateExpressionPolicyExtensions { +public static partial class PredicateExpressionPolicyExtensions { public enum RuleOperator { And, Or @@ -16,50 +18,14 @@ public static class PredicateExpressionPolicyExtensions { return new ExpressionRule(tuple); } - /// - /// Builds expressions using string member functions StartsWith, EndsWith or Contains as the comparator. - /// - public static Expression> AddStringPropertyExpression( - Expression> lambda, string filter, string filterType, bool ignoreCase = false, bool supportEF = false) - { + internal delegate Expression> AddStringPropertyExpression( + Expression> 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 expressionArgs = new List() { Expression.Constant(filter) }; - if (supportEF) { - ignoreCase = false; - } else { - if (ignoreCase) { - expressionArgs.Add(Expression.Constant(StringComparison.CurrentCultureIgnoreCase)); - } else { - expressionArgs.Add(Expression.Constant(StringComparison.CurrentCulture)); - } - } - - MethodInfo methodInfo = supportEF ? typeof(string).GetMethod(filterType, new[] { typeof(string) }) - : 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>( - filterExpression, - lambda.Parameters); - } /// /// Prepend the given predicate with a short circuiting null check. /// - public static Expression AddNullCheck( + internal static Expression AddNullCheck( Expression left, Expression expression) { // Check that the property isn't null, otherwise we'd hit null object exceptions at runtime @@ -74,7 +40,7 @@ public static class PredicateExpressionPolicyExtensions { /// /// /// - public static Expression> Negate(Expression> lambda) { + internal static Expression> Negate(Expression> lambda) { var body = lambda.Body; var parameters = lambda.Parameters; @@ -109,7 +75,7 @@ public static class PredicateExpressionPolicyExtensions { /// /// Dynamically build an expression suitable for filtering in a Where clause /// - public static Expression> GetPredicateExpressionForType(string property, string value, bool supportEF=false) { + public static Expression> GetPredicateExpressionForType(string property, string value) { var parameter = Expression.Parameter(typeof(T), "x"); var opLeft = Expression.Property(parameter, property); var opRight = Expression.Constant(value); @@ -160,13 +126,13 @@ public static class PredicateExpressionPolicyExtensions { } if (value.StartsWith("*") && value.EndsWith("*")) { - result = AddStringPropertyExpression(strParam, value.Trim('*'), "Contains", ignoreCase, supportEF); + result = funcs.AddStringPropertyExpression(strParam, value.Trim('*'), "Contains", ignoreCase); } else if (value.StartsWith("*")) { - result = AddStringPropertyExpression(strParam, value.TrimStart('*'), "EndsWith", ignoreCase, supportEF); + result = funcs.AddStringPropertyExpression(strParam, value.TrimStart('*'), "EndsWith", ignoreCase); } else if (value.EndsWith("*")) { - result = AddStringPropertyExpression(strParam, value.TrimEnd('*'), "StartsWith", ignoreCase, supportEF); + result = funcs.AddStringPropertyExpression(strParam, value.TrimEnd('*'), "StartsWith", ignoreCase); } else { - result = AddStringPropertyExpression(strParam, value, "Equals", ignoreCase, supportEF); + result = funcs.AddStringPropertyExpression(strParam, value, "Equals", ignoreCase); } if (negateResult) { @@ -216,7 +182,7 @@ public static class PredicateExpressionPolicyExtensions { var opRight = Expression.Constant(value); // Create generic method which is bound with the Call Expression below - var arrContainsRuntimeMethod = typeof(System.Linq.Enumerable).GetMethods() + var arrContainsRuntimeMethod = typeof(Enumerable).GetMethods() .Where(x => x.Name == "Contains") .Single(x => x.GetParameters().Length == 2) .MakeGenericMethod(value.GetType()); @@ -270,12 +236,16 @@ public static class PredicateExpressionPolicyExtensions { return CombineOr(predicates); } + private static CoreExtenionFunctions funcs; /// /// Generate an expression tree targeting an object type based on a given policy. /// public static Expression>? GetPredicateExpression(this ExpressionRuleCollection policy) { + CoreExtenionFunctions stdFuncs = new CoreExtensions(); + if (funcs == null) funcs = stdFuncs; + var predicates = new List>>(); var typeName = typeof(T).Name; foreach (var rule in policy.Rules.Where(x => x.TargetType != null)) { @@ -298,6 +268,7 @@ public static class PredicateExpressionPolicyExtensions { private class EfExpressionOptions : ExpressionOptions { public bool SupportEF => true; + public bool NoCache => true; } private static ExpressionOptions efExpressionOptions = new EfExpressionOptions(); @@ -306,23 +277,139 @@ public static class PredicateExpressionPolicyExtensions { /// public static Expression>? GetEFPredicateExpression(this ExpressionRuleCollection policy) { - var predicates = new List>>(); - 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(efExpressionOptions); - if (expression != null) predicates.Add(expression); + 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; } - var expressions = CombinePredicates(predicates, policy.RuleOperator); + Expression>? expressions = PredicateBuilder.False(); - if (expressions == null) { - System.Diagnostics.Debug.WriteLine($"No predicates available for type: <{typeof(T).Name}> in policy: {policy.Id}"); - return PredicateBuilder.False(); + try { + var predicates = new List>>(); + 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(efExpressionOptions); + if (expression != null) predicates.Add(expression); + } + + expressions = CombinePredicates(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(); + } + } + 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 { + /// + /// Builds expressions using string member functions StartsWith, EndsWith or Contains as the comparator. + /// + public Expression> AddStringPropertyExpression( + Expression> 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 expressionArgs = new List() { 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>( + filterExpression, + lambda.Parameters); + } + } + + internal class EFExtensions : CoreExtenionFunctions { + + /// + /// Builds expressions using string member functions StartsWith, EndsWith or Contains as the comparator. + /// + public Expression> AddStringPropertyExpression( + Expression> 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 expressionArgs = new List() { Expression.Constant(filter) }; + + var strPredicate = Expression.Call(lambda.Body, methodInfo, expressionArgs); + + Expression filterExpression = Expression.AndAlso(notNull, strPredicate); + + return Expression.Lambda>( + filterExpression, + lambda.Parameters); + } + } } \ No newline at end of file